Audit item #10. PriceReliability::fromUpdatedAt was being invoked ~10× per station per /api/stations response — twice in the sort comparator (once for $a, once for $b), once in the count groupBy, and once per resource render. PriceClassification::fromUpdatedAt was called twice inside the resource (value + label). The controller now computes the parsed datetime + reliability + classification once per row and stashes them on the row. Sort, groupBy, and StationResource read the cached values; the resource keeps a fresh-compute fallback for callers that bypass the controller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
6.0 KiB
PHP
154 lines
6.0 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Enums\PriceClassification;
|
||
use App\Enums\PriceReliability;
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Requests\Api\NearbyStationsRequest;
|
||
use App\Http\Resources\Api\StationResource;
|
||
use App\Models\Search;
|
||
use App\Models\Station;
|
||
use App\Models\User;
|
||
use App\Services\HaversineQuery;
|
||
use App\Services\NationalFuelPredictionService;
|
||
use App\Services\PlanFeatures;
|
||
use App\Services\PostcodeService;
|
||
use Illuminate\Database\Query\JoinClause;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
class StationController extends Controller
|
||
{
|
||
public function __construct(
|
||
private readonly PostcodeService $postcodeService,
|
||
private readonly NationalFuelPredictionService $predictionService,
|
||
) {}
|
||
|
||
public function index(NearbyStationsRequest $request): JsonResponse
|
||
{
|
||
if ($request->filled('postcode')) {
|
||
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
|
||
|
||
if ($location === null) {
|
||
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
|
||
}
|
||
|
||
$lat = $location->lat;
|
||
$lng = $location->lng;
|
||
} else {
|
||
$lat = (float) $request->input('lat');
|
||
$lng = (float) $request->input('lng');
|
||
}
|
||
$fuelType = $request->fuelType();
|
||
$radius = $request->radius();
|
||
$sort = $request->sort();
|
||
|
||
[$distanceSql, $distanceBindings] = HaversineQuery::distanceKm($lat, $lng);
|
||
|
||
$all = Station::query()
|
||
->selectRaw(
|
||
"stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, {$distanceSql} AS distance_km",
|
||
$distanceBindings,
|
||
)
|
||
->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void {
|
||
$join->on('stations.node_id', '=', 'spc.station_id')
|
||
->where('spc.fuel_type', '=', $fuelType->value);
|
||
})
|
||
->where('stations.temporary_closure', false)
|
||
->where('stations.permanent_closure', false)
|
||
->get();
|
||
|
||
// Compute reliability + classification once per row. The resource and
|
||
// sort/groupBy below all read these cached attributes — without this,
|
||
// PriceReliability::fromUpdatedAt is invoked ~10× per station per
|
||
// response (sort comparator, count groupBy, resource value+label).
|
||
$all->each(function ($s): void {
|
||
$updatedAt = $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null;
|
||
$s->_updated_at = $updatedAt;
|
||
$s->_reliability = PriceReliability::fromUpdatedAt($updatedAt);
|
||
$s->_classification = PriceClassification::fromUpdatedAt($updatedAt);
|
||
});
|
||
|
||
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius);
|
||
|
||
$stations = $sort === 'reliable'
|
||
? $filtered
|
||
->sort(function ($a, $b) {
|
||
return $a->_reliability->weight() <=> $b->_reliability->weight()
|
||
?: ((int) $a->price_pence <=> (int) $b->price_pence)
|
||
?: ((float) $a->distance_km <=> (float) $b->distance_km);
|
||
})
|
||
->values()
|
||
: $filtered->sortBy(match ($sort) {
|
||
'price' => fn ($s) => (int) $s->price_pence,
|
||
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
|
||
default => fn ($s) => (float) $s->distance_km,
|
||
})->values();
|
||
|
||
$prices = $stations->pluck('price_pence');
|
||
|
||
$reliabilityCounts = $stations
|
||
->groupBy(fn ($s) => $s->_reliability->value)
|
||
->map->count();
|
||
|
||
Search::create([
|
||
'lat_bucket' => round($lat, 2),
|
||
'lng_bucket' => round($lng, 2),
|
||
'fuel_type' => $fuelType->value,
|
||
'results_count' => $stations->count(),
|
||
'lowest_pence' => $prices->min(),
|
||
'highest_pence' => $prices->max(),
|
||
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||
'searched_at' => now(),
|
||
'ip_hash' => hash('sha256', $request->ip() ?? ''),
|
||
]);
|
||
|
||
return response()->json([
|
||
'data' => StationResource::collection($stations),
|
||
'meta' => [
|
||
'count' => $stations->count(),
|
||
'fuel_type' => $fuelType->value,
|
||
'radius_km' => $radius,
|
||
'lat' => $lat,
|
||
'lng' => $lng,
|
||
'lowest_pence' => $prices->min(),
|
||
'highest_pence' => $prices->max(),
|
||
'cheapest_price_pence' => $prices->min(),
|
||
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||
'reliability_counts' => [
|
||
'reliable' => (int) $reliabilityCounts->get(PriceReliability::Reliable->value, 0),
|
||
'stale' => (int) $reliabilityCounts->get(PriceReliability::Stale->value, 0),
|
||
'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0),
|
||
],
|
||
],
|
||
'prediction' => $this->predictionFor($request->user(), $lat, $lng),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Returns the prediction payload for embedding in the search response.
|
||
* Free/guest users get a stripped teaser; users with the ai_predictions
|
||
* feature get the full multi-signal payload.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function predictionFor(?User $user, float $lat, float $lng): array
|
||
{
|
||
$result = $this->predictionService->predict($lat, $lng);
|
||
|
||
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
||
|
||
if (! $canSeeFull) {
|
||
return [
|
||
'fuel_type' => $result['fuel_type'],
|
||
'predicted_direction' => $result['predicted_direction'],
|
||
'tier_locked' => true,
|
||
];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
}
|