feat(emex): wizard-based vehicle selection via GetWizard2 API
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

Replace static DB-based dropdown chain with live wizard from emexdwc.ae.
The wizard API (GetWizard2) works without auth and shows all models/options
for each catalog (e.g. AU1587 now shows 103 Audi models vs 11 before).

Backend: proxy GetWizard2 + search DB for matching vehicles after wizard completes.
Frontend: step-by-step wizard with breadcrumb navigation, search, and reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 09:43:08 +00:00
parent e7b1350876
commit 184bf4741e
6 changed files with 887 additions and 825 deletions

View File

@@ -15,6 +15,22 @@ export class EmexCatalogController {
return this.emexCatalogService.getVehicles(code);
}
@Get("brands/:code/wizard")
getWizard(
@Param("code") code: string,
@Query("ssd") ssd?: string,
) {
return this.emexCatalogService.getWizard(code, ssd || "");
}
@Get("brands/:code/wizard-vehicles")
getWizardVehicles(
@Param("code") code: string,
@Query("name") name: string,
) {
return this.emexCatalogService.getWizardVehicles(code, name);
}
@Get("vehicles/:id/groups")
getVehicleGroups(@Param("id") id: string) {
return this.emexCatalogService.getVehicleGroups(id);

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { Inject, Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { eq, and, sql, ilike, or } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import {
@@ -272,6 +272,121 @@ export class EmexCatalogService {
return result;
}
// ── Wizard (proxy emexdwc.ae GetWizard2) ─────────────
private static readonly EMEX_BASE = "https://emexdwc.ae";
private static readonly EMEX_HDR: Record<string, string> = {
Accept: "application/json",
Referer: "https://emexdwc.ae/CatalogParamSearch.aspx",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
};
async getWizard(catalogCode: string, ssd: string): Promise<unknown> {
const cacheKey = `emex:wizard:${catalogCode}:${ssd || "_root"}`;
const cached = await this.redis.getJson(cacheKey);
if (cached) return cached;
const params = new URLSearchParams({
catalogCode,
ssd,
_tstamp: String(Date.now()),
});
const url = `${EmexCatalogService.EMEX_BASE}/api/Catalog.svc/GetWizard2?${params}`;
try {
const res = await fetch(url, {
headers: EmexCatalogService.EMEX_HDR,
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
throw new Error(`EMEX wizard HTTP ${res.status}`);
}
const data = await res.json();
await this.redis.setJson(cacheKey, data, 3600); // 1h cache
return data;
} catch (err) {
this.logger.error(`getWizard failed: ${(err as Error).message}`);
throw new ServiceUnavailableException("EMEX wizard servisi kulanilamiyor");
}
}
/**
* Find vehicles in our DB that match a wizard "Sales Designation" name.
* Called after the user completes the wizard and picks a specific vehicle name.
*/
async getWizardVehicles(catalogCode: string, name: string): Promise<EmexVehicleDto[]> {
const cacheKey = `emex:wv:${catalogCode}:${Buffer.from(name).toString("base64url").slice(0, 32)}`;
const cached = await this.redis.getJson<EmexVehicleDto[]>(cacheKey);
if (cached) return cached;
const [catalog] = await this.db
.select({ id: emexCatalogs.id })
.from(emexCatalogs)
.where(eq(emexCatalogs.catalogId, catalogCode))
.limit(1);
if (!catalog) return [];
// Exact match on name
let rows = await this.db
.select({
id: emexVehicles.id,
vehicleId: emexVehicles.vehicleId,
name: emexVehicles.name,
engine: emexVehicles.engine,
engineCode: emexVehicles.engineCode,
bodyType: emexVehicles.bodyType,
transmission: emexVehicles.transmission,
driveType: emexVehicles.driveType,
fuelType: emexVehicles.fuelType,
yearFrom: emexVehicles.yearFrom,
yearTo: emexVehicles.yearTo,
optionsRaw: emexVehicles.optionsRaw,
})
.from(emexVehicles)
.where(
and(
eq(emexVehicles.catalogId, catalog.id),
eq(emexVehicles.name, name),
),
)
.orderBy(emexVehicles.optionsRaw);
// Partial match fallback: search by prefix
if (rows.length === 0) {
rows = await this.db
.select({
id: emexVehicles.id,
vehicleId: emexVehicles.vehicleId,
name: emexVehicles.name,
engine: emexVehicles.engine,
engineCode: emexVehicles.engineCode,
bodyType: emexVehicles.bodyType,
transmission: emexVehicles.transmission,
driveType: emexVehicles.driveType,
fuelType: emexVehicles.fuelType,
yearFrom: emexVehicles.yearFrom,
yearTo: emexVehicles.yearTo,
optionsRaw: emexVehicles.optionsRaw,
})
.from(emexVehicles)
.where(
and(
eq(emexVehicles.catalogId, catalog.id),
ilike(emexVehicles.name, `${name}%`),
),
)
.orderBy(emexVehicles.optionsRaw)
.limit(100);
}
if (rows.length > 0) {
await this.redis.setJson(cacheKey, rows, CACHE_TTL.vehicles);
}
return rows;
}
async searchByOem(query: string): Promise<EmexSearchResult[]> {
if (!query || query.length < 3) return [];