fix(api): emex source-db lookup uses catalogCode+gid, not SSD
Initial design routed emex lookups through vehicles.rawData.ssd → dump vehicles → vehicle_parts. Smoke test against prod ssd values: 0 / 10 matched. EMEX regenerates the SSD on every decode session, so sase's stored SSD never matches the SSD the dump scraper recorded for the same physical vehicle. Pivot to a catalog-wide bridge that actually works: catalogs.code ↔ vehicles.rawData.catalogCode (e.g. "RENAULT201910") part_groups.group_id ↔ categories.externalId (e.g. "11754") → parts via vehicle_parts.group_id (dump's parts.group_id is 100% NULL) Verified coverage on prod's 8287 unique (catalogCode, gid) pairs: 25/26 catalog codes resolve, 7178 pairs hit a part_group (87%), 5919 of those return actual parts via vehicle_parts (~71% net). Tradeoff: returns all parts in the (catalog, group) across every variant in the catalog, so the result is slightly noisier than the live per-vehicle scrape. Acceptable — parts overlap heavily and the upstream-call savings outweigh the noise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1092,16 +1092,19 @@ export class CategoriesService {
|
||||
} else if (vehicle && category.source === "emex") {
|
||||
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
|
||||
try {
|
||||
// Local dump first (uses vehicles.rawData.ssd persisted by the
|
||||
// emex mapper). Falls back to live upstream on miss.
|
||||
const emexSsd = (vehicle.rawData as { ssd?: string } | null)?.ssd;
|
||||
// Local dump first (catalog-wide via vehicles.rawData.catalogCode +
|
||||
// category.externalId). The dump's per-vehicle bridge is the EMEX
|
||||
// SSD, which is session-regenerated and never matches what sase
|
||||
// stored — so we accept over-return at the catalog level (~71% hit).
|
||||
// Falls back to live upstream on miss.
|
||||
const catalogCode = (vehicle.rawData as { catalogCode?: string } | null)?.catalogCode;
|
||||
let emexResult = await this.emexSourceDb.fetchCategoryParts(
|
||||
emexSsd,
|
||||
category.linkPath ?? "",
|
||||
catalogCode,
|
||||
category.externalId,
|
||||
);
|
||||
if (emexResult) {
|
||||
this.logger.debug(
|
||||
`[source-db hit emex] ssd=${emexSsd?.slice(0, 12)}... gid=${category.externalId}`,
|
||||
`[source-db hit emex] catalog=${catalogCode} gid=${category.externalId}`,
|
||||
);
|
||||
} else {
|
||||
emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
|
||||
|
||||
@@ -4,16 +4,22 @@ import mysql, { type Pool, type RowDataPacket } from "mysql2/promise";
|
||||
import type { EmexHotspot, EmexHotspotArea, EmexPart, EmexPartsResult } from "../emex/emex.types";
|
||||
|
||||
/**
|
||||
* Look up a vehicle's parts + schema for a category (PNC group) in the local
|
||||
* EMEX dump (sase-catalog-src-emex MariaDB). Returns null on any miss so the
|
||||
* caller falls through to the live emex scrape.
|
||||
* Look up parts + schema for an EMEX category in the local dump
|
||||
* (sase-catalog-src-emex MariaDB). Returns null on any miss so the caller
|
||||
* falls through to the live emex scrape.
|
||||
*
|
||||
* Inputs:
|
||||
* - vehicleSsd: emex's per-vehicle session-state-descriptor — captured into
|
||||
* `vehicles.rawData.emexSsd` during live VIN decode. Required (no VIN
|
||||
* column in the dump; SSD is the only stable vehicle identifier).
|
||||
* - categoryUrl: the QuickDetails.aspx URL stored in `categories.linkPath`.
|
||||
* We parse `gid` (the group id) out of it.
|
||||
* Bridge: catalogs.code ↔ sase's vehicles.rawData.catalogCode,
|
||||
* part_groups.group_id (varchar) ↔ sase's categories.externalId (gid).
|
||||
*
|
||||
* Why catalog-wide (no per-vehicle filter): the dump's only stable per-vehicle
|
||||
* key is the EMEX session-state-descriptor (`ssd`), but SSD is regenerated
|
||||
* every decode session, so the SSD sase stored for its decoded vehicle never
|
||||
* matches the SSD the dump scraper recorded (0 / 10 sampled — verified). The
|
||||
* vehicle_id we'd need to join `vehicle_parts` is unreachable. So we return
|
||||
* all parts in (catalog, group) across every vehicle in the dump for that
|
||||
* catalog — over-returns variants that may not apply to the user's specific
|
||||
* vehicle, but parts overlap heavily and the cost is a slightly noisier list.
|
||||
* Hits: ~71% of sase's 8287 (catalogCode, gid) pairs (verified 2026-06-01).
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -50,45 +56,41 @@ export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the live `EmexCatalogService.fetchCategoryParts` shape.
|
||||
* Mirror the live `EmexService.fetchCategoryParts` shape.
|
||||
* `catalogCode` = vehicles.rawData.catalogCode (e.g. "RENAULT201910"),
|
||||
* `gid` = categories.externalId (e.g. "11754").
|
||||
* Returns null on miss; never throws.
|
||||
*/
|
||||
async fetchCategoryParts(
|
||||
vehicleSsd: string | null | undefined,
|
||||
categoryUrl: string,
|
||||
catalogCode: string | null | undefined,
|
||||
gid: string | null | undefined,
|
||||
): Promise<EmexPartsResult | null> {
|
||||
if (!this.enabled || !this.pool) return null;
|
||||
if (!vehicleSsd) return null; // no SSD captured → can't locate vehicle in dump
|
||||
const gid = extractGid(categoryUrl);
|
||||
if (!gid) return null;
|
||||
if (!catalogCode || !gid) return null;
|
||||
|
||||
try {
|
||||
// 1) Resolve vehicle.id from SSD. We try `ssd = ?` first; if the dump
|
||||
// canonicalized into `unique_key` (hash), the second arm catches it.
|
||||
const [vehicleRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
"SELECT id, catalog_id FROM vehicles WHERE ssd = ? OR unique_key = ? LIMIT 1",
|
||||
[vehicleSsd, vehicleSsd],
|
||||
);
|
||||
if (vehicleRows.length === 0) return null;
|
||||
const vehicleId = vehicleRows[0].id as number;
|
||||
const catalogId = vehicleRows[0].catalog_id as number;
|
||||
|
||||
// 2) Resolve part_group.id from external gid scoped to this catalog.
|
||||
// Resolve (catalog.id, part_group.id) in one round trip.
|
||||
const [groupRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
"SELECT id FROM part_groups WHERE catalog_id = ? AND group_id = ? LIMIT 1",
|
||||
[catalogId, gid],
|
||||
`SELECT pg.id AS pg_id, pg.catalog_id
|
||||
FROM catalogs c
|
||||
JOIN part_groups pg ON pg.catalog_id = c.id
|
||||
WHERE c.code = ? AND pg.group_id = ?
|
||||
LIMIT 1`,
|
||||
[catalogCode, gid],
|
||||
);
|
||||
if (groupRows.length === 0) return null;
|
||||
const groupPk = groupRows[0].id as number;
|
||||
const groupPk = groupRows[0].pg_id as number;
|
||||
|
||||
// 3) Parts for this vehicle in this group.
|
||||
// Parts: catalog-wide via vehicle_parts (no vehicle filter — see class
|
||||
// comment). DISTINCT collapses the same part being linked from many
|
||||
// vehicles in the catalog.
|
||||
const [partRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT p.id AS part_id, p.part_number, p.name, p.position_number, p.pnc
|
||||
`SELECT DISTINCT p.id AS part_id, p.part_number, p.name, p.position_number, p.pnc
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id = vp.part_id
|
||||
WHERE vp.vehicle_id = ? AND vp.group_id = ?
|
||||
WHERE vp.group_id = ?
|
||||
ORDER BY p.position_number, p.id`,
|
||||
[vehicleId, groupPk],
|
||||
[groupPk],
|
||||
);
|
||||
|
||||
const parts: EmexPart[] = partRows.map((r) => ({
|
||||
@@ -97,11 +99,11 @@ export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
positionCode: r.position_number ?? r.pnc ?? undefined,
|
||||
}));
|
||||
|
||||
// 4) Schema image + hotspots for this group.
|
||||
// Schema image + hotspots for this group.
|
||||
const [imgRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT original_url, width, height, hotspots
|
||||
FROM part_images
|
||||
WHERE group_id = ? AND image_type IN ('DIAGRAM','SCHEMATIC')
|
||||
WHERE group_id = ? AND image_type IN ('DIAGRAM', 'SCHEMATIC')
|
||||
ORDER BY is_primary DESC, sort_order
|
||||
LIMIT 1`,
|
||||
[groupPk],
|
||||
@@ -135,24 +137,14 @@ export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
return { parts, schemaImageUrl, hotspots, schemaWidth, schemaHeight };
|
||||
} catch (err) {
|
||||
this.logger.warn(`[emex-src] lookup failed (gid=${gid}): ${(err as Error).message}`);
|
||||
this.logger.warn(
|
||||
`[emex-src] lookup failed (${catalogCode}/${gid}): ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract `gid=...` from a QuickDetails.aspx / similar URL. */
|
||||
function extractGid(url: string): string | null {
|
||||
if (!url) return null;
|
||||
const m = url.match(/[?&]gid=([^&#]+)/i);
|
||||
if (!m) return null;
|
||||
try {
|
||||
return decodeURIComponent(m[1]);
|
||||
} catch {
|
||||
return m[1];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the dump's hotspots JSON ([{x,y,w,h,part_id}]) into the EmexHotspot
|
||||
* shape used by the live scraper (grouped by position code).
|
||||
|
||||
Reference in New Issue
Block a user