2 Commits

Author SHA1 Message Date
232940b1eb feat: Version 3.6.2 - Correctifs tâches #17-20
- #17: Amélioration gestion des secteurs et statistiques
- #18: Optimisation services API et logs
- #19: Corrections Flutter widgets et repositories
- #20: Fix création passage - détection automatique ope_users.id vs users.id

Suppression dossier web/ (migration vers app Flutter)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 14:11:15 +01:00
pierre
7b78037175 chore: Suppression définitive du dossier opendata 2025-11-09 19:26:50 +01:00
196 changed files with 8483 additions and 7966 deletions

153
HOWTO-PROKOV.md Normal file
View 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
```

View File

@@ -1 +1 @@
3.5.2
3.6.2

View File

@@ -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
#### 7. Amélioration de la suppression des utilisateurs

View File

@@ -36,7 +36,7 @@ FINAL_OWNER_LOGS="nobody"
FINAL_GROUP_LOGS="nginx"
# Configuration de sauvegarde
BACKUP_DIR="/data/backup/geosector/api"
BACKUP_DIR="/home/pierre/samba/back/geosector/api"
# Couleurs pour les messages
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 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} -- 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 f -exec chmod 640 {} \; &&
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 664 {} \; &&
# Permissions spéciales pour 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)
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)
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
# GEOSECTOR API - Event stats aggregation (daily at 1am)
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
# Installer le nouveau crontab

View File

@@ -89,7 +89,75 @@ PUT /api/users/123 // users.id
1. **Reçus fiscaux** : PDF auto (<5KB) pour dons, envoi email queue
2. **Logos entités** : Upload PNG/JPG, redimensionnement 250x250px, base64
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
@@ -172,4 +240,4 @@ DELETE FROM operations WHERE id = 850;
---
**Mis à jour : 26 Octobre 2025**
**Mis à jour : 22 Décembre 2025**

View File

@@ -157,12 +157,21 @@ register_shutdown_function(function() use ($requestUri, $requestMethod) {
// Alerter sur les erreurs 500
if ($statusCode >= 500) {
$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', [
'endpoint' => $requestUri,
'method' => $requestMethod,
'error_message' => $error['message'] ?? 'Unknown error',
'error_file' => $error['file'] ?? 'Unknown',
'error_message' => $errorMessage,
'error_file' => $error['file'] ?? 'N/A',
'error_line' => $error['line'] ?? 0,
'stack_trace' => 'Consulter logs/app.log pour le stack trace complet',
'message' => "Erreur serveur 500 sur $requestUri"
], 'ERROR');
}

View File

@@ -94,30 +94,7 @@ Ce dossier contient les scripts automatisés de maintenance et de traitement pou
---
### 5. `update_stripe_devices.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`
### 5. `sync_databases.php`
**Fonction** : Synchronise les bases de données entre environnements
@@ -175,9 +152,6 @@ crontab -e
# 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
# 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
@@ -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_logs.log` : Nettoyage des anciens fichiers logs
- `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
@@ -216,9 +189,6 @@ tail -n 50 /var/www/geosector/api/logs/cleanup_logs.log
# Voir les dernières rotations des logs événements
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
```
---

View 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);

View File

@@ -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";
}
}

View 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';

View 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
--
-- ============================================================

View 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;

View 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;
}
}

View File

@@ -2086,18 +2086,38 @@ class LoginController {
], 201);
}
} catch (Exception $e) {
// Vérifier si une transaction est active avant de faire rollback
if ($this->db->inTransaction()) {
$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',
'error' => $e->getMessage(),
'email' => $email,
'amicaleName' => $amicaleName,
'postalCode' => $postalCode
]);
'exception_class' => get_class($e),
'error_message' => $e->getMessage(),
'error_code' => $e->getCode(),
'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([
'status' => 'error',
'message' => $e->getMessage()
'message' => $userMessage
], 500);
return;
}

View File

@@ -8,6 +8,7 @@ require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/ReceiptService.php';
require_once __DIR__ . '/../Services/SectorService.php';
use PDO;
use PDOException;
@@ -19,6 +20,7 @@ use Session;
use App\Services\LogService;
use App\Services\EventLogService;
use App\Services\ApiService;
use App\Services\SectorService;
use Exception;
use DateTime;
@@ -516,14 +518,26 @@ class PassageController {
}
// 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'];
// 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('
SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ?
');
$stmtOpeUser->execute([$passageUserId, $operationId]);
$opeUserId = $stmtOpeUser->fetchColumn();
}
if (!$opeUserId) {
Response::json([
@@ -533,6 +547,88 @@ class PassageController {
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
$encryptedName = '';
if (isset($data['name']) && !empty(trim($data['name']))) {
@@ -549,7 +645,7 @@ class PassageController {
// Préparation des données pour l'insertion
$insertData = [
'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_adresse' => $data['fk_adresse'] ?? '',
'passed_at' => isset($data['passed_at']) ? $data['passed_at'] : null,
@@ -562,8 +658,8 @@ class PassageController {
'appt' => $data['appt'] ?? '',
'niveau' => $data['niveau'] ?? '',
'residence' => $data['residence'] ?? '',
'gps_lat' => $data['gps_lat'] ?? '',
'gps_lng' => $data['gps_lng'] ?? '',
'gps_lat' => $gpsLat ?? '',
'gps_lng' => $gpsLng ?? '',
'encrypted_name' => $encryptedName,
'montant' => isset($data['montant']) ? (float)$data['montant'] : 0.00,
'fk_type_reglement' => isset($data['fk_type_reglement']) ? (int)$data['fk_type_reglement'] : 1,
@@ -596,7 +692,7 @@ class PassageController {
EventLogService::logPassageCreated(
(int)$passageId,
$insertData['fk_operation'],
$insertData['fk_sector'],
$insertData['fk_sector'] ?? 0, // 0 si secteur non trouvé
$insertData['montant'],
(string)$insertData['fk_type_reglement']
);

View File

@@ -15,14 +15,12 @@ require_once __DIR__ . '/../Services/ApiService.php';
class SectorController
{
private \PDO $db;
private LogService $logService;
private AddressService $addressService;
private DepartmentBoundaryService $boundaryService;
public function __construct()
{
$this->db = Database::getInstance();
$this->logService = new LogService();
$this->addressService = new AddressService();
$this->boundaryService = new DepartmentBoundaryService();
}
@@ -72,7 +70,7 @@ class SectorController
Response::json(['status' => 'success', 'data' => $sectors]);
} 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(),
'trace' => $e->getTraceAsString()
]);
@@ -152,14 +150,14 @@ class SectorController
$departmentsTouched = $this->boundaryService->getDepartmentsForSector($coordinates);
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'],
'entity_id' => $entityId,
'entity_dept' => $departement
]);
}
} 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(),
'libelle' => $data['libelle']
]);
@@ -169,7 +167,7 @@ class SectorController
try {
$addressCount = $this->addressService->countAddressesInPolygon($coordinates, $entityId);
} 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(),
'libelle' => $data['libelle'],
'entity_id' => $entityId
@@ -208,7 +206,7 @@ class SectorController
$opeUserId = $stmtOpeUser->fetchColumn();
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,
'operation_id' => $operationId
]);
@@ -275,7 +273,7 @@ class SectorController
}
} 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,
'error' => $e->getMessage()
]);
@@ -335,7 +333,7 @@ class SectorController
$firstOpeUserId = $stmtFirstOpeUser->fetchColumn();
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],
'operation_id' => $operationId
]);
@@ -401,7 +399,7 @@ class SectorController
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
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'],
'nb_passages' => $nbLog,
'gps_lat' => $gpsLat,
@@ -410,7 +408,7 @@ class SectorController
]);
}
} 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'],
'error' => $e->getMessage()
]);
@@ -421,7 +419,7 @@ class SectorController
}
} catch (\Exception $e) {
// 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,
'error' => $e->getMessage(),
'entity_id' => $entityId
@@ -525,7 +523,7 @@ class SectorController
$responseData['users_sectors'][] = $userData;
}
$this->logService->info('Secteur créé', [
LogService::info('Secteur créé', [
'sector_id' => $sectorId,
'libelle' => $sectorData['libelle'],
'entity_id' => $entityId,
@@ -567,7 +565,7 @@ class SectorController
if ($this->db->inTransaction()) {
$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(),
'data' => $data ?? null
]);
@@ -634,7 +632,7 @@ class SectorController
// Gestion des membres (reçus comme 'users' depuis Flutter)
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,
'users_demandes' => $data['users'],
'nb_users' => count($data['users'])
@@ -642,27 +640,27 @@ class SectorController
// Récupérer l'opération du secteur pour l'INSERT
$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,
'params' => ['sector_id' => $id]
]);
$opStmt = $this->db->prepare($opQuery);
$opStmt->execute(['sector_id' => $id]);
$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
]);
// Supprimer les affectations existantes
$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,
'params' => ['sector_id' => $id]
]);
$deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute(['sector_id' => $id]);
$deletedCount = $deleteStmt->rowCount();
$this->logService->info('[UPDATE USERS] Membres supprimés', [
LogService::info('[UPDATE USERS] Membres supprimés', [
'nb_deleted' => $deletedCount
]);
@@ -670,7 +668,7 @@ class SectorController
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)
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
]);
$insertStmt = $this->db->prepare($insertQuery);
@@ -689,7 +687,7 @@ class SectorController
$opeUserId = $stmtOpeUser->fetchColumn();
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,
'operation_id' => $operationId
]);
@@ -703,17 +701,17 @@ class SectorController
'sector_id' => $id,
'user_creat' => $_SESSION['user_id'] ?? null
];
$this->logService->info('[UPDATE USERS] SQL - INSERT user', [
LogService::info('[UPDATE USERS] SQL - INSERT user', [
'params' => $params
]);
$insertStmt->execute($params);
$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
]);
} catch (\PDOException $e) {
$failedUsers[] = $memberId;
$this->logService->warning('[UPDATE USERS] ERREUR insertion user', [
LogService::warning('[UPDATE USERS] ERREUR insertion user', [
'sector_id' => $id,
'user_id' => $memberId,
'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_inseres' => $insertedUsers,
'users_echoues' => $failedUsers,
@@ -744,7 +742,7 @@ class SectorController
$chkAdressesChange = $data['chk_adresses_change'] ?? 1;
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,
'chk_adresses_change' => $chkAdressesChange
]);
@@ -770,7 +768,7 @@ class SectorController
}
// 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,
'entity_id' => $entityId,
'nb_points' => count($coordinates)
@@ -781,7 +779,7 @@ class SectorController
// Enrichir les adresses avec les données bâtiments
$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,
'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,
'nb_stored' => count($addresses)
]);
} 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,
'entity_id' => $entityId
]);
@@ -828,19 +826,19 @@ class SectorController
// Vérifier si c'est un problème de connexion à la base d'adresses
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
]);
}
} 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,
'error' => $e->getMessage()
]);
}
// 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']);
}
@@ -934,7 +932,7 @@ class SectorController
WHERE ous.fk_sector = :sector_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,
'params' => ['sector_id' => $id]
]);
@@ -944,7 +942,7 @@ class SectorController
$usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC);
$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,
'users_ids' => $userIds,
'nb_users' => count($userIds),
@@ -971,7 +969,7 @@ class SectorController
$usersDecrypted[] = $userData;
}
$this->logService->info('Secteur modifié', [
LogService::info('Secteur modifié', [
'sector_id' => $id,
'updates' => array_keys($data),
'passage_counters' => $passageCounters,
@@ -999,7 +997,7 @@ class SectorController
if ($this->db->inTransaction()) {
$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,
'error' => $e->getMessage()
]);
@@ -1065,7 +1063,7 @@ class SectorController
]);
} 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,
'error' => $e->getMessage()
]);
@@ -1198,7 +1196,7 @@ class SectorController
$passagesDecrypted[] = $passage;
}
$this->logService->info('Secteur supprimé', [
LogService::info('Secteur supprimé', [
'sector_id' => $id,
'libelle' => $sector['libelle'],
'passages_deleted' => $passagesToDelete,
@@ -1216,7 +1214,7 @@ class SectorController
} catch (\Exception $e) {
$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,
'error' => $e->getMessage()
]);
@@ -1238,7 +1236,7 @@ class SectorController
]);
} 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()
]);
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
@@ -1298,7 +1296,7 @@ class SectorController
]);
} 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()
]);
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
@@ -1422,7 +1420,7 @@ class SectorController
$addressesStmt->execute(['sector_id' => $sectorId]);
$addresses = $addressesStmt->fetchAll();
$this->logService->info('[updatePassagesForSector] Adresses dans sectors_adresses', [
LogService::info('[updatePassagesForSector] Adresses dans sectors_adresses', [
'sector_id' => $sectorId,
'nb_addresses' => count($addresses)
]);
@@ -1435,7 +1433,7 @@ class SectorController
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
if ($firstUserId && !empty($addresses)) {
$this->logService->info('[updatePassagesForSector] Traitement des passages', [
LogService::info('[updatePassagesForSector] Traitement des passages', [
'user_id' => $firstUserId,
'nb_addresses' => count($addresses)
]);
@@ -1594,7 +1592,7 @@ class SectorController
$insertStmt->execute($insertParams);
$counters['passages_created'] = count($toInsert);
} 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,
'error' => $e->getMessage()
]);
@@ -1658,12 +1656,12 @@ class SectorController
$counters['passages_updated'] = count($toUpdate);
// 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),
'sector_id' => $sectorId
]);
} 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,
'error' => $e->getMessage()
]);
@@ -1680,7 +1678,7 @@ class SectorController
$deleteStmt->execute($toDelete);
$counters['passages_deleted'] += count($toDelete);
} 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,
'error' => $e->getMessage()
]);
@@ -1688,7 +1686,7 @@ class SectorController
}
} 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',
'first_user_id' => $firstUserId,
'nb_addresses' => count($addresses)
@@ -1697,14 +1695,14 @@ class SectorController
// Retourner les compteurs détaillés
$this->logService->info('[updatePassagesForSector] Fin traitement', [
LogService::info('[updatePassagesForSector] Fin traitement', [
'sector_id' => $sectorId,
'counters' => $counters
]);
return $counters;
} 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,
'error' => $e->getMessage()
]);

View File

@@ -196,7 +196,8 @@ class StripeController extends Controller {
SELECT p.*, o.fk_entite, o.id as operation_id
FROM ope_pass p
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()]);
$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
* 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());
}
}
/**
* 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());
}
}
}

View File

@@ -202,86 +202,47 @@ class StripeWebhookController extends Controller {
* Gérer un paiement réussi
*/
private function handlePaymentIntentSucceeded($paymentIntent): void {
// Mettre à jour le statut en base
$stmt = $this->db->prepare(
"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
]);
// Pour Tap to Pay, le paiement est instantané et déjà enregistré dans ope_pass
// Ce webhook sert principalement pour les Payment Links (QR Code) asynchrones
// Enregistrer dans l'historique
$stmt = $this->db->prepare(
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
);
// Vérifier si le passage existe et mettre à jour si nécessaire
$stmt = $this->db->prepare("
SELECT id FROM ope_pass
WHERE stripe_payment_id = :pi_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, '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
])
]);
}
if ($passage) {
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency} - Passage {$passage['id']}");
// TODO: Envoyer un reçu par email
// TODO: Mettre à jour les statistiques en temps réel
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency}");
} else {
error_log("Payment succeeded: {$paymentIntent->id} but no passage found in ope_pass");
}
}
/**
* Gérer un paiement échoué
*/
private function handlePaymentIntentFailed($paymentIntent): void {
// Mettre à jour le statut
$stmt = $this->db->prepare(
"UPDATE stripe_payment_intents
SET status = :status, updated_at = NOW()
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"
);
// Vérifier si le passage existe
$stmt = $this->db->prepare("
SELECT id FROM ope_pass
WHERE stripe_payment_id = :pi_id
");
$stmt->execute(['pi_id' => $paymentIntent->id]);
$localPayment = $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
])
]);
}
$passage = $stmt->fetch();
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));
}
}
/**
* 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
*/
private function handleTerminalReaderActionSucceeded($reader): void {
// Mettre à jour le statut du reader
$stmt = $this->db->prepare(
"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
]);
// Note: Pour Tap to Pay, il n'y a pas de readers physiques
// Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
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
*/
private function handleTerminalReaderActionFailed($reader): void {
// Mettre à jour le statut du reader
$stmt = $this->db->prepare(
"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
]);
// Note: Pour Tap to Pay, il n'y a pas de readers physiques
// Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
error_log("Terminal reader action failed: {$reader->id}");
}
}

View File

@@ -135,13 +135,13 @@ class Router {
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
// Tap to Pay - Vérification compatibilité et configuration
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
// Tap to Pay - Configuration
$this->post('stripe/locations', ['StripeController', 'createLocation']);
$this->post('stripe/terminal/connection-token', ['StripeController', 'createConnectionToken']);
// Paiements
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
$this->post('stripe/payments/cancel', ['StripeController', 'cancelPayment']);
$this->post('stripe/payment-links', ['StripeController', 'createPaymentLink']);
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
@@ -152,6 +152,14 @@ class Router {
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
$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)
$this->get('migrations/test-connections', ['MigrationController', 'testConnections']);
$this->get('migrations/entities/available', ['MigrationController', 'getAvailableEntities']);

View File

@@ -21,19 +21,16 @@ class AddressService
{
private ?PDO $addressesDb = null;
private PDO $mainDb;
private $logService;
private $buildingService;
public function __construct()
{
$this->logService = new LogService();
try {
$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) {
// 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(),
'trace' => $e->getTraceAsString()
]);
@@ -94,13 +91,13 @@ class AddressService
{
// Si pas de connexion à la base d'adresses, retourner un tableau vide
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
]);
return [];
}
$this->logService->info('[AddressService] Début recherche adresses', [
LogService::info('[AddressService] Début recherche adresses', [
'entity_id' => $entityId,
'nb_coordinates' => count($coordinates)
]);
@@ -117,11 +114,11 @@ class AddressService
// Si aucun département n'est trouvé par analyse spatiale,
// chercher d'abord dans le département de l'entité et ses limitrophes
$entityDept = $this->getDepartmentForEntity($entityId);
$this->logService->info('[AddressService] Département de l\'entité', [
LogService::info('[AddressService] Département de l\'entité', [
'departement' => $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
]);
throw new RuntimeException("Impossible de déterminer le département");
@@ -131,7 +128,7 @@ class AddressService
$priorityDepts = $boundaryService->getPriorityDepartments($entityDept);
// 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)
]);
@@ -204,7 +201,7 @@ class AddressService
}
// Log pour debug
$this->logService->info('[AddressService] Recherche dans table', [
LogService::info('[AddressService] Recherche dans table', [
'table' => $tableName,
'departement' => $deptCode,
'nb_adresses' => count($addresses)
@@ -212,7 +209,7 @@ class AddressService
} catch (PDOException $e) {
// Log l'erreur mais continue avec les autres départements
$this->logService->error('[AddressService] Erreur SQL', [
LogService::error('[AddressService] Erreur SQL', [
'table' => $tableName,
'departement' => $deptCode,
'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)
]);
return $allAddresses;
@@ -243,7 +240,7 @@ class AddressService
return [];
}
$this->logService->info('[AddressService] Début enrichissement avec bâtiments', [
LogService::info('[AddressService] Début enrichissement avec bâtiments', [
'entity_id' => $entityId,
'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),
'nb_immeubles' => $nbImmeubles,
'nb_maisons' => $nbMaisons
@@ -271,7 +268,7 @@ class AddressService
return $enrichedAddresses;
} catch (\Exception $e) {
$this->logService->error('[AddressService] Erreur lors de l\'enrichissement', [
LogService::error('[AddressService] Erreur lors de l\'enrichissement', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);

View File

@@ -231,7 +231,7 @@ class ApiService {
* @param int $minLength Longueur minimale du nom d'utilisateur (par défaut 10)
* @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
$name = preg_replace('/[^a-zA-Z0-9]/', '', strtolower($name));
$postalCode = preg_replace('/[^0-9]/', '', $postalCode);
@@ -277,7 +277,7 @@ class ApiService {
// Vérifier si le nom d'utilisateur existe déjà
$stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE encrypted_user_name = ?');
$stmt->execute([$encryptedUsername]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($result && $result['count'] == 0) {
$isUnique = true;

View File

@@ -14,9 +14,9 @@ class EmailTemplates {
Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br>
<b>Identifiant :</b> $username<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>
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>
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>
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>
L'équipe GeoSector";
L'équipe GeoSector<br>
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
}
/**

View File

@@ -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 ====================
/**

View 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;
}
}

View File

@@ -58,7 +58,7 @@ class ExportService {
$filepath = $exportDir . '/' . $filename;
// Créer le spreadsheet
$spreadsheet = new PhpOffice\PhpSpreadsheet\Spreadsheet();
$spreadsheet = new Spreadsheet();
// Insérer les données
$this->createPassagesSheet($spreadsheet, $operationId, $userId);
@@ -283,11 +283,11 @@ class ExportService {
$dateEve = $passage['passed_at'] ? date('d/m/Y', strtotime($passage['passed_at'])) : '';
$heureEve = $passage['passed_at'] ? date('H:i', strtotime($passage['passed_at'])) : '';
// Déchiffrer les données
$donateur = ApiService::decryptData($passage['encrypted_name']);
// Déchiffrer les données (avec vérification null)
$donateur = !empty($passage['encrypted_name']) ? ApiService::decryptData($passage['encrypted_name']) : '';
$email = !empty($passage['encrypted_email']) ? ApiService::decryptSearchableData($passage['encrypted_email']) : '';
$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
$typeLabels = [
@@ -382,7 +382,7 @@ class ExportService {
foreach ($users as $user) {
$rowData = [
$user['id'],
ApiService::decryptData($user['encrypted_name']),
!empty($user['encrypted_name']) ? ApiService::decryptData($user['encrypted_name']) : '',
$user['first_name'],
!empty($user['encrypted_email']) ? ApiService::decryptSearchableData($user['encrypted_email']) : '',
!empty($user['encrypted_phone']) ? ApiService::decryptData($user['encrypted_phone']) : '',
@@ -480,7 +480,7 @@ class ExportService {
$row = 2;
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;
$rowData = [
@@ -690,11 +690,11 @@ class ExportService {
$dateEve = $p["passed_at"] ? date("d/m/Y", strtotime($p["passed_at"])) : "";
$heureEve = $p["passed_at"] ? date("H:i", strtotime($p["passed_at"])) : "";
// Déchiffrer les données
$donateur = ApiService::decryptData($p["encrypted_name"]);
// Déchiffrer les données (avec vérification null)
$donateur = !empty($p["encrypted_name"]) ? ApiService::decryptData($p["encrypted_name"]) : "";
$email = !empty($p["encrypted_email"]) ? ApiService::decryptSearchableData($p["encrypted_email"]) : "";
$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)
$nom = str_replace("/", "-", $userName);

View File

@@ -8,6 +8,12 @@ use AppConfig;
use ClientDetector;
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 {
// Obtenir les informations client via ClientDetector
$clientInfo = ClientDetector::getClientInfo();
@@ -67,12 +73,10 @@ class LogService {
// Créer le dossier logs s'il n'existe pas
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}");
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
@@ -139,26 +143,29 @@ class LogService {
$message
]) . "\n";
// Écrire dans le fichier avec gestion d'erreur
if (file_put_contents($filename, $logLine, FILE_APPEND) === false) {
// Écrire dans le fichier avec gestion d'erreur et verrouillage
if (file_put_contents($filename, $logLine, FILE_APPEND | LOCK_EX) === false) {
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) {
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';
self::log($message, $metadata);
}
public function warning(string $message, array $metadata = []): void {
public static function warning(string $message, array $metadata = []): void {
$metadata['level'] = 'warning';
self::log($message, $metadata);
}
public function error(string $message, array $metadata = []): void {
public static function error(string $message, array $metadata = []): void {
$metadata['level'] = 'error';
self::log($message, $metadata);
}

View 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;
}
}

View File

@@ -465,6 +465,7 @@ class StripeService {
$entiteId = $params['fk_entite'] ?? 0;
$userId = $params['fk_user'] ?? 0;
$metadata = $params['metadata'] ?? [];
$paymentMethodTypes = $params['payment_method_types'] ?? ['card_present'];
if ($amount < 100) {
throw new Exception("Le montant minimum est de 1€");
@@ -481,117 +482,51 @@ class StripeService {
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
$paymentIntent = $this->stripe->paymentIntents->create([
// Configuration du PaymentIntent selon le mode
$paymentIntentData = [
'amount' => $amount,
'currency' => 'eur',
'payment_method_types' => ['card_present'],
'payment_method_types' => $paymentMethodTypes,
'capture_method' => 'automatic',
// Pas d'application_fee_amount - tout va à l'amicale
'transfer_data' => [
'destination' => $account['stripe_account_id'],
],
'metadata' => array_merge($metadata, [
'entite_id' => $entiteId,
'user_id' => $userId,
'calendrier_annee' => date('Y'),
]),
]);
];
// Sauvegarder en base
$stmt = $this->db->prepare(
"INSERT INTO stripe_payment_intents
(stripe_payment_intent_id, fk_entite, fk_user, amount, currency, status, application_fee, metadata, created_at)
VALUES (:pi_id, :fk_entite, :fk_user, :amount, :currency, :status, :app_fee, :metadata, NOW())"
// Options Stripe (avec ou sans stripe_account)
$stripeOptions = [];
if ($isTapToPay) {
// 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,
'fk_entite' => $entiteId,
'fk_user' => $userId,
'amount' => $amount,
'currency' => 'eur',
'status' => $paymentIntent->status,
'app_fee' => 0, // Pas de commission
'metadata' => json_encode($metadata)
]);
// Note : Le payment_intent_id est sauvegardé dans ope_pass.stripe_payment_id par le controller
return [
'success' => true,
'client_secret' => $paymentIntent->client_secret,
'payment_intent_id' => $paymentIntent->id,
'amount' => $amount,
'application_fee' => 0 // Pas de commission
];
} 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)
'mode' => $isTapToPay ? 'tap_to_pay' : 'payment_link'
];
} 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)
*/

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,208 +1,143 @@
# 🍎 Guide de Build iOS - GEOSECTOR
**Date de création** : 21/10/2025
**Version actuelle** : 3.4.2 (Build 342)
**Dernière mise à jour** : 16/11/2025
**Version système** : Workflow automatisé depuis Debian
---
## 📋 **Prérequis**
## 📋 Prérequis
### Sur le Mac mini
-macOS installé
-Xcode installé avec Command Line Tools
-Flutter installé (3.24.5 LTS recommandé)
- ✅ CocoaPods installé (`sudo gem install cocoapods`)
- ✅ Certificats Apple configurés (Team ID: 6WT84NWCTC)
### Mac mini (192.168.1.34)
-Xcode + Command Line Tools
-Flutter 3.24.5 LTS
-CocoaPods installé
- ✅ Certificats Apple (Team: **6WT84NWCTC**)
### Sur Debian
- ✅ Accès SSH au Mac mini (192.168.1.34)
-rsync installé
### PC Debian (développement)
- ✅ Accès SSH au Mac mini
-Fichier `../VERSION` à jour
---
## 🚀 **Procédure complète**
## 🚀 Build iOS - Workflow complet
### **Étape 1 : Transfert depuis Debian vers Mac mini**
### **Commande unique depuis Debian**
```bash
# Sur votre machine Debian
cd /home/pierre/dev/geosector/app
# Lancer le transfert
./transfer-to-mac.sh
./ios.sh
```
**Ce que fait le script** :
1. Détecte automatiquement la version (ex: 342)
2. Crée le dossier `app_342` sur le Mac mini
3. Transfert tous les fichiers nécessaires (lib, ios, pubspec.yaml, etc.)
4. Exclut les dossiers inutiles (build, .dart_tool, Pods, etc.)
**Durée** : 2-5 minutes (selon la connexion réseau)
**Note** : Vous devrez saisir le mot de passe du Mac mini
1. ✅ Lit `../VERSION` (ex: 3.5.3)
2. ✅ Met à jour `pubspec.yaml` (3.5.3+353)
3. ✅ Teste connexion Mac mini
4. ✅ Transfert rsync → `/Users/pierre/dev/geosector/app_353/`
5. 🔀 **Choix A** : Lance build SSH automatique
6. 🔀 **Choix B** : Instructions manuelles
---
### **É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
# Depuis Debian
ssh pierre@192.168.1.34
# Aller dans le dossier transféré
cd /Users/pierre/dev/geosector/app_342
```
---
### **Étape 3 : Lancer le build iOS**
```bash
# Sur le Mac mini
cd /Users/pierre/dev/geosector/app_353
./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. ⏳ Attendre le chargement (quelques secondes)
2. ✅ Vérifier **Signing & Capabilities** : Team = 6WT84NWCTC, "Automatically manage signing" coché
3. 🧹 **Product > Clean Build Folder** (Cmd+Shift+K)
4. 📦 **Product > Archive**
5. ⏳ Attendre l'archive (5-10 minutes)
6. 📤 **Organizer** s'ouvre → Clic **Distribute App**
7. ☁️ Choisir **App Store Connect**
8.**Upload** → Automatique
9. 🚀 **Next** jusqu'à validation finale
**⚠️ Ne PAS utiliser xcodebuild en ligne de commande** : erreurs de signature (errSecInternalComponent). Xcode GUI obligatoire.
1. **Apps** > **GeoSector** > **TestFlight**
2. ⏳ Attendre traitement (5-15 min)
3. Build **353 (3.5.3)** apparaît
4. **Conformité export** :
- Utilise chiffrement ? → **Oui**
- Algorithmes exempts ? → **Aucun des algorithmes mentionnés**
5. **Testeurs internes** → Ajouter ton Apple ID
6. 📧 Invitation TestFlight envoyée
---
## 📁 **Structure des dossiers sur Mac mini**
## ✅ Checklist rapide
```
/Users/pierre/dev/geosector/
├── app_342/ # Version 3.4.2 (Build 342)
│ ├── ios/
│ ├── lib/
│ ├── pubspec.yaml
│ ├── ios-build-mac.sh # Script de build
│ └── 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
- [ ] Mettre à jour `../VERSION` (ex: 3.5.4)
- [ ] Lancer `./ios.sh` depuis Debian
- [ ] Archive créée dans Xcode
- [ ] Upload vers App Store Connect
- [ ] Conformité export renseignée
- [ ] Testeur interne ajouté
- [ ] App installée via TestFlight
---
## 🔧 **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
# Vérifier que Flutter est dans le PATH
echo $PATH | grep flutter
# Ajouter Flutter au PATH (dans ~/.zshrc ou ~/.bash_profile)
export PATH="$PATH:/opt/flutter/bin"
source ~/.zshrc
### Erreur de signature Xcode
```
Signing & Capabilities > Team = 6WT84NWCTC
"Automatically manage signing" ✅
```
### **Erreur : "xcodebuild not found"**
### Pod install échoue
```bash
# Installer Xcode Command Line Tools
xcode-select --install
```
### **Erreur lors de pod install**
```bash
# Sur le Mac mini
cd ios
rm -rf Pods Podfile.lock
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`
- [ ] Compilation iOS réussie
- [ ] Archive validée dans Xcode Organizer
- [ ] Build uploadé vers App Store Connect
- [ ] **TestFlight** : Ajouter build au groupe "Testeurs externes"
- [ ] Renseigner "Infos sur l'exportation de conformité" :
- **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
```mermaid
Debian (dev) → Mac mini (build) → App Store Connect → TestFlight → iPhone
│ │ │ │ │
↓ ↓ ↓ ↓ ↓
ios.sh build iOS Upload Traitement Install
+ Archive (5-15 min)
```
**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
- **TestFlight** : App dans l'App Store
- **Flutter iOS** : https://docs.flutter.dev/deployment/ios
---
**Prêt pour la production !** 🚀
**Prêt pour TestFlight !** 🚀

View File

@@ -57,25 +57,142 @@ if ! command -v flutter &> /dev/null; then
exit 1
fi
# Récupérer la version depuis pubspec.yaml
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | sed 's/+/-/')
if [ -z "$VERSION" ]; then
print_error "Impossible de récupérer la version depuis pubspec.yaml"
# Étape 0 : Synchroniser la version depuis ../VERSION
print_message "Étape 0/5 : Synchronisation de la version..."
echo
VERSION_FILE="../VERSION"
if [ ! -f "$VERSION_FILE" ]; then
print_error "Fichier VERSION introuvable : $VERSION_FILE"
exit 1
fi
# Extraire le version code
VERSION_CODE=$(echo $VERSION | cut -d'-' -f2)
# 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 d'extraire le version code"
print_error "Impossible de calculer le versionCode"
exit 1
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"
echo
# Vérifier la présence du keystore
# Demander le mode Debug ou Release
print_message "========================================="
print_message " MODE DE BUILD"
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
# Demander le mode R8 SEULEMENT si Release
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
@@ -90,9 +207,31 @@ fi
print_success "Configuration de signature vérifiée"
echo
fi
# Activer R8 si demandé (modification temporaire du build.gradle.kts)
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
print_message "Étape 1/4 : Nettoyage du projet..."
print_message "Étape 1/5 : Nettoyage du projet..."
flutter clean
if [ $? -eq 0 ]; then
print_success "Projet nettoyé"
@@ -103,7 +242,7 @@ fi
echo
# É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
if [ $? -eq 0 ]; then
print_success "Dépendances récupérées"
@@ -114,7 +253,7 @@ fi
echo
# É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 || {
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
@@ -128,9 +267,9 @@ print_success "Analyse du code terminée"
echo
# É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..."
flutter build appbundle --release
flutter build appbundle $BUILD_MODE_FLAG
if [ $? -eq 0 ]; then
print_success "Bundle généré avec succès"
else
@@ -139,15 +278,23 @@ else
fi
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éé
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
print_error "Bundle introuvable à l'emplacement attendu : $BUNDLE_PATH"
exit 1
fi
# 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"
cp "$BUNDLE_PATH" "$FINAL_NAME"
@@ -162,6 +309,47 @@ else
exit 1
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
print_message "========================================="
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 "Version : $VERSION"
print_message "Chemin : $(pwd)/$FINAL_NAME"
if [ "$BUILD_MODE_FLAG" = "--debug" ]; then
echo
print_message "Mode : ${YELLOW}Debug${NC}"
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 sur Google Play Console"
print_message "3. Soumettre pour review"
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
# 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
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_message "Génération de l'APK..."
flutter build apk --release
flutter build apk $BUILD_MODE_FLAG
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
APK_NAME="geosector-$VERSION_CODE.apk"
APK_NAME="geosector-$VERSION_CODE-$MODE_SUFFIX.apk"
cp "$APK_PATH" "$APK_NAME"
print_success "APK généré : $APK_NAME"
# Afficher la taille de l'APK
APK_SIZE=$(du -h "$APK_NAME" | cut -f1)
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
else
print_warning "Échec de la génération de l'APK (le bundle a été créé avec succès)"

View File

@@ -55,9 +55,13 @@ android {
buildTypes {
release {
// Optimisations sans ProGuard pour éviter les problèmes
isMinifyEnabled = false
isShrinkResources = false
// Optimisations R8/ProGuard avec règles personnalisées
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Configuration de signature
if (keystorePropertiesFile.exists()) {

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -41,7 +41,7 @@ FINAL_OWNER="nginx"
FINAL_GROUP="nginx"
# Configuration de sauvegarde
BACKUP_DIR="/data/backup/geosector"
BACKUP_DIR="/home/pierre/samba/back/geosector/app/"
# Couleurs pour les messages
GREEN='\033[0;32m'

498
app/deploy-ios-full-auto.sh Executable file
View 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

View File

@@ -278,55 +278,102 @@ Log::info('Stripe account activated', [
## 📱 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
```
┌─────────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────┐
│ App Flutter │ │ API PHP │ │ Stripe │ │ Carte
└──────┬──────┘ └──────┬──────┘ └────┬─────┘ └────┬────┘
│ │ │ │
[1] │ Validation form │ │
│ + montant CB
│ │
[2] │ POST/PUT passage │ │
│──────────────────>│ │ │
│ │
[3] │<──────────────────│ │ │
Passage ID: 456
[4] │ POST create-intent│ │
──────────────────>│ (avec passage_id: 456)
│ │
[5] │ │ Create PaymentIntent
│─────────────────>│
│ │
[6] │ │<─────────────────│
│ pi_xxx + secret │ │
[7] │<──────────────────│ │
PaymentIntent ID
│ │
[8] │ SDK Terminal Init │ │
"Approchez carte"
│ │ │
[9] │<──────────────────────────────────────────────────────│
NFC : Lecture carte sans contact
[10] │ Process Payment │ │ │
───────────────────────────────────>│
│ │
[11] │<───────────────────────────────────│
Payment Success
│ │
[12] │ POST confirm
──────────────────>│ │
│ │
[13] │ PUT passage/456
──────────────────>│ (ajout stripe_payment_id)
│ │
[14] │<──────────────────│ │
Passage updated │ │ │
│ │
┌─────────────┐ ┌─────────────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐
│ App Flutter │ │ DeviceInfo │ │ API │ │ Stripe │ │ SDK Terminal
│ │ Service │ │ PHP │ │ │ │ │
└──────┬──────┘ └────────┬────────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘
│ │ │ │
[1] │ Login utilisateur │
────────────────────>│
│ │ │ │
[2] │ │ checkStripeCertification()
│ • Android SDK ≥ 28
│ │ • NFC disponible
│ │
[3] │<────────────────────│
│ ✅ Compatible │ │ │
│ │ │
[4] │ Validation form
│ + montant CB │ │ │
│ │ │
[5] │ POST/PUT passage
│────────────────────────────────────────>│ │
│ │
[6] │<────────────────────────────────────────
│ Passage ID: 456 │ │ │
│ │
[7] │ initialize()
│────────────────────────────────────────────────────────────────────────────>
│ │
[8] │ │ │ Terminal.initTerminal()
│ │ │ │ │ (fetchToken callback)
│ │ │
[9] │ │ POST /terminal/connection-token
│────────────────────────────────────────>│
{amicale_id, stripe_account, location_id} │
│ │
[10] │ │ │ CreateConnectionToken
│───────────────>│
│ │
[11] │ │<───────────────│
│ │ {secret: "..."}│
│ │
[12] │<────────────────────────────────────────
Connection Token │ │ │
│ │
[13] │────────────────────────────────────────────────────────────────────────────>
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
@@ -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
- **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
#### Étape 1 : VALIDATION DU FORMULAIRE
@@ -1085,28 +1185,168 @@ POST/PUT /api/passages
## 🔄 GESTION DES ERREURS
### 📱 Erreurs Tap to Pay
### 📱 Erreurs Tap to Pay - Messages utilisateur clairs
| Code erreur | Description | Action utilisateur |
|-------------|-------------|-------------------|
| `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 |
L'application détecte automatiquement le type d'erreur et affiche un message adapté :
### 🔄 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
2. Message utilisateur explicite
3. Option "Réessayer" proposée
4. Conservation du montant et contexte
5. Nouveau PaymentIntent si nécessaire
6. Maximum 3 tentatives
1. Erreur détectée → Analyse du type
2. Annulation automatique PaymentIntent (si applicable)
3. Message clair avec conseils contextuels
4. Bouton "Réessayer" disponible
5. Nouveau PaymentIntent créé automatiquement
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

216
app/docs/connexions-api.md Normal file
View 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.

View File

@@ -12,6 +12,9 @@
default_platform(:android)
# Configure PATH pour Homebrew (M1/M2/M4 Mac)
ENV['PATH'] = "/opt/homebrew/bin:#{ENV['PATH']}"
# =============================================================================
# ANDROID
# =============================================================================

View File

@@ -33,9 +33,21 @@ fi
# Récupérer la version depuis pubspec.yaml
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 name :${NC} $VERSION_NUMBER"
echo -e "${YELLOW} Build number :${NC} $VERSION_CODE"
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
echo -e "${YELLOW}🧹 Étape 1/5 : Nettoyage du projet...${NC}"
flutter clean
@@ -50,6 +62,12 @@ echo ""
# Étape 3 : Pod install
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
rm -rf Pods Podfile.lock
pod install --repo-update
@@ -57,10 +75,29 @@ cd ..
echo -e "${GREEN}✓ CocoaPods installés${NC}"
echo ""
# Étape 4 : Build iOS Release
echo -e "${YELLOW}🏗️ Étape 4/5 : Compilation iOS en mode release...${NC}"
flutter build ios --release
echo -e "${GREEN}✓ Compilation terminée${NC}"
# Étape 4 : Build iOS
echo -e "${YELLOW}🏗️ Étape 4/5 : Choix du mode de compilation...${NC}"
echo ""
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 ""
# Étape 5 : Ouvrir Xcode

249
app/ios.sh Executable file
View 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é !"

Binary file not shown.

View File

@@ -488,7 +488,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
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;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
@@ -504,7 +505,7 @@
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
PRODUCT_NAME = "$(TARGET_NAME)";
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_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -680,7 +681,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
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;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
@@ -696,7 +698,7 @@
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
PRODUCT_NAME = "$(TARGET_NAME)";
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_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -710,7 +712,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
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;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
@@ -726,7 +729,7 @@
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
PRODUCT_NAME = "$(TARGET_NAME)";
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_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";

View 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>

View File

@@ -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/operations_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)
final operationRepository = OperationRepository();
@@ -322,6 +323,15 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
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();
},
),
],
),
],

View File

@@ -29,7 +29,8 @@ class ChatService {
Timer? _syncTimer;
DateTime? _lastSyncTimestamp;
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);
/// Initialisation avec gestion des rôles et configuration YAML
@@ -76,9 +77,12 @@ class ChatService {
// Charger le dernier timestamp de sync depuis Hive
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);
debugPrint('✅ Sync initiale complète effectuée au login');
});
// Démarrer la synchronisation incrémentale périodique
_instance!._startSync();
@@ -136,6 +140,13 @@ class ChatService {
/// Obtenir les rooms avec synchronisation incrémentale
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é
if (!connectivityService.isConnected) {
debugPrint('📵 Pas de connexion réseau - utilisation du cache');
@@ -156,15 +167,17 @@ class ChatService {
if (needsFullSync || _lastSyncTimestamp == null) {
// Synchronisation complète
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;
} else {
// Synchronisation incrémentale
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
// debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp');
response = await _dio.get('/chat/rooms', queryParameters: {
'updated_after': isoTimestamp,
});
// response = await _dio.get('/chat/rooms', queryParameters: { // COMMENTÉ - Désactivation GET /chat/rooms
// 'updated_after': isoTimestamp,
// });
return; // Retour anticipé pour éviter l'appel API
}
// Extraire le timestamp de synchronisation fourni par l'API
@@ -348,6 +361,7 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
*/// Fin du code commenté
}
/// 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()
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)

View 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 [];
}

View File

@@ -247,10 +247,10 @@ class OperationRepository extends ChangeNotifier {
debugPrint('✅ Opérations traitées');
}
// Traiter les secteurs (groupe secteurs) via DataLoadingService
if (responseData['secteurs'] != null) {
// Traiter les secteurs (groupe sectors) via DataLoadingService
if (responseData['sectors'] != null) {
await DataLoadingService.instance
.processSectorsFromApi(responseData['secteurs']);
.processSectorsFromApi(responseData['sectors']);
debugPrint('✅ Secteurs traités');
}
@@ -521,10 +521,10 @@ class OperationRepository extends ChangeNotifier {
debugPrint('✅ Opérations traitées');
}
// Traiter les secteurs (groupe secteurs) via DataLoadingService
if (responseData['secteurs'] != null) {
// Traiter les secteurs (groupe sectors) via DataLoadingService
if (responseData['sectors'] != null) {
await DataLoadingService.instance
.processSectorsFromApi(responseData['secteurs']);
.processSectorsFromApi(responseData['sectors']);
debugPrint('✅ Secteurs traités');
}

View File

@@ -246,8 +246,9 @@ class PassageRepository extends ChangeNotifier {
// Mode online : traitement normal
if (response.statusCode == 201 || response.statusCode == 200) {
// Récupérer l'ID du nouveau passage depuis la réponse
final passageId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
// Récupérer l'ID du nouveau passage depuis la réponse (passage_id ou id)
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
final newPassage = passage.copyWith(
@@ -431,10 +432,10 @@ class PassageRepository extends ChangeNotifier {
return true;
}
return false;
throw Exception('Mise à jour refusée par le serveur');
} catch (e) {
debugPrint('Erreur lors de la mise à jour du passage: $e');
return false;
rethrow; // Propager l'exception originale avec son message
} finally {
_isLoading = false;
notifyListeners();

View File

@@ -2,6 +2,9 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
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:flutter/foundation.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
@@ -65,17 +68,45 @@ class ApiService {
headers['X-App-Identifier'] = _appIdentifier;
_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(
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) {
debugPrint('🔑 [API] Token: Bearer ${_sessionId!.substring(0, 20)}...');
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
} else {
debugPrint('⚠️ [API] PAS DE TOKEN - Requête non authentifiée');
}
handler.next(options);
},
onError: (DioException error, handler) {
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;
}
}
handler.next(error);
},
));
@@ -1066,16 +1097,22 @@ class ApiService {
if (data.containsKey('session_id')) {
final sessionId = data['session_id'];
if (sessionId != null) {
debugPrint('🔐 [LOGIN] Token reçu du serveur: $sessionId');
debugPrint('🔐 [LOGIN] Longueur token: ${sessionId.toString().length}');
setSessionId(sessionId);
debugPrint('🔐 [LOGIN] Token stocké dans _sessionId');
// 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((_) {
debugPrint('✅ Informations device collectées et envoyées');
}).catchError((error) {
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
// Ne pas bloquer le login si l'envoi des infos device échoue
});
});
}
}

View File

@@ -1,6 +1,6 @@
// ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
// 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
//
// GEOSECTOR App Version Service
@@ -8,10 +8,10 @@
class AppInfoService {
// 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)
static const String buildNumber = '352';
static const String buildNumber = '362';
// Full version string (format: vx.x.x+xxx)
static String get fullVersion => 'v$version+$buildNumber';

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:battery_plus/battery_plus.dart';
@@ -211,18 +212,18 @@ class DeviceInfoService {
}
bool _checkIosTapToPaySupport(String machine, String systemVersion) {
// iPhone XS et plus récents (liste des identifiants)
final supportedDevices = [
'iPhone11,', // XS, XS Max
'iPhone12,', // 11, 11 Pro, 11 Pro Max
'iPhone13,', // 12 series
'iPhone14,', // 13 series
'iPhone15,', // 14 series
'iPhone16,', // 15 series
];
// Vérifier le modèle : iPhone11,x ou supérieur (iPhone XS+)
// Extraire le numéro majeur de l'identifiant (ex: "iPhone17,3" -> 17)
bool deviceSupported = false;
// Vérifier le modèle
bool deviceSupported = supportedDevices.any((prefix) => machine.startsWith(prefix));
if (machine.startsWith('iPhone')) {
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)
final versionParts = systemVersion.split('.');
@@ -334,10 +335,10 @@ class DeviceInfoService {
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 {
try {
// Sur Web, toujours non certifié
// Sur Web, toujours non supporté
if (kIsWeb) {
debugPrint('📱 Web platform - Tap to Pay non supporté');
return false;
@@ -354,33 +355,35 @@ class DeviceInfoService {
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) {
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}');
try {
final response = await ApiService.instance.post(
'/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érifications préalables de base
if (androidInfo.version.sdkInt < 28) {
debugPrint('❌ Android SDK trop ancien: ${androidInfo.version.sdkInt} (minimum 28)');
return false;
}
// 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;
@@ -390,22 +393,89 @@ class DeviceInfoService {
}
}
/// Vérifie si le device peut utiliser Tap to Pay
bool canUseTapToPay() {
final deviceInfo = getStoredDeviceInfo();
// Vérifications requises
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
// Utiliser la certification Stripe si disponible, sinon l'ancienne vérification
// checkStripeCertification() fait déjà toutes les vérifications (modèle, iOS version, NFC Android)
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
final batteryLevel = deviceInfo['battery_level'] as int?;
// Batterie minimum 10% pour les paiements
final sufficientBattery = batteryLevel != null && batteryLevel >= 10;
return nfcCapable && stripeCertified == true && sufficientBattery;
return stripeCertified == true && sufficientBattery;
}
/// Stream pour surveiller les changements de batterie
Stream<BatteryState> get batteryStateStream => _battery.onBatteryStateChanged;
/// Retourne la raison pour laquelle Tap to Pay n'est pas disponible
/// 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;
}
}

View 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;
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:mek_stripe_terminal/mek_stripe_terminal.dart';
import 'api_service.dart';
import 'device_info_service.dart';
@@ -13,6 +14,7 @@ class StripeTapToPayService {
StripeTapToPayService._internal();
bool _isInitialized = false;
static bool _terminalInitialized = false; // Flag STATIC pour savoir si Terminal.initTerminal a été appelé (global à l'app)
String? _stripeAccountId;
String? _locationId;
bool _deviceCompatible = false;
@@ -78,6 +80,36 @@ class StripeTapToPayService {
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;
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
Future<PaymentIntentResult?> createPaymentIntent({
required int amountInCents,
@@ -124,9 +184,7 @@ class StripeTapToPayService {
// Extraire passage_id des metadata si présent
final passageId = metadata?['passage_id'] ?? '0';
final response = await ApiService.instance.post(
'/stripe/payments/create-intent',
data: {
final requestData = {
'amount': amountInCents,
'currency': 'eur',
'description': description ?? 'Calendrier pompiers',
@@ -138,7 +196,13 @@ class StripeTapToPayService {
'stripe_account': _stripeAccountId,
'location_id': _locationId,
'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(
@@ -169,11 +233,110 @@ class StripeTapToPayService {
}
}
/// Simule le processus de collecte de paiement
/// (Dans la version finale, cela appellera le SDK natif)
/// Découvre et connecte le reader Tap to Pay local
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 {
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(
type: TapToPayStatusType.processing,
@@ -181,11 +344,22 @@ class StripeTapToPayService {
paymentIntentId: paymentIntent.paymentIntentId,
));
// TODO: Ici, intégrer le vrai SDK Stripe Terminal
// Pour l'instant, on simule une attente
await Future.delayed(const Duration(seconds: 2));
// 2. Récupérer le PaymentIntent depuis le SDK avec le clientSecret
debugPrint('💳 Récupération du PaymentIntent...');
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(
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 {
try {
debugPrint('✅ Confirmation du paiement...');
debugPrint('✅ Confirmation du paiement via SDK...');
// Notifier le serveur du succès
await ApiService.instance.post(
'/stripe/payments/confirm',
data: {
'payment_intent_id': paymentIntent.paymentIntentId,
'amount': paymentIntent.amount,
'status': 'succeeded',
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'member_id': CurrentUserService.instance.userId,
},
// Vérifier que le paiement a été collecté
if (paymentIntent._collectedPaymentIntent == null) {
throw Exception('Le paiement doit d\'abord être collecté');
}
// Utiliser le SDK Stripe Terminal pour confirmer le paiement
final confirmedPaymentIntent = await Terminal.instance.confirmPaymentIntent(
paymentIntent._collectedPaymentIntent!,
);
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(
type: TapToPayStatusType.success,
@@ -235,6 +410,9 @@ class StripeTapToPayService {
));
return true;
} else {
throw Exception('Paiement non confirmé: ${confirmedPaymentIntent.status}');
}
} catch (e) {
debugPrint('❌ Erreur confirmation paiement: $e');
@@ -304,6 +482,9 @@ class PaymentIntentResult {
final String clientSecret;
final int amount;
// PaymentIntent collecté (utilisé entre collectPayment et confirmPayment)
PaymentIntent? _collectedPaymentIntent;
PaymentIntentResult({
required this.paymentIntentId,
required this.clientSecret,

View File

@@ -31,6 +31,7 @@ class ApiException implements Exception {
if (response?.data != null) {
try {
final data = response!.data as Map<String, dynamic>;
debugPrint('🔍 API Error Response: $data');
// Message spécifique de l'API
if (data.containsKey('message')) {
@@ -42,12 +43,21 @@ class ApiException implements Exception {
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')) {
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) {
// 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;
/// 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
bool get isValidationError => statusCode == 422 || statusCode == 400;

File diff suppressed because it is too large Load Diff

View File

@@ -807,7 +807,7 @@ class _RegisterPageState extends State<RegisterPage> {
const SizedBox(
height: 16),
Text(
'Vous allez recevoir un email contenant :',
'Vous allez recevoir 2 emails contenant :',
style: theme
.textTheme
.bodyMedium,
@@ -852,7 +852,7 @@ class _RegisterPageState extends State<RegisterPage> {
width: 4),
const Expanded(
child: Text(
'Un lien pour définir votre mot de passe'),
'Votre mot de passe de connexion'),
),
],
),

View 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,
),
);
}
}

View File

@@ -305,6 +305,11 @@ class NavigationHelper {
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
const NavigationDestination(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Connexions',
),
]);
}
@@ -341,6 +346,9 @@ class NavigationHelper {
case 5:
context.go('/admin/operations');
break;
case 6:
context.go('/admin/connexions');
break;
default:
context.go('/admin');
}
@@ -380,6 +388,7 @@ class NavigationHelper {
if (cleanRoute.contains('/admin/messages')) return 3;
if (cleanRoute.contains('/admin/amicale')) return 4;
if (cleanRoute.contains('/admin/operations')) return 5;
if (cleanRoute.contains('/admin/connexions')) return 6;
return 0; // Dashboard par défaut
} else {
if (cleanRoute.contains('/user/history')) return 1;
@@ -400,6 +409,7 @@ class NavigationHelper {
case 3: return 'messages';
case 4: return 'amicale';
case 5: return 'operations';
case 6: return 'connexions';
default: return 'dashboard';
}
} else {

View File

@@ -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>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, membresBox, child) {
return ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, passagesBox, child) {
final membres = membresBox.values.toList();
// Récupérer l'opération courante
@@ -186,6 +189,8 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
),
);
},
);
},
),
],
),

View File

@@ -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
if (!isMobile)
Expanded(

View File

@@ -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
if (!isMobile)
Expanded(

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.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:geosector_app/core/data/models/passage_model.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/utils/api_exception.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/form_section.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
@@ -88,13 +89,17 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Helpers de validation
String? _validateNumero(String? value) {
debugPrint('🔍 [VALIDATOR] _validateNumero appelé avec: "$value"');
if (value == null || value.trim().isEmpty) {
debugPrint('❌ [VALIDATOR] Numéro vide -> retourne erreur');
return 'Le numéro est obligatoire';
}
final numero = int.tryParse(value.trim());
if (numero == null || numero <= 0) {
debugPrint('❌ [VALIDATOR] Numéro invalide: $value -> retourne erreur');
return 'Numéro invalide';
}
debugPrint('✅ [VALIDATOR] Numéro valide: $numero');
return null;
}
@@ -329,48 +334,102 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
void _handleSubmit() async {
if (_isSubmitting) return;
debugPrint('🔵 [SUBMIT] Début _handleSubmit');
// ✅ Validation intégrée avec focus automatique sur erreur
if (!_formKey.currentState!.validate()) {
// Le focus est automatiquement mis sur le premier champ en erreur
// Les bordures rouges et messages d'erreur sont affichés automatiquement
if (_isSubmitting) {
debugPrint('⚠️ [SUBMIT] Déjà en cours de soumission, abandon');
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();
debugPrint('🔵 [SUBMIT] Fin _handleSubmit');
}
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(() {
_isSubmitting = true;
});
// Afficher l'overlay de chargement
debugPrint('🟢 [SAVE] Affichage overlay de chargement');
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Enregistrement en cours...',
);
try {
debugPrint('🟢 [SAVE] Récupération utilisateur actuel');
final currentUser = widget.userRepository.getCurrentUser();
debugPrint('🟢 [SAVE] currentUser: ${currentUser?.id} - ${currentUser?.name}');
if (currentUser == null) {
debugPrint('❌ [SAVE] ERREUR: Utilisateur non connecté');
throw Exception("Utilisateur non connecté");
}
debugPrint('🟢 [SAVE] Récupération opération active');
final currentOperation = widget.operationRepository.getCurrentOperation();
debugPrint('🟢 [SAVE] currentOperation: ${currentOperation?.id} - ${currentOperation?.name}');
if (currentOperation == null && widget.passage == null) {
debugPrint('❌ [SAVE] ERREUR: 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
debugPrint('🟢 [SAVE] Calcul des valeurs finales');
debugPrint('🟢 [SAVE] _selectedPassageType: $_selectedPassageType');
final String finalMontant =
(_selectedPassageType == 1 || _selectedPassageType == 5)
? _montantController.text.trim().replaceAll(',', '.')
: '0';
debugPrint('🟢 [SAVE] finalMontant: $finalMontant');
// Déterminer le type de règlement final selon le type de passage
final int finalTypeReglement;
if (_selectedPassageType == 1 || _selectedPassageType == 5) {
@@ -380,6 +439,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Pour tous les autres types, forcer "Non renseigné"
finalTypeReglement = 4;
}
debugPrint('🟢 [SAVE] finalTypeReglement: $finalTypeReglement');
// Déterminer la valeur de nbPassages selon le type de passage
final int finalNbPassages;
@@ -397,6 +457,31 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Nouveau passage : toujours 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(
fkType: _selectedPassageType!,
@@ -422,7 +507,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
PassageModel(
id: 0, // Nouveau passage
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
fkType: _selectedPassageType!,
fkAdresse: "0", // Adresse par défaut pour nouveau passage
@@ -435,8 +520,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
fkHabitat: _fkHabitat,
appt: _apptController.text.trim(),
niveau: _niveauController.text.trim(),
gpsLat: '0.0', // GPS par défaut
gpsLng: '0.0', // GPS par défaut
gpsLat: finalGpsLat, // GPS de l'utilisateur ou 0.0 si non disponible
gpsLng: finalGpsLng, // GPS de l'utilisateur ou 0.0 si non disponible
nomRecu: _nameController.text.trim(),
remarque: _remarqueController.text.trim(),
montant: finalMontant,
@@ -453,32 +538,37 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
);
// Sauvegarder le passage d'abord
debugPrint('🟢 [SAVE] Préparation sauvegarde passage');
PassageModel? savedPassage;
if (widget.passage == null || widget.passage!.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);
} else {
// Mise à jour d'un passage existant
final success = await widget.passageRepository.updatePassage(passageData);
if (success) {
savedPassage = passageData;
}
}
debugPrint('🟢 [SAVE] Passage créé avec ID: ${savedPassage?.id}');
if (savedPassage == null) {
throw Exception(widget.passage == null || widget.passage!.id == 0
? "Échec de la création du passage"
: "Échec de la mise à jour du passage");
debugPrint('❌ [SAVE] ERREUR: savedPassage est null après création');
throw Exception("Échec de la création 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
final confirmedPassage = savedPassage;
debugPrint('✅ [SAVE] Passage sauvegardé avec succès ID: ${confirmedPassage.id}');
// Mémoriser l'adresse pour la prochaine création de passage
debugPrint('🟢 [SAVE] Mémorisation adresse');
await _saveLastPassageAddress();
// Propager la résidence aux autres passages de l'immeuble si nécessaire
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
debugPrint('🟢 [SAVE] Propagation résidence à l\'immeuble');
await _propagateResidenceToBuilding(confirmedPassage);
}
@@ -514,17 +604,30 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Lancer le flow Tap to Pay
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
if (!paymentSuccess) {
debugPrint('⚠️ Paiement Tap to Pay échoué pour le passage ${confirmedPassage.id}');
}
},
);
// Fermer le formulaire après le choix de paiement
if (paymentSuccess) {
// Fermer le formulaire en cas de succès
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
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 {
// Stripe non activé pour cette amicale
@@ -563,30 +666,44 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
}
}
} catch (e) {
} catch (e, stackTrace) {
// 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);
// Afficher l'erreur
final errorMessage = ApiException.fromError(e).message;
debugPrint('❌ [SAVE] Message d\'erreur formaté: $errorMessage');
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
message: errorMessage,
);
}
} finally {
debugPrint('🟢 [SAVE] Bloc finally - Nettoyage');
if (mounted) {
setState(() {
_isSubmitting = false;
});
debugPrint('🟢 [SAVE] _isSubmitting = false');
}
debugPrint('🟢 [SAVE] Fin _savePassage');
}
}
/// Mémoriser l'adresse du passage pour la prochaine création
Future<void> _saveLastPassageAddress() async {
try {
debugPrint('🟡 [ADDRESS] Début mémorisation adresse');
debugPrint('🟡 [ADDRESS] _settingsBox.isOpen: ${_settingsBox.isOpen}');
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
await _settingsBox.put('lastPassageRueBis', _rueBisController.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('lastPassageNiveau', _niveauController.text.trim());
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage');
} catch (e) {
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e');
debugPrint(' [ADDRESS] Adresse mémorisée avec succès');
} catch (e, stackTrace) {
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)
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
try {
debugPrint('🟡 [PROPAGATE] Début propagation résidence');
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
debugPrint('🟡 [PROPAGATE] passagesBox.isOpen: ${passagesBox.isOpen}');
debugPrint('🟡 [PROPAGATE] passagesBox.length: ${passagesBox.length}');
final residence = _residenceController.text.trim();
debugPrint('🟡 [PROPAGATE] résidence: "$residence"');
// Clé d'adresse du passage sauvegardé
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
debugPrint('🟡 [PROPAGATE] addressKey: "$addressKey"');
int updatedCount = 0;
@@ -625,6 +750,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
passageAddressKey == addressKey && // Même adresse
passage.residence.trim().isEmpty) { // Résidence vide
debugPrint('🟡 [PROPAGATE] Mise à jour passage ID: ${passage.id}');
// Mettre à jour la résidence dans Hive
final updatedPassage = passage.copyWith(residence: residence);
await passagesBox.put(passage.key, updatedPassage);
@@ -634,10 +760,13 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
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) {
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $e');
} catch (e, stackTrace) {
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) {
// 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(() {
_currentState = 'error';
_errorMessage = e.toString();
_errorMessage = userMessage;
});
}
}

View File

@@ -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
/// 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 double amount;
final String habitantName;
final StripeConnectService stripeConnectService;
final PassageRepository? passageRepository;
final VoidCallback? onTapToPaySelected;
final VoidCallback? onQRCodeCompleted;
const PaymentMethodSelectionDialog({
super.key,
@@ -24,12 +25,61 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
required this.stripeConnectService,
this.passageRepository,
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
Widget build(BuildContext context) {
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
final amountEuros = amount.toStringAsFixed(2);
final canUseTapToPay = !_isCheckingNFC && _tapToPayUnavailableReason == null;
final amountEuros = widget.amount.toStringAsFixed(2);
return Dialog(
shape: RoundedRectangleBorder(
@@ -42,9 +92,6 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
// En-tête
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Règlement CB',
style: TextStyle(
@@ -52,12 +99,6 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 24),
// Informations du paiement
@@ -76,7 +117,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
habitantName,
widget.habitantName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
@@ -128,23 +169,28 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
description: 'Le client scanne le code avec son téléphone',
onPressed: () => _handleQRCodePayment(context),
color: Colors.blue,
isEnabled: true,
),
if (canUseTapToPay) ...[
const SizedBox(height: 12),
// Bouton Tap to Pay
// Bouton Tap to Pay (toujours affiché, désactivé si non disponible)
_buildPaymentButton(
context: context,
icon: Icons.contactless,
label: 'Tap to Pay',
description: 'Paiement sans contact sur cet appareil',
onPressed: () {
label: _isCheckingNFC ? 'Tap to Pay (vérification...)' : 'Tap to Pay',
description: canUseTapToPay
? 'Paiement sans contact sur cet appareil'
: _tapToPayUnavailableReason ?? 'Vérification en cours...',
onPressed: canUseTapToPay
? () {
Navigator.of(context).pop();
onTapToPaySelected?.call();
},
widget.onTapToPaySelected?.call();
}
: null,
color: Colors.green,
isEnabled: canUseTapToPay,
),
],
const SizedBox(height: 24),
@@ -179,18 +225,24 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
required IconData icon,
required String label,
required String description,
required VoidCallback onPressed,
required VoidCallback? onPressed,
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(
onTap: onPressed,
onTap: isEnabled ? onPressed : null,
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
border: Border.all(color: color.withOpacity(0.3), width: 2),
color: backgroundColor,
border: Border.all(color: borderColor, width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
@@ -198,36 +250,69 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
color: isEnabled ? color.withOpacity(0.2) : Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 32),
child: Icon(
icon,
color: effectiveColor,
size: 32,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
color: effectiveColor,
),
),
),
if (!isEnabled)
Icon(
Icons.lock_outline,
color: Colors.grey.shade600,
size: 20,
),
],
),
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,
style: TextStyle(
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 {
// Sauvegarder le navigator avant de fermer les dialogs
final navigator = Navigator.of(context);
bool loaderDisplayed = false;
try {
// Afficher un loader
@@ -248,19 +334,20 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
child: CircularProgressIndicator(),
),
);
loaderDisplayed = true;
// Créer le Payment Link
final amountInCents = (amount * 100).round();
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${passage.id}');
final amountInCents = (widget.amount * 100).round();
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${widget.passage.id}');
final paymentLink = await stripeConnectService.createPaymentLink(
final paymentLink = await widget.stripeConnectService.createPaymentLink(
amountInCents: amountInCents,
passageId: passage.id,
description: 'Calendrier pompiers - ${habitantName}',
passageId: widget.passage.id,
description: 'Calendrier pompiers - ${widget.habitantName}',
metadata: {
'passage_id': passage.id.toString(),
'habitant_name': habitantName,
'adresse': '${passage.numero} ${passage.rue}, ${passage.ville}',
'passage_id': widget.passage.id.toString(),
'habitant_name': widget.habitantName,
'adresse': '${widget.passage.numero} ${widget.passage.rue}, ${widget.passage.ville}',
},
);
@@ -270,22 +357,18 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
debugPrint(' ID: ${paymentLink.paymentLinkId}');
}
// Fermer le loader
navigator.pop();
debugPrint('🔵 Loader fermé');
if (paymentLink == null) {
throw Exception('Impossible de créer le lien de paiement');
}
// Sauvegarder l'URL du Payment Link dans le passage
if (passageRepository != null) {
if (widget.passageRepository != null) {
try {
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
final updatedPassage = passage.copyWith(
final updatedPassage = widget.passage.copyWith(
stripePaymentLinkUrl: paymentLink.url,
);
await passageRepository!.updatePassage(updatedPassage);
await widget.passageRepository!.updatePassage(updatedPassage);
debugPrint('✅ URL du Payment Link sauvegardée');
} catch (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();
debugPrint('🔵 Dialog de sélection fermé');
@@ -311,43 +399,25 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
);
debugPrint('🔵 Dialog QR Code affiché');
// Notifier que le QR Code est complété
widget.onQRCodeCompleted?.call();
debugPrint('✅ Callback onQRCodeCompleted appelé');
} catch (e, stack) {
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
debugPrint(' Stack: $stack');
// Fermer le loader si encore ouvert
if (loaderDisplayed) {
try {
navigator.pop();
} catch (_) {}
}
// Afficher l'erreur
// Afficher l'erreur (le dialogue de sélection reste ouvert)
if (context.mounted) {
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,
),
);
}
}

View File

@@ -818,7 +818,7 @@ class _UserFormState extends State<UserForm> {
),
helperText: widget.user?.id != 0
? "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,
validator: _validatePassword,
),
@@ -897,7 +897,7 @@ class _UserFormState extends State<UserForm> {
),
helperText: widget.user?.id != 0
? "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,
validator: _validatePassword,
),

View File

@@ -240,7 +240,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
user: widget.user,
readOnly: widget.readOnly,
allowUsernameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.isAdmin, // Toujours éditable pour un admin
amicale: widget.amicale, // Passer l'amicale
isAdmin: widget.isAdmin, // Passer isAdmin
onSubmit: null, // Pas besoin de callback

View File

@@ -1,10 +1,10 @@
// 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
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=3.5.2
FLUTTER_BUILD_NUMBER=352
FLUTTER_BUILD_NAME=3.6.2
FLUTTER_BUILD_NUMBER=362
DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false

View File

@@ -1,11 +1,11 @@
#!/bin/sh
# 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 "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=3.5.2"
export "FLUTTER_BUILD_NUMBER=352"
export "FLUTTER_BUILD_NAME=3.6.2"
export "FLUTTER_BUILD_NUMBER=362"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"

View File

@@ -130,10 +130,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8"
url: "https://pub.dev"
source: hosted
version: "8.12.0"
version: "8.12.3"
characters:
dependency: transitive
description:
@@ -222,6 +222,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -318,6 +326,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -630,10 +646,10 @@ packages:
dependency: transitive
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@@ -654,10 +670,10 @@ packages:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
version: "4.7.2"
image_picker:
dependency: "direct main"
description:
@@ -1435,10 +1451,10 @@ packages:
dependency: transitive
description:
name: watcher
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.1.4"
version: "1.2.1"
web:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
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.2+352
version: 3.6.2+362
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -22,6 +22,8 @@ dependencies:
# 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

113
app/pubspec.yaml.backup Executable file
View 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
View 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

View File

@@ -26,19 +26,52 @@ if [ ! -f "pubspec.yaml" ]; then
exit 1
fi
# Récupérer la version depuis pubspec.yaml
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
VERSION_CODE=$(echo $VERSION | cut -d'+' -f2)
# Synchroniser la version depuis ../VERSION
echo -e "${BLUE}📋 Synchronisation de la version depuis ../VERSION...${NC}"
echo ""
if [ -z "$VERSION_CODE" ]; then
echo -e "${RED}Impossible de récupérer le version code depuis pubspec.yaml${NC}"
VERSION_FILE="../VERSION"
if [ ! -f "$VERSION_FILE" ]; then
echo -e "${RED}Erreur: Fichier VERSION introuvable : $VERSION_FILE${NC}"
exit 1
fi
echo -e "${YELLOW}Version détectée :${NC} $VERSION"
echo -e "${YELLOW}Version code :${NC} $VERSION_CODE"
# 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
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 ""
VERSION="$VERSION_NUMBER+$VERSION_CODE"
# 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"
@@ -56,6 +89,7 @@ echo -e "${BLUE}rsync va créer le dossier de destination automatiquement${NC}"
echo ""
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/' \

214
docs/PLANNING-2026-Q1.md Normal file
View 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

View 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
---

View File

@@ -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

View File

@@ -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`

View File

@@ -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)
```

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Some files were not shown because too many files have changed in this diff Show More