Merge branch 'feat/self-hosted-postcodes'

Self-hosted UK postcode lookup: ONS Postcode Directory loaded into
local postcodes/outcodes tables; postcodes.io retained as fallback
for place names and unknown postcodes, with successful fallback
results persisted back to the local tables.
This commit is contained in:
Ovidiu U
2026-04-22 13:19:39 +01:00
10 changed files with 1602 additions and 18 deletions

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
final class ImportPostcodes extends Command
{
private const int CHUNK_SIZE = 1000;
public function handle(): int
{
$file = $this->option('file');
if ($file === null || ! is_readable($file)) {
$this->error('--file is required and must be a readable path to an ONSPD CSV.');
return self::FAILURE;
}
$handle = fopen($file, 'r');
if ($handle === false) {
$this->error("Unable to open {$file}.");
return self::FAILURE;
}
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV is empty.');
fclose($handle);
return self::FAILURE;
}
$headerCounts = array_count_values(array_map('strtolower', $header));
foreach (['pcd', 'lat', 'long'] as $required) {
if (($headerCounts[$required] ?? 0) > 1) {
$this->error("Column '{$required}' appears more than once — refusing to import.");
fclose($handle);
return self::FAILURE;
}
}
$columns = array_change_key_case(array_flip($header), CASE_LOWER);
foreach (['pcd', 'lat', 'long'] as $required) {
if (! isset($columns[$required])) {
$this->error("Missing required column '{$required}'.");
fclose($handle);
return self::FAILURE;
}
}
$hasDoterm = isset($columns['doterm']);
DB::table('postcodes')->truncate();
DB::table('outcodes')->truncate();
$buffer = [];
$imported = 0;
while (($row = fgetcsv($handle)) !== false) {
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
continue;
}
$lat = trim((string) ($row[$columns['lat']] ?? ''));
$lng = trim((string) ($row[$columns['long']] ?? ''));
if ($lat === '' || $lng === '') {
continue;
}
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns['pcd']]));
if ($pcd === '' || strlen($pcd) < 5) {
continue;
}
$buffer[] = [
'postcode' => $pcd,
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
'lat' => (float) $lat,
'lng' => (float) $lng,
];
if (count($buffer) >= self::CHUNK_SIZE) {
DB::table('postcodes')->insert($buffer);
$imported += count($buffer);
$buffer = [];
}
}
if ($buffer !== []) {
DB::table('postcodes')->insert($buffer);
$imported += count($buffer);
}
fclose($handle);
DB::statement(
'INSERT INTO outcodes (outcode, lat, lng)
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
);
$this->info("Imported {$imported} postcodes.");
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
return self::SUCCESS;
}
}

26
app/Models/Outcode.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['outcode', 'lat', 'lng'])]
class Outcode extends Model
{
public $timestamps = false;
protected $primaryKey = 'outcode';
public $incrementing = false;
protected $keyType = 'string';
protected function casts(): array
{
return [
'lat' => 'float',
'lng' => 'float',
];
}
}

26
app/Models/Postcode.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['postcode', 'outcode', 'lat', 'lng'])]
class Postcode extends Model
{
public $timestamps = false;
protected $primaryKey = 'postcode';
public $incrementing = false;
protected $keyType = 'string';
protected function casts(): array
{
return [
'lat' => 'float',
'lng' => 'float',
];
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Services; namespace App\Services;
use App\Models\Outcode;
use App\Models\Postcode;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -24,7 +26,16 @@ class PostcodeService
public function resolve(string $query): ?LocationResult public function resolve(string $query): ?LocationResult
{ {
$query = trim($query); $query = trim($query);
$cacheKey = 'postcode:'.strtolower(preg_replace('/\s+/', '', $query));
if ($this->isFullPostcode($query)) {
return $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query);
}
if ($this->isOutcode($query)) {
return $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query);
}
$cacheKey = 'place:'.strtolower(preg_replace('/\s+/', '', $query));
$cached = Cache::get($cacheKey); $cached = Cache::get($cacheKey);
@@ -32,11 +43,7 @@ class PostcodeService
return $cached; return $cached;
} }
$result = match (true) { $result = $this->lookupPlace($query);
$this->isFullPostcode($query) => $this->lookupPostcode($query),
$this->isOutcode($query) => $this->lookupOutcode($query),
default => $this->lookupPlace($query),
};
if ($result !== null) { if ($result !== null) {
Cache::put($cacheKey, $result, self::CACHE_TTL); Cache::put($cacheKey, $result, self::CACHE_TTL);
@@ -45,6 +52,11 @@ class PostcodeService
return $result; return $result;
} }
private function normalisePostcode(string $value): string
{
return strtoupper(preg_replace('/\s+/', '', $value));
}
private function isFullPostcode(string $query): bool private function isFullPostcode(string $query): bool
{ {
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query); return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query);
@@ -55,9 +67,55 @@ class PostcodeService
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query); return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query);
} }
private function lookupLocalPostcode(string $postcode): ?LocationResult
{
$normalised = $this->normalisePostcode($postcode);
$row = Postcode::find($normalised);
if ($row === null) {
return null;
}
return new LocationResult(
query: $postcode,
displayName: $this->formatPostcode($normalised),
lat: $row->lat,
lng: $row->lng,
);
}
private function lookupLocalOutcode(string $outcode): ?LocationResult
{
$normalised = strtoupper(trim($outcode));
$row = Outcode::find($normalised);
if ($row === null) {
return null;
}
return new LocationResult(
query: $outcode,
displayName: $normalised,
lat: $row->lat,
lng: $row->lng,
);
}
private function formatPostcode(string $normalised): string
{
// Insert the single space before the last 3 chars ("SW1A1AA" -> "SW1A 1AA").
if (strlen($normalised) < 5) {
return $normalised;
}
return substr($normalised, 0, -3).' '.substr($normalised, -3);
}
private function lookupPostcode(string $postcode): ?LocationResult private function lookupPostcode(string $postcode): ?LocationResult
{ {
$normalised = strtoupper(preg_replace('/\s+/', '', $postcode)); $normalised = $this->normalisePostcode($postcode);
$url = self::BASE_URL.'/postcodes/'.$normalised; $url = self::BASE_URL.'/postcodes/'.$normalised;
try { try {
@@ -69,12 +127,34 @@ class PostcodeService
$data = $response->json('result'); $data = $response->json('result');
return new LocationResult( if (! is_array($data) || ! isset($data['postcode'], $data['latitude'], $data['longitude'])) {
return null;
}
$result = new LocationResult(
query: $postcode, query: $postcode,
displayName: $data['postcode'], displayName: $data['postcode'],
lat: $data['latitude'], lat: $data['latitude'],
lng: $data['longitude'], lng: $data['longitude'],
); );
try {
Postcode::updateOrCreate(
['postcode' => $normalised],
[
'outcode' => substr($normalised, 0, strlen($normalised) - 3),
'lat' => $data['latitude'],
'lng' => $data['longitude'],
],
);
} catch (Throwable $e) {
Log::warning('PostcodeService: failed to persist postcode after HTTP fallback', [
'postcode' => $normalised,
'error' => $e->getMessage(),
]);
}
return $result;
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('PostcodeService: postcode lookup failed', [ Log::error('PostcodeService: postcode lookup failed', [
'postcode' => $postcode, 'postcode' => $postcode,
@@ -99,12 +179,33 @@ class PostcodeService
$data = $response->json('result'); $data = $response->json('result');
return new LocationResult( if (! is_array($data) || ! isset($data['outcode'], $data['latitude'], $data['longitude'])) {
return null;
}
$result = new LocationResult(
query: $outcode, query: $outcode,
displayName: $data['outcode'], displayName: $data['outcode'],
lat: $data['latitude'], lat: $data['latitude'],
lng: $data['longitude'], lng: $data['longitude'],
); );
try {
Outcode::updateOrCreate(
['outcode' => $normalised],
[
'lat' => $data['latitude'],
'lng' => $data['longitude'],
],
);
} catch (Throwable $e) {
Log::warning('PostcodeService: failed to persist outcode after HTTP fallback', [
'outcode' => $normalised,
'error' => $e->getMessage(),
]);
}
return $result;
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('PostcodeService: outcode lookup failed', [ Log::error('PostcodeService: outcode lookup failed', [
'outcode' => $outcode, 'outcode' => $outcode,

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('postcodes', function (Blueprint $table): void {
$table->string('postcode', 7)->primary()->comment('Normalised: uppercase, no spaces');
$table->string('outcode', 4)->index();
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
});
}
public function down(): void
{
Schema::dropIfExists('postcodes');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('outcodes', function (Blueprint $table): void {
$table->string('outcode', 4)->primary();
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
});
}
public function down(): void
{
Schema::dropIfExists('outcodes');
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -380,6 +380,7 @@
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500"> <div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p> <p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
<p>Data provided by official UK retail price transparency schemes.</p> <p>Data provided by official UK retail price transparency schemes.</p>
<p>Postcode data contains OS data © Crown copyright &amp; database right, Royal Mail data © Royal Mail copyright &amp; database right, and National Statistics data © Crown copyright &amp; database right.</p>
</div> </div>
</footer> </footer>

View File

@@ -0,0 +1,84 @@
<?php
use App\Models\Outcode;
use App\Models\Postcode;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
if (! function_exists('writeOnspdFixture')) {
function writeOnspdFixture(string $contents): string
{
$path = tempnam(sys_get_temp_dir(), 'onspd_').'.csv';
file_put_contents($path, $contents);
return $path;
}
}
it('imports active postcodes from an ONSPD CSV', function (): void {
$csv = <<<'CSV'
pcd,pcds,doterm,lat,long
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
"M11AD","M1 1AD",,53.480957,-2.237428
CSV;
$path = writeOnspdFixture($csv);
$this->artisan('postcodes:import', ['--file' => $path])
->assertSuccessful();
expect(Postcode::count())->toBe(2)
->and(Postcode::find('SW1A1AA')->outcode)->toBe('SW1A')
->and(Postcode::find('M11AD')->outcode)->toBe('M1');
});
it('skips terminated postcodes', function (): void {
$csv = <<<'CSV'
pcd,pcds,doterm,lat,long
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
"OLD1AA","OLD 1AA","202301",50.000000,-1.000000
CSV;
$path = writeOnspdFixture($csv);
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
expect(Postcode::count())->toBe(1)
->and(Postcode::find('OLD1AA'))->toBeNull();
});
it('skips rows with blank coordinates', function (): void {
$csv = <<<'CSV'
pcd,pcds,doterm,lat,long
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
"BT11AA","BT1 1AA","",,
CSV;
$path = writeOnspdFixture($csv);
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
expect(Postcode::count())->toBe(1)
->and(Postcode::find('BT11AA'))->toBeNull();
});
it('derives outcode centroids as the average of member postcodes', function (): void {
$csv = <<<'CSV'
pcd,pcds,doterm,lat,long
"PE71AA","PE7 1AA","",52.500000,-0.200000
"PE71AB","PE7 1AB","",52.600000,-0.220000
"M11AD","M1 1AD","",53.480957,-2.237428
CSV;
$path = writeOnspdFixture($csv);
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
$pe7 = Outcode::find('PE7');
expect($pe7)->not->toBeNull()
->and(round((float) $pe7->lat, 6))->toBe(52.550000)
->and(round((float) $pe7->lng, 6))->toBe(-0.210000)
->and(Outcode::find('M1'))->not->toBeNull();
});

View File

@@ -1,5 +1,7 @@
<?php <?php
use App\Models\Outcode;
use App\Models\Postcode;
use App\Services\ApiLogger; use App\Services\ApiLogger;
use App\Services\LocationResult; use App\Services\LocationResult;
use App\Services\PostcodeService; use App\Services\PostcodeService;
@@ -132,23 +134,41 @@ it('returns null when place name yields no results', function (): void {
// --- Caching --- // --- Caching ---
it('caches a successful resolution for 30 days', function (): void { it('caches a successful place resolution for 30 days', function (): void {
Http::fake([ Http::fake([
'*/outcodes/PE7' => Http::response([ '*/places*' => Http::response([
'status' => 200,
'result' => [[
'name_1' => 'Manchester',
'latitude' => 53.480957,
'longitude' => -2.237428,
]],
]),
]);
$this->service->resolve('Manchester');
$this->service->resolve('Manchester');
Http::assertSentCount(1);
expect(Cache::get('place:manchester'))->toBeInstanceOf(LocationResult::class);
});
it('does not cache postcode resolutions in the Cache store (DB is the cache)', function (): void {
Http::fake([
'*/postcodes/SW1A1AA' => Http::response([
'status' => 200, 'status' => 200,
'result' => [ 'result' => [
'outcode' => 'PE7', 'postcode' => 'SW1A 1AA',
'latitude' => 52.536397, 'latitude' => 51.501009,
'longitude' => -0.210181, 'longitude' => -0.141588,
], ],
]), ]),
]); ]);
$this->service->resolve('PE7'); $this->service->resolve('SW1A 1AA');
$this->service->resolve('PE7');
Http::assertSentCount(1); expect(Cache::get('postcode:sw1a1aa'))->toBeNull()
expect(Cache::get('postcode:pe7'))->toBeInstanceOf(LocationResult::class); ->and(Postcode::find('SW1A1AA'))->not->toBeNull();
}); });
it('does not cache failed lookups', function (): void { it('does not cache failed lookups', function (): void {
@@ -171,3 +191,91 @@ it('returns null and does not throw on API failure', function (): void {
expect($result)->toBeNull(); expect($result)->toBeNull();
}); });
// --- Local DB (full postcode) ---
it('resolves a full postcode from local DB without calling HTTP', function (): void {
Postcode::create([
'postcode' => 'SW1A1AA',
'outcode' => 'SW1A',
'lat' => 51.501009,
'lng' => -0.141588,
]);
Http::fake(); // any HTTP call will be recorded
$result = $this->service->resolve('SW1A 1AA');
expect($result)->toBeInstanceOf(LocationResult::class)
->and($result->displayName)->toBe('SW1A 1AA')
->and($result->lat)->toBe(51.501009)
->and($result->lng)->toBe(-0.141588);
Http::assertNothingSent();
});
// --- Local DB (outcode) ---
it('resolves an outcode from local DB without calling HTTP', function (): void {
Outcode::create([
'outcode' => 'PE7',
'lat' => 52.536397,
'lng' => -0.210181,
]);
Http::fake();
$result = $this->service->resolve('PE7');
expect($result)->toBeInstanceOf(LocationResult::class)
->and($result->displayName)->toBe('PE7')
->and($result->lat)->toBe(52.536397)
->and($result->lng)->toBe(-0.210181);
Http::assertNothingSent();
});
// --- HTTP fallback persistence ---
it('persists a full postcode resolved via HTTP fallback', function (): void {
Http::fake([
'*/postcodes/SW1A1AA' => Http::response([
'status' => 200,
'result' => [
'postcode' => 'SW1A 1AA',
'latitude' => 51.501009,
'longitude' => -0.141588,
],
]),
]);
$this->service->resolve('SW1A 1AA');
$row = Postcode::find('SW1A1AA');
expect($row)->not->toBeNull()
->and($row->outcode)->toBe('SW1A')
->and((float) $row->lat)->toBe(51.501009)
->and((float) $row->lng)->toBe(-0.141588);
});
it('persists an outcode resolved via HTTP fallback', function (): void {
Http::fake([
'*/outcodes/PE7' => Http::response([
'status' => 200,
'result' => [
'outcode' => 'PE7',
'latitude' => 52.536397,
'longitude' => -0.210181,
],
]),
]);
$this->service->resolve('PE7');
$row = Outcode::find('PE7');
expect($row)->not->toBeNull()
->and((float) $row->lat)->toBe(52.536397)
->and((float) $row->lng)->toBe(-0.210181);
});