Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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>
276 lines
7.8 KiB
TypeScript
276 lines
7.8 KiB
TypeScript
/**
|
||
* 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: [],
|
||
};
|
||
}
|