feat: EMEX HTTP decode + multi-candidate selection UI
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled

Replace Playwright browser with pure HTTP for EMEX VIN decode:
- Vehicles.aspx, QuickGroups.aspx, QuickDetails.aspx all work
  without authentication — no session/browser needed
- VIN decode drops from ~10-20s (Playwright) to ~1-5s (HTTP)
- Playwright browser kept as fallback if HTTP fails

Add multi-candidate selection for EMEX (mirrors PartsCatalogs flow):
- EmexCandidate interface matches PcatCar shape so VehicleSelectModal
  renders without changes
- decodeVinOrCandidates(): single HTTP fetch returns vehicle OR
  candidates (avoids double Vehicles.aspx request)
- decodeVinByIndex(): resolves user-selected candidate by index
- vehicles.service: emexCandidates in VinResolveResult, emexCarIndex
  param in decodeVin/resolveVin, resolveEmexCarByIndex() method
- vehicles.controller: @Body("emexCarIndex") accepted
- search.tsx: candidateSource state, handleCandidateSelect sends
  emexCarIndex or pcatCarId based on source

Test VIN NM417800006410193 (Fiat SIENA) now shows 2-option modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-03-05 09:46:25 +00:00
parent f29a923e6e
commit ce8784014e
5 changed files with 610 additions and 83 deletions

View File

@@ -2,11 +2,11 @@
* EMEX VIN Service
*
* NestJS service for emexdwc.ae VIN integration.
* Wraps the EmexVinScraper from scripts/emex-vin-scraper.js
* and provides standardized DecodedVehicle responses.
* Primary path: pure HTTP fetch (no browser) — fast (~1-5s).
* Fallback: Playwright scraper via EmexBrowserService.
*
* Uses EmexBrowserService for a persistent singleton browser —
* each request gets a pre-created Page (tab) via acquirePage().
* emexdwc.ae does NOT require authentication for Vehicles.aspx,
* QuickGroups.aspx, or QuickDetails.aspx — plain HTTP GET works.
*/
import {
@@ -30,6 +30,42 @@ import { mapEmexResponse, createEmptyDecodedVehicle } from './emex.mapper';
import { EmexBrowserService } from './emex.browser';
import { RedisService } from '../../redis/redis.service';
const EMEX_BASE_URL = 'https://emexdwc.ae';
const EMEX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
interface EmexHttpVehicle {
label: string;
model: string;
yearFrom: number | null;
catalogCode: string | null;
vid: string | null;
ssd: string | null;
quickGroupsUrl: string | null;
}
interface EmexHttpCategory {
gid: string | null;
name: string;
url: string;
}
/**
* Candidate vehicle returned when EMEX finds multiple matches for a VIN.
* Shape matches PcatCar so VehicleSelectModal can render without changes.
*/
export interface EmexCandidate {
/** String index into the Vehicles.aspx list — "0", "1", etc. */
id: string;
name: string;
parameters?: Array<{ key: string; idx: string; value: string }>;
catalogId: string;
/** Internal: used by resolveEmexCarByIndex to fetch the specific vehicle */
_index: number;
_quickGroupsUrl: string | null;
_ssd: string | null;
_vid: string | null;
}
// Type definition for the imported scraper module
interface EmexScraperModule {
EmexVinScraper: new (options?: { page?: unknown }) => EmexVinScraperInstance;
@@ -183,10 +219,316 @@ export class EmexService {
}
}
// ─── HTTP-based methods (no browser required) ────────────────
/**
* Decodes a VIN number using EMEX scraper.
* Works with or without a known catalog code — the scraper
* falls back to VIN URL search which doesn't require one.
* Fetch a URL from emexdwc.ae without a browser session.
* emexdwc.ae serves Vehicles.aspx, QuickGroups.aspx, and QuickDetails.aspx
* without requiring authentication cookies.
*/
private async fetchEmexHtml(url: string): Promise<string> {
const res = await fetch(url, {
headers: { 'User-Agent': EMEX_UA, 'Accept': 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(this.timeout),
});
if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
}
return res.text();
}
/**
* Parse Vehicles.aspx HTML — extract vehicle list from "Found vehicles" table.
*/
private parseVehiclesList(html: string): EmexHttpVehicle[] {
const linkRx = /href="(Vehicle\.aspx\?[^"]+)">([^<]+)<\/a>/g;
const seen = new Set<string>();
const vehicles: EmexHttpVehicle[] = [];
let m: RegExpExecArray | null;
while ((m = linkRx.exec(html)) !== null) {
const href = m[1].replace(/&amp;/g, '&');
if (seen.has(href)) continue;
seen.add(href);
const label = m[2].trim();
const params = new URLSearchParams(href.replace('Vehicle.aspx?', ''));
const c = params.get('c');
const vid = params.get('vid');
const ssd = params.get('ssd');
const modelMatch = label.match(/^([^\[]+)/);
const yearMatch = label.match(/\((\d{4})/);
vehicles.push({
label,
model: modelMatch ? modelMatch[1].trim() : label,
yearFrom: yearMatch ? parseInt(yearMatch[1], 10) : null,
catalogCode: c,
vid,
ssd,
quickGroupsUrl: c && vid != null && ssd
? `${EMEX_BASE_URL}/QuickGroups.aspx?c=${c}&vid=${vid}&ssd=${encodeURIComponent(ssd)}`
: null,
});
}
return vehicles;
}
/**
* Parse QuickGroups.aspx HTML — extract flat category list (QuickDetails links).
*/
private parseCategoryList(html: string): EmexHttpCategory[] {
const catRx = /href="(QuickDetails\.aspx\?[^"]+)">([^<]+)<\/a>/g;
const seen = new Set<string>();
const cats: EmexHttpCategory[] = [];
let m: RegExpExecArray | null;
while ((m = catRx.exec(html)) !== null) {
const href = m[1].replace(/&amp;/g, '&');
const name = m[2].trim();
if (name.length < 2 || seen.has(href)) continue;
seen.add(href);
const params = new URLSearchParams(href.replace('QuickDetails.aspx?', ''));
cats.push({ gid: params.get('gid'), name, url: `${EMEX_BASE_URL}/${href}` });
}
return cats;
}
/**
* Determine brand name from EMEX catalog code (e.g. "BMW202501" → "BMW").
*/
private brandFromCatalogCode(c: string | null): string | null {
if (!c) return null;
const upper = c.toUpperCase();
const prefixes: [string, string][] = [
['BMW', 'BMW'], ['MB', 'Mercedes-Benz'], ['AU', 'Audi'],
['VW', 'Volkswagen'], ['FFIAT', 'Fiat'], ['RFIAT', 'Alfa Romeo'],
['FORD', 'Ford'], ['RENAULT', 'Renault'], ['TOYOTA', 'Toyota'],
['HONDA', 'Honda'], ['KIA', 'Kia'], ['HYUNDAI', 'Hyundai'],
['PORSCHE', 'Porsche'], ['SUBARU', 'Subaru'], ['MAZDA', 'Mazda'],
['CPSA', 'Citroën/Peugeot'], ['VOLVO', 'Volvo'], ['NISSAN', 'Nissan'],
['OPEL', 'Opel'],
];
for (const [prefix, brand] of prefixes) {
if (upper.startsWith(prefix)) return brand;
}
return null;
}
/**
* VIN decode via pure HTTP (primary path — no browser needed).
* Returns null if VIN is not found in EMEX database.
*/
private async decodeVinHttp(vin: string): Promise<DecodedVehicle | null> {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
this.logger.log(`EMEX HTTP: fetching ${vinUrl}`);
const vinHtml = await this.fetchEmexHtml(vinUrl);
const vehicles = this.parseVehiclesList(vinHtml);
if (vehicles.length === 0) {
this.logger.log(`EMEX HTTP: no vehicles found for VIN ${vin}`);
return null;
}
const v = vehicles[0];
this.logger.log(`EMEX HTTP: found "${v.label}" (c=${v.catalogCode})`);
// Determine brand: prefer CATALOG_MAP lookup, then catalog code heuristic
const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
// Fetch categories from QuickGroups.aspx (fast HTTP, no browser)
let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) {
try {
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
categories = this.parseCategoryList(qgHtml);
this.logger.log(`EMEX HTTP: found ${categories.length} categories`);
} catch (err) {
this.logger.warn(`EMEX HTTP: category fetch failed: ${(err as Error).message}`);
}
}
// Build a response compatible with mapEmexResponse
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
vin,
catalogCode: v.catalogCode || '',
ssd: v.ssd || undefined,
vehicle: {
brand,
model: v.model,
year: v.yearFrom,
bodyType: null,
engineCode: null,
engineType: null,
engineVolume: null,
transmission: null,
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
return mapEmexResponse(response);
}
/**
* Single-fetch combined decode: fetches Vehicles.aspx once and returns either:
* - `{ type: 'vehicle', vehicle }` — single match decoded as DecodedVehicle
* - `{ type: 'candidates', candidates }` — multiple matches, caller shows selection UI
* - `{ type: 'notFound' }` — VIN not in EMEX
* - `{ type: 'error' }` — fetch failed
*/
async decodeVinOrCandidates(vin: string): Promise<
| { type: 'vehicle'; vehicle: DecodedVehicle }
| { type: 'candidates'; candidates: EmexCandidate[] }
| { type: 'notFound' }
| { type: 'error' }
> {
try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const html = await this.fetchEmexHtml(vinUrl);
const vehicleList = this.parseVehiclesList(html);
if (vehicleList.length === 0) return { type: 'notFound' };
if (vehicleList.length > 1) {
const candidates: EmexCandidate[] = vehicleList.map((v, i) => {
const params: Array<{ key: string; idx: string; value: string }> = [];
if (v.yearFrom) params.push({ key: 'year', idx: '0', value: String(v.yearFrom) });
if (v.catalogCode) params.push({ key: 'catalog', idx: '1', value: v.catalogCode });
return {
id: String(i),
name: v.label,
parameters: params,
catalogId: v.catalogCode || '',
_index: i,
_quickGroupsUrl: v.quickGroupsUrl,
_ssd: v.ssd,
_vid: v.vid,
};
});
return { type: 'candidates', candidates };
}
// Single result — decode directly
const v = vehicleList[0];
const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) {
try {
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
categories = this.parseCategoryList(qgHtml);
this.logger.log(`EMEX HTTP: found ${categories.length} categories`);
} catch (err) {
this.logger.warn(`EMEX HTTP category fetch failed: ${(err as Error).message}`);
}
}
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
vin,
catalogCode: v.catalogCode || '',
ssd: v.ssd || undefined,
vehicle: {
brand,
model: v.model,
year: v.yearFrom,
bodyType: null,
engineCode: null,
engineType: null,
engineVolume: null,
transmission: null,
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
return { type: 'vehicle', vehicle: mapEmexResponse(response) };
} catch (err) {
this.logger.warn(`decodeVinOrCandidates failed: ${(err as Error).message}`);
return { type: 'error' };
}
}
/**
* Decodes a specific EMEX candidate by its index in the Vehicles.aspx list.
* Used after the user selects a vehicle from the multi-candidate modal.
*/
async decodeVinByIndex(vin: string, index: number): Promise<DecodedVehicle | null> {
try {
const vinUrl = `${EMEX_BASE_URL}/Vehicles.aspx?ft=findByVIN&c=&ssd=&vin=${vin}`;
const vinHtml = await this.fetchEmexHtml(vinUrl);
const vehicleList = this.parseVehiclesList(vinHtml);
if (index < 0 || index >= vehicleList.length) {
this.logger.warn(`EMEX decodeVinByIndex: index ${index} out of range (${vehicleList.length} vehicles)`);
return null;
}
const v = vehicleList[index];
this.logger.log(`EMEX decodeVinByIndex[${index}]: "${v.label}" (c=${v.catalogCode})`);
const wmi = vin.substring(0, 3).toUpperCase();
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || this.brandFromCatalogCode(v.catalogCode) || 'Unknown';
let categories: EmexHttpCategory[] = [];
if (v.quickGroupsUrl) {
try {
const qgHtml = await this.fetchEmexHtml(v.quickGroupsUrl);
categories = this.parseCategoryList(qgHtml);
} catch (err) {
this.logger.warn(`EMEX decodeVinByIndex category fetch failed: ${(err as Error).message}`);
}
}
const response: EmexScraperResponse = {
success: true,
source: 'emexdwc.ae',
method: 'vin_url',
vin,
catalogCode: v.catalogCode || '',
ssd: v.ssd || undefined,
vehicle: {
brand,
model: v.model,
year: v.yearFrom,
bodyType: null,
engineCode: null,
engineType: null,
engineVolume: null,
transmission: null,
driveType: null,
},
quickGroupsUrl: v.quickGroupsUrl || null,
categories: categories.map((c) => ({ gid: c.gid || '', name: c.name, url: c.url })),
categoryTree: [],
timestamp: new Date().toISOString(),
};
return mapEmexResponse(response);
} catch (err) {
this.logger.warn(`EMEX decodeVinByIndex failed: ${(err as Error).message}`);
return null;
}
}
/**
* Decodes a VIN number.
* Primary: pure HTTP fetch (fast, ~1-5s).
* Fallback: Playwright browser scraper (slower, used if HTTP fails).
*/
async decodeVin(vin: string): Promise<DecodedVehicle> {
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, '');
@@ -197,6 +539,23 @@ export class EmexService {
this.logger.log(`Decoding VIN: ${cleanVin} (catalog supported: ${supported})`);
await this.touchActivity();
// ── Primary: HTTP (no browser) ──────────────────────────────
try {
const result = await this.decodeVinHttp(cleanVin);
if (result) {
this.logger.log(
`EMEX HTTP decode OK: ${result.brand} ${result.model} (${result.year})`,
);
return result;
}
// VIN not in EMEX — return empty rather than hitting browser
return createEmptyDecodedVehicle(cleanVin, 'Vehicle not found in EMEX database');
} catch (httpErr) {
const err = httpErr as Error;
this.logger.warn(`EMEX HTTP decode failed (${err.message}), falling back to browser`);
}
// ── Fallback: Playwright browser scraper ────────────────────
let release: (() => Promise<void>) | null = null;
try {
@@ -211,69 +570,27 @@ export class EmexService {
if (this.debug) {
this.logger.debug(
`EMEX raw response: ${JSON.stringify(response, null, 2)}`,
`EMEX browser raw response: ${JSON.stringify(response, null, 2)}`,
);
}
if (!response.success) {
this.logger.warn(
`EMEX search unsuccessful: ${response.message || response.error}`,
`EMEX browser search unsuccessful: ${response.message || response.error}`,
);
if (response.vehicle && response.vehicle.brand) {
return mapEmexResponse(response);
}
return createEmptyDecodedVehicle(
cleanVin,
response.message || response.error,
);
}
// Try category tree from QuickGroups.aspx (hierarchical)
if (response.quickGroupsUrl) {
this.logger.log(`Fetching category tree from QuickGroups.aspx: ${response.quickGroupsUrl}`);
try {
const tree = await this.executeWithTimeout(
scraper.getCategoryTree(response.quickGroupsUrl),
this.timeout,
);
if (tree && tree.length > 0) {
this.logger.log(`Found ${tree.length} top-level category groups`);
response.categoryTree = tree;
}
} catch (treeError) {
const err = treeError as Error;
this.logger.warn(`Failed to fetch category tree: ${err.message}`);
}
}
// Fall back to flat categories if tree failed
if (!response.categoryTree?.length && response.quickGroupsUrl) {
this.logger.log(`Falling back to flat categories from: ${response.quickGroupsUrl}`);
try {
const categories = await this.executeWithTimeout(
scraper.getCategories(response.quickGroupsUrl),
this.timeout,
);
if (categories && categories.length > 0) {
this.logger.log(`Found ${categories.length} flat categories (on-demand parts loading enabled)`);
response.categories = categories;
}
} catch (catError) {
const err = catError as Error;
this.logger.warn(`Failed to fetch categories: ${err.message}`);
}
}
const decodedVehicle = mapEmexResponse(response);
this.logger.log(
`VIN decoded successfully: ${decodedVehicle.brand} ${decodedVehicle.model} (${decodedVehicle.year})`,
`EMEX browser decode OK: ${decodedVehicle.brand} ${decodedVehicle.model} (${decodedVehicle.year})`,
);
return decodedVehicle;
} catch (error) {
const err = error as Error;
@@ -293,17 +610,6 @@ export class EmexService {
);
}
if (
err.message?.includes('browser') ||
err.message?.includes('playwright') ||
err.message?.includes('navigation')
) {
this.logger.error(`Browser error: ${err.message}`, err.stack);
throw new ServiceUnavailableException(
'EMEX servisine baglanamadi. Lutfen daha sonra tekrar deneyin.',
);
}
this.logger.error(`VIN decode error: ${err.message}`, err.stack);
throw new ServiceUnavailableException(
'VIN sorgulama sirasinda bir hata olustu',

View File

@@ -25,8 +25,9 @@ export class VehiclesController {
@CurrentUser("id") userId: string,
@Body("vin", VinValidationPipe) vin: string,
@Body("pcatCarId") pcatCarId?: string,
@Body("emexCarIndex") emexCarIndex?: number,
) {
return this.vehiclesService.decodeVin(vin, userId, pcatCarId);
return this.vehiclesService.decodeVin(vin, userId, pcatCarId, emexCarIndex);
}
@Get("history")

View File

@@ -27,6 +27,7 @@ import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { EmexService } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import type { PcatCar } from "../integrations/parts-catalogs/parts-catalogs.types";
import type { EmexCandidate } from "../integrations/emex/emex.service";
import { RedisService } from "../redis/redis.service";
import { isValidVin } from "@sase/shared";
@@ -43,6 +44,8 @@ interface VinResolveResult {
corgiResult: any;
/** When source is "parts-catalogs" and multiple cars found, these are the candidates */
pcatCandidates?: PcatCar[];
/** When source is "emex" and multiple vehicles found, these are the candidates */
emexCandidates?: EmexCandidate[];
}
@Injectable()
@@ -60,7 +63,7 @@ export class VehiclesService {
private redis: RedisService,
) {}
async decodeVin(vin: string, userId: string, pcatCarId?: string) {
async decodeVin(vin: string, userId: string, pcatCarId?: string, emexCarIndex?: number) {
const startTime = Date.now();
if (!isValidVin(vin)) {
@@ -88,7 +91,7 @@ export class VehiclesService {
}
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
const resolved = await this.resolveVin(vin, pcatCarId);
const resolved = await this.resolveVin(vin, pcatCarId, emexCarIndex, userId);
if (!resolved) {
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
@@ -100,6 +103,10 @@ export class VehiclesService {
await this.logQuery(userId, vin, null, "parts-catalogs", true, Date.now() - startTime);
return { candidates: resolved.pcatCandidates, vin, source: "parts-catalogs" };
}
if (resolved.emexCandidates && resolved.emexCandidates.length > 1) {
await this.logQuery(userId, vin, null, "emex", true, Date.now() - startTime);
return { candidates: resolved.emexCandidates, vin, source: "emex" };
}
// 3. Brand access check
let brandId: string | null = null;
@@ -189,7 +196,7 @@ export class VehiclesService {
return existing;
}
const resolved = await this.resolveVin(vin);
const resolved = await this.resolveVin(vin, undefined, undefined);
if (!resolved) {
throw new BadRequestException("Şase numarası tanınamadı");
}
@@ -208,12 +215,16 @@ export class VehiclesService {
* Corgi (offline) → PartsCatalogs → PL24 fallback → EMEX fallback.
*
* @param pcatCarId If provided, skip resolve chain and use this specific PC car
* @param emexCarIndex If provided, skip resolve chain and use this EMEX candidate index
*/
private async resolveVin(vin: string, pcatCarId?: string): Promise<VinResolveResult | null> {
private async resolveVin(vin: string, pcatCarId?: string, emexCarIndex?: number, userId?: string): Promise<VinResolveResult | null> {
// If user selected a specific PC car from candidates, resolve directly
if (pcatCarId) {
return this.resolvePcatCarById(vin, pcatCarId);
}
if (emexCarIndex !== undefined) {
return this.resolveEmexCarByIndex(vin, emexCarIndex);
}
const cacheKey = `vin:resolve:${vin}`;
const cached = await this.redis.getJson<VinResolveResult>(cacheKey);
@@ -268,7 +279,7 @@ export class VehiclesService {
let pl24Vehicle: any = null;
if (this.pl24Service.isDecodeable(vin)) {
try {
pl24Vehicle = await this.pl24Service.decodeVin(vin);
pl24Vehicle = await this.pl24Service.decodeVin(vin, userId);
if (!brandName && pl24Vehicle) {
brandName = this.pl24Service.getBrandName(vin) || null;
}
@@ -312,14 +323,37 @@ export class VehiclesService {
};
}
// 4. EMEX fallback (slowest, browser-based)
// 4. EMEX fallback (single HTTP call — no double fetch)
let emexVehicle: import("../integrations/emex/emex.types").DecodedVehicle | null = null;
if (!pl24Vehicle) {
try {
const emexResult = await this.emexService.decodeVin(vin);
if (emexResult && emexResult.brand !== "UNKNOWN") {
emexVehicle = emexResult;
if (!brandName) brandName = emexResult.brand || null;
const emexResult = await this.emexService.decodeVinOrCandidates(vin);
if (emexResult.type === 'candidates') {
this.logger.log(`EMEX returned ${emexResult.candidates.length} candidates for ${vin}`);
return {
brandName,
model: null,
year: null,
engine: null,
transmission: null,
bodyType: null,
rawData: null,
source: "emex",
corgiKnown,
corgiResult: corgiResult || null,
emexCandidates: emexResult.candidates,
};
}
if (emexResult.type === 'vehicle' && emexResult.vehicle.brand !== "UNKNOWN") {
emexVehicle = emexResult.vehicle;
if (!brandName) brandName = emexResult.vehicle.brand || null;
} else if (emexResult.type === 'error') {
// HTTP failed — try browser fallback
const fallback = await this.emexService.decodeVin(vin);
if (fallback && fallback.brand !== "UNKNOWN") {
emexVehicle = fallback;
if (!brandName) brandName = fallback.brand || null;
}
}
} catch (err) {
this.logger.warn(`EMEX fallback failed for ${vin}: ${(err as Error).message}`);
@@ -388,6 +422,36 @@ export class VehiclesService {
};
}
/**
* Resolve a specific EMEX vehicle by its candidate index
* (after user selects from the multi-candidate modal).
*/
private async resolveEmexCarByIndex(vin: string, index: number): Promise<VinResolveResult | null> {
const corgiResult = this.corgiService.decodeVin(vin);
const corgiKnown = !!(corgiResult && corgiResult.isKnown);
const decoded = await this.emexService.decodeVinByIndex(vin, index);
if (!decoded) {
this.logger.warn(`EMEX decodeVinByIndex returned null for ${vin}[${index}]`);
return null;
}
const brandName = decoded.brand !== "UNKNOWN" ? decoded.brand : (corgiResult?.brandName || null);
return {
brandName,
model: decoded.model || null,
year: decoded.year || corgiResult?.modelYear || null,
engine: decoded.engineCode || decoded.engineType || null,
transmission: decoded.transmission || null,
bodyType: decoded.bodyType || null,
rawData: decoded.raw || null,
source: "emex",
corgiKnown,
corgiResult: corgiResult || null,
};
}
// ─── PartsCatalogs helpers ─────────────────────────────
/** Map common parts-catalogs catalog IDs to brand display names */

View File

@@ -52,9 +52,10 @@ function SearchPage() {
const [reportSending, setReportSending] = useState(false);
const [reportSent, setReportSent] = useState(false);
// Vehicle candidate selection (PartsCatalogs multi-result)
// Vehicle candidate selection (PartsCatalogs or EMEX multi-result)
const [candidates, setCandidates] = useState<any[] | null>(null);
const [candidateVin, setCandidateVin] = useState("");
const [candidateSource, setCandidateSource] = useState<"parts-catalogs" | "emex" | null>(null);
const [selectLoading, setSelectLoading] = useState(false);
// Live preview state
@@ -149,13 +150,15 @@ function SearchPage() {
try {
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
// Handle multiple vehicle candidates (PartsCatalogs)
// Handle multiple vehicle candidates (PartsCatalogs or EMEX)
if (data.candidates && Array.isArray(data.candidates)) {
setCandidates(data.candidates);
setCandidateVin(cleanVin);
setCandidateSource(data.source ?? "parts-catalogs");
capture("vin_decode_candidates", {
vin: cleanVin,
count: data.candidates.length,
source: data.source,
});
setLoading(false);
return;
@@ -209,19 +212,24 @@ function SearchPage() {
}
}
async function handleCandidateSelect(pcatCarId: string) {
async function handleCandidateSelect(carId: string) {
setSelectLoading(true);
try {
const data = await api.post<any>("/vehicles/decode", {
vin: candidateVin,
pcatCarId,
});
const payload: Record<string, unknown> = { vin: candidateVin };
if (candidateSource === "emex") {
payload.emexCarIndex = parseInt(carId, 10);
} else {
payload.pcatCarId = carId;
}
const data = await api.post<any>("/vehicles/decode", payload);
capture("vin_decode_candidate_selected", {
vin: candidateVin,
pcatCarId,
source: candidateSource,
carId,
vehicle_id: data.id,
});
setCandidates(null);
setCandidateSource(null);
navigate({
to: "/dashboard/vehicles/$id",
params: { id: data.id },
@@ -233,6 +241,7 @@ function SearchPage() {
: "Bir hata oluştu. Lütfen tekrar deneyin.";
setError(message);
setCandidates(null);
setCandidateSource(null);
toast.error("Araç seçimi başarısız");
} finally {
setSelectLoading(false);
@@ -474,7 +483,7 @@ function SearchPage() {
</div>
)}
{/* ─── Vehicle Selection Modal (PartsCatalogs multi-result) ──── */}
{/* ─── Vehicle Selection Modal (PartsCatalogs / EMEX multi-result) ──── */}
{candidates && (
<VehicleSelectModal
open={!!candidates}