Files
fuel-price/app/Services/FuelPriceService.php
Ovidiu U e532cc1208
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
feat: add PostcodeService and price validation with DB constraints
- 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>
2026-04-04 12:40:43 +01:00

290 lines
10 KiB
PHP
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.

<?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 + 3075% 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);
}
}