Files
fuel-price/docs/superpowers/plans/2026-04-22-self-hosted-postcodes.md
2026-04-22 13:19:33 +01:00

1072 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# UK Postcode Self-Hosting Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Resolve full UK postcodes and outcodes to lat/lng from a local MySQL table sourced from the ONS Postcode Directory (ONSPD), keeping postcodes.io only as a fallback for place names and unknown postcodes.
**Architecture:** Two new tables (`postcodes`, `outcodes`) seeded from the quarterly ONSPD CSV by an idempotent Artisan command (`postcodes:import --file=...`). `PostcodeService::resolve()` checks local DB first; when a full postcode or outcode lookup misses, it falls through to the existing HTTP client and persists the successful result back into the table. Place-name lookups still go to postcodes.io (not in ONSPD). The 30-day `Cache::put` wrapper is removed for postcode/outcode paths (DB IS the cache) and re-scoped to a `place:` prefix for place names only.
**Tech Stack:** Laravel 13, MySQL, Pest 4, PHP 8.4.
---
## File Structure
**Create:**
- `database/migrations/2026_04_22_100000_create_postcodes_table.php`
- `database/migrations/2026_04_22_100001_create_outcodes_table.php`
- `app/Models/Postcode.php`
- `app/Models/Outcode.php`
- `app/Console/Commands/ImportPostcodes.php`
- `tests/Feature/Console/ImportPostcodesTest.php`
**Modify:**
- `app/Services/PostcodeService.php` — add local lookup + HTTP fallback persistence
- `tests/Unit/Services/PostcodeServiceTest.php` — update existing HTTP-heavy tests for new DB-first flow
- `resources/js/views/Home.vue` — add ONS/Royal Mail/OS attribution line in footer
---
### Task 1: Create `postcodes` and `outcodes` tables
**Files:**
- Create: `database/migrations/2026_04_22_100000_create_postcodes_table.php`
- Create: `database/migrations/2026_04_22_100001_create_outcodes_table.php`
- [ ] **Step 1: Generate migrations**
Run:
```bash
php artisan make:migration create_postcodes_table --no-interaction
php artisan make:migration create_outcodes_table --no-interaction
```
Rename the two generated files so their timestamps are `2026_04_22_100000` and `2026_04_22_100001` respectively (so they sit together in order).
- [ ] **Step 2: Fill in `create_postcodes_table`**
Replace file contents with:
```php
<?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');
}
};
```
- [ ] **Step 3: Fill in `create_outcodes_table`**
Replace file contents with:
```php
<?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');
}
};
```
- [ ] **Step 4: Run migrations**
Run: `php artisan migrate`
Expected: both tables created, no errors.
- [ ] **Step 5: Commit**
```bash
git add database/migrations/2026_04_22_100000_create_postcodes_table.php \
database/migrations/2026_04_22_100001_create_outcodes_table.php
git commit -m "feat: add postcodes and outcodes tables for self-hosted lookup"
```
---
### Task 2: Create `Postcode` and `Outcode` Eloquent models
**Files:**
- Create: `app/Models/Postcode.php`
- Create: `app/Models/Outcode.php`
- [ ] **Step 1: Generate both models**
Run:
```bash
php artisan make:model Postcode --no-interaction
php artisan make:model Outcode --no-interaction
```
- [ ] **Step 2: Configure `Postcode` model**
Replace `app/Models/Postcode.php` with:
```php
<?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',
];
}
}
```
- [ ] **Step 3: Configure `Outcode` model**
Replace `app/Models/Outcode.php` with:
```php
<?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',
];
}
}
```
- [ ] **Step 4: Commit**
```bash
git add app/Models/Postcode.php app/Models/Outcode.php
git commit -m "feat: add Postcode and Outcode Eloquent models"
```
---
### Task 3: Resolve full postcodes from local DB first
**Files:**
- Modify: `app/Services/PostcodeService.php`
- Modify: `tests/Unit/Services/PostcodeServiceTest.php`
- [ ] **Step 1: Add failing test — local DB hit skips HTTP**
Append to `tests/Unit/Services/PostcodeServiceTest.php`:
```php
// --- Local DB (full postcode) ---
it('resolves a full postcode from local DB without calling HTTP', function (): void {
\App\Models\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();
});
```
- [ ] **Step 2: Run test to confirm it fails**
Run: `timeout 10 php artisan test --compact --filter='resolves a full postcode from local DB'`
Expected: FAIL — test passes the resolve call but currently no local lookup exists, so HTTP `Http::fake()` returns empty and `resolve()` returns null (or sends HTTP and fails the `assertNothingSent`).
- [ ] **Step 3: Implement local full-postcode lookup**
In `app/Services/PostcodeService.php`, add a new private method above `lookupPostcode`:
```php
private function lookupLocalPostcode(string $postcode): ?LocationResult
{
$normalised = strtoupper(preg_replace('/\s+/', '', $postcode));
$row = \App\Models\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 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);
}
```
Then modify the `resolve()` method — change the full-postcode branch so it checks local first:
Replace this existing block inside `resolve()`:
```php
$result = match (true) {
$this->isFullPostcode($query) => $this->lookupPostcode($query),
$this->isOutcode($query) => $this->lookupOutcode($query),
default => $this->lookupPlace($query),
};
```
With:
```php
$result = match (true) {
$this->isFullPostcode($query) => $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query),
$this->isOutcode($query) => $this->lookupOutcode($query),
default => $this->lookupPlace($query),
};
```
- [ ] **Step 4: Run test — expect PASS**
Run: `timeout 10 php artisan test --compact --filter='resolves a full postcode from local DB'`
Expected: PASS.
- [ ] **Step 5: Run full PostcodeServiceTest — existing HTTP-based tests must still pass (local table is empty for those)**
Run: `timeout 10 php artisan test --compact --filter=PostcodeServiceTest`
Expected: all tests pass. The existing HTTP tests start with an empty DB, so they fall through to the HTTP fake as before.
- [ ] **Step 6: Commit**
```bash
git add app/Services/PostcodeService.php tests/Unit/Services/PostcodeServiceTest.php
git commit -m "feat: resolve full postcodes from local DB before HTTP"
```
---
### Task 4: Resolve outcodes from local DB first
**Files:**
- Modify: `app/Services/PostcodeService.php`
- Modify: `tests/Unit/Services/PostcodeServiceTest.php`
- [ ] **Step 1: Add failing test — local outcode hit skips HTTP**
Append to `tests/Unit/Services/PostcodeServiceTest.php`:
```php
// --- Local DB (outcode) ---
it('resolves an outcode from local DB without calling HTTP', function (): void {
\App\Models\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();
});
```
- [ ] **Step 2: Run test to confirm it fails**
Run: `timeout 10 php artisan test --compact --filter='resolves an outcode from local DB'`
Expected: FAIL.
- [ ] **Step 3: Implement local outcode lookup**
In `app/Services/PostcodeService.php`, add after `lookupLocalPostcode`:
```php
private function lookupLocalOutcode(string $outcode): ?LocationResult
{
$normalised = strtoupper(trim($outcode));
$row = \App\Models\Outcode::find($normalised);
if ($row === null) {
return null;
}
return new LocationResult(
query: $outcode,
displayName: $normalised,
lat: $row->lat,
lng: $row->lng,
);
}
```
Update the match in `resolve()` again:
```php
$result = match (true) {
$this->isFullPostcode($query) => $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query),
$this->isOutcode($query) => $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query),
default => $this->lookupPlace($query),
};
```
- [ ] **Step 4: Run test — expect PASS**
Run: `timeout 10 php artisan test --compact --filter='resolves an outcode from local DB'`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add app/Services/PostcodeService.php tests/Unit/Services/PostcodeServiceTest.php
git commit -m "feat: resolve outcodes from local DB before HTTP"
```
---
### Task 5: Persist HTTP fallback results into local DB
**Files:**
- Modify: `app/Services/PostcodeService.php`
- Modify: `tests/Unit/Services/PostcodeServiceTest.php`
- [ ] **Step 1: Add failing test — HTTP fallback persists postcode**
Append to `tests/Unit/Services/PostcodeServiceTest.php`:
```php
// --- 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 = \App\Models\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 = \App\Models\Outcode::find('PE7');
expect($row)->not->toBeNull()
->and((float) $row->lat)->toBe(52.536397)
->and((float) $row->lng)->toBe(-0.210181);
});
```
- [ ] **Step 2: Run tests — expect FAIL (rows not persisted yet)**
Run: `timeout 10 php artisan test --compact --filter='persists'`
Expected: both tests FAIL — rows are null.
- [ ] **Step 3: Add persistence after successful HTTP lookup**
In `app/Services/PostcodeService.php`, update `lookupPostcode` — after the `LocationResult` is constructed, persist:
Replace the current body of `lookupPostcode` with:
```php
private function lookupPostcode(string $postcode): ?LocationResult
{
$normalised = strtoupper(preg_replace('/\s+/', '', $postcode));
$url = self::BASE_URL.'/postcodes/'.$normalised;
try {
$response = $this->apiLogger->send('postcodes_io', 'GET', $url, fn () => Http::timeout(10)->get($url));
if (! $response->successful()) {
return null;
}
$data = $response->json('result');
$result = new LocationResult(
query: $postcode,
displayName: $data['postcode'],
lat: $data['latitude'],
lng: $data['longitude'],
);
\App\Models\Postcode::updateOrCreate(
['postcode' => $normalised],
[
'outcode' => substr($normalised, 0, strlen($normalised) - 3),
'lat' => $data['latitude'],
'lng' => $data['longitude'],
],
);
return $result;
} catch (Throwable $e) {
Log::error('PostcodeService: postcode lookup failed', [
'postcode' => $postcode,
'error' => $e->getMessage(),
]);
return null;
}
}
```
Replace the current body of `lookupOutcode` with:
```php
private function lookupOutcode(string $outcode): ?LocationResult
{
$normalised = strtoupper(trim($outcode));
$url = self::BASE_URL.'/outcodes/'.$normalised;
try {
$response = $this->apiLogger->send('postcodes_io', 'GET', $url, fn () => Http::timeout(10)->get($url));
if (! $response->successful()) {
return null;
}
$data = $response->json('result');
$result = new LocationResult(
query: $outcode,
displayName: $data['outcode'],
lat: $data['latitude'],
lng: $data['longitude'],
);
\App\Models\Outcode::updateOrCreate(
['outcode' => $normalised],
[
'lat' => $data['latitude'],
'lng' => $data['longitude'],
],
);
return $result;
} catch (Throwable $e) {
Log::error('PostcodeService: outcode lookup failed', [
'outcode' => $outcode,
'error' => $e->getMessage(),
]);
return null;
}
}
```
- [ ] **Step 4: Run persistence tests — expect PASS**
Run: `timeout 10 php artisan test --compact --filter='persists'`
Expected: PASS.
- [ ] **Step 5: Run full PostcodeServiceTest file**
Run: `timeout 10 php artisan test --compact --filter=PostcodeServiceTest`
Expected: all tests pass.
- [ ] **Step 6: Commit**
```bash
git add app/Services/PostcodeService.php tests/Unit/Services/PostcodeServiceTest.php
git commit -m "feat: persist postcodes.io fallback results into local DB"
```
---
### Task 6: Remove stale 30-day Cache for postcodes/outcodes; scope cache to places
**Files:**
- Modify: `app/Services/PostcodeService.php`
- Modify: `tests/Unit/Services/PostcodeServiceTest.php`
- [ ] **Step 1: Update the existing caching test so it only covers places**
In `tests/Unit/Services/PostcodeServiceTest.php`, replace the existing test titled `'caches a successful resolution for 30 days'` with:
```php
it('caches a successful place resolution for 30 days', function (): void {
Http::fake([
'*/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,
'result' => [
'postcode' => 'SW1A 1AA',
'latitude' => 51.501009,
'longitude' => -0.141588,
],
]),
]);
$this->service->resolve('SW1A 1AA');
expect(Cache::get('postcode:sw1a1aa'))->toBeNull()
->and(\App\Models\Postcode::find('SW1A1AA'))->not->toBeNull();
});
```
- [ ] **Step 2: Run the two cache tests — expect FAIL (cache key still written under old prefix)**
Run: `timeout 10 php artisan test --compact --filter='cache'`
Expected: FAIL — old code still writes under `postcode:...` for all lookup types.
- [ ] **Step 3: Scope the Cache wrapper to place names only**
In `app/Services/PostcodeService.php`, replace the `resolve()` method with:
```php
public function resolve(string $query): ?LocationResult
{
$query = trim($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);
if ($cached instanceof LocationResult) {
return $cached;
}
$result = $this->lookupPlace($query);
if ($result !== null) {
Cache::put($cacheKey, $result, self::CACHE_TTL);
}
return $result;
}
```
- [ ] **Step 4: Run PostcodeServiceTest — expect PASS**
Run: `timeout 10 php artisan test --compact --filter=PostcodeServiceTest`
Expected: all tests pass. If an older test still references the `postcode:` cache key for a place lookup, update it to `place:` — nothing else should break.
- [ ] **Step 5: Commit**
```bash
git add app/Services/PostcodeService.php tests/Unit/Services/PostcodeServiceTest.php
git commit -m "refactor: scope postcode cache to place names, DB is authoritative for postcodes"
```
---
### Task 7: `postcodes:import` command — parse ONSPD CSV and load `postcodes` table
**Files:**
- Create: `app/Console/Commands/ImportPostcodes.php`
- Create: `tests/Feature/Console/ImportPostcodesTest.php`
- [ ] **Step 1: Generate the command skeleton**
Run:
```bash
php artisan make:command ImportPostcodes --no-interaction
php artisan make:test --pest Console/ImportPostcodesTest --no-interaction
```
- [ ] **Step 2: Write failing test — small fixture CSV populates `postcodes`**
Replace `tests/Feature/Console/ImportPostcodesTest.php` with:
```php
<?php
use App\Models\Outcode;
use App\Models\Postcode;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
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');
});
```
- [ ] **Step 3: Run test — expect FAIL**
Run: `timeout 10 php artisan test --compact --filter='imports active postcodes'`
Expected: FAIL — command not implemented.
- [ ] **Step 4: Implement the command**
Replace `app/Console/Commands/ImportPostcodes.php` with:
```php
<?php
namespace App\Console\Commands;
use App\Models\Outcode;
use App\Models\Postcode;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use RuntimeException;
#[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) {
throw new RuntimeException("Unable to open {$file}");
}
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV is empty.');
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);
$this->info("Imported {$imported} postcodes.");
return self::SUCCESS;
}
}
```
- [ ] **Step 5: Run the test — expect PASS**
Run: `timeout 10 php artisan test --compact --filter='imports active postcodes'`
Expected: PASS. Two rows in `postcodes`.
- [ ] **Step 6: Commit**
```bash
git add app/Console/Commands/ImportPostcodes.php tests/Feature/Console/ImportPostcodesTest.php
git commit -m "feat: add postcodes:import command for loading ONSPD CSV"
```
---
### Task 8: Import command skips terminated rows and blank coordinates
**Files:**
- Modify: `tests/Feature/Console/ImportPostcodesTest.php`
- [ ] **Step 1: Add failing tests for skip behaviour**
Append to `tests/Feature/Console/ImportPostcodesTest.php`:
```php
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();
});
```
- [ ] **Step 2: Run tests — expect PASS (filters already coded in Task 7)**
Run: `timeout 10 php artisan test --compact --filter=ImportPostcodes`
Expected: PASS. If either test fails, revisit the filter logic in Task 7 before continuing.
- [ ] **Step 3: Commit**
```bash
git add tests/Feature/Console/ImportPostcodesTest.php
git commit -m "test: cover terminated + blank-coord skip paths for postcodes:import"
```
---
### Task 9: Derive outcode centroids during import
**Files:**
- Modify: `app/Console/Commands/ImportPostcodes.php`
- Modify: `tests/Feature/Console/ImportPostcodesTest.php`
- [ ] **Step 1: Add failing test — outcodes populated with averaged coords**
Append to `tests/Feature/Console/ImportPostcodesTest.php`:
```php
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();
});
```
- [ ] **Step 2: Run test — expect FAIL**
Run: `timeout 10 php artisan test --compact --filter='derives outcode centroids'`
Expected: FAIL — outcodes table empty.
- [ ] **Step 3: Populate outcodes after postcode insert**
In `app/Console/Commands/ImportPostcodes.php`, after the `fclose($handle);` line and before `$this->info(...)`, insert:
```php
DB::statement(
'INSERT INTO outcodes (outcode, lat, lng)
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
);
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
```
- [ ] **Step 4: Run test — expect PASS**
Run: `timeout 10 php artisan test --compact --filter='derives outcode centroids'`
Expected: PASS.
- [ ] **Step 5: Run full ImportPostcodes test file**
Run: `timeout 10 php artisan test --compact --filter=ImportPostcodes`
Expected: all four tests pass.
- [ ] **Step 6: Commit**
```bash
git add app/Console/Commands/ImportPostcodes.php tests/Feature/Console/ImportPostcodesTest.php
git commit -m "feat: derive outcode centroids from postcodes during import"
```
---
### Task 10: Add ONS / Royal Mail / Ordnance Survey attribution
**Files:**
- Modify: `resources/js/views/Home.vue`
The Open Government Licence v3 requires acknowledgement of ONS, Royal Mail, and Ordnance Survey when using ONSPD data.
- [ ] **Step 1: Add the attribution line in the footer**
In `resources/js/views/Home.vue`, locate the footer bottom-bar block that contains:
```html
<p>Data provided by official UK retail price transparency schemes.</p>
```
Directly below that `<p>`, add a second line:
```html
<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>
```
- [ ] **Step 2: Visually check the footer**
Run: `npm run dev` and load the home page in the browser. Confirm the attribution line is visible under the data-provider line. There is nothing to test programmatically here.
- [ ] **Step 3: Commit**
```bash
git add resources/js/views/Home.vue
git commit -m "docs: add ONS/Royal Mail/OS attribution required by OGL v3"
```
---
### Task 11: Run the real import end-to-end (manual)
This step is not a coding task — it is how you actually populate the table.
- [ ] **Step 1: Download ONSPD**
Go to the ONS Open Geography Portal and download the latest ONSPD release as a ZIP (~350MB). Extract the single-file CSV `Data/ONSPD_<MMM_YYYY>_UK.csv`.
- [ ] **Step 2: Run the import**
Run:
```bash
php artisan postcodes:import --file=/absolute/path/to/ONSPD_MMM_YYYY_UK.csv
```
Expected: takes 3090s; reports `Imported ~2,600,000 postcodes.` and `Derived ~2,900 outcode centroids.` (numbers approximate).
- [ ] **Step 3: Spot-check via tinker**
Run:
```bash
php artisan tinker --execute 'echo App\Models\Postcode::count();'
php artisan tinker --execute 'print_r(App\Models\Postcode::find("SW1A1AA")?->toArray());'
php artisan tinker --execute 'print_r(App\Models\Outcode::find("PE7")?->toArray());'
```
Expected: row counts in the millions / thousands; SW1A1AA resolves to ~(51.501, -0.142); PE7 resolves to a centroid near Peterborough.
- [ ] **Step 4: Format PHP**
Run: `vendor/bin/pint --dirty --format agent`
- [ ] **Step 5: Final full-suite check**
Run: `php artisan test --compact`
Expected: all tests green.
---
## Self-Review Notes
- **Spec coverage:** migrations ✓, models ✓, local DB lookup for full postcode ✓ and outcode ✓, HTTP fallback persistence ✓, cache re-scoping ✓, import command + terminated/blank filter ✓, outcode centroid derivation ✓, attribution ✓, end-to-end run ✓.
- **Method signatures are consistent:** `lookupLocalPostcode`, `lookupLocalOutcode`, `formatPostcode` appear exactly with those names in every task that references them.
- **Idempotency:** `postcodes:import` truncates both tables before inserting, so it is safe to re-run for quarterly refreshes.
- **No scheduler automation in v1:** ONSPD is quarterly and requires manual download (license click-through) — the command is operator-run by design. A scheduled auto-fetch can be added later if the user wants it.