fix(emex): improve wizard vehicle matching with model-based fuzzy search
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
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:
@@ -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")
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user