Reverse-geocode a general area label for logged searches
Each search now stores an `area_label` (district/town) reverse-geocoded from its coarsened ~1km lat/lng bucket via postcodes.io, surfaced in the Filament Searches admin as a sortable/searchable column plus an area filter. Geocoding is cached 30 days per bucket, queries a 2km radius so low-density buckets still match the default 100m miss, and fails gracefully to null. Adds `searches:backfill-areas` (scheduled hourly) to label existing rows and retry stragglers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
54
app/Console/Commands/BackfillSearchAreas.php
Normal file
54
app/Console/Commands/BackfillSearchAreas.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Search;
|
||||
use App\Services\PostcodeService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BackfillSearchAreas extends Command
|
||||
{
|
||||
protected $signature = 'searches:backfill-areas {--limit=0 : Max distinct areas to resolve this run (0 = no limit)}';
|
||||
|
||||
protected $description = 'Reverse-geocode searches that have no area_label yet';
|
||||
|
||||
public function handle(PostcodeService $postcodes): int
|
||||
{
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$buckets = Search::query()
|
||||
->whereNull('area_label')
|
||||
->select('lat_bucket', 'lng_bucket')
|
||||
->distinct()
|
||||
->when($limit > 0, fn ($query) => $query->limit($limit))
|
||||
->get();
|
||||
|
||||
if ($buckets->isEmpty()) {
|
||||
$this->info('No searches need an area label.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$resolved = 0;
|
||||
$rowsUpdated = 0;
|
||||
|
||||
foreach ($buckets as $bucket) {
|
||||
$label = $postcodes->reverseResolve((float) $bucket->lat_bucket, (float) $bucket->lng_bucket);
|
||||
|
||||
if ($label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolved++;
|
||||
$rowsUpdated += Search::query()
|
||||
->whereNull('area_label')
|
||||
->where('lat_bucket', $bucket->lat_bucket)
|
||||
->where('lng_bucket', $bucket->lng_bucket)
|
||||
->update(['area_label' => $label]);
|
||||
}
|
||||
|
||||
$this->info("Resolved {$resolved} of {$buckets->count()} areas, updated {$rowsUpdated} search rows.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ class SearchResource extends Resource
|
||||
->label('Searched At')
|
||||
->dateTime('d M Y H:i')
|
||||
->sortable(),
|
||||
TextColumn::make('area_label')
|
||||
->label('Area')
|
||||
->placeholder('Unknown')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('fuel_type')
|
||||
->label('Fuel Type')
|
||||
->badge(),
|
||||
@@ -67,6 +72,15 @@ class SearchResource extends Resource
|
||||
'B10' => 'B10',
|
||||
'HVO' => 'HVO',
|
||||
]),
|
||||
SelectFilter::make('area_label')
|
||||
->label('Area')
|
||||
->searchable()
|
||||
->options(fn (): array => Search::query()
|
||||
->whereNotNull('area_label')
|
||||
->distinct()
|
||||
->orderBy('area_label')
|
||||
->pluck('area_label', 'area_label')
|
||||
->all()),
|
||||
])
|
||||
->recordActions([])
|
||||
->toolbarActions([]);
|
||||
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['lat_bucket', 'lng_bucket', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
|
||||
#[Fillable(['lat_bucket', 'lng_bucket', 'area_label', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
|
||||
class Search extends Model
|
||||
{
|
||||
/** @use HasFactory<SearchFactory> */
|
||||
|
||||
@@ -52,6 +52,77 @@ class PostcodeService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-geocode coordinates to a general UK area label (e.g. "Peterborough").
|
||||
*
|
||||
* Coordinates are bucketed to ~1km (2dp) before lookup so the cache is shared
|
||||
* across nearby searches and nothing more precise than the stored bucket is
|
||||
* ever queried. Returns null if the area cannot be determined.
|
||||
*/
|
||||
public function reverseResolve(float $lat, float $lng): ?string
|
||||
{
|
||||
$latBucket = round($lat, 2);
|
||||
$lngBucket = round($lng, 2);
|
||||
$cacheKey = "revgeo:{$latBucket},{$lngBucket}";
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
|
||||
if (is_string($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$label = $this->lookupArea($latBucket, $lngBucket);
|
||||
|
||||
if ($label !== null) {
|
||||
Cache::put($cacheKey, $label, self::CACHE_TTL);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function lookupArea(float $lat, float $lng): ?string
|
||||
{
|
||||
$url = self::BASE_URL.'/postcodes';
|
||||
// radius=2000 (postcodes.io max): we query the ~1km bucket centroid, which
|
||||
// can sit up to ~780m from any real point in the bucket. The default 100m
|
||||
// radius misses in low-density areas, so widen it to guarantee a hit.
|
||||
$logUrl = $url.'?lon='.$lng.'&lat='.$lat.'&radius=2000&limit=1';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('postcodes_io', 'GET', $logUrl, fn () => Http::timeout(5)
|
||||
->get($url, ['lon' => $lng, 'lat' => $lat, 'radius' => 2000, 'limit' => 1]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$results = $response->json('result');
|
||||
|
||||
if (! is_array($results) || ! isset($results[0]) || ! is_array($results[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the most human "town/district" field, falling back to broader areas.
|
||||
foreach (['admin_district', 'parish', 'admin_ward', 'region', 'country'] as $field) {
|
||||
$value = $results[0][$field] ?? null;
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('PostcodeService: reverse geocode failed', [
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalisePostcode(string $value): string
|
||||
{
|
||||
return strtoupper(preg_replace('/\s+/', '', $value));
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Services\Forecasting\LocalSnapshotService;
|
||||
use App\Services\Forecasting\WeeklyForecastService;
|
||||
use App\Services\HaversineQuery;
|
||||
use App\Services\PlanFeatures;
|
||||
use App\Services\PostcodeService;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -20,6 +21,7 @@ final class StationSearchService
|
||||
public function __construct(
|
||||
private readonly WeeklyForecastService $weeklyForecast,
|
||||
private readonly LocalSnapshotService $localSnapshot,
|
||||
private readonly PostcodeService $postcodeService,
|
||||
) {}
|
||||
|
||||
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
||||
@@ -118,6 +120,7 @@ final class StationSearchService
|
||||
Search::create([
|
||||
'lat_bucket' => round($criteria->lat, 2),
|
||||
'lng_bucket' => round($criteria->lng, 2),
|
||||
'area_label' => $this->postcodeService->reverseResolve($criteria->lat, $criteria->lng),
|
||||
'fuel_type' => $criteria->fuelType->value,
|
||||
'results_count' => $resultsCount,
|
||||
'lowest_pence' => $prices->min(),
|
||||
|
||||
Reference in New Issue
Block a user