2 Commits

Author SHA1 Message Date
Ovidiu U
ea22387c9d 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>
2026-06-12 09:05:03 +01:00
Ovidiu U
040b2f627e Fix ICO no. 2026-06-12 08:37:37 +01:00
12 changed files with 335 additions and 5 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

@@ -14,7 +14,7 @@
</p> </p>
<p> <p>
Ovidiu Ungureanu is registered with the UK Information Commissioner's Office (ICO) as a Ovidiu Ungureanu is registered with the UK Information Commissioner's Office (ICO) as a
data controller. <strong>ICO registration reference: 00014395133.</strong> data controller. <strong>ICO registration reference: ZC171362.</strong>
</p> </p>
<p> <p>
If you have any questions about this policy or how we handle your personal data, contact us at If you have any questions about this policy or how we handle your personal data, contact us at
@@ -276,7 +276,7 @@
</p> </p>
<p class="text-sm text-zinc-600"> <p class="text-sm text-zinc-600">
Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom. Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
ICO registration reference: 00014395133. ICO registration reference: ZC171362.
</p> </p>
</section> </section>
</x-layouts.legal> </x-layouts.legal>

View File

@@ -11,7 +11,7 @@
FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in
Peterborough, United Kingdom ("we", "us", "our"). These terms form a legally binding Peterborough, United Kingdom ("we", "us", "our"). These terms form a legally binding
contract between you and Ovidiu Ungureanu trading as FuelAlert. contract between you and Ovidiu Ungureanu trading as FuelAlert.
ICO registration reference: 00014395133. ICO registration reference: ZC171362.
</p> </p>
<p> <p>
By creating an account or using the service, you confirm that you have read, understood By creating an account or using the service, you confirm that you have read, understood
@@ -242,7 +242,7 @@
</p> </p>
<p class="text-sm text-zinc-600"> <p class="text-sm text-zinc-600">
Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom. Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
ICO registration reference: 00014395133. ICO registration reference: ZC171362.
</p> </p>
</section> </section>
</x-layouts.legal> </x-layouts.legal>

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