Compare commits
1 Commits
main
...
fix/activa
| Author | SHA1 | Date | |
|---|---|---|---|
| 9758ae345d |
@@ -38,12 +38,8 @@ RUN pnpm build
|
|||||||
# ── Stage 4: Production ───────────────────────────────
|
# ── Stage 4: Production ───────────────────────────────
|
||||||
FROM node:22-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
# Chromium for Playwright (EMEX/PartsCatalogs). ffmpeg is required by the Vinpin
|
# Chromium for Playwright (EMEX/PartsCatalogs)
|
||||||
# OCR pipeline (vinpin.ocr cropScale): it crops+3x-upscales the tiny centered
|
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||||
# Rpartstore launch-error / decode-modal region so tesseract can read it. Without
|
|
||||||
# it cropScale silently returns the full 1600x900 frame and every clipped OCR
|
|
||||||
# degrades to an illegible full-frame 1x read (the DOWN-Rpartstore misclassify bug).
|
|
||||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont ffmpeg
|
|
||||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,3 @@ NOVU_API_KEY=
|
|||||||
APP_PUBLIC_URL=https://sase.tr
|
APP_PUBLIC_URL=https://sase.tr
|
||||||
# HMAC secret for signed track.sase.tr click links (Bitwarden "mailtrack tracking (track.sase.tr)"). Empty → no click tracking.
|
# HMAC secret for signed track.sase.tr click links (Bitwarden "mailtrack tracking (track.sase.tr)"). Empty → no click tracking.
|
||||||
MAILTRACK_SECRET=
|
MAILTRACK_SECRET=
|
||||||
# Unsubscribe links (List-Unsubscribe header + mail footer). Empty secret → mailto-only header, no footer link (dev default).
|
|
||||||
UNSUBSCRIBE_SECRET=
|
|
||||||
# Defaults to <APP_PUBLIC_URL>/api/email/unsubscribe when empty.
|
|
||||||
UNSUBSCRIBE_URL_BASE=
|
|
||||||
UNSUBSCRIBE_EMAIL=unsubscribe@sase.tr
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
-- Fiyat görünümleri artık markasız/araç-markalı bağlamlarda yalnız ORİJİNAL
|
|
||||||
-- (kokpit tur='orjinal' etiketli) teklifleri içerir. Mevcut satırlar eski
|
|
||||||
-- kuralla (orijinal + yan sanayi tek havuz) hesaplandığından tutarsız — sil;
|
|
||||||
-- seriler ilk görüntülenmede takip history'sinden OE-only kurallarla yeniden
|
|
||||||
-- backfill edilir (product_history 2026-01-29 epoch'undan beri eksiksiz),
|
|
||||||
-- cron sonraki günleri aynı kuralla ekler. (~166 track / ~4,8k satır — ucuz.)
|
|
||||||
DELETE FROM "part_price_daily";--> statement-breakpoint
|
|
||||||
DELETE FROM "part_price_tracks";
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- OEM kodu normalize-eşleşme index'i. Reverse-catalog (vehiclesByOem) ve
|
|
||||||
-- fiyat-kartı marka çözümü artık normalize edilmiş koda göre eşleşir: URL
|
|
||||||
-- kodu boşluksuz ("9827622780") gelirken katalog boşluklu saklayabilir
|
|
||||||
-- ("9827 622 780"; özellikle PSA/Peugeot/Citroen) → exact match kaçırıyordu.
|
|
||||||
-- Functional btree, sorgudaki ifadeyle birebir aynı olmalı ki planner kullansın.
|
|
||||||
-- Not: non-concurrent build kısa süreli YAZMA kilidi alır (prod ~10s, salt-okuma
|
|
||||||
-- etkilenmez); parts ~9,3M satır. IF NOT EXISTS → idempotent/yeniden koşulur.
|
|
||||||
CREATE INDEX IF NOT EXISTS "parts_oem_code_norm_idx"
|
|
||||||
ON "parts" (regexp_replace(upper("oem_code"), '[^A-Z0-9]', '', 'g'));
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Kalıcı tamlık işareti: prefetch zinciri parça ile bittiğinde true olur
|
|
||||||
-- (PrefetchWorkerService.incrementCompleted). Redis prefetch:complete:* (21g TTL,
|
|
||||||
-- operasyonel skip mantığı) yerine SÜRESİZ ölçüm — "% tam çekilmiş" güvenilir
|
|
||||||
-- takibi. _at son doğrulanma zamanını tutar. IF NOT EXISTS → idempotent.
|
|
||||||
ALTER TABLE "vehicles" ADD COLUMN IF NOT EXISTS "fully_fetched" boolean DEFAULT false NOT NULL;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "vehicles" ADD COLUMN IF NOT EXISTS "fully_fetched_at" timestamp with time zone;
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX IF NOT EXISTS "vehicles_fully_fetched_idx" ON "vehicles" USING btree ("fully_fetched");
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Dunning görünürlüğü: yenileme tahsilatı patlayınca (invoice.payment_failed)
|
|
||||||
-- damgalanır, fatura ödenince (invoice.paid) veya abonelik kesin iptal olunca
|
|
||||||
-- temizlenir. dunning_invoice_url = Stripe hosted fatura sayfası — kullanıcının
|
|
||||||
-- açık faturayı ödeyebileceği/yeni kart girebileceği TEK self-serve yüzey
|
|
||||||
-- (uygulamada kart-güncelleme yok; %0 dunning kurtarmanın kök nedeni buydu).
|
|
||||||
-- IF NOT EXISTS → idempotent.
|
|
||||||
ALTER TABLE "user_subscriptions" ADD COLUMN IF NOT EXISTS "dunning_since" timestamp with time zone;
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "user_subscriptions" ADD COLUMN IF NOT EXISTS "dunning_invoice_url" text;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Mitsubishi'nin parça listesi grup sanılıyordu.
|
|
||||||
--
|
|
||||||
-- `/p5mitsubishi/extern/details/vinDetails` yanıtı `partno`/`qty` taşıyan bir
|
|
||||||
-- PARÇA listesi, ama her kaydın kendi linki `partInfoTable` (parça-detay) ve ne
|
|
||||||
-- wid ne de yol sınıflandırıcıda karşılık buluyordu. Sonuç: 2.193 parça listesi
|
|
||||||
-- grup düğümüne dönüştü ve içlerindeki 19.576 tekil parça ("SCREW,LOCK CYLINDER",
|
|
||||||
-- "BOLT,STEERING COLUMN WASHER") kategori olarak kaydedildi. Ölçüm (prod,
|
|
||||||
-- 2026-09-20): bu 19.576 sahte düğümün TOPLAM 2 tanesinde parça var, ve her
|
|
||||||
-- prefetch turunda yeniden çekiliyorlar.
|
|
||||||
--
|
|
||||||
-- Sınıflandırıcı düzeltildi (detailsTable artık yaprak, partInfoTable hiç
|
|
||||||
-- kuyruklanmıyor), ama okuma yolu bir düğümün ÖNCE çocuklarına bakıyor: sahte
|
|
||||||
-- çocuklar dururken parça listesi asla çekilmez. Bu yüzden satırların silinmesi
|
|
||||||
-- düzeltmenin parçası, ayrı bir temizlik değil.
|
|
||||||
--
|
|
||||||
-- Güvenli: yalnız pl24 kaynaklı ve yalnız bu iki imzayı taşıyan satırlar; ikisi
|
|
||||||
-- de prod'da %100 Mitsubishi. Silinen ~2 parça satırı üst listeden yeniden gelir.
|
|
||||||
DELETE FROM "categories"
|
|
||||||
WHERE "source" = 'pl24'
|
|
||||||
AND ("link_wid" = 'partInfoTable' OR "link_path" ILIKE '%/details/vinpartinfo%');
|
|
||||||
@@ -225,41 +225,6 @@
|
|||||||
"when": 1783092194145,
|
"when": 1783092194145,
|
||||||
"tag": "0031_canonical_template_entries",
|
"tag": "0031_canonical_template_entries",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 32,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1784023885041,
|
|
||||||
"tag": "0032_part_price_oe_reset",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 33,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1784027180912,
|
|
||||||
"tag": "0033_parts_oem_code_norm_idx",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 34,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1784965129879,
|
|
||||||
"tag": "0034_vehicle_fully_fetched",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 35,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1786435493000,
|
|
||||||
"tag": "0035_subscription_dunning",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 36,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1786521893000,
|
|
||||||
"tag": "0036_pl24_mitsubishi_partinfo_cleanup",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,6 @@ import { RolesGuard } from "./common/guards/roles.guard";
|
|||||||
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
|
||||||
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
import { TimeoutInterceptor } from "./common/interceptors/timeout.interceptor";
|
||||||
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
import { TransformInterceptor } from "./common/interceptors/transform.interceptor";
|
||||||
import { TelegramModule } from "./common/telegram.module";
|
|
||||||
import configuration from "./config/configuration";
|
import configuration from "./config/configuration";
|
||||||
import { validate } from "./config/env.validation";
|
import { validate } from "./config/env.validation";
|
||||||
import { ContactModule } from "./contact/contact.module";
|
import { ContactModule } from "./contact/contact.module";
|
||||||
@@ -80,7 +79,6 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
|||||||
]),
|
]),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
TelegramModule,
|
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { isStaleBrowseArchitecture } from "../integrations/pl24/pl24-tree";
|
|
||||||
import { CatalogService } from "./catalog.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression lock for the pinned-browse-rows bug (plv2.md, finding consumers-09).
|
|
||||||
*
|
|
||||||
* `catalog_vehicles` is a permanent cache: `getModels` returns early as soon as
|
|
||||||
* a brand has any row, so `fetchVehicleList` never runs again for that brand.
|
|
||||||
* The 188 rows listed while PSA and Volvo were still P4 (127 LEGACY_PSA + 61
|
|
||||||
* LEGACY_VOLVO on prod, 2026-09-20) were therefore stuck serving the frozen
|
|
||||||
* 2024-02-13 PSA snapshot and a Volvo endpoint that answers HTTP 503.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Row = {
|
|
||||||
id: string;
|
|
||||||
serviceName: string;
|
|
||||||
architecture: string | null;
|
|
||||||
brandId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const psaRow = (id: string): Row => ({
|
|
||||||
id,
|
|
||||||
serviceName: "peugeot_parts",
|
|
||||||
architecture: "LEGACY_PSA",
|
|
||||||
brandId: "b1",
|
|
||||||
});
|
|
||||||
const freshRow = (id: string): Row => ({
|
|
||||||
id,
|
|
||||||
serviceName: "peugeot_parts",
|
|
||||||
architecture: "P5_MODERN",
|
|
||||||
brandId: "b1",
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeService(opts: {
|
|
||||||
lockFree?: boolean;
|
|
||||||
fetched?: Array<{ model: string; vehicleId: string }>;
|
|
||||||
fetchThrows?: boolean;
|
|
||||||
refetched?: Row[];
|
|
||||||
}) {
|
|
||||||
const deleted: string[][] = [];
|
|
||||||
const inserted: unknown[][] = [];
|
|
||||||
|
|
||||||
const tx = {
|
|
||||||
insert: () => ({
|
|
||||||
values: (v: unknown[]) => {
|
|
||||||
inserted.push(v);
|
|
||||||
return { onConflictDoNothing: async () => undefined };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
delete: () => ({
|
|
||||||
where: async (cond: unknown) => {
|
|
||||||
// drizzle's inArray() puts its bound values in a `queryChunks` entry
|
|
||||||
// that is itself an array of Param objects; pull the plain ids back out
|
|
||||||
// so a test can assert WHICH rows were removed, not just how many.
|
|
||||||
const chunks = (cond as { queryChunks?: unknown[] })?.queryChunks ?? [];
|
|
||||||
const params = chunks.find((c): c is unknown[] => Array.isArray(c)) ?? [];
|
|
||||||
const ids = params
|
|
||||||
.map((param) => (param as { value?: unknown })?.value)
|
|
||||||
.filter((v): v is string => typeof v === "string");
|
|
||||||
deleted.push(ids);
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const db = {
|
|
||||||
select: () => ({ from: () => ({ where: async () => opts.refetched ?? [] }) }),
|
|
||||||
transaction: async (cb: (t: typeof tx) => Promise<void>) => cb(tx),
|
|
||||||
};
|
|
||||||
const redis = { setNx: vi.fn(async () => opts.lockFree !== false) };
|
|
||||||
const pl24Service = {
|
|
||||||
fetchVehicleList: vi.fn(async () => {
|
|
||||||
if (opts.fetchThrows) throw new Error("upstream 503");
|
|
||||||
return opts.fetched ?? [];
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const svc = new CatalogService(
|
|
||||||
db as never,
|
|
||||||
redis as never,
|
|
||||||
pl24Service as never,
|
|
||||||
{} as never,
|
|
||||||
) as never as {
|
|
||||||
healStaleBrowseRows: (brand: string, rows: Row[], where: unknown[]) => Promise<Row[] | null>;
|
|
||||||
};
|
|
||||||
return { svc, redis, pl24Service, deleted, inserted };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("isStaleBrowseArchitecture", () => {
|
|
||||||
it("flags a row whose stored architecture is not the current one", () => {
|
|
||||||
expect(
|
|
||||||
isStaleBrowseArchitecture({
|
|
||||||
storedArchitecture: "LEGACY_PSA",
|
|
||||||
currentArchitecture: "P5_MODERN",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isStaleBrowseArchitecture({
|
|
||||||
storedArchitecture: "LEGACY_VOLVO",
|
|
||||||
currentArchitecture: "P5_MODERN",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a matching row alone", () => {
|
|
||||||
expect(
|
|
||||||
isStaleBrowseArchitecture({
|
|
||||||
storedArchitecture: "P5_MODERN",
|
|
||||||
currentArchitecture: "P5_MODERN",
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
// Brands that are still P4 upstream must not be touched.
|
|
||||||
expect(
|
|
||||||
isStaleBrowseArchitecture({
|
|
||||||
storedArchitecture: "LEGACY_FORD",
|
|
||||||
currentArchitecture: "LEGACY_FORD",
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing without both sides (unknown service / unlabelled row)", () => {
|
|
||||||
expect(
|
|
||||||
isStaleBrowseArchitecture({ storedArchitecture: null, currentArchitecture: "P5_MODERN" }),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
isStaleBrowseArchitecture({ storedArchitecture: "LEGACY_PSA", currentArchitecture: null }),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("healStaleBrowseRows", () => {
|
|
||||||
it("does not touch PL24 when every row is current", async () => {
|
|
||||||
const { svc, pl24Service, redis } = makeService({});
|
|
||||||
const out = await svc.healStaleBrowseRows("Peugeot", [freshRow("a"), freshRow("b")], []);
|
|
||||||
expect(out).toBeNull();
|
|
||||||
expect(pl24Service.fetchVehicleList).not.toHaveBeenCalled();
|
|
||||||
expect(redis.setNx).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-lists a stale brand and replaces its rows", async () => {
|
|
||||||
const refetched = [freshRow("new1"), freshRow("new2")];
|
|
||||||
const { svc, pl24Service, deleted, inserted } = makeService({
|
|
||||||
fetched: [
|
|
||||||
{ model: "208", vehicleId: "p5-208" },
|
|
||||||
{ model: "308", vehicleId: "p5-308" },
|
|
||||||
],
|
|
||||||
refetched,
|
|
||||||
});
|
|
||||||
const out = await svc.healStaleBrowseRows("Peugeot", [psaRow("old1"), psaRow("old2")], []);
|
|
||||||
expect(pl24Service.fetchVehicleList).toHaveBeenCalledWith("peugeot_parts");
|
|
||||||
expect(inserted).toHaveLength(1);
|
|
||||||
expect(inserted[0]).toHaveLength(2);
|
|
||||||
expect(deleted).toHaveLength(1);
|
|
||||||
expect(out).toEqual(refetched);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes only the stale rows when a service holds a mix", async () => {
|
|
||||||
// A row already re-listed under the new architecture conflicts on insert and
|
|
||||||
// is skipped, so deleting it would drop it for good.
|
|
||||||
const { svc, deleted } = makeService({
|
|
||||||
fetched: [{ model: "208", vehicleId: "p5-208" }],
|
|
||||||
refetched: [freshRow("keep")],
|
|
||||||
});
|
|
||||||
await svc.healStaleBrowseRows("Peugeot", [psaRow("old1"), freshRow("keep")], []);
|
|
||||||
expect(deleted).toHaveLength(1);
|
|
||||||
expect(deleted).toEqual([["old1"]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the stale rows when the re-list throws", async () => {
|
|
||||||
const { svc, deleted, inserted } = makeService({ fetchThrows: true });
|
|
||||||
const out = await svc.healStaleBrowseRows("Volvo", [psaRow("old1")], []);
|
|
||||||
expect(out).toBeNull();
|
|
||||||
expect(inserted).toHaveLength(0);
|
|
||||||
expect(deleted).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the stale rows when upstream returns an empty model list", async () => {
|
|
||||||
const { svc, deleted, inserted } = makeService({ fetched: [] });
|
|
||||||
const out = await svc.healStaleBrowseRows("Volvo", [psaRow("old1")], []);
|
|
||||||
expect(out).toBeNull();
|
|
||||||
expect(inserted).toHaveLength(0);
|
|
||||||
expect(deleted).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attempts at most once a day per brand", async () => {
|
|
||||||
const { svc, pl24Service } = makeService({ lockFree: false });
|
|
||||||
const out = await svc.healStaleBrowseRows("Peugeot", [psaRow("old1")], []);
|
|
||||||
expect(out).toBeNull();
|
|
||||||
expect(pl24Service.fetchVehicleList).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
userBrands,
|
userBrands,
|
||||||
userSubscriptions,
|
userSubscriptions,
|
||||||
} from "../database/schema/core";
|
} from "../database/schema/core";
|
||||||
import { isPl24LeafNode, isStaleBrowseArchitecture } from "../integrations/pl24/pl24-tree";
|
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
import {
|
import {
|
||||||
type PL24DecodedCategory,
|
type PL24DecodedCategory,
|
||||||
@@ -170,8 +169,7 @@ export class CatalogService {
|
|||||||
.where(and(...whereConditions));
|
.where(and(...whereConditions));
|
||||||
|
|
||||||
if (dbVehicles.length > 0) {
|
if (dbVehicles.length > 0) {
|
||||||
const healed = await this.healStaleBrowseRows(brandName, dbVehicles, whereConditions);
|
return dbVehicles;
|
||||||
return healed ?? dbVehicles;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from PL24 for each service
|
// Fetch from PL24 for each service
|
||||||
@@ -226,118 +224,6 @@ export class CatalogService {
|
|||||||
return allVehicles;
|
return allVehicles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-list a brand whose stored browse rows were created under a retired PL24
|
|
||||||
* architecture, and replace them with the current ones.
|
|
||||||
*
|
|
||||||
* WHY (plv2.md, finding consumers-09): `getModels` treats `catalog_vehicles`
|
|
||||||
* as a permanent cache — one row for the brand and `fetchVehicleList` is never
|
|
||||||
* called again. The rows listed while PSA and Volvo were still P4 are pinned
|
|
||||||
* forever, so Peugeot/Citroën browse keeps serving the frozen 2024-02-13
|
|
||||||
* snapshot and Volvo/Polestar browse keeps calling an endpoint that answers
|
|
||||||
* HTTP 503. A code fix alone never reaches these rows.
|
|
||||||
*
|
|
||||||
* Deliberately conservative, mirroring `healStalePl24Architecture` on the
|
|
||||||
* VIN side:
|
|
||||||
* - only rows whose stored architecture disagrees with the service table;
|
|
||||||
* - one attempt per brand per day (Redis lock) so a permanently failing
|
|
||||||
* re-list cannot hammer the one surviving account on every page view;
|
|
||||||
* - a failed or empty re-list leaves the old rows in place — stale data
|
|
||||||
* beats an empty catalog;
|
|
||||||
* - the stale rows are deleted only once the replacements are committed,
|
|
||||||
* and per service, so one broken sub-catalog cannot wipe a working one.
|
|
||||||
*
|
|
||||||
* Deleting a browse row cascades to its categories and parts. That is
|
|
||||||
* intended: those rows describe the retired tree and would otherwise survive
|
|
||||||
* as unreachable orphans under the new listing.
|
|
||||||
*
|
|
||||||
* Returns the refreshed rows, or null when nothing was migrated (caller keeps
|
|
||||||
* what it already had).
|
|
||||||
*/
|
|
||||||
private async healStaleBrowseRows(
|
|
||||||
brandName: string,
|
|
||||||
dbVehicles: (typeof catalogVehicles.$inferSelect)[],
|
|
||||||
whereConditions: ReturnType<typeof eq>[],
|
|
||||||
): Promise<(typeof catalogVehicles.$inferSelect)[] | null> {
|
|
||||||
const isStale = (v: typeof catalogVehicles.$inferSelect) =>
|
|
||||||
isStaleBrowseArchitecture({
|
|
||||||
storedArchitecture: v.architecture,
|
|
||||||
currentArchitecture: PL24_SERVICE_CATALOGS[v.serviceName]?.architecture,
|
|
||||||
});
|
|
||||||
const staleServices = [...new Set(dbVehicles.filter(isStale).map((v) => v.serviceName))];
|
|
||||||
if (staleServices.length === 0) return null;
|
|
||||||
|
|
||||||
if (!(await this.redis.setNx(`pl24:browse-heal:${brandName}`, "1", 86_400))) return null;
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[pl24-browse-heal] ${brandName}: ${staleServices.join(", ")} listed under a retired architecture — re-listing`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let migrated = 0;
|
|
||||||
for (const svc of staleServices) {
|
|
||||||
// ONLY the stale rows. A service can hold a mix — some rows already
|
|
||||||
// re-listed under the new architecture — and those must survive: the
|
|
||||||
// insert below skips them on conflict, so deleting them here would drop
|
|
||||||
// them for good.
|
|
||||||
const staleIds = dbVehicles
|
|
||||||
.filter((v) => v.serviceName === svc && isStale(v))
|
|
||||||
.map((v) => v.id);
|
|
||||||
try {
|
|
||||||
const fetched = await this.pl24Service.fetchVehicleList(svc);
|
|
||||||
if (fetched.length === 0) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[pl24-browse-heal] ${svc}: upstream returned no models — keeping ${staleIds.length} stale row(s)`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const config = PL24_SERVICE_CATALOGS[svc];
|
|
||||||
const brandId = dbVehicles.find((v) => v.serviceName === svc)?.brandId ?? null;
|
|
||||||
await this.db.transaction(async (tx) => {
|
|
||||||
await tx
|
|
||||||
.insert(catalogVehicles)
|
|
||||||
.values(
|
|
||||||
fetched.map((v) => ({
|
|
||||||
source: "pl24" as const,
|
|
||||||
serviceName: svc,
|
|
||||||
brandName,
|
|
||||||
brandId,
|
|
||||||
model: v.model,
|
|
||||||
year: v.year || null,
|
|
||||||
engine: v.engine || null,
|
|
||||||
bodyType: v.bodyType || null,
|
|
||||||
transmission: v.transmission || null,
|
|
||||||
market: v.market || null,
|
|
||||||
serviceVehicleId: v.vehicleId,
|
|
||||||
catalogPath: v.catalogPath || null,
|
|
||||||
architecture: config?.architecture || "P5_MODERN",
|
|
||||||
metadata: v.metadata || null,
|
|
||||||
categoriesFetched: false,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.onConflictDoNothing();
|
|
||||||
if (staleIds.length > 0) {
|
|
||||||
await tx.delete(catalogVehicles).where(inArray(catalogVehicles.id, staleIds));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
migrated += staleIds.length;
|
|
||||||
this.logger.log(
|
|
||||||
`[pl24-browse-heal] ${svc}: ${staleIds.length} stale row(s) → ${fetched.length} fresh model(s)`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[pl24-browse-heal] ${svc} re-list failed, keeping stale rows: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (migrated === 0) return null;
|
|
||||||
return await this.db
|
|
||||||
.select()
|
|
||||||
.from(catalogVehicles)
|
|
||||||
.where(and(...whereConditions));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single catalog vehicle by ID.
|
* Get a single catalog vehicle by ID.
|
||||||
*/
|
*/
|
||||||
@@ -1363,10 +1249,18 @@ export class CatalogService {
|
|||||||
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
|
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared PL24 leaf classifier — see integrations/pl24/pl24-tree. */
|
|
||||||
private isLeafPath(linkPath: string): boolean {
|
private isLeafPath(linkPath: string): boolean {
|
||||||
|
const lower = linkPath.toLowerCase();
|
||||||
return (
|
return (
|
||||||
isPl24LeafNode({ linkPath }) || linkPath.toLowerCase().includes("json-vin-bom-detail.action")
|
lower.includes("/bom/") ||
|
||||||
|
lower.includes("/bomdetails") ||
|
||||||
|
lower.includes("/partinfo/") ||
|
||||||
|
// PL24 P5 leaf items endpoints — chemicals, servicepart, accessories,
|
||||||
|
// any /extern/<kind>/(vin|mdl)_items combination. These return parts,
|
||||||
|
// not subgroups, so they must short-circuit drill-down.
|
||||||
|
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lower) ||
|
||||||
|
lower.includes("image-board.action") || // PSA illustration leaf
|
||||||
|
lower.includes("json-vin-bom-detail.action")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,9 +1367,13 @@ export class CatalogService {
|
|||||||
|
|
||||||
return cats.map((c) => {
|
return cats.map((c) => {
|
||||||
const dbChildCount = childCountMap.get(c.id) || 0;
|
const dbChildCount = childCountMap.get(c.id) || 0;
|
||||||
const isLeaf = c.linkPath
|
const isLeaf =
|
||||||
? isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid })
|
c.linkPath?.includes("/bom/") ||
|
||||||
: dbChildCount === 0;
|
c.linkPath?.includes("/bomdetails") ||
|
||||||
|
c.linkPath?.includes("/partinfo/") ||
|
||||||
|
c.linkPath?.includes("/servicepart/vin_items") ||
|
||||||
|
c.linkPath?.includes("image-board.action") || // PSA illustration leaf
|
||||||
|
(!c.linkPath && dbChildCount === 0);
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
schemaImageUrl: picMap.get(c.id) || null,
|
schemaImageUrl: picMap.get(c.id) || null,
|
||||||
|
|||||||
@@ -5,16 +5,9 @@ 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, @Query("source") source?: string) {
|
async getCategoryTree(@Param("vehicleId") vehicleId: string) {
|
||||||
return this.categoriesService.getCategoryTree(vehicleId, source);
|
return this.categoriesService.getCategoryTree(vehicleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("search/:vehicleId")
|
@Get("search/:vehicleId")
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ function createService(db: any) {
|
|||||||
};
|
};
|
||||||
const pl24Service = {
|
const pl24Service = {
|
||||||
getCategories: vi.fn().mockResolvedValue([]),
|
getCategories: vi.fn().mockResolvedValue([]),
|
||||||
// P4→P5 onarma yolu bunu çağırır (plv2 Faz 2).
|
|
||||||
decodeVin: vi.fn().mockResolvedValue(null),
|
|
||||||
};
|
};
|
||||||
const emexService = {
|
const emexService = {
|
||||||
isSupported: vi.fn().mockReturnValue(false),
|
isSupported: vi.fn().mockReturnValue(false),
|
||||||
@@ -464,81 +462,3 @@ describe("CategoriesService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── P4 → P5 kendi kendini onarma (plv2 Faz 2, bulgu psa-07) ──
|
|
||||||
// PSA/Volvo upstream'de P5'e taşındı; daha önce decode edilmiş araçlar donmuş
|
|
||||||
// 2024-02-13 PSA anlık görüntüsüne / ölü P4 Volvo ucuna işaret etmeye devam
|
|
||||||
// ediyor ve decodeVin'in db_hit kısa devresi kod düzeltmesini onlara ulaştırmıyor.
|
|
||||||
describe("CategoriesService — bayat PL24 mimarisini onarma", () => {
|
|
||||||
const makeVehicle = (catalogPath: string) => ({
|
|
||||||
id: "veh-1",
|
|
||||||
vin: "VF3MCBHZWHS150390",
|
|
||||||
rawData: { catalogInfo: { serviceName: "peugeot_parts", catalogPath } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const setup = (catalogPath: string, decodeResult: unknown) => {
|
|
||||||
const { service, redis, pl24Service } = createService({} as never);
|
|
||||||
const p = service as unknown as {
|
|
||||||
healStalePl24Architecture(
|
|
||||||
id: string,
|
|
||||||
v: { vin: string | null; rawData: unknown },
|
|
||||||
n: number,
|
|
||||||
): Promise<boolean>;
|
|
||||||
db: unknown;
|
|
||||||
};
|
|
||||||
// setNx: ilk denemeye izin ver (günlük tek deneme kilidi)
|
|
||||||
(redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(true);
|
|
||||||
(redis as unknown as { del: unknown }).del = vi.fn().mockResolvedValue(undefined);
|
|
||||||
pl24Service.decodeVin = vi.fn().mockResolvedValue(decodeResult);
|
|
||||||
return { service, p, redis, pl24Service, vehicle: makeVehicle(catalogPath) };
|
|
||||||
};
|
|
||||||
|
|
||||||
it("P4 PSA yolundaki araç yeniden decode edilir ve ağaç değişir", async () => {
|
|
||||||
const fresh = {
|
|
||||||
catalogInfo: { serviceName: "peugeot_parts", catalogPath: "/p5psa" },
|
|
||||||
categories: [
|
|
||||||
{
|
|
||||||
code: "_FCT0001",
|
|
||||||
nameTr: "Mekanik",
|
|
||||||
nameEn: "Mechanical",
|
|
||||||
linkPath: "/p5psa/x",
|
|
||||||
linkWid: "mainGroupTable",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const { p, pl24Service, vehicle } = setup("/psa/peugeot_parts", fresh);
|
|
||||||
const inserted: unknown[] = [];
|
|
||||||
(p as unknown as { db: unknown }).db = {
|
|
||||||
transaction: async (fn: (tx: unknown) => Promise<void>) => {
|
|
||||||
await fn({
|
|
||||||
delete: () => ({ where: async () => undefined }),
|
|
||||||
insert: () => ({ values: async (rows: unknown[]) => inserted.push(...rows) }),
|
|
||||||
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(true);
|
|
||||||
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VF3MCBHZWHS150390");
|
|
||||||
expect(inserted).toHaveLength(1);
|
|
||||||
expect((inserted[0] as { name: string }).name).toBe("Mekanik");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("zaten P5 olan araca dokunulmaz (decode çağrılmaz)", async () => {
|
|
||||||
const { p, pl24Service, vehicle } = setup("/p5psa", null);
|
|
||||||
await expect(p.healStalePl24Architecture("veh-1", vehicle, 6)).resolves.toBe(false);
|
|
||||||
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("yeniden decode boş dönerse eski ağaç korunur", async () => {
|
|
||||||
const { p, vehicle } = setup("/psa/peugeot_parts", { categories: [] });
|
|
||||||
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("günde bir denenir (setNx kilidi)", async () => {
|
|
||||||
const { p, redis, pl24Service, vehicle } = setup("/psa/peugeot_parts", { categories: [] });
|
|
||||||
(redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(false);
|
|
||||||
await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false);
|
|
||||||
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -21,13 +21,7 @@ import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catal
|
|||||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||||
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
||||||
import {
|
|
||||||
isPl24GroupNode,
|
|
||||||
isPl24LeafNode,
|
|
||||||
isStalePl24Architecture,
|
|
||||||
} from "../integrations/pl24/pl24-tree";
|
|
||||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||||
import { getServiceApiPath } from "../integrations/pl24/pl24.types";
|
|
||||||
import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon";
|
import { 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";
|
||||||
@@ -56,8 +50,8 @@ export class CategoriesService {
|
|||||||
private emexSourceDb: EmexSourceDbService,
|
private emexSourceDb: EmexSourceDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCategoryTree(vehicleId: string, sourceFilter?: string) {
|
async getCategoryTree(vehicleId: string) {
|
||||||
const cacheKey = `cat:tree:${vehicleId}${sourceFilter ? `:${sourceFilter}` : ""}`;
|
const cacheKey = `cat:tree:${vehicleId}`;
|
||||||
const cached = await this.redis.getJson<any[]>(cacheKey);
|
const cached = await this.redis.getJson<any[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
@@ -76,20 +70,6 @@ export class CategoriesService {
|
|||||||
.from(categories)
|
.from(categories)
|
||||||
.where(eq(categories.vehicleId, vehicleId));
|
.where(eq(categories.vehicleId, vehicleId));
|
||||||
|
|
||||||
// ── P4 → P5 self-healing (plv2.md Faz 2, bulgu psa-07) ──
|
|
||||||
// PSA and Volvo moved to P5 upstream; rows decoded before that still point at
|
|
||||||
// the frozen 2024-02-13 PSA snapshot or the dead P4 Volvo endpoint, and
|
|
||||||
// decodeVin's db_hit short-circuit means no code fix ever reaches them. Heal
|
|
||||||
// one vehicle per view instead of a bulk re-decode storm against the single
|
|
||||||
// surviving account.
|
|
||||||
const healed = await this.healStalePl24Architecture(vehicleId, vehicle, dbCategories.length);
|
|
||||||
if (healed) {
|
|
||||||
dbCategories = await this.db
|
|
||||||
.select()
|
|
||||||
.from(categories)
|
|
||||||
.where(eq(categories.vehicleId, vehicleId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no categories in DB, fetch from PL24
|
// If no categories in DB, fetch from PL24
|
||||||
if (dbCategories.length === 0 && vehicle.rawData) {
|
if (dbCategories.length === 0 && vehicle.rawData) {
|
||||||
const rawData = vehicle.rawData as any;
|
const rawData = vehicle.rawData as any;
|
||||||
@@ -477,23 +457,8 @@ 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(forTree, directPartCounts);
|
const tree = this.buildTree(dbCategories, 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
|
||||||
@@ -515,25 +480,6 @@ 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.
|
* The unified canonical taxonomy (20 top buckets), source/brand-independent.
|
||||||
* Used for the browse landing + as the fixed set of headings every vehicle's
|
* Used for the browse landing + as the fixed set of headings every vehicle's
|
||||||
@@ -916,13 +862,17 @@ export class CategoriesService {
|
|||||||
// subgroups. Drilling into them used to insert per-part endpoints as
|
// subgroups. Drilling into them used to insert per-part endpoints as
|
||||||
// ghost child categories — keep the regex wide so any /extern/{kind}/
|
// ghost child categories — keep the regex wide so any /extern/{kind}/
|
||||||
// (vin|mdl)_items endpoint is recognised, not just /servicepart/.
|
// (vin|mdl)_items endpoint is recognised, not just /servicepart/.
|
||||||
// One shared classifier (integrations/pl24/pl24-tree) — the inline lists here,
|
const lp = linkPath.toLowerCase();
|
||||||
// in catalog.service and in the prefetch worker used to disagree, which is how
|
|
||||||
// p5psa/p5volvo camelCase `bomDetails` leaves and `illusTable` group levels
|
|
||||||
// ended up on the wrong side (silent empty panels / unfetched parts).
|
|
||||||
if (
|
if (
|
||||||
isPl24LeafNode({ linkPath }) ||
|
lp.includes("/bom/") ||
|
||||||
linkPath.toLowerCase().includes("json-vin-bom-detail.action")
|
lp.includes("/bomdetails") ||
|
||||||
|
lp.includes("/partinfo/") ||
|
||||||
|
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) ||
|
||||||
|
// PSA / Hyundai / Opel / Volvo image-board pages and the Ford VIN
|
||||||
|
// vin-image-board.action equivalent. Drilling into them yields BOM rows,
|
||||||
|
// not sub-groups — let getCategoryWithParts handle those as parts.
|
||||||
|
lp.includes("image-board.action") ||
|
||||||
|
lp.includes("json-vin-bom-detail.action")
|
||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -1007,104 +957,6 @@ export class CategoriesService {
|
|||||||
* The per-node trail excludes the node itself, ordered root-first. Used by the
|
* The per-node trail excludes the node itself, ordered root-first. Used by the
|
||||||
* catalog search so each hit can show where it sits in the tree.
|
* catalog search so each hit can show where it sits in the tree.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Re-decode a vehicle whose stored catalogInfo still points at a retired PL24
|
|
||||||
* architecture (P4 PSA/Volvo) and replace its category tree with the P5 one.
|
|
||||||
*
|
|
||||||
* Returns true when the tree was rebuilt. Deliberately conservative:
|
|
||||||
* - only PSA/Volvo rows whose service is P5 today are touched;
|
|
||||||
* - a failed or empty re-decode leaves the old tree in place (stale data beats
|
|
||||||
* no data), and the attempt is remembered for a day so a permanently
|
|
||||||
* unresolvable VIN cannot re-hit PL24 on every page view;
|
|
||||||
* - the old rows are deleted only once the new tree is in hand, because the
|
|
||||||
* unique (vehicle_id, name, source) index would otherwise reject the insert.
|
|
||||||
*/
|
|
||||||
private async healStalePl24Architecture(
|
|
||||||
vehicleId: string,
|
|
||||||
vehicle: { vin: string | null; rawData: unknown },
|
|
||||||
existingCategoryCount: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const rawData = vehicle.rawData as {
|
|
||||||
catalogInfo?: { serviceName?: string; catalogPath?: string };
|
|
||||||
} | null;
|
|
||||||
const catalogInfo = rawData?.catalogInfo;
|
|
||||||
const serviceName = catalogInfo?.serviceName;
|
|
||||||
if (!vehicle.vin || !serviceName) return false;
|
|
||||||
if (
|
|
||||||
!isStalePl24Architecture({
|
|
||||||
catalogPath: catalogInfo?.catalogPath,
|
|
||||||
currentApiPath: getServiceApiPath(serviceName),
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attemptKey = `pl24:heal:${vehicleId}`;
|
|
||||||
if (!(await this.redis.setNx(attemptKey, "1", 86_400))) return false;
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[pl24-heal] ${vehicle.vin} (${serviceName}) was decoded on ${catalogInfo?.catalogPath} — re-decoding on P5`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let decoded: Awaited<ReturnType<PL24Service["decodeVin"]>> | null = null;
|
|
||||||
try {
|
|
||||||
await this.redis.del(`pl24:vehicle:${vehicle.vin}`);
|
|
||||||
decoded = await this.pl24Service.decodeVin(vehicle.vin);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`[pl24-heal] ${vehicle.vin} re-decode failed: ${(err as Error).message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!decoded?.categories?.length) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[pl24-heal] ${vehicle.vin} re-decode returned no categories — keeping old tree`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.transaction(async (tx) => {
|
|
||||||
await tx.delete(categories).where(eq(categories.vehicleId, vehicleId));
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const rows = decoded.categories
|
|
||||||
.filter((c) => {
|
|
||||||
const name = c.nameTr || c.nameEn;
|
|
||||||
if (!name || seen.has(name)) return false;
|
|
||||||
seen.add(name);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map((c) => ({
|
|
||||||
vehicleId,
|
|
||||||
catalogVehicleId: null as string | null,
|
|
||||||
name: c.nameTr || c.nameEn,
|
|
||||||
nameOriginal: c.nameEn,
|
|
||||||
parentId: null as string | null,
|
|
||||||
externalId: c.code,
|
|
||||||
linkPath: c.linkPath || null,
|
|
||||||
linkWid: c.linkWid || null,
|
|
||||||
source: "pl24" as const,
|
|
||||||
}));
|
|
||||||
if (rows.length) await tx.insert(categories).values(rows);
|
|
||||||
await tx
|
|
||||||
.update(vehicles)
|
|
||||||
.set({
|
|
||||||
rawData: { ...(rawData ?? {}), catalogInfo: decoded.catalogInfo },
|
|
||||||
fullyFetched: false,
|
|
||||||
fullyFetchedAt: null,
|
|
||||||
})
|
|
||||||
.where(eq(vehicles.id, vehicleId));
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
this.redis.del(`cat:tree:${vehicleId}`),
|
|
||||||
this.redis.del(`prefetch:complete:${vehicleId}`),
|
|
||||||
this.redis.del(`prefetch:noresult:${vehicleId}`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[pl24-heal] ${vehicle.vin} migrated to ${decoded.catalogInfo?.catalogPath}: ${existingCategoryCount} stale → ${decoded.categories.length} fresh categories`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildBreadcrumbs(
|
private async buildBreadcrumbs(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
): Promise<Map<string, Array<{ id: string; name: string }>>> {
|
): Promise<Map<string, Array<{ id: string; name: string }>>> {
|
||||||
@@ -1430,13 +1282,9 @@ export class CategoriesService {
|
|||||||
// AND lowercase (groupReferenceTable, groupTable, groupsTable). The old
|
// AND lowercase (groupReferenceTable, groupTable, groupsTable). The old
|
||||||
// case-sensitive includes("Group") missed the lowercase ones (~1157 nodes),
|
// case-sensitive includes("Group") missed the lowercase ones (~1157 nodes),
|
||||||
// so they skipped this group-drill branch and fell to the parts path.
|
// so they skipped this group-drill branch and fell to the parts path.
|
||||||
// A PL24 node is a parent when the shared classifier says it is not a parts
|
|
||||||
// leaf. The old `linkWid.includes("group")` test missed p5psa `illusTable`
|
|
||||||
// and p5volvo/p5subaru `illustrationsTable`, so those levels were fetched as
|
|
||||||
// parts, parsed to zero rows and rendered as an empty panel with no error.
|
|
||||||
if (
|
if (
|
||||||
category.source === "pl24" &&
|
category.source === "pl24" &&
|
||||||
isPl24GroupNode({ linkPath: category.linkPath, linkWid: category.linkWid }) &&
|
category.linkWid?.toLowerCase().includes("group") &&
|
||||||
category.vehicleId
|
category.vehicleId
|
||||||
) {
|
) {
|
||||||
const groupChildren = await this.getChildren(categoryId);
|
const groupChildren = await this.getChildren(categoryId);
|
||||||
@@ -2291,8 +2139,15 @@ export class CategoriesService {
|
|||||||
return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic
|
return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic
|
||||||
})()
|
})()
|
||||||
: (() => {
|
: (() => {
|
||||||
if (!c.linkPath) return dbChildCount === 0;
|
const lp = c.linkPath?.toLowerCase() ?? "";
|
||||||
return isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid });
|
return (
|
||||||
|
lp.includes("/bom/") ||
|
||||||
|
lp.includes("/bomdetails") ||
|
||||||
|
lp.includes("/partinfo/") ||
|
||||||
|
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) ||
|
||||||
|
lp.includes("image-board.action") ||
|
||||||
|
(!c.linkPath && dbChildCount === 0)
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Global, Module } from "@nestjs/common";
|
|
||||||
import { TelegramService } from "./telegram.service";
|
|
||||||
|
|
||||||
/** Global so any module can raise an operational alert without extra wiring. */
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [TelegramService],
|
|
||||||
exports: [TelegramService],
|
|
||||||
})
|
|
||||||
export class TelegramModule {}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal Telegram alerting for operational failures that need a human now
|
|
||||||
* (currently: PL24 auth down for an hour — i.e. the account may be banned
|
|
||||||
* again). Fire-and-forget: a failed alert must never affect a request.
|
|
||||||
*
|
|
||||||
* Uses the same bot as the Süper Panel — set TELEGRAM_BOT_TOKEN and
|
|
||||||
* TELEGRAM_CHAT_ID. Unconfigured = silently disabled (local/dev).
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class TelegramService {
|
|
||||||
private readonly logger = new Logger(TelegramService.name);
|
|
||||||
|
|
||||||
private get token(): string {
|
|
||||||
return process.env.TELEGRAM_BOT_TOKEN ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private get chatId(): string {
|
|
||||||
return process.env.TELEGRAM_CHAT_ID ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
isConfigured(): boolean {
|
|
||||||
return Boolean(this.token && this.chatId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Send a message. Returns false when disabled or the API refused. */
|
|
||||||
async send(text: string, opts: { silent?: boolean } = {}): Promise<boolean> {
|
|
||||||
if (!this.isConfigured()) {
|
|
||||||
this.logger.warn(`Telegram not configured — alert dropped: ${text.slice(0, 120)}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
chat_id: this.chatId,
|
|
||||||
text,
|
|
||||||
parse_mode: "HTML",
|
|
||||||
disable_notification: opts.silent ?? false,
|
|
||||||
disable_web_page_preview: true,
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(10_000),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
this.logger.warn(`Telegram sendMessage failed: HTTP ${res.status}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Telegram sendMessage error: ${(err as Error).message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -203,12 +203,6 @@ export const userSubscriptions = pgTable(
|
|||||||
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
|
// NULL for trials and legacy one-time purchases. Renewal invoices resolve
|
||||||
// our row through this.
|
// our row through this.
|
||||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||||
// Dunning state — set on the first failed renewal charge, cleared when the
|
|
||||||
// invoice recovers (invoice.paid) or the sub is finally cancelled. The URL
|
|
||||||
// is Stripe's hosted invoice page: the ONLY self-serve surface where the
|
|
||||||
// user can pay the open invoice / enter a new card / pass 3DS.
|
|
||||||
dunningSince: timestamp("dunning_since", { withTimezone: true }),
|
|
||||||
dunningInvoiceUrl: text("dunning_invoice_url"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
@@ -425,20 +419,10 @@ export const vehicles = pgTable(
|
|||||||
market: varchar("market", { length: 100 }),
|
market: varchar("market", { length: 100 }),
|
||||||
rawData: jsonb("raw_data"),
|
rawData: jsonb("raw_data"),
|
||||||
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
||||||
// Durable completeness marker: set true when the prefetch chain finishes with
|
|
||||||
// parts (see PrefetchWorkerService.incrementCompleted). Unlike the ephemeral
|
|
||||||
// Redis `prefetch:complete:*` marker (21-day TTL, operational skip logic) this
|
|
||||||
// never expires — it's the reliable "% fully fetched" measurement. `_at` tracks
|
|
||||||
// the last time completion was confirmed (re-set on each re-drill completion).
|
|
||||||
fullyFetched: boolean("fully_fetched").default(false).notNull(),
|
|
||||||
fullyFetchedAt: timestamp("fully_fetched_at", { withTimezone: true }),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [uniqueIndex("vehicles_vin_unique_idx").on(table.vin)],
|
||||||
uniqueIndex("vehicles_vin_unique_idx").on(table.vin),
|
|
||||||
index("vehicles_fully_fetched_idx").on(table.fullyFetched),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
|
// ─── User Vehicles (junction — user ↔ shared vehicle) ─
|
||||||
|
|||||||
@@ -33,11 +33,7 @@ export class CorgiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractYear(vin: string): number | null {
|
private extractYear(vin: string): number | null {
|
||||||
// Corgi is a WMI-only decoder — it resolves brand + year but never a model.
|
return extractModelYear(vin);
|
||||||
// Signalling modelResolved:false lets extractModelYear roll a 30-year-ambiguous
|
|
||||||
// position-10 code (e.g. "T" = 1996-or-2026) back a cycle for an old-shaped
|
|
||||||
// Renault VIN, so a ~1996 R19 isn't mislabelled as a 2026 car (see vin-validator).
|
|
||||||
return extractModelYear(vin, undefined, { modelResolved: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getBrandFromWmi(wmi: string): string | null {
|
getBrandFromWmi(wmi: string): string | null {
|
||||||
|
|||||||
@@ -94,16 +94,14 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
|
||||||
|
|
||||||
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
this.useProxy = this.configService.get<string>("EMEX_USE_PROXY", "true") === "true";
|
||||||
// Default dataimpulse: Floxy retired 2026-07 (permanently down). Set
|
|
||||||
// EMEX_PROXY_PROVIDER=floxy to re-enable it.
|
|
||||||
const rawProvider = this.configService
|
const rawProvider = this.configService
|
||||||
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
|
.get<string>("EMEX_PROXY_PROVIDER", "floxy")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
this.proxyProvider = !this.useProxy
|
this.proxyProvider = !this.useProxy
|
||||||
? "none"
|
? "none"
|
||||||
: rawProvider === "floxy" || rawProvider === "none"
|
: rawProvider === "dataimpulse" || rawProvider === "none"
|
||||||
? (rawProvider as "floxy" | "none")
|
? (rawProvider as "dataimpulse" | "none")
|
||||||
: "dataimpulse";
|
: "floxy";
|
||||||
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
this.proxyHost = this.configService.get<string>("EMEX_PROXY_HOST", "74.81.81.81");
|
||||||
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
this.proxyPortStart = this.configService.get<number>("EMEX_PROXY_PORT_START", 10001);
|
||||||
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
this.proxyPortEnd = this.configService.get<number>("EMEX_PROXY_PORT_END", 10099);
|
||||||
@@ -262,42 +260,6 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
|
|||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Block heavy resources to save proxy bandwidth. Scraping reads HTML/DOM
|
|
||||||
// only — schema-image URLs are parsed from the markup and their dims come
|
|
||||||
// from a separate Range GET, so the browser never needs images/fonts/CSS.
|
|
||||||
// Context-level route covers every tab (session establish + acquirePage).
|
|
||||||
await this.context.route("**/*", (route) => {
|
|
||||||
const url = route.request().url();
|
|
||||||
const type = route.request().resourceType();
|
|
||||||
|
|
||||||
if (["image", "font", "stylesheet", "media"].includes(type)) {
|
|
||||||
return route.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Third-party junk seen in proxy usage: analytics, ad networks, chat
|
|
||||||
// widgets, Google/Yandex assets, and our own ipify egress probe.
|
|
||||||
if (
|
|
||||||
url.includes("google-analytics.com") ||
|
|
||||||
url.includes("googletagmanager.com") ||
|
|
||||||
url.includes("mc.yandex.ru") ||
|
|
||||||
url.includes("yastatic.net") ||
|
|
||||||
url.includes("fonts.googleapis.com") ||
|
|
||||||
url.includes("fonts.gstatic.com") ||
|
|
||||||
url.includes("content-autofill.googleapis.com") ||
|
|
||||||
url.includes("facebook.net") ||
|
|
||||||
url.includes("doubleclick.net") ||
|
|
||||||
url.includes("hotjar.com") ||
|
|
||||||
url.includes("adsco.re") ||
|
|
||||||
url.includes("displayvertising.com") ||
|
|
||||||
url.includes("tidio.co") ||
|
|
||||||
url.includes("api.ipify.org")
|
|
||||||
) {
|
|
||||||
return route.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
return route.continue();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.startedAt = Date.now();
|
this.startedAt = Date.now();
|
||||||
this.sessionExpiry = 0; // force session establish on first acquirePage
|
this.sessionExpiry = 0; // force session establish on first acquirePage
|
||||||
|
|
||||||
|
|||||||
@@ -215,15 +215,11 @@ export class EmexService {
|
|||||||
// random-port-per-call primary proved that), but a sticky Floxy IP across the
|
// random-port-per-call primary proved that), but a sticky Floxy IP across the
|
||||||
// chained flow is still the safest choice — and avoids DataImpulse's ~50% dead
|
// chained flow is still the safest choice — and avoids DataImpulse's ~50% dead
|
||||||
// ports that were eating the decode budget before reaching the Floxy fallback.
|
// ports that were eating the decode budget before reaching the Floxy fallback.
|
||||||
// Default dataimpulse: Floxy retired 2026-07 (permanently down). Set
|
|
||||||
// EMEX_PROXY_PROVIDER=floxy to re-enable it.
|
|
||||||
this.emexProxyProvider = !useProxy
|
this.emexProxyProvider = !useProxy
|
||||||
? "none"
|
? "none"
|
||||||
: (() => {
|
: (() => {
|
||||||
const p = this.configService
|
const p = this.configService.get<string>("EMEX_PROXY_PROVIDER", "floxy").toLowerCase();
|
||||||
.get<string>("EMEX_PROXY_PROVIDER", "dataimpulse")
|
return p === "dataimpulse" || p === "none" ? p : "floxy";
|
||||||
.toLowerCase();
|
|
||||||
return p === "floxy" || p === "none" ? p : "dataimpulse";
|
|
||||||
})();
|
})();
|
||||||
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
|
// Default agent for the low-stakes image-dims path; fetchEmexHtml builds a
|
||||||
// fresh agent per request so a flaky exit can't pin every call.
|
// fresh agent per request so a flaky exit can't pin every call.
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* Parts-Catalogs capture asset cache.
|
|
||||||
*
|
|
||||||
* Every JWT capture opens a fresh browser context (empty HTTP cache) and
|
|
||||||
* re-downloads the SAME static widget assets through the billed residential
|
|
||||||
* proxy — measured ~2.2 MB/capture, ~93% of it the parts-catalogs widget
|
|
||||||
* bundle (`.../v3/bundle_<hash>.js`, identical across all partner sites) plus
|
|
||||||
* the sites' own versioned JS/CSS. At ~3–4k captures/day that is the single
|
|
||||||
* largest avoidable proxy cost (~20 GB/15d) with zero ban risk: the widget
|
|
||||||
* still runs (served from here) and still fires its token XHR live.
|
|
||||||
*
|
|
||||||
* This is a URL-keyed byte cache with a hot in-memory layer and a best-effort
|
|
||||||
* disk layer (survives redeploys). Only versioned static assets are cached —
|
|
||||||
* their URLs carry a content hash / `?_=<ver>` so a bundle bump is a new URL
|
|
||||||
* (cache miss → refetched once), i.e. invalidation is automatic. The token XHR
|
|
||||||
* (`/v3/api/proxy/*`) and HTML documents are never cached; they stay live.
|
|
||||||
*/
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
export interface CachedAsset {
|
|
||||||
contentType: string;
|
|
||||||
body: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PcatAssetCache {
|
|
||||||
private readonly dir: string;
|
|
||||||
private readonly mem = new Map<string, CachedAsset>();
|
|
||||||
private readonly maxMemEntries: number;
|
|
||||||
private readonly ready: Promise<void>;
|
|
||||||
|
|
||||||
constructor(opts: { dir?: string; maxMemEntries?: number } = {}) {
|
|
||||||
this.dir = opts.dir ?? join(tmpdir(), "pcat-asset-cache");
|
|
||||||
this.maxMemEntries = opts.maxMemEntries ?? 300;
|
|
||||||
this.ready = mkdir(this.dir, { recursive: true }).then(
|
|
||||||
() => undefined,
|
|
||||||
() => undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only versioned static JS/CSS is cacheable. Never the token/API path, never
|
|
||||||
* HTML documents (may carry a per-session nonce the token call needs).
|
|
||||||
*/
|
|
||||||
static isCacheable(resourceType: string, url: string): boolean {
|
|
||||||
if (resourceType !== "script" && resourceType !== "stylesheet") return false;
|
|
||||||
if (url.includes("/v3/api/proxy/")) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fileKey(url: string): string {
|
|
||||||
return createHash("sha1").update(url).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(url: string): Promise<CachedAsset | null> {
|
|
||||||
const hot = this.mem.get(url);
|
|
||||||
if (hot) return hot;
|
|
||||||
await this.ready;
|
|
||||||
const key = this.fileKey(url);
|
|
||||||
try {
|
|
||||||
const [meta, body] = await Promise.all([
|
|
||||||
readFile(join(this.dir, `${key}.json`), "utf8"),
|
|
||||||
readFile(join(this.dir, `${key}.bin`)),
|
|
||||||
]);
|
|
||||||
const asset: CachedAsset = { contentType: JSON.parse(meta).contentType, body };
|
|
||||||
this.remember(url, asset);
|
|
||||||
return asset;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(url: string, contentType: string, body: Buffer): Promise<void> {
|
|
||||||
if (!body.length) return;
|
|
||||||
this.remember(url, { contentType, body });
|
|
||||||
await this.ready;
|
|
||||||
const key = this.fileKey(url);
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
writeFile(join(this.dir, `${key}.bin`), body),
|
|
||||||
writeFile(join(this.dir, `${key}.json`), JSON.stringify({ contentType, url })),
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
// Disk is best-effort; the in-memory layer still serves this process.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private remember(url: string, asset: CachedAsset): void {
|
|
||||||
// Cheap FIFO bound — versioned URLs keep the working set small anyway.
|
|
||||||
if (this.mem.size >= this.maxMemEntries) {
|
|
||||||
const oldest = this.mem.keys().next().value;
|
|
||||||
if (oldest !== undefined) this.mem.delete(oldest);
|
|
||||||
}
|
|
||||||
this.mem.set(url, asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
isProxyConnectFailure,
|
isProxyConnectFailure,
|
||||||
resolveExitIp,
|
resolveExitIp,
|
||||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
} from "../proxy-telemetry/proxy-telemetry.service";
|
||||||
import { PcatAssetCache } from "./parts-catalogs-asset-cache";
|
|
||||||
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
|
||||||
|
|
||||||
// Shared single-slot cache so dev + prod (and any restarted container) can
|
// Shared single-slot cache so dev + prod (and any restarted container) can
|
||||||
@@ -196,13 +195,6 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// the exact failure seen 2026-06-11 (Floxy 402) and 2026-07-03 (Floxy tunnel
|
// the exact failure seen 2026-06-11 (Floxy 402) and 2026-07-03 (Floxy tunnel
|
||||||
// down). Disable with PCAT_CAPTURE_ALLOW_DIRECT=false.
|
// down). Disable with PCAT_CAPTURE_ALLOW_DIRECT=false.
|
||||||
private readonly captureAllowDirect: boolean;
|
private readonly captureAllowDirect: boolean;
|
||||||
// Exit-IP telemetry probe (one ipify call per capture). Off by default: it
|
|
||||||
// added ~54k billed proxy requests over 15 days for banned-IP telemetry only.
|
|
||||||
private readonly exitIpProbe: boolean;
|
|
||||||
// Cross-capture cache for the widget's static JS/CSS so each capture stops
|
|
||||||
// re-downloading ~2 MB of identical assets through the residential proxy.
|
|
||||||
// Null when disabled via PCAT_ASSET_CACHE=false (kill switch, no redeploy).
|
|
||||||
private readonly assetCache: PcatAssetCache | null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
@@ -224,16 +216,13 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.semaphore = new Semaphore(this.jwtSites.length);
|
this.semaphore = new Semaphore(this.jwtSites.length);
|
||||||
|
|
||||||
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
|
// Provider selection. Back-compat: PCAT_USE_PROXY=false still forces direct.
|
||||||
// Default is dataimpulse: Floxy was retired 2026-07 (permanently down —
|
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "floxy").toLowerCase();
|
||||||
// balance/tunnel dead), so floxy-primary just burned a failed attempt per
|
|
||||||
// call before failing over. Set PCAT_PROXY_PROVIDER=floxy to re-enable it.
|
|
||||||
const rawProvider = cfg.get<string>("PCAT_PROXY_PROVIDER", "dataimpulse").toLowerCase();
|
|
||||||
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
|
const useProxy = cfg.get<string>("PCAT_USE_PROXY", "true") === "true";
|
||||||
this.proxyProvider = !useProxy
|
this.proxyProvider = !useProxy
|
||||||
? "none"
|
? "none"
|
||||||
: rawProvider === "floxy" || rawProvider === "none"
|
: rawProvider === "dataimpulse" || rawProvider === "none"
|
||||||
? (rawProvider as ProxyProvider)
|
? (rawProvider as ProxyProvider)
|
||||||
: "dataimpulse";
|
: "floxy";
|
||||||
|
|
||||||
const toInt = (key: string, def: number): number => {
|
const toInt = (key: string, def: number): number => {
|
||||||
const n = Number(cfg.get(key, def));
|
const n = Number(cfg.get(key, def));
|
||||||
@@ -253,9 +242,6 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
this.diHost = cfg.get<string>("PCAT_PROXY_HOST", DI_HOST);
|
||||||
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
this.diUser = cfg.get<string>("PCAT_PROXY_USER", DI_DEFAULT_USER);
|
||||||
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
this.diPass = cfg.get<string>("PCAT_PROXY_PASS", DI_DEFAULT_PASS);
|
||||||
this.exitIpProbe = cfg.get<string>("PCAT_EXIT_IP_PROBE", "false") === "true";
|
|
||||||
this.assetCache =
|
|
||||||
cfg.get<string>("PCAT_ASSET_CACHE", "true") === "true" ? new PcatAssetCache() : null;
|
|
||||||
|
|
||||||
this.captureAllowDirect =
|
this.captureAllowDirect =
|
||||||
cfg.get<string>("PCAT_CAPTURE_ALLOW_DIRECT", "true") !== "false" &&
|
cfg.get<string>("PCAT_CAPTURE_ALLOW_DIRECT", "true") !== "false" &&
|
||||||
@@ -820,10 +806,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// The session is sticky, so a parallel ipify probe exits from the SAME IP
|
// The session is sticky, so a parallel ipify probe exits from the SAME IP
|
||||||
// the capture will use — that's what makes banned-IP tracking concrete.
|
// the capture will use — that's what makes banned-IP tracking concrete.
|
||||||
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
|
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
|
||||||
// Gated off by default (PCAT_EXIT_IP_PROBE): the probe is billed proxy
|
const exitIpPromise = proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
|
||||||
// traffic and only feeds exit-IP telemetry, not the capture itself.
|
|
||||||
const exitIpPromise =
|
|
||||||
this.exitIpProbe && proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
|
|
||||||
const logCapture = (ok: boolean, errorKind?: string): void => {
|
const logCapture = (ok: boolean, errorKind?: string): void => {
|
||||||
void exitIpPromise.then((exitIp) =>
|
void exitIpPromise.then((exitIp) =>
|
||||||
this.proxyTelemetry.record({
|
this.proxyTelemetry.record({
|
||||||
@@ -873,15 +856,8 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
|
this.logger.debug(`Token intercepted (key=${apiKey.slice(0, 16)}...)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Block heavy resources to save proxy bandwidth. Kept intentionally
|
// Block heavy resources to save proxy bandwidth
|
||||||
// minimal: an aggressive block (stylesheet + yastatic/font/ad domains)
|
await page.route("**/*", (route) => {
|
||||||
// shipped 2026-07-16 killed token capture on prod (dataimpulse 425→0
|
|
||||||
// captures/h at deploy time — the RU catalog widget needs its CSS and
|
|
||||||
// Yandex-hosted runtime to init and fire the /v3/api/proxy XHR). Only
|
|
||||||
// block what the widget provably never needs: images, fonts, media, and
|
|
||||||
// a few pure analytics/ad domains.
|
|
||||||
const assetCache = this.assetCache;
|
|
||||||
await page.route("**/*", async (route) => {
|
|
||||||
const url = route.request().url();
|
const url = route.request().url();
|
||||||
const type = route.request().resourceType();
|
const type = route.request().resourceType();
|
||||||
|
|
||||||
@@ -902,29 +878,6 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return route.abort();
|
return route.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve versioned static JS/CSS from the cross-capture cache; a hit
|
|
||||||
// costs zero proxy bytes. On miss, fetch once through the context proxy,
|
|
||||||
// store the decoded body, and serve it. Everything else (HTML document,
|
|
||||||
// the /v3/api/proxy token XHR, dynamic fetches) always goes live.
|
|
||||||
if (assetCache && PcatAssetCache.isCacheable(type, url)) {
|
|
||||||
const hit = await assetCache.get(url);
|
|
||||||
if (hit) {
|
|
||||||
return route.fulfill({ status: 200, contentType: hit.contentType, body: hit.body });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resp = await route.fetch({ timeout: 15_000 });
|
|
||||||
const body = await resp.body();
|
|
||||||
const contentType =
|
|
||||||
resp.headers()["content-type"] ??
|
|
||||||
(type === "script" ? "application/javascript" : "text/css");
|
|
||||||
if (resp.ok() && body.length) void assetCache.put(url, contentType, body);
|
|
||||||
return route.fulfill({ status: resp.status(), contentType, body });
|
|
||||||
} catch {
|
|
||||||
// Fetch failed (slow/dead proxy) — let the browser attempt it itself.
|
|
||||||
return route.continue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<html><head>
|
|
||||||
<title>Ford WF0RXXGCDRAP80417: Kuga - CBV (2008, 2012) - partslink24</title>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
</script></head><body>
|
|
||||||
<table>
|
|
||||||
<tr id="_nav-scope-table0" class="tc-row tc-data-row tc-selected" jsonurl="json-vin-main-group.action?lang=en&openVinDialog=false&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="Kuga - CBV (2008, 2012)"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">Kuga - CBV (2008, 2012)</div></div></td></tr>
|
|
||||||
<tr id="_nav-scope-table1" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&openVinDialog=false&sharedCatCode=ZE&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="ZE - Accessories (ZE)<div class="restrictionBlockWithMargin"></div>"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">ZE - Accessories (ZE)<div class="restrictionBlockWithMargin"></div></div></div></td></tr>
|
|
||||||
<tr id="_nav-scope-table2" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&openVinDialog=false&sharedCatCode=ZF&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="ZF - Fluids & Maintenance Products (ZF)<div class="restrictionBlockWithMargin"></div>"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">ZF - Fluids & Maintenance Products (ZF)<div class="restrictionBlockWithMargin"></div></div></div></td></tr>
|
|
||||||
<tr id="_nav-scope-table4" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&openVinDialog=false&sharedCatCode=C20DD0X&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="C20DD0X - 2.0 DI Diesel<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Engine Type = All 2.0L Duratorq Engines&nbsp;</span></div>"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">C20DD0X - 2.0 DI Diesel<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Engine Type = All 2.0L Duratorq Engines </span></div></div></div></td></tr>
|
|
||||||
<tr id="_nav-scope-table5" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&openVinDialog=false&sharedCatCode=CMPS6&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="CMPS6 - 6 Speed Trans Powershift<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Transmission = 6 Speed Powershift 6DCT450 - MPS6&nbsp;</span></div>"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">CMPS6 - 6 Speed Trans Powershift<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Transmission = 6 Speed Powershift 6DCT450 - MPS6 </span></div></div></div></td></tr>
|
|
||||||
<tr id="_nav-scope-table6" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?lang=en&openVinDialog=false&sharedCatCode=CVPTO&startup=false&vin=WF0RXXGCDRAP80417&mode=A0LW0DEDE&upds=2026.09.08+15%3A14%3A06+CEST" caption="CVPTO - Transfer Case<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Transmission = 6 Speed Powershift 6DCT450 - MPS6&nbsp;</span></div>"><td class="caption tc-lcell tc-rcell dynaheight"><div class="dynaheightWrapper"><div class="dblWrap">CVPTO - Transfer Case<div class="restrictionBlockWithMargin"><span class="vinHit" title="Attribute matches">Transmission = 6 Speed Powershift 6DCT450 - MPS6 </span></div></div></div></td></tr>
|
|
||||||
<tr class="tc-row tc-data-row " id="_nav-maingroup-table0"><td class="tc-lcell identifier">1</td><td class="tc-rcell caption">Information And Customisation</td></tr>
|
|
||||||
<tr class="tc-row tc-data-row " id="_nav-maingroup-table1"><td class="tc-lcell identifier">2</td><td class="tc-rcell caption">Chassis</td></tr>
|
|
||||||
<tr class="tc-row tc-data-row " id="_nav-maingroup-table2"><td class="tc-lcell identifier">4</td><td class="tc-rcell caption">Electrical</td></tr>
|
|
||||||
<tr class="tc-row tc-data-row " id="_nav-maingroup-table3"><td class="tc-lcell identifier">5</td><td class="tc-rcell caption">Body And Paint</td></tr>
|
|
||||||
</table></body></html>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<html><head>
|
|
||||||
<title>Hyundai KMHCT41BAGU283791: ACCENT 15 - partslink24</title>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
</script></head><body>
|
|
||||||
<table>
|
|
||||||
<tr id="_nav-mainGroup-table0" class="tc-row tc-data-row " url="vin-group.action?lang=en&mainGroup=BO&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">BO</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">BODY</a></td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table1" class="tc-row tc-data-row " url="vin-group.action?lang=en&mainGroup=CH&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">CH</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">CHASSIS</a></td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table2" class="tc-row tc-data-row " url="vin-group.action?lang=en&mainGroup=EL&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">EL</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">ELECTRIC</a></td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table3" class="tc-row tc-data-row " url="vin-group.action?lang=en&mainGroup=EN&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">EN</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">ENGINE</a></td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table4" class="tc-row tc-data-row " url="vin-group.action?lang=en&mainGroup=MI&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">MI</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">TRANSMISSION</a></td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table5" class="tc-row tc-data-row " url="vin-group.action?lang=en&mainGroup=TR&openVinDialog=false&startup=false&vin=KMHCT41BAGU283791&mode=A0LW0DEDE&upds=2026.09.05+11%3A12%3A58+CEST"><td class="mainGroup identifier tc-lcell"><a class="unlink">TR</a></td><td class="mainGroup caption tc-rcell"><a class="unlink">TRIM</a></td></tr>
|
|
||||||
</table></body></html>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<html><head>
|
|
||||||
<title>Nissan - partslink24</title>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
</script></head><body>
|
|
||||||
<table>
|
|
||||||
<tr id="_nav-model-table1" class="tc-row tc-data-row " caption="Repair & Maintenance Information" ident="" urltype="EXTERN" url="https://eu.nissan.biz/" jsonurl="https://eu.nissan.biz/"><td colspan="2" class="model caption tc-rcell"><a class="unlink">Repair & Maintenance Information</a></td></tr>
|
|
||||||
<tr id="_nav-model-table3" class="tc-row tc-data-row " caption="200SX (EL)" ident="S14_097_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=S14_097_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=S14_097_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">S14</td><td class="model caption tc-rcell"><a class="unlink">200SX</a></td></tr>
|
|
||||||
<tr id="_nav-model-table4" class="tc-row tc-data-row " caption="350Z (EL)" ident="Z33_007_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Z33_007_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z33_007_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z33</td><td class="model caption tc-rcell"><a class="unlink">350Z</a></td></tr>
|
|
||||||
<tr id="_nav-model-table5" class="tc-row tc-data-row " caption="370Z (EL)" ident="Z34_042_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Z34_042_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z34_042_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z34</td><td class="model caption tc-rcell"><a class="unlink">370Z</a></td></tr>
|
|
||||||
<tr id="_nav-model-table6" class="tc-row tc-data-row " caption="ALMERA (EL)" ident="B10RS_016_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=B10RS_016_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=B10RS_016_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">B10RS</td><td class="model caption tc-rcell"><a class="unlink">ALMERA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table7" class="tc-row tc-data-row " caption="ALMERA (EL)" ident="G15RA_110_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=G15RA_110_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=G15RA_110_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">G15RA</td><td class="model caption tc-rcell"><a class="unlink">ALMERA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table8" class="tc-row tc-data-row " caption="ALMERA (EL)" ident="N15_088_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=N15_088_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=N15_088_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">N15</td><td class="model caption tc-rcell"><a class="unlink">ALMERA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table9" class="tc-row tc-data-row " caption="ALMERA JPN MAKE (EL)" ident="N16_298_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=N16_298_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=N16_298_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">N16</td><td class="model caption tc-rcell"><a class="unlink">ALMERA JPN MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table10" class="tc-row tc-data-row " caption="ALMERA TINO (EL)" ident="V10M_302_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=V10M_302_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=V10M_302_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">V10M</td><td class="model caption tc-rcell"><a class="unlink">ALMERA TINO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table11" class="tc-row tc-data-row " caption="ALMERA UK MAKE (EL)" ident="N16E_307_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=N16E_307_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=N16E_307_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">N16E</td><td class="model caption tc-rcell"><a class="unlink">ALMERA UK MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table12" class="tc-row tc-data-row " caption="ALTIMA (EL)" ident="L33_138_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=L33_138_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=L33_138_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">L33</td><td class="model caption tc-rcell"><a class="unlink">ALTIMA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table13" class="tc-row tc-data-row " caption="ALTIMA (EL)" ident="L34_155_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=L34_155_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=L34_155_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">L34</td><td class="model caption tc-rcell"><a class="unlink">ALTIMA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table14" class="tc-row tc-data-row " caption="ARIYA (EL)" ident="FE0_158_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=FE0_158_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=FE0_158_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">FE0</td><td class="model caption tc-rcell"><a class="unlink">ARIYA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table15" class="tc-row tc-data-row " caption="ATLEON (EL)" ident="TK3_059_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=TK3_059_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=TK3_059_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">TK3</td><td class="model caption tc-rcell"><a class="unlink">ATLEON</a></td></tr>
|
|
||||||
<tr id="_nav-model-table16" class="tc-row tc-data-row " caption="CABSTAR (EL)" ident="F24M_021_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=F24M_021_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=F24M_021_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">F24M</td><td class="model caption tc-rcell"><a class="unlink">CABSTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table17" class="tc-row tc-data-row " caption="CUBE (EL)" ident="Z12_047_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Z12_047_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z12_047_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z12</td><td class="model caption tc-rcell"><a class="unlink">CUBE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table18" class="tc-row tc-data-row " caption="DATSUN MI-DO (EL)" ident="HBD0R_149_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=HBD0R_149_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=HBD0R_149_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">HBD0R</td><td class="model caption tc-rcell"><a class="unlink">DATSUN MI-DO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table19" class="tc-row tc-data-row " caption="DATSUN MI-DO (EL)" ident="HBD0_129_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=HBD0_129_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=HBD0_129_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">HBD0</td><td class="model caption tc-rcell"><a class="unlink">DATSUN MI-DO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table20" class="tc-row tc-data-row " caption="DATSUN ON-DO (EL)" ident="BD0R_148_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=BD0R_148_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=BD0R_148_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">BD0R</td><td class="model caption tc-rcell"><a class="unlink">DATSUN ON-DO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table21" class="tc-row tc-data-row " caption="DATSUN ON-DO (EL)" ident="BD0_121_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=BD0_121_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=BD0_121_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">BD0</td><td class="model caption tc-rcell"><a class="unlink">DATSUN ON-DO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table22" class="tc-row tc-data-row " caption="E-NV200 SPAIN (EL)" ident="ME0M_122_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=ME0M_122_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ME0M_122_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ME0M</td><td class="model caption tc-rcell"><a class="unlink">E-NV200 SPAIN</a></td></tr>
|
|
||||||
<tr id="_nav-model-table23" class="tc-row tc-data-row " caption="GT-R (EL)" ident="R35_040_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=R35_040_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R35_040_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R35</td><td class="model caption tc-rcell"><a class="unlink">GT-R</a></td></tr>
|
|
||||||
<tr id="_nav-model-table24" class="tc-row tc-data-row " caption="HARDBODY (EL)" ident="D22S_014_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=D22S_014_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D22S_014_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D22S</td><td class="model caption tc-rcell"><a class="unlink">HARDBODY</a></td></tr>
|
|
||||||
<tr id="_nav-model-table25" class="tc-row tc-data-row " caption="INTERSTAR (EL)" ident="X70_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=X70_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X70_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X70</td><td class="model caption tc-rcell"><a class="unlink">INTERSTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table26" class="tc-row tc-data-row " caption="INTERSTAR (EL)" ident="XDD_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=XDD_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=XDD_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">XDD</td><td class="model caption tc-rcell"><a class="unlink">INTERSTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table27" class="tc-row tc-data-row " caption="INTERSTAR (EL)" ident="XDE_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=XDE_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=XDE_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">XDE</td><td class="model caption tc-rcell"><a class="unlink">INTERSTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table28" class="tc-row tc-data-row " caption="JUKE (EL)" ident="F16E_157_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=F16E_157_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=F16E_157_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">F16E</td><td class="model caption tc-rcell"><a class="unlink">JUKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table29" class="tc-row tc-data-row " caption="JUKE JPN MAKE (EL)" ident="F15_052_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=F15_052_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=F15_052_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">F15</td><td class="model caption tc-rcell"><a class="unlink">JUKE JPN MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table30" class="tc-row tc-data-row " caption="JUKE UK MAKE (EL)" ident="F15E_056_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=F15E_056_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=F15E_056_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">F15E</td><td class="model caption tc-rcell"><a class="unlink">JUKE UK MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table31" class="tc-row tc-data-row " caption="KING CAB (EL)" ident="D22_073_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=D22_073_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D22_073_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D22</td><td class="model caption tc-rcell"><a class="unlink">KING CAB</a></td></tr>
|
|
||||||
<tr id="_nav-model-table32" class="tc-row tc-data-row " caption="KING CAB (EL)" ident="LCD22_030_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=LCD22_030_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=LCD22_030_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">LCD22</td><td class="model caption tc-rcell"><a class="unlink">KING CAB</a></td></tr>
|
|
||||||
<tr id="_nav-model-table33" class="tc-row tc-data-row " caption="KUBISTAR (EL)" ident="X76_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=X76_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X76_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X76</td><td class="model caption tc-rcell"><a class="unlink">KUBISTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table34" class="tc-row tc-data-row " caption="LEAF (EL)" ident="ZE0_054_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=ZE0_054_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ZE0_054_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ZE0</td><td class="model caption tc-rcell"><a class="unlink">LEAF</a></td></tr>
|
|
||||||
<tr id="_nav-model-table35" class="tc-row tc-data-row " caption="LEAF UK MAKE (EL)" ident="ZE0E_111_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=ZE0E_111_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ZE0E_111_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ZE0E</td><td class="model caption tc-rcell"><a class="unlink">LEAF UK MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table36" class="tc-row tc-data-row " caption="LEAF UK MAKE (EL)" ident="ZE1E_152_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=ZE1E_152_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ZE1E_152_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ZE1E</td><td class="model caption tc-rcell"><a class="unlink">LEAF UK MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table37" class="tc-row tc-data-row " caption="LEAF UK MAKE (EL)" ident="ZE2E_166_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=ZE2E_166_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=ZE2E_166_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">ZE2E</td><td class="model caption tc-rcell"><a class="unlink">LEAF UK MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table38" class="tc-row tc-data-row " caption="MAXIMA (EL)" ident="A32_065_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=A32_065_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=A32_065_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">A32</td><td class="model caption tc-rcell"><a class="unlink">MAXIMA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table39" class="tc-row tc-data-row " caption="MAXIMA (EL)" ident="A36_131_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=A36_131_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=A36_131_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">A36</td><td class="model caption tc-rcell"><a class="unlink">MAXIMA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table40" class="tc-row tc-data-row " caption="MAXIMA (EL)" ident="CA33_305_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=CA33_305_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=CA33_305_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">CA33</td><td class="model caption tc-rcell"><a class="unlink">MAXIMA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table41" class="tc-row tc-data-row " caption="MICRA (EL)" ident="K11E_081_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=K11E_081_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K11E_081_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K11E</td><td class="model caption tc-rcell"><a class="unlink">MICRA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table42" class="tc-row tc-data-row " caption="MICRA (EL)" ident="K12E_005_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=K12E_005_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K12E_005_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K12E</td><td class="model caption tc-rcell"><a class="unlink">MICRA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table43" class="tc-row tc-data-row " caption="MICRA (EL)" ident="K15_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=K15_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K15_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K15</td><td class="model caption tc-rcell"><a class="unlink">MICRA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table44" class="tc-row tc-data-row " caption="MICRA C+C (EL)" ident="CK12E_012_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=CK12E_012_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=CK12E_012_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">CK12E</td><td class="model caption tc-rcell"><a class="unlink">MICRA C+C</a></td></tr>
|
|
||||||
<tr id="_nav-model-table45" class="tc-row tc-data-row " caption="MICRA FLIN MAKE (EL)" ident="K14FR_144_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=K14FR_144_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K14FR_144_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K14FR</td><td class="model caption tc-rcell"><a class="unlink">MICRA FLIN MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table46" class="tc-row tc-data-row " caption="MICRA IND MAKE (EL)" ident="K13KK_136_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=K13KK_136_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K13KK_136_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K13KK</td><td class="model caption tc-rcell"><a class="unlink">MICRA IND MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table47" class="tc-row tc-data-row " caption="MICRA IND MAKE (EL)" ident="K13K_051_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=K13K_051_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=K13K_051_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">K13K</td><td class="model caption tc-rcell"><a class="unlink">MICRA IND MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table48" class="tc-row tc-data-row " caption="MURANO (EL)" ident="Z50_008_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Z50_008_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z50_008_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z50</td><td class="model caption tc-rcell"><a class="unlink">MURANO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table49" class="tc-row tc-data-row " caption="MURANO (EL)" ident="Z51_036_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Z51_036_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z51_036_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z51</td><td class="model caption tc-rcell"><a class="unlink">MURANO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table50" class="tc-row tc-data-row " caption="MURANO RUS MAKE (EL)" ident="Z51R_057_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Z51R_057_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z51R_057_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z51R</td><td class="model caption tc-rcell"><a class="unlink">MURANO RUS MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table51" class="tc-row tc-data-row " caption="MURANO RUS MAKE (EL)" ident="Z52R_137_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Z52R_137_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Z52R_137_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Z52R</td><td class="model caption tc-rcell"><a class="unlink">MURANO RUS MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table52" class="tc-row tc-data-row " caption="NAVARA (EL)" ident="D40M_010_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=D40M_010_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D40M_010_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D40M</td><td class="model caption tc-rcell"><a class="unlink">NAVARA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table53" class="tc-row tc-data-row " caption="NAVARA NP300 (EL)" ident="D23M_135_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=D23M_135_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D23M_135_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D23M</td><td class="model caption tc-rcell"><a class="unlink">NAVARA NP300</a></td></tr>
|
|
||||||
<tr id="_nav-model-table54" class="tc-row tc-data-row " caption="NOTE UK MAKE (EL)" ident="E11E_015_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=E11E_015_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=E11E_015_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">E11E</td><td class="model caption tc-rcell"><a class="unlink">NOTE UK MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table55" class="tc-row tc-data-row " caption="NOTE UK MAKE (EL)" ident="E12E_113_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=E12E_113_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=E12E_113_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">E12E</td><td class="model caption tc-rcell"><a class="unlink">NOTE UK MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table56" class="tc-row tc-data-row " caption="NP300 SAF MAKE (EL)" ident="D22SS_055_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=D22SS_055_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D22SS_055_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D22SS</td><td class="model caption tc-rcell"><a class="unlink">NP300 SAF MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table57" class="tc-row tc-data-row " caption="NV200 (EL)" ident="M20_046_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=M20_046_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=M20_046_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">M20</td><td class="model caption tc-rcell"><a class="unlink">NV200</a></td></tr>
|
|
||||||
<tr id="_nav-model-table58" class="tc-row tc-data-row " caption="NV200 SPAINMAKE (EL)" ident="M20M_050_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=M20M_050_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=M20M_050_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">M20M</td><td class="model caption tc-rcell"><a class="unlink">NV200 SPAINMAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table59" class="tc-row tc-data-row " caption="NV250 (EL)" ident="X61_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=X61_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X61_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X61</td><td class="model caption tc-rcell"><a class="unlink">NV250</a></td></tr>
|
|
||||||
<tr id="_nav-model-table60" class="tc-row tc-data-row " caption="NV300/PRIMASTAR (EL)" ident="X82_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=X82_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X82_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X82</td><td class="model caption tc-rcell"><a class="unlink">NV300/PRIMASTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table61" class="tc-row tc-data-row " caption="NV400 (EL)" ident="X62_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=X62_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X62_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X62</td><td class="model caption tc-rcell"><a class="unlink">NV400</a></td></tr>
|
|
||||||
<tr id="_nav-model-table62" class="tc-row tc-data-row " caption="NV400/INTERSTAR (EL)" ident="X62B_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=X62B_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X62B_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X62B</td><td class="model caption tc-rcell"><a class="unlink">NV400/INTERSTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table63" class="tc-row tc-data-row " caption="PATHFINDER (EL)" ident="R51M_009_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=R51M_009_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R51M_009_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R51M</td><td class="model caption tc-rcell"><a class="unlink">PATHFINDER</a></td></tr>
|
|
||||||
<tr id="_nav-model-table64" class="tc-row tc-data-row " caption="PATHFINDER (EL)" ident="R52R_123_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=R52R_123_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R52R_123_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R52R</td><td class="model caption tc-rcell"><a class="unlink">PATHFINDER</a></td></tr>
|
|
||||||
<tr id="_nav-model-table65" class="tc-row tc-data-row " caption="PATHFINDER (EL)" ident="R53_159_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=R53_159_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R53_159_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R53</td><td class="model caption tc-rcell"><a class="unlink">PATHFINDER</a></td></tr>
|
|
||||||
<tr id="_nav-model-table66" class="tc-row tc-data-row " caption="PATHFINDER RUS (EL)" ident="R52RR_132_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=R52RR_132_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R52RR_132_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R52RR</td><td class="model caption tc-rcell"><a class="unlink">PATHFINDER RUS</a></td></tr>
|
|
||||||
<tr id="_nav-model-table67" class="tc-row tc-data-row " caption="PATROL (EL)" ident="Y62_048_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Y62_048_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Y62_048_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Y62</td><td class="model caption tc-rcell"><a class="unlink">PATROL</a></td></tr>
|
|
||||||
<tr id="_nav-model-table68" class="tc-row tc-data-row " caption="PATROL(GR) (EL)" ident="Y61_107_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Y61_107_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Y61_107_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Y61</td><td class="model caption tc-rcell"><a class="unlink">PATROL(GR)</a></td></tr>
|
|
||||||
<tr id="_nav-model-table69" class="tc-row tc-data-row " caption="PIXO (EL)" ident="UA0_043_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=UA0_043_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=UA0_043_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">UA0</td><td class="model caption tc-rcell"><a class="unlink">PIXO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table70" class="tc-row tc-data-row " caption="PRIMASTAR (EL)" ident="X83_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=X83_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=X83_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">X83</td><td class="model caption tc-rcell"><a class="unlink">PRIMASTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table71" class="tc-row tc-data-row " caption="PRIMERA (EL)" ident="P11E_090_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=P11E_090_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=P11E_090_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">P11E</td><td class="model caption tc-rcell"><a class="unlink">PRIMERA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table72" class="tc-row tc-data-row " caption="PRIMERA (EL)" ident="P12E_001_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=P12E_001_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=P12E_001_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">P12E</td><td class="model caption tc-rcell"><a class="unlink">PRIMERA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table73" class="tc-row tc-data-row " caption="PRIMERA WAGON (EL)" ident="W10_102_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=W10_102_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=W10_102_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">W10</td><td class="model caption tc-rcell"><a class="unlink">PRIMERA WAGON</a></td></tr>
|
|
||||||
<tr id="_nav-model-table74" class="tc-row tc-data-row " caption="PRIMERA WAGON (EL)" ident="WP11E_104_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=WP11E_104_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=WP11E_104_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">WP11E</td><td class="model caption tc-rcell"><a class="unlink">PRIMERA WAGON</a></td></tr>
|
|
||||||
<tr id="_nav-model-table75" class="tc-row tc-data-row " caption="PULSAR (EL)" ident="C13M_124_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=C13M_124_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=C13M_124_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">C13M</td><td class="model caption tc-rcell"><a class="unlink">PULSAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table76" class="tc-row tc-data-row " caption="QASHQAI (EL)" ident="J10E_023_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=J10E_023_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J10E_023_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J10E</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI</a></td></tr>
|
|
||||||
<tr id="_nav-model-table77" class="tc-row tc-data-row " caption="QASHQAI (EL)" ident="J12E_160_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=J12E_160_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J12E_160_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J12E</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI</a></td></tr>
|
|
||||||
<tr id="_nav-model-table78" class="tc-row tc-data-row " caption="QASHQAI RUSSIA (EL)" ident="J11R_134_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=J11R_134_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J11R_134_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J11R</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI RUSSIA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table79" class="tc-row tc-data-row " caption="QASHQAI UK MAKE (EL)" ident="J11E_120_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=J11E_120_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J11E_120_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J11E</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI UK MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table80" class="tc-row tc-data-row " caption="QASHQAI+2 (EL)" ident="JJ10E_038_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=JJ10E_038_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=JJ10E_038_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">JJ10E</td><td class="model caption tc-rcell"><a class="unlink">QASHQAI+2</a></td></tr>
|
|
||||||
<tr id="_nav-model-table81" class="tc-row tc-data-row " caption="SENTRA RUS MAKE (EL)" ident="B17RR_126_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=B17RR_126_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=B17RR_126_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">B17RR</td><td class="model caption tc-rcell"><a class="unlink">SENTRA RUS MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table82" class="tc-row tc-data-row " caption="SENTRA RUS MAKE (EL)" ident="B17R_125_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=B17R_125_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=B17R_125_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">B17R</td><td class="model caption tc-rcell"><a class="unlink">SENTRA RUS MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table83" class="tc-row tc-data-row " caption="SERENA (EL)" ident="C23M_070_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=C23M_070_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=C23M_070_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">C23M</td><td class="model caption tc-rcell"><a class="unlink">SERENA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table84" class="tc-row tc-data-row " caption="SUNNY WAGON (EL)" ident="Y10_105_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=Y10_105_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=Y10_105_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">Y10</td><td class="model caption tc-rcell"><a class="unlink">SUNNY WAGON</a></td></tr>
|
|
||||||
<tr id="_nav-model-table85" class="tc-row tc-data-row " caption="TEANA (EL)" ident="J31_017_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=J31_017_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J31_017_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J31</td><td class="model caption tc-rcell"><a class="unlink">TEANA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table86" class="tc-row tc-data-row " caption="TEANA (EL)" ident="J32_031_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=J32_031_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J32_031_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J32</td><td class="model caption tc-rcell"><a class="unlink">TEANA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table87" class="tc-row tc-data-row " caption="TEANA RUS MAKE (EL)" ident="J32R_041_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=J32R_041_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=J32R_041_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">J32R</td><td class="model caption tc-rcell"><a class="unlink">TEANA RUS MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table88" class="tc-row tc-data-row " caption="TEANA RUS MAKE (EL)" ident="L33R_118_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=L33R_118_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=L33R_118_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">L33R</td><td class="model caption tc-rcell"><a class="unlink">TEANA RUS MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table89" class="tc-row tc-data-row " caption="TERRANO (EL)" ident="D10_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=D10_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=D10_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">D10</td><td class="model caption tc-rcell"><a class="unlink">TERRANO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table90" class="tc-row tc-data-row " caption="TERRANO (EL)" ident="R50_092_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=R50_092_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R50_092_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R50</td><td class="model caption tc-rcell"><a class="unlink">TERRANO</a></td></tr>
|
|
||||||
<tr id="_nav-model-table91" class="tc-row tc-data-row " caption="TERRANO2 (EL)" ident="R20_091_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=R20_091_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=R20_091_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">R20</td><td class="model caption tc-rcell"><a class="unlink">TERRANO2</a></td></tr>
|
|
||||||
<tr id="_nav-model-table92" class="tc-row tc-data-row " caption="TIIDA (EL)" ident="C11X_028_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=C11X_028_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=C11X_028_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">C11X</td><td class="model caption tc-rcell"><a class="unlink">TIIDA</a></td></tr>
|
|
||||||
<tr id="_nav-model-table93" class="tc-row tc-data-row " caption="TIIDA RUS MAKE (EL)" ident="C13R_128_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=C13R_128_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=C13R_128_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">C13R</td><td class="model caption tc-rcell"><a class="unlink">TIIDA RUS MAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table94" class="tc-row tc-data-row " caption="TIIDA SEDAN (EL)" ident="SC11X_024_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=SC11X_024_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=SC11X_024_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">SC11X</td><td class="model caption tc-rcell"><a class="unlink">TIIDA SEDAN</a></td></tr>
|
|
||||||
<tr id="_nav-model-table95" class="tc-row tc-data-row " caption="TOWNSTAR/e-TOWNSTAR (EL)" ident="XFK_EL" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=XFK_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=XFK_EL&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">XFK</td><td class="model caption tc-rcell"><a class="unlink">TOWNSTAR/e-TOWNSTAR</a></td></tr>
|
|
||||||
<tr id="_nav-model-table96" class="tc-row tc-data-row " caption="X-TRAIL (EL)" ident="AGT32_151_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=AGT32_151_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=AGT32_151_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">AGT32</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL</a></td></tr>
|
|
||||||
<tr id="_nav-model-table97" class="tc-row tc-data-row " caption="X-TRAIL (EL)" ident="T30_002_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=T30_002_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T30_002_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T30</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL</a></td></tr>
|
|
||||||
<tr id="_nav-model-table98" class="tc-row tc-data-row " caption="X-TRAIL (EL)" ident="T32_117_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=T32_117_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T32_117_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T32</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL</a></td></tr>
|
|
||||||
<tr id="_nav-model-table99" class="tc-row tc-data-row " caption="X-TRAIL (EL)" ident="T33_163_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=T33_163_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T33_163_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T33</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL</a></td></tr>
|
|
||||||
<tr id="_nav-model-table100" class="tc-row tc-data-row " caption="X-TRAIL JPNMAKE (EL)" ident="T31_026_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=T31_026_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T31_026_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T31</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL JPNMAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table101" class="tc-row tc-data-row " caption="X-TRAIL RUSMAKE (EL)" ident="T31R_045_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=T31R_045_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T31R_045_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T31R</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL RUSMAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table102" class="tc-row tc-data-row " caption="X-TRAIL RUSMAKE (EL)" ident="T32RR_140_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=T32RR_140_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T32RR_140_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T32RR</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL RUSMAKE</a></td></tr>
|
|
||||||
<tr id="_nav-model-table103" class="tc-row tc-data-row " caption="X-TRAIL RUSMAKE (EL)" ident="T32R_127_3" urltype="INTERN" url="vehicle.action?lang=en&localMarketOnly=true&model=T32R_127_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST" jsonurl="json-model-config.action?lang=en&localMarketOnly=true&model=T32R_127_3&spec=e30%3D&startup=false&mode=A0LW0DEDE&upds=2026.09.02+10%3A52%3A21+CEST"><td class="model identifier tc-lcell">T32R</td><td class="model caption tc-rcell"><a class="unlink">X-TRAIL RUSMAKE</a></td></tr>
|
|
||||||
</table></body></html>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<html><head>
|
|
||||||
<title>Opel W0LPD5EC9EG058575: P10 - ASTRA-J [2010-2020] - partslink24</title>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
</script></head><body>
|
|
||||||
<table>
|
|
||||||
<tr id="_nav-mainGroup-table0" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=394&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY SHELL AND PANELS" ident="394" maingroupcode="A"><td class="mainGroupCode tc-lcell ">A</td><td class="caption tc-rcell ">BODY SHELL AND PANELS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table1" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=395&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY EXTERIOR FITTINGS" ident="395" maingroupcode="B"><td class="mainGroupCode tc-lcell ">B</td><td class="caption tc-rcell ">BODY EXTERIOR FITTINGS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table2" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=396&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY INTERIOR FITTINGS" ident="396" maingroupcode="C"><td class="mainGroupCode tc-lcell ">C</td><td class="caption tc-rcell ">BODY INTERIOR FITTINGS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table3" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=397&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY INTERIOR TRIM" ident="397" maingroupcode="D"><td class="mainGroupCode tc-lcell ">D</td><td class="caption tc-rcell ">BODY INTERIOR TRIM</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table4" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=398&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="ENGINE AND CLUTCH" ident="398" maingroupcode="E"><td class="mainGroupCode tc-lcell ">E</td><td class="caption tc-rcell ">ENGINE AND CLUTCH</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table5" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=399&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="COOLING" ident="399" maingroupcode="F"><td class="mainGroupCode tc-lcell ">F</td><td class="caption tc-rcell ">COOLING</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table6" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=400&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="FUEL AND EXHAUST" ident="400" maingroupcode="G"><td class="mainGroupCode tc-lcell ">G</td><td class="caption tc-rcell ">FUEL AND EXHAUST</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table7" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=401&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="TRANSMISSION" ident="401" maingroupcode="H"><td class="mainGroupCode tc-lcell ">H</td><td class="caption tc-rcell ">TRANSMISSION</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table8" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=402&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="BRAKES" ident="402" maingroupcode="J"><td class="mainGroupCode tc-lcell ">J</td><td class="caption tc-rcell ">BRAKES</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table9" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=403&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="FRONT AXLE AND SUSPENSION" ident="403" maingroupcode="K"><td class="mainGroupCode tc-lcell ">K</td><td class="caption tc-rcell ">FRONT AXLE AND SUSPENSION</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table10" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=404&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="STEERING" ident="404" maingroupcode="L"><td class="mainGroupCode tc-lcell ">L</td><td class="caption tc-rcell ">STEERING</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table11" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=405&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="REAR AXLE AND SUSPENSION" ident="405" maingroupcode="M"><td class="mainGroupCode tc-lcell ">M</td><td class="caption tc-rcell ">REAR AXLE AND SUSPENSION</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table12" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=406&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="ROAD WHEELS" ident="406" maingroupcode="N"><td class="mainGroupCode tc-lcell ">N</td><td class="caption tc-rcell ">ROAD WHEELS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table13" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=407&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="ELECTRICAL" ident="407" maingroupcode="P"><td class="mainGroupCode tc-lcell ">P</td><td class="caption tc-rcell ">ELECTRICAL</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table14" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=408&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="ACCESSORIES" ident="408" maingroupcode="Q"><td class="mainGroupCode tc-lcell ">Q</td><td class="caption tc-rcell ">ACCESSORIES</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table15" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=409&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="SPECIAL OPTIONS / DUAL FUEL" ident="409" maingroupcode="R"><td class="mainGroupCode tc-lcell ">R</td><td class="caption tc-rcell ">SPECIAL OPTIONS / DUAL FUEL</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table16" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=410&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="PAINTS" ident="410" maingroupcode="Z"><td class="mainGroupCode tc-lcell ">Z</td><td class="caption tc-rcell ">PAINTS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table18" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=741&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="PAINTS" ident="741" maingroupcode="1"><td class="mainGroupCode tc-lcell ">1</td><td class="caption tc-rcell ">PAINTS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table19" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=742&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="WIRING HARNESS REPAIR KITS" ident="742" maingroupcode="2"><td class="mainGroupCode tc-lcell ">2</td><td class="caption tc-rcell ">WIRING HARNESS REPAIR KITS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table20" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=743&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="CAR CARE MATERIALS" ident="743" maingroupcode="3"><td class="mainGroupCode tc-lcell ">3</td><td class="caption tc-rcell ">CAR CARE MATERIALS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table21" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=744&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="ADHESIVES / REPAIR KITS" ident="744" maingroupcode="4"><td class="mainGroupCode tc-lcell ">4</td><td class="caption tc-rcell ">ADHESIVES / REPAIR KITS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table22" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=745&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="ANTI-FREEZE AND COOLANT" ident="745" maingroupcode="5"><td class="mainGroupCode tc-lcell ">5</td><td class="caption tc-rcell ">ANTI-FREEZE AND COOLANT</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table23" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=746&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="BODY REPAIR MATERIALS" ident="746" maingroupcode="6"><td class="mainGroupCode tc-lcell ">6</td><td class="caption tc-rcell ">BODY REPAIR MATERIALS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table24" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=747&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="BRAKE AND CLUTCH FLUID" ident="747" maingroupcode="7"><td class="mainGroupCode tc-lcell ">7</td><td class="caption tc-rcell ">BRAKE AND CLUTCH FLUID</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table25" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=748&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="OIL" ident="748" maingroupcode="8"><td class="mainGroupCode tc-lcell ">8</td><td class="caption tc-rcell ">OIL</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table26" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=749&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="GREASES AND PASTES" ident="749" maingroupcode="9"><td class="mainGroupCode tc-lcell ">9</td><td class="caption tc-rcell ">GREASES AND PASTES</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table27" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=750&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="SEALANT" ident="750" maingroupcode="A"><td class="mainGroupCode tc-lcell ">A</td><td class="caption tc-rcell ">SEALANT</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table28" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=751&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="TOOLS AND PERSONAL PROTECTION" ident="751" maingroupcode="B"><td class="mainGroupCode tc-lcell ">B</td><td class="caption tc-rcell ">TOOLS AND PERSONAL PROTECTION</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table29" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=752&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="MISCELLANEOUS" ident="752" maingroupcode="C"><td class="mainGroupCode tc-lcell ">C</td><td class="caption tc-rcell ">MISCELLANEOUS</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table30" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=753&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="EMISSION CONTROL FLUID / FUEL ADDITIVES" ident="753" maingroupcode="D"><td class="mainGroupCode tc-lcell ">D</td><td class="caption tc-rcell ">EMISSION CONTROL FLUID / FUEL ADDITIVES</td></tr>
|
|
||||||
<tr id="_nav-mainGroup-table31" class="tc-row tc-data-row " jsonurl="json-vin-main-group.action?catId=25&lang=en&mainGroupId=754&openVinDialog=true&startup=false&subGroupId=0&vin=W0LPD5EC9EG058575&mode=A0LW0DEDE&upds=2026.09.07+21%3A00%3A03+CEST" caption="DIAGNOSTIC DEVICES" ident="754" maingroupcode="S"><td class="mainGroupCode tc-lcell ">S</td><td class="caption tc-rcell ">DIAGNOSTIC DEVICES</td></tr>
|
|
||||||
</table></body></html>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
{
|
|
||||||
"link": {
|
|
||||||
"wid": "mainGroupTable",
|
|
||||||
"path": "/p5volvo/extern/groups/vin/mainGroup?lang=en&model=308&modelYear=1620&partnerGroup=46&serviceName=volvo_parts&upds=2026-08-24--11-02&vin=YV1AS84ABD1168166"
|
|
||||||
},
|
|
||||||
"segments": {
|
|
||||||
"vinfoBasic": {
|
|
||||||
"records": [
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Vehicle Identification No.",
|
|
||||||
"value": "YV1AS84ABD1168166"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Year",
|
|
||||||
"value": "2013"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Model",
|
|
||||||
"value": "S80 (07\\-)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Km/h or mph",
|
|
||||||
"value": "K"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Factory code",
|
|
||||||
"value": "21"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Steering gear prod no",
|
|
||||||
"value": "31360538"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Structure week",
|
|
||||||
"value": "201236"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Type",
|
|
||||||
"value": "S80"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Chassis",
|
|
||||||
"value": "168166"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Partner group",
|
|
||||||
"value": "Europe"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Upholstery/interior code",
|
|
||||||
"value": "210100"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Upholstery/interior",
|
|
||||||
"value": "LEATHER/ANTHR/QRTZCEIL/NV"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Exterior color",
|
|
||||||
"value": "61400"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Exterior color",
|
|
||||||
"value": "WHITE SOLID ICE WHITE"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Body style code",
|
|
||||||
"value": "0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Body style",
|
|
||||||
"value": "Sedan"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Special vehicle code",
|
|
||||||
"value": " "
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Special vehicles",
|
|
||||||
"value": " "
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Sales type",
|
|
||||||
"value": "42"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Sales type",
|
|
||||||
"value": "SALES VERSION 42"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Market code",
|
|
||||||
"value": "49"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Market",
|
|
||||||
"value": "TR"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Engine Code",
|
|
||||||
"value": "84"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Engine",
|
|
||||||
"value": "D4162T"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Engine part no",
|
|
||||||
"value": "6906309"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Engine serial no",
|
|
||||||
"value": "00000000000004138845 / 0ELD61 2208122233587"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Transmission Code",
|
|
||||||
"value": "B"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Transmission",
|
|
||||||
"value": "6\\-PSHIFT 2WD / MPS6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Transmission part no",
|
|
||||||
"value": "1285041"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Transmission serial no",
|
|
||||||
"value": "00AWBB1 170812170654"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Chassis code",
|
|
||||||
"value": "35659B7A6276"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
{
|
|
||||||
"link": {
|
|
||||||
"wid": "mainGroupTable",
|
|
||||||
"path": "/p5volvo/extern/groups/vin/mainGroup?lang=tr&model=308&modelYear=1620&partnerGroup=46&serviceName=volvo_parts&upds=2026-08-24--11-02&vin=YV1AS84ABD1168166"
|
|
||||||
},
|
|
||||||
"segments": {
|
|
||||||
"vinfoBasic": {
|
|
||||||
"records": [
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Sasi numarasi",
|
|
||||||
"value": "YV1AS84ABD1168166"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Model yili",
|
|
||||||
"value": "2013"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Model",
|
|
||||||
"value": "S80 (07\\-)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Hız Birimi",
|
|
||||||
"value": "K"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Fabrika Kodu",
|
|
||||||
"value": "21"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Direksiyon Kutusu Üretim No",
|
|
||||||
"value": "31360538"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Üretim Haftası",
|
|
||||||
"value": "201236"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Türü",
|
|
||||||
"value": "S80"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Şasi",
|
|
||||||
"value": "168166"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Ortak grubu",
|
|
||||||
"value": "Europe"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Döşeme/iç mekan kodu",
|
|
||||||
"value": "210100"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Döşeme",
|
|
||||||
"value": "LEATHER/ANTHR/QRTZCEIL/NV"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Dis rengi",
|
|
||||||
"value": "61400"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Dis rengi",
|
|
||||||
"value": "WHITE SOLID ICE WHITE"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Karoseri Tipi Kodu",
|
|
||||||
"value": "0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Kaporta Stili",
|
|
||||||
"value": "Sedan"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Özel Araç Kodu",
|
|
||||||
"value": " "
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Özel araçlar",
|
|
||||||
"value": " "
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Satis tipi",
|
|
||||||
"value": "42"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Satis tipi",
|
|
||||||
"value": "SALES VERSION 42"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Piyasa Kodu",
|
|
||||||
"value": "49"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Market",
|
|
||||||
"value": "TR"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Motor kodu",
|
|
||||||
"value": "84"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Motor",
|
|
||||||
"value": "D4162T"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Motor Parça No",
|
|
||||||
"value": "6906309"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Motor Seri Numarası",
|
|
||||||
"value": "00000000000004138845 / 0ELD61 2208122233587"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Şanzıman kodu",
|
|
||||||
"value": "B"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Şanzıman",
|
|
||||||
"value": "6\\-PSHIFT 2WD / MPS6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Şanzıman Parça No",
|
|
||||||
"value": "1285041"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Şanzıman Seri No",
|
|
||||||
"value": "00AWBB1 170812170654"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"values": {
|
|
||||||
"description": "Şasi Kodu",
|
|
||||||
"value": "35659B7A6276"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { PL24AuthService } from "./pl24-auth.service";
|
|
||||||
|
|
||||||
// Oturum modeli (1 login → PL24TOKEN çerezi → yalnız authorize ile yenileme),
|
|
||||||
// PL24_TR_DISABLED köprüsü ve login devre kesici. Ağ yok: global fetch stub'ı;
|
|
||||||
// private durum repo genelindeki `as unknown as` kalıbıyla okunur.
|
|
||||||
|
|
||||||
type RedisStub = {
|
|
||||||
store: Map<string, unknown>;
|
|
||||||
get: (k: string) => Promise<string | null>;
|
|
||||||
set: (k: string, v: string, ttl?: number) => Promise<void>;
|
|
||||||
exists: (k: string) => Promise<boolean>;
|
|
||||||
getJson: (k: string) => Promise<unknown>;
|
|
||||||
setJson: (k: string, v: unknown) => Promise<void>;
|
|
||||||
del: (k: string) => Promise<void>;
|
|
||||||
setNx: (k: string, v: string, ttl: number) => Promise<boolean>;
|
|
||||||
incr: (k: string) => Promise<number>;
|
|
||||||
expire: (k: string, ttl: number) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeRedis = (opts: { lockTaken?: boolean } = {}): RedisStub => {
|
|
||||||
const store = new Map<string, unknown>();
|
|
||||||
const counters = new Map<string, number>();
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
get: async (k: string) => (store.has(k) ? String(store.get(k)) : null),
|
|
||||||
set: async (k: string, v: string) => {
|
|
||||||
store.set(k, v);
|
|
||||||
},
|
|
||||||
exists: async (k: string) => store.has(k),
|
|
||||||
getJson: async (k: string) => store.get(k) ?? null,
|
|
||||||
setJson: async (k: string, v: unknown) => {
|
|
||||||
store.set(k, v);
|
|
||||||
},
|
|
||||||
del: async (k: string) => {
|
|
||||||
store.delete(k);
|
|
||||||
},
|
|
||||||
// Gerçek SET NX semantiği: var olan anahtarı ikinci kez yazmaz. Alarm
|
|
||||||
// tekrarının bastırılması buna dayanıyor.
|
|
||||||
setNx: async (k: string, v: string) => {
|
|
||||||
if (opts.lockTaken && k.includes("login-lock")) return false;
|
|
||||||
if (store.has(k)) return false;
|
|
||||||
store.set(k, v);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
incr: async (k: string) => {
|
|
||||||
const n = (counters.get(k) ?? 0) + 1;
|
|
||||||
counters.set(k, n);
|
|
||||||
return n;
|
|
||||||
},
|
|
||||||
expire: async () => {},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Bütçe servisi stub'ı: sayar ama engellemez, telemetriyi yutar. */
|
|
||||||
const makeBudget = () => ({
|
|
||||||
consumed: [] as string[],
|
|
||||||
recorded: [] as unknown[],
|
|
||||||
consume: async function (kind: string) {
|
|
||||||
this.consumed.push(kind);
|
|
||||||
},
|
|
||||||
record: function (e: unknown) {
|
|
||||||
this.recorded.push(e);
|
|
||||||
},
|
|
||||||
spentToday: async () => 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeService = (cfgOverrides: Record<string, string> = {}, redis = makeRedis()) => {
|
|
||||||
const cfg: Record<string, string> = {
|
|
||||||
"pl24.companyCode": "tr-000000",
|
|
||||||
"pl24.username": "admin",
|
|
||||||
"pl24.password": "pw-tr",
|
|
||||||
"pl24.companyCode2": "de-000000",
|
|
||||||
"pl24.username2": "admin",
|
|
||||||
"pl24.password2": "pw-de",
|
|
||||||
"pl24.proxyDe": "http://u:p@127.0.0.1:9",
|
|
||||||
...cfgOverrides,
|
|
||||||
};
|
|
||||||
const configService = { get: (k: string, d?: unknown) => cfg[k] ?? d } as never;
|
|
||||||
const budget = makeBudget();
|
|
||||||
const telegram = {
|
|
||||||
sent: [] as string[],
|
|
||||||
isConfigured: () => true,
|
|
||||||
send: async function (t: string) {
|
|
||||||
this.sent.push(t);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
svc: new PL24AuthService(configService, redis as never, budget as never, telegram as never),
|
|
||||||
redis,
|
|
||||||
budget,
|
|
||||||
telegram,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type Exposed = {
|
|
||||||
effectiveAccount(account: "tr" | "de"): "tr" | "de";
|
|
||||||
login(account: "tr" | "de"): Promise<unknown>;
|
|
||||||
ensureSession(account: "tr" | "de"): Promise<{ sessionToken: string }>;
|
|
||||||
sessions: Record<string, { sessionToken: string } | undefined>;
|
|
||||||
serviceTokens: Record<"tr" | "de", Map<string, { token: string; expiresAt: number }>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Portal login yanıtı: {loginStatus, sessionToken} + Set-Cookie PL24TOKEN. */
|
|
||||||
const loginOk = (token = "sess-token-1") => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (h: string) => (h.toLowerCase() === "set-cookie" ? `PL24TOKEN=${token}; Path=/` : null),
|
|
||||||
getSetCookie: () => [`PL24TOKEN=${token}; Path=/; Secure`],
|
|
||||||
},
|
|
||||||
json: async () => ({ loginStatus: "OK", sessionToken: token }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const jwt = (payload: Record<string, unknown>) =>
|
|
||||||
`h.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.s`;
|
|
||||||
|
|
||||||
const authorizeOk = (scope = "vw_parts pl24-usage", sessionStatus = "alive") => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: { get: () => null, getSetCookie: () => [] },
|
|
||||||
json: async () => ({
|
|
||||||
access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 600, sid: "sess-token-1" }),
|
|
||||||
expires_in: 600,
|
|
||||||
scope,
|
|
||||||
session_status: sessionStatus,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const problem = (status: number, type: string) => ({
|
|
||||||
ok: false,
|
|
||||||
status,
|
|
||||||
statusText: "Precondition Failed",
|
|
||||||
headers: { get: () => null, getSetCookie: () => [] },
|
|
||||||
json: async () => ({ type, title: type, detail: type }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedFlag = process.env.PL24_TR_DISABLED;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// "" ≠ "true" → bayrak kapalı; delete yerine atama (biome noDelete).
|
|
||||||
process.env.PL24_TR_DISABLED = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.PL24_TR_DISABLED = savedFlag ?? "";
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PL24AuthService — PL24_TR_DISABLED tr→de köprüsü", () => {
|
|
||||||
it("bayrak kapalıyken hesaplar olduğu gibi kalır", () => {
|
|
||||||
const { svc } = makeService();
|
|
||||||
const p = svc as unknown as Exposed;
|
|
||||||
expect(p.effectiveAccount("tr")).toBe("tr");
|
|
||||||
expect(p.effectiveAccount("de")).toBe("de");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bayrak açıkken tr → de'ye maplenir, de değişmez", () => {
|
|
||||||
process.env.PL24_TR_DISABLED = "true";
|
|
||||||
const { svc } = makeService();
|
|
||||||
const p = svc as unknown as Exposed;
|
|
||||||
expect(p.effectiveAccount("tr")).toBe("de");
|
|
||||||
expect(p.effectiveAccount("de")).toBe("de");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("de hesabı tanımlı değilse mapleme yapılmaz (duvara yönlendirme yok)", () => {
|
|
||||||
process.env.PL24_TR_DISABLED = "true";
|
|
||||||
const { svc } = makeService({ "pl24.companyCode2": "" });
|
|
||||||
expect((svc as unknown as Exposed).effectiveAccount("tr")).toBe("tr");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bayrak açıkken tr login'i ağa çıkmadan reddedilir", async () => {
|
|
||||||
process.env.PL24_TR_DISABLED = "true";
|
|
||||||
const fetchSpy = vi.fn();
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
await expect((svc as unknown as Exposed).login("tr")).rejects.toThrow(/PL24_TR_DISABLED/);
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("legacy clearTokens (tr) bayrak altında de servis token'larını temizler", () => {
|
|
||||||
process.env.PL24_TR_DISABLED = "true";
|
|
||||||
const { svc } = makeService();
|
|
||||||
const p = svc as unknown as Exposed;
|
|
||||||
p.serviceTokens.de.set("vw_parts", { token: "x", expiresAt: Date.now() + 60_000 });
|
|
||||||
svc.clearTokens();
|
|
||||||
expect(p.serviceTokens.de.size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PL24AuthService — oturum modeli", () => {
|
|
||||||
it("bir kez login eder, sonraki çağrılar aynı oturumu kullanır (yeniden login yok)", async () => {
|
|
||||||
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
|
|
||||||
const c1 = await svc.getSessionCookieForAccount("de");
|
|
||||||
const c2 = await svc.getSessionCookieForAccount("de");
|
|
||||||
|
|
||||||
expect(c1).toBe("PL24TOKEN=sess-token-1");
|
|
||||||
expect(c2).toBe(c1);
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("eşzamanlı çağrılar tek login paylaşır (single-flight)", async () => {
|
|
||||||
const fetchSpy = vi.fn().mockImplementation(async () => {
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
return loginOk();
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
svc.getSessionCookieForAccount("de"),
|
|
||||||
svc.getSessionCookieForAccount("de"),
|
|
||||||
svc.getSessionCookieForAccount("de"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("başka bir süreç oturumu tutuyorsa Redis'ten devralır, login etmez", async () => {
|
|
||||||
const redis = makeRedis();
|
|
||||||
redis.store.set("pl24:auth:session:de", {
|
|
||||||
sessionToken: "shared-token",
|
|
||||||
loginAt: Date.now(),
|
|
||||||
lastOkAt: Date.now(),
|
|
||||||
});
|
|
||||||
const fetchSpy = vi.fn();
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService({}, redis);
|
|
||||||
|
|
||||||
expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=shared-token");
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("login sadece çerez mint eder; servis token'ı authorize'dan gelir (Bearer'sız)", async () => {
|
|
||||||
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
|
||||||
if (String(url).includes("/login")) return loginOk();
|
|
||||||
return authorizeOk();
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
|
|
||||||
await svc.authorizeServiceForAccount("vw_parts", "de");
|
|
||||||
|
|
||||||
const authorizeCall = fetchSpy.mock.calls.find((c) => String(c[0]).includes("/authorize"));
|
|
||||||
expect(authorizeCall).toBeTruthy();
|
|
||||||
const headers = (authorizeCall?.[1] as { headers: Record<string, string> }).headers;
|
|
||||||
expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1");
|
|
||||||
expect(headers.Authorization).toBeUndefined();
|
|
||||||
expect(headers["User-Agent"]).toMatch(/Chrome\/\d/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("servis token'ı süresi dolmadan yeniden authorize edilmez", async () => {
|
|
||||||
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
|
||||||
if (String(url).includes("/login")) return loginOk();
|
|
||||||
return authorizeOk();
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
|
|
||||||
await svc.authorizeServiceForAccount("vw_parts", "de");
|
|
||||||
await svc.authorizeServiceForAccount("vw_parts", "de");
|
|
||||||
|
|
||||||
const authorizeCalls = fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize"));
|
|
||||||
expect(authorizeCalls).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("session_status=gone → oturumu düşürür, bir kez yeniden login edip devam eder", async () => {
|
|
||||||
let authorizeCalls = 0;
|
|
||||||
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
|
||||||
if (String(url).includes("/login")) return loginOk(`sess-${authorizeCalls}`);
|
|
||||||
authorizeCalls += 1;
|
|
||||||
return authorizeCalls === 1 ? authorizeOk("vw_parts", "gone") : authorizeOk();
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
|
|
||||||
const token = await svc.authorizeServiceForAccount("vw_parts", "de");
|
|
||||||
|
|
||||||
expect(token).toBeTruthy();
|
|
||||||
expect(authorizeCalls).toBe(2);
|
|
||||||
expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/login"))).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("authorize 401 → oturumu düşürür ve tek sefer yeniden dener", async () => {
|
|
||||||
let authorizeCalls = 0;
|
|
||||||
const fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
|
||||||
if (String(url).includes("/login")) return loginOk();
|
|
||||||
authorizeCalls += 1;
|
|
||||||
if (authorizeCalls === 1) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: 401,
|
|
||||||
statusText: "Unauthorized",
|
|
||||||
headers: { get: () => null, getSetCookie: () => [] },
|
|
||||||
json: async () => ({}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return authorizeOk();
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
|
|
||||||
await expect(svc.authorizeServiceForAccount("vw_parts", "de")).resolves.toBeTruthy();
|
|
||||||
expect(authorizeCalls).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("oturum sınırı aşıldı (412) → tek sefer squeezeOut ile tekrar dener", async () => {
|
|
||||||
const bodies: string[] = [];
|
|
||||||
let loginCalls = 0;
|
|
||||||
const fetchSpy = vi.fn().mockImplementation(async (url: string, opts: { body: string }) => {
|
|
||||||
if (!String(url).includes("/login")) return authorizeOk();
|
|
||||||
bodies.push(opts.body);
|
|
||||||
loginCalls += 1;
|
|
||||||
return loginCalls === 1
|
|
||||||
? problem(412, "urn:login:session-limit-exceeded")
|
|
||||||
: loginOk("after-squeeze");
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
|
|
||||||
expect(await svc.getSessionCookieForAccount("de")).toBe("PL24TOKEN=after-squeeze");
|
|
||||||
expect(JSON.parse(bodies[0]).squeezeOut).toBe(false);
|
|
||||||
expect(JSON.parse(bodies[1]).squeezeOut).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("P4 header'ı yalnız çerez taşır (authorize isteği atmaz)", async () => {
|
|
||||||
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
|
|
||||||
const headers = await svc.buildFordLegacyHeadersForAccount("opel_parts", "de");
|
|
||||||
|
|
||||||
expect(headers.Cookie).toBe("PL24TOKEN=sess-token-1");
|
|
||||||
expect(headers.Authorization).toBeUndefined();
|
|
||||||
expect(fetchSpy.mock.calls.filter((c) => String(c[0]).includes("/authorize"))).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Ford hintstoken değeri oturum token'ıdır", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk("hint-1")));
|
|
||||||
const { svc } = makeService();
|
|
||||||
await svc.getSessionCookieForAccount("de");
|
|
||||||
expect(svc.getPL24TokenValueForAccount("de")).toBe("hint-1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PL24AuthService — login devre kesici", () => {
|
|
||||||
it("3 ardışık başarısız login'den sonra açılır ve yeni ağ denemesini keser", async () => {
|
|
||||||
const fetchSpy = vi.fn().mockRejectedValue(new Error("network down"));
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
const p = svc as unknown as Exposed;
|
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
await expect(p.login("de")).rejects.toThrow(/giris hatasi/);
|
|
||||||
}
|
|
||||||
// Ağ hatasında squeezeOut denemesi yapılmaz → login başına tek fetch.
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
await expect(p.login("de")).rejects.toThrow(/breaker open/);
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hesap kapalı (account-not-active) → ilk hatada uzun süreli kesici", async () => {
|
|
||||||
const fetchSpy = vi.fn().mockResolvedValue(problem(400, "urn:login:account-not-active"));
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
const p = svc as unknown as Exposed;
|
|
||||||
|
|
||||||
await expect(p.login("de")).rejects.toThrow(/giris hatasi/);
|
|
||||||
await expect(p.login("de")).rejects.toThrow(/breaker open/);
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("başarılı login kesici sayacını sıfırlar", async () => {
|
|
||||||
let failFirst = 2;
|
|
||||||
const fetchSpy = vi.fn().mockImplementation(async () => {
|
|
||||||
if (failFirst > 0) {
|
|
||||||
failFirst -= 1;
|
|
||||||
throw new Error("network down");
|
|
||||||
}
|
|
||||||
return loginOk();
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
const p = svc as unknown as Exposed;
|
|
||||||
|
|
||||||
await expect(p.login("de")).rejects.toThrow();
|
|
||||||
await expect(p.login("de")).rejects.toThrow();
|
|
||||||
await expect(p.login("de")).resolves.toBeTruthy();
|
|
||||||
await expect(p.login("de")).resolves.toBeTruthy();
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saatlik login tavanı aşılırsa yeni login denenmez", async () => {
|
|
||||||
const fetchSpy = vi.fn().mockResolvedValue(loginOk());
|
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
|
||||||
const { svc } = makeService();
|
|
||||||
const p = svc as unknown as Exposed;
|
|
||||||
|
|
||||||
for (let i = 0; i < 6; i++) await p.login("de");
|
|
||||||
await expect(p.login("de")).rejects.toThrow(/login rate limit/);
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(6);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PL24AuthService — 1 saatlik kesinti alarmı (Telegram)", () => {
|
|
||||||
const loginFails = () => ({
|
|
||||||
ok: false,
|
|
||||||
status: 400,
|
|
||||||
statusText: "Bad Request",
|
|
||||||
headers: { get: () => null, getSetCookie: () => [] },
|
|
||||||
json: async () => ({ type: "urn:login:account-not-active", detail: "account not active" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ilk saat içinde alarm göndermez", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
|
|
||||||
const { svc, telegram } = makeService();
|
|
||||||
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
|
||||||
expect(telegram.sent).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("kesinti 1 saati geçince tek sefer alarm gönderir", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginFails()));
|
|
||||||
const redis = makeRedis();
|
|
||||||
// Kesinti 70 dakika önce başlamış gibi davran (paylaşılan Redis saati).
|
|
||||||
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 70 * 60_000));
|
|
||||||
const { svc, telegram } = makeService({}, redis);
|
|
||||||
|
|
||||||
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
|
||||||
await new Promise((r) => setTimeout(r, 10)); // void alarm promise'i
|
|
||||||
|
|
||||||
expect(telegram.sent).toHaveLength(1);
|
|
||||||
expect(telegram.sent[0]).toContain("PL24 giriş yapılamıyor");
|
|
||||||
expect(telegram.sent[0]).toContain("70 dakika");
|
|
||||||
|
|
||||||
// Kesici açık olsa bile ikinci kez sızlanmaz (alerted anahtarı).
|
|
||||||
await expect((svc as unknown as Exposed).login("de")).rejects.toThrow();
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
expect(telegram.sent).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("giriş düzelince kurtarma mesajı gönderir ve saati sıfırlar", async () => {
|
|
||||||
const redis = makeRedis();
|
|
||||||
redis.store.set("pl24:auth:fail-since:de", String(Date.now() - 90 * 60_000));
|
|
||||||
redis.store.set("pl24:auth:alerted:de", "1");
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(loginOk()));
|
|
||||||
const { svc, telegram } = makeService({}, redis);
|
|
||||||
|
|
||||||
await svc.getSessionCookieForAccount("de");
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
|
|
||||||
expect(telegram.sent.some((t) => t.includes("PL24 girişi düzeldi"))).toBe(true);
|
|
||||||
expect(redis.store.has("pl24:auth:fail-since:de")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,141 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { backfillContext } from "../../jobs/prefetch-context";
|
|
||||||
import { PL24BudgetExceededError, PL24BudgetService } from "./pl24-budget.service";
|
|
||||||
|
|
||||||
// Günlük PL24 HTTP tavanı + kullanıcı rezervi + proxy_logs telemetrisi.
|
|
||||||
// Redis ve telemetri stub'lanır; ağ yok.
|
|
||||||
|
|
||||||
const makeRedis = (opts: { fail?: boolean } = {}) => {
|
|
||||||
const counters = new Map<string, number>();
|
|
||||||
return {
|
|
||||||
counters,
|
|
||||||
incr: async (k: string) => {
|
|
||||||
if (opts.fail) throw new Error("redis down");
|
|
||||||
const n = (counters.get(k) ?? 0) + 1;
|
|
||||||
counters.set(k, n);
|
|
||||||
return n;
|
|
||||||
},
|
|
||||||
expire: async () => {},
|
|
||||||
get: async (k: string) => (counters.has(k) ? String(counters.get(k)) : null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeTelemetry = () => {
|
|
||||||
const events: Record<string, unknown>[] = [];
|
|
||||||
return { events, record: (e: Record<string, unknown>) => events.push(e) };
|
|
||||||
};
|
|
||||||
|
|
||||||
const make = (env: Record<string, string> = {}, redis = makeRedis()) => {
|
|
||||||
for (const [k, v] of Object.entries(env)) vi.stubEnv(k, v);
|
|
||||||
const telemetry = makeTelemetry();
|
|
||||||
return { svc: new PL24BudgetService(redis as never, telemetry as never), redis, telemetry };
|
|
||||||
};
|
|
||||||
|
|
||||||
const inBackfill = <T>(fn: () => Promise<T>) => backfillContext.run(true, fn);
|
|
||||||
|
|
||||||
describe("PL24BudgetService — günlük tavan", () => {
|
|
||||||
it("tavan altında çağrılara izin verir", async () => {
|
|
||||||
const { svc } = make({ PL24_HTTP_DAILY_MAX: "5" });
|
|
||||||
for (let i = 0; i < 5; i++) await svc.consume("catalog");
|
|
||||||
expect(await svc.spentToday()).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tavan aşılınca kullanıcı çağrısını da reddeder", async () => {
|
|
||||||
const { svc } = make({ PL24_HTTP_DAILY_MAX: "3" });
|
|
||||||
for (let i = 0; i < 3; i++) await svc.consume("catalog");
|
|
||||||
await expect(svc.consume("catalog")).rejects.toBeInstanceOf(PL24BudgetExceededError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("backfill kullanıcı rezervine dokunamaz, kullanıcı trafiği devam eder", async () => {
|
|
||||||
// tavan 10, rezerv %40 → backfill 6'da durur, kullanıcı 10'a kadar sürer
|
|
||||||
const { svc } = make({ PL24_HTTP_DAILY_MAX: "10", PL24_HTTP_USER_RESERVE: "0.4" });
|
|
||||||
|
|
||||||
for (let i = 0; i < 6; i++) await inBackfill(() => svc.consume("catalog"));
|
|
||||||
await expect(inBackfill(() => svc.consume("catalog"))).rejects.toBeInstanceOf(
|
|
||||||
PL24BudgetExceededError,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Aynı gün, aynı sayaç: kullanıcı hâlâ geçebilmeli (7., 8. … 10. istek)
|
|
||||||
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
|
||||||
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reddedilen çağrı yalnız reddedildiği yerde sayılır (ağ isteği yok)", async () => {
|
|
||||||
const { svc, telemetry } = make({ PL24_HTTP_DAILY_MAX: "1" });
|
|
||||||
await svc.consume("catalog");
|
|
||||||
await expect(svc.consume("catalog")).rejects.toBeInstanceOf(PL24BudgetExceededError);
|
|
||||||
expect(telemetry.events).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Redis düşükse PL24'ü bloklamaz (fail-open)", async () => {
|
|
||||||
const { svc } = make({ PL24_HTTP_DAILY_MAX: "1" }, makeRedis({ fail: true }));
|
|
||||||
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
|
||||||
await expect(svc.consume("catalog")).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PL24BudgetService — telemetri", () => {
|
|
||||||
it("katalog çağrısını proxy_logs biçiminde kaydeder", () => {
|
|
||||||
const { svc, telemetry } = make();
|
|
||||||
svc.record({
|
|
||||||
kind: "catalog",
|
|
||||||
url: "https://www.partslink24.com/p5psa/extern/group/vin/scope?vin=X",
|
|
||||||
proxied: true,
|
|
||||||
account: "de",
|
|
||||||
statusCode: 200,
|
|
||||||
success: true,
|
|
||||||
startedAt: Date.now() - 120,
|
|
||||||
});
|
|
||||||
const e = telemetry.events[0];
|
|
||||||
expect(e.service).toBe("pl24_http");
|
|
||||||
expect(e.provider).toBe("dataimpulse");
|
|
||||||
expect(e.targetHost).toBe("www.partslink24.com");
|
|
||||||
expect(e.success).toBe(true);
|
|
||||||
expect(e.errorKind).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("login çağrısını pl24_auth olarak ayırır", () => {
|
|
||||||
const { svc, telemetry } = make();
|
|
||||||
svc.record({
|
|
||||||
kind: "auth",
|
|
||||||
url: "https://www.partslink24.com/auth/ext/api/1.1/login",
|
|
||||||
proxied: false,
|
|
||||||
account: "tr",
|
|
||||||
statusCode: 400,
|
|
||||||
success: false,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
});
|
|
||||||
expect(telemetry.events[0].service).toBe("pl24_auth");
|
|
||||||
expect(telemetry.events[0].provider).toBe("none");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("403/429'u ban sinyali olarak işaretler", () => {
|
|
||||||
const { svc, telemetry } = make();
|
|
||||||
svc.record({
|
|
||||||
kind: "catalog",
|
|
||||||
url: "https://www.partslink24.com/x",
|
|
||||||
proxied: true,
|
|
||||||
account: "de",
|
|
||||||
statusCode: 403,
|
|
||||||
success: false,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
});
|
|
||||||
expect(telemetry.events[0].errorKind).toBe("banned");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("taşıma hatasını sınıflandırır", () => {
|
|
||||||
const { svc, telemetry } = make();
|
|
||||||
const err = Object.assign(new Error("fetch failed"), { name: "TimeoutError" });
|
|
||||||
svc.record({
|
|
||||||
kind: "catalog",
|
|
||||||
url: "https://www.partslink24.com/x",
|
|
||||||
proxied: true,
|
|
||||||
account: "de",
|
|
||||||
success: false,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
error: err,
|
|
||||||
});
|
|
||||||
expect(telemetry.events[0].errorKind).toBe("timeout");
|
|
||||||
expect(telemetry.events[0].statusCode).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
|
||||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
|
||||||
import { RedisService } from "../../redis/redis.service";
|
|
||||||
import {
|
|
||||||
ProxyTelemetryService,
|
|
||||||
classifyTransportError,
|
|
||||||
isBanStatus,
|
|
||||||
} from "../proxy-telemetry/proxy-telemetry.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PL24 HTTP budget + telemetry.
|
|
||||||
*
|
|
||||||
* WHY (see /home/s/ss/plv2.md §2.2, findings consumers_jobs-03/07):
|
|
||||||
* Both PL24 account bans (tr 2026-07-24, de 2026-09-04) followed days that wrote
|
|
||||||
* 5-7k new category rows — roughly 10k+ upstream calls/day — while real user
|
|
||||||
* decodes never exceeded 14/day. The existing guards are all job-level
|
|
||||||
* (jobs/min, jobs/day); nothing counted actual HTTP requests, and PL24 calls
|
|
||||||
* were invisible in `proxy_logs` (only pcat/emex were logged), so the volume was
|
|
||||||
* only reconstructable after the fact from DB row counts.
|
|
||||||
*
|
|
||||||
* This service is the one choke point every PL24 upstream call passes through:
|
|
||||||
* - counts requests per UTC day in Redis and refuses new ones past the cap,
|
|
||||||
* - reserves a share of that cap for real users so a runaway backfill can
|
|
||||||
* never starve a paying customer's decode,
|
|
||||||
* - records every attempt into `proxy_logs` (service `pl24_http`/`pl24_auth`).
|
|
||||||
*
|
|
||||||
* The cap is deliberately low to start (PL24_HTTP_DAILY_MAX, default 1200);
|
|
||||||
* raise it only with telemetry in hand.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class PL24BudgetService {
|
|
||||||
private readonly logger = new Logger(PL24BudgetService.name);
|
|
||||||
/** Log "budget exhausted" once per day per lane instead of on every call. */
|
|
||||||
private warnedFor = new Set<string>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly redis: RedisService,
|
|
||||||
private readonly telemetry: ProxyTelemetryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private get dailyMax(): number {
|
|
||||||
const raw = Number(process.env.PL24_HTTP_DAILY_MAX);
|
|
||||||
return Number.isFinite(raw) && raw > 0 ? raw : 1200;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fraction of the daily cap that only user-facing calls may spend. */
|
|
||||||
private get userReserve(): number {
|
|
||||||
const raw = Number(process.env.PL24_HTTP_USER_RESERVE);
|
|
||||||
return Number.isFinite(raw) && raw > 0 && raw < 1 ? raw : 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
private dayKey(): string {
|
|
||||||
return `pl24:http:${new Date().toISOString().slice(0, 10)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count one upstream call and decide whether it may proceed.
|
|
||||||
* Backfill stops at (1 - userReserve) of the cap; user traffic gets the rest.
|
|
||||||
*/
|
|
||||||
async consume(kind: "auth" | "catalog"): Promise<void> {
|
|
||||||
const backfill = isBackfillContext();
|
|
||||||
let spent: number;
|
|
||||||
try {
|
|
||||||
const key = this.dayKey();
|
|
||||||
spent = await this.redis.incr(key);
|
|
||||||
if (spent === 1) await this.redis.expire(key, 8 * 86_400);
|
|
||||||
} catch {
|
|
||||||
return; // Redis down → never block PL24 on telemetry
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = this.dailyMax;
|
|
||||||
const limit = backfill ? Math.floor(max * (1 - this.userReserve)) : max;
|
|
||||||
if (spent > limit) {
|
|
||||||
const lane = backfill ? "backfill" : "user";
|
|
||||||
if (!this.warnedFor.has(`${lane}:${this.dayKey()}`)) {
|
|
||||||
this.warnedFor.add(`${lane}:${this.dayKey()}`);
|
|
||||||
this.logger.warn(
|
|
||||||
`PL24 daily HTTP budget exhausted for the ${lane} lane (${spent}/${limit}, cap ${max}, kind=${kind}) — refusing further calls today`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new PL24BudgetExceededError(lane, spent, limit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current spend (for /health and the Süper Panel). */
|
|
||||||
async spentToday(): Promise<number> {
|
|
||||||
try {
|
|
||||||
return Number((await this.redis.get(this.dayKey())) ?? 0);
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
record(opts: {
|
|
||||||
kind: "auth" | "catalog";
|
|
||||||
url: string;
|
|
||||||
proxied: boolean;
|
|
||||||
account: string;
|
|
||||||
statusCode?: number | null;
|
|
||||||
success: boolean;
|
|
||||||
startedAt: number;
|
|
||||||
error?: unknown;
|
|
||||||
}): void {
|
|
||||||
let targetHost: string | null = null;
|
|
||||||
try {
|
|
||||||
targetHost = new URL(opts.url).hostname;
|
|
||||||
} catch {
|
|
||||||
/* keep null */
|
|
||||||
}
|
|
||||||
this.telemetry.record({
|
|
||||||
service: opts.kind === "auth" ? "pl24_auth" : "pl24_http",
|
|
||||||
provider: opts.proxied ? "dataimpulse" : "none",
|
|
||||||
sessionKey: opts.account,
|
|
||||||
targetHost,
|
|
||||||
statusCode: opts.statusCode ?? null,
|
|
||||||
errorKind: opts.error
|
|
||||||
? classifyTransportError(opts.error)
|
|
||||||
: isBanStatus(opts.statusCode)
|
|
||||||
? "banned"
|
|
||||||
: null,
|
|
||||||
success: opts.success,
|
|
||||||
durationMs: Date.now() - opts.startedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Thrown when the daily PL24 HTTP budget is used up for this lane. */
|
|
||||||
export class PL24BudgetExceededError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly lane: "user" | "backfill",
|
|
||||||
readonly spent: number,
|
|
||||||
readonly limit: number,
|
|
||||||
) {
|
|
||||||
super(`PL24 daily HTTP budget exhausted (${lane} lane: ${spent}/${limit})`);
|
|
||||||
this.name = "PL24BudgetExceededError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -116,7 +116,7 @@ export class PL24FordLegacyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
|
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
|
||||||
await this.authService.ensureSession(account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
|
|
||||||
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||||
if (!html) return null;
|
if (!html) return null;
|
||||||
@@ -124,8 +124,8 @@ export class PL24FordLegacyService {
|
|||||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||||
this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`);
|
this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||||
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
this.authService.clearTokensForAccount(account);
|
||||||
await this.authService.ensureSession(account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
|
|
||||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||||
if (!retryHtml) return null;
|
if (!retryHtml) return null;
|
||||||
@@ -1110,7 +1110,7 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.log(`Ford: fetching vehicle list for ${serviceName}`);
|
this.logger.log(`Ford: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
||||||
@@ -1126,8 +1126,8 @@ export class PL24FordLegacyService {
|
|||||||
let support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
let support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||||
this.logger.warn(`Ford: demo mode for ${serviceName}, retrying with fresh auth`);
|
this.logger.warn(`Ford: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||||
await this.authService.dropSessionForAccount("tr", "P4 page 401");
|
this.authService.clearTokens();
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
const retryHtml = await this.fetchP4Page(vinGroupUrl, serviceName, true);
|
const retryHtml = await this.fetchP4Page(vinGroupUrl, serviceName, true);
|
||||||
if (!retryHtml) return [];
|
if (!retryHtml) return [];
|
||||||
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
|
||||||
@@ -1221,7 +1221,7 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.log(`HyundaiKia: fetching vehicle list for ${serviceName}`);
|
this.logger.log(`HyundaiKia: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config
|
const basePath = config
|
||||||
@@ -1306,7 +1306,7 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.log(`Nissan: fetching vehicle list for ${serviceName}`);
|
this.logger.log(`Nissan: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/nissan/${serviceName}`;
|
const basePath = config ? `${config.basePath}/${serviceName}` : `/nissan/${serviceName}`;
|
||||||
@@ -1377,7 +1377,7 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.log(`Opel: fetching vehicle list for ${serviceName}`);
|
this.logger.log(`Opel: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/opel/${serviceName}`;
|
const basePath = config ? `${config.basePath}/${serviceName}` : `/opel/${serviceName}`;
|
||||||
@@ -1452,7 +1452,7 @@ export class PL24FordLegacyService {
|
|||||||
this.logger.log(`Volvo: fetching vehicle list for ${serviceName}`);
|
this.logger.log(`Volvo: fetching vehicle list for ${serviceName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/volvo/${serviceName}`;
|
const basePath = config ? `${config.basePath}/${serviceName}` : `/volvo/${serviceName}`;
|
||||||
@@ -1556,7 +1556,7 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
this.logger.log(`Volvo: fetching model config for ${serviceName} mdl=${mdlId}`);
|
this.logger.log(`Volvo: fetching model config for ${serviceName} mdl=${mdlId}`);
|
||||||
|
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
@@ -1626,7 +1626,7 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
this.logger.log(`Ford: fetching model config for ${serviceName} family=${familyId}`);
|
this.logger.log(`Ford: fetching model config for ${serviceName} family=${familyId}`);
|
||||||
|
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
const config = getServiceConfig(serviceName);
|
const config = getServiceConfig(serviceName);
|
||||||
const arch = config?.architecture;
|
const arch = config?.architecture;
|
||||||
@@ -1785,7 +1785,7 @@ export class PL24FordLegacyService {
|
|||||||
: `Ford: fetching main groups for ${serviceName} family=${familyId} year=${modelYear} engine=${engine} gearbox=${gearbox}`,
|
: `Ford: fetching main groups for ${serviceName} family=${familyId} year=${modelYear} engine=${engine} gearbox=${gearbox}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
const basePath = config ? `${config.basePath}/${serviceName}` : `/ford/${serviceName}`;
|
||||||
@@ -1917,10 +1917,6 @@ export class PL24FordLegacyService {
|
|||||||
* Fiat services always use 'de'; others use round-robin via Redis.
|
* Fiat services always use 'de'; others use round-robin via Redis.
|
||||||
*/
|
*/
|
||||||
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
|
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
|
||||||
// PL24_TR_DISABLED=true → the tr account is inactive on PL24's side; route
|
|
||||||
// everyone to de so page fetches, proxies and cache labels all agree with the
|
|
||||||
// auth layer's tr→de mapping (PL24AuthService.effectiveAccount).
|
|
||||||
if (process.env.PL24_TR_DISABLED === "true") return "de";
|
|
||||||
// Fiat always needs de account; Hyundai/Kia/Nissan parts are licensed only on de.
|
// Fiat always needs de account; Hyundai/Kia/Nissan parts are licensed only on de.
|
||||||
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
|
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
|
||||||
return "de";
|
return "de";
|
||||||
@@ -1957,7 +1953,7 @@ export class PL24FordLegacyService {
|
|||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
account: "tr" | "de",
|
account: "tr" | "de",
|
||||||
): Promise<PL24DecodedVehicle | null> {
|
): Promise<PL24DecodedVehicle | null> {
|
||||||
await this.authService.ensureSession(account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
|
|
||||||
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
const html = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||||
if (!html) return null;
|
if (!html) return null;
|
||||||
@@ -1965,8 +1961,8 @@ export class PL24FordLegacyService {
|
|||||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||||
this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`);
|
this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`);
|
||||||
await this.authService.dropSessionForAccount(account, "P4 demo page (session not accepted)");
|
this.authService.clearTokensForAccount(account);
|
||||||
await this.authService.ensureSession(account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
|
|
||||||
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
|
||||||
if (!retryHtml) return null;
|
if (!retryHtml) return null;
|
||||||
@@ -2893,7 +2889,7 @@ export class PL24FordLegacyService {
|
|||||||
private async initPsaSession(
|
private async initPsaSession(
|
||||||
serviceName: string,
|
serviceName: string,
|
||||||
): Promise<{ jsessionId: string; mode: string; upds: string } | null> {
|
): Promise<{ jsessionId: string; mode: string; upds: string } | null> {
|
||||||
await this.authService.ensureSession("tr");
|
await this.authService.authorizeService(serviceName);
|
||||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3328,7 +3324,7 @@ export class PL24FordLegacyService {
|
|||||||
const isDeOnly = LEGACY_DE_SERVICES.has(serviceName);
|
const isDeOnly = LEGACY_DE_SERVICES.has(serviceName);
|
||||||
const account = isDeOnly ? "de" : accountParam;
|
const account = isDeOnly ? "de" : accountParam;
|
||||||
if (isDeOnly) {
|
if (isDeOnly) {
|
||||||
await this.authService.ensureSession("de");
|
await this.authService.authorizeServiceForAccount(serviceName, "de");
|
||||||
}
|
}
|
||||||
// Some catalogs (Volvo vin-group.action) store hrefs relative to the catalog
|
// Some catalogs (Volvo vin-group.action) store hrefs relative to the catalog
|
||||||
// directory (e.g. "vin-group.action?group1=…"). Prefix the service basePath
|
// directory (e.g. "vin-group.action?group1=…"). Prefix the service basePath
|
||||||
@@ -3358,8 +3354,8 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||||
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
this.authService.clearTokensForAccount(account);
|
||||||
await this.authService.ensureSession(account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
||||||
serviceName,
|
serviceName,
|
||||||
account,
|
account,
|
||||||
@@ -3392,11 +3388,8 @@ export class PL24FordLegacyService {
|
|||||||
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
|
||||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||||
this.logger.warn(`Ford legacy: demo page for ${serviceName}, re-authing + retry`);
|
this.logger.warn(`Ford legacy: demo page for ${serviceName}, re-authing + retry`);
|
||||||
await this.authService.dropSessionForAccount(
|
this.authService.clearTokensForAccount(account);
|
||||||
account,
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
"P4 demo page (session not accepted)",
|
|
||||||
);
|
|
||||||
await this.authService.ensureSession(account);
|
|
||||||
return this.fetchP4Page(url, serviceName, isFullUrl, account, true);
|
return this.fetchP4Page(url, serviceName, isFullUrl, account, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3441,8 +3434,8 @@ export class PL24FordLegacyService {
|
|||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
|
||||||
await this.authService.dropSessionForAccount(account, "P4 page 401");
|
this.authService.clearTokensForAccount(account);
|
||||||
await this.authService.ensureSession(account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(
|
||||||
serviceName,
|
serviceName,
|
||||||
account,
|
account,
|
||||||
@@ -3655,10 +3648,7 @@ export class PL24FordLegacyService {
|
|||||||
segment.match(/\bjsonurl="([^"]+)"/);
|
segment.match(/\bjsonurl="([^"]+)"/);
|
||||||
if (!urlMatch) continue;
|
if (!urlMatch) continue;
|
||||||
|
|
||||||
// Row attributes are HTML-escaped (`&`), so every query-param check
|
const url = urlMatch[1];
|
||||||
// below — and the stored linkPath — must work on the decoded URL. Without
|
|
||||||
// this `[?&]mainGroup=` never matched Hyundai/Kia rows.
|
|
||||||
const url = urlMatch[1].replace(/&/g, "&").replace(/&/g, "&");
|
|
||||||
// Skip vehicle.action rows — those are sub-model selectors, not part group links
|
// Skip vehicle.action rows — those are sub-model selectors, not part group links
|
||||||
if (url.includes("vehicle.action")) continue;
|
if (url.includes("vehicle.action")) continue;
|
||||||
// Skip header/breadcrumb navigation links that leak into the group table as
|
// Skip header/breadcrumb navigation links that leak into the group table as
|
||||||
@@ -3667,16 +3657,7 @@ export class PL24FordLegacyService {
|
|||||||
// categories use group.action / group-detail.action / json-(main|sub)-group.action,
|
// categories use group.action / group-detail.action / json-(main|sub)-group.action,
|
||||||
// none of which match these. Volvo's vin-group.action?...group1=... is kept.
|
// none of which match these. Volvo's vin-group.action?...group1=... is kept.
|
||||||
if (/(portal|logout)\.action/i.test(url)) continue;
|
if (/(portal|logout)\.action/i.test(url)) continue;
|
||||||
// Volvo's vin-group.action rows are model pickers unless they carry
|
if (url.includes("vin-group.action") && !url.includes("group1=")) continue;
|
||||||
// group1=; Hyundai/Kia use the SAME endpoint for real main groups but key
|
|
||||||
// them with mainGroup= (BO/CH/EL/EN/MI/TR). Keep both, drop the rest.
|
|
||||||
if (
|
|
||||||
url.includes("vin-group.action") &&
|
|
||||||
!url.includes("group1=") &&
|
|
||||||
!/[?&]mainGroup=/i.test(url)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (/^https?:\/\//i.test(url) && !/\.action(\?|$)/i.test(url)) continue;
|
if (/^https?:\/\//i.test(url) && !/\.action(\?|$)/i.test(url)) continue;
|
||||||
if (seen.has(url)) continue;
|
if (seen.has(url)) continue;
|
||||||
seen.add(url);
|
seen.add(url);
|
||||||
@@ -4070,15 +4051,6 @@ export class PL24FordLegacyService {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Opel/Vauxhall and Hyundai/Kia ship their main groups as `tc-data-row`
|
|
||||||
// table rows whose link lives in a `url=`/`jsonurl=` ATTRIBUTE, not in an
|
|
||||||
// <a href>. parseP4NavigationCategories only reads anchors, so these brands
|
|
||||||
// decoded with ZERO categories (prod: Opel 169/173, Hyundai 11/11, Kia 5/5)
|
|
||||||
// even though the page carries the full list. parseFordGroupsFromHtml
|
|
||||||
// already understands those rows — it simply was never called from decode.
|
|
||||||
if (categories.length === 0) {
|
|
||||||
categories = this.parseFordGroupsFromHtml(html, serviceName, "");
|
|
||||||
}
|
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
categories = this.parseP4NavigationCategories(html);
|
categories = this.parseP4NavigationCategories(html);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { PL24Service } from "./pl24.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model-list entry point resolution (plv2.md, bulgu p5core-08).
|
|
||||||
*
|
|
||||||
* `BACKEND_MODEL_PATH` eksik kaldığında eski davranış yedi bilinen yolu sırayla
|
|
||||||
* denemekti: paylaşılan günlük bütçeden çağrı başına yedi isteğe kadar, ve
|
|
||||||
* listede olmayan bir yol için sessiz sıfır sonuç. Volvo tam olarak buydu —
|
|
||||||
* `/extern/vehicles/models` (çoğul "vehicles") listede yok, dolayısıyla
|
|
||||||
* Volvo/Polestar browse sıfır model tohumlayıp emekli P4 listesini sunmaya
|
|
||||||
* devam ediyordu.
|
|
||||||
*
|
|
||||||
* Otoritatif kaynak her P5 backend'inin kendi `/extern/catmeta` yanıtındaki
|
|
||||||
* `data.catalogEntryPoint.path`. Canlı doğrulama (2026-09-20):
|
|
||||||
* p5volvo → /p5volvo/extern/vehicles/models (60 model)
|
|
||||||
* p5psa → /p5psa/extern/vehicle/catalogs
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Resolver = {
|
|
||||||
resolveModelPathFromCatmeta: (
|
|
||||||
base: string,
|
|
||||||
svc: string,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
) => Promise<string | null>;
|
|
||||||
baseUrl: string;
|
|
||||||
language: string;
|
|
||||||
redis: {
|
|
||||||
get: (k: string) => Promise<string | null>;
|
|
||||||
set: (k: string, v: string, ttl?: number) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
logger: { log: (m: string) => void; warn: (m: string) => void };
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeService(opts: {
|
|
||||||
cached?: string | null;
|
|
||||||
catmeta?: unknown;
|
|
||||||
status?: number;
|
|
||||||
throws?: boolean;
|
|
||||||
}) {
|
|
||||||
const sets: Array<[string, string]> = [];
|
|
||||||
const svc = Object.create(PL24Service.prototype) as unknown as Resolver;
|
|
||||||
svc.baseUrl = "https://pl24.test";
|
|
||||||
svc.language = "tr";
|
|
||||||
svc.redis = {
|
|
||||||
get: vi.fn(async () => opts.cached ?? null),
|
|
||||||
set: vi.fn(async (k: string, v: string) => {
|
|
||||||
sets.push([k, v]);
|
|
||||||
return undefined;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
svc.logger = { log: vi.fn(), warn: vi.fn() };
|
|
||||||
const fetchMock = vi.fn(async () => {
|
|
||||||
if (opts.throws) throw new Error("network down");
|
|
||||||
return {
|
|
||||||
ok: (opts.status ?? 200) < 400,
|
|
||||||
status: opts.status ?? 200,
|
|
||||||
json: async () => opts.catmeta ?? {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
return { svc, sets, fetchMock };
|
|
||||||
}
|
|
||||||
|
|
||||||
const volvoMeta = {
|
|
||||||
data: {
|
|
||||||
catalogEntryPoint: {
|
|
||||||
wid: "modelsTable",
|
|
||||||
path: "/p5volvo/extern/vehicles/models?lang=en&serviceName=volvo_parts",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("resolveModelPathFromCatmeta", () => {
|
|
||||||
beforeEach(() => vi.unstubAllGlobals());
|
|
||||||
|
|
||||||
it("catmeta'daki giriş noktasını backend'e göreli yola indirger", async () => {
|
|
||||||
const { svc } = makeService({ catmeta: volvoMeta });
|
|
||||||
const out = await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
|
|
||||||
expect(out).toBe("/extern/vehicles/models");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("çözülen yolu 30 gün cache'ler", async () => {
|
|
||||||
const { svc, sets } = makeService({ catmeta: volvoMeta });
|
|
||||||
await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
|
|
||||||
expect(sets[0][0]).toBe("pl24:modelpath:p5volvo");
|
|
||||||
expect(sets[0][1]).toBe("/extern/vehicles/models");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cache'lenmiş yol için upstream'e hiç gitmez", async () => {
|
|
||||||
const { svc, fetchMock } = makeService({ cached: "/extern/vehicles/models" });
|
|
||||||
const out = await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {});
|
|
||||||
expect(out).toBe("/extern/vehicles/models");
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("olumsuz sonuç da cache'lenir — her browse'da yeniden denenmez", async () => {
|
|
||||||
const { svc, fetchMock } = makeService({ cached: "none" });
|
|
||||||
expect(await svc.resolveModelPathFromCatmeta("/p5x", "x_parts", {})).toBeNull();
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("catmeta yoksa veya şekil beklenmedikse null döner", async () => {
|
|
||||||
const { svc, sets } = makeService({ catmeta: { data: {} } });
|
|
||||||
expect(await svc.resolveModelPathFromCatmeta("/p5x", "x_parts", {})).toBeNull();
|
|
||||||
expect(sets[0][1]).toBe("none");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("HTTP hatasında null döner", async () => {
|
|
||||||
const { svc } = makeService({ status: 403, catmeta: volvoMeta });
|
|
||||||
expect(await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {})).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ağ hatası fırlatmaz", async () => {
|
|
||||||
const { svc } = makeService({ throws: true });
|
|
||||||
await expect(
|
|
||||||
svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {}),
|
|
||||||
).resolves.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("/extern/ ile başlamayan yolu kabul etmez", async () => {
|
|
||||||
const { svc } = makeService({
|
|
||||||
catmeta: { data: { catalogEntryPoint: { path: "https://baska.site/kotu" } } },
|
|
||||||
});
|
|
||||||
expect(await svc.resolveModelPathFromCatmeta("/p5volvo", "volvo_parts", {})).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opel / Hyundai / Kia "0 kategori" regresyon kilidi (plv2.md, bulgu p4legacy-03).
|
|
||||||
*
|
|
||||||
* Bu markalar ana gruplarını <a href> DEĞİL, `<tr class="tc-data-row"
|
|
||||||
* url=…|jsonurl=…>` satır attribute'unda taşıyor. Decode yolu yalnız anchor
|
|
||||||
* tarayan parser'ı kullandığı için prod'da Opel 169/173, Hyundai 11/11, Kia 5/5
|
|
||||||
* araç SIFIR kategoriyle kaydedilmişti — sayfa listeyi taşıdığı hâlde.
|
|
||||||
*
|
|
||||||
* Fixture'lar 2026-09-16 canlı keşfinden (plv2-artefakt/), yalnız ana grup
|
|
||||||
* tablosuna kırpılmış hâlleri.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fixture = (name: string) =>
|
|
||||||
readFileSync(join(__dirname, "__fixtures__", `${name}.html`), "utf-8");
|
|
||||||
|
|
||||||
// parseFordGroupsFromHtml saf bir metin işleyicisi: bağımlılıklar kullanılmıyor.
|
|
||||||
const svc = new PL24FordLegacyService(
|
|
||||||
{} as never, // authService
|
|
||||||
{ get: (_k: string, d?: unknown) => d } as never, // configService
|
|
||||||
{} as never, // redis
|
|
||||||
{} as never, // storage
|
|
||||||
) as unknown as {
|
|
||||||
parseFordGroupsFromHtml(
|
|
||||||
html: string,
|
|
||||||
serviceName: string,
|
|
||||||
familyId: string,
|
|
||||||
): Array<{ code: string; nameEn: string; linkPath?: string }>;
|
|
||||||
parseP4NavigationCategories(html: string): Array<{ nameEn: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("P4 ana grup tablosu — Opel/Hyundai (0 kategori regresyonu)", () => {
|
|
||||||
it("Opel: jsonurl satırlarından 31 ana grubu çıkarır", () => {
|
|
||||||
const groups = svc.parseFordGroupsFromHtml(fixture("p4_opel"), "opel_parts", "");
|
|
||||||
expect(groups.length).toBe(31);
|
|
||||||
expect(groups[0].nameEn).toBe("BODY SHELL AND PANELS");
|
|
||||||
expect(groups[0].code).toContain("json-vin-main-group.action");
|
|
||||||
expect(groups[0].code).toContain("mainGroupId=394");
|
|
||||||
// Adların hepsi gerçek metin olmalı (kod artığı değil)
|
|
||||||
expect(groups.every((g) => /[A-Za-z]{3,}/.test(g.nameEn))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Hyundai: mainGroup= anahtarlı vin-group satırları artık elenmez", () => {
|
|
||||||
const groups = svc.parseFordGroupsFromHtml(fixture("p4_hyundai"), "hyundai_parts", "");
|
|
||||||
expect(groups.length).toBe(6);
|
|
||||||
const names = groups.map((g) => g.nameEn);
|
|
||||||
expect(names).toContain("BODY");
|
|
||||||
expect(groups[0].code).toContain("mainGroup=BO");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("anchor-only parser bu sayfalarda hâlâ boş döner (fallback'in gerekçesi)", () => {
|
|
||||||
expect(svc.parseP4NavigationCategories(fixture("p4_opel")).length).toBe(0);
|
|
||||||
expect(svc.parseP4NavigationCategories(fixture("p4_hyundai")).length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Ford: scope satırları (sharedCatCode) bozulmadan okunur", () => {
|
|
||||||
const groups = svc.parseFordGroupsFromHtml(fixture("p4_ford"), "fordp_parts", "");
|
|
||||||
expect(groups.length).toBeGreaterThanOrEqual(6);
|
|
||||||
expect(groups.some((g) => g.code.includes("sharedCatCode=ZE"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Nissan: model seçim satırları ana grup sayılmaz (vehicle.action elenir)", () => {
|
|
||||||
const groups = svc.parseFordGroupsFromHtml(fixture("p4_nissan"), "nissan_parts", "");
|
|
||||||
expect(groups.every((g) => !g.code.includes("vehicle.action"))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -442,50 +442,31 @@ export class PL24PsaService {
|
|||||||
|
|
||||||
// ==================== PRIVATE: session + HTTP ====================
|
// ==================== PRIVATE: session + HTTP ====================
|
||||||
|
|
||||||
/**
|
|
||||||
* PSA requests must leave from the same egress as their auth account: under
|
|
||||||
* PL24_TR_DISABLED the legacy "tr" auth maps to de, whose session lives behind
|
|
||||||
* the DE proxy — the auth service picks the dispatcher (null for real tr).
|
|
||||||
*/
|
|
||||||
private async psaDispatcher(): Promise<any | null> {
|
|
||||||
return this.authService.getProxyAgent4Account("tr");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
|
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
|
||||||
*/
|
*/
|
||||||
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
|
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
|
||||||
// P4 PSA pages authenticate off the session cookie alone — no service token.
|
await this.authService.authorizeService(serviceName);
|
||||||
await this.authService.ensureSession("tr");
|
|
||||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||||
const dispatcher = await this.psaDispatcher();
|
|
||||||
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
|
|
||||||
dispatcher ? { ...o, dispatcher } : o;
|
|
||||||
try {
|
try {
|
||||||
const entryRes = await fetch(
|
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
|
||||||
`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`,
|
method: "GET",
|
||||||
withDispatcher({
|
headers,
|
||||||
method: "GET",
|
redirect: "manual",
|
||||||
headers,
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
redirect: "manual",
|
});
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
|
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
|
||||||
const loc1 = entryRes.headers.get("location");
|
const loc1 = entryRes.headers.get("location");
|
||||||
if (!loc1) return null;
|
if (!loc1) return null;
|
||||||
const hdrs2 = jsessionId
|
const hdrs2 = jsessionId
|
||||||
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
|
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
|
||||||
: headers;
|
: headers;
|
||||||
const startupRes = await fetch(
|
const startupRes = await fetch(loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`, {
|
||||||
loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`,
|
method: "GET",
|
||||||
withDispatcher({
|
headers: hdrs2,
|
||||||
method: "GET",
|
redirect: "manual",
|
||||||
headers: hdrs2,
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
redirect: "manual",
|
});
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const loc2 = startupRes.headers.get("location") || loc1;
|
const loc2 = startupRes.headers.get("location") || loc1;
|
||||||
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
|
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
|
||||||
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
|
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
|
||||||
@@ -513,15 +494,12 @@ export class PL24PsaService {
|
|||||||
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
|
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const dispatcher = await this.psaDispatcher();
|
const res = await fetch(url, {
|
||||||
const fetchOpts: RequestInit & { dispatcher?: any } = {
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
redirect: opts.manual ? "manual" : "follow",
|
redirect: opts.manual ? "manual" : "follow",
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
};
|
});
|
||||||
if (dispatcher) fetchOpts.dispatcher = dispatcher;
|
|
||||||
const res = await fetch(url, fetchOpts);
|
|
||||||
const location = res.headers.get("location");
|
const location = res.headers.get("location");
|
||||||
if (opts.manual && res.status >= 300 && res.status < 400) {
|
if (opts.manual && res.status >= 300 && res.status < 400) {
|
||||||
return { status: res.status, text: null, location };
|
return { status: res.status, text: null, location };
|
||||||
@@ -565,19 +543,13 @@ export class PL24PsaService {
|
|||||||
Accept: "application/json,image/*,*/*;q=0.9",
|
Accept: "application/json,image/*,*/*;q=0.9",
|
||||||
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
|
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
|
||||||
};
|
};
|
||||||
const dispatcher = await this.psaDispatcher();
|
|
||||||
const withDispatcher = (o: RequestInit): RequestInit & { dispatcher?: any } =>
|
|
||||||
dispatcher ? { ...o, dispatcher } : o;
|
|
||||||
try {
|
try {
|
||||||
const infoRes = await fetch(
|
const infoRes = await fetch(`${imageUrl}&request=GetImageInfo&cv=1`, {
|
||||||
`${imageUrl}&request=GetImageInfo&cv=1`,
|
method: "GET",
|
||||||
withDispatcher({
|
headers,
|
||||||
method: "GET",
|
redirect: "follow",
|
||||||
headers,
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
redirect: "follow",
|
});
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (!infoRes.ok) return null;
|
if (!infoRes.ok) return null;
|
||||||
const info = (await infoRes.json()) as {
|
const info = (await infoRes.json()) as {
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
@@ -598,15 +570,12 @@ export class PL24PsaService {
|
|||||||
const getImgUrl =
|
const getImgUrl =
|
||||||
`${imageUrl}&request=GetImage&format=image%2Fpng` +
|
`${imageUrl}&request=GetImage&format=image%2Fpng` +
|
||||||
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
|
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
|
||||||
const imgRes = await fetch(
|
const imgRes = await fetch(getImgUrl, {
|
||||||
getImgUrl,
|
method: "GET",
|
||||||
withDispatcher({
|
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
||||||
method: "GET",
|
redirect: "follow",
|
||||||
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
signal: AbortSignal.timeout(this.timeout),
|
||||||
redirect: "follow",
|
});
|
||||||
signal: AbortSignal.timeout(this.timeout),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (!imgRes.ok) return null;
|
if (!imgRes.ok) return null;
|
||||||
const contentType = imgRes.headers.get("content-type") || "image/png";
|
const contentType = imgRes.headers.get("content-type") || "image/png";
|
||||||
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
isPl24GroupNode,
|
|
||||||
isPl24LeafNode,
|
|
||||||
isPl24PartDetailNode,
|
|
||||||
isStalePl24Architecture,
|
|
||||||
} from "./pl24-tree";
|
|
||||||
|
|
||||||
// Canlı P5 yanıtlarından (2026-09-16 keşfi, plv2-artefakt/) alınan gerçek
|
|
||||||
// wid + path çiftleri. Bu dosya "0 parça" sınıfı hatanın regresyon kilidi.
|
|
||||||
|
|
||||||
describe("isPl24LeafNode — canlı P5 şekilleri", () => {
|
|
||||||
it("VW (p5vwag): maingroups/subgroups grup, bom/vin yaprak", () => {
|
|
||||||
expect(
|
|
||||||
isPl24GroupNode({
|
|
||||||
linkWid: "mainGroupsTable",
|
|
||||||
linkPath: "/p5vwag/extern/groups/vin_maingroups?vin=X",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isPl24GroupNode({
|
|
||||||
linkWid: "subGroupsIllusTable",
|
|
||||||
linkPath: "/p5vwag/extern/groups/vin_subgroups_illus?maingroup=4",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isPl24LeafNode({
|
|
||||||
linkWid: "bomlist",
|
|
||||||
linkPath: "/p5vwag/extern/bom/vin?illustration=407-000",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PSA (p5psa): scope + mainGroups + illusTable grup, bomDetails yaprak", () => {
|
|
||||||
expect(
|
|
||||||
isPl24GroupNode({
|
|
||||||
linkWid: "scopeTable",
|
|
||||||
linkPath: "/p5psa/extern/group/vin/scope?modelCode=1PD2",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isPl24GroupNode({
|
|
||||||
linkWid: "mainGroupTable",
|
|
||||||
linkPath: "/p5psa/extern/group/vin/mainGroups?scope=_FCT0001",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
// Eski `includes("group")` kuralının kaçırdığı seviye — sessiz boş panelin kaynağı.
|
|
||||||
expect(
|
|
||||||
isPl24GroupNode({
|
|
||||||
linkWid: "illusTable",
|
|
||||||
linkPath: "/p5psa/extern/group/vin/illus?mainGroup=_FCT0001_FCT0512",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
// camelCase bomDetails — eski `includes("/bomdetails")` kaçırıyordu.
|
|
||||||
expect(
|
|
||||||
isPl24LeafNode({
|
|
||||||
linkWid: "bomlist",
|
|
||||||
linkPath: "/p5psa/extern/details/vin/bomDetails?illustration=D2F001A48A",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Volvo (p5volvo): illustrationsTable grup, bom yaprak", () => {
|
|
||||||
expect(
|
|
||||||
isPl24GroupNode({
|
|
||||||
linkWid: "illustrationsTable",
|
|
||||||
linkPath: "/p5volvo/extern/groups/vin/illustration?group=0b00c8af80205942",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5volvo/extern/bom/vin?..." })).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
isPl24LeafNode({
|
|
||||||
linkWid: "partinfo",
|
|
||||||
linkPath: "/p5volvo/extern/partinfo/vin?partno=36050493",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Subaru (p5subaru): subgroups/illustrations grup, bom/vin?figNum yaprak", () => {
|
|
||||||
expect(
|
|
||||||
isPl24GroupNode({
|
|
||||||
linkWid: "illustrationsTable",
|
|
||||||
linkPath: "/p5subaru/extern/groups/vin/illustrations?subGroup=001",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5subaru/extern/bom/vin?figNum=01" }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wid yoksa yol kalıbına düşer (eski DB satırları)", () => {
|
|
||||||
expect(isPl24LeafNode({ linkPath: "/p5vwag/extern/bom/vin?illustration=1" })).toBe(true);
|
|
||||||
expect(
|
|
||||||
isPl24LeafNode({ linkPath: "/psa/peugeot_parts/vin-image-board.action?illCode=1" }),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isPl24GroupNode({
|
|
||||||
linkPath: "/psa/peugeot_parts/json-vin-main-groups.action?scope=_FCT0001",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hasSubgroups=true yol tahminini ezer", () => {
|
|
||||||
expect(isPl24LeafNode({ linkPath: "/p5x/extern/unknown", hasSubgroups: true })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("servicepart öğe listesi yapraktır", () => {
|
|
||||||
expect(
|
|
||||||
isPl24LeafNode({
|
|
||||||
linkWid: "servicePartsItemsTable",
|
|
||||||
linkPath: "/p5vwag/extern/servicepart/vin_items?x=1",
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isStalePl24Architecture — P4→P5 göç tespiti", () => {
|
|
||||||
it("eski PSA/Volvo yolu + artık P5 olan servis → bayat", () => {
|
|
||||||
expect(
|
|
||||||
isStalePl24Architecture({ catalogPath: "/psa/peugeot_parts", currentApiPath: "/p5psa" }),
|
|
||||||
).toBe(true);
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/volvo", currentApiPath: "/p5volvo" })).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("zaten P5 kaydı bayat değil", () => {
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/p5psa", currentApiPath: "/p5psa" })).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hâlâ P4 olan markalar (Ford/Opel/Hyundai) dokunulmaz", () => {
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/ford", currentApiPath: "/ford" })).toBe(false);
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/opel", currentApiPath: "/opel" })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("eksik bilgi → bayat sayma (güvenli taraf)", () => {
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: null, currentApiPath: "/p5psa" })).toBe(false);
|
|
||||||
expect(isStalePl24Architecture({ catalogPath: "/psa/x", currentApiPath: null })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mitsubishi: parça listesi grup sanılıyordu (plv2.md, bulgu consumers-13).
|
|
||||||
*
|
|
||||||
* `/p5mitsubishi/extern/details/vinDetails` yanıtı `partno`/`qty` taşıyan bir
|
|
||||||
* PARÇA listesi (canlı doğrulama 2026-09-20: 16 kayıt), ama her kaydın linki
|
|
||||||
* `partInfoTable`. Ne wid ne yol sınıflandırıcıda karşılık bulmuyordu → 2.193
|
|
||||||
* parça listesi gruba, içlerindeki 19.576 tekil parça kategoriye dönüşmüştü;
|
|
||||||
* hepsinde toplam 2 parça vardı ve her prefetch turunda yeniden çekiliyorlardı.
|
|
||||||
*/
|
|
||||||
describe("Mitsubishi detailsTable / partInfoTable", () => {
|
|
||||||
const detailsPath =
|
|
||||||
"/p5mitsubishi/extern/details/vinDetails?bomDetails=133_110D00125Y&mainGroup=33";
|
|
||||||
const partInfoPath = "/p5mitsubishi/extern/details/vinpartinfo?bomDetails=142_7103K22Y5T";
|
|
||||||
|
|
||||||
it("detailsTable bir parça listesi — yaprak", () => {
|
|
||||||
expect(isPl24LeafNode({ linkWid: "detailsTable", linkPath: detailsPath })).toBe(true);
|
|
||||||
expect(isPl24GroupNode({ linkWid: "detailsTable", linkPath: detailsPath })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("partInfoTable tekil parça detayı — ne grup ne liste", () => {
|
|
||||||
expect(isPl24PartDetailNode({ linkWid: "partInfoTable", linkPath: partInfoPath })).toBe(true);
|
|
||||||
expect(isPl24GroupNode({ linkWid: "partInfoTable", linkPath: partInfoPath })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wid yoksa yol kalıbı parça-detayını yine yakalar", () => {
|
|
||||||
expect(isPl24PartDetailNode({ linkPath: partInfoPath })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gerçek parça uçlarını parça-detayı sanmaz", () => {
|
|
||||||
// Volvo'nun partinfo yaprağı ve VW'nin bom listesi etkilenmemeli.
|
|
||||||
expect(
|
|
||||||
isPl24PartDetailNode({
|
|
||||||
linkWid: "partinfo",
|
|
||||||
linkPath: "/p5volvo/extern/partinfo/vin?partno=1",
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
isPl24PartDetailNode({ linkWid: "bomlist", linkPath: "/p5vwag/extern/bom/vin?x=1" }),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
/**
|
|
||||||
* One source of truth for "is this PL24 node a parts leaf or a group to drill?".
|
|
||||||
*
|
|
||||||
* WHY THIS FILE EXISTS (plv2.md, findings p5core-02 / psa-05 / consumers_jobs-01):
|
|
||||||
* the same question was answered by four independent heuristics —
|
|
||||||
* `categories.service` (twice), `catalog.service` and the prefetch worker — and
|
|
||||||
* they disagreed. Two failure modes shipped repeatedly:
|
|
||||||
*
|
|
||||||
* 1. `linkWid.includes("group")` as the "is a group" test. Live P5 PSA's second
|
|
||||||
* level has wid `illusTable`, Volvo's and Subaru's `illustrationsTable` —
|
|
||||||
* none contain "group", so those nodes fell through to the parts fetcher,
|
|
||||||
* which asked an *illustration list* for parts, got records with no partno,
|
|
||||||
* and rendered a silent empty panel (`parts: []`, no error). This is the same
|
|
||||||
* class of bug as the 2026-06 `isPsaParent` incident.
|
|
||||||
* 2. Case-sensitive `includes("/bomdetails")` while p5psa and p5volvo spell the
|
|
||||||
* endpoint `/details/vin/bomDetails` — so those leaves were queued as groups,
|
|
||||||
* `getChildren` returned [] and their parts were never prefetched (live today
|
|
||||||
* for Mitsubishi 99.9% / Fiat 80% / Renault 70% of bomDetails leaves).
|
|
||||||
*
|
|
||||||
* The reliable cross-brand marker is the response's own `link.wid`: `bomlist`
|
|
||||||
* (and the service-parts/partinfo variants) means parts, anything else means
|
|
||||||
* drill. Path matching stays as a fallback for stored rows without a wid.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `link.wid` values that identify a parts (BOM) node across every P5 backend.
|
|
||||||
*
|
|
||||||
* `detailstable` is Mitsubishi's: `/p5mitsubishi/extern/details/vinDetails`
|
|
||||||
* answers with 16-ish records carrying `partno`/`qty`, i.e. it IS the parts
|
|
||||||
* list — but each record's own link is a `partInfoTable` per-part detail, and
|
|
||||||
* neither the wid nor the path matched anything here, so the whole list was
|
|
||||||
* drilled as a group. Prod on 2026-09-20: 2,193 Mitsubishi parts lists turned
|
|
||||||
* into group nodes and their 19,576 individual parts ("SCREW,LOCK CYLINDER",
|
|
||||||
* "BOLT,STEERING COLUMN WASHER") became categories — 19,576 fake tree nodes
|
|
||||||
* with 2 parts between them, each re-fetched on every prefetch pass.
|
|
||||||
*/
|
|
||||||
const LEAF_WIDS = new Set([
|
|
||||||
"bomlist",
|
|
||||||
"bomoverviewlist",
|
|
||||||
"servicepartsitemstable",
|
|
||||||
"partinfo",
|
|
||||||
"detailstable",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nodes that describe ONE part rather than a list of them. They are neither a
|
|
||||||
* group to drill nor a list to fetch: the parent's own response already carried
|
|
||||||
* the part. Queueing them buys nothing and costs one upstream request each —
|
|
||||||
* 19,576 of them on prod before this was recognised.
|
|
||||||
*/
|
|
||||||
const PART_DETAIL_WIDS = new Set(["partinfotable"]);
|
|
||||||
const PART_DETAIL_PATH = /\/details\/vinpartinfo\b/i;
|
|
||||||
|
|
||||||
/** True when this node is a single part's detail view, not a listing. */
|
|
||||||
export function isPl24PartDetailNode(opts: {
|
|
||||||
linkPath?: string | null;
|
|
||||||
linkWid?: string | null;
|
|
||||||
}): boolean {
|
|
||||||
const wid = opts.linkWid?.toLowerCase().trim();
|
|
||||||
if (wid && PART_DETAIL_WIDS.has(wid)) return true;
|
|
||||||
return PART_DETAIL_PATH.test(opts.linkPath ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `link.wid` values that identify a drillable group node. */
|
|
||||||
const GROUP_WID_PATTERN =
|
|
||||||
/(group|scope|illus|illustration|catalog|model|vpages|msppages|chemicals|category|categories)/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path fragments that only ever appear on a parts endpoint. `image-board` has no
|
|
||||||
* leading slash on purpose: the legacy PSA leaf is `vin-image-board.action`.
|
|
||||||
*/
|
|
||||||
const LEAF_PATH_PATTERN =
|
|
||||||
/\/(bom|bomdetails|partinfo|vin_items|mdl_items|vin_bomdetails)\b|\/bom\/|\/details\/vin\/bomdetails|\/servicepart\/vin_items|image-board/i;
|
|
||||||
|
|
||||||
/** True when this node yields parts (never children). */
|
|
||||||
export function isPl24LeafNode(opts: {
|
|
||||||
linkPath?: string | null;
|
|
||||||
linkWid?: string | null;
|
|
||||||
hasSubgroups?: boolean | null;
|
|
||||||
}): boolean {
|
|
||||||
const wid = opts.linkWid?.toLowerCase().trim();
|
|
||||||
if (wid) {
|
|
||||||
if (LEAF_WIDS.has(wid)) return true;
|
|
||||||
if (GROUP_WID_PATTERN.test(wid)) return false;
|
|
||||||
}
|
|
||||||
// Explicit DB hint wins over path guessing when there is no usable wid.
|
|
||||||
if (opts.hasSubgroups === true) return false;
|
|
||||||
const lp = opts.linkPath?.toLowerCase() ?? "";
|
|
||||||
if (!lp) return false;
|
|
||||||
return LEAF_PATH_PATTERN.test(lp);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when this node should be drilled for children. */
|
|
||||||
export function isPl24GroupNode(opts: {
|
|
||||||
linkPath?: string | null;
|
|
||||||
linkWid?: string | null;
|
|
||||||
hasSubgroups?: boolean | null;
|
|
||||||
}): boolean {
|
|
||||||
if (!opts.linkPath && !opts.linkWid) return false;
|
|
||||||
// A per-part detail node is not a group; drilling it returns nothing.
|
|
||||||
if (isPl24PartDetailNode(opts)) return false;
|
|
||||||
return !isPl24LeafNode(opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when a stored vehicle's catalogInfo points at an architecture the service
|
|
||||||
* no longer uses — i.e. the row was decoded before PSA/Volvo moved to P5.
|
|
||||||
*
|
|
||||||
* These vehicles keep serving a tree built from the frozen P4 PSA snapshot
|
|
||||||
* (upds 2024-02-13) or the dead P4 Volvo endpoint (HTTP 503), and `decodeVin`'s
|
|
||||||
* db_hit short-circuit means a code fix never reaches them. Detecting the
|
|
||||||
* mismatch at read time lets each vehicle heal itself on first view instead of
|
|
||||||
* needing a bulk re-decode storm against the one surviving account.
|
|
||||||
*/
|
|
||||||
export function isStalePl24Architecture(opts: {
|
|
||||||
catalogPath?: string | null;
|
|
||||||
currentApiPath?: string | null;
|
|
||||||
}): boolean {
|
|
||||||
const stored = opts.catalogPath?.toLowerCase() ?? "";
|
|
||||||
const current = opts.currentApiPath?.toLowerCase() ?? "";
|
|
||||||
if (!stored || !current) return false;
|
|
||||||
// Only the two migrated legacy backends; unknown/other paths are left alone.
|
|
||||||
const storedIsLegacyPsaOrVolvo = stored.startsWith("/psa") || stored.startsWith("/volvo");
|
|
||||||
if (!storedIsLegacyPsaOrVolvo) return false;
|
|
||||||
return current.startsWith("/p5");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when a stored `catalog_vehicles` browse row was listed under an
|
|
||||||
* architecture the service table no longer uses.
|
|
||||||
*
|
|
||||||
* Browse rows are a PERMANENT cache: `CatalogService.getModels` returns early
|
|
||||||
* whenever the brand already has rows, so `fetchVehicleList` is never called
|
|
||||||
* again for that brand. The 188 rows listed while PSA and Volvo were still P4
|
|
||||||
* (127 LEGACY_PSA + 61 LEGACY_VOLVO on prod, 2026-09-20) are therefore pinned
|
|
||||||
* forever: Peugeot/Citroën browse serves the frozen 2024-02-13 snapshot and
|
|
||||||
* Volvo/Polestar browse serves an endpoint that answers HTTP 503. Detecting the
|
|
||||||
* mismatch at list time lets the brand re-list itself once, the same way
|
|
||||||
* `isStalePl24Architecture` heals a VIN-decoded vehicle.
|
|
||||||
*/
|
|
||||||
export function isStaleBrowseArchitecture(opts: {
|
|
||||||
storedArchitecture?: string | null;
|
|
||||||
currentArchitecture?: string | null;
|
|
||||||
}): boolean {
|
|
||||||
const stored = opts.storedArchitecture?.trim();
|
|
||||||
const current = opts.currentArchitecture?.trim();
|
|
||||||
// An unknown service (no config) or an unlabelled row is left alone: without a
|
|
||||||
// current architecture to compare against there is nothing to migrate TO.
|
|
||||||
if (!stored || !current) return false;
|
|
||||||
return stored !== current;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { PL24Service } from "./pl24.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Volvo P5 vinfoBasic regression lock (plv2.md, finding p5core-09).
|
|
||||||
*
|
|
||||||
* Volvo emits BOTH a bare internal code and a readable description for the same
|
|
||||||
* attribute — "Şanzıman kodu" = "B" next to "Şanzıman" = "6-PSHIFT 2WD / MPS6",
|
|
||||||
* and "Satis tipi" = "42" next to "Türü" = "S80". The key order the parser needs
|
|
||||||
* for VAG (whose "Şanzıman kodu" IS the useful value, e.g. "MQ200") therefore
|
|
||||||
* rendered a single letter as the gearbox and a bare number as the series.
|
|
||||||
*
|
|
||||||
* Fixtures are the real 2026-09-16 discovery captures for VIN
|
|
||||||
* YV1AS84ABD1168166 (S80), trimmed to the segments the parser reads, in both
|
|
||||||
* the Turkish and the English label locale.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fixture = (name: string) =>
|
|
||||||
JSON.parse(readFileSync(join(__dirname, "__fixtures__", `${name}.json`), "utf-8"));
|
|
||||||
|
|
||||||
// parseVehicleResponse only touches the response and the service name.
|
|
||||||
const parse = (data: unknown, serviceName = "volvo_parts") => {
|
|
||||||
const svc = Object.create(PL24Service.prototype) as unknown as {
|
|
||||||
parseVehicleResponse: (v: string, d: unknown, s: string) => Record<string, unknown>;
|
|
||||||
isDaimlerService: (s: string) => boolean;
|
|
||||||
};
|
|
||||||
svc.isDaimlerService = () => false;
|
|
||||||
return svc.parseVehicleResponse("YV1AS84ABD1168166", data, serviceName);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Volvo P5 vinfoBasic — kod yerine açıklama", () => {
|
|
||||||
it("Türkçe etiketlerde şanzımanı okunur değerinden alır", () => {
|
|
||||||
const out = parse(fixture("p5_volvo_tr"));
|
|
||||||
expect(out.transmission).toBe("6-PSHIFT 2WD / MPS6");
|
|
||||||
// Tek harflik "Şanzıman kodu" artık kazanmıyor.
|
|
||||||
expect(out.transmission).not.toBe("B");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("İngilizce etiketlerde de aynı sonucu verir", () => {
|
|
||||||
const out = parse(fixture("p5_volvo_en"));
|
|
||||||
expect(out.transmission).toBe("6-PSHIFT 2WD / MPS6");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seri, çıplak satış kodu yerine gerçek tipi döner", () => {
|
|
||||||
expect(parse(fixture("p5_volvo_tr")).series).toBe("S80");
|
|
||||||
expect(parse(fixture("p5_volvo_en")).series).toBe("S80");
|
|
||||||
expect(parse(fixture("p5_volvo_tr")).series).not.toBe("42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("kaporta stili iki dilde de çözülür ve '0' kodu sızmaz", () => {
|
|
||||||
expect(parse(fixture("p5_volvo_tr")).bodyType).toBe("Sedan");
|
|
||||||
expect(parse(fixture("p5_volvo_en")).bodyType).toBe("Sedan");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("motor alanları bozulmadan kalır", () => {
|
|
||||||
const out = parse(fixture("p5_volvo_tr"));
|
|
||||||
expect(out.engineCode).toBe("84");
|
|
||||||
expect(out.engineType).toBe("D4162T");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("model ve yıl korunur", () => {
|
|
||||||
const out = parse(fixture("p5_volvo_tr"));
|
|
||||||
expect(out.model).toBe("S80 (07-)");
|
|
||||||
expect(out.year).toBe(2013);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,29 +1,13 @@
|
|||||||
export const PL24_DEFAULTS = {
|
export const PL24_DEFAULTS = {
|
||||||
/** Service JWTs live 600s (measured); refresh this many ms before expiry. */
|
AUTH_TOKEN_TTL: 3600, // 1 hour in seconds
|
||||||
SERVICE_TOKEN_SKEW_MS: 60_000,
|
|
||||||
/** PL24TOKEN is a session cookie with no expiry — we keep our copy for a day. */
|
|
||||||
SESSION_TTL_S: 86_400,
|
|
||||||
CACHE_PREFIX: "pl24:",
|
CACHE_PREFIX: "pl24:",
|
||||||
REQUEST_TIMEOUT: 30000,
|
REQUEST_TIMEOUT: 30000,
|
||||||
MAX_RETRIES: 3,
|
MAX_RETRIES: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Real Chrome UA. The old value ("…AppleWebKit/537.36" with no Chrome/Safari
|
|
||||||
* token) matches no real browser and is a cheap automation tell.
|
|
||||||
*/
|
|
||||||
export const PL24_USER_AGENT =
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
|
|
||||||
|
|
||||||
export const PL24_ENDPOINTS = {
|
export const PL24_ENDPOINTS = {
|
||||||
// Auth — LOGIN is the portal/landing endpoint (returns {loginStatus, sessionToken}
|
// Auth
|
||||||
// + Set-Cookie PL24TOKEN). LOGIN_LEGACY is the in-SPA one our old code used; it
|
LOGIN: "/pl24-appgtw/ext/api/1.0/login",
|
||||||
// still works and returns a 600s base JWT we no longer need. PL24_LOGIN_API=legacy
|
|
||||||
// switches back for one release.
|
|
||||||
LOGIN: "/auth/ext/api/1.1/login",
|
|
||||||
LOGIN_LEGACY: "/pl24-appgtw/ext/api/1.0/login",
|
|
||||||
LOGOUT_LEGACY: "/pl24-appgtw/ext/api/1.0/logout",
|
|
||||||
SESSION: "/auth/ext/api/1.1/session",
|
|
||||||
AUTHORIZE: "/auth/ext/api/1.1/authorize",
|
AUTHORIZE: "/auth/ext/api/1.1/authorize",
|
||||||
|
|
||||||
// Catalog
|
// Catalog
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
|
|
||||||
import { PL24AuthService } from "./pl24-auth.service";
|
import { PL24AuthService } from "./pl24-auth.service";
|
||||||
import { PL24BudgetService } from "./pl24-budget.service";
|
|
||||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||||
import { PL24FordService } from "./pl24-ford.service";
|
import { PL24FordService } from "./pl24-ford.service";
|
||||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||||
@@ -13,7 +11,6 @@ import { PL24Service } from "./pl24.service";
|
|||||||
const PL24_PROVIDERS = [
|
const PL24_PROVIDERS = [
|
||||||
PL24Service,
|
PL24Service,
|
||||||
PL24AuthService,
|
PL24AuthService,
|
||||||
PL24BudgetService,
|
|
||||||
PL24FordLegacyService,
|
PL24FordLegacyService,
|
||||||
PL24PsaService,
|
PL24PsaService,
|
||||||
PL24VolvoService,
|
PL24VolvoService,
|
||||||
@@ -23,7 +20,6 @@ const PL24_PROVIDERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ProxyTelemetryModule],
|
|
||||||
providers: PL24_PROVIDERS,
|
providers: PL24_PROVIDERS,
|
||||||
exports: PL24_PROVIDERS,
|
exports: PL24_PROVIDERS,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const svc = new PL24Service(
|
|||||||
{} as never, // redis
|
{} as never, // redis
|
||||||
{} as never, // storage
|
{} as never, // storage
|
||||||
{} as never, // posthog
|
{} as never, // posthog
|
||||||
{} as never, // budget
|
|
||||||
);
|
);
|
||||||
const p = svc as unknown as {
|
const p = svc as unknown as {
|
||||||
parseVehicleResponse(
|
parseVehicleResponse(
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { PostHogService } from "../../posthog/posthog.service";
|
|||||||
import { RedisService } from "../../redis/redis.service";
|
import { RedisService } from "../../redis/redis.service";
|
||||||
import { StorageService } from "../../storage/storage.service";
|
import { StorageService } from "../../storage/storage.service";
|
||||||
import { PL24AuthService } from "./pl24-auth.service";
|
import { PL24AuthService } from "./pl24-auth.service";
|
||||||
import { PL24BudgetService } from "./pl24-budget.service";
|
|
||||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||||
import { PL24FordService } from "./pl24-ford.service";
|
import { PL24FordService } from "./pl24-ford.service";
|
||||||
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
|
||||||
@@ -56,65 +55,6 @@ const LEGACY_ARCH_SOURCE_TAG: Record<string, string> = {
|
|||||||
LEGACY_HYUNDAI_KIA: "hyundai-kia",
|
LEGACY_HYUNDAI_KIA: "hyundai-kia",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalise a vinfoBasic label into a lookup key.
|
|
||||||
*
|
|
||||||
* JS `toLowerCase()` maps Turkish "İ" to "i" + U+0307 (combining dot), so PSA
|
|
||||||
* labels like "AKTARMA SİSTEMLERİ" / "GÖVDE TİPİ" produced keys no lookup could
|
|
||||||
* ever match and the transmission/body fields silently stayed null. Lower-case
|
|
||||||
* with the Turkish locale, then strip combining marks and fold "ı" → "i" so a
|
|
||||||
* single spelling matches both "Model yılı" and "MODEL YILI".
|
|
||||||
*/
|
|
||||||
export function normalizeLabel(label: string): string {
|
|
||||||
return (
|
|
||||||
label
|
|
||||||
.toLocaleLowerCase("tr")
|
|
||||||
.normalize("NFD")
|
|
||||||
// \p{M} (all combining marks) rather than the U+0300–U+036F range: the range
|
|
||||||
// is a character class that can also match a base character followed by a
|
|
||||||
// combining one, which biome flags as misleading. NFD has already split every
|
|
||||||
// accent into its own mark, so matching marks directly is both correct and
|
|
||||||
// broader (Turkish, Latin-Extended, anything PL24 sends).
|
|
||||||
.replace(/\p{M}/gu, "")
|
|
||||||
.replace(/ı/g, "i")
|
|
||||||
.replace(/[\s/]+/g, "_")
|
|
||||||
.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PSA model year: "AM 2005" → 2005. Index labels ("01 MAJÖR ENDEKS",
|
|
||||||
* 'MAJÖR ENDEKS "0C"') are NOT years — returning one there is how a Citroën
|
|
||||||
* ended up as a 2001 model. PSA VINs also don't encode the model year in
|
|
||||||
* position 10, so the caller must fall back to DAM or leave it null.
|
|
||||||
*/
|
|
||||||
export function parsePsaModelYear(value: string | null | undefined): number | null {
|
|
||||||
if (!value) return null;
|
|
||||||
if (/endeks/i.test(value)) return null;
|
|
||||||
const m = value.match(/\b(?:AM\s*)?((?:19|20)\d{2})\b/i);
|
|
||||||
if (m) return Number(m[1]);
|
|
||||||
const short = value.match(/^\s*AM\s*(\d{2})\s*$/i);
|
|
||||||
if (short) {
|
|
||||||
const n = Number(short[1]);
|
|
||||||
return n >= 70 ? 1900 + n : 2000 + n;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PSA "DAM" (e.g. "10479CJ") = days since 1976-01-01 + plant code → build date.
|
|
||||||
* PSA model years roll in July, so a build after June belongs to the next one.
|
|
||||||
*/
|
|
||||||
export function damToModelYear(dam: string | null | undefined): number | null {
|
|
||||||
if (!dam) return null;
|
|
||||||
const m = dam.match(/^(\d{4,5})/);
|
|
||||||
if (!m) return null;
|
|
||||||
const date = new Date(Date.UTC(1976, 0, 1) + Number(m[1]) * 86_400_000);
|
|
||||||
const year = date.getUTCFullYear();
|
|
||||||
if (year < 1980 || year > 2100) return null;
|
|
||||||
return date.getUTCMonth() >= 6 ? year + 1 : year;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PL24Service {
|
export class PL24Service {
|
||||||
private readonly logger = new Logger(PL24Service.name);
|
private readonly logger = new Logger(PL24Service.name);
|
||||||
@@ -134,7 +74,6 @@ export class PL24Service {
|
|||||||
private redis: RedisService,
|
private redis: RedisService,
|
||||||
private storage: StorageService,
|
private storage: StorageService,
|
||||||
private posthog: PostHogService,
|
private posthog: PostHogService,
|
||||||
private readonly budget: PL24BudgetService,
|
|
||||||
) {
|
) {
|
||||||
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||||
this.timeout = 30000;
|
this.timeout = 30000;
|
||||||
@@ -1005,28 +944,15 @@ export class PL24Service {
|
|||||||
return opts;
|
return opts;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Every PL24 upstream call is counted and logged here (the one choke point).
|
const response = await fetch(url, buildOpts(headers));
|
||||||
const response = await this.budgetedFetch(
|
|
||||||
url,
|
|
||||||
buildOpts(headers),
|
|
||||||
account,
|
|
||||||
Boolean(dispatcher),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.logger.warn(`Got 401 (account=${account}), refreshing service token...`);
|
this.logger.warn(`Got 401 (account=${account}), refreshing token...`);
|
||||||
// A stale *service* token is the cheap explanation; the session itself is
|
|
||||||
// only dropped if the re-authorize also fails (handled in the auth service).
|
|
||||||
this.authService.clearTokensForAccount(account);
|
this.authService.clearTokensForAccount(account);
|
||||||
await this.authService.authorizeServiceForAccount(serviceName, account);
|
await this.authService.authorizeServiceForAccount(serviceName, account);
|
||||||
const newHeaders = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
const newHeaders = await this.authService.buildAuthHeadersForAccount(account, serviceName);
|
||||||
|
|
||||||
const retry = await this.budgetedFetch(
|
const retry = await fetch(url, buildOpts(newHeaders));
|
||||||
url,
|
|
||||||
buildOpts(newHeaders),
|
|
||||||
account,
|
|
||||||
Boolean(dispatcher),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!retry.ok) {
|
if (!retry.ok) {
|
||||||
throw new Error(`HTTP ${retry.status}: ${retry.statusText}`);
|
throw new Error(`HTTP ${retry.status}: ${retry.statusText}`);
|
||||||
@@ -1045,50 +971,6 @@ export class PL24Service {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Single gate for PL24 upstream traffic: daily budget first (a refusal costs
|
|
||||||
* no request at all), then the call, then telemetry into `proxy_logs`.
|
|
||||||
*/
|
|
||||||
private async budgetedFetch(
|
|
||||||
url: string,
|
|
||||||
opts: RequestInit & { dispatcher?: any },
|
|
||||||
account: "tr" | "de",
|
|
||||||
proxied: boolean,
|
|
||||||
): Promise<Response> {
|
|
||||||
// The kill switch used to gate decodeVin only, so drills, backfill and
|
|
||||||
// catalog browse kept hammering PL24 after the source was "killed".
|
|
||||||
if (!(await this.posthog.isSourceLive("pl24"))) {
|
|
||||||
throw new ServiceUnavailableException("PL24 kapali (kill-source-pl24)");
|
|
||||||
}
|
|
||||||
await this.budget.consume("catalog");
|
|
||||||
const startedAt = Date.now();
|
|
||||||
const activeAccount = this.authService.activeAccount(account);
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, opts);
|
|
||||||
this.budget.record({
|
|
||||||
kind: "catalog",
|
|
||||||
url,
|
|
||||||
proxied,
|
|
||||||
account: activeAccount,
|
|
||||||
statusCode: response.status,
|
|
||||||
success: response.ok,
|
|
||||||
startedAt,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
this.budget.record({
|
|
||||||
kind: "catalog",
|
|
||||||
url,
|
|
||||||
proxied,
|
|
||||||
account: activeAccount,
|
|
||||||
success: false,
|
|
||||||
startedAt,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== PRIVATE: Account routing ====================
|
// ==================== PRIVATE: Account routing ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1144,78 +1026,6 @@ export class PL24Service {
|
|||||||
|
|
||||||
// ==================== PRIVATE: Response parsers ====================
|
// ==================== PRIVATE: Response parsers ====================
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask a P5 backend where its own model list lives, instead of guessing.
|
|
||||||
*
|
|
||||||
* Every P5 backend serves `/extern/catmeta`, whose `data.catalogEntryPoint.path`
|
|
||||||
* is the authoritative browse entry point — e.g. p5psa answers
|
|
||||||
* `/p5psa/extern/vehicle/catalogs`, p5volvo `/p5volvo/extern/vehicles/models`.
|
|
||||||
* The old behaviour, when `BACKEND_MODEL_PATH` had no entry, was to fire the
|
|
||||||
* seven known paths in turn and keep whichever returned rows. That costs up to
|
|
||||||
* seven upstream requests per call on a shared daily budget, and it silently
|
|
||||||
* fails for any backend whose path is not already on the list — which is how
|
|
||||||
* Volvo/Polestar browse ended up seeding zero models while still serving the
|
|
||||||
* retired P4 listing.
|
|
||||||
*
|
|
||||||
* The resolved path is cached per backend (30 days) so this costs one request
|
|
||||||
* for a backend's whole lifetime, and it self-heals if PL24 moves an endpoint.
|
|
||||||
* Returns null on any failure; the caller then falls back as before.
|
|
||||||
*/
|
|
||||||
private async resolveModelPathFromCatmeta(
|
|
||||||
catalogBase: string,
|
|
||||||
serviceName: string,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const cacheKey = `pl24:modelpath:${catalogBase.replace(/^\//, "")}`;
|
|
||||||
try {
|
|
||||||
const cached = await this.redis.get(cacheKey);
|
|
||||||
if (cached) return cached === "none" ? null : cached;
|
|
||||||
} catch {
|
|
||||||
// Redis down — resolve live rather than failing the listing.
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolved: string | null = null;
|
|
||||||
try {
|
|
||||||
const url = `${this.baseUrl}${catalogBase}/extern/catmeta?serviceName=${serviceName}&country=DE&lang=${this.language}`;
|
|
||||||
const res = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(15000) });
|
|
||||||
if (res.ok) {
|
|
||||||
const meta = (await res.json()) as {
|
|
||||||
data?: { catalogEntryPoint?: { path?: string } };
|
|
||||||
};
|
|
||||||
const full = meta.data?.catalogEntryPoint?.path;
|
|
||||||
if (full) {
|
|
||||||
// catmeta returns the absolute path with query string
|
|
||||||
// ("/p5volvo/extern/vehicles/models?lang=en&serviceName=…"); the caller
|
|
||||||
// appends its own lang/serviceName, so keep only the backend-relative
|
|
||||||
// path segment.
|
|
||||||
const withoutQuery = full.split("?")[0];
|
|
||||||
const relative = withoutQuery.startsWith(catalogBase)
|
|
||||||
? withoutQuery.slice(catalogBase.length)
|
|
||||||
: withoutQuery;
|
|
||||||
if (relative.startsWith("/extern/")) resolved = relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`fetchVehicleList: catmeta lookup failed for ${serviceName}: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved) {
|
|
||||||
this.logger.log(
|
|
||||||
`fetchVehicleList: ${serviceName} model path resolved from catmeta → ${resolved}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Cache the negative too, so a backend without a usable catmeta does not
|
|
||||||
// re-probe on every browse.
|
|
||||||
await this.redis.set(cacheKey, resolved ?? "none", 30 * 86_400);
|
|
||||||
} catch {
|
|
||||||
// best effort
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse vehicle data from directAccess response.
|
* Parse vehicle data from directAccess response.
|
||||||
*/
|
*/
|
||||||
@@ -1241,7 +1051,7 @@ export class PL24Service {
|
|||||||
const label = (v.key !== undefined ? v.key : v.description) || "";
|
const label = (v.key !== undefined ? v.key : v.description) || "";
|
||||||
const value = (v.key !== undefined ? v.description : v.value) || "";
|
const value = (v.key !== undefined ? v.description : v.value) || "";
|
||||||
if (!label) continue;
|
if (!label) continue;
|
||||||
const key = normalizeLabel(label);
|
const key = label.toLowerCase().replace(/[\s\/]+/g, "_");
|
||||||
if (!(key in vehicleData)) {
|
if (!(key in vehicleData)) {
|
||||||
// Normalize like prNr col3: newlines→space, unescape the literal "\-" some P5
|
// Normalize like prNr col3: newlines→space, unescape the literal "\-" some P5
|
||||||
// backends emit (JLR "XJ 2010 \- 2019", Toyota/Suzuki dates "2023\-11\-29"/"2005\-07"),
|
// backends emit (JLR "XJ 2010 \- 2019", Toyota/Suzuki dates "2023\-11\-29"/"2005\-07"),
|
||||||
@@ -1263,31 +1073,6 @@ export class PL24Service {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Like `lookup`, but prefers a human-readable value over a bare internal
|
|
||||||
* code when the record carries both.
|
|
||||||
*
|
|
||||||
* Volvo's vinfoBasic has BOTH "Şanzıman kodu" ("B") and "Şanzıman"
|
|
||||||
* ("6-PSHIFT 2WD / MPS6"), and BOTH "Satis tipi" ("42") and a second
|
|
||||||
* "Satis tipi" ("SALES VERSION 42"). The code-first key order that VAG needs
|
|
||||||
* (its "Şanzıman kodu" IS the useful value, e.g. "MQ200") therefore rendered
|
|
||||||
* a single letter as the Volvo gearbox and a bare number as its series.
|
|
||||||
*
|
|
||||||
* Falls back to the first present value, so a backend that only ever emits
|
|
||||||
* codes behaves exactly as before.
|
|
||||||
*/
|
|
||||||
const lookupDescriptive = (...keys: string[]): string | null => {
|
|
||||||
let firstPresent: string | null = null;
|
|
||||||
for (const k of keys) {
|
|
||||||
const v = vehicleData[k]?.trim();
|
|
||||||
if (!v) continue;
|
|
||||||
if (firstPresent === null) firstPresent = v;
|
|
||||||
// Two characters or fewer, or digits only → an index, not a description.
|
|
||||||
if (v.length > 2 && !/^\d+$/.test(v)) return v;
|
|
||||||
}
|
|
||||||
return firstPresent;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract prNr records for richer vehicle attributes
|
// Extract prNr records for richer vehicle attributes
|
||||||
const prNrRecords = segments.prNr?.records || [];
|
const prNrRecords = segments.prNr?.records || [];
|
||||||
const prNrByCode: Record<string, string> = {};
|
const prNrByCode: Record<string, string> = {};
|
||||||
@@ -1313,47 +1098,26 @@ export class PL24Service {
|
|||||||
const engineCode = lookup("motor_kodu", "engine_code");
|
const engineCode = lookup("motor_kodu", "engine_code");
|
||||||
// Non-VAG P5 OEMs label the engine differently and give a description (sometimes with a
|
// Non-VAG P5 OEMs label the engine differently and give a description (sometimes with a
|
||||||
// code in parens): JLR "Motor Tipi", Toyota "ENGINE 1", MAN "Yedek motor", Suzuki "Motor No.".
|
// code in parens): JLR "Motor Tipi", Toyota "ENGINE 1", MAN "Yedek motor", Suzuki "Motor No.".
|
||||||
// PSA labels the full designation "MOTOR" ("TÜRBO DİZEL DV6TED4…"), Volvo
|
const engineLabel = lookup("motor_tipi", "engine_1", "yedek_motor", "motor_no.");
|
||||||
// "Motor" ("D4162T"), Subaru "Engine" — none of which the VAG-shaped list had.
|
|
||||||
const engineLabel = lookup(
|
|
||||||
"motor_tipi",
|
|
||||||
"engine_1",
|
|
||||||
"yedek_motor",
|
|
||||||
"motor_no.",
|
|
||||||
"motor",
|
|
||||||
"engine",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Transmission: VAG "Şanzıman kodu"; other OEMs use their own labels — JLR "Vites Kutusu",
|
// Transmission: VAG "Şanzıman kodu"; other OEMs use their own labels — JLR "Vites Kutusu",
|
||||||
// Toyota "ATM,MTM" (key "atm,mtm" — only spaces/slashes are underscored), MAN "Şanzıman",
|
// Toyota "ATM,MTM" (key "atm,mtm" — only spaces/slashes are underscored), MAN "Şanzıman",
|
||||||
// Suzuki "Şanzıman numarası".
|
// Suzuki "Şanzıman numarası".
|
||||||
// normalizeLabel folds ı→i and strips diacritics, so "Şanzıman kodu" and
|
const transmissionCode = lookup(
|
||||||
// "ŞANZIMAN KODU" both arrive as "sanziman_kodu". PSA uses "AKTARMA
|
"şanzıman_kodu",
|
||||||
// SİSTEMLERİ" ("5 MEKANİK VİTES KUTUSU"), Subaru "Mission".
|
"sanzıman_kodu",
|
||||||
const transmissionCode = lookupDescriptive(
|
|
||||||
"sanziman_kodu",
|
|
||||||
"transmission_code",
|
"transmission_code",
|
||||||
"vites_kutusu",
|
"vites_kutusu",
|
||||||
"atm,mtm",
|
"atm,mtm",
|
||||||
"aktarma_sistemleri",
|
"şanzıman",
|
||||||
"sanziman",
|
"şanzıman_numarası",
|
||||||
// Volvo in the English locale: "Transmission code" ("B") plus the readable
|
|
||||||
// "Transmission" ("6-PSHIFT 2WD / MPS6"). Without the plain key the
|
|
||||||
// English response falls back to the single-letter code.
|
|
||||||
"transmission",
|
|
||||||
"sanziman_numarasi",
|
|
||||||
"mission",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build body type from prNr K8* (Kaporta formları); brands without a prNr segment
|
// Build body type from prNr K8* (Kaporta formları); brands without a prNr segment
|
||||||
// (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine").
|
// (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine").
|
||||||
const bodyType =
|
const bodyType =
|
||||||
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] ||
|
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] ||
|
||||||
// PSA "GÖVDE TİPİ" ("4 KAPILI SEDAN"), Volvo "Kaporta Stili" ("Sedan").
|
lookup("karoseri", "body", "body_type") ||
|
||||||
// Volvo: TR "Kaporta Stili" / EN "Body style" ("Sedan"). Its
|
|
||||||
// "Karoseri Tipi Kodu" / "Body style code" is a bare "0" — never matched
|
|
||||||
// here because the lookup is exact-key, and that is deliberate.
|
|
||||||
lookup("karoseri", "body", "body_type", "govde_tipi", "kaporta_stili", "body_style") ||
|
|
||||||
null;
|
null;
|
||||||
|
|
||||||
// Engine description from prNr D3* (Motor nitelikleri)
|
// Engine description from prNr D3* (Motor nitelikleri)
|
||||||
@@ -1376,7 +1140,7 @@ export class PL24Service {
|
|||||||
// the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix.
|
// the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix.
|
||||||
const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi";
|
const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi";
|
||||||
const baseModel = isMitsubishi
|
const baseModel = isMitsubishi
|
||||||
? (lookup("arac") || (data.description as string) || lookup("model") || "")
|
? (lookup("araç") || (data.description as string) || lookup("model") || "")
|
||||||
.replace(/\s*\([^)]*\)\s*$/, "")
|
.replace(/\s*\([^)]*\)\s*$/, "")
|
||||||
.trim()
|
.trim()
|
||||||
: lookup("model_bilgisi", "model")?.trim() ||
|
: lookup("model_bilgisi", "model")?.trim() ||
|
||||||
@@ -1399,23 +1163,17 @@ export class PL24Service {
|
|||||||
// production date ("05/09/2014"); p5daimler → only "Teslimat tarihi" (delivery, "04.05.2009").
|
// production date ("05/09/2014"); p5daimler → only "Teslimat tarihi" (delivery, "04.05.2009").
|
||||||
// Falling through to the VIN year-char (extractModelYear) is the last resort and is often
|
// Falling through to the VIN year-char (extractModelYear) is the last resort and is often
|
||||||
// wrong for Mercedes (10th-char "1" → 2001 for a 2015 car), so read the dates first.
|
// wrong for Mercedes (10th-char "1" → 2001 for a 2015 car), so read the dates first.
|
||||||
// PSA writes "AM 2005" (or an index label that is NOT a year) and its VINs
|
|
||||||
// do not encode the model year in position 10, so parse the PSA forms
|
|
||||||
// first and fall back to the DAM build date before ever touching the VIN.
|
|
||||||
year:
|
year:
|
||||||
parsePsaModelYear(lookup("model_yili", "year")) ||
|
|
||||||
Number.parseInt(lookup("model_yili", "year") || "", 10) ||
|
Number.parseInt(lookup("model_yili", "year") || "", 10) ||
|
||||||
Number.parseInt(
|
Number.parseInt(
|
||||||
(lookup("my", "uretim_tarihi", "teslimat_tarihi") || "").match(/(19|20)\d{2}/)?.[0] || "",
|
(lookup("my", "üretim_tarihi", "uretim_tarihi", "teslimat_tarihi") || "").match(
|
||||||
|
/(19|20)\d{2}/,
|
||||||
|
)?.[0] || "",
|
||||||
10,
|
10,
|
||||||
) ||
|
) ||
|
||||||
damToModelYear(lookup("dam")) ||
|
|
||||||
extractModelYear(vin) ||
|
extractModelYear(vin) ||
|
||||||
0,
|
0,
|
||||||
// Volvo's "Satis tipi" is the bare sales code ("42") with the readable
|
series: lookup("seri", "satis_tipi", "sales_type"),
|
||||||
// form ("SALES VERSION 42") in a duplicate row, and its "Türü" is the real
|
|
||||||
// series ("S80") — so prefer whichever of these is actually descriptive.
|
|
||||||
series: lookupDescriptive("seri", "satis_tipi", "sales_type", "turu", "type"),
|
|
||||||
bodyType,
|
bodyType,
|
||||||
engineCode:
|
engineCode:
|
||||||
engineCode ||
|
engineCode ||
|
||||||
@@ -1429,7 +1187,7 @@ export class PL24Service {
|
|||||||
colorCode:
|
colorCode:
|
||||||
lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") ||
|
lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") ||
|
||||||
lookup("tavan_rengi", "roof_color"),
|
lookup("tavan_rengi", "roof_color"),
|
||||||
productionDate: lookup("uretim_tarihi", "date_of_production", "teslimat_tarihi"),
|
productionDate: lookup("üretim_tarihi", "date_of_production"),
|
||||||
raw: data,
|
raw: data,
|
||||||
catalogInfo: {
|
catalogInfo: {
|
||||||
serviceName,
|
serviceName,
|
||||||
@@ -1594,14 +1352,9 @@ export class PL24Service {
|
|||||||
const values = (record.values as Record<string, string>) || {};
|
const values = (record.values as Record<string, string>) || {};
|
||||||
const link = (record.link as Record<string, unknown>) || {};
|
const link = (record.link as Record<string, unknown>) || {};
|
||||||
|
|
||||||
// PSA (p5psa) shares one `record.id` (the illusPath) across many
|
|
||||||
// illustrations — 36 live records had only 11 distinct ids — so the unique
|
|
||||||
// key is `values.illustration` ("D2F 0 01A 48A", spaces stripped). Without
|
|
||||||
// this the children collapse onto each other and most get dropped.
|
|
||||||
const code =
|
const code =
|
||||||
values.subgroup ||
|
values.subgroup ||
|
||||||
values.illustrationNumber ||
|
values.illustrationNumber ||
|
||||||
values.illustration?.replace(/\s+/g, "") ||
|
|
||||||
values.id ||
|
values.id ||
|
||||||
values.code ||
|
values.code ||
|
||||||
String(record.id || "");
|
String(record.id || "");
|
||||||
@@ -1679,14 +1432,11 @@ export class PL24Service {
|
|||||||
records = responseData.parts as Array<Record<string, unknown>>;
|
records = responseData.parts as Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const partRecords = records.filter((record) => {
|
const partRecords = records.filter(
|
||||||
if (record.characteristic === "sectionrow") return false;
|
(record) =>
|
||||||
if (!(record.partno || (record.values as Record<string, unknown>)?.partno)) return false;
|
record.characteristic !== "sectionrow" &&
|
||||||
// Volvo (p5volvo) prefixes each BOM with a header row that carries a
|
(record.partno || (record.values as Record<string, unknown>)?.partno),
|
||||||
// partno but no link and is flagged unavailable — a phantom part if kept.
|
);
|
||||||
if (record.id === "null_null" || (record.unavailable === true && !record.link)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return partRecords.map((part) => {
|
return partRecords.map((part) => {
|
||||||
const values = (part.values as Record<string, string>) || {};
|
const values = (part.values as Record<string, string>) || {};
|
||||||
@@ -1694,14 +1444,11 @@ export class PL24Service {
|
|||||||
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
|
const formattedPartNo = ((part.partno as string) || values.partno || "").trim();
|
||||||
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
|
const cleanPartNo = formattedPartNo.replace(/\s+/g, "");
|
||||||
|
|
||||||
// qty (VAG/Subaru) · coef (PSA) · unit (Volvo) — same field, three names.
|
const qtyStr = values.qty || "";
|
||||||
const qtyStr = values.qty || values.coef || values.unit || "";
|
|
||||||
const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined;
|
const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined;
|
||||||
|
|
||||||
const remark = values.remark?.trim() || undefined;
|
const remark = values.remark?.trim() || undefined;
|
||||||
// PSA/Volvo/Subaru express applicability as `restriction`
|
const modelCodes = values.modelDescription?.trim() || undefined;
|
||||||
// ("+ DIESEL TURBO DV6TED4 WITHOUT FAP"); VAG uses modelDescription.
|
|
||||||
const modelCodes = (values.modelDescription || values.restriction)?.trim() || undefined;
|
|
||||||
|
|
||||||
let superseded: { oldCode: string; newCode: string } | undefined;
|
let superseded: { oldCode: string; newCode: string } | undefined;
|
||||||
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
|
const supersededByValue = (part.supersededBy as string) || values.supersededBy || "";
|
||||||
@@ -2305,22 +2052,11 @@ export class PL24Service {
|
|||||||
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
|
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
|
||||||
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
|
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
|
||||||
p5man: "/extern/model/categories", // MAN trucks
|
p5man: "/extern/model/categories", // MAN trucks
|
||||||
// Pinned 2026-09-20 from each backend's own catmeta `catalogEntryPoint`
|
|
||||||
// (see resolveModelPathFromCatmeta). Before this, p5psa needed five wasted
|
|
||||||
// probe requests to rediscover its path on every call, and p5volvo found
|
|
||||||
// nothing at all — none of the seven guessed paths matches its plural
|
|
||||||
// `/extern/vehicles/models`, so Volvo/Polestar browse silently seeded zero
|
|
||||||
// models and kept serving the dead P4 listing.
|
|
||||||
p5psa: "/extern/vehicle/catalogs", // Peugeot, Citroën, DS, psa_opel, psa_vauxhall
|
|
||||||
p5volvo: "/extern/vehicles/models", // Volvo, Polestar — NOTE: plural "vehicles"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// catalogBase is like "/p5vwag" — strip leading slash for map lookup
|
// catalogBase is like "/p5vwag" — strip leading slash for map lookup
|
||||||
const backendKey = catalogBase.replace(/^\//, "");
|
const backendKey = catalogBase.replace(/^\//, "");
|
||||||
const modelPath =
|
const modelPath = BACKEND_MODEL_PATH[backendKey] ?? "/extern/vehicle/modelfamilies";
|
||||||
BACKEND_MODEL_PATH[backendKey] ??
|
|
||||||
(await this.resolveModelPathFromCatmeta(catalogBase, serviceName, headers)) ??
|
|
||||||
"/extern/vehicle/modelfamilies";
|
|
||||||
|
|
||||||
const url = `${this.baseUrl}${catalogBase}${modelPath}?lang=${this.language}&serviceName=${serviceName}`;
|
const url = `${this.baseUrl}${catalogBase}${modelPath}?lang=${this.language}&serviceName=${serviceName}`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PL24_SERVICE_CATALOGS, PL24_WMI_SERVICE_MAP, SERVICE_TO_BRAND } from "./pl24.types";
|
import { PL24_WMI_SERVICE_MAP, SERVICE_TO_BRAND } from "./pl24.types";
|
||||||
|
|
||||||
// Q6 routing additions (undecoded-vin-rca.md). Both target services are already
|
// Q6 routing additions (undecoded-vin-rca.md). Both target services are already
|
||||||
// live in prod (nissan_parts: JN1 success; mercedesvans_parts: WDF decodes today),
|
// live in prod (nissan_parts: JN1 success; mercedesvans_parts: WDF decodes today),
|
||||||
@@ -27,71 +27,3 @@ describe("PL24_WMI_SERVICE_MAP — Q6 routing additions", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// PL24'te bulunmayan WMI'ler: haritada yer almamalı, yoksa her sorguda boşuna
|
|
||||||
// upstream isteği üretir ve gerçek kaynağa (pcat/emex/vinpin) geçişi geciktirir.
|
|
||||||
describe("PL24_WMI_SERVICE_MAP — kapsam dışı WMI'ler", () => {
|
|
||||||
it("VR7 haritada değil (canlı: HTTP 410 'no brands found for WMI = VR7')", () => {
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.VR7).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("NM4 (Tofaş) haritada değil (canlı: HTTP 410)", () => {
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.NM4).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("canlı doğrulanmış WMI'ler doğru katalogda", () => {
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.JF1).toBe("subaru_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.ZAR).toBe("alfa_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.VXK).toBe("psa_opel_parts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2026-09-20 canlı WMI servisi taraması (plv2.md, bulgu types_wmi-06/09).
|
|
||||||
* Prod'daki 1.406 haritasız araçtan hangilerinin PL24'te gerçekten karşılığı
|
|
||||||
* olduğu `/pl24-wmi/ext/api/2.0/decode` ile tek tek soruldu; bu test o cevapları
|
|
||||||
* kilitliyor — özellikle "yok" cevaplarını, çünkü onları haritaya eklemek
|
|
||||||
* boşuna istek üretir.
|
|
||||||
*/
|
|
||||||
describe("WMI haritası — canlı WMI servisi taraması (2026-09-20)", () => {
|
|
||||||
it("Renault yeniden açıldı (VIN tanımlama askısı kalktı)", () => {
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.VF1).toBe("renault_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.VF6).toBe("renault_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.VNE).toBe("renault_parts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("canlı servisin çözdüğü yeni WMI'ler haritada", () => {
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.NLH).toBe("hyundai_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.TMA).toBe("hyundai_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.NLJ).toBe("hyundai_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.KMF).toBe("hyundai_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.KNE).toBe("kia_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.KNC).toBe("kia_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.MMC).toBe("mmc_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.XMC).toBe("mmc_parts");
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.JSA).toBe("suzuki_parts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("NMB binek Mercedes değil, kamyon kataloğuna gider", () => {
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.NMB).toBe("mercedestrucks_parts");
|
|
||||||
// Binek WMI'leri bozulmadı.
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.WDD).toBe("mercedes_parts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PL24'te olmayan WMI'ler haritaya EKLENMEDİ (HTTP 410)", () => {
|
|
||||||
// Honda ve Chevrolet'nin PL24'te kataloğu yok; eklemek boşuna istek olurdu.
|
|
||||||
for (const wmi of ["JHM", "SHH", "SHS", "MAK", "NLA", "KL1", "NM4", "VR7"]) {
|
|
||||||
expect(PL24_WMI_SERVICE_MAP[wmi]).toBeUndefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("JMZ (Mazda) eklenmedi — servis Ford kataloğu öneriyor, marka tutarsız", () => {
|
|
||||||
expect(PL24_WMI_SERVICE_MAP.JMZ).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("eşlenen her servisin katalog tanımı var", () => {
|
|
||||||
for (const [wmi, svc] of Object.entries(PL24_WMI_SERVICE_MAP)) {
|
|
||||||
expect(PL24_SERVICE_CATALOGS[svc], `${wmi} → ${svc}`).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -53,58 +53,6 @@ export interface PL24JWTPayload {
|
|||||||
alo: string;
|
alo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Portal login (`/auth/ext/api/1.1/login`) request + response. */
|
|
||||||
export interface PL24LoginRequestV2 {
|
|
||||||
account: string;
|
|
||||||
user: string;
|
|
||||||
password: string;
|
|
||||||
squeezeOut: boolean;
|
|
||||||
/** Two-factor confirmation code, when PL24 asks for one. */
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PL24LoginResponseV2 {
|
|
||||||
loginStatus?: "OK" | string;
|
|
||||||
sessionToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RFC7807 body PL24 returns on a refused login (HTTP 400/412). `type` carries
|
|
||||||
* the reason; the UI bundle enumerates these.
|
|
||||||
*/
|
|
||||||
export interface PL24LoginProblem {
|
|
||||||
type?: string;
|
|
||||||
title?: string;
|
|
||||||
detail?: string;
|
|
||||||
/** For two-fa-required: "AUTHENTICATOR" | "EMAIL". */
|
|
||||||
method?: string;
|
|
||||||
code?: string;
|
|
||||||
token?: string;
|
|
||||||
completionToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PL24_LOGIN_PROBLEM = {
|
|
||||||
SESSION_LIMIT_EXCEEDED: "urn:login:session-limit-exceeded",
|
|
||||||
TWO_FA_REQUIRED: "urn:login:two-factor-authentication-required",
|
|
||||||
TWO_FA_INVALID: "urn:login:two-factor-authentication-invalid-code",
|
|
||||||
ACCOUNT_PENDING: "urn:login:account-pending",
|
|
||||||
PRECONDITION_FAILED: "urn:login:precondition-failed",
|
|
||||||
ACCOUNT_NOT_ACTIVE: "urn:login:account-not-active",
|
|
||||||
USER_NOT_ACTIVE: "urn:login:user-not-active",
|
|
||||||
AUTHENTICATION_FAILED: "urn:login:authentication",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One PL24 session = the PL24TOKEN cookie. It has no expiry of its own; it dies
|
|
||||||
* when PL24 squeezes it out (another login on the same account) or is revoked,
|
|
||||||
* which surfaces as authorize → 401 or `session_status: "gone"`.
|
|
||||||
*/
|
|
||||||
export interface PL24Session {
|
|
||||||
sessionToken: string;
|
|
||||||
loginAt: number;
|
|
||||||
lastOkAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PL24TokenData {
|
export interface PL24TokenData {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@@ -339,61 +287,19 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
|||||||
|
|
||||||
// PSA Group (Citroën, Peugeot)
|
// PSA Group (Citroën, Peugeot)
|
||||||
citroen_parts: {
|
citroen_parts: {
|
||||||
basePath: "/pl24-app/citroen_parts",
|
basePath: "/psa",
|
||||||
apiPath: "/p5psa",
|
apiPath: "/psa",
|
||||||
architecture: "P5_MODERN",
|
architecture: "LEGACY_PSA",
|
||||||
},
|
},
|
||||||
citroenDs_parts: {
|
citroenDs_parts: {
|
||||||
basePath: "/pl24-app/citroenDs_parts",
|
basePath: "/psa",
|
||||||
apiPath: "/p5psa",
|
apiPath: "/psa",
|
||||||
architecture: "P5_MODERN",
|
architecture: "LEGACY_PSA",
|
||||||
},
|
},
|
||||||
peugeot_parts: {
|
peugeot_parts: {
|
||||||
basePath: "/pl24-app/peugeot_parts",
|
basePath: "/psa",
|
||||||
apiPath: "/p5psa",
|
apiPath: "/psa",
|
||||||
architecture: "P5_MODERN",
|
architecture: "LEGACY_PSA",
|
||||||
},
|
|
||||||
|
|
||||||
// Stellantis-era Opel/Vauxhall — the PSA-platform catalogue (Corsa F, Mokka B…).
|
|
||||||
// GM-era W0L/W0V cars stay in the legacy opel_parts P4 catalogue below.
|
|
||||||
psa_opel_parts: {
|
|
||||||
basePath: "/pl24-app/psa_opel_parts",
|
|
||||||
apiPath: "/p5psa",
|
|
||||||
architecture: "P5_MODERN",
|
|
||||||
},
|
|
||||||
psa_vauxhall_parts: {
|
|
||||||
basePath: "/pl24-app/psa_vauxhall_parts",
|
|
||||||
apiPath: "/p5psa",
|
|
||||||
architecture: "P5_MODERN",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Subaru (own P5 backend)
|
|
||||||
subaru_parts: {
|
|
||||||
basePath: "/pl24-app/subaru_parts",
|
|
||||||
apiPath: "/p5subaru",
|
|
||||||
architecture: "P5_MODERN",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Rest of the Fiat/Stellantis family — same /p5fiat backend as fiatp/fiatt
|
|
||||||
abarth_parts: {
|
|
||||||
basePath: "/pl24-app/abarth_parts",
|
|
||||||
apiPath: "/p5fiat",
|
|
||||||
architecture: "P5_MODERN",
|
|
||||||
},
|
|
||||||
alfa_parts: {
|
|
||||||
basePath: "/pl24-app/alfa_parts",
|
|
||||||
apiPath: "/p5fiat",
|
|
||||||
architecture: "P5_MODERN",
|
|
||||||
},
|
|
||||||
jeep_parts: {
|
|
||||||
basePath: "/pl24-app/jeep_parts",
|
|
||||||
apiPath: "/p5fiat",
|
|
||||||
architecture: "P5_MODERN",
|
|
||||||
},
|
|
||||||
lancia_parts: {
|
|
||||||
basePath: "/pl24-app/lancia_parts",
|
|
||||||
apiPath: "/p5fiat",
|
|
||||||
architecture: "P5_MODERN",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Ford Group
|
// Ford Group
|
||||||
@@ -446,14 +352,14 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
|||||||
|
|
||||||
// Volvo/Polestar
|
// Volvo/Polestar
|
||||||
volvo_parts: {
|
volvo_parts: {
|
||||||
basePath: "/pl24-app/volvo_parts",
|
basePath: "/volvo",
|
||||||
apiPath: "/p5volvo",
|
apiPath: "/volvo",
|
||||||
architecture: "P5_MODERN",
|
architecture: "LEGACY_VOLVO",
|
||||||
},
|
},
|
||||||
polestar_parts: {
|
polestar_parts: {
|
||||||
basePath: "/pl24-app/polestar_parts",
|
basePath: "/volvo",
|
||||||
apiPath: "/p5volvo",
|
apiPath: "/volvo",
|
||||||
architecture: "P5_MODERN",
|
architecture: "LEGACY_VOLVO",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account.
|
// Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account.
|
||||||
@@ -540,10 +446,6 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
|
|
||||||
// Mercedes-Benz
|
// Mercedes-Benz
|
||||||
WDB: "mercedes_parts",
|
WDB: "mercedes_parts",
|
||||||
// NMB: canlı WMI servisi bunu mercedes_parts'a DEĞİL mercedestrucks_parts'a
|
|
||||||
// çözüyor (error:false) — 30 prod aracı "Mercedes-Benz" etiketliydi ama binek
|
|
||||||
// kataloğunda yok.
|
|
||||||
NMB: "mercedestrucks_parts",
|
|
||||||
WDD: "mercedes_parts",
|
WDD: "mercedes_parts",
|
||||||
WDC: "mercedes_parts",
|
WDC: "mercedes_parts",
|
||||||
W1K: "mercedes_parts",
|
W1K: "mercedes_parts",
|
||||||
@@ -582,21 +484,15 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
JTJ: "lexus_parts",
|
JTJ: "lexus_parts",
|
||||||
"2T2": "lexus_parts",
|
"2T2": "lexus_parts",
|
||||||
|
|
||||||
// Renault — RE-ENABLED 2026-09-20. It was disabled while PL24 had suspended
|
// Renault — DISABLED: PL24 has suspended Renault VIN identification ("Bu marka
|
||||||
// Renault VIN identification ("Bu marka için şasi numarası tanımlamasının
|
// için şasi numarası tanımlamasının belirsiz bir süre için mevcut olmayacağını
|
||||||
// belirsiz bir süre için mevcut olmayacağını üzülerek bildiririz."), which both
|
// üzülerek bildiririz."). renault_parts authorizes fine but every decode throws
|
||||||
// wasted a call per decode and, back then, tripped the global PL24 breaker.
|
// that message → wasted ~1s call AND it trips the PL24 circuit breaker, which
|
||||||
// BOTH reasons are gone, each verified against prod rather than assumed:
|
// then skips PL24 for ALL brands. PCAT + EMEX cover Renault. Re-enable when PL24
|
||||||
// 1. The suspension is over — live directAccess on /p5renault for a real
|
// restores Renault VIN decode.
|
||||||
// customer VIN (VF14SRCL458170337) returns resultStatus
|
// VF1: "renault_parts",
|
||||||
// VEHICLE_IDENTIFIED, "SYMBOL II/LOGAN II", and the WMI service answers
|
// VF6: "renault_parts",
|
||||||
// {service: renault_parts, valid: true, error: false}.
|
// VNE: "renault_parts",
|
||||||
// 2. The breaker no longer counts definitive upstream negatives, only
|
|
||||||
// transient transport faults (see vehicles.service `isTransient`).
|
|
||||||
// 450 prod vehicles carry VF1 and had no PL24 catalog at all.
|
|
||||||
VF1: "renault_parts",
|
|
||||||
VF6: "renault_parts",
|
|
||||||
VNE: "renault_parts",
|
|
||||||
|
|
||||||
// Dacia
|
// Dacia
|
||||||
UU1: "dacia_parts",
|
UU1: "dacia_parts",
|
||||||
@@ -616,10 +512,6 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
WMH: "man_parts",
|
WMH: "man_parts",
|
||||||
|
|
||||||
// Mitsubishi
|
// Mitsubishi
|
||||||
// XMC / MMC: canlı WMI servisi ikisini de mmc_parts'a çözüyor ve P5 doğruluyor
|
|
||||||
// (error:false) — 26 prod aracı haritasızdı.
|
|
||||||
MMC: "mmc_parts", // Mitsubishi Japan
|
|
||||||
XMC: "mmc_parts", // Mitsubishi (diğer pazarlar)
|
|
||||||
JMB: "mmc_parts",
|
JMB: "mmc_parts",
|
||||||
JMY: "mmc_parts",
|
JMY: "mmc_parts",
|
||||||
MMB: "mmc_parts",
|
MMB: "mmc_parts",
|
||||||
@@ -629,7 +521,6 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
JS2: "suzuki_parts",
|
JS2: "suzuki_parts",
|
||||||
JS3: "suzuki_parts",
|
JS3: "suzuki_parts",
|
||||||
TSM: "suzuki_parts",
|
TSM: "suzuki_parts",
|
||||||
JSA: "suzuki_parts", // Suzuki (canlı WMI: suzuki_parts, error:false)
|
|
||||||
MA3: "suzuki_parts",
|
MA3: "suzuki_parts",
|
||||||
MBH: "suzuki_parts",
|
MBH: "suzuki_parts",
|
||||||
|
|
||||||
@@ -645,27 +536,18 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
|
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
|
||||||
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
|
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
|
||||||
ZFF: "fiatp_parts", // Abarth / Fiat Sport
|
ZFF: "fiatp_parts", // Abarth / Fiat Sport
|
||||||
ZAR: "alfa_parts", // Alfa Romeo (kendi kataloğu, /p5fiat backend)
|
ZAR: "fiatp_parts", // Alfa Romeo
|
||||||
ZLA: "lancia_parts", // Lancia (kendi kataloğu, /p5fiat backend)
|
ZLA: "fiatp_parts", // Lancia
|
||||||
|
|
||||||
// Fiat Commercial (fiatt_parts)
|
// Fiat Commercial (fiatt_parts)
|
||||||
ZFC: "fiatt_parts", // Fiat Commercial
|
ZFC: "fiatt_parts", // Fiat Commercial
|
||||||
|
|
||||||
// Hyundai
|
// Hyundai
|
||||||
KMH: "hyundai_parts", // Hyundai Korea Motor House
|
KMH: "hyundai_parts", // Hyundai Korea Motor House
|
||||||
// Aşağıdakiler 2026-09-20'de canlı WMI servisinden alındı; hepsi hyundai_parts.
|
|
||||||
// TMA iki marka döndürüyor (hyundai_parts + kia_parts) — Hyundai Assan (Türkiye)
|
|
||||||
// üretimi olduğu için hyundai seçildi.
|
|
||||||
NLH: "hyundai_parts", // Hyundai Assan / Türkiye (106 prod aracı)
|
|
||||||
TMA: "hyundai_parts", // Hyundai Türkiye (49)
|
|
||||||
NLJ: "hyundai_parts", // Hyundai (8)
|
|
||||||
KMF: "hyundai_parts", // Hyundai (7)
|
|
||||||
TMK: "hyundai_parts", // Hyundai (Turkey/other markets)
|
TMK: "hyundai_parts", // Hyundai (Turkey/other markets)
|
||||||
|
|
||||||
// Kia
|
// Kia
|
||||||
KNA: "kia_parts", // Kia (worldwide production)
|
KNA: "kia_parts", // Kia (worldwide production)
|
||||||
KNE: "kia_parts", // Kia (canlı WMI, 31 prod aracı)
|
|
||||||
KNC: "kia_parts", // Kia (canlı WMI, 8)
|
|
||||||
U5Y: "kia_parts", // Kia Slovakia
|
U5Y: "kia_parts", // Kia Slovakia
|
||||||
|
|
||||||
// Nissan
|
// Nissan
|
||||||
@@ -681,16 +563,9 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
JNK: "infiniti_parts", // Infiniti (Japan/Korea)
|
JNK: "infiniti_parts", // Infiniti (Japan/Korea)
|
||||||
|
|
||||||
// Opel / Vauxhall
|
// Opel / Vauxhall
|
||||||
// Subaru (canlı WMI decode: JF1 → subaru_parts, error:false)
|
|
||||||
JF1: "subaru_parts",
|
|
||||||
JF2: "subaru_parts",
|
|
||||||
// Jeep (Stellantis; /p5fiat backend)
|
|
||||||
"1C4": "jeep_parts",
|
|
||||||
"1J4": "jeep_parts",
|
|
||||||
|
|
||||||
W0L: "opel_parts", // Opel AG (Germany)
|
W0L: "opel_parts", // Opel AG (Germany)
|
||||||
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
|
||||||
VXK: "psa_opel_parts", // PSA-platform Opel (Corsa F, Mokka B) → Stellantis kataloğu /p5psa
|
VXK: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants)
|
||||||
|
|
||||||
// Citroën (PSA)
|
// Citroën (PSA)
|
||||||
VF7: "citroen_parts", // Citroën SA (France)
|
VF7: "citroen_parts", // Citroën SA (France)
|
||||||
@@ -699,19 +574,7 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
|||||||
// Peugeot (PSA)
|
// Peugeot (PSA)
|
||||||
VF3: "peugeot_parts", // Peugeot SA (France)
|
VF3: "peugeot_parts", // Peugeot SA (France)
|
||||||
VR3: "peugeot_parts", // Peugeot (newer WMI)
|
VR3: "peugeot_parts", // Peugeot (newer WMI)
|
||||||
// VR7: PL24'ün WMI veritabanında HİÇ YOK — canlı doğrulama (2026-09-19,
|
VR7: "peugeot_parts", // Peugeot (newer WMI)
|
||||||
// /pl24-wmi/ext/api/2.0/decode): HTTP 410 "no brands found for WMI = VR7",
|
|
||||||
// tıpkı Tofaş NM4 gibi. Önceden peugeot_parts'a yönlendiriliyordu; prod'daki
|
|
||||||
// 17 VR7 aracının hepsi model çözülmeden ("Peugeot Peugeot") ve 0 kategoriyle
|
|
||||||
// kaydedilmişti. Haritada tutmak yalnız boşuna PL24 isteği üretir ve
|
|
||||||
// pcat/emex/vinpin fallback'ini geciktirir.
|
|
||||||
|
|
||||||
// PL24'TE HİÇ OLMAYANLAR — canlı WMI servisi HTTP 410 "no brands found for WMI"
|
|
||||||
// dedi (2026-09-20), tıpkı NM4 ve VR7 gibi. Haritaya eklenmemeleri kasıtlı:
|
|
||||||
// eklemek yalnız boşuna istek üretir ve pcat/emex/vinpin fallback'ini geciktirir.
|
|
||||||
// JHM, SHH, SHS, MAK, NLA (Honda) · KL1 (Chevrolet)
|
|
||||||
// JMZ (Mazda) 410 DEĞİL ama fordp/fordt döndürüyor (Ford-Mazda platform ortaklığı
|
|
||||||
// dönemi); marka tutarsız olduğu için eklenmedi — bir Mazda 3 Ford kataloğunda yok.
|
|
||||||
|
|
||||||
// Volvo
|
// Volvo
|
||||||
YV1: "volvo_parts", // Volvo Cars (Sweden)
|
YV1: "volvo_parts", // Volvo Cars (Sweden)
|
||||||
@@ -931,13 +794,6 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
|
|||||||
vauxhall_parts: "Opel",
|
vauxhall_parts: "Opel",
|
||||||
// Volvo/Polestar
|
// Volvo/Polestar
|
||||||
volvo_parts: "Volvo",
|
volvo_parts: "Volvo",
|
||||||
subaru_parts: "Subaru",
|
|
||||||
psa_opel_parts: "Opel",
|
|
||||||
psa_vauxhall_parts: "Opel",
|
|
||||||
abarth_parts: "Abarth",
|
|
||||||
alfa_parts: "Alfa Romeo",
|
|
||||||
jeep_parts: "Jeep",
|
|
||||||
lancia_parts: "Lancia",
|
|
||||||
polestar_parts: "Polestar",
|
polestar_parts: "Polestar",
|
||||||
// Fiat Group
|
// Fiat Group
|
||||||
fiatp_parts: "Fiat",
|
fiatp_parts: "Fiat",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { proxyLogs } from "../../database/schema/core";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
|
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
|
||||||
* (pcat call/capture/validate, emex http, pl24 catalog/auth) into `proxy_logs`, so the Süper Panel
|
* (pcat call/capture/validate, emex http) into `proxy_logs`, so the Süper Panel
|
||||||
* can grade proxy providers per service and track banned exit IPs / sessions.
|
* can grade proxy providers per service and track banned exit IPs / sessions.
|
||||||
*
|
*
|
||||||
* Design constraints:
|
* Design constraints:
|
||||||
@@ -13,13 +13,7 @@ import { proxyLogs } from "../../database/schema/core";
|
|||||||
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
|
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ProxyServiceLeg =
|
export type ProxyServiceLeg = "pcat_call" | "pcat_capture" | "pcat_validate" | "emex_http";
|
||||||
| "pcat_call"
|
|
||||||
| "pcat_capture"
|
|
||||||
| "pcat_validate"
|
|
||||||
| "emex_http"
|
|
||||||
| "pl24_http"
|
|
||||||
| "pl24_auth";
|
|
||||||
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
|
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
|
||||||
|
|
||||||
export interface ProxyLogEvent {
|
export interface ProxyLogEvent {
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { VinpinDaemonService } from "./vinpin-daemon.service";
|
|
||||||
import type { VinpinDriverService } from "./vinpin-driver.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for the Vinpin warm-session daemon lifecycle: the business-hours
|
|
||||||
* scheduler (warm/teardown reconciliation with an injected clock), warm-on-demand
|
|
||||||
* decode routing, and the reentrancy guard. The driver is a fake — the real seat
|
|
||||||
* lives on prod and can't be driven from a test.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface FakeDriver {
|
|
||||||
isWarm: ReturnType<typeof vi.fn>;
|
|
||||||
warmUp: ReturnType<typeof vi.fn>;
|
|
||||||
teardownWarm: ReturnType<typeof vi.fn>;
|
|
||||||
decode: ReturnType<typeof vi.fn>;
|
|
||||||
keepalivePing: ReturnType<typeof vi.fn>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDriver(warm = false): FakeDriver {
|
|
||||||
return {
|
|
||||||
isWarm: vi.fn(() => warm),
|
|
||||||
warmUp: vi.fn(async () => true),
|
|
||||||
teardownWarm: vi.fn(async () => undefined),
|
|
||||||
decode: vi.fn(async () => null),
|
|
||||||
keepalivePing: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function daemon(driver: FakeDriver, opts: { hours: boolean; enabled?: boolean }) {
|
|
||||||
return new VinpinDaemonService({
|
|
||||||
driver: driver as unknown as VinpinDriverService,
|
|
||||||
isBusinessHours: () => opts.hours,
|
|
||||||
isEnabled: () => opts.enabled ?? true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("VinpinDaemonService — scheduler reconcile", () => {
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
|
||||||
|
|
||||||
it("warms up inside business hours when enabled and not already warm", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
await daemon(driver, { hours: true }).reconcile();
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
|
||||||
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT re-warm when already warm inside hours", async () => {
|
|
||||||
const driver = makeDriver(true);
|
|
||||||
await daemon(driver, { hours: true }).reconcile();
|
|
||||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
|
||||||
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tears down the warm seat outside business hours", async () => {
|
|
||||||
const driver = makeDriver(true);
|
|
||||||
await daemon(driver, { hours: false }).reconcile();
|
|
||||||
expect(driver.teardownWarm).toHaveBeenCalledTimes(1);
|
|
||||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tears down a warm seat when the daemon is disabled (kill-switch)", async () => {
|
|
||||||
const driver = makeDriver(true);
|
|
||||||
await daemon(driver, { hours: true, enabled: false }).reconcile();
|
|
||||||
expect(driver.teardownWarm).toHaveBeenCalledTimes(1);
|
|
||||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when disabled and already cold", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
await daemon(driver, { hours: true, enabled: false }).reconcile();
|
|
||||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
|
||||||
expect(driver.teardownWarm).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not stack overlapping reconciles (reentrancy guard)", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
let release!: () => void;
|
|
||||||
driver.warmUp.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
release = () => resolve(true);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const d = daemon(driver, { hours: true });
|
|
||||||
const first = d.reconcile();
|
|
||||||
const second = d.reconcile(); // should early-return while the first is in flight
|
|
||||||
release();
|
|
||||||
await Promise.all([first, second]);
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("VinpinDaemonService — decode routing", () => {
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
|
||||||
|
|
||||||
it("warms on-demand then delegates when inside hours and cold", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
driver.decode.mockResolvedValue({ brand: "Fiat", model: "EGEA" });
|
|
||||||
const result = await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
|
||||||
expect(driver.decode).toHaveBeenCalledWith("NM435600006H43436");
|
|
||||||
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT warm on-demand when already warm — just delegates", async () => {
|
|
||||||
const driver = makeDriver(true);
|
|
||||||
await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
|
||||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
|
||||||
expect(driver.decode).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("off-hours delegates straight to the cold path (never holds the seat)", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
await daemon(driver, { hours: false }).decode("NM435600006H43436");
|
|
||||||
expect(driver.warmUp).not.toHaveBeenCalled();
|
|
||||||
expect(driver.decode).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null (never throws) if the driver decode rejects", async () => {
|
|
||||||
const driver = makeDriver(true);
|
|
||||||
driver.decode.mockRejectedValue(new Error("boom"));
|
|
||||||
const result = await daemon(driver, { hours: true }).decode("NM435600006H43436");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("VinpinDaemonService — warm-up backoff", () => {
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
|
||||||
|
|
||||||
function backoffDaemon(driver: FakeDriver, clock: { t: number }) {
|
|
||||||
return new VinpinDaemonService({
|
|
||||||
driver: driver as unknown as VinpinDriverService,
|
|
||||||
isBusinessHours: () => true,
|
|
||||||
isEnabled: () => true,
|
|
||||||
now: () => clock.t,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("a failed warmUp sets a cooldown that reconcile() respects (no immediate re-warm)", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
driver.warmUp.mockResolvedValue(false); // warm-up keeps failing
|
|
||||||
const clock = { t: 0 };
|
|
||||||
const d = backoffDaemon(driver, clock);
|
|
||||||
|
|
||||||
await d.reconcile();
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(1); // first attempt ran
|
|
||||||
|
|
||||||
// Still cold + in hours, but inside the cooldown → no second attempt.
|
|
||||||
clock.t = 30_000;
|
|
||||||
await d.reconcile();
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// After the base cooldown (60s) elapses → it tries again.
|
|
||||||
clock.t = 61_000;
|
|
||||||
await d.reconcile();
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warm-on-demand decode ALSO respects the cooldown, but still delegates the cold decode", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
driver.warmUp.mockResolvedValue(false);
|
|
||||||
const clock = { t: 0 };
|
|
||||||
const d = backoffDaemon(driver, clock);
|
|
||||||
|
|
||||||
await d.reconcile(); // fails → cooldown until 60_000
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
clock.t = 20_000;
|
|
||||||
await d.decode("NM435600006H43436"); // in cooldown → no warm-on-demand
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(1);
|
|
||||||
expect(driver.decode).toHaveBeenCalledTimes(1); // still decodes via the cold path
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a successful warm resets the backoff (next attempt is not blocked)", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
driver.warmUp.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValue(false);
|
|
||||||
const clock = { t: 0 };
|
|
||||||
const d = backoffDaemon(driver, clock);
|
|
||||||
|
|
||||||
await d.reconcile(); // fail → cooldown until 60_000
|
|
||||||
clock.t = 61_000;
|
|
||||||
await d.reconcile(); // success → cooldown reset
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
// isWarm() is still false in the fake, so reconcile would warm again — and with
|
|
||||||
// the cooldown reset it may attempt immediately (no leftover backoff window).
|
|
||||||
clock.t = 61_500;
|
|
||||||
await d.reconcile();
|
|
||||||
expect(driver.warmUp).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("VinpinDaemonService — start/stop lifecycle", () => {
|
|
||||||
beforeEach(() => vi.useFakeTimers());
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconciles on start and drives keepalive on the interval; stop tears down", async () => {
|
|
||||||
const driver = makeDriver(false);
|
|
||||||
const d = daemon(driver, { hours: true });
|
|
||||||
d.start();
|
|
||||||
// Immediate reconcile → warm.
|
|
||||||
await vi.waitFor(() => expect(driver.warmUp).toHaveBeenCalledTimes(1));
|
|
||||||
// Advance past a keepalive interval → a ping fires.
|
|
||||||
await vi.advanceTimersByTimeAsync(80_000);
|
|
||||||
expect(driver.keepalivePing).toHaveBeenCalled();
|
|
||||||
await d.stop();
|
|
||||||
expect(driver.teardownWarm).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* Vinpin warm-session daemon (worker-process singleton).
|
|
||||||
*
|
|
||||||
* Owns the LIFECYCLE of the persistent warm Vinpin seat, on top of the browser
|
|
||||||
* mechanics in VinpinDriverService:
|
|
||||||
* - a scheduler that warms the seat at 08:00 and tears it down at 21:00
|
|
||||||
* Europe/Istanbul (and warms on worker start if already inside the window),
|
|
||||||
* - an idle keepalive that nudges the RDS session every ~75s so it never drops,
|
|
||||||
* - `decode(vin)` — the entry the vinpin-decode processor calls. Inside business
|
|
||||||
* hours it makes sure the seat is warm (warming on-demand once), then delegates
|
|
||||||
* to the driver, which self-routes to the HOT warm path when warm and the
|
|
||||||
* proven COLD per-decode path otherwise. Outside hours it never holds the seat
|
|
||||||
* — the driver's cold path handles the decode and is torn down after.
|
|
||||||
*
|
|
||||||
* ADDITIVE + fail-safe: every warm operation degrades to the cold path, and
|
|
||||||
* `decode()` never throws (the driver returns null on any failure). Gated by
|
|
||||||
* VINPIN_ENABLED (feature flag) and VINPIN_WARM_DAEMON (kill-switch: set to
|
|
||||||
* "false" to force the legacy per-decode cold path with the daemon inert).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Logger } from "@nestjs/common";
|
|
||||||
import { getVinpinDriver } from "./vinpin-driver.service";
|
|
||||||
import type { VinpinDecodeResult, VinpinDriverService } from "./vinpin-driver.service";
|
|
||||||
import { VINPIN_WARM, isVinpinBusinessHours } from "./vinpin.constants";
|
|
||||||
|
|
||||||
/** Whether the warm daemon is allowed to run (feature flag + kill-switch). */
|
|
||||||
export function isVinpinWarmDaemonEnabled(): boolean {
|
|
||||||
return process.env.VINPIN_ENABLED === "true" && process.env.VINPIN_WARM_DAEMON !== "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VinpinDaemonDeps {
|
|
||||||
/** Driver singleton (injectable for tests). Defaults to the process-wide one. */
|
|
||||||
driver?: VinpinDriverService;
|
|
||||||
/** Business-hours predicate (injectable so tests can drive the clock). */
|
|
||||||
isBusinessHours?: () => boolean;
|
|
||||||
/** Warm-daemon enabled predicate (injectable for tests). */
|
|
||||||
isEnabled?: () => boolean;
|
|
||||||
/** Monotonic-ish clock (injectable so tests can drive the warm-up backoff). */
|
|
||||||
now?: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class VinpinDaemonService {
|
|
||||||
private readonly logger = new Logger(VinpinDaemonService.name);
|
|
||||||
private readonly driver: VinpinDriverService;
|
|
||||||
private readonly isBusinessHours: () => boolean;
|
|
||||||
private readonly isEnabled: () => boolean;
|
|
||||||
private readonly now: () => number;
|
|
||||||
|
|
||||||
private schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private reconciling = false;
|
|
||||||
private started = false;
|
|
||||||
|
|
||||||
// ─── Warm-up backoff ─────────────────────────────────────
|
|
||||||
/** Wall-clock time until which a re-warm is suppressed after a failed warmUp.
|
|
||||||
* Both reconcile() and decode()'s warm-on-demand honour this so a failing seat
|
|
||||||
* isn't hammered every ~60s (each failed attempt would leave a fresh dirty
|
|
||||||
* window). A successful warm resets it. */
|
|
||||||
private warmCooldownUntil = 0;
|
|
||||||
/** Current backoff span (ms): 0 when healthy, else base…max, doubling per
|
|
||||||
* consecutive failure. */
|
|
||||||
private warmBackoffMs = 0;
|
|
||||||
|
|
||||||
constructor(deps: VinpinDaemonDeps = {}) {
|
|
||||||
this.driver = deps.driver ?? getVinpinDriver();
|
|
||||||
this.isBusinessHours = deps.isBusinessHours ?? (() => isVinpinBusinessHours());
|
|
||||||
this.isEnabled = deps.isEnabled ?? isVinpinWarmDaemonEnabled;
|
|
||||||
this.now = deps.now ?? (() => Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True while a failed warmUp's cooldown is still in effect. */
|
|
||||||
private inWarmCooldown(): boolean {
|
|
||||||
return this.now() < this.warmCooldownUntil;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a warm-up outcome and update the backoff. Success clears the cooldown;
|
|
||||||
* failure sets/extends it (base, then exponential up to max). Returns `ok` so
|
|
||||||
* callers can chain.
|
|
||||||
*/
|
|
||||||
private noteWarmResult(ok: boolean): boolean {
|
|
||||||
if (ok) {
|
|
||||||
this.warmBackoffMs = 0;
|
|
||||||
this.warmCooldownUntil = 0;
|
|
||||||
} else {
|
|
||||||
this.warmBackoffMs =
|
|
||||||
this.warmBackoffMs === 0
|
|
||||||
? VINPIN_WARM.warmBackoffBaseMs
|
|
||||||
: Math.min(this.warmBackoffMs * 2, VINPIN_WARM.warmBackoffMaxMs);
|
|
||||||
this.warmCooldownUntil = this.now() + this.warmBackoffMs;
|
|
||||||
this.logger.warn(
|
|
||||||
`warmUp failed — backing off ${Math.round(this.warmBackoffMs / 1000)}s before the next attempt`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start the scheduler + keepalive loops (idempotent). */
|
|
||||||
start(): void {
|
|
||||||
if (this.started) return;
|
|
||||||
this.started = true;
|
|
||||||
// Reconcile once now (warm immediately if the worker booted inside hours).
|
|
||||||
void this.reconcile();
|
|
||||||
this.schedulerTimer = setInterval(() => {
|
|
||||||
void this.reconcile();
|
|
||||||
}, VINPIN_WARM.schedulerIntervalMs);
|
|
||||||
this.keepaliveTimer = setInterval(() => {
|
|
||||||
void this.driver.keepalivePing();
|
|
||||||
}, VINPIN_WARM.keepaliveIntervalMs);
|
|
||||||
// Don't keep the event loop alive just for these timers.
|
|
||||||
this.schedulerTimer.unref?.();
|
|
||||||
this.keepaliveTimer.unref?.();
|
|
||||||
this.logger.log(
|
|
||||||
`Vinpin warm daemon started (enabled=${this.isEnabled()}, hours ${VINPIN_WARM.businessStartHour}:00–${VINPIN_WARM.businessEndHour}:00 Europe/Istanbul)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop the loops and tear the warm seat down (worker shutdown). */
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
if (this.schedulerTimer) clearInterval(this.schedulerTimer);
|
|
||||||
if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
|
|
||||||
this.schedulerTimer = null;
|
|
||||||
this.keepaliveTimer = null;
|
|
||||||
this.started = false;
|
|
||||||
await this.driver.teardownWarm().catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconcile the warm seat against the schedule: warm up when enabled + inside
|
|
||||||
* hours + not already warm; tear down when warm but disabled or outside hours.
|
|
||||||
* Guards against overlapping runs (a slow warmUp must not stack). Never throws.
|
|
||||||
*/
|
|
||||||
async reconcile(): Promise<void> {
|
|
||||||
if (this.reconciling) return;
|
|
||||||
this.reconciling = true;
|
|
||||||
try {
|
|
||||||
const enabled = this.isEnabled();
|
|
||||||
const inHours = this.isBusinessHours();
|
|
||||||
if (enabled && inHours && !this.driver.isWarm()) {
|
|
||||||
if (this.inWarmCooldown()) {
|
|
||||||
this.logger.debug("scheduler: in warm-up backoff — skipping this cycle");
|
|
||||||
} else {
|
|
||||||
this.logger.log("scheduler: inside business hours — warming the seat");
|
|
||||||
this.noteWarmResult(await this.driver.warmUp());
|
|
||||||
}
|
|
||||||
} else if (this.driver.isWarm() && (!enabled || !inHours)) {
|
|
||||||
this.logger.log("scheduler: outside business hours / disabled — tearing the seat down");
|
|
||||||
await this.driver.teardownWarm();
|
|
||||||
// Intentional teardown → clear any stale warm-up backoff so the next window
|
|
||||||
// (e.g. tomorrow 08:00) isn't blocked by a leftover cooldown.
|
|
||||||
this.warmBackoffMs = 0;
|
|
||||||
this.warmCooldownUntil = 0;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`reconcile failed: ${(err as Error).message}`);
|
|
||||||
} finally {
|
|
||||||
this.reconciling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode entry the processor calls. Inside hours, ensure the seat is warm (warm
|
|
||||||
* on-demand once), then delegate to the driver — which uses the hot warm path
|
|
||||||
* when warm, else the cold per-decode path. Off-hours, delegate straight to the
|
|
||||||
* driver's cold path (no seat held). Never throws — returns null on any failure.
|
|
||||||
*/
|
|
||||||
async decode(vin: string): Promise<VinpinDecodeResult | null> {
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
this.isEnabled() &&
|
|
||||||
this.isBusinessHours() &&
|
|
||||||
!this.driver.isWarm() &&
|
|
||||||
!this.inWarmCooldown()
|
|
||||||
) {
|
|
||||||
// Warm-on-demand: a decode arrived inside hours before the scheduler warmed
|
|
||||||
// (e.g. right after 08:00, or after a drop). Best-effort — if it fails the
|
|
||||||
// driver silently runs the cold path for this decode, and the backoff spaces
|
|
||||||
// out the next warm attempt (respected by both this check and reconcile()).
|
|
||||||
this.noteWarmResult(await this.driver.warmUp().catch(() => false));
|
|
||||||
}
|
|
||||||
return await this.driver.decode(vin);
|
|
||||||
} catch (err) {
|
|
||||||
// Defensive: driver.decode never throws, but guarantee the processor a null.
|
|
||||||
this.logger.warn(`decode(${vin}) unexpected error: ${(err as Error).message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Worker singleton ────────────────────────────────────────
|
|
||||||
let daemonSingleton: VinpinDaemonService | null = null;
|
|
||||||
export function getVinpinDaemon(): VinpinDaemonService {
|
|
||||||
if (!daemonSingleton) daemonSingleton = new VinpinDaemonService();
|
|
||||||
return daemonSingleton;
|
|
||||||
}
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renault-decode robustness tests (Rpartstore-down hardening). These exercise the
|
|
||||||
* fixes that stop a DOWN Rpartstore from thrashing the shared seat:
|
|
||||||
* (2) ensureRpartstore no longer short-circuits "already open" on the flyout /
|
|
||||||
* window-title word "Rpartstore" — it gates on a CONTENT token and clicks the
|
|
||||||
* flyout entry when the submenu is up over the grid;
|
|
||||||
* (3) closeRpartstoreTab never clicks the language-selector coord (1298,14);
|
|
||||||
* (6) an in-memory cooldown routes Renault decodes straight to Dialogys during a
|
|
||||||
* persistent Rpartstore outage, and a successful load clears it.
|
|
||||||
* OCR is mocked so the screen-state sequence is fully controllable — the real seat
|
|
||||||
* lives on prod and can't be driven from a test.
|
|
||||||
*/
|
|
||||||
vi.mock("./vinpin.ocr", () => ({
|
|
||||||
ocrRegion: vi.fn(async () => ""),
|
|
||||||
pollForText: vi.fn(async () => ({ matched: false, text: "" })),
|
|
||||||
terminateOcr: vi.fn(async () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { VinpinDriverService } from "./vinpin-driver.service";
|
|
||||||
import { VINPIN_COORDS } from "./vinpin.constants";
|
|
||||||
import { ocrRegion } from "./vinpin.ocr";
|
|
||||||
|
|
||||||
const mockOcr = vi.mocked(ocrRegion);
|
|
||||||
type AnyDriver = Record<string, unknown>;
|
|
||||||
|
|
||||||
function fakePage(sink?: (x: number, y: number) => void) {
|
|
||||||
return {
|
|
||||||
isClosed: () => false,
|
|
||||||
frames: () => [] as unknown[],
|
|
||||||
mouse: {
|
|
||||||
click: vi.fn(async (x: number, y: number) => sink?.(x, y)),
|
|
||||||
move: vi.fn(async () => undefined),
|
|
||||||
down: vi.fn(async () => undefined),
|
|
||||||
up: vi.fn(async () => undefined),
|
|
||||||
},
|
|
||||||
keyboard: { press: vi.fn(async () => undefined) },
|
|
||||||
waitForTimeout: vi.fn(async () => undefined),
|
|
||||||
locator: () => ({
|
|
||||||
first: () => ({
|
|
||||||
count: async () => 0,
|
|
||||||
isVisible: async () => false,
|
|
||||||
click: async () => undefined,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("VinpinDriverService — ensureRpartstore flyout false-positive (change 2)", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
mockOcr.mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
const ensure = (driver: VinpinDriverService, page: unknown) =>
|
|
||||||
((driver as unknown as AnyDriver).ensureRpartstore as (p: unknown) => Promise<boolean>).call(
|
|
||||||
driver,
|
|
||||||
page,
|
|
||||||
);
|
|
||||||
|
|
||||||
it("does NOT declare Rpartstore open on the FLYOUT — it clicks the Rpartstore entry (584,779)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const page = fakePage();
|
|
||||||
// Flyout is open OVER the grid: brand tiles visible + the Renault submenu items
|
|
||||||
// "Rpartstore"/"Dialogys" — matches renaultSubmenu but has NO search-home content.
|
|
||||||
mockOcr
|
|
||||||
.mockResolvedValueOnce("Volkswagen Mitsubishi TecDoc Renault Rpartstore Dialogys")
|
|
||||||
.mockResolvedValue("Şasi no ile arama Güncel araçlar"); // then the LOADED home
|
|
||||||
|
|
||||||
const ok = await ensure(driver, page);
|
|
||||||
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
// The flyout Rpartstore entry was clicked (old code short-circuited without it).
|
|
||||||
expect(page.mouse.click).toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.renaultRpartstore.x,
|
|
||||||
VINPIN_COORDS.renaultRpartstore.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("short-circuits (no flyout/tile click) when the LOADED search-home content is already on screen", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const page = fakePage();
|
|
||||||
mockOcr.mockResolvedValue("Şasi no ile arama Ne arıyorsunuz Güncel araçlar");
|
|
||||||
|
|
||||||
expect(await ensure(driver, page)).toBe(true);
|
|
||||||
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.renaultRpartstore.x,
|
|
||||||
VINPIN_COORDS.renaultRpartstore.y,
|
|
||||||
);
|
|
||||||
expect(page.mouse.move).not.toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.renaultBrand.x,
|
|
||||||
VINPIN_COORDS.renaultBrand.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats an open Rpartstore WINDOW (title only, no grid behind) as present without re-clicking the flyout", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const page = fakePage();
|
|
||||||
// Window title bar "Renault Rpartstore" matches renaultSubmenu too, but there's
|
|
||||||
// NO grid behind it → must be handled by the open-window branch, not the flyout.
|
|
||||||
mockOcr.mockResolvedValue("Renault Rpartstore");
|
|
||||||
|
|
||||||
expect(await ensure(driver, page)).toBe(true);
|
|
||||||
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.renaultRpartstore.x,
|
|
||||||
VINPIN_COORDS.renaultRpartstore.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bails on the FIRST iteration when the DOWN launch-error modal is over the grid (no repeated flyout clicks)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const page = fakePage();
|
|
||||||
// DOWN Rpartstore: the launch-error modal sits OVER the brand grid. The FULL-frame
|
|
||||||
// read (no clip) sees the grid tiles + the modal TITLE "Renault Rpartstore" → it
|
|
||||||
// matches brandGrid AND renaultSubmenu, which — without the fix — makes the flyout
|
|
||||||
// branch re-click renaultRpartstore(584,779) on all ~6 iterations. The UPSCALED
|
|
||||||
// modal crop (clip passed) reads the Cyrillic launch error, so the launch-error
|
|
||||||
// short-circuit must fire and return false on iteration 1.
|
|
||||||
mockOcr.mockImplementation(async (_page: unknown, clip?: unknown) =>
|
|
||||||
clip
|
|
||||||
? "Owwu6ka 3anycka KaTanora" // upscaled modal crop → matches rpartstoreLaunchError
|
|
||||||
: "Volkswagen Mitsubishi Renault Rpartstore Dialogys",
|
|
||||||
);
|
|
||||||
|
|
||||||
const ok = await ensure(driver, page);
|
|
||||||
|
|
||||||
expect(ok).toBe(false);
|
|
||||||
// Never re-clicked the flyout Rpartstore entry (would be up to 6× without the fix).
|
|
||||||
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.renaultRpartstore.x,
|
|
||||||
VINPIN_COORDS.renaultRpartstore.y,
|
|
||||||
);
|
|
||||||
// Exactly ONE full-frame OCR read (no clip) proves it bailed on the first iteration
|
|
||||||
// rather than looping the 6-try budget.
|
|
||||||
const fullFrameReads = mockOcr.mock.calls.filter(([, clip]) => clip === undefined);
|
|
||||||
expect(fullFrameReads).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still enters the flyout branch when the modal crop is ILLEGIBLE (no ffmpeg) — behaviour unchanged", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const page = fakePage();
|
|
||||||
// Same grid+flyout-title screen, but the crop OCR yields no launch-error text (the
|
|
||||||
// no-ffmpeg / illegible case). The launch-error short-circuit must NOT fire, so the
|
|
||||||
// proven flyout path still runs and clicks the Rpartstore entry.
|
|
||||||
mockOcr.mockImplementation(async (_page: unknown, clip?: unknown) =>
|
|
||||||
clip
|
|
||||||
? "" // illegible crop → rpartstoreLaunchErrorPresent === false
|
|
||||||
: "Volkswagen Mitsubishi Renault Rpartstore Dialogys",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bounded loop; we only assert the flyout entry was clicked (grid+submenu branch).
|
|
||||||
await ensure(driver, page);
|
|
||||||
|
|
||||||
expect(page.mouse.click).toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.renaultRpartstore.x,
|
|
||||||
VINPIN_COORDS.renaultRpartstore.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("VinpinDriverService — closeRpartstoreTab never hits the language selector (change 3)", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
mockOcr.mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
const close = (driver: VinpinDriverService, page: unknown) =>
|
|
||||||
((driver as unknown as AnyDriver).closeRpartstoreTab as (p: unknown) => Promise<void>).call(
|
|
||||||
driver,
|
|
||||||
page,
|
|
||||||
);
|
|
||||||
|
|
||||||
it("uses the tab-✕ (93,45) + Escape and NEVER clicks windowClose (1298,14) on a stuck spinner", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const page = fakePage();
|
|
||||||
// Chrome/spinner only (no search-home content) → the second close must NOT fire.
|
|
||||||
mockOcr.mockResolvedValue("Renault Rpartstore");
|
|
||||||
|
|
||||||
await close(driver, page);
|
|
||||||
|
|
||||||
expect(page.mouse.click).toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.catalogTabClose.x,
|
|
||||||
VINPIN_COORDS.catalogTabClose.y,
|
|
||||||
);
|
|
||||||
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.windowClose.x,
|
|
||||||
VINPIN_COORDS.windowClose.y,
|
|
||||||
);
|
|
||||||
expect(page.keyboard.press).toHaveBeenCalledWith("Escape");
|
|
||||||
// Exactly ONE close click (no content → no retry).
|
|
||||||
const closeClicks = page.mouse.click.mock.calls.filter(
|
|
||||||
([x, y]) => x === VINPIN_COORDS.catalogTabClose.x && y === VINPIN_COORDS.catalogTabClose.y,
|
|
||||||
);
|
|
||||||
expect(closeClicks).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries the tab-✕ (NOT windowClose) when a LOADED home is still up after the first close", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const page = fakePage();
|
|
||||||
mockOcr.mockResolvedValue("Şasi no ile arama Güncel araçlar"); // loaded home persists
|
|
||||||
|
|
||||||
await close(driver, page);
|
|
||||||
|
|
||||||
const closeClicks = page.mouse.click.mock.calls.filter(
|
|
||||||
([x, y]) => x === VINPIN_COORDS.catalogTabClose.x && y === VINPIN_COORDS.catalogTabClose.y,
|
|
||||||
);
|
|
||||||
expect(closeClicks).toHaveLength(2); // first + one content-gated retry
|
|
||||||
expect(page.mouse.click).not.toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.windowClose.x,
|
|
||||||
VINPIN_COORDS.windowClose.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("VinpinDriverService — Rpartstore-down cooldown routes Renault → Dialogys (change 6)", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
process.env.VINPIN_USER = "user";
|
|
||||||
process.env.VINPIN_PASS = "pass";
|
|
||||||
mockOcr.mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
const inCooldown = (driver: VinpinDriverService) =>
|
|
||||||
((driver as unknown as AnyDriver).rpartstoreInCooldown as () => boolean).call(driver);
|
|
||||||
|
|
||||||
const acquire = (driver: VinpinDriverService, page: unknown, warm: boolean) =>
|
|
||||||
(
|
|
||||||
(driver as unknown as AnyDriver).acquireLoadedRpartstore as (
|
|
||||||
p: unknown,
|
|
||||||
c: unknown,
|
|
||||||
d: number,
|
|
||||||
w: boolean,
|
|
||||||
) => Promise<boolean>
|
|
||||||
).call(driver, page, {}, FAR_DEADLINE(), warm);
|
|
||||||
|
|
||||||
it("while in cooldown, warm Renault decode SKIPS Rpartstore entirely and uses Dialogys", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
any.rpartstoreCooldownUntil = Date.now() + 60_000; // cooldown active
|
|
||||||
const acquireSpy = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
|
||||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
|
||||||
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
|
|
||||||
status: "found",
|
|
||||||
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
|
||||||
rawText: "Megane",
|
|
||||||
via: "dialogys",
|
|
||||||
} as never);
|
|
||||||
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
|
||||||
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(acquireSpy).not.toHaveBeenCalled(); // Rpartstore never opened during the outage
|
|
||||||
expect(runRpartstore).not.toHaveBeenCalled();
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
brand: "RENAULT",
|
|
||||||
model: "MEGANE",
|
|
||||||
raw: { source: "vinpin-dialogys" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a hard launch-error SETS the cooldown", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
|
||||||
loaded: false,
|
|
||||||
launchError: true,
|
|
||||||
text: "Owwu6ka 3anycka KaTanora",
|
|
||||||
} as never);
|
|
||||||
const page = fakePage();
|
|
||||||
|
|
||||||
expect(inCooldown(driver)).toBe(false);
|
|
||||||
expect(await acquire(driver, page, true)).toBe(false);
|
|
||||||
expect(inCooldown(driver)).toBe(true); // → next Renault VIN routes straight to Dialogys
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a confirmed Rpartstore load CLEARS an active cooldown", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
any.rpartstoreCooldownUntil = Date.now() + 60_000; // pretend it was marked down
|
|
||||||
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
|
||||||
loaded: true,
|
|
||||||
launchError: false,
|
|
||||||
text: "Şasi no ile arama",
|
|
||||||
} as never);
|
|
||||||
const page = fakePage();
|
|
||||||
|
|
||||||
expect(inCooldown(driver)).toBe(true);
|
|
||||||
expect(await acquire(driver, page, true)).toBe(true);
|
|
||||||
expect(inCooldown(driver)).toBe(false); // healthy again → Rpartstore resumes as primary
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a transient born-stuck-spinner streak (maxOpens exhausted) does NOT set the cooldown", async () => {
|
|
||||||
// A born-stuck spinner is a TRANSIENT reopen-recoverable blip, not a server
|
|
||||||
// outage — exhausting maxOpens must fall back to Dialogys for THIS vin only and
|
|
||||||
// leave Rpartstore healthy for the next one, NOT suppress it for the full
|
|
||||||
// cooldown (which can't self-clear while it's being skipped).
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "closeRpartstoreTab").mockResolvedValue(undefined as never);
|
|
||||||
// Every poll = still spinning (never loaded, no launch error) → born-stuck path.
|
|
||||||
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
|
||||||
loaded: false,
|
|
||||||
launchError: false,
|
|
||||||
text: "",
|
|
||||||
} as never);
|
|
||||||
const page = fakePage();
|
|
||||||
|
|
||||||
expect(inCooldown(driver)).toBe(false);
|
|
||||||
expect(await acquire(driver, page, true)).toBe(false); // gave up → Dialogys
|
|
||||||
expect(inCooldown(driver)).toBe(false); // but NOT cooled down → next VIN retries Rpartstore
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ambiguous-retry cheap in-session reset (the dirty-resume relaunch-thrash fix).
|
|
||||||
* An AMBIGUOUS Renault outcome is transient/state-dependent, so the retry must NOT
|
|
||||||
* tear the browser down (a full teardown forces a ~60–90s relaunch + re-login that
|
|
||||||
* re-hits the seat's "catalog window resumed open" dirty-resume every attempt →
|
|
||||||
* 3 attempts blow the 180s budget → not_found). Instead it returns to a clean
|
|
||||||
* VinPower brand grid on the SAME live session via ensureBrandGrid (which runs the
|
|
||||||
* closeStrayRunningApps DOM recovery first), and re-runs runRenaultFlow — no
|
|
||||||
* close()/relaunch. GRADUATED safety: only when that cheap reset can't reach a clean
|
|
||||||
* grid does it fall back to close() + cold re-establish. OCR is mocked so the grid
|
|
||||||
* confirmation is controllable.
|
|
||||||
*/
|
|
||||||
describe("VinpinDriverService — ambiguous Renault retry is a CHEAP in-session grid reset (no relaunch)", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
|
||||||
const RENAULT_VIN = "VF1RFE00653633190"; // the live-trace VIN that thrashed on relaunch
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
process.env.VINPIN_USER = "user";
|
|
||||||
process.env.VINPIN_PASS = "pass";
|
|
||||||
mockOcr.mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Present the driver as a live, authenticated session so the cheap reset's
|
|
||||||
// precheck (browser connected + context + open page + authed) passes and it never
|
|
||||||
// short-circuits to a cold re-establish.
|
|
||||||
function liveSession(driver: VinpinDriverService): AnyDriver {
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
any.browser = { isConnected: () => true };
|
|
||||||
any.context = {};
|
|
||||||
any.page = fakePage();
|
|
||||||
any.authed = true;
|
|
||||||
return any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodeRenault = (driver: VinpinDriverService, vin: string) =>
|
|
||||||
(
|
|
||||||
(driver as unknown as AnyDriver).decodeRenaultLocked as (
|
|
||||||
v: string,
|
|
||||||
c: unknown,
|
|
||||||
d: number,
|
|
||||||
) => Promise<unknown>
|
|
||||||
).call(driver, vin, { maxAttempts: 3, budgetMs: 5 * 60_000 }, FAR_DEADLINE());
|
|
||||||
|
|
||||||
it("first ambiguous retry returns to the grid IN-SESSION (ensureBrandGrid) and re-runs runRenaultFlow WITHOUT close()/relaunch", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = liveSession(driver);
|
|
||||||
|
|
||||||
// ensureAuthenticated is a no-op (session already up → NO relaunch, NO web login).
|
|
||||||
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
|
|
||||||
// The cheap reset reaches a clean grid: ensureBrandGrid runs on the SAME session
|
|
||||||
// and the OCR read confirms the brand grid.
|
|
||||||
const ensureGrid = vi
|
|
||||||
.spyOn(any as never, "ensureBrandGrid")
|
|
||||||
.mockResolvedValue(undefined as never);
|
|
||||||
mockOcr.mockResolvedValue("Volkswagen Mitsubishi TecDoc"); // matches VINPIN_OCR.brandGrid
|
|
||||||
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
|
||||||
// attempt 1 → ambiguous (transient); attempt 2, same live session → found.
|
|
||||||
const runFlow = vi
|
|
||||||
.spyOn(any as never, "runRenaultFlow")
|
|
||||||
.mockResolvedValueOnce({ status: "ambiguous" } as never)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
status: "found",
|
|
||||||
parsed: { brand: "RENAULT", model: "KADJAR", modelYear: null },
|
|
||||||
rawText: "RENAULT Kadjar",
|
|
||||||
via: "dialogys",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await decodeRenault(driver, RENAULT_VIN);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ brand: "RENAULT", model: "KADJAR" });
|
|
||||||
expect(runFlow).toHaveBeenCalledTimes(2); // ambiguous, then a cheap in-session re-run
|
|
||||||
expect(ensureGrid).toHaveBeenCalled(); // the in-session grid reset ran (closeStrayRunningApps path)
|
|
||||||
expect(close).not.toHaveBeenCalled(); // NO teardown/relaunch on the cheap first retry
|
|
||||||
});
|
|
||||||
|
|
||||||
it("GRADUATED fallback: when the cheap grid reset can't confirm a clean grid, it close()s + cold re-establishes", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = liveSession(driver);
|
|
||||||
|
|
||||||
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
|
|
||||||
// ensureBrandGrid runs but the grid is NEVER confirmed (OCR reads no grid) → the
|
|
||||||
// cheap reset returns false → the graduated hard reset (close) must fire.
|
|
||||||
const ensureGrid = vi
|
|
||||||
.spyOn(any as never, "ensureBrandGrid")
|
|
||||||
.mockResolvedValue(undefined as never);
|
|
||||||
mockOcr.mockResolvedValue(""); // no brand grid → cheap reset fails
|
|
||||||
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
|
||||||
const runFlow = vi
|
|
||||||
.spyOn(any as never, "runRenaultFlow")
|
|
||||||
.mockResolvedValueOnce({ status: "ambiguous" } as never)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
status: "found",
|
|
||||||
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
|
||||||
rawText: "Megane",
|
|
||||||
via: "dialogys",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await decodeRenault(driver, RENAULT_VIN);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ brand: "RENAULT", model: "MEGANE" });
|
|
||||||
expect(ensureGrid).toHaveBeenCalled(); // the CHEAP reset was attempted first…
|
|
||||||
expect(close).toHaveBeenCalledTimes(1); // …then the graduated hard reset fired exactly once
|
|
||||||
expect(runFlow).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT cheap-reset when the session is already gone — falls straight to close() (graduated)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = liveSession(driver);
|
|
||||||
// Session looks broken: the page is closed → the cheap reset's precheck fails and
|
|
||||||
// it returns false without touching ensureBrandGrid, so the hard reset runs.
|
|
||||||
(any.page as { isClosed: () => boolean }).isClosed = () => true;
|
|
||||||
|
|
||||||
vi.spyOn(any as never, "ensureAuthenticated").mockResolvedValue(undefined as never);
|
|
||||||
const ensureGrid = vi
|
|
||||||
.spyOn(any as never, "ensureBrandGrid")
|
|
||||||
.mockResolvedValue(undefined as never);
|
|
||||||
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
|
||||||
vi.spyOn(any as never, "runRenaultFlow")
|
|
||||||
.mockResolvedValueOnce({ status: "ambiguous" } as never)
|
|
||||||
.mockResolvedValueOnce({ status: "not_found" } as never);
|
|
||||||
|
|
||||||
const result = await decodeRenault(driver, RENAULT_VIN);
|
|
||||||
|
|
||||||
expect(result).toBeNull(); // 2nd attempt not_found → null
|
|
||||||
expect(ensureGrid).not.toHaveBeenCalled(); // broken session → no in-session grid work
|
|
||||||
expect(close).toHaveBeenCalledTimes(1); // graduated hard reset on the broken session
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* runRenaultFlow definitive-not_found gate (the budget-burn + seat-poison fix).
|
|
||||||
* When Rpartstore is UNAVAILABLE (down-cooldown, or it never loaded so `primary`
|
|
||||||
* is only the placeholder ambiguous), a CLEAN Dialogys not_found is DEFINITIVE on
|
|
||||||
* its own — the old gate required BOTH catalogs to say not_found, so every
|
|
||||||
* undecodable Renault while Rpartstore was down got downgraded to ambiguous →
|
|
||||||
* retry loop → 180s budget → sessionPoisoned. A genuinely-ambiguous Dialogys
|
|
||||||
* (couldn't be reached) still returns ambiguous so the transient-failure retry
|
|
||||||
* survives. When Rpartstore actually RAN, the both-must-agree gate is preserved.
|
|
||||||
*/
|
|
||||||
describe("VinpinDriverService — runRenaultFlow definitive not_found when Rpartstore unavailable", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
|
||||||
const VIN = "VF1553K05TR596166"; // old (~1996) Renault 19 — the live budget-burn case
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
mockOcr.mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
const runFlow = (driver: VinpinDriverService, page: unknown) =>
|
|
||||||
(
|
|
||||||
(driver as unknown as AnyDriver).runRenaultFlow as (
|
|
||||||
p: unknown,
|
|
||||||
v: string,
|
|
||||||
c: unknown,
|
|
||||||
d: number,
|
|
||||||
) => Promise<{ status: string }>
|
|
||||||
).call(driver, page, VIN, { rpartstoreEnabled: true }, FAR_DEADLINE());
|
|
||||||
|
|
||||||
it("cooldown (Rpartstore down) + Dialogys not_found → DEFINITIVE not_found, no Rpartstore, no retry", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
any.rpartstoreCooldownUntil = Date.now() + 60_000; // Rpartstore in down-cooldown
|
|
||||||
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
|
||||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
|
||||||
const runDialogys = vi
|
|
||||||
.spyOn(any as never, "runDialogys")
|
|
||||||
.mockResolvedValue({ status: "not_found" } as never);
|
|
||||||
|
|
||||||
const outcome = await runFlow(driver, fakePage());
|
|
||||||
|
|
||||||
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
|
|
||||||
expect(acquire).not.toHaveBeenCalled(); // Rpartstore skipped during the outage
|
|
||||||
expect(runRpartstore).not.toHaveBeenCalled();
|
|
||||||
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Rpartstore never loaded (not cooldown) + Dialogys not_found → DEFINITIVE not_found", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
// Not in cooldown, but the acquire fails to load Rpartstore → primary never ran.
|
|
||||||
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(false as never);
|
|
||||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
|
||||||
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
|
||||||
|
|
||||||
const outcome = await runFlow(driver, fakePage());
|
|
||||||
|
|
||||||
expect(outcome.status).toBe("not_found");
|
|
||||||
expect(runRpartstore).not.toHaveBeenCalled(); // acquire failed → primary placeholder only
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Rpartstore unavailable + Dialogys AMBIGUOUS (unreachable) → still ambiguous (transient retry survives)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
any.rpartstoreCooldownUntil = Date.now() + 60_000; // down-cooldown
|
|
||||||
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "ambiguous" } as never);
|
|
||||||
|
|
||||||
const outcome = await runFlow(driver, fakePage());
|
|
||||||
|
|
||||||
expect(outcome.status).toBe("ambiguous"); // couldn't confirm not_found → caller retries
|
|
||||||
});
|
|
||||||
|
|
||||||
it("REGRESSION: Rpartstore RAN + ambiguous, Dialogys not_found → still ambiguous (both-must-agree preserved)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
// Rpartstore is UP: it loads and runs but is unsure (ambiguous). Dialogys then
|
|
||||||
// cleanly misses. Because Rpartstore actually ran, we must NOT treat this as a
|
|
||||||
// definitive not_found — the pre-fix both-must-agree behaviour is preserved.
|
|
||||||
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({ status: "ambiguous" } as never);
|
|
||||||
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
|
||||||
|
|
||||||
const outcome = await runFlow(driver, fakePage());
|
|
||||||
|
|
||||||
expect(outcome.status).toBe("ambiguous");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("REGRESSION: Rpartstore RAN + not_found, Dialogys not_found → definitive not_found (unchanged)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({ status: "not_found" } as never);
|
|
||||||
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
|
||||||
|
|
||||||
const outcome = await runFlow(driver, fakePage());
|
|
||||||
|
|
||||||
expect(outcome.status).toBe("not_found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VINPIN_RPARTSTORE_ENABLED=false kill-switch (KNOWN Rpartstore outage). Unlike the
|
|
||||||
* in-memory down-cooldown (which resets on every worker restart, so the first decode
|
|
||||||
* after each restart pays the full Rpartstore launch cost + leaves a stuck app), this
|
|
||||||
* config flag persists: when set, BOTH Renault paths (warm + cold) skip opening
|
|
||||||
* Rpartstore entirely and go straight to Dialogys, and a clean Dialogys not_found is
|
|
||||||
* DEFINITIVE (a flag-disabled Rpartstore is "unavailable" exactly like the cooldown).
|
|
||||||
* Default (unset / not "false") is TRUE — behaviour completely unchanged.
|
|
||||||
*/
|
|
||||||
describe("VinpinDriverService — VINPIN_RPARTSTORE_ENABLED=false skips Rpartstore in both Renault paths", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
mockOcr.mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("COLD runRenaultFlow (flag=false): never opens Rpartstore, goes straight to Dialogys, and a Dialogys not_found is DEFINITIVE (no retry)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
// NOT in cooldown — the ONLY reason to skip is the disabled flag.
|
|
||||||
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
|
||||||
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
|
|
||||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
|
||||||
const runDialogys = vi
|
|
||||||
.spyOn(any as never, "runDialogys")
|
|
||||||
.mockResolvedValue({ status: "not_found" } as never);
|
|
||||||
|
|
||||||
const outcome = await (
|
|
||||||
(any as AnyDriver).runRenaultFlow as (
|
|
||||||
p: unknown,
|
|
||||||
v: string,
|
|
||||||
c: unknown,
|
|
||||||
d: number,
|
|
||||||
) => Promise<{ status: string }>
|
|
||||||
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: false }, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
|
|
||||||
expect(ensureR).not.toHaveBeenCalled();
|
|
||||||
expect(runRpartstore).not.toHaveBeenCalled();
|
|
||||||
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
|
|
||||||
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
|
|
||||||
});
|
|
||||||
|
|
||||||
it("WARM warmRenaultDecode (flag=false): never opens Rpartstore, decodes via Dialogys", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
|
||||||
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
|
|
||||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
|
||||||
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
|
|
||||||
status: "found",
|
|
||||||
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
|
||||||
rawText: "Megane",
|
|
||||||
via: "dialogys",
|
|
||||||
} as never);
|
|
||||||
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
|
||||||
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: false }, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
|
|
||||||
expect(ensureR).not.toHaveBeenCalled();
|
|
||||||
expect(runRpartstore).not.toHaveBeenCalled();
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
brand: "RENAULT",
|
|
||||||
model: "MEGANE",
|
|
||||||
raw: { source: "vinpin-dialogys" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DEFAULT (flag=true): COLD path still ATTEMPTS Rpartstore (behaviour unchanged)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const acquire = vi
|
|
||||||
.spyOn(any as never, "acquireLoadedRpartstore")
|
|
||||||
.mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
|
|
||||||
status: "found",
|
|
||||||
parsed: { brand: "RENAULT", model: "CLIO", modelYear: null },
|
|
||||||
rawText: "Clio",
|
|
||||||
via: "rpartstore",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const outcome = await (
|
|
||||||
(any as AnyDriver).runRenaultFlow as (
|
|
||||||
p: unknown,
|
|
||||||
v: string,
|
|
||||||
c: unknown,
|
|
||||||
d: number,
|
|
||||||
) => Promise<{ status: string }>
|
|
||||||
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(acquire).toHaveBeenCalledTimes(1); // Rpartstore still primary by default
|
|
||||||
expect(outcome.status).toBe("found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,707 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { VinpinDriverService, VinpinSessionDroppedError } from "./vinpin-driver.service";
|
|
||||||
import { VINPIN_COORDS, VINPIN_OCR, VINPIN_WINDOW_FOREGROUND } from "./vinpin.constants";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Livelock-breaker unit tests for the Vinpin decode driver. These exercise the
|
|
||||||
* failure/abort machinery (wall-clock budget, sessionPoisoned cold re-establish,
|
|
||||||
* VIN sanity gate) in isolation by stubbing the browser-driving privates — the
|
|
||||||
* shared Vinpin seat lives on prod and can't be driven from a test.
|
|
||||||
*/
|
|
||||||
describe("VinpinDriverService — livelock breakers", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const VALID_FIAT_VIN = "NM435600006H43436"; // 17 alphanumerics → fiat flow
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// afterEach restores the full env snapshot, so VINPIN_DECODE_BUDGET_MS set by
|
|
||||||
// the budget test never leaks into the others.
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
process.env.VINPIN_USER = "user";
|
|
||||||
process.env.VINPIN_PASS = "pass";
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("aborts a single decode that blows past the wall-clock budget, tears down and poisons the seat", async () => {
|
|
||||||
process.env.VINPIN_DECODE_BUDGET_MS = "50";
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const anyDriver = driver as unknown as Record<string, unknown>;
|
|
||||||
|
|
||||||
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
|
|
||||||
// ensureReady succeeds cheaply, then runVinFlow hangs forever → the budget
|
|
||||||
// timer must win the race.
|
|
||||||
vi.spyOn(anyDriver as never, "ensureReady").mockImplementation((async () => {
|
|
||||||
anyDriver.page = { isClosed: () => false };
|
|
||||||
}) as never);
|
|
||||||
vi.spyOn(anyDriver as never, "runVinFlow").mockReturnValue(
|
|
||||||
new Promise(() => {}) as never, // never resolves
|
|
||||||
);
|
|
||||||
|
|
||||||
const started = Date.now();
|
|
||||||
const result = await driver.decode(VALID_FIAT_VIN);
|
|
||||||
const elapsed = Date.now() - started;
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(closeSpy).toHaveBeenCalled(); // torn down on abort
|
|
||||||
expect(anyDriver.sessionPoisoned).toBe(true); // seat marked poisoned
|
|
||||||
// Aborted near the budget, not after minutes / all 3 internal attempts.
|
|
||||||
expect(elapsed).toBeLessThan(2_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forces a full cold re-establish (close) when the seat was poisoned by a prior decode", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const anyDriver = driver as unknown as Record<string, unknown>;
|
|
||||||
anyDriver.sessionPoisoned = true;
|
|
||||||
|
|
||||||
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
|
|
||||||
const fiatSpy = vi
|
|
||||||
.spyOn(anyDriver as never, "decodeFiatLocked")
|
|
||||||
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
|
||||||
|
|
||||||
const result = await driver.decode(VALID_FIAT_VIN);
|
|
||||||
|
|
||||||
expect(closeSpy).toHaveBeenCalledTimes(1); // the poison-forced teardown
|
|
||||||
expect(anyDriver.sessionPoisoned).toBe(false); // flag consumed
|
|
||||||
expect(fiatSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT tear down a healthy warm session when the seat is not poisoned", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const anyDriver = driver as unknown as Record<string, unknown>;
|
|
||||||
anyDriver.sessionPoisoned = false;
|
|
||||||
|
|
||||||
const closeSpy = vi.spyOn(anyDriver as never, "close").mockResolvedValue(undefined as never);
|
|
||||||
vi.spyOn(anyDriver as never, "decodeFiatLocked").mockResolvedValue({
|
|
||||||
brand: "Fiat",
|
|
||||||
model: "EGEA",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const result = await driver.decode(VALID_FIAT_VIN);
|
|
||||||
|
|
||||||
expect(closeSpy).not.toHaveBeenCalled(); // healthy path untouched
|
|
||||||
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips obviously-junk input before it ever touches the shared seat", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const anyDriver = driver as unknown as Record<string, unknown>;
|
|
||||||
const fiatSpy = vi.spyOn(anyDriver as never, "decodeFiatLocked");
|
|
||||||
|
|
||||||
expect(await driver.decode("SHORT")).toBeNull();
|
|
||||||
expect(await driver.decode("VF1RFE0065363319")).toBeNull(); // 16 chars
|
|
||||||
expect(await driver.decode("NM4356 0006H43436")).toBeNull(); // space (non-alnum)
|
|
||||||
expect(fiatSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still runs a well-formed VIN through the decode loop (sanity gate is not over-filtering)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const anyDriver = driver as unknown as Record<string, unknown>;
|
|
||||||
const fiatSpy = vi
|
|
||||||
.spyOn(anyDriver as never, "decodeFiatLocked")
|
|
||||||
.mockResolvedValue(null as never);
|
|
||||||
|
|
||||||
expect(await driver.decode(VALID_FIAT_VIN)).toBeNull();
|
|
||||||
expect(fiatSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Warm-session daemon behaviours on the driver: warm-vs-cold routing, the
|
|
||||||
* keepalive that must SKIP while a decode holds the seat, and health-recovery
|
|
||||||
* (session-drop → teardown + re-warm + single retry). The browser-driving privates
|
|
||||||
* are stubbed — the real seat lives on prod.
|
|
||||||
*/
|
|
||||||
describe("VinpinDriverService — warm session", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const VALID_FIAT_VIN = "NM435600006H43436";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
process.env.VINPIN_USER = "user";
|
|
||||||
process.env.VINPIN_PASS = "pass";
|
|
||||||
process.env.VINPIN_WARM_DAEMON = "true";
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeWarm(driver: VinpinDriverService) {
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
any.warm = true;
|
|
||||||
any.browser = { isConnected: () => true };
|
|
||||||
any.page = { isClosed: () => false, mouse: { move: vi.fn(async () => undefined) } };
|
|
||||||
return any;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("routes a decode to the WARM path when a healthy warm session is up", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = makeWarm(driver);
|
|
||||||
const warmSpy = vi
|
|
||||||
.spyOn(any as never, "warmDecodeWithRecovery")
|
|
||||||
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
|
||||||
const coldSpy = vi.spyOn(any as never, "decodeFiatLocked");
|
|
||||||
|
|
||||||
const result = await driver.decode(VALID_FIAT_VIN);
|
|
||||||
|
|
||||||
expect(warmSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(coldSpy).not.toHaveBeenCalled(); // cold path bypassed while warm
|
|
||||||
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the COLD path when VINPIN_WARM_DAEMON=false even if warm is set", async () => {
|
|
||||||
process.env.VINPIN_WARM_DAEMON = "false";
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = makeWarm(driver);
|
|
||||||
const warmSpy = vi.spyOn(any as never, "warmDecodeWithRecovery");
|
|
||||||
const coldSpy = vi
|
|
||||||
.spyOn(any as never, "decodeFiatLocked")
|
|
||||||
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
|
||||||
|
|
||||||
await driver.decode(VALID_FIAT_VIN);
|
|
||||||
|
|
||||||
expect(warmSpy).not.toHaveBeenCalled();
|
|
||||||
expect(coldSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keepalive SKIPS while a decode holds the seat (busy), and nudges when idle", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = makeWarm(driver);
|
|
||||||
const move = (any.page as { mouse: { move: ReturnType<typeof vi.fn> } }).mouse.move;
|
|
||||||
|
|
||||||
// Busy (a decode is in flight) → no nudge, no lock contention.
|
|
||||||
any.busy = true;
|
|
||||||
await driver.keepalivePing();
|
|
||||||
expect(move).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Idle → the keepalive nudges the harmless in-window point.
|
|
||||||
any.busy = false;
|
|
||||||
await driver.keepalivePing();
|
|
||||||
expect(move).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("on a dropped warm session, tears down + re-warms ONCE, then retries the decode once", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = makeWarm(driver);
|
|
||||||
|
|
||||||
const warmDecode = vi
|
|
||||||
.spyOn(any as never, "warmDecode")
|
|
||||||
.mockRejectedValueOnce(new VinpinSessionDroppedError("dropped") as never)
|
|
||||||
.mockResolvedValueOnce({ brand: "Fiat", model: "EGEA" } as never);
|
|
||||||
const rewarm = vi.spyOn(any as never, "_warmUp").mockResolvedValue(true as never);
|
|
||||||
|
|
||||||
const result = await driver.decode(VALID_FIAT_VIN);
|
|
||||||
|
|
||||||
expect(warmDecode).toHaveBeenCalledTimes(2); // initial + one retry
|
|
||||||
expect(rewarm).toHaveBeenCalledTimes(1); // re-warmed exactly once
|
|
||||||
expect(result).toEqual({ brand: "Fiat", model: "EGEA" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("session-drop recovery gives up (null) when the re-warm fails — never throws", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = makeWarm(driver);
|
|
||||||
|
|
||||||
vi.spyOn(any as never, "warmDecode").mockRejectedValue(
|
|
||||||
new VinpinSessionDroppedError("dropped") as never,
|
|
||||||
);
|
|
||||||
vi.spyOn(any as never, "_warmUp").mockResolvedValue(false as never);
|
|
||||||
|
|
||||||
const result = await driver.decode(VALID_FIAT_VIN);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(any.warm).toBe(false); // dropped seat marked not-warm → cold path next time
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rpartstore acquire-with-spinner-guard. A freshly-opened Rpartstore instance
|
|
||||||
* sometimes gets "born stuck" on an infinite spinner; the fix is to close the
|
|
||||||
* stuck instance and reopen a FRESH one, up to N times, inside the wall-clock
|
|
||||||
* budget, and only then fall back to Dialogys. These stub the browser-driving
|
|
||||||
* privates (real seat lives on prod) and drive the OCR-load verdict via a mocked
|
|
||||||
* pollRpartstoreState, which reads LOADED off the full frame and the HARD
|
|
||||||
* launch-error modal off an UPSCALED crop (spinner = {loaded:false,launchError:
|
|
||||||
* false}, loaded = {loaded:true}, DOWN = {launchError:true}).
|
|
||||||
*/
|
|
||||||
describe("VinpinDriverService — Rpartstore spinner guard", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
process.env.VINPIN_USER = "user";
|
|
||||||
process.env.VINPIN_PASS = "pass";
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
function acquire(driver: VinpinDriverService, warm: boolean, deadline: number): Promise<boolean> {
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
any.acquireLoadedRpartstore as (
|
|
||||||
page: unknown,
|
|
||||||
cfg: unknown,
|
|
||||||
deadline: number,
|
|
||||||
warm: boolean,
|
|
||||||
) => Promise<boolean>
|
|
||||||
).call(driver, {}, {}, deadline, warm);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("reuses Rpartstore when the FIRST open already loaded (no reopen, no close)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
|
|
||||||
const close = vi.spyOn(any as never, "closeRpartstoreTab");
|
|
||||||
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
|
||||||
loaded: true,
|
|
||||||
launchError: false,
|
|
||||||
text: "Şasi no ile arama",
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
|
|
||||||
expect(reopen).not.toHaveBeenCalled();
|
|
||||||
expect(close).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes a born-stuck spinner and reopens a FRESH instance that loads (warm)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
|
|
||||||
const close = vi
|
|
||||||
.spyOn(any as never, "closeRpartstoreTab")
|
|
||||||
.mockResolvedValue(undefined as never);
|
|
||||||
// open 1 → spinner (not loaded); open 2 (fresh) → loaded.
|
|
||||||
vi.spyOn(any as never, "pollRpartstoreState")
|
|
||||||
.mockResolvedValueOnce({ loaded: false, launchError: false, text: "spinner" } as never)
|
|
||||||
.mockResolvedValueOnce({ loaded: true, launchError: false, text: "Ne arıyorsunuz" } as never);
|
|
||||||
|
|
||||||
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
|
|
||||||
expect(raise).toHaveBeenCalledTimes(1); // only the first attempt raises
|
|
||||||
expect(close).toHaveBeenCalledTimes(1); // the stuck instance was closed
|
|
||||||
expect(reopen).toHaveBeenCalledTimes(1); // one fresh reopen, which loaded
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives up (false) after maxOpens reopen attempts all spinner → caller falls to Dialogys", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
vi.spyOn(any as never, "ensureRpartstore").mockResolvedValue(true as never); // cold open path
|
|
||||||
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
|
|
||||||
const close = vi
|
|
||||||
.spyOn(any as never, "closeRpartstoreTab")
|
|
||||||
.mockResolvedValue(undefined as never);
|
|
||||||
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
|
||||||
loaded: false,
|
|
||||||
launchError: false,
|
|
||||||
text: "spinner",
|
|
||||||
} as never); // never loads
|
|
||||||
|
|
||||||
expect(await acquire(driver, false, FAR_DEADLINE())).toBe(false);
|
|
||||||
// maxOpens = 3: open 1 (ensureRpartstore) + 2 reopens; a close after each miss.
|
|
||||||
expect(reopen).toHaveBeenCalledTimes(2);
|
|
||||||
expect(close).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bails immediately (false) without opening when the budget is already spent (no livelock)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
const poll = vi.spyOn(any as never, "pollRpartstoreState");
|
|
||||||
|
|
||||||
expect(await acquire(driver, true, Date.now() - 1)).toBe(false);
|
|
||||||
expect(raise).not.toHaveBeenCalled();
|
|
||||||
expect(poll).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("HARD launch error (Rpartstore DOWN) → returns false after ONE open, NO reopens, dismiss clicked → caller falls to Dialogys", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
|
|
||||||
const close = vi.spyOn(any as never, "closeRpartstoreTab");
|
|
||||||
// Upscaled modal-crop OCR of "Ошибка запуска каталога" — the VERBATIM string
|
|
||||||
// captured live 2026-07-15 on seat trvinpin47828 (see rpartstoreLaunchError).
|
|
||||||
vi.spyOn(any as never, "pollRpartstoreState").mockResolvedValue({
|
|
||||||
loaded: false,
|
|
||||||
launchError: true,
|
|
||||||
text: "Rpartstore X Owwu6ka 3anycka KaTanora",
|
|
||||||
} as never);
|
|
||||||
const page = {
|
|
||||||
mouse: { click: vi.fn(async () => undefined) },
|
|
||||||
waitForTimeout: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
any.acquireLoadedRpartstore as (
|
|
||||||
p: unknown,
|
|
||||||
c: unknown,
|
|
||||||
d: number,
|
|
||||||
w: boolean,
|
|
||||||
) => Promise<boolean>
|
|
||||||
).call(driver, page, {}, FAR_DEADLINE(), true);
|
|
||||||
|
|
||||||
expect(result).toBe(false); // → warmRenaultDecode/runRenaultFlow go straight to Dialogys
|
|
||||||
expect(raise).toHaveBeenCalledTimes(1); // opened exactly ONCE
|
|
||||||
expect(reopen).not.toHaveBeenCalled(); // NO reopens burned (~54s saved)
|
|
||||||
expect(close).not.toHaveBeenCalled(); // not treated as a spinner
|
|
||||||
// The launch-error modal's OK button was clicked to dismiss it.
|
|
||||||
expect(page.mouse.click).toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
|
|
||||||
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("genuine spinner (no launch-error string) STILL triggers the close+reopen loop (unchanged)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore").mockResolvedValue(true as never);
|
|
||||||
const close = vi
|
|
||||||
.spyOn(any as never, "closeRpartstoreTab")
|
|
||||||
.mockResolvedValue(undefined as never);
|
|
||||||
// Bare chrome, no content AND no launch-error → born-stuck spinner. Reopen loop.
|
|
||||||
vi.spyOn(any as never, "pollRpartstoreState")
|
|
||||||
.mockResolvedValueOnce({ loaded: false, launchError: false, text: "RPartStore" } as never)
|
|
||||||
.mockResolvedValueOnce({ loaded: true, launchError: false, text: "Ne arıyorsunuz" } as never);
|
|
||||||
|
|
||||||
expect(await acquire(driver, true, FAR_DEADLINE())).toBe(true);
|
|
||||||
expect(close).toHaveBeenCalledTimes(1); // stuck instance closed (spinner path intact)
|
|
||||||
expect(reopen).toHaveBeenCalledTimes(1); // one fresh reopen, which loaded
|
|
||||||
});
|
|
||||||
|
|
||||||
it("launch-error OCR pattern matches the real Cyrillic and its live-captured eng-OCR transliterations", () => {
|
|
||||||
expect(VINPIN_OCR.rpartstoreLaunchError.test("Ошибка запуска каталога")).toBe(true);
|
|
||||||
// VERBATIM upscaled-crop OCR captured live 2026-07-15 (seat trvinpin47828): the
|
|
||||||
// "Ошибка" head varies (Owwu6ka/OwwbKa) but "3anycka"+"KaTanora" are stable.
|
|
||||||
expect(VINPIN_OCR.rpartstoreLaunchError.test("Rpartstore X Owwu6ka 3anycka KaTanora")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(VINPIN_OCR.rpartstoreLaunchError.test("Volvo (X) OwwbKa 3anycka KaTanora VY")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(VINPIN_OCR.rpartstoreLaunchError.test("Owwnbka 3anycka KaTanora")).toBe(true);
|
|
||||||
// Must NOT fire on a healthy loaded Rpartstore home (no false short-circuit) —
|
|
||||||
// this is the exact full-frame text a LOADED home shows.
|
|
||||||
expect(VINPIN_OCR.rpartstoreLaunchError.test("Şasi no ile arama Güncel araçlar")).toBe(false);
|
|
||||||
// Nor on the brand grid alone (the tiles behind the modal, read on a 1× frame).
|
|
||||||
expect(
|
|
||||||
VINPIN_OCR.rpartstoreLaunchError.test("Audi Fiat Renault KIA SEAT Volkswagen Toyota"),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("launch error over the grid (ensureRpartstore couldn't confirm a window) → bail to Dialogys, NO reopens", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
// Cold path: ensureRpartstore returns false because the DOWN Rpartstore shows
|
|
||||||
// only the centered launch-error modal over the grid (no catalog chrome).
|
|
||||||
vi.spyOn(any as never, "ensureRpartstore").mockResolvedValue(false as never);
|
|
||||||
const reopen = vi.spyOn(any as never, "reopenFreshRpartstore");
|
|
||||||
const close = vi.spyOn(any as never, "closeRpartstoreTab");
|
|
||||||
// The upscaled modal crop DOES read the launch error → dismiss + bail.
|
|
||||||
vi.spyOn(any as never, "rpartstoreLaunchErrorPresent").mockResolvedValue(true as never);
|
|
||||||
const page = {
|
|
||||||
mouse: { click: vi.fn(async () => undefined) },
|
|
||||||
waitForTimeout: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
any.acquireLoadedRpartstore as (
|
|
||||||
p: unknown,
|
|
||||||
c: unknown,
|
|
||||||
d: number,
|
|
||||||
w: boolean,
|
|
||||||
) => Promise<boolean>
|
|
||||||
).call(driver, page, {}, FAR_DEADLINE(), false);
|
|
||||||
|
|
||||||
expect(result).toBe(false); // → straight to Dialogys
|
|
||||||
expect(reopen).not.toHaveBeenCalled(); // no reopens burned
|
|
||||||
expect(close).not.toHaveBeenCalled();
|
|
||||||
expect(page.mouse.click).toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
|
|
||||||
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warm Renault decode: loaded Rpartstore is PRIMARY — Dialogys is not touched on a hit", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
|
|
||||||
status: "found",
|
|
||||||
parsed: { brand: "RENAULT", model: "KADJAR", modelYear: null },
|
|
||||||
rawText: "RENAULT Kadjar",
|
|
||||||
via: "rpartstore",
|
|
||||||
} as never);
|
|
||||||
const dialogys = vi.spyOn(any as never, "ensureWarmWindow");
|
|
||||||
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
|
||||||
).call(driver, page, "VF1RFE00653633190", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
brand: "RENAULT",
|
|
||||||
model: "KADJAR",
|
|
||||||
raw: { source: "vinpin-rpartstore" },
|
|
||||||
});
|
|
||||||
expect(dialogys).not.toHaveBeenCalled(); // Rpartstore hit → Dialogys never consulted
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warm Renault decode: exhausted Rpartstore → LAST-RESORT Dialogys fallback still decodes", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(false as never); // never loaded
|
|
||||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
|
||||||
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
|
|
||||||
status: "found",
|
|
||||||
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
|
|
||||||
rawText: "Megane II Classic",
|
|
||||||
via: "dialogys",
|
|
||||||
} as never);
|
|
||||||
const page = { keyboard: { press: vi.fn(async () => undefined) } };
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
|
|
||||||
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(runRpartstore).not.toHaveBeenCalled(); // never ran the search on a stuck Rpartstore
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
brand: "RENAULT",
|
|
||||||
model: "MEGANE",
|
|
||||||
raw: { source: "vinpin-dialogys" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("acquire give-up (budget-exhausted) DEFENSIVELY dismisses a leftover modal (Escape + launch-error OK) even when the OCR branch never fires", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
// Budget already spent → the inner acquire returns false at its first budget
|
|
||||||
// check WITHOUT ever hitting the OCR error-detect branch (raise/poll not called).
|
|
||||||
const raise = vi.spyOn(any as never, "raiseWarmWindow").mockResolvedValue(true as never);
|
|
||||||
const poll = vi.spyOn(any as never, "pollRpartstoreState");
|
|
||||||
const page = {
|
|
||||||
keyboard: { press: vi.fn(async () => undefined) },
|
|
||||||
mouse: { click: vi.fn(async () => undefined) },
|
|
||||||
waitForTimeout: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
any.acquireLoadedRpartstore as (
|
|
||||||
p: unknown,
|
|
||||||
c: unknown,
|
|
||||||
d: number,
|
|
||||||
w: boolean,
|
|
||||||
) => Promise<boolean>
|
|
||||||
).call(driver, page, {}, Date.now() - 1, true);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(raise).not.toHaveBeenCalled(); // budget already gone → never opened
|
|
||||||
expect(poll).not.toHaveBeenCalled();
|
|
||||||
// The wrapper's unconditional defensive clear ran on the false path: Escape +
|
|
||||||
// click the centered launch-error OK, so no modal is left to wedge the grid-return.
|
|
||||||
expect(page.keyboard.press).toHaveBeenCalledWith("Escape");
|
|
||||||
expect(page.mouse.click).toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.rpartstoreLaunchErrorOk.x,
|
|
||||||
VINPIN_COORDS.rpartstoreLaunchErrorOk.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("grid-return dismisses a blocking modal BEFORE each tab-✕ close (can't wedge into a relogin thrash)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
const order: string[] = [];
|
|
||||||
// ocrRegion on a fake page fails-safe to "" (never the brand grid) → the loop runs.
|
|
||||||
const dismiss = vi.spyOn(any as never, "dismissBlockingModal").mockImplementation((async () => {
|
|
||||||
order.push("dismiss");
|
|
||||||
}) as never);
|
|
||||||
vi.spyOn(any as never, "ensureBrandGrid").mockResolvedValue(undefined as never);
|
|
||||||
const page = {
|
|
||||||
mouse: {
|
|
||||||
click: vi.fn(async () => {
|
|
||||||
order.push("click");
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
keyboard: { press: vi.fn(async () => undefined) },
|
|
||||||
waitForTimeout: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
await (any.returnToBrandGrid as (p: unknown, c: unknown) => Promise<void>).call(
|
|
||||||
driver,
|
|
||||||
page,
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dismiss).toHaveBeenCalled();
|
|
||||||
// The modal-clear precedes the tab-✕ click on the very first iteration.
|
|
||||||
expect(order[0]).toBe("dismiss");
|
|
||||||
expect(order[1]).toBe("click");
|
|
||||||
expect(page.mouse.click).toHaveBeenCalledWith(
|
|
||||||
VINPIN_COORDS.catalogTabClose.x,
|
|
||||||
VINPIN_COORDS.catalogTabClose.y,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps the resumed-desktop escalation (closeStrayRunningApps, NOT logout+relogin) to ONE attempt per decode (no thrash)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
// Fresh decode → escalation budget starts unused.
|
|
||||||
any.reloginUsedThisDecode = false;
|
|
||||||
vi.spyOn(any as never, "submitVinPowerLogin").mockResolvedValue(false as never);
|
|
||||||
vi.spyOn(any as never, "dismissDisconnected").mockResolvedValue(undefined as never);
|
|
||||||
// logout+relogin is retired — it must not exist as a method any more.
|
|
||||||
expect(any.logoutAndRelogin).toBeUndefined();
|
|
||||||
// The escalation itself fails — the point is it's only TRIED once across two returns.
|
|
||||||
const escalate = vi
|
|
||||||
.spyOn(any as never, "closeStrayRunningApps")
|
|
||||||
.mockResolvedValue(false as never);
|
|
||||||
const page = {
|
|
||||||
waitForTimeout: vi.fn(async () => undefined),
|
|
||||||
keyboard: { press: vi.fn(async () => undefined) },
|
|
||||||
mouse: { click: vi.fn(async () => undefined) },
|
|
||||||
};
|
|
||||||
const ensureGrid = (p: unknown, c: unknown, allow?: boolean) =>
|
|
||||||
(any.ensureBrandGrid as (p: unknown, c: unknown, a?: boolean) => Promise<void>).call(
|
|
||||||
driver,
|
|
||||||
p,
|
|
||||||
c,
|
|
||||||
allow,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Two grid-returns inside ONE decode (as returnToBrandGrid's loop would do). The
|
|
||||||
// 2nd must NOT re-escalate → no repeated recovery thrash.
|
|
||||||
await ensureGrid(page, {}, true);
|
|
||||||
await ensureGrid(page, {}, true);
|
|
||||||
|
|
||||||
expect(escalate).toHaveBeenCalledTimes(1); // one escalation only — the 2nd is capped
|
|
||||||
expect(any.reloginUsedThisDecode).toBe(true);
|
|
||||||
expect(any.sessionPoisoned).toBe(true); // capped path poisons for a clean cold restart
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Warm-path Fiat fixes: the disambiguated foreground regex (Defect B) and the
|
|
||||||
* warmDecode Fiat branch that must never fail silently (Defect A) — genuine
|
|
||||||
* not-found → null, wrong/garbled window → cold fallback, no Fiat warm window →
|
|
||||||
* cold fallback.
|
|
||||||
*/
|
|
||||||
describe("VinpinDriverService — warm Fiat not-found / wrong-window hardening", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const VALID_FIAT_VIN = "NM435600006H43436"; // 17 alphanumerics → fiat warm window
|
|
||||||
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
process.env.VINPIN_USER = "user";
|
|
||||||
process.env.VINPIN_PASS = "pass";
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
function fakePage() {
|
|
||||||
return { isClosed: () => false, keyboard: { press: vi.fn(async () => undefined) } };
|
|
||||||
}
|
|
||||||
function callWarmDecode(driver: VinpinDriverService, vin: string, deadline: number) {
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
return (any.warmDecode as (v: string, c: unknown, d: number) => Promise<unknown>).call(
|
|
||||||
driver,
|
|
||||||
vin,
|
|
||||||
{},
|
|
||||||
deadline,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("FIX 1: VINPIN_WINDOW_FOREGROUND.fiat does NOT match a Dialogys 'ПОИСК' string but DOES match Fiat 'Dealer'/'ePER'/'TIPO-EGEA'", () => {
|
|
||||||
const fg = VINPIN_WINDOW_FOREGROUND.fiat;
|
|
||||||
// The shared-token bug: the Dialogys ПОИСК search button must NOT read as the
|
|
||||||
// Fiat foreground (this false-positive typed the VIN into Dialogys — Defect B).
|
|
||||||
expect(fg.test("ПОИСК Dialogys ИЗМЕНИТЬ")).toBe(false);
|
|
||||||
expect(fg.test("ПОИСК")).toBe(false);
|
|
||||||
// Fiat-window-only chrome + VIN-panel model tokens still match.
|
|
||||||
expect(fg.test("Fiat Dealer")).toBe(true);
|
|
||||||
expect(fg.test("ePER window")).toBe(true);
|
|
||||||
expect(fg.test("6J - TIPO - EGEA (2015-2021)")).toBe(true);
|
|
||||||
expect(fg.test("Spare Parts Catalogue")).toBe(true);
|
|
||||||
// Sanity: the Dialogys foreground regex STILL recognises ПОИСК (its own detection
|
|
||||||
// is unaffected — only the Fiat regex dropped the shared token).
|
|
||||||
expect(VINPIN_WINDOW_FOREGROUND.dialogys.test("ПОИСК Dialogys ИЗМЕНИТЬ")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FIX 2: Fiat unusable-parse NOT-on-panel (wrong/garbled window) → falls back to decodeFiatLocked, not a silent null", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
any.warm = true;
|
|
||||||
any.page = fakePage();
|
|
||||||
any.warmWindows = { fiat: true }; // has a Fiat window → not the FIX-4 route
|
|
||||||
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
|
|
||||||
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runVinFlow").mockResolvedValue(null as never); // no usable parse
|
|
||||||
// Frame shows the DIALOGYS window (Defect B), not the Fiat VIN panel.
|
|
||||||
vi.spyOn(any as never, "ocrFrame").mockResolvedValue("ПОИСК Dialogys ИЗМЕНИТЬ" as never);
|
|
||||||
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
|
||||||
const cold = vi
|
|
||||||
.spyOn(any as never, "decodeFiatLocked")
|
|
||||||
.mockResolvedValue({ brand: "Fiat", model: "TIPO" } as never);
|
|
||||||
|
|
||||||
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(cold).toHaveBeenCalledTimes(1); // cold fallback, NOT a silent null
|
|
||||||
expect(result).toMatchObject({ brand: "Fiat", model: "TIPO" });
|
|
||||||
expect(any.warm).toBe(false); // warm dropped (untrusted Fiat window)
|
|
||||||
expect(close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FIX 2: Fiat genuine not-found (on the VIN panel + not-found string) → returns null, no cold fallback", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
any.warm = true;
|
|
||||||
any.page = fakePage();
|
|
||||||
any.warmWindows = { fiat: true };
|
|
||||||
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
|
|
||||||
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
|
|
||||||
vi.spyOn(any as never, "runVinFlow").mockResolvedValue(null as never);
|
|
||||||
// Positively on the Fiat Spare-Parts catalogue AND a genuine "не найден" miss.
|
|
||||||
vi.spyOn(any as never, "ocrFrame").mockResolvedValue(
|
|
||||||
"Spare Parts Catalogue TIPO не найден" as never,
|
|
||||||
);
|
|
||||||
const cold = vi.spyOn(any as never, "decodeFiatLocked");
|
|
||||||
|
|
||||||
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(result).toBeNull(); // a real miss is correctly null
|
|
||||||
expect(cold).not.toHaveBeenCalled(); // NOT re-driven cold
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FIX 4: routes a Fiat VIN straight to the cold path when warmWindows.fiat is false (no Fiat warm window)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as Record<string, unknown>;
|
|
||||||
any.warm = true;
|
|
||||||
any.page = fakePage();
|
|
||||||
any.warmWindows = { fiat: false, dialogys: true }; // Renault-only partial warm
|
|
||||||
vi.spyOn(any as never, "assertSessionAlive").mockResolvedValue(undefined as never);
|
|
||||||
const ensure = vi.spyOn(any as never, "ensureWarmWindow");
|
|
||||||
const runVinFlow = vi.spyOn(any as never, "runVinFlow");
|
|
||||||
const close = vi.spyOn(any as never, "close").mockResolvedValue(undefined as never);
|
|
||||||
const cold = vi
|
|
||||||
.spyOn(any as never, "decodeFiatLocked")
|
|
||||||
.mockResolvedValue({ brand: "Fiat", model: "EGEA" } as never);
|
|
||||||
|
|
||||||
const result = await callWarmDecode(driver, VALID_FIAT_VIN, FAR_DEADLINE());
|
|
||||||
|
|
||||||
expect(cold).toHaveBeenCalledTimes(1); // straight to cold — never touched a warm window
|
|
||||||
expect(ensure).not.toHaveBeenCalled();
|
|
||||||
expect(runVinFlow).not.toHaveBeenCalled();
|
|
||||||
expect(result).toMatchObject({ brand: "Fiat", model: "EGEA" });
|
|
||||||
expect(any.warm).toBe(false);
|
|
||||||
expect(close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,328 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean-teardown + grid-return reliability tests for the Vinpin driver. These
|
|
||||||
* exercise the warm-establish fix: the clean-teardown routine (close resumed
|
|
||||||
* catalog windows via the corrected tab-✕, then log out of the RDS session before
|
|
||||||
* dropping the browser) and ensureBrandGrid's escalation to logout+relogin. OCR is
|
|
||||||
* mocked so the screen-state sequence is fully controllable — the real seat lives
|
|
||||||
* on prod and can't be driven from a test.
|
|
||||||
*/
|
|
||||||
vi.mock("./vinpin.ocr", () => ({
|
|
||||||
ocrRegion: vi.fn(async () => ""),
|
|
||||||
pollForText: vi.fn(async () => ({ matched: false, text: "" })),
|
|
||||||
terminateOcr: vi.fn(async () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { VinpinDriverService } from "./vinpin-driver.service";
|
|
||||||
import { ocrRegion } from "./vinpin.ocr";
|
|
||||||
|
|
||||||
const mockOcr = vi.mocked(ocrRegion);
|
|
||||||
|
|
||||||
type AnyDriver = Record<string, unknown>;
|
|
||||||
|
|
||||||
function fakePage(sink: (x: number, y: number) => void) {
|
|
||||||
return {
|
|
||||||
isClosed: () => false,
|
|
||||||
frames: () => [] as unknown[],
|
|
||||||
mouse: { click: vi.fn(async (x: number, y: number) => sink(x, y)) },
|
|
||||||
keyboard: { press: vi.fn(async () => undefined) },
|
|
||||||
waitForTimeout: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("VinpinDriverService — clean teardown", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
process.env.VINPIN_USER = "user";
|
|
||||||
process.env.VINPIN_PASS = "pass";
|
|
||||||
process.env.VINPIN_WARM_DAEMON = "true";
|
|
||||||
mockOcr.mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("teardownWarm closes each resumed catalog window (tab-✕) then logs out BEFORE closing the browser", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const events: string[] = [];
|
|
||||||
vi.spyOn(any as never, "close").mockImplementation((async () => {
|
|
||||||
events.push("close");
|
|
||||||
}) as never);
|
|
||||||
any.browser = { isConnected: () => true };
|
|
||||||
any.context = {};
|
|
||||||
any.warm = true;
|
|
||||||
any.page = fakePage((x, y) => {
|
|
||||||
if (x === 93 && y === 45) events.push("tabClose");
|
|
||||||
if (x === 1543 && y === 877) events.push("logout");
|
|
||||||
});
|
|
||||||
// Two catalog windows open, then a clear desktop.
|
|
||||||
mockOcr
|
|
||||||
.mockResolvedValueOnce("RPartStore")
|
|
||||||
.mockResolvedValueOnce("ePER Dealer")
|
|
||||||
.mockResolvedValue("");
|
|
||||||
|
|
||||||
await driver.teardownWarm();
|
|
||||||
|
|
||||||
// Both windows closed via the corrected tab-✕, then logout, then browser close.
|
|
||||||
expect(events).toEqual(["tabClose", "tabClose", "logout", "close"]);
|
|
||||||
expect(any.warm).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clean-teardown still logs out when no catalog window is open (bounded, no-op close loop)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const clicks: Array<[number, number]> = [];
|
|
||||||
any.browser = { isConnected: () => true };
|
|
||||||
any.context = {};
|
|
||||||
any.page = fakePage((x, y) => clicks.push([x, y]));
|
|
||||||
mockOcr.mockResolvedValue(""); // desktop already clear
|
|
||||||
|
|
||||||
await (any.cleanTeardown as () => Promise<void>).call(driver);
|
|
||||||
|
|
||||||
expect(clicks.filter(([x, y]) => x === 93 && y === 45)).toHaveLength(0); // nothing to close
|
|
||||||
expect(clicks).toContainEqual([1543, 877]); // still logs out to end the RDS session
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logout dismisses any blocking modal FIRST, and recovers a Disconnected drop by closing it + relaunching VINPIN for a fresh grid", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const events: string[] = [];
|
|
||||||
any.browser = { isConnected: () => true };
|
|
||||||
any.context = {};
|
|
||||||
any.page = fakePage((x, y) => {
|
|
||||||
if (x === 868 && y === 530) events.push("dismissModal"); // rpartstoreLaunchErrorOk
|
|
||||||
if (x === 1543 && y === 877) events.push("logout"); // Çıkış yap
|
|
||||||
if (x === 953 && y === 505) events.push("disconnectedClose");
|
|
||||||
if (x === 40 && y === 256) events.push("relaunchVinpin"); // vinpinApp
|
|
||||||
});
|
|
||||||
// (1) close-loop: desktop already clear → break. (2) after logout: a Disconnected
|
|
||||||
// dialog surfaced (dirty channel drop). (3) closing it lands on the launcher.
|
|
||||||
mockOcr
|
|
||||||
.mockResolvedValueOnce("") // close-loop: nothing open
|
|
||||||
.mockResolvedValueOnce("You have been disconnected") // after Çıkış yap
|
|
||||||
.mockResolvedValueOnce("No Running Items Available"); // launcher after Close
|
|
||||||
|
|
||||||
await (any.cleanTeardown as () => Promise<void>).call(driver);
|
|
||||||
|
|
||||||
// Modal dismissed BEFORE logout; then the disconnect is closed and VINPIN relaunched.
|
|
||||||
expect(events).toEqual(["dismissModal", "logout", "disconnectedClose", "relaunchVinpin"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logout does NOT relaunch VINPIN when logout was a clean log-off (no Disconnected dialog)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const clicks: Array<[number, number]> = [];
|
|
||||||
any.browser = { isConnected: () => true };
|
|
||||||
any.context = {};
|
|
||||||
any.page = fakePage((x, y) => clicks.push([x, y]));
|
|
||||||
mockOcr.mockResolvedValue(""); // clear desktop, and no Disconnected dialog after logout
|
|
||||||
|
|
||||||
await (any.cleanTeardown as () => Promise<void>).call(driver);
|
|
||||||
|
|
||||||
expect(clicks).toContainEqual([1543, 877]); // logged out
|
|
||||||
expect(clicks).not.toContainEqual([953, 505]); // no disconnect to close
|
|
||||||
expect(clicks).not.toContainEqual([40, 256]); // VINPIN not relaunched
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT relaunch VINPIN when logout lands on the HTML-Access launcher (clean log-off, not a drop)", async () => {
|
|
||||||
// The clean-logout launcher OCRs its "HTML Access" branding, which the broad
|
|
||||||
// sessionDropped token matches — but the launcher tokens must veto the recovery
|
|
||||||
// so a clean log-off is never mistaken for a Disconnected drop (would spuriously
|
|
||||||
// relaunch VinPower and leave a dirty resumed session).
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const clicks: Array<[number, number]> = [];
|
|
||||||
any.browser = { isConnected: () => true };
|
|
||||||
any.context = {};
|
|
||||||
any.page = fakePage((x, y) => clicks.push([x, y]));
|
|
||||||
mockOcr
|
|
||||||
.mockResolvedValueOnce("") // close-loop: nothing open
|
|
||||||
.mockResolvedValueOnce("VMware Horizon HTML Access — No Running Items Available"); // clean launcher after logout
|
|
||||||
|
|
||||||
await (any.cleanTeardown as () => Promise<void>).call(driver);
|
|
||||||
|
|
||||||
expect(clicks).toContainEqual([1543, 877]); // logged out
|
|
||||||
expect(clicks).not.toContainEqual([953, 505]); // NOT treated as a disconnect
|
|
||||||
expect(clicks).not.toContainEqual([40, 256]); // VINPIN NOT relaunched
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clean-teardown is a no-op when the browser/page is already gone", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
any.browser = null;
|
|
||||||
any.context = null;
|
|
||||||
any.page = null;
|
|
||||||
// Must not throw and must not touch OCR.
|
|
||||||
await expect((any.cleanTeardown as () => Promise<void>).call(driver)).resolves.toBeUndefined();
|
|
||||||
expect(mockOcr).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A running-app row handle for the Horizon "Running" panel. `$` resolves the
|
|
||||||
* name element (textContent) and the per-app close ✕; clicking the ✕ removes the
|
|
||||||
* app from the shared running list (models a real window teardown).
|
|
||||||
*/
|
|
||||||
function fakeRunningApp(name: string, onClose: (n: string) => void) {
|
|
||||||
return {
|
|
||||||
$: async (sel: string) => {
|
|
||||||
if (/running-app-name|focused-app-name/.test(sel)) {
|
|
||||||
return { textContent: async () => name };
|
|
||||||
}
|
|
||||||
if (/icon-close-app-image/.test(sel)) {
|
|
||||||
return { click: async () => onClose(name) };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fake page that also exposes the Horizon client-chrome DOM (`$`, `$$`,
|
|
||||||
* `page.click`) used by closeStrayRunningApps. `apps` is the mutable running list;
|
|
||||||
* closing a stray removes it. `closed` records the order strays were terminated.
|
|
||||||
*/
|
|
||||||
function fakeDomPage(apps: string[], sink?: (x: number, y: number) => void) {
|
|
||||||
const running = [...apps];
|
|
||||||
const closed: string[] = [];
|
|
||||||
const domClicks: string[] = [];
|
|
||||||
const page = {
|
|
||||||
isClosed: () => false,
|
|
||||||
frames: () => [] as unknown[],
|
|
||||||
mouse: { click: vi.fn(async (x: number, y: number) => sink?.(x, y)) },
|
|
||||||
keyboard: { press: vi.fn(async () => undefined) },
|
|
||||||
waitForTimeout: vi.fn(async () => undefined),
|
|
||||||
$: async (sel: string) =>
|
|
||||||
sel === "#sidebar-toggler" || sel === "#available-VINPIN" ? {} : null,
|
|
||||||
$$: async (sel: string) =>
|
|
||||||
sel === "ul.running-app"
|
|
||||||
? running.map((n) =>
|
|
||||||
fakeRunningApp(n, (name) => {
|
|
||||||
closed.push(name);
|
|
||||||
const idx = running.indexOf(name);
|
|
||||||
if (idx >= 0) running.splice(idx, 1);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
click: vi.fn(async (sel: string) => {
|
|
||||||
domClicks.push(sel);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
return { page, running, closed, domClicks };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("VinpinDriverService — ensureBrandGrid stray-app (Running-panel) recovery", () => {
|
|
||||||
const savedEnv = { ...process.env };
|
|
||||||
const cfg = { url: "https://vinpin.test", user: "user", pass: "pass" };
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.VINPIN_ENABLED = "true";
|
|
||||||
mockOcr.mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.env = { ...savedEnv };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closeStrayRunningApps closes every non-VinPower app via the DOM ✕ and leaves only VinPower", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const { page, running, closed } = fakeDomPage([
|
|
||||||
"VinPower",
|
|
||||||
"Renault Rpartstore",
|
|
||||||
"Loading application...",
|
|
||||||
]);
|
|
||||||
// After the strays close + the sidebar collapses, the canvas is on the brand grid.
|
|
||||||
mockOcr.mockResolvedValue("Volkswagen SsangYong TecDoc");
|
|
||||||
|
|
||||||
const ok = await (
|
|
||||||
any.closeStrayRunningApps as (p: unknown, c: unknown) => Promise<boolean>
|
|
||||||
).call(driver, page, cfg);
|
|
||||||
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
// Both non-VinPower apps terminated via their per-app ✕, in row order.
|
|
||||||
expect(closed).toEqual(["Renault Rpartstore", "Loading application..."]);
|
|
||||||
expect(running).toEqual(["VinPower"]); // VinPower kept
|
|
||||||
expect(any.sessionPoisoned).toBe(false); // reached a confirmed-clean grid
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closeStrayRunningApps degrades gracefully (false, no throw) when the sidebar DOM is absent", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const page = fakePage(() => undefined); // no $ / $$ / page.click — canvas-only render
|
|
||||||
mockOcr.mockResolvedValue("");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
(any.closeStrayRunningApps as (p: unknown, c: unknown) => Promise<boolean>).call(
|
|
||||||
driver,
|
|
||||||
page,
|
|
||||||
cfg,
|
|
||||||
),
|
|
||||||
).resolves.toBe(false); // → caller falls back to the canvas tab-✕ / OCR path
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ensureBrandGrid escalates via closeStrayRunningApps (NOT logout+relogin) + falls back to the tab-✕ (93,45), never the language-selector (1298,14)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const clicks: Array<[number, number]> = [];
|
|
||||||
const page = fakePage((x, y) => clicks.push([x, y]));
|
|
||||||
// Always a catalog window open → grid never reached → in-branch recovery + escalation.
|
|
||||||
mockOcr.mockResolvedValue("RPartStore catalog open");
|
|
||||||
// logout+relogin is retired — the method must not exist any more.
|
|
||||||
expect((any as Record<string, unknown>).logoutAndRelogin).toBeUndefined();
|
|
||||||
// DOM absent → the primary Running-panel recovery returns false → tab-✕ fallback.
|
|
||||||
const escalate = vi.spyOn(any as never, "closeStrayRunningApps");
|
|
||||||
|
|
||||||
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
|
|
||||||
driver,
|
|
||||||
page,
|
|
||||||
cfg,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(escalate).toHaveBeenCalled(); // primary + escalation is the Running-panel recovery
|
|
||||||
expect(clicks).toContainEqual([93, 45]); // tab-✕ fallback still used
|
|
||||||
expect(clicks).not.toContainEqual([1298, 14]); // language-selector coord NEVER clicked
|
|
||||||
expect(any.sessionPoisoned).toBe(true); // recovery failed (DOM absent) → poison, no relogin
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ensureBrandGrid clears the poison when closeStrayRunningApps reaches a clean grid", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const page = fakePage(() => undefined);
|
|
||||||
mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid by OCR
|
|
||||||
// The escalation succeeds (Running-panel recovery reached a fresh grid).
|
|
||||||
const escalate = vi
|
|
||||||
.spyOn(any as never, "closeStrayRunningApps")
|
|
||||||
.mockResolvedValue(true as never);
|
|
||||||
|
|
||||||
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
|
|
||||||
driver,
|
|
||||||
page,
|
|
||||||
cfg,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(escalate).toHaveBeenCalled();
|
|
||||||
expect(any.sessionPoisoned).toBe(false); // recovery reached a confirmed-clean grid
|
|
||||||
});
|
|
||||||
|
|
||||||
it("with allowRelogin=false it does NOT run the end-of-loop escalation and poisons the seat (one-shot, no livelock)", async () => {
|
|
||||||
const driver = new VinpinDriverService();
|
|
||||||
const any = driver as unknown as AnyDriver;
|
|
||||||
const page = fakePage(() => undefined);
|
|
||||||
mockOcr.mockResolvedValue("RPartStore catalog open"); // never reaches grid
|
|
||||||
|
|
||||||
await (any.ensureBrandGrid as (p: unknown, c: unknown, allow?: boolean) => Promise<void>).call(
|
|
||||||
driver,
|
|
||||||
page,
|
|
||||||
cfg,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(any.sessionPoisoned).toBe(true); // poisoned → next decode cold-restarts
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -12,8 +12,6 @@
|
|||||||
* coordinate if the brand-grid layout ever changes.
|
* coordinate if the brand-grid layout ever changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getBrandFromWmi } from "@sase/shared";
|
|
||||||
|
|
||||||
/** Viewport the coordinates are calibrated for. */
|
/** Viewport the coordinates are calibrated for. */
|
||||||
export const VINPIN_VIEWPORT = { width: 1600, height: 900 } as const;
|
export const VINPIN_VIEWPORT = { width: 1600, height: 900 } as const;
|
||||||
|
|
||||||
@@ -44,106 +42,12 @@ export const VINPIN_COORDS = {
|
|||||||
vinSubmitArrow: { x: 1283, y: 189 },
|
vinSubmitArrow: { x: 1283, y: 189 },
|
||||||
/** "OK" button of the "vehicles not found" alert (genuine not-found result). */
|
/** "OK" button of the "vehicles not found" alert (genuine not-found result). */
|
||||||
notFoundOk: { x: 816, y: 448 },
|
notFoundOk: { x: 816, y: 448 },
|
||||||
|
|
||||||
// ─── Renault flow (Rpartstore primary + Dialogys fallback) ───
|
|
||||||
// Coordinates below are 1600x900 STARTING POINTS from the live benchmark on the
|
|
||||||
// permanent seat (trvinpin41080). They are OCR-verified+retried at runtime like
|
|
||||||
// the Fiat flow, so minor drift self-heals — but re-verify if layout changes.
|
|
||||||
// TUNE.
|
|
||||||
/** Window-close (X) button of an open catalog window. ⚠️ DO NOT USE on the
|
|
||||||
* HTML-Access desktop: this coord actually hits the LANGUAGE SELECTOR (it opens a
|
|
||||||
* dropdown that makes the brand grid unrecognizable and wedges the ensureBrandGrid
|
|
||||||
* thrash). It is NO LONGER clicked by ANY path — the browser-tab ✕ below is the
|
|
||||||
* only close action. Kept documented so the bad coordinate isn't re-introduced. */
|
|
||||||
windowClose: { x: 1298, y: 14 },
|
|
||||||
/** Browser-tab ✕ of an open catalog app — the PRIMARY window-close action. The
|
|
||||||
* tab sits just under the window title bar, e.g. "Renault Rpartstore ✕".
|
|
||||||
* Validated live: clicking (93,45) closed the catalog and returned to the clean
|
|
||||||
* brand grid (the old {135,45} missed the ✕). TUNE. */
|
|
||||||
catalogTabClose: { x: 93, y: 45 },
|
|
||||||
/** "Çıkış yap" logout button (bottom-right of the RDS/Horizon desktop). Ends the
|
|
||||||
* remote session so the NEXT warm-up starts from a fresh login/grid instead of
|
|
||||||
* resuming into the last-open dirty catalog desktop. Used by the clean-teardown
|
|
||||||
* routine. ⚠️ With a blocking modal up (e.g. the Rpartstore launch-error dialog)
|
|
||||||
* this becomes a channel DISCONNECT rather than a clean RDS log-off — so the
|
|
||||||
* teardown dismisses any modal BEFORE clicking it. TUNE. */
|
|
||||||
logoutButton: { x: 1543, y: 877 },
|
|
||||||
/** "Close" button of the Horizon "You have been disconnected" dialog. If "Çıkış
|
|
||||||
* yap" turned into a channel disconnect (dirty RDS resume), clicking this lands
|
|
||||||
* back on the HTML-Access launcher ("No Running Items") from which the VINPIN app
|
|
||||||
* is relaunched for a fresh VinPower grid. Verified @ 1600x900. TUNE. */
|
|
||||||
disconnectedClose: { x: 953, y: 505 },
|
|
||||||
/** Renault brand tile on the VinPower grid (opens the Rpartstore/Dialogys
|
|
||||||
* submenu). mousedown/up like the Fiat tile. TUNE. */
|
|
||||||
renaultBrand: { x: 450, y: 707 },
|
|
||||||
/** Rpartstore entry in the Renault submenu (PRIMARY catalog). TUNE. */
|
|
||||||
renaultRpartstore: { x: 584, y: 779 },
|
|
||||||
/** Dialogys entry in the Renault submenu (FALLBACK catalog). TUNE. */
|
|
||||||
renaultDialogys: { x: 584, y: 733 },
|
|
||||||
/** Both Renault catalogs pop a Russian-language dialog on open → its OK. TUNE. */
|
|
||||||
renaultLangOk: { x: 746, y: 454 },
|
|
||||||
/** Rpartstore "Şasi no ile arama" VIN field. Submit with Enter (NOT the yellow
|
|
||||||
* button — it moves). TUNE. */
|
|
||||||
rpartstoreVinField: { x: 735, y: 194 },
|
|
||||||
/** Rpartstore "RPartStore" home/logo (top-left) — resets to the search home
|
|
||||||
* between VINs (more reliable than close+reopen). TUNE. */
|
|
||||||
rpartstoreHome: { x: 110, y: 92 },
|
|
||||||
/** Rpartstore error-card close (✕) — dismisses the "Şasi seçilmedi" /
|
|
||||||
* "bulunamadı" overlay that otherwise covers the VIN field. TUNE. */
|
|
||||||
rpartstoreErrorClose: { x: 928, y: 167 },
|
|
||||||
/** Rpartstore yellow search button (fallback to Enter submit). TUNE. */
|
|
||||||
rpartstoreSearchBtn: { x: 975, y: 195 },
|
|
||||||
/** "OK" button of the Rpartstore hard launch-error modal ("Ошибка запуска
|
|
||||||
* каталога" / title "Renault Rpartstore") that fires when Rpartstore is DOWN
|
|
||||||
* server-side. Verified live @ 1600x900 (viewport frame) — dismisses the modal
|
|
||||||
* so the driver can fall straight back to Dialogys. TUNE. */
|
|
||||||
rpartstoreLaunchErrorOk: { x: 868, y: 530 },
|
|
||||||
/** Dialogys VIN field. TUNE. */
|
|
||||||
dialogysVinField: { x: 457, y: 258 },
|
|
||||||
/** Dialogys ПОИСК (search) button. TUNE. */
|
|
||||||
dialogysSearch: { x: 590, y: 382 },
|
|
||||||
/** Dialogys Home button (reset step 1). TUNE. */
|
|
||||||
dialogysHome: { x: 78, y: 102 },
|
|
||||||
/** Dialogys ИЗМЕНИТЬ А/М — change-vehicle (reset step 2). TUNE. */
|
|
||||||
dialogysChangeVehicle: { x: 1217, y: 160 },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Screenshot clip (px) of the Renault Rpartstore/Dialogys vehicle-page HEADER
|
|
||||||
* region, fed to OCR. Rpartstore renders "RENAULT <model> (<code>)" /
|
|
||||||
* "DACIA <model>" + "Şasi: <VIN>" in x280–960,y182–258; Dialogys renders
|
|
||||||
* "<Model> (<platform>), <engine>" slightly higher. Captured a bit wider/taller
|
|
||||||
* than the benchmark box to tolerate drift. TUNE. */
|
|
||||||
export const VINPIN_RENAULT_HEADER_REGION = {
|
|
||||||
x: 260,
|
|
||||||
y: 150,
|
|
||||||
width: 720,
|
|
||||||
height: 140,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
|
/** Screenshot clip (px) of the decode-result modal body, fed to OCR. Captures
|
||||||
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
|
* the model line ("6J - TIPO - EGEA (2015-2021)"), SINCOM, trim, prod-date and
|
||||||
* the echoed VIN. Verified live. Widened to ~900px for the warm-session daemon
|
* the echoed VIN. Verified live. */
|
||||||
* (some decode modals render wider on the permanent seat). */
|
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 820, height: 480 } as const;
|
||||||
export const VINPIN_MODAL_REGION = { x: 250, y: 120, width: 900, height: 500 } as const;
|
|
||||||
|
|
||||||
/** Screenshot clip (px) of the CENTERED Rpartstore hard launch-error modal
|
|
||||||
* ("Renault Rpartstore" / "Ошибка запуска каталога", OK ~868,530), fed to OCR
|
|
||||||
* UPSCALED (ocrRegion's default 3×). ⚠️ ROOT-CAUSE NOTE (verified live 2026-07-15
|
|
||||||
* on seat trvinpin47828): the modal is a small (~220×140px) centered Cyrillic
|
|
||||||
* dialog. OCR'ing the FULL 1600×900 frame at 1× (what the old load-poll did)
|
|
||||||
* CANNOT read it — tesseract returns the surrounding brand-grid tiles and drops
|
|
||||||
* the tiny modal text, so `rpartstoreLaunchError` never matched → the DOWN
|
|
||||||
* Rpartstore was misclassified as a "born-stuck spinner" and 3 reopens (~54s) were
|
|
||||||
* burned before Dialogys. Cropping to THIS region + 3× upscale makes the string
|
|
||||||
* legible ("Owwu6ka 3anycka KaTanora"), so the launch error is detected on the
|
|
||||||
* FIRST open and the flow bails straight to Dialogys. Sized generously around the
|
|
||||||
* screen-centered dialog to tolerate drift while excluding the right sidebar. */
|
|
||||||
export const VINPIN_RPARTSTORE_ERROR_REGION = {
|
|
||||||
x: 540,
|
|
||||||
y: 370,
|
|
||||||
width: 560,
|
|
||||||
height: 230,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** OCR keyword sets for state detection (case-insensitive). */
|
/** OCR keyword sets for state detection (case-insensitive). */
|
||||||
export const VINPIN_OCR = {
|
export const VINPIN_OCR = {
|
||||||
@@ -166,236 +70,14 @@ export const VINPIN_OCR = {
|
|||||||
/** The decode modal actually carries a result (a model / prod-date / MVS). */
|
/** The decode modal actually carries a result (a model / prod-date / MVS). */
|
||||||
modalHit:
|
modalHit:
|
||||||
/MVS|найден|Prod\.?\s*date|TIPO|EGEA|DOBLO|PALIO|PANDA|PUNTO|LINEA|DUCATO|QUBO|FIORINO|ULYSSE/i,
|
/MVS|найден|Prod\.?\s*date|TIPO|EGEA|DOBLO|PALIO|PANDA|PUNTO|LINEA|DUCATO|QUBO|FIORINO|ULYSSE/i,
|
||||||
/** Genuine "no vehicle found" outcome (the catalog has no record). Used against
|
/** Genuine "no vehicle found" outcome (the catalog has no record). */
|
||||||
* the cropped decode-modal region in runVinFlow, where the loose `Catalogue`
|
|
||||||
* token is a useful settle signal. */
|
|
||||||
notFound: /не\s*найден|not\s*found|Catalogue/i,
|
notFound: /не\s*найден|not\s*found|Catalogue/i,
|
||||||
/** STRICT genuine not-found — requires an actual "не найден"/"not found" string,
|
|
||||||
* WITHOUT the loose `Catalogue` token. Use this against the FULL frame (where
|
|
||||||
* the "Spare Parts Catalogue" header is always present, so `notFound` would
|
|
||||||
* false-match), e.g. the warm-Fiat genuine-miss-vs-garble decision — so an
|
|
||||||
* on-panel transient garble falls through to the cold retry instead of null. */
|
|
||||||
notFoundStrict: /не\s*найден|not\s*found/i,
|
|
||||||
|
|
||||||
// ─── Renault flow OCR sets ───
|
|
||||||
/** A catalog app window (Rpartstore / Dialogys / Fiat ePER) is open OVER the
|
|
||||||
* brand grid — its chrome must be closed to fall back to the grid. Covers the
|
|
||||||
* Rpartstore/Dialogys titles + the ePER "Spare Parts" / "Son araca geri dön"
|
|
||||||
* navigation that only appear inside an open catalog. */
|
|
||||||
catalogWindowOpen:
|
|
||||||
/RPartStore|Rpartstore|Dialogys|Son\s*araca|Spare\s*Parts|Yedek\s*par|ePER|Üniversal\s*ürün/i,
|
|
||||||
/** Renault submenu is open (Rpartstore + Dialogys entries visible). */
|
|
||||||
renaultSubmenu: /Rpartstore|Rpart|Dialogys|Dialog/i,
|
|
||||||
/** Rpartstore app/window is open (home OR a vehicle page) — its chrome tokens
|
|
||||||
* ("RPartStore", "Güncel araçlar", "Ne arıyorsunuz", "Grup siparişi",
|
|
||||||
* "Şasi") appear only inside Rpartstore. */
|
|
||||||
rpartstoreOpen: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|arıyor|Şasi|Sasi/i,
|
|
||||||
/** Rpartstore home/search page (the VIN search field is up). */
|
|
||||||
rpartstoreReady: /Güncel\s*ara|arıyor|Grup\s*sipariş|Şasi\s*no|Sasi\s*no|Ara\b/i,
|
|
||||||
/** Rpartstore search-home actually RENDERED its landing markers ("Şasi no ile
|
|
||||||
* arama" / "Ne arıyorsunuz" / "Güncel araçlar" / "Grup siparişi"). This is the
|
|
||||||
* spinner-guard signal: it distinguishes a LOADED home from the "born-stuck"
|
|
||||||
* infinite spinner, which shows only the window CHROME ("RPartStore" title —
|
|
||||||
* matched by rpartstoreOpen) with no search-home content. Kept strictly to
|
|
||||||
* content-only tokens so a spinning-but-titled window never false-passes. */
|
|
||||||
rpartstoreLoaded:
|
|
||||||
/Şasi\s*no\s*ile|Sasi\s*no\s*ile|Ne\s*arıyor|Ne\s*ariyor|Güncel\s*ara(ç|c)|Guncel\s*ara(ç|c)|Grup\s*sipariş|arıyorsunuz/i,
|
|
||||||
/** Rpartstore HARD launch-error modal — the Russian "Ошибка запуска каталога"
|
|
||||||
* (Catalog launch error) dialog that fires within ~8-10s of opening when
|
|
||||||
* Rpartstore is DOWN server-side (entitlement/backend failure). Reopening never
|
|
||||||
* helps, so detecting this must SHORT-CIRCUIT straight to Dialogys instead of
|
|
||||||
* burning the reopen budget. ⚠️ This must be read from an UPSCALED crop of
|
|
||||||
* `VINPIN_RPARTSTORE_ERROR_REGION` — on a full 1×frame the modal is illegible
|
|
||||||
* (see that region's note). The eng-OCR of the Cyrillic "Ошибка запуска
|
|
||||||
* каталога" transliterates to "Owwu6ka 3anycka KaTanora" / "OwwbKa 3anycka
|
|
||||||
* KaTanora" (captured verbatim live 2026-07-15). The `3anycka`+`KaTanora` tokens
|
|
||||||
* are the stable anchors; the "Ошибка" head transliterates variably
|
|
||||||
* (Owwu6ka/OwwbKa/Owwnbka) so all seen forms are listed. Its title alone
|
|
||||||
* ("Renault Rpartstore") false-matches rpartstoreOpen — this content string is
|
|
||||||
* what disambiguates the error. */
|
|
||||||
rpartstoreLaunchError: /запуска\s*катал|Ошибка\s*запуск|3anycka|KaTanora|Owwu6ka|OwwbKa|Owwnbka/i,
|
|
||||||
/** Rpartstore decoded a vehicle — header shows RENAULT/DACIA <model> + Şasi. */
|
|
||||||
rpartstoreHit: /RENAULT|DACIA|Şasi\s*:|Sasi\s*:/i,
|
|
||||||
/** Rpartstore genuine not-found — the error card ("İlişikli araç bulunamadı" /
|
|
||||||
* "Şasi ... bulunamadı" / "Şasi seçilmedi"). */
|
|
||||||
rpartstoreNotFound: /bulunamad|İlişikli|Ilisikli|araç\s*bulun|seçilmedi|secilmedi/i,
|
|
||||||
/** Dialogys form/vehicle page reached (Cyrillic UI, ПОИСК / ИЗМЕНИТЬ). */
|
|
||||||
dialogysReady: /ПОИСК|ПОИC|Dialogys|VIN|ИЗМЕНИТЬ/i,
|
|
||||||
|
|
||||||
// ─── Warm-session daemon OCR sets ───
|
|
||||||
/** The RDS/Horizon session is still ALIVE — the desktop status panel shows the
|
|
||||||
* host/seat identifiers ("HORIZON-RDST01"/"HORIZON-RDST02" / "trvinpin41080").
|
|
||||||
* The seat now LOAD-BALANCES across RDST01 and RDST02, so both host tags count.
|
|
||||||
* Their ABSENCE combined with a `sessionDropped` marker means the session dropped. */
|
|
||||||
sessionAlive: /HORIZON[- ]?RDST0[12]|RDST0[12]|trvinpin\d*/i,
|
|
||||||
/** The RDS/Horizon session DROPPED — a "Disconnected"/reconnect dialog or the
|
|
||||||
* "no free sessions" gate. Triggers a teardown + re-warm + single retry. */
|
|
||||||
sessionDropped:
|
|
||||||
/Disconnected|nofreesession|no\s*free\s*session|reconnect|Session\s*(has\s*)?(been\s*)?(disconnected|ended|expired|timed\s*out)|HTML\s*Access/i,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Which taskbar button belongs to which catalog window. The warm daemon OCRs the
|
|
||||||
* bottom taskbar and matches these labels to bind brand→coordinate at runtime
|
|
||||||
* (the button ORDER depends on the open sequence, so we never hardcode the
|
|
||||||
* mapping — see `orderTaskbarWindows`). `grid` matches the VinPower brand-grid
|
|
||||||
* button used to open the next catalog without closing the current one.
|
|
||||||
*/
|
|
||||||
export const VINPIN_WINDOW_LABELS = {
|
|
||||||
fiat: /ePER|Dealer|Fiat/i,
|
|
||||||
rpartstore: /RPartStore|Rpart/i,
|
|
||||||
dialogys: /Dialogys|Dialog/i,
|
|
||||||
grid: /VinPower|VINPIN|Marka|Brand/i,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type VinpinWindowKey = keyof typeof VINPIN_WINDOW_LABELS;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OCR keyword that confirms a given catalog window is now in the FOREGROUND after
|
|
||||||
* a taskbar raise (reuses the existing state sets). Used to verify a raise landed
|
|
||||||
* on the right window before running a decode on it.
|
|
||||||
*/
|
|
||||||
export const VINPIN_WINDOW_FOREGROUND: Record<"fiat" | "rpartstore" | "dialogys", RegExp> = {
|
|
||||||
// ⚠️ Fiat-window-ONLY tokens. The old `Поиск` token was SHARED with the Dialogys
|
|
||||||
// "ПОИСК" search button (`/Поиск/i.test("ПОИСК") === true`), so in a partial-warm
|
|
||||||
// session where Dialogys ends up foreground the Fiat raise falsely reported success
|
|
||||||
// and the VIN was typed into Dialogys → silent garbage. `Dealer`/`ePER` are Fiat
|
|
||||||
// ePER chrome (never on Dialogys/Rpartstore); the rest are VIN-panel model tokens.
|
|
||||||
fiat: /Spare\s*Parts\s*Catalogue|GRANDE\s*PANDA|TIPO|EGEA|DOBLO|Dealer|ePER/i,
|
|
||||||
rpartstore: /RPartStore|Rpartstore|Güncel\s*ara|Grup\s*sipariş|Şasi|Sasi/i,
|
|
||||||
dialogys: /ПОИСК|ПОИC|Dialogys|ИЗМЕНИТЬ/i,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Horizon HTML-Access client-chrome DOM selectors (REAL DOM, outside the Blast
|
|
||||||
* canvas — hidden until the sidebar is opened). Used by the state-agnostic
|
|
||||||
* stray-app recovery (`closeStrayRunningApps`): the sidebar "Running" panel lists
|
|
||||||
* each RDS app with a per-app close ✕ that TERMINATES that specific app window
|
|
||||||
* regardless of its internal modal/spinner/loading state. Proven live to cleanly
|
|
||||||
* close both a Fiat "Fiat Dealer" window and a Renault Rpartstore launch-error
|
|
||||||
* resume, each returning to a pristine brand grid — the root fix for the
|
|
||||||
* dirty-resume failure the canvas tab-✕ (only hits a TABBED window's ✕) could not
|
|
||||||
* recover. Treated as GIVEN from the live probe; every use is guarded (try/catch +
|
|
||||||
* presence check) so a wrong/absent selector degrades to the canvas/OCR fallback
|
|
||||||
* rather than throwing. ⚠️ A Connection-Server logout+relogin is NOT a recovery
|
|
||||||
* here: the seat publishes apps-only (no desktop → no Start-menu Log Off), so the
|
|
||||||
* RDS session ends ONLY on server-side idle timeout — logout+relogin provably
|
|
||||||
* RESUMES the same dirty window. This DOM path is the recovery instead. */
|
|
||||||
export const VINPIN_DOM = {
|
|
||||||
/** Left-edge grip (~10,450) that reveals/collapses the Horizon sidebar. */
|
|
||||||
sidebarToggler: "#sidebar-toggler",
|
|
||||||
/** One <ul> per running RDS app in the sidebar "Running" panel. */
|
|
||||||
runningApp: "ul.running-app",
|
|
||||||
/** The app's display name inside a running-app row ("VinPower", "Fiat Dealer",
|
|
||||||
* "Renault Rpartstore", "Dialogys", "Loading application..."). */
|
|
||||||
runningAppName: "li.running-app-name, li.focused-app-name",
|
|
||||||
/** Per-app close ✕ inside a running-app row — terminates THAT app window. */
|
|
||||||
appCloseImage: "li.icon-close-app-image",
|
|
||||||
/** "Available → VINPIN" launch tile (relaunch VinPower if it was closed). */
|
|
||||||
availableVinpin: "#available-VINPIN",
|
|
||||||
/** App name to KEEP — every OTHER running app is a stray to terminate. */
|
|
||||||
vinPowerAppName: /VinPower/i,
|
|
||||||
/** Bounded passes over the Running panel (rows shift as apps close). */
|
|
||||||
maxClosePasses: 6,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Bottom Horizon/RDS taskbar clip (px), fed to OCR to read the open windows'
|
|
||||||
* button labels left→right. TUNE against the permanent seat @ 1600x900. */
|
|
||||||
export const VINPIN_TASKBAR_REGION = { x: 0, y: 866, width: 1600, height: 34 } as const;
|
|
||||||
|
|
||||||
/** Candidate taskbar button CENTERS along the bottom bar, left→right. The daemon
|
|
||||||
* binds each open window to a slot by the OCR'd label order. Extra slots give
|
|
||||||
* headroom for drift; a raise is OCR-verified and re-tried on the next slot.
|
|
||||||
* TUNE against the permanent seat @ 1600x900. */
|
|
||||||
export const VINPIN_TASKBAR_SLOTS = [
|
|
||||||
{ x: 220, y: 884 },
|
|
||||||
{ x: 360, y: 884 },
|
|
||||||
{ x: 500, y: 884 },
|
|
||||||
{ x: 640, y: 884 },
|
|
||||||
{ x: 780, y: 884 },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Global "VIN veya katalog ara" router box on the VinPower grid (~1455,96). Only
|
|
||||||
* used to OPEN a brand's window the first time (it routes, it does NOT decode and
|
|
||||||
* the VIN does not carry into the catalog). TUNE. */
|
|
||||||
export const VINPIN_ROUTER_BOX = { x: 1455, y: 96 } as const;
|
|
||||||
|
|
||||||
/** Warm-session daemon tunables. */
|
|
||||||
export const VINPIN_WARM = {
|
|
||||||
/** Business-hours window (Europe/Istanbul) the seat is held warm. */
|
|
||||||
businessStartHour: 8,
|
|
||||||
businessEndHour: 21,
|
|
||||||
/** Idle keepalive cadence — a `mouse.move` every ~75s held the RDS session
|
|
||||||
* 12.6 min with zero disconnect in the live proof. */
|
|
||||||
keepaliveIntervalMs: 75_000,
|
|
||||||
/** Harmless in-window point the keepalive nudges the cursor to. TUNE. */
|
|
||||||
keepalivePoint: { x: 800, y: 500 },
|
|
||||||
/** How often the scheduler reconciles warm-vs-hours. */
|
|
||||||
schedulerIntervalMs: 60_000,
|
|
||||||
/** Retries when verifying/re-trying a taskbar raise across slots. */
|
|
||||||
raiseVerifyRetries: 3,
|
|
||||||
/** After a taskbar-raise click, wait for the window to come forward. */
|
|
||||||
afterRaiseMs: 1_200,
|
|
||||||
/** Backoff after a FAILED warmUp: reconcile() and decode()'s warm-on-demand both
|
|
||||||
* skip re-warming until the cooldown elapses, so a failing seat is not hammered
|
|
||||||
* every ~60s (which leaves a fresh dirty window each attempt). Starts at the base
|
|
||||||
* and doubles per consecutive failure up to the max; a successful warm resets it. */
|
|
||||||
warmBackoffBaseMs: 60_000,
|
|
||||||
warmBackoffMaxMs: 300_000,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given the OCR'd taskbar text and the window labels, return the windows in the
|
|
||||||
* left→right order they appear in the bar. Pure + injectable so the brand→slot
|
|
||||||
* binding is unit-testable without a live seat. Windows whose label isn't found
|
|
||||||
* are omitted (the daemon then falls back to open-order for the missing ones).
|
|
||||||
*/
|
|
||||||
export function orderTaskbarWindows(
|
|
||||||
taskbarText: string,
|
|
||||||
keys: readonly ("fiat" | "rpartstore" | "dialogys")[] = ["fiat", "rpartstore", "dialogys"],
|
|
||||||
): ("fiat" | "rpartstore" | "dialogys")[] {
|
|
||||||
const found: { key: "fiat" | "rpartstore" | "dialogys"; idx: number }[] = [];
|
|
||||||
for (const key of keys) {
|
|
||||||
const label = VINPIN_WINDOW_LABELS[key];
|
|
||||||
const m = label.exec(taskbarText);
|
|
||||||
if (m) found.push({ key, idx: m.index });
|
|
||||||
}
|
|
||||||
return found.sort((a, b) => a.idx - b.idx).map((f) => f.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick which warm catalog WINDOW a VIN should be decoded on. Mirrors
|
|
||||||
* `selectVinpinBrandFlow`: Renault/Dacia → the Rpartstore window (Dialogys is the
|
|
||||||
* in-flow fallback), everything else (incl. Fiat) → the ePER window. Pure/testable.
|
|
||||||
*/
|
|
||||||
export function selectVinpinWarmWindow(vin: string): "fiat" | "rpartstore" {
|
|
||||||
return selectVinpinBrandFlow(vin) === "renault" ? "rpartstore" : "fiat";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current hour (0–23) in Europe/Istanbul. Injectable clock for tests. */
|
|
||||||
export function vinpinIstanbulHour(now: Date = new Date()): number {
|
|
||||||
const hourStr = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: "Europe/Istanbul",
|
|
||||||
hour: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
}).format(now);
|
|
||||||
// Some runtimes emit "24" for midnight with hour12:false — normalise to 0–23.
|
|
||||||
return Number.parseInt(hourStr, 10) % 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the seat should be held warm right now (08:00–21:00 Europe/Istanbul). */
|
|
||||||
export function isVinpinBusinessHours(now: Date = new Date()): boolean {
|
|
||||||
const h = vinpinIstanbulHour(now);
|
|
||||||
return h >= VINPIN_WARM.businessStartHour && h < VINPIN_WARM.businessEndHour;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
|
/** Wait budgets (ms) for each step. Verified live @ 1600x900. */
|
||||||
export const VINPIN_WAITS = {
|
export const VINPIN_WAITS = {
|
||||||
afterGoto: 7_000,
|
afterGoto: 7_000,
|
||||||
/** After submitting the web login: wait for the post-login screen (Horizon
|
afterLogin: 20_000,
|
||||||
* launcher or VinPower brand grid) to render. The real grid render measured
|
|
||||||
* ~32–46s on the permanent seat, so the old 20s always timed out (noise). */
|
|
||||||
afterLogin: 45_000,
|
|
||||||
/** After clicking the launcher's VINPIN app: wait for VinPower to connect and
|
/** After clicking the launcher's VINPIN app: wait for VinPower to connect and
|
||||||
* raise its login dialog (permanent seat only). */
|
* raise its login dialog (permanent seat only). */
|
||||||
afterVinpinLaunch: 14_000,
|
afterVinpinLaunch: 14_000,
|
||||||
@@ -405,91 +87,9 @@ export const VINPIN_WAITS = {
|
|||||||
afterLangDismiss: 2_500,
|
afterLangDismiss: 2_500,
|
||||||
afterSearchOpen: 5_000,
|
afterSearchOpen: 5_000,
|
||||||
afterVinSubmit: 7_000,
|
afterVinSubmit: 7_000,
|
||||||
afterAlertDismiss: 250,
|
afterAlertDismiss: 700,
|
||||||
/** After toggling the Horizon sidebar open/closed (real DOM chrome reveal). */
|
|
||||||
afterSidebarToggle: 1_000,
|
|
||||||
/** Between per-app closes in the Horizon "Running" panel (window teardown settles). */
|
|
||||||
afterRunningAppClose: 2_000,
|
|
||||||
/** OCR poll cadence when waiting for a detectable end-state. Each poll is
|
|
||||||
* CAPPED at the corresponding fixed-wait budget above (kept as a fallback), so
|
|
||||||
* if OCR never catches the transition the total wait == the old fixed sleep and
|
|
||||||
* worst-case behaviour is unchanged. */
|
|
||||||
pollIntervalMs: 700,
|
|
||||||
// ─── Renault flow waits ───
|
|
||||||
/** After clicking the window-close (X) of an open catalog window. */
|
|
||||||
afterWindowClose: 3_500,
|
|
||||||
/** After clicking the Renault brand tile (submenu animates in). */
|
|
||||||
afterRenaultTile: 2_500,
|
|
||||||
/** After picking Rpartstore/Dialogys from the submenu (catalog window opens). */
|
|
||||||
afterRenaultCatalogOpen: 12_000,
|
|
||||||
/** After a Rpartstore (re)open — poll for the search-home landing markers to
|
|
||||||
* confirm it actually LOADED (vs the "born-stuck" infinite spinner). Short so
|
|
||||||
* a stuck instance is detected fast and reopened fresh within the budget. */
|
|
||||||
afterRpartstoreLoad: 12_000,
|
|
||||||
/** After submitting the Rpartstore VIN (Enter) — wait for the vehicle page. */
|
|
||||||
afterRpartstoreSubmit: 8_000,
|
|
||||||
/** After submitting the Dialogys VIN (ПОИСК) — wait for the vehicle page. */
|
|
||||||
afterDialogysSubmit: 8_000,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Hard wall-clock budget (ms) for a SINGLE decode(vin) call, spanning all of its
|
|
||||||
* internal attempts. A single Vinpin seat is shared by every decode, so one VIN
|
|
||||||
* that gets stuck in a re-establish/relaunch livelock must never grind for
|
|
||||||
* minutes and starve real decodes. When the budget is exceeded the driver aborts,
|
|
||||||
* tears the browser down, poisons the seat (forcing the next decode to cold
|
|
||||||
* re-establish) and returns null. Kept well above the healthy p90 (a warm decode
|
|
||||||
* is seconds; a full cold re-establish is ~60-80s) so it only ever fires on a
|
|
||||||
* genuine stall. Bumped 150s→180s to leave a cheap safety margin for a slow cold
|
|
||||||
* establish stacking on the Rpartstore launch-error probe + the Dialogys fallback
|
|
||||||
* decode (~25s) — the Rpartstore launch-error short-circuit means we no longer
|
|
||||||
* waste ~54s of reopens, but the extra headroom covers a cold-establish edge.
|
|
||||||
* Override with VINPIN_DECODE_BUDGET_MS. */
|
|
||||||
export const VINPIN_DECODE_BUDGET_MS = 180_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rpartstore acquire-with-spinner-guard tunables. A freshly-opened Rpartstore
|
|
||||||
* instance sometimes gets "born stuck" on an infinite spinner (survives
|
|
||||||
* raise/maximize); the only reliable fix is to CLOSE the stuck instance and
|
|
||||||
* reopen a FRESH one. `maxOpens` bounds the initial-open-plus-reopen attempts;
|
|
||||||
* `acquireBudgetMs` sub-caps the whole acquire loop so a hopeless Rpartstore
|
|
||||||
* still leaves wall-clock headroom (inside VINPIN_DECODE_BUDGET_MS) to fall back
|
|
||||||
* to Dialogys rather than burning the entire decode budget on reopens.
|
|
||||||
*/
|
|
||||||
export const VINPIN_RPARTSTORE = {
|
|
||||||
// Rpartstore has two failure modes: a genuine born-stuck spinner (reopen can
|
|
||||||
// recover it) and a hard server-side launch error "Ошибка запуска каталога"
|
|
||||||
// (reopen NEVER helps — Rpartstore is DOWN). Detecting the tiny centered error
|
|
||||||
// modal by OCR proved unreliable across renderings, and burning reopens on a
|
|
||||||
// DOWN Rpartstore (a) wastes ~54s and (b) dirties the RDS desktop so the
|
|
||||||
// Dialogys fallback then can't finish inside the budget.
|
|
||||||
// `maxOpens` still bounds reopen attempts (a real transient spinner can recover
|
|
||||||
// on a reopen), but `acquireBudgetMs` is the hard lever: one open+load-poll is
|
|
||||||
// ~22s, so a 14s wall-clock sub-cap makes the loop bail after the FIRST open if
|
|
||||||
// it hasn't loaded — straight to Dialogys on a still-clean seat (~25–52s decode)
|
|
||||||
// — instead of grinding 3 reopens. A healthy Rpartstore loads within the first
|
|
||||||
// open's poll and is used as before; the cap only prevents wasted reopens.
|
|
||||||
maxOpens: 3,
|
|
||||||
acquireBudgetMs: 14_000,
|
|
||||||
/** In-memory cooldown after a Rpartstore HARD launch-error (or repeated
|
|
||||||
* load-failure across all reopens): Rpartstore-down is server-side and
|
|
||||||
* PERSISTENT, so reopening it on every Renault VIN just re-dirties the RDS
|
|
||||||
* desktop (each failed open leaves a resumed window / launch-error modal). While
|
|
||||||
* the cooldown is active, Renault decodes route STRAIGHT to the proven ~30s
|
|
||||||
* Dialogys path (Rpartstore is never opened). A successful Rpartstore load clears
|
|
||||||
* it immediately, so a transient blip self-heals on the next decode. */
|
|
||||||
launchErrorCooldownMs: 10 * 60_000,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** Per-key delay (ms) when typing a VIN into a focused canvas field. A focused
|
|
||||||
* field keeps up at ~20ms; the old 45-50ms was conservative padding (17-char VIN
|
|
||||||
* ≈ 340ms vs ≈ 850ms). */
|
|
||||||
export const VINPIN_TYPE_DELAY_MS = 20;
|
|
||||||
|
|
||||||
/** Backspaces used to clear a VIN field after Ctrl+A + Delete. A few for
|
|
||||||
* insurance on a focused field — the old 40 was overkill. */
|
|
||||||
export const VINPIN_FIELD_CLEAR_BACKSPACES = 5;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Known Fiat model tokens used to extract the model name from the decode modal
|
* Known Fiat model tokens used to extract the model name from the decode modal
|
||||||
* text. The modal interleaves Cyrillic boilerplate, a platform code, the model
|
* text. The modal interleaves Cyrillic boilerplate, a platform code, the model
|
||||||
@@ -525,92 +125,11 @@ export const FIAT_MODEL_TOKENS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Known Renault/Dacia model tokens used to extract the model name from the
|
* Brands for which the Vinpin fallback is enabled. Fiat-only for now (the
|
||||||
* Rpartstore/Dialogys vehicle-page header. Same token-matching strategy as the
|
* Egea/NM4356 no-catalog cluster); a Set so it's trivially extensible later.
|
||||||
* Fiat parser (far more robust than positional parsing against OCR noise).
|
* Compared case-insensitively against the canonical browse brand.
|
||||||
* Generation numbers / roman numerals in the header are ignored — PL24 catalog
|
|
||||||
* rows carry their own generation suffixes and matching is best-effort.
|
|
||||||
*/
|
*/
|
||||||
export const RENAULT_MODEL_TOKENS = [
|
export const VINPIN_BRAND_ALLOWLIST = new Set<string>(["fiat"]);
|
||||||
"CLIO",
|
|
||||||
"LUTECIA", // Clio's JP/TR export name — appears in PL24 rows ("CLIO 4 / LUTECIA 4")
|
|
||||||
"MEGANE",
|
|
||||||
"SCENIC",
|
|
||||||
"KANGOO",
|
|
||||||
"LAGUNA",
|
|
||||||
"LATITUDE",
|
|
||||||
"SAFRANE",
|
|
||||||
"FLUENCE",
|
|
||||||
"SYMBOL",
|
|
||||||
"THALIA",
|
|
||||||
"KADJAR",
|
|
||||||
"CAPTUR",
|
|
||||||
"DUSTER",
|
|
||||||
"SANDERO",
|
|
||||||
"LOGAN",
|
|
||||||
"TALISMAN",
|
|
||||||
"ESPACE",
|
|
||||||
"TWINGO",
|
|
||||||
"MODUS",
|
|
||||||
"KOLEOS",
|
|
||||||
"TRAFIC",
|
|
||||||
"MASTER",
|
|
||||||
"TALIANT",
|
|
||||||
"ARKANA",
|
|
||||||
"AUSTRAL",
|
|
||||||
"VELSATIS",
|
|
||||||
"TWIZY",
|
|
||||||
"ZOE",
|
|
||||||
"EXPRESS",
|
|
||||||
// Dacia
|
|
||||||
"DOKKER",
|
|
||||||
"LODGY",
|
|
||||||
"SPRING",
|
|
||||||
// Old R-number platforms + TR-market badges (pre-2000). These genuinely need
|
|
||||||
// Rpartstore to VIN-resolve, so this mostly future-proofs the header parse for
|
|
||||||
// an old Renault (e.g. VF1553… R19). The parser tokenises on non-alphanumerics,
|
|
||||||
// so a BARE "19" would collide with year/engine digits — the R-prefixed form
|
|
||||||
// ("R19") is required so it only matches the actual model badge, never a number.
|
|
||||||
"R5",
|
|
||||||
"R9",
|
|
||||||
"R11",
|
|
||||||
"R12",
|
|
||||||
"R19",
|
|
||||||
"R21",
|
|
||||||
"R25",
|
|
||||||
"EUROPA", // R19 Europa (TR)
|
|
||||||
"BROADWAY", // R9 Broadway (TR)
|
|
||||||
"TOROS", // R12 Toros (TR)
|
|
||||||
"FLASH", // R11 Flash (TR)
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** WMIs that route to the Renault (Rpartstore/Dialogys) flow. VF1/VF2 are the
|
|
||||||
* common Renault WMIs; VF6/VF7 are included per the benchmark (some Renault/
|
|
||||||
* Dacia LCVs and older platforms). Dacia-badged cars often carry a Renault WMI
|
|
||||||
* (e.g. VF1 Duster) — the RENAULT-vs-DACIA distinction is read from the decode
|
|
||||||
* header, not the WMI. UU1 is the dedicated Dacia WMI. */
|
|
||||||
const RENAULT_FLOW_WMIS = new Set<string>(["VF1", "VF2", "VF6", "VF7", "UU1"]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the Vinpin catalog flow for a VIN by brand. Renault/Dacia → the new
|
|
||||||
* Rpartstore/Dialogys flow; everything else (including Fiat) → the existing ePER
|
|
||||||
* flow, so Fiat behaviour is byte-identical. Derived from the WMI: trusts
|
|
||||||
* `getBrandFromWmi` first (Renault/Dacia), then a Renault-WMI allowlist.
|
|
||||||
*/
|
|
||||||
export function selectVinpinBrandFlow(vin: string): "renault" | "fiat" {
|
|
||||||
const wmi = (vin || "").toUpperCase().slice(0, 3);
|
|
||||||
const brand = getBrandFromWmi(wmi);
|
|
||||||
if (brand === "Renault" || brand === "Dacia") return "renault";
|
|
||||||
if (RENAULT_FLOW_WMIS.has(wmi)) return "renault";
|
|
||||||
return "fiat";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brands for which the Vinpin fallback is enabled. Fiat (Egea/NM4356 no-catalog
|
|
||||||
* cluster) + Renault/Dacia (Rpartstore/Dialogys flow). A Set so it's trivially
|
|
||||||
* extensible. Compared case-insensitively against the canonical browse brand.
|
|
||||||
*/
|
|
||||||
export const VINPIN_BRAND_ALLOWLIST = new Set<string>(["fiat", "renault", "dacia"]);
|
|
||||||
|
|
||||||
export function isVinpinBrandAllowed(brand: string | null | undefined): boolean {
|
export function isVinpinBrandAllowed(brand: string | null | undefined): boolean {
|
||||||
if (!brand) return false;
|
if (!brand) return false;
|
||||||
|
|||||||
@@ -12,18 +12,6 @@ describe("modelTokens", () => {
|
|||||||
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
|
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
|
||||||
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
|
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps engine displacements as tokens (only 1950-2039 count as years)", () => {
|
|
||||||
// The 4-digit engine sizes must survive — dropping them made the ancient
|
|
||||||
// "TIPO 1100-1370-1580" collapse to just ["TIPO"] and win the tiebreak.
|
|
||||||
expect(modelTokens("TIPO 1100-1370-1580 (1987-1993)")).toEqual([
|
|
||||||
"TIPO",
|
|
||||||
"1100",
|
|
||||||
"1370",
|
|
||||||
"1580",
|
|
||||||
]);
|
|
||||||
expect(modelTokens("TIPO 1750-2000 (1990-1993)")).toEqual(["TIPO", "1750"]); // 2000 = year-range
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseYearRange", () => {
|
describe("parseYearRange", () => {
|
||||||
@@ -64,124 +52,4 @@ describe("pickBestCatalogMatch", () => {
|
|||||||
it("returns null for an empty model", () => {
|
it("returns null for an empty model", () => {
|
||||||
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
|
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never falls back onto an ancient catalog for a bare, year-less model", () => {
|
|
||||||
const withAncient: CatalogCandidate[] = [
|
|
||||||
...candidates,
|
|
||||||
{ id: "tipo-1987", model: "TIPO 1100-1370-1580 (1987-1993)", year: "1987-1993" },
|
|
||||||
];
|
|
||||||
// Bare "TIPO", no year (the exact shape of the NM4131 false positive that
|
|
||||||
// used to land on the 1987 Tipo). Must resolve to a modern Egea instead.
|
|
||||||
const id = pickBestCatalogMatch({ model: "TIPO", modelYear: null }, withAncient);
|
|
||||||
expect(id).not.toBe("tipo-1987");
|
|
||||||
expect(["egea", "egea-mca"]).toContain(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("breaks a score tie toward the newer catalog", () => {
|
|
||||||
const puntos: CatalogCandidate[] = [
|
|
||||||
{ id: "punto-old", model: "PUNTO (1993-1999)", year: "1993-1999" },
|
|
||||||
{ id: "punto-new", model: "PUNTO (2012-2018)", year: "2012-2018" },
|
|
||||||
];
|
|
||||||
// Identical tokens + no year → equal score → recency decides.
|
|
||||||
expect(pickBestCatalogMatch({ model: "PUNTO", modelYear: null }, puntos)).toBe("punto-new");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("breaks a score tie toward the richer catalog (more categories)", () => {
|
|
||||||
const koleos: CatalogCandidate[] = [
|
|
||||||
{ id: "koleos-thin", model: "KOLEOS", year: null, categoryCount: 12 },
|
|
||||||
{ id: "koleos-full", model: "KOLEOS", year: null, categoryCount: 44 },
|
|
||||||
];
|
|
||||||
// Identical tokens + no year → equal score → category count decides.
|
|
||||||
expect(pickBestCatalogMatch({ model: "KOLEOS", modelYear: null }, koleos)).toBe("koleos-full");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pickBestCatalogMatch — market-qualifier precision (Renault/Dacia)", () => {
|
|
||||||
it("prefers the EXACT model over a wrong-market superset (KADJAR beats KADJAR ÇİN)", () => {
|
|
||||||
// The confirmed prod bug: a European VF1 Renault decoded as "KADJAR" landed
|
|
||||||
// on the China-market "KADJAR ÇİN" catalog (fewer categories, wrong parts).
|
|
||||||
const candidates: CatalogCandidate[] = [
|
|
||||||
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 22 },
|
|
||||||
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 44 },
|
|
||||||
];
|
|
||||||
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: "2018" }, candidates)).toBe(
|
|
||||||
"kadjar-eu",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still picks the exact model even when the market candidate is richer", () => {
|
|
||||||
// Market penalty must dominate — an exact match wins regardless of category
|
|
||||||
// count or ordering.
|
|
||||||
const candidates: CatalogCandidate[] = [
|
|
||||||
{ id: "kadjar-cn", model: "KADJAR ÇİN", year: null, categoryCount: 999 },
|
|
||||||
{ id: "kadjar-eu", model: "KADJAR", year: null, categoryCount: 1 },
|
|
||||||
];
|
|
||||||
expect(pickBestCatalogMatch({ model: "KADJAR", modelYear: null }, candidates)).toBe(
|
|
||||||
"kadjar-eu",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("penalizes a market qualifier but NOT a generation token (CLIO)", () => {
|
|
||||||
const candidates: CatalogCandidate[] = [
|
|
||||||
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
|
|
||||||
{ id: "clio", model: "CLIO", year: null },
|
|
||||||
];
|
|
||||||
// Exact "CLIO" beats the China-market superset.
|
|
||||||
expect(pickBestCatalogMatch({ model: "CLIO", modelYear: null }, candidates)).toBe("clio");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps generations matchable — a generation extra token is not a market penalty", () => {
|
|
||||||
// Decoded "CLIO" with only generation-qualified catalogs (no exact bare CLIO)
|
|
||||||
// and one China catalog. The generation candidate must win over the market one,
|
|
||||||
// proving the market penalty doesn't collateral-damage generations.
|
|
||||||
const candidates: CatalogCandidate[] = [
|
|
||||||
{ id: "clio-cn", model: "CLIO ÇİN", year: null },
|
|
||||||
{
|
|
||||||
id: "clio-4",
|
|
||||||
model: "CLIO 4 / LUTECIA 4 (2012-2019)",
|
|
||||||
year: "2012-2019",
|
|
||||||
categoryCount: 40,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "clio-3",
|
|
||||||
model: "CLIO 3 / LUTECIA 3 (2005-2014)",
|
|
||||||
year: "2005-2014",
|
|
||||||
categoryCount: 30,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const id = pickBestCatalogMatch({ model: "CLIO", modelYear: "2015" }, candidates);
|
|
||||||
// Year-overlap picks the 2012-2019 gen-4; the China catalog is penalized out.
|
|
||||||
expect(id).toBe("clio-4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches an exact generation model and penalizes only the market superset (DUSTER II)", () => {
|
|
||||||
// Roman-numeral generations survive tokenization (single digits are dropped by
|
|
||||||
// the length>=2 filter, an existing behavior). Decoded "DUSTER II" is exact on
|
|
||||||
// duster-2; "DUSTER II ÇİN" carries an extra market token and is penalized out.
|
|
||||||
const candidates: CatalogCandidate[] = [
|
|
||||||
{ id: "duster-2", model: "DUSTER II", year: null, categoryCount: 30 },
|
|
||||||
{ id: "duster-cn", model: "DUSTER II ÇİN", year: null, categoryCount: 30 },
|
|
||||||
];
|
|
||||||
expect(pickBestCatalogMatch({ model: "DUSTER II", modelYear: null }, candidates)).toBe(
|
|
||||||
"duster-2",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to a market catalog only when it is the sole candidate", () => {
|
|
||||||
// If the ONLY catalog for a decoded model is a regional one, still match it —
|
|
||||||
// the penalty lowers the score but does not disqualify.
|
|
||||||
const candidates: CatalogCandidate[] = [{ id: "x62-cn", model: "X62 CHINE", year: null }];
|
|
||||||
expect(pickBestCatalogMatch({ model: "X62", modelYear: null }, candidates)).toBe("x62-cn");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("modelTokens — diacritic folding", () => {
|
|
||||||
it("folds Turkish diacritics so market qualifiers survive as ASCII tokens", () => {
|
|
||||||
// Without folding, ÇİN loses Ç/İ to the ASCII strip and collapses to a
|
|
||||||
// dropped 1-char "N", making KADJAR ÇİN tokenize identically to KADJAR.
|
|
||||||
expect(modelTokens("KADJAR ÇİN")).toEqual(["KADJAR", "CIN"]);
|
|
||||||
expect(modelTokens("ARKANA RUSYA")).toEqual(["ARKANA", "RUSYA"]);
|
|
||||||
// The single-digit "2" is dropped by the length>=2 filter (existing behavior).
|
|
||||||
expect(modelTokens("KOLEOS 2 - ÇİN")).toEqual(["KOLEOS", "CIN"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,96 +18,26 @@ export interface CatalogCandidate {
|
|||||||
id: string;
|
id: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
year: string | null;
|
year: string | null;
|
||||||
/** How many catalog categories this vehicle has fetched. Used only as a final
|
|
||||||
* tiebreak among otherwise-equal candidates — the fuller catalog is the
|
|
||||||
* mainstream one. Optional so the pure picker can be unit-tested without it. */
|
|
||||||
categoryCount?: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Market/region qualifier tokens (diacritic-folded, upper-case). A catalog model
|
|
||||||
* that carries one of these tokens BEYOND the decoded model targets a specific
|
|
||||||
* regional market — e.g. "KADJAR ÇİN" is the China-market Kadjar catalog (source
|
|
||||||
* XZH, 22 categories) vs the mainstream European "KADJAR" (XFE, 44 categories).
|
|
||||||
* For a European (VF1…) VIN the regional catalog serves the wrong parts, so a
|
|
||||||
* candidate whose EXTRA tokens are region qualifiers is heavily penalized — but
|
|
||||||
* only when the token is extra (the decode itself doesn't carry a market today).
|
|
||||||
*
|
|
||||||
* Generation tokens (roman numerals, digits, platform codes like "II"/"3"/"XM3")
|
|
||||||
* are deliberately NOT here, so multi-generation models stay fully matchable;
|
|
||||||
* only MARKET qualifiers get the penalty.
|
|
||||||
*
|
|
||||||
* Grounded in prod data — the real Renault/Dacia catalog models that carry a
|
|
||||||
* region qualifier are: KADJAR ÇİN, CAPTUR II ÇİN, KOLEOS 2 - ÇİN, ARKANA RUSYA
|
|
||||||
* and X62 CHINE. ("EUROPE" appears too — ARKANA EUROPE, CAPTUR II EUROPE — but
|
|
||||||
* that is the mainstream market and is never penalized.)
|
|
||||||
*/
|
|
||||||
export const MARKET_QUALIFIERS = new Set<string>([
|
|
||||||
// China — "ÇİN" folds to CIN via diacritic normalization; CHINE is the French
|
|
||||||
// spelling seen in "X62 CHINE".
|
|
||||||
"CIN",
|
|
||||||
"CHINA",
|
|
||||||
"CHINE",
|
|
||||||
"CN",
|
|
||||||
// Russia
|
|
||||||
"RUSYA",
|
|
||||||
"RUSSIA",
|
|
||||||
"RU",
|
|
||||||
// Brazil
|
|
||||||
"BREZILYA",
|
|
||||||
"BRAZIL",
|
|
||||||
"BR",
|
|
||||||
// India
|
|
||||||
"HINDISTAN",
|
|
||||||
"INDIA",
|
|
||||||
"IN",
|
|
||||||
// Korea
|
|
||||||
"KORE",
|
|
||||||
"KOREA",
|
|
||||||
// Mexico / Mercosur
|
|
||||||
"MEKSIKA",
|
|
||||||
"MEXICO",
|
|
||||||
"MERCOSUR",
|
|
||||||
// Other regional
|
|
||||||
"GCC",
|
|
||||||
"USA",
|
|
||||||
"AMERIKA",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export interface DecodedForMatch {
|
export interface DecodedForMatch {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
modelYear: string | null;
|
modelYear: string | null;
|
||||||
/** Brand to match against (catalog_vehicles.brand_name). Defaults to Fiat when
|
|
||||||
* omitted, preserving the original Fiat-only behaviour. */
|
|
||||||
brand?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tokenize a model string into upper-case model tokens, dropping parenthetical
|
/** Tokenize a model string into upper-case model tokens, dropping parenthetical
|
||||||
* groups (year ranges) and bare 4-digit *years*. "TIPO - EGEA (2015-2021)" →
|
* groups (year ranges) and bare 4-digit years. "TIPO - EGEA (2015-2021)" →
|
||||||
* ["TIPO","EGEA"]. Keeps "500"/"500X" AND engine displacements like 1100 / 1370
|
* ["TIPO","EGEA"]. Keeps "500" / "500X" (not 4-digit years). */
|
||||||
* / 1580 / 2400 — only 1950-2039 (plausible car years) are treated as years.
|
|
||||||
* Dropping displacements made ancient catalogs ("TIPO 1100-1370-1580") collapse
|
|
||||||
* to just ["TIPO"], looking falsely specific and winning the fewest-extra-tokens
|
|
||||||
* tiebreak over the modern Egea. */
|
|
||||||
export function modelTokens(s: string | null | undefined): string[] {
|
export function modelTokens(s: string | null | undefined): string[] {
|
||||||
if (!s) return [];
|
if (!s) return [];
|
||||||
return (
|
return s
|
||||||
s
|
.toUpperCase()
|
||||||
.toUpperCase()
|
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
|
||||||
// Fold diacritics to ASCII so market qualifiers survive tokenization. Without
|
.replace(/\b\d{4}\b/g, " ") // drop bare years (keeps 3-digit "500")
|
||||||
// this the Turkish "ÇİN" (China) loses its Ç/İ to the [^A-Z0-9] strip below
|
.replace(/[^A-Z0-9]+/g, " ")
|
||||||
// and collapses to a dropped 1-char "N" — making "KADJAR ÇİN" tokenize
|
.split(" ")
|
||||||
// IDENTICALLY to "KADJAR" and defeating both the specificity tiebreak and the
|
.map((t) => t.trim())
|
||||||
// market penalty. NFD + combining-mark removal maps ÇİN→CIN, Ş→S, Ğ→G, Ö→O …
|
.filter((t) => t.length >= 2);
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/\p{M}/gu, "") // strip the combining marks NFD split off
|
|
||||||
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
|
|
||||||
.replace(/\b(19[5-9]\d|20[0-3]\d)\b/g, " ") // drop bare YEARS 1950-2039; keep displacements
|
|
||||||
.replace(/[^A-Z0-9]+/g, " ")
|
|
||||||
.split(" ")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter((t) => t.length >= 2)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a year range from free text. Returns {start, end} where end===null
|
/** Parse a year range from free text. Returns {start, end} where end===null
|
||||||
@@ -140,18 +70,8 @@ function yearInRange(year: number, range: { start: number; end: number | null })
|
|||||||
/**
|
/**
|
||||||
* Pick the best catalog_vehicles row for a decoded model + year. A candidate
|
* Pick the best catalog_vehicles row for a decoded model + year. A candidate
|
||||||
* qualifies only when EVERY decoded model token appears in the candidate's
|
* qualifies only when EVERY decoded model token appears in the candidate's
|
||||||
* model tokens (subset match). Scoring, strongest signal first:
|
* model tokens (subset match). Among qualifiers, prefer year-range overlap,
|
||||||
* 1. Year-range overlap — the strongest signal; routes multi-generation models
|
* then the most specific (fewest extra tokens). Returns the id or null.
|
||||||
* (a 2022 "TIPO-EGEA" → the 2020+ MCA catalog, not the 2015-2021 one).
|
|
||||||
* 2. EXACT normalized model match (candidate tokens ≡ decoded tokens) — a
|
|
||||||
* smaller bonus that breaks the tie in favour of a plain "KADJAR" over the
|
|
||||||
* superset "KADJAR ÇİN" when no year distinguishes them.
|
|
||||||
* 3. Market-qualifier penalty — extra tokens that are region qualifiers (ÇİN,
|
|
||||||
* RUSYA, …) make this a WRONG-market catalog; penalized hard so it only ever
|
|
||||||
* wins as the sole candidate. Generation / other extra tokens keep only the
|
|
||||||
* mild "fewer extras = more specific" penalty, so generations stay matchable.
|
|
||||||
* 4. Tiebreaks on equal score: richer catalog (more categories), then newer.
|
|
||||||
* Returns the id or null.
|
|
||||||
*/
|
*/
|
||||||
export function pickBestCatalogMatch(
|
export function pickBestCatalogMatch(
|
||||||
decoded: DecodedForMatch,
|
decoded: DecodedForMatch,
|
||||||
@@ -161,12 +81,7 @@ export function pickBestCatalogMatch(
|
|||||||
if (decTokens.length === 0) return null;
|
if (decTokens.length === 0) return null;
|
||||||
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
|
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
|
||||||
|
|
||||||
let best: {
|
let best: { id: string; score: number } | null = null;
|
||||||
id: string;
|
|
||||||
score: number;
|
|
||||||
categoryCount: number;
|
|
||||||
startYear: number;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
for (const cand of candidates) {
|
for (const cand of candidates) {
|
||||||
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
|
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
|
||||||
@@ -176,47 +91,17 @@ export function pickBestCatalogMatch(
|
|||||||
// Subset requirement — all decoded model tokens must be present.
|
// Subset requirement — all decoded model tokens must be present.
|
||||||
if (!decTokens.every((t) => candSet.has(t))) continue;
|
if (!decTokens.every((t) => candSet.has(t))) continue;
|
||||||
|
|
||||||
const extraTokens = candTokens.filter((t) => !decTokens.includes(t));
|
|
||||||
|
|
||||||
let score = 1000; // base for any qualifying candidate
|
let score = 1000; // base for any qualifying candidate
|
||||||
|
|
||||||
// (1) EXACT normalized model-name match: candidate carries NO tokens beyond
|
|
||||||
// the decoded model. This breaks the tie in favour of the plain model over a
|
|
||||||
// superset (e.g. "KADJAR" over "KADJAR ÇİN" when neither has a distinguishing
|
|
||||||
// year). Kept SMALLER than the year-range swing (±700) on purpose, so a
|
|
||||||
// decisive model-year still routes generations correctly — a 2022 "TIPO-EGEA"
|
|
||||||
// must still land on the 2020+ MCA catalog, not the exact-named 2015-2021 one.
|
|
||||||
if (extraTokens.length === 0) score += 300;
|
|
||||||
|
|
||||||
// (2) Year-range overlap.
|
|
||||||
const range = parseYearRange(candText);
|
const range = parseYearRange(candText);
|
||||||
if (decYear && Number.isFinite(decYear) && range) {
|
if (decYear && Number.isFinite(decYear) && range) {
|
||||||
score += yearInRange(decYear, range) ? 500 : -200;
|
score += yearInRange(decYear, range) ? 500 : -200;
|
||||||
}
|
}
|
||||||
|
// Tiebreak: fewer extra tokens = more specific match.
|
||||||
|
const extra = candTokens.filter((t) => !decTokens.includes(t)).length;
|
||||||
|
score -= extra;
|
||||||
|
|
||||||
// (3) Extra-token penalties. A region-qualifier extra token means the
|
if (!best || score > best.score) {
|
||||||
// candidate is a wrong-market catalog for a decode that carries no market —
|
best = { id: cand.id, score };
|
||||||
// penalize hard so it can only win when it is the ONLY candidate. Everything
|
|
||||||
// else (generation numerals, platform codes) keeps the mild specificity
|
|
||||||
// penalty, leaving multi-generation models fully matchable.
|
|
||||||
const marketExtras = extraTokens.filter((t) => MARKET_QUALIFIERS.has(t)).length;
|
|
||||||
const otherExtras = extraTokens.length - marketExtras;
|
|
||||||
score -= otherExtras;
|
|
||||||
score -= marketExtras * 5000;
|
|
||||||
|
|
||||||
// (4) Tiebreaks on equal score: prefer the richer catalog (more categories —
|
|
||||||
// the fuller catalog is the mainstream one), then the newer generation. A
|
|
||||||
// bare, year-less model (e.g. "TIPO") must never fall back onto an ancient
|
|
||||||
// generation — modern is the overwhelmingly likelier intent.
|
|
||||||
const categoryCount = cand.categoryCount ?? 0;
|
|
||||||
const startYear = range?.start ?? 0;
|
|
||||||
const better =
|
|
||||||
!best ||
|
|
||||||
score > best.score ||
|
|
||||||
(score === best.score && categoryCount > best.categoryCount) ||
|
|
||||||
(score === best.score && categoryCount === best.categoryCount && startYear > best.startYear);
|
|
||||||
if (better) {
|
|
||||||
best = { id: cand.id, score, categoryCount, startYear };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,35 +115,25 @@ export class VinpinCatalogMatcher {
|
|||||||
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
constructor(@Inject(DATABASE) private readonly db: Database) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the best PL24 catalog_vehicle (of the decoded brand — Fiat by default,
|
* Find the best PL24 Fiat catalog_vehicle for a decoded model + year.
|
||||||
* or Renault/Dacia) for a decoded model + year. Returns the id, or null.
|
* Returns the catalog_vehicle id, or null when nothing matches.
|
||||||
*/
|
*/
|
||||||
async match(decoded: DecodedForMatch): Promise<string | null> {
|
async match(decoded: DecodedForMatch): Promise<string | null> {
|
||||||
if (!decoded.model) return null;
|
if (!decoded.model) return null;
|
||||||
const brand = (decoded.brand ?? "Fiat").toLowerCase();
|
|
||||||
const rows = await this.db
|
const rows = await this.db
|
||||||
.select({
|
.select({
|
||||||
id: catalogVehicles.id,
|
id: catalogVehicles.id,
|
||||||
model: catalogVehicles.model,
|
model: catalogVehicles.model,
|
||||||
year: catalogVehicles.year,
|
year: catalogVehicles.year,
|
||||||
// Category count feeds the richer-catalog tiebreak (more categories = the
|
|
||||||
// mainstream catalog). Correlated subquery keeps the row shape flat.
|
|
||||||
categoryCount: sql<number>`(
|
|
||||||
SELECT count(*)::int FROM categories
|
|
||||||
WHERE categories.catalog_vehicle_id = ${catalogVehicles.id}
|
|
||||||
)`,
|
|
||||||
})
|
})
|
||||||
.from(catalogVehicles)
|
.from(catalogVehicles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(sql`lower(${catalogVehicles.brandName}) = 'fiat'`, eq(catalogVehicles.source, "pl24")),
|
||||||
sql`lower(${catalogVehicles.brandName}) = ${brand}`,
|
|
||||||
eq(catalogVehicles.source, "pl24"),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const id = pickBestCatalogMatch(decoded, rows as CatalogCandidate[]);
|
const id = pickBestCatalogMatch(decoded, rows as CatalogCandidate[]);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`match brand=${brand} model="${decoded.model}" year=${decoded.modelYear ?? "?"} → ${id ?? "null"} (over ${rows.length} ${brand} catalog vehicles)`,
|
`match model="${decoded.model}" year=${decoded.modelYear ?? "?"} → ${id ?? "null"} (over ${rows.length} Fiat catalog vehicles)`,
|
||||||
);
|
);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { pollForText } from "./vinpin.ocr";
|
|
||||||
|
|
||||||
/** Deterministic fake clock: `sleep` advances virtual time; `now` reads it. */
|
|
||||||
function makeClock() {
|
|
||||||
let t = 0;
|
|
||||||
const slept: number[] = [];
|
|
||||||
return {
|
|
||||||
now: () => t,
|
|
||||||
sleep: async (ms: number) => {
|
|
||||||
slept.push(ms);
|
|
||||||
t += ms;
|
|
||||||
},
|
|
||||||
slept,
|
|
||||||
total: () => slept.reduce((a, b) => a + b, 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("pollForText", () => {
|
|
||||||
it("returns early as soon as the predicate matches (fast path)", async () => {
|
|
||||||
const clock = makeClock();
|
|
||||||
const reads = ["no", "no", "hit"];
|
|
||||||
let i = 0;
|
|
||||||
const res = await pollForText({
|
|
||||||
read: async () => reads[i++] ?? "",
|
|
||||||
predicate: (t) => t === "hit",
|
|
||||||
capMs: 10_000,
|
|
||||||
intervalMs: 700,
|
|
||||||
sleep: clock.sleep,
|
|
||||||
now: clock.now,
|
|
||||||
});
|
|
||||||
expect(res.matched).toBe(true);
|
|
||||||
expect(res.text).toBe("hit");
|
|
||||||
// Stopped at the 3rd read — well under the cap.
|
|
||||||
expect(i).toBe(3);
|
|
||||||
expect(clock.total()).toBe(2100);
|
|
||||||
expect(clock.total()).toBeLessThan(10_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps total sleep at capMs when the predicate never matches (fallback == old fixed wait)", async () => {
|
|
||||||
const clock = makeClock();
|
|
||||||
let reads = 0;
|
|
||||||
const res = await pollForText({
|
|
||||||
read: async () => {
|
|
||||||
reads++;
|
|
||||||
return "no";
|
|
||||||
},
|
|
||||||
predicate: (t) => t === "hit",
|
|
||||||
capMs: 2_000,
|
|
||||||
intervalMs: 700,
|
|
||||||
sleep: clock.sleep,
|
|
||||||
now: clock.now,
|
|
||||||
});
|
|
||||||
expect(res.matched).toBe(false);
|
|
||||||
expect(res.text).toBe("no");
|
|
||||||
// Total sleep is exactly the cap — never longer than the old fixed wait.
|
|
||||||
expect(clock.total()).toBe(2_000);
|
|
||||||
// The final interval was clamped to the remaining budget (no overshoot).
|
|
||||||
expect(Math.max(...clock.slept)).toBeLessThanOrEqual(700);
|
|
||||||
expect(reads).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches on the very first read without over-sleeping", async () => {
|
|
||||||
const clock = makeClock();
|
|
||||||
const res = await pollForText({
|
|
||||||
read: async () => "ready",
|
|
||||||
predicate: (t) => t.includes("ready"),
|
|
||||||
capMs: 12_000,
|
|
||||||
intervalMs: 750,
|
|
||||||
sleep: clock.sleep,
|
|
||||||
now: clock.now,
|
|
||||||
});
|
|
||||||
expect(res.matched).toBe(true);
|
|
||||||
expect(clock.total()).toBe(750); // one interval, then matched
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -133,43 +133,6 @@ export async function ocrRegion(page: Page, clip?: OcrClip, scale = 3): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll a text `read` until `predicate(text)` holds, or the `capMs` budget is
|
|
||||||
* exhausted. Returns as soon as the predicate matches (the common, fast case);
|
|
||||||
* otherwise the total elapsed sleep equals `capMs` — the SAME fallback wait the
|
|
||||||
* old fixed `waitForTimeout(capMs)` used, so worst-case behaviour is unchanged.
|
|
||||||
*
|
|
||||||
* Pure/injectable (no Playwright/OCR deps) so it can be unit-tested directly:
|
|
||||||
* pass a fake `read`, `sleep` and `now`. The driver wraps it with an OCR read.
|
|
||||||
*/
|
|
||||||
export interface PollForTextOptions {
|
|
||||||
read: () => Promise<string>;
|
|
||||||
predicate: (text: string) => boolean;
|
|
||||||
/** Fallback cap — total sleep never exceeds this (== the old fixed wait). */
|
|
||||||
capMs: number;
|
|
||||||
/** Cadence between reads. */
|
|
||||||
intervalMs: number;
|
|
||||||
sleep: (ms: number) => Promise<void>;
|
|
||||||
/** Injectable clock (defaults to Date.now) — for deterministic tests. */
|
|
||||||
now?: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pollForText(
|
|
||||||
opts: PollForTextOptions,
|
|
||||||
): Promise<{ matched: boolean; text: string }> {
|
|
||||||
const now = opts.now ?? (() => Date.now());
|
|
||||||
const interval = Math.max(1, opts.intervalMs);
|
|
||||||
const deadline = now() + opts.capMs;
|
|
||||||
let text = "";
|
|
||||||
while (now() < deadline) {
|
|
||||||
const remaining = deadline - now();
|
|
||||||
await opts.sleep(Math.min(interval, remaining));
|
|
||||||
text = await opts.read();
|
|
||||||
if (opts.predicate(text)) return { matched: true, text };
|
|
||||||
}
|
|
||||||
return { matched: false, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tear down the shared tesseract worker (best-effort). */
|
/** Tear down the shared tesseract worker (best-effort). */
|
||||||
export async function terminateOcr(): Promise<void> {
|
export async function terminateOcr(): Promise<void> {
|
||||||
if (!workerPromise) return;
|
if (!workerPromise) return;
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { isUsableParse, parseVinpinModal } from "./vinpin.parser";
|
||||||
isUsableParse,
|
|
||||||
isUsableRenaultParse,
|
|
||||||
parseRenaultHeader,
|
|
||||||
parseVinpinModal,
|
|
||||||
} from "./vinpin.parser";
|
|
||||||
|
|
||||||
const SAMPLE =
|
const SAMPLE =
|
||||||
"MVS, найденные по vin NM435600006H43436 6J - TIPO - EGEA (2015-2021) 356G37001162 - 3V HIGH PLUS 1.6 120CV D5 CM E6 GS TR Двигатель: 8165300 Автомобиль: 279476 Prod. date: 2/8/2017 Vin: NM435600006H43436";
|
"MVS, найденные по vin NM435600006H43436 6J - TIPO - EGEA (2015-2021) 356G37001162 - 3V HIGH PLUS 1.6 120CV D5 CM E6 GS TR Двигатель: 8165300 Автомобиль: 279476 Prod. date: 2/8/2017 Vin: NM435600006H43436";
|
||||||
@@ -40,71 +35,4 @@ describe("parseVinpinModal", () => {
|
|||||||
expect(isUsableParse(p)).toBe(false);
|
expect(isUsableParse(p)).toBe(false);
|
||||||
expect(isUsableParse(parseVinpinModal(null))).toBe(false);
|
expect(isUsableParse(parseVinpinModal(null))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a stale-breadcrumb model with no SINCOM (vehicle-not-found false positive)", () => {
|
|
||||||
// Real garbled OCR of a failed NM4131 decode: the "не найден" alert is
|
|
||||||
// mangled ("He HaaeHs") so it slips the not-found check, and the screen
|
|
||||||
// still shows the operator's last catalog ("FIAT » TIPO - EGEA"). A model
|
|
||||||
// token is present but there is NO SINCOM → must be treated as unusable so
|
|
||||||
// it is not false-mapped onto the ancient "TIPO 1987-1993" catalog.
|
|
||||||
const p = parseVinpinModal(
|
|
||||||
"Fiat Dealer Spare Parts FIAT TIPO - EGBA 500 HYBRID TIPO-EGEA MCA (2020) DOBLO FREEMONT LINEA IDEA To yKasaHHeIM N3paMETpaM 3BTOMOBHMN He HaaeHs",
|
|
||||||
);
|
|
||||||
expect(p.model).not.toBeNull(); // breadcrumb model still extracted…
|
|
||||||
expect(p.sincom).toBeNull(); // …but no SINCOM
|
|
||||||
expect(isUsableParse(p)).toBe(false); // → not a real decode
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseRenaultHeader", () => {
|
|
||||||
it("parses an Rpartstore RENAULT header (model + brand)", () => {
|
|
||||||
const p = parseRenaultHeader("RENAULT KADJAR (RFE) Şasi: VF1RFE00653633190");
|
|
||||||
expect(p.model).toBe("KADJAR");
|
|
||||||
expect(p.brand).toBe("RENAULT");
|
|
||||||
expect(isUsableRenaultParse(p)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads DACIA as the brand when the header carries it", () => {
|
|
||||||
const p = parseRenaultHeader("DACIA DUSTER (HSA) Şasi: VF1HJD40865644941");
|
|
||||||
expect(p.model).toBe("DUSTER");
|
|
||||||
expect(p.brand).toBe("DACIA");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts the base model and ignores the generation numeral (CLIO IV)", () => {
|
|
||||||
const p = parseRenaultHeader("RENAULT CLIO IV (R0G) Şasi: VF15R0G0H49335605");
|
|
||||||
expect(p.model).toBe("CLIO");
|
|
||||||
expect(p.brand).toBe("RENAULT");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles a Dialogys header with no explicit brand (defaults RENAULT)", () => {
|
|
||||||
const p = parseRenaultHeader("Clio II (X65), 1.5 dCi");
|
|
||||||
expect(p.model).toBe("CLIO");
|
|
||||||
expect(p.brand).toBe("RENAULT");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("captures a model year when the header carries one", () => {
|
|
||||||
const p = parseRenaultHeader("RENAULT LATITUDE (2011-2015)");
|
|
||||||
expect(p.model).toBe("LATITUDE");
|
|
||||||
expect(p.modelYear).toBe("2011");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT mis-grab the status-bar license-expiry date as the model year", () => {
|
|
||||||
// The full-frame OCR fallback sweeps in the Rpartstore/Dialogys status bar,
|
|
||||||
// whose license EXPIRY date ("…14.07.2026") used to be read as the model year.
|
|
||||||
const p = parseRenaultHeader("RENAULT KADJAR (RFE) Şasi: VF1RFE00653633190 Lisans: 14.07.2026");
|
|
||||||
expect(p.model).toBe("KADJAR");
|
|
||||||
expect(p.modelYear).toBeNull(); // NOT "2026" from the expiry date
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still prefers a genuine generation range even when an expiry date is present", () => {
|
|
||||||
const p = parseRenaultHeader("RENAULT LATITUDE (2011-2015) Lisans: 14.07.2026");
|
|
||||||
expect(p.modelYear).toBe("2011"); // the (2011-2015) range wins over the date
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns no model / unusable for headers without a known token", () => {
|
|
||||||
const p = parseRenaultHeader("İlişikli araç bulunamadı");
|
|
||||||
expect(p.model).toBeNull();
|
|
||||||
expect(isUsableRenaultParse(p)).toBe(false);
|
|
||||||
expect(isUsableRenaultParse(parseRenaultHeader(""))).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* Kept dependency-free and side-effect-free so it can be unit-tested directly.
|
* Kept dependency-free and side-effect-free so it can be unit-tested directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FIAT_MODEL_TOKENS, RENAULT_MODEL_TOKENS } from "./vinpin.constants";
|
import { FIAT_MODEL_TOKENS } from "./vinpin.constants";
|
||||||
|
|
||||||
export interface VinpinParsed {
|
export interface VinpinParsed {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -91,83 +91,7 @@ export function parseVinpinModal(rawText: string | null | undefined): VinpinPars
|
|||||||
return { model, sincom, trim, modelYear, engine };
|
return { model, sincom, trim, modelYear, engine };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A parse is usable if we got at least a model or a SINCOM code. */
|
||||||
* A parse is usable only when we got a SINCOM code — the trim identifier that a
|
|
||||||
* genuine ePER decode always emits ("356G37001162"). A model *alone* is a stale
|
|
||||||
* breadcrumb: when the VIN search returns "vehicle not found" (a TR NM4 VIN ePER
|
|
||||||
* can't resolve), the screen still shows whatever catalog the operator was last
|
|
||||||
* browsing (e.g. "FIAT » TIPO - EGEA"), and the OCR of the Cyrillic "не найден"
|
|
||||||
* alert garbles enough to slip past the not-found check. Grabbing that
|
|
||||||
* breadcrumb model ("TIPO"/"PANDA") and matching it to a catalog is a false
|
|
||||||
* positive that lands users on the wrong (often ancient) catalog. Requiring the
|
|
||||||
* SINCOM cleanly separates the two: every genuine decode carries one; the
|
|
||||||
* breadcrumb false positives never do.
|
|
||||||
*/
|
|
||||||
export function isUsableParse(p: VinpinParsed): boolean {
|
export function isUsableParse(p: VinpinParsed): boolean {
|
||||||
return Boolean(p.sincom);
|
return Boolean(p.model || p.sincom);
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Renault (Rpartstore / Dialogys) header parser ───────────────
|
|
||||||
|
|
||||||
export interface VinpinRenaultParsed {
|
|
||||||
/** Extracted model, e.g. "KADJAR", "CLIO", "DUSTER". */
|
|
||||||
model: string | null;
|
|
||||||
/** "RENAULT" | "DACIA" — read from the header, else defaulted to "RENAULT". */
|
|
||||||
brand: "RENAULT" | "DACIA";
|
|
||||||
/** Model year if the header carried one (best-effort). */
|
|
||||||
modelYear: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RENAULT_TOKEN_SET = new Set<string>(RENAULT_MODEL_TOKENS as readonly string[]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the Renault/Dacia model from an OCR'd vehicle-page header.
|
|
||||||
*
|
|
||||||
* Rpartstore header (primary): "RENAULT KADJAR (RFE)" / "DACIA DUSTER (...)" +
|
|
||||||
* "Şasi: VF1RFE00653633190".
|
|
||||||
* Dialogys header (fallback): "Clio II (X65), 1.5 dCi" (no explicit brand).
|
|
||||||
*
|
|
||||||
* Strategy (mirrors the Fiat token-run parser): collect the contiguous run of
|
|
||||||
* KNOWN Renault/Dacia model tokens (so "GRAND KOLEOS" → "KOLEOS", "CLIO II" →
|
|
||||||
* "CLIO"), and read the brand from a leading RENAULT/DACIA token (Dialogys omits
|
|
||||||
* it → default RENAULT). Generation numbers / roman numerals are intentionally
|
|
||||||
* dropped — PL24 rows carry their own generation suffixes and matching is
|
|
||||||
* best-effort.
|
|
||||||
*/
|
|
||||||
export function parseRenaultHeader(rawText: string | null | undefined): VinpinRenaultParsed {
|
|
||||||
const text = (rawText ?? "").replace(/\s+/g, " ").trim();
|
|
||||||
const upper = text.toUpperCase();
|
|
||||||
|
|
||||||
const brand: "RENAULT" | "DACIA" = /\bDACIA\b/.test(upper) ? "DACIA" : "RENAULT";
|
|
||||||
|
|
||||||
const tokens = upper.split(/[^A-Z0-9]+/).filter(Boolean);
|
|
||||||
const collected: string[] = [];
|
|
||||||
for (const tok of tokens) {
|
|
||||||
if (RENAULT_TOKEN_SET.has(tok)) {
|
|
||||||
collected.push(tok);
|
|
||||||
} else if (collected.length > 0) {
|
|
||||||
// Stop at the first non-model token after the model run has started.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// De-dup consecutive repeats (e.g. "CLIO CLIO" OCR doubling) preserving order.
|
|
||||||
const deduped = collected.filter((t, i) => i === 0 || t !== collected[i - 1]);
|
|
||||||
const model = deduped.length > 0 ? deduped.join("-") : null;
|
|
||||||
|
|
||||||
// Model year: prefer an explicit generation range "(2015-2021)" from the header.
|
|
||||||
// A BARE year is only taken as a fallback AND only after stripping full calendar
|
|
||||||
// dates — the Rpartstore/Dialogys STATUS BAR carries the license EXPIRY date
|
|
||||||
// ("…14.07.2026") which the full-frame OCR fallback would otherwise mis-grab as
|
|
||||||
// the model year (the "2026" bug). Stripping dd.mm.yyyy / dd/mm/yyyy / dd-mm-yyyy
|
|
||||||
// first constrains the bare-year scan to real header text.
|
|
||||||
const withoutDates = text.replace(/\b\d{1,2}[./-]\d{1,2}[./-]\d{4}\b/g, " ");
|
|
||||||
const modelYear =
|
|
||||||
text.match(YEAR_RANGE_RE)?.[1] ?? withoutDates.match(/\b(19|20)\d{2}\b/)?.[0] ?? null;
|
|
||||||
|
|
||||||
return { model, brand, modelYear };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A Renault parse is usable once we have a model token. */
|
|
||||||
export function isUsableRenaultParse(p: VinpinRenaultParsed): boolean {
|
|
||||||
return Boolean(p.model);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
isVinpinBrandAllowed,
|
|
||||||
isVinpinBusinessHours,
|
|
||||||
orderTaskbarWindows,
|
|
||||||
selectVinpinBrandFlow,
|
|
||||||
selectVinpinWarmWindow,
|
|
||||||
} from "./vinpin.constants";
|
|
||||||
|
|
||||||
describe("selectVinpinBrandFlow", () => {
|
|
||||||
it("routes Renault WMIs (VF1/VF2) to the renault flow", () => {
|
|
||||||
expect(selectVinpinBrandFlow("VF1RFE00653633190")).toBe("renault"); // Kadjar
|
|
||||||
expect(selectVinpinBrandFlow("VF15R0G0H49335605")).toBe("renault"); // Clio IV
|
|
||||||
expect(selectVinpinBrandFlow("VF1LB030523661167")).toBe("renault"); // Clio II
|
|
||||||
expect(selectVinpinBrandFlow("VF2ABCDEFGH123456")).toBe("renault");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes a Dacia-badged VF1 VIN to the renault flow (brand read from header)", () => {
|
|
||||||
expect(selectVinpinBrandFlow("VF1HJD40865644941")).toBe("renault"); // Dacia Duster
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes the dedicated Dacia WMI (UU1) to the renault flow", () => {
|
|
||||||
expect(selectVinpinBrandFlow("UU1ABCDEFGH123456")).toBe("renault");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps Fiat VINs on the fiat flow (byte-identical behaviour)", () => {
|
|
||||||
expect(selectVinpinBrandFlow("NM435600006H43436")).toBe("fiat"); // Tofaş Egea
|
|
||||||
expect(selectVinpinBrandFlow("ZFA31200000000000")).toBe("fiat");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults unknown/other WMIs to the fiat flow", () => {
|
|
||||||
expect(selectVinpinBrandFlow("WVWZZZ1JZ3W597935")).toBe("fiat"); // VW
|
|
||||||
expect(selectVinpinBrandFlow("")).toBe("fiat");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isVinpinBrandAllowed", () => {
|
|
||||||
it("allows Fiat, Renault and Dacia (case-insensitive)", () => {
|
|
||||||
expect(isVinpinBrandAllowed("Fiat")).toBe(true);
|
|
||||||
expect(isVinpinBrandAllowed("renault")).toBe(true);
|
|
||||||
expect(isVinpinBrandAllowed("DACIA")).toBe(true);
|
|
||||||
expect(isVinpinBrandAllowed(" Renault ")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects other brands and empty input", () => {
|
|
||||||
expect(isVinpinBrandAllowed("Peugeot")).toBe(false);
|
|
||||||
expect(isVinpinBrandAllowed(null)).toBe(false);
|
|
||||||
expect(isVinpinBrandAllowed(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("selectVinpinWarmWindow — brand → taskbar window routing", () => {
|
|
||||||
it("routes Renault/Dacia VINs to the Rpartstore window (Dialogys is the in-flow fallback)", () => {
|
|
||||||
expect(selectVinpinWarmWindow("VF1RFE00653633190")).toBe("rpartstore"); // Renault Kadjar
|
|
||||||
expect(selectVinpinWarmWindow("UU1ABCDEFGH123456")).toBe("rpartstore"); // Dacia
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes Fiat (and everything else) to the ePER window", () => {
|
|
||||||
expect(selectVinpinWarmWindow("NM435600006H43436")).toBe("fiat"); // Tofaş Egea
|
|
||||||
expect(selectVinpinWarmWindow("WVWZZZ1JZ3W597935")).toBe("fiat"); // VW → fiat default
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("orderTaskbarWindows — OCR-order taskbar binding", () => {
|
|
||||||
it("returns the windows in their left→right OCR order", () => {
|
|
||||||
const bar = "VinPower Fiat Dealer ePER Renault RPartStore Renault Dialogys";
|
|
||||||
expect(orderTaskbarWindows(bar)).toEqual(["fiat", "rpartstore", "dialogys"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects a different open order (order is read, not hardcoded)", () => {
|
|
||||||
const bar = "Dialogys | RPartStore | ePER";
|
|
||||||
expect(orderTaskbarWindows(bar)).toEqual(["dialogys", "rpartstore", "fiat"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits windows whose label isn't on the bar", () => {
|
|
||||||
const bar = "some noise ePER more noise RPartStore";
|
|
||||||
expect(orderTaskbarWindows(bar)).toEqual(["fiat", "rpartstore"]);
|
|
||||||
expect(orderTaskbarWindows("nothing recognizable")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isVinpinBusinessHours — 08:00–21:00 Europe/Istanbul (UTC+3, no DST)", () => {
|
|
||||||
it("is true from 08:00 up to (not including) 21:00 local", () => {
|
|
||||||
expect(isVinpinBusinessHours(new Date("2026-07-14T05:00:00Z"))).toBe(true); // 08:00
|
|
||||||
expect(isVinpinBusinessHours(new Date("2026-07-14T12:00:00Z"))).toBe(true); // 15:00
|
|
||||||
expect(isVinpinBusinessHours(new Date("2026-07-14T17:59:00Z"))).toBe(true); // 20:59
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false before 08:00 and at/after 21:00 local", () => {
|
|
||||||
expect(isVinpinBusinessHours(new Date("2026-07-14T04:59:00Z"))).toBe(false); // 07:59
|
|
||||||
expect(isVinpinBusinessHours(new Date("2026-07-14T18:00:00Z"))).toBe(false); // 21:00
|
|
||||||
expect(isVinpinBusinessHours(new Date("2026-07-14T22:00:00Z"))).toBe(false); // 01:00
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -122,53 +122,3 @@ export class BillingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* User-scoped billing admin: start a fresh subscription for a user whose
|
|
||||||
* previous one is expired/cancelled (the panel's "Yeni abonelik başlat").
|
|
||||||
*/
|
|
||||||
@Controller("internal/admin/users")
|
|
||||||
@Public()
|
|
||||||
@UseGuards(InternalTokenGuard)
|
|
||||||
export class UserBillingController {
|
|
||||||
constructor(private billing: BillingService) {}
|
|
||||||
|
|
||||||
@Post(":id/subscriptions")
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
async startSubscription(
|
|
||||||
@Param("id") id: string,
|
|
||||||
@Body()
|
|
||||||
body: {
|
|
||||||
planId?: string;
|
|
||||||
billingPeriod?: string;
|
|
||||||
days?: number;
|
|
||||||
brandIds?: string[];
|
|
||||||
reason?: string;
|
|
||||||
founderId?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (!body.founderId) throw new BadRequestException("founderId required");
|
|
||||||
if (!body.reason || body.reason.trim().length < 5) {
|
|
||||||
throw new BadRequestException("start-subscription requires reason (min 5 chars)");
|
|
||||||
}
|
|
||||||
if (!body.planId) throw new BadRequestException("planId required");
|
|
||||||
if (body.billingPeriod !== "monthly" && body.billingPeriod !== "yearly") {
|
|
||||||
throw new BadRequestException("billingPeriod must be monthly|yearly");
|
|
||||||
}
|
|
||||||
if (body.days !== undefined && (typeof body.days !== "number" || body.days <= 0)) {
|
|
||||||
throw new BadRequestException("days must be a positive number");
|
|
||||||
}
|
|
||||||
if (body.brandIds !== undefined && !Array.isArray(body.brandIds)) {
|
|
||||||
throw new BadRequestException("brandIds must be an array");
|
|
||||||
}
|
|
||||||
return this.billing.startSubscription({
|
|
||||||
userId: id,
|
|
||||||
planId: body.planId,
|
|
||||||
billingPeriod: body.billingPeriod,
|
|
||||||
days: body.days,
|
|
||||||
brandIds: body.brandIds,
|
|
||||||
reason: body.reason,
|
|
||||||
founderId: body.founderId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { BillingService } from "./billing.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drizzle-shaped mock: every `db.select()` pops the next row-set from `selects`
|
|
||||||
* (order = order of queries in the service), `db.insert()` returns
|
|
||||||
* `insertRows` from `.returning()` and records `.values()` payloads.
|
|
||||||
*/
|
|
||||||
function createMockDb(selects: unknown[][], insertRows: unknown[] = []) {
|
|
||||||
const queue = [...selects];
|
|
||||||
const inserted: unknown[] = [];
|
|
||||||
function chain(terminal: unknown, onValues?: (v: unknown) => void) {
|
|
||||||
const c: Record<string, unknown> = {};
|
|
||||||
for (const m of [
|
|
||||||
"select",
|
|
||||||
"from",
|
|
||||||
"where",
|
|
||||||
"limit",
|
|
||||||
"insert",
|
|
||||||
"values",
|
|
||||||
"returning",
|
|
||||||
"update",
|
|
||||||
"set",
|
|
||||||
]) {
|
|
||||||
c[m] = vi.fn().mockReturnValue(c);
|
|
||||||
}
|
|
||||||
c.values = vi.fn().mockImplementation((v: unknown) => {
|
|
||||||
onValues?.(v);
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
// `await db.select()...where(...)` (no limit) resolves via thenable.
|
|
||||||
// biome-ignore lint/suspicious/noThenProperty: mimics Drizzle's thenable query builder
|
|
||||||
c.then = (res: (v: unknown) => unknown, rej?: (e: unknown) => unknown) =>
|
|
||||||
Promise.resolve(terminal).then(res, rej);
|
|
||||||
c.limit = vi.fn().mockReturnValue(Promise.resolve(terminal));
|
|
||||||
c.returning = vi.fn().mockReturnValue(Promise.resolve(terminal));
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
inserted,
|
|
||||||
select: vi.fn().mockImplementation(() => chain(queue.shift() ?? [])),
|
|
||||||
insert: vi.fn().mockImplementation(() => chain(insertRows, (v) => inserted.push(v))),
|
|
||||||
update: vi.fn().mockImplementation(() => chain([])),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const USER = { id: "user-1" };
|
|
||||||
const FULL = { id: "plan-full", name: "Full Paket", brandCount: 0, isActive: true };
|
|
||||||
const ONE = { id: "plan-1", name: "1 Marka", brandCount: 1, isActive: true };
|
|
||||||
const BRANDS = [{ id: "b1" }, { id: "b2" }, { id: "b3" }];
|
|
||||||
const base = { userId: "user-1", reason: "manuel tanım", founderId: "founder-1" } as const;
|
|
||||||
|
|
||||||
function svc(db: unknown) {
|
|
||||||
return new BillingService(db as any, {} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("BillingService.startSubscription", () => {
|
|
||||||
it("creates an active Full-plan subscription with every active brand and the given days", async () => {
|
|
||||||
const created = {
|
|
||||||
id: "sub-new",
|
|
||||||
startDate: new Date("2026-09-19T00:00:00Z"),
|
|
||||||
endDate: new Date("2029-09-19T00:00:00Z"),
|
|
||||||
};
|
|
||||||
const db = createMockDb([[USER], [], [FULL], BRANDS], [created]);
|
|
||||||
const res = await svc(db).startSubscription({
|
|
||||||
...base,
|
|
||||||
planId: FULL.id,
|
|
||||||
billingPeriod: "yearly",
|
|
||||||
days: 1095,
|
|
||||||
});
|
|
||||||
expect(res.success).toBe(true);
|
|
||||||
expect(res.subscriptionId).toBe("sub-new");
|
|
||||||
expect(res.brandCount).toBe(3);
|
|
||||||
// 1st insert = subscription row, 2nd = brand rows
|
|
||||||
expect(db.inserted).toHaveLength(2);
|
|
||||||
const subRow = db.inserted[0] as {
|
|
||||||
status: string;
|
|
||||||
billingPeriod: string;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
};
|
|
||||||
expect(subRow.status).toBe("active");
|
|
||||||
expect(subRow.billingPeriod).toBe("yearly");
|
|
||||||
const diffDays = Math.round(
|
|
||||||
(subRow.endDate.getTime() - subRow.startDate.getTime()) / 86_400_000,
|
|
||||||
);
|
|
||||||
expect(diffDays).toBe(1095);
|
|
||||||
expect(db.inserted[1]).toEqual(
|
|
||||||
BRANDS.map((b) => ({ userId: "user-1", subscriptionId: "sub-new", brandId: b.id })),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults the period from billingPeriod when days is omitted", async () => {
|
|
||||||
const db = createMockDb([[USER], [], [FULL], BRANDS], [{ id: "sub-new" }]);
|
|
||||||
await svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "monthly" });
|
|
||||||
const subRow = db.inserted[0] as { startDate: Date; endDate: Date };
|
|
||||||
const expected = new Date(subRow.startDate);
|
|
||||||
expected.setMonth(expected.getMonth() + 1);
|
|
||||||
expect(subRow.endDate.getTime()).toBe(expected.getTime());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the requested brands for a brand-limited plan", async () => {
|
|
||||||
const db = createMockDb([[USER], [], [ONE], BRANDS], [{ id: "sub-new" }]);
|
|
||||||
const res = await svc(db).startSubscription({
|
|
||||||
...base,
|
|
||||||
planId: ONE.id,
|
|
||||||
billingPeriod: "monthly",
|
|
||||||
brandIds: ["b2"],
|
|
||||||
});
|
|
||||||
expect(res.brandCount).toBe(1);
|
|
||||||
expect(db.inserted[1]).toEqual([
|
|
||||||
{ userId: "user-1", subscriptionId: "sub-new", brandId: "b2" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects wrong brand count / unknown brands for a brand-limited plan", async () => {
|
|
||||||
await expect(
|
|
||||||
svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({
|
|
||||||
...base,
|
|
||||||
planId: ONE.id,
|
|
||||||
billingPeriod: "monthly",
|
|
||||||
brandIds: [],
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
await expect(
|
|
||||||
svc(createMockDb([[USER], [], [ONE], BRANDS])).startSubscription({
|
|
||||||
...base,
|
|
||||||
planId: ONE.id,
|
|
||||||
billingPeriod: "monthly",
|
|
||||||
brandIds: ["nope"],
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses when the user already has an active or trial subscription", async () => {
|
|
||||||
const db = createMockDb([[USER], [{ id: "sub-live", status: "trial" }]]);
|
|
||||||
await expect(
|
|
||||||
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly" }),
|
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
expect(db.insert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("404s on unknown user / plan and rejects inactive plans", async () => {
|
|
||||||
await expect(
|
|
||||||
svc(createMockDb([[]])).startSubscription({
|
|
||||||
...base,
|
|
||||||
planId: FULL.id,
|
|
||||||
billingPeriod: "yearly",
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
await expect(
|
|
||||||
svc(createMockDb([[USER], [], []])).startSubscription({
|
|
||||||
...base,
|
|
||||||
planId: "missing",
|
|
||||||
billingPeriod: "yearly",
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
await expect(
|
|
||||||
svc(createMockDb([[USER], [], [{ ...FULL, isActive: false }]])).startSubscription({
|
|
||||||
...base,
|
|
||||||
planId: FULL.id,
|
|
||||||
billingPeriod: "yearly",
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates days range and billingPeriod before touching the db", async () => {
|
|
||||||
const db = createMockDb([]);
|
|
||||||
await expect(
|
|
||||||
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 0 }),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
await expect(
|
|
||||||
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "yearly", days: 3651 }),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
await expect(
|
|
||||||
svc(db).startSubscription({ ...base, planId: FULL.id, billingPeriod: "weekly" as any }),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
expect(db.select).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { brands, plans, userBrands, userSubscriptions, users } from "../database/schema/core";
|
import { brands, plans, userBrands, userSubscriptions } from "../database/schema/core";
|
||||||
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -31,8 +31,8 @@ export class BillingService {
|
|||||||
reason: string;
|
reason: string;
|
||||||
founderId: string;
|
founderId: string;
|
||||||
}) {
|
}) {
|
||||||
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
|
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 90) {
|
||||||
throw new BadRequestException("days must be 1..3650");
|
throw new BadRequestException("days must be 1..90");
|
||||||
}
|
}
|
||||||
const [sub] = await this.db
|
const [sub] = await this.db
|
||||||
.select()
|
.select()
|
||||||
@@ -74,133 +74,6 @@ export class BillingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a brand-new ACTIVE subscription for a user whose previous one is
|
|
||||||
* expired/cancelled (or who has none). Mirrors what activateSubscription does
|
|
||||||
* for a paid checkout — status/dates/plan-based brand assignment — but does
|
|
||||||
* NOT emit revenue events (PostHog subscription_activated / Meta Purchase) or
|
|
||||||
* consume referral credit: this is a founder grant, no money moved here.
|
|
||||||
* `days` overrides the default period (monthly → +1 ay, yearly → +1 yıl).
|
|
||||||
*/
|
|
||||||
async startSubscription(input: {
|
|
||||||
userId: string;
|
|
||||||
planId: string;
|
|
||||||
billingPeriod: "monthly" | "yearly";
|
|
||||||
days?: number;
|
|
||||||
brandIds?: string[];
|
|
||||||
reason: string;
|
|
||||||
founderId: string;
|
|
||||||
}) {
|
|
||||||
if (input.billingPeriod !== "monthly" && input.billingPeriod !== "yearly") {
|
|
||||||
throw new BadRequestException("billingPeriod must be monthly|yearly");
|
|
||||||
}
|
|
||||||
if (input.days !== undefined) {
|
|
||||||
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
|
|
||||||
throw new BadRequestException("days must be 1..3650");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [user] = await this.db
|
|
||||||
.select({ id: users.id })
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, input.userId))
|
|
||||||
.limit(1);
|
|
||||||
if (!user) throw new NotFoundException("Kullanıcı bulunamadı");
|
|
||||||
|
|
||||||
const [live] = await this.db
|
|
||||||
.select({ id: userSubscriptions.id, status: userSubscriptions.status })
|
|
||||||
.from(userSubscriptions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userSubscriptions.userId, input.userId),
|
|
||||||
inArray(userSubscriptions.status, ["active", "trial"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (live) {
|
|
||||||
throw new ConflictException(
|
|
||||||
`Kullanıcının zaten '${live.status}' bir subscription'ı var (${live.id}) — activate / plan değiştir / uzat kullanın`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [plan] = await this.db.select().from(plans).where(eq(plans.id, input.planId)).limit(1);
|
|
||||||
if (!plan) throw new NotFoundException("Plan bulunamadı");
|
|
||||||
if (!plan.isActive) throw new ConflictException("Plan aktif değil");
|
|
||||||
|
|
||||||
const activeBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
|
|
||||||
let brandIds: string[];
|
|
||||||
if (plan.brandCount === 0) {
|
|
||||||
// Full plan → every active brand, whatever the caller sent.
|
|
||||||
brandIds = activeBrands.map((b) => b.id);
|
|
||||||
} else {
|
|
||||||
const requested = input.brandIds ?? [];
|
|
||||||
if (requested.length !== plan.brandCount) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Bu plan tam olarak ${plan.brandCount} marka gerektirir, ${requested.length} seçildi`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (new Set(requested).size !== requested.length) {
|
|
||||||
throw new BadRequestException("Marka listesi tekrar içeriyor");
|
|
||||||
}
|
|
||||||
const valid = new Set(activeBrands.map((b) => b.id));
|
|
||||||
for (const id of requested) {
|
|
||||||
if (!valid.has(id)) throw new BadRequestException(`Geçersiz veya pasif marka: ${id}`);
|
|
||||||
}
|
|
||||||
brandIds = requested;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const endDate = new Date(now);
|
|
||||||
if (input.days !== undefined) {
|
|
||||||
endDate.setDate(endDate.getDate() + input.days);
|
|
||||||
} else if (input.billingPeriod === "yearly") {
|
|
||||||
endDate.setFullYear(endDate.getFullYear() + 1);
|
|
||||||
} else {
|
|
||||||
endDate.setMonth(endDate.getMonth() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [created] = await this.db
|
|
||||||
.insert(userSubscriptions)
|
|
||||||
.values({
|
|
||||||
userId: input.userId,
|
|
||||||
planId: input.planId,
|
|
||||||
status: "active",
|
|
||||||
billingPeriod: input.billingPeriod,
|
|
||||||
startDate: now,
|
|
||||||
endDate,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (brandIds.length > 0) {
|
|
||||||
await this.db.insert(userBrands).values(
|
|
||||||
brandIds.map((brandId) => ({
|
|
||||||
userId: input.userId,
|
|
||||||
subscriptionId: created.id,
|
|
||||||
brandId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`start: user=${input.userId} subscription=${created.id} plan=${plan.name} ` +
|
|
||||||
`${input.billingPeriod}${input.days !== undefined ? ` ${input.days}d` : ""} → ${endDate.toISOString()} ` +
|
|
||||||
`brands=${brandIds.length} founder=${input.founderId} reason="${input.reason.slice(0, 80)}"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
subscriptionId: created.id,
|
|
||||||
userId: input.userId,
|
|
||||||
planId: plan.id,
|
|
||||||
planName: plan.name,
|
|
||||||
billingPeriod: input.billingPeriod,
|
|
||||||
status: "active",
|
|
||||||
startDate: created.startDate?.toISOString() ?? null,
|
|
||||||
endDate: created.endDate?.toISOString() ?? null,
|
|
||||||
brandCount: brandIds.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async manuallyActivate(input: {
|
async manuallyActivate(input: {
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { StripeModule } from "../payments/stripe/stripe.module";
|
|||||||
import { PublicApiQuotaService } from "../public-api/public-api-quota.service";
|
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 { ApiKeysAdminController } from "./api-keys.controller";
|
||||||
import { BillingController, UserBillingController } 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";
|
||||||
import { ImpersonationService } from "./impersonation.service";
|
import { ImpersonationService } from "./impersonation.service";
|
||||||
@@ -20,7 +20,6 @@ import { InternalVehiclesService } from "./vehicles.service";
|
|||||||
ImpersonationController,
|
ImpersonationController,
|
||||||
LifecycleController,
|
LifecycleController,
|
||||||
BillingController,
|
BillingController,
|
||||||
UserBillingController,
|
|
||||||
PaymentsAdminController,
|
PaymentsAdminController,
|
||||||
InternalVehiclesController,
|
InternalVehiclesController,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -74,21 +74,14 @@ export async function checkCooldown(redis: RedisService, source: string): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Europe/Istanbul hour (0–23) at an arbitrary instant. */
|
/** Current hour (0–23) in Europe/Istanbul. */
|
||||||
function istanbulHourAt(ms: number): number {
|
function currentIstanbulHour(): number {
|
||||||
const hourStr = new Intl.DateTimeFormat("en-US", {
|
const hourStr = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: "Europe/Istanbul",
|
timeZone: "Europe/Istanbul",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
}).format(new Date(ms));
|
}).format(new Date());
|
||||||
// `% 24` because the h24 hour cycle renders midnight as "24", which would put
|
return Number.parseInt(hourStr, 10);
|
||||||
// the hour outside every window and silently park the source forever.
|
|
||||||
return Number.parseInt(hourStr, 10) % 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current hour (0–23) in Europe/Istanbul. */
|
|
||||||
function currentIstanbulHour(): number {
|
|
||||||
return istanbulHourAt(Date.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,35 +115,6 @@ export function checkTimeWindow(source: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The first instant at or after `fromMs` that falls inside `source`'s scrape
|
|
||||||
* window. Returns `fromMs` unchanged when the window is disabled (the default
|
|
||||||
* 0–24) or when `fromMs` is already inside it.
|
|
||||||
*
|
|
||||||
* WHY (plv2.md, finding consumers_jobs-03 — the budget/window deadlock):
|
|
||||||
* `checkSourceDailyBudget` defers a spent source to the next UTC midnight. With
|
|
||||||
* PREFETCH_PL24_START=9 that midnight lands at 03:00 Europe/Istanbul — six hours
|
|
||||||
* BEFORE the window opens. The woken job therefore did no work, threw
|
|
||||||
* `time-window`, and was deferred again to 09:00 — by which point the fresh
|
|
||||||
* daily budget had already been spent by the same stampede of no-op wake-ups.
|
|
||||||
* Measured on prod 2026-09-20: 600/600 pl24 budget consumed, 0 catalog requests
|
|
||||||
* and 0 new categories for the whole day. Landing the deferral inside the window
|
|
||||||
* breaks the cycle.
|
|
||||||
*/
|
|
||||||
export function alignToWindow(source: string, fromMs: number): number {
|
|
||||||
if (source !== "pl24") return fromMs;
|
|
||||||
if (PL24_WINDOW_START <= 0 && PL24_WINDOW_END >= 24) return fromMs;
|
|
||||||
let t = fromMs;
|
|
||||||
// Step by the hour rather than constructing a local-midnight date: DST-safe and
|
|
||||||
// free of month/year rollover edge cases. 48 steps covers any window shape.
|
|
||||||
for (let i = 0; i < 48; i++) {
|
|
||||||
const h = istanbulHourAt(t);
|
|
||||||
if (h >= PL24_WINDOW_START && h < PL24_WINDOW_END) return t;
|
|
||||||
t += 3_600_000;
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Milliseconds until the next 09:00 Europe/Istanbul.
|
* Milliseconds until the next 09:00 Europe/Istanbul.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression lock for the PL24 budget/window DEADLOCK (plv2.md, consumers_jobs-03).
|
|
||||||
*
|
|
||||||
* Two bugs combined to kill PL24 prefetch outright on prod:
|
|
||||||
* 1. `checkSourceDailyBudget` was debited in `process()` while the
|
|
||||||
* business-hours gate still sat at the top of each handler — so a job that
|
|
||||||
* woke outside the window paid a budget unit to do nothing.
|
|
||||||
* 2. A spent source was deferred to the next UTC midnight, which is 03:00
|
|
||||||
* Europe/Istanbul — six hours BEFORE a 09:00 window opens. Every deferred
|
|
||||||
* job therefore woke early, burned a unit of the fresh daily budget, hit
|
|
||||||
* the window gate and was deferred again.
|
|
||||||
*
|
|
||||||
* Measured on prod 2026-09-20 before the fix: `prefetch:daily:pl24` at 600/600
|
|
||||||
* with ZERO catalog requests and ZERO new pl24 categories for the whole day.
|
|
||||||
*
|
|
||||||
* The window constants are read at module load, so every case imports the
|
|
||||||
* modules fresh with the env already set.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const REAL_ENV = { ...process.env };
|
|
||||||
|
|
||||||
/** 2026-09-20 00:00 UTC = 03:00 Europe/Istanbul — outside a 09:00–18:00 window. */
|
|
||||||
const OUTSIDE_MS = Date.UTC(2026, 8, 20, 0, 0, 0);
|
|
||||||
/** 2026-09-20 09:00 UTC = 12:00 Europe/Istanbul — inside it. */
|
|
||||||
const INSIDE_MS = Date.UTC(2026, 8, 20, 9, 0, 0);
|
|
||||||
|
|
||||||
function istanbulHour(ms: number): number {
|
|
||||||
return (
|
|
||||||
Number.parseInt(
|
|
||||||
new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: "Europe/Istanbul",
|
|
||||||
hour: "numeric",
|
|
||||||
hour12: false,
|
|
||||||
}).format(new Date(ms)),
|
|
||||||
10,
|
|
||||||
) % 24
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModules(env: Record<string, string | undefined>) {
|
|
||||||
vi.resetModules();
|
|
||||||
for (const [k, v] of Object.entries(env)) {
|
|
||||||
if (v === undefined) delete process.env[k];
|
|
||||||
else process.env[k] = v;
|
|
||||||
}
|
|
||||||
const utils = await import("./prefetch-utils");
|
|
||||||
const worker = await import("./prefetch-worker.service");
|
|
||||||
return { utils, worker };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal deps: only what `process()` touches before dispatching a job. */
|
|
||||||
function makeDeps(redisOverrides: Record<string, unknown> = {}) {
|
|
||||||
const incrCalls: string[] = [];
|
|
||||||
const redis = {
|
|
||||||
exists: vi.fn(async (..._a: unknown[]) => false),
|
|
||||||
get: vi.fn(async (..._a: unknown[]): Promise<string | null> => null),
|
|
||||||
set: vi.fn(async (..._a: unknown[]) => undefined),
|
|
||||||
del: vi.fn(async (..._a: unknown[]) => undefined),
|
|
||||||
incr: vi.fn(async (k: string) => {
|
|
||||||
incrCalls.push(k);
|
|
||||||
return 1;
|
|
||||||
}),
|
|
||||||
expire: vi.fn(async (..._a: unknown[]) => undefined),
|
|
||||||
ttl: vi.fn(async (..._a: unknown[]) => -2), // no cooldown key
|
|
||||||
setNx: vi.fn(async (..._a: unknown[]) => true),
|
|
||||||
getJson: vi.fn(async (..._a: unknown[]): Promise<unknown> => null),
|
|
||||||
setJson: vi.fn(async (..._a: unknown[]) => undefined),
|
|
||||||
...redisOverrides,
|
|
||||||
};
|
|
||||||
const queue = {
|
|
||||||
name: "catalog-prefetch",
|
|
||||||
add: vi.fn(async (..._a: unknown[]) => undefined),
|
|
||||||
getJob: vi.fn(async (..._a: unknown[]): Promise<unknown> => null),
|
|
||||||
};
|
|
||||||
const categoriesService = {
|
|
||||||
getCategoryWithParts: vi.fn(async (..._a: unknown[]) => ({ parts: [] })),
|
|
||||||
getChildren: vi.fn(async (..._a: unknown[]) => []),
|
|
||||||
};
|
|
||||||
return { redis, queue, categoriesService, incrCalls };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeJob(name: string, data: Record<string, unknown>) {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
data,
|
|
||||||
moveToDelayed: vi.fn(async (..._a: unknown[]) => undefined),
|
|
||||||
attemptsMade: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyIncrs = (keys: string[]) => keys.filter((k) => k.startsWith("prefetch:daily:pl24"));
|
|
||||||
|
|
||||||
describe("alignToWindow", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...REAL_ENV };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pushes a pre-window instant into the configured window", async () => {
|
|
||||||
const { utils } = await loadModules({ PREFETCH_PL24_START: "9", PREFETCH_PL24_END: "18" });
|
|
||||||
const aligned = utils.alignToWindow("pl24", OUTSIDE_MS);
|
|
||||||
expect(aligned).toBeGreaterThan(OUTSIDE_MS);
|
|
||||||
const h = istanbulHour(aligned);
|
|
||||||
expect(h).toBeGreaterThanOrEqual(9);
|
|
||||||
expect(h).toBeLessThan(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves an in-window instant untouched", async () => {
|
|
||||||
const { utils } = await loadModules({ PREFETCH_PL24_START: "9", PREFETCH_PL24_END: "18" });
|
|
||||||
expect(utils.alignToWindow("pl24", INSIDE_MS)).toBe(INSIDE_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op for sources that have no window", async () => {
|
|
||||||
const { utils } = await loadModules({ PREFETCH_PL24_START: "9", PREFETCH_PL24_END: "18" });
|
|
||||||
expect(utils.alignToWindow("emex", OUTSIDE_MS)).toBe(OUTSIDE_MS);
|
|
||||||
expect(utils.alignToWindow("parts-catalogs", OUTSIDE_MS)).toBe(OUTSIDE_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when the window is disabled (the default 0–24)", async () => {
|
|
||||||
const { utils } = await loadModules({
|
|
||||||
PREFETCH_PL24_START: undefined,
|
|
||||||
PREFETCH_PL24_END: undefined,
|
|
||||||
});
|
|
||||||
expect(utils.alignToWindow("pl24", OUTSIDE_MS)).toBe(OUTSIDE_MS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("process() gate order — window before daily budget", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
process.env = { ...REAL_ENV };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT debit the daily budget for a job deferred by the window", async () => {
|
|
||||||
const { worker } = await loadModules({
|
|
||||||
PREFETCH_PL24_START: "9",
|
|
||||||
PREFETCH_PL24_END: "18",
|
|
||||||
PL24_TR_DISABLED: undefined,
|
|
||||||
});
|
|
||||||
vi.setSystemTime(OUTSIDE_MS);
|
|
||||||
const { redis, queue, categoriesService, incrCalls } = makeDeps();
|
|
||||||
const svc = new worker.PrefetchWorkerService(
|
|
||||||
queue as never,
|
|
||||||
queue as never,
|
|
||||||
categoriesService as never,
|
|
||||||
redis as never,
|
|
||||||
{ payload: vi.fn(async () => ({})) } as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
const job = makeJob("prefetch-parts", {
|
|
||||||
vehicleId: "v1",
|
|
||||||
categoryId: "c1",
|
|
||||||
source: "pl24",
|
|
||||||
fast: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
(svc as never as { process: (j: unknown, t?: string) => Promise<void> }).process(job, "tok"),
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
expect(job.moveToDelayed).toHaveBeenCalled();
|
|
||||||
expect(dailyIncrs(incrCalls)).toHaveLength(0);
|
|
||||||
// The per-minute counter is not charged either: the window gate is pure
|
|
||||||
// clock arithmetic and runs before any Redis write.
|
|
||||||
expect(incrCalls).toHaveLength(0);
|
|
||||||
// …and the handler never ran, so nothing was fetched upstream either.
|
|
||||||
expect(categoriesService.getCategoryWithParts).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defers the window-blocked job to an instant inside the window", async () => {
|
|
||||||
const { worker } = await loadModules({
|
|
||||||
PREFETCH_PL24_START: "9",
|
|
||||||
PREFETCH_PL24_END: "18",
|
|
||||||
PL24_TR_DISABLED: undefined,
|
|
||||||
});
|
|
||||||
vi.setSystemTime(OUTSIDE_MS);
|
|
||||||
const { redis, queue, categoriesService } = makeDeps();
|
|
||||||
const svc = new worker.PrefetchWorkerService(
|
|
||||||
queue as never,
|
|
||||||
queue as never,
|
|
||||||
categoriesService as never,
|
|
||||||
redis as never,
|
|
||||||
{ payload: vi.fn(async () => ({})) } as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
const job = makeJob("prefetch-parts", {
|
|
||||||
vehicleId: "v1",
|
|
||||||
categoryId: "c1",
|
|
||||||
source: "pl24",
|
|
||||||
fast: true,
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
(svc as never as { process: (j: unknown, t?: string) => Promise<void> }).process(job, "tok"),
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
const [when] = job.moveToDelayed.mock.calls[0] as [number];
|
|
||||||
const h = istanbulHour(when);
|
|
||||||
expect(h).toBeGreaterThanOrEqual(9);
|
|
||||||
expect(h).toBeLessThan(18);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("debits the daily budget once the window is open", async () => {
|
|
||||||
const { worker } = await loadModules({
|
|
||||||
PREFETCH_PL24_START: "9",
|
|
||||||
PREFETCH_PL24_END: "18",
|
|
||||||
PL24_TR_DISABLED: undefined,
|
|
||||||
});
|
|
||||||
vi.setSystemTime(INSIDE_MS);
|
|
||||||
const { redis, queue, categoriesService, incrCalls } = makeDeps();
|
|
||||||
const svc = new worker.PrefetchWorkerService(
|
|
||||||
queue as never,
|
|
||||||
queue as never,
|
|
||||||
categoriesService as never,
|
|
||||||
redis as never,
|
|
||||||
{ payload: vi.fn(async () => ({})) } as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
const job = makeJob("prefetch-parts", {
|
|
||||||
vehicleId: "v1",
|
|
||||||
categoryId: "c1",
|
|
||||||
source: "pl24",
|
|
||||||
fast: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await (svc as never as { process: (j: unknown, t?: string) => Promise<void> }).process(
|
|
||||||
job,
|
|
||||||
"tok",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dailyIncrs(incrCalls)).toHaveLength(1);
|
|
||||||
expect(categoriesService.getCategoryWithParts).toHaveBeenCalledWith("c1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkSourceDailyBudget — spent source retries inside the window", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
process.env = { ...REAL_ENV };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never parks a spent source at 03:00 Istanbul again", async () => {
|
|
||||||
const { worker } = await loadModules({
|
|
||||||
PREFETCH_PL24_START: "9",
|
|
||||||
PREFETCH_PL24_END: "18",
|
|
||||||
PREFETCH_DAILY_PL24: "600",
|
|
||||||
});
|
|
||||||
vi.setSystemTime(INSIDE_MS);
|
|
||||||
// Counter already at the fast-lane ceiling → the next job must be deferred.
|
|
||||||
const { redis, queue, categoriesService } = makeDeps({
|
|
||||||
get: vi.fn(async (k: string) => (String(k).startsWith("prefetch:daily:pl24") ? "600" : null)),
|
|
||||||
});
|
|
||||||
const svc = new worker.PrefetchWorkerService(
|
|
||||||
queue as never,
|
|
||||||
queue as never,
|
|
||||||
categoriesService as never,
|
|
||||||
redis as never,
|
|
||||||
{ payload: vi.fn(async () => ({})) } as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
const job = makeJob("prefetch-parts", {
|
|
||||||
vehicleId: "v1",
|
|
||||||
categoryId: "c1",
|
|
||||||
source: "pl24",
|
|
||||||
fast: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
(svc as never as { process: (j: unknown, t?: string) => Promise<void> }).process(job, "tok"),
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
const [when] = job.moveToDelayed.mock.calls[0] as [number];
|
|
||||||
// Past the UTC rollover…
|
|
||||||
expect(when).toBeGreaterThan(Date.UTC(2026, 8, 21, 0, 0, 0));
|
|
||||||
// …and inside the window, not at 03:00 Istanbul like the old deferral.
|
|
||||||
const h = istanbulHour(when);
|
|
||||||
expect(h).toBeGreaterThanOrEqual(9);
|
|
||||||
expect(h).toBeLessThan(18);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,30 +17,17 @@ function makeDb(limitResults: unknown[][]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
|
function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
|
||||||
// A queue double: getJob returns null (nothing deduped) and the ioredis client
|
const queue = {
|
||||||
// stub answers the delayed-ZSET zcount the pressure gate takes.
|
|
||||||
const makeQueue = (name: string, waiting: number) => ({
|
|
||||||
name,
|
|
||||||
// typed args so `.mock.calls[i]` is `unknown[]` (not a 0-length tuple) — the
|
// typed args so `.mock.calls[i]` is `unknown[]` (not a 0-length tuple) — the
|
||||||
// production `nest build` compiles spec files and rejects tuple-index access.
|
// production `nest build` compiles spec files and rejects tuple-index access.
|
||||||
add: vi.fn((..._args: unknown[]) => Promise.resolve(undefined)),
|
add: vi.fn((..._args: unknown[]) => Promise.resolve(undefined)),
|
||||||
getJob: vi.fn(async (..._args: unknown[]): Promise<unknown> => null),
|
getJobCounts: vi.fn(async () => ({ waiting: opts.waiting, delayed: 0, active: 0 })),
|
||||||
getJobCounts: vi.fn(async (..._args: unknown[]) => ({
|
};
|
||||||
waiting,
|
|
||||||
delayed: 0,
|
|
||||||
active: 0,
|
|
||||||
prioritized: 0,
|
|
||||||
})),
|
|
||||||
toKey: (t: string) => `bull:${name}:${t}`,
|
|
||||||
client: Promise.resolve({ zcount: vi.fn(async (..._args: unknown[]) => 0) }),
|
|
||||||
});
|
|
||||||
const queue = makeQueue("catalog-prefetch", opts.waiting);
|
|
||||||
const redis = {
|
const redis = {
|
||||||
// typed args (like queue.add) so mockImplementation((k)=>…) type-checks under
|
// typed args (like queue.add) so mockImplementation((k)=>…) type-checks under
|
||||||
// the production nest build, which compiles spec files.
|
// the production nest build, which compiles spec files.
|
||||||
exists: vi.fn(async (..._args: unknown[]) => false), // no cooldown / guard / complete marker
|
exists: vi.fn(async (..._args: unknown[]) => false), // no cooldown / guard / complete marker
|
||||||
// null → no no-result residue AND daily budget counters read as 0 (unspent).
|
get: vi.fn(async () => null), // no no-result residue
|
||||||
get: vi.fn(async (..._args: unknown[]): Promise<string | null> => null),
|
|
||||||
set: vi.fn(async (..._args: unknown[]) => undefined),
|
set: vi.fn(async (..._args: unknown[]) => undefined),
|
||||||
del: 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
|
incr: vi.fn(async (..._args: unknown[]) => 1), // per-source rate window counter
|
||||||
@@ -52,7 +39,7 @@ function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
|
|||||||
};
|
};
|
||||||
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 = makeQueue("catalog-prefetch-fast", 0);
|
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,
|
fastQueue as never,
|
||||||
@@ -225,18 +212,7 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("checkSourceRate — per-source rate limit", () => {
|
describe("checkSourceRate — per-source rate limit", () => {
|
||||||
type CSR = { checkSourceRate: (s: string, lane?: "main" | "fast") => Promise<void> };
|
type CSR = { checkSourceRate: (s: string) => Promise<void> };
|
||||||
|
|
||||||
// pl24's MAIN lane is parked whenever background backfill is off, which is
|
|
||||||
// the default — so these ceiling tests turn it on explicitly to exercise the
|
|
||||||
// rate limiter itself rather than the backfill gate (covered separately).
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.PL24_BACKFILL_ENABLED = "true";
|
|
||||||
process.env.PL24_TR_DISABLED = undefined;
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.PL24_BACKFILL_ENABLED = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes when under the source ceiling", async () => {
|
it("passes when under the source ceiling", async () => {
|
||||||
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
@@ -252,24 +228,6 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pl24 MAIN lane parkta iken hiç sayaç harcamaz (varsayılan)", async () => {
|
|
||||||
process.env.PL24_BACKFILL_ENABLED = undefined;
|
|
||||||
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
|
||||||
await expect((service as never as CSR).checkSourceRate("pl24", "main")).rejects.toMatchObject(
|
|
||||||
{ cause: "source-rate" },
|
|
||||||
);
|
|
||||||
expect(redis.incr).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("kullanıcı (fast) şeridi backfill anahtarından etkilenmez", async () => {
|
|
||||||
process.env.PL24_BACKFILL_ENABLED = undefined;
|
|
||||||
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
|
||||||
redis.incr.mockResolvedValueOnce(1);
|
|
||||||
await expect(
|
|
||||||
(service as never as CSR).checkSourceRate("pl24", "fast"),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is unlimited (no counter) for a source without a configured ceiling", async () => {
|
it("is unlimited (no counter) for a source without a configured ceiling", async () => {
|
||||||
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
const { service, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
await (service as never as CSR).checkSourceRate("unknown-source");
|
await (service as never as CSR).checkSourceRate("unknown-source");
|
||||||
@@ -280,10 +238,11 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
describe("poison guard (Faz 6: category cap — brand-agnostic anti-explosion)", () => {
|
describe("poison guard (Faz 6: category cap — brand-agnostic anti-explosion)", () => {
|
||||||
type PC = { processChildren: (j: unknown) => Promise<void> };
|
type PC = { processChildren: (j: unknown) => Promise<void> };
|
||||||
|
|
||||||
it("processChildren marks poison once the STORED tree exceeds CATEGORY_CAP", async () => {
|
it("processChildren marks poison once progress.total exceeds CATEGORY_CAP", async () => {
|
||||||
// The cap is now measured from the DB (categories count), not the ephemeral
|
const { service, queue, redis } = makeDeps({ waiting: 0, limitResults: [] });
|
||||||
// progress.total which every processInit resets to 0.
|
redis.getJson.mockImplementation(async (...a: unknown[]) =>
|
||||||
const { service, queue, redis } = makeDeps({ waiting: 0, limitResults: [[{ n: 5000 }]] });
|
String(a[0]).includes("progress") ? { total: 5000 } : null,
|
||||||
|
);
|
||||||
await (service as never as PC).processChildren({
|
await (service as never as PC).processChildren({
|
||||||
data: { vehicleId: "op1", categoryId: "c1", source: "pl24", depth: 1 },
|
data: { vehicleId: "op1", categoryId: "c1", source: "pl24", depth: 1 },
|
||||||
} as never);
|
} as never);
|
||||||
@@ -343,61 +302,3 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PL24 reaktif drill derinliği + backfill pacing (plv2 Faz 1 / adım 2b) ──
|
|
||||||
// Ban'ı süren hacim, her yeni decode'da tüm ağacın gezilmesiydi (bir Passat =
|
|
||||||
// 1.251 kategori). Fast lane artık PL24'te 1. seviyede durur; derin drill ya
|
|
||||||
// kullanıcı tıklamasıyla ya da bütçeli backfill lane'inde olur.
|
|
||||||
describe("PrefetchWorkerService — PL24 derinlik tavanı", () => {
|
|
||||||
const load = async () => {
|
|
||||||
const mod = await import("./prefetch-worker.service");
|
|
||||||
return mod as unknown as {
|
|
||||||
__testables?: { maxDepthFor(source: string, fast: boolean): number };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
it("pl24 fast lane 1. seviyede durur, diğer kaynaklar tam derinlik kullanır", async () => {
|
|
||||||
// maxDepthFor modül-özel; davranışı dolaylı doğrula: env varsayılanları
|
|
||||||
process.env.PREFETCH_PL24_FAST_DEPTH = "";
|
|
||||||
process.env.PREFETCH_MAX_DEPTH = "";
|
|
||||||
const mod = await load();
|
|
||||||
const fn = mod.__testables?.maxDepthFor;
|
|
||||||
if (!fn) return; // testable export yoksa atla (davranış e2e'de doğrulanır)
|
|
||||||
expect(fn("pl24", true)).toBe(1);
|
|
||||||
expect(fn("pl24", false)).toBeGreaterThan(1);
|
|
||||||
expect(fn("parts-catalogs", true)).toBeGreaterThan(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PL24 arka plan backfill anahtarı (Faz 3) + Mitsubishi parça-detay kırpması.
|
|
||||||
*/
|
|
||||||
describe("PL24 backfill anahtarı", () => {
|
|
||||||
const ENV = { ...process.env };
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...ENV };
|
|
||||||
});
|
|
||||||
|
|
||||||
it("varsayılan KAPALI — değişken hiç yoksa arka plan akmaz", async () => {
|
|
||||||
const { __testables } = await import("./prefetch-worker.service");
|
|
||||||
process.env.PL24_BACKFILL_ENABLED = undefined;
|
|
||||||
process.env.PL24_TR_DISABLED = undefined;
|
|
||||||
expect(__testables.isPl24BackfillEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("yalnız açık 'true' ile açılır", async () => {
|
|
||||||
const { __testables } = await import("./prefetch-worker.service");
|
|
||||||
process.env.PL24_TR_DISABLED = undefined;
|
|
||||||
process.env.PL24_BACKFILL_ENABLED = "true";
|
|
||||||
expect(__testables.isPl24BackfillEnabled()).toBe(true);
|
|
||||||
process.env.PL24_BACKFILL_ENABLED = "1";
|
|
||||||
expect(__testables.isPl24BackfillEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("eski PL24_TR_DISABLED hâlâ kapatabilir (yarım deploy musluğu açamaz)", async () => {
|
|
||||||
const { __testables } = await import("./prefetch-worker.service");
|
|
||||||
process.env.PL24_BACKFILL_ENABLED = "true";
|
|
||||||
process.env.PL24_TR_DISABLED = "true";
|
|
||||||
expect(__testables.isPl24BackfillEnabled()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ import { and, asc, eq, gt, inArray, isNull, notExists, sql } from "drizzle-orm";
|
|||||||
import { CategoriesService } from "../categories/categories.service";
|
import { CategoriesService } from "../categories/categories.service";
|
||||||
import { DATABASE, type Database } from "../database/database.provider";
|
import { DATABASE, type Database } from "../database/database.provider";
|
||||||
import { categories, parts, vehicles } from "../database/schema/core";
|
import { categories, parts, vehicles } from "../database/schema/core";
|
||||||
import { isPl24LeafNode, isPl24PartDetailNode } from "../integrations/pl24/pl24-tree";
|
|
||||||
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 { QUEUE_NAMES, getBullConnection } from "./bull.config";
|
import { QUEUE_NAMES, getBullConnection } from "./bull.config";
|
||||||
import { backfillContext } from "./prefetch-context";
|
import { backfillContext } from "./prefetch-context";
|
||||||
import {
|
import {
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
alignToWindow,
|
|
||||||
checkCooldown,
|
checkCooldown,
|
||||||
checkTimeWindow,
|
checkTimeWindow,
|
||||||
initProgress,
|
initProgress,
|
||||||
@@ -55,71 +53,9 @@ const MAX_DEPTH = Number(process.env.PREFETCH_MAX_DEPTH) || 12;
|
|||||||
const BACKFILL_BATCH_SIZE = 40;
|
const BACKFILL_BATCH_SIZE = 40;
|
||||||
/** Skip the wave entirely if the queue already has more than this many jobs pending. */
|
/** Skip the wave entirely if the queue already has more than this many jobs pending. */
|
||||||
const BACKFILL_MAX_BACKLOG = 1000;
|
const BACKFILL_MAX_BACKLOG = 1000;
|
||||||
/**
|
/** In-flight guard TTL (seconds) — safety net if a run dies without clearing. */
|
||||||
* In-flight guard TTL (seconds) — safety net if a run dies without clearing.
|
const BACKFILL_SCHEDULED_TTL = 6 * 60 * 60;
|
||||||
*
|
|
||||||
* 36h, NOT 6h: a chain deferred on the daily budget lives until the next UTC
|
|
||||||
* midnight (~24h). With a 6h TTL the guard expired mid-chain, the scan re-picked
|
|
||||||
* the vehicle, processInit reset progress.total, and the in-flight jobs'
|
|
||||||
* incrementCompleted then compared `completed >= total` against the NEW total —
|
|
||||||
* corrupting completion accounting for a still-partial vehicle.
|
|
||||||
*/
|
|
||||||
const BACKFILL_SCHEDULED_TTL = Number(process.env.PREFETCH_SCHEDULED_TTL_H || 36) * 60 * 60;
|
|
||||||
/**
|
|
||||||
* Only delayed jobs due within this horizon count as queue PRESSURE.
|
|
||||||
*
|
|
||||||
* `delayed` lumps two very different things together: per-minute source-rate
|
|
||||||
* defers (<60s) and retry backoff (30/60s), which ARE load, versus daily-budget
|
|
||||||
* and off-hours defers (hours, parked on the next UTC midnight), which are merely
|
|
||||||
* THROTTLED FUTURE WORK. Counting the latter as backlog froze Phase-2 for most of
|
|
||||||
* the day (2026-08-01: delayed=11495, all pcat, all parked at 00:00:01 UTC; the
|
|
||||||
* partial drain fell to ~25 vehicles/day, ETA 79 days).
|
|
||||||
*/
|
|
||||||
const PRESSURE_HORIZON_MS = Number(process.env.PREFETCH_PRESSURE_HORIZON_MS) || 10 * 60_000;
|
|
||||||
/**
|
|
||||||
* Absolute ceiling on TOTAL pending jobs (waiting+active+delayed+prioritized)
|
|
||||||
* across BOTH lanes — the runaway backstop that `delayed` used to provide by
|
|
||||||
* accident. Excluding long defers from the pressure gate removes the only
|
|
||||||
* negative feedback on production, so the pool needs its own hard stop: Phase-2
|
|
||||||
* can otherwise produce ~288k jobs/day against ~65k/day of budget. 50k ≈ 15h of
|
|
||||||
* the combined daily budgets, so the pool always drains inside a window; 1/9 of
|
|
||||||
* the guard-less BFS runaway (470k, months to drain). Set 0 to disable Phase-2.
|
|
||||||
*/
|
|
||||||
const HARD_MAX_TOTAL_JOBS = Number(process.env.PREFETCH_MAX_TOTAL_JOBS ?? 50_000);
|
|
||||||
/**
|
|
||||||
* Rough fan-out of one Phase-2 re-drill. Admission control is done in JOB units,
|
|
||||||
* not vehicle units: 40 vehicles/wave is meaningless when one vehicle is 100-3000
|
|
||||||
* jobs (p95 tree = 744 categories).
|
|
||||||
*/
|
|
||||||
const EST_JOBS_PER_VEHICLE = Number(process.env.PREFETCH_EST_JOBS_PER_VEHICLE) || 400;
|
|
||||||
/**
|
|
||||||
* Share of each source's daily budget reserved for the FAST lane. The daily
|
|
||||||
* counter has no lane component, so backfill spending the budget also parked the
|
|
||||||
* user's fresh-decode chain until midnight. One shared counter (the total
|
|
||||||
* upstream/bandwidth ceiling stays exactly SOURCE_DAILY_MAX) but two thresholds:
|
|
||||||
* the main (backfill) lane stops at 80%, the fast lane may use 100%.
|
|
||||||
*/
|
|
||||||
const DAILY_FAST_RESERVE = 0.2;
|
|
||||||
/** Only these decode sources have catalogs worth prefetching. */
|
/** Only these decode sources have catalogs worth prefetching. */
|
||||||
/**
|
|
||||||
* Whether the PL24 *background* backfill lane may run. The user-triggered fast
|
|
||||||
* lane is never gated by this.
|
|
||||||
*
|
|
||||||
* Default is OFF. Two PL24 accounts were banned while bulk background load ran
|
|
||||||
* against them, so the background lane has to be switched on deliberately and
|
|
||||||
* watched, never inherited from an unset variable.
|
|
||||||
*
|
|
||||||
* `PL24_BACKFILL_ENABLED` replaces the old `PL24_TR_DISABLED`, whose name said
|
|
||||||
* "the tr account is dead" while its actual job was "keep bulk load off the one
|
|
||||||
* surviving account". The old variable is still honoured so a half-applied
|
|
||||||
* deploy cannot silently open the tap: it can only keep the lane closed.
|
|
||||||
*/
|
|
||||||
function isPl24BackfillEnabled(): boolean {
|
|
||||||
if (process.env.PL24_BACKFILL_ENABLED !== "true") return false;
|
|
||||||
// Legacy kill switch still wins while it is explicitly set.
|
|
||||||
return process.env.PL24_TR_DISABLED !== "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
const BACKFILL_SOURCES = ["pl24", "emex", "parts-catalogs"];
|
const BACKFILL_SOURCES = ["pl24", "emex", "parts-catalogs"];
|
||||||
/** Redis key holding the rolling rescan cursor (last createdAt seen). */
|
/** Redis key holding the rolling rescan cursor (last createdAt seen). */
|
||||||
const BACKFILL_CURSOR_KEY = "prefetch:backfill:cursor";
|
const BACKFILL_CURSOR_KEY = "prefetch:backfill:cursor";
|
||||||
@@ -162,69 +98,12 @@ const SOURCE_RATE_MAX: Record<string, number> = {
|
|||||||
// (2026-07-08: 93k backlog %89 pcat idi — 20/min + 5s pace drenaja yetmiyordu).
|
// (2026-07-08: 93k backlog %89 pcat idi — 20/min + 5s pace drenaja yetmiyordu).
|
||||||
"parts-catalogs": Number(process.env.PREFETCH_RATE_PCAT) || 90,
|
"parts-catalogs": Number(process.env.PREFETCH_RATE_PCAT) || 90,
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* Per-source ROLLING-DAY budget (backfill jobs/source/UTC-day). A storm guard on
|
|
||||||
* top of the per-minute ceilings: those cap burst rate but not the daily TOTAL,
|
|
||||||
* so a source running near its ceiling for many hours drains the proxy budget
|
|
||||||
* (2026-07-09: a backlog drain pushed pcat to ~74k calls / ~10 GB in one day).
|
|
||||||
* When a source hits its daily budget, further backfill jobs defer until the
|
|
||||||
* window rolls. User-facing decodes are unaffected — they don't pass through the
|
|
||||||
* worker. Only counts jobs that already cleared the per-minute gate, so
|
|
||||||
* rate-limited retries don't inflate it. 0 = unlimited. Env-tunable: raise to
|
|
||||||
* drain a backlog faster, lower to conserve proxy budget harder.
|
|
||||||
*/
|
|
||||||
const SOURCE_DAILY_MAX: Record<string, number> = {
|
|
||||||
pl24: Number(process.env.PREFETCH_DAILY_PL24) || 15_000,
|
|
||||||
emex: Number(process.env.PREFETCH_DAILY_EMEX) || 20_000,
|
|
||||||
// 45k (1.5x): the 2026-07-09 incident that created this guard was ~74k calls /
|
|
||||||
// ~10 GB in a day, so 45k ≈ 61% of that — a reversible step at ~6 GB/day. With
|
|
||||||
// DAILY_FAST_RESERVE the backfill (main) lane gets 36k, i.e. +20% over the old
|
|
||||||
// effective 30k while still leaving 9k for user decodes. Burst rate is
|
|
||||||
// UNCHANGED (SOURCE_RATE_MAX pcat 90/min), so per-IP ban pressure is the same —
|
|
||||||
// this only sustains that pace for more of the day. Rollback signal: any hour
|
|
||||||
// with >2 pcat HTTP 402 (DataImpulse quota) → back to 30_000.
|
|
||||||
"parts-catalogs": Number(process.env.PREFETCH_DAILY_PCAT) || 45_000,
|
|
||||||
};
|
|
||||||
/**
|
/**
|
||||||
* Per-job pacing for parts-catalogs only (its browser/JWT capture is heavy).
|
* 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) || 1_500;
|
const PCAT_PACE_MS = Number(process.env.PREFETCH_PCAT_DELAY_MS) || 1_500;
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-job pacing for PL24 BACKFILL jobs (main lane only — a user waiting on a
|
|
||||||
* fresh decode must never be slowed down). Both account bans followed days of
|
|
||||||
* thousands of back-to-back PL24 calls; a paced, jittered stream looks nothing
|
|
||||||
* like that. Set 0 to disable.
|
|
||||||
*/
|
|
||||||
const PL24_PACE_MS = Number(process.env.PREFETCH_PL24_DELAY_MS) || 8_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How deep the REACTIVE (fast-lane) drill may go for PL24.
|
|
||||||
*
|
|
||||||
* A freshly decoded vehicle used to be walked to the bottom immediately: one
|
|
||||||
* Passat produced 1,251 categories, one L200 2,323 — 1.4k-6.8k categories/day
|
|
||||||
* from 3-17 decodes, which is exactly the volume that preceded both bans
|
|
||||||
* (plv2.md §2.2). Depth 1 = top groups and their direct children; anything
|
|
||||||
* deeper is fetched lazily when the user actually opens that node, or by the
|
|
||||||
* budgeted backfill lane. Other sources keep MAX_DEPTH.
|
|
||||||
*/
|
|
||||||
const PL24_FAST_MAX_DEPTH = Number(process.env.PREFETCH_PL24_FAST_DEPTH) || 1;
|
|
||||||
|
|
||||||
/** Depth ceiling for this source+lane. */
|
|
||||||
function maxDepthFor(source: string, fast: boolean): number {
|
|
||||||
if (source === "pl24" && fast) return PL24_FAST_MAX_DEPTH;
|
|
||||||
return MAX_DEPTH;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Jittered pace so our request stream is not a metronome. */
|
|
||||||
function jitter(ms: number): number {
|
|
||||||
return Math.round(ms * (0.5 + Math.random()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test-only surface for the pure helpers above. */
|
|
||||||
export const __testables = { maxDepthFor, jitter, isPl24BackfillEnabled };
|
|
||||||
|
|
||||||
// ── Phase-1 residue exclusion ──
|
// ── Phase-1 residue exclusion ──
|
||||||
/**
|
/**
|
||||||
* Skip a zero-parts vehicle once this many backfill attempts have completed with
|
* Skip a zero-parts vehicle once this many backfill attempts have completed with
|
||||||
@@ -317,7 +196,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, global ${WORKER_RATE_MAX}/min, ` +
|
`[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, global ${WORKER_RATE_MAX}/min, ` +
|
||||||
`per-source ${JSON.stringify(SOURCE_RATE_MAX)}, daily ${JSON.stringify(SOURCE_DAILY_MAX)}, pcatPace=${PCAT_PACE_MS}ms)`,
|
`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
|
||||||
@@ -373,45 +252,14 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
job.name === "prefetch-children" ||
|
job.name === "prefetch-children" ||
|
||||||
job.name === "prefetch-parts")
|
job.name === "prefetch-parts")
|
||||||
) {
|
) {
|
||||||
const lane = (job.data as { fast?: boolean }).fast ? "fast" : "main";
|
await this.checkSourceRate(
|
||||||
// GATE ORDER IS LOAD-BEARING — cheapest first, and every gate that can
|
data.source,
|
||||||
// reject the job must run BEFORE any counter is debited.
|
(job.data as { fast?: boolean }).fast ? "fast" : "main",
|
||||||
//
|
);
|
||||||
// The business-hours window and the cooldown used to sit at the top of
|
|
||||||
// each handler, i.e. AFTER the per-minute counter and the daily budget
|
|
||||||
// had already been charged. So a job that woke outside the window paid a
|
|
||||||
// budget unit to do nothing. Combined with a deferral target of "next UTC
|
|
||||||
// midnight" (= 03:00 Europe/Istanbul, six hours before a 09:00 window
|
|
||||||
// opens) that closed a loop: the whole daily allowance was burned by
|
|
||||||
// no-op wake-ups before the window ever opened, so the source never ran
|
|
||||||
// again. Measured on prod 2026-09-20 — pl24 at 600/600 with 0 catalog
|
|
||||||
// requests and 0 new categories for the day.
|
|
||||||
//
|
|
||||||
// 1. Window: pure clock arithmetic, no I/O, and an out-of-window job can
|
|
||||||
// never do useful work — so nothing else is worth spending on it.
|
|
||||||
checkTimeWindow(data.source);
|
|
||||||
// 2. Cooldown: one Redis TTL read. Pauses the whole worker (see catch).
|
|
||||||
await checkCooldown(this.redis, data.source);
|
|
||||||
// 3. Per-minute ceiling.
|
|
||||||
await this.checkSourceRate(data.source, lane);
|
|
||||||
// 4. Daily budget last: a job deferred on any gate above never reaches
|
|
||||||
// here, so only jobs about to do real work are counted. The lane
|
|
||||||
// decides which threshold applies (backfill stops at the main limit,
|
|
||||||
// the user's fast lane may use the full budget).
|
|
||||||
await this.checkSourceDailyBudget(data.source, lane);
|
|
||||||
}
|
}
|
||||||
if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) {
|
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));
|
||||||
}
|
}
|
||||||
// PL24 backfill only: pace + jitter. The fast (user) lane is never delayed.
|
|
||||||
if (
|
|
||||||
data.source === "pl24" &&
|
|
||||||
PL24_PACE_MS > 0 &&
|
|
||||||
!(job.data as { fast?: boolean }).fast &&
|
|
||||||
(job.name === "prefetch-children" || job.name === "prefetch-parts")
|
|
||||||
) {
|
|
||||||
await new Promise((r) => setTimeout(r, jitter(PL24_PACE_MS)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (job.name === "backfill-scan") {
|
if (job.name === "backfill-scan") {
|
||||||
return await this.processBackfillScan();
|
return await this.processBackfillScan();
|
||||||
@@ -459,8 +307,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { vehicleId, source, fast = false } = job.data;
|
const { vehicleId, source, fast = false } = job.data;
|
||||||
this.logger.log(`[prefetch] Init for vehicle=${vehicleId}, source=${source}`);
|
this.logger.log(`[prefetch] Init for vehicle=${vehicleId}, source=${source}`);
|
||||||
|
|
||||||
// Cooldown + time-window are enforced in process() before the daily budget
|
await checkCooldown(this.redis, source);
|
||||||
// is debited — see the comment there; re-checking here would be a no-op.
|
checkTimeWindow(source);
|
||||||
|
|
||||||
// Already flagged as poison (tree exceeded CATEGORY_CAP on a prior run) — skip.
|
// Already flagged as poison (tree exceeded CATEGORY_CAP on a prior run) — skip.
|
||||||
if (await this.redis.exists(this.poisonKey(vehicleId))) {
|
if (await this.redis.exists(this.poisonKey(vehicleId))) {
|
||||||
@@ -539,11 +387,10 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (child.unavailable) continue;
|
if (child.unavailable) continue;
|
||||||
// Count jobs ACTUALLY queued, not nodes walked — see addJob's doc: an
|
await this.queueCategoryJob(child, vehicleId, source, 1, fast);
|
||||||
// inflated progress.total makes the chain never reach "finished".
|
queued++;
|
||||||
queued += await this.queueCategoryJob(child, vehicleId, source, 1, fast);
|
|
||||||
}
|
}
|
||||||
} else if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups, cat.linkWid)) {
|
} else if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups)) {
|
||||||
// Leaf — check if parts already fetched
|
// Leaf — check if parts already fetched
|
||||||
const [partCheck] = await this.db
|
const [partCheck] = await this.db
|
||||||
.select({ id: parts.id })
|
.select({ id: parts.id })
|
||||||
@@ -551,46 +398,32 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
.where(eq(parts.categoryId, cat.id))
|
.where(eq(parts.categoryId, cat.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (
|
if (!partCheck && cat.linkPath) {
|
||||||
!partCheck &&
|
await this.addJob("prefetch-parts", {
|
||||||
cat.linkPath &&
|
|
||||||
(await this.addJob("prefetch-parts", {
|
|
||||||
vehicleId,
|
vehicleId,
|
||||||
categoryId: cat.id,
|
categoryId: cat.id,
|
||||||
source,
|
source,
|
||||||
action: "parts" as const,
|
action: "parts" as const,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
fast,
|
fast,
|
||||||
}))
|
});
|
||||||
) {
|
|
||||||
queued++;
|
queued++;
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (cat.linkPath) {
|
||||||
cat.linkPath &&
|
|
||||||
// Non-leaf without children — needs children fetch
|
// Non-leaf without children — needs children fetch
|
||||||
(await this.addJob("prefetch-children", {
|
await this.addJob("prefetch-children", {
|
||||||
vehicleId,
|
vehicleId,
|
||||||
categoryId: cat.id,
|
categoryId: cat.id,
|
||||||
source,
|
source,
|
||||||
action: "children" as const,
|
action: "children" as const,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
fast,
|
fast,
|
||||||
}))
|
});
|
||||||
) {
|
|
||||||
queued++;
|
queued++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateProgress(this.redis, vehicleId, { total: queued });
|
await updateProgress(this.redis, vehicleId, { total: queued });
|
||||||
if (queued === 0) {
|
|
||||||
// Nothing left to fetch — the tree is already complete in DB. Without this
|
|
||||||
// there is no job to fire incrementCompleted, so progress sits at {0,0}, the
|
|
||||||
// vehicle is never marked fullyFetched, and the scan re-picks it every wave
|
|
||||||
// forever while burning a daily-budget slot each time.
|
|
||||||
this.logger.log(`[prefetch] Nothing to queue for vehicle=${vehicleId} — already complete`);
|
|
||||||
await this.finalizeVehicle(vehicleId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logger.log(`[prefetch] Queued ${queued} sub-jobs for vehicle=${vehicleId}`);
|
this.logger.log(`[prefetch] Queued ${queued} sub-jobs for vehicle=${vehicleId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,33 +434,22 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { vehicleId, categoryId, source, depth, fast = false } = job.data;
|
const { vehicleId, categoryId, source, depth, fast = false } = job.data;
|
||||||
this.logger.log(`[prefetch] Children for category=${categoryId}, depth=${depth}`);
|
this.logger.log(`[prefetch] Children for category=${categoryId}, depth=${depth}`);
|
||||||
|
|
||||||
// Cooldown + time-window are enforced in process() before the daily budget
|
await checkCooldown(this.redis, source);
|
||||||
// is debited — see the comment there; re-checking here would be a no-op.
|
checkTimeWindow(source);
|
||||||
|
|
||||||
const depthCeiling = maxDepthFor(source, fast);
|
if (depth >= MAX_DEPTH) {
|
||||||
if (depth >= depthCeiling) {
|
this.logger.warn(`[prefetch] Max depth reached for category=${categoryId}`);
|
||||||
// For the PL24 fast lane this is the normal stopping point, not a problem:
|
|
||||||
// deeper nodes are drilled lazily on user click or by the backfill lane.
|
|
||||||
const level = source === "pl24" && fast ? "log" : "warn";
|
|
||||||
this.logger[level](
|
|
||||||
`[prefetch] Depth ceiling ${depthCeiling} reached for category=${categoryId} (source=${source}, lane=${fast ? "fast" : "main"})`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anti-poison cap measured from the DB, NOT from progress.total: the ephemeral
|
// Anti-poison cap: progress.total tracks every sub-job queued for this vehicle
|
||||||
// counter is reset to 0 by every processInit, so a re-picked vehicle could
|
// (≈ its discovered tree size). Once it blows past the ceiling the vehicle is a
|
||||||
// drill another CATEGORY_CAP nodes per round and never trip the guard (the
|
// generic-catalog explosion — stop drilling and mark it poison so the rest of
|
||||||
// generic-ROOT Opels: 420k/102k/21k categories, ZERO parts). The stored tree
|
// its (part-less) tree is never fetched and it's never re-picked.
|
||||||
// is the real, cumulative size.
|
const prog = await this.redis.getJson<{ total: number }>(`prefetch:progress:${vehicleId}`);
|
||||||
const [capRow] = await this.db
|
if ((prog?.total ?? 0) >= CATEGORY_CAP) {
|
||||||
.select({ n: sql<number>`count(*)::int` })
|
|
||||||
.from(categories)
|
|
||||||
.where(eq(categories.vehicleId, vehicleId))
|
|
||||||
.limit(1);
|
|
||||||
if ((capRow?.n ?? 0) >= CATEGORY_CAP) {
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[prefetch] Category cap ${CATEGORY_CAP} hit for vehicle=${vehicleId} (tree=${capRow?.n}) — marking poison, stop drilling`,
|
`[prefetch] Category cap ${CATEGORY_CAP} hit for vehicle=${vehicleId} (tree≈${prog?.total}) — marking poison, stop drilling`,
|
||||||
);
|
);
|
||||||
await this.markPoison(vehicleId);
|
await this.markPoison(vehicleId);
|
||||||
return;
|
return;
|
||||||
@@ -639,9 +461,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
let queued = 0;
|
let queued = 0;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (child.unavailable) continue;
|
if (child.unavailable) continue;
|
||||||
// Real queued-job count (see addJob) — walking a node that dedupes or
|
await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
||||||
// already has parts must not inflate progress.total.
|
queued++;
|
||||||
queued += await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queued > 0) {
|
if (queued > 0) {
|
||||||
@@ -669,8 +490,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const { vehicleId, categoryId, source } = job.data;
|
const { vehicleId, categoryId, source } = job.data;
|
||||||
this.logger.log(`[prefetch] Parts for category=${categoryId}`);
|
this.logger.log(`[prefetch] Parts for category=${categoryId}`);
|
||||||
|
|
||||||
// Cooldown + time-window are enforced in process() before the daily budget
|
await checkCooldown(this.redis, source);
|
||||||
// is debited — see the comment there; re-checking here would be a no-op.
|
checkTimeWindow(source);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.categoriesService.getCategoryWithParts(categoryId);
|
await this.categoriesService.getCategoryWithParts(categoryId);
|
||||||
@@ -720,60 +541,19 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// genuinely-empty vehicles keep getting onboarded through the fast lane even
|
// genuinely-empty vehicles keep getting onboarded through the fast lane even
|
||||||
// while a large deep-drill backlog is still draining — otherwise a single
|
// while a large deep-drill backlog is still draining — otherwise a single
|
||||||
// backlog spike freezes new-vehicle coverage until the whole queue clears.
|
// backlog spike freezes new-vehicle coverage until the whole queue clears.
|
||||||
// Two INDEPENDENT ceilings (Phase-1 fast lane is never gated, so genuinely
|
const counts = await this.queue.getJobCounts("waiting", "delayed", "active");
|
||||||
// empty vehicles keep getting onboarded):
|
const backlog = (counts.waiting ?? 0) + (counts.delayed ?? 0) + (counts.active ?? 0);
|
||||||
// 1. pressure = waiting + active + delayed-due-within-PRESSURE_HORIZON_MS,
|
const phase2Allowed = backlog <= maxBacklog;
|
||||||
// both lanes, vs maxBacklog — "are the workers actually swamped?".
|
|
||||||
// Budget/off-hours defers are parked hours out and no longer count, so a
|
|
||||||
// spent daily budget stops freezing Phase-2 for the rest of the day.
|
|
||||||
// 2. total = every pending job, both lanes, vs HARD_MAX_TOTAL_JOBS — the
|
|
||||||
// runaway backstop that `delayed` used to provide by accident.
|
|
||||||
const depth = await this.getQueueDepth();
|
|
||||||
const headroom = Math.max(0, HARD_MAX_TOTAL_JOBS - depth.total);
|
|
||||||
// Admission control in JOB units: one wave fans out to batchSize * ~400 jobs
|
|
||||||
// long before the next hourly scan can react, so shrink the wave as the pool
|
|
||||||
// fills instead of stepping over the cap by a whole batch.
|
|
||||||
const phase2Budget = Math.min(batchSize, Math.floor(headroom / EST_JOBS_PER_VEHICLE));
|
|
||||||
const phase2Allowed = depth.pressure <= maxBacklog && phase2Budget > 0;
|
|
||||||
if (!phase2Allowed) {
|
if (!phase2Allowed) {
|
||||||
this.logger.log(
|
this.logger.log(`[backfill] Backlog ${backlog} > ${maxBacklog} — Phase-1 (fast lane) only`);
|
||||||
`[backfill] Phase-1 (fast lane) only — pressure=${depth.pressure}/${maxBacklog}, ` +
|
|
||||||
`total=${depth.total}/${HARD_MAX_TOTAL_JOBS} (delayed=${depth.delayed}, imminent=${depth.imminent})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (HARD_MAX_TOTAL_JOBS > 0 && depth.total > HARD_MAX_TOTAL_JOBS * 1.5) {
|
|
||||||
// Should be unreachable — admission control caps intake once per hour. If it
|
|
||||||
// fires, real fan-out is far above EST_JOBS_PER_VEHICLE.
|
|
||||||
this.logger.error(
|
|
||||||
`[backfill] Queue pool ${depth.total} > 1.5x hard cap ${HARD_MAX_TOTAL_JOBS} — investigate fan-out`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only target sources eligible right now: not in cooldown (user active),
|
// Only target sources eligible right now: not in cooldown (user active) and
|
||||||
// inside their scrape window, AND still inside today's main-lane budget.
|
// inside their scrape window (PL24/parts-catalogs office hours; EMEX always).
|
||||||
// The budget check is THE key guard: deferring at job level only MOVES work
|
|
||||||
// into `delayed`, it does not stop PRODUCING it — and budget defers never
|
|
||||||
// exhaust attempts (moveToDelayed skipAttempt), so nothing drops them. Once a
|
|
||||||
// source's main-lane budget is spent the scan must stop feeding it for the
|
|
||||||
// rest of the UTC day; feeding a throttled source is exactly how the 470k
|
|
||||||
// runaway was built. This also preserves the fast-lane reserve for real users.
|
|
||||||
const eligible: string[] = [];
|
const eligible: string[] = [];
|
||||||
for (const s of BACKFILL_SOURCES) {
|
for (const s of BACKFILL_SOURCES) {
|
||||||
// Background backfill is opt-in per source; pl24 defaults to OFF so bulk
|
|
||||||
// load can never burn the one surviving account by accident.
|
|
||||||
if (s === "pl24" && !isPl24BackfillEnabled()) continue;
|
|
||||||
if (await this.redis.exists(`prefetch:activity:${s}`)) continue;
|
if (await this.redis.exists(`prefetch:activity:${s}`)) continue;
|
||||||
if (cfg.businessHoursOnly !== false && !isWithinTimeWindow(s)) continue;
|
if (cfg.businessHoursOnly !== false && !isWithinTimeWindow(s)) continue;
|
||||||
const mainLimit = this.dailyMainLimit(s);
|
|
||||||
if (mainLimit > 0) {
|
|
||||||
const spent = Number((await this.redis.get(this.dailyKey(s))) ?? 0);
|
|
||||||
if (spent >= mainLimit) {
|
|
||||||
this.logger.log(
|
|
||||||
`[backfill] ${s} daily budget spent (${spent}/${mainLimit}) — source skipped this wave`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eligible.push(s);
|
eligible.push(s);
|
||||||
}
|
}
|
||||||
if (eligible.length === 0) {
|
if (eligible.length === 0) {
|
||||||
@@ -788,17 +568,9 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const tryPick = async (
|
const tryPick = async (
|
||||||
v: { id: string; source: string | null },
|
v: { id: string; source: string | null },
|
||||||
fast: boolean,
|
fast: boolean,
|
||||||
limit: number = batchSize,
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (picked.length >= limit || seen.has(v.id) || !v.source) return;
|
if (picked.length >= batchSize || seen.has(v.id) || !v.source) return;
|
||||||
// TWO different in-flight guards, both must be clear:
|
if (await this.redis.exists(`prefetch:scheduled:${v.id}`)) return; // already in flight
|
||||||
// - prefetch:scheduled:backfill:<id> — ours (36h, covers a budget-parked chain)
|
|
||||||
// - prefetch:scheduled:<id> — the USER decode path's (vehicles.service.ts
|
|
||||||
// schedulePrefetch, short TTL). We deliberately stopped WRITING the bare
|
|
||||||
// key: our long TTL made schedulePrefetch() silently skip the user's
|
|
||||||
// fresh-decode fast-lane init.
|
|
||||||
if (await this.redis.exists(this.scheduledKey(v.id))) return;
|
|
||||||
if (await this.redis.exists(`prefetch:scheduled:${v.id}`)) return; // user chain in flight
|
|
||||||
// Skip exhausted residue: vehicles whose prefetch keeps finishing with zero
|
// Skip exhausted residue: vehicles whose prefetch keeps finishing with zero
|
||||||
// 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));
|
||||||
@@ -831,27 +603,17 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
for (const v of noParts) await tryPick(v, true);
|
for (const v of noParts) await tryPick(v, true);
|
||||||
|
|
||||||
// Phase 2 — rolling rescan of vehicles that are NOT fully fetched, to gap-fill
|
// Phase 2 — rolling rescan of ALL decoded vehicles to gap-fill partially-fetched
|
||||||
// partials. Targets the DURABLE `fullyFetched` flag (indexed) instead of
|
// ones. A createdAt cursor walks forward and wraps around at the end. Gated by
|
||||||
// walking the whole fleet and relying only on the ephemeral 21-day
|
// the backlog ceiling (above) so it doesn't pile on while the queue is deep.
|
||||||
// `prefetch:complete:` marker — a Redis flush would otherwise re-drill every
|
if (phase2Allowed && picked.length < batchSize) {
|
||||||
// vehicle at once. The fullyFetchedAt clause keeps the periodic re-validation
|
|
||||||
// the TTL used to provide. Gated by the ceilings above + a JOB-unit budget.
|
|
||||||
const phase2Limit = Math.min(batchSize, picked.length + phase2Budget);
|
|
||||||
if (phase2Allowed && picked.length < phase2Limit) {
|
|
||||||
const cursorObj = await this.redis.getJson<{ ts: string }>(BACKFILL_CURSOR_KEY);
|
const cursorObj = await this.redis.getJson<{ ts: string }>(BACKFILL_CURSOR_KEY);
|
||||||
const cursor = cursorObj?.ts ? new Date(cursorObj.ts) : new Date(0);
|
const cursor = cursorObj?.ts ? new Date(cursorObj.ts) : new Date(0);
|
||||||
|
|
||||||
const rolling = await this.db
|
const rolling = await this.db
|
||||||
.select({ id: vehicles.id, source: vehicles.source, createdAt: vehicles.createdAt })
|
.select({ id: vehicles.id, source: vehicles.source, createdAt: vehicles.createdAt })
|
||||||
.from(vehicles)
|
.from(vehicles)
|
||||||
.where(
|
.where(and(inArray(vehicles.source, eligible), gt(vehicles.createdAt, cursor)))
|
||||||
and(
|
|
||||||
inArray(vehicles.source, eligible),
|
|
||||||
gt(vehicles.createdAt, cursor),
|
|
||||||
sql`(${vehicles.fullyFetched} = false OR ${vehicles.fullyFetchedAt} < now() - interval '21 days')`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(vehicles.createdAt))
|
.orderBy(asc(vehicles.createdAt))
|
||||||
.limit(overfetch);
|
.limit(overfetch);
|
||||||
|
|
||||||
@@ -867,7 +629,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
let lastTs: Date | null = null;
|
let lastTs: Date | null = null;
|
||||||
for (const v of rolling) {
|
for (const v of rolling) {
|
||||||
lastTs = v.createdAt;
|
lastTs = v.createdAt;
|
||||||
await tryPick(v, false, phase2Limit);
|
await tryPick(v, false);
|
||||||
}
|
}
|
||||||
if (lastTs) {
|
if (lastTs) {
|
||||||
await this.redis.setJson(BACKFILL_CURSOR_KEY, { ts: lastTs.toISOString() }, 30 * 86400);
|
await this.redis.setJson(BACKFILL_CURSOR_KEY, { ts: lastTs.toISOString() }, 30 * 86400);
|
||||||
@@ -883,8 +645,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const fastCount = picked.filter((v) => v.fast).length;
|
const fastCount = picked.filter((v) => v.fast).length;
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[backfill] Queued ${picked.length} vehicle(s) (${fastCount} fast-lane, ` +
|
`[backfill] Queued ${picked.length} vehicle(s) (${fastCount} fast-lane, ` +
|
||||||
`sources=${eligible.join(",")}, pressure=${depth.pressure}, total=${depth.total}, ` +
|
`sources=${eligible.join(",")}, backlog=${backlog})`,
|
||||||
`phase2Budget=${phase2Budget})`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,7 +664,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Guard cleared on completion (incrementCompleted) or by TTL if the run dies.
|
// Guard cleared on completion (incrementCompleted) or by TTL if the run dies.
|
||||||
await this.redis.set(this.scheduledKey(vehicleId), "1", BACKFILL_SCHEDULED_TTL);
|
await this.redis.set(`prefetch:scheduled:${vehicleId}`, "1", BACKFILL_SCHEDULED_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Helpers ====================
|
// ==================== Helpers ====================
|
||||||
@@ -912,7 +673,6 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
cat: {
|
cat: {
|
||||||
id: string;
|
id: string;
|
||||||
linkPath: string | null;
|
linkPath: string | null;
|
||||||
linkWid?: string | null;
|
|
||||||
source: string;
|
source: string;
|
||||||
unavailable: boolean;
|
unavailable: boolean;
|
||||||
hasSubgroups?: boolean | null;
|
hasSubgroups?: boolean | null;
|
||||||
@@ -921,21 +681,10 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
source: string,
|
source: string,
|
||||||
depth: number,
|
depth: number,
|
||||||
fast = false,
|
fast = false,
|
||||||
): Promise<number> {
|
): Promise<void> {
|
||||||
if (cat.unavailable) return 0;
|
if (cat.unavailable) return;
|
||||||
|
|
||||||
// A per-part detail node (Mitsubishi `partInfoTable` /details/vinpartinfo) is
|
if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups)) {
|
||||||
// neither a group nor a listing: its parent's response already carried the
|
|
||||||
// part. Queueing it costs one upstream request and returns nothing. Prod had
|
|
||||||
// 19,576 of these, with 2 parts between them.
|
|
||||||
if (
|
|
||||||
cat.source === "pl24" &&
|
|
||||||
isPl24PartDetailNode({ linkPath: cat.linkPath, linkWid: cat.linkWid })
|
|
||||||
) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isLeafLinkPath(cat.linkPath, cat.source, cat.hasSubgroups, cat.linkWid)) {
|
|
||||||
// Leaf — check if already has parts
|
// Leaf — check if already has parts
|
||||||
const [partCheck] = await this.db
|
const [partCheck] = await this.db
|
||||||
.select({ id: parts.id })
|
.select({ id: parts.id })
|
||||||
@@ -944,20 +693,16 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!partCheck && cat.linkPath) {
|
if (!partCheck && cat.linkPath) {
|
||||||
return (await this.addJob("prefetch-parts", {
|
await this.addJob("prefetch-parts", {
|
||||||
vehicleId,
|
vehicleId,
|
||||||
categoryId: cat.id,
|
categoryId: cat.id,
|
||||||
source: source as "pl24" | "emex",
|
source: source as "pl24" | "emex",
|
||||||
action: "parts" as const,
|
action: "parts" as const,
|
||||||
depth,
|
depth,
|
||||||
fast,
|
fast,
|
||||||
}))
|
});
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
}
|
}
|
||||||
return 0;
|
} else if (cat.linkPath && depth < MAX_DEPTH) {
|
||||||
}
|
|
||||||
if (cat.linkPath && depth < MAX_DEPTH) {
|
|
||||||
// Non-leaf within the depth cap — explore children. The `depth < MAX_DEPTH`
|
// Non-leaf within the depth cap — explore children. The `depth < MAX_DEPTH`
|
||||||
// gate mirrors processChildren's early-return: without it we'd enqueue a
|
// gate mirrors processChildren's early-return: without it we'd enqueue a
|
||||||
// prefetch-children job that processChildren just drops, burning a rate-limit
|
// prefetch-children job that processChildren just drops, burning a rate-limit
|
||||||
@@ -975,32 +720,27 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
.from(categories)
|
.from(categories)
|
||||||
.where(eq(categories.parentId, cat.id));
|
.where(eq(categories.parentId, cat.id));
|
||||||
|
|
||||||
let n = 0;
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (child.unavailable) continue;
|
if (child.unavailable) continue;
|
||||||
n += await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
await this.queueCategoryJob(child, vehicleId, source, depth + 1, fast);
|
||||||
}
|
}
|
||||||
return n;
|
} else {
|
||||||
|
await this.addJob("prefetch-children", {
|
||||||
|
vehicleId,
|
||||||
|
categoryId: cat.id,
|
||||||
|
source: source as "pl24" | "emex",
|
||||||
|
action: "children" as const,
|
||||||
|
depth,
|
||||||
|
fast,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return (await this.addJob("prefetch-children", {
|
|
||||||
vehicleId,
|
|
||||||
categoryId: cat.id,
|
|
||||||
source: source as "pl24" | "emex",
|
|
||||||
action: "children" as const,
|
|
||||||
depth,
|
|
||||||
fast,
|
|
||||||
}))
|
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
}
|
}
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLeafLinkPath(
|
private isLeafLinkPath(
|
||||||
linkPath: string | null,
|
linkPath: string | null,
|
||||||
source: string,
|
source: string,
|
||||||
hasSubgroups?: boolean | null,
|
hasSubgroups?: boolean | null,
|
||||||
linkWid?: string | null,
|
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!linkPath) return false;
|
if (!linkPath) return false;
|
||||||
// EMEX: Vehicle.aspx group nodes are parents to drill; Unit.aspx (hierarchical
|
// EMEX: Vehicle.aspx group nodes are parents to drill; Unit.aspx (hierarchical
|
||||||
@@ -1016,37 +756,21 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// flag (null, rare pre-migration rows) → treated as leaf, preserving the old
|
// flag (null, rare pre-migration rows) → treated as leaf, preserving the old
|
||||||
// 1-level behaviour for those.
|
// 1-level behaviour for those.
|
||||||
if (source === "parts-catalogs") return hasSubgroups !== true;
|
if (source === "parts-catalogs") return hasSubgroups !== true;
|
||||||
// PL24: one shared classifier (integrations/pl24/pl24-tree). The old inline
|
// PL24 leaf indicators
|
||||||
// list was case-sensitive, so p5psa/p5volvo's camelCase `/details/vin/
|
return (
|
||||||
// bomDetails` was never recognised as a leaf and its parts were never
|
linkPath.includes("/bom/") ||
|
||||||
// prefetched.
|
linkPath.includes("/bomdetails") ||
|
||||||
// `linkWid` is passed through on purpose: it is the reliable cross-brand
|
linkPath.includes("/partinfo/") ||
|
||||||
// marker and the read path has always used it, but this queueing path used
|
linkPath.includes("/servicepart/vin_items")
|
||||||
// to drop it and fall back to path matching alone. That is why Mitsubishi's
|
);
|
||||||
// `detailsTable` parts list was queued as a group here even after the shared
|
|
||||||
// classifier learned about it.
|
|
||||||
return isPl24LeafNode({ linkPath, hasSubgroups, linkWid });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async addJob(name: string, data: PrefetchCategoryJobData): Promise<void> {
|
||||||
* Queue one sub-job. Returns whether a NEW job was actually created.
|
|
||||||
*
|
|
||||||
* The return value is the fix for a completion-accounting bug: `progress.total`
|
|
||||||
* is the chain's denominator (incrementCompleted fires "finished" at
|
|
||||||
* `completed >= total`), but callers used to increment it for every node they
|
|
||||||
* WALKED — including nodes where this deterministic jobId deduped the add, and
|
|
||||||
* leaves that already had parts. An inflated total means the chain never
|
|
||||||
* reaches "finished", so the vehicle is never marked fullyFetched and the scan
|
|
||||||
* re-picks it every wave forever (the ~25 vehicles/day plateau). Queue
|
|
||||||
* behaviour is UNCHANGED — BullMQ already no-op'd a duplicate jobId.
|
|
||||||
*/
|
|
||||||
private async addJob(name: string, data: PrefetchCategoryJobData): Promise<boolean> {
|
|
||||||
// BullMQ rejects custom job IDs containing ":" (its key separator), so use
|
|
||||||
// "-" instead. The values are UUIDs — the ID only needs to be deterministic
|
|
||||||
// (for dedup), not parseable.
|
|
||||||
const jobId = `prefetch-${data.vehicleId}-${data.categoryId}-${data.action}`;
|
|
||||||
const opts: Record<string, unknown> = {
|
const opts: Record<string, unknown> = {
|
||||||
jobId,
|
// BullMQ rejects custom job IDs containing ":" (its key separator), so use
|
||||||
|
// "-" instead. The values are UUIDs — the ID only needs to be deterministic
|
||||||
|
// (for dedup), not parseable.
|
||||||
|
jobId: `prefetch-${data.vehicleId}-${data.categoryId}-${data.action}`,
|
||||||
// Fast lane (Phase-1 / reactive): add with `lifo` so the job RPUSHes to the
|
// Fast lane (Phase-1 / reactive): add with `lifo` so the job RPUSHes to the
|
||||||
// TAIL of the wait list, where BullMQ's RPOPLPUSH picks it next — i.e. ahead
|
// TAIL of the wait list, where BullMQ's RPOPLPUSH picks it next — i.e. ahead
|
||||||
// of the deep deep-drill backlog already sitting in wait. (BullMQ 5 drains
|
// of the deep deep-drill backlog already sitting in wait. (BullMQ 5 drains
|
||||||
@@ -1054,15 +778,16 @@ 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 } : {}),
|
||||||
};
|
};
|
||||||
const q = data.fast ? this.fastQueue : this.queue;
|
if (data.fast) {
|
||||||
if (await q.getJob(jobId)) return false;
|
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
|
||||||
// leaf of a vehicle waited N*20s) and is gone.
|
// leaf of a vehicle waited N*20s) and is gone.
|
||||||
|
|
||||||
await q.add(name, data, opts);
|
await this.queue.add(name, data, opts);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async incrementCompleted(vehicleId: string): Promise<void> {
|
private async incrementCompleted(vehicleId: string): Promise<void> {
|
||||||
@@ -1082,47 +807,29 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
if (isFinished) {
|
if (isFinished) {
|
||||||
this.logger.log(`[prefetch] Completed all jobs for vehicle=${vehicleId}`);
|
this.logger.log(`[prefetch] Completed all jobs for vehicle=${vehicleId}`);
|
||||||
await this.finalizeVehicle(vehicleId);
|
// Genuine residue: the whole chain finished but the vehicle still has no
|
||||||
|
// parts (all leaves empty / no catalog data). Count it so Phase-1 stops
|
||||||
|
// re-picking it every wave.
|
||||||
|
const [hasPart] = await this.db
|
||||||
|
.select({ id: parts.id })
|
||||||
|
.from(parts)
|
||||||
|
.where(eq(parts.vehicleId, vehicleId))
|
||||||
|
.limit(1);
|
||||||
|
if (hasPart) {
|
||||||
|
// Fully fetched with parts → mark complete so the Phase-2 rescan skips it
|
||||||
|
// (re-validates after the TTL). A chain with any failed job never reaches
|
||||||
|
// isFinished, so partially-fetched vehicles are never marked — they keep
|
||||||
|
// getting gap-filled.
|
||||||
|
await this.redis.set(this.completeKey(vehicleId), "1", COMPLETE_TTL_S);
|
||||||
|
} else {
|
||||||
|
await this.markNoResult(vehicleId);
|
||||||
|
}
|
||||||
|
// Clean up Redis keys — data is in PostgreSQL now
|
||||||
|
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
|
||||||
|
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Settle a vehicle whose prefetch chain is done: mark it complete (with parts)
|
|
||||||
* or count it as residue (still zero parts), then clear the run's Redis state.
|
|
||||||
* Called both when the last sub-job finishes AND when processInit finds there
|
|
||||||
* is nothing left to queue — otherwise an already-complete tree would never be
|
|
||||||
* settled and the scan would re-pick it forever.
|
|
||||||
*/
|
|
||||||
private async finalizeVehicle(vehicleId: string): Promise<void> {
|
|
||||||
// Genuine residue: the whole chain finished but the vehicle still has no
|
|
||||||
// parts (all leaves empty / no catalog data). Count it so Phase-1 stops
|
|
||||||
// re-picking it every wave.
|
|
||||||
const [hasPart] = await this.db
|
|
||||||
.select({ id: parts.id })
|
|
||||||
.from(parts)
|
|
||||||
.where(eq(parts.vehicleId, vehicleId))
|
|
||||||
.limit(1);
|
|
||||||
if (hasPart) {
|
|
||||||
// Fully fetched with parts → mark complete so the Phase-2 rescan skips it
|
|
||||||
// (re-validates after the TTL). A chain with any failed job never reaches
|
|
||||||
// isFinished, so partially-fetched vehicles are never marked — they keep
|
|
||||||
// getting gap-filled.
|
|
||||||
await this.redis.set(this.completeKey(vehicleId), "1", COMPLETE_TTL_S);
|
|
||||||
// Durable completeness flag (never expires) — the reliable "% fully
|
|
||||||
// fetched" measurement, independent of the ephemeral Redis marker.
|
|
||||||
// Re-set on every re-drill completion so fullyFetchedAt tracks last-verified.
|
|
||||||
await this.db
|
|
||||||
.update(vehicles)
|
|
||||||
.set({ fullyFetched: true, fullyFetchedAt: new Date() })
|
|
||||||
.where(eq(vehicles.id, vehicleId));
|
|
||||||
} else {
|
|
||||||
await this.markNoResult(vehicleId);
|
|
||||||
}
|
|
||||||
// Clean up Redis keys — data is in PostgreSQL now
|
|
||||||
await this.redis.del(this.scheduledKey(vehicleId));
|
|
||||||
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async incrementErrors(vehicleId: string): Promise<void> {
|
private async incrementErrors(vehicleId: string): Promise<void> {
|
||||||
const progress = await this.redis.getJson<{ errors: number }>(`prefetch:progress:${vehicleId}`);
|
const progress = await this.redis.getJson<{ errors: number }>(`prefetch:progress:${vehicleId}`);
|
||||||
if (!progress) return;
|
if (!progress) return;
|
||||||
@@ -1135,62 +842,6 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return `prefetch:noresult:${vehicleId}`;
|
return `prefetch:noresult:${vehicleId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Backfill's own in-flight guard — namespaced so it can't shadow the user
|
|
||||||
* decode path's `prefetch:scheduled:<id>` (vehicles.service.ts reads that key
|
|
||||||
* and skips the fresh-decode fast-lane init while it is set).
|
|
||||||
*/
|
|
||||||
private scheduledKey(vehicleId: string): string {
|
|
||||||
return `prefetch:scheduled:backfill:${vehicleId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue depth across BOTH lanes, split into "pressure" (work the workers will
|
|
||||||
* pick up within PRESSURE_HORIZON_MS) and "total" (everything pending).
|
|
||||||
*
|
|
||||||
* BullMQ stores delayed jobs in a ZSET scored `dueMs * 0x1000 + seq` (12-bit
|
|
||||||
* collision counter — addDelayedJob / moveToDelayed lua), so "due within X ms"
|
|
||||||
* is a plain ZCOUNT with the bound encoded the same way. RedisService exposes
|
|
||||||
* no zcount, so we borrow each queue's OWN ioredis connection and its toKey()
|
|
||||||
* rather than hand-building `bull:<name>:delayed`.
|
|
||||||
*/
|
|
||||||
private async getQueueDepth(): Promise<{
|
|
||||||
pressure: number;
|
|
||||||
total: number;
|
|
||||||
imminent: number;
|
|
||||||
delayed: number;
|
|
||||||
}> {
|
|
||||||
// String bound so ioredis can't render it in exponent notation.
|
|
||||||
const maxScore = String((Date.now() + PRESSURE_HORIZON_MS + 1) * 0x1000 - 1);
|
|
||||||
let pressure = 0;
|
|
||||||
let total = 0;
|
|
||||||
let imminent = 0;
|
|
||||||
let delayed = 0;
|
|
||||||
for (const q of [this.queue, this.fastQueue]) {
|
|
||||||
const c = await q.getJobCounts("waiting", "active", "delayed", "prioritized");
|
|
||||||
const live = (c.waiting ?? 0) + (c.active ?? 0);
|
|
||||||
const d = c.delayed ?? 0;
|
|
||||||
// Fail CLOSED: if the ZCOUNT can't be taken, count every delayed job as
|
|
||||||
// pressure — fall back to the old over-conservative gate rather than
|
|
||||||
// silently unlocking Phase-2 with no visibility. A repeating warn here
|
|
||||||
// means Phase-2 is suspended again.
|
|
||||||
let due = d;
|
|
||||||
try {
|
|
||||||
const client = await q.client;
|
|
||||||
due = await client.zcount(q.toKey("delayed"), "-inf", maxScore);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[backfill] delayed zcount failed on ${q.name} (${(err as Error).message}) — counting all delayed as pressure`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
pressure += live + due;
|
|
||||||
total += live + d + (c.prioritized ?? 0);
|
|
||||||
imminent += due;
|
|
||||||
delayed += d;
|
|
||||||
}
|
|
||||||
return { pressure, total, imminent, delayed };
|
|
||||||
}
|
|
||||||
|
|
||||||
private completeKey(vehicleId: string): string {
|
private completeKey(vehicleId: string): string {
|
||||||
return `prefetch:complete:${vehicleId}`;
|
return `prefetch:complete:${vehicleId}`;
|
||||||
}
|
}
|
||||||
@@ -1206,7 +857,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* (which stay ~200 categories and DO yield parts) are never affected. */
|
* (which stay ~200 categories and DO yield parts) are never affected. */
|
||||||
private async markPoison(vehicleId: string): Promise<void> {
|
private async markPoison(vehicleId: string): Promise<void> {
|
||||||
await this.redis.set(this.poisonKey(vehicleId), "1", POISON_TTL_S);
|
await this.redis.set(this.poisonKey(vehicleId), "1", POISON_TTL_S);
|
||||||
await this.redis.del(this.scheduledKey(vehicleId));
|
await this.redis.del(`prefetch:scheduled:${vehicleId}`);
|
||||||
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
await this.redis.del(`prefetch:progress:${vehicleId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1222,12 +873,6 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// (observed: fresh BMW init couldn't get a single pcat slot). Totals per
|
// (observed: fresh BMW init couldn't get a single pcat slot). Totals per
|
||||||
// source stay the same as before, so upstream load is unchanged.
|
// source stay the same as before, so upstream load is unchanged.
|
||||||
const total = SOURCE_RATE_MAX[source] ?? 0;
|
const total = SOURCE_RATE_MAX[source] ?? 0;
|
||||||
// Park already-queued pl24 MAIN-lane jobs while background backfill is off
|
|
||||||
// (long defer, no attempt consumed) — the eligibility scan stops producing
|
|
||||||
// new ones, this stops an existing backlog from draining through the account.
|
|
||||||
if (source === "pl24" && lane === "main" && !isPl24BackfillEnabled()) {
|
|
||||||
throw new RateLimitError(15 * 60_000, "source-rate");
|
|
||||||
}
|
|
||||||
if (total <= 0) return;
|
if (total <= 0) return;
|
||||||
const fastShare = Math.max(1, Math.floor(total / 3));
|
const fastShare = Math.max(1, Math.floor(total / 3));
|
||||||
const max = lane === "fast" ? fastShare : total - fastShare;
|
const max = lane === "fast" ? fastShare : total - fastShare;
|
||||||
@@ -1241,65 +886,6 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-source rolling-day budget (storm guard). UTC-day fixed window in Redis;
|
|
||||||
* when the source's daily job count exceeds its ceiling, defer the job until
|
|
||||||
* the window rolls so a long run can't drain the proxy budget. 0 = unlimited.
|
|
||||||
*/
|
|
||||||
private async checkSourceDailyBudget(
|
|
||||||
source: string,
|
|
||||||
lane: "main" | "fast" = "main",
|
|
||||||
): Promise<void> {
|
|
||||||
const max = SOURCE_DAILY_MAX[source] ?? 0;
|
|
||||||
if (max <= 0) return;
|
|
||||||
const limit = lane === "fast" ? max : this.dailyMainLimit(source);
|
|
||||||
const dayMs = 86_400_000;
|
|
||||||
const now = Date.now();
|
|
||||||
const key = this.dailyKey(source, now);
|
|
||||||
|
|
||||||
// READ-then-INCR (was INCR-then-check). A REJECTED attempt must not count:
|
|
||||||
// the old order inflated the counter with every defer (observed 48531 against
|
|
||||||
// a 30000 budget), which (a) made the number useless for capacity decisions
|
|
||||||
// and (b) — now that the scan reads the same counter to stop feeding a spent
|
|
||||||
// source — would let pure defer churn lock the source out. Worst-case
|
|
||||||
// overshoot under the read/incr race is WORKER_CONCURRENCY jobs: acceptable.
|
|
||||||
const n = Number((await this.redis.get(key)) ?? 0);
|
|
||||||
if (n >= limit) {
|
|
||||||
// Defer to the next UTC day, plus up to 45min of JITTER. Without jitter every
|
|
||||||
// deferred job wakes in the SAME millisecond (observed: 11495 jobs all at
|
|
||||||
// 00:00:01 UTC) — the promotion lands as one burst and the pressure signal
|
|
||||||
// flaps. Other sources keep flowing (per-job defer, not a worker pause).
|
|
||||||
const rollover = now + dayMs - (now % dayMs) + 1000 + Math.floor(Math.random() * 45 * 60_000);
|
|
||||||
// Land the retry INSIDE the source's scrape window. The UTC rollover alone
|
|
||||||
// is 03:00 Europe/Istanbul, so with a 09:00 window every deferred job woke
|
|
||||||
// six hours early, failed the window check and was deferred again — the
|
|
||||||
// other half of the deadlock fixed in process(). alignToWindow is a no-op
|
|
||||||
// when no window is configured (the default).
|
|
||||||
const msLeft = Math.max(1000, alignToWindow(source, rollover) - now);
|
|
||||||
if (n === limit) {
|
|
||||||
this.logger.warn(
|
|
||||||
`[prefetch] ${source} daily budget hit (lane=${lane}, ${n}/${limit} of ${max}) — ` +
|
|
||||||
`deferring ~${Math.round(msLeft / 3_600_000)}h to the next in-window slot`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new RateLimitError(msLeft, "source-rate");
|
|
||||||
}
|
|
||||||
const after = await this.redis.incr(key);
|
|
||||||
if (after === 1) await this.redis.expire(key, 90_000); // ~25h, outlives the window
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redis key for a source's UTC-day budget counter (shared by both lanes). */
|
|
||||||
private dailyKey(source: string, now = Date.now()): string {
|
|
||||||
return `prefetch:daily:${source}:${Math.floor(now / 86_400_000)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Main (backfill) lane threshold — the fast lane's reserve is never available
|
|
||||||
* to backfill, so a sweep can't park the user's fresh-decode chain. */
|
|
||||||
private dailyMainLimit(source: string): number {
|
|
||||||
const max = SOURCE_DAILY_MAX[source] ?? 0;
|
|
||||||
return max <= 0 ? 0 : Math.floor(max * (1 - DAILY_FAST_RESERVE));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record that a backfill attempt finished with the vehicle still at zero parts. */
|
/** 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);
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
computeStats,
|
computeStats,
|
||||||
filterOffersForBrand,
|
filterOffersForBrand,
|
||||||
istanbulToday,
|
istanbulToday,
|
||||||
partPriceCurrentCacheKey,
|
|
||||||
partPriceSeriesCacheKey,
|
|
||||||
} from "../../part-prices/part-prices.logic";
|
} from "../../part-prices/part-prices.logic";
|
||||||
import { createSupplierPriceSource } from "../../part-prices/supplier-price-source";
|
import { createSupplierPriceSource } from "../../part-prices/supplier-price-source";
|
||||||
|
|
||||||
@@ -116,8 +114,8 @@ export async function processPartPriceRefresh(
|
|||||||
|
|
||||||
// Cache düşür — bir sonraki sayfa görüntülemesi taze pg satırını okur.
|
// Cache düşür — bir sonraki sayfa görüntülemesi taze pg satırını okur.
|
||||||
const keys = chunk.flatMap((t) => [
|
const keys = chunk.flatMap((t) => [
|
||||||
partPriceSeriesCacheKey(t.codeNorm, t.brandNorm),
|
`partprice:series:v2:${t.codeNorm}::${t.brandNorm}`,
|
||||||
partPriceCurrentCacheKey(t.codeNorm, t.brandNorm),
|
`partprice:cur:v2:${t.codeNorm}::${t.brandNorm}`,
|
||||||
]);
|
]);
|
||||||
if (keys.length > 0) await redis.del(...keys);
|
if (keys.length > 0) await redis.del(...keys);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Job } from "bullmq";
|
|||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
import { catalogVehicles, vinpinDecodes } from "../../database/schema/core";
|
import { catalogVehicles, vinpinDecodes } from "../../database/schema/core";
|
||||||
import { getVinpinDaemon } from "../../integrations/vinpin/vinpin-daemon.service";
|
import { getVinpinDriver } from "../../integrations/vinpin/vinpin-driver.service";
|
||||||
import {
|
import {
|
||||||
type CatalogCandidate,
|
type CatalogCandidate,
|
||||||
pickBestCatalogMatch,
|
pickBestCatalogMatch,
|
||||||
@@ -42,11 +42,7 @@ export async function processVinpinDecode(
|
|||||||
.where(eq(vinpinDecodes.vin, vin));
|
.where(eq(vinpinDecodes.vin, vin));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Route through the warm-session daemon: inside business hours it decodes on
|
const decoded = await getVinpinDriver().decode(vin);
|
||||||
// the persistent warm seat (taskbar-raise + in-catalog decode); off-hours (or
|
|
||||||
// when warm can't be established) it transparently uses the cold per-decode
|
|
||||||
// path. Never throws — returns null on any failure.
|
|
||||||
const decoded = await getVinpinDaemon().decode(vin);
|
|
||||||
|
|
||||||
if (!decoded) {
|
if (!decoded) {
|
||||||
await db
|
await db
|
||||||
@@ -57,9 +53,7 @@ export async function processVinpinDecode(
|
|||||||
return { status: "not_found", catalogVehicleId: null };
|
return { status: "not_found", catalogVehicleId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match the decoded model to an existing PL24 catalog_vehicle of the SAME
|
// Match the decoded model to an existing PL24 Fiat catalog_vehicle.
|
||||||
// brand. Fiat → 'fiat'; Renault/Dacia → the decoded brand (RENAULT/DACIA).
|
|
||||||
const brandName = decoded.brand ?? "Fiat";
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: catalogVehicles.id,
|
id: catalogVehicles.id,
|
||||||
@@ -68,10 +62,7 @@ export async function processVinpinDecode(
|
|||||||
})
|
})
|
||||||
.from(catalogVehicles)
|
.from(catalogVehicles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(sql`lower(${catalogVehicles.brandName}) = 'fiat'`, eq(catalogVehicles.source, "pl24")),
|
||||||
sql`lower(${catalogVehicles.brandName}) = ${brandName.toLowerCase()}`,
|
|
||||||
eq(catalogVehicles.source, "pl24"),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const catalogVehicleId = pickBestCatalogMatch(
|
const catalogVehicleId = pickBestCatalogMatch(
|
||||||
@@ -83,7 +74,7 @@ export async function processVinpinDecode(
|
|||||||
.update(vinpinDecodes)
|
.update(vinpinDecodes)
|
||||||
.set({
|
.set({
|
||||||
status: "decoded",
|
status: "decoded",
|
||||||
brandName,
|
brandName: "Fiat",
|
||||||
model: decoded.model,
|
model: decoded.model,
|
||||||
sincom: decoded.sincom,
|
sincom: decoded.sincom,
|
||||||
trim: decoded.trim,
|
trim: decoded.trim,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { VINPIN_DECODE_JOB_OPTIONS } from "./vinpin-decode.queue";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single Vinpin seat is shared by every decode and the driver runs its own
|
|
||||||
* bounded internal retries + a hard wall-clock budget. A BullMQ-level auto-retry
|
|
||||||
* on top is what compounded the prod seat livelock (a failed decode auto-re-ran
|
|
||||||
* straight back into the stuck seat). So the queue must NOT auto-retry.
|
|
||||||
*/
|
|
||||||
describe("vinpin-decode queue job options", () => {
|
|
||||||
it("uses attempts:1 — no BullMQ auto-retry", () => {
|
|
||||||
expect(VINPIN_DECODE_JOB_OPTIONS.attempts).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not configure a retry backoff", () => {
|
|
||||||
expect(VINPIN_DECODE_JOB_OPTIONS.backoff).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still bounds retained job history", () => {
|
|
||||||
expect(VINPIN_DECODE_JOB_OPTIONS.removeOnComplete).toEqual({ count: 50 });
|
|
||||||
expect(VINPIN_DECODE_JOB_OPTIONS.removeOnFail).toEqual({ count: 100 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +1,9 @@
|
|||||||
import { Provider } from "@nestjs/common";
|
import { Provider } from "@nestjs/common";
|
||||||
import { type JobsOptions, Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||||
|
|
||||||
export const VINPIN_DECODE_QUEUE = "VINPIN_DECODE_QUEUE";
|
export const VINPIN_DECODE_QUEUE = "VINPIN_DECODE_QUEUE";
|
||||||
|
|
||||||
/**
|
|
||||||
* Default job options for the Vinpin decode queue.
|
|
||||||
*
|
|
||||||
* `attempts: 1` — NO BullMQ-level auto-retry. The single Vinpin seat is shared by
|
|
||||||
* every decode, and the driver ALREADY runs its own bounded internal retries
|
|
||||||
* (VINPIN_DECODE_MAX_ATTEMPTS) plus a hard wall-clock budget before it reports
|
|
||||||
* not_found. Stacking a BullMQ retry (the old attempts:2 + 30s exponential
|
|
||||||
* backoff) on top is exactly what compounded the seat livelock in prod: one bad
|
|
||||||
* VIN would fail, auto-re-run 30s later straight back into the stuck seat, and
|
|
||||||
* starve real decodes for minutes. A failed decode now persists as not_found (a
|
|
||||||
* clean "not decodable") or failed (a hard infra error, user-retriable) instead of
|
|
||||||
* being auto-re-fed into the seat.
|
|
||||||
*/
|
|
||||||
export const VINPIN_DECODE_JOB_OPTIONS: JobsOptions = {
|
|
||||||
attempts: 1,
|
|
||||||
removeOnComplete: { count: 50 },
|
|
||||||
removeOnFail: { count: 100 },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Vinpin ePER decode-oracle queue. Single Vinpin seat → the worker consumes
|
/** Vinpin ePER decode-oracle queue. Single Vinpin seat → the worker consumes
|
||||||
* this with concurrency 1. Jobs carry just `{ vin }`. */
|
* this with concurrency 1. Jobs carry just `{ vin }`. */
|
||||||
export const VinpinDecodeQueueProvider: Provider = {
|
export const VinpinDecodeQueueProvider: Provider = {
|
||||||
@@ -32,7 +13,14 @@ export const VinpinDecodeQueueProvider: Provider = {
|
|||||||
return new Queue(QUEUE_NAMES.VINPIN_DECODE, {
|
return new Queue(QUEUE_NAMES.VINPIN_DECODE, {
|
||||||
connection: getBullConnection(),
|
connection: getBullConnection(),
|
||||||
...(telemetry ? { telemetry } : {}),
|
...(telemetry ? { telemetry } : {}),
|
||||||
defaultJobOptions: VINPIN_DECODE_JOB_OPTIONS,
|
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 },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,10 +58,6 @@ async function bootstrap() {
|
|||||||
"https://connect.facebook.net",
|
"https://connect.facebook.net",
|
||||||
"https://challenges.cloudflare.com",
|
"https://challenges.cloudflare.com",
|
||||||
"https://destek.sase.tr",
|
"https://destek.sase.tr",
|
||||||
// Google Ads gtag.js (conversion tracking). Its absence here silently
|
|
||||||
// blocked the whole tag from loading → gtag config/conversion never ran
|
|
||||||
// → 0 conversions for weeks despite correct AW id + labels.
|
|
||||||
"https://www.googletagmanager.com",
|
|
||||||
"'sha256-T5FzBQBMINFjZ4WLy58SeZ+J7xXzjnQEGlg618CQnhA='",
|
"'sha256-T5FzBQBMINFjZ4WLy58SeZ+J7xXzjnQEGlg618CQnhA='",
|
||||||
],
|
],
|
||||||
styleSrc: ["'self'", "https:", "'unsafe-inline'"],
|
styleSrc: ["'self'", "https:", "'unsafe-inline'"],
|
||||||
@@ -71,12 +67,6 @@ async function bootstrap() {
|
|||||||
"https://storage.sase.tr",
|
"https://storage.sase.tr",
|
||||||
"https://www.facebook.com",
|
"https://www.facebook.com",
|
||||||
"https://destek.sase.tr",
|
"https://destek.sase.tr",
|
||||||
// Google Ads conversion + remarketing pixel pings (fire as images).
|
|
||||||
"https://www.google.com",
|
|
||||||
"https://www.google.com.tr",
|
|
||||||
"https://www.googleadservices.com",
|
|
||||||
"https://googleads.g.doubleclick.net",
|
|
||||||
"https://ad.doubleclick.net",
|
|
||||||
],
|
],
|
||||||
fontSrc: ["'self'", "https:", "data:"],
|
fontSrc: ["'self'", "https:", "data:"],
|
||||||
mediaSrc: ["'self'", "data:", "https://destek.sase.tr"],
|
mediaSrc: ["'self'", "data:", "https://destek.sase.tr"],
|
||||||
@@ -89,14 +79,6 @@ async function bootstrap() {
|
|||||||
"https://challenges.cloudflare.com",
|
"https://challenges.cloudflare.com",
|
||||||
"https://destek.sase.tr",
|
"https://destek.sase.tr",
|
||||||
"wss://destek.sase.tr",
|
"wss://destek.sase.tr",
|
||||||
// Google Ads gtag config fetch + conversion pings (googleadservices /
|
|
||||||
// google.com 1p-conversion & ccm/collect enhanced-conversion beacons).
|
|
||||||
"https://www.googletagmanager.com",
|
|
||||||
"https://www.google.com",
|
|
||||||
"https://www.google.com.tr",
|
|
||||||
"https://www.googleadservices.com",
|
|
||||||
"https://googleads.g.doubleclick.net",
|
|
||||||
"https://ad.doubleclick.net",
|
|
||||||
// Sentry browser SDK envelope POSTs (otolog org, de region).
|
// Sentry browser SDK envelope POSTs (otolog org, de region).
|
||||||
// Without this CSP silently blocks every error/replay upload.
|
// Without this CSP silently blocks every error/replay upload.
|
||||||
"https://*.ingest.de.sentry.io",
|
"https://*.ingest.de.sentry.io",
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ export const OPTIONAL_WORKFLOWS = new Set<string>([
|
|||||||
"referral",
|
"referral",
|
||||||
"referral-qualified",
|
"referral-qualified",
|
||||||
"referral-reward",
|
"referral-reward",
|
||||||
// Manual campaign sends (host-side sase-conversion-campaign.sh) — marketing,
|
|
||||||
// so it must honour opt-out and its unsubscribe tokens must validate here.
|
|
||||||
"conversion",
|
|
||||||
"mobile_push",
|
"mobile_push",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -53,7 +50,6 @@ export const NOTIFICATION_CATEGORIES = [
|
|||||||
"referral",
|
"referral",
|
||||||
"referral-qualified",
|
"referral-qualified",
|
||||||
"referral-reward",
|
"referral-reward",
|
||||||
"conversion",
|
|
||||||
] as const,
|
] as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -162,19 +162,12 @@ export class NovuService {
|
|||||||
/** Dunning notice — fired when a charge fails. amount is in kuruş. */
|
/** Dunning notice — fired when a charge fails. amount is in kuruş. */
|
||||||
async paymentFailed(
|
async paymentFailed(
|
||||||
user: NovuUser,
|
user: NovuUser,
|
||||||
opts: { amountKurus?: number; retryDate?: Date | null; invoiceUrl?: string | null },
|
opts: { amountKurus?: number; retryDate?: Date | null },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
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) } : {}),
|
||||||
// Renewal failures pass Stripe's hosted invoice page — the only surface
|
ctaUrl: buildTrackedUrl("payment-failed", user.email, webUrl("/dashboard/subscription")),
|
||||||
// where the user can actually pay / enter a new card. The dashboard
|
|
||||||
// fallback (checkout abandons) has no payment UI for an active sub.
|
|
||||||
ctaUrl: buildTrackedUrl(
|
|
||||||
"payment-failed",
|
|
||||||
user.email,
|
|
||||||
opts.invoiceUrl ?? webUrl("/dashboard/subscription"),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,34 +62,24 @@ const NO_UNSUBSCRIBE_WORKFLOWS = new Set<string>([
|
|||||||
"payment-failed",
|
"payment-failed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Signed HTTPS unsubscribe link for a (workflow, user) pair — the same URL the
|
|
||||||
* List-Unsubscribe header carries. GET renders a confirmation page, POST is
|
|
||||||
* the RFC 8058 one-click. Null for transactional flows or when the secret is
|
|
||||||
* unset (dev), so callers can skip the payload/header entirely.
|
|
||||||
*/
|
|
||||||
export function buildUnsubscribeUrl(workflow: string, subscriberId: string): string | null {
|
|
||||||
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return null;
|
|
||||||
if (!UNSUBSCRIBE_URL_BASE || !UNSUBSCRIBE_SECRET) return null;
|
|
||||||
const token = createHmac("sha256", UNSUBSCRIBE_SECRET)
|
|
||||||
.update(`${subscriberId}|${workflow}`)
|
|
||||||
.digest("hex");
|
|
||||||
const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token });
|
|
||||||
return `${UNSUBSCRIBE_URL_BASE}?${q.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUnsubscribeHeaders(workflow: string, subscriberId: string): Record<string, string> {
|
function buildUnsubscribeHeaders(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[] = [];
|
||||||
const httpsUrl = buildUnsubscribeUrl(workflow, subscriberId);
|
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
|
||||||
if (httpsUrl) targets.push(`<${httpsUrl}>`);
|
const token = createHmac("sha256", UNSUBSCRIBE_SECRET)
|
||||||
|
.update(`${subscriberId}|${workflow}`)
|
||||||
|
.digest("hex");
|
||||||
|
const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token });
|
||||||
|
targets.push(`<${UNSUBSCRIBE_URL_BASE}?${q.toString()}>`);
|
||||||
|
}
|
||||||
targets.push(
|
targets.push(
|
||||||
`<mailto:${UNSUBSCRIBE_EMAIL}?subject=unsubscribe%3A${encodeURIComponent(workflow)}>`,
|
`<mailto:${UNSUBSCRIBE_EMAIL}?subject=unsubscribe%3A${encodeURIComponent(workflow)}>`,
|
||||||
);
|
);
|
||||||
const headers: Record<string, string> = { "List-Unsubscribe": targets.join(", ") };
|
const headers: Record<string, string> = { "List-Unsubscribe": targets.join(", ") };
|
||||||
// RFC 8058 one-click — only assert when an HTTPS endpoint is wired; Gmail
|
// RFC 8058 one-click — only assert when an HTTPS endpoint is wired; Gmail
|
||||||
// will probe the HTTPS target with POST when this header is present.
|
// will probe the HTTPS target with POST when this header is present, so
|
||||||
if (httpsUrl) {
|
// gate it behind both env vars being set.
|
||||||
|
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
|
||||||
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
|
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
@@ -186,14 +176,7 @@ export async function triggerNovu(
|
|||||||
// Postal only AFTER the host-side Novu NodemailerProvider patch is applied —
|
// Postal only AFTER the host-side Novu NodemailerProvider patch is applied —
|
||||||
// see postal/novu-patches/apply-headers-patch.sh.
|
// see postal/novu-patches/apply-headers-patch.sh.
|
||||||
const unsubHeaders = buildUnsubscribeHeaders(name, to.subscriberId);
|
const unsubHeaders = buildUnsubscribeHeaders(name, to.subscriberId);
|
||||||
// Visible body-footer variant of the same link — templates render it via
|
const body: Record<string, unknown> = { name, to, payload };
|
||||||
// `{{#if unsubscribeUrl}}` so mails stay valid when the secret is unset.
|
|
||||||
const unsubscribeUrl = buildUnsubscribeUrl(name, to.subscriberId);
|
|
||||||
const fullPayload =
|
|
||||||
unsubscribeUrl && payload.unsubscribeUrl === undefined
|
|
||||||
? { ...payload, unsubscribeUrl }
|
|
||||||
: payload;
|
|
||||||
const body: Record<string, unknown> = { name, to, payload: fullPayload };
|
|
||||||
if (Object.keys(unsubHeaders).length > 0) {
|
if (Object.keys(unsubHeaders).length > 0) {
|
||||||
body.overrides = { email: { headers: unsubHeaders } };
|
body.overrides = { email: { headers: unsubHeaders } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ const WORKFLOW_LABELS: Record<string, string> = {
|
|||||||
referral: "Davet hatırlatması",
|
referral: "Davet hatırlatması",
|
||||||
"referral-qualified": "Davet bildirimleri",
|
"referral-qualified": "Davet bildirimleri",
|
||||||
"referral-reward": "Ödül bildirimleri",
|
"referral-reward": "Ödül bildirimleri",
|
||||||
conversion: "Kampanya mailleri",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,7 +141,7 @@ function renderPage(ok: boolean, workflow: string): string {
|
|||||||
<h1 style="font-size:20px;margin:22px 0 14px;">Abonelikten çıkıldı</h1>
|
<h1 style="font-size:20px;margin:22px 0 14px;">Abonelikten çıkıldı</h1>
|
||||||
<p style="color:#4a4a4a;line-height:1.6;">Artık <strong>${escapeHtml(label)}</strong> almayacaksın. Hesabınla ilgili önemli bilgilendirme mailleri (e-posta doğrulama, ödeme bildirimleri) gelmeye devam eder.</p>
|
<p style="color:#4a4a4a;line-height:1.6;">Artık <strong>${escapeHtml(label)}</strong> almayacaksın. Hesabınla ilgili önemli bilgilendirme mailleri (e-posta doğrulama, ödeme bildirimleri) gelmeye devam eder.</p>
|
||||||
<p style="color:#777;font-size:14px;margin-top:24px;">Fikrini değiştirirsen ayarlar > bildirimler sayfasından geri açabilirsin.</p>
|
<p style="color:#777;font-size:14px;margin-top:24px;">Fikrini değiştirirsen ayarlar > bildirimler sayfasından geri açabilirsin.</p>
|
||||||
<p style="margin-top:22px;"><a href="https://sase.tr/dashboard/settings?tab=notifications" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:12px 26px;border-radius:8px;font-weight:600;">Ayarları aç</a></p>
|
<p style="margin-top:22px;"><a href="https://sase.tr/dashboard/settings/notifications" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:12px 26px;border-radius:8px;font-weight:600;">Ayarları aç</a></p>
|
||||||
</main></body>`;
|
</main></body>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import {
|
|||||||
brandCompatible,
|
brandCompatible,
|
||||||
computeStats,
|
computeStats,
|
||||||
filterOffersForBrand,
|
filterOffersForBrand,
|
||||||
isOeSupplyLabel,
|
|
||||||
istanbulToday,
|
istanbulToday,
|
||||||
normPartCode,
|
normPartCode,
|
||||||
oeFamilyOf,
|
|
||||||
percentile,
|
percentile,
|
||||||
reconstructDailySeries,
|
reconstructDailySeries,
|
||||||
selectOeOffers,
|
|
||||||
} from "./part-prices.logic";
|
} from "./part-prices.logic";
|
||||||
|
|
||||||
describe("normPartCode", () => {
|
describe("normPartCode", () => {
|
||||||
@@ -177,139 +174,17 @@ describe("filterOffersForBrand", () => {
|
|||||||
expect(filterOffersForBrand(offers, "", "27155")).toEqual([]);
|
expect(filterOffersForBrand(offers, "", "27155")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uzun kodda markasız istek YALNIZ orijinal teklifleri kullanır", () => {
|
it("uzun kodda markasız istek tüm teklifleri kullanır (OE vakası)", () => {
|
||||||
// 2026-07-14 vakası: orijinal+yan sanayi tek havuzda "hayalet medyan"
|
|
||||||
// üretiyordu (9827622780 → 4.495₺, hiçbir satıcının fiyatı değil).
|
|
||||||
// kokpit tur'una göre MAIS+OPAR orijinal; SAGEMFRANS/FEBI değil.
|
|
||||||
const mixed = [
|
|
||||||
{ brandNorm: "MAIS", price: 245 },
|
|
||||||
{ brandNorm: "OPAR", price: 247 },
|
|
||||||
{ brandNorm: "SAGEMFRANS", price: 210 },
|
|
||||||
{ brandNorm: "FEBI", price: 160 },
|
|
||||||
];
|
|
||||||
const out = filterOffersForBrand(mixed, "", "8200768913");
|
|
||||||
expect(out.map((o) => o.brandNorm).sort()).toEqual(["MAIS", "OPAR"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("markasız istekte hiç orijinal teklif yoksa boş (yan sanayi OE gibi gösterilmez)", () => {
|
|
||||||
const amOnly = [{ brandNorm: "BRUCKE", price: 890 }];
|
|
||||||
expect(filterOffersForBrand(amOnly, "", "46456072")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("araç markası → SADECE o markanın OE ailesi (PSA vs OPAR ayrışır)", () => {
|
|
||||||
// 9827622780 vakası: aynı koda PSA (Peugeot ailesi) ve OPAR (Fiat ailesi)
|
|
||||||
// düşüyor. Peugeot kodu istenince yalnız PSA gelir, OPAR elenir → medyan
|
|
||||||
// artık iki dağıtıcının ortası (hayalet) değil.
|
|
||||||
const mixed = [
|
|
||||||
{ brandNorm: "PSA", price: 5531 },
|
|
||||||
{ brandNorm: "OPAR", price: 3459 },
|
|
||||||
{ brandNorm: "FEBI", price: 900 },
|
|
||||||
];
|
|
||||||
expect(filterOffersForBrand(mixed, "PEUGEOT", "9827622780")).toEqual([
|
|
||||||
{ brandNorm: "PSA", price: 5531 },
|
|
||||||
]);
|
|
||||||
// Aynı kod Fiat aracından istenirse OPAR gelir.
|
|
||||||
expect(filterOffersForBrand(mixed, "FIAT", "9827622780")).toEqual([
|
|
||||||
{ brandNorm: "OPAR", price: 3459 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("araç markası dağıtıcı etiketine eşlenir (RENAULT → MAIS), yan sanayi elenir", () => {
|
|
||||||
const oe = [
|
const oe = [
|
||||||
{ brandNorm: "MAIS", price: 195 },
|
{ brandNorm: "MAIS", price: 195 },
|
||||||
{ brandNorm: "FEBI", price: 90 },
|
{ brandNorm: "RENAULT", price: 247 },
|
||||||
{ brandNorm: "OPAR", price: 210 }, // farklı aile → elenir
|
|
||||||
];
|
];
|
||||||
expect(filterOffersForBrand(oe, "RENAULT", "8200768913")).toEqual([
|
expect(filterOffersForBrand(oe, "", "8200768913")).toHaveLength(2);
|
||||||
{ brandNorm: "MAIS", price: 195 },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("araç markası + jenerik ORJINAL → elenir (aile belirsiz)", () => {
|
it("uzun kod + uyumsuz marka etiketi → dağıtıcı fallback'i (tümü)", () => {
|
||||||
const oe = [
|
const oe = [{ brandNorm: "MAIS", price: 195 }];
|
||||||
{ brandNorm: "PSA", price: 5531 },
|
expect(filterOffersForBrand(oe, "RENAULT", "8200768913")).toHaveLength(1);
|
||||||
{ brandNorm: "ORJINAL", price: 111 },
|
|
||||||
];
|
|
||||||
expect(filterOffersForBrand(oe, "CITROEN", "9827622780")).toEqual([
|
|
||||||
{ brandNorm: "PSA", price: 5531 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("yan sanayi markası çipi → yalnız etiketi uyumlular, OE'ye düşmez", () => {
|
|
||||||
const oe = [
|
|
||||||
{ brandNorm: "MAIS", price: 195 },
|
|
||||||
{ brandNorm: "FEBI", price: 160 },
|
|
||||||
];
|
|
||||||
// FEBI çipi FEBI'yi alır; MAIS (orijinal) gösterilmez.
|
|
||||||
expect(filterOffersForBrand(oe, "FEBIBILSTEIN", "8200768913")).toEqual([
|
|
||||||
{ brandNorm: "FEBI", price: 160 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isOeSupplyLabel / selectOeOffers (OE aileleri + jenerik ORJINAL)", () => {
|
|
||||||
it("dağıtıcı ve orijinal etiketleri tanır", () => {
|
|
||||||
expect(isOeSupplyLabel("MAIS")).toBe(true); // Renault dağıtıcısı
|
|
||||||
expect(isOeSupplyLabel("OPAR")).toBe(true); // Fiat/Tofaş
|
|
||||||
expect(isOeSupplyLabel("PSA")).toBe(true);
|
|
||||||
expect(isOeSupplyLabel("ORJINAL")).toBe(true); // jenerik orijinal
|
|
||||||
expect(isOeSupplyLabel("VECO")).toBe(true); // Iveco OE alias'ı
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ham araç-marka etiketleri de ORİJİNALDİR (kokpit'in yansanayi demesi yanlış)", () => {
|
|
||||||
// 2026-07-14 kullanıcı düzeltmesi: FORD/GM/BMW/Mercedes/VW OE etiketidir.
|
|
||||||
expect(isOeSupplyLabel("FORD")).toBe(true);
|
|
||||||
expect(isOeSupplyLabel("GM")).toBe(true); // Opel OE'si
|
|
||||||
expect(isOeSupplyLabel("BMW")).toBe(true);
|
|
||||||
expect(isOeSupplyLabel("MERCEDES")).toBe(true);
|
|
||||||
expect(isOeSupplyLabel("VOLKSWAGEN")).toBe(true);
|
|
||||||
expect(isOeSupplyLabel("RENAULT")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gerçek yan sanayi PARÇA markaları orijinal DEĞİL", () => {
|
|
||||||
expect(isOeSupplyLabel("BOSCH")).toBe(false);
|
|
||||||
expect(isOeSupplyLabel("FEBI")).toBe(false);
|
|
||||||
expect(isOeSupplyLabel("VALEO")).toBe(false);
|
|
||||||
expect(isOeSupplyLabel("TRW")).toBe(false);
|
|
||||||
expect(isOeSupplyLabel("")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("GM Opel ailesine, FORD kendi ailesine eşlenir", () => {
|
|
||||||
expect(oeFamilyOf("GM")).toBe("OPEL");
|
|
||||||
expect(oeFamilyOf("FORD")).toBe("FORD");
|
|
||||||
expect(oeFamilyOf("MERCEDES")).toBe("MERCEDES");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selectOeOffers karma havuzdan orijinalleri (araç markaları dahil) seçer", () => {
|
|
||||||
const out = selectOeOffers([
|
|
||||||
{ brandNorm: "OPAR", price: 767 },
|
|
||||||
{ brandNorm: "FORD", price: 480 }, // artık orijinal
|
|
||||||
{ brandNorm: "IBR", price: 909 }, // İbraş = yan sanayi
|
|
||||||
]);
|
|
||||||
expect(out.map((o) => o.brandNorm).sort()).toEqual(["FORD", "OPAR"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Ford kodu araç markası FORD → FORD teklifi gelir", () => {
|
|
||||||
const offers = [
|
|
||||||
{ brandNorm: "FORD", price: 480 },
|
|
||||||
{ brandNorm: "BOSCH", price: 300 },
|
|
||||||
];
|
|
||||||
expect(filterOffersForBrand(offers, "FORD", "1234567890")).toEqual([
|
|
||||||
{ brandNorm: "FORD", price: 480 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Opel kodu → OPEL + GM aynı aile, ikisi de gelir; PSA farklı aile elenir", () => {
|
|
||||||
const offers = [
|
|
||||||
{ brandNorm: "GM", price: 500 },
|
|
||||||
{ brandNorm: "OPEL", price: 520 },
|
|
||||||
{ brandNorm: "PSA", price: 610 },
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
filterOffersForBrand(offers, "OPEL", "9827622780")
|
|
||||||
.map((o) => o.brandNorm)
|
|
||||||
.sort(),
|
|
||||||
).toEqual(["GM", "OPEL"]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,104 +31,6 @@ export function normPartCode(code: string): string {
|
|||||||
// süzülür. Tedarikçiler markayı kısaltarak yazar (BCH/B→Bosch, BLP→Blue
|
// 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.
|
// Print, IBR→İbraş) — eşleşme önek VEYA sıralı-altdizi ile yapılır.
|
||||||
|
|
||||||
// ─── Orijinal (OE) etiketleri ve marka aileleri ────────────────────────────
|
|
||||||
// Markasız/araç-markalı bağlam (OEM detay kartı) yalnız ORİJİNAL teklifleri
|
|
||||||
// gösterir: orijinal + yan sanayi tek havuzda medyanlanınca hiçbir gerçek
|
|
||||||
// ürünün fiyatı olmayan "hayalet fiyat" çıkar (2026-07-14: 9827622780 →
|
|
||||||
// 4.495₺ = PSA 5.531 ile OPAR 3.459'un ortası).
|
|
||||||
//
|
|
||||||
// Bir OEM kodu tek bir araç markasına aittir → teklifleri o markanın OE
|
|
||||||
// ailesine süzeriz (aynı koda düşen PSA vs OPAR karışmaz). `family → üyeler`;
|
|
||||||
// üyeler hem araç markası adları (istek/sase brands) hem dağıtıcı etiketleridir
|
|
||||||
// (teklif/takip: MAIS=Renault, OPAR=Fiat, PSA, VECO=Iveco, GM=Opel).
|
|
||||||
//
|
|
||||||
// ORİJİNAL TESPİTİ: bir teklif etiketi orijinal sayılır ancak bir OE ailesinin
|
|
||||||
// üyesiyse ya da jenerik "ORJINAL" ise. Kaynak = sase araç markaları + Türkiye
|
|
||||||
// OE-dağıtıcı etiketleri. NOT (2026-07-14 kullanıcı düzeltmesi): ham araç-marka
|
|
||||||
// etiketleri (FORD, GM, BMW, MERCEDES, VOLKSWAGEN, RENAULT, FIAT…) ORİJİNALDİR
|
|
||||||
// — kokpit `tur` bunları 'yansanayi' sayıyordu, bu YANLIŞ; burada aileleriyle
|
|
||||||
// birlikte orijinal kabul edilir. Gerçek yan sanayi PARÇA markaları (Bosch/
|
|
||||||
// Febi/Valeo/TRW…) hiçbir ailede değildir → orijinal sayılmaz.
|
|
||||||
const OE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
|
||||||
RENAULT: ["RENAULT", "DACIA", "ALPINE", "MAIS"],
|
|
||||||
FIAT: ["FIAT", "ALFAROMEO", "LANCIA", "ABARTH", "OPAR", "TOFAS"],
|
|
||||||
PSA: ["PEUGEOT", "CITROEN", "DS", "PSA"],
|
|
||||||
OPEL: ["OPEL", "VAUXHALL", "GM"], // GM = General Motors, TR'de Opel OE'si
|
|
||||||
VAG: ["VOLKSWAGEN", "VW", "AUDI", "SEAT", "SKODA", "CUPRA", "BENTLEY"],
|
|
||||||
FORD: ["FORD"],
|
|
||||||
BMW: ["BMW", "MINI"],
|
|
||||||
MERCEDES: ["MERCEDESBENZ", "MERCEDES", "SMART"],
|
|
||||||
TOYOTA: ["TOYOTA", "TOYOTAORJINAL", "LEXUS"],
|
|
||||||
HONDA: ["HONDA"],
|
|
||||||
NISSAN: ["NISSAN", "INFINITI"],
|
|
||||||
HYUNDAI: ["HYUNDAI"],
|
|
||||||
KIA: ["KIA"],
|
|
||||||
MAZDA: ["MAZDA"],
|
|
||||||
VOLVO: ["VOLVO", "POLESTAR"],
|
|
||||||
PORSCHE: ["PORSCHE"],
|
|
||||||
JLR: ["JAGUAR", "LANDROVER"],
|
|
||||||
MAN: ["MAN"],
|
|
||||||
MITSUBISHI: ["MITSUBISHI"],
|
|
||||||
SUBARU: ["SUBARU"],
|
|
||||||
SUZUKI: ["SUZUKI"],
|
|
||||||
IVECO: ["IVECO", "VECO", "OEVECO"],
|
|
||||||
// sase araç listesinde olmayan ama teklif/istek olarak gelebilecek markalar.
|
|
||||||
CHRYSLER: ["CHRYSLER", "DODGE", "JEEP", "MOPAR", "LINCOLN", "BUICK"],
|
|
||||||
CHEVROLET: ["CHEVROLET"],
|
|
||||||
DAEWOO: ["DAEWOO"],
|
|
||||||
DAIHATSU: ["DAIHATSU"],
|
|
||||||
DFM: ["DFM"],
|
|
||||||
LADA: ["LADA"],
|
|
||||||
PROTON: ["PROTON"],
|
|
||||||
ROVER: ["ROVER", "RANGE"],
|
|
||||||
SAAB: ["SAAB"],
|
|
||||||
SSANGYONG: ["SSANGYONG"],
|
|
||||||
TATA: ["TATA"],
|
|
||||||
TESLA: ["TESLA"],
|
|
||||||
TOGG: ["TOGG"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const OE_FAMILY_OF: ReadonlyMap<string, string> = new Map(
|
|
||||||
Object.entries(OE_FAMILIES).flatMap(([family, members]) =>
|
|
||||||
members.map((m) => [m, family] as [string, string]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Markanın (araç ya da dağıtıcı etiketi) OE ailesi; bilinmiyorsa null.
|
|
||||||
* Jenerik "ORJINAL" hiçbir aileye ait değildir (markası belirsiz) → null. */
|
|
||||||
export function oeFamilyOf(brandNorm: string): string | null {
|
|
||||||
return OE_FAMILY_OF.get(brandNorm) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orijinal etiket seti = tüm OE aile üyeleri + jenerik "ORJINAL" (orijinal ama
|
|
||||||
// aile-belirsiz: markasız/taban görünümde sayılır, aile filtresinde elenir).
|
|
||||||
// Aileden TÜRETİLİR → OE-tespiti ile aile-filtresi asla drift etmez; yeni bir
|
|
||||||
// marka eklemek için tek yer OE_FAMILIES.
|
|
||||||
const OE_SUPPLY_LABELS: ReadonlySet<string> = new Set([
|
|
||||||
...Object.values(OE_FAMILIES).flat(),
|
|
||||||
"ORJINAL",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Teklifin etiketi orijinal mi (bir OE ailesinin üyesi ya da jenerik ORJINAL). */
|
|
||||||
export function isOeSupplyLabel(brandNorm: string): boolean {
|
|
||||||
return OE_SUPPLY_LABELS.has(brandNorm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Yalnız orijinal (OE) etiketli teklifler. */
|
|
||||||
export function selectOeOffers<T extends { brandNorm: string }>(offers: T[]): T[] {
|
|
||||||
return offers.filter((o) => isOeSupplyLabel(o.brandNorm));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Redis cache anahtarları ───────────────────────────────────────────────
|
|
||||||
// Service ve worker processor'ı AYNI üreticileri kullanır (literal kopya
|
|
||||||
// drift'i olmasın). v4 = OE seti araç-marka etiketlerini (FORD/GM/BMW/MERCEDES/
|
|
||||||
// VW…) de kapsayacak şekilde genişledi → v3'teki "miss" değerleri bayat.
|
|
||||||
export const PART_PRICE_CACHE_VERSION = "v4";
|
|
||||||
export const partPriceSeriesCacheKey = (codeNorm: string, brandNorm: string) =>
|
|
||||||
`partprice:series:${PART_PRICE_CACHE_VERSION}:${codeNorm}::${brandNorm}`;
|
|
||||||
export const partPriceCurrentCacheKey = (codeNorm: string, brandNorm: string) =>
|
|
||||||
`partprice:cur:${PART_PRICE_CACHE_VERSION}:${codeNorm}::${brandNorm}`;
|
|
||||||
|
|
||||||
/** a'nın tüm karakterleri b içinde aynı sırayla geçiyor mu (BCH ⊂ BOSCH). */
|
/** 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 {
|
export function inOrderSubsequence(a: string, b: string): boolean {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -159,17 +61,10 @@ export function allowBrandless(codeNorm: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Teklifleri istenen markaya süz:
|
* Teklifleri istenen markaya süz:
|
||||||
* - istenen marka bir ARAÇ/OE markasıysa (OEM kartına o kodun araç markası
|
* - marka istendi → uyumlular; hiçbiri uymuyorsa ve kod markasız-güvenliyse
|
||||||
* geçer, ör. PEUGEOT) → yalnız o markanın OE ailesinden orijinal teklifler
|
* hepsi (OE/dağıtıcı etiketi vakası: RENAULT istenir, teklifler MAIS taşır);
|
||||||
* (PEUGEOT → PSA ailesi; OPAR/MAIS gibi başka aile dağıtıcıları elenir).
|
* kısa kodda boş (yanlış veri göstermekten iyidir),
|
||||||
* Böylece aynı koda düşen PSA vs OPAR karışıp medyanı bozmaz. Jenerik
|
* - marka istenmedi → kod markasız-güvenliyse hepsi, değilse boş.
|
||||||
* "ORJINAL" (aile belirsiz) bu süzgeçte elenir,
|
|
||||||
* - istenen marka bir YAN SANAYİ markasıysa (FEBI çipi) → etiketi uyumlu
|
|
||||||
* teklifler (kısaltma/önek eşleşmesi); OE'ye düşme YOK,
|
|
||||||
* - marka istenmedi (kodun markası bilinmiyor) → yalnız orijinal (tüm OE
|
|
||||||
* aileleri) — güvenli taban; yan sanayi karışımı hayalet medyan üretirdi,
|
|
||||||
* - kısa/çakışmaya açık kodlarda (allowBrandless=false) araç-markası ve
|
|
||||||
* markasız yolları kapalıdır (yanlış veri göstermekten iyidir).
|
|
||||||
*/
|
*/
|
||||||
export function filterOffersForBrand<T extends { brandNorm: string }>(
|
export function filterOffersForBrand<T extends { brandNorm: string }>(
|
||||||
offers: T[],
|
offers: T[],
|
||||||
@@ -177,17 +72,11 @@ export function filterOffersForBrand<T extends { brandNorm: string }>(
|
|||||||
codeNorm: string,
|
codeNorm: string,
|
||||||
): T[] {
|
): T[] {
|
||||||
if (requestedBrandNorm) {
|
if (requestedBrandNorm) {
|
||||||
const family = oeFamilyOf(requestedBrandNorm);
|
const compat = offers.filter((o) => brandCompatible(o.brandNorm, requestedBrandNorm));
|
||||||
if (family) {
|
if (compat.length > 0) return compat;
|
||||||
if (!allowBrandless(codeNorm)) return [];
|
return allowBrandless(codeNorm) ? offers : [];
|
||||||
return offers.filter(
|
|
||||||
(o) => isOeSupplyLabel(o.brandNorm) && oeFamilyOf(o.brandNorm) === family,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Aile eşlemesi yok → yan sanayi markası: etiket uyumuna göre süz.
|
|
||||||
return offers.filter((o) => brandCompatible(o.brandNorm, requestedBrandNorm));
|
|
||||||
}
|
}
|
||||||
return allowBrandless(codeNorm) ? selectOeOffers(offers) : [];
|
return allowBrandless(codeNorm) ? offers : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SupplierOffer {
|
export interface SupplierOffer {
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
filterOffersForBrand,
|
filterOffersForBrand,
|
||||||
istanbulToday,
|
istanbulToday,
|
||||||
normPartCode,
|
normPartCode,
|
||||||
partPriceCurrentCacheKey,
|
|
||||||
partPriceSeriesCacheKey,
|
|
||||||
reconstructDailySeries,
|
reconstructDailySeries,
|
||||||
} from "./part-prices.logic";
|
} from "./part-prices.logic";
|
||||||
import { type SupplierPriceSource, createSupplierPriceSource } from "./supplier-price-source";
|
import { type SupplierPriceSource, createSupplierPriceSource } from "./supplier-price-source";
|
||||||
@@ -56,8 +54,10 @@ const BATCH_CACHE_TTL = 1800;
|
|||||||
const MAX_BATCH_PARTS = 400;
|
const MAX_BATCH_PARTS = 400;
|
||||||
|
|
||||||
export const partPriceKey = (codeNorm: string, brandNorm: string) => `${codeNorm}::${brandNorm}`;
|
export const partPriceKey = (codeNorm: string, brandNorm: string) => `${codeNorm}::${brandNorm}`;
|
||||||
const seriesKey = partPriceSeriesCacheKey;
|
const seriesKey = (codeNorm: string, brandNorm: string) =>
|
||||||
const currentKey = partPriceCurrentCacheKey;
|
`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:
|
* Parça (kod + marka) bazlı tedarikçi fiyat görünümleri. P-servisi sözleşmesi:
|
||||||
@@ -67,8 +67,7 @@ const currentKey = partPriceCurrentCacheKey;
|
|||||||
* Kimlik (kod, marka): kısa sayısal kodlar markalar arası çakışır (FEBI 27155
|
* 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
|
* ≠ GROS 27155 ≠ İBRAŞ 27155 — farklı fiziksel parçalar). Teklifler sku_map'in
|
||||||
* marka etiketiyle istenen markaya süzülür (filterOffersForBrand); markasız
|
* marka etiketiyle istenen markaya süzülür (filterOffersForBrand); markasız
|
||||||
* sorgu (OEM detay ana kartı) yalnızca ORİJİNAL (OE/dağıtıcı) teklifleri
|
* sorgu yalnızca uzun/benzersiz kodlarda tüm teklifleri kullanır.
|
||||||
* kullanır — orijinal+yan-sanayi havuzu hayalet medyan üretir (2026-07-14).
|
|
||||||
*
|
*
|
||||||
* - Seri: parça ilk kez istendiğinde takip history'sinden lazy-backfill edilir
|
* - 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
|
* ve pg'ye (part_price_tracks + part_price_daily) kalıcı yazılır; sonraki
|
||||||
|
|||||||
@@ -147,15 +147,13 @@ export class PartsService {
|
|||||||
* Reverse catalog: the user's own decoded vehicles whose parts list contains
|
* Reverse catalog: the user's own decoded vehicles whose parts list contains
|
||||||
* this exact OEM code. Powers the "bu kod kataloğunuzda şu araçlarda var"
|
* this exact OEM code. Powers the "bu kod kataloğunuzda şu araçlarda var"
|
||||||
* section of the OEM detail page — pure sase data, no TecDoc/vehicle-structure
|
* section of the OEM detail page — pure sase data, no TecDoc/vehicle-structure
|
||||||
* dependency. NORMALIZE-eşleşme: URL kodu boşluksuz ("9827622780") gelirken
|
* dependency. Exact match on the indexed `oem_code` (the code came from a real
|
||||||
* katalog aynı kodu boşluklu saklayabilir ("9827 622 780" — özellikle PSA);
|
* part row, so the spelling matches). One representative `categoryId` per
|
||||||
* exact match bunları kaçırıyordu (fiyat kartı marka çözümü de buna dayanır).
|
* vehicle lets the UI deep-link straight to a schema page showing the part.
|
||||||
* `parts_oem_code_norm_idx` functional index'iyle aynı ifade → index kullanır.
|
|
||||||
* Vitrin `categoryId`'si UI'ı doğrudan şema sayfasına götürür.
|
|
||||||
*/
|
*/
|
||||||
async vehiclesByOem(oemCode: string) {
|
async vehiclesByOem(oemCode: string) {
|
||||||
const norm = (oemCode ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
|
const code = (oemCode ?? "").trim();
|
||||||
if (!norm) return [];
|
if (!code) return [];
|
||||||
return this.db
|
return this.db
|
||||||
.select({
|
.select({
|
||||||
vehicleId: vehicles.id,
|
vehicleId: vehicles.id,
|
||||||
@@ -167,7 +165,7 @@ export class PartsService {
|
|||||||
})
|
})
|
||||||
.from(parts)
|
.from(parts)
|
||||||
.innerJoin(vehicles, eq(parts.vehicleId, vehicles.id))
|
.innerJoin(vehicles, eq(parts.vehicleId, vehicles.id))
|
||||||
.where(sql`regexp_replace(upper(${parts.oemCode}), '[^A-Z0-9]', '', 'g') = ${norm}`)
|
.where(eq(parts.oemCode, code))
|
||||||
.groupBy(vehicles.id, vehicles.brandName, vehicles.model, vehicles.year)
|
.groupBy(vehicles.id, vehicles.brandName, vehicles.model, vehicles.year)
|
||||||
.orderBy(vehicles.brandName, vehicles.model)
|
.orderBy(vehicles.brandName, vehicles.model)
|
||||||
.limit(50);
|
.limit(50);
|
||||||
|
|||||||
@@ -163,26 +163,16 @@ describe("StripeService recurring webhooks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleInvoiceFailed", () => {
|
describe("handleInvoiceFailed", () => {
|
||||||
it("persists dunning state, mails with retry date + hosted invoice URL, leaves end_date alone", async () => {
|
it("mails dunning with Stripe's retry date and leaves the paid-through date alone", async () => {
|
||||||
// selects: resolve sub → user
|
// selects: resolve sub → user
|
||||||
const { service, db, updateChains, posthog, novu } = createMocks([[activeSub], [user]]);
|
const { service, db, posthog, novu } = createMocks([[activeSub], [user]]);
|
||||||
|
|
||||||
await service.handleInvoiceFailed(
|
await service.handleInvoiceFailed(invoiceFixture());
|
||||||
invoiceFixture({ attempt_count: 1, hosted_invoice_url: "https://invoice.stripe.com/i/x" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// dunning stamped (so /subscriptions/me + banner can surface it) — but
|
|
||||||
// end_date untouched: access keeps running to the paid-through date.
|
|
||||||
expect(db.update).toHaveBeenCalledTimes(1);
|
|
||||||
const setArg = updateChains[0].set.mock.calls[0][0] as Record<string, unknown>;
|
|
||||||
expect(setArg.dunningSince).toBeInstanceOf(Date);
|
|
||||||
expect(setArg.dunningInvoiceUrl).toBe("https://invoice.stripe.com/i/x");
|
|
||||||
expect(setArg.endDate).toBeUndefined();
|
|
||||||
|
|
||||||
|
expect(db.update).not.toHaveBeenCalled(); // access keeps running to end_date
|
||||||
expect(novu.paymentFailed).toHaveBeenCalledTimes(1);
|
expect(novu.paymentFailed).toHaveBeenCalledTimes(1);
|
||||||
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date; invoiceUrl?: string };
|
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date };
|
||||||
expect(opts.retryDate?.getTime()).toBe(RETRY_AT_SEC * 1000);
|
expect(opts.retryDate?.getTime()).toBe(RETRY_AT_SEC * 1000);
|
||||||
expect(opts.invoiceUrl).toBe("https://invoice.stripe.com/i/x");
|
|
||||||
expect(posthog.captureForUser).toHaveBeenCalledWith(
|
expect(posthog.captureForUser).toHaveBeenCalledWith(
|
||||||
"user-1",
|
"user-1",
|
||||||
"payment_failed",
|
"payment_failed",
|
||||||
@@ -190,28 +180,6 @@ describe("StripeService recurring webhooks", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("suppresses the mail on non-milestone attempts but still records the failure", async () => {
|
|
||||||
const { service, db, posthog, novu } = createMocks([[activeSub], [user]]);
|
|
||||||
|
|
||||||
await service.handleInvoiceFailed(invoiceFixture({ attempt_count: 2 }));
|
|
||||||
|
|
||||||
expect(db.update).toHaveBeenCalledTimes(1); // dunning state still stamped
|
|
||||||
expect(posthog.captureForUser).toHaveBeenCalledTimes(1);
|
|
||||||
expect(novu.paymentFailed).not.toHaveBeenCalled(); // attempt 2 ≠ 1/3/final
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends the final notice when Stripe schedules no further retry", async () => {
|
|
||||||
const { service, novu } = createMocks([[activeSub], [user]]);
|
|
||||||
|
|
||||||
await service.handleInvoiceFailed(
|
|
||||||
invoiceFixture({ attempt_count: 7, next_payment_attempt: null }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(novu.paymentFailed).toHaveBeenCalledTimes(1);
|
|
||||||
const opts = novu.paymentFailed.mock.calls[0][1] as { retryDate?: Date | null };
|
|
||||||
expect(opts.retryDate).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores first-charge failures (checkout flow owns those)", async () => {
|
it("ignores first-charge failures (checkout flow owns those)", async () => {
|
||||||
const { service, posthog, novu } = createMocks([[activeSub]]);
|
const { service, posthog, novu } = createMocks([[activeSub]]);
|
||||||
|
|
||||||
|
|||||||
@@ -634,24 +634,17 @@ export class StripeService {
|
|||||||
|
|
||||||
if (!recovered) {
|
if (!recovered) {
|
||||||
// Normal renewal — extend. A 'cancelled' row that still got billed keeps
|
// Normal renewal — extend. A 'cancelled' row that still got billed keeps
|
||||||
// its status; access is governed by end_date either way. A paid invoice
|
// its status; access is governed by end_date either way.
|
||||||
// also ends any dunning episode.
|
|
||||||
await this.db
|
await this.db
|
||||||
.update(userSubscriptions)
|
.update(userSubscriptions)
|
||||||
.set({ endDate: newEndDate, dunningSince: null, dunningInvoiceUrl: null, updatedAt: now })
|
.set({ endDate: newEndDate, updatedAt: now })
|
||||||
.where(eq(userSubscriptions.id, sub.id));
|
.where(eq(userSubscriptions.id, sub.id));
|
||||||
} else {
|
} else {
|
||||||
// Late dunning recovery: the nightly cron already expired the row and
|
// Late dunning recovery: the nightly cron already expired the row and
|
||||||
// purged its brand grants. Restore access for the freshly paid period.
|
// purged its brand grants. Restore access for the freshly paid period.
|
||||||
await this.db
|
await this.db
|
||||||
.update(userSubscriptions)
|
.update(userSubscriptions)
|
||||||
.set({
|
.set({ status: "active", endDate: newEndDate, updatedAt: now })
|
||||||
status: "active",
|
|
||||||
endDate: newEndDate,
|
|
||||||
dunningSince: null,
|
|
||||||
dunningInvoiceUrl: null,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(userSubscriptions.id, sub.id));
|
.where(eq(userSubscriptions.id, sub.id));
|
||||||
if (plan?.brandCount === 0) {
|
if (plan?.brandCount === 0) {
|
||||||
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, sub.id));
|
await this.db.delete(userBrands).where(eq(userBrands.subscriptionId, sub.id));
|
||||||
@@ -752,19 +745,6 @@ export class StripeService {
|
|||||||
const retryDate = invoice.next_payment_attempt
|
const retryDate = invoice.next_payment_attempt
|
||||||
? new Date(invoice.next_payment_attempt * 1000)
|
? new Date(invoice.next_payment_attempt * 1000)
|
||||||
: null;
|
: null;
|
||||||
const hostedUrl = invoice.hosted_invoice_url ?? null;
|
|
||||||
|
|
||||||
// Persist the dunning episode so /subscriptions/me (and the dashboard
|
|
||||||
// banner) can surface it — before this, the app had no record that a
|
|
||||||
// subscription was failing and the user saw nothing until hard cutoff.
|
|
||||||
await this.db
|
|
||||||
.update(userSubscriptions)
|
|
||||||
.set({
|
|
||||||
dunningSince: sub.dunningSince ?? new Date(),
|
|
||||||
...(hostedUrl ? { dunningInvoiceUrl: hostedUrl } : {}),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(userSubscriptions.id, sub.id));
|
|
||||||
|
|
||||||
this.posthog.captureForUser(sub.userId, "payment_failed", {
|
this.posthog.captureForUser(sub.userId, "payment_failed", {
|
||||||
method: "stripe",
|
method: "stripe",
|
||||||
@@ -772,42 +752,28 @@ export class StripeService {
|
|||||||
reason: "renewal_charge_failed",
|
reason: "renewal_charge_failed",
|
||||||
amount: invoice.amount_due ?? null,
|
amount: invoice.amount_due ?? null,
|
||||||
stripe_invoice_id: invoice.id,
|
stripe_invoice_id: invoice.id,
|
||||||
attempt_count: invoice.attempt_count ?? null,
|
|
||||||
next_retry_at: retryDate ? retryDate.toISOString() : null,
|
next_retry_at: retryDate ? retryDate.toISOString() : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mail on milestones only — first failure, mid-cycle nudge, final notice.
|
try {
|
||||||
// Stripe retries ~4-9 times; one identical mail per attempt trained users
|
const [user] = await this.db
|
||||||
// to ignore them (measured recovery: 0%).
|
.select({ id: users.id, email: users.email, name: users.name })
|
||||||
const attempt = invoice.attempt_count ?? 1;
|
.from(users)
|
||||||
const isFinal = !invoice.next_payment_attempt;
|
.where(eq(users.id, sub.userId))
|
||||||
const shouldMail = attempt <= 1 || attempt === 3 || isFinal;
|
.limit(1);
|
||||||
|
if (user) {
|
||||||
if (shouldMail) {
|
await this.novu.paymentFailed(user, {
|
||||||
try {
|
amountKurus: invoice.amount_due ?? undefined,
|
||||||
const [user] = await this.db
|
retryDate,
|
||||||
.select({ id: users.id, email: users.email, name: users.name })
|
});
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, sub.userId))
|
|
||||||
.limit(1);
|
|
||||||
if (user) {
|
|
||||||
await this.novu.paymentFailed(user, {
|
|
||||||
amountKurus: invoice.amount_due ?? undefined,
|
|
||||||
retryDate,
|
|
||||||
// CTA goes to Stripe's hosted invoice page (pay now / new card /
|
|
||||||
// 3DS) — the dashboard has no self-serve payment surface.
|
|
||||||
invoiceUrl: hostedUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`renewal dunning mail failed (user=${sub.userId}): ${String(err)}`);
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`renewal dunning mail failed (user=${sub.userId}): ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Renewal charge failed: sub ${sub.id} (invoice ${invoice.id}, attempt ${attempt}), ` +
|
`Renewal charge failed: sub ${sub.id} (invoice ${invoice.id}), ` +
|
||||||
`next retry ${retryDate ? retryDate.toISOString() : "none (final)"}` +
|
`next retry ${retryDate ? retryDate.toISOString() : "none (final)"}`,
|
||||||
`${shouldMail ? "" : " — mail suppressed (non-milestone attempt)"}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,35 +790,14 @@ export class StripeService {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!sub) return;
|
if (!sub) return;
|
||||||
|
|
||||||
const wasDunning = !!sub.dunningSince;
|
|
||||||
// Flip to 'cancelled' (not just stamp cancelledAt): create() rejects new
|
|
||||||
// checkouts only while a row is 'active', so leaving a dunning-cancelled
|
|
||||||
// sub as 'active' silently BLOCKED the customer from re-subscribing — one
|
|
||||||
// of the root causes of 0% dunning recovery. Access still runs to end_date
|
|
||||||
// via the nightly expiry cron.
|
|
||||||
await this.db
|
await this.db
|
||||||
.update(userSubscriptions)
|
.update(userSubscriptions)
|
||||||
.set({
|
.set({ cancelledAt: sub.cancelledAt ?? new Date(), updatedAt: new Date() })
|
||||||
status: "cancelled",
|
|
||||||
cancelledAt: sub.cancelledAt ?? new Date(),
|
|
||||||
dunningSince: null,
|
|
||||||
dunningInvoiceUrl: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(userSubscriptions.id, sub.id));
|
.where(eq(userSubscriptions.id, sub.id));
|
||||||
|
|
||||||
// Churn was previously invisible in analytics (this handler captured
|
this.logger.log(
|
||||||
// nothing) — reason distinguishes dunning losses from voluntary cancels.
|
`Stripe subscription ${subscription.id} deleted — our sub ${sub.id} ` +
|
||||||
this.posthog.captureForUser(sub.userId, "subscription_churned", {
|
`(status=${sub.status}) will not renew; access runs out at its end_date`,
|
||||||
reason: wasDunning ? "payment_failure" : "cancelled",
|
|
||||||
subscription_id: sub.id,
|
|
||||||
stripe_subscription_id: subscription.id,
|
|
||||||
prior_status: sub.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.warn(
|
|
||||||
`Stripe subscription ${subscription.id} deleted (${wasDunning ? "dunning exhausted" : "cancel executed"}) — ` +
|
|
||||||
`our sub ${sub.id} → cancelled; access runs out at its end_date`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import { CatalogService } from "../catalog/catalog.service";
|
|
||||||
import { CategoriesService } from "../categories/categories.service";
|
import { CategoriesService } from "../categories/categories.service";
|
||||||
import { Public } from "../common/decorators/public.decorator";
|
import { Public } from "../common/decorators/public.decorator";
|
||||||
import { ApiKeyGuard, type VerifiedApiKey } from "../common/guards/api-key.guard";
|
import { ApiKeyGuard, type VerifiedApiKey } from "../common/guards/api-key.guard";
|
||||||
@@ -56,7 +55,6 @@ export class PublicApiController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly vehicles: VehiclesService,
|
private readonly vehicles: VehiclesService,
|
||||||
private readonly categories: CategoriesService,
|
private readonly categories: CategoriesService,
|
||||||
private readonly catalog: CatalogService,
|
|
||||||
private readonly p: PSourceDbService,
|
private readonly p: PSourceDbService,
|
||||||
private readonly quota: PublicApiQuotaService,
|
private readonly quota: PublicApiQuotaService,
|
||||||
) {}
|
) {}
|
||||||
@@ -314,149 +312,6 @@ export class PublicApiController {
|
|||||||
return this.categories.searchCatalog(vehicleId, q ?? "");
|
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 {
|
private setQuotaHeaders(res: Response, state: DecodeQuotaState): void {
|
||||||
res.setHeader("X-Decode-Quota-Limit", String(state.limit));
|
res.setHeader("X-Decode-Quota-Limit", String(state.limit));
|
||||||
res.setHeader("X-Decode-Quota-Used", String(state.used));
|
res.setHeader("X-Decode-Quota-Used", String(state.used));
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { CatalogModule } from "../catalog/catalog.module";
|
|
||||||
import { CategoriesModule } from "../categories/categories.module";
|
import { CategoriesModule } from "../categories/categories.module";
|
||||||
import { ApiKeyGuard } from "../common/guards/api-key.guard";
|
import { ApiKeyGuard } from "../common/guards/api-key.guard";
|
||||||
import { PModule } from "../integrations/p/p.module";
|
import { PModule } from "../integrations/p/p.module";
|
||||||
@@ -13,7 +12,7 @@ import { WidgetController } from "./widget.controller";
|
|||||||
* apiKey plugin'i + ApiKeyGuard; iç servisler aynen yeniden kullanılır.
|
* apiKey plugin'i + ApiKeyGuard; iç servisler aynen yeniden kullanılır.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [VehiclesModule, CategoriesModule, CatalogModule, PModule],
|
imports: [VehiclesModule, CategoriesModule, PModule],
|
||||||
controllers: [PublicApiController, WidgetController],
|
controllers: [PublicApiController, WidgetController],
|
||||||
providers: [ApiKeyGuard, PublicApiQuotaService],
|
providers: [ApiKeyGuard, PublicApiQuotaService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -185,13 +185,20 @@ export class SubscriptionsService {
|
|||||||
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
|
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
|
||||||
|
|
||||||
if (allBrands.length > 0) {
|
if (allBrands.length > 0) {
|
||||||
await this.db.insert(userBrands).values(
|
// Full-plan trials already carry the complete userBrands set for this
|
||||||
allBrands.map((b) => ({
|
// same subscription row; activating them must not re-insert (unique
|
||||||
userId: sub.userId,
|
// (userId, subscriptionId, brandId) would abort AFTER the status
|
||||||
subscriptionId: sub.id,
|
// update above, leaving a half-activated sub and a 500 to the panel).
|
||||||
brandId: b.id,
|
await this.db
|
||||||
})),
|
.insert(userBrands)
|
||||||
);
|
.values(
|
||||||
|
allBrands.map((b) => ({
|
||||||
|
userId: sub.userId,
|
||||||
|
subscriptionId: sub.id,
|
||||||
|
brandId: b.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
|||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import { getVinpinDaemon } from "./integrations/vinpin/vinpin-daemon.service";
|
|
||||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
|
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
|
||||||
import { processCanonicalBackfill } from "./jobs/processors/canonical-backfill.processor";
|
import { processCanonicalBackfill } from "./jobs/processors/canonical-backfill.processor";
|
||||||
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
|
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
|
||||||
@@ -244,15 +243,6 @@ vinpinDecodeWorker.on("failed", (job, err) => {
|
|||||||
|
|
||||||
workers.push(vinpinDecodeWorker);
|
workers.push(vinpinDecodeWorker);
|
||||||
|
|
||||||
// Vinpin warm-session daemon: holds the single Vinpin seat warm (browser + login
|
|
||||||
// + Fiat ePER / Renault Rpartstore / Dialogys windows open) during business hours
|
|
||||||
// (08:00–21:00 Europe/Istanbul), keepalive-nudged every ~75s, so decodes run on
|
|
||||||
// the warm seat instead of paying the per-decode launch/login/open cold-start.
|
|
||||||
// Inert unless VINPIN_ENABLED=true (and not VINPIN_WARM_DAEMON=false). The
|
|
||||||
// processor calls this daemon; off-hours it transparently uses the cold path.
|
|
||||||
const vinpinDaemon = getVinpinDaemon();
|
|
||||||
vinpinDaemon.start();
|
|
||||||
|
|
||||||
// Canonical Backfill Worker (maps raw OEM category names → unified taxonomy).
|
// Canonical Backfill Worker (maps raw OEM category names → unified taxonomy).
|
||||||
// On-demand only (no cron); a single pass is a big sweep so concurrency 1.
|
// On-demand only (no cron); a single pass is a big sweep so concurrency 1.
|
||||||
const canonicalBackfillWorker = new Worker(
|
const canonicalBackfillWorker = new Worker(
|
||||||
@@ -351,10 +341,6 @@ async function shutdown(signal: string) {
|
|||||||
await Promise.all(workers.map((w) => w.close()));
|
await Promise.all(workers.map((w) => w.close()));
|
||||||
console.log("[worker] All workers closed");
|
console.log("[worker] All workers closed");
|
||||||
|
|
||||||
// 2b. Stop the Vinpin warm daemon and release the seat.
|
|
||||||
await vinpinDaemon.stop();
|
|
||||||
console.log("[worker] Vinpin warm daemon stopped");
|
|
||||||
|
|
||||||
// 3. Close database connection
|
// 3. Close database connection
|
||||||
await sql.end();
|
await sql.end();
|
||||||
console.log("[worker] Database connection closed");
|
console.log("[worker] Database connection closed");
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import { SegmentQualifier } from "@/components/auth/segment-qualifier";
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import { capture, setPeopleProperties } from "@/lib/posthog";
|
|
||||||
import { Button } from "@sase/ui";
|
|
||||||
import { useLocation } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Universal post-auth segment gate.
|
|
||||||
*
|
|
||||||
* The register page gates segment selection behind the form, but that only
|
|
||||||
* covers email + register-page-OAuth signups. A user who starts Google OAuth
|
|
||||||
* from the LOGIN page (login.tsx) gets an account created server-side and lands
|
|
||||||
* on the dashboard with no segment ever chosen — this is the bulk of the ~41%
|
|
||||||
* of signups with a null segment. Segment isn't persisted server-side (only
|
|
||||||
* localStorage + a PostHog person property), so we enforce it here at the one
|
|
||||||
* chokepoint every authenticated user passes through: the dashboard.
|
|
||||||
*
|
|
||||||
* Behaviour (product decision 2026-07-27):
|
|
||||||
* - New users (account created in the last few minutes → the login-OAuth gap):
|
|
||||||
* MANDATORY, non-dismissible modal.
|
|
||||||
* - Existing segment-less users (the ~538 backfill): SOFT, dismissible with a
|
|
||||||
* cooldown so we re-ask later without nagging or blocking active/paying users.
|
|
||||||
*
|
|
||||||
* This is a baseline fix shipped to everyone — NOT the `funnel-bucket` A/B
|
|
||||||
* experiment (that gates the tailored value/upgrade experience separately).
|
|
||||||
*/
|
|
||||||
const SEGMENT_KEY = "sase-b2b-segment";
|
|
||||||
const DISMISS_UNTIL_KEY = "sase-b2b-segment-prompt-until";
|
|
||||||
// Account younger than this ⇒ treat as a fresh signup (mandatory). Generous so
|
|
||||||
// the signup → first dashboard load always counts, even with a slow OAuth hop.
|
|
||||||
const NEW_USER_WINDOW_MS = 15 * 60 * 1000;
|
|
||||||
// How long a "Daha sonra" dismissal silences the soft prompt for existing users.
|
|
||||||
const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
function readSegment(): string | null {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(SEGMENT_KEY);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SegmentGate() {
|
|
||||||
const { user, isLoading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [mandatory, setMandatory] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only inside the app, only once the session has resolved to a real user.
|
|
||||||
if (isLoading || !user) {
|
|
||||||
setOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!location.pathname.startsWith("/dashboard")) {
|
|
||||||
setOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Already segmented — nothing to do. (Register writes this pre-redirect, so
|
|
||||||
// email + register-OAuth signups never see the gate.)
|
|
||||||
if (readSegment()) {
|
|
||||||
setOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdAt = user.createdAt ? new Date(user.createdAt).getTime() : 0;
|
|
||||||
const isNew = createdAt > 0 && Date.now() - createdAt < NEW_USER_WINDOW_MS;
|
|
||||||
|
|
||||||
if (isNew) {
|
|
||||||
setMandatory(true);
|
|
||||||
setOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Existing segment-less user: honour the soft-dismiss cooldown.
|
|
||||||
let dismissedUntil = 0;
|
|
||||||
try {
|
|
||||||
dismissedUntil = Number(localStorage.getItem(DISMISS_UNTIL_KEY)) || 0;
|
|
||||||
} catch {
|
|
||||||
dismissedUntil = 0;
|
|
||||||
}
|
|
||||||
if (Date.now() < dismissedUntil) {
|
|
||||||
setOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMandatory(false);
|
|
||||||
setOpen(true);
|
|
||||||
}, [user, isLoading, location.pathname]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
function handleSelect(seg: string) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(SEGMENT_KEY, seg);
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable — still fire analytics below.
|
|
||||||
}
|
|
||||||
// `source` distinguishes the login-OAuth gap fill from the backfill so we can
|
|
||||||
// measure each in PostHog; mirrors register.tsx's signup_segment_selected.
|
|
||||||
capture("signup_segment_selected", {
|
|
||||||
segment: seg,
|
|
||||||
source: mandatory ? "post_signup_gate" : "backfill_gate",
|
|
||||||
});
|
|
||||||
setPeopleProperties({ b2b_segment: seg });
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDismiss() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DISMISS_UNTIL_KEY, String(Date.now() + DISMISS_COOLDOWN_MS));
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
capture("signup_segment_prompt_dismissed");
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center overflow-y-auto bg-background/80 p-4 backdrop-blur-sm"
|
|
||||||
// biome-ignore lint/a11y/useSemanticElements: overlay modal — role="dialog"+aria-modal is the correct ARIA here; a native <dialog> would need imperative showModal() plumbing this SPA overlay doesn't use
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="İşletme türü seçimi"
|
|
||||||
>
|
|
||||||
<div className="my-auto w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-xl">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold tracking-tight">Bu platform kimler için?</h2>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
İşletmeni seç — sana uygun sınırsız şase sorgulama erişimini açalım.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5">
|
|
||||||
<SegmentQualifier onSelect={handleSelect} />
|
|
||||||
</div>
|
|
||||||
{!mandatory && (
|
|
||||||
<Button variant="ghost" className="mt-3 w-full" onClick={handleDismiss}>
|
|
||||||
Daha sonra
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -66,9 +66,8 @@ function DeltaBadge({ pct }: { pct: number }) {
|
|||||||
|
|
||||||
interface PartPriceSectionProps {
|
interface PartPriceSectionProps {
|
||||||
code: string;
|
code: string;
|
||||||
/** Kartta = kodun araç markası (teklifleri o markanın OE ailesine süzer,
|
/** Parça markası — kısa kodlarda teklifleri doğru markaya süzer; OEM
|
||||||
* PSA vs OPAR karışmaz); çipte = satırın markası. Bilinmiyorsa boş → tüm
|
* detayın ana kodu gibi markasız bağlamlarda boş bırakılır. */
|
||||||
* OE aileleri (güvenli taban). */
|
|
||||||
brand?: string;
|
brand?: string;
|
||||||
/** Ana sayfa yerleşiminde kart çerçevesi; dialog içinde çıplak. */
|
/** Ana sayfa yerleşiminde kart çerçevesi; dialog içinde çıplak. */
|
||||||
variant?: "card" | "plain";
|
variant?: "card" | "plain";
|
||||||
@@ -134,13 +133,9 @@ export function PartPriceSection({
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
{variant === "card" ? (
|
{variant === "card" ? (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold">
|
<h2 className="text-sm font-semibold">Tedarikçi fiyat analizi</h2>
|
||||||
Orijinal (OE) fiyat analizi{brand ? ` · ${brand}` : ""}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
{brand
|
Stoktaki tekliflerin dağılımı — P50 medyan · P95 · P99
|
||||||
? `Stoktaki ${brand} orijinal/dağıtıcı tekliflerinin dağılımı — P50 medyan · P95 · P99`
|
|
||||||
: "Stoktaki orijinal/dağıtıcı tekliflerin dağılımı — P50 medyan · P95 · P99"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -216,10 +211,8 @@ export function PartPriceSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
{variant === "card"
|
Fiyatlar stoktaki tedarikçi tekliflerinin istatistiksel dağılımıdır; tedarikçi bilgisi
|
||||||
? "Fiyatlar yalnızca orijinal (OE/dağıtıcı) tekliflerin dağılımıdır — yan sanayi teklifleri dahil edilmez; tedarikçi bilgisi paylaşılmaz."
|
paylaşılmaz. Son fiyat değişimi: {formatDateLong(data.series[data.series.length - 1].date)}
|
||||||
: "Fiyatlar stoktaki tedarikçi tekliflerinin istatistiksel dağılımıdır; tedarikçi bilgisi paylaşılmaz."}{" "}
|
|
||||||
Son fiyat değişimi: {formatDateLong(data.series[data.series.length - 1].date)}
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { api } from "@/lib/api-client";
|
|
||||||
import { capture } from "@/lib/posthog";
|
|
||||||
import { Button } from "@sase/ui";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { CreditCard } from "lucide-react";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
interface Subscription {
|
|
||||||
status: string;
|
|
||||||
plan?: { name: string; key: string };
|
|
||||||
// Dunning fields — stamped by the invoice.payment_failed webhook, cleared on
|
|
||||||
// recovery. invoiceUrl is Stripe's hosted invoice page (pay / new card / 3DS).
|
|
||||||
dunningSince?: string | null;
|
|
||||||
dunningInvoiceUrl?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Failed-renewal (dunning) banner. Before this, a past_due subscriber saw NO
|
|
||||||
* in-app signal at all — full access continued until the hard cutoff, then the
|
|
||||||
* subscription silently died (measured dunning recovery: 0%, first churn
|
|
||||||
* 2026-08-10). Renders on every dashboard page while a renewal invoice is
|
|
||||||
* unpaid; not dismissible on purpose — this is about to cost the user their
|
|
||||||
* subscription. CTA opens Stripe's hosted invoice page, the only surface where
|
|
||||||
* they can actually pay or enter a new card.
|
|
||||||
*/
|
|
||||||
export function DunningBanner() {
|
|
||||||
const viewedRef = useRef(false);
|
|
||||||
|
|
||||||
const { data: subData } = useQuery({
|
|
||||||
queryKey: ["subscription", "me"],
|
|
||||||
queryFn: () =>
|
|
||||||
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
|
|
||||||
"/subscriptions/me",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sub = subData?.subscription;
|
|
||||||
// status stays 'active' during dunning (access runs to end_date); once the
|
|
||||||
// sub is cancelled/expired the trial/expiry surfaces take over.
|
|
||||||
const visible = !!sub?.dunningSince && sub.status === "active";
|
|
||||||
const invoiceUrl = sub?.dunningInvoiceUrl ?? null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible || viewedRef.current) return;
|
|
||||||
viewedRef.current = true;
|
|
||||||
capture("dunning_banner_viewed", {
|
|
||||||
plan_name: sub?.plan?.name,
|
|
||||||
has_invoice_url: !!invoiceUrl,
|
|
||||||
});
|
|
||||||
}, [visible, sub?.plan?.name, invoiceUrl]);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
const handleCta = () => {
|
|
||||||
capture("dunning_banner_cta_clicked", { has_invoice_url: !!invoiceUrl });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-label="Ödeme sorunu"
|
|
||||||
className="flex items-start gap-3 border-b border-destructive/40 bg-destructive/5 px-4 py-3 sm:items-center sm:px-6"
|
|
||||||
>
|
|
||||||
<CreditCard className="mt-0.5 size-5 shrink-0 text-destructive sm:mt-0" />
|
|
||||||
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-foreground">Abonelik ödemen alınamadı</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Bankan yenileme tahsilatını reddetti. Erişimin kesilmemesi için faturayı öde veya farklı
|
|
||||||
bir kartla tamamla — banka onayı (3D Secure) gerekebilir.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{invoiceUrl ? (
|
|
||||||
<a
|
|
||||||
href={invoiceUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={handleCta}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Faturayı öde
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<p className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
Ödeme e-postandaki bağlantıyı kullan veya destek ile iletişime geç.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
|
||||||
import { trackGoogleAdsConversion } from "@/lib/google-ads";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Universal post-auth Google Ads sign-up conversion.
|
|
||||||
*
|
|
||||||
* The register page fires the sign-up conversion only on the EMAIL path
|
|
||||||
* (register.tsx). Google OAuth signups — both the register-page Google button
|
|
||||||
* and the login-page Google button — complete server-side after a redirect and
|
|
||||||
* never fire it, so Google Ads saw ~0 of them (measured: ~79% of paid-driven
|
|
||||||
* signups were OAuth → invisible to Google Ads, starving Max Conversions of its
|
|
||||||
* signal). This mounts at the root and fires for any brand-new authenticated
|
|
||||||
* user, covering every signup path.
|
|
||||||
*
|
|
||||||
* Double-count safety: the conversion carries transaction_id `signup_<userId>`,
|
|
||||||
* which Google dedupes on — so an email signup that already fired on the
|
|
||||||
* register page counts once even though this fires again. The localStorage guard
|
|
||||||
* additionally stops repeat fires across the new user's first-session pageviews;
|
|
||||||
* register.tsx sets the same guard so email signups skip the redundant fire.
|
|
||||||
*
|
|
||||||
* Attribution: gtag's own `_gcl_aw` cookie (set from gclid on the ad landing)
|
|
||||||
* survives the same-domain OAuth round-trip, so firing here still attributes to
|
|
||||||
* the original click. Organic (non-ad) new users fire too, but Google only
|
|
||||||
* counts conversions it can tie to an ad click — the rest are ignored.
|
|
||||||
*/
|
|
||||||
const NEW_USER_WINDOW_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
function guardKey(userId: string): string {
|
|
||||||
return `sase-ga-signup-conv-${userId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GoogleAdsSignupConversion() {
|
|
||||||
const { user, isLoading } = useAuth();
|
|
||||||
const firedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoading || !user || firedRef.current) return;
|
|
||||||
const createdAt = user.createdAt ? new Date(user.createdAt).getTime() : 0;
|
|
||||||
// Only genuinely fresh accounts — this is a signup conversion, not a login.
|
|
||||||
if (!createdAt || Date.now() - createdAt > NEW_USER_WINDOW_MS) return;
|
|
||||||
|
|
||||||
const key = guardKey(user.id);
|
|
||||||
try {
|
|
||||||
if (localStorage.getItem(key)) return;
|
|
||||||
localStorage.setItem(key, "1");
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable — firedRef still prevents in-session repeats.
|
|
||||||
}
|
|
||||||
firedRef.current = true;
|
|
||||||
|
|
||||||
trackGoogleAdsConversion(import.meta.env.VITE_GOOGLE_ADS_SIGNUP_LABEL, {
|
|
||||||
transactionId: `signup_${user.id}`,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
}, [user, isLoading]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { capture, subscribeFeatureFlag } from "@/lib/posthog";
|
import { capture } from "@/lib/posthog";
|
||||||
import { Button } from "@sase/ui";
|
import { Button } from "@sase/ui";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
@@ -25,31 +25,6 @@ const VALUE_THRESHOLD = 3;
|
|||||||
// Urgency banner owns days <= 3; this one owns days > 3 (no overlap).
|
// Urgency banner owns days <= 3; this one owns days > 3 (no overlap).
|
||||||
const URGENCY_WINDOW_DAYS = 3;
|
const URGENCY_WINDOW_DAYS = 3;
|
||||||
|
|
||||||
// Kova B of the `funnel-bucket` A/B experiment: segment-tailored, Meta-proven
|
|
||||||
// value copy for the b2b_qualified variant. These hooks are the ones that won on
|
|
||||||
// Meta (iade / yanlış-parça / sınırsız-şase framing). Only real B2B segments get
|
|
||||||
// the pitch; vehicle_owner / unknown fall back to the neutral control copy
|
|
||||||
// (product decision 2026-07-27 — don't push a subscription on consumers).
|
|
||||||
const SEGMENT_KEY = "sase-b2b-segment";
|
|
||||||
const B2B_COPY: Record<string, { title: string; desc: string }> = {
|
|
||||||
parts_dealer: {
|
|
||||||
title: "Yanlış parça siparişine son",
|
|
||||||
desc: "Dükkânına gelen her araçta şaseden doğru OEM parça — iade ve stok derdi yok.",
|
|
||||||
},
|
|
||||||
wholesaler: {
|
|
||||||
title: "Her siparişte birebir OEM",
|
|
||||||
desc: "Toptan siparişlerde şaseden doğru OEM eşleşmesi — yanlış kalem, iade yok.",
|
|
||||||
},
|
|
||||||
ecommerce: {
|
|
||||||
title: "Listelemende doğru parça",
|
|
||||||
desc: "Şaseden doğru OEM ile hatalı satışı ve iade oranını düşür.",
|
|
||||||
},
|
|
||||||
service_fleet: {
|
|
||||||
title: "Serviste doğru parça, ilk seferde",
|
|
||||||
desc: "Her araçta şaseden doğru OEM — bekleme yok, yanlış sipariş yok.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function dismissStorageKey(userId: string | null | undefined, endDate: string): string {
|
function dismissStorageKey(userId: string | null | undefined, endDate: string): string {
|
||||||
return `trialValueUpsellDismissed-${userId ?? "anon"}-${endDate}`;
|
return `trialValueUpsellDismissed-${userId ?? "anon"}-${endDate}`;
|
||||||
}
|
}
|
||||||
@@ -105,26 +80,6 @@ export function TrialValueUpsell() {
|
|||||||
|
|
||||||
const visible = inWindow && hasProvenValue && !dismissed;
|
const visible = inWindow && hasProvenValue && !dismissed;
|
||||||
|
|
||||||
// Read the experiment flag ONLY once the nudge is visible, so the PostHog
|
|
||||||
// exposure ($feature_flag_called) population = activated trial users who
|
|
||||||
// actually see it — not every dashboard mount. Keeps the thin signal undiluted.
|
|
||||||
const [bucket, setBucket] = useState<string | boolean | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible) return;
|
|
||||||
return subscribeFeatureFlag("funnel-bucket", setBucket);
|
|
||||||
}, [visible]);
|
|
||||||
const [segment] = useState<string | null>(() => {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(SEGMENT_KEY);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const tailored =
|
|
||||||
bucket === "b2b_qualified" && segment && segment !== "vehicle_owner"
|
|
||||||
? B2B_COPY[segment]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || viewedRef.current || days === null) return;
|
if (!visible || viewedRef.current || days === null) return;
|
||||||
viewedRef.current = true;
|
viewedRef.current = true;
|
||||||
@@ -165,16 +120,16 @@ export function TrialValueUpsell() {
|
|||||||
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
{tailored ? tailored.title : t("subscription.valueUpsell.title")}
|
{t("subscription.valueUpsell.title")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{tailored ? tailored.desc : t("subscription.valueUpsell.description")}
|
{t("subscription.valueUpsell.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
|
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
|
||||||
<Button size="sm" className="bg-brand text-white hover:bg-brand/90">
|
<Button size="sm" className="bg-brand text-white hover:bg-brand/90">
|
||||||
{tailored ? "Sınırsız erişime geç" : t("subscription.valueUpsell.cta")}
|
{t("subscription.valueUpsell.cta")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,15 +39,9 @@ export function initGoogleAds(): void {
|
|||||||
_adsId = id;
|
_adsId = id;
|
||||||
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
// gtag.js executes only queue entries that are the `arguments` object; pushing a
|
const gtag: (...args: GtagArgs) => void = (...args) => {
|
||||||
// plain array (rest params → `args`) is stored as inert dataLayer data and the
|
window.dataLayer?.push(args);
|
||||||
// command (config/event) NEVER runs. That silently disabled ALL conversion
|
};
|
||||||
// tracking — no _gcl_aw linker cookie, no conversion pings, 0 conversions in the
|
|
||||||
// panel. Use Google's canonical snippet form that pushes `arguments`.
|
|
||||||
function gtag() {
|
|
||||||
// biome-ignore lint/style/noArguments: gtag.js only processes the raw arguments object
|
|
||||||
window.dataLayer?.push(arguments as unknown as GtagArgs);
|
|
||||||
}
|
|
||||||
if (!window.gtag) window.gtag = gtag;
|
if (!window.gtag) window.gtag = gtag;
|
||||||
|
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
|
|||||||
@@ -719,10 +719,7 @@
|
|||||||
"previewHint": "Click Decode VIN to access the full parts catalog.",
|
"previewHint": "Click Decode VIN to access the full parts catalog.",
|
||||||
"noCatalogTitle": "We recognized this vehicle",
|
"noCatalogTitle": "We recognized this vehicle",
|
||||||
"noCatalogHint": "There's no ready catalog for this VIN yet. Browse {brand} models in the catalog to find the customer vehicle's parts.",
|
"noCatalogHint": "There's no ready catalog for this VIN yet. Browse {brand} models in the catalog to find the customer vehicle's parts.",
|
||||||
"noCatalogHintNeutral": "There's no ready catalog for this VIN yet. Browse models in the catalog to find the customer vehicle's parts.",
|
|
||||||
"browseCatalogCta": "Browse the {brand} catalog by model",
|
"browseCatalogCta": "Browse the {brand} catalog by model",
|
||||||
"browseCatalogCtaNeutral": "Browse the catalog by model",
|
|
||||||
"decodePendingHint": "The vehicle is being identified and will be ready in a few minutes. You can search again shortly.",
|
|
||||||
"recent": "Recent searches",
|
"recent": "Recent searches",
|
||||||
"seeAll": "See all →",
|
"seeAll": "See all →",
|
||||||
"historyAria": "{brand} {model} — VIN {vin}, open from history",
|
"historyAria": "{brand} {model} — VIN {vin}, open from history",
|
||||||
|
|||||||
@@ -719,10 +719,7 @@
|
|||||||
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
|
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
|
||||||
"noCatalogTitle": "Bu aracı tanıdık",
|
"noCatalogTitle": "Bu aracı tanıdık",
|
||||||
"noCatalogHint": "Bu şase için hazır katalog henüz yok. {brand} modellerini katalogdan seçerek müşteri aracının parçalarına ulaşabilirsiniz.",
|
"noCatalogHint": "Bu şase için hazır katalog henüz yok. {brand} modellerini katalogdan seçerek müşteri aracının parçalarına ulaşabilirsiniz.",
|
||||||
"noCatalogHintNeutral": "Bu şase için hazır katalog henüz yok. Modelleri katalogdan seçerek müşteri aracının parçalarına ulaşabilirsiniz.",
|
|
||||||
"browseCatalogCta": "{brand} kataloğunu modelden incele",
|
"browseCatalogCta": "{brand} kataloğunu modelden incele",
|
||||||
"browseCatalogCtaNeutral": "Kataloğu modelden incele",
|
|
||||||
"decodePendingHint": "Araç tanımlanıyor, birkaç dakika içinde hazır olacak. Birazdan tekrar sorgulayabilirsiniz.",
|
|
||||||
"recent": "Son Aramalar",
|
"recent": "Son Aramalar",
|
||||||
"seeAll": "Tümünü Gör →",
|
"seeAll": "Tümünü Gör →",
|
||||||
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç",
|
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { Route as DashboardSubscriptionIndexRouteImport } from './routes/dashboa
|
|||||||
import { Route as DashboardCatalogIndexRouteImport } from './routes/dashboard/catalog/index'
|
import { Route as DashboardCatalogIndexRouteImport } from './routes/dashboard/catalog/index'
|
||||||
import { Route as DashboardAdminIndexRouteImport } from './routes/dashboard/admin/index'
|
import { Route as DashboardAdminIndexRouteImport } from './routes/dashboard/admin/index'
|
||||||
import { Route as DemoCategoriesCategoryIdRouteImport } from './routes/demo_/categories_/$categoryId'
|
import { Route as DemoCategoriesCategoryIdRouteImport } from './routes/demo_/categories_/$categoryId'
|
||||||
import { Route as DashboardSettingsTabRouteImport } from './routes/dashboard/settings_/$tab'
|
|
||||||
import { Route as DashboardOemCodeRouteImport } from './routes/dashboard/oem.$code'
|
import { Route as DashboardOemCodeRouteImport } from './routes/dashboard/oem.$code'
|
||||||
import { Route as DashboardBlogSlugRouteImport } from './routes/dashboard/blog_/$slug'
|
import { Route as DashboardBlogSlugRouteImport } from './routes/dashboard/blog_/$slug'
|
||||||
import { Route as DashboardAdminUsersRouteImport } from './routes/dashboard/admin/users'
|
import { Route as DashboardAdminUsersRouteImport } from './routes/dashboard/admin/users'
|
||||||
@@ -211,11 +210,6 @@ const DemoCategoriesCategoryIdRoute =
|
|||||||
path: '/demo/categories/$categoryId',
|
path: '/demo/categories/$categoryId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const DashboardSettingsTabRoute = DashboardSettingsTabRouteImport.update({
|
|
||||||
id: '/settings_/$tab',
|
|
||||||
path: '/settings/$tab',
|
|
||||||
getParentRoute: () => DashboardRoute,
|
|
||||||
} as any)
|
|
||||||
const DashboardOemCodeRoute = DashboardOemCodeRouteImport.update({
|
const DashboardOemCodeRoute = DashboardOemCodeRouteImport.update({
|
||||||
id: '/oem/$code',
|
id: '/oem/$code',
|
||||||
path: '/oem/$code',
|
path: '/oem/$code',
|
||||||
@@ -358,7 +352,6 @@ export interface FileRoutesByFullPath {
|
|||||||
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
|
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
|
||||||
'/dashboard/blog/$slug': typeof DashboardBlogSlugRoute
|
'/dashboard/blog/$slug': typeof DashboardBlogSlugRoute
|
||||||
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
|
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
|
||||||
'/dashboard/settings/$tab': typeof DashboardSettingsTabRoute
|
|
||||||
'/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute
|
'/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute
|
||||||
'/dashboard/admin/': typeof DashboardAdminIndexRoute
|
'/dashboard/admin/': typeof DashboardAdminIndexRoute
|
||||||
'/dashboard/catalog/': typeof DashboardCatalogIndexRoute
|
'/dashboard/catalog/': typeof DashboardCatalogIndexRoute
|
||||||
@@ -408,7 +401,6 @@ export interface FileRoutesByTo {
|
|||||||
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
|
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
|
||||||
'/dashboard/blog/$slug': typeof DashboardBlogSlugRoute
|
'/dashboard/blog/$slug': typeof DashboardBlogSlugRoute
|
||||||
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
|
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
|
||||||
'/dashboard/settings/$tab': typeof DashboardSettingsTabRoute
|
|
||||||
'/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute
|
'/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute
|
||||||
'/dashboard/admin': typeof DashboardAdminIndexRoute
|
'/dashboard/admin': typeof DashboardAdminIndexRoute
|
||||||
'/dashboard/catalog': typeof DashboardCatalogIndexRoute
|
'/dashboard/catalog': typeof DashboardCatalogIndexRoute
|
||||||
@@ -461,7 +453,6 @@ export interface FileRoutesById {
|
|||||||
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
|
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
|
||||||
'/dashboard/blog_/$slug': typeof DashboardBlogSlugRoute
|
'/dashboard/blog_/$slug': typeof DashboardBlogSlugRoute
|
||||||
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
|
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
|
||||||
'/dashboard/settings_/$tab': typeof DashboardSettingsTabRoute
|
|
||||||
'/demo_/categories_/$categoryId': typeof DemoCategoriesCategoryIdRoute
|
'/demo_/categories_/$categoryId': typeof DemoCategoriesCategoryIdRoute
|
||||||
'/dashboard/admin/': typeof DashboardAdminIndexRoute
|
'/dashboard/admin/': typeof DashboardAdminIndexRoute
|
||||||
'/dashboard/catalog/': typeof DashboardCatalogIndexRoute
|
'/dashboard/catalog/': typeof DashboardCatalogIndexRoute
|
||||||
@@ -514,7 +505,6 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/admin/users'
|
| '/dashboard/admin/users'
|
||||||
| '/dashboard/blog/$slug'
|
| '/dashboard/blog/$slug'
|
||||||
| '/dashboard/oem/$code'
|
| '/dashboard/oem/$code'
|
||||||
| '/dashboard/settings/$tab'
|
|
||||||
| '/demo/categories/$categoryId'
|
| '/demo/categories/$categoryId'
|
||||||
| '/dashboard/admin/'
|
| '/dashboard/admin/'
|
||||||
| '/dashboard/catalog/'
|
| '/dashboard/catalog/'
|
||||||
@@ -564,7 +554,6 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/admin/users'
|
| '/dashboard/admin/users'
|
||||||
| '/dashboard/blog/$slug'
|
| '/dashboard/blog/$slug'
|
||||||
| '/dashboard/oem/$code'
|
| '/dashboard/oem/$code'
|
||||||
| '/dashboard/settings/$tab'
|
|
||||||
| '/demo/categories/$categoryId'
|
| '/demo/categories/$categoryId'
|
||||||
| '/dashboard/admin'
|
| '/dashboard/admin'
|
||||||
| '/dashboard/catalog'
|
| '/dashboard/catalog'
|
||||||
@@ -616,7 +605,6 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/admin/users'
|
| '/dashboard/admin/users'
|
||||||
| '/dashboard/blog_/$slug'
|
| '/dashboard/blog_/$slug'
|
||||||
| '/dashboard/oem/$code'
|
| '/dashboard/oem/$code'
|
||||||
| '/dashboard/settings_/$tab'
|
|
||||||
| '/demo_/categories_/$categoryId'
|
| '/demo_/categories_/$categoryId'
|
||||||
| '/dashboard/admin/'
|
| '/dashboard/admin/'
|
||||||
| '/dashboard/catalog/'
|
| '/dashboard/catalog/'
|
||||||
@@ -863,13 +851,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DemoCategoriesCategoryIdRouteImport
|
preLoaderRoute: typeof DemoCategoriesCategoryIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/dashboard/settings_/$tab': {
|
|
||||||
id: '/dashboard/settings_/$tab'
|
|
||||||
path: '/settings/$tab'
|
|
||||||
fullPath: '/dashboard/settings/$tab'
|
|
||||||
preLoaderRoute: typeof DashboardSettingsTabRouteImport
|
|
||||||
parentRoute: typeof DashboardRoute
|
|
||||||
}
|
|
||||||
'/dashboard/oem/$code': {
|
'/dashboard/oem/$code': {
|
||||||
id: '/dashboard/oem/$code'
|
id: '/dashboard/oem/$code'
|
||||||
path: '/oem/$code'
|
path: '/oem/$code'
|
||||||
@@ -1041,7 +1022,6 @@ interface DashboardRouteChildren {
|
|||||||
DashboardAdminUsersRoute: typeof DashboardAdminUsersRoute
|
DashboardAdminUsersRoute: typeof DashboardAdminUsersRoute
|
||||||
DashboardBlogSlugRoute: typeof DashboardBlogSlugRoute
|
DashboardBlogSlugRoute: typeof DashboardBlogSlugRoute
|
||||||
DashboardOemCodeRoute: typeof DashboardOemCodeRoute
|
DashboardOemCodeRoute: typeof DashboardOemCodeRoute
|
||||||
DashboardSettingsTabRoute: typeof DashboardSettingsTabRoute
|
|
||||||
DashboardAdminIndexRoute: typeof DashboardAdminIndexRoute
|
DashboardAdminIndexRoute: typeof DashboardAdminIndexRoute
|
||||||
DashboardCatalogIndexRoute: typeof DashboardCatalogIndexRoute
|
DashboardCatalogIndexRoute: typeof DashboardCatalogIndexRoute
|
||||||
DashboardSubscriptionIndexRoute: typeof DashboardSubscriptionIndexRoute
|
DashboardSubscriptionIndexRoute: typeof DashboardSubscriptionIndexRoute
|
||||||
@@ -1076,7 +1056,6 @@ const DashboardRouteChildren: DashboardRouteChildren = {
|
|||||||
DashboardAdminUsersRoute: DashboardAdminUsersRoute,
|
DashboardAdminUsersRoute: DashboardAdminUsersRoute,
|
||||||
DashboardBlogSlugRoute: DashboardBlogSlugRoute,
|
DashboardBlogSlugRoute: DashboardBlogSlugRoute,
|
||||||
DashboardOemCodeRoute: DashboardOemCodeRoute,
|
DashboardOemCodeRoute: DashboardOemCodeRoute,
|
||||||
DashboardSettingsTabRoute: DashboardSettingsTabRoute,
|
|
||||||
DashboardAdminIndexRoute: DashboardAdminIndexRoute,
|
DashboardAdminIndexRoute: DashboardAdminIndexRoute,
|
||||||
DashboardCatalogIndexRoute: DashboardCatalogIndexRoute,
|
DashboardCatalogIndexRoute: DashboardCatalogIndexRoute,
|
||||||
DashboardSubscriptionIndexRoute: DashboardSubscriptionIndexRoute,
|
DashboardSubscriptionIndexRoute: DashboardSubscriptionIndexRoute,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { SegmentGate } from "@/components/auth/segment-gate";
|
|
||||||
import { GoogleAdsSignupConversion } from "@/components/google-ads-signup-conversion";
|
|
||||||
import { InAppEscape } from "@/components/in-app-escape";
|
import { InAppEscape } from "@/components/in-app-escape";
|
||||||
import { SurveyPopover } from "@/components/survey-popover";
|
import { SurveyPopover } from "@/components/survey-popover";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@@ -159,8 +157,6 @@ function RootComponent() {
|
|||||||
</a>
|
</a>
|
||||||
<InAppEscape />
|
<InAppEscape />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<SegmentGate />
|
|
||||||
<GoogleAdsSignupConversion />
|
|
||||||
<SurveyPopover />
|
<SurveyPopover />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -189,13 +189,6 @@ function RegisterPage() {
|
|||||||
transactionId: data?.user ? `signup_${data.user.id}` : undefined,
|
transactionId: data?.user ? `signup_${data.user.id}` : undefined,
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
// Guard so the universal post-auth conversion (which covers OAuth signups)
|
|
||||||
// skips this email signup — Google would dedupe on transaction_id anyway.
|
|
||||||
try {
|
|
||||||
if (data?.user) localStorage.setItem(`sase-ga-signup-conv-${data.user.id}`, "1");
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable — the transaction_id dedupe still protects us.
|
|
||||||
}
|
|
||||||
if (plan) localStorage.setItem(PENDING_PLAN_KEY, plan);
|
if (plan) localStorage.setItem(PENDING_PLAN_KEY, plan);
|
||||||
toast.success("Hesap oluşturuldu!");
|
toast.success("Hesap oluşturuldu!");
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { DunningBanner } from "@/components/dunning-banner";
|
|
||||||
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
|
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
|
||||||
import { TrialValueUpsell } from "@/components/trial-value-upsell";
|
import { TrialValueUpsell } from "@/components/trial-value-upsell";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@@ -520,7 +519,6 @@ function DashboardLayout() {
|
|||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
|
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
|
||||||
<DunningBanner />
|
|
||||||
<TrialUrgencyBanner />
|
<TrialUrgencyBanner />
|
||||||
<TrialValueUpsell />
|
<TrialValueUpsell />
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6">
|
||||||
|
|||||||
@@ -145,36 +145,12 @@ function OemDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reverse catalog: the user's own vehicles that use this OEM code (sase data).
|
// Reverse catalog: the user's own vehicles that use this OEM code (sase data).
|
||||||
const { data: catalogVehicles, isFetched: catalogVehiclesFetched } = useQuery({
|
const { data: catalogVehicles } = useQuery({
|
||||||
queryKey: ["oem-vehicles", code],
|
queryKey: ["oem-vehicles", code],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<CatalogVehicle[]>(`/parts/oem-vehicles?code=${encodeURIComponent(code)}`),
|
api.get<CatalogVehicle[]>(`/parts/oem-vehicles?code=${encodeURIComponent(code)}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bu OEM kodunun araç markası — kataloğumuzdaki araçlardan en çok geçen
|
|
||||||
// marka. Fiyat kartı bununla filtreleniyor: bir OEM kodu tek markaya aittir,
|
|
||||||
// teklifleri o markanın OE ailesine süzeriz (Peugeot kodu → PSA, OPAR
|
|
||||||
// karışmaz). Belirlenemezse (kod kataloğumuzda yok) boş → kart tüm OE
|
|
||||||
// ailelerini gösterir (güvenli taban). `v` search param'ı yalnızca serbest
|
|
||||||
// metin etiket olduğundan marka için güvenilmez; yapılandırılmış brandName
|
|
||||||
// esas alınır.
|
|
||||||
const codeBrand = useMemo(() => {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const cv of catalogVehicles ?? []) {
|
|
||||||
const b = cv.brandName?.trim();
|
|
||||||
if (b) counts.set(b, (counts.get(b) ?? 0) + Math.max(1, cv.occurrences));
|
|
||||||
}
|
|
||||||
let top: string | undefined;
|
|
||||||
let best = 0;
|
|
||||||
for (const [b, n] of counts) {
|
|
||||||
if (n > best) {
|
|
||||||
best = n;
|
|
||||||
top = b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return top;
|
|
||||||
}, [catalogVehicles]);
|
|
||||||
|
|
||||||
// Sayfadaki tüm parçaların (kod + marka) güncel tedarikçi fiyatı (tek batch
|
// Sayfadaki tüm parçaların (kod + marka) güncel tedarikçi fiyatı (tek batch
|
||||||
// isteği). Marka şart: kısa sayısal kodlar markalar arası çakışır (FEBI
|
// isteği). Marka şart: kısa sayısal kodlar markalar arası çakışır (FEBI
|
||||||
// 27155 ≠ GROS 27155) — sunucu teklifleri markaya süzer. Eşleşmeyen parça
|
// 27155 ≠ GROS 27155) — sunucu teklifleri markaya süzer. Eşleşmeyen parça
|
||||||
@@ -188,14 +164,14 @@ function OemDetailPage() {
|
|||||||
seen.add(k);
|
seen.add(k);
|
||||||
parts.push(b ? { code: c, brand: b } : { code: c });
|
parts.push(b ? { code: c, brand: b } : { code: c });
|
||||||
};
|
};
|
||||||
// Ana OEM kodunun fiyatını PartPriceSection serisi gösterir — batch'e girmez.
|
push(code); // sayfanın ana OEM kodu — markasız (uzun/benzersiz kodlar eşleşir)
|
||||||
if (data?.matched) {
|
if (data?.matched) {
|
||||||
for (const a of data.articles) push(a.articleNumber, a.brand);
|
for (const a of data.articles) push(a.articleNumber, a.brand);
|
||||||
for (const p of data.aftermarketParts) push(p.articleNumber, p.brand);
|
for (const p of data.aftermarketParts) push(p.articleNumber, p.brand);
|
||||||
for (const oe of data.oeCrossReferences) push(oe.code, oe.brand);
|
for (const oe of data.oeCrossReferences) push(oe.code, oe.brand);
|
||||||
}
|
}
|
||||||
return parts.slice(0, 400);
|
return parts.slice(0, 400);
|
||||||
}, [data]);
|
}, [data, code]);
|
||||||
|
|
||||||
const { data: priceBatch } = useQuery({
|
const { data: priceBatch } = useQuery({
|
||||||
queryKey: ["part-prices-batch", code, priceParts.length],
|
queryKey: ["part-prices-batch", code, priceParts.length],
|
||||||
@@ -283,10 +259,8 @@ function OemDetailPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ─── Tedarikçi fiyat geçmişi (sorgulanan kodun kendisi) ──────────
|
{/* ─── Tedarikçi fiyat geçmişi (sorgulanan kodun kendisi) ──────────
|
||||||
Kodun araç markasına (codeBrand) süzülür → yalnız o markanın OE
|
Kendi verisi yoksa kendini gizler; P eşleşmesinden bağımsız. */}
|
||||||
ailesi. Marka çözümü için oem-vehicles'ın dönmesini bekleriz (çift
|
<PartPriceSection code={code} />
|
||||||
istek + bayat markasız iz oluşmasın); kendi verisi yoksa gizlenir. */}
|
|
||||||
{catalogVehiclesFetched && <PartPriceSection code={code} brand={codeBrand} />}
|
|
||||||
|
|
||||||
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
{/* ─── Loading ────────────────────────────────────────────────────── */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user