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:
@@ -10,6 +10,7 @@ import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
StationSearchBody,
|
||||
StationMatchBody,
|
||||
SaveStationBody,
|
||||
StationParams,
|
||||
UpdateSavedStationBody
|
||||
@@ -53,6 +54,29 @@ export class StationsController {
|
||||
}
|
||||
}
|
||||
|
||||
async matchStation(request: FastifyRequest<{ Body: StationMatchBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { merchantName } = request.body;
|
||||
|
||||
if (!merchantName || !merchantName.trim()) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Merchant name is required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.stationsService.matchStationFromReceipt(merchantName);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error matching station from receipt', { error, merchantName: request.body?.merchantName });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to match station',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import {
|
||||
StationSearchBody,
|
||||
StationMatchBody,
|
||||
SaveStationBody,
|
||||
StationParams,
|
||||
UpdateSavedStationBody
|
||||
@@ -25,6 +26,12 @@ export const stationsRoutes: FastifyPluginAsync = async (
|
||||
handler: stationsController.searchStations.bind(stationsController)
|
||||
});
|
||||
|
||||
// POST /api/stations/match - Match station from receipt merchant name
|
||||
fastify.post<{ Body: StationMatchBody }>('/stations/match', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: stationsController.matchStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// POST /api/stations/save - Save a station to user's favorites
|
||||
fastify.post<{ Body: SaveStationBody }>('/stations/save', {
|
||||
preHandler: [fastify.authenticate],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { googleMapsClient } from '../external/google-maps/google-maps.client';
|
||||
import {
|
||||
StationSearchRequest,
|
||||
StationSearchResponse,
|
||||
StationMatchResponse,
|
||||
SavedStation,
|
||||
StationSavedMetadata,
|
||||
UpdateSavedStationBody
|
||||
@@ -154,6 +155,27 @@ export class StationsService {
|
||||
return enriched;
|
||||
}
|
||||
|
||||
async matchStationFromReceipt(merchantName: string): Promise<StationMatchResponse> {
|
||||
const trimmed = merchantName.trim();
|
||||
if (!trimmed) {
|
||||
return { matched: false, station: null };
|
||||
}
|
||||
|
||||
logger.info('Matching station from receipt merchant name', { merchantName: trimmed });
|
||||
|
||||
const station = await googleMapsClient.searchStationByName(trimmed);
|
||||
|
||||
if (station) {
|
||||
// Cache matched station for future reference (e.g. saveStation)
|
||||
await this.repository.cacheStation(station);
|
||||
}
|
||||
|
||||
return {
|
||||
matched: station !== null,
|
||||
station,
|
||||
};
|
||||
}
|
||||
|
||||
async removeSavedStation(placeId: string, userId: string) {
|
||||
const removed = await this.repository.deleteSavedStation(userId, placeId);
|
||||
|
||||
|
||||
@@ -89,3 +89,12 @@ export interface StationSavedMetadata {
|
||||
has93Octane: boolean;
|
||||
has93OctaneEthanolFree: boolean;
|
||||
}
|
||||
|
||||
export interface StationMatchBody {
|
||||
merchantName: string;
|
||||
}
|
||||
|
||||
export interface StationMatchResponse {
|
||||
matched: boolean;
|
||||
station: Station | null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import axios from 'axios';
|
||||
import { appConfig } from '../../../../core/config/config-loader';
|
||||
import { logger } from '../../../../core/logging/logger';
|
||||
import { cacheService } from '../../../../core/config/redis';
|
||||
import { GooglePlacesResponse, GooglePlace } from './google-maps.types';
|
||||
import { GooglePlacesResponse, GoogleTextSearchResponse, GooglePlace } from './google-maps.types';
|
||||
import { Station } from '../../domain/stations.types';
|
||||
|
||||
export class GoogleMapsClient {
|
||||
@@ -103,6 +103,87 @@ export class GoogleMapsClient {
|
||||
return station;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a gas station by merchant name using Google Places Text Search API.
|
||||
* Used to match receipt merchant names (e.g. "Shell", "COSTCO #123") to actual stations.
|
||||
*/
|
||||
async searchStationByName(merchantName: string): Promise<Station | null> {
|
||||
const query = `${merchantName} gas station`;
|
||||
const cacheKey = `station-match:${query.toLowerCase().trim()}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheService.get<Station | null>(cacheKey);
|
||||
if (cached !== undefined && cached !== null) {
|
||||
logger.debug('Station name match cache hit', { merchantName });
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.info('Searching Google Places Text Search for station', { merchantName, query });
|
||||
|
||||
const response = await axios.get<GoogleTextSearchResponse>(
|
||||
`${this.baseURL}/textsearch/json`,
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
type: 'gas_station',
|
||||
key: this.apiKey,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') {
|
||||
throw new Error(`Google Places Text Search API error: ${response.data.status}`);
|
||||
}
|
||||
|
||||
if (response.data.results.length === 0) {
|
||||
await cacheService.set(cacheKey, null, this.cacheTTL);
|
||||
return null;
|
||||
}
|
||||
|
||||
const topResult = response.data.results[0];
|
||||
const station = this.transformTextSearchResult(topResult);
|
||||
|
||||
await cacheService.set(cacheKey, station, this.cacheTTL);
|
||||
return station;
|
||||
} catch (error) {
|
||||
logger.error('Station name search failed', { error, merchantName });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private transformTextSearchResult(place: GooglePlace): Station {
|
||||
let photoReference: string | undefined;
|
||||
if (place.photos && place.photos.length > 0 && place.photos[0]) {
|
||||
photoReference = place.photos[0].photo_reference;
|
||||
}
|
||||
|
||||
// Text Search returns formatted_address instead of vicinity
|
||||
const address = (place as any).formatted_address || place.vicinity || '';
|
||||
|
||||
const station: Station = {
|
||||
id: place.place_id,
|
||||
placeId: place.place_id,
|
||||
name: place.name,
|
||||
address,
|
||||
latitude: place.geometry.location.lat,
|
||||
longitude: place.geometry.location.lng,
|
||||
};
|
||||
|
||||
if (photoReference !== undefined) {
|
||||
station.photoReference = photoReference;
|
||||
}
|
||||
|
||||
if (place.opening_hours?.open_now !== undefined) {
|
||||
station.isOpen = place.opening_hours.open_now;
|
||||
}
|
||||
|
||||
if (place.rating !== undefined) {
|
||||
station.rating = place.rating;
|
||||
}
|
||||
|
||||
return station;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch photo from Google Maps API using photo reference
|
||||
* Used by photo proxy endpoint to serve photos without exposing API key
|
||||
|
||||
@@ -52,4 +52,10 @@ export interface GooglePlaceDetails {
|
||||
website?: string;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GoogleTextSearchResponse {
|
||||
results: GooglePlace[];
|
||||
status: string;
|
||||
next_page_token?: string;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for station matching from receipt merchant names
|
||||
*/
|
||||
|
||||
// Mock config-loader before any imports that use it
|
||||
jest.mock('../../../../core/config/config-loader', () => ({
|
||||
appConfig: {
|
||||
secrets: { google_maps_api_key: 'mock-api-key' },
|
||||
getDatabaseUrl: () => 'postgresql://mock:mock@localhost/mock',
|
||||
getRedisUrl: () => 'redis://localhost',
|
||||
get: () => ({}),
|
||||
},
|
||||
}));
|
||||
jest.mock('axios');
|
||||
jest.mock('../../../../core/config/redis');
|
||||
jest.mock('../../../../core/logging/logger');
|
||||
jest.mock('../../data/stations.repository');
|
||||
jest.mock('../../external/google-maps/google-maps.client', () => {
|
||||
const { GoogleMapsClient } = jest.requireActual('../../external/google-maps/google-maps.client');
|
||||
return {
|
||||
GoogleMapsClient,
|
||||
googleMapsClient: {
|
||||
searchNearbyStations: jest.fn(),
|
||||
searchStationByName: jest.fn(),
|
||||
fetchPhoto: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import axios from 'axios';
|
||||
import { GoogleMapsClient } from '../../external/google-maps/google-maps.client';
|
||||
import { StationsService } from '../../domain/stations.service';
|
||||
import { StationsRepository } from '../../data/stations.repository';
|
||||
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
|
||||
import { mockStations } from '../fixtures/mock-stations';
|
||||
|
||||
describe('Station Matching from Receipt', () => {
|
||||
describe('GoogleMapsClient.searchStationByName', () => {
|
||||
let client: GoogleMapsClient;
|
||||
let mockAxios: jest.Mocked<typeof axios>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAxios = axios as jest.Mocked<typeof axios>;
|
||||
client = new GoogleMapsClient();
|
||||
});
|
||||
|
||||
it('should match a known station name like "Shell"', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJ_shell_match',
|
||||
name: 'Shell Gas Station',
|
||||
formatted_address: '123 Main St, San Francisco, CA 94105',
|
||||
geometry: { location: { lat: 37.7749, lng: -122.4194 } },
|
||||
rating: 4.2,
|
||||
photos: [{ photo_reference: 'shell-photo-ref' }],
|
||||
opening_hours: { open_now: true },
|
||||
types: ['gas_station'],
|
||||
},
|
||||
],
|
||||
status: 'OK',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('Shell');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.placeId).toBe('ChIJ_shell_match');
|
||||
expect(result?.name).toBe('Shell Gas Station');
|
||||
expect(result?.address).toBe('123 Main St, San Francisco, CA 94105');
|
||||
expect(mockAxios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('textsearch/json'),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: 'Shell gas station',
|
||||
type: 'gas_station',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should match abbreviated names like "COSTCO #123"', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJ_costco_match',
|
||||
name: 'Costco Gasoline',
|
||||
formatted_address: '2000 El Camino Real, Redwood City, CA',
|
||||
geometry: { location: { lat: 37.4849, lng: -122.2278 } },
|
||||
rating: 4.5,
|
||||
types: ['gas_station'],
|
||||
},
|
||||
],
|
||||
status: 'OK',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('COSTCO #123');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Costco Gasoline');
|
||||
expect(result?.placeId).toBe('ChIJ_costco_match');
|
||||
});
|
||||
|
||||
it('should match "BP" station name', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJ_bp_match',
|
||||
name: 'BP',
|
||||
formatted_address: '500 Market St, San Francisco, CA',
|
||||
geometry: { location: { lat: 37.79, lng: -122.40 } },
|
||||
types: ['gas_station'],
|
||||
},
|
||||
],
|
||||
status: 'OK',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('BP');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('BP');
|
||||
});
|
||||
|
||||
it('should return null when no match is found', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [],
|
||||
status: 'ZERO_RESULTS',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('Unknown Station XYZ123');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null gracefully on API error', async () => {
|
||||
mockAxios.get.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await client.searchStationByName('Shell');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on API denial', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [],
|
||||
status: 'REQUEST_DENIED',
|
||||
error_message: 'Invalid key',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('Shell');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should include rating and photo reference when available', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJ_rated',
|
||||
name: 'Chevron',
|
||||
formatted_address: '789 Oak Ave, Portland, OR',
|
||||
geometry: { location: { lat: 45.52, lng: -122.68 } },
|
||||
rating: 4.7,
|
||||
photos: [{ photo_reference: 'chevron-photo' }],
|
||||
opening_hours: { open_now: false },
|
||||
types: ['gas_station'],
|
||||
},
|
||||
],
|
||||
status: 'OK',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('Chevron');
|
||||
|
||||
expect(result?.rating).toBe(4.7);
|
||||
expect(result?.photoReference).toBe('chevron-photo');
|
||||
expect(result?.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StationsService.matchStationFromReceipt', () => {
|
||||
let service: StationsService;
|
||||
let mockRepository: jest.Mocked<StationsRepository>;
|
||||
|
||||
const mockSearchByName = googleMapsClient.searchStationByName as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockRepository = {
|
||||
cacheStation: jest.fn().mockResolvedValue(undefined),
|
||||
getCachedStation: jest.fn(),
|
||||
saveStation: jest.fn(),
|
||||
getUserSavedStations: jest.fn().mockResolvedValue([]),
|
||||
updateSavedStation: jest.fn(),
|
||||
deleteSavedStation: jest.fn(),
|
||||
} as unknown as jest.Mocked<StationsRepository>;
|
||||
|
||||
service = new StationsService(mockRepository);
|
||||
});
|
||||
|
||||
it('should return matched station for known merchant name', async () => {
|
||||
const matchedStation = mockStations[0]!;
|
||||
mockSearchByName.mockResolvedValue(matchedStation);
|
||||
|
||||
const result = await service.matchStationFromReceipt('Shell');
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.station).not.toBeNull();
|
||||
expect(result.station?.name).toBe('Shell Gas Station - Downtown');
|
||||
expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation);
|
||||
});
|
||||
|
||||
it('should return no match for unknown merchant', async () => {
|
||||
mockSearchByName.mockResolvedValue(null);
|
||||
|
||||
const result = await service.matchStationFromReceipt('Unknown Store');
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.station).toBeNull();
|
||||
expect(mockRepository.cacheStation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty merchant name', async () => {
|
||||
const result = await service.matchStationFromReceipt('');
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.station).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle whitespace-only merchant name', async () => {
|
||||
const result = await service.matchStationFromReceipt(' ');
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.station).toBeNull();
|
||||
});
|
||||
|
||||
it('should cache matched station for future saveStation calls', async () => {
|
||||
const matchedStation = mockStations[1]!;
|
||||
mockSearchByName.mockResolvedValue(matchedStation);
|
||||
|
||||
await service.matchStationFromReceipt('Chevron');
|
||||
|
||||
expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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