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 { logger } from '../../../core/logging/logger';
|
||||||
import {
|
import {
|
||||||
StationSearchBody,
|
StationSearchBody,
|
||||||
|
StationMatchBody,
|
||||||
SaveStationBody,
|
SaveStationBody,
|
||||||
StationParams,
|
StationParams,
|
||||||
UpdateSavedStationBody
|
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) {
|
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = (request as any).user.sub;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
import {
|
import {
|
||||||
StationSearchBody,
|
StationSearchBody,
|
||||||
|
StationMatchBody,
|
||||||
SaveStationBody,
|
SaveStationBody,
|
||||||
StationParams,
|
StationParams,
|
||||||
UpdateSavedStationBody
|
UpdateSavedStationBody
|
||||||
@@ -25,6 +26,12 @@ export const stationsRoutes: FastifyPluginAsync = async (
|
|||||||
handler: stationsController.searchStations.bind(stationsController)
|
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
|
// POST /api/stations/save - Save a station to user's favorites
|
||||||
fastify.post<{ Body: SaveStationBody }>('/stations/save', {
|
fastify.post<{ Body: SaveStationBody }>('/stations/save', {
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { googleMapsClient } from '../external/google-maps/google-maps.client';
|
|||||||
import {
|
import {
|
||||||
StationSearchRequest,
|
StationSearchRequest,
|
||||||
StationSearchResponse,
|
StationSearchResponse,
|
||||||
|
StationMatchResponse,
|
||||||
SavedStation,
|
SavedStation,
|
||||||
StationSavedMetadata,
|
StationSavedMetadata,
|
||||||
UpdateSavedStationBody
|
UpdateSavedStationBody
|
||||||
@@ -154,6 +155,27 @@ export class StationsService {
|
|||||||
return enriched;
|
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) {
|
async removeSavedStation(placeId: string, userId: string) {
|
||||||
const removed = await this.repository.deleteSavedStation(userId, placeId);
|
const removed = await this.repository.deleteSavedStation(userId, placeId);
|
||||||
|
|
||||||
|
|||||||
@@ -89,3 +89,12 @@ export interface StationSavedMetadata {
|
|||||||
has93Octane: boolean;
|
has93Octane: boolean;
|
||||||
has93OctaneEthanolFree: 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 { appConfig } from '../../../../core/config/config-loader';
|
||||||
import { logger } from '../../../../core/logging/logger';
|
import { logger } from '../../../../core/logging/logger';
|
||||||
import { cacheService } from '../../../../core/config/redis';
|
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';
|
import { Station } from '../../domain/stations.types';
|
||||||
|
|
||||||
export class GoogleMapsClient {
|
export class GoogleMapsClient {
|
||||||
@@ -103,6 +103,87 @@ export class GoogleMapsClient {
|
|||||||
return station;
|
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
|
* Fetch photo from Google Maps API using photo reference
|
||||||
* Used by photo proxy endpoint to serve photos without exposing API key
|
* Used by photo proxy endpoint to serve photos without exposing API key
|
||||||
|
|||||||
@@ -53,3 +53,9 @@ export interface GooglePlaceDetails {
|
|||||||
};
|
};
|
||||||
status: 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,
|
acceptResult,
|
||||||
reset: resetOcr,
|
reset: resetOcr,
|
||||||
updateField,
|
updateField,
|
||||||
|
clearMatchedStation,
|
||||||
} = useReceiptOcr();
|
} = useReceiptOcr();
|
||||||
|
|
||||||
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
|
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) {
|
if (mappedFields.fuelGrade) {
|
||||||
setValue('fuelGrade', mappedFields.fuelGrade);
|
setValue('fuelGrade', mappedFields.fuelGrade);
|
||||||
}
|
}
|
||||||
if (mappedFields.locationData?.stationName) {
|
if (mappedFields.locationData) {
|
||||||
// Set station name in locationData if no station is already selected
|
// Set location data from OCR + station matching if no station is already selected
|
||||||
const currentLocation = watch('locationData');
|
const currentLocation = watch('locationData');
|
||||||
if (!currentLocation?.stationName && !currentLocation?.googlePlaceId) {
|
if (!currentLocation?.stationName && !currentLocation?.googlePlaceId) {
|
||||||
setValue('locationData', {
|
setValue('locationData', {
|
||||||
...currentLocation,
|
...currentLocation,
|
||||||
stationName: mappedFields.locationData.stationName,
|
...mappedFields.locationData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,10 +444,12 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
|||||||
open={!!ocrResult}
|
open={!!ocrResult}
|
||||||
extractedFields={ocrResult.extractedFields}
|
extractedFields={ocrResult.extractedFields}
|
||||||
receiptImageUrl={receiptImageUrl}
|
receiptImageUrl={receiptImageUrl}
|
||||||
|
matchedStation={ocrResult.matchedStation}
|
||||||
onAccept={handleAcceptOcrResult}
|
onAccept={handleAcceptOcrResult}
|
||||||
onRetake={handleRetakePhoto}
|
onRetake={handleRetakePhoto}
|
||||||
onCancel={resetOcr}
|
onCancel={resetOcr}
|
||||||
onFieldEdit={updateField}
|
onFieldEdit={updateField}
|
||||||
|
onClearMatchedStation={clearMatchedStation}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ import EditIcon from '@mui/icons-material/Edit';
|
|||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||||
|
import PlaceIcon from '@mui/icons-material/Place';
|
||||||
import {
|
import {
|
||||||
ExtractedReceiptFields,
|
ExtractedReceiptFields,
|
||||||
ExtractedReceiptField,
|
ExtractedReceiptField,
|
||||||
|
MatchedStation,
|
||||||
LOW_CONFIDENCE_THRESHOLD,
|
LOW_CONFIDENCE_THRESHOLD,
|
||||||
} from '../hooks/useReceiptOcr';
|
} from '../hooks/useReceiptOcr';
|
||||||
import { ReceiptPreview } from './ReceiptPreview';
|
import { ReceiptPreview } from './ReceiptPreview';
|
||||||
@@ -38,6 +40,8 @@ export interface ReceiptOcrReviewModalProps {
|
|||||||
extractedFields: ExtractedReceiptFields;
|
extractedFields: ExtractedReceiptFields;
|
||||||
/** Receipt image URL for preview */
|
/** Receipt image URL for preview */
|
||||||
receiptImageUrl: string | null;
|
receiptImageUrl: string | null;
|
||||||
|
/** Matched station from merchant name (if any) */
|
||||||
|
matchedStation?: MatchedStation | null;
|
||||||
/** Called when user accepts the fields */
|
/** Called when user accepts the fields */
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
/** Called when user wants to retake the photo */
|
/** Called when user wants to retake the photo */
|
||||||
@@ -46,6 +50,8 @@ export interface ReceiptOcrReviewModalProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
/** Called when user edits a field */
|
/** Called when user edits a field */
|
||||||
onFieldEdit: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
|
onFieldEdit: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
|
||||||
|
/** Called when user clears the matched station */
|
||||||
|
onClearMatchedStation?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confidence indicator component */
|
/** Confidence indicator component */
|
||||||
@@ -209,10 +215,12 @@ export const ReceiptOcrReviewModal: React.FC<ReceiptOcrReviewModalProps> = ({
|
|||||||
open,
|
open,
|
||||||
extractedFields,
|
extractedFields,
|
||||||
receiptImageUrl,
|
receiptImageUrl,
|
||||||
|
matchedStation,
|
||||||
onAccept,
|
onAccept,
|
||||||
onRetake,
|
onRetake,
|
||||||
onCancel,
|
onCancel,
|
||||||
onFieldEdit,
|
onFieldEdit,
|
||||||
|
onClearMatchedStation,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
@@ -354,6 +362,40 @@ export const ReceiptOcrReviewModal: React.FC<ReceiptOcrReviewModalProps> = ({
|
|||||||
onEdit={(value) => onFieldEdit('merchantName', value)}
|
onEdit={(value) => onFieldEdit('merchantName', value)}
|
||||||
type="text"
|
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>
|
</Collapse>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|||||||
@@ -31,15 +31,25 @@ export interface MappedFuelLogFields {
|
|||||||
fuelGrade?: FuelGrade;
|
fuelGrade?: FuelGrade;
|
||||||
locationData?: {
|
locationData?: {
|
||||||
stationName?: string;
|
stationName?: string;
|
||||||
|
googlePlaceId?: string;
|
||||||
|
address?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Matched station from receipt merchant name */
|
||||||
|
export interface MatchedStation {
|
||||||
|
placeId: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Receipt OCR result */
|
/** Receipt OCR result */
|
||||||
export interface ReceiptOcrResult {
|
export interface ReceiptOcrResult {
|
||||||
extractedFields: ExtractedReceiptFields;
|
extractedFields: ExtractedReceiptFields;
|
||||||
mappedFields: MappedFuelLogFields;
|
mappedFields: MappedFuelLogFields;
|
||||||
rawText: string;
|
rawText: string;
|
||||||
overallConfidence: number;
|
overallConfidence: number;
|
||||||
|
matchedStation: MatchedStation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hook state */
|
/** Hook state */
|
||||||
@@ -59,6 +69,7 @@ export interface UseReceiptOcrReturn extends UseReceiptOcrState {
|
|||||||
acceptResult: () => MappedFuelLogFields | null;
|
acceptResult: () => MappedFuelLogFields | null;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
updateField: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
|
updateField: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
|
||||||
|
clearMatchedStation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confidence threshold for highlighting low-confidence fields */
|
/** 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 */
|
/** 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 {
|
return {
|
||||||
dateTime: parseTransactionDate(fields.transactionDate.value),
|
dateTime: parseTransactionDate(fields.transactionDate.value),
|
||||||
fuelUnits: parseNumber(fields.fuelQuantity.value),
|
fuelUnits: parseNumber(fields.fuelQuantity.value),
|
||||||
costPerUnit: parseNumber(fields.pricePerUnit.value),
|
costPerUnit: parseNumber(fields.pricePerUnit.value),
|
||||||
fuelGrade: mapFuelGrade(fields.fuelGrade.value),
|
fuelGrade: mapFuelGrade(fields.fuelGrade.value),
|
||||||
locationData: fields.merchantName.value
|
locationData,
|
||||||
? { stationName: String(fields.merchantName.value) }
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +275,22 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { extractedFields, rawText, confidence } = await extractReceiptFromImage(imageToProcess);
|
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({
|
setResult({
|
||||||
extractedFields,
|
extractedFields,
|
||||||
mappedFields,
|
mappedFields,
|
||||||
rawText,
|
rawText,
|
||||||
overallConfidence: confidence,
|
overallConfidence: confidence,
|
||||||
|
matchedStation,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Receipt OCR processing failed:', err);
|
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 {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
extractedFields: updatedFields,
|
extractedFields: updatedFields,
|
||||||
mappedFields: mapFieldsToFuelLog(updatedFields),
|
mappedFields: mapFieldsToFuelLog(updatedFields, station),
|
||||||
|
matchedStation: station,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -291,6 +347,17 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
|
|||||||
return mappedFields;
|
return mappedFields;
|
||||||
}, [result, receiptImageUrl]);
|
}, [result, receiptImageUrl]);
|
||||||
|
|
||||||
|
const clearMatchedStation = useCallback(() => {
|
||||||
|
setResult((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
matchedStation: null,
|
||||||
|
mappedFields: mapFieldsToFuelLog(prev.extractedFields, null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setIsCapturing(false);
|
setIsCapturing(false);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
@@ -314,5 +381,6 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
|
|||||||
acceptResult,
|
acceptResult,
|
||||||
reset,
|
reset,
|
||||||
updateField,
|
updateField,
|
||||||
|
clearMatchedStation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user