feat: add station matching from receipt merchant name (refs #132)
Add Google Places Text Search to match receipt merchant names (e.g. "Shell", "COSTCO #123") to real gas stations. Backend exposes POST /api/stations/match endpoint. Frontend calls it after OCR extraction and pre-fills locationData with matched station's placeId, name, and address. Users can clear the match in the review modal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,7 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
acceptResult,
|
||||
reset: resetOcr,
|
||||
updateField,
|
||||
clearMatchedStation,
|
||||
} = useReceiptOcr();
|
||||
|
||||
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
|
||||
@@ -159,13 +160,13 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
if (mappedFields.fuelGrade) {
|
||||
setValue('fuelGrade', mappedFields.fuelGrade);
|
||||
}
|
||||
if (mappedFields.locationData?.stationName) {
|
||||
// Set station name in locationData if no station is already selected
|
||||
if (mappedFields.locationData) {
|
||||
// Set location data from OCR + station matching if no station is already selected
|
||||
const currentLocation = watch('locationData');
|
||||
if (!currentLocation?.stationName && !currentLocation?.googlePlaceId) {
|
||||
setValue('locationData', {
|
||||
...currentLocation,
|
||||
stationName: mappedFields.locationData.stationName,
|
||||
...mappedFields.locationData,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -443,10 +444,12 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
open={!!ocrResult}
|
||||
extractedFields={ocrResult.extractedFields}
|
||||
receiptImageUrl={receiptImageUrl}
|
||||
matchedStation={ocrResult.matchedStation}
|
||||
onAccept={handleAcceptOcrResult}
|
||||
onRetake={handleRetakePhoto}
|
||||
onCancel={resetOcr}
|
||||
onFieldEdit={updateField}
|
||||
onClearMatchedStation={clearMatchedStation}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ import EditIcon from '@mui/icons-material/Edit';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||
import PlaceIcon from '@mui/icons-material/Place';
|
||||
import {
|
||||
ExtractedReceiptFields,
|
||||
ExtractedReceiptField,
|
||||
MatchedStation,
|
||||
LOW_CONFIDENCE_THRESHOLD,
|
||||
} from '../hooks/useReceiptOcr';
|
||||
import { ReceiptPreview } from './ReceiptPreview';
|
||||
@@ -38,6 +40,8 @@ export interface ReceiptOcrReviewModalProps {
|
||||
extractedFields: ExtractedReceiptFields;
|
||||
/** Receipt image URL for preview */
|
||||
receiptImageUrl: string | null;
|
||||
/** Matched station from merchant name (if any) */
|
||||
matchedStation?: MatchedStation | null;
|
||||
/** Called when user accepts the fields */
|
||||
onAccept: () => void;
|
||||
/** Called when user wants to retake the photo */
|
||||
@@ -46,6 +50,8 @@ export interface ReceiptOcrReviewModalProps {
|
||||
onCancel: () => void;
|
||||
/** Called when user edits a field */
|
||||
onFieldEdit: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
|
||||
/** Called when user clears the matched station */
|
||||
onClearMatchedStation?: () => void;
|
||||
}
|
||||
|
||||
/** Confidence indicator component */
|
||||
@@ -209,10 +215,12 @@ export const ReceiptOcrReviewModal: React.FC<ReceiptOcrReviewModalProps> = ({
|
||||
open,
|
||||
extractedFields,
|
||||
receiptImageUrl,
|
||||
matchedStation,
|
||||
onAccept,
|
||||
onRetake,
|
||||
onCancel,
|
||||
onFieldEdit,
|
||||
onClearMatchedStation,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
@@ -354,6 +362,40 @@ export const ReceiptOcrReviewModal: React.FC<ReceiptOcrReviewModalProps> = ({
|
||||
onEdit={(value) => onFieldEdit('merchantName', value)}
|
||||
type="text"
|
||||
/>
|
||||
{matchedStation && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
py: 1,
|
||||
px: 1,
|
||||
ml: '100px',
|
||||
gap: 1,
|
||||
backgroundColor: 'success.light',
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<PlaceIcon fontSize="small" color="success" />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={500} noWrap>
|
||||
{matchedStation.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{matchedStation.address}
|
||||
</Typography>
|
||||
</Box>
|
||||
{onClearMatchedStation && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClearMatchedStation}
|
||||
aria-label="Clear matched station"
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Collapse>
|
||||
|
||||
{isMobile && (
|
||||
|
||||
@@ -31,15 +31,25 @@ export interface MappedFuelLogFields {
|
||||
fuelGrade?: FuelGrade;
|
||||
locationData?: {
|
||||
stationName?: string;
|
||||
googlePlaceId?: string;
|
||||
address?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Matched station from receipt merchant name */
|
||||
export interface MatchedStation {
|
||||
placeId: string;
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
/** Receipt OCR result */
|
||||
export interface ReceiptOcrResult {
|
||||
extractedFields: ExtractedReceiptFields;
|
||||
mappedFields: MappedFuelLogFields;
|
||||
rawText: string;
|
||||
overallConfidence: number;
|
||||
matchedStation: MatchedStation | null;
|
||||
}
|
||||
|
||||
/** Hook state */
|
||||
@@ -59,6 +69,7 @@ export interface UseReceiptOcrReturn extends UseReceiptOcrState {
|
||||
acceptResult: () => MappedFuelLogFields | null;
|
||||
reset: () => void;
|
||||
updateField: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
|
||||
clearMatchedStation: () => void;
|
||||
}
|
||||
|
||||
/** Confidence threshold for highlighting low-confidence fields */
|
||||
@@ -185,16 +196,48 @@ async function extractReceiptFromImage(file: File): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/** Match station from merchant name via backend */
|
||||
async function matchStationFromMerchant(merchantName: string): Promise<MatchedStation | null> {
|
||||
try {
|
||||
const response = await apiClient.post('/stations/match', { merchantName });
|
||||
const data = response.data;
|
||||
|
||||
if (data.matched && data.station) {
|
||||
return {
|
||||
placeId: data.station.placeId,
|
||||
name: data.station.name,
|
||||
address: data.station.address,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('Station matching failed (non-blocking):', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map extracted fields to fuel log form fields */
|
||||
function mapFieldsToFuelLog(fields: ExtractedReceiptFields): MappedFuelLogFields {
|
||||
function mapFieldsToFuelLog(
|
||||
fields: ExtractedReceiptFields,
|
||||
matchedStation?: MatchedStation | null
|
||||
): MappedFuelLogFields {
|
||||
// If station was matched, use matched data; otherwise fall back to merchant name
|
||||
const locationData = matchedStation
|
||||
? {
|
||||
stationName: matchedStation.name,
|
||||
googlePlaceId: matchedStation.placeId,
|
||||
address: matchedStation.address,
|
||||
}
|
||||
: fields.merchantName.value
|
||||
? { stationName: String(fields.merchantName.value) }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
dateTime: parseTransactionDate(fields.transactionDate.value),
|
||||
fuelUnits: parseNumber(fields.fuelQuantity.value),
|
||||
costPerUnit: parseNumber(fields.pricePerUnit.value),
|
||||
fuelGrade: mapFuelGrade(fields.fuelGrade.value),
|
||||
locationData: fields.merchantName.value
|
||||
? { stationName: String(fields.merchantName.value) }
|
||||
: undefined,
|
||||
locationData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,13 +275,22 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
|
||||
|
||||
try {
|
||||
const { extractedFields, rawText, confidence } = await extractReceiptFromImage(imageToProcess);
|
||||
const mappedFields = mapFieldsToFuelLog(extractedFields);
|
||||
|
||||
// Attempt station matching from merchant name (non-blocking)
|
||||
let matchedStation: MatchedStation | null = null;
|
||||
const merchantName = extractedFields.merchantName.value;
|
||||
if (merchantName && String(merchantName).trim()) {
|
||||
matchedStation = await matchStationFromMerchant(String(merchantName));
|
||||
}
|
||||
|
||||
const mappedFields = mapFieldsToFuelLog(extractedFields, matchedStation);
|
||||
|
||||
setResult({
|
||||
extractedFields,
|
||||
mappedFields,
|
||||
rawText,
|
||||
overallConfidence: confidence,
|
||||
matchedStation,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Receipt OCR processing failed:', err);
|
||||
@@ -268,10 +320,14 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
|
||||
},
|
||||
};
|
||||
|
||||
// Clear matched station if merchant name was edited (user override)
|
||||
const station = fieldName === 'merchantName' ? null : prev.matchedStation;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
extractedFields: updatedFields,
|
||||
mappedFields: mapFieldsToFuelLog(updatedFields),
|
||||
mappedFields: mapFieldsToFuelLog(updatedFields, station),
|
||||
matchedStation: station,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
@@ -291,6 +347,17 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
|
||||
return mappedFields;
|
||||
}, [result, receiptImageUrl]);
|
||||
|
||||
const clearMatchedStation = useCallback(() => {
|
||||
setResult((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
matchedStation: null,
|
||||
mappedFields: mapFieldsToFuelLog(prev.extractedFields, null),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setIsCapturing(false);
|
||||
setIsProcessing(false);
|
||||
@@ -314,5 +381,6 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
|
||||
acceptResult,
|
||||
reset,
|
||||
updateField,
|
||||
clearMatchedStation,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user