fix(emex): improve wizard vehicle matching with model-based fuzzy search
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

- Backend: add `model` query param to wizard-vehicles endpoint
- Backend: 3-tier matching: exact name → name prefix → model fuzzy
- Frontend: simplified wizard UI, show determined params as tags

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 09:50:52 +00:00
parent 184bf4741e
commit 4e838fdace
3 changed files with 107 additions and 125 deletions

View File

@@ -27,8 +27,9 @@ export class EmexCatalogController {
getWizardVehicles( getWizardVehicles(
@Param("code") code: string, @Param("code") code: string,
@Query("name") name: string, @Query("name") name: string,
@Query("model") model?: string,
) { ) {
return this.emexCatalogService.getWizardVehicles(code, name); return this.emexCatalogService.getWizardVehicles(code, name, model);
} }
@Get("vehicles/:id/groups") @Get("vehicles/:id/groups")

View File

@@ -312,11 +312,14 @@ export class EmexCatalogService {
} }
/** /**
* Find vehicles in our DB that match a wizard "Sales Designation" name. * Find vehicles in our DB that match a wizard selection.
* Called after the user completes the wizard and picks a specific vehicle name. * Tries: exact name match → name prefix → model-based fuzzy match.
* `name` is the "Sales Designation" from the wizard.
* `model` is the "Model" parameter from the wizard (optional).
*/ */
async getWizardVehicles(catalogCode: string, name: string): Promise<EmexVehicleDto[]> { async getWizardVehicles(catalogCode: string, name: string, model?: string): Promise<EmexVehicleDto[]> {
const cacheKey = `emex:wv:${catalogCode}:${Buffer.from(name).toString("base64url").slice(0, 32)}`; const hashInput = `${name}|${model || ""}`;
const cacheKey = `emex:wv:${catalogCode}:${Buffer.from(hashInput).toString("base64url").slice(0, 40)}`;
const cached = await this.redis.getJson<EmexVehicleDto[]>(cacheKey); const cached = await this.redis.getJson<EmexVehicleDto[]>(cacheKey);
if (cached) return cached; if (cached) return cached;
@@ -328,59 +331,62 @@ export class EmexCatalogService {
if (!catalog) return []; if (!catalog) return [];
// Exact match on name const vehicleCols = {
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,
};
// 1. Exact match on Sales Designation name
let rows = await this.db let rows = await this.db
.select({ .select(vehicleCols)
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) .from(emexVehicles)
.where( .where(and(eq(emexVehicles.catalogId, catalog.id), eq(emexVehicles.name, name)))
and(
eq(emexVehicles.catalogId, catalog.id),
eq(emexVehicles.name, name),
),
)
.orderBy(emexVehicles.optionsRaw); .orderBy(emexVehicles.optionsRaw);
// Partial match fallback: search by prefix // 2. Prefix match on name
if (rows.length === 0) { if (rows.length === 0) {
rows = await this.db rows = await this.db
.select({ .select(vehicleCols)
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) .from(emexVehicles)
.where( .where(and(eq(emexVehicles.catalogId, catalog.id), ilike(emexVehicles.name, `${name}%`)))
and(
eq(emexVehicles.catalogId, catalog.id),
ilike(emexVehicles.name, `${name}%`),
),
)
.orderBy(emexVehicles.optionsRaw) .orderBy(emexVehicles.optionsRaw)
.limit(100); .limit(100);
} }
// 3. Model-based fuzzy match: extract short prefix from model name
// e.g. model="A3 Cabriolet" → search for names containing "A3 Cab"
if (rows.length === 0 && model) {
// Build search patterns from model name
// "A3 Cabriolet" → try "A3 Cab", "A3 Cabrio", "A3"
const words = model.split(/[\s/]+/);
const patterns: string[] = [];
if (words.length >= 2) {
patterns.push(`%${words[0]} ${words[1].slice(0, 3)}%`);
patterns.push(`%${words[0]} ${words[1].slice(0, 6)}%`);
}
patterns.push(`%${words[0]}%`);
for (const pattern of patterns) {
rows = await this.db
.select(vehicleCols)
.from(emexVehicles)
.where(and(eq(emexVehicles.catalogId, catalog.id), ilike(emexVehicles.name, pattern)))
.orderBy(emexVehicles.optionsRaw)
.limit(100);
if (rows.length > 0) break;
}
}
if (rows.length > 0) { if (rows.length > 0) {
await this.redis.setJson(cacheKey, rows, CACHE_TTL.vehicles); await this.redis.setJson(cacheKey, rows, CACHE_TTL.vehicles);
} }

View File

@@ -55,10 +55,6 @@ function EmexVehicleListPage() {
// Current SSD state for wizard navigation // Current SSD state for wizard navigation
const [ssd, setSsd] = useState(""); const [ssd, setSsd] = useState("");
// History of SSD selections for display
const [selections, setSelections] = useState<
{ name: string; value: string; ssd: string }[]
>([]);
// Fetch wizard data for current SSD // Fetch wizard data for current SSD
const { const {
@@ -79,18 +75,20 @@ function EmexVehicleListPage() {
[wizardRows], [wizardRows],
); );
const undetermined = useMemo( const undetermined = useMemo(
() => wizardRows?.filter((r) => !r.determined && r.options?.length > 0) ?? [], () =>
wizardRows?.filter((r) => !r.determined && r.options?.length > 0) ?? [],
[wizardRows], [wizardRows],
); );
const allDetermined = wizardRows const allDetermined = wizardRows
? wizardRows.length > 0 && undetermined.length === 0 ? wizardRows.length > 0 && undetermined.length === 0
: false; : false;
// Get the "Sales Designation" or "Name" from determined params // Get the "Sales Designation" and "Model" from determined params
// to match against DB vehicles const wizardMatch = useMemo(() => {
const salesDesignation = useMemo(() => {
if (!allDetermined || !determined.length) return null; if (!allDetermined || !determined.length) return null;
// Try known name fields let salesDesignation: string | null = null;
let model: string | null = null;
for (const key of [ for (const key of [
"Sales Designation", "Sales Designation",
"Name", "Name",
@@ -98,46 +96,49 @@ function EmexVehicleListPage() {
"Model name", "Model name",
]) { ]) {
const row = determined.find((r) => r.name === key); const row = determined.find((r) => r.name === key);
if (row?.value && row.value !== "None") return row.value; if (row?.value && row.value !== "None") {
salesDesignation = row.value;
break;
}
} }
return null; const modelRow = determined.find((r) => r.name === "Model");
if (modelRow?.value && modelRow.value !== "None") {
model = modelRow.value;
}
const name = salesDesignation || model;
if (!name) return null;
return { name, model };
}, [allDetermined, determined]); }, [allDetermined, determined]);
// When all wizard params are determined, search DB for matching vehicles // When all wizard params are determined, search DB for matching vehicles
const { data: matchedVehicles, isLoading: matchLoading } = useQuery({ const { data: matchedVehicles, isLoading: matchLoading } = useQuery({
queryKey: ["emex-wizard-vehicles", catalogCode, salesDesignation], queryKey: [
queryFn: () => "emex-wizard-vehicles",
api.get<EmexVehicle[]>( catalogCode,
`/catalog/emex/brands/${catalogCode}/wizard-vehicles?name=${encodeURIComponent(salesDesignation!)}`, wizardMatch?.name,
), wizardMatch?.model,
enabled: !!salesDesignation, ],
queryFn: () => {
const params = new URLSearchParams({ name: wizardMatch!.name });
if (wizardMatch!.model) params.set("model", wizardMatch!.model);
return api.get<EmexVehicle[]>(
`/catalog/emex/brands/${catalogCode}/wizard-vehicles?${params}`,
);
},
enabled: !!wizardMatch,
}); });
// Handle wizard option selection // Handle wizard option selection — navigate to new SSD
const handleSelect = useCallback( const handleSelect = useCallback((_rowName: string, option: WizardOption) => {
(rowName: string, option: WizardOption) => { setSsd(option.key);
setSelections((prev) => [...prev, { name: rowName, value: option.value, ssd }]); }, []);
setSsd(option.key);
},
[ssd],
);
// Reset wizard to initial state // Reset wizard to initial state
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setSsd(""); setSsd("");
setSelections([]);
}, []); }, []);
// Go back to a previous step
const handleBack = useCallback(
(stepIndex: number) => {
const target = selections[stepIndex];
setSsd(target.ssd);
setSelections((prev) => prev.slice(0, stepIndex));
},
[selections],
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
@@ -151,7 +152,7 @@ function EmexVehicleListPage() {
<h1 className="text-xl font-bold"> <h1 className="text-xl font-bold">
{decodeURIComponent(catalogCode)} {decodeURIComponent(catalogCode)}
</h1> </h1>
{selections.length > 0 && ( {determined.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleReset}> <Button variant="ghost" size="sm" onClick={handleReset}>
<RotateCcw className="mr-1 size-3.5" /> <RotateCcw className="mr-1 size-3.5" />
{t("catalog.resetSelection")} {t("catalog.resetSelection")}
@@ -159,24 +160,18 @@ function EmexVehicleListPage() {
)} )}
</div> </div>
{/* Breadcrumb: show selected wizard steps */} {/* Determined params — shown as tags */}
{selections.length > 0 && ( {determined.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm"> <div className="flex flex-wrap gap-2">
{selections.map((sel, i) => ( {determined.map((r) => (
<span key={i} className="flex items-center gap-1"> <span
<button key={r.name}
type="button" className="rounded-md border border-border bg-muted/50 px-2 py-1 text-xs"
onClick={() => handleBack(i)} >
className="rounded px-1.5 py-0.5 text-muted-foreground hover:bg-accent hover:text-foreground" <span className="text-muted-foreground">{r.name}:</span>{" "}
> {r.value}
{sel.value}
</button>
<ChevronRight className="size-3 text-muted-foreground" />
</span> </span>
))} ))}
{allDetermined && salesDesignation && (
<span className="font-medium">{salesDesignation}</span>
)}
</div> </div>
)} )}
@@ -191,40 +186,20 @@ function EmexVehicleListPage() {
{/* Loading */} {/* Loading */}
{wizardLoading && ( {wizardLoading && (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" /> <Skeleton key={i} className="h-10 w-full rounded-lg" />
))} ))}
</div> </div>
)} )}
{/* Wizard: show undetermined rows as selection lists */} {/* Wizard: show first undetermined row as clickable list */}
{!wizardLoading && !wizardError && !allDetermined && undetermined.length > 0 && ( {!wizardLoading && !wizardError && !allDetermined && undetermined.length > 0 && (
<WizardStep <WizardStep row={undetermined[0]} onSelect={handleSelect} />
row={undetermined[0]}
onSelect={handleSelect}
/>
)} )}
{/* All determined: show matched vehicles from DB */} {/* All determined: show matched vehicles from DB */}
{allDetermined && ( {allDetermined && (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Seçilen araç parametreleri
</p>
<div className="flex flex-wrap gap-2">
{determined.map((r) => (
<span
key={r.name}
className="rounded-md bg-background px-2 py-1 text-xs"
>
<span className="text-muted-foreground">{r.name}:</span>{" "}
{r.value}
</span>
))}
</div>
</div>
{matchLoading ? ( {matchLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
@@ -258,7 +233,7 @@ function EmexVehicleListPage() {
</div> </div>
)} )}
{/* Empty state: no wizard rows */} {/* Empty state: no wizard rows at all */}
{!wizardLoading && {!wizardLoading &&
!wizardError && !wizardError &&
wizardRows && wizardRows &&
@@ -297,7 +272,7 @@ function WizardStep({
</span> </span>
</div> </div>
{row.options.length > 8 && ( {row.options.length > 10 && (
<input <input
type="text" type="text"
value={search} value={search}