diff --git a/app/Http/Controllers/Api/PredictionController.php b/app/Http/Controllers/Api/PredictionController.php new file mode 100644 index 0000000..b1958b9 --- /dev/null +++ b/app/Http/Controllers/Api/PredictionController.php @@ -0,0 +1,26 @@ +fuelType(); + $lat = $request->filled('lat') ? (float) $request->input('lat') : null; + $lng = $request->filled('lng') ? (float) $request->input('lng') : null; + + $result = $this->predictionService->predict($fuelType, $lat, $lng); + + return response()->json($result); + } +} diff --git a/app/Http/Requests/Api/PredictionRequest.php b/app/Http/Requests/Api/PredictionRequest.php new file mode 100644 index 0000000..88ce78c --- /dev/null +++ b/app/Http/Requests/Api/PredictionRequest.php @@ -0,0 +1,33 @@ + ['required', 'string'], + 'lat' => ['nullable', 'numeric', 'between:-90,90'], + 'lng' => ['nullable', 'numeric', 'between:-180,180'], + ]; + } + + public function fuelType(): FuelType + { + try { + return FuelType::fromAlias($this->string('fuel_type')->toString()); + } catch (\ValueError) { + throw ValidationException::withMessages(['fuel_type' => 'Unknown fuel type. Use: diesel, petrol, e10, e5, hvo, b10.']); + } + } +} diff --git a/app/Services/NationalFuelPredictionService.php b/app/Services/NationalFuelPredictionService.php index 6587187..a4c7324 100644 --- a/app/Services/NationalFuelPredictionService.php +++ b/app/Services/NationalFuelPredictionService.php @@ -344,7 +344,7 @@ class NationalFuelPredictionService ->join('stations', 'station_prices.station_id', '=', 'stations.node_id') ->where('station_prices.fuel_type', $fuelType->value) ->where('station_prices.price_effective_at', '>=', now()->subDays(14)) - ->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat]) + ->whereRaw('(6371 * acos(CASE WHEN (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) > 1.0 THEN 1.0 ELSE (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) END)) <= 50', [$lat, $lng, $lat, $lat, $lng, $lat]) ->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price') ->groupBy('day') ->orderBy('day') diff --git a/tests/Feature/Api/PredictionControllerTest.php b/tests/Feature/Api/PredictionControllerTest.php new file mode 100644 index 0000000..95c879d --- /dev/null +++ b/tests/Feature/Api/PredictionControllerTest.php @@ -0,0 +1,59 @@ +getJson('/api/prediction?fuel_type=diesel') + ->assertOk() + ->assertJsonStructure([ + 'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence', + 'confidence_score', 'confidence_label', 'action', 'reasoning', + 'prediction_horizon_days', 'region_key', 'methodology', + 'signals' => [ + 'trend' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'day_of_week' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'brand_behaviour' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'national_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'regional_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + 'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'], + ], + ]) + ->assertJsonPath('fuel_type', 'b7_standard') + ->assertJsonPath('region_key', 'national'); +}); + +it('includes current average from live prices', function () { + $station = Station::factory()->create(); + StationPriceCurrent::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::B7Standard, + 'price_pence' => 14750, + ]); + + $response = $this->getJson('/api/prediction?fuel_type=diesel')->assertOk(); + + expect($response->json('current_avg'))->toBe(147.5); +}); + +it('accepts optional lat and lng for regional context', function () { + $this->getJson('/api/prediction?fuel_type=diesel&lat=52.5&lng=-0.2') + ->assertOk() + ->assertJsonPath('region_key', 'national'); // still national, regional_momentum signal updated internally +}); + +it('returns 422 when fuel_type is missing', function () { + $this->getJson('/api/prediction') + ->assertUnprocessable() + ->assertJsonValidationErrors(['fuel_type']); +}); + +it('returns 422 for unknown fuel_type alias', function () { + $this->getJson('/api/prediction?fuel_type=rocket_fuel') + ->assertUnprocessable() + ->assertJsonValidationErrors(['fuel_type']); +});