Files
sase.tr/apps/api/src/integrations/emex/emex.mapper.ts
Semih Yesilyurt 223c4bc12f
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
fix(pl24): correct PSA VIN decode via FI/VIN-indexed flow + cycle-correct model year
PSA (Peugeot/Citroën/DS) VIN decode was systemically broken: the catalog
vin-group page returns all families unfiltered, so decode fell back to the first
family/salesType (a manual base variant) — yielding "{Brand} {VIN}" model names,
empty transmission, wrong model year, and manual-only parts trees (automatic
gearbox parts missing). Reported for a 1999 Peugeot 106 automatic shown as a 2029
manual with no automatic parts.

- New self-contained PL24PsaService: consumes PL24's FI flow (vin.action →
  hintstoken → FI page → json-vin-main-groups → json-vin-illustrations →
  vin-image-board). Reads model/year/transmission from the FI identification
  table; builds the VIN-indexed parts tree (correct per actual VIN). Does not
  touch Ford/Volvo/Nissan/Opel/Hyundai-Kia/Fiat.
- Orchestrator + categories.service route PSA VIN decode/drill to the new service.
- Cycle-correct extractModelYear in @sase/shared (X→1999, not 2029): resolve the
  30-yr VIN year code to the most-recent plausible year (≤ now+1); dedupe 6 copies.

Validated live against 13 already-decoded PSA VINs: 12/13 full trees with real
model/year/transmission; automatics correctly detected (106 BVA, 206 AL4, 3008 BVA8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:27:04 +03:00

276 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* EMEX Response Mapper
*
* Transforms raw EmexVinScraper responses into the standardized DecodedVehicle
* format. Vehicle attribute translations (body type, engine type, transmission,
* drive type) live here. Category and part name translations now live in
* TranslationsService (apps/api/src/translations/translations.service.ts) —
* mapCategories returns nameTr=null and the insertion path
* (categories.service.ts) calls translationsService.translateMany() before
* persisting.
*/
import { extractModelYear } from "@sase/shared";
import {
CATALOG_MAP,
type DecodedCategory,
type DecodedVehicle,
type EmexCategory,
type EmexScraperResponse,
} from "./emex.types";
// ==================== VEHICLE ATTRIBUTE TRANSLATIONS ====================
const TR_TRANSLATIONS = {
// Body types
bodyTypes: {
sedan: "Sedan",
coupe: "Coupe",
hatchback: "Hatchback",
wagon: "Station Wagon",
"station wagon": "Station Wagon",
estate: "Station Wagon",
convertible: "Ustu Acik",
cabriolet: "Kabriyole",
suv: "SUV",
crossover: "Crossover",
pickup: "Pikap",
van: "Minivan",
minivan: "Minivan",
mpv: "Cok Amacli Arac",
roadster: "Roadster",
} as Record<string, string>,
engineTypes: {
gasoline: "Benzin",
petrol: "Benzin",
benzin: "Benzin",
diesel: "Dizel",
electric: "Elektrik",
hybrid: "Hibrit",
"plug-in hybrid": "Sarjli Hibrit",
phev: "Sarjli Hibrit",
lpg: "LPG",
cng: "CNG",
hydrogen: "Hidrojen",
} as Record<string, string>,
transmissions: {
automatic: "Otomatik",
manual: "Manuel",
"semi-automatic": "Yari Otomatik",
dct: "Cift Kavramali",
cvt: "CVT",
"dual clutch": "Cift Kavramali",
dsg: "DSG",
tiptronic: "Tiptronic",
steptronic: "Steptronic",
at: "Otomatik",
mt: "Manuel",
} as Record<string, string>,
driveTypes: {
fwd: "Ondan Cekis",
rwd: "Arkadan Itis",
awd: "Dort Ceker",
"4wd": "Dort Ceker",
"4x4": "Dort Ceker",
"front-wheel drive": "Ondan Cekis",
"rear-wheel drive": "Arkadan Itis",
"all-wheel drive": "Dort Ceker",
quattro: "Quattro (Dort Ceker)",
xdrive: "xDrive (Dort Ceker)",
"4matic": "4MATIC (Dort Ceker)",
} as Record<string, string>,
};
// ==================== TRANSLATION HELPERS ====================
function translateToTurkish(
term: string | null | undefined,
dictionary: Record<string, string>,
): string | null {
if (!term) return null;
const normalized = term.toLowerCase().trim();
// Dictionary miss → return original term unchanged. Upstream values are
// often heterogeneous (engine codes, multi-word descriptors) that the
// single-word dictionary can't cover; losing them would be worse than
// leaving them untranslated.
return dictionary[normalized] ?? term;
}
export function translateBodyType(bodyType: string | null): string | null {
return translateToTurkish(bodyType, TR_TRANSLATIONS.bodyTypes);
}
export function translateEngineType(engineType: string | null): string | null {
return translateToTurkish(engineType, TR_TRANSLATIONS.engineTypes);
}
export function translateTransmission(transmission: string | null): string | null {
return translateToTurkish(transmission, TR_TRANSLATIONS.transmissions);
}
export function translateDriveType(driveType: string | null): string | null {
return translateToTurkish(driveType, TR_TRANSLATIONS.driveTypes);
}
// ==================== MAPPER FUNCTIONS ====================
/**
* Maps raw EMEX scraper response to standardized DecodedVehicle
*/
export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
const vehicle = response.vehicle;
// Get brand from catalog map or use the one from response
const wmi = response.vin.substring(0, 3);
const catalogEntry = CATALOG_MAP[wmi];
const brand = catalogEntry?.brand || vehicle.brand || "Unknown";
return {
brand: brand.toUpperCase(),
model: vehicle.model || "Unknown",
year: vehicle.year || extractYearFromVin(response.vin),
series: vehicle.series || null,
bodyType: vehicle.bodyType || null,
engineCode: vehicle.engineCode || null,
engineType: vehicle.engineType || null,
engineVolume: vehicle.engineVolume || null,
transmission: vehicle.transmission || null,
driveType: vehicle.driveType || null,
colorCode: null, // EMEX doesn't provide color info
raw: buildRawResponse(response),
categories: mapCategories(response.categories),
};
}
/**
* Extracts year from VIN (10th character)
*/
function extractYearFromVin(vin: string): number {
// Servis yıl vermediğinde son çare; bilinmeyen kod için içinde bulunulan yıl.
return extractModelYear(vin) ?? new Date().getFullYear();
}
/**
* Builds the raw response object for storage
* Includes category URLs for on-demand parts fetching
*/
function buildRawResponse(response: EmexScraperResponse): Record<string, unknown> {
return {
source: "emex", // Explicit source identifier for on-demand loading
method: response.method,
vin: response.vin,
catalogCode: response.catalogCode,
ssd: response.ssd,
quickGroupsUrl: response.quickGroupsUrl,
timestamp: response.timestamp,
success: response.success,
message: response.message,
parsedOptions: response.parsedOptions,
rawResponse: response.rawResponse,
// Store category tree for hierarchical insertion (QuickGroups.aspx)
emexCategoryTree: response.categoryTree || [],
// Store flat category URLs for on-demand parts fetching (fallback)
emexCategories:
response.categories?.map((cat) => ({
gid: cat.gid,
name: cat.name,
url: cat.url,
})) || [],
};
}
/**
* Maps EMEX categories to standardized DecodedCategory format.
* nameTr is left null on purpose — the consumer (categories.service.ts)
* runs nameEn through TranslationsService.translateMany() before insert.
* Parts are NOT included here; they are fetched on-demand when the user
* clicks a category.
*/
function mapCategories(categories?: EmexCategory[]): DecodedCategory[] {
if (!categories || categories.length === 0) {
return [];
}
return categories.map((cat, index) => {
return {
code: cat.gid || `CAT_${index}`,
nameEn: cat.name,
nameTr: undefined,
description: null,
iconName: deriveIconName(cat.name),
schemaImageUrl: null,
parts: [], // Parts will be fetched on-demand
};
});
}
/**
* Derives icon name from category name
*/
function deriveIconName(categoryName: string): string | null {
const normalized = categoryName.toLowerCase();
const iconMap: Record<string, string> = {
engine: "engine",
motor: "engine",
brake: "brake",
brakes: "brake",
suspension: "suspension",
steering: "steering",
transmission: "transmission",
gearbox: "transmission",
exhaust: "exhaust",
cooling: "cooling",
electrical: "electrical",
interior: "interior",
exterior: "exterior",
body: "body",
lighting: "lighting",
lights: "lighting",
wheels: "wheels",
fuel: "fuel",
air: "air",
climate: "climate",
filters: "filters",
};
for (const [key, icon] of Object.entries(iconMap)) {
if (normalized.includes(key)) {
return icon;
}
}
return null;
}
/**
* Creates an empty/default DecodedVehicle for error cases
*/
export function createEmptyDecodedVehicle(vin: string, errorMessage?: string): DecodedVehicle {
const wmi = vin.substring(0, 3);
const catalogEntry = CATALOG_MAP[wmi];
return {
brand: catalogEntry?.brand?.toUpperCase() || "UNKNOWN",
model: "Unknown",
year: extractYearFromVin(vin),
series: null,
bodyType: null,
engineCode: null,
engineType: null,
engineVolume: null,
transmission: null,
driveType: null,
colorCode: null,
raw: {
vin,
error: errorMessage || "Vehicle data not found",
source: "emexdwc.ae",
},
categories: [],
};
}