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:
Ovidiu U
2026-06-12 09:05:03 +01:00
parent 040b2f627e
commit ea22387c9d
10 changed files with 331 additions and 1 deletions

View 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;
}
}