Compare commits
1 Commits
7b78037175
...
v3.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 232940b1eb |
153
HOWTO-PROKOV.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Prokov - Gestion des tâches
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Prokov est l'outil de gestion de projets et tâches utilisé pour suivre l'avancement de tous les projets 2026.
|
||||||
|
|
||||||
|
**URL** : https://prokov.unikoffice.com
|
||||||
|
**API** : https://prokov.unikoffice.com/api/
|
||||||
|
|
||||||
|
## Compte Claude
|
||||||
|
|
||||||
|
Claude Code peut interagir directement avec l'API Prokov.
|
||||||
|
|
||||||
|
| Paramètre | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Email | pierre@d6mail.fr |
|
||||||
|
| Password | d66,Pierre |
|
||||||
|
| Entity | 1 |
|
||||||
|
| Role | owner |
|
||||||
|
|
||||||
|
## Projets
|
||||||
|
|
||||||
|
| ID | Projet | Parent | Description |
|
||||||
|
|----|--------|--------|-------------|
|
||||||
|
| 1 | Prokov | - | Gestionnaire de tâches |
|
||||||
|
| 2 | Sogoms | - | API auto-générée Go |
|
||||||
|
| 4 | Geosector | - | Application Amicales Pompiers |
|
||||||
|
| 14 | Geosector-App | 4 | App Flutter |
|
||||||
|
| 15 | Geosector-API | 4 | API backend |
|
||||||
|
| 16 | Geosector-Web | 4 | Site web |
|
||||||
|
| 5 | Cleo | - | - |
|
||||||
|
| 6 | Serveurs | - | Infra |
|
||||||
|
| 8 | UnikOffice | - | - |
|
||||||
|
| 21 | 2026 | - | Plateforme micro-services |
|
||||||
|
| 22 | 2026-Go | 21 | Modules Go (Thierry) |
|
||||||
|
| 23 | 2026-Flutter | 21 | App Flutter (Pierre) |
|
||||||
|
| 24 | 2026-Infra | 21 | Infrastructure (commun) |
|
||||||
|
|
||||||
|
## Statuts
|
||||||
|
|
||||||
|
| ID | Nom | Actif |
|
||||||
|
|----|-----|-------|
|
||||||
|
| 1 | Backlog | Oui |
|
||||||
|
| 2 | À faire | Oui |
|
||||||
|
| 3 | En cours | Oui |
|
||||||
|
| 4 | À tester | Oui |
|
||||||
|
| 5 | Livré | Oui |
|
||||||
|
| 6 | Terminé | Non |
|
||||||
|
| 7 | Archivé | Non |
|
||||||
|
|
||||||
|
## Utilisation avec Claude Code
|
||||||
|
|
||||||
|
### Lire les tâches d'un projet
|
||||||
|
|
||||||
|
> "Montre-moi les tâches du projet 2026"
|
||||||
|
|
||||||
|
Claude va récupérer les tâches via l'API.
|
||||||
|
|
||||||
|
### Créer une tâche
|
||||||
|
|
||||||
|
> "Crée une tâche 'Implémenter mod-cpu' dans 2026-Go avec priorité 3"
|
||||||
|
|
||||||
|
### Mettre à jour un statut
|
||||||
|
|
||||||
|
> "Passe la tâche #170 en statut 'En cours'"
|
||||||
|
|
||||||
|
### Marquer comme terminé
|
||||||
|
|
||||||
|
> "Marque la tâche #170 comme terminée"
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login (récupère le token JWT)
|
||||||
|
curl -s -X POST "https://prokov.unikoffice.com/api/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"pierre@d6mail.fr","password":"d66,Pierre"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Projets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Liste des projets
|
||||||
|
curl -s "https://prokov.unikoffice.com/api/projects" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Créer un projet
|
||||||
|
curl -s -X POST "https://prokov.unikoffice.com/api/projects" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Mon Projet","description":"...","color":"#2563eb"}'
|
||||||
|
|
||||||
|
# Créer un sous-projet
|
||||||
|
curl -s -X POST "https://prokov.unikoffice.com/api/projects" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Sous-Projet","parent_id":21}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tâches
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tâches d'un projet
|
||||||
|
curl -s "https://prokov.unikoffice.com/api/tasks?project_id=21" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# Créer une tâche
|
||||||
|
curl -s -X POST "https://prokov.unikoffice.com/api/tasks" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"Ma tâche","project_id":22,"status_id":2,"priority":3}'
|
||||||
|
|
||||||
|
# Mettre à jour une tâche
|
||||||
|
curl -s -X PUT "https://prokov.unikoffice.com/api/tasks/170" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status_id":3}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statuts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Liste des statuts
|
||||||
|
curl -s "https://prokov.unikoffice.com/api/statuses" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Git (à implémenter)
|
||||||
|
|
||||||
|
Le hook post-commit pourra détecter les `#ID` dans les messages de commit et mettre automatiquement les tâches en "À tester".
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: nouvelle fonctionnalité #170 #171"
|
||||||
|
# → Tâches 170 et 171 passent en statut 4 (À tester)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure projets 2026
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/pierre/dev/2026/
|
||||||
|
├── prokov/ # ID 1 - Gestionnaire tâches
|
||||||
|
├── sogoms/ # ID 2 - API Go
|
||||||
|
├── geosector/ # ID 4 - App géospatiale
|
||||||
|
│ ├── app/ # ID 14
|
||||||
|
│ ├── api/ # ID 15
|
||||||
|
│ └── web/ # ID 16
|
||||||
|
├── resalice/ # Migration vers Sogoms
|
||||||
|
├── monipocket/ # À intégrer dans 2026
|
||||||
|
├── unikoffice/ # ID 8
|
||||||
|
└── cleo/ # ID 5
|
||||||
|
```
|
||||||
@@ -1225,6 +1225,86 @@ php scripts/php/migrate_from_backup.php \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### 7. Statistiques Events pour Admin Flutter
|
||||||
|
|
||||||
|
**Demandé le :** 22/12/2025
|
||||||
|
**Objectif :** Permettre aux admins Flutter de consulter les logs Events avec des stats quotidiennes, hebdomadaires et mensuelles, et drill-down vers le détail.
|
||||||
|
|
||||||
|
**Architecture choisie :** Stats pré-agrégées en SQL + détail JSONL à la demande
|
||||||
|
|
||||||
|
**Pourquoi cette approche :**
|
||||||
|
|
||||||
|
- Évite de parser les fichiers JSONL à chaque requête Flutter
|
||||||
|
- Transfert minimal (~1-10 KB par requête)
|
||||||
|
- Calculs hebdo/mensuel à la volée depuis `daily` (pas de tables supplémentaires)
|
||||||
|
- Détail paginé uniquement sur demande
|
||||||
|
|
||||||
|
**Phase 1 : Base de données** ✅ (22/12/2025)
|
||||||
|
|
||||||
|
- [x] Créer la table `event_stats_daily`
|
||||||
|
- Colonnes : `stat_date`, `entity_id`, `event_type`, `count`, `sum_amount`, `unique_users`, `metadata`
|
||||||
|
- Index : `(entity_id, stat_date)`, unique `(stat_date, entity_id, event_type)`
|
||||||
|
- [x] Script SQL de création : `scripts/sql/create_event_stats_daily.sql`
|
||||||
|
|
||||||
|
**Phase 2 : CRON d'agrégation** ✅ (22/12/2025)
|
||||||
|
|
||||||
|
- [x] Créer `scripts/cron/aggregate_event_stats.php`
|
||||||
|
- Parse le fichier JSONL de J-1 (ou date passée en paramètre)
|
||||||
|
- Agrège par entity_id et event_type
|
||||||
|
- INSERT/UPDATE dans `event_stats_daily`
|
||||||
|
- Calcule `unique_users` (COUNT DISTINCT sur user_id)
|
||||||
|
- Calcule `sum_amount` pour les passages
|
||||||
|
- Stocke metadata JSON (top 5 secteurs, erreurs fréquentes, etc.)
|
||||||
|
- [x] Ajouter au crontab : exécution à 01h00 chaque nuit (via deploy-api.sh)
|
||||||
|
- [x] Script de rattrapage : `php aggregate_event_stats.php --from=2025-01-01 --to=2025-12-21`
|
||||||
|
|
||||||
|
**Phase 3 : Service EventStatsService** ✅ (22/12/2025)
|
||||||
|
|
||||||
|
- [x] Créer `src/Services/EventStatsService.php`
|
||||||
|
- `getSummary(?int $entityId, ?string $date)` : Stats du jour
|
||||||
|
- `getDaily(?int $entityId, string $from, string $to, array $eventTypes)` : Stats journalières
|
||||||
|
- `getWeekly(?int $entityId, string $from, string $to, array $eventTypes)` : Calculé depuis daily
|
||||||
|
- `getMonthly(?int $entityId, int $year, array $eventTypes)` : Calculé depuis daily
|
||||||
|
- `getDetails(?int $entityId, string $date, ?string $eventType, int $limit, int $offset)` : Lecture JSONL paginée
|
||||||
|
- `getEventTypes()` : Liste des types d'événements disponibles
|
||||||
|
- `hasStatsForDate(string $date)` : Vérifie si stats existent
|
||||||
|
|
||||||
|
**Phase 4 : Controller et Routes** ✅ (22/12/2025)
|
||||||
|
|
||||||
|
- [x] Créer `src/Controllers/EventStatsController.php`
|
||||||
|
- `summary()` : GET /api/events/stats/summary?date=
|
||||||
|
- `daily()` : GET /api/events/stats/daily?from=&to=&events=
|
||||||
|
- `weekly()` : GET /api/events/stats/weekly?from=&to=&events=
|
||||||
|
- `monthly()` : GET /api/events/stats/monthly?year=&events=
|
||||||
|
- `details()` : GET /api/events/stats/details?date=&event=&limit=&offset=
|
||||||
|
- `types()` : GET /api/events/stats/types
|
||||||
|
- [x] Ajouter les routes dans `Router.php`
|
||||||
|
- [x] Vérification des droits : Admin entité (role_id = 2) ou Super-admin (role_id = 1)
|
||||||
|
- [x] Super-admin : peut voir toutes les entités (entity_id = NULL ou ?entity_id=X)
|
||||||
|
|
||||||
|
**Phase 5 : Optimisations** ✅ (22/12/2025)
|
||||||
|
|
||||||
|
- [x] Compression gzip sur les réponses JSON (si >1KB et client supporte)
|
||||||
|
- [x] Header `ETag` sur /summary et /daily (cache 5 min, 304 Not Modified)
|
||||||
|
- [x] Filtrage des champs sensibles dans /details (IP tronquée, user_agent supprimé)
|
||||||
|
- [x] Limite max 100 events par requête /details
|
||||||
|
|
||||||
|
**Phase 6 : Tests et documentation**
|
||||||
|
|
||||||
|
- [ ] Tests unitaires EventStatsService
|
||||||
|
- [ ] Tests endpoints avec différents rôles
|
||||||
|
- [ ] Documentation Postman/Swagger des endpoints
|
||||||
|
- [ ] Mise à jour TECHBOOK.md avec exemples de réponses JSON
|
||||||
|
|
||||||
|
**Estimation :** 2-3 jours de développement
|
||||||
|
|
||||||
|
**Dépendances :**
|
||||||
|
|
||||||
|
- EventLogService déjà en place ✅
|
||||||
|
- Fichiers JSONL générés quotidiennement ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 🟢 PRIORITÉ BASSE
|
### 🟢 PRIORITÉ BASSE
|
||||||
|
|
||||||
#### 7. Amélioration de la suppression des utilisateurs
|
#### 7. Amélioration de la suppression des utilisateurs
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ FINAL_OWNER_LOGS="nobody"
|
|||||||
FINAL_GROUP_LOGS="nginx"
|
FINAL_GROUP_LOGS="nginx"
|
||||||
|
|
||||||
# Configuration de sauvegarde
|
# Configuration de sauvegarde
|
||||||
BACKUP_DIR="/data/backup/geosector/api"
|
BACKUP_DIR="/home/pierre/samba/back/geosector/api"
|
||||||
|
|
||||||
# Couleurs pour les messages
|
# Couleurs pour les messages
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -288,11 +288,11 @@ if [ "$DEST_HOST" != "local" ]; then
|
|||||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type d -exec chmod 755 {} \; &&
|
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type d -exec chmod 755 {} \; &&
|
||||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type f -exec chmod 644 {} \; &&
|
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type f -exec chmod 644 {} \; &&
|
||||||
|
|
||||||
# Permissions spéciales pour logs
|
# Permissions spéciales pour logs (PHP-FPM tourne sous nobody)
|
||||||
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/logs/events &&
|
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/logs/events &&
|
||||||
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER_LOGS}:${FINAL_GROUP} ${API_PATH}/logs &&
|
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER_LOGS}:${FINAL_GROUP} ${API_PATH}/logs &&
|
||||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type d -exec chmod 750 {} \; &&
|
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type d -exec chmod 775 {} \; &&
|
||||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type f -exec chmod 640 {} \; &&
|
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type f -exec chmod 664 {} \; &&
|
||||||
|
|
||||||
# Permissions spéciales pour uploads
|
# Permissions spéciales pour uploads
|
||||||
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/uploads &&
|
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/uploads &&
|
||||||
@@ -342,8 +342,8 @@ if [ "$DEST_HOST" != "local" ]; then
|
|||||||
# GEOSECTOR API - Security data cleanup (daily at 2am)
|
# GEOSECTOR API - Security data cleanup (daily at 2am)
|
||||||
0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/www/geosector/api/logs/cleanup_security.log 2>&1
|
0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/www/geosector/api/logs/cleanup_security.log 2>&1
|
||||||
|
|
||||||
# GEOSECTOR API - Stripe devices update (weekly Sunday at 3am)
|
# GEOSECTOR API - Event stats aggregation (daily at 1am)
|
||||||
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1
|
0 1 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/aggregate_event_stats.php >> /var/www/geosector/api/logs/aggregate_stats.log 2>&1
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Installer le nouveau crontab
|
# Installer le nouveau crontab
|
||||||
|
|||||||
@@ -89,7 +89,75 @@ PUT /api/users/123 // users.id
|
|||||||
1. **Reçus fiscaux** : PDF auto (<5KB) pour dons, envoi email queue
|
1. **Reçus fiscaux** : PDF auto (<5KB) pour dons, envoi email queue
|
||||||
2. **Logos entités** : Upload PNG/JPG, redimensionnement 250x250px, base64
|
2. **Logos entités** : Upload PNG/JPG, redimensionnement 250x250px, base64
|
||||||
3. **Migration** : Endpoints REST par entité (9 phases)
|
3. **Migration** : Endpoints REST par entité (9 phases)
|
||||||
4. **CRONs** : Email queue (*/5), cleanup sécurité (2h), Stripe devices (dim 3h)
|
4. **CRONs** : Email queue (*/5), cleanup sécurité (2h)
|
||||||
|
|
||||||
|
## 📊 Statistiques Events (Admin Flutter)
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
**Principe** : Stats pré-agrégées en SQL + détail JSONL à la demande
|
||||||
|
|
||||||
|
| Source | Usage | Performance |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| Table `event_stats_daily` | Dashboard, graphiques, tendances | Instantané (~1ms) |
|
||||||
|
| Fichiers JSONL | Détail événements (clic sur stat) | Paginé (~50-100ms) |
|
||||||
|
|
||||||
|
### Flux de données
|
||||||
|
|
||||||
|
1. **EventLogService** écrit les événements dans `/logs/events/YYYY-MM-DD.jsonl`
|
||||||
|
2. **CRON nightly** agrège J-1 dans `event_stats_daily`
|
||||||
|
3. **API** sert les stats agrégées (SQL) ou le détail paginé (JSONL)
|
||||||
|
4. **Flutter Admin** affiche dashboard avec drill-down
|
||||||
|
|
||||||
|
### Table d'agrégation
|
||||||
|
|
||||||
|
**`event_stats_daily`** : Une ligne par (date, entité, type d'événement)
|
||||||
|
|
||||||
|
| Colonne | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `stat_date` | Date des stats |
|
||||||
|
| `entity_id` | Entité (NULL = global super-admin) |
|
||||||
|
| `event_type` | Type événement (login_success, passage_created, etc.) |
|
||||||
|
| `count` | Nombre d'occurrences |
|
||||||
|
| `sum_amount` | Somme montants (passages) |
|
||||||
|
| `unique_users` | Utilisateurs distincts |
|
||||||
|
| `metadata` | JSON agrégé (top secteurs, erreurs fréquentes, etc.) |
|
||||||
|
|
||||||
|
### Endpoints API
|
||||||
|
|
||||||
|
| Endpoint | Période | Source | Taille réponse |
|
||||||
|
|----------|---------|--------|----------------|
|
||||||
|
| `GET /events/stats/summary` | Jour courant | SQL | ~1 KB |
|
||||||
|
| `GET /events/stats/daily` | Plage dates | SQL | ~5 KB |
|
||||||
|
| `GET /events/stats/weekly` | Calculé depuis daily | SQL | ~2 KB |
|
||||||
|
| `GET /events/stats/monthly` | Calculé depuis daily | SQL | ~1 KB |
|
||||||
|
| `GET /events/details` | Détail paginé | JSONL | ~10 KB |
|
||||||
|
|
||||||
|
### Optimisations transfert Flutter
|
||||||
|
|
||||||
|
- **Pagination** : 50 events max par requête détail
|
||||||
|
- **Champs filtrés** : Pas d'IP ni user_agent complet dans les réponses
|
||||||
|
- **Compression gzip** : -70% sur JSON
|
||||||
|
- **Cache HTTP** : ETag sur stats (changent 1x/jour)
|
||||||
|
- **Calcul hebdo/mensuel** : À la volée depuis `daily` (pas de tables supplémentaires)
|
||||||
|
|
||||||
|
### Types d'événements agrégés
|
||||||
|
|
||||||
|
| Catégorie | Events |
|
||||||
|
|-----------|--------|
|
||||||
|
| **Auth** | login_success, login_failed, logout |
|
||||||
|
| **Passages** | passage_created, passage_updated, passage_deleted |
|
||||||
|
| **Secteurs** | sector_created, sector_updated, sector_deleted |
|
||||||
|
| **Users** | user_created, user_updated, user_deleted |
|
||||||
|
| **Entités** | entity_created, entity_updated, entity_deleted |
|
||||||
|
| **Opérations** | operation_created, operation_updated, operation_deleted |
|
||||||
|
| **Stripe** | stripe_payment_created, stripe_payment_success, stripe_payment_failed, stripe_payment_cancelled, stripe_terminal_error |
|
||||||
|
|
||||||
|
### Accès et sécurité
|
||||||
|
|
||||||
|
- **Rôle requis** : Admin entité (role_id = 2) ou Super-admin (role_id = 1)
|
||||||
|
- **Isolation** : Admin voit uniquement les stats de son entité
|
||||||
|
- **Super-admin** : Accès global (entity_id = NULL dans requêtes)
|
||||||
|
|
||||||
## 🚀 Déploiement
|
## 🚀 Déploiement
|
||||||
|
|
||||||
@@ -172,4 +240,4 @@ DELETE FROM operations WHERE id = 850;
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Mis à jour : 26 Octobre 2025**
|
**Mis à jour : 22 Décembre 2025**
|
||||||
|
|||||||
@@ -157,12 +157,21 @@ register_shutdown_function(function() use ($requestUri, $requestMethod) {
|
|||||||
// Alerter sur les erreurs 500
|
// Alerter sur les erreurs 500
|
||||||
if ($statusCode >= 500) {
|
if ($statusCode >= 500) {
|
||||||
$error = error_get_last();
|
$error = error_get_last();
|
||||||
|
$errorMessage = $error['message'] ?? null;
|
||||||
|
|
||||||
|
// Si pas d'erreur PHP, c'est probablement une exception capturée
|
||||||
|
// Le détail de l'erreur sera dans les logs applicatifs
|
||||||
|
if ($errorMessage === null) {
|
||||||
|
$errorMessage = 'Exception capturée (voir logs/app.log pour détails)';
|
||||||
|
}
|
||||||
|
|
||||||
AlertService::trigger('HTTP_500', [
|
AlertService::trigger('HTTP_500', [
|
||||||
'endpoint' => $requestUri,
|
'endpoint' => $requestUri,
|
||||||
'method' => $requestMethod,
|
'method' => $requestMethod,
|
||||||
'error_message' => $error['message'] ?? 'Unknown error',
|
'error_message' => $errorMessage,
|
||||||
'error_file' => $error['file'] ?? 'Unknown',
|
'error_file' => $error['file'] ?? 'N/A',
|
||||||
'error_line' => $error['line'] ?? 0,
|
'error_line' => $error['line'] ?? 0,
|
||||||
|
'stack_trace' => 'Consulter logs/app.log pour le stack trace complet',
|
||||||
'message' => "Erreur serveur 500 sur $requestUri"
|
'message' => "Erreur serveur 500 sur $requestUri"
|
||||||
], 'ERROR');
|
], 'ERROR');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,30 +94,7 @@ Ce dossier contient les scripts automatisés de maintenance et de traitement pou
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. `update_stripe_devices.php`
|
### 5. `sync_databases.php`
|
||||||
|
|
||||||
**Fonction** : Met à jour la liste des appareils Android certifiés pour Tap to Pay
|
|
||||||
|
|
||||||
**Caractéristiques** :
|
|
||||||
|
|
||||||
- Liste de 95+ devices intégrée
|
|
||||||
- Ajoute les nouveaux appareils certifiés
|
|
||||||
- Met à jour les versions Android minimales
|
|
||||||
- Désactive les appareils obsolètes
|
|
||||||
- Notification email si changements importants
|
|
||||||
- Possibilité de personnaliser via `/data/stripe_certified_devices.json`
|
|
||||||
|
|
||||||
**Fréquence recommandée** : Hebdomadaire le dimanche à 3h
|
|
||||||
|
|
||||||
**Ligne crontab** :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. `sync_databases.php`
|
|
||||||
|
|
||||||
**Fonction** : Synchronise les bases de données entre environnements
|
**Fonction** : Synchronise les bases de données entre environnements
|
||||||
|
|
||||||
@@ -175,9 +152,6 @@ crontab -e
|
|||||||
|
|
||||||
# Rotation des logs événements (mensuel le 1er à 3h)
|
# Rotation des logs événements (mensuel le 1er à 3h)
|
||||||
0 3 1 * * /usr/bin/php /var/www/geosector/api/scripts/cron/rotate_event_logs.php >> /var/www/geosector/api/logs/rotation_events.log 2>&1
|
0 3 1 * * /usr/bin/php /var/www/geosector/api/scripts/cron/rotate_event_logs.php >> /var/www/geosector/api/logs/rotation_events.log 2>&1
|
||||||
|
|
||||||
# Mise à jour des devices Stripe (hebdomadaire dimanche à 3h)
|
|
||||||
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Vérifier que les CRONs sont actifs
|
### 4. Vérifier que les CRONs sont actifs
|
||||||
@@ -203,7 +177,6 @@ Tous les logs CRON sont stockés dans `/var/www/geosector/api/logs/` :
|
|||||||
- `cleanup_security.log` : Nettoyage des données de sécurité
|
- `cleanup_security.log` : Nettoyage des données de sécurité
|
||||||
- `cleanup_logs.log` : Nettoyage des anciens fichiers logs
|
- `cleanup_logs.log` : Nettoyage des anciens fichiers logs
|
||||||
- `rotation_events.log` : Rotation des logs événements JSONL
|
- `rotation_events.log` : Rotation des logs événements JSONL
|
||||||
- `stripe_devices.log` : Mise à jour des devices Tap to Pay
|
|
||||||
|
|
||||||
### Vérification de l'exécution
|
### Vérification de l'exécution
|
||||||
|
|
||||||
@@ -216,9 +189,6 @@ tail -n 50 /var/www/geosector/api/logs/cleanup_logs.log
|
|||||||
|
|
||||||
# Voir les dernières rotations des logs événements
|
# Voir les dernières rotations des logs événements
|
||||||
tail -n 50 /var/www/geosector/api/logs/rotation_events.log
|
tail -n 50 /var/www/geosector/api/logs/rotation_events.log
|
||||||
|
|
||||||
# Voir les dernières mises à jour Stripe
|
|
||||||
tail -n 50 /var/www/geosector/api/logs/stripe_devices.log
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
456
api/scripts/cron/aggregate_event_stats.php
Executable file
@@ -0,0 +1,456 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script CRON pour agrégation des statistiques d'événements
|
||||||
|
*
|
||||||
|
* Parse les fichiers JSONL et agrège les données dans event_stats_daily
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php aggregate_event_stats.php # Agrège J-1
|
||||||
|
* php aggregate_event_stats.php --date=2025-12-20 # Agrège une date spécifique
|
||||||
|
* php aggregate_event_stats.php --from=2025-12-01 --to=2025-12-21 # Rattrapage plage
|
||||||
|
*
|
||||||
|
* À exécuter quotidiennement via crontab (1h du matin) :
|
||||||
|
* 0 1 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/aggregate_event_stats.php >> /var/www/geosector/api/logs/aggregate_stats.log 2>&1
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
define('LOCK_FILE', '/tmp/aggregate_event_stats.lock');
|
||||||
|
define('EVENT_LOG_DIR', __DIR__ . '/../../logs/events');
|
||||||
|
|
||||||
|
// Empêcher l'exécution multiple simultanée
|
||||||
|
if (file_exists(LOCK_FILE)) {
|
||||||
|
$lockTime = filemtime(LOCK_FILE);
|
||||||
|
if (time() - $lockTime > 3600) {
|
||||||
|
unlink(LOCK_FILE);
|
||||||
|
} else {
|
||||||
|
die("[" . date('Y-m-d H:i:s') . "] Le processus est déjà en cours d'exécution\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents(LOCK_FILE, (string) getmypid());
|
||||||
|
|
||||||
|
register_shutdown_function(function () {
|
||||||
|
if (file_exists(LOCK_FILE)) {
|
||||||
|
unlink(LOCK_FILE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simuler l'environnement web pour AppConfig en CLI
|
||||||
|
if (php_sapi_name() === 'cli') {
|
||||||
|
$hostname = gethostname();
|
||||||
|
if (strpos($hostname, 'pra') !== false) {
|
||||||
|
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
|
||||||
|
} elseif (strpos($hostname, 'rca') !== false) {
|
||||||
|
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
|
||||||
|
} else {
|
||||||
|
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr';
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
|
||||||
|
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||||
|
|
||||||
|
if (!function_exists('getallheaders')) {
|
||||||
|
function getallheaders()
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chargement de l'environnement
|
||||||
|
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../../src/Config/AppConfig.php';
|
||||||
|
require_once __DIR__ . '/../../src/Core/Database.php';
|
||||||
|
require_once __DIR__ . '/../../src/Services/LogService.php';
|
||||||
|
|
||||||
|
use App\Services\LogService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse les arguments CLI
|
||||||
|
*/
|
||||||
|
function parseArgs(array $argv): array
|
||||||
|
{
|
||||||
|
$args = [
|
||||||
|
'date' => null,
|
||||||
|
'from' => null,
|
||||||
|
'to' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($argv as $arg) {
|
||||||
|
if (strpos($arg, '--date=') === 0) {
|
||||||
|
$args['date'] = substr($arg, 7);
|
||||||
|
} elseif (strpos($arg, '--from=') === 0) {
|
||||||
|
$args['from'] = substr($arg, 7);
|
||||||
|
} elseif (strpos($arg, '--to=') === 0) {
|
||||||
|
$args['to'] = substr($arg, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère la liste des dates à traiter
|
||||||
|
*/
|
||||||
|
function getDatesToProcess(array $args): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
|
||||||
|
if ($args['date']) {
|
||||||
|
$dates[] = $args['date'];
|
||||||
|
} elseif ($args['from'] && $args['to']) {
|
||||||
|
$current = new DateTime($args['from']);
|
||||||
|
$end = new DateTime($args['to']);
|
||||||
|
while ($current <= $end) {
|
||||||
|
$dates[] = $current->format('Y-m-d');
|
||||||
|
$current->modify('+1 day');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Par défaut : J-1
|
||||||
|
$dates[] = date('Y-m-d', strtotime('-1 day'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse un fichier JSONL et retourne les événements
|
||||||
|
*/
|
||||||
|
function parseJsonlFile(string $filePath): array
|
||||||
|
{
|
||||||
|
$events = [];
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($filePath, 'r');
|
||||||
|
if (!$handle) {
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($line = fgets($handle)) !== false) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (empty($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = json_decode($line, true);
|
||||||
|
if ($event && isset($event['event'])) {
|
||||||
|
$events[] = $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrège les événements par entity_id et event_type
|
||||||
|
*/
|
||||||
|
function aggregateEvents(array $events): array
|
||||||
|
{
|
||||||
|
$stats = [];
|
||||||
|
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$entityId = $event['entity_id'] ?? null;
|
||||||
|
$eventType = $event['event'] ?? 'unknown';
|
||||||
|
$userId = $event['user_id'] ?? null;
|
||||||
|
|
||||||
|
// Clé d'agrégation : entity_id peut être NULL (stats globales)
|
||||||
|
$key = ($entityId ?? 'NULL') . '|' . $eventType;
|
||||||
|
|
||||||
|
if (!isset($stats[$key])) {
|
||||||
|
$stats[$key] = [
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'count' => 0,
|
||||||
|
'sum_amount' => 0.0,
|
||||||
|
'user_ids' => [],
|
||||||
|
'metadata' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats[$key]['count']++;
|
||||||
|
|
||||||
|
// Collecter les user_ids pour unique_users
|
||||||
|
if ($userId !== null) {
|
||||||
|
$stats[$key]['user_ids'][$userId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somme des montants pour les passages et paiements Stripe
|
||||||
|
if (in_array($eventType, ['passage_created', 'passage_updated'])) {
|
||||||
|
$amount = $event['amount'] ?? 0;
|
||||||
|
$stats[$key]['sum_amount'] += (float) $amount;
|
||||||
|
} elseif (in_array($eventType, ['stripe_payment_success', 'stripe_payment_created'])) {
|
||||||
|
// Montant en centimes -> euros
|
||||||
|
$amount = ($event['amount'] ?? 0) / 100;
|
||||||
|
$stats[$key]['sum_amount'] += (float) $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collecter metadata spécifiques
|
||||||
|
collectMetadata($stats[$key], $event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir user_ids en count
|
||||||
|
foreach ($stats as &$stat) {
|
||||||
|
$stat['unique_users'] = count($stat['user_ids']);
|
||||||
|
unset($stat['user_ids']);
|
||||||
|
|
||||||
|
// Finaliser les metadata
|
||||||
|
$stat['metadata'] = finalizeMetadata($stat['metadata'], $stat['event_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collecte les métadonnées spécifiques par type d'événement
|
||||||
|
*/
|
||||||
|
function collectMetadata(array &$stat, array $event): void
|
||||||
|
{
|
||||||
|
$eventType = $event['event'] ?? '';
|
||||||
|
|
||||||
|
switch ($eventType) {
|
||||||
|
case 'login_failed':
|
||||||
|
$reason = $event['reason'] ?? 'unknown';
|
||||||
|
$stat['metadata']['reasons'][$reason] = ($stat['metadata']['reasons'][$reason] ?? 0) + 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'passage_created':
|
||||||
|
$sectorId = $event['sector_id'] ?? null;
|
||||||
|
if ($sectorId) {
|
||||||
|
$stat['metadata']['sectors'][$sectorId] = ($stat['metadata']['sectors'][$sectorId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
$paymentType = $event['payment_type'] ?? 'unknown';
|
||||||
|
$stat['metadata']['payment_types'][$paymentType] = ($stat['metadata']['payment_types'][$paymentType] ?? 0) + 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stripe_payment_failed':
|
||||||
|
$errorCode = $event['error_code'] ?? 'unknown';
|
||||||
|
$stat['metadata']['error_codes'][$errorCode] = ($stat['metadata']['error_codes'][$errorCode] ?? 0) + 1;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stripe_terminal_error':
|
||||||
|
$errorCode = $event['error_code'] ?? 'unknown';
|
||||||
|
$stat['metadata']['error_codes'][$errorCode] = ($stat['metadata']['error_codes'][$errorCode] ?? 0) + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalise les métadonnées (top 5, tri, etc.)
|
||||||
|
*/
|
||||||
|
function finalizeMetadata(array $metadata, string $eventType): ?array
|
||||||
|
{
|
||||||
|
if (empty($metadata)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
// Top 5 secteurs
|
||||||
|
if (isset($metadata['sectors'])) {
|
||||||
|
arsort($metadata['sectors']);
|
||||||
|
$result['top_sectors'] = array_slice($metadata['sectors'], 0, 5, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raisons d'échec login
|
||||||
|
if (isset($metadata['reasons'])) {
|
||||||
|
arsort($metadata['reasons']);
|
||||||
|
$result['failure_reasons'] = $metadata['reasons'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types de paiement
|
||||||
|
if (isset($metadata['payment_types'])) {
|
||||||
|
arsort($metadata['payment_types']);
|
||||||
|
$result['payment_types'] = $metadata['payment_types'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codes d'erreur
|
||||||
|
if (isset($metadata['error_codes'])) {
|
||||||
|
arsort($metadata['error_codes']);
|
||||||
|
$result['error_codes'] = $metadata['error_codes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty($result) ? null : $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insère ou met à jour les stats dans la base de données
|
||||||
|
*/
|
||||||
|
function upsertStats(PDO $db, string $date, array $stats): int
|
||||||
|
{
|
||||||
|
$upsertedCount = 0;
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
INSERT INTO event_stats_daily
|
||||||
|
(stat_date, entity_id, event_type, count, sum_amount, unique_users, metadata)
|
||||||
|
VALUES
|
||||||
|
(:stat_date, :entity_id, :event_type, :count, :sum_amount, :unique_users, :metadata)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
count = VALUES(count),
|
||||||
|
sum_amount = VALUES(sum_amount),
|
||||||
|
unique_users = VALUES(unique_users),
|
||||||
|
metadata = VALUES(metadata),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
|
||||||
|
foreach ($stats as $stat) {
|
||||||
|
try {
|
||||||
|
$stmt->execute([
|
||||||
|
'stat_date' => $date,
|
||||||
|
'entity_id' => $stat['entity_id'],
|
||||||
|
'event_type' => $stat['event_type'],
|
||||||
|
'count' => $stat['count'],
|
||||||
|
'sum_amount' => $stat['sum_amount'],
|
||||||
|
'unique_users' => $stat['unique_users'],
|
||||||
|
'metadata' => $stat['metadata'] ? json_encode($stat['metadata'], JSON_UNESCAPED_UNICODE) : null,
|
||||||
|
]);
|
||||||
|
$upsertedCount++;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo " ERREUR insertion {$stat['event_type']}: " . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $upsertedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère également les stats globales (entity_id = NULL)
|
||||||
|
*/
|
||||||
|
function generateGlobalStats(array $statsByEntity): array
|
||||||
|
{
|
||||||
|
$globalStats = [];
|
||||||
|
|
||||||
|
foreach ($statsByEntity as $stat) {
|
||||||
|
$eventType = $stat['event_type'];
|
||||||
|
|
||||||
|
if (!isset($globalStats[$eventType])) {
|
||||||
|
$globalStats[$eventType] = [
|
||||||
|
'entity_id' => null,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'count' => 0,
|
||||||
|
'sum_amount' => 0.0,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'metadata' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$globalStats[$eventType]['count'] += $stat['count'];
|
||||||
|
$globalStats[$eventType]['sum_amount'] += $stat['sum_amount'];
|
||||||
|
$globalStats[$eventType]['unique_users'] += $stat['unique_users'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($globalStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MAIN
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
try {
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Démarrage de l'agrégation des statistiques\n";
|
||||||
|
|
||||||
|
// Initialisation
|
||||||
|
$appConfig = AppConfig::getInstance();
|
||||||
|
$config = $appConfig->getFullConfig();
|
||||||
|
$environment = $appConfig->getEnvironment();
|
||||||
|
|
||||||
|
echo "Environnement: {$environment}\n";
|
||||||
|
|
||||||
|
Database::init($config['database']);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Parser les arguments
|
||||||
|
$args = parseArgs($argv);
|
||||||
|
$dates = getDatesToProcess($args);
|
||||||
|
|
||||||
|
echo "Dates à traiter: " . implode(', ', $dates) . "\n\n";
|
||||||
|
|
||||||
|
$totalStats = 0;
|
||||||
|
$totalEvents = 0;
|
||||||
|
|
||||||
|
foreach ($dates as $date) {
|
||||||
|
$jsonlFile = EVENT_LOG_DIR . '/' . $date . '.jsonl';
|
||||||
|
|
||||||
|
echo "--- Traitement de {$date} ---\n";
|
||||||
|
|
||||||
|
if (!file_exists($jsonlFile)) {
|
||||||
|
echo " Fichier non trouvé: {$jsonlFile}\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileSize = filesize($jsonlFile);
|
||||||
|
echo " Fichier: " . basename($jsonlFile) . " (" . number_format($fileSize / 1024, 2) . " KB)\n";
|
||||||
|
|
||||||
|
// Parser le fichier
|
||||||
|
$events = parseJsonlFile($jsonlFile);
|
||||||
|
$eventCount = count($events);
|
||||||
|
echo " Événements parsés: {$eventCount}\n";
|
||||||
|
|
||||||
|
if ($eventCount === 0) {
|
||||||
|
echo " Aucun événement à agréger\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalEvents += $eventCount;
|
||||||
|
|
||||||
|
// Agréger par entity/event_type
|
||||||
|
$stats = aggregateEvents($events);
|
||||||
|
echo " Agrégations par entité: " . count($stats) . "\n";
|
||||||
|
|
||||||
|
// Générer les stats globales
|
||||||
|
$globalStats = generateGlobalStats($stats);
|
||||||
|
echo " Agrégations globales: " . count($globalStats) . "\n";
|
||||||
|
|
||||||
|
// Fusionner stats entités + globales
|
||||||
|
$allStats = array_merge(array_values($stats), $globalStats);
|
||||||
|
|
||||||
|
// Insérer en base
|
||||||
|
$upserted = upsertStats($db, $date, $allStats);
|
||||||
|
echo " Stats insérées/mises à jour: {$upserted}\n";
|
||||||
|
|
||||||
|
$totalStats += $upserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résumé
|
||||||
|
echo "\n=== RÉSUMÉ ===\n";
|
||||||
|
echo "Dates traitées: " . count($dates) . "\n";
|
||||||
|
echo "Événements traités: {$totalEvents}\n";
|
||||||
|
echo "Stats agrégées: {$totalStats}\n";
|
||||||
|
|
||||||
|
// Log
|
||||||
|
LogService::log('Agrégation des statistiques terminée', [
|
||||||
|
'level' => 'info',
|
||||||
|
'script' => 'aggregate_event_stats.php',
|
||||||
|
'environment' => $environment,
|
||||||
|
'dates_count' => count($dates),
|
||||||
|
'events_count' => $totalEvents,
|
||||||
|
'stats_count' => $totalStats,
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "\n[" . date('Y-m-d H:i:s') . "] Agrégation terminée avec succès\n";
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$errorMsg = 'Erreur lors de l\'agrégation: ' . $e->getMessage();
|
||||||
|
|
||||||
|
LogService::log($errorMsg, [
|
||||||
|
'level' => 'error',
|
||||||
|
'script' => 'aggregate_event_stats.php',
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "\n❌ ERREUR: {$errorMsg}\n";
|
||||||
|
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
|
||||||
|
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(0);
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Script CRON pour mettre à jour la liste des appareils certifiés Stripe Tap to Pay
|
|
||||||
*
|
|
||||||
* Ce script récupère et met à jour la liste des appareils Android certifiés
|
|
||||||
* pour Tap to Pay en France dans la table stripe_android_certified_devices
|
|
||||||
*
|
|
||||||
* À exécuter hebdomadairement via crontab :
|
|
||||||
* Exemple: 0 3 * * 0 /usr/bin/php /path/to/api/scripts/cron/update_stripe_devices.php
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
define('LOCK_FILE', '/tmp/update_stripe_devices.lock');
|
|
||||||
define('DEVICES_JSON_URL', 'https://raw.githubusercontent.com/stripe/stripe-terminal-android/master/tap-to-pay/certified-devices.json');
|
|
||||||
define('DEVICES_LOCAL_FILE', __DIR__ . '/../../data/stripe_certified_devices.json');
|
|
||||||
|
|
||||||
// Empêcher l'exécution multiple simultanée
|
|
||||||
if (file_exists(LOCK_FILE)) {
|
|
||||||
$lockTime = filemtime(LOCK_FILE);
|
|
||||||
if (time() - $lockTime > 3600) { // Lock de plus d'1 heure = processus bloqué
|
|
||||||
unlink(LOCK_FILE);
|
|
||||||
} else {
|
|
||||||
die("[" . date('Y-m-d H:i:s') . "] Le processus est déjà en cours d'exécution\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Créer le fichier de lock
|
|
||||||
file_put_contents(LOCK_FILE, getmypid());
|
|
||||||
|
|
||||||
// Enregistrer un handler pour supprimer le lock en cas d'arrêt
|
|
||||||
register_shutdown_function(function() {
|
|
||||||
if (file_exists(LOCK_FILE)) {
|
|
||||||
unlink(LOCK_FILE);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simuler l'environnement web pour AppConfig en CLI
|
|
||||||
if (php_sapi_name() === 'cli') {
|
|
||||||
$hostname = gethostname();
|
|
||||||
if (strpos($hostname, 'prod') !== false || strpos($hostname, 'pra') !== false) {
|
|
||||||
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
|
|
||||||
} elseif (strpos($hostname, 'rec') !== false || strpos($hostname, 'rca') !== false) {
|
|
||||||
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
|
|
||||||
} else {
|
|
||||||
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
|
|
||||||
}
|
|
||||||
$_SERVER['REQUEST_URI'] = '/cron/update_stripe_devices';
|
|
||||||
$_SERVER['REQUEST_METHOD'] = 'CLI';
|
|
||||||
$_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'];
|
|
||||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
|
||||||
|
|
||||||
// Définir getallheaders si elle n'existe pas (CLI)
|
|
||||||
if (!function_exists('getallheaders')) {
|
|
||||||
function getallheaders() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Charger l'environnement
|
|
||||||
require_once dirname(dirname(__DIR__)) . '/vendor/autoload.php';
|
|
||||||
require_once dirname(dirname(__DIR__)) . '/src/Config/AppConfig.php';
|
|
||||||
require_once dirname(dirname(__DIR__)) . '/src/Core/Database.php';
|
|
||||||
require_once dirname(dirname(__DIR__)) . '/src/Services/LogService.php';
|
|
||||||
|
|
||||||
use App\Services\LogService;
|
|
||||||
|
|
||||||
try {
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] Début de la mise à jour des devices Stripe certifiés\n";
|
|
||||||
|
|
||||||
// Initialiser la configuration et la base de données
|
|
||||||
$appConfig = AppConfig::getInstance();
|
|
||||||
$dbConfig = $appConfig->getDatabaseConfig();
|
|
||||||
Database::init($dbConfig);
|
|
||||||
$db = Database::getInstance();
|
|
||||||
|
|
||||||
// Logger le début
|
|
||||||
LogService::log("Début de la mise à jour des devices Stripe certifiés", [
|
|
||||||
'source' => 'cron',
|
|
||||||
'script' => 'update_stripe_devices.php'
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Étape 1: Récupérer la liste des devices
|
|
||||||
$devicesData = fetchCertifiedDevices();
|
|
||||||
|
|
||||||
if (empty($devicesData)) {
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] Aucune donnée de devices récupérée\n";
|
|
||||||
LogService::log("Aucune donnée de devices récupérée", ['level' => 'warning']);
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Étape 2: Traiter et mettre à jour la base de données
|
|
||||||
$stats = updateDatabase($db, $devicesData);
|
|
||||||
|
|
||||||
// Étape 3: Logger les résultats
|
|
||||||
$message = sprintf(
|
|
||||||
"Mise à jour terminée : %d ajoutés, %d modifiés, %d désactivés, %d inchangés",
|
|
||||||
$stats['added'],
|
|
||||||
$stats['updated'],
|
|
||||||
$stats['disabled'],
|
|
||||||
$stats['unchanged']
|
|
||||||
);
|
|
||||||
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] $message\n";
|
|
||||||
|
|
||||||
LogService::log($message, [
|
|
||||||
'source' => 'cron',
|
|
||||||
'stats' => $stats
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Étape 4: Envoyer une notification si changements significatifs
|
|
||||||
if ($stats['added'] > 0 || $stats['disabled'] > 0) {
|
|
||||||
sendNotification($stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] Mise à jour terminée avec succès\n";
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$errorMsg = "Erreur lors de la mise à jour des devices: " . $e->getMessage();
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] $errorMsg\n";
|
|
||||||
LogService::log($errorMsg, [
|
|
||||||
'level' => 'error',
|
|
||||||
'trace' => $e->getTraceAsString()
|
|
||||||
]);
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère la liste des devices certifiés
|
|
||||||
* Essaie d'abord depuis une URL externe, puis depuis un fichier local en fallback
|
|
||||||
*/
|
|
||||||
function fetchCertifiedDevices(): array {
|
|
||||||
// Liste maintenue manuellement des devices certifiés en France
|
|
||||||
// Source: Documentation Stripe Terminal et tests confirmés
|
|
||||||
$frenchCertifiedDevices = [
|
|
||||||
// Samsung Galaxy S Series
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21', 'model_identifier' => 'SM-G991B', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21+', 'model_identifier' => 'SM-G996B', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 Ultra', 'model_identifier' => 'SM-G998B', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 FE', 'model_identifier' => 'SM-G990B', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22', 'model_identifier' => 'SM-S901B', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22+', 'model_identifier' => 'SM-S906B', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22 Ultra', 'model_identifier' => 'SM-S908B', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23', 'model_identifier' => 'SM-S911B', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23+', 'model_identifier' => 'SM-S916B', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 Ultra', 'model_identifier' => 'SM-S918B', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 FE', 'model_identifier' => 'SM-S711B', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24', 'model_identifier' => 'SM-S921B', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24+', 'model_identifier' => 'SM-S926B', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24 Ultra', 'model_identifier' => 'SM-S928B', 'min_android_version' => 14],
|
|
||||||
|
|
||||||
// Samsung Galaxy Note
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20', 'model_identifier' => 'SM-N980F', 'min_android_version' => 10],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20 Ultra', 'model_identifier' => 'SM-N986B', 'min_android_version' => 10],
|
|
||||||
|
|
||||||
// Samsung Galaxy Z Fold
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold3', 'model_identifier' => 'SM-F926B', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold4', 'model_identifier' => 'SM-F936B', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold5', 'model_identifier' => 'SM-F946B', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold6', 'model_identifier' => 'SM-F956B', 'min_android_version' => 14],
|
|
||||||
|
|
||||||
// Samsung Galaxy Z Flip
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip3', 'model_identifier' => 'SM-F711B', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip4', 'model_identifier' => 'SM-F721B', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip5', 'model_identifier' => 'SM-F731B', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip6', 'model_identifier' => 'SM-F741B', 'min_android_version' => 14],
|
|
||||||
|
|
||||||
// Samsung Galaxy A Series (haut de gamme)
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy A54', 'model_identifier' => 'SM-A546B', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy A73', 'model_identifier' => 'SM-A736B', 'min_android_version' => 12],
|
|
||||||
|
|
||||||
// Google Pixel
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 6', 'model_identifier' => 'oriole', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 6 Pro', 'model_identifier' => 'raven', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 6a', 'model_identifier' => 'bluejay', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 7', 'model_identifier' => 'panther', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 7 Pro', 'model_identifier' => 'cheetah', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 7a', 'model_identifier' => 'lynx', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 8', 'model_identifier' => 'shiba', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 8 Pro', 'model_identifier' => 'husky', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 8a', 'model_identifier' => 'akita', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 9', 'model_identifier' => 'tokay', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro', 'model_identifier' => 'caiman', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro XL', 'model_identifier' => 'komodo', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel Fold', 'model_identifier' => 'felix', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Google', 'model' => 'Pixel Tablet', 'model_identifier' => 'tangorpro', 'min_android_version' => 13],
|
|
||||||
|
|
||||||
// OnePlus
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => '9', 'model_identifier' => 'LE2113', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => '9 Pro', 'model_identifier' => 'LE2123', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => '10 Pro', 'model_identifier' => 'NE2213', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => '10T', 'model_identifier' => 'CPH2413', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => '11', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => '11R', 'model_identifier' => 'CPH2487', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => '12', 'model_identifier' => 'CPH2581', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => '12R', 'model_identifier' => 'CPH2585', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'OnePlus', 'model' => 'Open', 'model_identifier' => 'CPH2551', 'min_android_version' => 13],
|
|
||||||
|
|
||||||
// Xiaomi
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11', 'model_identifier' => 'M2011K2G', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11 Ultra', 'model_identifier' => 'M2102K1G', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '12', 'model_identifier' => '2201123G', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '12 Pro', 'model_identifier' => '2201122G', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '12T Pro', 'model_identifier' => '2207122MC', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '13', 'model_identifier' => '2211133G', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '13 Pro', 'model_identifier' => '2210132G', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '13T Pro', 'model_identifier' => '23078PND5G', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '14', 'model_identifier' => '23127PN0CG', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '14 Pro', 'model_identifier' => '23116PN5BG', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Xiaomi', 'model' => '14 Ultra', 'model_identifier' => '24030PN60G', 'min_android_version' => 14],
|
|
||||||
|
|
||||||
// OPPO
|
|
||||||
['manufacturer' => 'OPPO', 'model' => 'Find X3 Pro', 'model_identifier' => 'CPH2173', 'min_android_version' => 11],
|
|
||||||
['manufacturer' => 'OPPO', 'model' => 'Find X5 Pro', 'model_identifier' => 'CPH2305', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'OPPO', 'model' => 'Find X6 Pro', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'OPPO', 'model' => 'Find N2', 'model_identifier' => 'CPH2399', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'OPPO', 'model' => 'Find N3', 'model_identifier' => 'CPH2499', 'min_android_version' => 13],
|
|
||||||
|
|
||||||
// Realme
|
|
||||||
['manufacturer' => 'Realme', 'model' => 'GT 2 Pro', 'model_identifier' => 'RMX3301', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Realme', 'model' => 'GT 3', 'model_identifier' => 'RMX3709', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Realme', 'model' => 'GT 5 Pro', 'model_identifier' => 'RMX3888', 'min_android_version' => 14],
|
|
||||||
|
|
||||||
// Honor
|
|
||||||
['manufacturer' => 'Honor', 'model' => 'Magic5 Pro', 'model_identifier' => 'PGT-N19', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Honor', 'model' => 'Magic6 Pro', 'model_identifier' => 'BVL-N49', 'min_android_version' => 14],
|
|
||||||
['manufacturer' => 'Honor', 'model' => '90', 'model_identifier' => 'REA-NX9', 'min_android_version' => 13],
|
|
||||||
|
|
||||||
// ASUS
|
|
||||||
['manufacturer' => 'ASUS', 'model' => 'Zenfone 9', 'model_identifier' => 'AI2202', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'ASUS', 'model' => 'Zenfone 10', 'model_identifier' => 'AI2302', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'ASUS', 'model' => 'ROG Phone 7', 'model_identifier' => 'AI2205', 'min_android_version' => 13],
|
|
||||||
|
|
||||||
// Nothing
|
|
||||||
['manufacturer' => 'Nothing', 'model' => 'Phone (1)', 'model_identifier' => 'A063', 'min_android_version' => 12],
|
|
||||||
['manufacturer' => 'Nothing', 'model' => 'Phone (2)', 'model_identifier' => 'A065', 'min_android_version' => 13],
|
|
||||||
['manufacturer' => 'Nothing', 'model' => 'Phone (2a)', 'model_identifier' => 'A142', 'min_android_version' => 14],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Essayer de charger depuis un fichier JSON local si présent
|
|
||||||
if (file_exists(DEVICES_LOCAL_FILE)) {
|
|
||||||
$localData = json_decode(file_get_contents(DEVICES_LOCAL_FILE), true);
|
|
||||||
if (!empty($localData)) {
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] Données chargées depuis le fichier local\n";
|
|
||||||
return array_merge($frenchCertifiedDevices, $localData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] Utilisation de la liste intégrée des devices certifiés\n";
|
|
||||||
return $frenchCertifiedDevices;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Met à jour la base de données avec les nouvelles données
|
|
||||||
*/
|
|
||||||
function updateDatabase($db, array $devices): array {
|
|
||||||
$stats = [
|
|
||||||
'added' => 0,
|
|
||||||
'updated' => 0,
|
|
||||||
'disabled' => 0,
|
|
||||||
'unchanged' => 0,
|
|
||||||
'total' => 0
|
|
||||||
];
|
|
||||||
|
|
||||||
// Récupérer tous les devices existants
|
|
||||||
$stmt = $db->prepare("SELECT * FROM stripe_android_certified_devices WHERE country = 'FR'");
|
|
||||||
$stmt->execute();
|
|
||||||
$existingDevices = [];
|
|
||||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
|
||||||
$key = $row['manufacturer'] . '|' . $row['model'] . '|' . $row['model_identifier'];
|
|
||||||
$existingDevices[$key] = $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer tous les devices pour tracking
|
|
||||||
$processedKeys = [];
|
|
||||||
|
|
||||||
// Traiter chaque device de la nouvelle liste
|
|
||||||
foreach ($devices as $device) {
|
|
||||||
$key = $device['manufacturer'] . '|' . $device['model'] . '|' . $device['model_identifier'];
|
|
||||||
$processedKeys[$key] = true;
|
|
||||||
|
|
||||||
if (isset($existingDevices[$key])) {
|
|
||||||
// Le device existe, vérifier s'il faut le mettre à jour
|
|
||||||
$existing = $existingDevices[$key];
|
|
||||||
|
|
||||||
// Vérifier si des champs ont changé
|
|
||||||
$needsUpdate = false;
|
|
||||||
if ($existing['min_android_version'] != $device['min_android_version']) {
|
|
||||||
$needsUpdate = true;
|
|
||||||
}
|
|
||||||
if ($existing['tap_to_pay_certified'] != 1) {
|
|
||||||
$needsUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($needsUpdate) {
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
UPDATE stripe_android_certified_devices
|
|
||||||
SET min_android_version = :min_version,
|
|
||||||
tap_to_pay_certified = 1,
|
|
||||||
last_verified = NOW(),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE manufacturer = :manufacturer
|
|
||||||
AND model = :model
|
|
||||||
AND model_identifier = :model_identifier
|
|
||||||
AND country = 'FR'
|
|
||||||
");
|
|
||||||
$stmt->execute([
|
|
||||||
'min_version' => $device['min_android_version'],
|
|
||||||
'manufacturer' => $device['manufacturer'],
|
|
||||||
'model' => $device['model'],
|
|
||||||
'model_identifier' => $device['model_identifier']
|
|
||||||
]);
|
|
||||||
$stats['updated']++;
|
|
||||||
|
|
||||||
LogService::log("Device mis à jour", [
|
|
||||||
'device' => $device['manufacturer'] . ' ' . $device['model']
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
// Juste mettre à jour last_verified
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
UPDATE stripe_android_certified_devices
|
|
||||||
SET last_verified = NOW()
|
|
||||||
WHERE manufacturer = :manufacturer
|
|
||||||
AND model = :model
|
|
||||||
AND model_identifier = :model_identifier
|
|
||||||
AND country = 'FR'
|
|
||||||
");
|
|
||||||
$stmt->execute([
|
|
||||||
'manufacturer' => $device['manufacturer'],
|
|
||||||
'model' => $device['model'],
|
|
||||||
'model_identifier' => $device['model_identifier']
|
|
||||||
]);
|
|
||||||
$stats['unchanged']++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Nouveau device, l'ajouter
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
INSERT INTO stripe_android_certified_devices
|
|
||||||
(manufacturer, model, model_identifier, tap_to_pay_certified,
|
|
||||||
certification_date, min_android_version, country, notes, last_verified)
|
|
||||||
VALUES
|
|
||||||
(:manufacturer, :model, :model_identifier, 1,
|
|
||||||
NOW(), :min_version, 'FR', 'Ajouté automatiquement via CRON', NOW())
|
|
||||||
");
|
|
||||||
$stmt->execute([
|
|
||||||
'manufacturer' => $device['manufacturer'],
|
|
||||||
'model' => $device['model'],
|
|
||||||
'model_identifier' => $device['model_identifier'],
|
|
||||||
'min_version' => $device['min_android_version']
|
|
||||||
]);
|
|
||||||
$stats['added']++;
|
|
||||||
|
|
||||||
LogService::log("Nouveau device ajouté", [
|
|
||||||
'device' => $device['manufacturer'] . ' ' . $device['model']
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Désactiver les devices qui ne sont plus dans la liste
|
|
||||||
foreach ($existingDevices as $key => $existing) {
|
|
||||||
if (!isset($processedKeys[$key]) && $existing['tap_to_pay_certified'] == 1) {
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
UPDATE stripe_android_certified_devices
|
|
||||||
SET tap_to_pay_certified = 0,
|
|
||||||
notes = CONCAT(IFNULL(notes, ''), ' | Désactivé le ', NOW(), ' (non présent dans la mise à jour)'),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = :id
|
|
||||||
");
|
|
||||||
$stmt->execute(['id' => $existing['id']]);
|
|
||||||
$stats['disabled']++;
|
|
||||||
|
|
||||||
LogService::log("Device désactivé", [
|
|
||||||
'device' => $existing['manufacturer'] . ' ' . $existing['model'],
|
|
||||||
'reason' => 'Non présent dans la liste mise à jour'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stats['total'] = count($devices);
|
|
||||||
return $stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Envoie une notification email aux administrateurs si changements importants
|
|
||||||
*/
|
|
||||||
function sendNotification(array $stats): void {
|
|
||||||
try {
|
|
||||||
// Récupérer la configuration
|
|
||||||
$appConfig = AppConfig::getInstance();
|
|
||||||
$emailConfig = $appConfig->getEmailConfig();
|
|
||||||
|
|
||||||
if (empty($emailConfig['admin_email'])) {
|
|
||||||
return; // Pas d'email admin configuré
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = Database::getInstance();
|
|
||||||
|
|
||||||
// Préparer le contenu de l'email
|
|
||||||
$subject = "Mise à jour des devices Stripe Tap to Pay";
|
|
||||||
$body = "Bonjour,\n\n";
|
|
||||||
$body .= "La mise à jour automatique de la liste des appareils certifiés Stripe Tap to Pay a été effectuée.\n\n";
|
|
||||||
$body .= "Résumé des changements :\n";
|
|
||||||
$body .= "- Nouveaux appareils ajoutés : " . $stats['added'] . "\n";
|
|
||||||
$body .= "- Appareils mis à jour : " . $stats['updated'] . "\n";
|
|
||||||
$body .= "- Appareils désactivés : " . $stats['disabled'] . "\n";
|
|
||||||
$body .= "- Appareils inchangés : " . $stats['unchanged'] . "\n";
|
|
||||||
$body .= "- Total d'appareils traités : " . $stats['total'] . "\n\n";
|
|
||||||
|
|
||||||
if ($stats['added'] > 0) {
|
|
||||||
$body .= "Les nouveaux appareils ont été automatiquement ajoutés à la base de données.\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($stats['disabled'] > 0) {
|
|
||||||
$body .= "Certains appareils ont été désactivés car ils ne sont plus certifiés.\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$body .= "\nConsultez les logs pour plus de détails.\n";
|
|
||||||
$body .= "\nCordialement,\nLe système GeoSector";
|
|
||||||
|
|
||||||
// Insérer dans la queue d'emails
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
INSERT INTO email_queue
|
|
||||||
(to_email, subject, body, status, created_at, attempts)
|
|
||||||
VALUES
|
|
||||||
(:to_email, :subject, :body, 'pending', NOW(), 0)
|
|
||||||
");
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
'to_email' => $emailConfig['admin_email'],
|
|
||||||
'subject' => $subject,
|
|
||||||
'body' => $body
|
|
||||||
]);
|
|
||||||
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] Notification ajoutée à la queue d'emails\n";
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
// Ne pas faire échouer le script si l'email ne peut pas être envoyé
|
|
||||||
echo "[" . date('Y-m-d H:i:s') . "] Impossible d'envoyer la notification: " . $e->getMessage() . "\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
api/scripts/sql/alter_ope_pass_fk_sector_default_null.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Migration: Modifier fk_sector pour avoir DEFAULT NULL au lieu de DEFAULT 0
|
||||||
|
-- Raison: La FK vers ope_sectors(id) ne permet pas la valeur 0 (aucun secteur avec id=0)
|
||||||
|
-- Date: 2026-01-16
|
||||||
|
|
||||||
|
-- 1. D'abord, mettre à NULL les passages qui ont fk_sector = 0
|
||||||
|
UPDATE ope_pass SET fk_sector = NULL WHERE fk_sector = 0;
|
||||||
|
|
||||||
|
-- 2. Modifier la colonne pour avoir DEFAULT NULL
|
||||||
|
ALTER TABLE ope_pass MODIFY COLUMN fk_sector INT UNSIGNED DEFAULT NULL;
|
||||||
|
|
||||||
|
-- Vérification
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
COLUMN_DEFAULT,
|
||||||
|
IS_NULLABLE
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ope_pass'
|
||||||
|
AND COLUMN_NAME = 'fk_sector';
|
||||||
57
api/scripts/sql/create_event_stats_daily.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Table event_stats_daily - Statistiques d'événements agrégées
|
||||||
|
-- Version: 1.0
|
||||||
|
-- Date: 2025-12-22
|
||||||
|
-- ============================================================
|
||||||
|
--
|
||||||
|
-- Usage: Exécuter dans les 3 environnements (DEV, REC, PROD)
|
||||||
|
-- mysql -u user -p database < create_event_stats_daily.sql
|
||||||
|
--
|
||||||
|
-- Description:
|
||||||
|
-- Stocke les statistiques quotidiennes agrégées depuis les
|
||||||
|
-- fichiers JSONL (/logs/events/YYYY-MM-DD.jsonl)
|
||||||
|
-- Alimentée par le CRON aggregate_event_stats.php (1x/nuit)
|
||||||
|
--
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_stats_daily (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Clés d'agrégation
|
||||||
|
stat_date DATE NOT NULL COMMENT 'Date des statistiques',
|
||||||
|
entity_id INT UNSIGNED NULL COMMENT 'ID entité (NULL = stats globales super-admin)',
|
||||||
|
event_type VARCHAR(50) NOT NULL COMMENT 'Type événement (login_success, passage_created, etc.)',
|
||||||
|
|
||||||
|
-- Compteurs
|
||||||
|
count INT UNSIGNED DEFAULT 0 COMMENT 'Nombre d\'occurrences',
|
||||||
|
sum_amount DECIMAL(12,2) DEFAULT 0.00 COMMENT 'Somme des montants (passages/paiements)',
|
||||||
|
unique_users INT UNSIGNED DEFAULT 0 COMMENT 'Nombre d\'utilisateurs distincts',
|
||||||
|
|
||||||
|
-- Métadonnées agrégées (JSON)
|
||||||
|
metadata JSON NULL COMMENT 'Données agrégées: top secteurs, erreurs fréquentes, etc.',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
-- Contraintes
|
||||||
|
UNIQUE KEY uk_date_entity_event (stat_date, entity_id, event_type),
|
||||||
|
INDEX idx_entity_date (entity_id, stat_date),
|
||||||
|
INDEX idx_date (stat_date),
|
||||||
|
INDEX idx_event_type (event_type)
|
||||||
|
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Statistiques quotidiennes agrégées des événements (EventLogService)';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Exemples de données attendues
|
||||||
|
-- ============================================================
|
||||||
|
--
|
||||||
|
-- | stat_date | entity_id | event_type | count | sum_amount | unique_users |
|
||||||
|
-- |------------|-----------|------------------|-------|------------|--------------|
|
||||||
|
-- | 2025-12-22 | 5 | login_success | 45 | 0.00 | 12 |
|
||||||
|
-- | 2025-12-22 | 5 | passage_created | 128 | 2450.00 | 8 |
|
||||||
|
-- | 2025-12-22 | 5 | stripe_payment_success | 12 | 890.00 | 6 |
|
||||||
|
-- | 2025-12-22 | NULL | login_success | 320 | 0.00 | 85 | <- Global
|
||||||
|
--
|
||||||
|
-- ============================================================
|
||||||
5
api/scripts/sql/drop_stripe_devices_table.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Suppression de la table stripe_android_certified_devices
|
||||||
|
-- Cette table n'est plus utilisée : la vérification de compatibilité Tap to Pay
|
||||||
|
-- se fait maintenant directement côté client via le SDK Stripe Terminal
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS stripe_android_certified_devices;
|
||||||
484
api/src/Controllers/EventStatsController.php
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../Services/LogService.php';
|
||||||
|
require_once __DIR__ . '/../Services/EventStatsService.php';
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use Database;
|
||||||
|
use Response;
|
||||||
|
use Session;
|
||||||
|
use App\Services\LogService;
|
||||||
|
use App\Services\EventStatsService;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventStatsController - Contrôleur des statistiques d'événements
|
||||||
|
*
|
||||||
|
* Endpoints pour consulter les stats agrégées et le détail des événements.
|
||||||
|
* Accès réservé aux Admin entité (role_id = 2) et Super-admin (role_id = 1).
|
||||||
|
*/
|
||||||
|
class EventStatsController
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
private EventStatsService $statsService;
|
||||||
|
|
||||||
|
/** @var array Rôles autorisés à consulter les stats */
|
||||||
|
private const ALLOWED_ROLES = [1, 2]; // Super-admin, Admin
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
$this->statsService = new EventStatsService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/stats/summary
|
||||||
|
* Récupère le résumé des stats pour une date
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - date: Date (YYYY-MM-DD), défaut = aujourd'hui
|
||||||
|
*/
|
||||||
|
public function summary(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->checkAccess()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityId = $this->getEntityIdForQuery();
|
||||||
|
$date = $_GET['date'] ?? date('Y-m-d');
|
||||||
|
|
||||||
|
// Validation de la date
|
||||||
|
if (!$this->isValidDate($date)) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = $this->statsService->getSummary($entityId, $date);
|
||||||
|
|
||||||
|
$this->jsonWithCache([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => $summary,
|
||||||
|
], true);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogService::error('Erreur lors de la récupération du résumé des stats', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Erreur lors de la récupération des statistiques',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/stats/daily
|
||||||
|
* Récupère les stats journalières sur une plage de dates
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - from: Date début (YYYY-MM-DD), requis
|
||||||
|
* - to: Date fin (YYYY-MM-DD), requis
|
||||||
|
* - events: Types d'événements (comma-separated), optionnel
|
||||||
|
*/
|
||||||
|
public function daily(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->checkAccess()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityId = $this->getEntityIdForQuery();
|
||||||
|
$from = $_GET['from'] ?? null;
|
||||||
|
$to = $_GET['to'] ?? null;
|
||||||
|
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||||
|
|
||||||
|
// Validation des dates
|
||||||
|
if (!$from || !$to) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Les paramètres from et to sont requis',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'La date de début doit être antérieure à la date de fin',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter la plage à 90 jours
|
||||||
|
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
|
||||||
|
if ($daysDiff > 90) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'La plage de dates ne peut pas dépasser 90 jours',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$daily = $this->statsService->getDaily($entityId, $from, $to, $eventTypes);
|
||||||
|
|
||||||
|
$this->jsonWithCache([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'days' => $daily,
|
||||||
|
],
|
||||||
|
], true);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogService::error('Erreur lors de la récupération des stats journalières', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Erreur lors de la récupération des statistiques',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/stats/weekly
|
||||||
|
* Récupère les stats hebdomadaires sur une plage de dates
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - from: Date début (YYYY-MM-DD), requis
|
||||||
|
* - to: Date fin (YYYY-MM-DD), requis
|
||||||
|
* - events: Types d'événements (comma-separated), optionnel
|
||||||
|
*/
|
||||||
|
public function weekly(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->checkAccess()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityId = $this->getEntityIdForQuery();
|
||||||
|
$from = $_GET['from'] ?? null;
|
||||||
|
$to = $_GET['to'] ?? null;
|
||||||
|
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||||
|
|
||||||
|
// Validation des dates
|
||||||
|
if (!$from || !$to) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Les paramètres from et to sont requis',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter la plage à 1 an
|
||||||
|
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
|
||||||
|
if ($daysDiff > 365) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'La plage de dates ne peut pas dépasser 1 an',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weekly = $this->statsService->getWeekly($entityId, $from, $to, $eventTypes);
|
||||||
|
|
||||||
|
Response::json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'weeks' => $weekly,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogService::error('Erreur lors de la récupération des stats hebdomadaires', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Erreur lors de la récupération des statistiques',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/stats/monthly
|
||||||
|
* Récupère les stats mensuelles pour une année
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - year: Année (YYYY), défaut = année courante
|
||||||
|
* - events: Types d'événements (comma-separated), optionnel
|
||||||
|
*/
|
||||||
|
public function monthly(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->checkAccess()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityId = $this->getEntityIdForQuery();
|
||||||
|
$year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y');
|
||||||
|
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||||
|
|
||||||
|
// Validation de l'année
|
||||||
|
if ($year < 2020 || $year > (int) date('Y') + 1) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Année invalide',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$monthly = $this->statsService->getMonthly($entityId, $year, $eventTypes);
|
||||||
|
|
||||||
|
Response::json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'year' => $year,
|
||||||
|
'months' => $monthly,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogService::error('Erreur lors de la récupération des stats mensuelles', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Erreur lors de la récupération des statistiques',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/stats/details
|
||||||
|
* Récupère le détail des événements (lecture JSONL paginée)
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - date: Date (YYYY-MM-DD), requis
|
||||||
|
* - event: Type d'événement, optionnel
|
||||||
|
* - limit: Nombre max (défaut 50, max 100)
|
||||||
|
* - offset: Décalage pour pagination (défaut 0)
|
||||||
|
*/
|
||||||
|
public function details(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->checkAccess()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityId = $this->getEntityIdForQuery();
|
||||||
|
$date = $_GET['date'] ?? null;
|
||||||
|
$eventType = $_GET['event'] ?? null;
|
||||||
|
$limit = isset($_GET['limit']) ? min((int) $_GET['limit'], 100) : 50;
|
||||||
|
$offset = isset($_GET['offset']) ? max((int) $_GET['offset'], 0) : 0;
|
||||||
|
|
||||||
|
// Validation de la date
|
||||||
|
if (!$date) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Le paramètre date est requis',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isValidDate($date)) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||||
|
], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = $this->statsService->getDetails($entityId, $date, $eventType, $limit, $offset);
|
||||||
|
|
||||||
|
Response::json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => $details,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogService::error('Erreur lors de la récupération du détail des événements', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Erreur lors de la récupération des événements',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/stats/types
|
||||||
|
* Récupère la liste des types d'événements disponibles
|
||||||
|
*/
|
||||||
|
public function types(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$this->checkAccess()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$types = $this->statsService->getEventTypes();
|
||||||
|
|
||||||
|
Response::json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => $types,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogService::error('Erreur lors de la récupération des types d\'événements', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Erreur lors de la récupération des types',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MÉTHODES PRIVÉES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'utilisateur a accès aux stats
|
||||||
|
*/
|
||||||
|
private function checkAccess(): bool
|
||||||
|
{
|
||||||
|
$roleId = Session::getRole();
|
||||||
|
|
||||||
|
if (!in_array($roleId, self::ALLOWED_ROLES)) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Accès non autorisé. Rôle Admin ou Super-admin requis.',
|
||||||
|
], 403);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine l'entity_id à utiliser pour la requête
|
||||||
|
* Super-admin (role_id = 1) peut voir toutes les entités (null)
|
||||||
|
* Admin (role_id = 2) voit uniquement son entité
|
||||||
|
*/
|
||||||
|
private function getEntityIdForQuery(): ?int
|
||||||
|
{
|
||||||
|
$roleId = Session::getRole();
|
||||||
|
|
||||||
|
// Super-admin : accès global
|
||||||
|
if ($roleId === 1) {
|
||||||
|
// Permettre de filtrer par entité si spécifié
|
||||||
|
if (isset($_GET['entity_id'])) {
|
||||||
|
return (int) $_GET['entity_id'];
|
||||||
|
}
|
||||||
|
return null; // Stats globales
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin : uniquement son entité
|
||||||
|
return Session::getEntityId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide le format d'une date
|
||||||
|
*/
|
||||||
|
private function isValidDate(string $date): bool
|
||||||
|
{
|
||||||
|
$d = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
return $d && $d->format('Y-m-d') === $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie une réponse JSON avec support ETag et compression gzip
|
||||||
|
*
|
||||||
|
* @param array $data Données à envoyer
|
||||||
|
* @param bool $useCache Activer le cache ETag
|
||||||
|
*/
|
||||||
|
private function jsonWithCache(array $data, bool $useCache = true): void
|
||||||
|
{
|
||||||
|
// Nettoyer tout buffer existant
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encoder en JSON
|
||||||
|
$jsonResponse = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if ($jsonResponse === false) {
|
||||||
|
Response::json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Erreur d\'encodage de la réponse',
|
||||||
|
], 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers CORS
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
|
||||||
|
header("Access-Control-Allow-Origin: $origin");
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Origin, Content-Type, X-Auth-Token, Authorization, X-Requested-With');
|
||||||
|
header('Access-Control-Expose-Headers: Content-Length, ETag');
|
||||||
|
|
||||||
|
// Content-Type
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
// ETag pour le cache
|
||||||
|
if ($useCache) {
|
||||||
|
$etag = '"' . md5($jsonResponse) . '"';
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
header('Cache-Control: private, max-age=300'); // 5 minutes
|
||||||
|
|
||||||
|
// Vérifier If-None-Match
|
||||||
|
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
|
||||||
|
if ($ifNoneMatch === $etag) {
|
||||||
|
http_response_code(304); // Not Modified
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compression gzip si supportée
|
||||||
|
$supportsGzip = isset($_SERVER['HTTP_ACCEPT_ENCODING'])
|
||||||
|
&& strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false;
|
||||||
|
|
||||||
|
if ($supportsGzip && strlen($jsonResponse) > 1024) {
|
||||||
|
$compressed = gzencode($jsonResponse, 6);
|
||||||
|
if ($compressed !== false) {
|
||||||
|
header('Content-Encoding: gzip');
|
||||||
|
header('Content-Length: ' . strlen($compressed));
|
||||||
|
http_response_code(200);
|
||||||
|
echo $compressed;
|
||||||
|
flush();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réponse non compressée
|
||||||
|
header('Content-Length: ' . strlen($jsonResponse));
|
||||||
|
http_response_code(200);
|
||||||
|
echo $jsonResponse;
|
||||||
|
flush();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2086,18 +2086,38 @@ class LoginController {
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
// Vérifier si une transaction est active avant de faire rollback
|
||||||
|
if ($this->db->inTransaction()) {
|
||||||
$this->db->rollBack();
|
$this->db->rollBack();
|
||||||
LogService::log('Erreur lors de la création du compte GeoSector', [
|
}
|
||||||
|
|
||||||
|
// Construire un message d'erreur détaillé pour le logging
|
||||||
|
$errorDetails = [
|
||||||
'level' => 'error',
|
'level' => 'error',
|
||||||
'error' => $e->getMessage(),
|
'exception_class' => get_class($e),
|
||||||
'email' => $email,
|
'error_message' => $e->getMessage(),
|
||||||
'amicaleName' => $amicaleName,
|
'error_code' => $e->getCode(),
|
||||||
'postalCode' => $postalCode
|
'file' => $e->getFile(),
|
||||||
]);
|
'line' => $e->getLine(),
|
||||||
|
'email' => $email ?? 'non disponible',
|
||||||
|
'amicaleName' => $amicaleName ?? 'non disponible',
|
||||||
|
'postalCode' => $postalCode ?? 'non disponible',
|
||||||
|
'trace' => $e->getTraceAsString()
|
||||||
|
];
|
||||||
|
|
||||||
|
// Si c'est une PDOException, ajouter les infos SQL
|
||||||
|
if ($e instanceof PDOException) {
|
||||||
|
$errorDetails['pdo_error_info'] = $this->db->errorInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
LogService::log('Erreur lors de la création du compte GeoSector', $errorDetails);
|
||||||
|
|
||||||
|
// Retourner un message utilisateur clair (ne pas exposer les détails techniques)
|
||||||
|
$userMessage = 'Une erreur est survenue lors de la création du compte. Veuillez réessayer ou contacter le support.';
|
||||||
|
|
||||||
Response::json([
|
Response::json([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => $e->getMessage()
|
'message' => $userMessage
|
||||||
], 500);
|
], 500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ require_once __DIR__ . '/../Services/LogService.php';
|
|||||||
require_once __DIR__ . '/../Services/EventLogService.php';
|
require_once __DIR__ . '/../Services/EventLogService.php';
|
||||||
require_once __DIR__ . '/../Services/ApiService.php';
|
require_once __DIR__ . '/../Services/ApiService.php';
|
||||||
require_once __DIR__ . '/../Services/ReceiptService.php';
|
require_once __DIR__ . '/../Services/ReceiptService.php';
|
||||||
|
require_once __DIR__ . '/../Services/SectorService.php';
|
||||||
|
|
||||||
use PDO;
|
use PDO;
|
||||||
use PDOException;
|
use PDOException;
|
||||||
@@ -19,6 +20,7 @@ use Session;
|
|||||||
use App\Services\LogService;
|
use App\Services\LogService;
|
||||||
use App\Services\EventLogService;
|
use App\Services\EventLogService;
|
||||||
use App\Services\ApiService;
|
use App\Services\ApiService;
|
||||||
|
use App\Services\SectorService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
|
|
||||||
@@ -516,14 +518,26 @@ class PassageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer ope_users.id pour l'utilisateur du passage
|
// Récupérer ope_users.id pour l'utilisateur du passage
|
||||||
// $data['fk_user'] contient users.id, on doit le convertir en ope_users.id
|
// $data['fk_user'] peut contenir soit users.id soit ope_users.id
|
||||||
$passageUserId = (int)$data['fk_user'];
|
$passageUserId = (int)$data['fk_user'];
|
||||||
|
|
||||||
|
// Vérifier d'abord si c'est déjà un ope_users.id valide
|
||||||
|
$stmtCheckOpeUser = $this->db->prepare('
|
||||||
|
SELECT id FROM ope_users
|
||||||
|
WHERE id = ? AND fk_operation = ?
|
||||||
|
');
|
||||||
|
$stmtCheckOpeUser->execute([$passageUserId, $operationId]);
|
||||||
|
$opeUserId = $stmtCheckOpeUser->fetchColumn();
|
||||||
|
|
||||||
|
if (!$opeUserId) {
|
||||||
|
// Ce n'est pas un ope_users.id, essayer de le convertir depuis users.id
|
||||||
$stmtOpeUser = $this->db->prepare('
|
$stmtOpeUser = $this->db->prepare('
|
||||||
SELECT id FROM ope_users
|
SELECT id FROM ope_users
|
||||||
WHERE fk_user = ? AND fk_operation = ?
|
WHERE fk_user = ? AND fk_operation = ?
|
||||||
');
|
');
|
||||||
$stmtOpeUser->execute([$passageUserId, $operationId]);
|
$stmtOpeUser->execute([$passageUserId, $operationId]);
|
||||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
if (!$opeUserId) {
|
if (!$opeUserId) {
|
||||||
Response::json([
|
Response::json([
|
||||||
@@ -533,6 +547,88 @@ class PassageController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Détermination automatique du secteur
|
||||||
|
$sectorId = null;
|
||||||
|
$gpsLat = isset($data['gps_lat']) && $data['gps_lat'] !== '' ? (float)$data['gps_lat'] : null;
|
||||||
|
$gpsLng = isset($data['gps_lng']) && $data['gps_lng'] !== '' ? (float)$data['gps_lng'] : null;
|
||||||
|
|
||||||
|
// 1. Si fk_sector > 0 fourni → l'utiliser directement
|
||||||
|
if (isset($data['fk_sector']) && (int)$data['fk_sector'] > 0) {
|
||||||
|
$sectorId = (int)$data['fk_sector'];
|
||||||
|
LogService::info('[PassageController] Secteur fourni par le client', [
|
||||||
|
'sector_id' => $sectorId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Si pas de secteur et GPS valide (différent de 0.0) → recherche par GPS
|
||||||
|
if ($sectorId === null && $gpsLat !== null && $gpsLng !== null && ($gpsLat != 0.0 || $gpsLng != 0.0)) {
|
||||||
|
$sectorService = new SectorService();
|
||||||
|
$sectorId = $sectorService->findSectorByGps($operationId, $gpsLat, $gpsLng);
|
||||||
|
LogService::info('[PassageController] Recherche secteur par GPS', [
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'lat' => $gpsLat,
|
||||||
|
'lng' => $gpsLng,
|
||||||
|
'sector_found' => $sectorId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Si toujours pas de secteur et adresse fournie → géocodage + recherche
|
||||||
|
if ($sectorId === null && !empty($data['numero']) && !empty($data['rue']) && !empty($data['ville'])) {
|
||||||
|
// Récupérer le code postal de l'entité pour la vérification du département
|
||||||
|
$stmtEntite = $this->db->prepare('
|
||||||
|
SELECT e.code_postal FROM entites e
|
||||||
|
INNER JOIN operations o ON o.fk_entite = e.id
|
||||||
|
WHERE o.id = ?
|
||||||
|
');
|
||||||
|
$stmtEntite->execute([$operationId]);
|
||||||
|
$entiteCp = $stmtEntite->fetchColumn() ?: '';
|
||||||
|
|
||||||
|
$sectorService = new SectorService();
|
||||||
|
$result = $sectorService->findSectorByAddress(
|
||||||
|
$operationId,
|
||||||
|
trim($data['numero']),
|
||||||
|
$data['rue_bis'] ?? '',
|
||||||
|
trim($data['rue']),
|
||||||
|
trim($data['ville']),
|
||||||
|
$entiteCp
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$sectorId = $result['sector_id'];
|
||||||
|
// Mettre à jour les coordonnées GPS si le géocodage les a trouvées
|
||||||
|
if ($result['gps_lat'] && $result['gps_lng']) {
|
||||||
|
$gpsLat = $result['gps_lat'];
|
||||||
|
$gpsLng = $result['gps_lng'];
|
||||||
|
}
|
||||||
|
LogService::info('[PassageController] Recherche secteur par adresse', [
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'adresse' => $data['numero'] . ' ' . $data['rue'] . ' ' . $data['ville'],
|
||||||
|
'sector_found' => $sectorId,
|
||||||
|
'gps_geocoded' => ($result['gps_lat'] && $result['gps_lng'])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback : si toujours pas de secteur, prendre le 1er secteur de l'opération
|
||||||
|
if ($sectorId === null) {
|
||||||
|
$stmtFirstSector = $this->db->prepare('
|
||||||
|
SELECT id FROM ope_sectors
|
||||||
|
WHERE fk_operation = ? AND chk_active = 1
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
');
|
||||||
|
$stmtFirstSector->execute([$operationId]);
|
||||||
|
$firstSectorId = $stmtFirstSector->fetchColumn();
|
||||||
|
|
||||||
|
if ($firstSectorId) {
|
||||||
|
$sectorId = (int)$firstSectorId;
|
||||||
|
LogService::info('[PassageController] Fallback: premier secteur de l\'opération', [
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'sector_id' => $sectorId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chiffrement des données sensibles
|
// Chiffrement des données sensibles
|
||||||
$encryptedName = '';
|
$encryptedName = '';
|
||||||
if (isset($data['name']) && !empty(trim($data['name']))) {
|
if (isset($data['name']) && !empty(trim($data['name']))) {
|
||||||
@@ -549,7 +645,7 @@ class PassageController {
|
|||||||
// Préparation des données pour l'insertion
|
// Préparation des données pour l'insertion
|
||||||
$insertData = [
|
$insertData = [
|
||||||
'fk_operation' => $operationId,
|
'fk_operation' => $operationId,
|
||||||
'fk_sector' => isset($data['fk_sector']) ? (int)$data['fk_sector'] : 0,
|
'fk_sector' => $sectorId, // Peut être NULL si aucun secteur trouvé
|
||||||
'fk_user' => $opeUserId,
|
'fk_user' => $opeUserId,
|
||||||
'fk_adresse' => $data['fk_adresse'] ?? '',
|
'fk_adresse' => $data['fk_adresse'] ?? '',
|
||||||
'passed_at' => isset($data['passed_at']) ? $data['passed_at'] : null,
|
'passed_at' => isset($data['passed_at']) ? $data['passed_at'] : null,
|
||||||
@@ -562,8 +658,8 @@ class PassageController {
|
|||||||
'appt' => $data['appt'] ?? '',
|
'appt' => $data['appt'] ?? '',
|
||||||
'niveau' => $data['niveau'] ?? '',
|
'niveau' => $data['niveau'] ?? '',
|
||||||
'residence' => $data['residence'] ?? '',
|
'residence' => $data['residence'] ?? '',
|
||||||
'gps_lat' => $data['gps_lat'] ?? '',
|
'gps_lat' => $gpsLat ?? '',
|
||||||
'gps_lng' => $data['gps_lng'] ?? '',
|
'gps_lng' => $gpsLng ?? '',
|
||||||
'encrypted_name' => $encryptedName,
|
'encrypted_name' => $encryptedName,
|
||||||
'montant' => isset($data['montant']) ? (float)$data['montant'] : 0.00,
|
'montant' => isset($data['montant']) ? (float)$data['montant'] : 0.00,
|
||||||
'fk_type_reglement' => isset($data['fk_type_reglement']) ? (int)$data['fk_type_reglement'] : 1,
|
'fk_type_reglement' => isset($data['fk_type_reglement']) ? (int)$data['fk_type_reglement'] : 1,
|
||||||
@@ -596,7 +692,7 @@ class PassageController {
|
|||||||
EventLogService::logPassageCreated(
|
EventLogService::logPassageCreated(
|
||||||
(int)$passageId,
|
(int)$passageId,
|
||||||
$insertData['fk_operation'],
|
$insertData['fk_operation'],
|
||||||
$insertData['fk_sector'],
|
$insertData['fk_sector'] ?? 0, // 0 si secteur non trouvé
|
||||||
$insertData['montant'],
|
$insertData['montant'],
|
||||||
(string)$insertData['fk_type_reglement']
|
(string)$insertData['fk_type_reglement']
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ require_once __DIR__ . '/../Services/ApiService.php';
|
|||||||
class SectorController
|
class SectorController
|
||||||
{
|
{
|
||||||
private \PDO $db;
|
private \PDO $db;
|
||||||
private LogService $logService;
|
|
||||||
private AddressService $addressService;
|
private AddressService $addressService;
|
||||||
private DepartmentBoundaryService $boundaryService;
|
private DepartmentBoundaryService $boundaryService;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->db = Database::getInstance();
|
$this->db = Database::getInstance();
|
||||||
$this->logService = new LogService();
|
|
||||||
$this->addressService = new AddressService();
|
$this->addressService = new AddressService();
|
||||||
$this->boundaryService = new DepartmentBoundaryService();
|
$this->boundaryService = new DepartmentBoundaryService();
|
||||||
}
|
}
|
||||||
@@ -72,7 +70,7 @@ class SectorController
|
|||||||
Response::json(['status' => 'success', 'data' => $sectors]);
|
Response::json(['status' => 'success', 'data' => $sectors]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('Erreur lors de la récupération des secteurs', [
|
LogService::error('Erreur lors de la récupération des secteurs', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'trace' => $e->getTraceAsString()
|
'trace' => $e->getTraceAsString()
|
||||||
]);
|
]);
|
||||||
@@ -152,14 +150,14 @@ class SectorController
|
|||||||
$departmentsTouched = $this->boundaryService->getDepartmentsForSector($coordinates);
|
$departmentsTouched = $this->boundaryService->getDepartmentsForSector($coordinates);
|
||||||
|
|
||||||
if (empty($departmentsTouched)) {
|
if (empty($departmentsTouched)) {
|
||||||
$this->logService->warning('Aucun département trouvé pour le secteur', [
|
LogService::warning('Aucun département trouvé pour le secteur', [
|
||||||
'libelle' => $data['libelle'],
|
'libelle' => $data['libelle'],
|
||||||
'entity_id' => $entityId,
|
'entity_id' => $entityId,
|
||||||
'entity_dept' => $departement
|
'entity_dept' => $departement
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->warning('Impossible de vérifier les limites départementales', [
|
LogService::warning('Impossible de vérifier les limites départementales', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'libelle' => $data['libelle']
|
'libelle' => $data['libelle']
|
||||||
]);
|
]);
|
||||||
@@ -169,7 +167,7 @@ class SectorController
|
|||||||
try {
|
try {
|
||||||
$addressCount = $this->addressService->countAddressesInPolygon($coordinates, $entityId);
|
$addressCount = $this->addressService->countAddressesInPolygon($coordinates, $entityId);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->warning('Impossible de récupérer les adresses du secteur', [
|
LogService::warning('Impossible de récupérer les adresses du secteur', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'libelle' => $data['libelle'],
|
'libelle' => $data['libelle'],
|
||||||
'entity_id' => $entityId
|
'entity_id' => $entityId
|
||||||
@@ -208,7 +206,7 @@ class SectorController
|
|||||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||||
|
|
||||||
if (!$opeUserId) {
|
if (!$opeUserId) {
|
||||||
$this->logService->warning('ope_users.id non trouvé pour cette opération', [
|
LogService::warning('ope_users.id non trouvé pour cette opération', [
|
||||||
'ope_users_id' => $memberId,
|
'ope_users_id' => $memberId,
|
||||||
'operation_id' => $operationId
|
'operation_id' => $operationId
|
||||||
]);
|
]);
|
||||||
@@ -275,7 +273,7 @@ class SectorController
|
|||||||
|
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->warning('Erreur lors de la récupération des passages orphelins', [
|
LogService::warning('Erreur lors de la récupération des passages orphelins', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
@@ -335,7 +333,7 @@ class SectorController
|
|||||||
$firstOpeUserId = $stmtFirstOpeUser->fetchColumn();
|
$firstOpeUserId = $stmtFirstOpeUser->fetchColumn();
|
||||||
|
|
||||||
if (!$firstOpeUserId) {
|
if (!$firstOpeUserId) {
|
||||||
$this->logService->warning('Premier ope_users.id non trouvé pour cette opération', [
|
LogService::warning('Premier ope_users.id non trouvé pour cette opération', [
|
||||||
'ope_users_id' => $users[0],
|
'ope_users_id' => $users[0],
|
||||||
'operation_id' => $operationId
|
'operation_id' => $operationId
|
||||||
]);
|
]);
|
||||||
@@ -401,7 +399,7 @@ class SectorController
|
|||||||
|
|
||||||
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
|
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
|
||||||
if ($fkHabitat == 2 && $nbLog > 1) {
|
if ($fkHabitat == 2 && $nbLog > 1) {
|
||||||
$this->logService->info('[SectorController] Création passages immeuble avec GPS uniformisés', [
|
LogService::info('[SectorController] Création passages immeuble avec GPS uniformisés', [
|
||||||
'address_id' => $address['id'],
|
'address_id' => $address['id'],
|
||||||
'nb_passages' => $nbLog,
|
'nb_passages' => $nbLog,
|
||||||
'gps_lat' => $gpsLat,
|
'gps_lat' => $gpsLat,
|
||||||
@@ -410,7 +408,7 @@ class SectorController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->warning('Erreur lors de la création d\'un passage', [
|
LogService::warning('Erreur lors de la création d\'un passage', [
|
||||||
'address_id' => $address['id'],
|
'address_id' => $address['id'],
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
@@ -421,7 +419,7 @@ class SectorController
|
|||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// En cas d'erreur avec les adresses, on ne bloque pas la création du secteur
|
// En cas d'erreur avec les adresses, on ne bloque pas la création du secteur
|
||||||
$this->logService->error('Erreur lors du stockage des adresses du secteur', [
|
LogService::error('Erreur lors du stockage des adresses du secteur', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'entity_id' => $entityId
|
'entity_id' => $entityId
|
||||||
@@ -525,7 +523,7 @@ class SectorController
|
|||||||
$responseData['users_sectors'][] = $userData;
|
$responseData['users_sectors'][] = $userData;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('Secteur créé', [
|
LogService::info('Secteur créé', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'libelle' => $sectorData['libelle'],
|
'libelle' => $sectorData['libelle'],
|
||||||
'entity_id' => $entityId,
|
'entity_id' => $entityId,
|
||||||
@@ -567,7 +565,7 @@ class SectorController
|
|||||||
if ($this->db->inTransaction()) {
|
if ($this->db->inTransaction()) {
|
||||||
$this->db->rollBack();
|
$this->db->rollBack();
|
||||||
}
|
}
|
||||||
$this->logService->error('Erreur lors de la création du secteur', [
|
LogService::error('Erreur lors de la création du secteur', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'data' => $data ?? null
|
'data' => $data ?? null
|
||||||
]);
|
]);
|
||||||
@@ -634,7 +632,7 @@ class SectorController
|
|||||||
|
|
||||||
// Gestion des membres (reçus comme 'users' depuis Flutter)
|
// Gestion des membres (reçus comme 'users' depuis Flutter)
|
||||||
if (isset($data['users'])) {
|
if (isset($data['users'])) {
|
||||||
$this->logService->info('[UPDATE USERS] Début modification des membres', [
|
LogService::info('[UPDATE USERS] Début modification des membres', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'users_demandes' => $data['users'],
|
'users_demandes' => $data['users'],
|
||||||
'nb_users' => count($data['users'])
|
'nb_users' => count($data['users'])
|
||||||
@@ -642,27 +640,27 @@ class SectorController
|
|||||||
|
|
||||||
// Récupérer l'opération du secteur pour l'INSERT
|
// Récupérer l'opération du secteur pour l'INSERT
|
||||||
$opQuery = "SELECT fk_operation FROM ope_sectors WHERE id = :sector_id";
|
$opQuery = "SELECT fk_operation FROM ope_sectors WHERE id = :sector_id";
|
||||||
$this->logService->info('[UPDATE USERS] SQL - Récupération fk_operation', [
|
LogService::info('[UPDATE USERS] SQL - Récupération fk_operation', [
|
||||||
'query' => $opQuery,
|
'query' => $opQuery,
|
||||||
'params' => ['sector_id' => $id]
|
'params' => ['sector_id' => $id]
|
||||||
]);
|
]);
|
||||||
$opStmt = $this->db->prepare($opQuery);
|
$opStmt = $this->db->prepare($opQuery);
|
||||||
$opStmt->execute(['sector_id' => $id]);
|
$opStmt->execute(['sector_id' => $id]);
|
||||||
$operationId = $opStmt->fetch()['fk_operation'];
|
$operationId = $opStmt->fetch()['fk_operation'];
|
||||||
$this->logService->info('[UPDATE USERS] fk_operation récupéré', [
|
LogService::info('[UPDATE USERS] fk_operation récupéré', [
|
||||||
'operation_id' => $operationId
|
'operation_id' => $operationId
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Supprimer les affectations existantes
|
// Supprimer les affectations existantes
|
||||||
$deleteQuery = "DELETE FROM ope_users_sectors WHERE fk_sector = :sector_id";
|
$deleteQuery = "DELETE FROM ope_users_sectors WHERE fk_sector = :sector_id";
|
||||||
$this->logService->info('[UPDATE USERS] SQL - Suppression des anciens membres', [
|
LogService::info('[UPDATE USERS] SQL - Suppression des anciens membres', [
|
||||||
'query' => $deleteQuery,
|
'query' => $deleteQuery,
|
||||||
'params' => ['sector_id' => $id]
|
'params' => ['sector_id' => $id]
|
||||||
]);
|
]);
|
||||||
$deleteStmt = $this->db->prepare($deleteQuery);
|
$deleteStmt = $this->db->prepare($deleteQuery);
|
||||||
$deleteStmt->execute(['sector_id' => $id]);
|
$deleteStmt->execute(['sector_id' => $id]);
|
||||||
$deletedCount = $deleteStmt->rowCount();
|
$deletedCount = $deleteStmt->rowCount();
|
||||||
$this->logService->info('[UPDATE USERS] Membres supprimés', [
|
LogService::info('[UPDATE USERS] Membres supprimés', [
|
||||||
'nb_deleted' => $deletedCount
|
'nb_deleted' => $deletedCount
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -670,7 +668,7 @@ class SectorController
|
|||||||
if (!empty($data['users'])) {
|
if (!empty($data['users'])) {
|
||||||
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
|
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
|
||||||
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
|
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
|
||||||
$this->logService->info('[UPDATE USERS] SQL - Requête INSERT préparée', [
|
LogService::info('[UPDATE USERS] SQL - Requête INSERT préparée', [
|
||||||
'query' => $insertQuery
|
'query' => $insertQuery
|
||||||
]);
|
]);
|
||||||
$insertStmt = $this->db->prepare($insertQuery);
|
$insertStmt = $this->db->prepare($insertQuery);
|
||||||
@@ -689,7 +687,7 @@ class SectorController
|
|||||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||||
|
|
||||||
if (!$opeUserId) {
|
if (!$opeUserId) {
|
||||||
$this->logService->warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
|
LogService::warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
|
||||||
'ope_users_id' => $memberId,
|
'ope_users_id' => $memberId,
|
||||||
'operation_id' => $operationId
|
'operation_id' => $operationId
|
||||||
]);
|
]);
|
||||||
@@ -703,17 +701,17 @@ class SectorController
|
|||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'user_creat' => $_SESSION['user_id'] ?? null
|
'user_creat' => $_SESSION['user_id'] ?? null
|
||||||
];
|
];
|
||||||
$this->logService->info('[UPDATE USERS] SQL - INSERT user', [
|
LogService::info('[UPDATE USERS] SQL - INSERT user', [
|
||||||
'params' => $params
|
'params' => $params
|
||||||
]);
|
]);
|
||||||
$insertStmt->execute($params);
|
$insertStmt->execute($params);
|
||||||
$insertedUsers[] = $memberId;
|
$insertedUsers[] = $memberId;
|
||||||
$this->logService->info('[UPDATE USERS] User inséré avec succès', [
|
LogService::info('[UPDATE USERS] User inséré avec succès', [
|
||||||
'user_id' => $memberId
|
'user_id' => $memberId
|
||||||
]);
|
]);
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
$failedUsers[] = $memberId;
|
$failedUsers[] = $memberId;
|
||||||
$this->logService->warning('[UPDATE USERS] ERREUR insertion user', [
|
LogService::warning('[UPDATE USERS] ERREUR insertion user', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'user_id' => $memberId,
|
'user_id' => $memberId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
@@ -722,7 +720,7 @@ class SectorController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('[UPDATE USERS] Résultat des insertions', [
|
LogService::info('[UPDATE USERS] Résultat des insertions', [
|
||||||
'users_demandes' => $data['users'],
|
'users_demandes' => $data['users'],
|
||||||
'users_inseres' => $insertedUsers,
|
'users_inseres' => $insertedUsers,
|
||||||
'users_echoues' => $failedUsers,
|
'users_echoues' => $failedUsers,
|
||||||
@@ -744,7 +742,7 @@ class SectorController
|
|||||||
$chkAdressesChange = $data['chk_adresses_change'] ?? 1;
|
$chkAdressesChange = $data['chk_adresses_change'] ?? 1;
|
||||||
|
|
||||||
if (isset($data['sector']) && $chkAdressesChange == 0) {
|
if (isset($data['sector']) && $chkAdressesChange == 0) {
|
||||||
$this->logService->info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
|
LogService::info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'chk_adresses_change' => $chkAdressesChange
|
'chk_adresses_change' => $chkAdressesChange
|
||||||
]);
|
]);
|
||||||
@@ -770,7 +768,7 @@ class SectorController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer et stocker les nouvelles adresses
|
// Récupérer et stocker les nouvelles adresses
|
||||||
$this->logService->info('[UPDATE] Récupération des adresses', [
|
LogService::info('[UPDATE] Récupération des adresses', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'entity_id' => $entityId,
|
'entity_id' => $entityId,
|
||||||
'nb_points' => count($coordinates)
|
'nb_points' => count($coordinates)
|
||||||
@@ -781,7 +779,7 @@ class SectorController
|
|||||||
// Enrichir les adresses avec les données bâtiments
|
// Enrichir les adresses avec les données bâtiments
|
||||||
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
|
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
|
||||||
|
|
||||||
$this->logService->info('[UPDATE] Adresses récupérées', [
|
LogService::info('[UPDATE] Adresses récupérées', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'nb_addresses' => count($addresses)
|
'nb_addresses' => count($addresses)
|
||||||
]);
|
]);
|
||||||
@@ -815,12 +813,12 @@ class SectorController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('[UPDATE] Adresses stockées dans sectors_adresses', [
|
LogService::info('[UPDATE] Adresses stockées dans sectors_adresses', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'nb_stored' => count($addresses)
|
'nb_stored' => count($addresses)
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$this->logService->warning('[UPDATE] Aucune adresse trouvée pour le secteur', [
|
LogService::warning('[UPDATE] Aucune adresse trouvée pour le secteur', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'entity_id' => $entityId
|
'entity_id' => $entityId
|
||||||
]);
|
]);
|
||||||
@@ -828,19 +826,19 @@ class SectorController
|
|||||||
|
|
||||||
// Vérifier si c'est un problème de connexion à la base d'adresses
|
// Vérifier si c'est un problème de connexion à la base d'adresses
|
||||||
if (!$this->addressService->isConnected()) {
|
if (!$this->addressService->isConnected()) {
|
||||||
$this->logService->warning('[UPDATE] Base d\'adresses non accessible - passages créés sans adresses', [
|
LogService::warning('[UPDATE] Base d\'adresses non accessible - passages créés sans adresses', [
|
||||||
'sector_id' => $id
|
'sector_id' => $id
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('[UPDATE] Erreur lors de la mise à jour des adresses du secteur', [
|
LogService::error('[UPDATE] Erreur lors de la mise à jour des adresses du secteur', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maintenant que les adresses sont mises à jour, traiter les passages
|
// Maintenant que les adresses sont mises à jour, traiter les passages
|
||||||
$this->logService->info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
|
LogService::info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
|
||||||
$passageCounters = $this->updatePassagesForSector($id, $data['sector']);
|
$passageCounters = $this->updatePassagesForSector($id, $data['sector']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,7 +932,7 @@ class SectorController
|
|||||||
WHERE ous.fk_sector = :sector_id
|
WHERE ous.fk_sector = :sector_id
|
||||||
ORDER BY u.id";
|
ORDER BY u.id";
|
||||||
|
|
||||||
$this->logService->info('[UPDATE USERS] SQL - Récupération finale des users', [
|
LogService::info('[UPDATE USERS] SQL - Récupération finale des users', [
|
||||||
'query' => $usersQuery,
|
'query' => $usersQuery,
|
||||||
'params' => ['sector_id' => $id]
|
'params' => ['sector_id' => $id]
|
||||||
]);
|
]);
|
||||||
@@ -944,7 +942,7 @@ class SectorController
|
|||||||
$usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC);
|
$usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
$userIds = array_column($usersSectors, 'id');
|
$userIds = array_column($usersSectors, 'id');
|
||||||
$this->logService->info('[UPDATE USERS] Users récupérés après commit', [
|
LogService::info('[UPDATE USERS] Users récupérés après commit', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'users_ids' => $userIds,
|
'users_ids' => $userIds,
|
||||||
'nb_users' => count($userIds),
|
'nb_users' => count($userIds),
|
||||||
@@ -971,7 +969,7 @@ class SectorController
|
|||||||
$usersDecrypted[] = $userData;
|
$usersDecrypted[] = $userData;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('Secteur modifié', [
|
LogService::info('Secteur modifié', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'updates' => array_keys($data),
|
'updates' => array_keys($data),
|
||||||
'passage_counters' => $passageCounters,
|
'passage_counters' => $passageCounters,
|
||||||
@@ -999,7 +997,7 @@ class SectorController
|
|||||||
if ($this->db->inTransaction()) {
|
if ($this->db->inTransaction()) {
|
||||||
$this->db->rollBack();
|
$this->db->rollBack();
|
||||||
}
|
}
|
||||||
$this->logService->error('Erreur lors de la modification du secteur', [
|
LogService::error('Erreur lors de la modification du secteur', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
@@ -1065,7 +1063,7 @@ class SectorController
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('Erreur lors de la récupération des adresses du secteur', [
|
LogService::error('Erreur lors de la récupération des adresses du secteur', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
@@ -1198,7 +1196,7 @@ class SectorController
|
|||||||
$passagesDecrypted[] = $passage;
|
$passagesDecrypted[] = $passage;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('Secteur supprimé', [
|
LogService::info('Secteur supprimé', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'libelle' => $sector['libelle'],
|
'libelle' => $sector['libelle'],
|
||||||
'passages_deleted' => $passagesToDelete,
|
'passages_deleted' => $passagesToDelete,
|
||||||
@@ -1216,7 +1214,7 @@ class SectorController
|
|||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->db->rollBack();
|
$this->db->rollBack();
|
||||||
$this->logService->error('Erreur lors de la suppression du secteur', [
|
LogService::error('Erreur lors de la suppression du secteur', [
|
||||||
'sector_id' => $id,
|
'sector_id' => $id,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
@@ -1238,7 +1236,7 @@ class SectorController
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('Erreur lors de la vérification des contours départementaux', [
|
LogService::error('Erreur lors de la vérification des contours départementaux', [
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
|
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
|
||||||
@@ -1298,7 +1296,7 @@ class SectorController
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('Erreur lors de la vérification des limites', [
|
LogService::error('Erreur lors de la vérification des limites', [
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
|
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
|
||||||
@@ -1422,7 +1420,7 @@ class SectorController
|
|||||||
$addressesStmt->execute(['sector_id' => $sectorId]);
|
$addressesStmt->execute(['sector_id' => $sectorId]);
|
||||||
$addresses = $addressesStmt->fetchAll();
|
$addresses = $addressesStmt->fetchAll();
|
||||||
|
|
||||||
$this->logService->info('[updatePassagesForSector] Adresses dans sectors_adresses', [
|
LogService::info('[updatePassagesForSector] Adresses dans sectors_adresses', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'nb_addresses' => count($addresses)
|
'nb_addresses' => count($addresses)
|
||||||
]);
|
]);
|
||||||
@@ -1435,7 +1433,7 @@ class SectorController
|
|||||||
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
|
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
|
||||||
|
|
||||||
if ($firstUserId && !empty($addresses)) {
|
if ($firstUserId && !empty($addresses)) {
|
||||||
$this->logService->info('[updatePassagesForSector] Traitement des passages', [
|
LogService::info('[updatePassagesForSector] Traitement des passages', [
|
||||||
'user_id' => $firstUserId,
|
'user_id' => $firstUserId,
|
||||||
'nb_addresses' => count($addresses)
|
'nb_addresses' => count($addresses)
|
||||||
]);
|
]);
|
||||||
@@ -1594,7 +1592,7 @@ class SectorController
|
|||||||
$insertStmt->execute($insertParams);
|
$insertStmt->execute($insertParams);
|
||||||
$counters['passages_created'] = count($toInsert);
|
$counters['passages_created'] = count($toInsert);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('Erreur lors de l\'insertion multiple des passages', [
|
LogService::error('Erreur lors de l\'insertion multiple des passages', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
@@ -1658,12 +1656,12 @@ class SectorController
|
|||||||
$counters['passages_updated'] = count($toUpdate);
|
$counters['passages_updated'] = count($toUpdate);
|
||||||
|
|
||||||
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
|
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
|
||||||
$this->logService->info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
|
LogService::info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
|
||||||
'nb_updated' => count($toUpdate),
|
'nb_updated' => count($toUpdate),
|
||||||
'sector_id' => $sectorId
|
'sector_id' => $sectorId
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('Erreur lors de la mise à jour multiple des passages', [
|
LogService::error('Erreur lors de la mise à jour multiple des passages', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
@@ -1680,7 +1678,7 @@ class SectorController
|
|||||||
$deleteStmt->execute($toDelete);
|
$deleteStmt->execute($toDelete);
|
||||||
$counters['passages_deleted'] += count($toDelete);
|
$counters['passages_deleted'] += count($toDelete);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('Erreur lors de la suppression multiple des passages', [
|
LogService::error('Erreur lors de la suppression multiple des passages', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
@@ -1688,7 +1686,7 @@ class SectorController
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
$this->logService->warning('[updatePassagesForSector] Pas de création de passages', [
|
LogService::warning('[updatePassagesForSector] Pas de création de passages', [
|
||||||
'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses',
|
'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses',
|
||||||
'first_user_id' => $firstUserId,
|
'first_user_id' => $firstUserId,
|
||||||
'nb_addresses' => count($addresses)
|
'nb_addresses' => count($addresses)
|
||||||
@@ -1697,14 +1695,14 @@ class SectorController
|
|||||||
|
|
||||||
|
|
||||||
// Retourner les compteurs détaillés
|
// Retourner les compteurs détaillés
|
||||||
$this->logService->info('[updatePassagesForSector] Fin traitement', [
|
LogService::info('[updatePassagesForSector] Fin traitement', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'counters' => $counters
|
'counters' => $counters
|
||||||
]);
|
]);
|
||||||
return $counters;
|
return $counters;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('Erreur lors de la mise à jour des passages', [
|
LogService::error('Erreur lors de la mise à jour des passages', [
|
||||||
'sector_id' => $sectorId,
|
'sector_id' => $sectorId,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ class StripeController extends Controller {
|
|||||||
SELECT p.*, o.fk_entite, o.id as operation_id
|
SELECT p.*, o.fk_entite, o.id as operation_id
|
||||||
FROM ope_pass p
|
FROM ope_pass p
|
||||||
JOIN operations o ON p.fk_operation = o.id
|
JOIN operations o ON p.fk_operation = o.id
|
||||||
WHERE p.id = ? AND p.fk_user = ?
|
JOIN ope_users ou ON p.fk_user = ou.id
|
||||||
|
WHERE p.id = ? AND ou.fk_user = ?
|
||||||
');
|
');
|
||||||
$stmt->execute([$passageId, Session::getUserId()]);
|
$stmt->execute([$passageId, Session::getUserId()]);
|
||||||
$passage = $stmt->fetch();
|
$passage = $stmt->fetch();
|
||||||
@@ -469,70 +470,6 @@ class StripeController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/stripe/devices/check-tap-to-pay
|
|
||||||
* Vérifier la compatibilité Tap to Pay d'un appareil
|
|
||||||
*/
|
|
||||||
public function checkTapToPayCapability(): void {
|
|
||||||
try {
|
|
||||||
$data = $this->getJsonInput();
|
|
||||||
|
|
||||||
$platform = $data['platform'] ?? '';
|
|
||||||
|
|
||||||
if ($platform === 'ios') {
|
|
||||||
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 16.4+)
|
|
||||||
$this->sendSuccess([
|
|
||||||
'message' => 'Vérification iOS à faire côté client',
|
|
||||||
'requirements' => 'iPhone XS ou plus récent avec iOS 16.4+',
|
|
||||||
'details' => 'iOS 16.4 minimum requis pour le support PIN complet'
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($platform === 'android') {
|
|
||||||
$manufacturer = $data['manufacturer'] ?? '';
|
|
||||||
$model = $data['model'] ?? '';
|
|
||||||
|
|
||||||
if (!$manufacturer || !$model) {
|
|
||||||
$this->sendError('Manufacturer et model requis pour Android', 400);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->stripeService->checkAndroidTapToPayCompatibility($manufacturer, $model);
|
|
||||||
|
|
||||||
if ($result['success']) {
|
|
||||||
$this->sendSuccess($result);
|
|
||||||
} else {
|
|
||||||
$this->sendError($result['message'], 400);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->sendError('Platform doit être ios ou android', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->sendError('Erreur: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/stripe/devices/certified-android
|
|
||||||
* Récupérer la liste des appareils Android certifiés
|
|
||||||
*/
|
|
||||||
public function getCertifiedAndroidDevices(): void {
|
|
||||||
try {
|
|
||||||
$result = $this->stripeService->getCertifiedAndroidDevices();
|
|
||||||
|
|
||||||
if ($result['success']) {
|
|
||||||
$this->sendSuccess(['devices' => $result['devices']]);
|
|
||||||
} else {
|
|
||||||
$this->sendError($result['message'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->sendError('Erreur: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/stripe/config
|
* GET /api/stripe/config
|
||||||
* Récupérer la configuration publique Stripe
|
* Récupérer la configuration publique Stripe
|
||||||
@@ -784,4 +721,117 @@ class StripeController extends Controller {
|
|||||||
$this->sendError('Erreur lors de la création de la location: ' . $e->getMessage());
|
$this->sendError('Erreur lors de la création de la location: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/terminal/connection-token
|
||||||
|
* Créer un Connection Token pour Stripe Terminal/Tap to Pay
|
||||||
|
* Requis par le SDK Stripe Terminal pour se connecter aux readers
|
||||||
|
*/
|
||||||
|
public function createConnectionToken(): void {
|
||||||
|
try {
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$data = $this->getJsonInput();
|
||||||
|
$entiteId = $data['amicale_id'] ?? Session::getEntityId();
|
||||||
|
|
||||||
|
if (!$entiteId) {
|
||||||
|
$this->sendError('ID entité requis', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les droits sur cette entité
|
||||||
|
$userRole = Session::getRole() ?? 0;
|
||||||
|
if (Session::getEntityId() != $entiteId && $userRole < 3) {
|
||||||
|
$this->sendError('Non autorisé pour cette entité', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->stripeService->createConnectionToken($entiteId);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$this->sendSuccess([
|
||||||
|
'secret' => $result['secret']
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$this->sendError($result['message'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->sendError('Erreur lors de la création du connection token: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/payments/cancel
|
||||||
|
* Annuler un PaymentIntent Stripe
|
||||||
|
*
|
||||||
|
* Payload:
|
||||||
|
* {
|
||||||
|
* "payment_intent_id": "pi_3SWvho2378xpV4Rn1J43Ks7M"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function cancelPayment(): void {
|
||||||
|
try {
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$data = $this->getJsonInput();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!isset($data['payment_intent_id'])) {
|
||||||
|
$this->sendError('payment_intent_id requis', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentIntentId = $data['payment_intent_id'];
|
||||||
|
|
||||||
|
// Vérifier que le passage existe et appartient à l'utilisateur
|
||||||
|
$stmt = $this->db->prepare('
|
||||||
|
SELECT p.*, o.fk_entite, ou.fk_user as ope_user_id
|
||||||
|
FROM ope_pass p
|
||||||
|
JOIN operations o ON p.fk_operation = o.id
|
||||||
|
JOIN ope_users ou ON p.fk_user = ou.id
|
||||||
|
WHERE p.stripe_payment_id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$paymentIntentId]);
|
||||||
|
$passage = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$passage) {
|
||||||
|
$this->sendError('Paiement non trouvé', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les droits
|
||||||
|
$userId = Session::getUserId();
|
||||||
|
$userEntityId = Session::getEntityId();
|
||||||
|
|
||||||
|
if ($passage['ope_user_id'] != $userId && $passage['fk_entite'] != $userEntityId) {
|
||||||
|
$this->sendError('Non autorisé', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annuler le PaymentIntent via StripeService
|
||||||
|
$result = $this->stripeService->cancelPaymentIntent($paymentIntentId);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
// Retirer le stripe_payment_id du passage
|
||||||
|
$stmt = $this->db->prepare('
|
||||||
|
UPDATE ope_pass
|
||||||
|
SET stripe_payment_id = NULL, updated_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$passage['id']]);
|
||||||
|
|
||||||
|
$this->sendSuccess([
|
||||||
|
'status' => 'canceled',
|
||||||
|
'payment_intent_id' => $paymentIntentId,
|
||||||
|
'passage_id' => $passage['id']
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$this->sendError($result['message'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->sendError('Erreur: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -202,86 +202,47 @@ class StripeWebhookController extends Controller {
|
|||||||
* Gérer un paiement réussi
|
* Gérer un paiement réussi
|
||||||
*/
|
*/
|
||||||
private function handlePaymentIntentSucceeded($paymentIntent): void {
|
private function handlePaymentIntentSucceeded($paymentIntent): void {
|
||||||
// Mettre à jour le statut en base
|
// Pour Tap to Pay, le paiement est instantané et déjà enregistré dans ope_pass
|
||||||
$stmt = $this->db->prepare(
|
// Ce webhook sert principalement pour les Payment Links (QR Code) asynchrones
|
||||||
"UPDATE stripe_payment_intents
|
|
||||||
SET status = :status, updated_at = NOW()
|
|
||||||
WHERE stripe_payment_intent_id = :pi_id"
|
|
||||||
);
|
|
||||||
$stmt->execute([
|
|
||||||
'status' => 'succeeded',
|
|
||||||
'pi_id' => $paymentIntent->id
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Enregistrer dans l'historique
|
// Vérifier si le passage existe et mettre à jour si nécessaire
|
||||||
$stmt = $this->db->prepare(
|
$stmt = $this->db->prepare("
|
||||||
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
|
SELECT id FROM ope_pass
|
||||||
);
|
WHERE stripe_payment_id = :pi_id
|
||||||
|
");
|
||||||
$stmt->execute(['pi_id' => $paymentIntent->id]);
|
$stmt->execute(['pi_id' => $paymentIntent->id]);
|
||||||
$localPayment = $stmt->fetch();
|
$passage = $stmt->fetch();
|
||||||
|
|
||||||
if ($localPayment) {
|
if ($passage) {
|
||||||
$stmt = $this->db->prepare(
|
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency} - Passage {$passage['id']}");
|
||||||
"INSERT INTO stripe_payment_history
|
|
||||||
(fk_payment_intent, event_type, event_data, created_at)
|
|
||||||
VALUES (:fk_pi, 'succeeded', :data, NOW())"
|
|
||||||
);
|
|
||||||
$stmt->execute([
|
|
||||||
'fk_pi' => $localPayment['id'],
|
|
||||||
'data' => json_encode([
|
|
||||||
'amount' => $paymentIntent->amount,
|
|
||||||
'currency' => $paymentIntent->currency,
|
|
||||||
'payment_method' => $paymentIntent->payment_method,
|
|
||||||
'charges' => $paymentIntent->charges->data
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Envoyer un reçu par email
|
// TODO: Envoyer un reçu par email
|
||||||
// TODO: Mettre à jour les statistiques en temps réel
|
// TODO: Mettre à jour les statistiques en temps réel
|
||||||
|
} else {
|
||||||
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency}");
|
error_log("Payment succeeded: {$paymentIntent->id} but no passage found in ope_pass");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gérer un paiement échoué
|
* Gérer un paiement échoué
|
||||||
*/
|
*/
|
||||||
private function handlePaymentIntentFailed($paymentIntent): void {
|
private function handlePaymentIntentFailed($paymentIntent): void {
|
||||||
// Mettre à jour le statut
|
// Vérifier si le passage existe
|
||||||
$stmt = $this->db->prepare(
|
$stmt = $this->db->prepare("
|
||||||
"UPDATE stripe_payment_intents
|
SELECT id FROM ope_pass
|
||||||
SET status = :status, updated_at = NOW()
|
WHERE stripe_payment_id = :pi_id
|
||||||
WHERE stripe_payment_intent_id = :pi_id"
|
");
|
||||||
);
|
|
||||||
$stmt->execute([
|
|
||||||
'status' => 'failed',
|
|
||||||
'pi_id' => $paymentIntent->id
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Enregistrer dans l'historique avec la raison de l'échec
|
|
||||||
$stmt = $this->db->prepare(
|
|
||||||
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
|
|
||||||
);
|
|
||||||
$stmt->execute(['pi_id' => $paymentIntent->id]);
|
$stmt->execute(['pi_id' => $paymentIntent->id]);
|
||||||
$localPayment = $stmt->fetch();
|
$passage = $stmt->fetch();
|
||||||
|
|
||||||
if ($localPayment) {
|
|
||||||
$stmt = $this->db->prepare(
|
|
||||||
"INSERT INTO stripe_payment_history
|
|
||||||
(fk_payment_intent, event_type, event_data, created_at)
|
|
||||||
VALUES (:fk_pi, 'failed', :data, NOW())"
|
|
||||||
);
|
|
||||||
$stmt->execute([
|
|
||||||
'fk_pi' => $localPayment['id'],
|
|
||||||
'data' => json_encode([
|
|
||||||
'error' => $paymentIntent->last_payment_error,
|
|
||||||
'cancellation_reason' => $paymentIntent->cancellation_reason
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if ($passage) {
|
||||||
|
// Optionnel : Marquer le passage comme échec ou supprimer le stripe_payment_id
|
||||||
|
// Pour l'instant on log seulement
|
||||||
|
error_log("Payment failed: {$paymentIntent->id} for passage {$passage['id']}, reason: " . json_encode($paymentIntent->last_payment_error));
|
||||||
|
} else {
|
||||||
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
|
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gérer la complétion d'une session de paiement (Payment Link / Checkout)
|
* Gérer la complétion d'une session de paiement (Payment Link / Checkout)
|
||||||
@@ -358,17 +319,8 @@ class StripeWebhookController extends Controller {
|
|||||||
* Gérer une action réussie sur un Terminal reader
|
* Gérer une action réussie sur un Terminal reader
|
||||||
*/
|
*/
|
||||||
private function handleTerminalReaderActionSucceeded($reader): void {
|
private function handleTerminalReaderActionSucceeded($reader): void {
|
||||||
// Mettre à jour le statut du reader
|
// Note: Pour Tap to Pay, il n'y a pas de readers physiques
|
||||||
$stmt = $this->db->prepare(
|
// Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
|
||||||
"UPDATE stripe_terminal_readers
|
|
||||||
SET status = :status, last_seen_at = NOW()
|
|
||||||
WHERE stripe_reader_id = :reader_id"
|
|
||||||
);
|
|
||||||
$stmt->execute([
|
|
||||||
'status' => 'online',
|
|
||||||
'reader_id' => $reader->id
|
|
||||||
]);
|
|
||||||
|
|
||||||
error_log("Terminal reader action succeeded: {$reader->id}");
|
error_log("Terminal reader action succeeded: {$reader->id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,17 +328,8 @@ class StripeWebhookController extends Controller {
|
|||||||
* Gérer une action échouée sur un Terminal reader
|
* Gérer une action échouée sur un Terminal reader
|
||||||
*/
|
*/
|
||||||
private function handleTerminalReaderActionFailed($reader): void {
|
private function handleTerminalReaderActionFailed($reader): void {
|
||||||
// Mettre à jour le statut du reader
|
// Note: Pour Tap to Pay, il n'y a pas de readers physiques
|
||||||
$stmt = $this->db->prepare(
|
// Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
|
||||||
"UPDATE stripe_terminal_readers
|
|
||||||
SET status = :status, last_seen_at = NOW()
|
|
||||||
WHERE stripe_reader_id = :reader_id"
|
|
||||||
);
|
|
||||||
$stmt->execute([
|
|
||||||
'status' => 'error',
|
|
||||||
'reader_id' => $reader->id
|
|
||||||
]);
|
|
||||||
|
|
||||||
error_log("Terminal reader action failed: {$reader->id}");
|
error_log("Terminal reader action failed: {$reader->id}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,13 +135,13 @@ class Router {
|
|||||||
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
|
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
|
||||||
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
|
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
|
||||||
|
|
||||||
// Tap to Pay - Vérification compatibilité et configuration
|
// Tap to Pay - Configuration
|
||||||
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
|
|
||||||
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
|
|
||||||
$this->post('stripe/locations', ['StripeController', 'createLocation']);
|
$this->post('stripe/locations', ['StripeController', 'createLocation']);
|
||||||
|
$this->post('stripe/terminal/connection-token', ['StripeController', 'createConnectionToken']);
|
||||||
|
|
||||||
// Paiements
|
// Paiements
|
||||||
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
|
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
|
||||||
|
$this->post('stripe/payments/cancel', ['StripeController', 'cancelPayment']);
|
||||||
$this->post('stripe/payment-links', ['StripeController', 'createPaymentLink']);
|
$this->post('stripe/payment-links', ['StripeController', 'createPaymentLink']);
|
||||||
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
|
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
|
||||||
|
|
||||||
@@ -152,6 +152,14 @@ class Router {
|
|||||||
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
|
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
|
||||||
$this->post('stripe/webhooks', ['StripeWebhookController', 'handleWebhook']);
|
$this->post('stripe/webhooks', ['StripeWebhookController', 'handleWebhook']);
|
||||||
|
|
||||||
|
// Routes Statistiques Events (Admin uniquement)
|
||||||
|
$this->get('events/stats/summary', ['EventStatsController', 'summary']);
|
||||||
|
$this->get('events/stats/daily', ['EventStatsController', 'daily']);
|
||||||
|
$this->get('events/stats/weekly', ['EventStatsController', 'weekly']);
|
||||||
|
$this->get('events/stats/monthly', ['EventStatsController', 'monthly']);
|
||||||
|
$this->get('events/stats/details', ['EventStatsController', 'details']);
|
||||||
|
$this->get('events/stats/types', ['EventStatsController', 'types']);
|
||||||
|
|
||||||
// Routes Migration (Admin uniquement)
|
// Routes Migration (Admin uniquement)
|
||||||
$this->get('migrations/test-connections', ['MigrationController', 'testConnections']);
|
$this->get('migrations/test-connections', ['MigrationController', 'testConnections']);
|
||||||
$this->get('migrations/entities/available', ['MigrationController', 'getAvailableEntities']);
|
$this->get('migrations/entities/available', ['MigrationController', 'getAvailableEntities']);
|
||||||
|
|||||||
@@ -21,19 +21,16 @@ class AddressService
|
|||||||
{
|
{
|
||||||
private ?PDO $addressesDb = null;
|
private ?PDO $addressesDb = null;
|
||||||
private PDO $mainDb;
|
private PDO $mainDb;
|
||||||
private $logService;
|
|
||||||
private $buildingService;
|
private $buildingService;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->logService = new LogService();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->addressesDb = \AddressesDatabase::getInstance();
|
$this->addressesDb = \AddressesDatabase::getInstance();
|
||||||
$this->logService->info('[AddressService] Connexion à la base d\'adresses réussie');
|
LogService::info('[AddressService] Connexion à la base d\'adresses réussie');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Si la connexion échoue, on continue sans la base d'adresses
|
// Si la connexion échoue, on continue sans la base d'adresses
|
||||||
$this->logService->error('[AddressService] Connexion à la base d\'adresses impossible', [
|
LogService::error('[AddressService] Connexion à la base d\'adresses impossible', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'trace' => $e->getTraceAsString()
|
'trace' => $e->getTraceAsString()
|
||||||
]);
|
]);
|
||||||
@@ -94,13 +91,13 @@ class AddressService
|
|||||||
{
|
{
|
||||||
// Si pas de connexion à la base d'adresses, retourner un tableau vide
|
// Si pas de connexion à la base d'adresses, retourner un tableau vide
|
||||||
if (!$this->addressesDb) {
|
if (!$this->addressesDb) {
|
||||||
$this->logService->error('[AddressService] Pas de connexion à la base d\'adresses externe', [
|
LogService::error('[AddressService] Pas de connexion à la base d\'adresses externe', [
|
||||||
'entity_id' => $entityId
|
'entity_id' => $entityId
|
||||||
]);
|
]);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('[AddressService] Début recherche adresses', [
|
LogService::info('[AddressService] Début recherche adresses', [
|
||||||
'entity_id' => $entityId,
|
'entity_id' => $entityId,
|
||||||
'nb_coordinates' => count($coordinates)
|
'nb_coordinates' => count($coordinates)
|
||||||
]);
|
]);
|
||||||
@@ -117,11 +114,11 @@ class AddressService
|
|||||||
// Si aucun département n'est trouvé par analyse spatiale,
|
// Si aucun département n'est trouvé par analyse spatiale,
|
||||||
// chercher d'abord dans le département de l'entité et ses limitrophes
|
// chercher d'abord dans le département de l'entité et ses limitrophes
|
||||||
$entityDept = $this->getDepartmentForEntity($entityId);
|
$entityDept = $this->getDepartmentForEntity($entityId);
|
||||||
$this->logService->info('[AddressService] Département de l\'entité', [
|
LogService::info('[AddressService] Département de l\'entité', [
|
||||||
'departement' => $entityDept
|
'departement' => $entityDept
|
||||||
]);
|
]);
|
||||||
if (!$entityDept) {
|
if (!$entityDept) {
|
||||||
$this->logService->error('[AddressService] Impossible de déterminer le département de l\'entité', [
|
LogService::error('[AddressService] Impossible de déterminer le département de l\'entité', [
|
||||||
'entity_id' => $entityId
|
'entity_id' => $entityId
|
||||||
]);
|
]);
|
||||||
throw new RuntimeException("Impossible de déterminer le département");
|
throw new RuntimeException("Impossible de déterminer le département");
|
||||||
@@ -131,7 +128,7 @@ class AddressService
|
|||||||
$priorityDepts = $boundaryService->getPriorityDepartments($entityDept);
|
$priorityDepts = $boundaryService->getPriorityDepartments($entityDept);
|
||||||
|
|
||||||
// Log pour debug
|
// Log pour debug
|
||||||
$this->logService->warning('[AddressService] Aucun département trouvé par analyse spatiale', [
|
LogService::warning('[AddressService] Aucun département trouvé par analyse spatiale', [
|
||||||
'departements_prioritaires' => implode(', ', $priorityDepts)
|
'departements_prioritaires' => implode(', ', $priorityDepts)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -204,7 +201,7 @@ class AddressService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log pour debug
|
// Log pour debug
|
||||||
$this->logService->info('[AddressService] Recherche dans table', [
|
LogService::info('[AddressService] Recherche dans table', [
|
||||||
'table' => $tableName,
|
'table' => $tableName,
|
||||||
'departement' => $deptCode,
|
'departement' => $deptCode,
|
||||||
'nb_adresses' => count($addresses)
|
'nb_adresses' => count($addresses)
|
||||||
@@ -212,7 +209,7 @@ class AddressService
|
|||||||
|
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
// Log l'erreur mais continue avec les autres départements
|
// Log l'erreur mais continue avec les autres départements
|
||||||
$this->logService->error('[AddressService] Erreur SQL', [
|
LogService::error('[AddressService] Erreur SQL', [
|
||||||
'table' => $tableName,
|
'table' => $tableName,
|
||||||
'departement' => $deptCode,
|
'departement' => $deptCode,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
@@ -221,7 +218,7 @@ class AddressService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('[AddressService] Fin recherche adresses', [
|
LogService::info('[AddressService] Fin recherche adresses', [
|
||||||
'total_adresses' => count($allAddresses)
|
'total_adresses' => count($allAddresses)
|
||||||
]);
|
]);
|
||||||
return $allAddresses;
|
return $allAddresses;
|
||||||
@@ -243,7 +240,7 @@ class AddressService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('[AddressService] Début enrichissement avec bâtiments', [
|
LogService::info('[AddressService] Début enrichissement avec bâtiments', [
|
||||||
'entity_id' => $entityId,
|
'entity_id' => $entityId,
|
||||||
'nb_addresses' => count($addresses)
|
'nb_addresses' => count($addresses)
|
||||||
]);
|
]);
|
||||||
@@ -262,7 +259,7 @@ class AddressService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logService->info('[AddressService] Fin enrichissement avec bâtiments', [
|
LogService::info('[AddressService] Fin enrichissement avec bâtiments', [
|
||||||
'total_adresses' => count($enrichedAddresses),
|
'total_adresses' => count($enrichedAddresses),
|
||||||
'nb_immeubles' => $nbImmeubles,
|
'nb_immeubles' => $nbImmeubles,
|
||||||
'nb_maisons' => $nbMaisons
|
'nb_maisons' => $nbMaisons
|
||||||
@@ -271,7 +268,7 @@ class AddressService
|
|||||||
return $enrichedAddresses;
|
return $enrichedAddresses;
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logService->error('[AddressService] Erreur lors de l\'enrichissement', [
|
LogService::error('[AddressService] Erreur lors de l\'enrichissement', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'trace' => $e->getTraceAsString()
|
'trace' => $e->getTraceAsString()
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class ApiService {
|
|||||||
* @param int $minLength Longueur minimale du nom d'utilisateur (par défaut 10)
|
* @param int $minLength Longueur minimale du nom d'utilisateur (par défaut 10)
|
||||||
* @return string Nom d'utilisateur généré
|
* @return string Nom d'utilisateur généré
|
||||||
*/
|
*/
|
||||||
public static function generateUserName(PDO $db, string $name, string $postalCode, string $cityName, int $minLength = 10): string {
|
public static function generateUserName(\PDO $db, string $name, string $postalCode, string $cityName, int $minLength = 10): string {
|
||||||
// Nettoyer et préparer les chaînes
|
// Nettoyer et préparer les chaînes
|
||||||
$name = preg_replace('/[^a-zA-Z0-9]/', '', strtolower($name));
|
$name = preg_replace('/[^a-zA-Z0-9]/', '', strtolower($name));
|
||||||
$postalCode = preg_replace('/[^0-9]/', '', $postalCode);
|
$postalCode = preg_replace('/[^0-9]/', '', $postalCode);
|
||||||
@@ -277,7 +277,7 @@ class ApiService {
|
|||||||
// Vérifier si le nom d'utilisateur existe déjà
|
// Vérifier si le nom d'utilisateur existe déjà
|
||||||
$stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE encrypted_user_name = ?');
|
$stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE encrypted_user_name = ?');
|
||||||
$stmt->execute([$encryptedUsername]);
|
$stmt->execute([$encryptedUsername]);
|
||||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if ($result && $result['count'] == 0) {
|
if ($result && $result['count'] == 0) {
|
||||||
$isUnique = true;
|
$isUnique = true;
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ class EmailTemplates {
|
|||||||
Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br>
|
Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br>
|
||||||
<b>Identifiant :</b> $username<br>
|
<b>Identifiant :</b> $username<br>
|
||||||
<b>Mot de passe :</b> $password<br><br>
|
<b>Mot de passe :</b> $password<br><br>
|
||||||
Vous pouvez vous connecter dès maintenant sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
|
|
||||||
À très bientôt,<br>
|
À très bientôt,<br>
|
||||||
L'équipe GeoSector";
|
L'équipe GeoSector<br>
|
||||||
|
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,9 +80,9 @@ class EmailTemplates {
|
|||||||
Bonjour $name,<br><br>
|
Bonjour $name,<br><br>
|
||||||
Vous avez demandé la réinitialisation de votre mot de passe sur <b>GeoSector</b>.<br><br>
|
Vous avez demandé la réinitialisation de votre mot de passe sur <b>GeoSector</b>.<br><br>
|
||||||
<b>Nouveau mot de passe :</b> $password<br><br>
|
<b>Nouveau mot de passe :</b> $password<br><br>
|
||||||
Vous pouvez vous connecter avec ce nouveau mot de passe sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
|
|
||||||
À très bientôt,<br>
|
À très bientôt,<br>
|
||||||
L'équipe GeoSector";
|
L'équipe GeoSector<br>
|
||||||
|
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -305,6 +305,141 @@ class EventLogService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== MÉTHODES STRIPE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log la création d'un PaymentIntent
|
||||||
|
*
|
||||||
|
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
||||||
|
* @param int $passageId ID du passage
|
||||||
|
* @param int $amount Montant en centimes
|
||||||
|
* @param string $method Méthode (tap_to_pay, qr_code, web)
|
||||||
|
*/
|
||||||
|
public static function logStripePaymentCreated(
|
||||||
|
string $paymentIntentId,
|
||||||
|
int $passageId,
|
||||||
|
int $amount,
|
||||||
|
string $method
|
||||||
|
): void {
|
||||||
|
$entityId = Session::getEntityId();
|
||||||
|
self::writeEvent('stripe_payment_created', [
|
||||||
|
'payment_intent_id' => $paymentIntentId,
|
||||||
|
'passage_id' => $passageId,
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'amount' => $amount,
|
||||||
|
'method' => $method
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log un paiement Stripe réussi
|
||||||
|
*
|
||||||
|
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
||||||
|
* @param int $passageId ID du passage
|
||||||
|
* @param int $amount Montant en centimes
|
||||||
|
* @param string $method Méthode (tap_to_pay, qr_code, web)
|
||||||
|
*/
|
||||||
|
public static function logStripePaymentSuccess(
|
||||||
|
string $paymentIntentId,
|
||||||
|
int $passageId,
|
||||||
|
int $amount,
|
||||||
|
string $method
|
||||||
|
): void {
|
||||||
|
$entityId = Session::getEntityId();
|
||||||
|
self::writeEvent('stripe_payment_success', [
|
||||||
|
'payment_intent_id' => $paymentIntentId,
|
||||||
|
'passage_id' => $passageId,
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'amount' => $amount,
|
||||||
|
'method' => $method
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log un paiement Stripe échoué
|
||||||
|
*
|
||||||
|
* @param string|null $paymentIntentId ID Stripe (peut être null si création échouée)
|
||||||
|
* @param int|null $passageId ID du passage (peut être null)
|
||||||
|
* @param int|null $amount Montant en centimes (peut être null)
|
||||||
|
* @param string $method Méthode tentée
|
||||||
|
* @param string $errorCode Code d'erreur
|
||||||
|
* @param string $errorMessage Message d'erreur
|
||||||
|
*/
|
||||||
|
public static function logStripePaymentFailed(
|
||||||
|
?string $paymentIntentId,
|
||||||
|
?int $passageId,
|
||||||
|
?int $amount,
|
||||||
|
string $method,
|
||||||
|
string $errorCode,
|
||||||
|
string $errorMessage
|
||||||
|
): void {
|
||||||
|
$entityId = Session::getEntityId();
|
||||||
|
$data = [
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'method' => $method,
|
||||||
|
'error_code' => $errorCode,
|
||||||
|
'error_message' => $errorMessage
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($paymentIntentId !== null) {
|
||||||
|
$data['payment_intent_id'] = $paymentIntentId;
|
||||||
|
}
|
||||||
|
if ($passageId !== null) {
|
||||||
|
$data['passage_id'] = $passageId;
|
||||||
|
}
|
||||||
|
if ($amount !== null) {
|
||||||
|
$data['amount'] = $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::writeEvent('stripe_payment_failed', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log l'annulation d'un paiement Stripe
|
||||||
|
*
|
||||||
|
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
||||||
|
* @param int|null $passageId ID du passage (peut être null)
|
||||||
|
* @param string $reason Raison (user_cancelled, timeout, error, etc.)
|
||||||
|
*/
|
||||||
|
public static function logStripePaymentCancelled(
|
||||||
|
string $paymentIntentId,
|
||||||
|
?int $passageId,
|
||||||
|
string $reason
|
||||||
|
): void {
|
||||||
|
$entityId = Session::getEntityId();
|
||||||
|
$data = [
|
||||||
|
'payment_intent_id' => $paymentIntentId,
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'reason' => $reason
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($passageId !== null) {
|
||||||
|
$data['passage_id'] = $passageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::writeEvent('stripe_payment_cancelled', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log une erreur du Terminal Tap to Pay
|
||||||
|
*
|
||||||
|
* @param string $errorCode Code d'erreur (cardReadTimedOut, device_not_compatible, etc.)
|
||||||
|
* @param string $errorMessage Message d'erreur
|
||||||
|
* @param array $metadata Métadonnées supplémentaires (device_model, is_simulated, etc.)
|
||||||
|
*/
|
||||||
|
public static function logStripeTerminalError(
|
||||||
|
string $errorCode,
|
||||||
|
string $errorMessage,
|
||||||
|
array $metadata = []
|
||||||
|
): void {
|
||||||
|
$entityId = Session::getEntityId();
|
||||||
|
self::writeEvent('stripe_terminal_error', array_merge([
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'error_code' => $errorCode,
|
||||||
|
'error_message' => $errorMessage
|
||||||
|
], $metadata));
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== MÉTHODES OPÉRATIONS ====================
|
// ==================== MÉTHODES OPÉRATIONS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
535
api/src/Services/EventStatsService.php
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventStatsService - Service de statistiques d'événements
|
||||||
|
*
|
||||||
|
* Fournit des méthodes pour récupérer les statistiques agrégées
|
||||||
|
* depuis la table event_stats_daily et le détail depuis les fichiers JSONL.
|
||||||
|
*
|
||||||
|
* @see docs/TECHBOOK.md section "Statistiques Events"
|
||||||
|
*/
|
||||||
|
class EventStatsService
|
||||||
|
{
|
||||||
|
/** @var string Chemin du dossier des logs événements */
|
||||||
|
private const EVENT_LOG_DIR = __DIR__ . '/../../logs/events';
|
||||||
|
|
||||||
|
/** @var int Limite max pour le détail */
|
||||||
|
private const MAX_DETAILS_LIMIT = 100;
|
||||||
|
|
||||||
|
/** @var PDO Instance de la base de données */
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MÉTHODES PUBLIQUES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le résumé des stats pour une date donnée
|
||||||
|
*
|
||||||
|
* @param int|null $entityId ID entité (null = toutes entités pour super-admin)
|
||||||
|
* @param string|null $date Date (YYYY-MM-DD), défaut = aujourd'hui
|
||||||
|
* @return array Stats résumées par catégorie
|
||||||
|
*/
|
||||||
|
public function getSummary(?int $entityId, ?string $date = null): array
|
||||||
|
{
|
||||||
|
$date = $date ?? date('Y-m-d');
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT event_type, count, sum_amount, unique_users, metadata
|
||||||
|
FROM event_stats_daily
|
||||||
|
WHERE stat_date = :date
|
||||||
|
";
|
||||||
|
$params = ['date' => $date];
|
||||||
|
|
||||||
|
if ($entityId !== null) {
|
||||||
|
$sql .= " AND entity_id = :entity_id";
|
||||||
|
$params['entity_id'] = $entityId;
|
||||||
|
} else {
|
||||||
|
$sql .= " AND entity_id IS NULL";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $this->formatSummary($date, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les stats journalières sur une plage de dates
|
||||||
|
*
|
||||||
|
* @param int|null $entityId ID entité (null = toutes entités)
|
||||||
|
* @param string $from Date début (YYYY-MM-DD)
|
||||||
|
* @param string $to Date fin (YYYY-MM-DD)
|
||||||
|
* @param array $eventTypes Filtrer par types d'événements (optionnel)
|
||||||
|
* @return array Stats par jour
|
||||||
|
*/
|
||||||
|
public function getDaily(?int $entityId, string $from, string $to, array $eventTypes = []): array
|
||||||
|
{
|
||||||
|
$sql = "
|
||||||
|
SELECT stat_date, event_type, count, sum_amount, unique_users
|
||||||
|
FROM event_stats_daily
|
||||||
|
WHERE stat_date BETWEEN :from AND :to
|
||||||
|
";
|
||||||
|
$params = ['from' => $from, 'to' => $to];
|
||||||
|
|
||||||
|
if ($entityId !== null) {
|
||||||
|
$sql .= " AND entity_id = :entity_id";
|
||||||
|
$params['entity_id'] = $entityId;
|
||||||
|
} else {
|
||||||
|
$sql .= " AND entity_id IS NULL";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($eventTypes)) {
|
||||||
|
$placeholders = [];
|
||||||
|
foreach ($eventTypes as $i => $type) {
|
||||||
|
$key = "event_type_{$i}";
|
||||||
|
$placeholders[] = ":{$key}";
|
||||||
|
$params[$key] = $type;
|
||||||
|
}
|
||||||
|
$sql .= " AND event_type IN (" . implode(', ', $placeholders) . ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY stat_date ASC, event_type ASC";
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return $this->formatDaily($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les stats hebdomadaires (calculées depuis daily)
|
||||||
|
*
|
||||||
|
* @param int|null $entityId ID entité
|
||||||
|
* @param string $from Date début
|
||||||
|
* @param string $to Date fin
|
||||||
|
* @param array $eventTypes Filtrer par types d'événements
|
||||||
|
* @return array Stats par semaine
|
||||||
|
*/
|
||||||
|
public function getWeekly(?int $entityId, string $from, string $to, array $eventTypes = []): array
|
||||||
|
{
|
||||||
|
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
|
||||||
|
|
||||||
|
$weekly = [];
|
||||||
|
|
||||||
|
foreach ($daily as $day) {
|
||||||
|
$date = new \DateTime($day['date']);
|
||||||
|
$weekStart = clone $date;
|
||||||
|
$weekStart->modify('monday this week');
|
||||||
|
$weekKey = $weekStart->format('Y-m-d');
|
||||||
|
|
||||||
|
if (!isset($weekly[$weekKey])) {
|
||||||
|
$weekly[$weekKey] = [
|
||||||
|
'week_start' => $weekKey,
|
||||||
|
'week_number' => (int) $date->format('W'),
|
||||||
|
'year' => (int) $date->format('Y'),
|
||||||
|
'events' => [],
|
||||||
|
'totals' => [
|
||||||
|
'count' => 0,
|
||||||
|
'sum_amount' => 0.0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agréger les événements
|
||||||
|
foreach ($day['events'] as $eventType => $stats) {
|
||||||
|
if (!isset($weekly[$weekKey]['events'][$eventType])) {
|
||||||
|
$weekly[$weekKey]['events'][$eventType] = [
|
||||||
|
'count' => 0,
|
||||||
|
'sum_amount' => 0.0,
|
||||||
|
'unique_users' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$weekly[$weekKey]['events'][$eventType]['count'] += $stats['count'];
|
||||||
|
$weekly[$weekKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
|
||||||
|
$weekly[$weekKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$weekly[$weekKey]['totals']['count'] += $day['totals']['count'];
|
||||||
|
$weekly[$weekKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($weekly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les stats mensuelles (calculées depuis daily)
|
||||||
|
*
|
||||||
|
* @param int|null $entityId ID entité
|
||||||
|
* @param int $year Année
|
||||||
|
* @param array $eventTypes Filtrer par types d'événements
|
||||||
|
* @return array Stats par mois
|
||||||
|
*/
|
||||||
|
public function getMonthly(?int $entityId, int $year, array $eventTypes = []): array
|
||||||
|
{
|
||||||
|
$from = "{$year}-01-01";
|
||||||
|
$to = "{$year}-12-31";
|
||||||
|
|
||||||
|
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
|
||||||
|
|
||||||
|
$monthly = [];
|
||||||
|
|
||||||
|
foreach ($daily as $day) {
|
||||||
|
$monthKey = substr($day['date'], 0, 7); // YYYY-MM
|
||||||
|
|
||||||
|
if (!isset($monthly[$monthKey])) {
|
||||||
|
$monthly[$monthKey] = [
|
||||||
|
'month' => $monthKey,
|
||||||
|
'year' => $year,
|
||||||
|
'month_number' => (int) substr($monthKey, 5, 2),
|
||||||
|
'events' => [],
|
||||||
|
'totals' => [
|
||||||
|
'count' => 0,
|
||||||
|
'sum_amount' => 0.0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agréger les événements
|
||||||
|
foreach ($day['events'] as $eventType => $stats) {
|
||||||
|
if (!isset($monthly[$monthKey]['events'][$eventType])) {
|
||||||
|
$monthly[$monthKey]['events'][$eventType] = [
|
||||||
|
'count' => 0,
|
||||||
|
'sum_amount' => 0.0,
|
||||||
|
'unique_users' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$monthly[$monthKey]['events'][$eventType]['count'] += $stats['count'];
|
||||||
|
$monthly[$monthKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
|
||||||
|
$monthly[$monthKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$monthly[$monthKey]['totals']['count'] += $day['totals']['count'];
|
||||||
|
$monthly[$monthKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($monthly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le détail des événements depuis le fichier JSONL
|
||||||
|
*
|
||||||
|
* @param int|null $entityId ID entité (null = tous)
|
||||||
|
* @param string $date Date (YYYY-MM-DD)
|
||||||
|
* @param string|null $eventType Filtrer par type d'événement
|
||||||
|
* @param int $limit Nombre max de résultats
|
||||||
|
* @param int $offset Décalage pour pagination
|
||||||
|
* @return array Événements détaillés avec pagination
|
||||||
|
*/
|
||||||
|
public function getDetails(
|
||||||
|
?int $entityId,
|
||||||
|
string $date,
|
||||||
|
?string $eventType = null,
|
||||||
|
int $limit = 50,
|
||||||
|
int $offset = 0
|
||||||
|
): array {
|
||||||
|
$limit = min($limit, self::MAX_DETAILS_LIMIT);
|
||||||
|
|
||||||
|
$filePath = self::EVENT_LOG_DIR . '/' . $date . '.jsonl';
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
return [
|
||||||
|
'date' => $date,
|
||||||
|
'events' => [],
|
||||||
|
'pagination' => [
|
||||||
|
'total' => 0,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
'has_more' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
$total = 0;
|
||||||
|
$currentIndex = 0;
|
||||||
|
|
||||||
|
$handle = fopen($filePath, 'r');
|
||||||
|
if (!$handle) {
|
||||||
|
return [
|
||||||
|
'date' => $date,
|
||||||
|
'events' => [],
|
||||||
|
'pagination' => [
|
||||||
|
'total' => 0,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
'has_more' => false,
|
||||||
|
],
|
||||||
|
'error' => 'Impossible de lire le fichier',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($line = fgets($handle)) !== false) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (empty($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = json_decode($line, true);
|
||||||
|
if (!$event || !isset($event['event'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer par entity_id
|
||||||
|
if ($entityId !== null) {
|
||||||
|
$eventEntityId = $event['entity_id'] ?? null;
|
||||||
|
if ($eventEntityId !== $entityId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer par event_type
|
||||||
|
if ($eventType !== null && ($event['event'] ?? '') !== $eventType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total++;
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
if ($currentIndex >= $offset && count($events) < $limit) {
|
||||||
|
$events[] = $this->sanitizeEventForOutput($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'date' => $date,
|
||||||
|
'events' => $events,
|
||||||
|
'pagination' => [
|
||||||
|
'total' => $total,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
'has_more' => ($offset + $limit) < $total,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les types d'événements disponibles
|
||||||
|
*
|
||||||
|
* @return array Liste des types d'événements
|
||||||
|
*/
|
||||||
|
public function getEventTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'auth' => ['login_success', 'login_failed', 'logout'],
|
||||||
|
'passages' => ['passage_created', 'passage_updated', 'passage_deleted'],
|
||||||
|
'sectors' => ['sector_created', 'sector_updated', 'sector_deleted'],
|
||||||
|
'users' => ['user_created', 'user_updated', 'user_deleted'],
|
||||||
|
'entities' => ['entity_created', 'entity_updated', 'entity_deleted'],
|
||||||
|
'operations' => ['operation_created', 'operation_updated', 'operation_deleted'],
|
||||||
|
'stripe' => [
|
||||||
|
'stripe_payment_created',
|
||||||
|
'stripe_payment_success',
|
||||||
|
'stripe_payment_failed',
|
||||||
|
'stripe_payment_cancelled',
|
||||||
|
'stripe_terminal_error',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si des stats existent pour une date
|
||||||
|
*
|
||||||
|
* @param string $date Date à vérifier
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasStatsForDate(string $date): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
SELECT COUNT(*) FROM event_stats_daily WHERE stat_date = :date
|
||||||
|
");
|
||||||
|
$stmt->execute(['date' => $date]);
|
||||||
|
return (int) $stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MÉTHODES PRIVÉES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate le résumé des stats par catégorie
|
||||||
|
*/
|
||||||
|
private function formatSummary(string $date, array $rows): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'date' => $date,
|
||||||
|
'stats' => [
|
||||||
|
'auth' => ['success' => 0, 'failed' => 0, 'logout' => 0],
|
||||||
|
'passages' => ['created' => 0, 'updated' => 0, 'deleted' => 0, 'amount' => 0.0],
|
||||||
|
'users' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||||
|
'sectors' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||||
|
'entities' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||||
|
'operations' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||||
|
'stripe' => ['created' => 0, 'success' => 0, 'failed' => 0, 'cancelled' => 0, 'amount' => 0.0],
|
||||||
|
],
|
||||||
|
'totals' => [
|
||||||
|
'events' => 0,
|
||||||
|
'unique_users' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$uniqueUsersSet = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$eventType = $row['event_type'];
|
||||||
|
$count = (int) $row['count'];
|
||||||
|
$amount = (float) $row['sum_amount'];
|
||||||
|
$uniqueUsers = (int) $row['unique_users'];
|
||||||
|
|
||||||
|
$summary['totals']['events'] += $count;
|
||||||
|
$uniqueUsersSet[$eventType] = $uniqueUsers;
|
||||||
|
|
||||||
|
// Mapper vers les catégories
|
||||||
|
switch ($eventType) {
|
||||||
|
case 'login_success':
|
||||||
|
$summary['stats']['auth']['success'] = $count;
|
||||||
|
break;
|
||||||
|
case 'login_failed':
|
||||||
|
$summary['stats']['auth']['failed'] = $count;
|
||||||
|
break;
|
||||||
|
case 'logout':
|
||||||
|
$summary['stats']['auth']['logout'] = $count;
|
||||||
|
break;
|
||||||
|
case 'passage_created':
|
||||||
|
$summary['stats']['passages']['created'] = $count;
|
||||||
|
$summary['stats']['passages']['amount'] += $amount;
|
||||||
|
break;
|
||||||
|
case 'passage_updated':
|
||||||
|
$summary['stats']['passages']['updated'] = $count;
|
||||||
|
break;
|
||||||
|
case 'passage_deleted':
|
||||||
|
$summary['stats']['passages']['deleted'] = $count;
|
||||||
|
break;
|
||||||
|
case 'user_created':
|
||||||
|
$summary['stats']['users']['created'] = $count;
|
||||||
|
break;
|
||||||
|
case 'user_updated':
|
||||||
|
$summary['stats']['users']['updated'] = $count;
|
||||||
|
break;
|
||||||
|
case 'user_deleted':
|
||||||
|
$summary['stats']['users']['deleted'] = $count;
|
||||||
|
break;
|
||||||
|
case 'sector_created':
|
||||||
|
$summary['stats']['sectors']['created'] = $count;
|
||||||
|
break;
|
||||||
|
case 'sector_updated':
|
||||||
|
$summary['stats']['sectors']['updated'] = $count;
|
||||||
|
break;
|
||||||
|
case 'sector_deleted':
|
||||||
|
$summary['stats']['sectors']['deleted'] = $count;
|
||||||
|
break;
|
||||||
|
case 'entity_created':
|
||||||
|
$summary['stats']['entities']['created'] = $count;
|
||||||
|
break;
|
||||||
|
case 'entity_updated':
|
||||||
|
$summary['stats']['entities']['updated'] = $count;
|
||||||
|
break;
|
||||||
|
case 'entity_deleted':
|
||||||
|
$summary['stats']['entities']['deleted'] = $count;
|
||||||
|
break;
|
||||||
|
case 'operation_created':
|
||||||
|
$summary['stats']['operations']['created'] = $count;
|
||||||
|
break;
|
||||||
|
case 'operation_updated':
|
||||||
|
$summary['stats']['operations']['updated'] = $count;
|
||||||
|
break;
|
||||||
|
case 'operation_deleted':
|
||||||
|
$summary['stats']['operations']['deleted'] = $count;
|
||||||
|
break;
|
||||||
|
case 'stripe_payment_created':
|
||||||
|
$summary['stats']['stripe']['created'] = $count;
|
||||||
|
break;
|
||||||
|
case 'stripe_payment_success':
|
||||||
|
$summary['stats']['stripe']['success'] = $count;
|
||||||
|
$summary['stats']['stripe']['amount'] += $amount;
|
||||||
|
break;
|
||||||
|
case 'stripe_payment_failed':
|
||||||
|
$summary['stats']['stripe']['failed'] = $count;
|
||||||
|
break;
|
||||||
|
case 'stripe_payment_cancelled':
|
||||||
|
$summary['stats']['stripe']['cancelled'] = $count;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimation des utilisateurs uniques (max des catégories car overlap possible)
|
||||||
|
$summary['totals']['unique_users'] = !empty($uniqueUsersSet) ? max($uniqueUsersSet) : 0;
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate les stats journalières
|
||||||
|
*/
|
||||||
|
private function formatDaily(array $rows): array
|
||||||
|
{
|
||||||
|
$daily = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$date = $row['stat_date'];
|
||||||
|
|
||||||
|
if (!isset($daily[$date])) {
|
||||||
|
$daily[$date] = [
|
||||||
|
'date' => $date,
|
||||||
|
'events' => [],
|
||||||
|
'totals' => [
|
||||||
|
'count' => 0,
|
||||||
|
'sum_amount' => 0.0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventType = $row['event_type'];
|
||||||
|
$count = (int) $row['count'];
|
||||||
|
$amount = (float) $row['sum_amount'];
|
||||||
|
|
||||||
|
$daily[$date]['events'][$eventType] = [
|
||||||
|
'count' => $count,
|
||||||
|
'sum_amount' => $amount,
|
||||||
|
'unique_users' => (int) $row['unique_users'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$daily[$date]['totals']['count'] += $count;
|
||||||
|
$daily[$date]['totals']['sum_amount'] += $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($daily);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoie un événement pour l'affichage (supprime données sensibles)
|
||||||
|
*/
|
||||||
|
private function sanitizeEventForOutput(array $event): array
|
||||||
|
{
|
||||||
|
// Supprimer l'IP complète, garder seulement les 2 premiers octets
|
||||||
|
if (isset($event['ip'])) {
|
||||||
|
$parts = explode('.', $event['ip']);
|
||||||
|
if (count($parts) === 4) {
|
||||||
|
$event['ip'] = $parts[0] . '.' . $parts[1] . '.x.x';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer le user_agent complet
|
||||||
|
unset($event['user_agent']);
|
||||||
|
|
||||||
|
// Supprimer les données chiffrées si présentes
|
||||||
|
unset($event['encrypted_name']);
|
||||||
|
unset($event['encrypted_email']);
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ class ExportService {
|
|||||||
$filepath = $exportDir . '/' . $filename;
|
$filepath = $exportDir . '/' . $filename;
|
||||||
|
|
||||||
// Créer le spreadsheet
|
// Créer le spreadsheet
|
||||||
$spreadsheet = new PhpOffice\PhpSpreadsheet\Spreadsheet();
|
$spreadsheet = new Spreadsheet();
|
||||||
|
|
||||||
// Insérer les données
|
// Insérer les données
|
||||||
$this->createPassagesSheet($spreadsheet, $operationId, $userId);
|
$this->createPassagesSheet($spreadsheet, $operationId, $userId);
|
||||||
@@ -283,11 +283,11 @@ class ExportService {
|
|||||||
$dateEve = $passage['passed_at'] ? date('d/m/Y', strtotime($passage['passed_at'])) : '';
|
$dateEve = $passage['passed_at'] ? date('d/m/Y', strtotime($passage['passed_at'])) : '';
|
||||||
$heureEve = $passage['passed_at'] ? date('H:i', strtotime($passage['passed_at'])) : '';
|
$heureEve = $passage['passed_at'] ? date('H:i', strtotime($passage['passed_at'])) : '';
|
||||||
|
|
||||||
// Déchiffrer les données
|
// Déchiffrer les données (avec vérification null)
|
||||||
$donateur = ApiService::decryptData($passage['encrypted_name']);
|
$donateur = !empty($passage['encrypted_name']) ? ApiService::decryptData($passage['encrypted_name']) : '';
|
||||||
$email = !empty($passage['encrypted_email']) ? ApiService::decryptSearchableData($passage['encrypted_email']) : '';
|
$email = !empty($passage['encrypted_email']) ? ApiService::decryptSearchableData($passage['encrypted_email']) : '';
|
||||||
$phone = !empty($passage['encrypted_phone']) ? ApiService::decryptData($passage['encrypted_phone']) : '';
|
$phone = !empty($passage['encrypted_phone']) ? ApiService::decryptData($passage['encrypted_phone']) : '';
|
||||||
$userName = ApiService::decryptData($passage['user_name']);
|
$userName = !empty($passage['user_name']) ? ApiService::decryptData($passage['user_name']) : '';
|
||||||
|
|
||||||
// Type de passage
|
// Type de passage
|
||||||
$typeLabels = [
|
$typeLabels = [
|
||||||
@@ -382,7 +382,7 @@ class ExportService {
|
|||||||
foreach ($users as $user) {
|
foreach ($users as $user) {
|
||||||
$rowData = [
|
$rowData = [
|
||||||
$user['id'],
|
$user['id'],
|
||||||
ApiService::decryptData($user['encrypted_name']),
|
!empty($user['encrypted_name']) ? ApiService::decryptData($user['encrypted_name']) : '',
|
||||||
$user['first_name'],
|
$user['first_name'],
|
||||||
!empty($user['encrypted_email']) ? ApiService::decryptSearchableData($user['encrypted_email']) : '',
|
!empty($user['encrypted_email']) ? ApiService::decryptSearchableData($user['encrypted_email']) : '',
|
||||||
!empty($user['encrypted_phone']) ? ApiService::decryptData($user['encrypted_phone']) : '',
|
!empty($user['encrypted_phone']) ? ApiService::decryptData($user['encrypted_phone']) : '',
|
||||||
@@ -480,7 +480,7 @@ class ExportService {
|
|||||||
|
|
||||||
$row = 2;
|
$row = 2;
|
||||||
foreach ($userSectors as $us) {
|
foreach ($userSectors as $us) {
|
||||||
$userName = ApiService::decryptData($us['user_name']);
|
$userName = !empty($us['user_name']) ? ApiService::decryptData($us['user_name']) : '';
|
||||||
$fullUserName = $us['first_name'] ? $us['first_name'] . ' ' . $userName : $userName;
|
$fullUserName = $us['first_name'] ? $us['first_name'] . ' ' . $userName : $userName;
|
||||||
|
|
||||||
$rowData = [
|
$rowData = [
|
||||||
@@ -690,11 +690,11 @@ class ExportService {
|
|||||||
$dateEve = $p["passed_at"] ? date("d/m/Y", strtotime($p["passed_at"])) : "";
|
$dateEve = $p["passed_at"] ? date("d/m/Y", strtotime($p["passed_at"])) : "";
|
||||||
$heureEve = $p["passed_at"] ? date("H:i", strtotime($p["passed_at"])) : "";
|
$heureEve = $p["passed_at"] ? date("H:i", strtotime($p["passed_at"])) : "";
|
||||||
|
|
||||||
// Déchiffrer les données
|
// Déchiffrer les données (avec vérification null)
|
||||||
$donateur = ApiService::decryptData($p["encrypted_name"]);
|
$donateur = !empty($p["encrypted_name"]) ? ApiService::decryptData($p["encrypted_name"]) : "";
|
||||||
$email = !empty($p["encrypted_email"]) ? ApiService::decryptSearchableData($p["encrypted_email"]) : "";
|
$email = !empty($p["encrypted_email"]) ? ApiService::decryptSearchableData($p["encrypted_email"]) : "";
|
||||||
$phone = !empty($p["encrypted_phone"]) ? ApiService::decryptData($p["encrypted_phone"]) : "";
|
$phone = !empty($p["encrypted_phone"]) ? ApiService::decryptData($p["encrypted_phone"]) : "";
|
||||||
$userName = ApiService::decryptData($p["user_name"]);
|
$userName = !empty($p["user_name"]) ? ApiService::decryptData($p["user_name"]) : "";
|
||||||
|
|
||||||
// Nettoyer les données (comme dans l'ancienne version)
|
// Nettoyer les données (comme dans l'ancienne version)
|
||||||
$nom = str_replace("/", "-", $userName);
|
$nom = str_replace("/", "-", $userName);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ use AppConfig;
|
|||||||
use ClientDetector;
|
use ClientDetector;
|
||||||
|
|
||||||
class LogService {
|
class LogService {
|
||||||
|
/** @var int Permissions du dossier */
|
||||||
|
private const DIR_PERMISSIONS = 0750;
|
||||||
|
|
||||||
|
/** @var int Permissions des fichiers */
|
||||||
|
private const FILE_PERMISSIONS = 0640;
|
||||||
|
|
||||||
public static function log(string $message, array $metadata = []): void {
|
public static function log(string $message, array $metadata = []): void {
|
||||||
// Obtenir les informations client via ClientDetector
|
// Obtenir les informations client via ClientDetector
|
||||||
$clientInfo = ClientDetector::getClientInfo();
|
$clientInfo = ClientDetector::getClientInfo();
|
||||||
@@ -67,12 +73,10 @@ class LogService {
|
|||||||
|
|
||||||
// Créer le dossier logs s'il n'existe pas
|
// Créer le dossier logs s'il n'existe pas
|
||||||
if (!is_dir($logDir)) {
|
if (!is_dir($logDir)) {
|
||||||
if (!mkdir($logDir, 0777, true)) {
|
if (!mkdir($logDir, self::DIR_PERMISSIONS, true)) {
|
||||||
error_log("Impossible de créer le dossier de logs: {$logDir}");
|
error_log("Impossible de créer le dossier de logs: {$logDir}");
|
||||||
return; // Sortir de la fonction si on ne peut pas créer le dossier
|
return; // Sortir de la fonction si on ne peut pas créer le dossier
|
||||||
}
|
}
|
||||||
// S'assurer que les permissions sont correctes
|
|
||||||
chmod($logDir, 0777);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier si le dossier est accessible en écriture
|
// Vérifier si le dossier est accessible en écriture
|
||||||
@@ -139,26 +143,29 @@ class LogService {
|
|||||||
$message
|
$message
|
||||||
]) . "\n";
|
]) . "\n";
|
||||||
|
|
||||||
// Écrire dans le fichier avec gestion d'erreur
|
// Écrire dans le fichier avec gestion d'erreur et verrouillage
|
||||||
if (file_put_contents($filename, $logLine, FILE_APPEND) === false) {
|
if (file_put_contents($filename, $logLine, FILE_APPEND | LOCK_EX) === false) {
|
||||||
error_log("Impossible d'écrire dans le fichier de logs: {$filename}");
|
error_log("Impossible d'écrire dans le fichier de logs: {$filename}");
|
||||||
|
} else {
|
||||||
|
// Appliquer les permissions au fichier
|
||||||
|
@chmod($filename, self::FILE_PERMISSIONS);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log("Erreur lors de l'écriture des logs: " . $e->getMessage());
|
error_log("Erreur lors de l'écriture des logs: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function info(string $message, array $metadata = []): void {
|
public static function info(string $message, array $metadata = []): void {
|
||||||
$metadata['level'] = 'info';
|
$metadata['level'] = 'info';
|
||||||
self::log($message, $metadata);
|
self::log($message, $metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function warning(string $message, array $metadata = []): void {
|
public static function warning(string $message, array $metadata = []): void {
|
||||||
$metadata['level'] = 'warning';
|
$metadata['level'] = 'warning';
|
||||||
self::log($message, $metadata);
|
self::log($message, $metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function error(string $message, array $metadata = []): void {
|
public static function error(string $message, array $metadata = []): void {
|
||||||
$metadata['level'] = 'error';
|
$metadata['level'] = 'error';
|
||||||
self::log($message, $metadata);
|
self::log($message, $metadata);
|
||||||
}
|
}
|
||||||
|
|||||||
292
api/src/Services/SectorService.php
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Database;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service global pour la gestion des secteurs
|
||||||
|
*
|
||||||
|
* Fournit des fonctions réutilisables pour :
|
||||||
|
* - Géocoder une adresse via api-adresse.data.gouv.fr
|
||||||
|
* - Trouver un secteur à partir de coordonnées GPS
|
||||||
|
* - Trouver un secteur à partir d'une adresse
|
||||||
|
*/
|
||||||
|
class SectorService
|
||||||
|
{
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Géocode une adresse via api-adresse.data.gouv.fr
|
||||||
|
*
|
||||||
|
* @param string $num Numéro de rue
|
||||||
|
* @param string $bis Complément (bis, ter, etc.)
|
||||||
|
* @param string $rue Nom de la rue
|
||||||
|
* @param string $ville Nom de la ville
|
||||||
|
* @param string $cp Code postal (pour vérifier le département)
|
||||||
|
* @return array|null [lat, lng] ou null si non trouvé ou score trop faible
|
||||||
|
*/
|
||||||
|
public function geocodeAddress(string $num, string $bis, string $rue, string $ville, string $cp): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Construire l'URL de l'API
|
||||||
|
$query = trim($num . $bis) . ' ' . $rue . ' ' . $ville;
|
||||||
|
$url = 'https://api-adresse.data.gouv.fr/search/?q=' . urlencode($query);
|
||||||
|
|
||||||
|
LogService::info('[SectorService] Géocodage adresse', [
|
||||||
|
'url' => $url,
|
||||||
|
'adresse' => $query
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Appel à l'API
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||||
|
$json = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200 || empty($json)) {
|
||||||
|
LogService::warning('[SectorService] Erreur API géocodage', [
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'adresse' => $query
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json);
|
||||||
|
|
||||||
|
if (empty($data->features)) {
|
||||||
|
LogService::info('[SectorService] Aucun résultat de géocodage', [
|
||||||
|
'adresse' => $query
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$score = $data->features[0]->properties->score ?? 0;
|
||||||
|
|
||||||
|
// Vérifier le score (> 0.7 = 70% de confiance)
|
||||||
|
if (floatval($score) <= 0.7) {
|
||||||
|
LogService::info('[SectorService] Score géocodage trop faible', [
|
||||||
|
'score' => $score,
|
||||||
|
'adresse' => $query
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le département
|
||||||
|
$cpTrouve = $data->features[0]->properties->postcode ?? '';
|
||||||
|
$deptTrouve = substr($cpTrouve, 0, 2);
|
||||||
|
|
||||||
|
$cpAmicale = $cp;
|
||||||
|
if (strlen($cpAmicale) == 4) {
|
||||||
|
$cpAmicale = '0' . $cpAmicale;
|
||||||
|
}
|
||||||
|
$deptAmicale = substr($cpAmicale, 0, 2);
|
||||||
|
|
||||||
|
if ($deptTrouve !== $deptAmicale) {
|
||||||
|
LogService::warning('[SectorService] Département différent', [
|
||||||
|
'dept_trouve' => $deptTrouve,
|
||||||
|
'dept_attendu' => $deptAmicale,
|
||||||
|
'adresse' => $query
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire les coordonnées [lng, lat] -> [lat, lng]
|
||||||
|
$coordinates = $data->features[0]->geometry->coordinates;
|
||||||
|
$lat = (float)$coordinates[1];
|
||||||
|
$lng = (float)$coordinates[0];
|
||||||
|
|
||||||
|
LogService::info('[SectorService] Géocodage réussi', [
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'score' => $score,
|
||||||
|
'adresse' => $query
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['lat' => $lat, 'lng' => $lng];
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
LogService::error('[SectorService] Erreur géocodage', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'adresse' => "$num$bis $rue $ville"
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve le secteur contenant une position GPS pour une opération donnée
|
||||||
|
*
|
||||||
|
* @param int $operationId ID de l'opération
|
||||||
|
* @param float $lat Latitude
|
||||||
|
* @param float $lng Longitude
|
||||||
|
* @return int|null ID du secteur trouvé ou null
|
||||||
|
*/
|
||||||
|
public function findSectorByGps(int $operationId, float $lat, float $lng): ?int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Récupérer tous les secteurs de l'opération avec leur polygone
|
||||||
|
$query = "SELECT id, sector FROM ope_sectors
|
||||||
|
WHERE fk_operation = :operation_id
|
||||||
|
AND chk_active = 1
|
||||||
|
AND sector IS NOT NULL
|
||||||
|
AND sector != ''";
|
||||||
|
|
||||||
|
$stmt = $this->db->prepare($query);
|
||||||
|
$stmt->execute(['operation_id' => $operationId]);
|
||||||
|
$sectors = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (empty($sectors)) {
|
||||||
|
LogService::info('[SectorService] Aucun secteur trouvé pour l\'opération', [
|
||||||
|
'operation_id' => $operationId
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester chaque secteur
|
||||||
|
foreach ($sectors as $sector) {
|
||||||
|
$polygon = $this->parseSectorPolygon($sector['sector']);
|
||||||
|
|
||||||
|
if (empty($polygon)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isPointInsidePolygon($lat, $lng, $polygon)) {
|
||||||
|
LogService::info('[SectorService] Secteur trouvé par GPS', [
|
||||||
|
'sector_id' => $sector['id'],
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng
|
||||||
|
]);
|
||||||
|
return (int)$sector['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogService::info('[SectorService] Aucun secteur ne contient ce point GPS', [
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'nb_sectors_tested' => count($sectors)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
LogService::error('[SectorService] Erreur findSectorByGps', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve le secteur pour une adresse (géocodage + recherche GPS)
|
||||||
|
*
|
||||||
|
* @param int $operationId ID de l'opération
|
||||||
|
* @param string $num Numéro de rue
|
||||||
|
* @param string $bis Complément
|
||||||
|
* @param string $rue Nom de la rue
|
||||||
|
* @param string $ville Nom de la ville
|
||||||
|
* @param string $cp Code postal
|
||||||
|
* @return array|null ['sector_id' => int, 'gps_lat' => float, 'gps_lng' => float] ou null
|
||||||
|
*/
|
||||||
|
public function findSectorByAddress(int $operationId, string $num, string $bis, string $rue, string $ville, string $cp): ?array
|
||||||
|
{
|
||||||
|
// Étape 1 : Géocoder l'adresse
|
||||||
|
$coords = $this->geocodeAddress($num, $bis, $rue, $ville, $cp);
|
||||||
|
|
||||||
|
if (!$coords) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Étape 2 : Chercher le secteur avec les coordonnées obtenues
|
||||||
|
$sectorId = $this->findSectorByGps($operationId, $coords['lat'], $coords['lng']);
|
||||||
|
|
||||||
|
if (!$sectorId) {
|
||||||
|
// Retourner quand même les coordonnées GPS trouvées (utiles pour mettre à jour le passage)
|
||||||
|
return [
|
||||||
|
'sector_id' => null,
|
||||||
|
'gps_lat' => $coords['lat'],
|
||||||
|
'gps_lng' => $coords['lng']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sector_id' => $sectorId,
|
||||||
|
'gps_lat' => $coords['lat'],
|
||||||
|
'gps_lng' => $coords['lng']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse le format de polygone stocké en base (lat/lng#lat/lng#...)
|
||||||
|
*
|
||||||
|
* @param string $sectorString Format "lat/lng#lat/lng#..."
|
||||||
|
* @return array Array de ['lat' => float, 'lng' => float]
|
||||||
|
*/
|
||||||
|
private function parseSectorPolygon(string $sectorString): array
|
||||||
|
{
|
||||||
|
$polygon = [];
|
||||||
|
$points = explode('#', rtrim($sectorString, '#'));
|
||||||
|
|
||||||
|
foreach ($points as $point) {
|
||||||
|
if (!empty($point) && strpos($point, '/') !== false) {
|
||||||
|
list($lat, $lng) = explode('/', $point);
|
||||||
|
$polygon[] = [
|
||||||
|
'lat' => (float)$lat,
|
||||||
|
'lng' => (float)$lng
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $polygon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un point est à l'intérieur d'un polygone
|
||||||
|
* Utilise l'algorithme de ray casting
|
||||||
|
*
|
||||||
|
* @param float $lat Latitude du point
|
||||||
|
* @param float $lng Longitude du point
|
||||||
|
* @param array $polygon Array de ['lat' => float, 'lng' => float]
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function isPointInsidePolygon(float $lat, float $lng, array $polygon): bool
|
||||||
|
{
|
||||||
|
$x = $lat;
|
||||||
|
$y = $lng;
|
||||||
|
$inside = false;
|
||||||
|
$count = count($polygon);
|
||||||
|
|
||||||
|
for ($i = 0, $j = $count - 1; $i < $count; $j = $i++) {
|
||||||
|
$xi = $polygon[$i]['lat'];
|
||||||
|
$yi = $polygon[$i]['lng'];
|
||||||
|
$xj = $polygon[$j]['lat'];
|
||||||
|
$yj = $polygon[$j]['lng'];
|
||||||
|
|
||||||
|
$intersect = (($yi > $y) != ($yj > $y))
|
||||||
|
&& ($x < ($xj - $xi) * ($y - $yi) / ($yj - $yi) + $xi);
|
||||||
|
|
||||||
|
if ($intersect) {
|
||||||
|
$inside = !$inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -465,6 +465,7 @@ class StripeService {
|
|||||||
$entiteId = $params['fk_entite'] ?? 0;
|
$entiteId = $params['fk_entite'] ?? 0;
|
||||||
$userId = $params['fk_user'] ?? 0;
|
$userId = $params['fk_user'] ?? 0;
|
||||||
$metadata = $params['metadata'] ?? [];
|
$metadata = $params['metadata'] ?? [];
|
||||||
|
$paymentMethodTypes = $params['payment_method_types'] ?? ['card_present'];
|
||||||
|
|
||||||
if ($amount < 100) {
|
if ($amount < 100) {
|
||||||
throw new Exception("Le montant minimum est de 1€");
|
throw new Exception("Le montant minimum est de 1€");
|
||||||
@@ -481,117 +482,51 @@ class StripeService {
|
|||||||
throw new Exception("Compte Stripe non trouvé");
|
throw new Exception("Compte Stripe non trouvé");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pas de commission plateforme - 100% pour l'amicale
|
// Déterminer le mode : Tap to Pay (card_present) ou Payment Link (card)
|
||||||
|
$isTapToPay = in_array('card_present', $paymentMethodTypes);
|
||||||
|
|
||||||
// Créer le PaymentIntent sans commission
|
// Configuration du PaymentIntent selon le mode
|
||||||
$paymentIntent = $this->stripe->paymentIntents->create([
|
$paymentIntentData = [
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'currency' => 'eur',
|
'currency' => 'eur',
|
||||||
'payment_method_types' => ['card_present'],
|
'payment_method_types' => $paymentMethodTypes,
|
||||||
'capture_method' => 'automatic',
|
'capture_method' => 'automatic',
|
||||||
// Pas d'application_fee_amount - tout va à l'amicale
|
|
||||||
'transfer_data' => [
|
|
||||||
'destination' => $account['stripe_account_id'],
|
|
||||||
],
|
|
||||||
'metadata' => array_merge($metadata, [
|
'metadata' => array_merge($metadata, [
|
||||||
'entite_id' => $entiteId,
|
'entite_id' => $entiteId,
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'calendrier_annee' => date('Y'),
|
'calendrier_annee' => date('Y'),
|
||||||
]),
|
]),
|
||||||
]);
|
];
|
||||||
|
|
||||||
// Sauvegarder en base
|
// Options Stripe (avec ou sans stripe_account)
|
||||||
$stmt = $this->db->prepare(
|
$stripeOptions = [];
|
||||||
"INSERT INTO stripe_payment_intents
|
|
||||||
(stripe_payment_intent_id, fk_entite, fk_user, amount, currency, status, application_fee, metadata, created_at)
|
if ($isTapToPay) {
|
||||||
VALUES (:pi_id, :fk_entite, :fk_user, :amount, :currency, :status, :app_fee, :metadata, NOW())"
|
// TAP TO PAY : Paiement direct sur le compte connecté
|
||||||
|
// Le PaymentIntent est créé sur le compte de l'amicale
|
||||||
|
$stripeOptions['stripe_account'] = $account['stripe_account_id'];
|
||||||
|
} else {
|
||||||
|
// PAYMENT LINK / WEB : Paiement via la plateforme avec transfert
|
||||||
|
// Le PaymentIntent est créé sur la plateforme et transféré
|
||||||
|
$paymentIntentData['transfer_data'] = [
|
||||||
|
'destination' => $account['stripe_account_id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le PaymentIntent
|
||||||
|
$paymentIntent = $this->stripe->paymentIntents->create(
|
||||||
|
$paymentIntentData,
|
||||||
|
$stripeOptions
|
||||||
);
|
);
|
||||||
$stmt->execute([
|
|
||||||
'pi_id' => $paymentIntent->id,
|
// Note : Le payment_intent_id est sauvegardé dans ope_pass.stripe_payment_id par le controller
|
||||||
'fk_entite' => $entiteId,
|
|
||||||
'fk_user' => $userId,
|
|
||||||
'amount' => $amount,
|
|
||||||
'currency' => 'eur',
|
|
||||||
'status' => $paymentIntent->status,
|
|
||||||
'app_fee' => 0, // Pas de commission
|
|
||||||
'metadata' => json_encode($metadata)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'client_secret' => $paymentIntent->client_secret,
|
'client_secret' => $paymentIntent->client_secret,
|
||||||
'payment_intent_id' => $paymentIntent->id,
|
'payment_intent_id' => $paymentIntent->id,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
'application_fee' => 0 // Pas de commission
|
'mode' => $isTapToPay ? 'tap_to_pay' : 'payment_link'
|
||||||
];
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Erreur: ' . $e->getMessage()
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vérifier la compatibilité Tap to Pay d'un appareil Android
|
|
||||||
*/
|
|
||||||
public function checkAndroidTapToPayCompatibility(string $manufacturer, string $model): array {
|
|
||||||
try {
|
|
||||||
$stmt = $this->db->prepare(
|
|
||||||
"SELECT * FROM stripe_android_certified_devices
|
|
||||||
WHERE manufacturer = :manufacturer
|
|
||||||
AND model = :model
|
|
||||||
AND tap_to_pay_certified = 1
|
|
||||||
AND country = 'FR'"
|
|
||||||
);
|
|
||||||
$stmt->execute([
|
|
||||||
'manufacturer' => $manufacturer,
|
|
||||||
'model' => $model
|
|
||||||
]);
|
|
||||||
|
|
||||||
$device = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
if ($device) {
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'tap_to_pay_supported' => true,
|
|
||||||
'message' => 'Tap to Pay disponible sur cet appareil',
|
|
||||||
'min_android_version' => $device['min_android_version']
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'tap_to_pay_supported' => false,
|
|
||||||
'message' => 'Appareil non certifié pour Tap to Pay en France',
|
|
||||||
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 16.4+'
|
|
||||||
];
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Erreur: ' . $e->getMessage()
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupérer les appareils Android certifiés
|
|
||||||
*/
|
|
||||||
public function getCertifiedAndroidDevices(): array {
|
|
||||||
try {
|
|
||||||
$stmt = $this->db->prepare(
|
|
||||||
"SELECT manufacturer, model, model_identifier, min_android_version
|
|
||||||
FROM stripe_android_certified_devices
|
|
||||||
WHERE tap_to_pay_certified = 1 AND country = 'FR'
|
|
||||||
ORDER BY manufacturer, model"
|
|
||||||
);
|
|
||||||
$stmt->execute();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'devices' => $stmt->fetchAll(PDO::FETCH_ASSOC)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -747,6 +682,47 @@ class StripeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annuler un PaymentIntent Stripe
|
||||||
|
*
|
||||||
|
* @param string $paymentIntentId L'ID du PaymentIntent à annuler
|
||||||
|
* @return array ['success' => bool, 'status' => string|null, 'message' => string|null]
|
||||||
|
*/
|
||||||
|
public function cancelPaymentIntent(string $paymentIntentId): array {
|
||||||
|
try {
|
||||||
|
// Annuler le PaymentIntent via l'API Stripe
|
||||||
|
$paymentIntent = $this->stripe->paymentIntents->cancel($paymentIntentId);
|
||||||
|
|
||||||
|
LogService::log('PaymentIntent annulé', [
|
||||||
|
'payment_intent_id' => $paymentIntentId,
|
||||||
|
'status' => $paymentIntent->status
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'status' => $paymentIntent->status,
|
||||||
|
'payment_intent_id' => $paymentIntentId
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (ApiErrorException $e) {
|
||||||
|
LogService::log('Erreur annulation PaymentIntent Stripe', [
|
||||||
|
'level' => 'error',
|
||||||
|
'payment_intent_id' => $paymentIntentId,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Erreur Stripe: ' . $e->getMessage()
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Erreur: ' . $e->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtenir le mode actuel (test ou live)
|
* Obtenir le mode actuel (test ou live)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,208 +1,143 @@
|
|||||||
# 🍎 Guide de Build iOS - GEOSECTOR
|
# 🍎 Guide de Build iOS - GEOSECTOR
|
||||||
|
|
||||||
**Date de création** : 21/10/2025
|
**Dernière mise à jour** : 16/11/2025
|
||||||
**Version actuelle** : 3.4.2 (Build 342)
|
**Version système** : Workflow automatisé depuis Debian
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 **Prérequis**
|
## 📋 Prérequis
|
||||||
|
|
||||||
### Sur le Mac mini
|
### Mac mini (192.168.1.34)
|
||||||
- ✅ macOS installé
|
- ✅ Xcode + Command Line Tools
|
||||||
- ✅ Xcode installé avec Command Line Tools
|
- ✅ Flutter 3.24.5 LTS
|
||||||
- ✅ Flutter installé (3.24.5 LTS recommandé)
|
- ✅ CocoaPods installé
|
||||||
- ✅ CocoaPods installé (`sudo gem install cocoapods`)
|
- ✅ Certificats Apple (Team: **6WT84NWCTC**)
|
||||||
- ✅ Certificats Apple configurés (Team ID: 6WT84NWCTC)
|
|
||||||
|
|
||||||
### Sur Debian
|
### PC Debian (développement)
|
||||||
- ✅ Accès SSH au Mac mini (192.168.1.34)
|
- ✅ Accès SSH au Mac mini
|
||||||
- ✅ rsync installé
|
- ✅ Fichier `../VERSION` à jour
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 **Procédure complète**
|
## 🚀 Build iOS - Workflow complet
|
||||||
|
|
||||||
### **Étape 1 : Transfert depuis Debian vers Mac mini**
|
### **Commande unique depuis Debian**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sur votre machine Debian
|
|
||||||
cd /home/pierre/dev/geosector/app
|
cd /home/pierre/dev/geosector/app
|
||||||
|
./ios.sh
|
||||||
# Lancer le transfert
|
|
||||||
./transfer-to-mac.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ce que fait le script** :
|
**Ce que fait le script** :
|
||||||
1. Détecte automatiquement la version (ex: 342)
|
1. ✅ Lit `../VERSION` (ex: 3.5.3)
|
||||||
2. Crée le dossier `app_342` sur le Mac mini
|
2. ✅ Met à jour `pubspec.yaml` (3.5.3+353)
|
||||||
3. Transfert tous les fichiers nécessaires (lib, ios, pubspec.yaml, etc.)
|
3. ✅ Teste connexion Mac mini
|
||||||
4. Exclut les dossiers inutiles (build, .dart_tool, Pods, etc.)
|
4. ✅ Transfert rsync → `/Users/pierre/dev/geosector/app_353/`
|
||||||
|
5. 🔀 **Choix A** : Lance build SSH automatique
|
||||||
**Durée** : 2-5 minutes (selon la connexion réseau)
|
6. 🔀 **Choix B** : Instructions manuelles
|
||||||
|
|
||||||
**Note** : Vous devrez saisir le mot de passe du Mac mini
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Étape 2 : Connexion au Mac mini**
|
### **Option A : Build automatique (recommandé)**
|
||||||
|
|
||||||
|
Sélectionner **A** dans le menu :
|
||||||
|
- SSH automatique vers Mac mini
|
||||||
|
- Lance `ios-build-mac.sh`
|
||||||
|
- Ouvre Xcode pour l'archive
|
||||||
|
|
||||||
|
### **Option B : Build manuel**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Depuis Debian
|
|
||||||
ssh pierre@192.168.1.34
|
ssh pierre@192.168.1.34
|
||||||
|
cd /Users/pierre/dev/geosector/app_353
|
||||||
# Aller dans le dossier transféré
|
|
||||||
cd /Users/pierre/dev/geosector/app_342
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Étape 3 : Lancer le build iOS**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Sur le Mac mini
|
|
||||||
./ios-build-mac.sh
|
./ios-build-mac.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ce que fait le script** :
|
---
|
||||||
1. ✅ Nettoie le projet (`flutter clean`)
|
|
||||||
2. ✅ Récupère les dépendances (`flutter pub get`)
|
|
||||||
3. ✅ Installe les pods (`pod install`)
|
|
||||||
4. ✅ Compile en release (`flutter build ios --release`)
|
|
||||||
5. ✅ Ouvre Xcode pour l'archive (signature manuelle plus fiable)
|
|
||||||
|
|
||||||
**Durée de préparation** : 5-10 minutes
|
## 📦 Archive et Upload (Xcode)
|
||||||
|
|
||||||
**Résultat** : Xcode s'ouvre, prêt pour Product > Archive
|
**Xcode s'ouvre automatiquement** après le build ✅
|
||||||
|
|
||||||
|
1. ⏳ Attendre chargement Xcode
|
||||||
|
2. ✅ Vérifier **Signing & Capabilities**
|
||||||
|
- Team : `6WT84NWCTC`
|
||||||
|
- "Automatically manage signing" : ✅
|
||||||
|
3. 🧹 **Product > Clean Build Folder** (⌘⇧K)
|
||||||
|
4. 📦 **Product > Archive** (⏳ 5-10 min)
|
||||||
|
5. 📤 **Organizer** → **Distribute App**
|
||||||
|
6. ☁️ **App Store Connect** → **Upload**
|
||||||
|
7. ✅ **Upload** (⏳ 2-5 min)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Étape 4 : Créer l'archive et upload vers App Store Connect**
|
## 📱 TestFlight (App Store Connect)
|
||||||
|
|
||||||
**Xcode est ouvert automatiquement** ✅
|
https://appstoreconnect.apple.com
|
||||||
|
|
||||||
Dans Xcode :
|
1. **Apps** > **GeoSector** > **TestFlight**
|
||||||
1. ⏳ Attendre le chargement (quelques secondes)
|
2. ⏳ Attendre traitement (5-15 min)
|
||||||
2. ✅ Vérifier **Signing & Capabilities** : Team = 6WT84NWCTC, "Automatically manage signing" coché
|
3. Build **353 (3.5.3)** apparaît
|
||||||
3. 🧹 **Product > Clean Build Folder** (Cmd+Shift+K)
|
4. **Conformité export** :
|
||||||
4. 📦 **Product > Archive**
|
- Utilise chiffrement ? → **Oui**
|
||||||
5. ⏳ Attendre l'archive (5-10 minutes)
|
- Algorithmes exempts ? → **Aucun des algorithmes mentionnés**
|
||||||
6. 📤 **Organizer** s'ouvre → Clic **Distribute App**
|
5. **Testeurs internes** → Ajouter ton Apple ID
|
||||||
7. ☁️ Choisir **App Store Connect**
|
6. 📧 Invitation TestFlight envoyée
|
||||||
8. ✅ **Upload** → Automatique
|
|
||||||
9. 🚀 **Next** jusqu'à validation finale
|
|
||||||
|
|
||||||
**⚠️ Ne PAS utiliser xcodebuild en ligne de commande** : erreurs de signature (errSecInternalComponent). Xcode GUI obligatoire.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 **Structure des dossiers sur Mac mini**
|
## ✅ Checklist rapide
|
||||||
|
|
||||||
```
|
- [ ] Mettre à jour `../VERSION` (ex: 3.5.4)
|
||||||
/Users/pierre/dev/geosector/
|
- [ ] Lancer `./ios.sh` depuis Debian
|
||||||
├── app_342/ # Version 3.4.2 (Build 342)
|
- [ ] Archive créée dans Xcode
|
||||||
│ ├── ios/
|
- [ ] Upload vers App Store Connect
|
||||||
│ ├── lib/
|
- [ ] Conformité export renseignée
|
||||||
│ ├── pubspec.yaml
|
- [ ] Testeur interne ajouté
|
||||||
│ ├── ios-build-mac.sh # Script de build
|
- [ ] App installée via TestFlight
|
||||||
│ └── build/
|
|
||||||
│ └── Runner.xcarchive # Archive générée
|
|
||||||
├── app_341/ # Version précédente (si existe)
|
|
||||||
└── app_343/ # Version future
|
|
||||||
```
|
|
||||||
|
|
||||||
**Avantage** : Garder plusieurs versions côte à côte pour tests/rollback
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 **Résolution de problèmes**
|
## 🔧 Résolution problèmes
|
||||||
|
|
||||||
### **Erreur : "Flutter not found"**
|
### Erreur SSH "Too many authentication failures"
|
||||||
|
✅ **Corrigé** : Le script force l'authentification par mot de passe
|
||||||
|
|
||||||
```bash
|
### Erreur de signature Xcode
|
||||||
# Vérifier que Flutter est dans le PATH
|
```
|
||||||
echo $PATH | grep flutter
|
Signing & Capabilities > Team = 6WT84NWCTC
|
||||||
|
"Automatically manage signing" ✅
|
||||||
# Ajouter Flutter au PATH (dans ~/.zshrc ou ~/.bash_profile)
|
|
||||||
export PATH="$PATH:/opt/flutter/bin"
|
|
||||||
source ~/.zshrc
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Erreur : "xcodebuild not found"**
|
### Pod install échoue
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Installer Xcode Command Line Tools
|
|
||||||
xcode-select --install
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Erreur lors de pod install**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Sur le Mac mini
|
|
||||||
cd ios
|
cd ios
|
||||||
rm -rf Pods Podfile.lock
|
rm -rf Pods Podfile.lock
|
||||||
pod install --repo-update
|
pod install --repo-update
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Erreur de signature**
|
|
||||||
|
|
||||||
1. Ouvrir Xcode : `open ios/Runner.xcworkspace`
|
|
||||||
2. Sélectionner le target "Runner"
|
|
||||||
3. Onglet "Signing & Capabilities"
|
|
||||||
4. Vérifier Team ID : `6WT84NWCTC`
|
|
||||||
5. Cocher "Automatically manage signing"
|
|
||||||
|
|
||||||
### **Archive créée mais vide**
|
|
||||||
|
|
||||||
Vérifier que la compilation iOS a réussi :
|
|
||||||
```bash
|
|
||||||
flutter build ios --release --no-codesign --verbose
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 **Checklist de validation**
|
## 🎯 Workflow version complète
|
||||||
|
|
||||||
- [ ] Version/Build incrémenté dans `pubspec.yaml`
|
```mermaid
|
||||||
- [ ] Compilation iOS réussie
|
Debian (dev) → Mac mini (build) → App Store Connect → TestFlight → iPhone
|
||||||
- [ ] Archive validée dans Xcode Organizer
|
│ │ │ │ │
|
||||||
- [ ] Build uploadé vers App Store Connect
|
↓ ↓ ↓ ↓ ↓
|
||||||
- [ ] **TestFlight** : Ajouter build au groupe "Testeurs externes"
|
ios.sh build iOS Upload Traitement Install
|
||||||
- [ ] Renseigner "Infos sur l'exportation de conformité" :
|
+ Archive (5-15 min)
|
||||||
- **App utilise chiffrement ?** → Oui
|
|
||||||
- **Algorithmes exempts listés ?** → **Aucun des algorithmes mentionnés ci-dessus**
|
|
||||||
- (App utilise HTTPS standard iOS uniquement)
|
|
||||||
- [ ] Soumettre build pour révision TestFlight
|
|
||||||
- [ ] *(Optionnel)* Captures/Release notes pour production App Store
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 **Workflow complet**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Debian → Transfert
|
|
||||||
cd /home/pierre/dev/geosector/app
|
|
||||||
./transfer-to-mac.sh
|
|
||||||
|
|
||||||
# 2. Mac mini → Build + Archive
|
|
||||||
ssh pierre@192.168.1.34
|
|
||||||
cd /Users/pierre/dev/geosector/app_342
|
|
||||||
./ios-build-mac.sh
|
|
||||||
# Xcode s'ouvre → Product > Clean + Archive
|
|
||||||
|
|
||||||
# 3. Upload → TestFlight
|
|
||||||
# Organizer > Distribute App > App Store Connect > Upload
|
|
||||||
# App Store Connect > TestFlight > Conformité export
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Temps total** : 20-30 minutes (build + upload + traitement Apple)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📞 **Support**
|
## 📞 Liens utiles
|
||||||
|
|
||||||
- **Documentation Apple** : https://developer.apple.com
|
|
||||||
- **App Store Connect** : https://appstoreconnect.apple.com
|
- **App Store Connect** : https://appstoreconnect.apple.com
|
||||||
|
- **TestFlight** : App dans l'App Store
|
||||||
- **Flutter iOS** : https://docs.flutter.dev/deployment/ios
|
- **Flutter iOS** : https://docs.flutter.dev/deployment/ios
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
✅ **Prêt pour la production !** 🚀
|
✅ **Prêt pour TestFlight !** 🚀
|
||||||
|
|||||||
306
app/android.sh
@@ -57,42 +57,181 @@ if ! command -v flutter &> /dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Récupérer la version depuis pubspec.yaml
|
# Étape 0 : Synchroniser la version depuis ../VERSION
|
||||||
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | sed 's/+/-/')
|
print_message "Étape 0/5 : Synchronisation de la version..."
|
||||||
if [ -z "$VERSION" ]; then
|
echo
|
||||||
print_error "Impossible de récupérer la version depuis pubspec.yaml"
|
|
||||||
|
VERSION_FILE="../VERSION"
|
||||||
|
if [ ! -f "$VERSION_FILE" ]; then
|
||||||
|
print_error "Fichier VERSION introuvable : $VERSION_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Extraire le version code
|
# Lire la version depuis le fichier (enlever espaces/retours à la ligne)
|
||||||
VERSION_CODE=$(echo $VERSION | cut -d'-' -f2)
|
VERSION_NUMBER=$(cat "$VERSION_FILE" | tr -d '\n\r ' | tr -d '[:space:]')
|
||||||
|
if [ -z "$VERSION_NUMBER" ]; then
|
||||||
|
print_error "Le fichier VERSION est vide"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "Version lue depuis $VERSION_FILE : $VERSION_NUMBER"
|
||||||
|
|
||||||
|
# Calculer le versionCode (supprimer les points)
|
||||||
|
VERSION_CODE=$(echo $VERSION_NUMBER | tr -d '.')
|
||||||
if [ -z "$VERSION_CODE" ]; then
|
if [ -z "$VERSION_CODE" ]; then
|
||||||
print_error "Impossible d'extraire le version code"
|
print_error "Impossible de calculer le versionCode"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_message "Version détectée : $VERSION"
|
print_message "Version code calculé : $VERSION_CODE"
|
||||||
|
|
||||||
|
# Mettre à jour pubspec.yaml
|
||||||
|
print_message "Mise à jour de pubspec.yaml..."
|
||||||
|
sed -i.bak "s/^version:.*/version: $VERSION_NUMBER+$VERSION_CODE/" pubspec.yaml
|
||||||
|
|
||||||
|
# Vérifier que la mise à jour a réussi
|
||||||
|
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //')
|
||||||
|
if [ "$UPDATED_VERSION" != "$VERSION_NUMBER+$VERSION_CODE" ]; then
|
||||||
|
print_error "Échec de la mise à jour de pubspec.yaml"
|
||||||
|
print_error "Attendu : $VERSION_NUMBER+$VERSION_CODE"
|
||||||
|
print_error "Obtenu : $UPDATED_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "pubspec.yaml mis à jour : version: $VERSION_NUMBER+$VERSION_CODE"
|
||||||
|
print_message "build.gradle.kts se synchronisera automatiquement via Flutter Gradle Plugin"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Récupérer la version finale pour l'affichage
|
||||||
|
VERSION="$VERSION_NUMBER-$VERSION_CODE"
|
||||||
|
print_message "Version finale : $VERSION"
|
||||||
print_message "Version code : $VERSION_CODE"
|
print_message "Version code : $VERSION_CODE"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Vérifier la présence du keystore
|
# Demander le mode Debug ou Release
|
||||||
if [ ! -f "android/app/geosector2025.jks" ]; then
|
print_message "========================================="
|
||||||
print_error "Fichier keystore introuvable : android/app/geosector2025.jks"
|
print_message " MODE DE BUILD"
|
||||||
exit 1
|
print_message "========================================="
|
||||||
|
echo
|
||||||
|
print_message "Choisissez le mode de build :"
|
||||||
|
echo
|
||||||
|
print_message " ${YELLOW}[D]${NC} Debug"
|
||||||
|
print_message " ✓ Installation rapide via ADB"
|
||||||
|
print_message " ✓ Hot reload possible"
|
||||||
|
print_message " ✓ Logs complets"
|
||||||
|
print_message " ⚠ Tap to Pay simulé uniquement"
|
||||||
|
print_message " ⚠ Performance non optimisée"
|
||||||
|
echo
|
||||||
|
print_message " ${GREEN}[R]${NC} Release (recommandé)"
|
||||||
|
print_message " ✓ APK/AAB optimisé"
|
||||||
|
print_message " ✓ Tap to Pay réel en production"
|
||||||
|
print_message " ✓ Performance maximale"
|
||||||
|
echo
|
||||||
|
read -p "Votre choix (D/R) [défaut: R] : " -n 1 -r BUILD_TYPE
|
||||||
|
echo
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Définir le flag de build et le suffixe pour les noms de fichiers
|
||||||
|
BUILD_MODE_FLAG="--release"
|
||||||
|
MODE_SUFFIX="release"
|
||||||
|
SKIP_R8_CHOICE=false
|
||||||
|
|
||||||
|
if [[ $BUILD_TYPE =~ ^[Dd]$ ]]; then
|
||||||
|
BUILD_MODE_FLAG="--debug"
|
||||||
|
MODE_SUFFIX="debug"
|
||||||
|
SKIP_R8_CHOICE=true
|
||||||
|
print_success "Mode Debug sélectionné"
|
||||||
|
echo
|
||||||
|
print_warning "Attention : Tap to Pay ne fonctionnera qu'en mode simulé"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# En mode debug, pas de choix R8 ni de vérification keystore
|
||||||
|
USE_R8=false
|
||||||
|
COPY_DEBUG_FILES=false
|
||||||
|
else
|
||||||
|
print_success "Mode Release sélectionné"
|
||||||
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Vérifier la présence du fichier key.properties
|
# Demander le mode R8 SEULEMENT si Release
|
||||||
if [ ! -f "android/key.properties" ]; then
|
if [ "$SKIP_R8_CHOICE" = false ]; then
|
||||||
|
print_message "========================================="
|
||||||
|
print_message " OPTIMISATION RELEASE"
|
||||||
|
print_message "========================================="
|
||||||
|
echo
|
||||||
|
print_message "Choisissez le niveau d'optimisation :"
|
||||||
|
echo
|
||||||
|
print_message " ${GREEN}[A]${NC} Production - R8/ProGuard activé"
|
||||||
|
print_message " ✓ Taille réduite (~30-40%)"
|
||||||
|
print_message " ✓ Code obscurci (sécurité)"
|
||||||
|
print_message " ✓ Génère mapping.txt pour débogage"
|
||||||
|
print_message " ✓ Génère symboles natifs"
|
||||||
|
echo
|
||||||
|
print_message " ${YELLOW}[B]${NC} Test interne - Sans R8/ProGuard (défaut)"
|
||||||
|
print_message " ✓ Build plus rapide"
|
||||||
|
print_message " ✓ Pas d'obscurcissement (débogage facile)"
|
||||||
|
print_message " ⚠ Taille plus importante"
|
||||||
|
print_message " ⚠ Avertissements Google Play Console"
|
||||||
|
echo
|
||||||
|
read -p "Votre choix (A/B) [défaut: B] : " -n 1 -r BUILD_MODE
|
||||||
|
echo
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Définir les variables selon le choix
|
||||||
|
USE_R8=false
|
||||||
|
COPY_DEBUG_FILES=false
|
||||||
|
|
||||||
|
if [[ $BUILD_MODE =~ ^[Aa]$ ]]; then
|
||||||
|
USE_R8=true
|
||||||
|
COPY_DEBUG_FILES=true
|
||||||
|
print_success "Mode Production sélectionné - R8/ProGuard activé"
|
||||||
|
else
|
||||||
|
print_success "Mode Test interne sélectionné - R8/ProGuard désactivé"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vérifier la présence du keystore SEULEMENT si Release
|
||||||
|
if [ "$SKIP_R8_CHOICE" = false ]; then
|
||||||
|
if [ ! -f "android/app/geosector2025.jks" ]; then
|
||||||
|
print_error "Fichier keystore introuvable : android/app/geosector2025.jks"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vérifier la présence du fichier key.properties
|
||||||
|
if [ ! -f "android/key.properties" ]; then
|
||||||
print_error "Fichier key.properties introuvable"
|
print_error "Fichier key.properties introuvable"
|
||||||
print_error "Ce fichier est nécessaire pour signer l'application"
|
print_error "Ce fichier est nécessaire pour signer l'application"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "Configuration de signature vérifiée"
|
||||||
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_success "Configuration de signature vérifiée"
|
# Activer R8 si demandé (modification temporaire du build.gradle.kts)
|
||||||
echo
|
GRADLE_FILE="android/app/build.gradle.kts"
|
||||||
|
GRADLE_BACKUP="android/app/build.gradle.kts.backup"
|
||||||
|
|
||||||
|
if [ "$USE_R8" = true ]; then
|
||||||
|
print_message "Activation de R8/ProGuard dans build.gradle.kts..."
|
||||||
|
|
||||||
|
# Créer une sauvegarde
|
||||||
|
cp "$GRADLE_FILE" "$GRADLE_BACKUP"
|
||||||
|
|
||||||
|
# Activer minifyEnabled et shrinkResources
|
||||||
|
sed -i.tmp 's/isMinifyEnabled = false/isMinifyEnabled = true/' "$GRADLE_FILE"
|
||||||
|
sed -i.tmp 's/isShrinkResources = false/isShrinkResources = true/' "$GRADLE_FILE"
|
||||||
|
|
||||||
|
# Nettoyer les fichiers temporaires de sed
|
||||||
|
rm -f "${GRADLE_FILE}.tmp"
|
||||||
|
|
||||||
|
print_success "R8/ProGuard activé temporairement"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
# Étape 1 : Nettoyer le projet
|
# Étape 1 : Nettoyer le projet
|
||||||
print_message "Étape 1/4 : Nettoyage du projet..."
|
print_message "Étape 1/5 : Nettoyage du projet..."
|
||||||
flutter clean
|
flutter clean
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
print_success "Projet nettoyé"
|
print_success "Projet nettoyé"
|
||||||
@@ -103,7 +242,7 @@ fi
|
|||||||
echo
|
echo
|
||||||
|
|
||||||
# Étape 2 : Récupérer les dépendances
|
# Étape 2 : Récupérer les dépendances
|
||||||
print_message "Étape 2/4 : Récupération des dépendances..."
|
print_message "Étape 2/5 : Récupération des dépendances..."
|
||||||
flutter pub get
|
flutter pub get
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
print_success "Dépendances récupérées"
|
print_success "Dépendances récupérées"
|
||||||
@@ -114,7 +253,7 @@ fi
|
|||||||
echo
|
echo
|
||||||
|
|
||||||
# Étape 3 : Analyser le code (optionnel mais recommandé)
|
# Étape 3 : Analyser le code (optionnel mais recommandé)
|
||||||
print_message "Étape 3/4 : Analyse du code Dart..."
|
print_message "Étape 3/5 : Analyse du code Dart..."
|
||||||
flutter analyze --no-fatal-infos --no-fatal-warnings || {
|
flutter analyze --no-fatal-infos --no-fatal-warnings || {
|
||||||
print_warning "Des avertissements ont été détectés dans le code"
|
print_warning "Des avertissements ont été détectés dans le code"
|
||||||
read -p "Voulez-vous continuer malgré les avertissements ? (y/n) " -n 1 -r
|
read -p "Voulez-vous continuer malgré les avertissements ? (y/n) " -n 1 -r
|
||||||
@@ -128,9 +267,9 @@ print_success "Analyse du code terminée"
|
|||||||
echo
|
echo
|
||||||
|
|
||||||
# Étape 4 : Générer le bundle
|
# Étape 4 : Générer le bundle
|
||||||
print_message "Étape 4/4 : Génération du bundle Android..."
|
print_message "Étape 4/5 : Génération du bundle Android..."
|
||||||
print_message "Cette opération peut prendre plusieurs minutes..."
|
print_message "Cette opération peut prendre plusieurs minutes..."
|
||||||
flutter build appbundle --release
|
flutter build appbundle $BUILD_MODE_FLAG
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
print_success "Bundle généré avec succès"
|
print_success "Bundle généré avec succès"
|
||||||
else
|
else
|
||||||
@@ -139,15 +278,23 @@ else
|
|||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
|
|
||||||
|
# Restaurer le build.gradle.kts original si modifié
|
||||||
|
if [ "$USE_R8" = true ] && [ -f "$GRADLE_BACKUP" ]; then
|
||||||
|
print_message "Restauration du build.gradle.kts original..."
|
||||||
|
mv "$GRADLE_BACKUP" "$GRADLE_FILE"
|
||||||
|
print_success "Fichier restauré"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
# Vérifier que le bundle a été créé
|
# Vérifier que le bundle a été créé
|
||||||
BUNDLE_PATH="build/app/outputs/bundle/release/app-release.aab"
|
BUNDLE_PATH="build/app/outputs/bundle/$MODE_SUFFIX/app-$MODE_SUFFIX.aab"
|
||||||
if [ ! -f "$BUNDLE_PATH" ]; then
|
if [ ! -f "$BUNDLE_PATH" ]; then
|
||||||
print_error "Bundle introuvable à l'emplacement attendu : $BUNDLE_PATH"
|
print_error "Bundle introuvable à l'emplacement attendu : $BUNDLE_PATH"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copier le bundle à la racine avec le nouveau nom
|
# Copier le bundle à la racine avec le nouveau nom
|
||||||
FINAL_NAME="geosector-$VERSION_CODE.aab"
|
FINAL_NAME="geosector-$VERSION_CODE-$MODE_SUFFIX.aab"
|
||||||
print_message "Copie du bundle vers : $FINAL_NAME"
|
print_message "Copie du bundle vers : $FINAL_NAME"
|
||||||
cp "$BUNDLE_PATH" "$FINAL_NAME"
|
cp "$BUNDLE_PATH" "$FINAL_NAME"
|
||||||
|
|
||||||
@@ -162,6 +309,47 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Copier les fichiers de débogage si Option A sélectionnée
|
||||||
|
if [ "$COPY_DEBUG_FILES" = true ]; then
|
||||||
|
echo
|
||||||
|
print_message "Copie des fichiers de débogage pour Google Play Console..."
|
||||||
|
|
||||||
|
# Créer un dossier de release
|
||||||
|
RELEASE_DIR="release-$VERSION_CODE"
|
||||||
|
mkdir -p "$RELEASE_DIR"
|
||||||
|
|
||||||
|
# Copier le bundle
|
||||||
|
cp "$FINAL_NAME" "$RELEASE_DIR/"
|
||||||
|
|
||||||
|
# Copier le fichier mapping.txt (R8/ProGuard)
|
||||||
|
MAPPING_FILE="build/app/outputs/mapping/release/mapping.txt"
|
||||||
|
if [ -f "$MAPPING_FILE" ]; then
|
||||||
|
cp "$MAPPING_FILE" "$RELEASE_DIR/mapping.txt"
|
||||||
|
print_success "Fichier mapping.txt copié"
|
||||||
|
else
|
||||||
|
print_warning "Fichier mapping.txt introuvable (peut être normal)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copier les symboles natifs
|
||||||
|
SYMBOLS_ZIP="build/app/intermediates/merged_native_libs/release/out/lib"
|
||||||
|
if [ -d "$SYMBOLS_ZIP" ]; then
|
||||||
|
# Créer une archive des symboles
|
||||||
|
cd build/app/intermediates/merged_native_libs/release/out
|
||||||
|
zip -r "../../../../../../$RELEASE_DIR/native-symbols.zip" lib/
|
||||||
|
cd - > /dev/null
|
||||||
|
print_success "Symboles natifs archivés"
|
||||||
|
else
|
||||||
|
print_warning "Symboles natifs introuvables (peut être normal)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "Fichiers de débogage copiés dans : $RELEASE_DIR/"
|
||||||
|
echo
|
||||||
|
print_message "Pour uploader sur Google Play Console :"
|
||||||
|
print_message "1. Bundle : $RELEASE_DIR/$FINAL_NAME"
|
||||||
|
print_message "2. Mapping : $RELEASE_DIR/mapping.txt"
|
||||||
|
print_message "3. Symboles : $RELEASE_DIR/native-symbols.zip"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
print_message "========================================="
|
print_message "========================================="
|
||||||
print_success " GÉNÉRATION TERMINÉE AVEC SUCCÈS !"
|
print_success " GÉNÉRATION TERMINÉE AVEC SUCCÈS !"
|
||||||
@@ -170,11 +358,37 @@ echo
|
|||||||
print_message "Bundle généré : ${GREEN}$FINAL_NAME${NC}"
|
print_message "Bundle généré : ${GREEN}$FINAL_NAME${NC}"
|
||||||
print_message "Version : $VERSION"
|
print_message "Version : $VERSION"
|
||||||
print_message "Chemin : $(pwd)/$FINAL_NAME"
|
print_message "Chemin : $(pwd)/$FINAL_NAME"
|
||||||
echo
|
|
||||||
print_message "Prochaines étapes :"
|
if [ "$BUILD_MODE_FLAG" = "--debug" ]; then
|
||||||
print_message "1. Tester le bundle sur un appareil Android"
|
echo
|
||||||
print_message "2. Uploader sur Google Play Console"
|
print_message "Mode : ${YELLOW}Debug${NC}"
|
||||||
print_message "3. Soumettre pour review"
|
print_message "⚠ Tap to Pay simulé uniquement"
|
||||||
|
print_message "✓ Logs complets disponibles"
|
||||||
|
echo
|
||||||
|
print_message "Prochaines étapes :"
|
||||||
|
print_message "1. Installer l'APK sur l'appareil (proposé ci-dessous)"
|
||||||
|
print_message "2. Tester l'application avec adb logcat"
|
||||||
|
print_message "3. Pour Tap to Pay réel, relancer en mode Release"
|
||||||
|
elif [ "$USE_R8" = true ]; then
|
||||||
|
echo
|
||||||
|
print_message "Mode : ${GREEN}Release - Production (R8/ProGuard activé)${NC}"
|
||||||
|
print_message "Dossier release : ${GREEN}$RELEASE_DIR/${NC}"
|
||||||
|
echo
|
||||||
|
print_message "Prochaines étapes :"
|
||||||
|
print_message "1. Tester le bundle sur un appareil Android"
|
||||||
|
print_message "2. Uploader le bundle sur Google Play Console"
|
||||||
|
print_message "3. Uploader mapping.txt et native-symbols.zip"
|
||||||
|
print_message "4. Soumettre pour review"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
print_message "Mode : ${GREEN}Release${NC} - ${YELLOW}Test interne (R8/ProGuard désactivé)${NC}"
|
||||||
|
print_warning "Avertissements attendus sur Google Play Console"
|
||||||
|
echo
|
||||||
|
print_message "Prochaines étapes :"
|
||||||
|
print_message "1. Tester le bundle sur un appareil Android"
|
||||||
|
print_message "2. Uploader sur Google Play Console (test interne)"
|
||||||
|
print_message "3. Pour production, relancer avec Option A"
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Optionnel : Générer aussi l'APK
|
# Optionnel : Générer aussi l'APK
|
||||||
@@ -182,18 +396,48 @@ read -p "Voulez-vous aussi générer l'APK pour des tests ? (y/n) " -n 1 -r
|
|||||||
echo
|
echo
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
print_message "Génération de l'APK..."
|
print_message "Génération de l'APK..."
|
||||||
flutter build apk --release
|
flutter build apk $BUILD_MODE_FLAG
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
APK_PATH="build/app/outputs/flutter-apk/app-release.apk"
|
APK_PATH="build/app/outputs/flutter-apk/app-$MODE_SUFFIX.apk"
|
||||||
if [ -f "$APK_PATH" ]; then
|
if [ -f "$APK_PATH" ]; then
|
||||||
APK_NAME="geosector-$VERSION_CODE.apk"
|
APK_NAME="geosector-$VERSION_CODE-$MODE_SUFFIX.apk"
|
||||||
cp "$APK_PATH" "$APK_NAME"
|
cp "$APK_PATH" "$APK_NAME"
|
||||||
print_success "APK généré : $APK_NAME"
|
print_success "APK généré : $APK_NAME"
|
||||||
|
|
||||||
# Afficher la taille de l'APK
|
# Afficher la taille de l'APK
|
||||||
APK_SIZE=$(du -h "$APK_NAME" | cut -f1)
|
APK_SIZE=$(du -h "$APK_NAME" | cut -f1)
|
||||||
print_message "Taille de l'APK : $APK_SIZE"
|
print_message "Taille de l'APK : $APK_SIZE"
|
||||||
|
|
||||||
|
# Si mode Debug, proposer installation automatique
|
||||||
|
if [ "$BUILD_MODE_FLAG" = "--debug" ]; then
|
||||||
|
echo
|
||||||
|
read -p "Installer l'APK debug sur l'appareil connecté ? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
print_message "Installation sur l'appareil..."
|
||||||
|
adb install -r "$APK_NAME"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
print_success "APK installé avec succès"
|
||||||
|
|
||||||
|
# Proposer de lancer l'app
|
||||||
|
read -p "Lancer l'application ? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
adb shell am start -n fr.geosector.app3/.MainActivity
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
print_success "Application lancée"
|
||||||
|
else
|
||||||
|
print_warning "Impossible de lancer l'application"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_error "Échec de l'installation"
|
||||||
|
print_message "Vérifiez qu'un appareil est bien connecté : adb devices"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
print_warning "Échec de la génération de l'APK (le bundle a été créé avec succès)"
|
print_warning "Échec de la génération de l'APK (le bundle a été créé avec succès)"
|
||||||
|
|||||||
@@ -55,9 +55,13 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Optimisations sans ProGuard pour éviter les problèmes
|
// Optimisations R8/ProGuard avec règles personnalisées
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
isShrinkResources = false
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
|
||||||
// Configuration de signature
|
// Configuration de signature
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
|||||||
92
app/android/app/build.gradle.kts.backup
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les propriétés de signature
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "fr.geosector.app3"
|
||||||
|
compileSdk = 35 // Requis par plusieurs plugins (flutter_local_notifications, stripe, etc.)
|
||||||
|
ndkVersion = "27.0.12077973"
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "21"
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// Application ID for Google Play Store
|
||||||
|
applicationId = "fr.geosector.app3"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
// Minimum SDK 28 requis pour Stripe Tap to Pay
|
||||||
|
minSdk = 28
|
||||||
|
targetSdk = 35 // API 35 requise par Google Play (Oct 2024+)
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// Optimisations sans ProGuard pour éviter les problèmes
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
|
||||||
|
// Configuration de signature
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug {
|
||||||
|
// Mode debug pour le développement
|
||||||
|
isDebuggable = true
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-DEBUG"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résolution des conflits de fichiers dupliqués (Stripe + BouncyCastle)
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
pickFirst("org/bouncycastle/**")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
57
app/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Règles ProGuard/R8 pour GEOSECTOR
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
## Règles générées automatiquement par R8 (classes manquantes)
|
||||||
|
## Ces classes Java ne sont pas disponibles sur Android mais ne sont pas utilisées
|
||||||
|
-dontwarn java.beans.ConstructorProperties
|
||||||
|
-dontwarn java.beans.Transient
|
||||||
|
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||||
|
-dontwarn org.slf4j.impl.StaticMDCBinder
|
||||||
|
|
||||||
|
## Règles pour Google Play Core (composants différés - non utilisés)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.OnFailureListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.Task
|
||||||
|
|
||||||
|
## Règles pour Stripe SDK
|
||||||
|
-keep class com.stripe.** { *; }
|
||||||
|
-keepclassmembers class com.stripe.** { *; }
|
||||||
|
|
||||||
|
## Règles pour Jackson (utilisé par Stripe)
|
||||||
|
-keep class com.fasterxml.jackson.** { *; }
|
||||||
|
-keepclassmembers class com.fasterxml.jackson.** { *; }
|
||||||
|
-dontwarn com.fasterxml.jackson.databind.**
|
||||||
|
|
||||||
|
## Règles pour les modèles de données (Hive)
|
||||||
|
-keep class fr.geosector.app3.** { *; }
|
||||||
|
-keepclassmembers class fr.geosector.app3.** { *; }
|
||||||
|
|
||||||
|
## Règles pour les réflexions Flutter
|
||||||
|
-keep class io.flutter.app.** { *; }
|
||||||
|
-keep class io.flutter.plugin.** { *; }
|
||||||
|
-keep class io.flutter.util.** { *; }
|
||||||
|
-keep class io.flutter.view.** { *; }
|
||||||
|
-keep class io.flutter.** { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
## Règles pour les annotations
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes Exceptions
|
||||||
|
-keepattributes InnerClasses
|
||||||
|
-keepattributes EnclosingMethod
|
||||||
|
|
||||||
|
## Optimisation
|
||||||
|
-optimizationpasses 5
|
||||||
|
-dontusemixedcaseclassnames
|
||||||
|
-dontskipnonpubliclibraryclasses
|
||||||
|
-dontpreverify
|
||||||
|
-verbose
|
||||||
BIN
app/assets/images/geosector-1024x500.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
app/assets/images/geosector-admin-amicale-1800x1800.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
app/assets/images/geosector-admin-tbord-1800x1800.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
app/assets/images/geosector-user-carte-1800x1800.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
app/assets/images/geosector-user-histo-1800x1800.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
app/assets/images/geosector-user-login-1800x1800.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
app/assets/images/geosector-user-stripe-1800x1800.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
app/assets/images/geosector-user-tbord-1800x1800.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
@@ -41,7 +41,7 @@ FINAL_OWNER="nginx"
|
|||||||
FINAL_GROUP="nginx"
|
FINAL_GROUP="nginx"
|
||||||
|
|
||||||
# Configuration de sauvegarde
|
# Configuration de sauvegarde
|
||||||
BACKUP_DIR="/data/backup/geosector"
|
BACKUP_DIR="/home/pierre/samba/back/geosector/app/"
|
||||||
|
|
||||||
# Couleurs pour les messages
|
# Couleurs pour les messages
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
|
|||||||
498
app/deploy-ios-full-auto.sh
Executable file
@@ -0,0 +1,498 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script de déploiement iOS automatisé pour GEOSECTOR
|
||||||
|
# Version: 1.0
|
||||||
|
# Date: 2025-12-05
|
||||||
|
# Auteur: Pierre (avec l'aide de Claude)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy-ios-full-auto.sh # Utilise ../VERSION
|
||||||
|
# ./deploy-ios-full-auto.sh 3.6.0 # Version spécifique
|
||||||
|
# ./deploy-ios-full-auto.sh 3.6.0 --skip-build # Skip Flutter build si déjà fait
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Configuration
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
# Couleurs pour les messages
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
MAGENTA='\033[0;35m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration Mac mini
|
||||||
|
MAC_MINI_HOST="minipi4" # Nom défini dans ~/.ssh/config
|
||||||
|
MAC_BASE_DIR="/Users/pierre/dev/geosector"
|
||||||
|
|
||||||
|
# Timestamp pour logs et archives
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
LOG_FILE="./logs/deploy-ios-${TIMESTAMP}.log"
|
||||||
|
mkdir -p ./logs
|
||||||
|
|
||||||
|
# Variables globales pour le rapport
|
||||||
|
STEP_START_TIME=0
|
||||||
|
TOTAL_START_TIME=$(date +%s)
|
||||||
|
ERRORS_COUNT=0
|
||||||
|
WARNINGS_COUNT=0
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Fonctions utilitaires
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "$1" | tee -a "${LOG_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_step() {
|
||||||
|
STEP_START_TIME=$(date +%s)
|
||||||
|
log "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
log "${CYAN}▶ $1${NC}"
|
||||||
|
log "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_substep() {
|
||||||
|
log "${MAGENTA} ➜ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
log "${BLUE}ℹ ${NC}$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
local elapsed=$(($(date +%s) - STEP_START_TIME))
|
||||||
|
log "${GREEN}✓${NC} $1 ${CYAN}(${elapsed}s)${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
((WARNINGS_COUNT++))
|
||||||
|
log "${YELLOW}⚠ ${NC}$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
((ERRORS_COUNT++))
|
||||||
|
log "${RED}✗${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_fatal() {
|
||||||
|
log_error "$1"
|
||||||
|
log "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
log "${RED}DÉPLOIEMENT ÉCHOUÉ${NC}"
|
||||||
|
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
log_error "Consultez le log: ${LOG_FILE}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour exécuter une commande et capturer les erreurs
|
||||||
|
safe_exec() {
|
||||||
|
local cmd="$1"
|
||||||
|
local error_msg="$2"
|
||||||
|
|
||||||
|
if ! eval "$cmd" >> "${LOG_FILE}" 2>&1; then
|
||||||
|
log_fatal "$error_msg"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour exécuter une commande SSH avec gestion d'erreurs
|
||||||
|
ssh_exec() {
|
||||||
|
local cmd="$1"
|
||||||
|
local error_msg="$2"
|
||||||
|
|
||||||
|
if ! ssh "$MAC_MINI_HOST" "$cmd" >> "${LOG_FILE}" 2>&1; then
|
||||||
|
log_fatal "$error_msg"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# En-tête
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
clear
|
||||||
|
log "${BLUE}╔════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
log "${BLUE}║ ║${NC}"
|
||||||
|
log "${BLUE}║ ${GREEN}🍎 DÉPLOIEMENT iOS AUTOMATISÉ${BLUE} ║${NC}"
|
||||||
|
log "${BLUE}║ ${CYAN}GEOSECTOR - Full Automation${BLUE} ║${NC}"
|
||||||
|
log "${BLUE}║ ║${NC}"
|
||||||
|
log "${BLUE}╚════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
log ""
|
||||||
|
log_info "Démarrage: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
log_info "Log file: ${LOG_FILE}"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Étape 1 : Gestion de la version
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
log_step "ÉTAPE 1/8 : Gestion de la version"
|
||||||
|
|
||||||
|
# Déterminer la version à utiliser
|
||||||
|
if [ "${1:-}" != "" ] && [[ ! "${1}" =~ ^-- ]]; then
|
||||||
|
VERSION="$1"
|
||||||
|
log_info "Version fournie en argument: ${VERSION}"
|
||||||
|
else
|
||||||
|
# Lire depuis ../VERSION
|
||||||
|
if [ ! -f ../VERSION ]; then
|
||||||
|
log_fatal "Fichier ../VERSION introuvable et aucune version fournie"
|
||||||
|
fi
|
||||||
|
VERSION=$(cat ../VERSION | tr -d '\n\r ' | tr -d '[:space:]')
|
||||||
|
log_info "Version lue depuis ../VERSION: ${VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vérifier le format de version
|
||||||
|
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
log_fatal "Format de version invalide: ${VERSION} (attendu: x.x.x)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Calculer le build number
|
||||||
|
BUILD_NUMBER=$(echo $VERSION | tr -d '.')
|
||||||
|
FULL_VERSION="${VERSION}+${BUILD_NUMBER}"
|
||||||
|
|
||||||
|
log_success "Version configurée"
|
||||||
|
log_info " Version name: ${GREEN}${VERSION}${NC}"
|
||||||
|
log_info " Build number: ${GREEN}${BUILD_NUMBER}${NC}"
|
||||||
|
log_info " Full version: ${GREEN}${FULL_VERSION}${NC}"
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Étape 2 : Mise à jour pubspec.yaml
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
log_step "ÉTAPE 2/8 : Mise à jour pubspec.yaml"
|
||||||
|
|
||||||
|
# Backup du pubspec.yaml
|
||||||
|
cp pubspec.yaml pubspec.yaml.backup
|
||||||
|
|
||||||
|
# Mise à jour de la version
|
||||||
|
sed -i "s/^version: .*/version: $FULL_VERSION/" pubspec.yaml
|
||||||
|
|
||||||
|
# Vérifier la mise à jour
|
||||||
|
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
|
||||||
|
if [ "$UPDATED_VERSION" != "$FULL_VERSION" ]; then
|
||||||
|
log_fatal "Échec de la mise à jour de pubspec.yaml (attendu: $FULL_VERSION, obtenu: $UPDATED_VERSION)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "pubspec.yaml mis à jour"
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Étape 3 : Préparation du projet
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
SKIP_BUILD=false
|
||||||
|
if [[ "${2:-}" == "--skip-build" ]]; then
|
||||||
|
SKIP_BUILD=true
|
||||||
|
log_warning "Mode --skip-build activé, Flutter build sera ignoré"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$SKIP_BUILD" = false ]; then
|
||||||
|
log_step "ÉTAPE 3/8 : Préparation du projet Flutter"
|
||||||
|
|
||||||
|
log_substep "Configuration du cache local"
|
||||||
|
export PUB_CACHE="$PWD/.pub-cache-local"
|
||||||
|
export GRADLE_USER_HOME="$PWD/.gradle-local"
|
||||||
|
mkdir -p "$PUB_CACHE" "$GRADLE_USER_HOME"
|
||||||
|
log_info " Cache Pub: $PUB_CACHE"
|
||||||
|
log_info " Cache Gradle: $GRADLE_USER_HOME"
|
||||||
|
|
||||||
|
log_substep "Nettoyage du projet"
|
||||||
|
safe_exec "flutter clean" "Échec du nettoyage Flutter"
|
||||||
|
|
||||||
|
log_substep "Récupération des dépendances"
|
||||||
|
safe_exec "flutter pub get" "Échec de flutter pub get"
|
||||||
|
|
||||||
|
log_substep "Application du patch nfc_manager"
|
||||||
|
safe_exec "./fastlane/scripts/commun/fix-nfc-manager.sh" "Échec du patch nfc_manager"
|
||||||
|
|
||||||
|
log_substep "Application du patch permission_handler (si nécessaire)"
|
||||||
|
if [ -f "./fastlane/scripts/commun/fix-permission-handler.sh" ]; then
|
||||||
|
safe_exec "./fastlane/scripts/commun/fix-permission-handler.sh" "Échec du patch permission_handler"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_substep "Génération des fichiers Hive"
|
||||||
|
safe_exec "dart run build_runner build --delete-conflicting-outputs" "Échec de la génération de code"
|
||||||
|
|
||||||
|
log_success "Projet préparé (dépendances + patchs + génération de code)"
|
||||||
|
log_info " ⚠️ Build iOS sera fait sur le Mac mini via Fastlane"
|
||||||
|
else
|
||||||
|
log_step "ÉTAPE 3/8 : Préparation du projet (BUILD SKIPPED)"
|
||||||
|
|
||||||
|
log_substep "Configuration du cache local uniquement"
|
||||||
|
export PUB_CACHE="$PWD/.pub-cache-local"
|
||||||
|
export GRADLE_USER_HOME="$PWD/.gradle-local"
|
||||||
|
|
||||||
|
if [ ! -d "$PUB_CACHE" ]; then
|
||||||
|
log_warning "Cache local introuvable, le build pourrait échouer sur le Mac mini"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Cache configuré (build Flutter ignoré)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Étape 4 : Vérification de la connexion Mac mini
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
log_step "ÉTAPE 4/8 : Connexion au Mac mini"
|
||||||
|
|
||||||
|
log_substep "Test de connexion SSH à ${MAC_MINI_HOST}"
|
||||||
|
|
||||||
|
if ! ssh "$MAC_MINI_HOST" "echo 'Connection OK'" >> "${LOG_FILE}" 2>&1; then
|
||||||
|
log_fatal "Impossible de se connecter au Mac mini (${MAC_MINI_HOST})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Connexion SSH établie"
|
||||||
|
|
||||||
|
# Vérifier l'environnement Mac
|
||||||
|
log_substep "Vérification de l'environnement Mac"
|
||||||
|
MAC_INFO=$(ssh "$MAC_MINI_HOST" "sw_vers -productVersion && xcodebuild -version | head -1 && flutter --version | head -1" 2>/dev/null || echo "N/A")
|
||||||
|
log_info "$(echo "$MAC_INFO" | head -1 | xargs -I {} echo " macOS: {}")"
|
||||||
|
log_info "$(echo "$MAC_INFO" | sed -n '2p' | xargs -I {} echo " Xcode: {}")"
|
||||||
|
log_info "$(echo "$MAC_INFO" | sed -n '3p' | xargs -I {} echo " Flutter: {}")"
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Étape 5 : Transfert vers Mac mini
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
log_step "ÉTAPE 5/8 : Transfert du projet vers Mac mini"
|
||||||
|
|
||||||
|
DEST_DIR="${MAC_BASE_DIR}/app_${BUILD_NUMBER}"
|
||||||
|
|
||||||
|
log_substep "Création du dossier de destination: ${DEST_DIR}"
|
||||||
|
ssh_exec "mkdir -p ${DEST_DIR}" "Impossible de créer le dossier ${DEST_DIR} sur le Mac mini"
|
||||||
|
|
||||||
|
log_substep "Transfert rsync (peut prendre 2-5 minutes)"
|
||||||
|
TRANSFER_START=$(date +%s)
|
||||||
|
|
||||||
|
rsync -avz --progress \
|
||||||
|
--exclude='build/' \
|
||||||
|
--exclude='.dart_tool/' \
|
||||||
|
--exclude='ios/Pods/' \
|
||||||
|
--exclude='ios/.symlinks/' \
|
||||||
|
--exclude='macos/Pods/' \
|
||||||
|
--exclude='linux/flutter/ephemeral/' \
|
||||||
|
--exclude='windows/flutter/ephemeral/' \
|
||||||
|
--exclude='android/build/' \
|
||||||
|
--exclude='*.aab' \
|
||||||
|
--exclude='*.apk' \
|
||||||
|
--exclude='logs/' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
./ "${MAC_MINI_HOST}:${DEST_DIR}/" >> "${LOG_FILE}" 2>&1 || log_fatal "Échec du transfert rsync"
|
||||||
|
|
||||||
|
TRANSFER_TIME=$(($(date +%s) - TRANSFER_START))
|
||||||
|
|
||||||
|
log_success "Transfert terminé"
|
||||||
|
log_info " Destination: ${DEST_DIR}"
|
||||||
|
log_info " Durée: ${TRANSFER_TIME}s"
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Étape 6 : Build et Archive avec Fastlane
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
log_step "ÉTAPE 6/8 : Build et Archive iOS avec Fastlane"
|
||||||
|
|
||||||
|
log_info "Cette étape peut prendre 15-25 minutes"
|
||||||
|
log_info "Fastlane va :"
|
||||||
|
log_info " 1. Nettoyer les artefacts"
|
||||||
|
log_info " 2. Installer les CocoaPods"
|
||||||
|
log_info " 3. Analyser le code"
|
||||||
|
log_info " 4. Build Flutter iOS"
|
||||||
|
log_info " 5. Archive Xcode (gym)"
|
||||||
|
log_info " 6. Export IPA"
|
||||||
|
log_info ""
|
||||||
|
log_substep "Lancement de: cd ${DEST_DIR} && fastlane ios build"
|
||||||
|
|
||||||
|
FASTLANE_START=$(date +%s)
|
||||||
|
|
||||||
|
# Créer un fichier temporaire pour capturer la sortie Fastlane
|
||||||
|
FASTLANE_LOG="/tmp/fastlane-ios-${TIMESTAMP}.log"
|
||||||
|
|
||||||
|
# Exécuter Fastlane en temps réel avec affichage des étapes importantes
|
||||||
|
ssh -t "$MAC_MINI_HOST" "cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios build" 2>&1 | tee -a "${LOG_FILE}" | tee "${FASTLANE_LOG}" | while IFS= read -r line; do
|
||||||
|
# Afficher les lignes importantes
|
||||||
|
if echo "$line" | grep -qE "(🧹|📦|🔧|🔍|🏗️|✓|✗|Error|error:|ERROR|Build succeeded|Build failed)"; then
|
||||||
|
echo -e "${CYAN} ${line}${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Vérifier le code de retour de Fastlane
|
||||||
|
FASTLANE_EXIT_CODE=${PIPESTATUS[0]}
|
||||||
|
FASTLANE_TIME=$(($(date +%s) - FASTLANE_START))
|
||||||
|
|
||||||
|
if [ $FASTLANE_EXIT_CODE -ne 0 ]; then
|
||||||
|
log_error "Fastlane a échoué (code: ${FASTLANE_EXIT_CODE})"
|
||||||
|
log_error "Analyse des erreurs..."
|
||||||
|
|
||||||
|
# Extraire les erreurs du log Fastlane
|
||||||
|
if [ -f "${FASTLANE_LOG}" ]; then
|
||||||
|
log ""
|
||||||
|
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
log "${RED}ERREURS DÉTECTÉES :${NC}"
|
||||||
|
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
grep -i "error:\|Error:\|ERROR:\|❌\|✗" "${FASTLANE_LOG}" | head -20 | while IFS= read -r error_line; do
|
||||||
|
log "${RED} ${error_line}${NC}"
|
||||||
|
done
|
||||||
|
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_fatal "Build iOS échoué via Fastlane. Consultez ${FASTLANE_LOG} pour plus de détails."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Build et Archive iOS réussis"
|
||||||
|
log_info " Durée totale Fastlane: ${FASTLANE_TIME}s ($((FASTLANE_TIME/60))m $((FASTLANE_TIME%60))s)"
|
||||||
|
|
||||||
|
# Vérifier que l'IPA existe
|
||||||
|
log_substep "Vérification de l'IPA généré"
|
||||||
|
IPA_EXISTS=$(ssh "$MAC_MINI_HOST" "test -f ${DEST_DIR}/build/ios/ipa/Runner.ipa && echo 'YES' || echo 'NO'")
|
||||||
|
|
||||||
|
if [ "$IPA_EXISTS" != "YES" ]; then
|
||||||
|
log_fatal "IPA non trouvé dans ${DEST_DIR}/build/ios/ipa/Runner.ipa"
|
||||||
|
fi
|
||||||
|
|
||||||
|
IPA_SIZE=$(ssh "$MAC_MINI_HOST" "du -h ${DEST_DIR}/build/ios/ipa/Runner.ipa | cut -f1")
|
||||||
|
log_info " IPA trouvé: ${GREEN}${IPA_SIZE}${NC}"
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Étape 7 : Upload vers TestFlight (optionnel)
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
log_step "ÉTAPE 7/8 : Upload vers TestFlight"
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log_info "${YELLOW}Voulez-vous uploader l'IPA vers TestFlight maintenant ?${NC}"
|
||||||
|
log_info " [Y] Oui - Upload automatique via fastlane ios upload"
|
||||||
|
log_info " [N] Non - Je ferai l'upload manuellement plus tard"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
read -p "$(echo -e ${CYAN}Votre choix [Y/n]: ${NC})" -n 1 -r UPLOAD_CHOICE
|
||||||
|
echo
|
||||||
|
log ""
|
||||||
|
|
||||||
|
if [[ $UPLOAD_CHOICE =~ ^[Yy]$ ]] || [ -z "$UPLOAD_CHOICE" ]; then
|
||||||
|
log_substep "Lancement de: fastlane ios upload"
|
||||||
|
log_info "Upload vers TestFlight (peut prendre 5-10 minutes)"
|
||||||
|
|
||||||
|
UPLOAD_START=$(date +%s)
|
||||||
|
|
||||||
|
ssh -t "$MAC_MINI_HOST" "cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios upload" 2>&1 | tee -a "${LOG_FILE}"
|
||||||
|
UPLOAD_EXIT_CODE=${PIPESTATUS[0]}
|
||||||
|
UPLOAD_TIME=$(($(date +%s) - UPLOAD_START))
|
||||||
|
|
||||||
|
if [ $UPLOAD_EXIT_CODE -ne 0 ]; then
|
||||||
|
log_error "Upload TestFlight échoué (code: ${UPLOAD_EXIT_CODE})"
|
||||||
|
log_warning "L'IPA est disponible sur le Mac mini, vous pouvez réessayer manuellement"
|
||||||
|
log_info " Commande: ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\""
|
||||||
|
else
|
||||||
|
log_success "Upload TestFlight réussi"
|
||||||
|
log_info " Durée: ${UPLOAD_TIME}s"
|
||||||
|
log_info " URL: ${CYAN}https://appstoreconnect.apple.com${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_info "Upload ignoré. Pour uploader manuellement plus tard :"
|
||||||
|
log_info " ${CYAN}ssh $MAC_MINI_HOST \"cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios upload\"${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Étape 8 : Nettoyage et archivage
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
log_step "ÉTAPE 8/8 : Nettoyage et archivage"
|
||||||
|
|
||||||
|
log_substep "Voulez-vous archiver le dossier de build ?"
|
||||||
|
log_info " [Y] Oui - Créer une archive ${DEST_DIR}.tar.gz"
|
||||||
|
log_info " [N] Non - Garder le dossier tel quel (défaut)"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
read -p "$(echo -e ${CYAN}Votre choix [y/N]: ${NC})" -n 1 -r ARCHIVE_CHOICE
|
||||||
|
echo
|
||||||
|
log ""
|
||||||
|
|
||||||
|
if [[ $ARCHIVE_CHOICE =~ ^[Yy]$ ]]; then
|
||||||
|
log_substep "Création de l'archive..."
|
||||||
|
ssh_exec "cd ${MAC_BASE_DIR} && tar -czf app_${BUILD_NUMBER}.tar.gz app_${BUILD_NUMBER}" \
|
||||||
|
"Échec de la création de l'archive"
|
||||||
|
|
||||||
|
ARCHIVE_SIZE=$(ssh "$MAC_MINI_HOST" "du -h ${MAC_BASE_DIR}/app_${BUILD_NUMBER}.tar.gz | cut -f1")
|
||||||
|
log_success "Archive créée"
|
||||||
|
log_info " Archive: ${MAC_BASE_DIR}/app_${BUILD_NUMBER}.tar.gz (${ARCHIVE_SIZE})"
|
||||||
|
|
||||||
|
log_substep "Suppression du dossier de build"
|
||||||
|
ssh_exec "rm -rf ${DEST_DIR}" "Échec de la suppression du dossier"
|
||||||
|
log_success "Dossier de build supprimé"
|
||||||
|
else
|
||||||
|
log_info "Dossier conservé: ${DEST_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Restaurer le pubspec.yaml original (optionnel)
|
||||||
|
log_substep "Restauration de pubspec.yaml local"
|
||||||
|
mv pubspec.yaml.backup pubspec.yaml
|
||||||
|
log_info " pubspec.yaml local restauré à son état initial"
|
||||||
|
|
||||||
|
# =====================================
|
||||||
|
# Rapport final
|
||||||
|
# =====================================
|
||||||
|
|
||||||
|
TOTAL_TIME=$(($(date +%s) - TOTAL_START_TIME))
|
||||||
|
TOTAL_MINUTES=$((TOTAL_TIME / 60))
|
||||||
|
TOTAL_SECONDS=$((TOTAL_TIME % 60))
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
log "${GREEN}║ ║${NC}"
|
||||||
|
log "${GREEN}║ ✓ DÉPLOIEMENT iOS TERMINÉ AVEC SUCCÈS ║${NC}"
|
||||||
|
log "${GREEN}║ ║${NC}"
|
||||||
|
log "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
log ""
|
||||||
|
log "${CYAN}📊 RAPPORT DE DÉPLOIEMENT${NC}"
|
||||||
|
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
log ""
|
||||||
|
log " ${BLUE}Version déployée:${NC} ${GREEN}${VERSION} (Build ${BUILD_NUMBER})${NC}"
|
||||||
|
log " ${BLUE}Destination:${NC} ${DEST_DIR}"
|
||||||
|
log " ${BLUE}IPA généré:${NC} ${GREEN}${IPA_SIZE}${NC}"
|
||||||
|
log " ${BLUE}Durée totale:${NC} ${GREEN}${TOTAL_MINUTES}m ${TOTAL_SECONDS}s${NC}"
|
||||||
|
log ""
|
||||||
|
if [ $WARNINGS_COUNT -gt 0 ]; then
|
||||||
|
log " ${YELLOW}⚠ Avertissements:${NC} ${WARNINGS_COUNT}"
|
||||||
|
fi
|
||||||
|
if [ $ERRORS_COUNT -gt 0 ]; then
|
||||||
|
log " ${RED}✗ Erreurs:${NC} ${ERRORS_COUNT}"
|
||||||
|
fi
|
||||||
|
log ""
|
||||||
|
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
log ""
|
||||||
|
log "${BLUE}📱 PROCHAINES ÉTAPES${NC}"
|
||||||
|
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
if [[ $UPLOAD_CHOICE =~ ^[Yy]$ ]] || [ -z "$UPLOAD_CHOICE" ]; then
|
||||||
|
if [ $UPLOAD_EXIT_CODE -eq 0 ]; then
|
||||||
|
log " 1. ${GREEN}✓${NC} IPA uploadé sur TestFlight"
|
||||||
|
log " 2. Accéder à App Store Connect:"
|
||||||
|
log " ${CYAN}https://appstoreconnect.apple.com${NC}"
|
||||||
|
log " 3. Attendre le traitement Apple (5-15 min)"
|
||||||
|
log " 4. Configurer la conformité export si demandée"
|
||||||
|
log " 5. Ajouter des testeurs internes"
|
||||||
|
log " 6. Installer via TestFlight sur iPhone"
|
||||||
|
else
|
||||||
|
log " 1. ${YELLOW}⚠${NC} Upload TestFlight a échoué"
|
||||||
|
log " 2. Réessayer manuellement:"
|
||||||
|
log " ${CYAN}ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\"${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log " 1. L'IPA est prêt sur le Mac mini"
|
||||||
|
log " 2. Pour uploader vers TestFlight:"
|
||||||
|
log " ${CYAN}ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\"${NC}"
|
||||||
|
log " 3. Ou distribuer l'IPA manuellement via Xcode"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
log ""
|
||||||
|
log " ${BLUE}Log complet:${NC} ${LOG_FILE}"
|
||||||
|
log " ${BLUE}Fin:${NC} $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
# Nettoyer le log Fastlane temporaire
|
||||||
|
rm -f "${FASTLANE_LOG}"
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -278,55 +278,102 @@ Log::info('Stripe account activated', [
|
|||||||
|
|
||||||
## 📱 FLOW TAP TO PAY (Application Flutter)
|
## 📱 FLOW TAP TO PAY (Application Flutter)
|
||||||
|
|
||||||
|
### 🎯 Architecture technique
|
||||||
|
|
||||||
|
Le flow Tap to Pay repose sur trois composants principaux :
|
||||||
|
|
||||||
|
1. **DeviceInfoService** - Vérification de compatibilité hardware (Android SDK ≥ 28, NFC disponible)
|
||||||
|
2. **StripeTapToPayService** - Gestion du SDK Stripe Terminal et des paiements
|
||||||
|
3. **Backend API** - Endpoints PHP pour les tokens de connexion et PaymentIntents
|
||||||
|
|
||||||
### 🔄 Diagramme de séquence complet
|
### 🔄 Diagramme de séquence complet
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────┐
|
┌─────────────┐ ┌─────────────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐
|
||||||
│ App Flutter │ │ API PHP │ │ Stripe │ │ Carte │
|
│ App Flutter │ │ DeviceInfo │ │ API │ │ Stripe │ │ SDK Terminal │
|
||||||
└──────┬──────┘ └──────┬──────┘ └────┬─────┘ └────┬────┘
|
│ │ │ Service │ │ PHP │ │ │ │ │
|
||||||
│ │ │ │
|
└──────┬──────┘ └────────┬────────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘
|
||||||
[1] │ Validation form │ │ │
|
│ │ │ │ │
|
||||||
│ + montant CB │ │ │
|
[1] │ Login utilisateur │ │ │ │
|
||||||
│ │ │ │
|
│────────────────────>│ │ │ │
|
||||||
[2] │ POST/PUT passage │ │ │
|
│ │ │ │ │
|
||||||
│──────────────────>│ │ │
|
[2] │ │ checkStripeCertification() │ │
|
||||||
│ │ │ │
|
│ │ • Android SDK ≥ 28 │ │
|
||||||
[3] │<──────────────────│ │ │
|
│ │ • NFC disponible │ │
|
||||||
│ Passage ID: 456 │ │ │
|
│ │ │ │ │
|
||||||
│ │ │ │
|
[3] │<────────────────────│ │ │ │
|
||||||
[4] │ POST create-intent│ │ │
|
│ ✅ Compatible │ │ │ │
|
||||||
│──────────────────>│ (avec passage_id: 456) │
|
│ │ │ │ │
|
||||||
│ │ │ │
|
[4] │ Validation form │ │ │ │
|
||||||
[5] │ │ Create PaymentIntent │
|
│ + montant CB │ │ │ │
|
||||||
│ │─────────────────>│ │
|
│ │ │ │ │
|
||||||
│ │ │ │
|
[5] │ POST/PUT passage │ │ │ │
|
||||||
[6] │ │<─────────────────│ │
|
│────────────────────────────────────────>│ │ │
|
||||||
│ │ pi_xxx + secret │ │
|
│ │ │ │ │
|
||||||
│ │ │ │
|
[6] │<────────────────────────────────────────│ │ │
|
||||||
[7] │<──────────────────│ │ │
|
│ Passage ID: 456 │ │ │ │
|
||||||
│ PaymentIntent ID │ │ │
|
│ │ │ │ │
|
||||||
│ │ │ │
|
[7] │ initialize() │ │ │ │
|
||||||
[8] │ SDK Terminal Init │ │ │
|
│────────────────────────────────────────────────────────────────────────────>│
|
||||||
│ "Approchez carte" │ │ │
|
│ │ │ │ │
|
||||||
│ │ │ │
|
[8] │ │ │ │ Terminal.initTerminal()
|
||||||
[9] │<──────────────────────────────────────────────────────│
|
│ │ │ │ │ (fetchToken callback)
|
||||||
│ NFC : Lecture carte sans contact │
|
│ │ │ │ │
|
||||||
│ │ │ │
|
[9] │ │ │ POST /terminal/connection-token │
|
||||||
[10] │ Process Payment │ │ │
|
│────────────────────────────────────────>│ │ │
|
||||||
│───────────────────────────────────>│ │
|
│ {amicale_id, stripe_account, location_id} │ │
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
[11] │<───────────────────────────────────│ │
|
[10] │ │ │ CreateConnectionToken │
|
||||||
│ Payment Success │ │
|
│ │ │───────────────>│ │
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
[12] │ POST confirm │ │ │
|
[11] │ │ │<───────────────│ │
|
||||||
│──────────────────>│ │ │
|
│ │ │ {secret: "..."}│ │
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
[13] │ PUT passage/456 │ │ │
|
[12] │<────────────────────────────────────────│ │ │
|
||||||
│──────────────────>│ (ajout stripe_payment_id) │
|
│ Connection Token │ │ │ │
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
[14] │<──────────────────│ │ │
|
[13] │────────────────────────────────────────────────────────────────────────────>│
|
||||||
│ Passage updated │ │ │
|
│ Token delivered to SDK │ │ ✅ SDK Ready │
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
|
[14] │ createPaymentIntent() │ │ │
|
||||||
|
│────────────────────────────────────────>│ │ │
|
||||||
|
│ {amount, passage_id, amicale_id} │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
[15] │ │ │ Create PaymentIntent │
|
||||||
|
│ │ │───────────────>│ │
|
||||||
|
│ │ │ │ │
|
||||||
|
[16] │ │ │<───────────────│ │
|
||||||
|
│ │ │ pi_xxx + secret│ │
|
||||||
|
│ │ │ │ │
|
||||||
|
[17] │<────────────────────────────────────────│ │ │
|
||||||
|
│ PaymentIntent ID │ │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
[18] │ collectPayment() │ │ │ │
|
||||||
|
│────────────────────────────────────────────────────────────────────────────>│
|
||||||
|
│ │ │ │ │
|
||||||
|
[19] │ │ │ │ discoverReaders()
|
||||||
|
│ │ │ │ + connectReader()
|
||||||
|
│ │ │ │ │
|
||||||
|
[20] │ │ │ │ collectPaymentMethod()
|
||||||
|
│ 💳 "Présentez la carte" │ │ ⏳ Attente NFC │
|
||||||
|
│ │ │ │ │
|
||||||
|
[21] │<──────────────────────────────────────────────────────────── 📱 TAP CARTE │
|
||||||
|
│ │ │ │ │
|
||||||
|
[22] │ confirmPayment() │ │ │ │
|
||||||
|
│────────────────────────────────────────────────────────────────────────────>│
|
||||||
|
│ │ │ │ │
|
||||||
|
[23] │ │ │ │ confirmPaymentIntent()
|
||||||
|
│ │ │ │ │
|
||||||
|
[24] │ │ │ │ ✅ Succeeded │
|
||||||
|
│<────────────────────────────────────────────────────────────────────────────│
|
||||||
|
│ Payment Success │ │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
[25] │ PUT passage/456 │ │ │ │
|
||||||
|
│────────────────────────────────────────>│ │ │
|
||||||
|
│ {stripe_payment_id: "pi_xxx"} │ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
[26] │<────────────────────────────────────────│ │ │
|
||||||
|
│ ✅ Passage updated │ │ │ │
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🎮 Gestion du Terminal de Paiement
|
### 🎮 Gestion du Terminal de Paiement
|
||||||
@@ -378,6 +425,59 @@ Le terminal de paiement reste affiché jusqu'à la réponse définitive de Strip
|
|||||||
- **Persistence** : Le terminal reste ouvert jusqu'à réponse définitive de Stripe
|
- **Persistence** : Le terminal reste ouvert jusqu'à réponse définitive de Stripe
|
||||||
- **Gestion d'erreur** : Possibilité de réessayer sans perdre le contexte
|
- **Gestion d'erreur** : Possibilité de réessayer sans perdre le contexte
|
||||||
|
|
||||||
|
### 🔑 Connection Token - Flow détaillé
|
||||||
|
|
||||||
|
Le Connection Token est crucial pour le SDK Stripe Terminal. Il est récupéré via un callback au moment de l'initialisation.
|
||||||
|
|
||||||
|
**Code côté App (stripe_tap_to_pay_service.dart:87-89) :**
|
||||||
|
```dart
|
||||||
|
await Terminal.initTerminal(
|
||||||
|
fetchToken: _fetchConnectionToken, // Callback appelé automatiquement
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callback de récupération (lignes 137-161) :**
|
||||||
|
```dart
|
||||||
|
Future<String> _fetchConnectionToken() async {
|
||||||
|
debugPrint('🔑 Récupération du token de connexion Stripe...');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.post(
|
||||||
|
'/stripe/terminal/connection-token',
|
||||||
|
data: {
|
||||||
|
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||||
|
'stripe_account': _stripeAccountId,
|
||||||
|
'location_id': _locationId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
final token = response.data['secret'];
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw Exception('Token de connexion invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('✅ Token de connexion récupéré');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend PHP :**
|
||||||
|
```php
|
||||||
|
// POST /stripe/terminal/connection-token
|
||||||
|
$token = \Stripe\Terminal\ConnectionToken::create([], [
|
||||||
|
'stripe_account' => $amicale->stripe_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'secret' => $token->secret,
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points importants :**
|
||||||
|
- ✅ Le token est **temporaire** (valide quelques minutes)
|
||||||
|
- ✅ Un nouveau token est créé à **chaque initialisation** du SDK
|
||||||
|
- ✅ Le token est spécifique au **compte Stripe Connect** de l'amicale
|
||||||
|
- ✅ Utilisé pour **authentifier** le Terminal SDK auprès de Stripe
|
||||||
|
|
||||||
### 📋 Détail des étapes
|
### 📋 Détail des étapes
|
||||||
|
|
||||||
#### Étape 1 : VALIDATION DU FORMULAIRE
|
#### Étape 1 : VALIDATION DU FORMULAIRE
|
||||||
@@ -1085,28 +1185,168 @@ POST/PUT /api/passages
|
|||||||
|
|
||||||
## 🔄 GESTION DES ERREURS
|
## 🔄 GESTION DES ERREURS
|
||||||
|
|
||||||
### 📱 Erreurs Tap to Pay
|
### 📱 Erreurs Tap to Pay - Messages utilisateur clairs
|
||||||
|
|
||||||
| Code erreur | Description | Action utilisateur |
|
L'application détecte automatiquement le type d'erreur et affiche un message adapté :
|
||||||
|-------------|-------------|-------------------|
|
|
||||||
| `device_not_compatible` | iPhone non compatible | Afficher message explicatif |
|
|
||||||
| `nfc_disabled` | NFC désactivé | Demander activation dans réglages |
|
|
||||||
| `card_declined` | Carte refusée | Essayer autre carte |
|
|
||||||
| `insufficient_funds` | Solde insuffisant | Essayer autre carte |
|
|
||||||
| `network_error` | Erreur réseau | Réessayer ou mode offline |
|
|
||||||
| `timeout` | Timeout lecture carte | Rapprocher carte et réessayer |
|
|
||||||
|
|
||||||
### 🔄 Flow de retry
|
#### Gestion intelligente des erreurs (passage_form_dialog.dart)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
catch (e) {
|
||||||
|
final errorMsg = e.toString().toLowerCase();
|
||||||
|
String userMessage;
|
||||||
|
bool shouldCancelPayment = true;
|
||||||
|
|
||||||
|
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
|
||||||
|
// Annulation volontaire
|
||||||
|
userMessage = 'Paiement annulé';
|
||||||
|
|
||||||
|
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
|
||||||
|
// Timeout NFC avec conseils
|
||||||
|
userMessage = 'Lecture de la carte impossible.\n\n'
|
||||||
|
'Conseils :\n'
|
||||||
|
'• Maintenez la carte contre le dos du téléphone\n'
|
||||||
|
'• Ne bougez pas jusqu\'à confirmation\n'
|
||||||
|
'• Retirez la coque si nécessaire\n'
|
||||||
|
'• Essayez différentes positions sur le téléphone';
|
||||||
|
|
||||||
|
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
|
||||||
|
// PaymentIntent existe déjà
|
||||||
|
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
|
||||||
|
'Veuillez réessayer dans quelques instants.';
|
||||||
|
shouldCancelPayment = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annulation automatique du PaymentIntent pour permettre nouvelle tentative
|
||||||
|
if (shouldCancelPayment && _paymentIntentId != null) {
|
||||||
|
await StripeTapToPayService.instance.cancelPayment(_paymentIntentId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Table des erreurs et actions
|
||||||
|
|
||||||
|
| Type erreur | Message utilisateur | Action automatique |
|
||||||
|
|-------------|--------------------|--------------------|
|
||||||
|
| `canceled` / `cancelled` | "Paiement annulé" | Annulation PaymentIntent ✅ |
|
||||||
|
| `cardReadTimedOut` | Message avec 4 conseils NFC | Annulation PaymentIntent ✅ |
|
||||||
|
| `already payment` | "Paiement déjà en cours" | Pas d'annulation ⏳ |
|
||||||
|
| `device_not_compatible` | "Appareil non compatible" | Annulation PaymentIntent ✅ |
|
||||||
|
| `nfc_disabled` | "NFC désactivé" | Annulation PaymentIntent ✅ |
|
||||||
|
| Autre erreur | Message technique complet | Annulation PaymentIntent ✅ |
|
||||||
|
|
||||||
|
### ⚠️ Contraintes NFC - Tap to Pay vs Google Pay
|
||||||
|
|
||||||
|
**Différence fondamentale :**
|
||||||
|
- **Google Pay (émission)** : Le téléphone *émet* un signal NFC puissant → fonctionne avec coque
|
||||||
|
- **Tap to Pay (réception)** : Le téléphone *lit* le signal de la carte → très sensible aux interférences
|
||||||
|
|
||||||
|
#### Coques problématiques
|
||||||
|
- ❌ **Kevlar / Carbone** : Fibres conductrices perturbent la réception NFC
|
||||||
|
- ❌ **Métal** : Bloque complètement les ondes NFC
|
||||||
|
- ❌ **Coque épaisse** : Réduit la portée effective
|
||||||
|
- ✅ **TPU / Silicone** : Compatible
|
||||||
|
|
||||||
|
#### Bonnes pratiques pour réussite NFC
|
||||||
|
|
||||||
|
**Position optimale :**
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 📱 Téléphone │
|
||||||
|
│ │
|
||||||
|
│ [Capteur NFC]│ ← Généralement vers le haut du dos
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ 💳 Carte ici │ ← Maintenir immobile 3-5 secondes
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checklist utilisateur :**
|
||||||
|
1. ✅ Retirer la coque si échec
|
||||||
|
2. ✅ Carte à plat contre le dos du téléphone
|
||||||
|
3. ✅ Ne pas bouger pendant toute la lecture
|
||||||
|
4. ✅ Essayer différentes positions (haut/milieu du téléphone)
|
||||||
|
5. ✅ Carte sans contact activée (logo sans contact visible)
|
||||||
|
|
||||||
|
### 🔄 Flow de retry automatique
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Erreur détectée
|
1. Erreur détectée → Analyse du type
|
||||||
2. Message utilisateur explicite
|
2. Annulation automatique PaymentIntent (si applicable)
|
||||||
3. Option "Réessayer" proposée
|
3. Message clair avec conseils contextuels
|
||||||
4. Conservation du montant et contexte
|
4. Bouton "Réessayer" disponible
|
||||||
5. Nouveau PaymentIntent si nécessaire
|
5. Nouveau PaymentIntent créé automatiquement
|
||||||
6. Maximum 3 tentatives
|
6. Conservation du contexte (montant, passage)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Avantages :**
|
||||||
|
- ✅ Pas de blocage "PaymentIntent déjà existant"
|
||||||
|
- ✅ Nombre illimité de tentatives
|
||||||
|
- ✅ Contexte préservé (pas besoin de tout ressaisir)
|
||||||
|
- ✅ Messages orientés solution plutôt qu'erreur technique
|
||||||
|
|
||||||
|
### 🏗️ Environnement et Build Release
|
||||||
|
|
||||||
|
#### Détection automatique de l'environnement
|
||||||
|
|
||||||
|
L'application détecte l'environnement via l'URL de l'API (plus fiable que `kDebugMode`) :
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// stripe_tap_to_pay_service.dart (lignes 236-252)
|
||||||
|
Future<bool> _ensureReaderConnected() async {
|
||||||
|
// Détection via URL API
|
||||||
|
final apiUrl = ApiService.instance.baseUrl;
|
||||||
|
final isProduction = apiUrl.contains('app3.geosector.fr');
|
||||||
|
final isSimulated = !isProduction;
|
||||||
|
|
||||||
|
final config = TapToPayDiscoveryConfiguration(
|
||||||
|
isSimulated: isSimulated,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('🔧 Environnement: ${isProduction ? "PRODUCTION (réel)" : "DEV/REC (simulé)"}');
|
||||||
|
debugPrint('🔧 isSimulated: $isSimulated');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mapping environnement :**
|
||||||
|
| URL API | Environnement | Reader Stripe | Cartes acceptées |
|
||||||
|
|---------|---------------|---------------|------------------|
|
||||||
|
| `dapp.geosector.fr` | DEV | Simulé | Cartes test uniquement |
|
||||||
|
| `rapp.geosector.fr` | REC | Simulé | Cartes test uniquement |
|
||||||
|
| `app3.geosector.fr` | PROD | Réel | Cartes réelles uniquement |
|
||||||
|
|
||||||
|
#### ⚠️ Restriction Stripe - Build Release obligatoire en PROD
|
||||||
|
|
||||||
|
**Erreur si app debuggable en PROD :**
|
||||||
|
```
|
||||||
|
Debuggable applications are not supported when using the production
|
||||||
|
version of the Tap to Pay reader. Please use a simulated version of
|
||||||
|
the reader by setting TapToPayDiscoveryConfiguration.isSimulated to true.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution - Build release :**
|
||||||
|
```bash
|
||||||
|
# Build APK optimisé pour production
|
||||||
|
flutter build apk --release
|
||||||
|
|
||||||
|
# Installation sur device
|
||||||
|
adb install build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Différences debug vs release :**
|
||||||
|
| Aspect | Debug (`flutter run`) | Release (`flutter build`) |
|
||||||
|
|--------|-----------------------|--------------------|
|
||||||
|
| **Optimisation** | ❌ Code non optimisé | ✅ R8/ProGuard activé |
|
||||||
|
| **Taille APK** | ~200 MB | ~30-50 MB |
|
||||||
|
| **Performance** | Lente (dev mode) | Rapide (optimisée) |
|
||||||
|
| **Tap to Pay PROD** | ❌ Refusé par Stripe | ✅ Accepté |
|
||||||
|
| **Débogage** | ✅ Hot reload, logs | ❌ Pas de hot reload |
|
||||||
|
|
||||||
|
**Pourquoi Stripe refuse les apps debug :**
|
||||||
|
- **Sécurité renforcée** : Les apps debuggables peuvent être inspectées
|
||||||
|
- **Conformité PCI-DSS** : Exigences de sécurité pour paiements réels
|
||||||
|
- **Protection production** : Éviter utilisation accidentelle de readers réels en dev
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 MONITORING ET LOGS
|
## 📊 MONITORING ET LOGS
|
||||||
|
|||||||
216
app/docs/connexions-api.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
API Event Stats - Guide Flutter
|
||||||
|
|
||||||
|
Authentification
|
||||||
|
|
||||||
|
Toutes les routes nécessitent un Bearer Token (session) et un rôle Admin (2) ou Super-admin (1).
|
||||||
|
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $sessionToken',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Résumé du jour
|
||||||
|
|
||||||
|
GET /api/events/stats/summary?date=2025-12-22
|
||||||
|
|
||||||
|
| Param | Type | Requis | Description |
|
||||||
|
| ----- | ------ | ------ | ------------------------------------- |
|
||||||
|
| date | string | Non | Date YYYY-MM-DD (défaut: aujourd'hui) |
|
||||||
|
|
||||||
|
Réponse (~1 KB) :
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"date": "2025-12-22",
|
||||||
|
"stats": {
|
||||||
|
"auth": { "success": 45, "failed": 3, "logout": 12 },
|
||||||
|
"passages": { "created": 128, "updated": 5, "deleted": 2, "amount": 2450.00 },
|
||||||
|
"users": { "created": 2, "updated": 5, "deleted": 0 },
|
||||||
|
"sectors": { "created": 1, "updated": 3, "deleted": 0 },
|
||||||
|
"stripe": { "created": 15, "success": 12, "failed": 1, "cancelled": 2, "amount": 890.00 }
|
||||||
|
},
|
||||||
|
"totals": { "events": 245, "unique_users": 18 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
2. Stats journalières (graphiques)
|
||||||
|
|
||||||
|
GET /api/events/stats/daily?from=2025-12-01&to=2025-12-22&events=passage_created,login_success
|
||||||
|
|
||||||
|
| Param | Type | Requis | Description |
|
||||||
|
| ------ | ------ | ------ | ------------------------------- |
|
||||||
|
| from | string | Oui | Date début YYYY-MM-DD |
|
||||||
|
| to | string | Oui | Date fin YYYY-MM-DD |
|
||||||
|
| events | string | Non | Types filtrés (comma-separated) |
|
||||||
|
|
||||||
|
Limite : 90 jours max
|
||||||
|
|
||||||
|
Réponse (~5 KB) :
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"from": "2025-12-01",
|
||||||
|
"to": "2025-12-22",
|
||||||
|
"days": [
|
||||||
|
{
|
||||||
|
"date": "2025-12-01",
|
||||||
|
"events": {
|
||||||
|
"passage_created": { "count": 45, "sum_amount": 850.00, "unique_users": 8 },
|
||||||
|
"login_success": { "count": 12, "sum_amount": 0, "unique_users": 12 }
|
||||||
|
},
|
||||||
|
"totals": { "count": 57, "sum_amount": 850.00 }
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
3. Stats hebdomadaires
|
||||||
|
|
||||||
|
GET /api/events/stats/weekly?from=2025-10-01&to=2025-12-22
|
||||||
|
|
||||||
|
| Param | Type | Requis | Description |
|
||||||
|
| ------ | ------ | ------ | ------------- |
|
||||||
|
| from | string | Oui | Date début |
|
||||||
|
| to | string | Oui | Date fin |
|
||||||
|
| events | string | Non | Types filtrés |
|
||||||
|
|
||||||
|
Limite : 365 jours max
|
||||||
|
|
||||||
|
Réponse (~2 KB) :
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"from": "2025-10-01",
|
||||||
|
"to": "2025-12-22",
|
||||||
|
"weeks": [
|
||||||
|
{
|
||||||
|
"week_start": "2025-12-16",
|
||||||
|
"week_number": 51,
|
||||||
|
"year": 2025,
|
||||||
|
"events": {
|
||||||
|
"passage_created": { "count": 320, "sum_amount": 5200.00, "unique_users": 15 }
|
||||||
|
},
|
||||||
|
"totals": { "count": 450, "sum_amount": 5200.00 }
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
4. Stats mensuelles
|
||||||
|
|
||||||
|
GET /api/events/stats/monthly?year=2025&events=passage_created,stripe_payment_success
|
||||||
|
|
||||||
|
| Param | Type | Requis | Description |
|
||||||
|
| ------ | ------ | ------ | ------------------------------ |
|
||||||
|
| year | int | Non | Année (défaut: année courante) |
|
||||||
|
| events | string | Non | Types filtrés |
|
||||||
|
|
||||||
|
Réponse (~1 KB) :
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"year": 2025,
|
||||||
|
"months": [
|
||||||
|
{
|
||||||
|
"month": "2025-01",
|
||||||
|
"year": 2025,
|
||||||
|
"month_number": 1,
|
||||||
|
"events": {
|
||||||
|
"passage_created": { "count": 1250, "sum_amount": 18500.00, "unique_users": 25 }
|
||||||
|
},
|
||||||
|
"totals": { "count": 1800, "sum_amount": 18500.00 }
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
5. Détail des événements (drill-down)
|
||||||
|
|
||||||
|
GET /api/events/stats/details?date=2025-12-22&event=login_failed&limit=50&offset=0
|
||||||
|
|
||||||
|
| Param | Type | Requis | Description |
|
||||||
|
| ------ | ------ | ------ | ------------------------------------ |
|
||||||
|
| date | string | Oui | Date YYYY-MM-DD |
|
||||||
|
| event | string | Non | Filtrer par type |
|
||||||
|
| limit | int | Non | Max résultats (défaut: 50, max: 100) |
|
||||||
|
| offset | int | Non | Pagination (défaut: 0) |
|
||||||
|
|
||||||
|
Réponse (~10 KB) :
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"date": "2025-12-22",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"timestamp": "2025-12-22T08:15:32Z",
|
||||||
|
"event": "login_failed",
|
||||||
|
"username": "jean.dupont",
|
||||||
|
"reason": "invalid_password",
|
||||||
|
"attempt": 2,
|
||||||
|
"ip": "192.168.x.x",
|
||||||
|
"platform": "ios",
|
||||||
|
"app_version": "3.5.2"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"total": 3,
|
||||||
|
"limit": 50,
|
||||||
|
"offset": 0,
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
6. Types d'événements disponibles
|
||||||
|
|
||||||
|
GET /api/events/stats/types
|
||||||
|
|
||||||
|
Réponse :
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"auth": ["login_success", "login_failed", "logout"],
|
||||||
|
"passages": ["passage_created", "passage_updated", "passage_deleted"],
|
||||||
|
"sectors": ["sector_created", "sector_updated", "sector_deleted"],
|
||||||
|
"users": ["user_created", "user_updated", "user_deleted"],
|
||||||
|
"entities": ["entity_created", "entity_updated", "entity_deleted"],
|
||||||
|
"operations": ["operation_created", "operation_updated", "operation_deleted"],
|
||||||
|
"stripe": ["stripe_payment_created", "stripe_payment_success", "stripe_payment_failed", "stripe_payment_cancelled", "stripe_terminal_error"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Codes d'erreur
|
||||||
|
|
||||||
|
| Code | Signification |
|
||||||
|
| ---- | -------------------------------------------- |
|
||||||
|
| 200 | Succès |
|
||||||
|
| 400 | Paramètre invalide (date, plage trop grande) |
|
||||||
|
| 403 | Accès refusé (rôle insuffisant) |
|
||||||
|
| 500 | Erreur serveur |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Super-admin uniquement
|
||||||
|
|
||||||
|
Le super-admin peut ajouter ?entity_id=X pour filtrer par entité :
|
||||||
|
GET /api/events/stats/summary?date=2025-12-22&entity_id=5
|
||||||
|
|
||||||
|
Sans ce paramètre, il voit les stats globales de toutes les entités.
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
|
|
||||||
default_platform(:android)
|
default_platform(:android)
|
||||||
|
|
||||||
|
# Configure PATH pour Homebrew (M1/M2/M4 Mac)
|
||||||
|
ENV['PATH'] = "/opt/homebrew/bin:#{ENV['PATH']}"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ANDROID
|
# ANDROID
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -33,9 +33,21 @@ fi
|
|||||||
|
|
||||||
# Récupérer la version depuis pubspec.yaml
|
# Récupérer la version depuis pubspec.yaml
|
||||||
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
|
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
|
||||||
|
VERSION_NUMBER=$(echo $VERSION | cut -d'+' -f1)
|
||||||
|
VERSION_CODE=$(echo $VERSION | cut -d'+' -f2)
|
||||||
|
|
||||||
echo -e "${YELLOW}📦 Version détectée :${NC} $VERSION"
|
echo -e "${YELLOW}📦 Version détectée :${NC} $VERSION"
|
||||||
|
echo -e "${YELLOW} Version name :${NC} $VERSION_NUMBER"
|
||||||
|
echo -e "${YELLOW} Build number :${NC} $VERSION_CODE"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Vérifier que la version est bien synchronisée depuis transfer-to-mac.sh
|
||||||
|
if [ -z "$VERSION_CODE" ]; then
|
||||||
|
echo -e "${RED}⚠️ Avertissement: Version code introuvable${NC}"
|
||||||
|
echo -e "${YELLOW}Assurez-vous d'avoir utilisé transfer-to-mac.sh pour synchroniser la version${NC}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
# Étape 1 : Clean
|
# Étape 1 : Clean
|
||||||
echo -e "${YELLOW}🧹 Étape 1/5 : Nettoyage du projet...${NC}"
|
echo -e "${YELLOW}🧹 Étape 1/5 : Nettoyage du projet...${NC}"
|
||||||
flutter clean
|
flutter clean
|
||||||
@@ -50,6 +62,12 @@ echo ""
|
|||||||
|
|
||||||
# Étape 3 : Pod install
|
# Étape 3 : Pod install
|
||||||
echo -e "${YELLOW}🔧 Étape 3/5 : Installation des CocoaPods...${NC}"
|
echo -e "${YELLOW}🔧 Étape 3/5 : Installation des CocoaPods...${NC}"
|
||||||
|
|
||||||
|
# Configurer l'environnement Ruby Homebrew
|
||||||
|
export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/bin:$PATH"
|
||||||
|
export GEM_HOME="/opt/homebrew/lib/ruby/gems/3.4.0"
|
||||||
|
echo -e "${BLUE}ℹ Environnement Ruby Homebrew configuré${NC}"
|
||||||
|
|
||||||
cd ios
|
cd ios
|
||||||
rm -rf Pods Podfile.lock
|
rm -rf Pods Podfile.lock
|
||||||
pod install --repo-update
|
pod install --repo-update
|
||||||
@@ -57,10 +75,29 @@ cd ..
|
|||||||
echo -e "${GREEN}✓ CocoaPods installés${NC}"
|
echo -e "${GREEN}✓ CocoaPods installés${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Étape 4 : Build iOS Release
|
# Étape 4 : Build iOS
|
||||||
echo -e "${YELLOW}🏗️ Étape 4/5 : Compilation iOS en mode release...${NC}"
|
echo -e "${YELLOW}🏗️ Étape 4/5 : Choix du mode de compilation...${NC}"
|
||||||
flutter build ios --release
|
echo ""
|
||||||
echo -e "${GREEN}✓ Compilation terminée${NC}"
|
echo -e "${BLUE}Quel mode de compilation souhaitez-vous utiliser ?${NC}"
|
||||||
|
echo -e " ${GREEN}[D]${NC} Debug - Pour tester Stripe Tap to Pay (défaut)"
|
||||||
|
echo -e " ${YELLOW}[R]${NC} Release - Pour distribution App Store"
|
||||||
|
echo ""
|
||||||
|
read -p "Votre choix (D/R) [défaut: D] : " -n 1 -r BUILD_MODE
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Définir le mode de build
|
||||||
|
if [[ $BUILD_MODE =~ ^[Rr]$ ]]; then
|
||||||
|
BUILD_FLAG="--release"
|
||||||
|
BUILD_MODE_NAME="Release"
|
||||||
|
else
|
||||||
|
BUILD_FLAG="--debug"
|
||||||
|
BUILD_MODE_NAME="Debug"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}🏗️ Compilation iOS en mode ${BUILD_MODE_NAME}...${NC}"
|
||||||
|
flutter build ios $BUILD_FLAG
|
||||||
|
echo -e "${GREEN}✓ Compilation terminée (mode ${BUILD_MODE_NAME})${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Étape 5 : Ouvrir Xcode
|
# Étape 5 : Ouvrir Xcode
|
||||||
|
|||||||
249
app/ios.sh
Executable file
@@ -0,0 +1,249 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script de génération iOS pour GEOSECTOR
|
||||||
|
# Usage: ./ios.sh
|
||||||
|
|
||||||
|
set -e # Arrêter le script en cas d'erreur
|
||||||
|
|
||||||
|
# Couleurs pour les messages
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration Mac mini
|
||||||
|
MAC_MINI_IP="192.168.1.34"
|
||||||
|
MAC_USER="pierre"
|
||||||
|
|
||||||
|
# Fonction pour afficher les messages
|
||||||
|
print_message() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour gérer les erreurs
|
||||||
|
handle_error() {
|
||||||
|
print_error "Une erreur est survenue lors de l'exécution de la commande"
|
||||||
|
print_error "Ligne $1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Trap pour capturer les erreurs
|
||||||
|
trap 'handle_error $LINENO' ERR
|
||||||
|
|
||||||
|
# Vérifier que nous sommes dans le bon dossier
|
||||||
|
if [ ! -f "pubspec.yaml" ]; then
|
||||||
|
print_error "Ce script doit être exécuté depuis le dossier racine de l'application Flutter"
|
||||||
|
print_error "Fichier pubspec.yaml introuvable"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "========================================="
|
||||||
|
print_message " GEOSECTOR - Génération iOS"
|
||||||
|
print_message "========================================="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Vérifier que Flutter est installé
|
||||||
|
if ! command -v flutter &> /dev/null; then
|
||||||
|
print_error "Flutter n'est pas installé ou n'est pas dans le PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Étape 1 : Synchroniser la version depuis ../VERSION
|
||||||
|
print_message "Étape 1/4 : Synchronisation de la version..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
VERSION_FILE="../VERSION"
|
||||||
|
if [ ! -f "$VERSION_FILE" ]; then
|
||||||
|
print_error "Fichier VERSION introuvable : $VERSION_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lire la version depuis le fichier (enlever espaces/retours à la ligne)
|
||||||
|
VERSION_NUMBER=$(cat "$VERSION_FILE" | tr -d '\n\r ' | tr -d '[:space:]')
|
||||||
|
if [ -z "$VERSION_NUMBER" ]; then
|
||||||
|
print_error "Le fichier VERSION est vide"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "Version lue depuis $VERSION_FILE : $VERSION_NUMBER"
|
||||||
|
|
||||||
|
# Calculer le versionCode (supprimer les points)
|
||||||
|
VERSION_CODE=$(echo $VERSION_NUMBER | tr -d '.')
|
||||||
|
if [ -z "$VERSION_CODE" ]; then
|
||||||
|
print_error "Impossible de calculer le versionCode"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_message "Version code calculé : $VERSION_CODE"
|
||||||
|
|
||||||
|
# Mettre à jour pubspec.yaml
|
||||||
|
print_message "Mise à jour de pubspec.yaml..."
|
||||||
|
sed -i.bak "s/^version:.*/version: $VERSION_NUMBER+$VERSION_CODE/" pubspec.yaml
|
||||||
|
|
||||||
|
# Vérifier que la mise à jour a réussi
|
||||||
|
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //')
|
||||||
|
if [ "$UPDATED_VERSION" != "$VERSION_NUMBER+$VERSION_CODE" ]; then
|
||||||
|
print_error "Échec de la mise à jour de pubspec.yaml"
|
||||||
|
print_error "Attendu : $VERSION_NUMBER+$VERSION_CODE"
|
||||||
|
print_error "Obtenu : $UPDATED_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "pubspec.yaml mis à jour : version: $VERSION_NUMBER+$VERSION_CODE"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Récupérer la version finale pour l'affichage
|
||||||
|
VERSION="$VERSION_NUMBER-$VERSION_CODE"
|
||||||
|
print_message "Version finale : $VERSION"
|
||||||
|
print_message "Version code : $VERSION_CODE"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Étape 2 : Vérifier la connexion au Mac mini
|
||||||
|
print_message "Étape 2/4 : Vérification de la connexion au Mac mini..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
print_message "Test de connexion à $MAC_USER@$MAC_MINI_IP..."
|
||||||
|
|
||||||
|
if ssh -o ConnectTimeout=5 -o BatchMode=yes "$MAC_USER@$MAC_MINI_IP" exit 2>/dev/null; then
|
||||||
|
print_success "Connexion SSH au Mac mini établie"
|
||||||
|
else
|
||||||
|
print_warning "Impossible de se connecter au Mac mini en mode non-interactif"
|
||||||
|
print_message "Le transfert demandera votre mot de passe"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Étape 3 : Nettoyer le projet
|
||||||
|
print_message "Étape 3/4 : Nettoyage du projet local..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
print_message "Nettoyage en cours..."
|
||||||
|
flutter clean
|
||||||
|
print_success "Projet nettoyé"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Étape 4 : Transfert vers Mac mini
|
||||||
|
print_message "Étape 4/4 : Transfert vers Mac mini..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Construire le chemin de destination avec numéro de version
|
||||||
|
DESTINATION_DIR="app_$VERSION_CODE"
|
||||||
|
DESTINATION="$MAC_USER@$MAC_MINI_IP:/Users/pierre/dev/geosector/$DESTINATION_DIR"
|
||||||
|
|
||||||
|
print_message "Configuration du transfert :"
|
||||||
|
print_message " Source : $(pwd)"
|
||||||
|
print_message " Destination : $DESTINATION"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Supprimer le dossier de destination s'il existe déjà
|
||||||
|
print_message "Suppression du dossier existant sur le Mac mini (si présent)..."
|
||||||
|
ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password \
|
||||||
|
"$MAC_USER@$MAC_MINI_IP" "rm -rf /Users/pierre/dev/geosector/$DESTINATION_DIR" 2>/dev/null || true
|
||||||
|
print_success "Dossier nettoyé"
|
||||||
|
echo
|
||||||
|
|
||||||
|
print_warning "Note: Vous allez devoir saisir le mot de passe du Mac mini"
|
||||||
|
print_message "rsync va créer le dossier de destination automatiquement"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Transfert réel (rsync créera le dossier automatiquement)
|
||||||
|
# Options SSH pour éviter "too many authentication failures"
|
||||||
|
rsync -avz --progress \
|
||||||
|
-e "ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password" \
|
||||||
|
--rsync-path="mkdir -p /Users/pierre/dev/geosector/$DESTINATION_DIR && rsync" \
|
||||||
|
--exclude='build/' \
|
||||||
|
--exclude='.dart_tool/' \
|
||||||
|
--exclude='ios/Pods/' \
|
||||||
|
--exclude='ios/.symlinks/' \
|
||||||
|
--exclude='macos/Pods/' \
|
||||||
|
--exclude='linux/flutter/ephemeral/' \
|
||||||
|
--exclude='windows/flutter/ephemeral/' \
|
||||||
|
--exclude='.pub-cache/' \
|
||||||
|
--exclude='android/build/' \
|
||||||
|
--exclude='*.aab' \
|
||||||
|
--exclude='*.apk' \
|
||||||
|
./ "$DESTINATION/"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo
|
||||||
|
print_success "Transfert terminé avec succès !"
|
||||||
|
echo
|
||||||
|
else
|
||||||
|
print_error "Erreur lors du transfert"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Afficher le résumé
|
||||||
|
echo
|
||||||
|
print_message "========================================="
|
||||||
|
print_success " TRANSFERT TERMINÉ AVEC SUCCÈS !"
|
||||||
|
print_message "========================================="
|
||||||
|
echo
|
||||||
|
print_message "Version : ${GREEN}$VERSION${NC}"
|
||||||
|
print_message "Dossier sur le Mac : ${GREEN}/Users/pierre/dev/geosector/$DESTINATION_DIR${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Proposer de lancer le build automatiquement
|
||||||
|
print_message "Voulez-vous lancer le build iOS maintenant ?"
|
||||||
|
echo
|
||||||
|
print_message " ${GREEN}[A]${NC} Se connecter en SSH et lancer le build automatiquement"
|
||||||
|
print_message " ${YELLOW}[B]${NC} Afficher les instructions manuelles (défaut)"
|
||||||
|
echo
|
||||||
|
read -p "Votre choix (A/B) [défaut: B] : " -n 1 -r BUILD_CHOICE
|
||||||
|
echo
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ $BUILD_CHOICE =~ ^[Aa]$ ]]; then
|
||||||
|
print_message "Connexion au Mac mini et lancement du build..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Se connecter en SSH et lancer le build
|
||||||
|
# Options SSH pour éviter "too many authentication failures"
|
||||||
|
ssh -t -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password \
|
||||||
|
"$MAC_USER@$MAC_MINI_IP" "cd /Users/pierre/dev/geosector/$DESTINATION_DIR && ./ios-build-mac.sh"
|
||||||
|
|
||||||
|
echo
|
||||||
|
print_success "Build terminé sur le Mac mini"
|
||||||
|
echo
|
||||||
|
print_message "Prochaines étapes sur le Mac mini :"
|
||||||
|
print_message "1. Xcode est maintenant ouvert"
|
||||||
|
print_message "2. Vérifier Signing & Capabilities (Team: 6WT84NWCTC)"
|
||||||
|
print_message "3. Product > Archive"
|
||||||
|
print_message "4. Organizer > Distribute App > App Store Connect"
|
||||||
|
else
|
||||||
|
print_message "========================================="
|
||||||
|
print_message " INSTRUCTIONS MANUELLES"
|
||||||
|
print_message "========================================="
|
||||||
|
echo
|
||||||
|
print_message "Sur votre Mac mini, exécutez les commandes suivantes :"
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}# Se connecter au Mac mini${NC}"
|
||||||
|
echo "ssh $MAC_USER@$MAC_MINI_IP"
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}# Aller dans le dossier du projet${NC}"
|
||||||
|
echo "cd /Users/pierre/dev/geosector/$DESTINATION_DIR"
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}# Lancer le build iOS${NC}"
|
||||||
|
echo "./ios-build-mac.sh"
|
||||||
|
echo
|
||||||
|
print_message "========================================="
|
||||||
|
echo
|
||||||
|
print_message "Ou copiez-collez cette commande complète :"
|
||||||
|
echo
|
||||||
|
echo -e "${GREEN}ssh -t $MAC_USER@$MAC_MINI_IP \"cd /Users/pierre/dev/geosector/$DESTINATION_DIR && ./ios-build-mac.sh\"${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
print_success "Script terminé !"
|
||||||
BIN
app/ios/GeoSector_v3_Development.mobileprovision
Normal file
@@ -488,7 +488,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
@@ -504,7 +505,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store";
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -680,7 +681,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
@@ -696,7 +698,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store";
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -710,7 +712,8 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
@@ -726,7 +729,7 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store";
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
|||||||
8
app/ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.proximity-reader.payment.acceptance</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -24,6 +24,7 @@ import 'package:geosector_app/presentation/pages/messages_page.dart';
|
|||||||
import 'package:geosector_app/presentation/pages/amicale_page.dart';
|
import 'package:geosector_app/presentation/pages/amicale_page.dart';
|
||||||
import 'package:geosector_app/presentation/pages/operations_page.dart';
|
import 'package:geosector_app/presentation/pages/operations_page.dart';
|
||||||
import 'package:geosector_app/presentation/pages/field_mode_page.dart';
|
import 'package:geosector_app/presentation/pages/field_mode_page.dart';
|
||||||
|
import 'package:geosector_app/presentation/pages/connexions_page.dart';
|
||||||
|
|
||||||
// Instances globales des repositories (plus besoin d'injecter ApiService)
|
// Instances globales des repositories (plus besoin d'injecter ApiService)
|
||||||
final operationRepository = OperationRepository();
|
final operationRepository = OperationRepository();
|
||||||
@@ -322,6 +323,15 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
|
|||||||
return const OperationsPage();
|
return const OperationsPage();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// Sous-route pour connexions (role 2+ uniquement)
|
||||||
|
GoRoute(
|
||||||
|
path: 'connexions',
|
||||||
|
name: 'admin-connexions',
|
||||||
|
builder: (context, state) {
|
||||||
|
debugPrint('GoRoute: Affichage de ConnexionsPage (unifiée)');
|
||||||
|
return const ConnexionsPage();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ class ChatService {
|
|||||||
Timer? _syncTimer;
|
Timer? _syncTimer;
|
||||||
DateTime? _lastSyncTimestamp;
|
DateTime? _lastSyncTimestamp;
|
||||||
DateTime? _lastFullSync;
|
DateTime? _lastFullSync;
|
||||||
static const Duration _syncInterval = Duration(seconds: 15); // Changé à 15 secondes comme suggéré par l'API
|
static const Duration _syncInterval = Duration(seconds: 30); // Sync toutes les 30 secondes
|
||||||
|
static const Duration _initialSyncDelay = Duration(seconds: 10); // Délai avant première sync
|
||||||
static const Duration _fullSyncInterval = Duration(minutes: 5);
|
static const Duration _fullSyncInterval = Duration(minutes: 5);
|
||||||
|
|
||||||
/// Initialisation avec gestion des rôles et configuration YAML
|
/// Initialisation avec gestion des rôles et configuration YAML
|
||||||
@@ -76,9 +77,12 @@ class ChatService {
|
|||||||
// Charger le dernier timestamp de sync depuis Hive
|
// Charger le dernier timestamp de sync depuis Hive
|
||||||
await _instance!._loadSyncTimestamp();
|
await _instance!._loadSyncTimestamp();
|
||||||
|
|
||||||
// Faire la sync initiale complète au login
|
// Faire la sync initiale complète au login avec délai de 10 secondes
|
||||||
|
debugPrint('⏳ Sync initiale chat programmée dans 10 secondes...');
|
||||||
|
Future.delayed(_initialSyncDelay, () async {
|
||||||
await _instance!.getRooms(forceFullSync: true);
|
await _instance!.getRooms(forceFullSync: true);
|
||||||
debugPrint('✅ Sync initiale complète effectuée au login');
|
debugPrint('✅ Sync initiale complète effectuée au login');
|
||||||
|
});
|
||||||
|
|
||||||
// Démarrer la synchronisation incrémentale périodique
|
// Démarrer la synchronisation incrémentale périodique
|
||||||
_instance!._startSync();
|
_instance!._startSync();
|
||||||
@@ -136,6 +140,13 @@ class ChatService {
|
|||||||
|
|
||||||
/// Obtenir les rooms avec synchronisation incrémentale
|
/// Obtenir les rooms avec synchronisation incrémentale
|
||||||
Future<List<Room>> getRooms({bool forceFullSync = false}) async {
|
Future<List<Room>> getRooms({bool forceFullSync = false}) async {
|
||||||
|
// DÉSACTIVATION TEMPORAIRE - Retour direct du cache sans appeler l'API
|
||||||
|
debugPrint('🚫 API /chat/rooms désactivée - utilisation du cache uniquement');
|
||||||
|
return _roomsBox.values.toList()
|
||||||
|
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||||
|
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||||
|
|
||||||
|
/* Code original commenté pour désactiver les appels API
|
||||||
// Vérifier la connectivité
|
// Vérifier la connectivité
|
||||||
if (!connectivityService.isConnected) {
|
if (!connectivityService.isConnected) {
|
||||||
debugPrint('📵 Pas de connexion réseau - utilisation du cache');
|
debugPrint('📵 Pas de connexion réseau - utilisation du cache');
|
||||||
@@ -156,15 +167,17 @@ class ChatService {
|
|||||||
if (needsFullSync || _lastSyncTimestamp == null) {
|
if (needsFullSync || _lastSyncTimestamp == null) {
|
||||||
// Synchronisation complète
|
// Synchronisation complète
|
||||||
debugPrint('🔄 Synchronisation complète des rooms...');
|
debugPrint('🔄 Synchronisation complète des rooms...');
|
||||||
response = await _dio.get('/chat/rooms');
|
// response = await _dio.get('/chat/rooms'); // COMMENTÉ - Désactivation GET /chat/rooms
|
||||||
|
return; // Retour anticipé pour éviter l'appel API
|
||||||
_lastFullSync = now;
|
_lastFullSync = now;
|
||||||
} else {
|
} else {
|
||||||
// Synchronisation incrémentale
|
// Synchronisation incrémentale
|
||||||
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
|
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
|
||||||
// debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp');
|
// debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp');
|
||||||
response = await _dio.get('/chat/rooms', queryParameters: {
|
// response = await _dio.get('/chat/rooms', queryParameters: { // COMMENTÉ - Désactivation GET /chat/rooms
|
||||||
'updated_after': isoTimestamp,
|
// 'updated_after': isoTimestamp,
|
||||||
});
|
// });
|
||||||
|
return; // Retour anticipé pour éviter l'appel API
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extraire le timestamp de synchronisation fourni par l'API
|
// Extraire le timestamp de synchronisation fourni par l'API
|
||||||
@@ -348,6 +361,7 @@ class ChatService {
|
|||||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||||
}
|
}
|
||||||
|
*/// Fin du code commenté
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Créer une room avec vérification des permissions
|
/// Créer une room avec vérification des permissions
|
||||||
@@ -754,7 +768,7 @@ class ChatService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Pas de sync immédiate ici car déjà faite dans init()
|
// Pas de sync immédiate ici car déjà faite dans init()
|
||||||
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)');
|
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 30 secondes)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mettre en pause les synchronisations (app en arrière-plan)
|
/// Mettre en pause les synchronisations (app en arrière-plan)
|
||||||
|
|||||||
594
app/lib/core/data/models/event_stats_model.dart
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
// Modèles pour les statistiques d'événements (connexions, passages, etc.)
|
||||||
|
//
|
||||||
|
// Ces modèles ne sont PAS stockés dans Hive car les données sont récupérées
|
||||||
|
// à la demande depuis l'API et ne nécessitent pas de persistance locale.
|
||||||
|
|
||||||
|
/// Statistiques d'authentification
|
||||||
|
class AuthStats {
|
||||||
|
final int success;
|
||||||
|
final int failed;
|
||||||
|
final int logout;
|
||||||
|
|
||||||
|
const AuthStats({
|
||||||
|
required this.success,
|
||||||
|
required this.failed,
|
||||||
|
required this.logout,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AuthStats(
|
||||||
|
success: _parseInt(json['success']),
|
||||||
|
failed: _parseInt(json['failed']),
|
||||||
|
logout: _parseInt(json['logout']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get total => success + failed + logout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques de passages
|
||||||
|
class PassageStats {
|
||||||
|
final int created;
|
||||||
|
final int updated;
|
||||||
|
final int deleted;
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
const PassageStats({
|
||||||
|
required this.created,
|
||||||
|
required this.updated,
|
||||||
|
required this.deleted,
|
||||||
|
required this.amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PassageStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PassageStats(
|
||||||
|
created: _parseInt(json['created']),
|
||||||
|
updated: _parseInt(json['updated']),
|
||||||
|
deleted: _parseInt(json['deleted']),
|
||||||
|
amount: _parseDouble(json['amount']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get total => created + updated + deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques utilisateurs
|
||||||
|
class UserStats {
|
||||||
|
final int created;
|
||||||
|
final int updated;
|
||||||
|
final int deleted;
|
||||||
|
|
||||||
|
const UserStats({
|
||||||
|
required this.created,
|
||||||
|
required this.updated,
|
||||||
|
required this.deleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserStats(
|
||||||
|
created: _parseInt(json['created']),
|
||||||
|
updated: _parseInt(json['updated']),
|
||||||
|
deleted: _parseInt(json['deleted']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get total => created + updated + deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques secteurs
|
||||||
|
class SectorStats {
|
||||||
|
final int created;
|
||||||
|
final int updated;
|
||||||
|
final int deleted;
|
||||||
|
|
||||||
|
const SectorStats({
|
||||||
|
required this.created,
|
||||||
|
required this.updated,
|
||||||
|
required this.deleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SectorStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SectorStats(
|
||||||
|
created: _parseInt(json['created']),
|
||||||
|
updated: _parseInt(json['updated']),
|
||||||
|
deleted: _parseInt(json['deleted']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get total => created + updated + deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques Stripe
|
||||||
|
class StripeStats {
|
||||||
|
final int created;
|
||||||
|
final int success;
|
||||||
|
final int failed;
|
||||||
|
final int cancelled;
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
const StripeStats({
|
||||||
|
required this.created,
|
||||||
|
required this.success,
|
||||||
|
required this.failed,
|
||||||
|
required this.cancelled,
|
||||||
|
required this.amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StripeStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StripeStats(
|
||||||
|
created: _parseInt(json['created']),
|
||||||
|
success: _parseInt(json['success']),
|
||||||
|
failed: _parseInt(json['failed']),
|
||||||
|
cancelled: _parseInt(json['cancelled']),
|
||||||
|
amount: _parseDouble(json['amount']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get total => created + success + failed + cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques globales d'une journée
|
||||||
|
class DayStats {
|
||||||
|
final AuthStats auth;
|
||||||
|
final PassageStats passages;
|
||||||
|
final UserStats users;
|
||||||
|
final SectorStats sectors;
|
||||||
|
final StripeStats stripe;
|
||||||
|
|
||||||
|
const DayStats({
|
||||||
|
required this.auth,
|
||||||
|
required this.passages,
|
||||||
|
required this.users,
|
||||||
|
required this.sectors,
|
||||||
|
required this.stripe,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DayStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DayStats(
|
||||||
|
auth: AuthStats.fromJson(json['auth'] ?? {}),
|
||||||
|
passages: PassageStats.fromJson(json['passages'] ?? {}),
|
||||||
|
users: UserStats.fromJson(json['users'] ?? {}),
|
||||||
|
sectors: SectorStats.fromJson(json['sectors'] ?? {}),
|
||||||
|
stripe: StripeStats.fromJson(json['stripe'] ?? {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Totaux d'une journée
|
||||||
|
class DayTotals {
|
||||||
|
final int events;
|
||||||
|
final int uniqueUsers;
|
||||||
|
|
||||||
|
const DayTotals({
|
||||||
|
required this.events,
|
||||||
|
required this.uniqueUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DayTotals.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DayTotals(
|
||||||
|
events: _parseInt(json['events']),
|
||||||
|
uniqueUsers: _parseInt(json['unique_users']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résumé complet d'une journée (réponse de /stats/summary)
|
||||||
|
class EventSummary {
|
||||||
|
final DateTime date;
|
||||||
|
final DayStats stats;
|
||||||
|
final DayTotals totals;
|
||||||
|
|
||||||
|
const EventSummary({
|
||||||
|
required this.date,
|
||||||
|
required this.stats,
|
||||||
|
required this.totals,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EventSummary(
|
||||||
|
date: DateTime.parse(json['date']),
|
||||||
|
stats: DayStats.fromJson(json['stats'] ?? {}),
|
||||||
|
totals: DayTotals.fromJson(json['totals'] ?? {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques d'un type d'événement pour une période
|
||||||
|
class EventTypeStats {
|
||||||
|
final int count;
|
||||||
|
final double sumAmount;
|
||||||
|
final int uniqueUsers;
|
||||||
|
|
||||||
|
const EventTypeStats({
|
||||||
|
required this.count,
|
||||||
|
required this.sumAmount,
|
||||||
|
required this.uniqueUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventTypeStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EventTypeStats(
|
||||||
|
count: _parseInt(json['count']),
|
||||||
|
sumAmount: _parseDouble(json['sum_amount']),
|
||||||
|
uniqueUsers: _parseInt(json['unique_users']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Données d'une journée dans les stats quotidiennes
|
||||||
|
class DailyStatsEntry {
|
||||||
|
final DateTime date;
|
||||||
|
final Map<String, EventTypeStats> events;
|
||||||
|
final int totalCount;
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
const DailyStatsEntry({
|
||||||
|
required this.date,
|
||||||
|
required this.events,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.totalAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DailyStatsEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
|
||||||
|
final events = <String, EventTypeStats>{};
|
||||||
|
|
||||||
|
for (final entry in eventsJson.entries) {
|
||||||
|
events[entry.key] = EventTypeStats.fromJson(entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
final totals = json['totals'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
return DailyStatsEntry(
|
||||||
|
date: DateTime.parse(json['date']),
|
||||||
|
events: events,
|
||||||
|
totalCount: _parseInt(totals['count']),
|
||||||
|
totalAmount: _parseDouble(totals['sum_amount']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse des stats quotidiennes (/stats/daily)
|
||||||
|
class DailyStats {
|
||||||
|
final DateTime from;
|
||||||
|
final DateTime to;
|
||||||
|
final List<DailyStatsEntry> days;
|
||||||
|
|
||||||
|
const DailyStats({
|
||||||
|
required this.from,
|
||||||
|
required this.to,
|
||||||
|
required this.days,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DailyStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
final daysJson = json['days'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
return DailyStats(
|
||||||
|
from: DateTime.parse(json['from']),
|
||||||
|
to: DateTime.parse(json['to']),
|
||||||
|
days: daysJson.map((d) => DailyStatsEntry.fromJson(d)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Données d'une semaine dans les stats hebdomadaires
|
||||||
|
class WeeklyStatsEntry {
|
||||||
|
final DateTime weekStart;
|
||||||
|
final int weekNumber;
|
||||||
|
final int year;
|
||||||
|
final Map<String, EventTypeStats> events;
|
||||||
|
final int totalCount;
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
const WeeklyStatsEntry({
|
||||||
|
required this.weekStart,
|
||||||
|
required this.weekNumber,
|
||||||
|
required this.year,
|
||||||
|
required this.events,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.totalAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WeeklyStatsEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
|
||||||
|
final events = <String, EventTypeStats>{};
|
||||||
|
|
||||||
|
for (final entry in eventsJson.entries) {
|
||||||
|
events[entry.key] = EventTypeStats.fromJson(entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
final totals = json['totals'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
return WeeklyStatsEntry(
|
||||||
|
weekStart: DateTime.parse(json['week_start']),
|
||||||
|
weekNumber: _parseInt(json['week_number']),
|
||||||
|
year: _parseInt(json['year']),
|
||||||
|
events: events,
|
||||||
|
totalCount: _parseInt(totals['count']),
|
||||||
|
totalAmount: _parseDouble(totals['sum_amount']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse des stats hebdomadaires (/stats/weekly)
|
||||||
|
class WeeklyStats {
|
||||||
|
final DateTime from;
|
||||||
|
final DateTime to;
|
||||||
|
final List<WeeklyStatsEntry> weeks;
|
||||||
|
|
||||||
|
const WeeklyStats({
|
||||||
|
required this.from,
|
||||||
|
required this.to,
|
||||||
|
required this.weeks,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WeeklyStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
final weeksJson = json['weeks'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
return WeeklyStats(
|
||||||
|
from: DateTime.parse(json['from']),
|
||||||
|
to: DateTime.parse(json['to']),
|
||||||
|
weeks: weeksJson.map((w) => WeeklyStatsEntry.fromJson(w)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Données d'un mois dans les stats mensuelles
|
||||||
|
class MonthlyStatsEntry {
|
||||||
|
final String month; // Format: "2025-01"
|
||||||
|
final int year;
|
||||||
|
final int monthNumber;
|
||||||
|
final Map<String, EventTypeStats> events;
|
||||||
|
final int totalCount;
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
const MonthlyStatsEntry({
|
||||||
|
required this.month,
|
||||||
|
required this.year,
|
||||||
|
required this.monthNumber,
|
||||||
|
required this.events,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.totalAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MonthlyStatsEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
|
||||||
|
final events = <String, EventTypeStats>{};
|
||||||
|
|
||||||
|
for (final entry in eventsJson.entries) {
|
||||||
|
events[entry.key] = EventTypeStats.fromJson(entry.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
final totals = json['totals'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
return MonthlyStatsEntry(
|
||||||
|
month: json['month'] ?? '',
|
||||||
|
year: _parseInt(json['year']),
|
||||||
|
monthNumber: _parseInt(json['month_number']),
|
||||||
|
events: events,
|
||||||
|
totalCount: _parseInt(totals['count']),
|
||||||
|
totalAmount: _parseDouble(totals['sum_amount']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse des stats mensuelles (/stats/monthly)
|
||||||
|
class MonthlyStats {
|
||||||
|
final int year;
|
||||||
|
final List<MonthlyStatsEntry> months;
|
||||||
|
|
||||||
|
const MonthlyStats({
|
||||||
|
required this.year,
|
||||||
|
required this.months,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MonthlyStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
final monthsJson = json['months'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
return MonthlyStats(
|
||||||
|
year: _parseInt(json['year']),
|
||||||
|
months: monthsJson.map((m) => MonthlyStatsEntry.fromJson(m)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Détail d'un événement individuel
|
||||||
|
class EventDetail {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String event;
|
||||||
|
final String? username;
|
||||||
|
final String? reason;
|
||||||
|
final int? attempt;
|
||||||
|
final String? ip;
|
||||||
|
final String? platform;
|
||||||
|
final String? appVersion;
|
||||||
|
final Map<String, dynamic>? extra;
|
||||||
|
|
||||||
|
const EventDetail({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.event,
|
||||||
|
this.username,
|
||||||
|
this.reason,
|
||||||
|
this.attempt,
|
||||||
|
this.ip,
|
||||||
|
this.platform,
|
||||||
|
this.appVersion,
|
||||||
|
this.extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventDetail.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EventDetail(
|
||||||
|
timestamp: DateTime.parse(json['timestamp']),
|
||||||
|
event: json['event'] ?? '',
|
||||||
|
username: json['username'],
|
||||||
|
reason: json['reason'],
|
||||||
|
attempt: json['attempt'] != null ? _parseInt(json['attempt']) : null,
|
||||||
|
ip: json['ip'],
|
||||||
|
platform: json['platform'],
|
||||||
|
appVersion: json['app_version'],
|
||||||
|
extra: json,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pagination pour les détails
|
||||||
|
class EventPagination {
|
||||||
|
final int total;
|
||||||
|
final int limit;
|
||||||
|
final int offset;
|
||||||
|
final bool hasMore;
|
||||||
|
|
||||||
|
const EventPagination({
|
||||||
|
required this.total,
|
||||||
|
required this.limit,
|
||||||
|
required this.offset,
|
||||||
|
required this.hasMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventPagination.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EventPagination(
|
||||||
|
total: _parseInt(json['total']),
|
||||||
|
limit: _parseInt(json['limit']),
|
||||||
|
offset: _parseInt(json['offset']),
|
||||||
|
hasMore: json['has_more'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse des détails d'événements (/stats/details)
|
||||||
|
class EventDetails {
|
||||||
|
final DateTime date;
|
||||||
|
final List<EventDetail> events;
|
||||||
|
final EventPagination pagination;
|
||||||
|
|
||||||
|
const EventDetails({
|
||||||
|
required this.date,
|
||||||
|
required this.events,
|
||||||
|
required this.pagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventDetails.fromJson(Map<String, dynamic> json) {
|
||||||
|
final eventsJson = json['events'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
return EventDetails(
|
||||||
|
date: DateTime.parse(json['date']),
|
||||||
|
events: eventsJson.map((e) => EventDetail.fromJson(e)).toList(),
|
||||||
|
pagination: EventPagination.fromJson(json['pagination'] ?? {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Types d'événements disponibles
|
||||||
|
class EventTypes {
|
||||||
|
final List<String> auth;
|
||||||
|
final List<String> passages;
|
||||||
|
final List<String> sectors;
|
||||||
|
final List<String> users;
|
||||||
|
final List<String> entities;
|
||||||
|
final List<String> operations;
|
||||||
|
final List<String> stripe;
|
||||||
|
|
||||||
|
const EventTypes({
|
||||||
|
required this.auth,
|
||||||
|
required this.passages,
|
||||||
|
required this.sectors,
|
||||||
|
required this.users,
|
||||||
|
required this.entities,
|
||||||
|
required this.operations,
|
||||||
|
required this.stripe,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventTypes.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EventTypes(
|
||||||
|
auth: _parseStringList(json['auth']),
|
||||||
|
passages: _parseStringList(json['passages']),
|
||||||
|
sectors: _parseStringList(json['sectors']),
|
||||||
|
users: _parseStringList(json['users']),
|
||||||
|
entities: _parseStringList(json['entities']),
|
||||||
|
operations: _parseStringList(json['operations']),
|
||||||
|
stripe: _parseStringList(json['stripe']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient tous les types d'événements à plat
|
||||||
|
List<String> get allTypes => [
|
||||||
|
...auth,
|
||||||
|
...passages,
|
||||||
|
...sectors,
|
||||||
|
...users,
|
||||||
|
...entities,
|
||||||
|
...operations,
|
||||||
|
...stripe,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Obtient le libellé français d'un type d'événement
|
||||||
|
static String getLabel(String eventType) {
|
||||||
|
switch (eventType) {
|
||||||
|
// Auth
|
||||||
|
case 'login_success': return 'Connexion réussie';
|
||||||
|
case 'login_failed': return 'Connexion échouée';
|
||||||
|
case 'logout': return 'Déconnexion';
|
||||||
|
// Passages
|
||||||
|
case 'passage_created': return 'Passage créé';
|
||||||
|
case 'passage_updated': return 'Passage modifié';
|
||||||
|
case 'passage_deleted': return 'Passage supprimé';
|
||||||
|
// Sectors
|
||||||
|
case 'sector_created': return 'Secteur créé';
|
||||||
|
case 'sector_updated': return 'Secteur modifié';
|
||||||
|
case 'sector_deleted': return 'Secteur supprimé';
|
||||||
|
// Users
|
||||||
|
case 'user_created': return 'Utilisateur créé';
|
||||||
|
case 'user_updated': return 'Utilisateur modifié';
|
||||||
|
case 'user_deleted': return 'Utilisateur supprimé';
|
||||||
|
// Entities
|
||||||
|
case 'entity_created': return 'Entité créée';
|
||||||
|
case 'entity_updated': return 'Entité modifiée';
|
||||||
|
case 'entity_deleted': return 'Entité supprimée';
|
||||||
|
// Operations
|
||||||
|
case 'operation_created': return 'Opération créée';
|
||||||
|
case 'operation_updated': return 'Opération modifiée';
|
||||||
|
case 'operation_deleted': return 'Opération supprimée';
|
||||||
|
// Stripe
|
||||||
|
case 'stripe_payment_created': return 'Paiement créé';
|
||||||
|
case 'stripe_payment_success': return 'Paiement réussi';
|
||||||
|
case 'stripe_payment_failed': return 'Paiement échoué';
|
||||||
|
case 'stripe_payment_cancelled': return 'Paiement annulé';
|
||||||
|
case 'stripe_terminal_error': return 'Erreur terminal';
|
||||||
|
default: return eventType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient la catégorie d'un type d'événement
|
||||||
|
static String getCategory(String eventType) {
|
||||||
|
if (eventType.startsWith('login') || eventType == 'logout') return 'auth';
|
||||||
|
if (eventType.startsWith('passage')) return 'passages';
|
||||||
|
if (eventType.startsWith('sector')) return 'sectors';
|
||||||
|
if (eventType.startsWith('user')) return 'users';
|
||||||
|
if (eventType.startsWith('entity')) return 'entities';
|
||||||
|
if (eventType.startsWith('operation')) return 'operations';
|
||||||
|
if (eventType.startsWith('stripe')) return 'stripe';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers pour parser les types depuis JSON (gère int/string)
|
||||||
|
int _parseInt(dynamic value) {
|
||||||
|
if (value == null) return 0;
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
if (value is double) return value.toInt();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _parseDouble(dynamic value) {
|
||||||
|
if (value == null) return 0.0;
|
||||||
|
if (value is double) return value;
|
||||||
|
if (value is int) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value) ?? 0.0;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseStringList(dynamic value) {
|
||||||
|
if (value == null) return [];
|
||||||
|
if (value is List) return value.map((e) => e.toString()).toList();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -247,10 +247,10 @@ class OperationRepository extends ChangeNotifier {
|
|||||||
debugPrint('✅ Opérations traitées');
|
debugPrint('✅ Opérations traitées');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Traiter les secteurs (groupe secteurs) via DataLoadingService
|
// Traiter les secteurs (groupe sectors) via DataLoadingService
|
||||||
if (responseData['secteurs'] != null) {
|
if (responseData['sectors'] != null) {
|
||||||
await DataLoadingService.instance
|
await DataLoadingService.instance
|
||||||
.processSectorsFromApi(responseData['secteurs']);
|
.processSectorsFromApi(responseData['sectors']);
|
||||||
debugPrint('✅ Secteurs traités');
|
debugPrint('✅ Secteurs traités');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,10 +521,10 @@ class OperationRepository extends ChangeNotifier {
|
|||||||
debugPrint('✅ Opérations traitées');
|
debugPrint('✅ Opérations traitées');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Traiter les secteurs (groupe secteurs) via DataLoadingService
|
// Traiter les secteurs (groupe sectors) via DataLoadingService
|
||||||
if (responseData['secteurs'] != null) {
|
if (responseData['sectors'] != null) {
|
||||||
await DataLoadingService.instance
|
await DataLoadingService.instance
|
||||||
.processSectorsFromApi(responseData['secteurs']);
|
.processSectorsFromApi(responseData['sectors']);
|
||||||
debugPrint('✅ Secteurs traités');
|
debugPrint('✅ Secteurs traités');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,8 +246,9 @@ class PassageRepository extends ChangeNotifier {
|
|||||||
|
|
||||||
// Mode online : traitement normal
|
// Mode online : traitement normal
|
||||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||||
// Récupérer l'ID du nouveau passage depuis la réponse
|
// Récupérer l'ID du nouveau passage depuis la réponse (passage_id ou id)
|
||||||
final passageId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
|
final rawId = response.data['passage_id'] ?? response.data['id'];
|
||||||
|
final passageId = rawId is String ? int.parse(rawId) : rawId as int;
|
||||||
|
|
||||||
// Créer le passage localement avec l'ID retourné par l'API
|
// Créer le passage localement avec l'ID retourné par l'API
|
||||||
final newPassage = passage.copyWith(
|
final newPassage = passage.copyWith(
|
||||||
@@ -431,10 +432,10 @@ class PassageRepository extends ChangeNotifier {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
throw Exception('Mise à jour refusée par le serveur');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Erreur lors de la mise à jour du passage: $e');
|
debugPrint('Erreur lors de la mise à jour du passage: $e');
|
||||||
return false;
|
rethrow; // Propager l'exception originale avec son message
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:cookie_jar/cookie_jar.dart';
|
||||||
|
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||||
@@ -65,17 +68,45 @@ class ApiService {
|
|||||||
headers['X-App-Identifier'] = _appIdentifier;
|
headers['X-App-Identifier'] = _appIdentifier;
|
||||||
_dio.options.headers.addAll(headers);
|
_dio.options.headers.addAll(headers);
|
||||||
|
|
||||||
|
// Gestionnaire de cookies pour les sessions PHP
|
||||||
|
// Utilise CookieJar en mémoire (cookies maintenus pendant la durée de vie de l'app)
|
||||||
|
final cookieJar = CookieJar();
|
||||||
|
_dio.interceptors.add(CookieManager(cookieJar));
|
||||||
|
debugPrint('🍪 [API] Gestionnaire de cookies activé');
|
||||||
|
|
||||||
_dio.interceptors.add(InterceptorsWrapper(
|
_dio.interceptors.add(InterceptorsWrapper(
|
||||||
onRequest: (options, handler) {
|
onRequest: (options, handler) {
|
||||||
|
debugPrint('🌐 [API] Requête: ${options.method} ${options.path}');
|
||||||
|
debugPrint('🔑 [API] _sessionId présent: ${_sessionId != null}');
|
||||||
|
debugPrint('🔑 [API] Headers: ${options.headers}');
|
||||||
if (_sessionId != null) {
|
if (_sessionId != null) {
|
||||||
|
debugPrint('🔑 [API] Token: Bearer ${_sessionId!.substring(0, 20)}...');
|
||||||
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
|
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
|
||||||
|
} else {
|
||||||
|
debugPrint('⚠️ [API] PAS DE TOKEN - Requête non authentifiée');
|
||||||
}
|
}
|
||||||
handler.next(options);
|
handler.next(options);
|
||||||
},
|
},
|
||||||
onError: (DioException error, handler) {
|
onError: (DioException error, handler) {
|
||||||
if (error.response?.statusCode == 401) {
|
if (error.response?.statusCode == 401) {
|
||||||
|
final path = error.requestOptions.path;
|
||||||
|
debugPrint('❌ [API] Erreur 401 sur: $path');
|
||||||
|
|
||||||
|
// Ne pas reset le token pour les requêtes non critiques
|
||||||
|
final nonCriticalPaths = [
|
||||||
|
'/users/device-info',
|
||||||
|
'/chat/rooms',
|
||||||
|
];
|
||||||
|
|
||||||
|
final isNonCritical = nonCriticalPaths.any((p) => path.contains(p));
|
||||||
|
|
||||||
|
if (isNonCritical) {
|
||||||
|
debugPrint('⚠️ [API] Requête non critique - Token conservé');
|
||||||
|
} else {
|
||||||
|
debugPrint('❌ [API] Requête critique - Token invalidé');
|
||||||
_sessionId = null;
|
_sessionId = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
handler.next(error);
|
handler.next(error);
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
@@ -1066,16 +1097,22 @@ class ApiService {
|
|||||||
if (data.containsKey('session_id')) {
|
if (data.containsKey('session_id')) {
|
||||||
final sessionId = data['session_id'];
|
final sessionId = data['session_id'];
|
||||||
if (sessionId != null) {
|
if (sessionId != null) {
|
||||||
|
debugPrint('🔐 [LOGIN] Token reçu du serveur: $sessionId');
|
||||||
|
debugPrint('🔐 [LOGIN] Longueur token: ${sessionId.toString().length}');
|
||||||
setSessionId(sessionId);
|
setSessionId(sessionId);
|
||||||
|
debugPrint('🔐 [LOGIN] Token stocké dans _sessionId');
|
||||||
|
|
||||||
// Collecter et envoyer les informations du device après login réussi
|
// Collecter et envoyer les informations du device après login réussi
|
||||||
debugPrint('📱 Collecte des informations device après login...');
|
// Délai de 1 seconde pour laisser la session PHP se stabiliser
|
||||||
|
debugPrint('📱 Collecte des informations device après login (délai 1s)...');
|
||||||
|
Future.delayed(const Duration(seconds: 1), () {
|
||||||
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
|
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
|
||||||
debugPrint('✅ Informations device collectées et envoyées');
|
debugPrint('✅ Informations device collectées et envoyées');
|
||||||
}).catchError((error) {
|
}).catchError((error) {
|
||||||
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
|
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
|
||||||
// Ne pas bloquer le login si l'envoi des infos device échoue
|
// Ne pas bloquer le login si l'envoi des infos device échoue
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
|
// ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
|
||||||
// This file is automatically generated by deploy-app.sh script
|
// This file is automatically generated by deploy-app.sh script
|
||||||
// Last update: 2025-11-09 12:39:26
|
// Last update: 2026-01-16 13:37:45
|
||||||
// Source: ../VERSION file
|
// Source: ../VERSION file
|
||||||
//
|
//
|
||||||
// GEOSECTOR App Version Service
|
// GEOSECTOR App Version Service
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
|
|
||||||
class AppInfoService {
|
class AppInfoService {
|
||||||
// Version number (format: x.x.x)
|
// Version number (format: x.x.x)
|
||||||
static const String version = '3.5.2';
|
static const String version = '3.6.2';
|
||||||
|
|
||||||
// Build number (version without dots: xxx)
|
// Build number (version without dots: xxx)
|
||||||
static const String buildNumber = '352';
|
static const String buildNumber = '362';
|
||||||
|
|
||||||
// Full version string (format: vx.x.x+xxx)
|
// Full version string (format: vx.x.x+xxx)
|
||||||
static String get fullVersion => 'v$version+$buildNumber';
|
static String get fullVersion => 'v$version+$buildNumber';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:battery_plus/battery_plus.dart';
|
import 'package:battery_plus/battery_plus.dart';
|
||||||
@@ -211,18 +212,18 @@ class DeviceInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _checkIosTapToPaySupport(String machine, String systemVersion) {
|
bool _checkIosTapToPaySupport(String machine, String systemVersion) {
|
||||||
// iPhone XS et plus récents (liste des identifiants)
|
// Vérifier le modèle : iPhone11,x ou supérieur (iPhone XS+)
|
||||||
final supportedDevices = [
|
// Extraire le numéro majeur de l'identifiant (ex: "iPhone17,3" -> 17)
|
||||||
'iPhone11,', // XS, XS Max
|
bool deviceSupported = false;
|
||||||
'iPhone12,', // 11, 11 Pro, 11 Pro Max
|
|
||||||
'iPhone13,', // 12 series
|
|
||||||
'iPhone14,', // 13 series
|
|
||||||
'iPhone15,', // 14 series
|
|
||||||
'iPhone16,', // 15 series
|
|
||||||
];
|
|
||||||
|
|
||||||
// Vérifier le modèle
|
if (machine.startsWith('iPhone')) {
|
||||||
bool deviceSupported = supportedDevices.any((prefix) => machine.startsWith(prefix));
|
final match = RegExp(r'iPhone(\d+),').firstMatch(machine);
|
||||||
|
if (match != null) {
|
||||||
|
final majorVersion = int.tryParse(match.group(1) ?? '0') ?? 0;
|
||||||
|
// iPhone XS = iPhone11,x donc >= 11 pour supporter Tap to Pay
|
||||||
|
deviceSupported = majorVersion >= 11;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Vérifier la version iOS (16.4+ selon la documentation officielle Stripe)
|
// Vérifier la version iOS (16.4+ selon la documentation officielle Stripe)
|
||||||
final versionParts = systemVersion.split('.');
|
final versionParts = systemVersion.split('.');
|
||||||
@@ -334,10 +335,10 @@ class DeviceInfoService {
|
|||||||
return deviceInfo;
|
return deviceInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifie la certification Stripe Tap to Pay via l'API
|
/// Vérifie la certification Stripe Tap to Pay via le SDK Stripe Terminal
|
||||||
Future<bool> checkStripeCertification() async {
|
Future<bool> checkStripeCertification() async {
|
||||||
try {
|
try {
|
||||||
// Sur Web, toujours non certifié
|
// Sur Web, toujours non supporté
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
debugPrint('📱 Web platform - Tap to Pay non supporté');
|
debugPrint('📱 Web platform - Tap to Pay non supporté');
|
||||||
return false;
|
return false;
|
||||||
@@ -354,33 +355,35 @@ class DeviceInfoService {
|
|||||||
return isSupported;
|
return isSupported;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android : vérification via l'API Stripe
|
// Android : vérification des pré-requis hardware de base
|
||||||
|
// Note: Le vrai check de compatibilité avec découverte de readers se fera
|
||||||
|
// dans StripeTapToPayService lors du premier paiement
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final androidInfo = await _deviceInfo.androidInfo;
|
final androidInfo = await _deviceInfo.androidInfo;
|
||||||
|
debugPrint('📱 Vérification Tap to Pay pour ${androidInfo.manufacturer} ${androidInfo.model}');
|
||||||
|
|
||||||
debugPrint('📱 Vérification certification Stripe pour ${androidInfo.manufacturer} ${androidInfo.model}');
|
// Vérifications préalables de base
|
||||||
|
if (androidInfo.version.sdkInt < 28) {
|
||||||
try {
|
debugPrint('❌ Android SDK trop ancien: ${androidInfo.version.sdkInt} (minimum 28)');
|
||||||
final response = await ApiService.instance.post(
|
return false;
|
||||||
'/stripe/devices/check-tap-to-pay',
|
|
||||||
data: {
|
|
||||||
'platform': 'android',
|
|
||||||
'manufacturer': androidInfo.manufacturer,
|
|
||||||
'model': androidInfo.model,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final tapToPaySupported = response.data['tap_to_pay_supported'] == true;
|
|
||||||
final message = response.data['message'] ?? '';
|
|
||||||
|
|
||||||
debugPrint('📱 Résultat certification Stripe: $tapToPaySupported - $message');
|
|
||||||
return tapToPaySupported;
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ Erreur lors de la vérification Stripe: $e');
|
|
||||||
// En cas d'erreur API, on se base sur la vérification locale
|
|
||||||
return androidInfo.version.sdkInt >= 28;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vérifier la disponibilité du NFC
|
||||||
|
try {
|
||||||
|
final nfcAvailable = await NfcManager.instance.isAvailable();
|
||||||
|
if (!nfcAvailable) {
|
||||||
|
debugPrint('❌ NFC non disponible sur cet appareil');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
debugPrint('✅ NFC disponible');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ Impossible de vérifier NFC: $e');
|
||||||
|
// On continue quand même, ce n'est pas bloquant à ce stade
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pré-requis de base OK
|
||||||
|
debugPrint('✅ Pré-requis Android OK (SDK ${androidInfo.version.sdkInt}, NFC disponible)');
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -390,22 +393,89 @@ class DeviceInfoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Vérifie si le device peut utiliser Tap to Pay
|
/// Vérifie si le device peut utiliser Tap to Pay
|
||||||
bool canUseTapToPay() {
|
bool canUseTapToPay() {
|
||||||
final deviceInfo = getStoredDeviceInfo();
|
final deviceInfo = getStoredDeviceInfo();
|
||||||
|
|
||||||
// Vérifications requises
|
// checkStripeCertification() fait déjà toutes les vérifications (modèle, iOS version, NFC Android)
|
||||||
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
|
|
||||||
// Utiliser la certification Stripe si disponible, sinon l'ancienne vérification
|
|
||||||
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||||
|
|
||||||
// Batterie minimum 10% pour les paiements
|
// Batterie minimum 10% pour les paiements
|
||||||
final sufficientBattery = batteryLevel != null && batteryLevel >= 10;
|
final sufficientBattery = batteryLevel != null && batteryLevel >= 10;
|
||||||
|
|
||||||
return nfcCapable && stripeCertified == true && sufficientBattery;
|
return stripeCertified == true && sufficientBattery;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stream pour surveiller les changements de batterie
|
/// Retourne la raison pour laquelle Tap to Pay n'est pas disponible
|
||||||
Stream<BatteryState> get batteryStateStream => _battery.onBatteryStateChanged;
|
/// Retourne null si Tap to Pay est disponible
|
||||||
|
String? getTapToPayUnavailableReason() {
|
||||||
|
// Sur Web, Tap to Pay n'est jamais disponible
|
||||||
|
if (kIsWeb) {
|
||||||
|
return 'Tap to Pay non disponible sur Web';
|
||||||
|
}
|
||||||
|
|
||||||
|
final deviceInfo = getStoredDeviceInfo();
|
||||||
|
|
||||||
|
// Vérifier la batterie
|
||||||
|
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||||
|
if (batteryLevel == null) {
|
||||||
|
return 'Niveau de batterie inconnu';
|
||||||
|
}
|
||||||
|
if (batteryLevel < 10) {
|
||||||
|
return 'Batterie insuffisante ($batteryLevel%) - Min. 10% requis';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la certification Stripe (inclut déjà modèle, iOS version, NFC Android)
|
||||||
|
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||||
|
if (stripeCertified != true) {
|
||||||
|
return 'Appareil non certifié pour Tap to Pay';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tout est OK
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version asynchrone avec vérification NFC en temps réel (Android uniquement)
|
||||||
|
Future<String?> getTapToPayUnavailableReasonAsync() async {
|
||||||
|
// Sur Web, Tap to Pay n'est jamais disponible
|
||||||
|
if (kIsWeb) {
|
||||||
|
return 'Tap to Pay non disponible sur Web';
|
||||||
|
}
|
||||||
|
|
||||||
|
final deviceInfo = getStoredDeviceInfo();
|
||||||
|
final platform = deviceInfo['platform'];
|
||||||
|
|
||||||
|
// Vérifier la batterie
|
||||||
|
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||||
|
if (batteryLevel == null) {
|
||||||
|
return 'Niveau de batterie inconnu';
|
||||||
|
}
|
||||||
|
if (batteryLevel < 10) {
|
||||||
|
return 'Batterie insuffisante ($batteryLevel%) - Min. 10% requis';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sur Android, vérifier le NFC EN TEMPS RÉEL (peut être désactivé dans les paramètres)
|
||||||
|
if (platform == 'Android') {
|
||||||
|
try {
|
||||||
|
final nfcAvailable = await NfcManager.instance.isAvailable();
|
||||||
|
if (!nfcAvailable) {
|
||||||
|
return 'NFC désactivé - Activez-le dans les paramètres Android';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ Impossible de vérifier le statut NFC: $e');
|
||||||
|
return 'Impossible de vérifier le NFC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la certification Stripe (inclut déjà modèle, iOS version)
|
||||||
|
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||||
|
if (stripeCertified != true) {
|
||||||
|
return 'Appareil non certifié pour Tap to Pay';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tout est OK
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
312
app/lib/core/services/event_stats_service.dart
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:geosector_app/core/services/api_service.dart';
|
||||||
|
import 'package:geosector_app/core/data/models/event_stats_model.dart';
|
||||||
|
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// Service pour récupérer les statistiques d'événements depuis l'API.
|
||||||
|
///
|
||||||
|
/// Ce service est un singleton qui gère les appels API vers les endpoints
|
||||||
|
/// /api/events/stats/*. Il est accessible uniquement aux admins (rôle >= 2).
|
||||||
|
class EventStatsService {
|
||||||
|
static EventStatsService? _instance;
|
||||||
|
|
||||||
|
EventStatsService._internal();
|
||||||
|
|
||||||
|
static EventStatsService get instance {
|
||||||
|
_instance ??= EventStatsService._internal();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _dateFormat = DateFormat('yyyy-MM-dd');
|
||||||
|
|
||||||
|
/// Récupère le résumé des stats pour une date donnée.
|
||||||
|
///
|
||||||
|
/// GET /api/events/stats/summary?date=YYYY-MM-DD&entity_id=X
|
||||||
|
///
|
||||||
|
/// [date] : Date à récupérer (défaut: aujourd'hui)
|
||||||
|
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||||
|
Future<EventSummary> getSummary({
|
||||||
|
DateTime? date,
|
||||||
|
int? entityId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = <String, dynamic>{};
|
||||||
|
|
||||||
|
if (date != null) {
|
||||||
|
queryParams['date'] = _dateFormat.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityId != null) {
|
||||||
|
queryParams['entity_id'] = entityId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('📊 [EventStats] Récupération résumé: $queryParams');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.get(
|
||||||
|
'/events/stats/summary',
|
||||||
|
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data['status'] == 'success') {
|
||||||
|
return EventSummary.fromJson(response.data['data']);
|
||||||
|
} else {
|
||||||
|
throw ApiException(
|
||||||
|
response.data['message'] ?? 'Erreur lors de la récupération du résumé',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [EventStats] Erreur getSummary: $e');
|
||||||
|
if (e is ApiException) rethrow;
|
||||||
|
throw ApiException('Erreur lors de la récupération des statistiques', originalError: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les stats quotidiennes pour une période.
|
||||||
|
///
|
||||||
|
/// GET /api/events/stats/daily?from=YYYY-MM-DD&to=YYYY-MM-DD&events=type1,type2
|
||||||
|
///
|
||||||
|
/// [from] : Date de début (obligatoire)
|
||||||
|
/// [to] : Date de fin (obligatoire)
|
||||||
|
/// [events] : Types d'événements à filtrer (optionnel)
|
||||||
|
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||||
|
///
|
||||||
|
/// Limite : 90 jours maximum
|
||||||
|
Future<DailyStats> getDailyStats({
|
||||||
|
required DateTime from,
|
||||||
|
required DateTime to,
|
||||||
|
List<String>? events,
|
||||||
|
int? entityId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Vérifier la limite de 90 jours
|
||||||
|
final daysDiff = to.difference(from).inDays;
|
||||||
|
if (daysDiff > 90) {
|
||||||
|
throw const ApiException('La période ne peut pas dépasser 90 jours');
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'from': _dateFormat.format(from),
|
||||||
|
'to': _dateFormat.format(to),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events != null && events.isNotEmpty) {
|
||||||
|
queryParams['events'] = events.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityId != null) {
|
||||||
|
queryParams['entity_id'] = entityId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('📊 [EventStats] Récupération stats quotidiennes: $queryParams');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.get(
|
||||||
|
'/events/stats/daily',
|
||||||
|
queryParameters: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data['status'] == 'success') {
|
||||||
|
return DailyStats.fromJson(response.data['data']);
|
||||||
|
} else {
|
||||||
|
throw ApiException(
|
||||||
|
response.data['message'] ?? 'Erreur lors de la récupération des stats quotidiennes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [EventStats] Erreur getDailyStats: $e');
|
||||||
|
if (e is ApiException) rethrow;
|
||||||
|
throw ApiException('Erreur lors de la récupération des statistiques quotidiennes', originalError: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les stats hebdomadaires pour une période.
|
||||||
|
///
|
||||||
|
/// GET /api/events/stats/weekly?from=YYYY-MM-DD&to=YYYY-MM-DD&events=type1,type2
|
||||||
|
///
|
||||||
|
/// [from] : Date de début (obligatoire)
|
||||||
|
/// [to] : Date de fin (obligatoire)
|
||||||
|
/// [events] : Types d'événements à filtrer (optionnel)
|
||||||
|
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||||
|
///
|
||||||
|
/// Limite : 365 jours maximum
|
||||||
|
Future<WeeklyStats> getWeeklyStats({
|
||||||
|
required DateTime from,
|
||||||
|
required DateTime to,
|
||||||
|
List<String>? events,
|
||||||
|
int? entityId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Vérifier la limite de 365 jours
|
||||||
|
final daysDiff = to.difference(from).inDays;
|
||||||
|
if (daysDiff > 365) {
|
||||||
|
throw const ApiException('La période ne peut pas dépasser 365 jours');
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'from': _dateFormat.format(from),
|
||||||
|
'to': _dateFormat.format(to),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (events != null && events.isNotEmpty) {
|
||||||
|
queryParams['events'] = events.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityId != null) {
|
||||||
|
queryParams['entity_id'] = entityId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('📊 [EventStats] Récupération stats hebdomadaires: $queryParams');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.get(
|
||||||
|
'/events/stats/weekly',
|
||||||
|
queryParameters: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data['status'] == 'success') {
|
||||||
|
return WeeklyStats.fromJson(response.data['data']);
|
||||||
|
} else {
|
||||||
|
throw ApiException(
|
||||||
|
response.data['message'] ?? 'Erreur lors de la récupération des stats hebdomadaires',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [EventStats] Erreur getWeeklyStats: $e');
|
||||||
|
if (e is ApiException) rethrow;
|
||||||
|
throw ApiException('Erreur lors de la récupération des statistiques hebdomadaires', originalError: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les stats mensuelles pour une année.
|
||||||
|
///
|
||||||
|
/// GET /api/events/stats/monthly?year=YYYY&events=type1,type2
|
||||||
|
///
|
||||||
|
/// [year] : Année (défaut: année courante)
|
||||||
|
/// [events] : Types d'événements à filtrer (optionnel)
|
||||||
|
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||||
|
Future<MonthlyStats> getMonthlyStats({
|
||||||
|
int? year,
|
||||||
|
List<String>? events,
|
||||||
|
int? entityId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = <String, dynamic>{};
|
||||||
|
|
||||||
|
if (year != null) {
|
||||||
|
queryParams['year'] = year.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events != null && events.isNotEmpty) {
|
||||||
|
queryParams['events'] = events.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityId != null) {
|
||||||
|
queryParams['entity_id'] = entityId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('📊 [EventStats] Récupération stats mensuelles: $queryParams');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.get(
|
||||||
|
'/events/stats/monthly',
|
||||||
|
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data['status'] == 'success') {
|
||||||
|
return MonthlyStats.fromJson(response.data['data']);
|
||||||
|
} else {
|
||||||
|
throw ApiException(
|
||||||
|
response.data['message'] ?? 'Erreur lors de la récupération des stats mensuelles',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [EventStats] Erreur getMonthlyStats: $e');
|
||||||
|
if (e is ApiException) rethrow;
|
||||||
|
throw ApiException('Erreur lors de la récupération des statistiques mensuelles', originalError: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les détails des événements pour une date.
|
||||||
|
///
|
||||||
|
/// GET /api/events/stats/details?date=YYYY-MM-DD&event=type&limit=50&offset=0
|
||||||
|
///
|
||||||
|
/// [date] : Date à récupérer (obligatoire)
|
||||||
|
/// [event] : Type d'événement à filtrer (optionnel)
|
||||||
|
/// [limit] : Nombre de résultats max (défaut: 50, max: 100)
|
||||||
|
/// [offset] : Pagination (défaut: 0)
|
||||||
|
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||||
|
Future<EventDetails> getDetails({
|
||||||
|
required DateTime date,
|
||||||
|
String? event,
|
||||||
|
int? limit,
|
||||||
|
int? offset,
|
||||||
|
int? entityId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'date': _dateFormat.format(date),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event != null && event.isNotEmpty) {
|
||||||
|
queryParams['event'] = event;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit != null) {
|
||||||
|
queryParams['limit'] = limit.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset != null) {
|
||||||
|
queryParams['offset'] = offset.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityId != null) {
|
||||||
|
queryParams['entity_id'] = entityId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('📊 [EventStats] Récupération détails: $queryParams');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.get(
|
||||||
|
'/events/stats/details',
|
||||||
|
queryParameters: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data['status'] == 'success') {
|
||||||
|
return EventDetails.fromJson(response.data['data']);
|
||||||
|
} else {
|
||||||
|
throw ApiException(
|
||||||
|
response.data['message'] ?? 'Erreur lors de la récupération des détails',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [EventStats] Erreur getDetails: $e');
|
||||||
|
if (e is ApiException) rethrow;
|
||||||
|
throw ApiException('Erreur lors de la récupération des détails', originalError: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les types d'événements disponibles.
|
||||||
|
///
|
||||||
|
/// GET /api/events/stats/types
|
||||||
|
Future<EventTypes> getEventTypes() async {
|
||||||
|
try {
|
||||||
|
debugPrint('📊 [EventStats] Récupération types d\'événements');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.get('/events/stats/types');
|
||||||
|
|
||||||
|
if (response.data['status'] == 'success') {
|
||||||
|
return EventTypes.fromJson(response.data['data']);
|
||||||
|
} else {
|
||||||
|
throw ApiException(
|
||||||
|
response.data['message'] ?? 'Erreur lors de la récupération des types',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [EventStats] Erreur getEventTypes: $e');
|
||||||
|
if (e is ApiException) rethrow;
|
||||||
|
throw ApiException('Erreur lors de la récupération des types d\'événements', originalError: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réinitialise le singleton (pour les tests)
|
||||||
|
static void reset() {
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:mek_stripe_terminal/mek_stripe_terminal.dart';
|
||||||
|
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
import 'device_info_service.dart';
|
import 'device_info_service.dart';
|
||||||
@@ -13,6 +14,7 @@ class StripeTapToPayService {
|
|||||||
StripeTapToPayService._internal();
|
StripeTapToPayService._internal();
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
static bool _terminalInitialized = false; // Flag STATIC pour savoir si Terminal.initTerminal a été appelé (global à l'app)
|
||||||
String? _stripeAccountId;
|
String? _stripeAccountId;
|
||||||
String? _locationId;
|
String? _locationId;
|
||||||
bool _deviceCompatible = false;
|
bool _deviceCompatible = false;
|
||||||
@@ -78,6 +80,36 @@ class StripeTapToPayService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Initialiser le SDK Stripe Terminal (une seule fois par session app)
|
||||||
|
if (!_terminalInitialized) {
|
||||||
|
try {
|
||||||
|
debugPrint('🔧 Initialisation du SDK Stripe Terminal...');
|
||||||
|
await Terminal.initTerminal(
|
||||||
|
fetchToken: _fetchConnectionToken,
|
||||||
|
);
|
||||||
|
_terminalInitialized = true;
|
||||||
|
debugPrint('✅ SDK Stripe Terminal initialisé');
|
||||||
|
} catch (e) {
|
||||||
|
final errorMsg = e.toString().toLowerCase();
|
||||||
|
debugPrint('🔍 Exception capturée lors de l\'initialisation: $e');
|
||||||
|
debugPrint('🔍 Type d\'exception: ${e.runtimeType}');
|
||||||
|
|
||||||
|
// Vérifier plusieurs variantes du message "already initialized"
|
||||||
|
if (errorMsg.contains('already initialized') ||
|
||||||
|
errorMsg.contains('already been initialized') ||
|
||||||
|
errorMsg.contains('sdkfailure')) {
|
||||||
|
debugPrint('ℹ️ SDK Stripe Terminal déjà initialisé (détecté via exception)');
|
||||||
|
_terminalInitialized = true;
|
||||||
|
// Ne PAS rethrow - continuer normalement car c'est un état valide
|
||||||
|
} else {
|
||||||
|
debugPrint('❌ Erreur inattendue lors de l\'initialisation du SDK');
|
||||||
|
rethrow; // Autre erreur, on la propage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint('ℹ️ SDK Stripe Terminal déjà initialisé, réutilisation');
|
||||||
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
debugPrint('✅ Tap to Pay initialisé avec succès');
|
debugPrint('✅ Tap to Pay initialisé avec succès');
|
||||||
|
|
||||||
@@ -101,6 +133,34 @@ class StripeTapToPayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Callback pour récupérer un token de connexion depuis l'API
|
||||||
|
Future<String> _fetchConnectionToken() async {
|
||||||
|
try {
|
||||||
|
debugPrint('🔑 Récupération du token de connexion Stripe...');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.post(
|
||||||
|
'/stripe/terminal/connection-token',
|
||||||
|
data: {
|
||||||
|
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||||
|
'stripe_account': _stripeAccountId,
|
||||||
|
'location_id': _locationId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
final token = response.data['secret'];
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw Exception('Token de connexion invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('✅ Token de connexion récupéré');
|
||||||
|
return token;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ Erreur récupération token: $e');
|
||||||
|
throw Exception('Impossible de récupérer le token de connexion');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Crée un PaymentIntent pour un paiement Tap to Pay
|
/// Crée un PaymentIntent pour un paiement Tap to Pay
|
||||||
Future<PaymentIntentResult?> createPaymentIntent({
|
Future<PaymentIntentResult?> createPaymentIntent({
|
||||||
required int amountInCents,
|
required int amountInCents,
|
||||||
@@ -124,9 +184,7 @@ class StripeTapToPayService {
|
|||||||
// Extraire passage_id des metadata si présent
|
// Extraire passage_id des metadata si présent
|
||||||
final passageId = metadata?['passage_id'] ?? '0';
|
final passageId = metadata?['passage_id'] ?? '0';
|
||||||
|
|
||||||
final response = await ApiService.instance.post(
|
final requestData = {
|
||||||
'/stripe/payments/create-intent',
|
|
||||||
data: {
|
|
||||||
'amount': amountInCents,
|
'amount': amountInCents,
|
||||||
'currency': 'eur',
|
'currency': 'eur',
|
||||||
'description': description ?? 'Calendrier pompiers',
|
'description': description ?? 'Calendrier pompiers',
|
||||||
@@ -138,7 +196,13 @@ class StripeTapToPayService {
|
|||||||
'stripe_account': _stripeAccountId,
|
'stripe_account': _stripeAccountId,
|
||||||
'location_id': _locationId,
|
'location_id': _locationId,
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
debugPrint('🔵 Données envoyées create-intent: $requestData');
|
||||||
|
|
||||||
|
final response = await ApiService.instance.post(
|
||||||
|
'/stripe/payments/create-intent',
|
||||||
|
data: requestData,
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = PaymentIntentResult(
|
final result = PaymentIntentResult(
|
||||||
@@ -169,11 +233,110 @@ class StripeTapToPayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simule le processus de collecte de paiement
|
/// Découvre et connecte le reader Tap to Pay local
|
||||||
/// (Dans la version finale, cela appellera le SDK natif)
|
Future<bool> _ensureReaderConnected() async {
|
||||||
|
try {
|
||||||
|
debugPrint('🔍 Découverte du reader Tap to Pay...');
|
||||||
|
|
||||||
|
// Configuration pour découvrir le reader local (Tap to Pay)
|
||||||
|
// Détection de l'environnement via l'URL de l'API (plus fiable que kDebugMode)
|
||||||
|
final apiUrl = ApiService.instance.baseUrl;
|
||||||
|
final isProduction = apiUrl.contains('app3.geosector.fr');
|
||||||
|
final isSimulated = !isProduction; // Simulé uniquement si pas en PROD
|
||||||
|
|
||||||
|
final config = TapToPayDiscoveryConfiguration(
|
||||||
|
isSimulated: isSimulated,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('🔧 Environnement: ${isProduction ? "PRODUCTION (réel)" : "DEV/REC (simulé)"}');
|
||||||
|
debugPrint('🔧 isSimulated: $isSimulated');
|
||||||
|
|
||||||
|
// Découvrir les readers avec un Completer pour gérer le stream correctement
|
||||||
|
final completer = Completer<Reader?>();
|
||||||
|
StreamSubscription<List<Reader>>? subscription;
|
||||||
|
|
||||||
|
subscription = Terminal.instance.discoverReaders(config).listen(
|
||||||
|
(readers) {
|
||||||
|
debugPrint('📡 Stream readers reçu: ${readers.length} reader(s)');
|
||||||
|
if (readers.isNotEmpty && !completer.isCompleted) {
|
||||||
|
debugPrint('📱 ${readers.length} reader(s) trouvé(s): ${readers.map((r) => r.label).join(", ")}');
|
||||||
|
completer.complete(readers.first);
|
||||||
|
subscription?.cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
debugPrint('❌ Erreur lors de la découverte: $error');
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(null);
|
||||||
|
}
|
||||||
|
subscription?.cancel();
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
debugPrint('🏁 Stream découverte terminé');
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
debugPrint('⚠️ Découverte terminée sans reader trouvé');
|
||||||
|
completer.complete(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('⏳ Attente du résultat de la découverte...');
|
||||||
|
|
||||||
|
// Attendre le résultat avec timeout
|
||||||
|
final reader = await completer.future.timeout(
|
||||||
|
const Duration(seconds: 15),
|
||||||
|
onTimeout: () {
|
||||||
|
debugPrint('⏱️ Timeout lors de la découverte du reader');
|
||||||
|
subscription?.cancel();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reader == null) {
|
||||||
|
debugPrint('❌ Aucun reader Tap to Pay trouvé');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('📱 Reader trouvé: ${reader.label}');
|
||||||
|
|
||||||
|
// Se connecter au reader
|
||||||
|
debugPrint('🔌 Connexion au reader...');
|
||||||
|
final connectionConfig = TapToPayConnectionConfiguration(
|
||||||
|
locationId: _locationId ?? '',
|
||||||
|
readerDelegate: null, // Pas de delegate pour l'instant
|
||||||
|
);
|
||||||
|
|
||||||
|
await Terminal.instance.connectReader(
|
||||||
|
reader,
|
||||||
|
configuration: connectionConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('✅ Connecté au reader Tap to Pay');
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ Erreur connexion reader: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collecte le paiement avec le SDK Stripe Terminal
|
||||||
Future<bool> collectPayment(PaymentIntentResult paymentIntent) async {
|
Future<bool> collectPayment(PaymentIntentResult paymentIntent) async {
|
||||||
try {
|
try {
|
||||||
debugPrint('💳 Collecte du paiement...');
|
debugPrint('💳 Collecte du paiement avec SDK...');
|
||||||
|
|
||||||
|
_paymentStatusController.add(TapToPayStatus(
|
||||||
|
type: TapToPayStatusType.processing,
|
||||||
|
message: 'Préparation du terminal...',
|
||||||
|
paymentIntentId: paymentIntent.paymentIntentId,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 1. S'assurer qu'un reader est connecté
|
||||||
|
debugPrint('🔌 Vérification connexion reader...');
|
||||||
|
final readerConnected = await _ensureReaderConnected();
|
||||||
|
if (!readerConnected) {
|
||||||
|
throw Exception('Impossible de se connecter au reader Tap to Pay');
|
||||||
|
}
|
||||||
|
|
||||||
_paymentStatusController.add(TapToPayStatus(
|
_paymentStatusController.add(TapToPayStatus(
|
||||||
type: TapToPayStatusType.processing,
|
type: TapToPayStatusType.processing,
|
||||||
@@ -181,11 +344,22 @@ class StripeTapToPayService {
|
|||||||
paymentIntentId: paymentIntent.paymentIntentId,
|
paymentIntentId: paymentIntent.paymentIntentId,
|
||||||
));
|
));
|
||||||
|
|
||||||
// TODO: Ici, intégrer le vrai SDK Stripe Terminal
|
// 2. Récupérer le PaymentIntent depuis le SDK avec le clientSecret
|
||||||
// Pour l'instant, on simule une attente
|
debugPrint('💳 Récupération du PaymentIntent...');
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
final stripePaymentIntent = await Terminal.instance.retrievePaymentIntent(
|
||||||
|
paymentIntent.clientSecret,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint('✅ Paiement collecté');
|
// 3. Utiliser le SDK Stripe Terminal pour collecter le paiement
|
||||||
|
debugPrint('💳 En attente du paiement sans contact...');
|
||||||
|
final collectedPaymentIntent = await Terminal.instance.collectPaymentMethod(
|
||||||
|
stripePaymentIntent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sauvegarder le PaymentIntent collecté pour l'étape de confirmation
|
||||||
|
paymentIntent._collectedPaymentIntent = collectedPaymentIntent;
|
||||||
|
|
||||||
|
debugPrint('✅ Paiement collecté via SDK');
|
||||||
|
|
||||||
_paymentStatusController.add(TapToPayStatus(
|
_paymentStatusController.add(TapToPayStatus(
|
||||||
type: TapToPayStatusType.confirming,
|
type: TapToPayStatusType.confirming,
|
||||||
@@ -208,24 +382,25 @@ class StripeTapToPayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirme le paiement auprès du serveur
|
/// Confirme le paiement via le SDK Stripe Terminal
|
||||||
Future<bool> confirmPayment(PaymentIntentResult paymentIntent) async {
|
Future<bool> confirmPayment(PaymentIntentResult paymentIntent) async {
|
||||||
try {
|
try {
|
||||||
debugPrint('✅ Confirmation du paiement...');
|
debugPrint('✅ Confirmation du paiement via SDK...');
|
||||||
|
|
||||||
// Notifier le serveur du succès
|
// Vérifier que le paiement a été collecté
|
||||||
await ApiService.instance.post(
|
if (paymentIntent._collectedPaymentIntent == null) {
|
||||||
'/stripe/payments/confirm',
|
throw Exception('Le paiement doit d\'abord être collecté');
|
||||||
data: {
|
}
|
||||||
'payment_intent_id': paymentIntent.paymentIntentId,
|
|
||||||
'amount': paymentIntent.amount,
|
// Utiliser le SDK Stripe Terminal pour confirmer le paiement
|
||||||
'status': 'succeeded',
|
final confirmedPaymentIntent = await Terminal.instance.confirmPaymentIntent(
|
||||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
paymentIntent._collectedPaymentIntent!,
|
||||||
'member_id': CurrentUserService.instance.userId,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('🎉 Paiement confirmé avec succès');
|
// Vérifier le statut final
|
||||||
|
if (confirmedPaymentIntent.status == PaymentIntentStatus.succeeded) {
|
||||||
|
debugPrint('🎉 Paiement confirmé avec succès via SDK');
|
||||||
|
debugPrint(' Payment Intent: ${paymentIntent.paymentIntentId}');
|
||||||
|
|
||||||
_paymentStatusController.add(TapToPayStatus(
|
_paymentStatusController.add(TapToPayStatus(
|
||||||
type: TapToPayStatusType.success,
|
type: TapToPayStatusType.success,
|
||||||
@@ -235,6 +410,9 @@ class StripeTapToPayService {
|
|||||||
));
|
));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
} else {
|
||||||
|
throw Exception('Paiement non confirmé: ${confirmedPaymentIntent.status}');
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ Erreur confirmation paiement: $e');
|
debugPrint('❌ Erreur confirmation paiement: $e');
|
||||||
@@ -304,6 +482,9 @@ class PaymentIntentResult {
|
|||||||
final String clientSecret;
|
final String clientSecret;
|
||||||
final int amount;
|
final int amount;
|
||||||
|
|
||||||
|
// PaymentIntent collecté (utilisé entre collectPayment et confirmPayment)
|
||||||
|
PaymentIntent? _collectedPaymentIntent;
|
||||||
|
|
||||||
PaymentIntentResult({
|
PaymentIntentResult({
|
||||||
required this.paymentIntentId,
|
required this.paymentIntentId,
|
||||||
required this.clientSecret,
|
required this.clientSecret,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ApiException implements Exception {
|
|||||||
if (response?.data != null) {
|
if (response?.data != null) {
|
||||||
try {
|
try {
|
||||||
final data = response!.data as Map<String, dynamic>;
|
final data = response!.data as Map<String, dynamic>;
|
||||||
|
debugPrint('🔍 API Error Response: $data');
|
||||||
|
|
||||||
// Message spécifique de l'API
|
// Message spécifique de l'API
|
||||||
if (data.containsKey('message')) {
|
if (data.containsKey('message')) {
|
||||||
@@ -42,12 +43,21 @@ class ApiException implements Exception {
|
|||||||
errorCode = data['error_code'] as String;
|
errorCode = data['error_code'] as String;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Détails supplémentaires
|
// Détails supplémentaires - peut être une Map ou une List
|
||||||
if (data.containsKey('errors')) {
|
if (data.containsKey('errors')) {
|
||||||
details = data['errors'] as Map<String, dynamic>?;
|
final errorsData = data['errors'];
|
||||||
|
if (errorsData is Map<String, dynamic>) {
|
||||||
|
// Format: {field: [errors]}
|
||||||
|
details = errorsData;
|
||||||
|
} else if (errorsData is List) {
|
||||||
|
// Format: [error1, error2, ...]
|
||||||
|
details = {'errors': errorsData};
|
||||||
|
}
|
||||||
|
debugPrint('🔍 Validation Errors: $details');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Si on ne peut pas parser la réponse, utiliser le message par défaut
|
// Si on ne peut pas parser la réponse, utiliser le message par défaut
|
||||||
|
debugPrint('⚠️ Impossible de parser la réponse d\'erreur: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +140,43 @@ class ApiException implements Exception {
|
|||||||
String toString() => message;
|
String toString() => message;
|
||||||
|
|
||||||
/// Obtenir un message d'erreur formaté pour l'affichage
|
/// Obtenir un message d'erreur formaté pour l'affichage
|
||||||
String get displayMessage => message;
|
String get displayMessage {
|
||||||
|
debugPrint('🔍 [displayMessage] statusCode: $statusCode');
|
||||||
|
debugPrint('🔍 [displayMessage] isValidationError: $isValidationError');
|
||||||
|
debugPrint('🔍 [displayMessage] details: $details');
|
||||||
|
debugPrint('🔍 [displayMessage] details != null: ${details != null}');
|
||||||
|
debugPrint('🔍 [displayMessage] details!.isNotEmpty: ${details != null ? details!.isNotEmpty : "null"}');
|
||||||
|
|
||||||
|
// Si c'est une erreur de validation avec des détails, formater le message
|
||||||
|
if (isValidationError && details != null && details!.isNotEmpty) {
|
||||||
|
debugPrint('✅ [displayMessage] Formatage des erreurs de validation');
|
||||||
|
final buffer = StringBuffer(message);
|
||||||
|
buffer.write('\n');
|
||||||
|
|
||||||
|
details!.forEach((field, errors) {
|
||||||
|
if (errors is List) {
|
||||||
|
// Si le champ est 'errors', c'est une liste simple d'erreurs
|
||||||
|
if (field == 'errors') {
|
||||||
|
for (final error in errors) {
|
||||||
|
buffer.write('• $error\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sinon c'est un champ avec une liste d'erreurs
|
||||||
|
for (final error in errors) {
|
||||||
|
buffer.write('• $field: $error\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buffer.write('• $field: $errors\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return buffer.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('⚠️ [displayMessage] Retour du message simple');
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
/// Vérifier si c'est une erreur de validation
|
/// Vérifier si c'est une erreur de validation
|
||||||
bool get isValidationError => statusCode == 422 || statusCode == 400;
|
bool get isValidationError => statusCode == 422 || statusCode == 400;
|
||||||
|
|||||||
1135
app/lib/presentation/admin/admin_connexions_page.dart
Normal file
@@ -807,7 +807,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 16),
|
height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Vous allez recevoir un email contenant :',
|
'Vous allez recevoir 2 emails contenant :',
|
||||||
style: theme
|
style: theme
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodyMedium,
|
.bodyMedium,
|
||||||
@@ -852,7 +852,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
|||||||
width: 4),
|
width: 4),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Un lien pour définir votre mot de passe'),
|
'Votre mot de passe de connexion'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
39
app/lib/presentation/pages/connexions_page.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
|
||||||
|
import 'package:geosector_app/presentation/admin/admin_connexions_page.dart';
|
||||||
|
import 'package:geosector_app/app.dart';
|
||||||
|
|
||||||
|
/// Page des connexions et événements utilisant AppScaffold.
|
||||||
|
/// Accessible uniquement aux administrateurs (rôle >= 2).
|
||||||
|
///
|
||||||
|
/// - Admin Amicale (rôle 2) : voit les connexions de son amicale uniquement
|
||||||
|
/// - Super Admin (rôle >= 3) : voit les connexions de toutes les amicales
|
||||||
|
class ConnexionsPage extends StatelessWidget {
|
||||||
|
const ConnexionsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Vérifier le rôle pour l'accès
|
||||||
|
final currentUser = userRepository.getCurrentUser();
|
||||||
|
final userRole = currentUser?.role ?? 1;
|
||||||
|
|
||||||
|
// Vérifier que l'utilisateur a le rôle 2 minimum (admin amicale)
|
||||||
|
if (userRole < 2) {
|
||||||
|
// Rediriger vers le dashboard user
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Navigator.of(context).pushReplacementNamed('/user/dashboard');
|
||||||
|
});
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppScaffold(
|
||||||
|
key: const ValueKey('connexions_scaffold_admin'),
|
||||||
|
selectedIndex: 6, // Connexions est l'index 6
|
||||||
|
pageTitle: 'Connexions',
|
||||||
|
body: AdminConnexionsPage(
|
||||||
|
userRepository: userRepository,
|
||||||
|
amicaleRepository: amicaleRepository,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -305,6 +305,11 @@ class NavigationHelper {
|
|||||||
selectedIcon: Icon(Icons.calendar_today),
|
selectedIcon: Icon(Icons.calendar_today),
|
||||||
label: 'Opérations',
|
label: 'Opérations',
|
||||||
),
|
),
|
||||||
|
const NavigationDestination(
|
||||||
|
icon: Icon(Icons.analytics_outlined),
|
||||||
|
selectedIcon: Icon(Icons.analytics),
|
||||||
|
label: 'Connexions',
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +346,9 @@ class NavigationHelper {
|
|||||||
case 5:
|
case 5:
|
||||||
context.go('/admin/operations');
|
context.go('/admin/operations');
|
||||||
break;
|
break;
|
||||||
|
case 6:
|
||||||
|
context.go('/admin/connexions');
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
context.go('/admin');
|
context.go('/admin');
|
||||||
}
|
}
|
||||||
@@ -380,6 +388,7 @@ class NavigationHelper {
|
|||||||
if (cleanRoute.contains('/admin/messages')) return 3;
|
if (cleanRoute.contains('/admin/messages')) return 3;
|
||||||
if (cleanRoute.contains('/admin/amicale')) return 4;
|
if (cleanRoute.contains('/admin/amicale')) return 4;
|
||||||
if (cleanRoute.contains('/admin/operations')) return 5;
|
if (cleanRoute.contains('/admin/operations')) return 5;
|
||||||
|
if (cleanRoute.contains('/admin/connexions')) return 6;
|
||||||
return 0; // Dashboard par défaut
|
return 0; // Dashboard par défaut
|
||||||
} else {
|
} else {
|
||||||
if (cleanRoute.contains('/user/history')) return 1;
|
if (cleanRoute.contains('/user/history')) return 1;
|
||||||
@@ -400,6 +409,7 @@ class NavigationHelper {
|
|||||||
case 3: return 'messages';
|
case 3: return 'messages';
|
||||||
case 4: return 'amicale';
|
case 4: return 'amicale';
|
||||||
case 5: return 'operations';
|
case 5: return 'operations';
|
||||||
|
case 6: return 'connexions';
|
||||||
default: return 'dashboard';
|
default: return 'dashboard';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -124,10 +124,13 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Corps avec le tableau
|
// Corps avec le tableau - écoute membres ET passages pour rafraîchissement auto
|
||||||
ValueListenableBuilder<Box<MembreModel>>(
|
ValueListenableBuilder<Box<MembreModel>>(
|
||||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||||
builder: (context, membresBox, child) {
|
builder: (context, membresBox, child) {
|
||||||
|
return ValueListenableBuilder<Box<PassageModel>>(
|
||||||
|
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||||
|
builder: (context, passagesBox, child) {
|
||||||
final membres = membresBox.values.toList();
|
final membres = membresBox.values.toList();
|
||||||
|
|
||||||
// Récupérer l'opération courante
|
// Récupérer l'opération courante
|
||||||
@@ -186,6 +189,8 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ class MembreRowWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Tournée (sectName) - masqué en mobile
|
||||||
|
if (!isMobile)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Text(
|
||||||
|
membre.sectName ?? '',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Email - masqué en mobile
|
// Email - masqué en mobile
|
||||||
if (!isMobile)
|
if (!isMobile)
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -113,6 +113,19 @@ class MembreTableWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Tournée (sectName) - masqué en mobile
|
||||||
|
if (!isMobile)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Text(
|
||||||
|
'Tournée',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Email - masqué en mobile
|
// Email - masqué en mobile
|
||||||
if (!isMobile)
|
if (!isMobile)
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||||
@@ -15,6 +15,7 @@ import 'package:geosector_app/core/services/stripe_connect_service.dart';
|
|||||||
import 'package:geosector_app/core/services/api_service.dart';
|
import 'package:geosector_app/core/services/api_service.dart';
|
||||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||||
|
import 'package:geosector_app/core/services/location_service.dart';
|
||||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||||
import 'package:geosector_app/presentation/widgets/form_section.dart';
|
import 'package:geosector_app/presentation/widgets/form_section.dart';
|
||||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||||
@@ -88,13 +89,17 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
|
|
||||||
// Helpers de validation
|
// Helpers de validation
|
||||||
String? _validateNumero(String? value) {
|
String? _validateNumero(String? value) {
|
||||||
|
debugPrint('🔍 [VALIDATOR] _validateNumero appelé avec: "$value"');
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
debugPrint('❌ [VALIDATOR] Numéro vide -> retourne erreur');
|
||||||
return 'Le numéro est obligatoire';
|
return 'Le numéro est obligatoire';
|
||||||
}
|
}
|
||||||
final numero = int.tryParse(value.trim());
|
final numero = int.tryParse(value.trim());
|
||||||
if (numero == null || numero <= 0) {
|
if (numero == null || numero <= 0) {
|
||||||
|
debugPrint('❌ [VALIDATOR] Numéro invalide: $value -> retourne erreur');
|
||||||
return 'Numéro invalide';
|
return 'Numéro invalide';
|
||||||
}
|
}
|
||||||
|
debugPrint('✅ [VALIDATOR] Numéro valide: $numero');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,48 +334,102 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleSubmit() async {
|
void _handleSubmit() async {
|
||||||
if (_isSubmitting) return;
|
debugPrint('🔵 [SUBMIT] Début _handleSubmit');
|
||||||
|
|
||||||
// ✅ Validation intégrée avec focus automatique sur erreur
|
if (_isSubmitting) {
|
||||||
if (!_formKey.currentState!.validate()) {
|
debugPrint('⚠️ [SUBMIT] Déjà en cours de soumission, abandon');
|
||||||
// Le focus est automatiquement mis sur le premier champ en erreur
|
|
||||||
// Les bordures rouges et messages d'erreur sont affichés automatiquement
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toujours sauvegarder le passage en premier
|
debugPrint('🔵 [SUBMIT] Vérification de l\'état du formulaire');
|
||||||
|
debugPrint('🔵 [SUBMIT] _formKey: $_formKey');
|
||||||
|
debugPrint('🔵 [SUBMIT] _formKey.currentState: ${_formKey.currentState}');
|
||||||
|
|
||||||
|
// Validation avec protection contre le null
|
||||||
|
if (_formKey.currentState == null) {
|
||||||
|
debugPrint('❌ [SUBMIT] ERREUR: _formKey.currentState est null !');
|
||||||
|
if (mounted) {
|
||||||
|
await ResultDialog.show(
|
||||||
|
context: context,
|
||||||
|
success: false,
|
||||||
|
message: "Erreur d'initialisation du formulaire",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('🔵 [SUBMIT] Validation du formulaire...');
|
||||||
|
final isValid = _formKey.currentState!.validate();
|
||||||
|
debugPrint('🔵 [SUBMIT] Résultat validation: $isValid');
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
debugPrint('⚠️ [SUBMIT] Validation échouée, abandon');
|
||||||
|
|
||||||
|
// Afficher un dialog d'erreur clair à l'utilisateur
|
||||||
|
if (mounted) {
|
||||||
|
await ResultDialog.show(
|
||||||
|
context: context,
|
||||||
|
success: false,
|
||||||
|
message: 'Veuillez vérifier tous les champs marqués comme obligatoires',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('✅ [SUBMIT] Validation OK, appel _savePassage()');
|
||||||
await _savePassage();
|
await _savePassage();
|
||||||
|
debugPrint('🔵 [SUBMIT] Fin _handleSubmit');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _savePassage() async {
|
Future<void> _savePassage() async {
|
||||||
if (_isSubmitting) return;
|
debugPrint('🟢 [SAVE] Début _savePassage');
|
||||||
|
|
||||||
|
if (_isSubmitting) {
|
||||||
|
debugPrint('⚠️ [SAVE] Déjà en cours de soumission, abandon');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('🟢 [SAVE] Mise à jour état _isSubmitting = true');
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSubmitting = true;
|
_isSubmitting = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Afficher l'overlay de chargement
|
// Afficher l'overlay de chargement
|
||||||
|
debugPrint('🟢 [SAVE] Affichage overlay de chargement');
|
||||||
final overlay = LoadingSpinOverlayUtils.show(
|
final overlay = LoadingSpinOverlayUtils.show(
|
||||||
context: context,
|
context: context,
|
||||||
message: 'Enregistrement en cours...',
|
message: 'Enregistrement en cours...',
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
debugPrint('🟢 [SAVE] Récupération utilisateur actuel');
|
||||||
final currentUser = widget.userRepository.getCurrentUser();
|
final currentUser = widget.userRepository.getCurrentUser();
|
||||||
|
debugPrint('🟢 [SAVE] currentUser: ${currentUser?.id} - ${currentUser?.name}');
|
||||||
|
|
||||||
if (currentUser == null) {
|
if (currentUser == null) {
|
||||||
|
debugPrint('❌ [SAVE] ERREUR: Utilisateur non connecté');
|
||||||
throw Exception("Utilisateur non connecté");
|
throw Exception("Utilisateur non connecté");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint('🟢 [SAVE] Récupération opération active');
|
||||||
final currentOperation = widget.operationRepository.getCurrentOperation();
|
final currentOperation = widget.operationRepository.getCurrentOperation();
|
||||||
|
debugPrint('🟢 [SAVE] currentOperation: ${currentOperation?.id} - ${currentOperation?.name}');
|
||||||
|
|
||||||
if (currentOperation == null && widget.passage == null) {
|
if (currentOperation == null && widget.passage == null) {
|
||||||
|
debugPrint('❌ [SAVE] ERREUR: Aucune opération active trouvée');
|
||||||
throw Exception("Aucune opération active trouvée");
|
throw Exception("Aucune opération active trouvée");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Déterminer les valeurs de montant et type de règlement selon le type de passage
|
// Déterminer les valeurs de montant et type de règlement selon le type de passage
|
||||||
|
debugPrint('🟢 [SAVE] Calcul des valeurs finales');
|
||||||
|
debugPrint('🟢 [SAVE] _selectedPassageType: $_selectedPassageType');
|
||||||
|
|
||||||
final String finalMontant =
|
final String finalMontant =
|
||||||
(_selectedPassageType == 1 || _selectedPassageType == 5)
|
(_selectedPassageType == 1 || _selectedPassageType == 5)
|
||||||
? _montantController.text.trim().replaceAll(',', '.')
|
? _montantController.text.trim().replaceAll(',', '.')
|
||||||
: '0';
|
: '0';
|
||||||
|
debugPrint('🟢 [SAVE] finalMontant: $finalMontant');
|
||||||
|
|
||||||
// Déterminer le type de règlement final selon le type de passage
|
// Déterminer le type de règlement final selon le type de passage
|
||||||
final int finalTypeReglement;
|
final int finalTypeReglement;
|
||||||
if (_selectedPassageType == 1 || _selectedPassageType == 5) {
|
if (_selectedPassageType == 1 || _selectedPassageType == 5) {
|
||||||
@@ -380,6 +439,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
// Pour tous les autres types, forcer "Non renseigné"
|
// Pour tous les autres types, forcer "Non renseigné"
|
||||||
finalTypeReglement = 4;
|
finalTypeReglement = 4;
|
||||||
}
|
}
|
||||||
|
debugPrint('🟢 [SAVE] finalTypeReglement: $finalTypeReglement');
|
||||||
|
|
||||||
// Déterminer la valeur de nbPassages selon le type de passage
|
// Déterminer la valeur de nbPassages selon le type de passage
|
||||||
final int finalNbPassages;
|
final int finalNbPassages;
|
||||||
@@ -397,6 +457,31 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
// Nouveau passage : toujours 1
|
// Nouveau passage : toujours 1
|
||||||
finalNbPassages = 1;
|
finalNbPassages = 1;
|
||||||
}
|
}
|
||||||
|
debugPrint('🟢 [SAVE] finalNbPassages: $finalNbPassages');
|
||||||
|
|
||||||
|
// Récupérer les coordonnées GPS pour un nouveau passage
|
||||||
|
String finalGpsLat = '0.0';
|
||||||
|
String finalGpsLng = '0.0';
|
||||||
|
if (widget.passage == null) {
|
||||||
|
// Nouveau passage : tenter de récupérer la position GPS actuelle
|
||||||
|
debugPrint('🟢 [SAVE] Récupération de la position GPS...');
|
||||||
|
try {
|
||||||
|
final position = await LocationService.getCurrentPosition();
|
||||||
|
if (position != null) {
|
||||||
|
finalGpsLat = position.latitude.toString();
|
||||||
|
finalGpsLng = position.longitude.toString();
|
||||||
|
debugPrint('🟢 [SAVE] GPS récupéré: lat=$finalGpsLat, lng=$finalGpsLng');
|
||||||
|
} else {
|
||||||
|
debugPrint('🟢 [SAVE] GPS non disponible, utilisation de 0.0 (l\'API utilisera le géocodage)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [SAVE] Erreur récupération GPS: $e - l\'API utilisera le géocodage');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Modification : conserver les coordonnées existantes
|
||||||
|
finalGpsLat = widget.passage!.gpsLat;
|
||||||
|
finalGpsLng = widget.passage!.gpsLng;
|
||||||
|
}
|
||||||
|
|
||||||
final passageData = widget.passage?.copyWith(
|
final passageData = widget.passage?.copyWith(
|
||||||
fkType: _selectedPassageType!,
|
fkType: _selectedPassageType!,
|
||||||
@@ -422,7 +507,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
PassageModel(
|
PassageModel(
|
||||||
id: 0, // Nouveau passage
|
id: 0, // Nouveau passage
|
||||||
fkOperation: currentOperation!.id, // Opération active
|
fkOperation: currentOperation!.id, // Opération active
|
||||||
fkSector: 0, // Secteur par défaut
|
fkSector: 0, // Secteur par défaut (sera déterminé par l'API)
|
||||||
fkUser: currentUser.id, // Utilisateur actuel
|
fkUser: currentUser.id, // Utilisateur actuel
|
||||||
fkType: _selectedPassageType!,
|
fkType: _selectedPassageType!,
|
||||||
fkAdresse: "0", // Adresse par défaut pour nouveau passage
|
fkAdresse: "0", // Adresse par défaut pour nouveau passage
|
||||||
@@ -435,8 +520,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
fkHabitat: _fkHabitat,
|
fkHabitat: _fkHabitat,
|
||||||
appt: _apptController.text.trim(),
|
appt: _apptController.text.trim(),
|
||||||
niveau: _niveauController.text.trim(),
|
niveau: _niveauController.text.trim(),
|
||||||
gpsLat: '0.0', // GPS par défaut
|
gpsLat: finalGpsLat, // GPS de l'utilisateur ou 0.0 si non disponible
|
||||||
gpsLng: '0.0', // GPS par défaut
|
gpsLng: finalGpsLng, // GPS de l'utilisateur ou 0.0 si non disponible
|
||||||
nomRecu: _nameController.text.trim(),
|
nomRecu: _nameController.text.trim(),
|
||||||
remarque: _remarqueController.text.trim(),
|
remarque: _remarqueController.text.trim(),
|
||||||
montant: finalMontant,
|
montant: finalMontant,
|
||||||
@@ -453,32 +538,37 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Sauvegarder le passage d'abord
|
// Sauvegarder le passage d'abord
|
||||||
|
debugPrint('🟢 [SAVE] Préparation sauvegarde passage');
|
||||||
PassageModel? savedPassage;
|
PassageModel? savedPassage;
|
||||||
if (widget.passage == null || widget.passage!.id == 0) {
|
if (widget.passage == null || widget.passage!.id == 0) {
|
||||||
// Création d'un nouveau passage (passage null OU id=0)
|
// Création d'un nouveau passage (passage null OU id=0)
|
||||||
|
debugPrint('🟢 [SAVE] Création d\'un nouveau passage');
|
||||||
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
|
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
|
||||||
} else {
|
debugPrint('🟢 [SAVE] Passage créé avec ID: ${savedPassage?.id}');
|
||||||
// Mise à jour d'un passage existant
|
|
||||||
final success = await widget.passageRepository.updatePassage(passageData);
|
|
||||||
if (success) {
|
|
||||||
savedPassage = passageData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedPassage == null) {
|
if (savedPassage == null) {
|
||||||
throw Exception(widget.passage == null || widget.passage!.id == 0
|
debugPrint('❌ [SAVE] ERREUR: savedPassage est null après création');
|
||||||
? "Échec de la création du passage"
|
throw Exception("Échec de la création du passage");
|
||||||
: "Échec de la mise à jour du passage");
|
}
|
||||||
|
} else {
|
||||||
|
// Mise à jour d'un passage existant
|
||||||
|
debugPrint('🟢 [SAVE] Mise à jour passage existant ID: ${widget.passage!.id}');
|
||||||
|
await widget.passageRepository.updatePassage(passageData);
|
||||||
|
debugPrint('🟢 [SAVE] Mise à jour réussie');
|
||||||
|
savedPassage = passageData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Garantir le type non-nullable après la vérification
|
// Garantir le type non-nullable après la vérification
|
||||||
final confirmedPassage = savedPassage;
|
final confirmedPassage = savedPassage;
|
||||||
|
debugPrint('✅ [SAVE] Passage sauvegardé avec succès ID: ${confirmedPassage.id}');
|
||||||
|
|
||||||
// Mémoriser l'adresse pour la prochaine création de passage
|
// Mémoriser l'adresse pour la prochaine création de passage
|
||||||
|
debugPrint('🟢 [SAVE] Mémorisation adresse');
|
||||||
await _saveLastPassageAddress();
|
await _saveLastPassageAddress();
|
||||||
|
|
||||||
// Propager la résidence aux autres passages de l'immeuble si nécessaire
|
// Propager la résidence aux autres passages de l'immeuble si nécessaire
|
||||||
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
|
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
|
||||||
|
debugPrint('🟢 [SAVE] Propagation résidence à l\'immeuble');
|
||||||
await _propagateResidenceToBuilding(confirmedPassage);
|
await _propagateResidenceToBuilding(confirmedPassage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,17 +604,30 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
// Lancer le flow Tap to Pay
|
// Lancer le flow Tap to Pay
|
||||||
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
|
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
|
||||||
|
|
||||||
if (!paymentSuccess) {
|
if (paymentSuccess) {
|
||||||
debugPrint('⚠️ Paiement Tap to Pay échoué pour le passage ${confirmedPassage.id}');
|
// Fermer le formulaire en cas de succès
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fermer le formulaire après le choix de paiement
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context, rootNavigator: false).pop();
|
Navigator.of(context, rootNavigator: false).pop();
|
||||||
widget.onSuccess?.call();
|
widget.onSuccess?.call();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint('⚠️ Paiement Tap to Pay échoué - formulaire reste ouvert');
|
||||||
|
// Ne pas fermer le formulaire en cas d'échec
|
||||||
|
// L'utilisateur peut réessayer ou annuler
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onQRCodeCompleted: () {
|
||||||
|
// Pour QR Code: fermer le formulaire après l'affichage du QR
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context, rootNavigator: false).pop();
|
||||||
|
widget.onSuccess?.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOTE: Le formulaire n'est plus fermé systématiquement ici
|
||||||
|
// Il est fermé dans onQRCodeCompleted pour QR Code
|
||||||
|
// ou dans onTapToPaySelected en cas de succès Tap to Pay
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Stripe non activé pour cette amicale
|
// Stripe non activé pour cette amicale
|
||||||
@@ -563,30 +666,44 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
// Masquer le loading
|
// Masquer le loading
|
||||||
|
debugPrint('❌ [SAVE] ERREUR CAPTURÉE');
|
||||||
|
debugPrint('❌ [SAVE] Type erreur: ${e.runtimeType}');
|
||||||
|
debugPrint('❌ [SAVE] Message erreur: $e');
|
||||||
|
debugPrint('❌ [SAVE] Stack trace:\n$stackTrace');
|
||||||
|
|
||||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||||
|
|
||||||
// Afficher l'erreur
|
// Afficher l'erreur
|
||||||
|
final errorMessage = ApiException.fromError(e).message;
|
||||||
|
debugPrint('❌ [SAVE] Message d\'erreur formaté: $errorMessage');
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
await ResultDialog.show(
|
await ResultDialog.show(
|
||||||
context: context,
|
context: context,
|
||||||
success: false,
|
success: false,
|
||||||
message: ApiException.fromError(e).message,
|
message: errorMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
debugPrint('🟢 [SAVE] Bloc finally - Nettoyage');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSubmitting = false;
|
_isSubmitting = false;
|
||||||
});
|
});
|
||||||
|
debugPrint('🟢 [SAVE] _isSubmitting = false');
|
||||||
}
|
}
|
||||||
|
debugPrint('🟢 [SAVE] Fin _savePassage');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mémoriser l'adresse du passage pour la prochaine création
|
/// Mémoriser l'adresse du passage pour la prochaine création
|
||||||
Future<void> _saveLastPassageAddress() async {
|
Future<void> _saveLastPassageAddress() async {
|
||||||
try {
|
try {
|
||||||
|
debugPrint('🟡 [ADDRESS] Début mémorisation adresse');
|
||||||
|
debugPrint('🟡 [ADDRESS] _settingsBox.isOpen: ${_settingsBox.isOpen}');
|
||||||
|
|
||||||
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
|
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
|
||||||
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
|
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
|
||||||
await _settingsBox.put('lastPassageRue', _rueController.text.trim());
|
await _settingsBox.put('lastPassageRue', _rueController.text.trim());
|
||||||
@@ -596,20 +713,28 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
|
await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
|
||||||
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
|
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
|
||||||
|
|
||||||
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage');
|
debugPrint('✅ [ADDRESS] Adresse mémorisée avec succès');
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e');
|
debugPrint('❌ [ADDRESS] Erreur lors de la mémorisation: $e');
|
||||||
|
debugPrint('❌ [ADDRESS] Stack trace:\n$stackTrace');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide)
|
/// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide)
|
||||||
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
|
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
|
||||||
try {
|
try {
|
||||||
|
debugPrint('🟡 [PROPAGATE] Début propagation résidence');
|
||||||
|
|
||||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||||
|
debugPrint('🟡 [PROPAGATE] passagesBox.isOpen: ${passagesBox.isOpen}');
|
||||||
|
debugPrint('🟡 [PROPAGATE] passagesBox.length: ${passagesBox.length}');
|
||||||
|
|
||||||
final residence = _residenceController.text.trim();
|
final residence = _residenceController.text.trim();
|
||||||
|
debugPrint('🟡 [PROPAGATE] résidence: "$residence"');
|
||||||
|
|
||||||
// Clé d'adresse du passage sauvegardé
|
// Clé d'adresse du passage sauvegardé
|
||||||
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
|
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
|
||||||
|
debugPrint('🟡 [PROPAGATE] addressKey: "$addressKey"');
|
||||||
|
|
||||||
int updatedCount = 0;
|
int updatedCount = 0;
|
||||||
|
|
||||||
@@ -625,6 +750,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
passageAddressKey == addressKey && // Même adresse
|
passageAddressKey == addressKey && // Même adresse
|
||||||
passage.residence.trim().isEmpty) { // Résidence vide
|
passage.residence.trim().isEmpty) { // Résidence vide
|
||||||
|
|
||||||
|
debugPrint('🟡 [PROPAGATE] Mise à jour passage ID: ${passage.id}');
|
||||||
// Mettre à jour la résidence dans Hive
|
// Mettre à jour la résidence dans Hive
|
||||||
final updatedPassage = passage.copyWith(residence: residence);
|
final updatedPassage = passage.copyWith(residence: residence);
|
||||||
await passagesBox.put(passage.key, updatedPassage);
|
await passagesBox.put(passage.key, updatedPassage);
|
||||||
@@ -634,10 +760,13 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updatedCount > 0) {
|
if (updatedCount > 0) {
|
||||||
debugPrint('✅ Résidence propagée à $updatedCount passage(s) de l\'immeuble');
|
debugPrint('✅ [PROPAGATE] Résidence propagée à $updatedCount passage(s)');
|
||||||
|
} else {
|
||||||
|
debugPrint('✅ [PROPAGATE] Aucun passage à mettre à jour');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $e');
|
debugPrint('❌ [PROPAGATE] Erreur lors de la propagation: $e');
|
||||||
|
debugPrint('❌ [PROPAGATE] Stack trace:\n$stackTrace');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1819,9 +1948,46 @@ class _TapToPayFlowDialogState extends State<_TapToPayFlowDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Analyser le type d'erreur pour afficher un message clair
|
||||||
|
final errorMsg = e.toString().toLowerCase();
|
||||||
|
|
||||||
|
String userMessage;
|
||||||
|
bool shouldCancelPayment = true;
|
||||||
|
|
||||||
|
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
|
||||||
|
// Annulation volontaire par l'utilisateur
|
||||||
|
userMessage = 'Paiement annulé';
|
||||||
|
|
||||||
|
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
|
||||||
|
// Timeout de lecture NFC
|
||||||
|
userMessage = 'Lecture de la carte impossible.\n\n'
|
||||||
|
'Conseils :\n'
|
||||||
|
'• Maintenez la carte contre le dos du téléphone\n'
|
||||||
|
'• Ne bougez pas jusqu\'à confirmation\n'
|
||||||
|
'• Retirez la coque si nécessaire\n'
|
||||||
|
'• Essayez différentes positions sur le téléphone';
|
||||||
|
|
||||||
|
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
|
||||||
|
// PaymentIntent existe déjà
|
||||||
|
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
|
||||||
|
'Veuillez réessayer dans quelques instants.';
|
||||||
|
shouldCancelPayment = false; // Déjà en cours, pas besoin d'annuler
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Autre erreur technique
|
||||||
|
userMessage = 'Erreur lors du paiement.\n\n$e';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annuler le PaymentIntent si créé pour permettre une nouvelle tentative
|
||||||
|
if (shouldCancelPayment && _paymentIntentId != null) {
|
||||||
|
StripeTapToPayService.instance.cancelPayment(_paymentIntentId!).catchError((cancelError) {
|
||||||
|
debugPrint('⚠️ Erreur annulation PaymentIntent: $cancelError');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentState = 'error';
|
_currentState = 'error';
|
||||||
_errorMessage = e.toString();
|
_errorMessage = userMessage;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
|
|||||||
|
|
||||||
/// Dialog de sélection de la méthode de paiement CB
|
/// Dialog de sélection de la méthode de paiement CB
|
||||||
/// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité
|
/// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité
|
||||||
class PaymentMethodSelectionDialog extends StatelessWidget {
|
class PaymentMethodSelectionDialog extends StatefulWidget {
|
||||||
final PassageModel passage;
|
final PassageModel passage;
|
||||||
final double amount;
|
final double amount;
|
||||||
final String habitantName;
|
final String habitantName;
|
||||||
final StripeConnectService stripeConnectService;
|
final StripeConnectService stripeConnectService;
|
||||||
final PassageRepository? passageRepository;
|
final PassageRepository? passageRepository;
|
||||||
final VoidCallback? onTapToPaySelected;
|
final VoidCallback? onTapToPaySelected;
|
||||||
|
final VoidCallback? onQRCodeCompleted;
|
||||||
|
|
||||||
const PaymentMethodSelectionDialog({
|
const PaymentMethodSelectionDialog({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -24,12 +25,61 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
required this.stripeConnectService,
|
required this.stripeConnectService,
|
||||||
this.passageRepository,
|
this.passageRepository,
|
||||||
this.onTapToPaySelected,
|
this.onTapToPaySelected,
|
||||||
|
this.onQRCodeCompleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PaymentMethodSelectionDialog> createState() => _PaymentMethodSelectionDialogState();
|
||||||
|
|
||||||
|
/// Afficher le dialog de sélection de méthode de paiement
|
||||||
|
static Future<void> show({
|
||||||
|
required BuildContext context,
|
||||||
|
required PassageModel passage,
|
||||||
|
required double amount,
|
||||||
|
required String habitantName,
|
||||||
|
required StripeConnectService stripeConnectService,
|
||||||
|
PassageRepository? passageRepository,
|
||||||
|
VoidCallback? onTapToPaySelected,
|
||||||
|
VoidCallback? onQRCodeCompleted,
|
||||||
|
}) {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false, // Ne peut pas fermer en cliquant à côté
|
||||||
|
builder: (context) => PaymentMethodSelectionDialog(
|
||||||
|
passage: passage,
|
||||||
|
amount: amount,
|
||||||
|
habitantName: habitantName,
|
||||||
|
stripeConnectService: stripeConnectService,
|
||||||
|
passageRepository: passageRepository,
|
||||||
|
onTapToPaySelected: onTapToPaySelected,
|
||||||
|
onQRCodeCompleted: onQRCodeCompleted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaymentMethodSelectionDialogState extends State<PaymentMethodSelectionDialog> {
|
||||||
|
String? _tapToPayUnavailableReason;
|
||||||
|
bool _isCheckingNFC = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkTapToPayAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkTapToPayAvailability() async {
|
||||||
|
final reason = await DeviceInfoService.instance.getTapToPayUnavailableReasonAsync();
|
||||||
|
setState(() {
|
||||||
|
_tapToPayUnavailableReason = reason;
|
||||||
|
_isCheckingNFC = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
|
final canUseTapToPay = !_isCheckingNFC && _tapToPayUnavailableReason == null;
|
||||||
final amountEuros = amount.toStringAsFixed(2);
|
final amountEuros = widget.amount.toStringAsFixed(2);
|
||||||
|
|
||||||
return Dialog(
|
return Dialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -42,9 +92,6 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// En-tête
|
// En-tête
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
const Text(
|
||||||
'Règlement CB',
|
'Règlement CB',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -52,12 +99,6 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Informations du paiement
|
// Informations du paiement
|
||||||
@@ -76,7 +117,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
habitantName,
|
widget.habitantName,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -128,23 +169,28 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
description: 'Le client scanne le code avec son téléphone',
|
description: 'Le client scanne le code avec son téléphone',
|
||||||
onPressed: () => _handleQRCodePayment(context),
|
onPressed: () => _handleQRCodePayment(context),
|
||||||
color: Colors.blue,
|
color: Colors.blue,
|
||||||
|
isEnabled: true,
|
||||||
),
|
),
|
||||||
|
|
||||||
if (canUseTapToPay) ...[
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Bouton Tap to Pay
|
|
||||||
|
// Bouton Tap to Pay (toujours affiché, désactivé si non disponible)
|
||||||
_buildPaymentButton(
|
_buildPaymentButton(
|
||||||
context: context,
|
context: context,
|
||||||
icon: Icons.contactless,
|
icon: Icons.contactless,
|
||||||
label: 'Tap to Pay',
|
label: _isCheckingNFC ? 'Tap to Pay (vérification...)' : 'Tap to Pay',
|
||||||
description: 'Paiement sans contact sur cet appareil',
|
description: canUseTapToPay
|
||||||
onPressed: () {
|
? 'Paiement sans contact sur cet appareil'
|
||||||
|
: _tapToPayUnavailableReason ?? 'Vérification en cours...',
|
||||||
|
onPressed: canUseTapToPay
|
||||||
|
? () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
onTapToPaySelected?.call();
|
widget.onTapToPaySelected?.call();
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
|
isEnabled: canUseTapToPay,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@@ -179,18 +225,24 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String label,
|
required String label,
|
||||||
required String description,
|
required String description,
|
||||||
required VoidCallback onPressed,
|
required VoidCallback? onPressed,
|
||||||
required Color color,
|
required Color color,
|
||||||
|
required bool isEnabled,
|
||||||
}) {
|
}) {
|
||||||
|
// Couleurs selon l'état activé/désactivé
|
||||||
|
final effectiveColor = isEnabled ? color : Colors.grey;
|
||||||
|
final backgroundColor = isEnabled ? color.withOpacity(0.1) : Colors.grey.shade100;
|
||||||
|
final borderColor = isEnabled ? color.withOpacity(0.3) : Colors.grey.shade300;
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onPressed,
|
onTap: isEnabled ? onPressed : null,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withOpacity(0.1),
|
color: backgroundColor,
|
||||||
border: Border.all(color: color.withOpacity(0.3), width: 2),
|
border: Border.all(color: borderColor, width: 2),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -198,36 +250,69 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withOpacity(0.2),
|
color: isEnabled ? color.withOpacity(0.2) : Colors.grey.shade200,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: color, size: 32),
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: effectiveColor,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: color,
|
color: effectiveColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (!isEnabled)
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (!isEnabled) ...[
|
||||||
|
Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
description,
|
description,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Colors.grey.shade700,
|
color: isEnabled ? Colors.grey.shade700 : Colors.orange.shade700,
|
||||||
|
fontWeight: isEnabled ? FontWeight.normal : FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Icon(Icons.arrow_forward_ios, color: color, size: 20),
|
),
|
||||||
|
if (isEnabled)
|
||||||
|
Icon(Icons.arrow_forward_ios, color: effectiveColor, size: 20),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -238,6 +323,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
Future<void> _handleQRCodePayment(BuildContext context) async {
|
Future<void> _handleQRCodePayment(BuildContext context) async {
|
||||||
// Sauvegarder le navigator avant de fermer les dialogs
|
// Sauvegarder le navigator avant de fermer les dialogs
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
|
bool loaderDisplayed = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Afficher un loader
|
// Afficher un loader
|
||||||
@@ -248,19 +334,20 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
loaderDisplayed = true;
|
||||||
|
|
||||||
// Créer le Payment Link
|
// Créer le Payment Link
|
||||||
final amountInCents = (amount * 100).round();
|
final amountInCents = (widget.amount * 100).round();
|
||||||
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${passage.id}');
|
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${widget.passage.id}');
|
||||||
|
|
||||||
final paymentLink = await stripeConnectService.createPaymentLink(
|
final paymentLink = await widget.stripeConnectService.createPaymentLink(
|
||||||
amountInCents: amountInCents,
|
amountInCents: amountInCents,
|
||||||
passageId: passage.id,
|
passageId: widget.passage.id,
|
||||||
description: 'Calendrier pompiers - ${habitantName}',
|
description: 'Calendrier pompiers - ${widget.habitantName}',
|
||||||
metadata: {
|
metadata: {
|
||||||
'passage_id': passage.id.toString(),
|
'passage_id': widget.passage.id.toString(),
|
||||||
'habitant_name': habitantName,
|
'habitant_name': widget.habitantName,
|
||||||
'adresse': '${passage.numero} ${passage.rue}, ${passage.ville}',
|
'adresse': '${widget.passage.numero} ${widget.passage.rue}, ${widget.passage.ville}',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -270,22 +357,18 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
debugPrint(' ID: ${paymentLink.paymentLinkId}');
|
debugPrint(' ID: ${paymentLink.paymentLinkId}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fermer le loader
|
|
||||||
navigator.pop();
|
|
||||||
debugPrint('🔵 Loader fermé');
|
|
||||||
|
|
||||||
if (paymentLink == null) {
|
if (paymentLink == null) {
|
||||||
throw Exception('Impossible de créer le lien de paiement');
|
throw Exception('Impossible de créer le lien de paiement');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sauvegarder l'URL du Payment Link dans le passage
|
// Sauvegarder l'URL du Payment Link dans le passage
|
||||||
if (passageRepository != null) {
|
if (widget.passageRepository != null) {
|
||||||
try {
|
try {
|
||||||
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
|
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
|
||||||
final updatedPassage = passage.copyWith(
|
final updatedPassage = widget.passage.copyWith(
|
||||||
stripePaymentLinkUrl: paymentLink.url,
|
stripePaymentLinkUrl: paymentLink.url,
|
||||||
);
|
);
|
||||||
await passageRepository!.updatePassage(updatedPassage);
|
await widget.passageRepository!.updatePassage(updatedPassage);
|
||||||
debugPrint('✅ URL du Payment Link sauvegardée');
|
debugPrint('✅ URL du Payment Link sauvegardée');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
|
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
|
||||||
@@ -293,7 +376,12 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fermer le dialog de sélection
|
// Fermer le loader
|
||||||
|
navigator.pop();
|
||||||
|
loaderDisplayed = false;
|
||||||
|
debugPrint('🔵 Loader fermé');
|
||||||
|
|
||||||
|
// Fermer le dialog de sélection (seulement en cas de succès)
|
||||||
navigator.pop();
|
navigator.pop();
|
||||||
debugPrint('🔵 Dialog de sélection fermé');
|
debugPrint('🔵 Dialog de sélection fermé');
|
||||||
|
|
||||||
@@ -311,43 +399,25 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
debugPrint('🔵 Dialog QR Code affiché');
|
debugPrint('🔵 Dialog QR Code affiché');
|
||||||
|
|
||||||
|
// Notifier que le QR Code est complété
|
||||||
|
widget.onQRCodeCompleted?.call();
|
||||||
|
debugPrint('✅ Callback onQRCodeCompleted appelé');
|
||||||
|
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
|
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
|
||||||
debugPrint(' Stack: $stack');
|
debugPrint(' Stack: $stack');
|
||||||
|
|
||||||
// Fermer le loader si encore ouvert
|
// Fermer le loader si encore ouvert
|
||||||
|
if (loaderDisplayed) {
|
||||||
try {
|
try {
|
||||||
navigator.pop();
|
navigator.pop();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Afficher l'erreur
|
// Afficher l'erreur (le dialogue de sélection reste ouvert)
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ApiException.showError(context, e);
|
ApiException.showError(context, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Afficher le dialog de sélection de méthode de paiement
|
|
||||||
static Future<void> show({
|
|
||||||
required BuildContext context,
|
|
||||||
required PassageModel passage,
|
|
||||||
required double amount,
|
|
||||||
required String habitantName,
|
|
||||||
required StripeConnectService stripeConnectService,
|
|
||||||
PassageRepository? passageRepository,
|
|
||||||
VoidCallback? onTapToPaySelected,
|
|
||||||
}) {
|
|
||||||
return showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: true,
|
|
||||||
builder: (context) => PaymentMethodSelectionDialog(
|
|
||||||
passage: passage,
|
|
||||||
amount: amount,
|
|
||||||
habitantName: habitantName,
|
|
||||||
stripeConnectService: stripeConnectService,
|
|
||||||
passageRepository: passageRepository,
|
|
||||||
onTapToPaySelected: onTapToPaySelected,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -818,7 +818,7 @@ class _UserFormState extends State<UserForm> {
|
|||||||
),
|
),
|
||||||
helperText: widget.user?.id != 0
|
helperText: widget.user?.id != 0
|
||||||
? "Laissez vide pour conserver le mot de passe actuel"
|
? "Laissez vide pour conserver le mot de passe actuel"
|
||||||
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
|
: "8 à 64 caractères, alphanumériques et caractères spéciaux",
|
||||||
helperMaxLines: 3,
|
helperMaxLines: 3,
|
||||||
validator: _validatePassword,
|
validator: _validatePassword,
|
||||||
),
|
),
|
||||||
@@ -897,7 +897,7 @@ class _UserFormState extends State<UserForm> {
|
|||||||
),
|
),
|
||||||
helperText: widget.user?.id != 0
|
helperText: widget.user?.id != 0
|
||||||
? "Laissez vide pour conserver le mot de passe actuel"
|
? "Laissez vide pour conserver le mot de passe actuel"
|
||||||
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
|
: "8 à 64 caractères, alphanumériques et caractères spéciaux",
|
||||||
helperMaxLines: 3,
|
helperMaxLines: 3,
|
||||||
validator: _validatePassword,
|
validator: _validatePassword,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
|
|||||||
user: widget.user,
|
user: widget.user,
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
allowUsernameEdit: widget.allowUsernameEdit,
|
allowUsernameEdit: widget.allowUsernameEdit,
|
||||||
allowSectNameEdit: widget.allowUsernameEdit,
|
allowSectNameEdit: widget.isAdmin, // Toujours éditable pour un admin
|
||||||
amicale: widget.amicale, // Passer l'amicale
|
amicale: widget.amicale, // Passer l'amicale
|
||||||
isAdmin: widget.isAdmin, // Passer isAdmin
|
isAdmin: widget.isAdmin, // Passer isAdmin
|
||||||
onSubmit: null, // Pas besoin de callback
|
onSubmit: null, // Pas besoin de callback
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// This is a generated file; do not edit or check into version control.
|
// This is a generated file; do not edit or check into version control.
|
||||||
FLUTTER_ROOT=/opt/flutter
|
FLUTTER_ROOT=/home/pierre/.local/flutter
|
||||||
FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app
|
FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app
|
||||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||||
FLUTTER_BUILD_DIR=build
|
FLUTTER_BUILD_DIR=build
|
||||||
FLUTTER_BUILD_NAME=3.5.2
|
FLUTTER_BUILD_NAME=3.6.2
|
||||||
FLUTTER_BUILD_NUMBER=352
|
FLUTTER_BUILD_NUMBER=362
|
||||||
DART_OBFUSCATION=false
|
DART_OBFUSCATION=false
|
||||||
TRACK_WIDGET_CREATION=true
|
TRACK_WIDGET_CREATION=true
|
||||||
TREE_SHAKE_ICONS=false
|
TREE_SHAKE_ICONS=false
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# This is a generated file; do not edit or check into version control.
|
# This is a generated file; do not edit or check into version control.
|
||||||
export "FLUTTER_ROOT=/opt/flutter"
|
export "FLUTTER_ROOT=/home/pierre/.local/flutter"
|
||||||
export "FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app"
|
export "FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app"
|
||||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||||
export "FLUTTER_BUILD_DIR=build"
|
export "FLUTTER_BUILD_DIR=build"
|
||||||
export "FLUTTER_BUILD_NAME=3.5.2"
|
export "FLUTTER_BUILD_NAME=3.6.2"
|
||||||
export "FLUTTER_BUILD_NUMBER=352"
|
export "FLUTTER_BUILD_NUMBER=362"
|
||||||
export "DART_OBFUSCATION=false"
|
export "DART_OBFUSCATION=false"
|
||||||
export "TRACK_WIDGET_CREATION=true"
|
export "TRACK_WIDGET_CREATION=true"
|
||||||
export "TREE_SHAKE_ICONS=false"
|
export "TREE_SHAKE_ICONS=false"
|
||||||
|
|||||||
@@ -130,10 +130,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: built_value
|
name: built_value
|
||||||
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.12.0"
|
version: "8.12.3"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -222,6 +222,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
|
cookie_jar:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cookie_jar
|
||||||
|
sha256: a6ac027d3ed6ed756bfce8f3ff60cb479e266f3b0fdabd6242b804b6765e52de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.8"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -318,6 +326,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.2"
|
version: "3.2.2"
|
||||||
|
dio_cookie_manager:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dio_cookie_manager
|
||||||
|
sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.3.0"
|
||||||
dio_web_adapter:
|
dio_web_adapter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -630,10 +646,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
version: "1.6.0"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -654,10 +670,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.4"
|
version: "4.7.2"
|
||||||
image_picker:
|
image_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1435,10 +1451,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: watcher
|
name: watcher
|
||||||
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.4"
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: geosector_app
|
name: geosector_app
|
||||||
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 3.5.2+352
|
version: 3.6.2+362
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -22,6 +22,8 @@ dependencies:
|
|||||||
|
|
||||||
# API & Réseau
|
# API & Réseau
|
||||||
dio: ^5.3.3
|
dio: ^5.3.3
|
||||||
|
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
|
||||||
|
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
|
||||||
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||||
retry: ^3.1.2
|
retry: ^3.1.2
|
||||||
|
|
||||||
|
|||||||
113
app/pubspec.yaml.backup
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
name: geosector_app
|
||||||
|
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 3.5.9+359
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
cupertino_icons: ^1.0.6
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
go_router: ^15.1.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 16.0.0)
|
||||||
|
|
||||||
|
# État et gestion des données
|
||||||
|
hive: ^2.2.3
|
||||||
|
hive_flutter: ^1.1.0
|
||||||
|
|
||||||
|
# API & Réseau
|
||||||
|
dio: ^5.3.3
|
||||||
|
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
|
||||||
|
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
|
||||||
|
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||||
|
retry: ^3.1.2
|
||||||
|
|
||||||
|
# UI et animations
|
||||||
|
google_fonts: ^6.1.0
|
||||||
|
flutter_svg: ^2.0.9
|
||||||
|
# package_info_plus: ^4.2.0 # ❌ SUPPRIMÉ - Remplacé par AppInfoService auto-généré (13/10/2025)
|
||||||
|
|
||||||
|
# Utilitaires
|
||||||
|
intl: 0.19.0 # Version requise par flutter_localizations Flutter 3.24.5 LTS
|
||||||
|
uuid: ^4.2.1
|
||||||
|
syncfusion_flutter_charts: 27.2.5 # ⬇️ Compatible Flutter 3.24.5 LTS (sweet spot)
|
||||||
|
# shared_preferences: ^2.3.3 # Remplacé par Hive pour cohérence architecturale
|
||||||
|
|
||||||
|
# Cartes et géolocalisation
|
||||||
|
url_launcher: ^6.1.14 # ⬇️ Version stable (depuis 6.3.1)
|
||||||
|
flutter_map: ^7.0.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 8.2.2)
|
||||||
|
flutter_map_cache: ^1.5.1 # Compatible Flutter 3.24.5 LTS (Dart 3.5.4)
|
||||||
|
dio_cache_interceptor_hive_store: ^3.2.1 # ✅ Compatible flutter_map_cache 1.5.1
|
||||||
|
path_provider: ^2.1.2 # Requis pour le cache
|
||||||
|
latlong2: ^0.9.1
|
||||||
|
geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS)
|
||||||
|
geolocator_android: 4.6.1 # ✅ Force version sans toARGB32()
|
||||||
|
universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env)
|
||||||
|
# sensors_plus: ^3.1.0 # ❌ SUPPRIMÉ - Mode boussole retiré (feature optionnelle peu utilisée) (13/10/2025)
|
||||||
|
|
||||||
|
# Chat et notifications
|
||||||
|
# mqtt5_client: ^4.11.0
|
||||||
|
flutter_local_notifications: ^19.0.1
|
||||||
|
|
||||||
|
# Upload d'images
|
||||||
|
image_picker: ^0.8.9 # ⬇️ Avant SwiftPM (depuis 1.1.2)
|
||||||
|
|
||||||
|
# Configuration YAML
|
||||||
|
yaml: ^3.1.2
|
||||||
|
|
||||||
|
# Stripe Terminal et détection device (V2)
|
||||||
|
device_info_plus: ^11.3.0 # ✅ Compatible Flutter 3.24.5 LTS (depuis 12.1.0)
|
||||||
|
battery_plus: 6.0.3 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||||
|
# network_info_plus: ^4.1.0 # ❌ SUPPRIMÉ - Remplacé par NetworkInterface natif Dart (13/10/2025)
|
||||||
|
nfc_manager: 3.3.0 # ✅ Version stable - Nécessite patch AndroidManifest (fix-nfc-manager.sh)
|
||||||
|
mek_stripe_terminal: ^4.6.0
|
||||||
|
flutter_stripe: ^11.5.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 12.0.0)
|
||||||
|
permission_handler: ^12.0.1
|
||||||
|
qr_flutter: ^4.1.0 # Génération de QR codes pour paiements Stripe
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^5.0.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 6.0.0)
|
||||||
|
hive_generator: ^2.0.1
|
||||||
|
build_runner: ^2.4.6
|
||||||
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
image_path: 'assets/images/icons/icon-1024.png'
|
||||||
|
min_sdk_android: 21
|
||||||
|
adaptive_icon_background: '#FFFFFF'
|
||||||
|
adaptive_icon_foreground: 'assets/images/icons/icon-1024.png'
|
||||||
|
remove_alpha_ios: true
|
||||||
|
web:
|
||||||
|
generate: true
|
||||||
|
image_path: 'assets/images/icons/icon-1024.png'
|
||||||
|
background_color: '#FFFFFF'
|
||||||
|
theme_color: '#4B77BE'
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: 'assets/images/icons/icon-1024.png'
|
||||||
|
icon_size: 48
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/images/
|
||||||
|
- assets/icons/
|
||||||
|
- assets/animations/
|
||||||
|
- lib/chat/chat_config.yaml
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
- family: Inter
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/InterVariable.ttf
|
||||||
|
- asset: assets/fonts/InterVariable-Italic.ttf
|
||||||
|
style: italic
|
||||||
113
app/pubspec.yaml.bak
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
name: geosector_app
|
||||||
|
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 3.5.9+359
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
cupertino_icons: ^1.0.6
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
go_router: ^15.1.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 16.0.0)
|
||||||
|
|
||||||
|
# État et gestion des données
|
||||||
|
hive: ^2.2.3
|
||||||
|
hive_flutter: ^1.1.0
|
||||||
|
|
||||||
|
# API & Réseau
|
||||||
|
dio: ^5.3.3
|
||||||
|
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
|
||||||
|
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
|
||||||
|
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||||
|
retry: ^3.1.2
|
||||||
|
|
||||||
|
# UI et animations
|
||||||
|
google_fonts: ^6.1.0
|
||||||
|
flutter_svg: ^2.0.9
|
||||||
|
# package_info_plus: ^4.2.0 # ❌ SUPPRIMÉ - Remplacé par AppInfoService auto-généré (13/10/2025)
|
||||||
|
|
||||||
|
# Utilitaires
|
||||||
|
intl: 0.19.0 # Version requise par flutter_localizations Flutter 3.24.5 LTS
|
||||||
|
uuid: ^4.2.1
|
||||||
|
syncfusion_flutter_charts: 27.2.5 # ⬇️ Compatible Flutter 3.24.5 LTS (sweet spot)
|
||||||
|
# shared_preferences: ^2.3.3 # Remplacé par Hive pour cohérence architecturale
|
||||||
|
|
||||||
|
# Cartes et géolocalisation
|
||||||
|
url_launcher: ^6.1.14 # ⬇️ Version stable (depuis 6.3.1)
|
||||||
|
flutter_map: ^7.0.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 8.2.2)
|
||||||
|
flutter_map_cache: ^1.5.1 # Compatible Flutter 3.24.5 LTS (Dart 3.5.4)
|
||||||
|
dio_cache_interceptor_hive_store: ^3.2.1 # ✅ Compatible flutter_map_cache 1.5.1
|
||||||
|
path_provider: ^2.1.2 # Requis pour le cache
|
||||||
|
latlong2: ^0.9.1
|
||||||
|
geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS)
|
||||||
|
geolocator_android: 4.6.1 # ✅ Force version sans toARGB32()
|
||||||
|
universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env)
|
||||||
|
# sensors_plus: ^3.1.0 # ❌ SUPPRIMÉ - Mode boussole retiré (feature optionnelle peu utilisée) (13/10/2025)
|
||||||
|
|
||||||
|
# Chat et notifications
|
||||||
|
# mqtt5_client: ^4.11.0
|
||||||
|
flutter_local_notifications: ^19.0.1
|
||||||
|
|
||||||
|
# Upload d'images
|
||||||
|
image_picker: ^0.8.9 # ⬇️ Avant SwiftPM (depuis 1.1.2)
|
||||||
|
|
||||||
|
# Configuration YAML
|
||||||
|
yaml: ^3.1.2
|
||||||
|
|
||||||
|
# Stripe Terminal et détection device (V2)
|
||||||
|
device_info_plus: ^11.3.0 # ✅ Compatible Flutter 3.24.5 LTS (depuis 12.1.0)
|
||||||
|
battery_plus: 6.0.3 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||||
|
# network_info_plus: ^4.1.0 # ❌ SUPPRIMÉ - Remplacé par NetworkInterface natif Dart (13/10/2025)
|
||||||
|
nfc_manager: 3.3.0 # ✅ Version stable - Nécessite patch AndroidManifest (fix-nfc-manager.sh)
|
||||||
|
mek_stripe_terminal: ^4.6.0
|
||||||
|
flutter_stripe: ^11.5.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 12.0.0)
|
||||||
|
permission_handler: ^12.0.1
|
||||||
|
qr_flutter: ^4.1.0 # Génération de QR codes pour paiements Stripe
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^5.0.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 6.0.0)
|
||||||
|
hive_generator: ^2.0.1
|
||||||
|
build_runner: ^2.4.6
|
||||||
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
image_path: 'assets/images/icons/icon-1024.png'
|
||||||
|
min_sdk_android: 21
|
||||||
|
adaptive_icon_background: '#FFFFFF'
|
||||||
|
adaptive_icon_foreground: 'assets/images/icons/icon-1024.png'
|
||||||
|
remove_alpha_ios: true
|
||||||
|
web:
|
||||||
|
generate: true
|
||||||
|
image_path: 'assets/images/icons/icon-1024.png'
|
||||||
|
background_color: '#FFFFFF'
|
||||||
|
theme_color: '#4B77BE'
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: 'assets/images/icons/icon-1024.png'
|
||||||
|
icon_size: 48
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/images/
|
||||||
|
- assets/icons/
|
||||||
|
- assets/animations/
|
||||||
|
- lib/chat/chat_config.yaml
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
- family: Inter
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/InterVariable.ttf
|
||||||
|
- asset: assets/fonts/InterVariable-Italic.ttf
|
||||||
|
style: italic
|
||||||
@@ -26,19 +26,52 @@ if [ ! -f "pubspec.yaml" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Récupérer la version depuis pubspec.yaml
|
# Synchroniser la version depuis ../VERSION
|
||||||
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
|
echo -e "${BLUE}📋 Synchronisation de la version depuis ../VERSION...${NC}"
|
||||||
VERSION_CODE=$(echo $VERSION | cut -d'+' -f2)
|
echo ""
|
||||||
|
|
||||||
if [ -z "$VERSION_CODE" ]; then
|
VERSION_FILE="../VERSION"
|
||||||
echo -e "${RED}Impossible de récupérer le version code depuis pubspec.yaml${NC}"
|
if [ ! -f "$VERSION_FILE" ]; then
|
||||||
|
echo -e "${RED}Erreur: Fichier VERSION introuvable : $VERSION_FILE${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}Version détectée :${NC} $VERSION"
|
# Lire la version depuis le fichier (enlever espaces/retours à la ligne)
|
||||||
echo -e "${YELLOW}Version code :${NC} $VERSION_CODE"
|
VERSION_NUMBER=$(cat "$VERSION_FILE" | tr -d '\n\r ' | tr -d '[:space:]')
|
||||||
|
if [ -z "$VERSION_NUMBER" ]; then
|
||||||
|
echo -e "${RED}Erreur: Le fichier VERSION est vide${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Version lue depuis $VERSION_FILE :${NC} $VERSION_NUMBER"
|
||||||
|
|
||||||
|
# Calculer le versionCode (supprimer les points)
|
||||||
|
VERSION_CODE=$(echo $VERSION_NUMBER | tr -d '.')
|
||||||
|
if [ -z "$VERSION_CODE" ]; then
|
||||||
|
echo -e "${RED}Erreur: Impossible de calculer le versionCode${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Version code calculé :${NC} $VERSION_CODE"
|
||||||
|
|
||||||
|
# Mettre à jour pubspec.yaml
|
||||||
|
echo -e "${BLUE}Mise à jour de pubspec.yaml...${NC}"
|
||||||
|
sed -i.bak "s/^version:.*/version: $VERSION_NUMBER+$VERSION_CODE/" pubspec.yaml
|
||||||
|
|
||||||
|
# Vérifier que la mise à jour a réussi
|
||||||
|
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
|
||||||
|
if [ "$UPDATED_VERSION" != "$VERSION_NUMBER+$VERSION_CODE" ]; then
|
||||||
|
echo -e "${RED}Erreur: Échec de la mise à jour de pubspec.yaml${NC}"
|
||||||
|
echo -e "${RED}Attendu : $VERSION_NUMBER+$VERSION_CODE${NC}"
|
||||||
|
echo -e "${RED}Obtenu : $UPDATED_VERSION${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ pubspec.yaml mis à jour : version: $VERSION_NUMBER+$VERSION_CODE${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
VERSION="$VERSION_NUMBER+$VERSION_CODE"
|
||||||
|
|
||||||
# Construire le chemin de destination avec numéro de version
|
# Construire le chemin de destination avec numéro de version
|
||||||
DESTINATION_DIR="app_$VERSION_CODE"
|
DESTINATION_DIR="app_$VERSION_CODE"
|
||||||
DESTINATION="$MAC_USER@$MAC_MINI_IP:/Users/pierre/dev/geosector/$DESTINATION_DIR"
|
DESTINATION="$MAC_USER@$MAC_MINI_IP:/Users/pierre/dev/geosector/$DESTINATION_DIR"
|
||||||
@@ -56,6 +89,7 @@ echo -e "${BLUE}rsync va créer le dossier de destination automatiquement${NC}"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
rsync -avz --progress \
|
rsync -avz --progress \
|
||||||
|
-e "ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password" \
|
||||||
--rsync-path="mkdir -p /Users/pierre/dev/geosector/$DESTINATION_DIR && rsync" \
|
--rsync-path="mkdir -p /Users/pierre/dev/geosector/$DESTINATION_DIR && rsync" \
|
||||||
--exclude='build/' \
|
--exclude='build/' \
|
||||||
--exclude='.dart_tool/' \
|
--exclude='.dart_tool/' \
|
||||||
|
|||||||
214
docs/PLANNING-2026-Q1.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Planning Geosector Q1 2026 - COMPLET
|
||||||
|
|
||||||
|
**Période** : 16/01/2026 - 16/03/2026 (60 jours)
|
||||||
|
**Tâches** : 126 tâches actives
|
||||||
|
**Priorités** : UI/UX et MAP en premier
|
||||||
|
**Stack Techno** : Flutter et Hive pour Web et Mobiles / API REST Full PHP8.3
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 1 : BUGS CRITIQUES
|
||||||
|
### 16-18 janvier (3 jours) - 5 tâches
|
||||||
|
|
||||||
|
| Date | ID | Tâche | Catégorie |
|
||||||
|
|------|-----|-------|-----------|
|
||||||
|
| 16/01 | #17 | ✅ Création membre impossible | BUG |
|
||||||
|
| 16/01 | #18 | ✅ Création opération impossible | BUG |
|
||||||
|
| 17/01 | #19 | ✅ Export opération cassé | BUG |
|
||||||
|
| 17/01 | #20 | Enregistrement des passages ne fonctionne pas | BUG |
|
||||||
|
| 18/01 | #14 | Bug F5 - déconnexion lors du rafraîchissement | BUG |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2 : STRIPE iOS + UX
|
||||||
|
### 19-25 janvier (7 jours) - 14 tâches
|
||||||
|
|
||||||
|
**Tâche principale** : #13 Tests Stripe iOS (5 jours du 19 au 23)
|
||||||
|
|
||||||
|
| Date | Stripe iOS | En parallèle (UX) |
|
||||||
|
|------|------------|-------------------|
|
||||||
|
| 19/01 | #13 Jour 1 | #204 Design couleurs flashy |
|
||||||
|
| 19/01 | | #205 Écrans utilisateurs simplifiés |
|
||||||
|
| 20/01 | #13 Jour 2 | #113 Couleur repasses orange |
|
||||||
|
| 20/01 | | #72 Épaisseur police lisibilité |
|
||||||
|
| 21/01 | #13 Jour 3 | #71 Visibilité bouton "Envoyer message" |
|
||||||
|
| 21/01 | | #59 Listing rues invisible (clavier) |
|
||||||
|
| 22/01 | #13 Jour 4 | #46 Figer headers tableau Home |
|
||||||
|
| 22/01 | | #42 Historique adresses cliquables |
|
||||||
|
| 23/01 | #13 Jour 5 | #74 Simplifier DashboardLayout/AppScaffold |
|
||||||
|
| 23/01 | | #110 Supprimer refresh session partiels |
|
||||||
|
| 24/01 | Buffer | #28 Gestion reçus Flutter nouveaux champs |
|
||||||
|
| 25/01 | Buffer | #50 Modifier secteur au clic |
|
||||||
|
| 25/01 | | #41 Secteurs avec membres visible carte |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3 : MAP / CARTE
|
||||||
|
### 26 janvier - 9 février (15 jours) - 28 tâches
|
||||||
|
|
||||||
|
| Date | ID | Tâche |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 26/01 | #206 | Corriger géolocalisation par défaut Rennes |
|
||||||
|
| 26/01 | #22 | S'assurer cache Mapbox en place |
|
||||||
|
| 27/01 | #215 | Mode boussole + carte IGN/satellite zoom max |
|
||||||
|
| 27/01 | #53 | Définir zoom maximal éviter sur-zoom |
|
||||||
|
| 28/01 | #37 | Clic sur la carte pour créer un passage |
|
||||||
|
| 28/01 | #61 | Valider passage directement depuis carte |
|
||||||
|
| 29/01 | #51 | Déplacer markers double-clic |
|
||||||
|
| 29/01 | #115 | Déplacement marker sans bouton Enregistrer |
|
||||||
|
| 30/01 | #123 | Déplacer rapidement un pointeur |
|
||||||
|
| 30/01 | #58 | Points carte devant textes (z-index) |
|
||||||
|
| 31/01 | #55 | Optimiser précision GPS mode terrain |
|
||||||
|
| 31/01 | #56 | Mode Web : se déplacer sur carte terrain |
|
||||||
|
| 01/02 | #57 | Mode terrain smartphone : zoom auto |
|
||||||
|
| 01/02 | #60 | Recherche rue hors proximité |
|
||||||
|
| 02/02 | #209 | Filtres Particuliers / Entreprises |
|
||||||
|
| 02/02 | #216 | Vérifier géolocalisation nouveau passage |
|
||||||
|
| 03/02 | #217 | Chercher adresse hors secteur |
|
||||||
|
| 03/02 | #49 | Secteur sans membre |
|
||||||
|
| 04/02 | #25 | Membres affectés en 1er modif secteur |
|
||||||
|
| 04/02 | #31 | Gestion ajout/suppression membre secteur |
|
||||||
|
| 05/02 | #54 | Style carte type Snapchat |
|
||||||
|
| 05/02 | #210 | Base SIREN géolocalisation entreprises |
|
||||||
|
| 06/02 | #67 | Graphique règlements par secteur |
|
||||||
|
| 06/02 | #104 | Tests multi-départements |
|
||||||
|
| 07/02 | #89 | Page clients paiements en ligne |
|
||||||
|
| 07/02 | #94 | Paiement en ligne formulaire passage |
|
||||||
|
| 08/02 | #96 | Option "Paiement par carte" |
|
||||||
|
| 08/02 | #99 | Paiement Stripe mode hors ligne |
|
||||||
|
| 09/02 | Buffer MAP | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 4 : STRIPE + PASSAGES
|
||||||
|
### 10-21 février (12 jours) - 20 tâches
|
||||||
|
|
||||||
|
| Date | ID | Tâche | Cat |
|
||||||
|
|------|-----|-------|-----|
|
||||||
|
| 10/02 | #92 | 💳 Stripe (config générale) | STRIPE |
|
||||||
|
| 10/02 | #93 | Double configuration Stripe | STRIPE |
|
||||||
|
| 11/02 | #97 | Interface paiement sécurisée intégrée | STRIPE |
|
||||||
|
| 11/02 | #98 | Génération auto reçu après paiement | STRIPE |
|
||||||
|
| 13/02 | #207 | Dashboard clic card règlement filtrer | |
|
||||||
|
| 13/02 | #208 | Type règlement Virement bancaire à ajouter | |
|
||||||
|
| 14/02 | #62 | 📋 Gestion des passages | PASSAGE |
|
||||||
|
| 14/02 | #16 | Modifier passage sur l'application | PASSAGE |
|
||||||
|
| 15/02 | #40 | Suppression lot de passages | PASSAGE |
|
||||||
|
| 15/02 | #63 | Corbeille passages admin | PASSAGE |
|
||||||
|
| 16/02 | #64 | Supprimer passages sauvegardés | PASSAGE |
|
||||||
|
| 16/02 | #66 | Récupérer passages supprimés | PASSAGE |
|
||||||
|
| 17/02 | #65 | Désactiver envoi reçu temporaire | PASSAGE |
|
||||||
|
| 17/02 | #118 | Prévenir habitants du passage | PASSAGE |
|
||||||
|
| 18/02 | #119 | Historique montant année précédente | PASSAGE |
|
||||||
|
| 19/02 | #81 | Ralentissement suppressions amicales | BUG |
|
||||||
|
| 19/02 | #219 | Double authentification super-admin (fk_role=9) | ADMIN |
|
||||||
|
| 20-21/02 | Buffer | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 5 : ADMIN + MEMBRES
|
||||||
|
### 22 février - 6 mars (13 jours) - 29 tâches
|
||||||
|
|
||||||
|
| Date | ID | Tâche | Cat |
|
||||||
|
|------|-----|-------|-----|
|
||||||
|
| 22/02 | #79 | 👑 Mode Super Admin | ADMIN |
|
||||||
|
| 22/02 | #80 | FAQ gérée depuis Super-Admin | ADMIN |
|
||||||
|
| 23/02 | #76 | Accès admin limité web uniquement | ADMIN |
|
||||||
|
| 23/02 | #77 | Choisir rôle admin/membre connexion | ADMIN |
|
||||||
|
| 24/02 | #78 | Admin peut se connecter utilisateur | ADMIN |
|
||||||
|
| 24/02 | #82 | Optimiser purge données | ADMIN |
|
||||||
|
| 25/02 | #83 | Filtres liste amicales | ADMIN |
|
||||||
|
| 25/02 | #85 | Distinguer amicales actives | ADMIN |
|
||||||
|
| 26/02 | #24 | Trier liste membres | ADMIN |
|
||||||
|
| 26/02 | #29 | Filtres liste membres | ADMIN |
|
||||||
|
| 27/02 | #33 | Communication membres <-> admin | ADMIN |
|
||||||
|
| 27/02 | #70 | Revoir chat complet | ADMIN |
|
||||||
|
| 28/02 | #108 | MQTT temps réel ⭐⭐⭐ | ADMIN |
|
||||||
|
| 28/02 | #43 | Nb amicales partenariat ODP | ADMIN |
|
||||||
|
| 01/03 | #211 | Modifier lots avec montants | ADMIN |
|
||||||
|
| 01/03 | #218 | Tests montée charge Poissy | ADMIN |
|
||||||
|
| 02/03 | #15 | Nouveau membre non synchronisé | MEMBRE |
|
||||||
|
| 02/03 | #23 | Emails failed intégrer base | MEMBRE |
|
||||||
|
| 03/03 | #26 | Figer membres combobox | MEMBRE |
|
||||||
|
| 03/03 | #27 | Autocomplete combobox membres | MEMBRE |
|
||||||
|
| 04/03 | #30 | Membres sélectionnés haut liste | MEMBRE |
|
||||||
|
| 04/03 | #32 | Modifier identifiant utilisateur | MEMBRE |
|
||||||
|
| 05/03 | #34 | Email non obligatoire | MEMBRE |
|
||||||
|
| 05/03 | #36 | Textes aide fiches membres | MEMBRE |
|
||||||
|
| 06/03 | #90 | 📧 Processus inscription | MEMBRE |
|
||||||
|
| 06/03 | #91 | 2 emails séparés inscription | MEMBRE |
|
||||||
|
| 06/03 | #117 | Prénoms accents majuscule | MEMBRE |
|
||||||
|
| 06/03 | #122 | Modif rapide email renvoi reçu | MEMBRE |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 6 : EXPORT + COM + DIVERS
|
||||||
|
### 7-16 mars (10 jours) - 30 tâches
|
||||||
|
|
||||||
|
| Date | ID | Tâche | Cat |
|
||||||
|
|------|-----|-------|-----|
|
||||||
|
| 07/03 | #45 | Home filtres et graphes | EXPORT |
|
||||||
|
| 07/03 | #47 | Home bouton export données | EXPORT |
|
||||||
|
| 07/03 | #48 | Export par membre | EXPORT |
|
||||||
|
| 08/03 | #68 | Comparatif année précédente | EXPORT |
|
||||||
|
| 08/03 | #212 | Bergerac logs + export Excel | EXPORT |
|
||||||
|
| 09/03 | #35 | Bouton alerte 3s messagerie | COM |
|
||||||
|
| 09/03 | #109 | SMS impératif ⭐⭐⭐ | COM |
|
||||||
|
| 10/03 | #69 | Bloquer création opération | OPER |
|
||||||
|
| 10/03 | #86 | Suppression opé réactiver précédente | OPER |
|
||||||
|
| 10/03 | #87 | 🏢 Gestion Clients | OPER |
|
||||||
|
| 11/03 | #88 | Écran Clients créer/améliorer | OPER |
|
||||||
|
| 11/03 | #116 | Remarque sous adresse | OPER |
|
||||||
|
| 11/03 | #214 | Opérations afficher texte | OPER |
|
||||||
|
| 12/03 | #102 | Compatibilité appareils test | TEST |
|
||||||
|
| 12/03 | #103 | 🧪 Tests | TEST |
|
||||||
|
| 12/03 | #213 | Lots montant nb calendriers Poissy | TEST |
|
||||||
|
| 13/03 | #21 | Requêtes en attente dupliquées | AUTRE |
|
||||||
|
| 13/03 | #38 | Parrainage | AUTRE |
|
||||||
|
| 13/03 | #39 | Multilingue ? | AUTRE |
|
||||||
|
| 14/03 | #44 | Envoi contrat | AUTRE |
|
||||||
|
| 14/03 | #52 | Même adresse par niveau | AUTRE |
|
||||||
|
| 14/03 | #73 | Reconnaissance biométrique | AUTRE |
|
||||||
|
| 14/03 | #75 | Refactoriser responsabilités | AUTRE |
|
||||||
|
| 15/03 | #84 | Mode démo présentations | AUTRE |
|
||||||
|
| 15/03 | #105 | 🌍 Internationalization | AUTRE |
|
||||||
|
| 15/03 | #106 | Devises Franc Suisse | AUTRE |
|
||||||
|
| 15/03 | #107 | 📡 Fonctionnalités futures | AUTRE |
|
||||||
|
| 16/03 | #111 | iwanttobealone | AUTRE |
|
||||||
|
| 16/03 | #112 | db-backup site 256 | AUTRE |
|
||||||
|
| 16/03 | #114 | Liste adresses mail d6soft/unikoffice | AUTRE |
|
||||||
|
| 16/03 | #120 | Double auth faceId/touchId | AUTRE |
|
||||||
|
| 16/03 | #121 | Recette (lien) | AUTRE |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RÉCAPITULATIF
|
||||||
|
|
||||||
|
| Phase | Période | Jours | Tâches | Focus |
|
||||||
|
|-------|---------|-------|--------|-------|
|
||||||
|
| 1 | 16-18/01 | 3 | 5 | Bugs critiques |
|
||||||
|
| 2 | 19-25/01 | 7 | 14 | **Stripe iOS #13** + UX |
|
||||||
|
| 3 | 26/01-09/02 | 15 | 28 | **MAP / Carte** |
|
||||||
|
| 4 | 10-21/02 | 12 | 20 | Stripe + Passages |
|
||||||
|
| 5 | 22/02-06/03 | 13 | 29 | Admin + Membres |
|
||||||
|
| 6 | 07-16/03 | 10 | 30 | Export + Divers |
|
||||||
|
| **TOTAL** | **60 jours** | | **126** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Jalons clés
|
||||||
|
|
||||||
|
- **18/01** : Bugs critiques résolus
|
||||||
|
- **23/01** : Tests Stripe iOS terminés
|
||||||
|
- **09/02** : Carte/Map finalisée
|
||||||
|
- **21/02** : Paiements + Passages OK
|
||||||
|
- **06/03** : Admin + Membres OK
|
||||||
|
- **16/03** : **Livraison Q1 complète**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Rythme : ~2 tâches/jour en moyenne
|
||||||
|
- Weekends = buffer si besoin
|
||||||
|
- Tâches ⭐⭐⭐ (#108 MQTT, #109 SMS) intégrées dans le planning
|
||||||
|
- La tâche #13 (Stripe iOS) reste bloquante pour paiement mobile
|
||||||
97
docs/geosector-point-20251230.txt
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
Geosector point du 30/12/2025
|
||||||
|
|
||||||
|
Dans le choix des types d'un passage
|
||||||
|
Choisir le type de ce passage
|
||||||
|
"A repasser"
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Design : revoir les couleurs pour qu'elles soient aussi flashy que l'ancienne version utilisateur
|
||||||
|
Rendre les écrans des utilisateurs les plus simples possibles sans saisie de filtre, etc...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Historique :
|
||||||
|
|
||||||
|
Carte : sur Rennes la geolocalisation par défaut à revoir
|
||||||
|
Filtres uniquement sur la page dashboard
|
||||||
|
Clic sur la card des types de règlement pour filtrer les passages
|
||||||
|
|
||||||
|
Type de règlemenent : ajout Virement bancaire
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Filtres sur la carte :
|
||||||
|
Particuliers
|
||||||
|
Entreprises
|
||||||
|
|
||||||
|
Base SIREN : voir si géolocalisation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Option : donner le montant du passage de l'année dernière
|
||||||
|
Lorsque l'utilisateur saisit son passage il peut voir le montant de l'année dernière sur ce passage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Pour les administrateurs : donner un max de possibilités
|
||||||
|
|
||||||
|
- déplacer un pointeur
|
||||||
|
- modifier les lots avec des montants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bergerac : voir les logs + export Excel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Export Excel : extractions Excel enrichies : feuilles par secteur, villes, membre avec graphiques ?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
SMS : à mettre en place avec envoi de reçu. Obligation ?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Lot : gestion à revoir : montant et nombre de calendriers sur Poissy ?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Gestion des opérations : mettre le texte directement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Carte : créer un passage en cliquant directement sur la carte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Carte : mode boussole et carte IGN avec mode satellite avec zoom max à définir
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Si nouveau passage : voir la géolocalisation !
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Carte : repositionner les pointeurs sur la carte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Admin : Dessin des secteurs sans affecter les secteurs et puis esuite cliquer sur un secteur pour affecter les membres
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
En utilisateur, pouvoir chercher une adresse hors de ses secteurs et la sélectionner pour saisir un passage effectué
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Amicale et membres : filtre sur un nom d'amicaliste
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Faire des tests sur l'amicale de Poissy : 7878787878 : montée en charge et stabilité de l'API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Configuration du serveur hôte Debian 12
|
|
||||||
HOST_SSH_HOST=195.154.80.116 # Adresse IP du serveur hôte
|
|
||||||
HOST_SSH_USER=pierre # Utilisateur SSH sur le serveur hôte
|
|
||||||
HOST_SSH_PORT=22 # Port SSH du serveur hôte
|
|
||||||
HOST_SSH_KEY=/home/pierre/.ssh/id_rsa_mbpi # Clé SSH privée pour accéder au serveur hôte
|
|
||||||
|
|
||||||
# Configuration du conteneur Incus hébergeant cette application
|
|
||||||
CT_PROJECT_NAME=default # Nom du projet Incus où se trouve le conteneur
|
|
||||||
CT_NAME=dva-geo # Nom du conteneur Incus
|
|
||||||
CT_IP=13.23.33.43 # IP interne du conteneur Incus
|
|
||||||
CT_SSH_USER=root # Utilisateur SSH dans le conteneur
|
|
||||||
CT_SSH_PORT=22 # Port SSH interne du conteneur
|
|
||||||
CT_SSH_KEY=/root/.ssh/id_rsa_in3_pierre # Clé SSH privée pour accéder au conteneur
|
|
||||||
|
|
||||||
# Configuration de l'application
|
|
||||||
DOMAIN_NAME=dev.geosector.fr # Nom de domaine du site
|
|
||||||
SERVER_PORT=3000 # Port du serveur Node.js
|
|
||||||
ADMIN_PORT=3001 # Port du serveur d'administration
|
|
||||||
DEPLOY_DIR=/var/www # Répertoire de déploiement sur le conteneur
|
|
||||||
APP_NAME=geosector # Nom de l'application et du fichier de config nginx
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Build Commands
|
|
||||||
- Development: `npm run dev` - Start Vite development server with hot-reload
|
|
||||||
- Build: `npm run build` - Build production-ready static files to `dist/` directory
|
|
||||||
- Preview: `npm run preview` - Preview production build locally
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
This is a Svelte + Vite + TailwindCSS project without SvelteKit. Key characteristics:
|
|
||||||
- Pure Svelte application with custom client-side routing
|
|
||||||
- TailwindCSS for styling with custom Figtree font family
|
|
||||||
- No server-side rendering or API routes
|
|
||||||
- Static site deployment via `deploy-web.sh`
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
- **Routing**: Custom client-side routing implemented in `App.svelte` using browser history API
|
|
||||||
- **Pages**: Located in `src/pages/` - each page is a Svelte component
|
|
||||||
- **Components**: Reusable components in `src/components/`
|
|
||||||
- **Services**: Business logic in `src/lib/` (analytics, cookies)
|
|
||||||
- **Styling**: TailwindCSS with configuration in `tailwind.config.js`
|
|
||||||
- **Assets**: Static assets in `public/` (fonts, images, icons)
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
- Pages are added to `src/pages/` and imported in `App.svelte`
|
|
||||||
- Route handling is done through the `activePage` state variable
|
|
||||||
- Cookie consent and analytics tracking are integrated throughout
|
|
||||||
- Static deployment copies `dist/` contents to production server
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
- Production deployment script: `deploy-web.sh`
|
|
||||||
- Builds and packages the application for deployment to Incus container
|
|
||||||
- Environment variables loaded from `.env-deploy-geosector-dev`
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Svelte + Vite
|
|
||||||
|
|
||||||
This template should help get you started developing with Svelte in Vite.
|
|
||||||
|
|
||||||
## Recommended IDE Setup
|
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
|
||||||
|
|
||||||
## Need an official Svelte framework?
|
|
||||||
|
|
||||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
|
||||||
|
|
||||||
## Technical considerations
|
|
||||||
|
|
||||||
**Why use this over SvelteKit?**
|
|
||||||
|
|
||||||
- It brings its own routing solution which might not be preferable for some users.
|
|
||||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
|
||||||
|
|
||||||
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
|
||||||
|
|
||||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
|
||||||
|
|
||||||
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
|
||||||
|
|
||||||
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
|
||||||
|
|
||||||
**Why include `.vscode/extensions.json`?**
|
|
||||||
|
|
||||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
|
||||||
|
|
||||||
**Why enable `checkJs` in the JS template?**
|
|
||||||
|
|
||||||
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
|
||||||
|
|
||||||
**Why is HMR not preserving my local component state?**
|
|
||||||
|
|
||||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
|
||||||
|
|
||||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// store.js
|
|
||||||
// An extremely simple external store
|
|
||||||
import { writable } from 'svelte/store'
|
|
||||||
export default writable(0)
|
|
||||||
```
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Script de déploiement unifié pour GEOSECTOR Web (Svelte)
|
|
||||||
# Version: 4.0 (Janvier 2025)
|
|
||||||
# Auteur: Pierre (avec l'aide de Claude)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./deploy-web.sh # Déploiement local DEV (build → container geo)
|
|
||||||
# ./deploy-web.sh rca # Livraison RECETTE (container geo → rca-geo)
|
|
||||||
# ./deploy-web.sh pra # Livraison PRODUCTION (rca-geo → pra-geo)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd /home/pierre/dev/geosector/web
|
|
||||||
|
|
||||||
# =====================================
|
|
||||||
# Configuration générale
|
|
||||||
# =====================================
|
|
||||||
|
|
||||||
# Paramètre optionnel pour l'environnement cible
|
|
||||||
TARGET_ENV=${1:-dev}
|
|
||||||
|
|
||||||
# Configuration SSH
|
|
||||||
HOST_KEY="/home/pierre/.ssh/id_rsa_mbpi"
|
|
||||||
HOST_PORT="22"
|
|
||||||
HOST_USER="root"
|
|
||||||
|
|
||||||
# Configuration des serveurs
|
|
||||||
RCA_HOST="195.154.80.116" # Serveur de recette
|
|
||||||
PRA_HOST="51.159.7.190" # Serveur de production
|
|
||||||
|
|
||||||
# Configuration Incus
|
|
||||||
INCUS_PROJECT="default"
|
|
||||||
WEB_PATH="/var/www/geosector/web"
|
|
||||||
FINAL_OWNER="nginx"
|
|
||||||
FINAL_GROUP="nginx"
|
|
||||||
|
|
||||||
# Configuration de sauvegarde
|
|
||||||
BACKUP_DIR="/data/backup/geosector"
|
|
||||||
|
|
||||||
# Couleurs pour les messages
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
YELLOW='\033[0;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# =====================================
|
|
||||||
# Fonctions utilitaires
|
|
||||||
# =====================================
|
|
||||||
|
|
||||||
echo_step() {
|
|
||||||
echo -e "${GREEN}==>${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo_info() {
|
|
||||||
echo -e "${BLUE}Info:${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo_warning() {
|
|
||||||
echo -e "${YELLOW}Warning:${NC} $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo_error() {
|
|
||||||
echo -e "${RED}Error:${NC} $1"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fonction pour créer une sauvegarde locale
|
|
||||||
create_local_backup() {
|
|
||||||
local archive_file=$1
|
|
||||||
local backup_type=$2
|
|
||||||
|
|
||||||
echo_info "Creating backup in ${BACKUP_DIR}..."
|
|
||||||
|
|
||||||
if [ ! -d "${BACKUP_DIR}" ]; then
|
|
||||||
mkdir -p "${BACKUP_DIR}" || echo_warning "Could not create backup directory ${BACKUP_DIR}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "${BACKUP_DIR}" ]; then
|
|
||||||
BACKUP_FILE="${BACKUP_DIR}/web-${backup_type}-$(date +%Y%m%d-%H%M%S).tar.gz"
|
|
||||||
cp "${archive_file}" "${BACKUP_FILE}" && {
|
|
||||||
echo_info "Backup saved to: ${BACKUP_FILE}"
|
|
||||||
echo_info "Backup size: $(du -h "${BACKUP_FILE}" | cut -f1)"
|
|
||||||
|
|
||||||
# Nettoyer les anciens backups (garder les 10 derniers)
|
|
||||||
echo_info "Cleaning old backups (keeping last 10)..."
|
|
||||||
ls -t "${BACKUP_DIR}"/web-${backup_type}-*.tar.gz 2>/dev/null | tail -n +11 | xargs -r rm -f && {
|
|
||||||
REMAINING_BACKUPS=$(ls "${BACKUP_DIR}"/web-${backup_type}-*.tar.gz 2>/dev/null | wc -l)
|
|
||||||
echo_info "Kept ${REMAINING_BACKUPS} backup(s)"
|
|
||||||
}
|
|
||||||
} || echo_warning "Failed to create backup in ${BACKUP_DIR}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# =====================================
|
|
||||||
# Détermination de la configuration selon l'environnement
|
|
||||||
# =====================================
|
|
||||||
|
|
||||||
case $TARGET_ENV in
|
|
||||||
"dev")
|
|
||||||
echo_step "Configuring for LOCAL DEV deployment"
|
|
||||||
SOURCE_TYPE="local_build"
|
|
||||||
DEST_CONTAINER="geo"
|
|
||||||
DEST_HOST="local"
|
|
||||||
ENV_NAME="DEVELOPMENT"
|
|
||||||
;;
|
|
||||||
"rca")
|
|
||||||
echo_step "Configuring for RECETTE delivery"
|
|
||||||
SOURCE_TYPE="local_container"
|
|
||||||
SOURCE_CONTAINER="geo"
|
|
||||||
DEST_CONTAINER="rca-geo"
|
|
||||||
DEST_HOST="${RCA_HOST}"
|
|
||||||
ENV_NAME="RECETTE"
|
|
||||||
;;
|
|
||||||
"pra")
|
|
||||||
echo_step "Configuring for PRODUCTION delivery"
|
|
||||||
SOURCE_TYPE="remote_container"
|
|
||||||
SOURCE_HOST="${RCA_HOST}"
|
|
||||||
SOURCE_CONTAINER="rca-geo"
|
|
||||||
DEST_CONTAINER="pra-geo"
|
|
||||||
DEST_HOST="${PRA_HOST}"
|
|
||||||
ENV_NAME="PRODUCTION"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo_error "Unknown environment: $TARGET_ENV. Use 'dev', 'rca' or 'pra'"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo_info "Deployment flow: ${ENV_NAME}"
|
|
||||||
|
|
||||||
# =====================================
|
|
||||||
# Création de l'archive selon la source
|
|
||||||
# =====================================
|
|
||||||
|
|
||||||
TIMESTAMP=$(date +%s)
|
|
||||||
ARCHIVE_NAME="web-deploy-${TIMESTAMP}.tar.gz"
|
|
||||||
TEMP_ARCHIVE="/tmp/${ARCHIVE_NAME}"
|
|
||||||
|
|
||||||
if [ "$SOURCE_TYPE" = "local_build" ]; then
|
|
||||||
# DEV: Build Svelte et créer une archive
|
|
||||||
echo_step "Building Svelte app for DEV..."
|
|
||||||
|
|
||||||
# Variables du projet
|
|
||||||
BUILD_DIR="dist"
|
|
||||||
SERVER_DIR="server"
|
|
||||||
LOCAL_DEPLOY_DIR="deploy"
|
|
||||||
|
|
||||||
# Installer les dépendances si nécessaire
|
|
||||||
if [ ! -d "node_modules" ] || [ ! -f "package-lock.json" ]; then
|
|
||||||
echo_info "Installing dependencies..."
|
|
||||||
npm install || echo_error "npm install failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build du frontend principal
|
|
||||||
echo_info "Building frontend..."
|
|
||||||
npm run build || echo_error "Build failed"
|
|
||||||
|
|
||||||
# Vérifier que le build a réussi
|
|
||||||
if [ ! -d "$BUILD_DIR" ]; then
|
|
||||||
echo_error "Build directory not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Préparer le package de déploiement
|
|
||||||
echo_info "Preparing deployment package..."
|
|
||||||
rm -rf $LOCAL_DEPLOY_DIR
|
|
||||||
mkdir -p $LOCAL_DEPLOY_DIR
|
|
||||||
|
|
||||||
# Copier les fichiers frontend
|
|
||||||
cp -r $BUILD_DIR/* $LOCAL_DEPLOY_DIR/
|
|
||||||
|
|
||||||
# Préparer le dossier serveur si nécessaire
|
|
||||||
if [ -d "$SERVER_DIR" ]; then
|
|
||||||
echo_info "Preparing server files..."
|
|
||||||
mkdir -p $LOCAL_DEPLOY_DIR/server
|
|
||||||
cp -r $SERVER_DIR/package.json $LOCAL_DEPLOY_DIR/server/ 2>/dev/null || echo_warning "package.json not found"
|
|
||||||
cp -r $SERVER_DIR/server.js $LOCAL_DEPLOY_DIR/server/ 2>/dev/null || echo_warning "server.js not found"
|
|
||||||
cp -r $SERVER_DIR/.env $LOCAL_DEPLOY_DIR/server/ 2>/dev/null || echo_warning ".env not found"
|
|
||||||
mkdir -p $LOCAL_DEPLOY_DIR/server/logs
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Créer l'archive
|
|
||||||
echo_info "Creating archive..."
|
|
||||||
COPYFILE_DISABLE=1 tar --exclude=".*" -czf "${TEMP_ARCHIVE}" -C $LOCAL_DEPLOY_DIR . || echo_error "Failed to create archive"
|
|
||||||
|
|
||||||
create_local_backup "${TEMP_ARCHIVE}" "dev"
|
|
||||||
|
|
||||||
# Nettoyer
|
|
||||||
rm -rf $LOCAL_DEPLOY_DIR
|
|
||||||
|
|
||||||
elif [ "$SOURCE_TYPE" = "local_container" ]; then
|
|
||||||
# RCA: Créer une archive depuis le container local
|
|
||||||
echo_step "Creating archive from local container ${SOURCE_CONTAINER}..."
|
|
||||||
|
|
||||||
echo_info "Switching to Incus project ${INCUS_PROJECT}..."
|
|
||||||
incus project switch ${INCUS_PROJECT} || echo_error "Failed to switch project"
|
|
||||||
|
|
||||||
# Créer l'archive directement depuis le container local
|
|
||||||
incus exec ${SOURCE_CONTAINER} -- tar -czf /tmp/${ARCHIVE_NAME} -C ${WEB_PATH} . || echo_error "Failed to create archive from container"
|
|
||||||
|
|
||||||
# Récupérer l'archive depuis le container
|
|
||||||
incus file pull ${SOURCE_CONTAINER}/tmp/${ARCHIVE_NAME} ${TEMP_ARCHIVE} || echo_error "Failed to pull archive from container"
|
|
||||||
incus exec ${SOURCE_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME}
|
|
||||||
|
|
||||||
create_local_backup "${TEMP_ARCHIVE}" "to-rca"
|
|
||||||
|
|
||||||
elif [ "$SOURCE_TYPE" = "remote_container" ]; then
|
|
||||||
# PRA: Créer une archive depuis un container distant
|
|
||||||
echo_step "Creating archive from remote container ${SOURCE_CONTAINER} on ${SOURCE_HOST}..."
|
|
||||||
|
|
||||||
# Créer l'archive sur le serveur source
|
|
||||||
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "
|
|
||||||
incus project switch ${INCUS_PROJECT} &&
|
|
||||||
incus exec ${SOURCE_CONTAINER} -- tar -czf /tmp/${ARCHIVE_NAME} -C ${WEB_PATH} .
|
|
||||||
" || echo_error "Failed to create archive on remote"
|
|
||||||
|
|
||||||
# Extraire l'archive du container vers l'hôte
|
|
||||||
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "
|
|
||||||
incus file pull ${SOURCE_CONTAINER}/tmp/${ARCHIVE_NAME} /tmp/${ARCHIVE_NAME} &&
|
|
||||||
incus exec ${SOURCE_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME}
|
|
||||||
" || echo_error "Failed to extract archive from remote container"
|
|
||||||
|
|
||||||
# Copier l'archive vers la machine locale pour backup
|
|
||||||
scp -i ${HOST_KEY} -P ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST}:/tmp/${ARCHIVE_NAME} ${TEMP_ARCHIVE} || echo_error "Failed to copy archive locally"
|
|
||||||
|
|
||||||
create_local_backup "${TEMP_ARCHIVE}" "to-pra"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ARCHIVE_SIZE=$(du -h "${TEMP_ARCHIVE}" | cut -f1)
|
|
||||||
echo_info "Archive size: ${ARCHIVE_SIZE}"
|
|
||||||
|
|
||||||
# =====================================
|
|
||||||
# Déploiement selon la destination
|
|
||||||
# =====================================
|
|
||||||
|
|
||||||
if [ "$DEST_HOST" = "local" ]; then
|
|
||||||
# Déploiement sur container local (DEV)
|
|
||||||
echo_step "Deploying to local container ${DEST_CONTAINER}..."
|
|
||||||
|
|
||||||
echo_info "Switching to Incus project ${INCUS_PROJECT}..."
|
|
||||||
incus project switch ${INCUS_PROJECT} || echo_error "Failed to switch to project ${INCUS_PROJECT}"
|
|
||||||
|
|
||||||
echo_info "Pushing archive to container..."
|
|
||||||
incus file push "${TEMP_ARCHIVE}" ${DEST_CONTAINER}/tmp/${ARCHIVE_NAME} || echo_error "Failed to push archive to container"
|
|
||||||
|
|
||||||
echo_info "Preparing deployment directory..."
|
|
||||||
incus exec ${DEST_CONTAINER} -- mkdir -p ${WEB_PATH} || echo_error "Failed to create deployment directory"
|
|
||||||
incus exec ${DEST_CONTAINER} -- rm -rf ${WEB_PATH}/* || echo_warning "Could not clean deployment directory"
|
|
||||||
|
|
||||||
echo_info "Extracting archive..."
|
|
||||||
incus exec ${DEST_CONTAINER} -- tar -xzf /tmp/${ARCHIVE_NAME} -C ${WEB_PATH}/ || echo_error "Failed to extract archive"
|
|
||||||
|
|
||||||
echo_info "Setting permissions..."
|
|
||||||
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_GROUP} ${WEB_PATH}
|
|
||||||
incus exec ${DEST_CONTAINER} -- find ${WEB_PATH} -type d -exec chmod 755 {} \;
|
|
||||||
incus exec ${DEST_CONTAINER} -- find ${WEB_PATH} -type f -exec chmod 644 {} \;
|
|
||||||
|
|
||||||
# Permissions spéciales pour les dossiers server
|
|
||||||
incus exec ${DEST_CONTAINER} -- sh -c "
|
|
||||||
if [ -f ${WEB_PATH}/server/server.js ]; then
|
|
||||||
chmod +x ${WEB_PATH}/server/server.js
|
|
||||||
fi
|
|
||||||
if [ -d ${WEB_PATH}/server/logs ]; then
|
|
||||||
chmod 775 ${WEB_PATH}/server/logs
|
|
||||||
fi
|
|
||||||
" || true
|
|
||||||
|
|
||||||
# Installer les dépendances du serveur si présent
|
|
||||||
echo_info "Installing server dependencies if needed..."
|
|
||||||
incus exec ${DEST_CONTAINER} -- sh -c "
|
|
||||||
if [ -d ${WEB_PATH}/server ] && [ -f ${WEB_PATH}/server/package.json ]; then
|
|
||||||
cd ${WEB_PATH}/server && npm install --production
|
|
||||||
fi
|
|
||||||
" || echo_warning "Server dependencies installation skipped"
|
|
||||||
|
|
||||||
echo_info "Cleaning up..."
|
|
||||||
incus exec ${DEST_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME}
|
|
||||||
|
|
||||||
else
|
|
||||||
# Déploiement sur container distant (RCA ou PRA)
|
|
||||||
echo_step "Deploying to remote container ${DEST_CONTAINER} on ${DEST_HOST}..."
|
|
||||||
|
|
||||||
# Créer une sauvegarde sur le serveur de destination
|
|
||||||
BACKUP_TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
|
||||||
REMOTE_BACKUP_DIR="${WEB_PATH}_backup_${BACKUP_TIMESTAMP}"
|
|
||||||
|
|
||||||
echo_info "Creating backup on destination..."
|
|
||||||
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${DEST_HOST} "
|
|
||||||
incus project switch ${INCUS_PROJECT} &&
|
|
||||||
incus exec ${DEST_CONTAINER} -- test -d ${WEB_PATH} &&
|
|
||||||
incus exec ${DEST_CONTAINER} -- cp -r ${WEB_PATH} ${REMOTE_BACKUP_DIR} &&
|
|
||||||
echo 'Backup created: ${REMOTE_BACKUP_DIR}'
|
|
||||||
" || echo_warning "No existing installation to backup"
|
|
||||||
|
|
||||||
# Transférer l'archive vers le serveur de destination
|
|
||||||
echo_info "Transferring archive to ${DEST_HOST}..."
|
|
||||||
|
|
||||||
if [ "$SOURCE_TYPE" = "local_container" ]; then
|
|
||||||
# Pour RCA: copier depuis local vers distant
|
|
||||||
scp -i ${HOST_KEY} -P ${HOST_PORT} ${TEMP_ARCHIVE} ${HOST_USER}@${DEST_HOST}:/tmp/${ARCHIVE_NAME} || echo_error "Failed to copy archive to destination"
|
|
||||||
else
|
|
||||||
# Pour PRA: copier de serveur à serveur
|
|
||||||
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "
|
|
||||||
scp -i ${HOST_KEY} -P ${HOST_PORT} /tmp/${ARCHIVE_NAME} ${HOST_USER}@${DEST_HOST}:/tmp/${ARCHIVE_NAME}
|
|
||||||
" || echo_error "Failed to transfer archive between servers"
|
|
||||||
|
|
||||||
# Nettoyer sur le serveur source
|
|
||||||
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "rm -f /tmp/${ARCHIVE_NAME}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Déployer sur le container de destination
|
|
||||||
echo_info "Extracting on destination container..."
|
|
||||||
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${DEST_HOST} "
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Pousser l'archive dans le container
|
|
||||||
incus project switch ${INCUS_PROJECT} &&
|
|
||||||
incus file push /tmp/${ARCHIVE_NAME} ${DEST_CONTAINER}/tmp/${ARCHIVE_NAME} &&
|
|
||||||
|
|
||||||
# Nettoyer et recréer le dossier
|
|
||||||
incus exec ${DEST_CONTAINER} -- rm -rf ${WEB_PATH} &&
|
|
||||||
incus exec ${DEST_CONTAINER} -- mkdir -p ${WEB_PATH} &&
|
|
||||||
|
|
||||||
# Extraire l'archive
|
|
||||||
incus exec ${DEST_CONTAINER} -- tar -xzf /tmp/${ARCHIVE_NAME} -C ${WEB_PATH}/ &&
|
|
||||||
|
|
||||||
# Permissions
|
|
||||||
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_GROUP} ${WEB_PATH} &&
|
|
||||||
incus exec ${DEST_CONTAINER} -- find ${WEB_PATH} -type d -exec chmod 755 {} \; &&
|
|
||||||
incus exec ${DEST_CONTAINER} -- find ${WEB_PATH} -type f -exec chmod 644 {} \; &&
|
|
||||||
|
|
||||||
# Permissions spéciales pour server
|
|
||||||
incus exec ${DEST_CONTAINER} -- sh -c '
|
|
||||||
if [ -f ${WEB_PATH}/server/server.js ]; then
|
|
||||||
chmod +x ${WEB_PATH}/server/server.js
|
|
||||||
fi
|
|
||||||
if [ -d ${WEB_PATH}/server/logs ]; then
|
|
||||||
chmod 775 ${WEB_PATH}/server/logs
|
|
||||||
fi
|
|
||||||
' || true &&
|
|
||||||
|
|
||||||
# Installer les dépendances du serveur si présent
|
|
||||||
incus exec ${DEST_CONTAINER} -- sh -c '
|
|
||||||
if [ -d ${WEB_PATH}/server ] && [ -f ${WEB_PATH}/server/package.json ]; then
|
|
||||||
cd ${WEB_PATH}/server && npm install --production
|
|
||||||
fi
|
|
||||||
' || echo 'Server dependencies skipped' &&
|
|
||||||
|
|
||||||
# Nettoyage
|
|
||||||
incus exec ${DEST_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME} &&
|
|
||||||
rm -f /tmp/${ARCHIVE_NAME}
|
|
||||||
" || echo_error "Deployment failed on destination"
|
|
||||||
|
|
||||||
echo_info "Remote backup saved: ${REMOTE_BACKUP_DIR} on ${DEST_CONTAINER}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Nettoyage local
|
|
||||||
rm -f "${TEMP_ARCHIVE}"
|
|
||||||
|
|
||||||
# =====================================
|
|
||||||
# Résumé final
|
|
||||||
# =====================================
|
|
||||||
|
|
||||||
echo_step "Deployment completed successfully!"
|
|
||||||
echo_info "Environment: ${ENV_NAME}"
|
|
||||||
|
|
||||||
if [ "$TARGET_ENV" = "dev" ]; then
|
|
||||||
echo_info "Built and deployed Svelte web app to container ${DEST_CONTAINER}"
|
|
||||||
elif [ "$TARGET_ENV" = "rca" ]; then
|
|
||||||
echo_info "Delivered from ${SOURCE_CONTAINER} (local) to ${DEST_CONTAINER} on ${DEST_HOST}"
|
|
||||||
elif [ "$TARGET_ENV" = "pra" ]; then
|
|
||||||
echo_info "Delivered from ${SOURCE_CONTAINER} on ${SOURCE_HOST} to ${DEST_CONTAINER} on ${DEST_HOST}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo_info "Deployment completed at: $(date)"
|
|
||||||
|
|
||||||
# Journaliser le déploiement
|
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - Web app deployed to ${ENV_NAME} (${DEST_CONTAINER})" >> ~/.geo_deploy_history
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB |