Files
fuel-price/app/Services/FuelPriceService.php
Ovidiu U 097f1b0529
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
apilogs
2026-04-04 08:41:21 +01:00

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);
}
}