feat: Add fuzzy matching to VIN decode for partial model/trim names (refs #9)
Some checks failed
Deploy to Staging / Build Images (pull_request) Failing after 3m1s
Deploy to Staging / Deploy to Staging (pull_request) Has been skipped
Deploy to Staging / Verify Staging (pull_request) Has been skipped
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s
Some checks failed
Deploy to Staging / Build Images (pull_request) Failing after 3m1s
Deploy to Staging / Deploy to Staging (pull_request) Has been skipped
Deploy to Staging / Verify Staging (pull_request) Has been skipped
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s
Backend: Enhanced matchField function with prefix and contains matching so NHTSA values like "Sierra" match dropdown options like "Sierra 1500". Matching hierarchy: 1. Exact match (case-insensitive) -> high confidence 2. Normalized match (remove special chars) -> medium confidence 3. Prefix match (option starts with value) -> medium confidence (NEW) 4. Contains match (option contains value) -> medium confidence (NEW) Frontend: Fixed VIN decode form population by loading dropdown options before setting form values, preventing cascade useEffects from clearing decoded values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -441,8 +441,9 @@ export class VehiclesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Match a value against dropdown options using case-insensitive exact matching
|
* Match a value against dropdown options using fuzzy matching
|
||||||
* Returns the matched dropdown value with confidence level
|
* Returns the matched dropdown value with confidence level
|
||||||
|
* Matching order: exact -> normalized -> prefix -> contains
|
||||||
*/
|
*/
|
||||||
private matchField(nhtsaValue: string, options: string[]): MatchedField<string> {
|
private matchField(nhtsaValue: string, options: string[]): MatchedField<string> {
|
||||||
if (!nhtsaValue || options.length === 0) {
|
if (!nhtsaValue || options.length === 0) {
|
||||||
@@ -466,6 +467,18 @@ export class VehiclesService {
|
|||||||
return { value: normalizedMatch, nhtsaValue, confidence: 'medium' };
|
return { value: normalizedMatch, nhtsaValue, confidence: 'medium' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try prefix match - option starts with NHTSA value
|
||||||
|
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa));
|
||||||
|
if (prefixMatch) {
|
||||||
|
return { value: prefixMatch, nhtsaValue, confidence: 'medium' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try contains match - option contains NHTSA value
|
||||||
|
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa));
|
||||||
|
if (containsMatch) {
|
||||||
|
return { value: containsMatch, nhtsaValue, confidence: 'medium' };
|
||||||
|
}
|
||||||
|
|
||||||
// No match found - return NHTSA value as hint with no match
|
// No match found - return NHTSA value as hint with no match
|
||||||
return { value: null, nhtsaValue, confidence: 'none' };
|
return { value: null, nhtsaValue, confidence: 'none' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,13 +448,45 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
isVinDecoding.current = true;
|
isVinDecoding.current = true;
|
||||||
setLoadingDropdowns(true);
|
setLoadingDropdowns(true);
|
||||||
|
|
||||||
// Track which values we're setting (only populate empty fields)
|
// Determine final values (decoded value if field is empty, otherwise keep existing)
|
||||||
const yearToSet = !watchedYear && decoded.year.value ? decoded.year.value : watchedYear;
|
const yearValue = !watchedYear && decoded.year.value ? decoded.year.value : watchedYear;
|
||||||
const makeToSet = !watchedMake && decoded.make.value ? decoded.make.value : watchedMake;
|
const makeValue = !watchedMake && decoded.make.value ? decoded.make.value : watchedMake;
|
||||||
const modelToSet = !watchedModel && decoded.model.value ? decoded.model.value : watchedModel;
|
const modelValue = !watchedModel && decoded.model.value ? decoded.model.value : watchedModel;
|
||||||
const trimToSet = !watchedTrim && decoded.trimLevel.value ? decoded.trimLevel.value : watchedTrim;
|
const trimValue = !watchedTrim && decoded.trimLevel.value ? decoded.trimLevel.value : watchedTrim;
|
||||||
|
const engineValue = !watchedEngine && decoded.engine.value ? decoded.engine.value : watchedEngine;
|
||||||
|
const transmissionValue = !watchedTransmission && decoded.transmission.value ? decoded.transmission.value : watchedTransmission;
|
||||||
|
|
||||||
// Set form values
|
// FIRST: Load all dropdown options hierarchically (like edit mode initialization)
|
||||||
|
// Options must exist BEFORE setting form values for selects to display correctly
|
||||||
|
if (yearValue) {
|
||||||
|
prevYear.current = yearValue;
|
||||||
|
const makesData = await vehiclesApi.getMakes(yearValue);
|
||||||
|
setMakes(makesData);
|
||||||
|
|
||||||
|
if (makeValue) {
|
||||||
|
prevMake.current = makeValue;
|
||||||
|
const modelsData = await vehiclesApi.getModels(yearValue, makeValue);
|
||||||
|
setModels(modelsData);
|
||||||
|
|
||||||
|
if (modelValue) {
|
||||||
|
prevModel.current = modelValue;
|
||||||
|
const trimsData = await vehiclesApi.getTrims(yearValue, makeValue, modelValue);
|
||||||
|
setTrims(trimsData);
|
||||||
|
|
||||||
|
if (trimValue) {
|
||||||
|
prevTrim.current = trimValue;
|
||||||
|
const [enginesData, transmissionsData] = await Promise.all([
|
||||||
|
vehiclesApi.getEngines(yearValue, makeValue, modelValue, trimValue),
|
||||||
|
vehiclesApi.getTransmissions(yearValue, makeValue, modelValue, trimValue)
|
||||||
|
]);
|
||||||
|
setEngines(enginesData);
|
||||||
|
setTransmissions(transmissionsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THEN: Set form values (after options are loaded)
|
||||||
if (!watchedYear && decoded.year.value) {
|
if (!watchedYear && decoded.year.value) {
|
||||||
setValue('year', decoded.year.value);
|
setValue('year', decoded.year.value);
|
||||||
}
|
}
|
||||||
@@ -474,36 +506,6 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
setValue('transmission', decoded.transmission.value);
|
setValue('transmission', decoded.transmission.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load dropdown options hierarchically (like edit mode initialization)
|
|
||||||
// This ensures dropdowns have the right options for the decoded values
|
|
||||||
if (yearToSet) {
|
|
||||||
prevYear.current = yearToSet;
|
|
||||||
const makesData = await vehiclesApi.getMakes(yearToSet);
|
|
||||||
setMakes(makesData);
|
|
||||||
|
|
||||||
if (makeToSet) {
|
|
||||||
prevMake.current = makeToSet;
|
|
||||||
const modelsData = await vehiclesApi.getModels(yearToSet, makeToSet);
|
|
||||||
setModels(modelsData);
|
|
||||||
|
|
||||||
if (modelToSet) {
|
|
||||||
prevModel.current = modelToSet;
|
|
||||||
const trimsData = await vehiclesApi.getTrims(yearToSet, makeToSet, modelToSet);
|
|
||||||
setTrims(trimsData);
|
|
||||||
|
|
||||||
if (trimToSet) {
|
|
||||||
prevTrim.current = trimToSet;
|
|
||||||
const [enginesData, transmissionsData] = await Promise.all([
|
|
||||||
vehiclesApi.getEngines(yearToSet, makeToSet, modelToSet, trimToSet),
|
|
||||||
vehiclesApi.getTransmissions(yearToSet, makeToSet, modelToSet, trimToSet)
|
|
||||||
]);
|
|
||||||
setEngines(enginesData);
|
|
||||||
setTransmissions(transmissionsData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingDropdowns(false);
|
setLoadingDropdowns(false);
|
||||||
isVinDecoding.current = false;
|
isVinDecoding.current = false;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user