226 lines
8.0 KiB
PHP
226 lines
8.0 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';
|
|
|
|
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));
|
|
|
|
$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));
|
|
|
|
$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] ?? []));
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
$pricePence = (int) round($priceData['price'] * 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);
|
|
}
|
|
}
|