Merge branch 'release/0.0.1'
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
src/.env
|
||||||
|
src/.env.backup
|
||||||
|
src/.env.production
|
||||||
|
src/.phpactor.json
|
||||||
|
src/.phpunit.result.cache
|
||||||
|
/.codex
|
||||||
|
/.cursor/
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
src/auth.json
|
||||||
|
src/node_modules
|
||||||
|
src/public/build
|
||||||
|
src/public/fonts-manifest.dev.json
|
||||||
|
src/public/hot
|
||||||
|
src/public/storage
|
||||||
|
src/storage/*.key
|
||||||
|
src/storage/pail
|
||||||
|
src/vendor
|
||||||
|
src/package-lock.json
|
||||||
|
src/composer.lock
|
||||||
|
_ide_helper.php
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
vitePipeline(name: 'laundry-backend', phpVersion: '8.3')
|
||||||
@@ -1,2 +1,64 @@
|
|||||||
# backend
|
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
/c/wamp64/bin/php/php8.3.28/php.exe artisan serve
|
||||||
|
npm run dev
|
||||||
|
/c/wamp64/bin/php/php8.3.28/php.exe artisan queue:work
|
||||||
|
```
|
||||||
|
|
||||||
|
## About Laravel
|
||||||
|
|
||||||
|
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||||
|
|
||||||
|
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||||
|
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||||
|
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||||
|
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||||
|
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||||
|
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||||
|
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||||
|
|
||||||
|
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||||
|
|
||||||
|
## Learning Laravel
|
||||||
|
|
||||||
|
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||||
|
|
||||||
|
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||||
|
|
||||||
|
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||||
|
|
||||||
|
## Agentic Development
|
||||||
|
|
||||||
|
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require laravel/boost --dev
|
||||||
|
|
||||||
|
php artisan boost:install
|
||||||
|
```
|
||||||
|
|
||||||
|
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||||
|
|
||||||
|
## Security Vulnerabilities
|
||||||
|
|
||||||
|
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
|
|||||||
@@ -0,0 +1,667 @@
|
|||||||
|
# Cahier des Charges — API & Base de Données
|
||||||
|
## Projet : Laverie Connectée — Backend
|
||||||
|
**Version :** 1.0
|
||||||
|
**Date :** 2026-06-27
|
||||||
|
**Stack principale :** Laravel 11, MySQL 8, Redis, Docker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & Objectifs
|
||||||
|
|
||||||
|
### 1.1 Contexte
|
||||||
|
Développement d'une API REST centrale servant :
|
||||||
|
- Une application mobile multi-plateforme (Flutter : Android / iOS / Web)
|
||||||
|
- Un back-office d'administration (Vue.js)
|
||||||
|
- Des automates de laverie connectés via passerelle LLDP
|
||||||
|
|
||||||
|
L'API est le **seul point d'entrée** pour toute la logique métier. Aucun client ne communique directement avec la base de données.
|
||||||
|
|
||||||
|
### 1.2 Objectifs
|
||||||
|
- Exposer des endpoints REST sécurisés (JWT) pour tous les clients
|
||||||
|
- Gérer les utilisateurs, l'authentification, les porte-monnaie et les paiements
|
||||||
|
- Orchestrer les communications avec les automates (commandes machines)
|
||||||
|
- Automatiser les notifications (cron, push, événementiel)
|
||||||
|
- Garantir la conformité RGPD sur toutes les données personnelles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stack Technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Version | Justification |
|
||||||
|
|-----------|-------------|---------|---------------|
|
||||||
|
| Framework | Laravel | 11.x | Robustesse, écosystème, ORM Eloquent |
|
||||||
|
| Base de données principale | MySQL | 8.0 | Transactions ACID, maturité |
|
||||||
|
| Cache & Queues | Redis | 7.x | Queues asynchrones, cache sessions |
|
||||||
|
| Auth | Laravel Sanctum | 4.x | JWT stateless multi-guard |
|
||||||
|
| ORM | Eloquent | (Laravel) | Relations, scopes, casting |
|
||||||
|
| Queues | Laravel Horizon | 5.x | Monitoring des workers Redis |
|
||||||
|
| Scheduler | Laravel Scheduler | (Laravel) | Cron jobs natifs |
|
||||||
|
| Notifications Push | Firebase FCM | HTTP v1 | Android + Web |
|
||||||
|
| Notifications Push | APNs (Apple) | HTTP/2 | iOS |
|
||||||
|
| Paiement | Abstraction PaymentProvider | — | Multi-partenaire |
|
||||||
|
| Tests | PHPUnit + Pest | — | TDD sur logique métier critique |
|
||||||
|
| Documentation API | Scramble (L5-Swagger) | — | OpenAPI 3.0 auto-générée |
|
||||||
|
| Conteneurisation | Docker + Docker Compose | — | Dev/staging/prod reproductibles |
|
||||||
|
| CI/CD | GitHub Actions | — | Tests + déploiement automatisé |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Structure du projet Laravel
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Http/
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ │ ├── Auth/
|
||||||
|
│ │ │ ├── UserAuthController.php
|
||||||
|
│ │ │ └── SupervisorAuthController.php
|
||||||
|
│ │ ├── User/
|
||||||
|
│ │ │ ├── WalletController.php
|
||||||
|
│ │ │ ├── BookingController.php
|
||||||
|
│ │ │ └── WashController.php
|
||||||
|
│ │ ├── Supervisor/
|
||||||
|
│ │ │ ├── DashboardController.php
|
||||||
|
│ │ │ ├── MachineController.php
|
||||||
|
│ │ │ └── PricingController.php
|
||||||
|
│ │ └── Webhook/
|
||||||
|
│ │ └── PaymentWebhookController.php
|
||||||
|
│ ├── Middleware/
|
||||||
|
│ │ ├── EnsureEmailVerified.php
|
||||||
|
│ │ ├── CheckWalletBalance.php
|
||||||
|
│ │ └── RateLimitPayment.php
|
||||||
|
│ └── Requests/ # Form Requests (validation)
|
||||||
|
├── Models/
|
||||||
|
│ ├── User.php
|
||||||
|
│ ├── Supervisor.php
|
||||||
|
│ ├── Establishment.php
|
||||||
|
│ ├── Machine.php
|
||||||
|
│ ├── Booking.php
|
||||||
|
│ ├── Wash.php
|
||||||
|
│ ├── WalletTransaction.php
|
||||||
|
│ ├── PricingRule.php
|
||||||
|
│ ├── Promotion.php
|
||||||
|
│ ├── Notification.php
|
||||||
|
│ └── GdprConsent.php
|
||||||
|
├── Services/
|
||||||
|
│ ├── WalletService.php # Logique porte-monnaie (atomique)
|
||||||
|
│ ├── BookingService.php # Réservation + débit auto no-show
|
||||||
|
│ ├── MachineCommandService.php # Abstraction commandes LLDP
|
||||||
|
│ ├── NotificationService.php # FCM + APNs
|
||||||
|
│ ├── Payment/
|
||||||
|
│ │ ├── PaymentProviderInterface.php
|
||||||
|
│ │ ├── StripeProvider.php
|
||||||
|
│ │ └── LydiProvider.php # Exemple partenaire alternatif
|
||||||
|
│ └── PricingService.php # Calcul tarif dynamique
|
||||||
|
├── Jobs/
|
||||||
|
│ ├── SendPushNotificationJob.php
|
||||||
|
│ ├── DebitNoShowBookingJob.php
|
||||||
|
│ └── SyncMachineStatusJob.php
|
||||||
|
├── Events/ & Listeners/
|
||||||
|
├── Policies/ # Autorisation par modèle
|
||||||
|
└── Console/Commands/ # Artisan custom
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Guards d'authentification
|
||||||
|
|
||||||
|
```
|
||||||
|
sanctum guards :
|
||||||
|
├── user → token scope [user] → app mobile
|
||||||
|
├── supervisor → token scope [supervisor] → back-office
|
||||||
|
└── machine → token scope [machine] → automates (optionnel)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Flux de données principal
|
||||||
|
|
||||||
|
```
|
||||||
|
App Flutter → HTTPS → API Laravel → MySQL
|
||||||
|
↓
|
||||||
|
Redis Queue → Job Workers
|
||||||
|
↓
|
||||||
|
FCM / APNs / LLDP Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Base de Données — Schéma Complet
|
||||||
|
|
||||||
|
### 4.1 Table `users`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL, -- Identifiant public exposé dans l'API
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
phone VARCHAR(20) UNIQUE,
|
||||||
|
birthdate DATE,
|
||||||
|
email_verified_at TIMESTAMP NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
wallet_balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
referral_code VARCHAR(20) UNIQUE,
|
||||||
|
referred_by BIGINT UNSIGNED NULL REFERENCES users(id),
|
||||||
|
loyalty_points INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
fcm_token VARCHAR(255) NULL, -- Token push Android/Web
|
||||||
|
apns_token VARCHAR(255) NULL, -- Token push iOS
|
||||||
|
locale VARCHAR(10) DEFAULT 'fr',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
anonymized_at TIMESTAMP NULL, -- RGPD : suppression logique
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL -- Soft delete
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Table `gdpr_consents`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE gdpr_consents (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id),
|
||||||
|
type ENUM('data_processing','marketing','analytics') NOT NULL,
|
||||||
|
accepted BOOLEAN NOT NULL,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent TEXT,
|
||||||
|
accepted_at TIMESTAMP NOT NULL,
|
||||||
|
revoked_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Table `supervisors`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE supervisors (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id),
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('admin','manager','viewer') DEFAULT 'manager',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Table `establishments`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE establishments (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
city VARCHAR(100),
|
||||||
|
zip_code VARCHAR(10),
|
||||||
|
latitude DECIMAL(10,8),
|
||||||
|
longitude DECIMAL(11,8),
|
||||||
|
timezone VARCHAR(50) DEFAULT 'Europe/Paris',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Table `machines`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machines (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
type ENUM('washer_small','washer_large','dryer_small','dryer_large') NOT NULL,
|
||||||
|
qr_code VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
lldp_device_id VARCHAR(100), -- Identifiant côté passerelle LLDP
|
||||||
|
status ENUM('available','running','reserved','maintenance','offline') DEFAULT 'available',
|
||||||
|
current_user_id BIGINT UNSIGNED NULL REFERENCES users(id),
|
||||||
|
cycle_started_at TIMESTAMP NULL,
|
||||||
|
cycle_ends_at TIMESTAMP NULL,
|
||||||
|
last_heartbeat TIMESTAMP NULL, -- Dernier ping de l'automate
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 Table `pricing_rules`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pricing_rules (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id),
|
||||||
|
day_type ENUM('weekday','weekend','holiday','all') DEFAULT 'all',
|
||||||
|
slot_start TIME NOT NULL,
|
||||||
|
slot_end TIME NOT NULL,
|
||||||
|
price DECIMAL(6,2) NOT NULL,
|
||||||
|
label VARCHAR(100), -- Ex: "Heure creuse", "Heure pleine"
|
||||||
|
requires_app BOOLEAN DEFAULT FALSE, -- Tarif exclusif app
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 Table `promotions`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE promotions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id),
|
||||||
|
machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large','all') DEFAULT 'all',
|
||||||
|
discount_type ENUM('percent','fixed') NOT NULL,
|
||||||
|
discount_value DECIMAL(6,2) NOT NULL,
|
||||||
|
starts_at TIMESTAMP NOT NULL,
|
||||||
|
ends_at TIMESTAMP NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.8 Table `bookings`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE bookings (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id),
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id),
|
||||||
|
slot_start TIMESTAMP NOT NULL,
|
||||||
|
slot_end TIMESTAMP NOT NULL,
|
||||||
|
amount_reserved DECIMAL(6,2) NOT NULL, -- Montant débité au moment de la réservation
|
||||||
|
amount_penalty DECIMAL(6,2) DEFAULT 0.00, -- Pénalité no-show
|
||||||
|
status ENUM('pending','confirmed','active','completed','cancelled','no_show') DEFAULT 'pending',
|
||||||
|
cancelled_at TIMESTAMP NULL,
|
||||||
|
penalty_applied_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.9 Table `washes`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE washes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id),
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id),
|
||||||
|
booking_id BIGINT UNSIGNED NULL REFERENCES bookings(id),
|
||||||
|
trigger_method ENUM('qr_code','booking','terminal') NOT NULL,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
ended_at TIMESTAMP NULL,
|
||||||
|
duration_minutes INT UNSIGNED,
|
||||||
|
cost DECIMAL(6,2) NOT NULL,
|
||||||
|
pricing_rule_id BIGINT UNSIGNED NULL REFERENCES pricing_rules(id),
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.10 Table `wallet_transactions`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE wallet_transactions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id),
|
||||||
|
type ENUM('credit','debit') NOT NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
balance_after DECIMAL(10,2) NOT NULL,
|
||||||
|
source ENUM('top_up','wash','booking','penalty','refund','referral_bonus') NOT NULL,
|
||||||
|
reference_id BIGINT UNSIGNED NULL, -- ID du wash ou booking associé
|
||||||
|
reference_type VARCHAR(100) NULL, -- Morph polymorphique
|
||||||
|
payment_provider VARCHAR(50) NULL,
|
||||||
|
payment_ref VARCHAR(255) NULL, -- Référence transaction externe
|
||||||
|
created_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.11 Table `push_notifications`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE push_notifications (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NULL REFERENCES users(id), -- NULL = broadcast
|
||||||
|
type VARCHAR(100) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
data JSON NULL,
|
||||||
|
channel ENUM('fcm','apns','both') DEFAULT 'both',
|
||||||
|
status ENUM('pending','sent','failed') DEFAULT 'pending',
|
||||||
|
sent_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Endpoints API — Spécification Complète
|
||||||
|
|
||||||
|
### 5.1 Authentification utilisateur
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| POST | `/api/v1/auth/register` | Inscription + collecte consentements RGPD | Public |
|
||||||
|
| POST | `/api/v1/auth/login` | Connexion, retourne access_token + refresh_token | Public |
|
||||||
|
| POST | `/api/v1/auth/refresh` | Renouvelle le token | Public |
|
||||||
|
| POST | `/api/v1/auth/logout` | Révoque le token | User |
|
||||||
|
| POST | `/api/v1/auth/forgot-password` | Envoi email reset | Public |
|
||||||
|
| POST | `/api/v1/auth/reset-password` | Réinitialisation | Public |
|
||||||
|
| GET | `/api/v1/auth/verify-email/{token}` | Vérification email | Public |
|
||||||
|
|
||||||
|
**Payload register :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"first_name": "Jean",
|
||||||
|
"last_name": "Dupont",
|
||||||
|
"email": "jean@example.com",
|
||||||
|
"phone": "+33612345678",
|
||||||
|
"password": "••••••••",
|
||||||
|
"birthdate": "1990-01-15",
|
||||||
|
"referral_code": "ABC123",
|
||||||
|
"consents": {
|
||||||
|
"data_processing": true,
|
||||||
|
"marketing": false,
|
||||||
|
"analytics": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse login :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"refresh_token": "def50200...",
|
||||||
|
"user": {
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"first_name": "Jean",
|
||||||
|
"wallet_balance": "12.50"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Porte-monnaie
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| GET | `/api/v1/wallet` | Solde actuel | User |
|
||||||
|
| GET | `/api/v1/wallet/transactions` | Historique paginé | User |
|
||||||
|
| POST | `/api/v1/wallet/top-up/initiate` | Initier rechargement | User |
|
||||||
|
| POST | `/api/v1/wallet/top-up/confirm` | Confirmer après paiement | User |
|
||||||
|
| GET | `/api/v1/wallet/top-up/providers` | Liste partenaires paiement disponibles | User |
|
||||||
|
|
||||||
|
**Payload top-up initiate :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"amount": 20.00,
|
||||||
|
"provider": "stripe",
|
||||||
|
"return_url": "laverie://wallet/topup-result"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"payment_intent_id": "pi_xxx",
|
||||||
|
"redirect_url": "https://checkout.stripe.com/...",
|
||||||
|
"expires_at": "2026-06-27T15:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Établissements & Machines
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| GET | `/api/v1/establishments` | Liste avec coordonnées GPS | User |
|
||||||
|
| GET | `/api/v1/establishments/{uuid}` | Détail + machines + disponibilités | User |
|
||||||
|
| GET | `/api/v1/machines/{uuid}` | Détail machine + tarif actuel | User |
|
||||||
|
| GET | `/api/v1/machines/{uuid}/availability` | Créneaux disponibles sur N jours | User |
|
||||||
|
| GET | `/api/v1/machines/{uuid}/pricing` | Grille tarifaire active | User |
|
||||||
|
| POST | `/api/v1/machines/{uuid}/heartbeat` | Ping de l'automate (machine scope) | Machine |
|
||||||
|
|
||||||
|
### 5.4 Réservations
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| POST | `/api/v1/bookings` | Créer une réservation | User |
|
||||||
|
| GET | `/api/v1/bookings` | Mes réservations | User |
|
||||||
|
| GET | `/api/v1/bookings/{uuid}` | Détail réservation | User |
|
||||||
|
| PATCH | `/api/v1/bookings/{uuid}/cancel` | Annuler | User |
|
||||||
|
| PATCH | `/api/v1/bookings/{uuid}/move` | Déplacer le créneau | User |
|
||||||
|
|
||||||
|
**Payload POST /bookings :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_uuid": "550e8400-...",
|
||||||
|
"slot_start": "2026-06-28T09:00:00Z",
|
||||||
|
"slot_end": "2026-06-28T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règles métier :**
|
||||||
|
- Vérification atomique du solde (mutex Redis sur `wallet:user:{id}`)
|
||||||
|
- Débit immédiat du montant + supplément réservation (1€)
|
||||||
|
- Si annulation > 2h avant : remboursement intégral
|
||||||
|
- Si annulation < 2h ou no-show : pénalité définie par `machine.no_show_penalty`
|
||||||
|
- Cron toutes les 5 minutes : détection no-show → `DebitNoShowBookingJob`
|
||||||
|
|
||||||
|
### 5.5 Lavages
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| POST | `/api/v1/washes/start` | Démarrer via QR code | User |
|
||||||
|
| GET | `/api/v1/washes/{uuid}` | Statut en cours | User |
|
||||||
|
| GET | `/api/v1/washes` | Historique | User |
|
||||||
|
|
||||||
|
**Payload POST /washes/start :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"qr_code": "MACHINE_QR_ABC123",
|
||||||
|
"program": "60C_standard"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flux :**
|
||||||
|
1. Valider le QR → identifier la machine
|
||||||
|
2. Vérifier statut machine (`available`)
|
||||||
|
3. Vérifier solde suffisant
|
||||||
|
4. Calculer prix (tarif actuel + promo éventuelle)
|
||||||
|
5. Débit atomique du porte-monnaie
|
||||||
|
6. Envoyer commande à la passerelle LLDP via `MachineCommandService`
|
||||||
|
7. Mettre à jour le statut machine (`running`)
|
||||||
|
8. Retourner confirmation + durée estimée
|
||||||
|
|
||||||
|
### 5.6 Authentification superviseur
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| POST | `/api/v1/supervisor/auth/login` | Connexion superviseur | Public |
|
||||||
|
| POST | `/api/v1/supervisor/auth/logout` | Déconnexion | Supervisor |
|
||||||
|
| GET | `/api/v1/supervisor/dashboard` | Statistiques + alertes | Supervisor |
|
||||||
|
| GET | `/api/v1/supervisor/machines` | Parc machines de l'établissement | Supervisor |
|
||||||
|
| PATCH | `/api/v1/supervisor/machines/{uuid}` | Mise à jour machine | Supervisor |
|
||||||
|
| GET | `/api/v1/supervisor/pricing` | Grille tarifaire | Supervisor |
|
||||||
|
| POST | `/api/v1/supervisor/pricing` | Créer règle tarifaire | Supervisor |
|
||||||
|
| PUT | `/api/v1/supervisor/pricing/{id}` | Modifier règle | Supervisor |
|
||||||
|
| DELETE | `/api/v1/supervisor/pricing/{id}` | Supprimer règle | Supervisor |
|
||||||
|
| GET | `/api/v1/supervisor/promotions` | Promotions actives | Supervisor |
|
||||||
|
| POST | `/api/v1/supervisor/promotions` | Créer promotion | Supervisor |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Services Métier Critiques
|
||||||
|
|
||||||
|
### 6.1 WalletService — Opérations atomiques
|
||||||
|
|
||||||
|
Toute opération sur le porte-monnaie passe par ce service. Les débits/crédits sont encapsulés dans des transactions MySQL + verrou Redis pour éviter les race conditions.
|
||||||
|
|
||||||
|
```
|
||||||
|
WalletService::credit(user, amount, source, referenceId)
|
||||||
|
WalletService::debit(user, amount, source, referenceId) ← lance WalletInsufficientFundsException si insuffisant
|
||||||
|
WalletService::reserve(user, amount, bookingId) ← débit préventif réservation
|
||||||
|
WalletService::refund(user, walletTransactionId)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle absolue :** Aucun controller ne doit modifier `users.wallet_balance` directement. Tout passe par `WalletService`.
|
||||||
|
|
||||||
|
### 6.2 PricingService — Tarif dynamique
|
||||||
|
|
||||||
|
```
|
||||||
|
PricingService::getCurrentPrice(machine, datetime)
|
||||||
|
→ Cherche la PricingRule active pour (machine_id, day_type, heure)
|
||||||
|
→ Applique Promotion active si existante
|
||||||
|
→ Retourne { base_price, discount, final_price, label, requires_app }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priorité des règles :** Règle spécifique machine > Règle par type machine > Tarif défaut établissement.
|
||||||
|
|
||||||
|
### 6.3 MachineCommandService — Abstraction LLDP
|
||||||
|
|
||||||
|
```
|
||||||
|
MachineCommandService::startWash(machine, program)
|
||||||
|
MachineCommandService::stopWash(machine)
|
||||||
|
MachineCommandService::getStatus(machine)
|
||||||
|
```
|
||||||
|
|
||||||
|
L'implémentation concrète (`LldpGatewayAdapter`) communique avec la passerelle LLDP via HTTP interne. En cas d'indisponibilité de la passerelle, la commande est mise en queue Redis et retentée 3 fois (backoff exponentiel).
|
||||||
|
|
||||||
|
### 6.4 PaymentProviderInterface
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface PaymentProviderInterface {
|
||||||
|
public function initiateTopUp(User $user, float $amount, string $returnUrl): TopUpIntent;
|
||||||
|
public function confirmTopUp(string $paymentRef): TopUpConfirmation;
|
||||||
|
public function handleWebhook(Request $request): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implémentations : `StripeProvider`, `LydiProvider`, `SumUpProvider` (extensible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tâches Planifiées (Cron)
|
||||||
|
|
||||||
|
| Fréquence | Job | Description |
|
||||||
|
|-----------|-----|-------------|
|
||||||
|
| Toutes les 5 min | `CheckNoShowBookings` | Détecter et débiter les no-shows |
|
||||||
|
| Toutes les 5 min | `SyncMachineStatuses` | Interroger la passerelle LLDP |
|
||||||
|
| Toutes les heures | `SendScheduledNotifications` | Notifications planifiées en attente |
|
||||||
|
| Chaque jour 02:00 | `GenerateDailyStats` | Agrégation statistiques pour dashboard |
|
||||||
|
| Chaque jour 03:00 | `CleanExpiredTokens` | Purge tokens révoqués |
|
||||||
|
| Chaque semaine | `AnonymizeInactiveUsers` | RGPD : users inactifs > 3 ans |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Notifications — Architecture
|
||||||
|
|
||||||
|
### 8.1 Déclencheurs
|
||||||
|
| Événement | Canal | Timing |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
| Rechargement confirmé | Push | Immédiat |
|
||||||
|
| Réservation confirmée | Push | Immédiat |
|
||||||
|
| Rappel créneau | Push | 30 min avant |
|
||||||
|
| Lavage terminé | Push | Immédiat |
|
||||||
|
| Solde faible (< 5€) | Push | À chaque débit |
|
||||||
|
| Promotion disponible | Push | Planifié par superviseur |
|
||||||
|
| No-show détecté | Push | À la détection |
|
||||||
|
|
||||||
|
### 8.2 Implémentation
|
||||||
|
- Les notifications sont toujours créées en base (`push_notifications`) avant envoi
|
||||||
|
- L'envoi passe par un Job Redis (`SendPushNotificationJob`) → résilience
|
||||||
|
- FCM HTTP v1 pour Android/Web, APNs HTTP/2 pour iOS
|
||||||
|
- En cas d'échec, 3 retries avec backoff (1min, 5min, 15min)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Sécurité
|
||||||
|
|
||||||
|
### 9.1 Authentification
|
||||||
|
- Tokens JWT via Laravel Sanctum (stateless)
|
||||||
|
- Refresh token rotation (invalidation à chaque renouvellement)
|
||||||
|
- Rate limiting : 5 tentatives de login / 10 minutes / IP
|
||||||
|
- Rate limiting paiements : 3 initiations / minute / user
|
||||||
|
|
||||||
|
### 9.2 Données
|
||||||
|
- Mots de passe hashés : `bcrypt` (cost 12)
|
||||||
|
- Données sensibles (IBAN, tokens paiement) : chiffrées `AES-256-CBC` en base
|
||||||
|
- HTTPS obligatoire (TLS 1.3 minimum)
|
||||||
|
- UUID exposés dans l'API (jamais les auto-increment IDs)
|
||||||
|
- Soft deletes sur `users` — anonymisation RGPD séparée
|
||||||
|
|
||||||
|
### 9.3 API
|
||||||
|
- Validation stricte de tous les inputs (`FormRequest`)
|
||||||
|
- Politique CORS restrictive (origines whitelist)
|
||||||
|
- Headers sécurité : `Strict-Transport-Security`, `X-Frame-Options`, `Content-Security-Policy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tests
|
||||||
|
|
||||||
|
| Type | Outil | Couverture cible |
|
||||||
|
|------|-------|-----------------|
|
||||||
|
| Tests unitaires | Pest | WalletService, PricingService, BookingService |
|
||||||
|
| Tests d'intégration | Pest + SQLite in-memory | Tous les endpoints API |
|
||||||
|
| Tests de contrats | — | Webhooks paiement |
|
||||||
|
| Tests de charge | k6 | Endpoints critiques (start wash, top-up) |
|
||||||
|
|
||||||
|
**Cas critiques à tester :**
|
||||||
|
- Race condition débit simultané du porte-monnaie
|
||||||
|
- Timeout passerelle LLDP → comportement de la queue
|
||||||
|
- Webhook paiement reçu en doublon (idempotence)
|
||||||
|
- No-show avec annulation simultanée
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Variables d'environnement requises
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_ENV=production
|
||||||
|
APP_KEY=base64:...
|
||||||
|
|
||||||
|
DB_HOST=
|
||||||
|
DB_DATABASE=laverie
|
||||||
|
DB_USERNAME=
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
REDIS_HOST=
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# Paiement
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_PRIVATE_KEY=
|
||||||
|
FIREBASE_CLIENT_EMAIL=
|
||||||
|
APNS_KEY_ID=
|
||||||
|
APNS_TEAM_ID=
|
||||||
|
APNS_PRIVATE_KEY_PATH=
|
||||||
|
|
||||||
|
# LLDP Gateway
|
||||||
|
LLDP_GATEWAY_URL=
|
||||||
|
LLDP_GATEWAY_TOKEN=
|
||||||
|
|
||||||
|
# URLs
|
||||||
|
FRONTEND_URL=
|
||||||
|
APP_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Livrables attendus
|
||||||
|
|
||||||
|
- [ ] Code source Laravel complet (GitHub)
|
||||||
|
- [ ] Migrations SQL versionnées
|
||||||
|
- [ ] Seeders (données de démo)
|
||||||
|
- [ ] Documentation OpenAPI 3.0 auto-générée (`/api/documentation`)
|
||||||
|
- [ ] Collection Postman exportée
|
||||||
|
- [ ] `docker-compose.yml` pour dev local
|
||||||
|
- [ ] `.env.example` documenté
|
||||||
|
- [ ] README avec instructions d'installation
|
||||||
|
- [ ] Suite de tests (couverture > 80% sur les services critiques)
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
# Cahier des Charges — API & Base de Données
|
||||||
|
## Projet : Laverie Connectée — Backend
|
||||||
|
**Version :** 2.0
|
||||||
|
**Stack principale :** Laravel 11, MySQL 8, Redis, Docker
|
||||||
|
**Positionnement :** V1 démontrable, extensible vers V2 sans refonte majeure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & Objectifs
|
||||||
|
|
||||||
|
### 1.1 Contexte
|
||||||
|
Développement d'une API centrale servant :
|
||||||
|
- une application utilisateur multi-plateforme,
|
||||||
|
- un back-office exploitant,
|
||||||
|
- une couche d'intégration avec les systèmes machines fournis par le client ou ses partenaires techniques.
|
||||||
|
|
||||||
|
L'API est le **point d'entrée unique de la logique métier**. Les clients applicatifs ne communiquent jamais directement avec la base de données.
|
||||||
|
|
||||||
|
### 1.2 Objectifs V1
|
||||||
|
- authentifier les utilisateurs finaux et les exploitants,
|
||||||
|
- gérer plusieurs laveries et plusieurs exploitants dans une même plateforme,
|
||||||
|
- exposer les données établissements / machines / disponibilités,
|
||||||
|
- gérer un porte-monnaie électronique avec traçabilité,
|
||||||
|
- permettre la réservation d'un créneau,
|
||||||
|
- permettre le démarrage d'un lavage via intégration machine,
|
||||||
|
- historiser les événements machines,
|
||||||
|
- fournir un socle d'audit, de sécurité et de statistiques simples.
|
||||||
|
|
||||||
|
### 1.3 Principes de conception
|
||||||
|
- **MVP strict** : seules les fonctionnalités nécessaires à la démonstration et à la première mise en service sont incluses en V1.
|
||||||
|
- **Extensibilité** : fidélité, abonnements, parrainage, campagnes marketing et analytics avancées sont prévues mais non implémentées en V1.
|
||||||
|
- **Traçabilité** : tout flux critique doit être rejouable et auditable.
|
||||||
|
- **Isolation métier** : chaque exploitant ne voit que ses établissements, ses machines et ses chiffres.
|
||||||
|
- **Intégration externe souple** : la couche machine doit supporter aussi bien un modèle où notre système appelle une API partenaire qu'un modèle où le partenaire pousse des événements vers notre API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Périmètre fonctionnel
|
||||||
|
|
||||||
|
### 2.1 MVP V1
|
||||||
|
|
||||||
|
#### Utilisateur final
|
||||||
|
- inscription / connexion,
|
||||||
|
- consultation des laveries,
|
||||||
|
- consultation des machines et de leur statut,
|
||||||
|
- consultation du solde wallet,
|
||||||
|
- historique simple des transactions,
|
||||||
|
- rechargement du wallet,
|
||||||
|
- réservation d'un créneau,
|
||||||
|
- démarrage d'un lavage,
|
||||||
|
- historique des lavages,
|
||||||
|
- notifications transactionnelles.
|
||||||
|
|
||||||
|
#### Exploitant
|
||||||
|
- connexion superviseur,
|
||||||
|
- consultation du parc machines,
|
||||||
|
- consultation d'un tableau de bord simple,
|
||||||
|
- consultation des réservations et lavages,
|
||||||
|
- gestion simple de la tarification,
|
||||||
|
- visualisation des alertes techniques de base.
|
||||||
|
|
||||||
|
#### Plateforme
|
||||||
|
- multi-exploitants,
|
||||||
|
- audit logs,
|
||||||
|
- agrégats journaliers simples,
|
||||||
|
- intégration machine simulable pour démonstration.
|
||||||
|
|
||||||
|
### 2.2 V2 prévue
|
||||||
|
- fidélité,
|
||||||
|
- parrainage,
|
||||||
|
- abonnements,
|
||||||
|
- campagnes marketing,
|
||||||
|
- promotions avancées,
|
||||||
|
- exports RGPD complets,
|
||||||
|
- analytics détaillées,
|
||||||
|
- règles tarifaires complexes,
|
||||||
|
- exports métier avancés.
|
||||||
|
|
||||||
|
### 2.3 Hors périmètre V1
|
||||||
|
- chat,
|
||||||
|
- avis,
|
||||||
|
- FAQ dynamique,
|
||||||
|
- recommandation de cycle par photo,
|
||||||
|
- moteur prédictif / deep learning,
|
||||||
|
- orchestration matérielle bas niveau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stack Technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Version | Commentaire |
|
||||||
|
|-----------|-------------|---------|-------------|
|
||||||
|
| Framework API | Laravel | 11.x | Coeur métier principal |
|
||||||
|
| Base de données | MySQL | 8.0 | Référentiel transactionnel |
|
||||||
|
| Cache / queues | Redis | 7.x | Queues, verrous applicatifs, cache court |
|
||||||
|
| Workers | Laravel Horizon | 5.x | Supervision des jobs |
|
||||||
|
| Scheduler | Laravel Scheduler | natif | Cron applicatif |
|
||||||
|
| Documentation API | Scramble ou L5-Swagger | — | OpenAPI |
|
||||||
|
| Conteneurisation | Docker | — | Environnements reproductibles |
|
||||||
|
| CI/CD | GitHub Actions | — | Tests + build |
|
||||||
|
| Monitoring erreurs | Sentry | — | Backend |
|
||||||
|
| Logs | JSON structurés | — | Corrélation et exploitation |
|
||||||
|
|
||||||
|
### 3.1 Authentification recommandée
|
||||||
|
- **App mobile / web utilisateur** : access token court + refresh token.
|
||||||
|
- **Back-office exploitant** : session cookie Laravel.
|
||||||
|
- **Intégrations machines / partenaires** : API key technique ou signature HMAC.
|
||||||
|
|
||||||
|
Cette séparation évite de mélanger les contraintes des apps utilisateurs, du back-office et des systèmes techniques.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture logique
|
||||||
|
|
||||||
|
### 4.1 Domaines métier principaux
|
||||||
|
- Authentification
|
||||||
|
- Organisations / exploitants
|
||||||
|
- Établissements
|
||||||
|
- Machines
|
||||||
|
- Tarification
|
||||||
|
- Wallet & paiements
|
||||||
|
- Réservations
|
||||||
|
- Lavages
|
||||||
|
- Notifications
|
||||||
|
- Intégrations machines
|
||||||
|
- Audit & statistiques
|
||||||
|
|
||||||
|
### 4.2 Multi-tenant logique
|
||||||
|
Le système n'est pas multi-base ni multi-schémas. L'isolation est assurée au niveau applicatif par :
|
||||||
|
- `organization_id`,
|
||||||
|
- policies Laravel,
|
||||||
|
- query scopes explicites,
|
||||||
|
- ressources d'API filtrées selon l'utilisateur connecté.
|
||||||
|
|
||||||
|
### 4.3 Structure recommandée Laravel
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Domain/
|
||||||
|
│ ├── Auth/
|
||||||
|
│ ├── Organization/
|
||||||
|
│ ├── Machine/
|
||||||
|
│ ├── Wallet/
|
||||||
|
│ ├── Booking/
|
||||||
|
│ ├── Wash/
|
||||||
|
│ ├── Pricing/
|
||||||
|
│ ├── Notification/
|
||||||
|
│ └── Integration/
|
||||||
|
├── Http/
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ ├── Middleware/
|
||||||
|
│ └── Requests/
|
||||||
|
├── Jobs/
|
||||||
|
├── Events/
|
||||||
|
├── Listeners/
|
||||||
|
├── Policies/
|
||||||
|
├── Models/
|
||||||
|
└── Support/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Base de Données — Schéma révisé
|
||||||
|
|
||||||
|
## 5.0 Connexion bdd de test
|
||||||
|
|
||||||
|
hostname: localhost
|
||||||
|
port: 3306
|
||||||
|
user: root
|
||||||
|
password:
|
||||||
|
shema: laverie
|
||||||
|
|
||||||
|
## 5.1 Exploitants et périmètre
|
||||||
|
|
||||||
|
### Table `organizations`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE organizations (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
code VARCHAR(50) UNIQUE NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `establishments`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE establishments (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
organization_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
city VARCHAR(100) NULL,
|
||||||
|
zip_code VARCHAR(10) NULL,
|
||||||
|
latitude DECIMAL(10,8) NULL,
|
||||||
|
longitude DECIMAL(11,8) NULL,
|
||||||
|
timezone VARCHAR(50) NOT NULL DEFAULT 'Europe/Paris',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_establishments_organization FOREIGN KEY (organization_id) REFERENCES organizations(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `supervisors`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE supervisors (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
organization_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
establishment_id BIGINT UNSIGNED NULL,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('platform_admin','owner','manager','viewer') NOT NULL DEFAULT 'manager',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_login_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_supervisors_organization FOREIGN KEY (organization_id) REFERENCES organizations(id),
|
||||||
|
CONSTRAINT fk_supervisors_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2 Utilisateurs finaux
|
||||||
|
|
||||||
|
### Table `users`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
phone VARCHAR(20) UNIQUE NULL,
|
||||||
|
birthdate DATE NULL,
|
||||||
|
email_verified_at TIMESTAMP NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
locale VARCHAR(10) NOT NULL DEFAULT 'fr',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
anonymized_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
deleted_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `gdpr_consents`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE gdpr_consents (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
type ENUM('data_processing','marketing','analytics') NOT NULL,
|
||||||
|
accepted BOOLEAN NOT NULL,
|
||||||
|
policy_version VARCHAR(50) NOT NULL,
|
||||||
|
policy_text_hash VARCHAR(255) NOT NULL,
|
||||||
|
source ENUM('mobile','web','backoffice') NOT NULL,
|
||||||
|
consent_language VARCHAR(10) NOT NULL DEFAULT 'fr',
|
||||||
|
ip_address VARCHAR(45) NULL,
|
||||||
|
user_agent TEXT NULL,
|
||||||
|
accepted_at TIMESTAMP NOT NULL,
|
||||||
|
revoked_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_gdpr_consents_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `user_devices`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_devices (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
platform ENUM('android','ios','web') NOT NULL,
|
||||||
|
push_token VARCHAR(255) NOT NULL,
|
||||||
|
app_version VARCHAR(50) NULL,
|
||||||
|
device_name VARCHAR(100) NULL,
|
||||||
|
last_seen_at TIMESTAMP NULL,
|
||||||
|
revoked_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_user_device_token (push_token),
|
||||||
|
CONSTRAINT fk_user_devices_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `notification_preferences`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE notification_preferences (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
transaction_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
reminder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
marketing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
system_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_notification_preferences_user (user_id),
|
||||||
|
CONSTRAINT fk_notification_preferences_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.3 Machines et intégrations
|
||||||
|
|
||||||
|
### Table `machines`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machines (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
type ENUM('washer_small','washer_large','dryer_small','dryer_large') NOT NULL,
|
||||||
|
qr_code VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
status ENUM('available','reserved','running','maintenance','offline','error') NOT NULL DEFAULT 'available',
|
||||||
|
current_user_id BIGINT UNSIGNED NULL,
|
||||||
|
cycle_started_at TIMESTAMP NULL,
|
||||||
|
cycle_ends_at TIMESTAMP NULL,
|
||||||
|
last_heartbeat_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_machines_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id),
|
||||||
|
CONSTRAINT fk_machines_current_user FOREIGN KEY (current_user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `machine_integrations`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machine_integrations (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider VARCHAR(100) NOT NULL,
|
||||||
|
external_machine_id VARCHAR(100) NOT NULL,
|
||||||
|
external_site_id VARCHAR(100) NULL,
|
||||||
|
mode ENUM('pull','push','hybrid','simulated') NOT NULL DEFAULT 'simulated',
|
||||||
|
config JSON NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_machine_provider_external (provider, external_machine_id),
|
||||||
|
CONSTRAINT fk_machine_integrations_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `machine_commands`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machine_commands (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider VARCHAR(100) NOT NULL,
|
||||||
|
command_type ENUM('start_cycle','stop_cycle','refresh_status') NOT NULL,
|
||||||
|
payload JSON NULL,
|
||||||
|
status ENUM('pending','sent','acknowledged','failed','timeout','cancelled') NOT NULL DEFAULT 'pending',
|
||||||
|
external_reference VARCHAR(255) NULL,
|
||||||
|
correlation_id VARCHAR(255) NULL,
|
||||||
|
requested_by_user_id BIGINT UNSIGNED NULL,
|
||||||
|
requested_by_supervisor_id BIGINT UNSIGNED NULL,
|
||||||
|
sent_at TIMESTAMP NULL,
|
||||||
|
responded_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_machine_commands_machine FOREIGN KEY (machine_id) REFERENCES machines(id),
|
||||||
|
CONSTRAINT fk_machine_commands_user FOREIGN KEY (requested_by_user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_machine_commands_supervisor FOREIGN KEY (requested_by_supervisor_id) REFERENCES supervisors(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `machine_events`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machine_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider VARCHAR(100) NOT NULL,
|
||||||
|
external_event_id VARCHAR(255) NULL,
|
||||||
|
event_type ENUM('heartbeat','machine_online','machine_offline','cycle_started','cycle_completed','cycle_failed','error_reported','status_changed') NOT NULL,
|
||||||
|
payload JSON NULL,
|
||||||
|
occurred_at TIMESTAMP NOT NULL,
|
||||||
|
received_at TIMESTAMP NOT NULL,
|
||||||
|
processed_at TIMESTAMP NULL,
|
||||||
|
processing_status ENUM('pending','processed','failed','ignored') NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_machine_events_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `machine_status_history`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machine_status_history (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
previous_status VARCHAR(50) NULL,
|
||||||
|
new_status VARCHAR(50) NOT NULL,
|
||||||
|
source VARCHAR(100) NOT NULL,
|
||||||
|
reason VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_machine_status_history_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.4 Tarification
|
||||||
|
|
||||||
|
### Table `pricing_rules`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pricing_rules (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
machine_id BIGINT UNSIGNED NULL,
|
||||||
|
machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large') NULL,
|
||||||
|
day_type ENUM('weekday','weekend','holiday','all') NOT NULL DEFAULT 'all',
|
||||||
|
slot_start TIME NOT NULL,
|
||||||
|
slot_end TIME NOT NULL,
|
||||||
|
price DECIMAL(8,2) NOT NULL,
|
||||||
|
label VARCHAR(100) NULL,
|
||||||
|
requires_app BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
priority SMALLINT NOT NULL DEFAULT 100,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_pricing_rules_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id),
|
||||||
|
CONSTRAINT fk_pricing_rules_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `promotions`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE promotions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large','all') NOT NULL DEFAULT 'all',
|
||||||
|
discount_type ENUM('percent','fixed') NOT NULL,
|
||||||
|
discount_value DECIMAL(8,2) NOT NULL,
|
||||||
|
starts_at TIMESTAMP NOT NULL,
|
||||||
|
ends_at TIMESTAMP NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_promotions_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.5 Wallet et paiements
|
||||||
|
|
||||||
|
### Table `wallets`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE wallets (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
currency CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
current_balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
status ENUM('active','blocked','closed') NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_wallets_user (user_id),
|
||||||
|
CONSTRAINT fk_wallets_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `payment_transactions`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE payment_transactions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
provider_payment_id VARCHAR(255) NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
currency CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
status ENUM('initiated','pending','succeeded','failed','cancelled','refunded') NOT NULL DEFAULT 'initiated',
|
||||||
|
idempotency_key VARCHAR(100) NOT NULL,
|
||||||
|
return_url VARCHAR(255) NULL,
|
||||||
|
raw_payload JSON NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_payment_idempotency (idempotency_key),
|
||||||
|
CONSTRAINT fk_payment_transactions_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `wallet_transactions`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE wallet_transactions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
wallet_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
type ENUM('credit','debit','hold','release','refund','adjustment') NOT NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
balance_before DECIMAL(10,2) NOT NULL,
|
||||||
|
balance_after DECIMAL(10,2) NOT NULL,
|
||||||
|
source_type VARCHAR(100) NOT NULL,
|
||||||
|
source_id BIGINT UNSIGNED NULL,
|
||||||
|
idempotency_key VARCHAR(100) NULL,
|
||||||
|
metadata JSON NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_wallet_transactions_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.6 Réservations et lavages
|
||||||
|
|
||||||
|
### Table `bookings`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE bookings (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
slot_start TIMESTAMP NOT NULL,
|
||||||
|
slot_end TIMESTAMP NOT NULL,
|
||||||
|
booking_fee DECIMAL(8,2) NOT NULL DEFAULT 0.00,
|
||||||
|
reserved_amount DECIMAL(8,2) NOT NULL DEFAULT 0.00,
|
||||||
|
penalty_amount DECIMAL(8,2) NOT NULL DEFAULT 0.00,
|
||||||
|
status ENUM('pending','confirmed','cancelled','expired','active','completed','no_show') NOT NULL DEFAULT 'pending',
|
||||||
|
cancelled_at TIMESTAMP NULL,
|
||||||
|
penalty_applied_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_bookings_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_bookings_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `washes`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE washes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
booking_id BIGINT UNSIGNED NULL,
|
||||||
|
machine_command_id BIGINT UNSIGNED NULL,
|
||||||
|
trigger_method ENUM('qr_code','booking','supervisor','system') NOT NULL,
|
||||||
|
status ENUM('pending_start','running','completed','failed','cancelled') NOT NULL DEFAULT 'pending_start',
|
||||||
|
program VARCHAR(100) NULL,
|
||||||
|
started_at TIMESTAMP NULL,
|
||||||
|
ended_at TIMESTAMP NULL,
|
||||||
|
duration_minutes INT UNSIGNED NULL,
|
||||||
|
cost DECIMAL(8,2) NOT NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_washes_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_washes_machine FOREIGN KEY (machine_id) REFERENCES machines(id),
|
||||||
|
CONSTRAINT fk_washes_booking FOREIGN KEY (booking_id) REFERENCES bookings(id),
|
||||||
|
CONSTRAINT fk_washes_command FOREIGN KEY (machine_command_id) REFERENCES machine_commands(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.7 Notifications, audit et statistiques
|
||||||
|
|
||||||
|
### Table `push_notifications`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE push_notifications (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NULL,
|
||||||
|
type VARCHAR(100) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
data JSON NULL,
|
||||||
|
status ENUM('pending','sent','failed') NOT NULL DEFAULT 'pending',
|
||||||
|
sent_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_push_notifications_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `audit_logs`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
actor_type VARCHAR(50) NOT NULL,
|
||||||
|
actor_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
organization_id BIGINT UNSIGNED NULL,
|
||||||
|
establishment_id BIGINT UNSIGNED NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
target_type VARCHAR(100) NOT NULL,
|
||||||
|
target_id BIGINT UNSIGNED NULL,
|
||||||
|
before_data JSON NULL,
|
||||||
|
after_data JSON NULL,
|
||||||
|
ip_address VARCHAR(45) NULL,
|
||||||
|
created_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `daily_establishment_stats`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE daily_establishment_stats (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
stat_date DATE NOT NULL,
|
||||||
|
total_washes INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
total_revenue DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
bookings_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
no_show_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
top_up_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
occupancy_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_daily_establishment_stats (establishment_id, stat_date),
|
||||||
|
CONSTRAINT fk_daily_establishment_stats_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Contrat d'intégration machine
|
||||||
|
|
||||||
|
### 6.1 Principe
|
||||||
|
Le backend doit supporter deux scénarios :
|
||||||
|
- **pull** : notre système appelle une API partenaire pour envoyer une commande ou lire un statut,
|
||||||
|
- **push** : le partenaire pousse des événements vers notre système,
|
||||||
|
- **hybrid** : combinaison des deux,
|
||||||
|
- **simulated** : mode démonstration / sandbox.
|
||||||
|
|
||||||
|
### 6.2 Abstraction applicative
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface MachineProviderInterface {
|
||||||
|
public function startCycle(Machine $machine, array $payload): MachineCommandResult;
|
||||||
|
public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult;
|
||||||
|
public function refreshStatus(Machine $machine): MachineStatusSnapshot;
|
||||||
|
public function handleInboundEvent(array $payload): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Événements entrants attendus
|
||||||
|
- `heartbeat`
|
||||||
|
- `machine_online`
|
||||||
|
- `machine_offline`
|
||||||
|
- `cycle_started`
|
||||||
|
- `cycle_completed`
|
||||||
|
- `cycle_failed`
|
||||||
|
- `error_reported`
|
||||||
|
- `status_changed`
|
||||||
|
|
||||||
|
### 6.4 Endpoints d'intégration proposés
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Usage |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| POST | `/api/v1/integrations/machines/events` | Réception d'événements machine |
|
||||||
|
| POST | `/api/v1/integrations/machines/heartbeat` | Heartbeat passerelle / machine |
|
||||||
|
| POST | `/api/v1/integrations/machines/commands/{uuid}/ack` | Accusé de réception optionnel |
|
||||||
|
|
||||||
|
### 6.5 Payload minimal d'événement
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "client_gateway",
|
||||||
|
"event_type": "cycle_completed",
|
||||||
|
"occurred_at": "2026-06-27T10:15:00Z",
|
||||||
|
"machine_external_id": "MACH-001",
|
||||||
|
"correlation_id": "cmd_12345",
|
||||||
|
"data": {
|
||||||
|
"status": "available",
|
||||||
|
"duration_minutes": 42,
|
||||||
|
"error_code": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Exigences minimales côté partenaire
|
||||||
|
- identifiant machine externe stable,
|
||||||
|
- horodatage fiable,
|
||||||
|
- type d'événement,
|
||||||
|
- état courant de la machine,
|
||||||
|
- identifiant de corrélation si une commande a été envoyée par notre système,
|
||||||
|
- stratégie d'authentification technique,
|
||||||
|
- documentation des erreurs métiers.
|
||||||
|
|
||||||
|
### 6.7 Mode démonstration
|
||||||
|
Un provider simulé doit permettre :
|
||||||
|
- démarrage immédiat d'un cycle,
|
||||||
|
- passage en statut `running`,
|
||||||
|
- émission d'un faux événement `cycle_completed` après un délai configurable,
|
||||||
|
- retour en statut `available`.
|
||||||
|
|
||||||
|
Ce mode permet une démo sans matériel réel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Endpoints API — V1
|
||||||
|
|
||||||
|
### 7.1 Auth utilisateur
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| POST | `/api/v1/auth/register` | Inscription |
|
||||||
|
| POST | `/api/v1/auth/login` | Connexion utilisateur |
|
||||||
|
| POST | `/api/v1/auth/refresh` | Renouvellement token |
|
||||||
|
| POST | `/api/v1/auth/logout` | Déconnexion |
|
||||||
|
| GET | `/api/v1/auth/me` | Profil courant |
|
||||||
|
|
||||||
|
### 7.2 Wallet
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| GET | `/api/v1/wallet` | Solde |
|
||||||
|
| GET | `/api/v1/wallet/transactions` | Historique |
|
||||||
|
| POST | `/api/v1/wallet/top-up/initiate` | Démarrer un rechargement |
|
||||||
|
| POST | `/api/v1/wallet/top-up/confirm` | Confirmation de rechargement |
|
||||||
|
| POST | `/api/v1/wallet/top-up/webhook/{provider}` | Webhook PSP |
|
||||||
|
|
||||||
|
### 7.3 Établissements & machines
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| GET | `/api/v1/establishments` | Liste établissements |
|
||||||
|
| GET | `/api/v1/establishments/{uuid}` | Détail établissement |
|
||||||
|
| GET | `/api/v1/machines/{uuid}` | Détail machine |
|
||||||
|
| GET | `/api/v1/machines/{uuid}/availability` | Créneaux disponibles |
|
||||||
|
| GET | `/api/v1/machines/{uuid}/pricing` | Tarif actif |
|
||||||
|
|
||||||
|
### 7.4 Réservations
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| POST | `/api/v1/bookings` | Créer une réservation |
|
||||||
|
| GET | `/api/v1/bookings` | Mes réservations |
|
||||||
|
| GET | `/api/v1/bookings/{uuid}` | Détail réservation |
|
||||||
|
| PATCH | `/api/v1/bookings/{uuid}/cancel` | Annuler |
|
||||||
|
| PATCH | `/api/v1/bookings/{uuid}/move` | Déplacer |
|
||||||
|
|
||||||
|
### 7.5 Lavages
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| POST | `/api/v1/washes/start` | Démarrer un lavage |
|
||||||
|
| GET | `/api/v1/washes` | Historique |
|
||||||
|
| GET | `/api/v1/washes/{uuid}` | Détail |
|
||||||
|
|
||||||
|
### 7.6 Superviseur
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| POST | `/api/v1/supervisor/auth/login` | Connexion superviseur |
|
||||||
|
| POST | `/api/v1/supervisor/auth/logout` | Déconnexion |
|
||||||
|
| GET | `/api/v1/supervisor/dashboard` | Dashboard |
|
||||||
|
| GET | `/api/v1/supervisor/machines` | Parc machines |
|
||||||
|
| GET | `/api/v1/supervisor/bookings` | Réservations |
|
||||||
|
| GET | `/api/v1/supervisor/washes` | Lavages |
|
||||||
|
| GET | `/api/v1/supervisor/pricing` | Tarifs |
|
||||||
|
| POST | `/api/v1/supervisor/pricing` | Création tarif |
|
||||||
|
| PUT | `/api/v1/supervisor/pricing/{id}` | Modification tarif |
|
||||||
|
| GET | `/api/v1/supervisor/promotions` | Promotions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Règles métier critiques
|
||||||
|
|
||||||
|
### 8.1 Wallet
|
||||||
|
- aucune écriture directe de solde hors `WalletService`,
|
||||||
|
- toute opération critique porte une clé d'idempotence,
|
||||||
|
- séparation stricte entre transaction de paiement externe et mouvement de wallet interne.
|
||||||
|
|
||||||
|
### 8.2 Réservations
|
||||||
|
- un créneau machine ne peut jamais être réservé deux fois,
|
||||||
|
- création de réservation sous transaction avec verrou logique,
|
||||||
|
- pénalité et remboursement centralisés dans `BookingService`.
|
||||||
|
|
||||||
|
### 8.3 Lavages
|
||||||
|
- un lavage ne peut démarrer que si la machine est éligible,
|
||||||
|
- toute commande machine doit être historisée,
|
||||||
|
- toute fin de cycle doit être confirmée par événement ou simulation contrôlée.
|
||||||
|
|
||||||
|
### 8.4 Scoping exploitant
|
||||||
|
- toute requête superviseur doit être filtrée par `organization_id`,
|
||||||
|
- si `establishment_id` est renseigné sur le superviseur, la visibilité est limitée à cet établissement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tâches planifiées
|
||||||
|
|
||||||
|
| Fréquence | Job | Description |
|
||||||
|
|-----------|-----|-------------|
|
||||||
|
| Toutes les 5 min | `CheckNoShowBookings` | Détection des no-shows |
|
||||||
|
| Toutes les 5 min | `RefreshOfflineMachines` | Contrôle heartbeat / statuts |
|
||||||
|
| Toutes les heures | `SendScheduledNotifications` | Notifications en attente |
|
||||||
|
| Chaque nuit | `GenerateDailyStats` | Agrégats journaliers |
|
||||||
|
| Chaque nuit | `PurgeRevokedTokens` | Nettoyage technique |
|
||||||
|
| Chaque semaine | `AnonymizeInactiveUsers` | Politique RGPD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sécurité
|
||||||
|
|
||||||
|
### 10.1 Général
|
||||||
|
- HTTPS obligatoire,
|
||||||
|
- validation stricte des entrées,
|
||||||
|
- rate limiting sur auth, paiements et endpoints techniques,
|
||||||
|
- headers de sécurité côté web,
|
||||||
|
- UUID publics dans l'API.
|
||||||
|
|
||||||
|
### 10.2 Paiements
|
||||||
|
- vérification systématique des webhooks,
|
||||||
|
- aucune confiance dans le retour client seul,
|
||||||
|
- conservation du payload brut du PSP.
|
||||||
|
|
||||||
|
### 10.3 Intégrations machines
|
||||||
|
- API key dédiée ou signature HMAC,
|
||||||
|
- journalisation des appels,
|
||||||
|
- horodatage et contrôle anti-rejeu si possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Observabilité
|
||||||
|
|
||||||
|
- Sentry backend,
|
||||||
|
- logs structurés JSON,
|
||||||
|
- suivi des jobs Laravel Horizon,
|
||||||
|
- métriques minimales :
|
||||||
|
- taux d'échec commandes machines,
|
||||||
|
- taux d'échec paiements,
|
||||||
|
- machines offline,
|
||||||
|
- jobs en échec,
|
||||||
|
- no-shows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Tests
|
||||||
|
|
||||||
|
| Type | Outil | Portée |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Unitaires | Pest | Wallet, Booking, Pricing, Machine provider |
|
||||||
|
| Intégration | Pest + MySQL réel | Endpoints critiques |
|
||||||
|
| Contrat | Payloads partenaires | Intégration machine / paiement |
|
||||||
|
| Charge | k6 | Wallet, booking, start wash |
|
||||||
|
|
||||||
|
### Cas critiques obligatoires
|
||||||
|
- double webhook paiement,
|
||||||
|
- double réservation même créneau,
|
||||||
|
- timeout commande machine,
|
||||||
|
- événement machine reçu en doublon,
|
||||||
|
- annulation et no-show simultanés,
|
||||||
|
- scoping superviseur inter-organisation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Livrables attendus
|
||||||
|
|
||||||
|
- code source Laravel,
|
||||||
|
- migrations et seeders,
|
||||||
|
- documentation OpenAPI,
|
||||||
|
- `.env.example`,
|
||||||
|
- Docker pour dev,
|
||||||
|
- suite de tests critique,
|
||||||
|
- mode simulation machine,
|
||||||
|
- données de démonstration multi-laveries.
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
# Cahier des Charges — Back-Office Superviseur
|
||||||
|
## Projet : Laverie Connectée — Interface d'Administration
|
||||||
|
**Version :** 1.0
|
||||||
|
**Date :** 2026-06-27
|
||||||
|
**Stack principale :** Laravel 11 (API déjà existante) + Vue.js 3 + Inertia.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & Objectifs
|
||||||
|
|
||||||
|
### 1.1 Contexte
|
||||||
|
Interface web d'administration destinée aux **superviseurs** (exploitants de laveries). Elle s'appuie sur les endpoints `/api/v1/supervisor/*` de l'API Laravel et s'authentifie avec le guard `supervisor`.
|
||||||
|
|
||||||
|
Le back-office est une **SPA (Single Page Application)** rendue côté serveur via Inertia.js pour simplifier le déploiement (pas d'API séparée pour le back-office, utilisation directe de Laravel comme backend Inertia).
|
||||||
|
|
||||||
|
### 1.2 Utilisateurs cibles
|
||||||
|
| Rôle | Droits |
|
||||||
|
|------|--------|
|
||||||
|
| `admin` | Accès complet multi-établissements, gestion des superviseurs |
|
||||||
|
| `manager` | Accès complet à son établissement |
|
||||||
|
| `viewer` | Lecture seule (statistiques, tableau de bord) |
|
||||||
|
|
||||||
|
### 1.3 Objectifs fonctionnels
|
||||||
|
- Visualiser l'occupation en temps réel du parc de machines
|
||||||
|
- Consulter les statistiques d'utilisation et le chiffre d'affaires
|
||||||
|
- Gérer la tarification et les promotions
|
||||||
|
- Recevoir et gérer les alertes machines (pannes, hors ligne)
|
||||||
|
- (v2) Envoyer des campagnes de notifications aux utilisateurs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stack Technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Version | Justification |
|
||||||
|
|-----------|-------------|---------|---------------|
|
||||||
|
| Backend | Laravel 11 | (partagé avec l'API) | Inertia server-side rendering |
|
||||||
|
| Intégration SPA | Inertia.js | 2.x | SSR Laravel → Vue.js sans API REST dédiée |
|
||||||
|
| Framework UI | Vue.js | 3.x (Composition API) | Réactivité, écosystème riche |
|
||||||
|
| Build tool | Vite | 5.x | HMR rapide, bundling optimisé |
|
||||||
|
| UI Components | PrimeVue | 4.x | DataTable, Chart, Calendar, etc. |
|
||||||
|
| CSS | Tailwind CSS | 3.x | Utility-first, cohérence design |
|
||||||
|
| Graphiques | Chart.js via PrimeVue | 4.x | Courbes CA, camembert machines |
|
||||||
|
| State management | Pinia | 2.x | Store réactif Vue 3 |
|
||||||
|
| Temps réel | Laravel Echo + Pusher (ou Soketi self-hosted) | — | Mise à jour statut machines |
|
||||||
|
| Validation forms | VeeValidate + Zod | — | Validation côté client |
|
||||||
|
| Tables | PrimeVue DataTable | — | Tri, filtre, pagination, export |
|
||||||
|
| Auth | Laravel Sanctum (session cookie) | — | SPA same-domain |
|
||||||
|
| Tests back | Pest | — | Tests controllers superviseur |
|
||||||
|
| Tests front | Vitest + Vue Test Utils | — | Composants Vue |
|
||||||
|
| E2E | Playwright | — | Parcours critiques superviseur |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Structure des fichiers
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/
|
||||||
|
├── js/
|
||||||
|
│ ├── app.js # Point d'entrée Inertia
|
||||||
|
│ ├── bootstrap.js # Echo, Axios
|
||||||
|
│ ├── Components/
|
||||||
|
│ │ ├── Layout/
|
||||||
|
│ │ │ ├── AppLayout.vue # Layout principal (sidebar + topbar)
|
||||||
|
│ │ │ ├── Sidebar.vue
|
||||||
|
│ │ │ └── Topbar.vue
|
||||||
|
│ │ ├── Charts/
|
||||||
|
│ │ │ ├── RevenueChart.vue
|
||||||
|
│ │ │ ├── OccupancyChart.vue
|
||||||
|
│ │ │ └── MachineStatusChart.vue
|
||||||
|
│ │ ├── Machines/
|
||||||
|
│ │ │ ├── MachineCard.vue # Carte statut en temps réel
|
||||||
|
│ │ │ └── MachineGrid.vue
|
||||||
|
│ │ └── UI/
|
||||||
|
│ │ ├── StatCard.vue
|
||||||
|
│ │ ├── AlertBadge.vue
|
||||||
|
│ │ └── ConfirmDialog.vue
|
||||||
|
│ │
|
||||||
|
│ └── Pages/
|
||||||
|
│ ├── Auth/
|
||||||
|
│ │ └── Login.vue
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ │ └── Index.vue
|
||||||
|
│ ├── Machines/
|
||||||
|
│ │ ├── Index.vue
|
||||||
|
│ │ └── Show.vue
|
||||||
|
│ ├── Pricing/
|
||||||
|
│ │ ├── Index.vue
|
||||||
|
│ │ └── Edit.vue
|
||||||
|
│ ├── Promotions/
|
||||||
|
│ │ ├── Index.vue
|
||||||
|
│ │ └── Create.vue
|
||||||
|
│ ├── Stats/
|
||||||
|
│ │ └── Index.vue
|
||||||
|
│ └── Settings/
|
||||||
|
│ └── Index.vue
|
||||||
|
│
|
||||||
|
app/
|
||||||
|
├── Http/Controllers/Supervisor/ # (déjà définis dans CDC API)
|
||||||
|
│ ├── DashboardController.php
|
||||||
|
│ ├── MachineController.php
|
||||||
|
│ ├── PricingController.php
|
||||||
|
│ ├── PromotionController.php
|
||||||
|
│ └── StatsController.php
|
||||||
|
└── Events/
|
||||||
|
└── MachineStatusUpdated.php # Broadcast temps réel
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Routage Laravel (Inertia)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// routes/supervisor.php (middleware: auth:supervisor, inertia)
|
||||||
|
Route::prefix('supervisor')->name('supervisor.')->group(function () {
|
||||||
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
Route::resource('/machines', MachineController::class)->only(['index','show','update']);
|
||||||
|
Route::resource('/pricing', PricingController::class);
|
||||||
|
Route::resource('/promotions', PromotionController::class);
|
||||||
|
Route::get('/stats', StatsController::class)->name('stats');
|
||||||
|
Route::get('/settings', SettingsController::class)->name('settings');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Écrans & Fonctionnalités
|
||||||
|
|
||||||
|
### 4.1 Connexion (`/supervisor/login`)
|
||||||
|
|
||||||
|
**Formulaire :**
|
||||||
|
- Email + Mot de passe
|
||||||
|
- Bouton "Se connecter"
|
||||||
|
- Lien "Mot de passe oublié"
|
||||||
|
|
||||||
|
**Comportement :**
|
||||||
|
- Session cookie Sanctum (SPA same-domain)
|
||||||
|
- Redirection automatique vers `/supervisor/dashboard` si déjà connecté
|
||||||
|
- Rate limiting : 5 tentatives / 10 min
|
||||||
|
|
||||||
|
**Données Inertia passées :**
|
||||||
|
```php
|
||||||
|
// Aucune — page statique
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Tableau de Bord (`/supervisor/dashboard`)
|
||||||
|
|
||||||
|
C'est l'écran central. Il se rafraîchit partiellement en temps réel via Laravel Echo.
|
||||||
|
|
||||||
|
#### 4.2.1 Bandeau de KPIs (haut de page)
|
||||||
|
|
||||||
|
| KPI | Description | Période |
|
||||||
|
|-----|-------------|---------|
|
||||||
|
| CA du jour | Somme des `washes.cost` du jour | Aujourd'hui |
|
||||||
|
| Lavages du jour | Nombre de `washes` terminés | Aujourd'hui |
|
||||||
|
| Taux d'occupation | % machines actives / total | Temps réel |
|
||||||
|
| Solde moyen rechargé | Moyenne des top-ups | Cette semaine |
|
||||||
|
|
||||||
|
Chaque KPI affiche la variation vs. la veille (flèche + couleur).
|
||||||
|
|
||||||
|
#### 4.2.2 Grille des Machines (temps réel)
|
||||||
|
|
||||||
|
- Grille de cartes, une carte par machine
|
||||||
|
- Mise à jour via **WebSocket** (Laravel Echo, channel `establishment.{id}`, event `MachineStatusUpdated`)
|
||||||
|
- Chaque carte affiche :
|
||||||
|
- Nom et type de machine (icône)
|
||||||
|
- Statut (badge coloré)
|
||||||
|
- Temps restant si en cours (countdown)
|
||||||
|
- Utilisateur en cours (avatar anonymisé : initiales)
|
||||||
|
- Dernière activité
|
||||||
|
|
||||||
|
**Couleurs statut :**
|
||||||
|
| Statut | Couleur |
|
||||||
|
|--------|---------|
|
||||||
|
| `available` | 🟢 Vert |
|
||||||
|
| `running` | 🔵 Bleu + animation pulse |
|
||||||
|
| `reserved` | 🟡 Jaune |
|
||||||
|
| `maintenance` | 🟠 Orange |
|
||||||
|
| `offline` | 🔴 Rouge |
|
||||||
|
|
||||||
|
**Actions rapides (rôle manager/admin) :**
|
||||||
|
- Mettre en maintenance
|
||||||
|
- Forcer disponible
|
||||||
|
- Voir le détail
|
||||||
|
|
||||||
|
#### 4.2.3 Alertes Actives
|
||||||
|
|
||||||
|
Panel latéral ou section dédié listant :
|
||||||
|
- Machines hors ligne (dernier heartbeat > 5 min)
|
||||||
|
- Machines en erreur signalée
|
||||||
|
- Réservations no-show récentes
|
||||||
|
|
||||||
|
Chaque alerte : icône, machine concernée, heure, bouton "Traité".
|
||||||
|
|
||||||
|
#### 4.2.4 Graphique d'occupation journalière
|
||||||
|
|
||||||
|
Courbe Chart.js (via PrimeVue) :
|
||||||
|
- Axe X : heures (00h → 23h)
|
||||||
|
- Axe Y : % d'occupation
|
||||||
|
- Deux courbes : Aujourd'hui vs Moyenne 30 jours
|
||||||
|
|
||||||
|
**Données Inertia passées :**
|
||||||
|
```php
|
||||||
|
Inertia::render('Dashboard/Index', [
|
||||||
|
'machines' => MachineResource::collection($machines),
|
||||||
|
'kpis' => $dashboardService->getTodayKpis($establishment),
|
||||||
|
'alerts' => AlertResource::collection($activeAlerts),
|
||||||
|
'occupancy_chart' => $statsService->getHourlyOccupancy($establishment, today()),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Statistiques (`/supervisor/stats`)
|
||||||
|
|
||||||
|
Écran dédié à l'analyse de données avec filtres temporels.
|
||||||
|
|
||||||
|
#### 4.3.1 Filtres
|
||||||
|
|
||||||
|
- Période : Aujourd'hui / 7 jours / 30 jours / 3 mois / Personnalisée (date picker)
|
||||||
|
- Machine : Toutes / Par type / Par machine spécifique
|
||||||
|
|
||||||
|
#### 4.3.2 Blocs de statistiques
|
||||||
|
|
||||||
|
**Chiffre d'affaires**
|
||||||
|
- Graphique en barres : CA par jour sur la période
|
||||||
|
- Ligne de tendance (moyenne mobile 7 jours)
|
||||||
|
- Total période, meilleur jour, pire jour
|
||||||
|
|
||||||
|
**Utilisation des machines**
|
||||||
|
- Graphique camembert : Répartition par type de machine
|
||||||
|
- Nombre de cycles par machine (tableau trié)
|
||||||
|
- Taux d'utilisation horaire moyen
|
||||||
|
|
||||||
|
**Utilisateurs**
|
||||||
|
- Nouveaux inscrits sur la période
|
||||||
|
- Utilisateurs actifs (au moins 1 lavage)
|
||||||
|
- Top 10 utilisateurs (anonymisés : "Utilisateur #XXX")
|
||||||
|
|
||||||
|
**Réservations**
|
||||||
|
- Nombre de réservations vs. lavages directs
|
||||||
|
- Taux de no-show
|
||||||
|
- Taux d'annulation
|
||||||
|
|
||||||
|
**Rechargements**
|
||||||
|
- CA par source de paiement (Stripe, etc.)
|
||||||
|
- Montant moyen rechargé
|
||||||
|
- Fréquence de rechargement
|
||||||
|
|
||||||
|
#### 4.3.3 Export
|
||||||
|
|
||||||
|
- Export CSV des données brutes de la période sélectionnée
|
||||||
|
- Export PDF du rapport (via impression navigateur, layout print-optimized)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Gestion de la Tarification (`/supervisor/pricing`)
|
||||||
|
|
||||||
|
#### 4.4.1 Vue Liste
|
||||||
|
|
||||||
|
Tableau (PrimeVue DataTable) listant toutes les règles tarifaires :
|
||||||
|
|
||||||
|
| Machine | Jours | Horaire | Prix | Exclusive App | Actions |
|
||||||
|
|---------|-------|---------|------|---------------|---------|
|
||||||
|
| Lave-linge 7kg | Semaine | 08h-12h | 3.50€ | Non | ✏️ 🗑 |
|
||||||
|
| Lave-linge 7kg | Semaine | 18h-22h | 4.50€ | Oui | ✏️ 🗑 |
|
||||||
|
|
||||||
|
- Filtrable par machine, type de jour
|
||||||
|
- Tri par colonne
|
||||||
|
- Badge "Actif" / "Inactif" si les horaires sont hors plage
|
||||||
|
|
||||||
|
#### 4.4.2 Formulaire Création / Édition
|
||||||
|
|
||||||
|
**Champs :**
|
||||||
|
- Machine (select, avec recherche)
|
||||||
|
- Jours applicables (checkboxes : Lun–Ven / Sam–Dim / Jours fériés / Tous)
|
||||||
|
- Heure de début / fin (time pickers, validation : début < fin)
|
||||||
|
- Prix en euros (input numérique, 2 décimales, min 0.50€)
|
||||||
|
- Label (ex: "Heure creuse", "Heure pleine")
|
||||||
|
- Exclusive app ✓ (si coché, ce tarif n'est visible que via l'application)
|
||||||
|
|
||||||
|
**Validation :**
|
||||||
|
- Vérification de non-chevauchement avec les règles existantes sur la même machine
|
||||||
|
- Avertissement si la plage couvre toute la journée sans tarif de base restant
|
||||||
|
|
||||||
|
**Règle de priorité affichée :**
|
||||||
|
> "Une règle spécifique à une machine a priorité sur une règle par type de machine."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 Gestion des Promotions (`/supervisor/promotions`)
|
||||||
|
|
||||||
|
#### 4.5.1 Vue Liste
|
||||||
|
|
||||||
|
Tableau avec onglets : **Actives** / **À venir** / **Terminées**
|
||||||
|
|
||||||
|
Colonnes : Type machine, Réduction, Début, Fin, Description, Statut, Actions.
|
||||||
|
|
||||||
|
#### 4.5.2 Formulaire Création
|
||||||
|
|
||||||
|
**Champs :**
|
||||||
|
- Type de machine concernée (Lave-linge petit / grand / Sèche-linge / Tous)
|
||||||
|
- Type de réduction :
|
||||||
|
- Pourcentage (ex: -20%)
|
||||||
|
- Montant fixe (ex: -0.50€)
|
||||||
|
- Valeur de la réduction
|
||||||
|
- Date/heure de début (datetime picker)
|
||||||
|
- Date/heure de fin (datetime picker)
|
||||||
|
- Description interne (texte libre, visible uniquement dans le back-office)
|
||||||
|
|
||||||
|
**Validation :**
|
||||||
|
- Date de fin > Date de début
|
||||||
|
- Réduction % : 1–90%
|
||||||
|
- Réduction fixe : ne peut pas dépasser le prix minimum (0.50€ minimum après remise)
|
||||||
|
- Avertissement si chevauchement avec une promotion existante du même type
|
||||||
|
|
||||||
|
**Impact affiché en temps réel :**
|
||||||
|
Aperçu du nouveau prix après promotion pour chaque règle tarifaire concernée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.6 Détail Machine (`/supervisor/machines/{uuid}`)
|
||||||
|
|
||||||
|
- Informations générales (nom, type, QR code, ID LLDP)
|
||||||
|
- Statut actuel avec boutons d'action (Maintenance / Disponible)
|
||||||
|
- Historique des 30 derniers cycles (tableau paginé)
|
||||||
|
- Grille tarifaire appliquée
|
||||||
|
- Uptime sur 30 jours (%)
|
||||||
|
- Historique des alertes / pannes (30 derniers jours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Temps Réel — Laravel Echo
|
||||||
|
|
||||||
|
### 5.1 Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// bootstrap.js
|
||||||
|
import Echo from 'laravel-echo';
|
||||||
|
import Pusher from 'pusher-js';
|
||||||
|
|
||||||
|
window.Echo = new Echo({
|
||||||
|
broadcaster: 'pusher',
|
||||||
|
key: import.meta.env.VITE_PUSHER_APP_KEY,
|
||||||
|
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
|
||||||
|
wsHost: import.meta.env.VITE_PUSHER_HOST, // Soketi en self-hosted
|
||||||
|
wsPort: 6001,
|
||||||
|
forceTLS: false,
|
||||||
|
enabledTransports: ['ws', 'wss'],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Channels & Events
|
||||||
|
|
||||||
|
| Channel | Event | Payload | Déclencheur |
|
||||||
|
|---------|-------|---------|-------------|
|
||||||
|
| `private-establishment.{id}` | `MachineStatusUpdated` | `{ machine_uuid, status, current_user_initials, cycle_ends_at }` | Heartbeat LLDP + fin de cycle |
|
||||||
|
| `private-establishment.{id}` | `NewAlert` | `{ type, machine_uuid, message, created_at }` | Offline détecté, erreur machine |
|
||||||
|
| `private-establishment.{id}` | `KpiUpdated` | `{ ca_today, washes_today, occupancy_rate }` | Fin de chaque lavage |
|
||||||
|
|
||||||
|
### 5.3 Implémentation Vue
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// DashboardController.vue (Composition API)
|
||||||
|
onMounted(() => {
|
||||||
|
window.Echo
|
||||||
|
.private(`establishment.${props.establishment.id}`)
|
||||||
|
.listen('MachineStatusUpdated', (event) => {
|
||||||
|
machineStore.updateMachineStatus(event.machine_uuid, event);
|
||||||
|
})
|
||||||
|
.listen('NewAlert', (event) => {
|
||||||
|
alertStore.addAlert(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.Echo.leave(`establishment.${props.establishment.id}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sécurité
|
||||||
|
|
||||||
|
### 6.1 Authentification
|
||||||
|
- Session cookie Sanctum (SameSite=Lax, Secure en prod)
|
||||||
|
- CSRF token sur toutes les mutations
|
||||||
|
- Timeout de session : 8 heures d'inactivité
|
||||||
|
|
||||||
|
### 6.2 Autorisation (Policies Laravel)
|
||||||
|
```
|
||||||
|
MachinePolicy::update() → role manager ou admin ET même établissement
|
||||||
|
PricingPolicy::create() → role manager ou admin
|
||||||
|
PromotionPolicy::delete() → role admin seulement
|
||||||
|
StatsPolicy::view() → tous les rôles
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Données
|
||||||
|
- Les données utilisateurs affichées sont **anonymisées** (initiales, jamais nom complet ou email)
|
||||||
|
- Logs d'audit sur toutes les mutations (qui a modifié quoi et quand)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Responsive & Accessibilité
|
||||||
|
|
||||||
|
- Layout responsive : sidebar collapsible sur tablette, menu bottom sur mobile
|
||||||
|
- Taille minimale cible : tablette 768px (utilisation en mobilité sur le terrain)
|
||||||
|
- Contraste WCAG AA sur tous les textes
|
||||||
|
- Navigation clavier complète (focus visible)
|
||||||
|
- Attributs ARIA sur les graphiques (descriptions alternatives)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tests
|
||||||
|
|
||||||
|
| Type | Outil | Portée |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Tests unitaires | Pest | Services stats, services tarification |
|
||||||
|
| Tests controllers | Pest | Tous les controllers superviseur (mocks auth) |
|
||||||
|
| Tests composants | Vitest + VTU | StatCard, MachineCard, PricingForm |
|
||||||
|
| Tests E2E | Playwright | Login, modifier un tarif, créer une promo, consulter stats |
|
||||||
|
|
||||||
|
**Scénarios Playwright critiques :**
|
||||||
|
1. Login échoué (mauvais mot de passe) → message d'erreur
|
||||||
|
2. Login réussi → redirection dashboard
|
||||||
|
3. Créer une règle tarifaire → vérification dans la liste
|
||||||
|
4. Créer une promotion avec chevauchement → message d'avertissement
|
||||||
|
5. Mise en maintenance d'une machine → badge mis à jour en temps réel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Variables d'environnement spécifiques
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Pusher / Soketi (temps réel)
|
||||||
|
PUSHER_APP_ID=
|
||||||
|
PUSHER_APP_KEY=
|
||||||
|
PUSHER_APP_SECRET=
|
||||||
|
PUSHER_HOST=127.0.0.1
|
||||||
|
PUSHER_PORT=6001
|
||||||
|
PUSHER_SCHEME=http
|
||||||
|
PUSHER_APP_CLUSTER=mt1
|
||||||
|
|
||||||
|
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||||
|
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||||
|
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Livrables attendus
|
||||||
|
|
||||||
|
- [ ] Code source Vue.js (dans le même dépôt Laravel ou dépôt séparé)
|
||||||
|
- [ ] Migrations et seeders pour les superviseurs de démo
|
||||||
|
- [ ] Documentation des rôles et permissions
|
||||||
|
- [ ] Tests Pest controllers (couverture > 80%)
|
||||||
|
- [ ] Tests Playwright (5 parcours critiques)
|
||||||
|
- [ ] Guide d'utilisation superviseur (PDF ou page dans le back-office)
|
||||||
|
- [ ] `README.md` avec instructions de déploiement front (Vite build + assets)
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
# Cahier des Charges — Back-Office Exploitant
|
||||||
|
## Projet : Laverie Connectée — Interface d'Administration
|
||||||
|
**Version :** 2.0
|
||||||
|
**Stack principale :** Laravel 11 + Vue.js 3 + Inertia.js
|
||||||
|
**Positionnement :** V1 simple, cloisonnée par exploitant, démontrable rapidement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & Objectifs
|
||||||
|
|
||||||
|
### 1.1 Objectif
|
||||||
|
Le back-office permet à un exploitant de piloter ses laveries sans exposer les données des autres exploitants présents sur la plateforme.
|
||||||
|
|
||||||
|
### 1.2 Objectifs V1
|
||||||
|
- authentifier les exploitants,
|
||||||
|
- afficher les machines et leur état,
|
||||||
|
- afficher les indicateurs métier de base,
|
||||||
|
- consulter réservations et lavages,
|
||||||
|
- gérer les tarifs,
|
||||||
|
- consulter les alertes techniques simples,
|
||||||
|
- garantir un cloisonnement strict par exploitant.
|
||||||
|
|
||||||
|
### 1.3 Principes
|
||||||
|
- une organisation ne voit que ses établissements,
|
||||||
|
- un manager peut être restreint à un établissement,
|
||||||
|
- la V1 privilégie la lisibilité et la fiabilité plutôt que la richesse fonctionnelle,
|
||||||
|
- le temps réel peut être simulé ou remplacé par du polling si nécessaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Périmètre fonctionnel
|
||||||
|
|
||||||
|
### 2.1 MVP V1
|
||||||
|
- connexion superviseur,
|
||||||
|
- tableau de bord simple,
|
||||||
|
- liste des machines,
|
||||||
|
- détail machine,
|
||||||
|
- liste des réservations,
|
||||||
|
- liste des lavages,
|
||||||
|
- gestion simple des tarifs,
|
||||||
|
- consultation simple des promotions si activées en V1,
|
||||||
|
- affichage des alertes de base,
|
||||||
|
- audit minimal des actions sensibles.
|
||||||
|
|
||||||
|
### 2.2 V2 prévue
|
||||||
|
- campagnes de notifications,
|
||||||
|
- exports avancés,
|
||||||
|
- analytics détaillées,
|
||||||
|
- gestion multi-utilisateurs plus riche,
|
||||||
|
- segmentation marketing,
|
||||||
|
- vue consolidée plus poussée pour groupes de laveries.
|
||||||
|
|
||||||
|
### 2.3 Hors périmètre V1
|
||||||
|
- CRM,
|
||||||
|
- marketing automation,
|
||||||
|
- édition de rapports complexes,
|
||||||
|
- configuration technique profonde des intégrations machines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Utilisateurs et droits
|
||||||
|
|
||||||
|
| Rôle | Portée | Droits |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `platform_admin` | plateforme complète | vision globale, administration complète |
|
||||||
|
| `owner` | organization complète | accès complet à ses laveries |
|
||||||
|
| `manager` | organization ou établissement | gestion opérationnelle |
|
||||||
|
| `viewer` | organization ou établissement | lecture seule |
|
||||||
|
|
||||||
|
### 3.1 Règle de cloisonnement
|
||||||
|
Toute donnée visible dans le back-office doit être filtrée au minimum par `organization_id`. Si le superviseur est attaché à un établissement précis, la visibilité est encore plus restreinte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stack Technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Version | Commentaire |
|
||||||
|
|-----------|-------------|---------|-------------|
|
||||||
|
| Backend | Laravel 11 | — | Backend partagé avec l'API |
|
||||||
|
| Rendu SPA | Inertia.js | 2.x | Intégration Laravel / Vue |
|
||||||
|
| UI | Vue.js | 3.x | Composition API |
|
||||||
|
| UI kit | PrimeVue | 4.x | Rapide pour dashboard / tables |
|
||||||
|
| Styles | Tailwind CSS | 3.x | Mise en forme rapide |
|
||||||
|
| State | Pinia | 2.x | Si besoin de stores front |
|
||||||
|
| Graphiques | Chart.js | 4.x | KPIs simples |
|
||||||
|
| Tests front | Vitest + Vue Test Utils | — | Composants critiques |
|
||||||
|
| Tests E2E | Playwright | — | Flux exploitant |
|
||||||
|
|
||||||
|
### 4.1 Temps réel
|
||||||
|
Le temps réel n'est pas un prérequis absolu de la V1. Deux stratégies possibles :
|
||||||
|
- **V1 rapide** : polling 15 à 30 secondes,
|
||||||
|
- **V1 enrichie** : Laravel Echo / Soketi ou Pusher.
|
||||||
|
|
||||||
|
Si le délai est serré, le polling est acceptable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architecture recommandée
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/js/
|
||||||
|
├── Components/
|
||||||
|
│ ├── Layout/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ ├── Machines/
|
||||||
|
│ ├── Pricing/
|
||||||
|
│ ├── Bookings/
|
||||||
|
│ ├── Washes/
|
||||||
|
│ └── UI/
|
||||||
|
├── Pages/
|
||||||
|
│ ├── Auth/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ ├── Machines/
|
||||||
|
│ ├── Pricing/
|
||||||
|
│ ├── Bookings/
|
||||||
|
│ ├── Washes/
|
||||||
|
│ └── Settings/
|
||||||
|
└── app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 Principe de navigation
|
||||||
|
- menu latéral simple,
|
||||||
|
- accès rapide au dashboard,
|
||||||
|
- pages data-centric,
|
||||||
|
- peu de modales complexes en V1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Écrans V1
|
||||||
|
|
||||||
|
## 6.1 Connexion
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
- email,
|
||||||
|
- mot de passe,
|
||||||
|
- message d'erreur clair,
|
||||||
|
- redirection vers dashboard si session active.
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
- session cookie,
|
||||||
|
- CSRF,
|
||||||
|
- rate limiting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.2 Tableau de bord
|
||||||
|
|
||||||
|
### Objectif
|
||||||
|
Donner à l'exploitant une vue immédiate de l'état de ses laveries.
|
||||||
|
|
||||||
|
### KPIs V1
|
||||||
|
- chiffre d'affaires du jour,
|
||||||
|
- nombre de lavages du jour,
|
||||||
|
- réservations du jour,
|
||||||
|
- taux d'occupation courant,
|
||||||
|
- nombre de machines offline / en erreur.
|
||||||
|
|
||||||
|
### Sections recommandées
|
||||||
|
- bandeau KPI,
|
||||||
|
- liste des alertes,
|
||||||
|
- aperçu du parc machines,
|
||||||
|
- graphique simple de CA ou occupation.
|
||||||
|
|
||||||
|
### Données affichées
|
||||||
|
Toujours filtrées sur le périmètre du superviseur connecté.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.3 Liste des machines
|
||||||
|
|
||||||
|
### Colonnes minimales
|
||||||
|
- nom,
|
||||||
|
- établissement,
|
||||||
|
- type,
|
||||||
|
- statut,
|
||||||
|
- dernier heartbeat,
|
||||||
|
- utilisateur courant anonymisé si pertinent,
|
||||||
|
- actions.
|
||||||
|
|
||||||
|
### Filtres
|
||||||
|
- établissement,
|
||||||
|
- statut,
|
||||||
|
- type.
|
||||||
|
|
||||||
|
### Actions V1
|
||||||
|
- voir détail,
|
||||||
|
- basculer maintenance si autorisé,
|
||||||
|
- forcer disponibilité seulement si besoin réel et journalisé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.4 Détail machine
|
||||||
|
|
||||||
|
### Contenu
|
||||||
|
- informations générales,
|
||||||
|
- statut actuel,
|
||||||
|
- historique récent des cycles,
|
||||||
|
- historique récent des événements machines,
|
||||||
|
- tarification appliquée,
|
||||||
|
- alertes récentes,
|
||||||
|
- uptime simple si disponible.
|
||||||
|
|
||||||
|
Cette page est très utile pour la démo car elle montre la profondeur du produit sans nécessiter trop d'écrans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.5 Réservations
|
||||||
|
|
||||||
|
### Vue liste
|
||||||
|
- utilisateur anonymisé,
|
||||||
|
- machine,
|
||||||
|
- établissement,
|
||||||
|
- créneau,
|
||||||
|
- statut,
|
||||||
|
- montant réservé,
|
||||||
|
- pénalité éventuelle.
|
||||||
|
|
||||||
|
### Filtres
|
||||||
|
- période,
|
||||||
|
- établissement,
|
||||||
|
- machine,
|
||||||
|
- statut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.6 Lavages
|
||||||
|
|
||||||
|
### Vue liste
|
||||||
|
- utilisateur anonymisé,
|
||||||
|
- machine,
|
||||||
|
- établissement,
|
||||||
|
- heure de démarrage,
|
||||||
|
- heure de fin,
|
||||||
|
- coût,
|
||||||
|
- statut.
|
||||||
|
|
||||||
|
### Intérêt
|
||||||
|
Permet à l'exploitant de relier l'activité terrain au chiffre d'affaires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.7 Tarification
|
||||||
|
|
||||||
|
### Vue liste
|
||||||
|
- établissement,
|
||||||
|
- machine ou type de machine,
|
||||||
|
- plage horaire,
|
||||||
|
- prix,
|
||||||
|
- libellé,
|
||||||
|
- actif / inactif.
|
||||||
|
|
||||||
|
### Formulaire V1
|
||||||
|
- machine ou type,
|
||||||
|
- jours applicables,
|
||||||
|
- heure début / fin,
|
||||||
|
- prix,
|
||||||
|
- libellé,
|
||||||
|
- tarif exclusif app si retenu.
|
||||||
|
|
||||||
|
### Validations
|
||||||
|
- pas de plage inversée,
|
||||||
|
- pas de conflit de règles non géré,
|
||||||
|
- audit de toute modification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.8 Promotions
|
||||||
|
|
||||||
|
Si activé en V1, les promotions restent simples :
|
||||||
|
- portée établissement,
|
||||||
|
- type de machine,
|
||||||
|
- réduction fixe ou pourcentage,
|
||||||
|
- date début / fin.
|
||||||
|
|
||||||
|
Si le timing est trop serré, cette page peut être préparée mais non activée en démonstration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.9 Paramètres
|
||||||
|
|
||||||
|
### Contenu minimal
|
||||||
|
- profil superviseur,
|
||||||
|
- établissement ou organisation associés,
|
||||||
|
- informations de session,
|
||||||
|
- éventuellement préférences simples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cloisonnement des données
|
||||||
|
|
||||||
|
### Règles obligatoires
|
||||||
|
- `platform_admin` : accès global,
|
||||||
|
- `owner` : toutes les laveries de son organization,
|
||||||
|
- `manager` : selon son scope,
|
||||||
|
- `viewer` : lecture seule.
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
- policies Laravel,
|
||||||
|
- query scopes,
|
||||||
|
- tests dédiés au cloisonnement.
|
||||||
|
|
||||||
|
### Interdiction
|
||||||
|
Aucune page ne doit faire remonter des agrégats globaux non filtrés à un exploitant local.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Audit
|
||||||
|
|
||||||
|
### Actions à journaliser
|
||||||
|
- connexion superviseur,
|
||||||
|
- modification de tarif,
|
||||||
|
- création / modification de promotion,
|
||||||
|
- changement manuel de statut machine,
|
||||||
|
- toute action d'administration sensible.
|
||||||
|
|
||||||
|
### Utilité
|
||||||
|
- sécurité,
|
||||||
|
- compréhension métier,
|
||||||
|
- support,
|
||||||
|
- preuve en cas de litige.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Temps réel / polling
|
||||||
|
|
||||||
|
### Option 1 - Polling V1 recommandé si délai serré
|
||||||
|
- refresh du dashboard toutes les 30 secondes,
|
||||||
|
- refresh détail machine toutes les 15 à 30 secondes.
|
||||||
|
|
||||||
|
### Option 2 - Temps réel enrichi
|
||||||
|
- Echo,
|
||||||
|
- Soketi ou Pusher,
|
||||||
|
- mise à jour machine / alertes / KPIs.
|
||||||
|
|
||||||
|
### Recommandation
|
||||||
|
Pour la démo de fin de mois, le polling propre est souvent suffisant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sécurité
|
||||||
|
|
||||||
|
### 10.1 Authentification
|
||||||
|
- session cookie sécurisée,
|
||||||
|
- CSRF,
|
||||||
|
- timeout de session,
|
||||||
|
- rate limiting login.
|
||||||
|
|
||||||
|
### 10.2 Autorisation
|
||||||
|
- policies explicites par rôle,
|
||||||
|
- filtre systématique des établissements.
|
||||||
|
|
||||||
|
### 10.3 Données utilisateur
|
||||||
|
- anonymisation des noms dans les écrans exploitants si non nécessaire,
|
||||||
|
- pas d'affichage d'email complet côté exploitation terrain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Dashboard de démo
|
||||||
|
|
||||||
|
Le dashboard de démo doit montrer visuellement :
|
||||||
|
- plusieurs laveries sur la plateforme,
|
||||||
|
- un exploitant qui ne voit que les siennes,
|
||||||
|
- des machines dans plusieurs états,
|
||||||
|
- un lavage qui démarre puis se termine,
|
||||||
|
- un KPI qui évolue,
|
||||||
|
- une tarification modifiable.
|
||||||
|
|
||||||
|
C'est le meilleur compromis entre crédibilité et temps de développement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Tests
|
||||||
|
|
||||||
|
| Type | Outil | Portée |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Controllers | Pest | Accès dashboard, pricing, machines |
|
||||||
|
| Components | Vitest | KPIs, listes, formulaires |
|
||||||
|
| E2E | Playwright | Connexion, dashboard, tarif, machine |
|
||||||
|
|
||||||
|
### Cas critiques
|
||||||
|
1. un exploitant A ne voit pas les données de B,
|
||||||
|
2. un viewer ne peut pas modifier un tarif,
|
||||||
|
3. un manager voit les bons KPI,
|
||||||
|
4. une machine passe de disponible à en cours,
|
||||||
|
5. une modification de tarif est auditée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Livrables attendus
|
||||||
|
|
||||||
|
- code source back-office,
|
||||||
|
- pages Inertia principales,
|
||||||
|
- dataset de démonstration multi-exploitants,
|
||||||
|
- audit minimal,
|
||||||
|
- tests critiques,
|
||||||
|
- guide court de démonstration.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Cadrage global — Laverie Connectée
|
||||||
|
|
||||||
|
## 1. Vision produit
|
||||||
|
|
||||||
|
Le produit vise à centraliser la gestion de plusieurs laveries dans une même plateforme, avec deux populations principales :
|
||||||
|
|
||||||
|
- **les utilisateurs finaux**, qui consultent les laveries, rechargent leur wallet, réservent un créneau et lancent des lavages ;
|
||||||
|
- **les exploitants**, qui pilotent uniquement leurs propres établissements, leurs machines et leurs indicateurs.
|
||||||
|
|
||||||
|
La première version doit être **démontrable rapidement**, avec une base technique suffisamment propre pour permettre une V2 sans refonte majeure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Périmètre
|
||||||
|
|
||||||
|
### MVP V1
|
||||||
|
|
||||||
|
- authentification utilisateur
|
||||||
|
- authentification exploitant
|
||||||
|
- multi-laveries
|
||||||
|
- cloisonnement des exploitants par organization
|
||||||
|
- consultation des établissements et machines
|
||||||
|
- wallet + rechargement
|
||||||
|
- réservation de créneau
|
||||||
|
- lancement d'un lavage
|
||||||
|
- historique simple des transactions, réservations et lavages
|
||||||
|
- dashboard exploitant simple
|
||||||
|
- tarification simple
|
||||||
|
- intégration machine simulable
|
||||||
|
- audit minimal
|
||||||
|
- statistiques journalières simples
|
||||||
|
|
||||||
|
### V2
|
||||||
|
|
||||||
|
- fidélité
|
||||||
|
- parrainage
|
||||||
|
- abonnements
|
||||||
|
- promotions avancées
|
||||||
|
- campagnes marketing
|
||||||
|
- exports avancés
|
||||||
|
- analytics détaillées
|
||||||
|
- RGPD enrichi
|
||||||
|
- modes d'intégration machine plus complets
|
||||||
|
|
||||||
|
### Hors périmètre V1
|
||||||
|
|
||||||
|
- chat
|
||||||
|
- avis
|
||||||
|
- FAQ dynamique
|
||||||
|
- IA / lecture photo étiquette
|
||||||
|
- moteur prédictif
|
||||||
|
- interfaçage matériel bas niveau
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture métier cible
|
||||||
|
|
||||||
|
### Entités principales
|
||||||
|
|
||||||
|
- `organizations`
|
||||||
|
- `establishments`
|
||||||
|
- `supervisors`
|
||||||
|
- `users`
|
||||||
|
- `machines`
|
||||||
|
- `machine_integrations`
|
||||||
|
- `machine_commands`
|
||||||
|
- `machine_events`
|
||||||
|
- `wallets`
|
||||||
|
- `payment_transactions`
|
||||||
|
- `wallet_transactions`
|
||||||
|
- `bookings`
|
||||||
|
- `washes`
|
||||||
|
- `pricing_rules`
|
||||||
|
- `promotions`
|
||||||
|
- `push_notifications`
|
||||||
|
- `audit_logs`
|
||||||
|
- `daily_establishment_stats`
|
||||||
|
|
||||||
|
### Cloisonnement
|
||||||
|
|
||||||
|
- un exploitant ne voit que les données de son `organization_id`
|
||||||
|
- un manager peut être limité à un seul établissement
|
||||||
|
- un `platform_admin` peut avoir une vue globale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stratégie d'intégration machine
|
||||||
|
|
||||||
|
Le système doit être pensé comme une **couche d'intégration** entre le métier laverie et un fournisseur technique externe.
|
||||||
|
|
||||||
|
### Modes supportés
|
||||||
|
|
||||||
|
- **push** : le partenaire envoie des événements à notre API
|
||||||
|
- **pull** : notre backend appelle son API
|
||||||
|
- **hybrid** : combinaison des deux
|
||||||
|
- **simulated** : mode démo
|
||||||
|
|
||||||
|
### Principe recommandé
|
||||||
|
|
||||||
|
Pour la V1 et la démo, implémenter d'abord un **provider simulé**.
|
||||||
|
|
||||||
|
Cela permet de démontrer :
|
||||||
|
- le lancement d'un lavage,
|
||||||
|
- le passage machine en cours,
|
||||||
|
- la fin de cycle,
|
||||||
|
- les mises à jour du dashboard,
|
||||||
|
- les notifications,
|
||||||
|
|
||||||
|
sans dépendre du matériel réel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Démo de fin de mois
|
||||||
|
|
||||||
|
### Parcours utilisateur
|
||||||
|
|
||||||
|
1. connexion
|
||||||
|
2. affichage des laveries
|
||||||
|
3. consultation des machines
|
||||||
|
4. rechargement wallet
|
||||||
|
5. réservation d'un créneau
|
||||||
|
6. lancement d'un lavage
|
||||||
|
7. cycle simulé
|
||||||
|
8. notification de fin
|
||||||
|
9. historique mis à jour
|
||||||
|
|
||||||
|
### Parcours exploitant
|
||||||
|
|
||||||
|
1. connexion exploitant
|
||||||
|
2. dashboard limité à ses laveries
|
||||||
|
3. visualisation d'une machine qui passe en `running`
|
||||||
|
4. évolution d'un KPI
|
||||||
|
5. modification d'un tarif
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Backlog priorisé
|
||||||
|
|
||||||
|
### Sprint 1 — Fondations
|
||||||
|
|
||||||
|
- modèle organizations / establishments / supervisors
|
||||||
|
- auth utilisateur
|
||||||
|
- auth exploitant
|
||||||
|
- seeders de démo
|
||||||
|
- policies de cloisonnement
|
||||||
|
|
||||||
|
### Sprint 2 — Coeur métier
|
||||||
|
|
||||||
|
- machines
|
||||||
|
- wallet
|
||||||
|
- rechargement
|
||||||
|
- réservation
|
||||||
|
- lavages
|
||||||
|
- provider machine simulé
|
||||||
|
|
||||||
|
### Sprint 3 — Interfaces
|
||||||
|
|
||||||
|
- écrans Flutter MVP
|
||||||
|
- back-office MVP
|
||||||
|
- dashboard simple
|
||||||
|
- historique simple
|
||||||
|
|
||||||
|
### Sprint 4 — Démo et stabilisation
|
||||||
|
|
||||||
|
- audit minimal
|
||||||
|
- notifications
|
||||||
|
- agrégats journaliers
|
||||||
|
- nettoyage UX
|
||||||
|
- dataset final de démonstration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risques à surveiller
|
||||||
|
|
||||||
|
- dérive de périmètre côté client
|
||||||
|
- ambiguïté sur l'intégration machine réelle
|
||||||
|
- sous-estimation de la partie paiement
|
||||||
|
- dette technique si trop d'optimisation UI inutile en V1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Règle de pilotage recommandée
|
||||||
|
|
||||||
|
Toute fonctionnalité V1 doit répondre à au moins un de ces critères :
|
||||||
|
|
||||||
|
- utile à la démonstration,
|
||||||
|
- nécessaire au fonctionnement métier minimal,
|
||||||
|
- nécessaire à la sécurité / traçabilité,
|
||||||
|
- nécessaire pour éviter une refonte en V2.
|
||||||
|
|
||||||
|
Si elle ne remplit aucun de ces critères, elle doit être repoussée.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Stack de développement Laverie Connectée
|
||||||
|
# Usage : docker compose up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: laverie-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${MYSQL_PORT:-3306}:3306"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:-secret}"
|
||||||
|
MYSQL_DATABASE: laverie
|
||||||
|
MYSQL_USER: laverie
|
||||||
|
MYSQL_PASSWORD: "${MYSQL_PASSWORD:-laverie}"
|
||||||
|
volumes:
|
||||||
|
- laverie_mysql_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-secret}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: laverie-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
volumes:
|
||||||
|
- laverie_redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Service applicatif Laravel (optionnel — décommenter pour lancer l'API dans Docker)
|
||||||
|
# app:
|
||||||
|
# build:
|
||||||
|
# context: ./backend
|
||||||
|
# dockerfile: Dockerfile
|
||||||
|
# container_name: laverie-app
|
||||||
|
# restart: unless-stopped
|
||||||
|
# ports:
|
||||||
|
# - "${APP_PORT:-8000}:8000"
|
||||||
|
# environment:
|
||||||
|
# APP_ENV: local
|
||||||
|
# APP_DEBUG: "true"
|
||||||
|
# DB_CONNECTION: mysql
|
||||||
|
# DB_HOST: mysql
|
||||||
|
# DB_PORT: 3306
|
||||||
|
# DB_DATABASE: laverie
|
||||||
|
# DB_USERNAME: laverie
|
||||||
|
# DB_PASSWORD: "${MYSQL_PASSWORD:-laverie}"
|
||||||
|
# REDIS_HOST: redis
|
||||||
|
# REDIS_PORT: 6379
|
||||||
|
# LAVERIE_MACHINE_API_KEY: "${LAVERIE_MACHINE_API_KEY:-demo-machine-api-key}"
|
||||||
|
# volumes:
|
||||||
|
# - ./backend:/var/www/html
|
||||||
|
# depends_on:
|
||||||
|
# mysql:
|
||||||
|
# condition: service_healthy
|
||||||
|
# redis:
|
||||||
|
# condition: service_healthy
|
||||||
|
# command: php artisan serve --host=0.0.0.0 --port=8000
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
laverie_mysql_data:
|
||||||
|
laverie_redis_data:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[{compose,docker-compose}.{yml,yaml}]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
APP_NAME=laundry-backend
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
APP_TIMEZONE=Europe/Paris
|
||||||
|
|
||||||
|
APP_LOCALE=fr
|
||||||
|
APP_FALLBACK_LOCALE=fr
|
||||||
|
APP_FAKER_LOCALE=fr_FR
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# Base de données MySQL (schéma laverie — voir docker-compose.yml)
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=laverie
|
||||||
|
DB_USERNAME=laverie
|
||||||
|
DB_PASSWORD=laverie
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
|
CACHE_STORE=redis
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="noreply@laverie.local"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=eu-west-3
|
||||||
|
AWS_BUCKET=
|
||||||
|
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)
|
||||||
|
LAVERIE_MACHINE_API_KEY=demo-machine-api-key-change-me
|
||||||
|
|
||||||
|
# Simulation de cycle machine (démo sans matériel)
|
||||||
|
LAVERIE_SIM_CYCLE_SECONDS=30
|
||||||
|
|
||||||
|
# Réservations
|
||||||
|
LAVERIE_BOOKING_FEE=1.00
|
||||||
|
LAVERIE_BOOKING_CANCEL_GRACE_HOURS=2
|
||||||
|
LAVERIE_BOOKING_NO_SHOW_GRACE_MINUTES=15
|
||||||
|
LAVERIE_BOOKING_PENALTY_AMOUNT=3.00
|
||||||
|
|
||||||
|
# Machines
|
||||||
|
LAVERIE_MACHINE_OFFLINE_THRESHOLD_MINUTES=10
|
||||||
|
|
||||||
|
# Paiements
|
||||||
|
LAVERIE_PAYMENT_PROVIDER=stripe
|
||||||
|
LAVERIE_TOP_UP_MIN=5
|
||||||
|
LAVERIE_TOP_UP_MAX=150
|
||||||
|
|
||||||
|
STRIPE_KEY=pk_test_51TpANRJRUgjTIwfBR9PoU4Lu201yD5R0JzvOv8Nmyva7ISX3GJPJ3IX4lSqnkg13siYwi3B9Qq0tIpEj6VCzeVFB00PSt9o0OA
|
||||||
|
STRIPE_SECRET=sk_test_51TpANRJRUgjTIwfB2jUa5nr7IpolnWNwwi0MQHbCG6JERu7npCmBrJPm6wbyTfo0lkZUh7PYyuHQ8qeecEfcfSXa0007BAKC7T
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ignore-scripts=true
|
||||||
|
audit=true
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Integration;
|
||||||
|
|
||||||
|
readonly class MachineCommandResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $success,
|
||||||
|
public string $status,
|
||||||
|
public ?string $externalReference = null,
|
||||||
|
public ?string $correlationId = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function success(
|
||||||
|
string $status = 'acknowledged',
|
||||||
|
?string $externalReference = null,
|
||||||
|
?string $correlationId = null,
|
||||||
|
): self {
|
||||||
|
return new self(true, $status, $externalReference, $correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function failure(
|
||||||
|
string $status = 'failed',
|
||||||
|
?string $externalReference = null,
|
||||||
|
?string $correlationId = null,
|
||||||
|
): self {
|
||||||
|
return new self(false, $status, $externalReference, $correlationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Integration;
|
||||||
|
|
||||||
|
use App\Models\Machine;
|
||||||
|
|
||||||
|
interface MachineProviderInterface
|
||||||
|
{
|
||||||
|
public function startCycle(Machine $machine, array $payload): MachineCommandResult;
|
||||||
|
|
||||||
|
public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult;
|
||||||
|
|
||||||
|
public function refreshStatus(Machine $machine): MachineStatusSnapshot;
|
||||||
|
|
||||||
|
public function handleInboundEvent(array $payload): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Integration;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
readonly class MachineStatusSnapshot
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $status,
|
||||||
|
public ?CarbonInterface $lastHeartbeat = null,
|
||||||
|
public array $metadata = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Integration;
|
||||||
|
|
||||||
|
use App\Jobs\SimulateCycleCompletion;
|
||||||
|
use App\Models\Machine;
|
||||||
|
use App\Models\MachineCommand;
|
||||||
|
use App\Models\MachineEvent;
|
||||||
|
use App\Models\MachineStatusHistory;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SimulatedMachineProvider implements MachineProviderInterface
|
||||||
|
{
|
||||||
|
public function startCycle(Machine $machine, array $payload): MachineCommandResult
|
||||||
|
{
|
||||||
|
$correlationId = (string) Str::uuid();
|
||||||
|
$externalReference = 'sim-'.Str::random(12);
|
||||||
|
$provider = config('laverie.simulation.provider_name', 'simulated');
|
||||||
|
$durationSeconds = (int) config('laverie.simulation.cycle_duration_seconds', 30);
|
||||||
|
$cycleEndsAt = now()->addSeconds($durationSeconds);
|
||||||
|
|
||||||
|
$command = MachineCommand::query()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'provider' => $provider,
|
||||||
|
'command_type' => 'start_cycle',
|
||||||
|
'payload' => $payload,
|
||||||
|
'status' => 'acknowledged',
|
||||||
|
'external_reference' => $externalReference,
|
||||||
|
'correlation_id' => $correlationId,
|
||||||
|
'requested_by_user_id' => $payload['requested_by_user_id'] ?? null,
|
||||||
|
'sent_at' => now(),
|
||||||
|
'responded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$previousStatus = $machine->status;
|
||||||
|
|
||||||
|
$machine->update([
|
||||||
|
'status' => 'running',
|
||||||
|
'current_user_id' => $payload['requested_by_user_id'] ?? $machine->current_user_id,
|
||||||
|
'cycle_started_at' => now(),
|
||||||
|
'cycle_ends_at' => $cycleEndsAt,
|
||||||
|
'last_heartbeat_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->recordStatusChange($machine, $previousStatus, 'running', $provider, 'cycle_started');
|
||||||
|
$this->recordEvent($machine, $provider, 'cycle_started', [
|
||||||
|
'command_uuid' => $command->uuid,
|
||||||
|
'wash_uuid' => $payload['wash_uuid'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SimulateCycleCompletion::dispatch(
|
||||||
|
$machine->id,
|
||||||
|
$command->id,
|
||||||
|
$payload['wash_id'] ?? null,
|
||||||
|
)->delay($cycleEndsAt);
|
||||||
|
|
||||||
|
return MachineCommandResult::success('acknowledged', $externalReference, $correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult
|
||||||
|
{
|
||||||
|
$provider = config('laverie.simulation.provider_name', 'simulated');
|
||||||
|
$correlationId = (string) Str::uuid();
|
||||||
|
|
||||||
|
MachineCommand::query()->create([
|
||||||
|
'uuid' => (string) Str::uuid(),
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'provider' => $provider,
|
||||||
|
'command_type' => 'stop_cycle',
|
||||||
|
'payload' => $payload,
|
||||||
|
'status' => 'acknowledged',
|
||||||
|
'correlation_id' => $correlationId,
|
||||||
|
'sent_at' => now(),
|
||||||
|
'responded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$previousStatus = $machine->status;
|
||||||
|
|
||||||
|
$machine->update([
|
||||||
|
'status' => 'available',
|
||||||
|
'current_user_id' => null,
|
||||||
|
'cycle_started_at' => null,
|
||||||
|
'cycle_ends_at' => null,
|
||||||
|
'last_heartbeat_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->recordStatusChange($machine, $previousStatus, 'available', $provider, 'cycle_stopped');
|
||||||
|
$this->recordEvent($machine, $provider, 'cycle_completed', ['reason' => 'manual_stop']);
|
||||||
|
|
||||||
|
return MachineCommandResult::success('acknowledged', null, $correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshStatus(Machine $machine): MachineStatusSnapshot
|
||||||
|
{
|
||||||
|
$machine->refresh();
|
||||||
|
|
||||||
|
return new MachineStatusSnapshot(
|
||||||
|
status: $machine->status,
|
||||||
|
lastHeartbeat: $machine->last_heartbeat_at,
|
||||||
|
metadata: [
|
||||||
|
'cycle_started_at' => $machine->cycle_started_at?->toIso8601String(),
|
||||||
|
'cycle_ends_at' => $machine->cycle_ends_at?->toIso8601String(),
|
||||||
|
'current_user_id' => $machine->current_user_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleInboundEvent(array $payload): void
|
||||||
|
{
|
||||||
|
$externalMachineId = $payload['external_machine_id'] ?? $payload['machine_id'] ?? null;
|
||||||
|
|
||||||
|
if ($externalMachineId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$integration = \App\Models\MachineIntegration::query()
|
||||||
|
->where('external_machine_id', (string) $externalMachineId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($integration === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$machine = $integration->machine;
|
||||||
|
$provider = $integration->provider;
|
||||||
|
$eventType = $payload['event_type'] ?? 'status_changed';
|
||||||
|
|
||||||
|
if ($eventType === 'heartbeat') {
|
||||||
|
$machine->update(['last_heartbeat_at' => now()]);
|
||||||
|
|
||||||
|
if ($machine->status === 'offline') {
|
||||||
|
$previousStatus = $machine->status;
|
||||||
|
$machine->update(['status' => 'available']);
|
||||||
|
$this->recordStatusChange($machine, $previousStatus, 'available', $provider, 'heartbeat');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventType === 'machine_offline') {
|
||||||
|
$previousStatus = $machine->status;
|
||||||
|
$machine->update(['status' => 'offline']);
|
||||||
|
$this->recordStatusChange($machine, $previousStatus, 'offline', $provider, 'machine_offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventType === 'machine_online') {
|
||||||
|
$previousStatus = $machine->status;
|
||||||
|
$machine->update(['status' => 'available', 'last_heartbeat_at' => now()]);
|
||||||
|
$this->recordStatusChange($machine, $previousStatus, 'available', $provider, 'machine_online');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventType === 'cycle_completed') {
|
||||||
|
$previousStatus = $machine->status;
|
||||||
|
$machine->update([
|
||||||
|
'status' => 'available',
|
||||||
|
'current_user_id' => null,
|
||||||
|
'cycle_started_at' => null,
|
||||||
|
'cycle_ends_at' => null,
|
||||||
|
'last_heartbeat_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->recordStatusChange($machine, $previousStatus, 'available', $provider, 'cycle_completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventType === 'error_reported') {
|
||||||
|
$previousStatus = $machine->status;
|
||||||
|
$machine->update(['status' => 'error']);
|
||||||
|
$this->recordStatusChange($machine, $previousStatus, 'error', $provider, 'error_reported');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recordEvent(
|
||||||
|
$machine,
|
||||||
|
$provider,
|
||||||
|
$eventType,
|
||||||
|
$payload,
|
||||||
|
$payload['external_event_id'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordStatusChange(
|
||||||
|
Machine $machine,
|
||||||
|
?string $previousStatus,
|
||||||
|
string $newStatus,
|
||||||
|
string $source,
|
||||||
|
string $reason,
|
||||||
|
): void {
|
||||||
|
MachineStatusHistory::query()->create([
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'previous_status' => $previousStatus,
|
||||||
|
'new_status' => $newStatus,
|
||||||
|
'source' => $source,
|
||||||
|
'reason' => $reason,
|
||||||
|
'created_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function recordEvent(
|
||||||
|
Machine $machine,
|
||||||
|
string $provider,
|
||||||
|
string $eventType,
|
||||||
|
array $payload,
|
||||||
|
?string $externalEventId = null,
|
||||||
|
): void {
|
||||||
|
MachineEvent::query()->create([
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'provider' => $provider,
|
||||||
|
'external_event_id' => $externalEventId,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'payload' => $payload,
|
||||||
|
'occurred_at' => now(),
|
||||||
|
'received_at' => now(),
|
||||||
|
'processed_at' => now(),
|
||||||
|
'processing_status' => 'processed',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class BookingConflictException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $message = 'Ce créneau est déjà réservé.')
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class MachineNotAvailableException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $message = 'La machine n\'est pas disponible.')
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class WalletInsufficientFundsException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly float $required,
|
||||||
|
public readonly float $available,
|
||||||
|
) {
|
||||||
|
parent::__construct("Solde insuffisant : {$available} EUR disponibles, {$required} EUR requis.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Requests\Api\V1\Auth\LoginRequest;
|
||||||
|
use App\Http\Requests\Api\V1\Auth\RefreshRequest;
|
||||||
|
use App\Http\Requests\Api\V1\Auth\RegisterRequest;
|
||||||
|
use App\Http\Resources\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Wallet;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Laravel\Sanctum\PersonalAccessToken;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function register(RegisterRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'first_name' => $request->string('first_name')->toString(),
|
||||||
|
'last_name' => $request->string('last_name')->toString(),
|
||||||
|
'email' => $request->string('email')->toString(),
|
||||||
|
'phone' => $request->input('phone'),
|
||||||
|
'birthdate' => $request->input('birthdate'),
|
||||||
|
'password' => $request->string('password')->toString(),
|
||||||
|
'locale' => $request->input('locale', 'fr'),
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Wallet::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'current_balance' => 0,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tokens = $this->issueTokenPair($user, $request->input('device_name', 'mobile'));
|
||||||
|
|
||||||
|
return $this->created([
|
||||||
|
'user' => new UserResource($user),
|
||||||
|
...$tokens,
|
||||||
|
], 'Inscription réussie.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(LoginRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = User::query()->where('email', $request->string('email')->toString())->first();
|
||||||
|
|
||||||
|
if ($user === null || ! Hash::check($request->string('password')->toString(), $user->password)) {
|
||||||
|
return $this->error('Identifiants invalides.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
return $this->error('Compte utilisateur désactivé.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = $this->issueTokenPair($user, $request->input('device_name', 'mobile'));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'user' => new UserResource($user),
|
||||||
|
...$tokens,
|
||||||
|
], 'Connexion réussie.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refresh(RefreshRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$token = PersonalAccessToken::findToken($request->string('refresh_token')->toString());
|
||||||
|
|
||||||
|
if ($token === null || ! $token->can('refresh') || ! $token->can('user')) {
|
||||||
|
return $this->error('Refresh token invalide.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $token->tokenable;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $user->is_active) {
|
||||||
|
return $this->error('Utilisateur non autorisé.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->delete();
|
||||||
|
$user->tokens()->where('name', 'like', '%-access')->delete();
|
||||||
|
|
||||||
|
$tokens = $this->issueTokenPair($user, 'mobile');
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'user' => new UserResource($user),
|
||||||
|
...$tokens,
|
||||||
|
], 'Token renouvelé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->user()?->currentAccessToken()?->delete();
|
||||||
|
|
||||||
|
return $this->success(message: 'Déconnexion réussie.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->success([
|
||||||
|
'user' => new UserResource($request->user()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function issueTokenPair(User $user, string $deviceName): array
|
||||||
|
{
|
||||||
|
$accessMinutes = (int) config('laverie.auth.access_token_ttl_minutes', 60);
|
||||||
|
$refreshDays = (int) config('laverie.auth.refresh_token_ttl_days', 30);
|
||||||
|
|
||||||
|
$accessToken = $user->createToken(
|
||||||
|
"{$deviceName}-access",
|
||||||
|
['user', 'access'],
|
||||||
|
now()->addMinutes($accessMinutes),
|
||||||
|
);
|
||||||
|
|
||||||
|
$refreshToken = $user->createToken(
|
||||||
|
"{$deviceName}-refresh",
|
||||||
|
['user', 'refresh'],
|
||||||
|
now()->addDays($refreshDays),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'access_token' => $accessToken->plainTextToken,
|
||||||
|
'refresh_token' => $refreshToken->plainTextToken,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'expires_in' => $accessMinutes * 60,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\BookingConflictException;
|
||||||
|
use App\Exceptions\WalletInsufficientFundsException;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Requests\Api\V1\Booking\MoveBookingRequest;
|
||||||
|
use App\Http\Requests\Api\V1\Booking\StoreBookingRequest;
|
||||||
|
use App\Http\Resources\BookingResource;
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\Machine;
|
||||||
|
use App\Services\BookingService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class BookingController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly BookingService $bookingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function store(StoreBookingRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$machine = Machine::query()
|
||||||
|
->where('uuid', $request->string('machine_uuid')->toString())
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$booking = $this->bookingService->createBooking(
|
||||||
|
$request->user(),
|
||||||
|
$machine,
|
||||||
|
Carbon::parse($request->input('slot_start')),
|
||||||
|
Carbon::parse($request->input('slot_end')),
|
||||||
|
$request->input('idempotency_key'),
|
||||||
|
);
|
||||||
|
} catch (BookingConflictException $e) {
|
||||||
|
return $this->error($e->getMessage(), 409);
|
||||||
|
} catch (WalletInsufficientFundsException $e) {
|
||||||
|
return $this->error($e->getMessage(), 402);
|
||||||
|
} catch (\RuntimeException|\InvalidArgumentException $e) {
|
||||||
|
return $this->error($e->getMessage(), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->created([
|
||||||
|
'booking' => new BookingResource($booking->load('machine.establishment')),
|
||||||
|
], 'Réservation créée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$bookings = Booking::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->with(['machine.establishment'])
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
|
||||||
|
->orderByDesc('slot_start')
|
||||||
|
->paginate($request->integer('per_page', 20));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'bookings' => BookingResource::collection($bookings),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $bookings->currentPage(),
|
||||||
|
'last_page' => $bookings->lastPage(),
|
||||||
|
'per_page' => $bookings->perPage(),
|
||||||
|
'total' => $bookings->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$booking = Booking::query()
|
||||||
|
->where('uuid', $uuid)
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->with(['machine.establishment'])
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'booking' => new BookingResource($booking),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Request $request, string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$booking = Booking::query()
|
||||||
|
->where('uuid', $uuid)
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$booking = $this->bookingService->cancelBooking($booking, $request->user());
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return $this->error($e->getMessage(), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'booking' => new BookingResource($booking->load('machine.establishment')),
|
||||||
|
], 'Réservation annulée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(MoveBookingRequest $request, string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$booking = Booking::query()
|
||||||
|
->where('uuid', $uuid)
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$booking = $this->bookingService->moveBooking(
|
||||||
|
$booking,
|
||||||
|
$request->user(),
|
||||||
|
Carbon::parse($request->input('slot_start')),
|
||||||
|
Carbon::parse($request->input('slot_end')),
|
||||||
|
);
|
||||||
|
} catch (BookingConflictException $e) {
|
||||||
|
return $this->error($e->getMessage(), 409);
|
||||||
|
} catch (WalletInsufficientFundsException $e) {
|
||||||
|
return $this->error($e->getMessage(), 402);
|
||||||
|
} catch (\RuntimeException|\InvalidArgumentException $e) {
|
||||||
|
return $this->error($e->getMessage(), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'booking' => new BookingResource($booking->load('machine.establishment')),
|
||||||
|
], 'Réservation déplacée.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Resources\EstablishmentResource;
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EstablishmentController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$establishments = Establishment::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->when($request->filled('city'), fn ($q) => $q->where('city', $request->string('city')->toString()))
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'establishments' => EstablishmentResource::collection($establishments),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$establishment = Establishment::query()
|
||||||
|
->where('uuid', $uuid)
|
||||||
|
->where('is_active', true)
|
||||||
|
->with(['machines' => fn ($q) => $q->orderBy('name')])
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'establishment' => new EstablishmentResource($establishment),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class HealthController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->success([
|
||||||
|
'status' => 'ok',
|
||||||
|
'service' => 'laverie-api',
|
||||||
|
'timezone' => config('app.timezone'),
|
||||||
|
'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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Integration;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Requests\Api\V1\Integration\AckCommandRequest;
|
||||||
|
use App\Http\Requests\Api\V1\Integration\HeartbeatRequest;
|
||||||
|
use App\Http\Requests\Api\V1\Integration\MachineEventRequest;
|
||||||
|
use App\Models\MachineCommand;
|
||||||
|
use App\Models\MachineIntegration;
|
||||||
|
use App\Services\MachineIntegrationService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class MachineEventController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly MachineIntegrationService $machineIntegrationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function store(MachineEventRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
$this->machineIntegrationService->handleInboundEvent(
|
||||||
|
$payload['provider'],
|
||||||
|
$payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->created(['received' => true], 'Événement enregistré.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function heartbeat(HeartbeatRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payload['event_type'] = 'heartbeat';
|
||||||
|
$payload['occurred_at'] = $payload['occurred_at'] ?? now()->toIso8601String();
|
||||||
|
|
||||||
|
$integration = MachineIntegration::query()
|
||||||
|
->where('provider', $payload['provider'])
|
||||||
|
->where('external_machine_id', $payload['machine_external_id'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->with('machine')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($integration?->machine !== null) {
|
||||||
|
$integration->machine->update(['last_heartbeat_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->machineIntegrationService->handleInboundEvent(
|
||||||
|
$payload['provider'],
|
||||||
|
$payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->success(['received' => true], 'Heartbeat enregistré.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ackCommand(AckCommandRequest $request, string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$command = MachineCommand::query()->where('uuid', $uuid)->firstOrFail();
|
||||||
|
|
||||||
|
$command->update([
|
||||||
|
'status' => $request->string('status')->toString(),
|
||||||
|
'external_reference' => $request->input('external_reference'),
|
||||||
|
'responded_at' => now(),
|
||||||
|
'payload' => array_merge($command->payload ?? [], [
|
||||||
|
'ack' => $request->input('data', []),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'command_uuid' => $command->uuid,
|
||||||
|
'status' => $command->status,
|
||||||
|
], 'Accusé de réception enregistré.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class MachineController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PricingService $pricingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$machine = Machine::query()
|
||||||
|
->where('uuid', $uuid)
|
||||||
|
->with('establishment')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function availability(Request $request, string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$machine = Machine::query()
|
||||||
|
->where('uuid', $uuid)
|
||||||
|
->with('establishment')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$date = $request->filled('date')
|
||||||
|
? Carbon::parse($request->string('date')->toString())->startOfDay()
|
||||||
|
: now()->startOfDay();
|
||||||
|
|
||||||
|
$slotDuration = $request->integer('slot_minutes', 60);
|
||||||
|
$dayStart = $date->copy()->setTime(7, 0);
|
||||||
|
$dayEnd = $date->copy()->setTime(22, 0);
|
||||||
|
|
||||||
|
$bookings = Booking::query()
|
||||||
|
->where('machine_id', $machine->id)
|
||||||
|
->whereIn('status', ['pending', 'confirmed', 'active'])
|
||||||
|
->where('slot_start', '<', $dayEnd)
|
||||||
|
->where('slot_end', '>', $dayStart)
|
||||||
|
->get(['slot_start', 'slot_end']);
|
||||||
|
|
||||||
|
$slots = [];
|
||||||
|
$cursor = $dayStart->copy();
|
||||||
|
|
||||||
|
while ($cursor->copy()->addMinutes($slotDuration)->lte($dayEnd)) {
|
||||||
|
$slotEnd = $cursor->copy()->addMinutes($slotDuration);
|
||||||
|
$isAvailable = $cursor->isFuture() && ! in_array($machine->status, ['offline', 'maintenance', 'out_of_order'], true);
|
||||||
|
|
||||||
|
if ($isAvailable) {
|
||||||
|
foreach ($bookings as $booking) {
|
||||||
|
if ($booking->slot_start < $slotEnd && $booking->slot_end > $cursor) {
|
||||||
|
$isAvailable = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isAvailable) {
|
||||||
|
$slots[] = [
|
||||||
|
'start' => $cursor->toIso8601String(),
|
||||||
|
'end' => $slotEnd->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cursor->addMinutes($slotDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'machine_uuid' => $machine->uuid,
|
||||||
|
'date' => $date->toDateString(),
|
||||||
|
'slots' => $slots,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pricing(string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$machine = Machine::query()
|
||||||
|
->where('uuid', $uuid)
|
||||||
|
->with('establishment')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$price = $this->pricingService->getActivePrice($machine);
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'machine_uuid' => $machine->uuid,
|
||||||
|
'price' => $price,
|
||||||
|
'currency' => 'EUR',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Requests\Api\V1\Supervisor\LoginRequest;
|
||||||
|
use App\Http\Resources\SupervisorResource;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function login(LoginRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$supervisor = Supervisor::query()
|
||||||
|
->where('email', $request->string('email')->toString())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($supervisor === null || ! Hash::check($request->string('password')->toString(), $supervisor->password)) {
|
||||||
|
return $this->error('Identifiants invalides.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $supervisor->is_active) {
|
||||||
|
return $this->error('Compte superviseur désactivé.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$supervisor->update(['last_login_at' => now()]);
|
||||||
|
|
||||||
|
$deviceName = $request->input('device_name', 'backoffice');
|
||||||
|
$accessMinutes = (int) config('laverie.auth.access_token_ttl_minutes', 60);
|
||||||
|
$refreshDays = (int) config('laverie.auth.refresh_token_ttl_days', 30);
|
||||||
|
|
||||||
|
$accessToken = $supervisor->createToken(
|
||||||
|
"{$deviceName}-access",
|
||||||
|
['supervisor', 'access'],
|
||||||
|
now()->addMinutes($accessMinutes),
|
||||||
|
);
|
||||||
|
|
||||||
|
$refreshToken = $supervisor->createToken(
|
||||||
|
"{$deviceName}-refresh",
|
||||||
|
['supervisor', 'refresh'],
|
||||||
|
now()->addDays($refreshDays),
|
||||||
|
);
|
||||||
|
|
||||||
|
Auth::guard('supervisor')->login($supervisor);
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'supervisor' => new SupervisorResource($supervisor),
|
||||||
|
'access_token' => $accessToken->plainTextToken,
|
||||||
|
'refresh_token' => $refreshToken->plainTextToken,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'expires_in' => $accessMinutes * 60,
|
||||||
|
], 'Connexion superviseur réussie.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user instanceof Supervisor) {
|
||||||
|
$user->currentAccessToken()?->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::guard('supervisor')->logout();
|
||||||
|
|
||||||
|
return $this->success(message: 'Déconnexion superviseur réussie.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Middleware\EnsureSupervisorScope;
|
||||||
|
use App\Http\Resources\BookingResource;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class BookingController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
|
||||||
|
|
||||||
|
$bookings = EnsureSupervisorScope::bookingsQuery($supervisor)
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
|
||||||
|
->when($request->filled('date'), fn ($q) => $q->whereDate('slot_start', $request->string('date')->toString()))
|
||||||
|
->orderByDesc('slot_start')
|
||||||
|
->paginate($request->integer('per_page', 50));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'bookings' => BookingResource::collection($bookings),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $bookings->currentPage(),
|
||||||
|
'last_page' => $bookings->lastPage(),
|
||||||
|
'per_page' => $bookings->perPage(),
|
||||||
|
'total' => $bookings->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Middleware\EnsureSupervisorScope;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
|
||||||
|
|
||||||
|
$today = Carbon::today();
|
||||||
|
$statsQuery = EnsureSupervisorScope::dailyStatsQuery($supervisor)
|
||||||
|
->where('stat_date', $today);
|
||||||
|
|
||||||
|
$todayStats = $statsQuery->get();
|
||||||
|
|
||||||
|
$machinesQuery = EnsureSupervisorScope::machinesQuery($supervisor);
|
||||||
|
$offlineThreshold = now()->subMinutes((int) config('laverie.machine.offline_threshold_minutes', 10));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'summary' => [
|
||||||
|
'machines_total' => (clone $machinesQuery)->count(),
|
||||||
|
'machines_available' => (clone $machinesQuery)->where('status', 'available')->count(),
|
||||||
|
'machines_running' => (clone $machinesQuery)->where('status', 'running')->count(),
|
||||||
|
'machines_offline' => (clone $machinesQuery)->where(function ($q) use ($offlineThreshold) {
|
||||||
|
$q->where('status', 'offline')
|
||||||
|
->orWhere(fn ($q) => $q->where('last_heartbeat_at', '<', $offlineThreshold));
|
||||||
|
})->count(),
|
||||||
|
'bookings_today' => EnsureSupervisorScope::bookingsQuery($supervisor)
|
||||||
|
->whereDate('slot_start', $today)
|
||||||
|
->count(),
|
||||||
|
'washes_today' => EnsureSupervisorScope::washesQuery($supervisor)
|
||||||
|
->whereDate('started_at', $today)
|
||||||
|
->count(),
|
||||||
|
'revenue_today' => (float) $todayStats->sum('total_revenue'),
|
||||||
|
'top_up_today' => (float) $todayStats->sum('top_up_amount'),
|
||||||
|
],
|
||||||
|
'establishments' => $todayStats->map(fn ($stat) => [
|
||||||
|
'establishment_id' => $stat->establishment_id,
|
||||||
|
'establishment_name' => $stat->establishment?->name,
|
||||||
|
'total_washes' => $stat->total_washes,
|
||||||
|
'total_revenue' => (float) $stat->total_revenue,
|
||||||
|
'bookings_count' => $stat->bookings_count,
|
||||||
|
'no_show_count' => $stat->no_show_count,
|
||||||
|
'occupancy_rate' => (float) $stat->occupancy_rate,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Middleware\EnsureSupervisorScope;
|
||||||
|
use App\Http\Resources\MachineResource;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MachineController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
|
||||||
|
|
||||||
|
$machines = EnsureSupervisorScope::machinesQuery($supervisor)
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
|
||||||
|
->when($request->filled('establishment_id'), fn ($q) => $q->where('establishment_id', $request->integer('establishment_id')))
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate($request->integer('per_page', 50));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'machines' => MachineResource::collection($machines),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $machines->currentPage(),
|
||||||
|
'last_page' => $machines->lastPage(),
|
||||||
|
'per_page' => $machines->perPage(),
|
||||||
|
'total' => $machines->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Middleware\EnsureSupervisorScope;
|
||||||
|
use App\Http\Requests\Api\V1\Supervisor\StorePricingRequest;
|
||||||
|
use App\Http\Requests\Api\V1\Supervisor\UpdatePricingRequest;
|
||||||
|
use App\Http\Resources\PricingRuleResource;
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Models\PricingRule;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PricingController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
|
||||||
|
|
||||||
|
$rules = EnsureSupervisorScope::pricingRulesQuery($supervisor)
|
||||||
|
->when($request->filled('establishment_id'), fn ($q) => $q->where('establishment_id', $request->integer('establishment_id')))
|
||||||
|
->orderBy('priority')
|
||||||
|
->paginate($request->integer('per_page', 50));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'pricing_rules' => PricingRuleResource::collection($rules),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $rules->currentPage(),
|
||||||
|
'last_page' => $rules->lastPage(),
|
||||||
|
'per_page' => $rules->perPage(),
|
||||||
|
'total' => $rules->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StorePricingRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
|
||||||
|
|
||||||
|
$establishment = Establishment::query()->findOrFail($request->integer('establishment_id'));
|
||||||
|
|
||||||
|
if ($establishment->organization_id !== $supervisor->organization_id) {
|
||||||
|
return $this->error('Établissement hors périmètre.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($supervisor->establishment_id !== null && $supervisor->establishment_id !== $establishment->id) {
|
||||||
|
return $this->error('Établissement hors périmètre.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rule = PricingRule::query()->create([
|
||||||
|
'establishment_id' => $establishment->id,
|
||||||
|
'machine_id' => $request->input('machine_id'),
|
||||||
|
'machine_type' => $request->input('machine_type'),
|
||||||
|
'day_type' => $request->string('day_type')->toString(),
|
||||||
|
'slot_start' => $request->string('slot_start')->toString(),
|
||||||
|
'slot_end' => $request->string('slot_end')->toString(),
|
||||||
|
'price' => $request->input('price'),
|
||||||
|
'label' => $request->input('label'),
|
||||||
|
'requires_app' => $request->boolean('requires_app'),
|
||||||
|
'priority' => $request->integer('priority', 100),
|
||||||
|
'is_active' => $request->boolean('is_active', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->created([
|
||||||
|
'pricing_rule' => new PricingRuleResource($rule->load(['establishment', 'machine'])),
|
||||||
|
], 'Règle tarifaire créée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdatePricingRequest $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
|
||||||
|
|
||||||
|
$rule = EnsureSupervisorScope::pricingRulesQuery($supervisor)
|
||||||
|
->whereKey($id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$rule->update($request->validated());
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'pricing_rule' => new PricingRuleResource($rule->fresh(['establishment', 'machine'])),
|
||||||
|
], 'Règle tarifaire mise à jour.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Middleware\EnsureSupervisorScope;
|
||||||
|
use App\Http\Resources\PromotionResource;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PromotionController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
|
||||||
|
|
||||||
|
$promotions = EnsureSupervisorScope::promotionsQuery($supervisor)
|
||||||
|
->when($request->filled('establishment_id'), fn ($q) => $q->where('establishment_id', $request->integer('establishment_id')))
|
||||||
|
->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true))
|
||||||
|
->orderByDesc('starts_at')
|
||||||
|
->paginate($request->integer('per_page', 50));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'promotions' => PromotionResource::collection($promotions),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $promotions->currentPage(),
|
||||||
|
'last_page' => $promotions->lastPage(),
|
||||||
|
'per_page' => $promotions->perPage(),
|
||||||
|
'total' => $promotions->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Middleware\EnsureSupervisorScope;
|
||||||
|
use App\Http\Resources\WashResource;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class WashController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = $request->attributes->get('supervisor') ?? $request->user();
|
||||||
|
|
||||||
|
$washes = EnsureSupervisorScope::washesQuery($supervisor)
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
|
||||||
|
->when($request->filled('date'), fn ($q) => $q->whereDate('started_at', $request->string('date')->toString()))
|
||||||
|
->orderByDesc('started_at')
|
||||||
|
->paginate($request->integer('per_page', 50));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'washes' => WashResource::collection($washes),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $washes->currentPage(),
|
||||||
|
'last_page' => $washes->lastPage(),
|
||||||
|
'per_page' => $washes->perPage(),
|
||||||
|
'total' => $washes->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Requests\Api\V1\Wallet\ConfirmTopUpRequest;
|
||||||
|
use App\Http\Requests\Api\V1\Wallet\InitiateTopUpRequest;
|
||||||
|
use App\Http\Resources\PaymentTransactionResource;
|
||||||
|
use App\Http\Resources\WalletResource;
|
||||||
|
use App\Http\Resources\WalletTransactionResource;
|
||||||
|
use App\Models\Wallet;
|
||||||
|
use App\Models\WalletTransaction;
|
||||||
|
use App\Services\PaymentService;
|
||||||
|
use App\Services\WalletService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class WalletController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly WalletService $walletService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$wallet = Wallet::query()->firstOrCreate(
|
||||||
|
['user_id' => $user->id],
|
||||||
|
['currency' => 'EUR', 'current_balance' => 0, 'status' => 'active'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'balance' => $this->walletService->getBalance($user),
|
||||||
|
'wallet' => new WalletResource($wallet->fresh()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transactions(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$wallet = Wallet::query()->where('user_id', $request->user()->id)->first();
|
||||||
|
|
||||||
|
if ($wallet === null) {
|
||||||
|
return $this->success(['transactions' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = WalletTransaction::query()
|
||||||
|
->where('wallet_id', $wallet->id)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate($request->integer('per_page', 20));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'transactions' => WalletTransactionResource::collection($transactions),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $transactions->currentPage(),
|
||||||
|
'last_page' => $transactions->lastPage(),
|
||||||
|
'per_page' => $transactions->perPage(),
|
||||||
|
'total' => $transactions->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initiateTopUp(InitiateTopUpRequest $request, PaymentService $paymentService): JsonResponse
|
||||||
|
{
|
||||||
|
$payment = $paymentService->initiateTopUp(
|
||||||
|
$request->user(),
|
||||||
|
(float) $request->input('amount'),
|
||||||
|
$request->string('idempotency_key')->toString(),
|
||||||
|
$request->input('return_url'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->created([
|
||||||
|
'payment' => new PaymentTransactionResource($payment),
|
||||||
|
], 'Rechargement initié.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmTopUp(ConfirmTopUpRequest $request, PaymentService $paymentService): JsonResponse
|
||||||
|
{
|
||||||
|
$payment = $paymentService->confirmTopUp(
|
||||||
|
$request->string('payment_uuid')->toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($payment->user_id !== $request->user()->id) {
|
||||||
|
return $this->error('Paiement non autorisé.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'payment' => new PaymentTransactionResource($payment),
|
||||||
|
'balance' => $this->walletService->getBalance($request->user()),
|
||||||
|
], 'Rechargement confirmé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhook(Request $request, string $provider, PaymentService $paymentService): JsonResponse
|
||||||
|
{
|
||||||
|
if ($provider === 'stripe') {
|
||||||
|
try {
|
||||||
|
$paymentService->handleStripeWebhook(
|
||||||
|
$request->getContent(),
|
||||||
|
$request->header('Stripe-Signature', ''),
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return $this->error($e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$paymentService->handleWebhook($provider, $request->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success(message: 'Webhook traité.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\MachineNotAvailableException;
|
||||||
|
use App\Exceptions\WalletInsufficientFundsException;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Concerns\RespondsWithJson;
|
||||||
|
use App\Http\Requests\Api\V1\Wash\StartWashRequest;
|
||||||
|
use App\Http\Resources\WashResource;
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\Machine;
|
||||||
|
use App\Models\Wash;
|
||||||
|
use App\Services\WashService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class WashController extends Controller
|
||||||
|
{
|
||||||
|
use RespondsWithJson;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly WashService $washService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function start(StartWashRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$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')) {
|
||||||
|
$bookingId = Booking::query()
|
||||||
|
->where('uuid', $request->string('booking_uuid')->toString())
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->value('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$wash = $this->washService->startWash(
|
||||||
|
$request->user(),
|
||||||
|
$machine,
|
||||||
|
$request->string('trigger_method')->toString(),
|
||||||
|
$bookingId,
|
||||||
|
$request->input('program'),
|
||||||
|
);
|
||||||
|
} catch (MachineNotAvailableException $e) {
|
||||||
|
return $this->error($e->getMessage(), 409);
|
||||||
|
} catch (WalletInsufficientFundsException $e) {
|
||||||
|
return $this->error($e->getMessage(), 402);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return $this->error($e->getMessage(), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->created([
|
||||||
|
'wash' => new WashResource($wash),
|
||||||
|
], 'Lavage démarré.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$washes = Wash::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->with(['machine.establishment', 'booking'])
|
||||||
|
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString()))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate($request->integer('per_page', 20));
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'washes' => WashResource::collection($washes),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $washes->currentPage(),
|
||||||
|
'last_page' => $washes->lastPage(),
|
||||||
|
'per_page' => $washes->perPage(),
|
||||||
|
'total' => $washes->total(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, string $uuid): JsonResponse
|
||||||
|
{
|
||||||
|
$wash = Wash::query()
|
||||||
|
->where('uuid', $uuid)
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->with(['machine.establishment', 'booking'])
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'wash' => new WashResource($wash),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\LoginRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class AuthenticatedSessionController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the login view.
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/Login', [
|
||||||
|
'canResetPassword' => Route::has('password.request'),
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming authentication request.
|
||||||
|
*/
|
||||||
|
public function store(LoginRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->authenticate();
|
||||||
|
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy an authenticated session.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ConfirmablePasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the confirm password view.
|
||||||
|
*/
|
||||||
|
public function show(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/ConfirmPassword');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the user's password.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! Auth::guard('web')->validate([
|
||||||
|
'email' => $request->user()->email,
|
||||||
|
'password' => $request->password,
|
||||||
|
])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'password' => __('auth.password'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('auth.password_confirmed_at', time());
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EmailVerificationNotificationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a new email verification notification.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->sendEmailVerificationNotification();
|
||||||
|
|
||||||
|
return back()->with('status', 'verification-link-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class EmailVerificationPromptController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the email verification prompt.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): RedirectResponse|Response
|
||||||
|
{
|
||||||
|
return $request->user()->hasVerifiedEmail()
|
||||||
|
? redirect()->intended(route('dashboard', absolute: false))
|
||||||
|
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class NewPasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the password reset view.
|
||||||
|
*/
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/ResetPassword', [
|
||||||
|
'email' => $request->email,
|
||||||
|
'token' => $request->route('token'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming new password request.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => 'required',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Here we will attempt to reset the user's password. If it is successful we
|
||||||
|
// will update the password on an actual user model and persist it to the
|
||||||
|
// database. Otherwise we will parse the error and return the response.
|
||||||
|
$status = Password::reset(
|
||||||
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
|
function ($user) use ($request) {
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
'remember_token' => Str::random(60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
event(new PasswordReset($user));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the password was successfully reset, we will redirect the user back to
|
||||||
|
// the application's home authenticated view. If there is an error we can
|
||||||
|
// redirect them back to where they came from with their error message.
|
||||||
|
if ($status == Password::PASSWORD_RESET) {
|
||||||
|
return redirect()->route('login')->with('status', __($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [trans($status)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class PasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the user's password.
|
||||||
|
*/
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'current_password' => ['required', 'current_password'],
|
||||||
|
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PasswordResetLinkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the password reset link request view.
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/ForgotPassword', [
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming password reset link request.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// We will send the password reset link to this user. Once we have attempted
|
||||||
|
// to send the link, we will examine the response then see the message we
|
||||||
|
// need to show to the user. Finally, we'll send out a proper response.
|
||||||
|
$status = Password::sendResetLink(
|
||||||
|
$request->only('email')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status == Password::RESET_LINK_SENT) {
|
||||||
|
return back()->with('status', __($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [trans($status)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class RegisteredUserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the registration view.
|
||||||
|
*/
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Auth/Register');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming registration request.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $request->name,
|
||||||
|
'email' => $request->email,
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
]);
|
||||||
|
|
||||||
|
event(new Registered($user));
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
return redirect(route('dashboard', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class VerifyEmailController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mark the authenticated user's email address as verified.
|
||||||
|
*/
|
||||||
|
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->user()->markEmailAsVerified()) {
|
||||||
|
event(new Verified($request->user()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
trait RespondsWithJson
|
||||||
|
{
|
||||||
|
protected function success(mixed $data = null, ?string $message = null, int $status = 200): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = ['success' => true];
|
||||||
|
|
||||||
|
if ($message !== null) {
|
||||||
|
$payload['message'] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data !== null) {
|
||||||
|
$payload['data'] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($payload, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function created(mixed $data, ?string $message = null): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->success($data, $message, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function error(string $message, int $status = 400, ?array $errors = null): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($errors !== null) {
|
||||||
|
$payload['errors'] = $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($payload, $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ProfileUpdateRequest;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the user's profile form.
|
||||||
|
*/
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Profile/Edit', [
|
||||||
|
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's profile information.
|
||||||
|
*/
|
||||||
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->fill($request->validated());
|
||||||
|
|
||||||
|
if ($request->user()->isDirty('email')) {
|
||||||
|
$request->user()->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->save();
|
||||||
|
|
||||||
|
return Redirect::route('profile.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's account.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'password' => ['required', 'current_password'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return Redirect::to('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Supervisor\LoginRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Supervisor/Auth/Login', [
|
||||||
|
'status' => session('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(LoginRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->authenticate();
|
||||||
|
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
$supervisor = Auth::guard('supervisor')->user();
|
||||||
|
$supervisor?->update(['last_login_at' => now()]);
|
||||||
|
|
||||||
|
return redirect()->intended(route('supervisor.dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::guard('supervisor')->logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Booking;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class BookingPageController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = Booking::query()
|
||||||
|
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
|
||||||
|
|
||||||
|
$this->scopeMachineRelationQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query, 'machine');
|
||||||
|
$this->applyUserSearchFilter($request, $query);
|
||||||
|
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->string('status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('date')) {
|
||||||
|
$query->whereDate('slot_start', $request->string('date'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookings = $query
|
||||||
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
|
'slot_start' => 'bookings.slot_start',
|
||||||
|
'user' => fn ($builder, string $direction) => $this->sortByRelatedUser($builder, 'bookings', 'user_id', $direction),
|
||||||
|
'machine' => fn ($builder, string $direction) => $this->sortByRelatedMachine($builder, 'bookings', $direction),
|
||||||
|
'status' => 'bookings.status',
|
||||||
|
'booking_fee' => 'bookings.booking_fee',
|
||||||
|
], 'slot_start', 'desc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (Booking $booking) => [
|
||||||
|
'uuid' => $booking->uuid,
|
||||||
|
'status' => $booking->status,
|
||||||
|
'status_label' => $this->statusLabel($booking->status),
|
||||||
|
'slot_start' => $booking->slot_start?->toIso8601String(),
|
||||||
|
'slot_end' => $booking->slot_end?->toIso8601String(),
|
||||||
|
'booking_fee' => (float) $booking->booking_fee,
|
||||||
|
'reserved_amount' => (float) $booking->reserved_amount,
|
||||||
|
'penalty_amount' => (float) $booking->penalty_amount,
|
||||||
|
'user' => $booking->user ? [
|
||||||
|
'name' => trim($booking->user->first_name.' '.$booking->user->last_name),
|
||||||
|
'email' => $booking->user->email,
|
||||||
|
] : null,
|
||||||
|
'machine' => $booking->machine ? [
|
||||||
|
'uuid' => $booking->machine->uuid,
|
||||||
|
'name' => $booking->machine->name,
|
||||||
|
'establishment_name' => $booking->machine->establishment?->name,
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Bookings/Index', [
|
||||||
|
'bookings' => $bookings,
|
||||||
|
'establishments' => $this->establishmentOptions(),
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'user' => $request->string('user')->toString() ?: null,
|
||||||
|
'status' => $request->string('status')->toString() ?: null,
|
||||||
|
'date' => $request->string('date')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'slot_start',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'asc' ? 'asc' : 'desc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
|
'statusOptions' => $this->statusOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function statusOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending' => 'En attente',
|
||||||
|
'confirmed' => 'Confirmée',
|
||||||
|
'cancelled' => 'Annulée',
|
||||||
|
'expired' => 'Expirée',
|
||||||
|
'active' => 'Active',
|
||||||
|
'completed' => 'Terminée',
|
||||||
|
'no_show' => 'Absent',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statusLabel(string $status): string
|
||||||
|
{
|
||||||
|
return $this->statusOptions()[$status] ?? $status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
trait ScopesSupervisorResources
|
||||||
|
{
|
||||||
|
protected function supervisor(): Supervisor
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = Auth::guard('supervisor')->user();
|
||||||
|
|
||||||
|
return $supervisor;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function supervisorListPerPage(Request $request): int
|
||||||
|
{
|
||||||
|
$perPage = $request->integer('per_page', 10);
|
||||||
|
|
||||||
|
return in_array($perPage, $this->supervisorPerPageOptions(), true) ? $perPage : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
protected function supervisorPerPageOptions(): array
|
||||||
|
{
|
||||||
|
return [10, 25, 50];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function scopeEstablishmentQuery(Builder $query, string $establishmentColumn = 'establishment_id'): Builder
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
$query->whereHas('establishment', function (Builder $query) use ($supervisor) {
|
||||||
|
$query->where('organization_id', $supervisor->organization_id);
|
||||||
|
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
$query->where('id', $supervisor->establishment_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($supervisor->establishment_id !== null && $establishmentColumn !== '') {
|
||||||
|
$query->where($establishmentColumn, $supervisor->establishment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function scopeMachineRelationQuery(Builder $query): Builder
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
return $query->whereHas('machine.establishment', function (Builder $query) use ($supervisor) {
|
||||||
|
$query->where('organization_id', $supervisor->organization_id);
|
||||||
|
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
$query->where('id', $supervisor->establishment_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applyEstablishmentFilter(Request $request, Builder $query, string $mode = 'direct'): void
|
||||||
|
{
|
||||||
|
if (! $request->filled('establishment_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishmentId = (int) $request->input('establishment_id');
|
||||||
|
$this->assertEstablishmentAccessible($establishmentId);
|
||||||
|
|
||||||
|
if ($mode === 'machine') {
|
||||||
|
$query->whereHas('machine', fn (Builder $query) => $query->where('establishment_id', $establishmentId));
|
||||||
|
} else {
|
||||||
|
$query->where('establishment_id', $establishmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applyUserSearchFilter(Request $request, Builder $query): void
|
||||||
|
{
|
||||||
|
if (! $request->filled('user')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = $request->string('user');
|
||||||
|
|
||||||
|
$query->whereHas('user', function (Builder $query) use ($search) {
|
||||||
|
$query->where(function (Builder $query) use ($search) {
|
||||||
|
$query->where('first_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('last_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertEstablishmentAccessible(int $establishmentId): void
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
$exists = Establishment::query()
|
||||||
|
->where('id', $establishmentId)
|
||||||
|
->where('organization_id', $supervisor->organization_id)
|
||||||
|
->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
abort_unless($exists, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{id: int, name: string}>
|
||||||
|
*/
|
||||||
|
protected function establishmentOptions(): array
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
return Establishment::query()
|
||||||
|
->where('organization_id', $supervisor->organization_id)
|
||||||
|
->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id))
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name'])
|
||||||
|
->map(fn (Establishment $establishment) => [
|
||||||
|
'id' => $establishment->id,
|
||||||
|
'name' => $establishment->name,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string|callable(Builder, string): void> $sortableColumns
|
||||||
|
*/
|
||||||
|
protected function applySort(
|
||||||
|
Request $request,
|
||||||
|
Builder $query,
|
||||||
|
array $sortableColumns,
|
||||||
|
string $defaultColumn,
|
||||||
|
string $defaultDirection = 'asc',
|
||||||
|
): void {
|
||||||
|
$sort = $request->string('sort')->toString();
|
||||||
|
$direction = strtolower($request->string('direction')->toString()) === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
if ($sort === '' || ! array_key_exists($sort, $sortableColumns)) {
|
||||||
|
$sort = $defaultColumn;
|
||||||
|
$direction = $defaultDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handler = $sortableColumns[$sort];
|
||||||
|
|
||||||
|
if (is_callable($handler)) {
|
||||||
|
$handler($query, $direction);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy($handler, $direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sortByRelatedUser(Builder $query, string $parentTable, string $foreignKey, string $direction): void
|
||||||
|
{
|
||||||
|
$query->leftJoin('users', "{$parentTable}.{$foreignKey}", '=', 'users.id')
|
||||||
|
->orderBy('users.last_name', $direction)
|
||||||
|
->orderBy('users.first_name', $direction)
|
||||||
|
->select("{$parentTable}.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sortByRelatedMachine(Builder $query, string $parentTable, string $direction): void
|
||||||
|
{
|
||||||
|
$query->leftJoin('machines', "{$parentTable}.machine_id", '=', 'machines.id')
|
||||||
|
->orderBy('machines.name', $direction)
|
||||||
|
->select("{$parentTable}.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sortByRelatedEstablishment(
|
||||||
|
Builder $query,
|
||||||
|
string $parentTable,
|
||||||
|
string $foreignKeyColumn,
|
||||||
|
string $direction,
|
||||||
|
): void {
|
||||||
|
$query->leftJoin('establishments', "{$parentTable}.{$foreignKeyColumn}", '=', 'establishments.id')
|
||||||
|
->orderBy('establishments.name', $direction)
|
||||||
|
->select("{$parentTable}.*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use App\Services\DashboardService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function index(Request $request, DashboardService $dashboardService): Response
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
$establishmentFilter = $this->resolveDashboardEstablishmentFilter($request, $supervisor);
|
||||||
|
$establishments = $this->establishmentOptions();
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Dashboard', [
|
||||||
|
'kpis' => $dashboardService->getKpis($supervisor, $establishmentFilter),
|
||||||
|
'alerts' => $dashboardService->getAlerts($supervisor, $establishmentFilter),
|
||||||
|
'machinesOverview' => $dashboardService->getMachinesOverview($supervisor, $establishmentFilter),
|
||||||
|
'machineStatusBreakdown' => $dashboardService->getMachineStatusBreakdown($supervisor, $establishmentFilter),
|
||||||
|
'recentWashes' => $dashboardService->getRecentWashes($supervisor, $establishmentFilter),
|
||||||
|
'context' => $dashboardService->getContext($supervisor, $establishmentFilter),
|
||||||
|
'establishments' => $establishments,
|
||||||
|
'showEstablishmentFilter' => $supervisor->establishment_id === null && count($establishments) > 1,
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $establishmentFilter,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDashboardEstablishmentFilter(Request $request, Supervisor $supervisor): ?int
|
||||||
|
{
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
return $supervisor->establishment_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->filled('establishment_id')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishmentId = (int) $request->input('establishment_id');
|
||||||
|
$this->assertEstablishmentAccessible($establishmentId);
|
||||||
|
|
||||||
|
return $establishmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Models\Machine;
|
||||||
|
use App\Models\MachineCommand;
|
||||||
|
use App\Models\MachineEvent;
|
||||||
|
use App\Models\MachineIntegration;
|
||||||
|
use App\Models\MachineStatusHistory;
|
||||||
|
use App\Models\PricingRule;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class MachinePageController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = Machine::query()
|
||||||
|
->with('establishment:id,name,uuid');
|
||||||
|
|
||||||
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query);
|
||||||
|
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->string('status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('type')) {
|
||||||
|
$query->where('type', $request->string('type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->string('search');
|
||||||
|
$query->where(function ($query) use ($search) {
|
||||||
|
$query->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('qr_code', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$machines = $query
|
||||||
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
|
'name' => 'machines.name',
|
||||||
|
'establishment' => fn ($builder, string $direction) => $builder
|
||||||
|
->leftJoin('establishments', 'machines.establishment_id', '=', 'establishments.id')
|
||||||
|
->orderBy('establishments.name', $direction)
|
||||||
|
->select('machines.*'),
|
||||||
|
'type' => 'machines.type',
|
||||||
|
'status' => 'machines.status',
|
||||||
|
'last_heartbeat_at' => 'machines.last_heartbeat_at',
|
||||||
|
], 'name', 'asc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (Machine $machine) => $this->formatMachineListItem($machine));
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Machines/Index', [
|
||||||
|
'machines' => $machines,
|
||||||
|
'establishments' => $this->establishmentOptions(),
|
||||||
|
'canManageMachines' => $this->canManageMachines(),
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'status' => $request->string('status')->toString() ?: null,
|
||||||
|
'type' => $request->string('type')->toString() ?: null,
|
||||||
|
'search' => $request->string('search')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'name',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
|
'statusOptions' => $this->statusOptions(),
|
||||||
|
'typeOptions' => $this->typeOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageMachines(), 403);
|
||||||
|
|
||||||
|
$validated = $this->validateMachine($request);
|
||||||
|
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
|
||||||
|
|
||||||
|
$validated['qr_code'] = $this->resolveQrCode($validated['qr_code'] ?? null);
|
||||||
|
$validated['status'] = $validated['status'] ?? 'available';
|
||||||
|
|
||||||
|
$machine = Machine::query()->create($validated);
|
||||||
|
$this->createSimulatedIntegration($machine);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->with('success', 'Machine créée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, string $uuid): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageMachines(), 403);
|
||||||
|
|
||||||
|
$machine = $this->findAccessibleMachine($uuid);
|
||||||
|
$validated = $this->validateMachine($request, $machine);
|
||||||
|
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
|
||||||
|
|
||||||
|
if (array_key_exists('qr_code', $validated) && blank($validated['qr_code'])) {
|
||||||
|
unset($validated['qr_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$machine->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->with('success', 'Machine mise à jour.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $uuid): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageMachines(), 403);
|
||||||
|
|
||||||
|
$machine = $this->findAccessibleMachine($uuid);
|
||||||
|
|
||||||
|
if (in_array($machine->status, ['running', 'reserved'], true)) {
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->withErrors(['delete' => 'Impossible de supprimer une machine en cours d\'utilisation.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($machine->bookings()->exists() || $machine->washes()->exists()) {
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->withErrors(['delete' => 'Impossible de supprimer une machine avec un historique de réservations ou de lavages.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
PricingRule::query()->where('machine_id', $machine->id)->update(['machine_id' => null]);
|
||||||
|
MachineCommand::query()->where('machine_id', $machine->id)->delete();
|
||||||
|
MachineEvent::query()->where('machine_id', $machine->id)->delete();
|
||||||
|
MachineStatusHistory::query()->where('machine_id', $machine->id)->delete();
|
||||||
|
$machine->machineIntegration()?->delete();
|
||||||
|
$machine->delete();
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->with('success', 'Machine supprimée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $uuid): Response
|
||||||
|
{
|
||||||
|
$query = Machine::query()->where('uuid', $uuid);
|
||||||
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
|
||||||
|
$machine = $query
|
||||||
|
->with(['establishment:id,name,uuid,address,city', 'machineIntegration', 'currentUser:id,first_name,last_name,email'])
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$recentEvents = MachineEvent::query()
|
||||||
|
->where('machine_id', $machine->id)
|
||||||
|
->orderByDesc('occurred_at')
|
||||||
|
->limit(20)
|
||||||
|
->get()
|
||||||
|
->map(fn (MachineEvent $event) => [
|
||||||
|
'event_type' => $event->event_type,
|
||||||
|
'event_type_label' => $this->eventTypeLabel($event->event_type),
|
||||||
|
'occurred_at' => $event->occurred_at?->toIso8601String(),
|
||||||
|
'processing_status' => $event->processing_status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Machines/Show', [
|
||||||
|
'machine' => [
|
||||||
|
'uuid' => $machine->uuid,
|
||||||
|
'name' => $machine->name,
|
||||||
|
'type' => $machine->type,
|
||||||
|
'type_label' => $this->typeLabel($machine->type),
|
||||||
|
'qr_code' => $machine->qr_code,
|
||||||
|
'status' => $machine->status,
|
||||||
|
'status_label' => $this->statusLabel($machine->status),
|
||||||
|
'cycle_started_at' => $machine->cycle_started_at?->toIso8601String(),
|
||||||
|
'cycle_ends_at' => $machine->cycle_ends_at?->toIso8601String(),
|
||||||
|
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
|
||||||
|
'establishment' => $machine->establishment ? [
|
||||||
|
'uuid' => $machine->establishment->uuid,
|
||||||
|
'name' => $machine->establishment->name,
|
||||||
|
'address' => $machine->establishment->address,
|
||||||
|
'city' => $machine->establishment->city,
|
||||||
|
] : null,
|
||||||
|
'current_user' => $machine->currentUser ? [
|
||||||
|
'name' => trim($machine->currentUser->first_name.' '.$machine->currentUser->last_name),
|
||||||
|
'email' => $machine->currentUser->email,
|
||||||
|
] : null,
|
||||||
|
'integration' => $machine->machineIntegration ? [
|
||||||
|
'provider' => $machine->machineIntegration->provider,
|
||||||
|
'mode' => $machine->machineIntegration->mode,
|
||||||
|
'is_active' => $machine->machineIntegration->is_active,
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
'recentEvents' => $recentEvents,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formatMachineListItem(Machine $machine): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $machine->uuid,
|
||||||
|
'establishment_id' => $machine->establishment_id,
|
||||||
|
'name' => $machine->name,
|
||||||
|
'type' => $machine->type,
|
||||||
|
'type_label' => $this->typeLabel($machine->type),
|
||||||
|
'qr_code' => $machine->qr_code,
|
||||||
|
'status' => $machine->status,
|
||||||
|
'status_label' => $this->statusLabel($machine->status),
|
||||||
|
'establishment_name' => $machine->establishment?->name,
|
||||||
|
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function validateMachine(Request $request, ?Machine $machine = null): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'type' => ['required', 'in:washer_small,washer_large,dryer_small,dryer_large'],
|
||||||
|
'qr_code' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('machines', 'qr_code')->ignore($machine?->id),
|
||||||
|
],
|
||||||
|
'status' => ['required', 'in:available,reserved,running,maintenance,offline,error'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAccessibleMachine(string $uuid): Machine
|
||||||
|
{
|
||||||
|
$query = Machine::query()->where('uuid', $uuid);
|
||||||
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
|
||||||
|
return $query->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveQrCode(?string $qrCode): string
|
||||||
|
{
|
||||||
|
if (filled($qrCode)) {
|
||||||
|
return $qrCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
$candidate = 'LAVERIE-'.strtoupper(Str::random(8));
|
||||||
|
} while (Machine::query()->where('qr_code', $candidate)->exists());
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSimulatedIntegration(Machine $machine): void
|
||||||
|
{
|
||||||
|
$machine->load('establishment');
|
||||||
|
|
||||||
|
MachineIntegration::query()->create([
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'provider' => config('laverie.simulation.provider_name', 'simulated'),
|
||||||
|
'external_machine_id' => 'SIM-'.$machine->uuid,
|
||||||
|
'external_site_id' => 'SITE-'.$machine->establishment?->uuid,
|
||||||
|
'mode' => 'simulated',
|
||||||
|
'config' => [
|
||||||
|
'cycle_duration_seconds' => config('laverie.simulation.cycle_duration_seconds', 30),
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canManageMachines(): bool
|
||||||
|
{
|
||||||
|
return $this->supervisor()->role !== 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function statusOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'available' => 'Disponible',
|
||||||
|
'reserved' => 'Réservée',
|
||||||
|
'running' => 'En cours',
|
||||||
|
'maintenance' => 'Maintenance',
|
||||||
|
'offline' => 'Hors ligne',
|
||||||
|
'error' => 'Erreur',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function typeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'washer_small' => 'Lave-linge petit',
|
||||||
|
'washer_large' => 'Lave-linge grand',
|
||||||
|
'dryer_small' => 'Sèche-linge petit',
|
||||||
|
'dryer_large' => 'Sèche-linge grand',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statusLabel(string $status): string
|
||||||
|
{
|
||||||
|
return $this->statusOptions()[$status] ?? $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function typeLabel(string $type): string
|
||||||
|
{
|
||||||
|
return $this->typeOptions()[$type] ?? $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function eventTypeLabel(string $type): string
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'heartbeat' => 'Signal de vie',
|
||||||
|
'machine_online' => 'En ligne',
|
||||||
|
'machine_offline' => 'Hors ligne',
|
||||||
|
'cycle_started' => 'Cycle démarré',
|
||||||
|
'cycle_completed' => 'Cycle terminé',
|
||||||
|
'cycle_failed' => 'Cycle échoué',
|
||||||
|
'error_reported' => 'Erreur signalée',
|
||||||
|
'status_changed' => 'Changement de statut',
|
||||||
|
default => $type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Services\GeocodingService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class OrganizationPageController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function __construct(private GeocodingService $geocoding) {}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
$baseQuery = Establishment::query()
|
||||||
|
->where('organization_id', $supervisor->organization_id);
|
||||||
|
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
$baseQuery->where('id', $supervisor->establishment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalCount = (clone $baseQuery)->count();
|
||||||
|
$viewMode = $totalCount === 1 ? 'single' : 'list';
|
||||||
|
|
||||||
|
$canManageEstablishments = $this->canManageEstablishments();
|
||||||
|
$canCreateEstablishments = $this->canCreateEstablishments();
|
||||||
|
|
||||||
|
if ($viewMode === 'single') {
|
||||||
|
$establishment = (clone $baseQuery)->withCount('machines')->first();
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Organizations/Index', [
|
||||||
|
'establishments' => [$this->formatEstablishment($establishment)],
|
||||||
|
'viewMode' => 'single',
|
||||||
|
'canManageEstablishments' => $canManageEstablishments,
|
||||||
|
'canCreateEstablishments' => $canCreateEstablishments,
|
||||||
|
'filters' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = (clone $baseQuery)->withCount('machines');
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->string('search');
|
||||||
|
$query->where(function ($builder) use ($search) {
|
||||||
|
$builder->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('address', 'like', "%{$search}%")
|
||||||
|
->orWhere('city', 'like', "%{$search}%")
|
||||||
|
->orWhere('zip_code', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('is_active') && $request->string('is_active')->toString() !== '') {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishments = $query
|
||||||
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
|
'name' => 'name',
|
||||||
|
'city' => 'city',
|
||||||
|
'machines_count' => 'machines_count',
|
||||||
|
'is_active' => 'is_active',
|
||||||
|
], 'name', 'asc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (Establishment $establishment) => $this->formatEstablishment($establishment));
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Organizations/Index', [
|
||||||
|
'establishments' => $establishments,
|
||||||
|
'viewMode' => 'list',
|
||||||
|
'canManageEstablishments' => $canManageEstablishments,
|
||||||
|
'canCreateEstablishments' => $canCreateEstablishments,
|
||||||
|
'filters' => [
|
||||||
|
'search' => $request->string('search')->toString() ?: null,
|
||||||
|
'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== ''
|
||||||
|
? ($request->boolean('is_active') ? '1' : '0')
|
||||||
|
: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'name',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchAddresses(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageEstablishments(), 403);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'q' => ['required', 'string', 'min:3', 'max:200'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$this->geocoding->search($validated['q']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeEstablishment(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageEstablishments(), 403);
|
||||||
|
abort_if($this->supervisor()->establishment_id !== null, 403);
|
||||||
|
|
||||||
|
$validated = $this->prepareEstablishmentData(
|
||||||
|
$request->validate($this->establishmentRules()),
|
||||||
|
);
|
||||||
|
$validated['organization_id'] = $this->supervisor()->organization_id;
|
||||||
|
|
||||||
|
Establishment::query()->create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.organizations.index')
|
||||||
|
->with('success', 'Enseigne créée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateEstablishment(Request $request, int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageEstablishments(), 403);
|
||||||
|
|
||||||
|
$establishment = $this->findAccessibleEstablishment($id);
|
||||||
|
$validated = $this->prepareEstablishmentData(
|
||||||
|
$request->validate($this->establishmentRules()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! empty($validated['is_active']) && ! $establishment->is_active) {
|
||||||
|
$this->validateEstablishmentReadyForActivation($validated, $establishment);
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishment->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.organizations.index')
|
||||||
|
->with('success', 'Enseigne mise à jour.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleEstablishmentActive(int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageEstablishments(), 403);
|
||||||
|
|
||||||
|
$establishment = $this->findAccessibleEstablishment($id);
|
||||||
|
$isActive = ! $establishment->is_active;
|
||||||
|
|
||||||
|
if ($isActive) {
|
||||||
|
$this->validateEstablishmentReadyForActivation([
|
||||||
|
'name' => $establishment->name,
|
||||||
|
'address' => $establishment->address,
|
||||||
|
'timezone' => $establishment->timezone,
|
||||||
|
], $establishment);
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishment->update(['is_active' => $isActive]);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.organizations.index')
|
||||||
|
->with('success', $isActive ? 'Enseigne réactivée.' : 'Enseigne désactivée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formatEstablishment(Establishment $establishment): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $establishment->id,
|
||||||
|
'uuid' => $establishment->uuid,
|
||||||
|
'name' => $establishment->name,
|
||||||
|
'address' => $establishment->address,
|
||||||
|
'city' => $establishment->city,
|
||||||
|
'zip_code' => $establishment->zip_code,
|
||||||
|
'latitude' => $establishment->latitude !== null ? (float) $establishment->latitude : null,
|
||||||
|
'longitude' => $establishment->longitude !== null ? (float) $establishment->longitude : null,
|
||||||
|
'timezone' => $establishment->timezone,
|
||||||
|
'is_active' => $establishment->is_active,
|
||||||
|
'machines_count' => $establishment->machines_count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function establishmentRules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:200'],
|
||||||
|
'address' => ['required', 'string'],
|
||||||
|
'city' => ['nullable', 'string', 'max:100'],
|
||||||
|
'zip_code' => ['nullable', 'string', 'max:10'],
|
||||||
|
'timezone' => ['required', 'string', 'max:50'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function prepareEstablishmentData(array $validated): array
|
||||||
|
{
|
||||||
|
$coordinates = $this->geocoding->geocode(
|
||||||
|
$validated['address'],
|
||||||
|
$validated['city'] ?? null,
|
||||||
|
$validated['zip_code'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$validated['latitude'] = $coordinates['latitude'] ?? null;
|
||||||
|
$validated['longitude'] = $coordinates['longitude'] ?? null;
|
||||||
|
|
||||||
|
return $validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAccessibleEstablishment(int $id): Establishment
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
return Establishment::query()
|
||||||
|
->where('id', $id)
|
||||||
|
->where('organization_id', $supervisor->organization_id)
|
||||||
|
->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id))
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canCreateEstablishments(): bool
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
if ($supervisor->role === 'viewer') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($supervisor->role, ['owner', 'platform_admin'], true)
|
||||||
|
&& $supervisor->establishment_id === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canManageEstablishments(): bool
|
||||||
|
{
|
||||||
|
return $this->supervisor()->role !== 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function validateEstablishmentReadyForActivation(array $data, Establishment $establishment): void
|
||||||
|
{
|
||||||
|
$validator = validator(
|
||||||
|
$data,
|
||||||
|
[
|
||||||
|
'name' => ['required', 'string', 'max:200'],
|
||||||
|
'address' => ['required', 'string'],
|
||||||
|
'timezone' => ['required', 'string', 'max:50'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name.required' => 'Le nom est requis pour réactiver l\'enseigne.',
|
||||||
|
'address.required' => 'L\'adresse est requise pour réactiver l\'enseigne.',
|
||||||
|
'timezone.required' => 'Le fuseau horaire est requis pour réactiver l\'enseigne.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw ValidationException::withMessages($validator->errors()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishment->loadMissing('organization');
|
||||||
|
|
||||||
|
if (! $establishment->organization?->is_active) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'is_active' => 'L\'organisation doit être active pour réactiver cette enseigne.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Models\PricingRule;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PricingPageController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = PricingRule::query()
|
||||||
|
->with(['establishment:id,name', 'machine:id,uuid,name']);
|
||||||
|
|
||||||
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query);
|
||||||
|
|
||||||
|
if ($request->filled('machine_type')) {
|
||||||
|
$query->where('machine_type', $request->string('machine_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('day_type')) {
|
||||||
|
$query->where('day_type', $request->string('day_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('is_active') && $request->string('is_active')->toString() !== '') {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->string('search');
|
||||||
|
$query->where('label', 'like', "%{$search}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = $query
|
||||||
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
|
'establishment' => fn ($builder, string $direction) => $this->sortByRelatedEstablishment(
|
||||||
|
$builder,
|
||||||
|
'pricing_rules',
|
||||||
|
'establishment_id',
|
||||||
|
$direction,
|
||||||
|
),
|
||||||
|
'slot_start' => 'slot_start',
|
||||||
|
'machine_type' => 'machine_type',
|
||||||
|
'price' => 'price',
|
||||||
|
'priority' => 'priority',
|
||||||
|
'is_active' => 'is_active',
|
||||||
|
], 'priority', 'asc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (PricingRule $rule) => $this->formatRule($rule));
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Pricing/Index', [
|
||||||
|
'rules' => $rules,
|
||||||
|
'establishments' => $this->establishmentOptions(),
|
||||||
|
'machineTypes' => $this->machineTypeOptions(),
|
||||||
|
'dayTypes' => $this->dayTypeOptions(),
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'machine_type' => $request->string('machine_type')->toString() ?: null,
|
||||||
|
'day_type' => $request->string('day_type')->toString() ?: null,
|
||||||
|
'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== ''
|
||||||
|
? ($request->boolean('is_active') ? '1' : '0')
|
||||||
|
: null,
|
||||||
|
'search' => $request->string('search')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'priority',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $this->validateRule($request);
|
||||||
|
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
|
||||||
|
|
||||||
|
PricingRule::query()->create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.pricing.index')
|
||||||
|
->with('success', 'Règle tarifaire créée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
$rule = $this->findAccessibleRule($id);
|
||||||
|
$validated = $this->validateRule($request);
|
||||||
|
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
|
||||||
|
|
||||||
|
$rule->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.pricing.index')
|
||||||
|
->with('success', 'Règle tarifaire mise à jour.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
$rule = $this->findAccessibleRule($id);
|
||||||
|
$rule->delete();
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.pricing.index')
|
||||||
|
->with('success', 'Règle tarifaire supprimée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function validateRule(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
|
||||||
|
'machine_id' => ['nullable', 'integer', 'exists:machines,id'],
|
||||||
|
'machine_type' => ['nullable', 'in:washer_small,washer_large,dryer_small,dryer_large'],
|
||||||
|
'day_type' => ['required', 'in:weekday,weekend,holiday,all'],
|
||||||
|
'slot_start' => ['required', 'date_format:H:i'],
|
||||||
|
'slot_end' => ['required', 'date_format:H:i', 'after:slot_start'],
|
||||||
|
'price' => ['required', 'numeric', 'min:0'],
|
||||||
|
'label' => ['nullable', 'string', 'max:100'],
|
||||||
|
'requires_app' => ['boolean'],
|
||||||
|
'priority' => ['integer', 'min:0'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAccessibleRule(int $id): PricingRule
|
||||||
|
{
|
||||||
|
$query = PricingRule::query()->where('id', $id);
|
||||||
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
|
||||||
|
return $query->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertEstablishmentAccessible(int $establishmentId): void
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
$exists = Establishment::query()
|
||||||
|
->where('id', $establishmentId)
|
||||||
|
->where('organization_id', $supervisor->organization_id)
|
||||||
|
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
abort_unless($exists, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formatRule(PricingRule $rule): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $rule->id,
|
||||||
|
'establishment_id' => $rule->establishment_id,
|
||||||
|
'establishment_name' => $rule->establishment?->name,
|
||||||
|
'machine_id' => $rule->machine_id,
|
||||||
|
'machine_name' => $rule->machine?->name,
|
||||||
|
'machine_type' => $rule->machine_type,
|
||||||
|
'machine_type_label' => $rule->machine_type ? ($this->machineTypeOptions()[$rule->machine_type] ?? $rule->machine_type) : null,
|
||||||
|
'day_type' => $rule->day_type,
|
||||||
|
'day_type_label' => $this->dayTypeOptions()[$rule->day_type] ?? $rule->day_type,
|
||||||
|
'slot_start' => $rule->slot_start ? substr((string) $rule->slot_start, 0, 5) : null,
|
||||||
|
'slot_end' => $rule->slot_end ? substr((string) $rule->slot_end, 0, 5) : null,
|
||||||
|
'price' => (float) $rule->price,
|
||||||
|
'label' => $rule->label,
|
||||||
|
'requires_app' => $rule->requires_app,
|
||||||
|
'priority' => $rule->priority,
|
||||||
|
'is_active' => $rule->is_active,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function machineTypeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'washer_small' => 'Lave-linge petit',
|
||||||
|
'washer_large' => 'Lave-linge grand',
|
||||||
|
'dryer_small' => 'Sèche-linge petit',
|
||||||
|
'dryer_large' => 'Sèche-linge grand',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function dayTypeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'all' => 'Tous les jours',
|
||||||
|
'weekday' => 'Semaine',
|
||||||
|
'weekend' => 'Week-end',
|
||||||
|
'holiday' => 'Jours fériés',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Models\Promotion;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PromotionPageController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = Promotion::query()->with('establishment:id,name');
|
||||||
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query);
|
||||||
|
|
||||||
|
if ($request->filled('machine_type')) {
|
||||||
|
$query->where('machine_type', $request->string('machine_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('is_active') && $request->string('is_active')->toString() !== '') {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->string('search');
|
||||||
|
$query->where('description', 'like', "%{$search}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
$promotions = $query
|
||||||
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
|
'establishment' => fn ($builder, string $direction) => $this->sortByRelatedEstablishment(
|
||||||
|
$builder,
|
||||||
|
'promotions',
|
||||||
|
'establishment_id',
|
||||||
|
$direction,
|
||||||
|
),
|
||||||
|
'discount_value' => 'discount_value',
|
||||||
|
'starts_at' => 'starts_at',
|
||||||
|
'ends_at' => 'ends_at',
|
||||||
|
'machine_type' => 'machine_type',
|
||||||
|
'is_active' => 'is_active',
|
||||||
|
], 'starts_at', 'desc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (Promotion $promotion) => $this->formatPromotion($promotion));
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Promotions/Index', [
|
||||||
|
'promotions' => $promotions,
|
||||||
|
'establishments' => $this->establishmentOptions(),
|
||||||
|
'machineTypes' => $this->machineTypeOptions(),
|
||||||
|
'discountTypes' => $this->discountTypeOptions(),
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'machine_type' => $request->string('machine_type')->toString() ?: null,
|
||||||
|
'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== ''
|
||||||
|
? ($request->boolean('is_active') ? '1' : '0')
|
||||||
|
: null,
|
||||||
|
'search' => $request->string('search')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'starts_at',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $this->validatePromotion($request);
|
||||||
|
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
|
||||||
|
|
||||||
|
Promotion::query()->create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.promotions.index')
|
||||||
|
->with('success', 'Promotion créée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
$promotion = $this->findAccessiblePromotion($id);
|
||||||
|
$validated = $this->validatePromotion($request);
|
||||||
|
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
|
||||||
|
|
||||||
|
$promotion->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.promotions.index')
|
||||||
|
->with('success', 'Promotion mise à jour.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
$promotion = $this->findAccessiblePromotion($id);
|
||||||
|
$promotion->delete();
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.promotions.index')
|
||||||
|
->with('success', 'Promotion supprimée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function validatePromotion(Request $request): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
|
||||||
|
'machine_type' => ['required', 'in:washer_small,washer_large,dryer_small,dryer_large,all'],
|
||||||
|
'discount_type' => ['required', 'in:percent,fixed'],
|
||||||
|
'discount_value' => ['required', 'numeric', 'min:0'],
|
||||||
|
'starts_at' => ['required', 'date'],
|
||||||
|
'ends_at' => ['required', 'date', 'after:starts_at'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAccessiblePromotion(int $id): Promotion
|
||||||
|
{
|
||||||
|
$query = Promotion::query()->where('id', $id);
|
||||||
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
|
||||||
|
return $query->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertEstablishmentAccessible(int $establishmentId): void
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
$exists = Establishment::query()
|
||||||
|
->where('id', $establishmentId)
|
||||||
|
->where('organization_id', $supervisor->organization_id)
|
||||||
|
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
abort_unless($exists, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formatPromotion(Promotion $promotion): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $promotion->id,
|
||||||
|
'establishment_id' => $promotion->establishment_id,
|
||||||
|
'establishment_name' => $promotion->establishment?->name,
|
||||||
|
'machine_type' => $promotion->machine_type,
|
||||||
|
'machine_type_label' => $this->machineTypeOptions()[$promotion->machine_type] ?? $promotion->machine_type,
|
||||||
|
'discount_type' => $promotion->discount_type,
|
||||||
|
'discount_type_label' => $this->discountTypeOptions()[$promotion->discount_type] ?? $promotion->discount_type,
|
||||||
|
'discount_value' => (float) $promotion->discount_value,
|
||||||
|
'starts_at' => $promotion->starts_at?->format('Y-m-d\TH:i'),
|
||||||
|
'ends_at' => $promotion->ends_at?->format('Y-m-d\TH:i'),
|
||||||
|
'description' => $promotion->description,
|
||||||
|
'is_active' => $promotion->is_active,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function machineTypeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'all' => 'Toutes les machines',
|
||||||
|
'washer_small' => 'Lave-linge petit',
|
||||||
|
'washer_large' => 'Lave-linge grand',
|
||||||
|
'dryer_small' => 'Sèche-linge petit',
|
||||||
|
'dryer_large' => 'Sèche-linge grand',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function discountTypeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'percent' => 'Pourcentage',
|
||||||
|
'fixed' => 'Montant fixe',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Wash;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class WashPageController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = Wash::query()
|
||||||
|
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
|
||||||
|
|
||||||
|
$this->scopeMachineRelationQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query, 'machine');
|
||||||
|
$this->applyUserSearchFilter($request, $query);
|
||||||
|
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->string('status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('date')) {
|
||||||
|
$query->whereDate('started_at', $request->string('date'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$washes = $query
|
||||||
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
|
'started_at' => 'washes.started_at',
|
||||||
|
'user' => fn ($builder, string $direction) => $this->sortByRelatedUser($builder, 'washes', 'user_id', $direction),
|
||||||
|
'machine' => fn ($builder, string $direction) => $this->sortByRelatedMachine($builder, 'washes', $direction),
|
||||||
|
'status' => 'washes.status',
|
||||||
|
'duration_minutes' => 'washes.duration_minutes',
|
||||||
|
'cost' => 'washes.cost',
|
||||||
|
], 'started_at', 'desc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (Wash $wash) => [
|
||||||
|
'uuid' => $wash->uuid,
|
||||||
|
'status' => $wash->status,
|
||||||
|
'status_label' => $this->statusLabel($wash->status),
|
||||||
|
'program' => $wash->program,
|
||||||
|
'trigger_method' => $wash->trigger_method,
|
||||||
|
'trigger_method_label' => $this->triggerMethodLabel($wash->trigger_method),
|
||||||
|
'started_at' => $wash->started_at?->toIso8601String(),
|
||||||
|
'ended_at' => $wash->ended_at?->toIso8601String(),
|
||||||
|
'duration_minutes' => $wash->duration_minutes,
|
||||||
|
'cost' => (float) $wash->cost,
|
||||||
|
'user' => $wash->user ? [
|
||||||
|
'name' => trim($wash->user->first_name.' '.$wash->user->last_name),
|
||||||
|
'email' => $wash->user->email,
|
||||||
|
] : null,
|
||||||
|
'machine' => $wash->machine ? [
|
||||||
|
'uuid' => $wash->machine->uuid,
|
||||||
|
'name' => $wash->machine->name,
|
||||||
|
'establishment_name' => $wash->machine->establishment?->name,
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Washes/Index', [
|
||||||
|
'washes' => $washes,
|
||||||
|
'establishments' => $this->establishmentOptions(),
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'user' => $request->string('user')->toString() ?: null,
|
||||||
|
'status' => $request->string('status')->toString() ?: null,
|
||||||
|
'date' => $request->string('date')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'started_at',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'asc' ? 'asc' : 'desc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
|
'statusOptions' => $this->statusOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function statusOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pending_start' => 'En attente',
|
||||||
|
'running' => 'En cours',
|
||||||
|
'completed' => 'Terminé',
|
||||||
|
'failed' => 'Échoué',
|
||||||
|
'cancelled' => 'Annulé',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function statusLabel(string $status): string
|
||||||
|
{
|
||||||
|
return $this->statusOptions()[$status] ?? $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function triggerMethodLabel(string $method): string
|
||||||
|
{
|
||||||
|
return match ($method) {
|
||||||
|
'qr_code' => 'QR code',
|
||||||
|
'booking' => 'Réservation',
|
||||||
|
'supervisor' => 'Superviseur',
|
||||||
|
'system' => 'Système',
|
||||||
|
default => $method,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AuthenticateMachineIntegration
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$apiKey = config('laverie.machine_api_key');
|
||||||
|
|
||||||
|
if ($apiKey === null || $apiKey === '') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Intégration machine non configurée.',
|
||||||
|
], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$providedKey = $request->header('X-API-Key');
|
||||||
|
|
||||||
|
if (! is_string($providedKey) || ! hash_equals($apiKey, $providedKey)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Clé API machine invalide.',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Laravel\Sanctum\PersonalAccessToken;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AuthenticateSupervisor
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if ($request->bearerToken() !== null) {
|
||||||
|
$token = PersonalAccessToken::findToken($request->bearerToken());
|
||||||
|
|
||||||
|
if ($token !== null
|
||||||
|
&& $token->tokenable instanceof Supervisor
|
||||||
|
&& $token->can('supervisor')
|
||||||
|
&& $token->tokenable->is_active
|
||||||
|
) {
|
||||||
|
Auth::guard('supervisor')->setUser($token->tokenable);
|
||||||
|
$request->setUserResolver(fn () => $token->tokenable);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Token superviseur invalide.',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Auth::guard('supervisor')->check()) {
|
||||||
|
$supervisor = Auth::guard('supervisor')->user();
|
||||||
|
|
||||||
|
if ($supervisor instanceof Supervisor && $supervisor->is_active) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Superviseur non authentifié.',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureApiUser
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Authentification utilisateur requise.',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Compte utilisateur désactivé.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\DailyEstablishmentStat;
|
||||||
|
use App\Models\Machine;
|
||||||
|
use App\Models\PricingRule;
|
||||||
|
use App\Models\Promotion;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use App\Models\Wash;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureSupervisorScope
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$supervisor = $this->resolveSupervisor($request);
|
||||||
|
|
||||||
|
if ($supervisor === null) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Superviseur non authentifié.',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $supervisor->is_active) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Compte superviseur désactivé.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('supervisor', $supervisor);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveSupervisor(Request $request): ?Supervisor
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return $user instanceof Supervisor ? $user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function machinesQuery(Supervisor $supervisor): Builder
|
||||||
|
{
|
||||||
|
$query = Machine::query()->with(['establishment', 'machineIntegration']);
|
||||||
|
|
||||||
|
return self::applyEstablishmentScope($query, $supervisor, 'establishment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bookingsQuery(Supervisor $supervisor): Builder
|
||||||
|
{
|
||||||
|
$query = Booking::query()
|
||||||
|
->with(['user', 'machine.establishment'])
|
||||||
|
->whereHas('machine.establishment', function (Builder $query) use ($supervisor) {
|
||||||
|
self::applyOrganizationFilter($query, $supervisor);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
$query->whereHas('machine', fn (Builder $q) => $q->where('establishment_id', $supervisor->establishment_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function washesQuery(Supervisor $supervisor): Builder
|
||||||
|
{
|
||||||
|
$query = Wash::query()
|
||||||
|
->with(['user', 'machine.establishment', 'booking'])
|
||||||
|
->whereHas('machine.establishment', function (Builder $query) use ($supervisor) {
|
||||||
|
self::applyOrganizationFilter($query, $supervisor);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
$query->whereHas('machine', fn (Builder $q) => $q->where('establishment_id', $supervisor->establishment_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pricingRulesQuery(Supervisor $supervisor): Builder
|
||||||
|
{
|
||||||
|
$query = PricingRule::query()->with(['establishment', 'machine']);
|
||||||
|
|
||||||
|
return self::applyEstablishmentScope($query, $supervisor, 'establishment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function promotionsQuery(Supervisor $supervisor): Builder
|
||||||
|
{
|
||||||
|
$query = Promotion::query()->with('establishment');
|
||||||
|
|
||||||
|
return self::applyEstablishmentScope($query, $supervisor, 'establishment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dailyStatsQuery(Supervisor $supervisor): Builder
|
||||||
|
{
|
||||||
|
$query = DailyEstablishmentStat::query()->with('establishment');
|
||||||
|
|
||||||
|
return self::applyEstablishmentScope($query, $supervisor, 'establishment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyEstablishmentScope(Builder $query, Supervisor $supervisor, string $column): Builder
|
||||||
|
{
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
return $query->where($column, $supervisor->establishment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->whereHas('establishment', function (Builder $query) use ($supervisor) {
|
||||||
|
self::applyOrganizationFilter($query, $supervisor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function applyOrganizationFilter(Builder $query, Supervisor $supervisor): void
|
||||||
|
{
|
||||||
|
$query->where('organization_id', $supervisor->organization_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Middleware;
|
||||||
|
|
||||||
|
class HandleInertiaRequests extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The root template that is loaded on the first page visit.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $rootView = 'app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the current asset version.
|
||||||
|
*/
|
||||||
|
public function version(Request $request): ?string
|
||||||
|
{
|
||||||
|
return parent::version($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the props that are shared by default.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function share(Request $request): array
|
||||||
|
{
|
||||||
|
$supervisor = $request->user('supervisor');
|
||||||
|
|
||||||
|
return [
|
||||||
|
...parent::share($request),
|
||||||
|
'auth' => [
|
||||||
|
'supervisor' => $supervisor ? [
|
||||||
|
'uuid' => $supervisor->uuid,
|
||||||
|
'name' => trim($supervisor->first_name.' '.$supervisor->last_name),
|
||||||
|
'email' => $supervisor->email,
|
||||||
|
'role' => $supervisor->role,
|
||||||
|
'organization_id' => $supervisor->organization_id,
|
||||||
|
'establishment_id' => $supervisor->establishment_id,
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
'flash' => [
|
||||||
|
'success' => fn () => $request->session()->get('success'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
'device_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class RefreshRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'refresh_token' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class RegisterRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'first_name' => ['required', 'string', 'max:100'],
|
||||||
|
'last_name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:30'],
|
||||||
|
'birthdate' => ['nullable', 'date', 'before:today'],
|
||||||
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
'locale' => ['nullable', 'string', 'max:10'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Booking;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class MoveBookingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'slot_start' => ['required', 'date', 'after:now'],
|
||||||
|
'slot_end' => ['required', 'date', 'after:slot_start'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Booking;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreBookingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'machine_uuid' => ['required', 'uuid', 'exists:machines,uuid'],
|
||||||
|
'slot_start' => ['required', 'date', 'after:now'],
|
||||||
|
'slot_end' => ['required', 'date', 'after:slot_start'],
|
||||||
|
'idempotency_key' => ['nullable', 'string', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Integration;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class AckCommandRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => ['required', Rule::in(['acknowledged', 'rejected', 'completed', 'failed'])],
|
||||||
|
'external_reference' => ['nullable', 'string', 'max:255'],
|
||||||
|
'data' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Integration;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class HeartbeatRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => ['required', 'string', 'max:100'],
|
||||||
|
'machine_external_id' => ['required', 'string', 'max:255'],
|
||||||
|
'occurred_at' => ['nullable', 'date'],
|
||||||
|
'data' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Integration;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class MachineEventRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => ['required', 'string', 'max:100'],
|
||||||
|
'event_type' => ['required', 'string', 'max:100'],
|
||||||
|
'occurred_at' => ['required', 'date'],
|
||||||
|
'machine_external_id' => ['required', 'string', 'max:255'],
|
||||||
|
'correlation_id' => ['nullable', 'string', 'max:255'],
|
||||||
|
'data' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
'device_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StorePricingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
|
||||||
|
'machine_id' => ['nullable', 'integer', 'exists:machines,id'],
|
||||||
|
'machine_type' => ['nullable', Rule::in(['washer_small', 'washer_large', 'dryer_small', 'dryer_large'])],
|
||||||
|
'day_type' => ['required', Rule::in(['weekday', 'weekend', 'holiday', 'all'])],
|
||||||
|
'slot_start' => ['required', 'date_format:H:i'],
|
||||||
|
'slot_end' => ['required', 'date_format:H:i', 'after:slot_start'],
|
||||||
|
'price' => ['required', 'numeric', 'min:0'],
|
||||||
|
'label' => ['nullable', 'string', 'max:100'],
|
||||||
|
'requires_app' => ['sometimes', 'boolean'],
|
||||||
|
'priority' => ['sometimes', 'integer', 'min:1', 'max:999'],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Supervisor;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdatePricingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'machine_id' => ['nullable', 'integer', 'exists:machines,id'],
|
||||||
|
'machine_type' => ['nullable', Rule::in(['washer_small', 'washer_large', 'dryer_small', 'dryer_large'])],
|
||||||
|
'day_type' => ['sometimes', Rule::in(['weekday', 'weekend', 'holiday', 'all'])],
|
||||||
|
'slot_start' => ['sometimes', 'date_format:H:i'],
|
||||||
|
'slot_end' => ['sometimes', 'date_format:H:i'],
|
||||||
|
'price' => ['sometimes', 'numeric', 'min:0'],
|
||||||
|
'label' => ['nullable', 'string', 'max:100'],
|
||||||
|
'requires_app' => ['sometimes', 'boolean'],
|
||||||
|
'priority' => ['sometimes', 'integer', 'min:1', 'max:999'],
|
||||||
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Wallet;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ConfirmTopUpRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'payment_uuid' => ['required', 'uuid'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Wallet;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class InitiateTopUpRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'amount' => ['required', 'numeric', 'min:5', 'max:150'],
|
||||||
|
'idempotency_key' => ['required', 'string', 'max:100'],
|
||||||
|
'return_url' => ['nullable', 'url', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1\Wash;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StartWashRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Events\Lockout;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to authenticate the request's credentials.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticate(): void
|
||||||
|
{
|
||||||
|
$this->ensureIsNotRateLimited();
|
||||||
|
|
||||||
|
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||||
|
RateLimiter::hit($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($this->throttleKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the login request is not rate limited.
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function ensureIsNotRateLimited(): void
|
||||||
|
{
|
||||||
|
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event(new Lockout($this));
|
||||||
|
|
||||||
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.throttle', [
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'minutes' => ceil($seconds / 60),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rate limiting throttle key for the request.
|
||||||
|
*/
|
||||||
|
public function throttleKey(): string
|
||||||
|
{
|
||||||
|
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ProfileUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'lowercase',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(User::class)->ignore($this->user()->id),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Supervisor;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Events\Lockout;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticate(): void
|
||||||
|
{
|
||||||
|
$this->ensureIsNotRateLimited();
|
||||||
|
|
||||||
|
if (! Auth::guard('supervisor')->attempt(
|
||||||
|
$this->only('email', 'password'),
|
||||||
|
$this->boolean('remember')
|
||||||
|
)) {
|
||||||
|
RateLimiter::hit($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$supervisor = Auth::guard('supervisor')->user();
|
||||||
|
|
||||||
|
if ($supervisor !== null && ! $supervisor->is_active) {
|
||||||
|
Auth::guard('supervisor')->logout();
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => 'Ce compte superviseur est désactivé.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($this->throttleKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function ensureIsNotRateLimited(): void
|
||||||
|
{
|
||||||
|
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event(new Lockout($this));
|
||||||
|
|
||||||
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.throttle', [
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'minutes' => ceil($seconds / 60),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function throttleKey(): string
|
||||||
|
{
|
||||||
|
return Str::transliterate(Str::lower($this->string('email')).'|supervisor|'.$this->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class BookingResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'slot_start' => $this->slot_start?->toIso8601String(),
|
||||||
|
'slot_end' => $this->slot_end?->toIso8601String(),
|
||||||
|
'booking_fee' => (float) $this->booking_fee,
|
||||||
|
'reserved_amount' => (float) $this->reserved_amount,
|
||||||
|
'penalty_amount' => (float) $this->penalty_amount,
|
||||||
|
'status' => $this->status,
|
||||||
|
'cancelled_at' => $this->cancelled_at?->toIso8601String(),
|
||||||
|
'penalty_applied_at' => $this->penalty_applied_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'machine' => new MachineResource($this->whenLoaded('machine')),
|
||||||
|
'user' => new UserResource($this->whenLoaded('user')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class EstablishmentResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'name' => $this->name,
|
||||||
|
'address' => $this->address,
|
||||||
|
'city' => $this->city,
|
||||||
|
'zip_code' => $this->zip_code,
|
||||||
|
'latitude' => $this->latitude !== null ? (float) $this->latitude : null,
|
||||||
|
'longitude' => $this->longitude !== null ? (float) $this->longitude : null,
|
||||||
|
'timezone' => $this->timezone,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'machines' => MachineResource::collection($this->whenLoaded('machines')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class MachineResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'name' => $this->name,
|
||||||
|
'type' => $this->type,
|
||||||
|
'status' => $this->status,
|
||||||
|
'qr_code' => $this->qr_code,
|
||||||
|
'cycle_started_at' => $this->cycle_started_at?->toIso8601String(),
|
||||||
|
'cycle_ends_at' => $this->cycle_ends_at?->toIso8601String(),
|
||||||
|
'last_heartbeat_at' => $this->last_heartbeat_at?->toIso8601String(),
|
||||||
|
'establishment' => new EstablishmentResource($this->whenLoaded('establishment')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PaymentTransactionResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'provider_payment_id' => $this->provider_payment_id,
|
||||||
|
'amount' => (float) $this->amount,
|
||||||
|
'currency' => $this->currency,
|
||||||
|
'status' => $this->status,
|
||||||
|
'return_url' => $this->return_url,
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->provider === 'stripe' && is_array($this->raw_payload)) {
|
||||||
|
$clientSecret = $this->raw_payload['client_secret'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($clientSecret) && $clientSecret !== '') {
|
||||||
|
$data['stripe'] = [
|
||||||
|
'payment_intent_id' => $this->provider_payment_id,
|
||||||
|
'client_secret' => $clientSecret,
|
||||||
|
'publishable_key' => config('services.stripe.key'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PricingRuleResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'establishment_id' => $this->establishment_id,
|
||||||
|
'machine_id' => $this->machine_id,
|
||||||
|
'machine_type' => $this->machine_type,
|
||||||
|
'day_type' => $this->day_type,
|
||||||
|
'slot_start' => $this->slot_start,
|
||||||
|
'slot_end' => $this->slot_end,
|
||||||
|
'price' => (float) $this->price,
|
||||||
|
'label' => $this->label,
|
||||||
|
'requires_app' => $this->requires_app,
|
||||||
|
'priority' => $this->priority,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'establishment' => new EstablishmentResource($this->whenLoaded('establishment')),
|
||||||
|
'machine' => new MachineResource($this->whenLoaded('machine')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PromotionResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'establishment_id' => $this->establishment_id,
|
||||||
|
'machine_type' => $this->machine_type,
|
||||||
|
'discount_type' => $this->discount_type,
|
||||||
|
'discount_value' => (float) $this->discount_value,
|
||||||
|
'starts_at' => $this->starts_at?->toIso8601String(),
|
||||||
|
'ends_at' => $this->ends_at?->toIso8601String(),
|
||||||
|
'description' => $this->description,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'establishment' => new EstablishmentResource($this->whenLoaded('establishment')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class SupervisorResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'first_name' => $this->first_name,
|
||||||
|
'last_name' => $this->last_name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'role' => $this->role,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'organization_id' => $this->organization_id,
|
||||||
|
'establishment_id' => $this->establishment_id,
|
||||||
|
'last_login_at' => $this->last_login_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class UserResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'first_name' => $this->first_name,
|
||||||
|
'last_name' => $this->last_name,
|
||||||
|
'full_name' => $this->full_name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'phone' => $this->phone,
|
||||||
|
'birthdate' => $this->birthdate?->toDateString(),
|
||||||
|
'locale' => $this->locale,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class WalletResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'currency' => $this->currency,
|
||||||
|
'current_balance' => (float) $this->current_balance,
|
||||||
|
'status' => $this->status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class WalletTransactionResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'type' => $this->type,
|
||||||
|
'amount' => (float) $this->amount,
|
||||||
|
'balance_before' => (float) $this->balance_before,
|
||||||
|
'balance_after' => (float) $this->balance_after,
|
||||||
|
'source_type' => $this->source_type,
|
||||||
|
'source_id' => $this->source_id,
|
||||||
|
'metadata' => $this->metadata ?? [],
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Support\WashCycleProgress;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class WashResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $this->uuid,
|
||||||
|
'trigger_method' => $this->trigger_method,
|
||||||
|
'status' => $this->status,
|
||||||
|
'program' => $this->program,
|
||||||
|
'started_at' => $this->started_at?->toIso8601String(),
|
||||||
|
'ended_at' => $this->ended_at?->toIso8601String(),
|
||||||
|
'duration_minutes' => $this->duration_minutes,
|
||||||
|
'cost' => (float) $this->cost,
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'machine' => new MachineResource($this->whenLoaded('machine')),
|
||||||
|
'booking' => new BookingResource($this->whenLoaded('booking')),
|
||||||
|
'user' => new UserResource($this->whenLoaded('user')),
|
||||||
|
'progress' => WashCycleProgress::forWash($this->resource),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\BookingService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class CheckNoShowBookings implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function handle(BookingService $bookingService): void
|
||||||
|
{
|
||||||
|
$bookingService->checkNoShows();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Booking;
|
||||||
|
use App\Models\DailyEstablishmentStat;
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Models\PaymentTransaction;
|
||||||
|
use App\Models\Wash;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class GenerateDailyStats implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public ?string $statDate = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$date = $this->statDate !== null
|
||||||
|
? Carbon::parse($this->statDate)->startOfDay()
|
||||||
|
: now()->subDay()->startOfDay();
|
||||||
|
|
||||||
|
$dayStart = $date->copy();
|
||||||
|
$dayEnd = $date->copy()->endOfDay();
|
||||||
|
|
||||||
|
Establishment::query()->where('is_active', true)->each(function (Establishment $establishment) use ($dayStart, $dayEnd, $date) {
|
||||||
|
DB::transaction(function () use ($establishment, $dayStart, $dayEnd, $date) {
|
||||||
|
$machineIds = $establishment->machines()->pluck('id');
|
||||||
|
|
||||||
|
if ($machineIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalWashes = Wash::query()
|
||||||
|
->whereIn('machine_id', $machineIds)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->whereBetween('ended_at', [$dayStart, $dayEnd])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$totalRevenue = (float) Wash::query()
|
||||||
|
->whereIn('machine_id', $machineIds)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->whereBetween('ended_at', [$dayStart, $dayEnd])
|
||||||
|
->sum('cost');
|
||||||
|
|
||||||
|
$bookingsCount = Booking::query()
|
||||||
|
->whereIn('machine_id', $machineIds)
|
||||||
|
->whereBetween('created_at', [$dayStart, $dayEnd])
|
||||||
|
->whereNotIn('status', ['cancelled'])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$noShowCount = Booking::query()
|
||||||
|
->whereIn('machine_id', $machineIds)
|
||||||
|
->where('status', 'no_show')
|
||||||
|
->whereBetween('slot_start', [$dayStart, $dayEnd])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$userIds = Wash::query()
|
||||||
|
->whereIn('machine_id', $machineIds)
|
||||||
|
->whereBetween('started_at', [$dayStart, $dayEnd])
|
||||||
|
->pluck('user_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
$topUpAmount = $userIds->isEmpty()
|
||||||
|
? 0.0
|
||||||
|
: (float) PaymentTransaction::query()
|
||||||
|
->whereIn('user_id', $userIds)
|
||||||
|
->where('status', 'succeeded')
|
||||||
|
->whereBetween('updated_at', [$dayStart, $dayEnd])
|
||||||
|
->sum('amount');
|
||||||
|
|
||||||
|
$machineCount = max(1, $machineIds->count());
|
||||||
|
$minutesInDay = 24 * 60;
|
||||||
|
$runningMinutes = Wash::query()
|
||||||
|
->whereIn('machine_id', $machineIds)
|
||||||
|
->where('status', 'completed')
|
||||||
|
->whereBetween('ended_at', [$dayStart, $dayEnd])
|
||||||
|
->get(['duration_minutes'])
|
||||||
|
->sum(fn (Wash $wash) => $wash->duration_minutes ?? 0);
|
||||||
|
|
||||||
|
$occupancyRate = round(min(100, ($runningMinutes / ($machineCount * $minutesInDay)) * 100), 2);
|
||||||
|
|
||||||
|
DailyEstablishmentStat::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'establishment_id' => $establishment->id,
|
||||||
|
'stat_date' => $date->toDateString(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'total_washes' => $totalWashes,
|
||||||
|
'total_revenue' => $totalRevenue,
|
||||||
|
'bookings_count' => $bookingsCount,
|
||||||
|
'no_show_count' => $noShowCount,
|
||||||
|
'top_up_amount' => $topUpAmount,
|
||||||
|
'occupancy_rate' => $occupancyRate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Machine;
|
||||||
|
use App\Models\MachineEvent;
|
||||||
|
use App\Models\MachineStatusHistory;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class RefreshOfflineMachines implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$thresholdMinutes = (int) config('laverie.machine.offline_threshold_minutes', 10);
|
||||||
|
$cutoff = now()->subMinutes($thresholdMinutes);
|
||||||
|
|
||||||
|
$machines = Machine::query()
|
||||||
|
->whereNotIn('status', ['offline', 'maintenance'])
|
||||||
|
->where(function ($query) use ($cutoff) {
|
||||||
|
$query->whereNull('last_heartbeat_at')
|
||||||
|
->orWhere('last_heartbeat_at', '<', $cutoff);
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($machines as $machine) {
|
||||||
|
DB::transaction(function () use ($machine) {
|
||||||
|
$locked = Machine::query()->whereKey($machine->id)->lockForUpdate()->firstOrFail();
|
||||||
|
|
||||||
|
if (in_array($locked->status, ['offline', 'maintenance', 'running'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$thresholdMinutes = (int) config('laverie.machine.offline_threshold_minutes', 10);
|
||||||
|
$cutoff = now()->subMinutes($thresholdMinutes);
|
||||||
|
|
||||||
|
if ($locked->last_heartbeat_at !== null && $locked->last_heartbeat_at->gte($cutoff)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousStatus = $locked->status;
|
||||||
|
$provider = config('laverie.simulation.provider_name', 'simulated');
|
||||||
|
|
||||||
|
$locked->update(['status' => 'offline']);
|
||||||
|
|
||||||
|
MachineStatusHistory::query()->create([
|
||||||
|
'machine_id' => $locked->id,
|
||||||
|
'previous_status' => $previousStatus,
|
||||||
|
'new_status' => 'offline',
|
||||||
|
'source' => 'RefreshOfflineMachines',
|
||||||
|
'reason' => 'heartbeat_timeout',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
MachineEvent::query()->create([
|
||||||
|
'machine_id' => $locked->id,
|
||||||
|
'provider' => $provider,
|
||||||
|
'event_type' => 'machine_offline',
|
||||||
|
'payload' => ['reason' => 'heartbeat_timeout'],
|
||||||
|
'occurred_at' => now(),
|
||||||
|
'received_at' => now(),
|
||||||
|
'processed_at' => now(),
|
||||||
|
'processing_status' => 'processed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Machine;
|
||||||
|
use App\Models\MachineEvent;
|
||||||
|
use App\Models\MachineStatusHistory;
|
||||||
|
use App\Models\Wash;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class SimulateCycleCompletion implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $machineId,
|
||||||
|
public int $machineCommandId,
|
||||||
|
public ?int $washId = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () {
|
||||||
|
$machine = Machine::query()->whereKey($this->machineId)->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if ($machine === null || $machine->status !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = config('laverie.simulation.provider_name', 'simulated');
|
||||||
|
$previousStatus = $machine->status;
|
||||||
|
|
||||||
|
$machine->update([
|
||||||
|
'status' => 'available',
|
||||||
|
'current_user_id' => null,
|
||||||
|
'cycle_started_at' => null,
|
||||||
|
'cycle_ends_at' => null,
|
||||||
|
'last_heartbeat_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
MachineStatusHistory::query()->create([
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'previous_status' => $previousStatus,
|
||||||
|
'new_status' => 'available',
|
||||||
|
'source' => $provider,
|
||||||
|
'reason' => 'simulated_cycle_completion',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
MachineEvent::query()->create([
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'provider' => $provider,
|
||||||
|
'event_type' => 'cycle_completed',
|
||||||
|
'payload' => [
|
||||||
|
'machine_command_id' => $this->machineCommandId,
|
||||||
|
'wash_id' => $this->washId,
|
||||||
|
'simulated' => true,
|
||||||
|
],
|
||||||
|
'occurred_at' => now(),
|
||||||
|
'received_at' => now(),
|
||||||
|
'processed_at' => now(),
|
||||||
|
'processing_status' => 'processed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->washId !== null) {
|
||||||
|
$wash = Wash::query()->whereKey($this->washId)->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if ($wash !== null && $wash->status === 'running') {
|
||||||
|
$startedAt = $wash->started_at ?? now();
|
||||||
|
$endedAt = now();
|
||||||
|
|
||||||
|
$wash->update([
|
||||||
|
'status' => 'completed',
|
||||||
|
'ended_at' => $endedAt,
|
||||||
|
'duration_minutes' => max(1, (int) $startedAt->diffInMinutes($endedAt)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($wash->booking_id !== null) {
|
||||||
|
\App\Models\Booking::query()
|
||||||
|
->whereKey($wash->booking_id)
|
||||||
|
->update(['status' => 'completed']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
#[Fillable(['actor_type', 'actor_id', 'organization_id', 'establishment_id', 'action', 'target_type', 'target_id', 'before_data', 'after_data', 'ip_address'])]
|
||||||
|
class AuditLog extends Model
|
||||||
|
{
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'before_data' => 'array',
|
||||||
|
'after_data' => 'array',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function actor(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function establishment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Establishment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function target(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasUuid;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['user_id', 'machine_id', 'slot_start', 'slot_end', 'booking_fee', 'reserved_amount', 'penalty_amount', 'status', 'cancelled_at', 'penalty_applied_at'])]
|
||||||
|
class Booking extends Model
|
||||||
|
{
|
||||||
|
use HasUuid;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'slot_start' => 'datetime',
|
||||||
|
'slot_end' => 'datetime',
|
||||||
|
'booking_fee' => 'decimal:2',
|
||||||
|
'reserved_amount' => 'decimal:2',
|
||||||
|
'penalty_amount' => 'decimal:2',
|
||||||
|
'status' => 'string',
|
||||||
|
'cancelled_at' => 'datetime',
|
||||||
|
'penalty_applied_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function machine(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Machine::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function washes(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Wash::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCancellable(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->status, ['pending', 'confirmed'], true)
|
||||||
|
&& $this->slot_start->isFuture();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
trait BelongsToOrganization
|
||||||
|
{
|
||||||
|
public function scopeForOrganization(Builder $query, Organization|int $organization): Builder
|
||||||
|
{
|
||||||
|
$organizationId = $organization instanceof Organization ? $organization->id : $organization;
|
||||||
|
|
||||||
|
return $query->whereHas('establishment', function (Builder $query) use ($organizationId) {
|
||||||
|
$query->where('organization_id', $organizationId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
trait HasUuid
|
||||||
|
{
|
||||||
|
protected static function bootHasUuid(): void
|
||||||
|
{
|
||||||
|
static::creating(function ($model) {
|
||||||
|
if (empty($model->uuid)) {
|
||||||
|
$model->uuid = (string) Str::uuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\BelongsToOrganization;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['establishment_id', 'stat_date', 'total_washes', 'total_revenue', 'bookings_count', 'no_show_count', 'top_up_amount', 'occupancy_rate'])]
|
||||||
|
class DailyEstablishmentStat extends Model
|
||||||
|
{
|
||||||
|
use BelongsToOrganization;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stat_date' => 'date',
|
||||||
|
'total_washes' => 'integer',
|
||||||
|
'total_revenue' => 'decimal:2',
|
||||||
|
'bookings_count' => 'integer',
|
||||||
|
'no_show_count' => 'integer',
|
||||||
|
'top_up_amount' => 'decimal:2',
|
||||||
|
'occupancy_rate' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function establishment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Establishment::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasUuid;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[Fillable(['organization_id', 'name', 'address', 'city', 'zip_code', 'latitude', 'longitude', 'timezone', 'is_active'])]
|
||||||
|
class Establishment extends Model
|
||||||
|
{
|
||||||
|
use HasUuid;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'latitude' => 'decimal:8',
|
||||||
|
'longitude' => 'decimal:8',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function organization(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Organization::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function machines(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Machine::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pricingRules(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PricingRule::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['user_id', 'type', 'accepted', 'policy_version', 'policy_text_hash', 'source', 'consent_language', 'ip_address', 'user_agent', 'accepted_at', 'revoked_at'])]
|
||||||
|
class GdprConsent extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'string',
|
||||||
|
'accepted' => 'boolean',
|
||||||
|
'source' => 'string',
|
||||||
|
'accepted_at' => 'datetime',
|
||||||
|
'revoked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user