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

View File

@@ -28,6 +28,11 @@ class SearchResource extends Resource
->label('Searched At') ->label('Searched At')
->dateTime('d M Y H:i') ->dateTime('d M Y H:i')
->sortable(), ->sortable(),
TextColumn::make('area_label')
->label('Area')
->placeholder('Unknown')
->searchable()
->sortable(),
TextColumn::make('fuel_type') TextColumn::make('fuel_type')
->label('Fuel Type') ->label('Fuel Type')
->badge(), ->badge(),
@@ -67,6 +72,15 @@ class SearchResource extends Resource
'B10' => 'B10', 'B10' => 'B10',
'HVO' => 'HVO', '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([]) ->recordActions([])
->toolbarActions([]); ->toolbarActions([]);

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; 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 class Search extends Model
{ {
/** @use HasFactory<SearchFactory> */ /** @use HasFactory<SearchFactory> */

View File

@@ -52,6 +52,77 @@ class PostcodeService
return $result; 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 private function normalisePostcode(string $value): string
{ {
return strtoupper(preg_replace('/\s+/', '', $value)); return strtoupper(preg_replace('/\s+/', '', $value));

View File

@@ -11,6 +11,7 @@ use App\Services\Forecasting\LocalSnapshotService;
use App\Services\Forecasting\WeeklyForecastService; use App\Services\Forecasting\WeeklyForecastService;
use App\Services\HaversineQuery; use App\Services\HaversineQuery;
use App\Services\PlanFeatures; use App\Services\PlanFeatures;
use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -20,6 +21,7 @@ final class StationSearchService
public function __construct( public function __construct(
private readonly WeeklyForecastService $weeklyForecast, private readonly WeeklyForecastService $weeklyForecast,
private readonly LocalSnapshotService $localSnapshot, private readonly LocalSnapshotService $localSnapshot,
private readonly PostcodeService $postcodeService,
) {} ) {}
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
@@ -118,6 +120,7 @@ final class StationSearchService
Search::create([ Search::create([
'lat_bucket' => round($criteria->lat, 2), 'lat_bucket' => round($criteria->lat, 2),
'lng_bucket' => round($criteria->lng, 2), 'lng_bucket' => round($criteria->lng, 2),
'area_label' => $this->postcodeService->reverseResolve($criteria->lat, $criteria->lng),
'fuel_type' => $criteria->fuelType->value, 'fuel_type' => $criteria->fuelType->value,
'results_count' => $resultsCount, 'results_count' => $resultsCount,
'lowest_pence' => $prices->min(), 'lowest_pence' => $prices->min(),

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('searches', function (Blueprint $table): void {
$table->string('area_label', 100)
->nullable()
->after('lng_bucket')
->comment('General UK area (e.g. district/town) reverse-geocoded from the lat/lng bucket');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('searches', function (Blueprint $table): void {
$table->dropColumn('area_label');
});
}
};

View File

@@ -77,6 +77,16 @@ Schedule::command('fuel:archive')
->onOneServer() ->onOneServer()
->runInBackground(); ->runInBackground();
// Retry area labels that failed to reverse-geocode at search time (transient
// postcodes.io blip, or a genuinely remote point). Searches normally get their
// area_label inline; this just mops up stragglers. Cached per bucket, so it
// only calls the API for buckets it hasn't resolved yet.
Schedule::command('searches:backfill-areas')
->hourly()
->withoutOverlapping()
->onOneServer()
->runInBackground();
// Scheduled WhatsApp updates — morning and evening // Scheduled WhatsApp updates — morning and evening
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer(); Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer(); Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();

View File

@@ -12,6 +12,17 @@ uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]); $this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
// Every search reverse-geocodes its lat/lng bucket to an area label. Fake the
// postcodes.io reverse endpoint (query-string form) so tests never hit the
// network; the path form (/postcodes/SW1A1AA) used for forward lookups is
// matched by per-test stubs and is unaffected by this.
Http::fake([
'api.postcodes.io/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Testshire']],
]),
]);
}); });
function asPaidUserOnStations(string $tier = 'plus'): User function asPaidUserOnStations(string $tier = 'plus'): User
@@ -114,6 +125,19 @@ it('logs a search record for each request', function () {
]); ]);
}); });
it('stores the reverse-geocoded area label on the search record', function () {
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
StationPriceCurrent::factory()->create(['station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]);
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10');
$this->assertDatabaseHas('searches', [
'lat_bucket' => '52.56',
'lng_bucket' => '-0.26',
'area_label' => 'Testshire',
]);
});
it('returns 422 when required params are missing', function () { it('returns 422 when required params are missing', function () {
$this->getJson('/api/stations?lat=52.5') $this->getJson('/api/stations?lat=52.5')
->assertUnprocessable(); ->assertUnprocessable();

View File

@@ -0,0 +1,50 @@
<?php
use App\Models\Search;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
it('fills the area label for searches missing one, once per bucket', function () {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Peterborough']],
]),
]);
// Two searches share a bucket; a third is in a different bucket.
Search::factory()->count(2)->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => null]);
Search::factory()->create(['lat_bucket' => 51.50, 'lng_bucket' => -0.14, 'area_label' => null]);
$this->artisan('searches:backfill-areas')->assertSuccessful();
expect(Search::whereNull('area_label')->count())->toBe(0)
->and(Search::where('area_label', 'Peterborough')->count())->toBe(3);
// One reverse-geocode call per distinct bucket, not per row.
Http::assertSentCount(2);
});
it('leaves searches that already have an area label untouched', function () {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Peterborough']],
]),
]);
Search::factory()->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => 'Manchester']);
$this->artisan('searches:backfill-areas')->assertSuccessful();
expect(Search::where('area_label', 'Manchester')->count())->toBe(1);
Http::assertNothingSent();
});
it('reports when there is nothing to backfill', function () {
$this->artisan('searches:backfill-areas')
->expectsOutputToContain('No searches need an area label.')
->assertSuccessful();
});

View File

@@ -279,3 +279,76 @@ it('persists an outcode resolved via HTTP fallback', function (): void {
->and((float) $row->lat)->toBe(52.536397) ->and((float) $row->lat)->toBe(52.536397)
->and((float) $row->lng)->toBe(-0.210181); ->and((float) $row->lng)->toBe(-0.210181);
}); });
// --- Reverse geocoding (area label) ---
it('reverse-geocodes coordinates to the admin district', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [
['admin_district' => 'Peterborough', 'region' => 'East of England'],
],
]),
]);
expect($this->service->reverseResolve(52.5364, -0.2102))->toBe('Peterborough');
});
it('falls back to a broader area when admin district is missing', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [
['admin_district' => null, 'region' => 'Scotland'],
],
]),
]);
expect($this->service->reverseResolve(57.4, -4.2))->toBe('Scotland');
});
it('returns null when reverse geocoding finds no area', function (): void {
Http::fake([
'*/postcodes?*' => Http::response(['status' => 200, 'result' => null]),
]);
expect($this->service->reverseResolve(0.0, 0.0))->toBeNull();
});
it('returns null when the reverse geocode request fails', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([], 500),
]);
expect($this->service->reverseResolve(52.5364, -0.2102))->toBeNull();
});
it('caches the reverse-geocoded area per bucket', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Peterborough']],
]),
]);
// Two coordinates inside the same ~1km (2dp) bucket → one HTTP call.
$this->service->reverseResolve(52.5364, -0.2102);
$this->service->reverseResolve(52.5359, -0.2148);
Http::assertSentCount(1);
});
it('queries postcodes.io with a wide radius so low-density buckets still match', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Peterborough']],
]),
]);
$this->service->reverseResolve(52.54, -0.25);
// The default 100m radius misses the bucket centroid in rural areas; we send 2000m.
Http::assertSent(fn ($request) => str_contains($request->url(), 'radius=2000'));
});