- Add PostcodeService to resolve UK postcodes, outcodes, and place names to coordinates via postcodes.io API with 30-day caching - Add LocationResult value object for resolved location data - Add per-fuel-type price validation (80p-1050p range) to FuelPriceService with warning logs for out-of-range prices - Change price_pence column from unsignedSmallInteger to unsignedMediumInteger in station_prices tables - Add CHECK constraints (5000-50000 range) on price_pence columns as database-level guard - Improve error handling in PollFuelPrices command with file/line/trace output - Add tests for PostcodeService covering full postcodes, outcodes, place names, caching, and error handling - Add test for price validation range checks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
290 lines
10 KiB
PHP
290 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\FuelType;
|
||
use App\Models\Station;
|
||
use App\Models\StationPrice;
|
||
use App\Models\StationPriceCurrent;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Throwable;
|
||
use ValueError;
|
||
|
||
class FuelPriceService
|
||
{
|
||
private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token';
|
||
|
||
/**
|
||
* Per-fuel-type valid price range in pence (as returned by the API).
|
||
* Based on UK all-time records + 30–75% headroom for future spikes.
|
||
* All-time records: petrol 191.6p, diesel 199.2p (Jul 2022).
|
||
*
|
||
* @var array<string, array{min: int, max: int}>
|
||
*/
|
||
private const array PRICE_LIMITS_PENCE = [
|
||
'e10' => ['min' => 80, 'max' => 750],
|
||
'e5' => ['min' => 80, 'max' => 840],
|
||
'b7_standard' => ['min' => 80, 'max' => 840],
|
||
'b7_premium' => ['min' => 80, 'max' => 960],
|
||
'b10' => ['min' => 80, 'max' => 840],
|
||
'hvo' => ['min' => 80, 'max' => 1050],
|
||
];
|
||
|
||
public function __construct(
|
||
private readonly StationTaggingService $taggingService,
|
||
private readonly ApiLogger $apiLogger,
|
||
) {}
|
||
|
||
public function getAccessToken(): string
|
||
{
|
||
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
|
||
$url = config('services.fuel_finder.base_url').'/oauth/generate_access_token';
|
||
$response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::timeout(10)
|
||
->post($url, [
|
||
'client_id' => config('services.fuel_finder.client_id'),
|
||
'client_secret' => config('services.fuel_finder.client_secret'),
|
||
]));
|
||
|
||
return $response->json('data.access_token');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Poll the prices endpoint, deduplicate, and persist changes.
|
||
*
|
||
* @return int Number of new price records inserted
|
||
*/
|
||
public function pollPrices(): int
|
||
{
|
||
$token = $this->getAccessToken();
|
||
$inserted = 0;
|
||
$batch = 1;
|
||
|
||
do {
|
||
try {
|
||
$baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices';
|
||
$params = ['batch-number' => $batch];
|
||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
||
->withToken($token)
|
||
->get($baseUrl, $params));
|
||
|
||
if ($response->notFound()) {
|
||
break; // No more batches
|
||
}
|
||
|
||
if (! $response->successful()) {
|
||
Log::error('FuelPriceService: price batch returned error', [
|
||
'batch' => $batch,
|
||
'status' => $response->status(),
|
||
]);
|
||
break;
|
||
}
|
||
|
||
$stations = $response->json() ?? [];
|
||
} catch (Throwable $e) {
|
||
Log::error('FuelPriceService: price batch fetch failed', [
|
||
'batch' => $batch,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
break;
|
||
}
|
||
|
||
if (empty($stations)) {
|
||
break;
|
||
}
|
||
|
||
$inserted += $this->processPriceBatch($stations);
|
||
$batch++;
|
||
} while (true);
|
||
|
||
return $inserted;
|
||
}
|
||
|
||
/**
|
||
* Fetch and upsert all station metadata.
|
||
* Called on full daily refresh before pollPrices().
|
||
*/
|
||
public function refreshStations(): void
|
||
{
|
||
$token = $this->getAccessToken();
|
||
$batch = 1;
|
||
|
||
do {
|
||
try {
|
||
$baseUrl = config('services.fuel_finder.base_url').'/pfs';
|
||
$params = ['batch-number' => $batch];
|
||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
||
->withToken($token)
|
||
->get($baseUrl, $params));
|
||
|
||
if ($response->notFound()) {
|
||
break; // No more batches
|
||
}
|
||
|
||
if (! $response->successful()) {
|
||
Log::error('FuelPriceService: station batch returned error', [
|
||
'batch' => $batch,
|
||
'status' => $response->status(),
|
||
]);
|
||
break;
|
||
}
|
||
|
||
$stations = $response->json() ?? [];
|
||
} catch (Throwable $e) {
|
||
Log::error('FuelPriceService: station batch fetch failed', [
|
||
'batch' => $batch,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
break;
|
||
}
|
||
|
||
if (empty($stations)) {
|
||
break;
|
||
}
|
||
|
||
$this->upsertStations($stations);
|
||
$batch++;
|
||
} while (true);
|
||
}
|
||
|
||
/** @param array<int, array<string, mixed>> $apiStations */
|
||
public function upsertStations(array $apiStations): void
|
||
{
|
||
$now = now();
|
||
$rows = [];
|
||
|
||
foreach ($apiStations as $data) {
|
||
$station = new Station([
|
||
'node_id' => $data['node_id'],
|
||
'trading_name' => $data['trading_name'],
|
||
'brand_name' => $data['brand_name'] ?? null,
|
||
'is_same_trading_and_brand' => $data['is_same_trading_and_brand_name'] ?? false,
|
||
'is_supermarket' => false,
|
||
'is_motorway_service_station' => $data['is_motorway_service_station'] ?? false,
|
||
'is_supermarket_service_station' => $data['is_supermarket_service_station'] ?? false,
|
||
'temporary_closure' => $data['temporary_closure'] ?? false,
|
||
'permanent_closure' => $data['permanent_closure'] ?? false,
|
||
'permanent_closure_date' => $data['permanent_closure_date'] ?? null,
|
||
'public_phone_number' => $data['public_phone_number'] ?? null,
|
||
'address_line_1' => $data['location']['address_line_1'] ?? null,
|
||
'address_line_2' => $data['location']['address_line_2'] ?? null,
|
||
'city' => $data['location']['city'] ?? null,
|
||
'county' => $data['location']['county'] ?? null,
|
||
'country' => $data['location']['country'] ?? null,
|
||
'postcode' => $data['location']['postcode'],
|
||
'lat' => $data['location']['latitude'],
|
||
'lng' => $data['location']['longitude'],
|
||
'amenities' => $data['amenities'] ?? [],
|
||
'opening_times' => $data['opening_times'] ?? null,
|
||
'fuel_types' => $data['fuel_types'] ?? [],
|
||
'last_seen_at' => $now,
|
||
]);
|
||
|
||
$this->taggingService->tag($station);
|
||
$rows[] = $station->getAttributes();
|
||
}
|
||
|
||
Station::upsert($rows, ['node_id'], array_keys($rows[0] ?? []));
|
||
}
|
||
|
||
private function isValidPrice(FuelType $fuelType, float $pricePence): bool
|
||
{
|
||
$limits = self::PRICE_LIMITS_PENCE[$fuelType->value] ?? null;
|
||
|
||
if ($limits === null) {
|
||
return false;
|
||
}
|
||
|
||
return $pricePence >= $limits['min'] && $pricePence <= $limits['max'];
|
||
}
|
||
|
||
/**
|
||
* Process one batch of API price data.
|
||
*
|
||
* Loads current prices for all stations in the batch, inserts a new
|
||
* station_prices row only when the price has changed, and upserts
|
||
* station_prices_current to reflect the latest known price.
|
||
*
|
||
* @param array<int, array<string, mixed>> $apiBatch
|
||
*/
|
||
private function processPriceBatch(array $apiBatch): int
|
||
{
|
||
$stationIds = array_column($apiBatch, 'node_id');
|
||
|
||
// Load current prices for all stations in this batch in one query
|
||
$currentPrices = StationPriceCurrent::whereIn('station_id', $stationIds)
|
||
->get()
|
||
->groupBy('station_id')
|
||
->map(fn ($rows) => $rows->keyBy(fn ($r) => $r->fuel_type->value));
|
||
|
||
$now = now();
|
||
$newPrices = [];
|
||
$upsertRows = [];
|
||
|
||
foreach ($apiBatch as $station) {
|
||
$stationId = $station['node_id'];
|
||
|
||
foreach ($station['fuel_prices'] ?? [] as $priceData) {
|
||
try {
|
||
$fuelType = FuelType::fromApiValue($priceData['fuel_type']);
|
||
} catch (ValueError) {
|
||
continue; // Skip unknown fuel types
|
||
}
|
||
|
||
$rawPrice = (float) $priceData['price'];
|
||
|
||
if (! $this->isValidPrice($fuelType, $rawPrice)) {
|
||
Log::warning('FuelPriceService: price out of valid range — skipped', [
|
||
'station_id' => $stationId,
|
||
'fuel_type' => $fuelType->value,
|
||
'price' => $rawPrice,
|
||
'limits' => self::PRICE_LIMITS_PENCE[$fuelType->value],
|
||
]);
|
||
|
||
continue;
|
||
}
|
||
|
||
$pricePence = (int) round($rawPrice * 100);
|
||
$effectiveAt = Carbon::parse($priceData['price_change_effective_timestamp']);
|
||
$reportedAt = Carbon::parse($priceData['price_last_updated']);
|
||
$currentPricePence = $currentPrices[$stationId][$fuelType->value]->price_pence ?? null;
|
||
|
||
$row = [
|
||
'station_id' => $stationId,
|
||
'fuel_type' => $fuelType->value,
|
||
'price_pence' => $pricePence,
|
||
'price_effective_at' => $effectiveAt,
|
||
'price_reported_at' => $reportedAt,
|
||
'recorded_at' => $now,
|
||
];
|
||
|
||
// Always upsert current; only write history when price changed
|
||
$upsertRows[] = $row;
|
||
|
||
if ($currentPricePence !== $pricePence) {
|
||
$newPrices[] = $row;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (! empty($newPrices)) {
|
||
StationPrice::insert($newPrices);
|
||
}
|
||
|
||
if (! empty($upsertRows)) {
|
||
StationPriceCurrent::upsert(
|
||
$upsertRows,
|
||
['station_id', 'fuel_type'],
|
||
['price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'],
|
||
);
|
||
}
|
||
|
||
return count($newPrices);
|
||
}
|
||
}
|