From 55a558b5369e1c293fe5b0f73f8d8854ec130dea Mon Sep 17 00:00:00 2001 From: bastien Date: Fri, 3 Jul 2026 19:01:49 +0200 Subject: [PATCH] =?UTF-8?q?Int=C3=A9gration=20reservations=20et=20mock=20l?= =?UTF-8?q?avage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + README.md | 1 + .../Controllers/Api/V1/BookingController.php | 2 +- .../Controllers/Api/V1/HealthController.php | 40 +++++++ .../Controllers/Api/V1/MachineController.php | 29 +++++ .../Controllers/Api/V1/WashController.php | 6 +- .../Requests/Api/V1/Wash/StartWashRequest.php | 3 +- app/Http/Resources/WashResource.php | 2 + app/Models/MachineStatusHistory.php | 2 + app/Services/WashService.php | 2 + app/Support/WashCycleProgress.php | 108 ++++++++++++++++++ config/cors.php | 39 +++++++ routes/api.php | 5 + tests/Feature/Api/CorsTest.php | 35 ++++++ tests/Feature/Api/HealthTest.php | 59 ++++++++++ tests/Unit/MachineStatusHistoryTest.php | 14 +++ 16 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/HealthController.php create mode 100644 app/Support/WashCycleProgress.php create mode 100644 config/cors.php create mode 100644 tests/Feature/Api/CorsTest.php create mode 100644 tests/Feature/Api/HealthTest.php create mode 100644 tests/Unit/MachineStatusHistoryTest.php diff --git a/.env.example b/.env.example index 50a5b8b..28c7af3 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,10 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +# --- CORS (Flutter Web / clients navigateur) --- +# * = toutes origines (dev). En prod : http://localhost:5173,https://app.example.com +CORS_ALLOWED_ORIGINS=* + # --- Configuration Laverie --- # Clé API pour les webhooks / intégrations machines (header X-Machine-Api-Key) diff --git a/README.md b/README.md index 5a68b5d..51af913 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ ```cmd /c/wamp64/bin/php/php8.3.28/php.exe artisan serve vite +/c/wamp64/bin/php/php8.3.28/php.exe artisan queue:work ``` ## About Laravel diff --git a/app/Http/Controllers/Api/V1/BookingController.php b/app/Http/Controllers/Api/V1/BookingController.php index 29f3d4b..b4c8356 100644 --- a/app/Http/Controllers/Api/V1/BookingController.php +++ b/app/Http/Controllers/Api/V1/BookingController.php @@ -12,9 +12,9 @@ use App\Http\Resources\BookingResource; use App\Models\Booking; use App\Models\Machine; use App\Services\BookingService; -use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; class BookingController extends Controller { diff --git a/app/Http/Controllers/Api/V1/HealthController.php b/app/Http/Controllers/Api/V1/HealthController.php new file mode 100644 index 0000000..0ba1b4e --- /dev/null +++ b/app/Http/Controllers/Api/V1/HealthController.php @@ -0,0 +1,40 @@ +success([ + 'status' => 'ok', + 'service' => 'laverie-api', + 'timestamp' => now()->toIso8601String(), + ]); + } + + public function database(): JsonResponse + { + try { + DB::select('SELECT 1'); + + return $this->success([ + 'status' => 'ok', + 'database' => config('database.default'), + 'timestamp' => now()->toIso8601String(), + ]); + } catch (Throwable $e) { + return $this->error('Database connection failed', 503, [ + 'database' => ['Connection unavailable'], + ]); + } + } +} diff --git a/app/Http/Controllers/Api/V1/MachineController.php b/app/Http/Controllers/Api/V1/MachineController.php index 6c9869c..234621c 100644 --- a/app/Http/Controllers/Api/V1/MachineController.php +++ b/app/Http/Controllers/Api/V1/MachineController.php @@ -8,6 +8,7 @@ use App\Http\Resources\MachineResource; use App\Models\Booking; use App\Models\Machine; use App\Services\PricingService; +use App\Support\WashCycleProgress; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -29,6 +30,34 @@ class MachineController extends Controller return $this->success([ 'machine' => new MachineResource($machine), + 'pricing' => [ + 'price' => $this->pricingService->getActivePrice($machine), + 'currency' => 'EUR', + ], + 'estimated_duration_minutes' => WashCycleProgress::estimateDurationMinutes($machine->type), + ]); + } + + public function lookup(Request $request): JsonResponse + { + $request->validate([ + 'qr_code' => ['required_without:machine_uuid', 'nullable', 'string', 'max:255'], + 'machine_uuid' => ['required_without:qr_code', 'nullable', 'uuid'], + ]); + + $machine = $request->filled('machine_uuid') + ? Machine::query()->where('uuid', $request->string('machine_uuid')->toString())->firstOrFail() + : Machine::query()->where('qr_code', $request->string('qr_code')->toString())->firstOrFail(); + + $machine->load('establishment'); + + return $this->success([ + 'machine' => new MachineResource($machine), + 'pricing' => [ + 'price' => $this->pricingService->getActivePrice($machine), + 'currency' => 'EUR', + ], + 'estimated_duration_minutes' => WashCycleProgress::estimateDurationMinutes($machine->type), ]); } diff --git a/app/Http/Controllers/Api/V1/WashController.php b/app/Http/Controllers/Api/V1/WashController.php index 1a1f5d8..f9c0048 100644 --- a/app/Http/Controllers/Api/V1/WashController.php +++ b/app/Http/Controllers/Api/V1/WashController.php @@ -25,9 +25,9 @@ class WashController extends Controller public function start(StartWashRequest $request): JsonResponse { - $machine = Machine::query() - ->where('uuid', $request->string('machine_uuid')->toString()) - ->firstOrFail(); + $machine = $request->filled('machine_uuid') + ? Machine::query()->where('uuid', $request->string('machine_uuid')->toString())->firstOrFail() + : Machine::query()->where('qr_code', $request->string('qr_code')->toString())->firstOrFail(); $bookingId = null; if ($request->filled('booking_uuid')) { diff --git a/app/Http/Requests/Api/V1/Wash/StartWashRequest.php b/app/Http/Requests/Api/V1/Wash/StartWashRequest.php index 9978b0c..eaa7b80 100644 --- a/app/Http/Requests/Api/V1/Wash/StartWashRequest.php +++ b/app/Http/Requests/Api/V1/Wash/StartWashRequest.php @@ -15,7 +15,8 @@ class StartWashRequest extends FormRequest public function rules(): array { return [ - 'machine_uuid' => ['required', 'uuid', 'exists:machines,uuid'], + 'machine_uuid' => ['required_without:qr_code', 'nullable', 'uuid', 'exists:machines,uuid'], + 'qr_code' => ['required_without:machine_uuid', 'nullable', 'string', 'max:255', 'exists:machines,qr_code'], 'booking_uuid' => ['nullable', 'uuid', 'exists:bookings,uuid'], 'trigger_method' => ['required', Rule::in(['qr_code', 'booking', 'supervisor', 'system'])], 'program' => ['nullable', 'string', 'max:100'], diff --git a/app/Http/Resources/WashResource.php b/app/Http/Resources/WashResource.php index 1e4c7f4..4feb1aa 100644 --- a/app/Http/Resources/WashResource.php +++ b/app/Http/Resources/WashResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Support\WashCycleProgress; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -22,6 +23,7 @@ class WashResource extends JsonResource 'machine' => new MachineResource($this->whenLoaded('machine')), 'booking' => new BookingResource($this->whenLoaded('booking')), 'user' => new UserResource($this->whenLoaded('user')), + 'progress' => WashCycleProgress::forWash($this->resource), ]; } } diff --git a/app/Models/MachineStatusHistory.php b/app/Models/MachineStatusHistory.php index 1ef1efc..107c16e 100644 --- a/app/Models/MachineStatusHistory.php +++ b/app/Models/MachineStatusHistory.php @@ -11,6 +11,8 @@ class MachineStatusHistory extends Model { public const UPDATED_AT = null; + protected $table = 'machine_status_history'; + protected function casts(): array { return [ diff --git a/app/Services/WashService.php b/app/Services/WashService.php index 2fa196f..976a513 100644 --- a/app/Services/WashService.php +++ b/app/Services/WashService.php @@ -7,6 +7,7 @@ use App\Models\Booking; use App\Models\Machine; use App\Models\User; use App\Models\Wash; +use App\Support\WashCycleProgress; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -71,6 +72,7 @@ class WashService 'status' => 'pending_start', 'program' => $program, 'cost' => $cost, + 'duration_minutes' => WashCycleProgress::estimateDurationMinutes($machine->type), ]); $result = $this->machineIntegrationService->startCycle($machine, [ diff --git a/app/Support/WashCycleProgress.php b/app/Support/WashCycleProgress.php new file mode 100644 index 0000000..1a6ca45 --- /dev/null +++ b/app/Support/WashCycleProgress.php @@ -0,0 +1,108 @@ +|null + */ + public static function forWash(Wash $wash): ?array + { + if (! in_array($wash->status, ['pending_start', 'running', 'active'], true)) { + return null; + } + + $wash->loadMissing('machine'); + $machine = $wash->machine; + $startedAt = $wash->started_at ?? $wash->created_at; + $endsAt = $machine?->cycle_ends_at; + + if ($startedAt === null) { + return [ + 'percent' => 0, + 'phase' => 'pending', + 'phase_label' => 'En attente de démarrage', + 'estimated_end_at' => $endsAt?->toIso8601String(), + 'remaining_seconds' => null, + ]; + } + + $totalSeconds = $endsAt !== null + ? max(1, (int) $startedAt->diffInSeconds($endsAt)) + : max(1, ($wash->duration_minutes ?? 45) * 60); + + $elapsed = max(0, (int) $startedAt->diffInSeconds(now())); + $percent = min(99, (int) round(($elapsed / $totalSeconds) * 100)); + + [$phase, $phaseLabel] = self::phaseForPercent($percent, $machine?->type); + + $remainingSeconds = $endsAt !== null + ? max(0, (int) now()->diffInSeconds($endsAt, false)) + : max(0, $totalSeconds - $elapsed); + + return [ + 'percent' => $percent, + 'phase' => $phase, + 'phase_label' => $phaseLabel, + 'estimated_end_at' => $endsAt?->toIso8601String(), + 'remaining_seconds' => $remainingSeconds, + ]; + } + + /** + * @return array{0: string, 1: string} + */ + private static function phaseForPercent(int $percent, ?string $type): array + { + $isDryer = str_starts_with($type ?? '', 'dryer'); + + if ($percent < 5) { + return ['lock', 'Verrouillage']; + } + + if ($isDryer) { + if ($percent < 15) { + return ['heat', 'Préchauffage']; + } + if ($percent < 85) { + return ['dry', 'Séchage']; + } + + return ['finish', 'Refroidissement']; + } + + if ($percent < 15) { + return ['fill', 'Remplissage']; + } + if ($percent < 55) { + return ['wash', 'Lavage']; + } + if ($percent < 75) { + return ['rinse', 'Rinçage']; + } + if ($percent < 90) { + return ['spin', 'Essorage']; + } + + return ['finish', 'Finition']; + } + + public static function estimateDurationMinutes(?string $machineType): int + { + $simSeconds = (int) config('laverie.simulation.cycle_duration_seconds', 30); + + if ($simSeconds >= 120) { + return max(1, (int) ceil($simSeconds / 60)); + } + + return match (true) { + str_starts_with($machineType ?? '', 'dryer_large') => 55, + str_starts_with($machineType ?? '', 'dryer') => 45, + str_starts_with($machineType ?? '', 'washer_large') => 50, + default => 40, + }; + } +} diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..f43c064 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,39 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => $allowedOrigins !== null && $allowedOrigins !== '' + ? array_values(array_filter(array_map('trim', explode(',', $allowedOrigins)))) + : ['*'], + + 'allowed_origins_patterns' => [ + '#^https?://localhost(:\d+)?$#', + '#^https?://127\.0\.0\.1(:\d+)?$#', + '#^https?://\[::1\](:\d+)?$#', + ], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 86400, + + 'supports_credentials' => false, + +]; diff --git a/routes/api.php b/routes/api.php index d2944af..0e3a9a6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ group(function () { + Route::get('health', [HealthController::class, 'index']); + Route::get('health/db', [HealthController::class, 'database']); + Route::prefix('auth')->group(function () { Route::post('register', [AuthController::class, 'register']); Route::post('login', [AuthController::class, 'login']); @@ -49,6 +53,7 @@ Route::prefix('v1')->group(function () { Route::get('establishments', [EstablishmentController::class, 'index']); Route::get('establishments/{uuid}', [EstablishmentController::class, 'show']); + Route::get('machines/lookup', [MachineController::class, 'lookup']); Route::get('machines/{uuid}', [MachineController::class, 'show']); Route::get('machines/{uuid}/availability', [MachineController::class, 'availability']); Route::get('machines/{uuid}/pricing', [MachineController::class, 'pricing']); diff --git a/tests/Feature/Api/CorsTest.php b/tests/Feature/Api/CorsTest.php new file mode 100644 index 0000000..52e7594 --- /dev/null +++ b/tests/Feature/Api/CorsTest.php @@ -0,0 +1,35 @@ +call( + 'OPTIONS', + '/api/v1/health', + server: [ + 'HTTP_ORIGIN' => 'http://localhost:5173', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'Authorization, Content-Type', + ], + ); + + $response->assertOk(); + $response->assertHeader('Access-Control-Allow-Origin', '*'); + $response->assertHeader('Access-Control-Allow-Methods'); + } + + public function test_get_request_includes_cors_headers_for_browser_origin(): void + { + $response = $this->getJson('/api/v1/health', [ + 'Origin' => 'http://localhost:5173', + ]); + + $response->assertOk(); + $response->assertHeader('Access-Control-Allow-Origin', '*'); + } +} diff --git a/tests/Feature/Api/HealthTest.php b/tests/Feature/Api/HealthTest.php new file mode 100644 index 0000000..2248f96 --- /dev/null +++ b/tests/Feature/Api/HealthTest.php @@ -0,0 +1,59 @@ +getJson('/api/v1/health'); + + $response->assertOk() + ->assertJson([ + 'success' => true, + 'data' => [ + 'status' => 'ok', + 'service' => 'laverie-api', + ], + ]) + ->assertJsonStructure([ + 'data' => ['timestamp'], + ]); + } + + public function test_database_health_endpoint_returns_ok_when_connected(): void + { + $response = $this->getJson('/api/v1/health/db'); + + $response->assertOk() + ->assertJson([ + 'success' => true, + 'data' => [ + 'status' => 'ok', + 'database' => 'sqlite', + ], + ]); + } + + public function test_database_health_endpoint_returns_service_unavailable_when_disconnected(): void + { + DB::shouldReceive('select') + ->once() + ->with('SELECT 1') + ->andThrow(new \RuntimeException('Connection refused')); + + $response = $this->getJson('/api/v1/health/db'); + + $response->assertStatus(503) + ->assertJson([ + 'success' => false, + 'message' => 'Database connection failed', + 'errors' => [ + 'database' => ['Connection unavailable'], + ], + ]); + } +} diff --git a/tests/Unit/MachineStatusHistoryTest.php b/tests/Unit/MachineStatusHistoryTest.php new file mode 100644 index 0000000..57537b2 --- /dev/null +++ b/tests/Unit/MachineStatusHistoryTest.php @@ -0,0 +1,14 @@ +assertSame('machine_status_history', (new MachineStatusHistory)->getTable()); + } +}