- Move SearchBar.vue to PostSearchFilters.vue and expand to include sort buttons, brand filter dropdown, and station count - Integrate sort controls (Reliable/Price/Distance/Updated) with icons into filter bar - Add brand filter dropdown with dynamic brand list from parent, emit update events - Move station count from StationList to PostSearchFilters, display as "X station(s) found" - Remove sort tabs and brand filter from StationList component - Add force-new-line div for mobile layout between Refine and Sort groups - Include brand filter in hasActive check and resetFilters function - Update Home.vue to pass brands/brandFilter props and handle brandFilter updates - Add reset() method to useStations composable to clear state on empty query - Clear search state when route query is empty instead of attempting search - Update Fuel Finder API base URL to include /api/v1 path - Adjust map zoom levels for 10-15 mile radius range - Update API token request to use retry and increase timeout to 60s
375 lines
13 KiB
PHP
375 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Enums\FuelType;
|
||
use App\Models\Station;
|
||
use App\Models\StationPrice;
|
||
use App\Models\StationPriceCurrent;
|
||
use Carbon\CarbonInterface;
|
||
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';
|
||
|
||
private const string LAST_PRICE_POLL_CACHE_KEY = 'fuel_finder_last_price_poll_at';
|
||
|
||
/**
|
||
* 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::retry(3, 500)
|
||
->timeout(60)
|
||
->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.
|
||
*
|
||
* Uses incremental polling when a previous poll timestamp is cached — only
|
||
* stations with prices changed since then are returned by the API. Falls
|
||
* back to a full fetch on cold start (cache miss).
|
||
*
|
||
* @return int Number of new price records inserted
|
||
*/
|
||
public function pollPrices(): int
|
||
{
|
||
$token = $this->getAccessToken();
|
||
$inserted = 0;
|
||
$batch = 1;
|
||
$pollStartedAt = now();
|
||
$since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY);
|
||
$completedCleanly = false;
|
||
|
||
do {
|
||
try {
|
||
$baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices';
|
||
$params = ['batch-number' => $batch];
|
||
|
||
if ($since instanceof CarbonInterface) {
|
||
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
|
||
}
|
||
|
||
$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()) {
|
||
$completedCleanly = true;
|
||
break;
|
||
}
|
||
|
||
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)) {
|
||
$completedCleanly = true;
|
||
break;
|
||
}
|
||
|
||
$inserted += $this->processPriceBatch($stations);
|
||
$batch++;
|
||
} while (true);
|
||
|
||
if ($completedCleanly) {
|
||
Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt);
|
||
}
|
||
|
||
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) {
|
||
if (! $this->hasRequiredStationFields($data)) {
|
||
Log::warning('FuelPriceService: station skipped — missing required fields', [
|
||
'node_id' => $data['node_id'] ?? null,
|
||
]);
|
||
|
||
continue;
|
||
}
|
||
|
||
$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' => self::flattenEnabledFlags($data['amenities'] ?? []),
|
||
'opening_times' => $data['opening_times'] ?? null,
|
||
'fuel_types' => self::flattenEnabledFlags($data['fuel_types'] ?? []),
|
||
'last_seen_at' => $now,
|
||
]);
|
||
|
||
$this->taggingService->tag($station);
|
||
$rows[] = $station->getAttributes();
|
||
}
|
||
|
||
if ($rows === []) {
|
||
return;
|
||
}
|
||
|
||
Station::upsert($rows, ['node_id'], array_keys($rows[0]));
|
||
}
|
||
|
||
/** @param array<string, mixed> $data */
|
||
private function hasRequiredStationFields(array $data): bool
|
||
{
|
||
return ! empty($data['node_id'])
|
||
&& ! empty($data['trading_name'])
|
||
&& isset($data['location']['postcode'], $data['location']['latitude'], $data['location']['longitude']);
|
||
}
|
||
|
||
/**
|
||
* The API returns `amenities` and `fuel_types` as objects with boolean
|
||
* flags (e.g. {"E10": true, "car_wash": false}). Flatten to a list of
|
||
* enabled keys. If the payload is already an array of strings, return as-is.
|
||
*
|
||
* @param array<string, bool>|array<int, string> $flags
|
||
* @return array<int, string>
|
||
*/
|
||
private static function flattenEnabledFlags(array $flags): array
|
||
{
|
||
if ($flags === []) {
|
||
return [];
|
||
}
|
||
|
||
if (array_is_list($flags)) {
|
||
return array_values($flags);
|
||
}
|
||
|
||
return array_values(array_keys(array_filter($flags, fn ($v) => filter_var($v, FILTER_VALIDATE_BOOLEAN))));
|
||
}
|
||
|
||
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');
|
||
|
||
// Filter to stations that exist in the stations table — prevents FK
|
||
// violations when the API surfaces a station before the next metadata
|
||
// refresh picks it up.
|
||
$knownStationIds = array_flip(
|
||
Station::whereIn('node_id', $stationIds)->pluck('node_id')->all(),
|
||
);
|
||
|
||
$unknown = array_diff($stationIds, array_keys($knownStationIds));
|
||
if ($unknown !== []) {
|
||
Log::info('FuelPriceService: skipped prices for unknown stations', [
|
||
'count' => count($unknown),
|
||
]);
|
||
}
|
||
|
||
// Load current prices for all stations in this batch in one query
|
||
$currentPrices = StationPriceCurrent::whereIn('station_id', array_keys($knownStationIds))
|
||
->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'] ?? null;
|
||
|
||
if ($stationId === null || ! isset($knownStationIds[$stationId])) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($station['fuel_prices'] ?? [] as $priceData) {
|
||
if (! isset($priceData['fuel_type'], $priceData['price'], $priceData['price_last_updated'], $priceData['price_change_effective_timestamp'])) {
|
||
continue;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|