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 */ 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; } }