Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdcf253ca7 | ||
|
|
cf373a85f9 | ||
|
|
ea22387c9d | ||
|
|
040b2f627e | ||
|
|
5ca7232029 |
54
app/Console/Commands/BackfillSearchAreas.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,13 @@ final class ImportPostcodes extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
// ONS marks non-geographic postcodes (no grid reference) with a
|
||||
// placeholder latitude of 99.999999 — drop them so they don't
|
||||
// poison nearest-station distance maths with a bogus location.
|
||||
if (abs((float) $lat) >= 90) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
|
||||
|
||||
if ($pcd === '' || strlen($pcd) < 5) {
|
||||
|
||||
@@ -28,6 +28,11 @@ class SearchResource extends Resource
|
||||
->label('Searched At')
|
||||
->dateTime('d M Y H:i')
|
||||
->sortable(),
|
||||
TextColumn::make('area_label')
|
||||
->label('Area')
|
||||
->placeholder('Unknown')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('fuel_type')
|
||||
->label('Fuel Type')
|
||||
->badge(),
|
||||
@@ -67,6 +72,15 @@ class SearchResource extends Resource
|
||||
'B10' => 'B10',
|
||||
'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([])
|
||||
->toolbarActions([]);
|
||||
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
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
|
||||
{
|
||||
/** @use HasFactory<SearchFactory> */
|
||||
|
||||
@@ -52,6 +52,77 @@ class PostcodeService
|
||||
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
|
||||
{
|
||||
return strtoupper(preg_replace('/\s+/', '', $value));
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Services\Forecasting\LocalSnapshotService;
|
||||
use App\Services\Forecasting\WeeklyForecastService;
|
||||
use App\Services\HaversineQuery;
|
||||
use App\Services\PlanFeatures;
|
||||
use App\Services\PostcodeService;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -20,6 +21,7 @@ final class StationSearchService
|
||||
public function __construct(
|
||||
private readonly WeeklyForecastService $weeklyForecast,
|
||||
private readonly LocalSnapshotService $localSnapshot,
|
||||
private readonly PostcodeService $postcodeService,
|
||||
) {}
|
||||
|
||||
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
||||
@@ -118,6 +120,7 @@ final class StationSearchService
|
||||
Search::create([
|
||||
'lat_bucket' => round($criteria->lat, 2),
|
||||
'lng_bucket' => round($criteria->lng, 2),
|
||||
'area_label' => $this->postcodeService->reverseResolve($criteria->lat, $criteria->lng),
|
||||
'fuel_type' => $criteria->fuelType->value,
|
||||
'results_count' => $resultsCount,
|
||||
'lowest_pence' => $prices->min(),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
72
deploy.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# FuelAlert deploy script — run on the VPS from the project root:
|
||||
#
|
||||
# ./deploy.sh # deploy the latest main
|
||||
# ./deploy.sh v0.1.3 # deploy a specific tag
|
||||
#
|
||||
# It puts the site in maintenance mode, updates the code, runs migrations and
|
||||
# cache rebuilds, restarts the queue, then brings the site back up. composer
|
||||
# install and npm build only run when their inputs actually changed, so most
|
||||
# deploys skip them. If any step fails the script aborts and the site stays in
|
||||
# maintenance mode on purpose — fix the issue, then re-run.
|
||||
#
|
||||
# See docs/ops/deployment.md for first-time setup and troubleshooting.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
REF="${1:-main}"
|
||||
echo "==> Deploying ref: ${REF}"
|
||||
|
||||
# Remember the current commit so we can see what changed after checkout.
|
||||
BEFORE="$(git rev-parse HEAD)"
|
||||
|
||||
echo "==> Maintenance mode on"
|
||||
php artisan down --retry=15
|
||||
|
||||
git fetch --tags --prune origin
|
||||
git checkout "${REF}"
|
||||
|
||||
# Fast-forward to the remote only when on a branch (a tag leaves a detached HEAD).
|
||||
if git symbolic-ref -q HEAD >/dev/null; then
|
||||
git pull --ff-only
|
||||
fi
|
||||
|
||||
AFTER="$(git rev-parse HEAD)"
|
||||
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}" || true)"
|
||||
|
||||
# Reinstall PHP deps only if the lockfile moved.
|
||||
if grep -q '^composer\.lock$' <<<"${CHANGED}"; then
|
||||
echo "==> composer.lock changed — installing PHP deps"
|
||||
composer install --no-dev --optimize-autoloader
|
||||
else
|
||||
echo "==> composer.lock unchanged — skipping composer install"
|
||||
fi
|
||||
|
||||
# Rebuild the Vue SPA only if frontend sources or the JS lockfile moved.
|
||||
if grep -qE '^(package(-lock)?\.json|vite\.config\.|resources/(js|css)/)' <<<"${CHANGED}"; then
|
||||
echo "==> Frontend changed — rebuilding SPA"
|
||||
npm ci
|
||||
npm run build
|
||||
else
|
||||
echo "==> No frontend changes — skipping npm build"
|
||||
fi
|
||||
|
||||
echo "==> Running migrations"
|
||||
php artisan migrate --force
|
||||
|
||||
echo "==> Rebuilding caches"
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
|
||||
echo "==> Restarting queue workers"
|
||||
php artisan queue:restart
|
||||
|
||||
echo "==> Maintenance mode off"
|
||||
php artisan up
|
||||
|
||||
echo "==> Deploy complete"
|
||||
php artisan about
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 823 B |
@@ -1,3 +1,3 @@
|
||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="166" height="166" viewBox="0 0 512 512">
|
||||
<path fill="#bb5b3e" d="M32 64C32 28.7 60.7 0 96 0L256 0c35.3 0 64 28.7 64 64l0 192 8 0c48.6 0 88 39.4 88 88l0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-154c-27.6-7.1-48-32.2-48-62l0-59.5-25.8-28.3c-8.9-9.8-8.2-25 1.6-33.9s25-8.2 33.9 1.6l71.7 78.8c9.4 10.3 14.6 23.7 14.6 37.7L512 376c0 39.8-32.2 72-72 72s-72-32.2-72-72l0-32c0-22.1-17.9-40-40-40l-8 0 0 161.4c9.3 3.3 16 12.2 16 22.6 0 13.3-10.7 24-24 24L40 512c-13.3 0-24-10.7-24-24 0-10.5 6.7-19.3 16-22.6L32 64zM96 80l0 96c0 8.8 7.2 16 16 16l128 0c8.8 0 16-7.2 16-16l0-96c0-8.8-7.2-16-16-16L112 64c-8.8 0-16 7.2-16 16z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 672 B |
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
<p>
|
||||
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>
|
||||
If you have any questions about this policy or how we handle your personal data, contact us at
|
||||
@@ -276,7 +276,7 @@
|
||||
</p>
|
||||
<p class="text-sm text-zinc-600">
|
||||
Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
||||
ICO registration reference: 00014395133.
|
||||
ICO registration reference: ZC171362.
|
||||
</p>
|
||||
</section>
|
||||
</x-layouts.legal>
|
||||
@@ -11,7 +11,7 @@
|
||||
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
|
||||
contract between you and Ovidiu Ungureanu trading as FuelAlert.
|
||||
ICO registration reference: 00014395133.
|
||||
ICO registration reference: ZC171362.
|
||||
</p>
|
||||
<p>
|
||||
By creating an account or using the service, you confirm that you have read, understood
|
||||
@@ -242,7 +242,7 @@
|
||||
</p>
|
||||
<p class="text-sm text-zinc-600">
|
||||
Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
||||
ICO registration reference: 00014395133.
|
||||
ICO registration reference: ZC171362.
|
||||
</p>
|
||||
</section>
|
||||
</x-layouts.legal>
|
||||
@@ -77,6 +77,16 @@ Schedule::command('fuel:archive')
|
||||
->onOneServer()
|
||||
->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
|
||||
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
|
||||
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();
|
||||
|
||||
@@ -12,6 +12,17 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$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
|
||||
@@ -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 () {
|
||||
$this->getJson('/api/stations?lat=52.5')
|
||||
->assertUnprocessable();
|
||||
|
||||
50
tests/Feature/Console/BackfillSearchAreasTest.php
Normal 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();
|
||||
});
|
||||
@@ -63,6 +63,21 @@ CSV;
|
||||
->and(Postcode::find('BT11AA'))->toBeNull();
|
||||
});
|
||||
|
||||
it('skips postcodes with placeholder coordinates (no grid reference)', function (): void {
|
||||
$csv = <<<'CSV'
|
||||
pcd,pcds,doterm,lat,long
|
||||
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
|
||||
"GIR0AA","GIR 0AA","",99.999999,0.000000
|
||||
CSV;
|
||||
|
||||
$path = writeOnspdFixture($csv);
|
||||
|
||||
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||
|
||||
expect(Postcode::count())->toBe(1)
|
||||
->and(Postcode::find('GIR0AA'))->toBeNull();
|
||||
});
|
||||
|
||||
it('accepts ArcGIS ONSPD exports that use PCD7 instead of PCD', function (): void {
|
||||
$csv = <<<'CSV'
|
||||
OBJECTID,PCD7,PCD8,PCDS,DOTERM,LAT,LONG,x,y
|
||||
|
||||
@@ -279,3 +279,76 @@ it('persists an outcode resolved via HTTP fallback', function (): void {
|
||||
->and((float) $row->lat)->toBe(52.536397)
|
||||
->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'));
|
||||
});
|
||||
|
||||