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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user