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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -380,4 +380,4 @@ fi
|
||||
echo_info "Deployment completed at: $(date)"
|
||||
|
||||
# Journaliser le déploiement
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - API deployed to ${ENV_NAME} (${DEST_CONTAINER}) - Archive: ${ARCHIVE_NAME}" >> ~/.geo_deploy_history
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - API deployed to ${ENV_NAME} (${DEST_CONTAINER}) - Archive: ${ARCHIVE_NAME}" >> ~/.geo_deploy_history
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
456
api/scripts/cron/aggregate_event_stats.php
Executable file
456
api/scripts/cron/aggregate_event_stats.php
Executable file
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Script CRON pour agrégation des statistiques d'événements
|
||||
*
|
||||
* Parse les fichiers JSONL et agrège les données dans event_stats_daily
|
||||
*
|
||||
* Usage:
|
||||
* php aggregate_event_stats.php # Agrège J-1
|
||||
* php aggregate_event_stats.php --date=2025-12-20 # Agrège une date spécifique
|
||||
* php aggregate_event_stats.php --from=2025-12-01 --to=2025-12-21 # Rattrapage plage
|
||||
*
|
||||
* À exécuter quotidiennement via crontab (1h du matin) :
|
||||
* 0 1 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/aggregate_event_stats.php >> /var/www/geosector/api/logs/aggregate_stats.log 2>&1
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Configuration
|
||||
define('LOCK_FILE', '/tmp/aggregate_event_stats.lock');
|
||||
define('EVENT_LOG_DIR', __DIR__ . '/../../logs/events');
|
||||
|
||||
// Empêcher l'exécution multiple simultanée
|
||||
if (file_exists(LOCK_FILE)) {
|
||||
$lockTime = filemtime(LOCK_FILE);
|
||||
if (time() - $lockTime > 3600) {
|
||||
unlink(LOCK_FILE);
|
||||
} else {
|
||||
die("[" . date('Y-m-d H:i:s') . "] Le processus est déjà en cours d'exécution\n");
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents(LOCK_FILE, (string) getmypid());
|
||||
|
||||
register_shutdown_function(function () {
|
||||
if (file_exists(LOCK_FILE)) {
|
||||
unlink(LOCK_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
// Simuler l'environnement web pour AppConfig en CLI
|
||||
if (php_sapi_name() === 'cli') {
|
||||
$hostname = gethostname();
|
||||
if (strpos($hostname, 'pra') !== false) {
|
||||
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
|
||||
} elseif (strpos($hostname, 'rca') !== false) {
|
||||
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
|
||||
} else {
|
||||
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr';
|
||||
}
|
||||
|
||||
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
|
||||
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
|
||||
if (!function_exists('getallheaders')) {
|
||||
function getallheaders()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chargement de l'environnement
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../../src/Config/AppConfig.php';
|
||||
require_once __DIR__ . '/../../src/Core/Database.php';
|
||||
require_once __DIR__ . '/../../src/Services/LogService.php';
|
||||
|
||||
use App\Services\LogService;
|
||||
|
||||
/**
|
||||
* Parse les arguments CLI
|
||||
*/
|
||||
function parseArgs(array $argv): array
|
||||
{
|
||||
$args = [
|
||||
'date' => null,
|
||||
'from' => null,
|
||||
'to' => null,
|
||||
];
|
||||
|
||||
foreach ($argv as $arg) {
|
||||
if (strpos($arg, '--date=') === 0) {
|
||||
$args['date'] = substr($arg, 7);
|
||||
} elseif (strpos($arg, '--from=') === 0) {
|
||||
$args['from'] = substr($arg, 7);
|
||||
} elseif (strpos($arg, '--to=') === 0) {
|
||||
$args['to'] = substr($arg, 5);
|
||||
}
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère la liste des dates à traiter
|
||||
*/
|
||||
function getDatesToProcess(array $args): array
|
||||
{
|
||||
$dates = [];
|
||||
|
||||
if ($args['date']) {
|
||||
$dates[] = $args['date'];
|
||||
} elseif ($args['from'] && $args['to']) {
|
||||
$current = new DateTime($args['from']);
|
||||
$end = new DateTime($args['to']);
|
||||
while ($current <= $end) {
|
||||
$dates[] = $current->format('Y-m-d');
|
||||
$current->modify('+1 day');
|
||||
}
|
||||
} else {
|
||||
// Par défaut : J-1
|
||||
$dates[] = date('Y-m-d', strtotime('-1 day'));
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse un fichier JSONL et retourne les événements
|
||||
*/
|
||||
function parseJsonlFile(string $filePath): array
|
||||
{
|
||||
$events = [];
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
return $events;
|
||||
}
|
||||
|
||||
$handle = fopen($filePath, 'r');
|
||||
if (!$handle) {
|
||||
return $events;
|
||||
}
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$event = json_decode($line, true);
|
||||
if ($event && isset($event['event'])) {
|
||||
$events[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrège les événements par entity_id et event_type
|
||||
*/
|
||||
function aggregateEvents(array $events): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$entityId = $event['entity_id'] ?? null;
|
||||
$eventType = $event['event'] ?? 'unknown';
|
||||
$userId = $event['user_id'] ?? null;
|
||||
|
||||
// Clé d'agrégation : entity_id peut être NULL (stats globales)
|
||||
$key = ($entityId ?? 'NULL') . '|' . $eventType;
|
||||
|
||||
if (!isset($stats[$key])) {
|
||||
$stats[$key] = [
|
||||
'entity_id' => $entityId,
|
||||
'event_type' => $eventType,
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
'user_ids' => [],
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$stats[$key]['count']++;
|
||||
|
||||
// Collecter les user_ids pour unique_users
|
||||
if ($userId !== null) {
|
||||
$stats[$key]['user_ids'][$userId] = true;
|
||||
}
|
||||
|
||||
// Somme des montants pour les passages et paiements Stripe
|
||||
if (in_array($eventType, ['passage_created', 'passage_updated'])) {
|
||||
$amount = $event['amount'] ?? 0;
|
||||
$stats[$key]['sum_amount'] += (float) $amount;
|
||||
} elseif (in_array($eventType, ['stripe_payment_success', 'stripe_payment_created'])) {
|
||||
// Montant en centimes -> euros
|
||||
$amount = ($event['amount'] ?? 0) / 100;
|
||||
$stats[$key]['sum_amount'] += (float) $amount;
|
||||
}
|
||||
|
||||
// Collecter metadata spécifiques
|
||||
collectMetadata($stats[$key], $event);
|
||||
}
|
||||
|
||||
// Convertir user_ids en count
|
||||
foreach ($stats as &$stat) {
|
||||
$stat['unique_users'] = count($stat['user_ids']);
|
||||
unset($stat['user_ids']);
|
||||
|
||||
// Finaliser les metadata
|
||||
$stat['metadata'] = finalizeMetadata($stat['metadata'], $stat['event_type']);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collecte les métadonnées spécifiques par type d'événement
|
||||
*/
|
||||
function collectMetadata(array &$stat, array $event): void
|
||||
{
|
||||
$eventType = $event['event'] ?? '';
|
||||
|
||||
switch ($eventType) {
|
||||
case 'login_failed':
|
||||
$reason = $event['reason'] ?? 'unknown';
|
||||
$stat['metadata']['reasons'][$reason] = ($stat['metadata']['reasons'][$reason] ?? 0) + 1;
|
||||
break;
|
||||
|
||||
case 'passage_created':
|
||||
$sectorId = $event['sector_id'] ?? null;
|
||||
if ($sectorId) {
|
||||
$stat['metadata']['sectors'][$sectorId] = ($stat['metadata']['sectors'][$sectorId] ?? 0) + 1;
|
||||
}
|
||||
$paymentType = $event['payment_type'] ?? 'unknown';
|
||||
$stat['metadata']['payment_types'][$paymentType] = ($stat['metadata']['payment_types'][$paymentType] ?? 0) + 1;
|
||||
break;
|
||||
|
||||
case 'stripe_payment_failed':
|
||||
$errorCode = $event['error_code'] ?? 'unknown';
|
||||
$stat['metadata']['error_codes'][$errorCode] = ($stat['metadata']['error_codes'][$errorCode] ?? 0) + 1;
|
||||
break;
|
||||
|
||||
case 'stripe_terminal_error':
|
||||
$errorCode = $event['error_code'] ?? 'unknown';
|
||||
$stat['metadata']['error_codes'][$errorCode] = ($stat['metadata']['error_codes'][$errorCode] ?? 0) + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalise les métadonnées (top 5, tri, etc.)
|
||||
*/
|
||||
function finalizeMetadata(array $metadata, string $eventType): ?array
|
||||
{
|
||||
if (empty($metadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
// Top 5 secteurs
|
||||
if (isset($metadata['sectors'])) {
|
||||
arsort($metadata['sectors']);
|
||||
$result['top_sectors'] = array_slice($metadata['sectors'], 0, 5, true);
|
||||
}
|
||||
|
||||
// Raisons d'échec login
|
||||
if (isset($metadata['reasons'])) {
|
||||
arsort($metadata['reasons']);
|
||||
$result['failure_reasons'] = $metadata['reasons'];
|
||||
}
|
||||
|
||||
// Types de paiement
|
||||
if (isset($metadata['payment_types'])) {
|
||||
arsort($metadata['payment_types']);
|
||||
$result['payment_types'] = $metadata['payment_types'];
|
||||
}
|
||||
|
||||
// Codes d'erreur
|
||||
if (isset($metadata['error_codes'])) {
|
||||
arsort($metadata['error_codes']);
|
||||
$result['error_codes'] = $metadata['error_codes'];
|
||||
}
|
||||
|
||||
return empty($result) ? null : $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insère ou met à jour les stats dans la base de données
|
||||
*/
|
||||
function upsertStats(PDO $db, string $date, array $stats): int
|
||||
{
|
||||
$upsertedCount = 0;
|
||||
|
||||
$sql = "
|
||||
INSERT INTO event_stats_daily
|
||||
(stat_date, entity_id, event_type, count, sum_amount, unique_users, metadata)
|
||||
VALUES
|
||||
(:stat_date, :entity_id, :event_type, :count, :sum_amount, :unique_users, :metadata)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
count = VALUES(count),
|
||||
sum_amount = VALUES(sum_amount),
|
||||
unique_users = VALUES(unique_users),
|
||||
metadata = VALUES(metadata),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
";
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
|
||||
foreach ($stats as $stat) {
|
||||
try {
|
||||
$stmt->execute([
|
||||
'stat_date' => $date,
|
||||
'entity_id' => $stat['entity_id'],
|
||||
'event_type' => $stat['event_type'],
|
||||
'count' => $stat['count'],
|
||||
'sum_amount' => $stat['sum_amount'],
|
||||
'unique_users' => $stat['unique_users'],
|
||||
'metadata' => $stat['metadata'] ? json_encode($stat['metadata'], JSON_UNESCAPED_UNICODE) : null,
|
||||
]);
|
||||
$upsertedCount++;
|
||||
} catch (PDOException $e) {
|
||||
echo " ERREUR insertion {$stat['event_type']}: " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $upsertedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère également les stats globales (entity_id = NULL)
|
||||
*/
|
||||
function generateGlobalStats(array $statsByEntity): array
|
||||
{
|
||||
$globalStats = [];
|
||||
|
||||
foreach ($statsByEntity as $stat) {
|
||||
$eventType = $stat['event_type'];
|
||||
|
||||
if (!isset($globalStats[$eventType])) {
|
||||
$globalStats[$eventType] = [
|
||||
'entity_id' => null,
|
||||
'event_type' => $eventType,
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
'unique_users' => 0,
|
||||
'metadata' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$globalStats[$eventType]['count'] += $stat['count'];
|
||||
$globalStats[$eventType]['sum_amount'] += $stat['sum_amount'];
|
||||
$globalStats[$eventType]['unique_users'] += $stat['unique_users'];
|
||||
}
|
||||
|
||||
return array_values($globalStats);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN
|
||||
// ============================================================
|
||||
|
||||
try {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Démarrage de l'agrégation des statistiques\n";
|
||||
|
||||
// Initialisation
|
||||
$appConfig = AppConfig::getInstance();
|
||||
$config = $appConfig->getFullConfig();
|
||||
$environment = $appConfig->getEnvironment();
|
||||
|
||||
echo "Environnement: {$environment}\n";
|
||||
|
||||
Database::init($config['database']);
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Parser les arguments
|
||||
$args = parseArgs($argv);
|
||||
$dates = getDatesToProcess($args);
|
||||
|
||||
echo "Dates à traiter: " . implode(', ', $dates) . "\n\n";
|
||||
|
||||
$totalStats = 0;
|
||||
$totalEvents = 0;
|
||||
|
||||
foreach ($dates as $date) {
|
||||
$jsonlFile = EVENT_LOG_DIR . '/' . $date . '.jsonl';
|
||||
|
||||
echo "--- Traitement de {$date} ---\n";
|
||||
|
||||
if (!file_exists($jsonlFile)) {
|
||||
echo " Fichier non trouvé: {$jsonlFile}\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileSize = filesize($jsonlFile);
|
||||
echo " Fichier: " . basename($jsonlFile) . " (" . number_format($fileSize / 1024, 2) . " KB)\n";
|
||||
|
||||
// Parser le fichier
|
||||
$events = parseJsonlFile($jsonlFile);
|
||||
$eventCount = count($events);
|
||||
echo " Événements parsés: {$eventCount}\n";
|
||||
|
||||
if ($eventCount === 0) {
|
||||
echo " Aucun événement à agréger\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalEvents += $eventCount;
|
||||
|
||||
// Agréger par entity/event_type
|
||||
$stats = aggregateEvents($events);
|
||||
echo " Agrégations par entité: " . count($stats) . "\n";
|
||||
|
||||
// Générer les stats globales
|
||||
$globalStats = generateGlobalStats($stats);
|
||||
echo " Agrégations globales: " . count($globalStats) . "\n";
|
||||
|
||||
// Fusionner stats entités + globales
|
||||
$allStats = array_merge(array_values($stats), $globalStats);
|
||||
|
||||
// Insérer en base
|
||||
$upserted = upsertStats($db, $date, $allStats);
|
||||
echo " Stats insérées/mises à jour: {$upserted}\n";
|
||||
|
||||
$totalStats += $upserted;
|
||||
}
|
||||
|
||||
// Résumé
|
||||
echo "\n=== RÉSUMÉ ===\n";
|
||||
echo "Dates traitées: " . count($dates) . "\n";
|
||||
echo "Événements traités: {$totalEvents}\n";
|
||||
echo "Stats agrégées: {$totalStats}\n";
|
||||
|
||||
// Log
|
||||
LogService::log('Agrégation des statistiques terminée', [
|
||||
'level' => 'info',
|
||||
'script' => 'aggregate_event_stats.php',
|
||||
'environment' => $environment,
|
||||
'dates_count' => count($dates),
|
||||
'events_count' => $totalEvents,
|
||||
'stats_count' => $totalStats,
|
||||
]);
|
||||
|
||||
echo "\n[" . date('Y-m-d H:i:s') . "] Agrégation terminée avec succès\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errorMsg = 'Erreur lors de l\'agrégation: ' . $e->getMessage();
|
||||
|
||||
LogService::log($errorMsg, [
|
||||
'level' => 'error',
|
||||
'script' => 'aggregate_event_stats.php',
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
echo "\n❌ ERREUR: {$errorMsg}\n";
|
||||
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -1,444 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Script CRON pour mettre à jour la liste des appareils certifiés Stripe Tap to Pay
|
||||
*
|
||||
* Ce script récupère et met à jour la liste des appareils Android certifiés
|
||||
* pour Tap to Pay en France dans la table stripe_android_certified_devices
|
||||
*
|
||||
* À exécuter hebdomadairement via crontab :
|
||||
* Exemple: 0 3 * * 0 /usr/bin/php /path/to/api/scripts/cron/update_stripe_devices.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Configuration
|
||||
define('LOCK_FILE', '/tmp/update_stripe_devices.lock');
|
||||
define('DEVICES_JSON_URL', 'https://raw.githubusercontent.com/stripe/stripe-terminal-android/master/tap-to-pay/certified-devices.json');
|
||||
define('DEVICES_LOCAL_FILE', __DIR__ . '/../../data/stripe_certified_devices.json');
|
||||
|
||||
// Empêcher l'exécution multiple simultanée
|
||||
if (file_exists(LOCK_FILE)) {
|
||||
$lockTime = filemtime(LOCK_FILE);
|
||||
if (time() - $lockTime > 3600) { // Lock de plus d'1 heure = processus bloqué
|
||||
unlink(LOCK_FILE);
|
||||
} else {
|
||||
die("[" . date('Y-m-d H:i:s') . "] Le processus est déjà en cours d'exécution\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Créer le fichier de lock
|
||||
file_put_contents(LOCK_FILE, getmypid());
|
||||
|
||||
// Enregistrer un handler pour supprimer le lock en cas d'arrêt
|
||||
register_shutdown_function(function() {
|
||||
if (file_exists(LOCK_FILE)) {
|
||||
unlink(LOCK_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
// Simuler l'environnement web pour AppConfig en CLI
|
||||
if (php_sapi_name() === 'cli') {
|
||||
$hostname = gethostname();
|
||||
if (strpos($hostname, 'prod') !== false || strpos($hostname, 'pra') !== false) {
|
||||
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
|
||||
} elseif (strpos($hostname, 'rec') !== false || strpos($hostname, 'rca') !== false) {
|
||||
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
|
||||
} else {
|
||||
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
|
||||
}
|
||||
$_SERVER['REQUEST_URI'] = '/cron/update_stripe_devices';
|
||||
$_SERVER['REQUEST_METHOD'] = 'CLI';
|
||||
$_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'];
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
|
||||
// Définir getallheaders si elle n'existe pas (CLI)
|
||||
if (!function_exists('getallheaders')) {
|
||||
function getallheaders() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Charger l'environnement
|
||||
require_once dirname(dirname(__DIR__)) . '/vendor/autoload.php';
|
||||
require_once dirname(dirname(__DIR__)) . '/src/Config/AppConfig.php';
|
||||
require_once dirname(dirname(__DIR__)) . '/src/Core/Database.php';
|
||||
require_once dirname(dirname(__DIR__)) . '/src/Services/LogService.php';
|
||||
|
||||
use App\Services\LogService;
|
||||
|
||||
try {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Début de la mise à jour des devices Stripe certifiés\n";
|
||||
|
||||
// Initialiser la configuration et la base de données
|
||||
$appConfig = AppConfig::getInstance();
|
||||
$dbConfig = $appConfig->getDatabaseConfig();
|
||||
Database::init($dbConfig);
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Logger le début
|
||||
LogService::log("Début de la mise à jour des devices Stripe certifiés", [
|
||||
'source' => 'cron',
|
||||
'script' => 'update_stripe_devices.php'
|
||||
]);
|
||||
|
||||
// Étape 1: Récupérer la liste des devices
|
||||
$devicesData = fetchCertifiedDevices();
|
||||
|
||||
if (empty($devicesData)) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Aucune donnée de devices récupérée\n";
|
||||
LogService::log("Aucune donnée de devices récupérée", ['level' => 'warning']);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Étape 2: Traiter et mettre à jour la base de données
|
||||
$stats = updateDatabase($db, $devicesData);
|
||||
|
||||
// Étape 3: Logger les résultats
|
||||
$message = sprintf(
|
||||
"Mise à jour terminée : %d ajoutés, %d modifiés, %d désactivés, %d inchangés",
|
||||
$stats['added'],
|
||||
$stats['updated'],
|
||||
$stats['disabled'],
|
||||
$stats['unchanged']
|
||||
);
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] $message\n";
|
||||
|
||||
LogService::log($message, [
|
||||
'source' => 'cron',
|
||||
'stats' => $stats
|
||||
]);
|
||||
|
||||
// Étape 4: Envoyer une notification si changements significatifs
|
||||
if ($stats['added'] > 0 || $stats['disabled'] > 0) {
|
||||
sendNotification($stats);
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Mise à jour terminée avec succès\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errorMsg = "Erreur lors de la mise à jour des devices: " . $e->getMessage();
|
||||
echo "[" . date('Y-m-d H:i:s') . "] $errorMsg\n";
|
||||
LogService::log($errorMsg, [
|
||||
'level' => 'error',
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la liste des devices certifiés
|
||||
* Essaie d'abord depuis une URL externe, puis depuis un fichier local en fallback
|
||||
*/
|
||||
function fetchCertifiedDevices(): array {
|
||||
// Liste maintenue manuellement des devices certifiés en France
|
||||
// Source: Documentation Stripe Terminal et tests confirmés
|
||||
$frenchCertifiedDevices = [
|
||||
// Samsung Galaxy S Series
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21', 'model_identifier' => 'SM-G991B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21+', 'model_identifier' => 'SM-G996B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 Ultra', 'model_identifier' => 'SM-G998B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 FE', 'model_identifier' => 'SM-G990B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22', 'model_identifier' => 'SM-S901B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22+', 'model_identifier' => 'SM-S906B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22 Ultra', 'model_identifier' => 'SM-S908B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23', 'model_identifier' => 'SM-S911B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23+', 'model_identifier' => 'SM-S916B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 Ultra', 'model_identifier' => 'SM-S918B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 FE', 'model_identifier' => 'SM-S711B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24', 'model_identifier' => 'SM-S921B', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24+', 'model_identifier' => 'SM-S926B', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24 Ultra', 'model_identifier' => 'SM-S928B', 'min_android_version' => 14],
|
||||
|
||||
// Samsung Galaxy Note
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20', 'model_identifier' => 'SM-N980F', 'min_android_version' => 10],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20 Ultra', 'model_identifier' => 'SM-N986B', 'min_android_version' => 10],
|
||||
|
||||
// Samsung Galaxy Z Fold
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold3', 'model_identifier' => 'SM-F926B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold4', 'model_identifier' => 'SM-F936B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold5', 'model_identifier' => 'SM-F946B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold6', 'model_identifier' => 'SM-F956B', 'min_android_version' => 14],
|
||||
|
||||
// Samsung Galaxy Z Flip
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip3', 'model_identifier' => 'SM-F711B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip4', 'model_identifier' => 'SM-F721B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip5', 'model_identifier' => 'SM-F731B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip6', 'model_identifier' => 'SM-F741B', 'min_android_version' => 14],
|
||||
|
||||
// Samsung Galaxy A Series (haut de gamme)
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy A54', 'model_identifier' => 'SM-A546B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy A73', 'model_identifier' => 'SM-A736B', 'min_android_version' => 12],
|
||||
|
||||
// Google Pixel
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 6', 'model_identifier' => 'oriole', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 6 Pro', 'model_identifier' => 'raven', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 6a', 'model_identifier' => 'bluejay', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 7', 'model_identifier' => 'panther', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 7 Pro', 'model_identifier' => 'cheetah', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 7a', 'model_identifier' => 'lynx', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 8', 'model_identifier' => 'shiba', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 8 Pro', 'model_identifier' => 'husky', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 8a', 'model_identifier' => 'akita', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 9', 'model_identifier' => 'tokay', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro', 'model_identifier' => 'caiman', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro XL', 'model_identifier' => 'komodo', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel Fold', 'model_identifier' => 'felix', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel Tablet', 'model_identifier' => 'tangorpro', 'min_android_version' => 13],
|
||||
|
||||
// OnePlus
|
||||
['manufacturer' => 'OnePlus', 'model' => '9', 'model_identifier' => 'LE2113', 'min_android_version' => 11],
|
||||
['manufacturer' => 'OnePlus', 'model' => '9 Pro', 'model_identifier' => 'LE2123', 'min_android_version' => 11],
|
||||
['manufacturer' => 'OnePlus', 'model' => '10 Pro', 'model_identifier' => 'NE2213', 'min_android_version' => 12],
|
||||
['manufacturer' => 'OnePlus', 'model' => '10T', 'model_identifier' => 'CPH2413', 'min_android_version' => 12],
|
||||
['manufacturer' => 'OnePlus', 'model' => '11', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
|
||||
['manufacturer' => 'OnePlus', 'model' => '11R', 'model_identifier' => 'CPH2487', 'min_android_version' => 13],
|
||||
['manufacturer' => 'OnePlus', 'model' => '12', 'model_identifier' => 'CPH2581', 'min_android_version' => 14],
|
||||
['manufacturer' => 'OnePlus', 'model' => '12R', 'model_identifier' => 'CPH2585', 'min_android_version' => 14],
|
||||
['manufacturer' => 'OnePlus', 'model' => 'Open', 'model_identifier' => 'CPH2551', 'min_android_version' => 13],
|
||||
|
||||
// Xiaomi
|
||||
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11', 'model_identifier' => 'M2011K2G', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11 Ultra', 'model_identifier' => 'M2102K1G', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '12', 'model_identifier' => '2201123G', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '12 Pro', 'model_identifier' => '2201122G', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '12T Pro', 'model_identifier' => '2207122MC', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '13', 'model_identifier' => '2211133G', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '13 Pro', 'model_identifier' => '2210132G', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '13T Pro', 'model_identifier' => '23078PND5G', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '14', 'model_identifier' => '23127PN0CG', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '14 Pro', 'model_identifier' => '23116PN5BG', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '14 Ultra', 'model_identifier' => '24030PN60G', 'min_android_version' => 14],
|
||||
|
||||
// OPPO
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find X3 Pro', 'model_identifier' => 'CPH2173', 'min_android_version' => 11],
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find X5 Pro', 'model_identifier' => 'CPH2305', 'min_android_version' => 12],
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find X6 Pro', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find N2', 'model_identifier' => 'CPH2399', 'min_android_version' => 13],
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find N3', 'model_identifier' => 'CPH2499', 'min_android_version' => 13],
|
||||
|
||||
// Realme
|
||||
['manufacturer' => 'Realme', 'model' => 'GT 2 Pro', 'model_identifier' => 'RMX3301', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Realme', 'model' => 'GT 3', 'model_identifier' => 'RMX3709', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Realme', 'model' => 'GT 5 Pro', 'model_identifier' => 'RMX3888', 'min_android_version' => 14],
|
||||
|
||||
// Honor
|
||||
['manufacturer' => 'Honor', 'model' => 'Magic5 Pro', 'model_identifier' => 'PGT-N19', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Honor', 'model' => 'Magic6 Pro', 'model_identifier' => 'BVL-N49', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Honor', 'model' => '90', 'model_identifier' => 'REA-NX9', 'min_android_version' => 13],
|
||||
|
||||
// ASUS
|
||||
['manufacturer' => 'ASUS', 'model' => 'Zenfone 9', 'model_identifier' => 'AI2202', 'min_android_version' => 12],
|
||||
['manufacturer' => 'ASUS', 'model' => 'Zenfone 10', 'model_identifier' => 'AI2302', 'min_android_version' => 13],
|
||||
['manufacturer' => 'ASUS', 'model' => 'ROG Phone 7', 'model_identifier' => 'AI2205', 'min_android_version' => 13],
|
||||
|
||||
// Nothing
|
||||
['manufacturer' => 'Nothing', 'model' => 'Phone (1)', 'model_identifier' => 'A063', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Nothing', 'model' => 'Phone (2)', 'model_identifier' => 'A065', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Nothing', 'model' => 'Phone (2a)', 'model_identifier' => 'A142', 'min_android_version' => 14],
|
||||
];
|
||||
|
||||
// Essayer de charger depuis un fichier JSON local si présent
|
||||
if (file_exists(DEVICES_LOCAL_FILE)) {
|
||||
$localData = json_decode(file_get_contents(DEVICES_LOCAL_FILE), true);
|
||||
if (!empty($localData)) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Données chargées depuis le fichier local\n";
|
||||
return array_merge($frenchCertifiedDevices, $localData);
|
||||
}
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Utilisation de la liste intégrée des devices certifiés\n";
|
||||
return $frenchCertifiedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la base de données avec les nouvelles données
|
||||
*/
|
||||
function updateDatabase($db, array $devices): array {
|
||||
$stats = [
|
||||
'added' => 0,
|
||||
'updated' => 0,
|
||||
'disabled' => 0,
|
||||
'unchanged' => 0,
|
||||
'total' => 0
|
||||
];
|
||||
|
||||
// Récupérer tous les devices existants
|
||||
$stmt = $db->prepare("SELECT * FROM stripe_android_certified_devices WHERE country = 'FR'");
|
||||
$stmt->execute();
|
||||
$existingDevices = [];
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$key = $row['manufacturer'] . '|' . $row['model'] . '|' . $row['model_identifier'];
|
||||
$existingDevices[$key] = $row;
|
||||
}
|
||||
|
||||
// Marquer tous les devices pour tracking
|
||||
$processedKeys = [];
|
||||
|
||||
// Traiter chaque device de la nouvelle liste
|
||||
foreach ($devices as $device) {
|
||||
$key = $device['manufacturer'] . '|' . $device['model'] . '|' . $device['model_identifier'];
|
||||
$processedKeys[$key] = true;
|
||||
|
||||
if (isset($existingDevices[$key])) {
|
||||
// Le device existe, vérifier s'il faut le mettre à jour
|
||||
$existing = $existingDevices[$key];
|
||||
|
||||
// Vérifier si des champs ont changé
|
||||
$needsUpdate = false;
|
||||
if ($existing['min_android_version'] != $device['min_android_version']) {
|
||||
$needsUpdate = true;
|
||||
}
|
||||
if ($existing['tap_to_pay_certified'] != 1) {
|
||||
$needsUpdate = true;
|
||||
}
|
||||
|
||||
if ($needsUpdate) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE stripe_android_certified_devices
|
||||
SET min_android_version = :min_version,
|
||||
tap_to_pay_certified = 1,
|
||||
last_verified = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE manufacturer = :manufacturer
|
||||
AND model = :model
|
||||
AND model_identifier = :model_identifier
|
||||
AND country = 'FR'
|
||||
");
|
||||
$stmt->execute([
|
||||
'min_version' => $device['min_android_version'],
|
||||
'manufacturer' => $device['manufacturer'],
|
||||
'model' => $device['model'],
|
||||
'model_identifier' => $device['model_identifier']
|
||||
]);
|
||||
$stats['updated']++;
|
||||
|
||||
LogService::log("Device mis à jour", [
|
||||
'device' => $device['manufacturer'] . ' ' . $device['model']
|
||||
]);
|
||||
} else {
|
||||
// Juste mettre à jour last_verified
|
||||
$stmt = $db->prepare("
|
||||
UPDATE stripe_android_certified_devices
|
||||
SET last_verified = NOW()
|
||||
WHERE manufacturer = :manufacturer
|
||||
AND model = :model
|
||||
AND model_identifier = :model_identifier
|
||||
AND country = 'FR'
|
||||
");
|
||||
$stmt->execute([
|
||||
'manufacturer' => $device['manufacturer'],
|
||||
'model' => $device['model'],
|
||||
'model_identifier' => $device['model_identifier']
|
||||
]);
|
||||
$stats['unchanged']++;
|
||||
}
|
||||
} else {
|
||||
// Nouveau device, l'ajouter
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO stripe_android_certified_devices
|
||||
(manufacturer, model, model_identifier, tap_to_pay_certified,
|
||||
certification_date, min_android_version, country, notes, last_verified)
|
||||
VALUES
|
||||
(:manufacturer, :model, :model_identifier, 1,
|
||||
NOW(), :min_version, 'FR', 'Ajouté automatiquement via CRON', NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
'manufacturer' => $device['manufacturer'],
|
||||
'model' => $device['model'],
|
||||
'model_identifier' => $device['model_identifier'],
|
||||
'min_version' => $device['min_android_version']
|
||||
]);
|
||||
$stats['added']++;
|
||||
|
||||
LogService::log("Nouveau device ajouté", [
|
||||
'device' => $device['manufacturer'] . ' ' . $device['model']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Désactiver les devices qui ne sont plus dans la liste
|
||||
foreach ($existingDevices as $key => $existing) {
|
||||
if (!isset($processedKeys[$key]) && $existing['tap_to_pay_certified'] == 1) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE stripe_android_certified_devices
|
||||
SET tap_to_pay_certified = 0,
|
||||
notes = CONCAT(IFNULL(notes, ''), ' | Désactivé le ', NOW(), ' (non présent dans la mise à jour)'),
|
||||
updated_at = NOW()
|
||||
WHERE id = :id
|
||||
");
|
||||
$stmt->execute(['id' => $existing['id']]);
|
||||
$stats['disabled']++;
|
||||
|
||||
LogService::log("Device désactivé", [
|
||||
'device' => $existing['manufacturer'] . ' ' . $existing['model'],
|
||||
'reason' => 'Non présent dans la liste mise à jour'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$stats['total'] = count($devices);
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification email aux administrateurs si changements importants
|
||||
*/
|
||||
function sendNotification(array $stats): void {
|
||||
try {
|
||||
// Récupérer la configuration
|
||||
$appConfig = AppConfig::getInstance();
|
||||
$emailConfig = $appConfig->getEmailConfig();
|
||||
|
||||
if (empty($emailConfig['admin_email'])) {
|
||||
return; // Pas d'email admin configuré
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Préparer le contenu de l'email
|
||||
$subject = "Mise à jour des devices Stripe Tap to Pay";
|
||||
$body = "Bonjour,\n\n";
|
||||
$body .= "La mise à jour automatique de la liste des appareils certifiés Stripe Tap to Pay a été effectuée.\n\n";
|
||||
$body .= "Résumé des changements :\n";
|
||||
$body .= "- Nouveaux appareils ajoutés : " . $stats['added'] . "\n";
|
||||
$body .= "- Appareils mis à jour : " . $stats['updated'] . "\n";
|
||||
$body .= "- Appareils désactivés : " . $stats['disabled'] . "\n";
|
||||
$body .= "- Appareils inchangés : " . $stats['unchanged'] . "\n";
|
||||
$body .= "- Total d'appareils traités : " . $stats['total'] . "\n\n";
|
||||
|
||||
if ($stats['added'] > 0) {
|
||||
$body .= "Les nouveaux appareils ont été automatiquement ajoutés à la base de données.\n";
|
||||
}
|
||||
|
||||
if ($stats['disabled'] > 0) {
|
||||
$body .= "Certains appareils ont été désactivés car ils ne sont plus certifiés.\n";
|
||||
}
|
||||
|
||||
$body .= "\nConsultez les logs pour plus de détails.\n";
|
||||
$body .= "\nCordialement,\nLe système GeoSector";
|
||||
|
||||
// Insérer dans la queue d'emails
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO email_queue
|
||||
(to_email, subject, body, status, created_at, attempts)
|
||||
VALUES
|
||||
(:to_email, :subject, :body, 'pending', NOW(), 0)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
'to_email' => $emailConfig['admin_email'],
|
||||
'subject' => $subject,
|
||||
'body' => $body
|
||||
]);
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Notification ajoutée à la queue d'emails\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Ne pas faire échouer le script si l'email ne peut pas être envoyé
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Impossible d'envoyer la notification: " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
19
api/scripts/sql/alter_ope_pass_fk_sector_default_null.sql
Normal file
19
api/scripts/sql/alter_ope_pass_fk_sector_default_null.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration: Modifier fk_sector pour avoir DEFAULT NULL au lieu de DEFAULT 0
|
||||
-- Raison: La FK vers ope_sectors(id) ne permet pas la valeur 0 (aucun secteur avec id=0)
|
||||
-- Date: 2026-01-16
|
||||
|
||||
-- 1. D'abord, mettre à NULL les passages qui ont fk_sector = 0
|
||||
UPDATE ope_pass SET fk_sector = NULL WHERE fk_sector = 0;
|
||||
|
||||
-- 2. Modifier la colonne pour avoir DEFAULT NULL
|
||||
ALTER TABLE ope_pass MODIFY COLUMN fk_sector INT UNSIGNED DEFAULT NULL;
|
||||
|
||||
-- Vérification
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_DEFAULT,
|
||||
IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ope_pass'
|
||||
AND COLUMN_NAME = 'fk_sector';
|
||||
57
api/scripts/sql/create_event_stats_daily.sql
Normal file
57
api/scripts/sql/create_event_stats_daily.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- ============================================================
|
||||
-- Table event_stats_daily - Statistiques d'événements agrégées
|
||||
-- Version: 1.0
|
||||
-- Date: 2025-12-22
|
||||
-- ============================================================
|
||||
--
|
||||
-- Usage: Exécuter dans les 3 environnements (DEV, REC, PROD)
|
||||
-- mysql -u user -p database < create_event_stats_daily.sql
|
||||
--
|
||||
-- Description:
|
||||
-- Stocke les statistiques quotidiennes agrégées depuis les
|
||||
-- fichiers JSONL (/logs/events/YYYY-MM-DD.jsonl)
|
||||
-- Alimentée par le CRON aggregate_event_stats.php (1x/nuit)
|
||||
--
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_stats_daily (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
-- Clés d'agrégation
|
||||
stat_date DATE NOT NULL COMMENT 'Date des statistiques',
|
||||
entity_id INT UNSIGNED NULL COMMENT 'ID entité (NULL = stats globales super-admin)',
|
||||
event_type VARCHAR(50) NOT NULL COMMENT 'Type événement (login_success, passage_created, etc.)',
|
||||
|
||||
-- Compteurs
|
||||
count INT UNSIGNED DEFAULT 0 COMMENT 'Nombre d\'occurrences',
|
||||
sum_amount DECIMAL(12,2) DEFAULT 0.00 COMMENT 'Somme des montants (passages/paiements)',
|
||||
unique_users INT UNSIGNED DEFAULT 0 COMMENT 'Nombre d\'utilisateurs distincts',
|
||||
|
||||
-- Métadonnées agrégées (JSON)
|
||||
metadata JSON NULL COMMENT 'Données agrégées: top secteurs, erreurs fréquentes, etc.',
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Contraintes
|
||||
UNIQUE KEY uk_date_entity_event (stat_date, entity_id, event_type),
|
||||
INDEX idx_entity_date (entity_id, stat_date),
|
||||
INDEX idx_date (stat_date),
|
||||
INDEX idx_event_type (event_type)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Statistiques quotidiennes agrégées des événements (EventLogService)';
|
||||
|
||||
-- ============================================================
|
||||
-- Exemples de données attendues
|
||||
-- ============================================================
|
||||
--
|
||||
-- | stat_date | entity_id | event_type | count | sum_amount | unique_users |
|
||||
-- |------------|-----------|------------------|-------|------------|--------------|
|
||||
-- | 2025-12-22 | 5 | login_success | 45 | 0.00 | 12 |
|
||||
-- | 2025-12-22 | 5 | passage_created | 128 | 2450.00 | 8 |
|
||||
-- | 2025-12-22 | 5 | stripe_payment_success | 12 | 890.00 | 6 |
|
||||
-- | 2025-12-22 | NULL | login_success | 320 | 0.00 | 85 | <- Global
|
||||
--
|
||||
-- ============================================================
|
||||
5
api/scripts/sql/drop_stripe_devices_table.sql
Normal file
5
api/scripts/sql/drop_stripe_devices_table.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Suppression de la table stripe_android_certified_devices
|
||||
-- Cette table n'est plus utilisée : la vérification de compatibilité Tap to Pay
|
||||
-- se fait maintenant directement côté client via le SDK Stripe Terminal
|
||||
|
||||
DROP TABLE IF EXISTS stripe_android_certified_devices;
|
||||
484
api/src/Controllers/EventStatsController.php
Normal file
484
api/src/Controllers/EventStatsController.php
Normal file
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
require_once __DIR__ . '/../Services/LogService.php';
|
||||
require_once __DIR__ . '/../Services/EventStatsService.php';
|
||||
|
||||
use PDO;
|
||||
use Database;
|
||||
use Response;
|
||||
use Session;
|
||||
use App\Services\LogService;
|
||||
use App\Services\EventStatsService;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* EventStatsController - Contrôleur des statistiques d'événements
|
||||
*
|
||||
* Endpoints pour consulter les stats agrégées et le détail des événements.
|
||||
* Accès réservé aux Admin entité (role_id = 2) et Super-admin (role_id = 1).
|
||||
*/
|
||||
class EventStatsController
|
||||
{
|
||||
private PDO $db;
|
||||
private EventStatsService $statsService;
|
||||
|
||||
/** @var array Rôles autorisés à consulter les stats */
|
||||
private const ALLOWED_ROLES = [1, 2]; // Super-admin, Admin
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
$this->statsService = new EventStatsService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/summary
|
||||
* Récupère le résumé des stats pour une date
|
||||
*
|
||||
* Query params:
|
||||
* - date: Date (YYYY-MM-DD), défaut = aujourd'hui
|
||||
*/
|
||||
public function summary(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$date = $_GET['date'] ?? date('Y-m-d');
|
||||
|
||||
// Validation de la date
|
||||
if (!$this->isValidDate($date)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$summary = $this->statsService->getSummary($entityId, $date);
|
||||
|
||||
$this->jsonWithCache([
|
||||
'status' => 'success',
|
||||
'data' => $summary,
|
||||
], true);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération du résumé des stats', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des statistiques',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/daily
|
||||
* Récupère les stats journalières sur une plage de dates
|
||||
*
|
||||
* Query params:
|
||||
* - from: Date début (YYYY-MM-DD), requis
|
||||
* - to: Date fin (YYYY-MM-DD), requis
|
||||
* - events: Types d'événements (comma-separated), optionnel
|
||||
*/
|
||||
public function daily(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$from = $_GET['from'] ?? null;
|
||||
$to = $_GET['to'] ?? null;
|
||||
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||
|
||||
// Validation des dates
|
||||
if (!$from || !$to) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Les paramètres from et to sont requis',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($from > $to) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'La date de début doit être antérieure à la date de fin',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limiter la plage à 90 jours
|
||||
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
|
||||
if ($daysDiff > 90) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'La plage de dates ne peut pas dépasser 90 jours',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$daily = $this->statsService->getDaily($entityId, $from, $to, $eventTypes);
|
||||
|
||||
$this->jsonWithCache([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'days' => $daily,
|
||||
],
|
||||
], true);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération des stats journalières', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des statistiques',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/weekly
|
||||
* Récupère les stats hebdomadaires sur une plage de dates
|
||||
*
|
||||
* Query params:
|
||||
* - from: Date début (YYYY-MM-DD), requis
|
||||
* - to: Date fin (YYYY-MM-DD), requis
|
||||
* - events: Types d'événements (comma-separated), optionnel
|
||||
*/
|
||||
public function weekly(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$from = $_GET['from'] ?? null;
|
||||
$to = $_GET['to'] ?? null;
|
||||
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||
|
||||
// Validation des dates
|
||||
if (!$from || !$to) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Les paramètres from et to sont requis',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limiter la plage à 1 an
|
||||
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
|
||||
if ($daysDiff > 365) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'La plage de dates ne peut pas dépasser 1 an',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$weekly = $this->statsService->getWeekly($entityId, $from, $to, $eventTypes);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'weeks' => $weekly,
|
||||
],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération des stats hebdomadaires', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des statistiques',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/monthly
|
||||
* Récupère les stats mensuelles pour une année
|
||||
*
|
||||
* Query params:
|
||||
* - year: Année (YYYY), défaut = année courante
|
||||
* - events: Types d'événements (comma-separated), optionnel
|
||||
*/
|
||||
public function monthly(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y');
|
||||
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||
|
||||
// Validation de l'année
|
||||
if ($year < 2020 || $year > (int) date('Y') + 1) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Année invalide',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$monthly = $this->statsService->getMonthly($entityId, $year, $eventTypes);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'year' => $year,
|
||||
'months' => $monthly,
|
||||
],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération des stats mensuelles', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des statistiques',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/details
|
||||
* Récupère le détail des événements (lecture JSONL paginée)
|
||||
*
|
||||
* Query params:
|
||||
* - date: Date (YYYY-MM-DD), requis
|
||||
* - event: Type d'événement, optionnel
|
||||
* - limit: Nombre max (défaut 50, max 100)
|
||||
* - offset: Décalage pour pagination (défaut 0)
|
||||
*/
|
||||
public function details(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$date = $_GET['date'] ?? null;
|
||||
$eventType = $_GET['event'] ?? null;
|
||||
$limit = isset($_GET['limit']) ? min((int) $_GET['limit'], 100) : 50;
|
||||
$offset = isset($_GET['offset']) ? max((int) $_GET['offset'], 0) : 0;
|
||||
|
||||
// Validation de la date
|
||||
if (!$date) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Le paramètre date est requis',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isValidDate($date)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$details = $this->statsService->getDetails($entityId, $date, $eventType, $limit, $offset);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'data' => $details,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération du détail des événements', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des événements',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/types
|
||||
* Récupère la liste des types d'événements disponibles
|
||||
*/
|
||||
public function types(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$types = $this->statsService->getEventTypes();
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'data' => $types,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération des types d\'événements', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des types',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== MÉTHODES PRIVÉES ====================
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur a accès aux stats
|
||||
*/
|
||||
private function checkAccess(): bool
|
||||
{
|
||||
$roleId = Session::getRole();
|
||||
|
||||
if (!in_array($roleId, self::ALLOWED_ROLES)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Accès non autorisé. Rôle Admin ou Super-admin requis.',
|
||||
], 403);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine l'entity_id à utiliser pour la requête
|
||||
* Super-admin (role_id = 1) peut voir toutes les entités (null)
|
||||
* Admin (role_id = 2) voit uniquement son entité
|
||||
*/
|
||||
private function getEntityIdForQuery(): ?int
|
||||
{
|
||||
$roleId = Session::getRole();
|
||||
|
||||
// Super-admin : accès global
|
||||
if ($roleId === 1) {
|
||||
// Permettre de filtrer par entité si spécifié
|
||||
if (isset($_GET['entity_id'])) {
|
||||
return (int) $_GET['entity_id'];
|
||||
}
|
||||
return null; // Stats globales
|
||||
}
|
||||
|
||||
// Admin : uniquement son entité
|
||||
return Session::getEntityId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide le format d'une date
|
||||
*/
|
||||
private function isValidDate(string $date): bool
|
||||
{
|
||||
$d = \DateTime::createFromFormat('Y-m-d', $date);
|
||||
return $d && $d->format('Y-m-d') === $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une réponse JSON avec support ETag et compression gzip
|
||||
*
|
||||
* @param array $data Données à envoyer
|
||||
* @param bool $useCache Activer le cache ETag
|
||||
*/
|
||||
private function jsonWithCache(array $data, bool $useCache = true): void
|
||||
{
|
||||
// Nettoyer tout buffer existant
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Encoder en JSON
|
||||
$jsonResponse = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($jsonResponse === false) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur d\'encodage de la réponse',
|
||||
], 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Headers CORS
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Origin, Content-Type, X-Auth-Token, Authorization, X-Requested-With');
|
||||
header('Access-Control-Expose-Headers: Content-Length, ETag');
|
||||
|
||||
// Content-Type
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// ETag pour le cache
|
||||
if ($useCache) {
|
||||
$etag = '"' . md5($jsonResponse) . '"';
|
||||
header('ETag: ' . $etag);
|
||||
header('Cache-Control: private, max-age=300'); // 5 minutes
|
||||
|
||||
// Vérifier If-None-Match
|
||||
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
|
||||
if ($ifNoneMatch === $etag) {
|
||||
http_response_code(304); // Not Modified
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Compression gzip si supportée
|
||||
$supportsGzip = isset($_SERVER['HTTP_ACCEPT_ENCODING'])
|
||||
&& strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false;
|
||||
|
||||
if ($supportsGzip && strlen($jsonResponse) > 1024) {
|
||||
$compressed = gzencode($jsonResponse, 6);
|
||||
if ($compressed !== false) {
|
||||
header('Content-Encoding: gzip');
|
||||
header('Content-Length: ' . strlen($compressed));
|
||||
http_response_code(200);
|
||||
echo $compressed;
|
||||
flush();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Réponse non compressée
|
||||
header('Content-Length: ' . strlen($jsonResponse));
|
||||
http_response_code(200);
|
||||
echo $jsonResponse;
|
||||
flush();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -2086,18 +2086,38 @@ class LoginController {
|
||||
], 201);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->db->rollBack();
|
||||
LogService::log('Erreur lors de la création du compte GeoSector', [
|
||||
// Vérifier si une transaction est active avant de faire rollback
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
$stmtOpeUser = $this->db->prepare('
|
||||
|
||||
// Vérifier d'abord si c'est déjà un ope_users.id valide
|
||||
$stmtCheckOpeUser = $this->db->prepare('
|
||||
SELECT id FROM ope_users
|
||||
WHERE fk_user = ? AND fk_operation = ?
|
||||
WHERE id = ? AND fk_operation = ?
|
||||
');
|
||||
$stmtOpeUser->execute([$passageUserId, $operationId]);
|
||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||
$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']
|
||||
);
|
||||
|
||||
@@ -12,17 +12,15 @@ use App\Services\DepartmentBoundaryService;
|
||||
require_once __DIR__ . '/../Services/EventLogService.php';
|
||||
require_once __DIR__ . '/../Services/ApiService.php';
|
||||
|
||||
class SectorController
|
||||
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()
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
@@ -468,71 +469,7 @@ class StripeController extends Controller {
|
||||
$this->sendError('Erreur: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,85 +202,46 @@ 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
|
||||
]);
|
||||
|
||||
// Enregistrer dans l'historique
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_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
|
||||
|
||||
// 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();
|
||||
|
||||
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
|
||||
])
|
||||
]);
|
||||
$passage = $stmt->fetch();
|
||||
|
||||
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
|
||||
} else {
|
||||
error_log("Payment succeeded: {$paymentIntent->id} but no passage found in ope_pass");
|
||||
}
|
||||
|
||||
// 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}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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()
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
535
api/src/Services/EventStatsService.php
Normal file
535
api/src/Services/EventStatsService.php
Normal file
@@ -0,0 +1,535 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Database;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* EventStatsService - Service de statistiques d'événements
|
||||
*
|
||||
* Fournit des méthodes pour récupérer les statistiques agrégées
|
||||
* depuis la table event_stats_daily et le détail depuis les fichiers JSONL.
|
||||
*
|
||||
* @see docs/TECHBOOK.md section "Statistiques Events"
|
||||
*/
|
||||
class EventStatsService
|
||||
{
|
||||
/** @var string Chemin du dossier des logs événements */
|
||||
private const EVENT_LOG_DIR = __DIR__ . '/../../logs/events';
|
||||
|
||||
/** @var int Limite max pour le détail */
|
||||
private const MAX_DETAILS_LIMIT = 100;
|
||||
|
||||
/** @var PDO Instance de la base de données */
|
||||
private PDO $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
// ==================== MÉTHODES PUBLIQUES ====================
|
||||
|
||||
/**
|
||||
* Récupère le résumé des stats pour une date donnée
|
||||
*
|
||||
* @param int|null $entityId ID entité (null = toutes entités pour super-admin)
|
||||
* @param string|null $date Date (YYYY-MM-DD), défaut = aujourd'hui
|
||||
* @return array Stats résumées par catégorie
|
||||
*/
|
||||
public function getSummary(?int $entityId, ?string $date = null): array
|
||||
{
|
||||
$date = $date ?? date('Y-m-d');
|
||||
|
||||
$sql = "
|
||||
SELECT event_type, count, sum_amount, unique_users, metadata
|
||||
FROM event_stats_daily
|
||||
WHERE stat_date = :date
|
||||
";
|
||||
$params = ['date' => $date];
|
||||
|
||||
if ($entityId !== null) {
|
||||
$sql .= " AND entity_id = :entity_id";
|
||||
$params['entity_id'] = $entityId;
|
||||
} else {
|
||||
$sql .= " AND entity_id IS NULL";
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $this->formatSummary($date, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les stats journalières sur une plage de dates
|
||||
*
|
||||
* @param int|null $entityId ID entité (null = toutes entités)
|
||||
* @param string $from Date début (YYYY-MM-DD)
|
||||
* @param string $to Date fin (YYYY-MM-DD)
|
||||
* @param array $eventTypes Filtrer par types d'événements (optionnel)
|
||||
* @return array Stats par jour
|
||||
*/
|
||||
public function getDaily(?int $entityId, string $from, string $to, array $eventTypes = []): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT stat_date, event_type, count, sum_amount, unique_users
|
||||
FROM event_stats_daily
|
||||
WHERE stat_date BETWEEN :from AND :to
|
||||
";
|
||||
$params = ['from' => $from, 'to' => $to];
|
||||
|
||||
if ($entityId !== null) {
|
||||
$sql .= " AND entity_id = :entity_id";
|
||||
$params['entity_id'] = $entityId;
|
||||
} else {
|
||||
$sql .= " AND entity_id IS NULL";
|
||||
}
|
||||
|
||||
if (!empty($eventTypes)) {
|
||||
$placeholders = [];
|
||||
foreach ($eventTypes as $i => $type) {
|
||||
$key = "event_type_{$i}";
|
||||
$placeholders[] = ":{$key}";
|
||||
$params[$key] = $type;
|
||||
}
|
||||
$sql .= " AND event_type IN (" . implode(', ', $placeholders) . ")";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY stat_date ASC, event_type ASC";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $this->formatDaily($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les stats hebdomadaires (calculées depuis daily)
|
||||
*
|
||||
* @param int|null $entityId ID entité
|
||||
* @param string $from Date début
|
||||
* @param string $to Date fin
|
||||
* @param array $eventTypes Filtrer par types d'événements
|
||||
* @return array Stats par semaine
|
||||
*/
|
||||
public function getWeekly(?int $entityId, string $from, string $to, array $eventTypes = []): array
|
||||
{
|
||||
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
|
||||
|
||||
$weekly = [];
|
||||
|
||||
foreach ($daily as $day) {
|
||||
$date = new \DateTime($day['date']);
|
||||
$weekStart = clone $date;
|
||||
$weekStart->modify('monday this week');
|
||||
$weekKey = $weekStart->format('Y-m-d');
|
||||
|
||||
if (!isset($weekly[$weekKey])) {
|
||||
$weekly[$weekKey] = [
|
||||
'week_start' => $weekKey,
|
||||
'week_number' => (int) $date->format('W'),
|
||||
'year' => (int) $date->format('Y'),
|
||||
'events' => [],
|
||||
'totals' => [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Agréger les événements
|
||||
foreach ($day['events'] as $eventType => $stats) {
|
||||
if (!isset($weekly[$weekKey]['events'][$eventType])) {
|
||||
$weekly[$weekKey]['events'][$eventType] = [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
'unique_users' => 0,
|
||||
];
|
||||
}
|
||||
$weekly[$weekKey]['events'][$eventType]['count'] += $stats['count'];
|
||||
$weekly[$weekKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
|
||||
$weekly[$weekKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
|
||||
}
|
||||
|
||||
$weekly[$weekKey]['totals']['count'] += $day['totals']['count'];
|
||||
$weekly[$weekKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
|
||||
}
|
||||
|
||||
return array_values($weekly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les stats mensuelles (calculées depuis daily)
|
||||
*
|
||||
* @param int|null $entityId ID entité
|
||||
* @param int $year Année
|
||||
* @param array $eventTypes Filtrer par types d'événements
|
||||
* @return array Stats par mois
|
||||
*/
|
||||
public function getMonthly(?int $entityId, int $year, array $eventTypes = []): array
|
||||
{
|
||||
$from = "{$year}-01-01";
|
||||
$to = "{$year}-12-31";
|
||||
|
||||
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
|
||||
|
||||
$monthly = [];
|
||||
|
||||
foreach ($daily as $day) {
|
||||
$monthKey = substr($day['date'], 0, 7); // YYYY-MM
|
||||
|
||||
if (!isset($monthly[$monthKey])) {
|
||||
$monthly[$monthKey] = [
|
||||
'month' => $monthKey,
|
||||
'year' => $year,
|
||||
'month_number' => (int) substr($monthKey, 5, 2),
|
||||
'events' => [],
|
||||
'totals' => [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Agréger les événements
|
||||
foreach ($day['events'] as $eventType => $stats) {
|
||||
if (!isset($monthly[$monthKey]['events'][$eventType])) {
|
||||
$monthly[$monthKey]['events'][$eventType] = [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
'unique_users' => 0,
|
||||
];
|
||||
}
|
||||
$monthly[$monthKey]['events'][$eventType]['count'] += $stats['count'];
|
||||
$monthly[$monthKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
|
||||
$monthly[$monthKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
|
||||
}
|
||||
|
||||
$monthly[$monthKey]['totals']['count'] += $day['totals']['count'];
|
||||
$monthly[$monthKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
|
||||
}
|
||||
|
||||
return array_values($monthly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le détail des événements depuis le fichier JSONL
|
||||
*
|
||||
* @param int|null $entityId ID entité (null = tous)
|
||||
* @param string $date Date (YYYY-MM-DD)
|
||||
* @param string|null $eventType Filtrer par type d'événement
|
||||
* @param int $limit Nombre max de résultats
|
||||
* @param int $offset Décalage pour pagination
|
||||
* @return array Événements détaillés avec pagination
|
||||
*/
|
||||
public function getDetails(
|
||||
?int $entityId,
|
||||
string $date,
|
||||
?string $eventType = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0
|
||||
): array {
|
||||
$limit = min($limit, self::MAX_DETAILS_LIMIT);
|
||||
|
||||
$filePath = self::EVENT_LOG_DIR . '/' . $date . '.jsonl';
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
return [
|
||||
'date' => $date,
|
||||
'events' => [],
|
||||
'pagination' => [
|
||||
'total' => 0,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'has_more' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$events = [];
|
||||
$total = 0;
|
||||
$currentIndex = 0;
|
||||
|
||||
$handle = fopen($filePath, 'r');
|
||||
if (!$handle) {
|
||||
return [
|
||||
'date' => $date,
|
||||
'events' => [],
|
||||
'pagination' => [
|
||||
'total' => 0,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'has_more' => false,
|
||||
],
|
||||
'error' => 'Impossible de lire le fichier',
|
||||
];
|
||||
}
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$event = json_decode($line, true);
|
||||
if (!$event || !isset($event['event'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtrer par entity_id
|
||||
if ($entityId !== null) {
|
||||
$eventEntityId = $event['entity_id'] ?? null;
|
||||
if ($eventEntityId !== $entityId) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer par event_type
|
||||
if ($eventType !== null && ($event['event'] ?? '') !== $eventType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total++;
|
||||
|
||||
// Pagination
|
||||
if ($currentIndex >= $offset && count($events) < $limit) {
|
||||
$events[] = $this->sanitizeEventForOutput($event);
|
||||
}
|
||||
|
||||
$currentIndex++;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'events' => $events,
|
||||
'pagination' => [
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'has_more' => ($offset + $limit) < $total,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les types d'événements disponibles
|
||||
*
|
||||
* @return array Liste des types d'événements
|
||||
*/
|
||||
public function getEventTypes(): array
|
||||
{
|
||||
return [
|
||||
'auth' => ['login_success', 'login_failed', 'logout'],
|
||||
'passages' => ['passage_created', 'passage_updated', 'passage_deleted'],
|
||||
'sectors' => ['sector_created', 'sector_updated', 'sector_deleted'],
|
||||
'users' => ['user_created', 'user_updated', 'user_deleted'],
|
||||
'entities' => ['entity_created', 'entity_updated', 'entity_deleted'],
|
||||
'operations' => ['operation_created', 'operation_updated', 'operation_deleted'],
|
||||
'stripe' => [
|
||||
'stripe_payment_created',
|
||||
'stripe_payment_success',
|
||||
'stripe_payment_failed',
|
||||
'stripe_payment_cancelled',
|
||||
'stripe_terminal_error',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si des stats existent pour une date
|
||||
*
|
||||
* @param string $date Date à vérifier
|
||||
* @return bool
|
||||
*/
|
||||
public function hasStatsForDate(string $date): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT COUNT(*) FROM event_stats_daily WHERE stat_date = :date
|
||||
");
|
||||
$stmt->execute(['date' => $date]);
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
// ==================== MÉTHODES PRIVÉES ====================
|
||||
|
||||
/**
|
||||
* Formate le résumé des stats par catégorie
|
||||
*/
|
||||
private function formatSummary(string $date, array $rows): array
|
||||
{
|
||||
$summary = [
|
||||
'date' => $date,
|
||||
'stats' => [
|
||||
'auth' => ['success' => 0, 'failed' => 0, 'logout' => 0],
|
||||
'passages' => ['created' => 0, 'updated' => 0, 'deleted' => 0, 'amount' => 0.0],
|
||||
'users' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||
'sectors' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||
'entities' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||
'operations' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||
'stripe' => ['created' => 0, 'success' => 0, 'failed' => 0, 'cancelled' => 0, 'amount' => 0.0],
|
||||
],
|
||||
'totals' => [
|
||||
'events' => 0,
|
||||
'unique_users' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
$uniqueUsersSet = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$eventType = $row['event_type'];
|
||||
$count = (int) $row['count'];
|
||||
$amount = (float) $row['sum_amount'];
|
||||
$uniqueUsers = (int) $row['unique_users'];
|
||||
|
||||
$summary['totals']['events'] += $count;
|
||||
$uniqueUsersSet[$eventType] = $uniqueUsers;
|
||||
|
||||
// Mapper vers les catégories
|
||||
switch ($eventType) {
|
||||
case 'login_success':
|
||||
$summary['stats']['auth']['success'] = $count;
|
||||
break;
|
||||
case 'login_failed':
|
||||
$summary['stats']['auth']['failed'] = $count;
|
||||
break;
|
||||
case 'logout':
|
||||
$summary['stats']['auth']['logout'] = $count;
|
||||
break;
|
||||
case 'passage_created':
|
||||
$summary['stats']['passages']['created'] = $count;
|
||||
$summary['stats']['passages']['amount'] += $amount;
|
||||
break;
|
||||
case 'passage_updated':
|
||||
$summary['stats']['passages']['updated'] = $count;
|
||||
break;
|
||||
case 'passage_deleted':
|
||||
$summary['stats']['passages']['deleted'] = $count;
|
||||
break;
|
||||
case 'user_created':
|
||||
$summary['stats']['users']['created'] = $count;
|
||||
break;
|
||||
case 'user_updated':
|
||||
$summary['stats']['users']['updated'] = $count;
|
||||
break;
|
||||
case 'user_deleted':
|
||||
$summary['stats']['users']['deleted'] = $count;
|
||||
break;
|
||||
case 'sector_created':
|
||||
$summary['stats']['sectors']['created'] = $count;
|
||||
break;
|
||||
case 'sector_updated':
|
||||
$summary['stats']['sectors']['updated'] = $count;
|
||||
break;
|
||||
case 'sector_deleted':
|
||||
$summary['stats']['sectors']['deleted'] = $count;
|
||||
break;
|
||||
case 'entity_created':
|
||||
$summary['stats']['entities']['created'] = $count;
|
||||
break;
|
||||
case 'entity_updated':
|
||||
$summary['stats']['entities']['updated'] = $count;
|
||||
break;
|
||||
case 'entity_deleted':
|
||||
$summary['stats']['entities']['deleted'] = $count;
|
||||
break;
|
||||
case 'operation_created':
|
||||
$summary['stats']['operations']['created'] = $count;
|
||||
break;
|
||||
case 'operation_updated':
|
||||
$summary['stats']['operations']['updated'] = $count;
|
||||
break;
|
||||
case 'operation_deleted':
|
||||
$summary['stats']['operations']['deleted'] = $count;
|
||||
break;
|
||||
case 'stripe_payment_created':
|
||||
$summary['stats']['stripe']['created'] = $count;
|
||||
break;
|
||||
case 'stripe_payment_success':
|
||||
$summary['stats']['stripe']['success'] = $count;
|
||||
$summary['stats']['stripe']['amount'] += $amount;
|
||||
break;
|
||||
case 'stripe_payment_failed':
|
||||
$summary['stats']['stripe']['failed'] = $count;
|
||||
break;
|
||||
case 'stripe_payment_cancelled':
|
||||
$summary['stats']['stripe']['cancelled'] = $count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Estimation des utilisateurs uniques (max des catégories car overlap possible)
|
||||
$summary['totals']['unique_users'] = !empty($uniqueUsersSet) ? max($uniqueUsersSet) : 0;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate les stats journalières
|
||||
*/
|
||||
private function formatDaily(array $rows): array
|
||||
{
|
||||
$daily = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$date = $row['stat_date'];
|
||||
|
||||
if (!isset($daily[$date])) {
|
||||
$daily[$date] = [
|
||||
'date' => $date,
|
||||
'events' => [],
|
||||
'totals' => [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$eventType = $row['event_type'];
|
||||
$count = (int) $row['count'];
|
||||
$amount = (float) $row['sum_amount'];
|
||||
|
||||
$daily[$date]['events'][$eventType] = [
|
||||
'count' => $count,
|
||||
'sum_amount' => $amount,
|
||||
'unique_users' => (int) $row['unique_users'],
|
||||
];
|
||||
|
||||
$daily[$date]['totals']['count'] += $count;
|
||||
$daily[$date]['totals']['sum_amount'] += $amount;
|
||||
}
|
||||
|
||||
return array_values($daily);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie un événement pour l'affichage (supprime données sensibles)
|
||||
*/
|
||||
private function sanitizeEventForOutput(array $event): array
|
||||
{
|
||||
// Supprimer l'IP complète, garder seulement les 2 premiers octets
|
||||
if (isset($event['ip'])) {
|
||||
$parts = explode('.', $event['ip']);
|
||||
if (count($parts) === 4) {
|
||||
$event['ip'] = $parts[0] . '.' . $parts[1] . '.x.x';
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer le user_agent complet
|
||||
unset($event['user_agent']);
|
||||
|
||||
// Supprimer les données chiffrées si présentes
|
||||
unset($event['encrypted_name']);
|
||||
unset($event['encrypted_email']);
|
||||
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class ExportService {
|
||||
$filepath = $exportDir . '/' . $filename;
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
292
api/src/Services/SectorService.php
Normal file
292
api/src/Services/SectorService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Database;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Service global pour la gestion des secteurs
|
||||
*
|
||||
* Fournit des fonctions réutilisables pour :
|
||||
* - Géocoder une adresse via api-adresse.data.gouv.fr
|
||||
* - Trouver un secteur à partir de coordonnées GPS
|
||||
* - Trouver un secteur à partir d'une adresse
|
||||
*/
|
||||
class SectorService
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Géocode une adresse via api-adresse.data.gouv.fr
|
||||
*
|
||||
* @param string $num Numéro de rue
|
||||
* @param string $bis Complément (bis, ter, etc.)
|
||||
* @param string $rue Nom de la rue
|
||||
* @param string $ville Nom de la ville
|
||||
* @param string $cp Code postal (pour vérifier le département)
|
||||
* @return array|null [lat, lng] ou null si non trouvé ou score trop faible
|
||||
*/
|
||||
public function geocodeAddress(string $num, string $bis, string $rue, string $ville, string $cp): ?array
|
||||
{
|
||||
try {
|
||||
// Construire l'URL de l'API
|
||||
$query = trim($num . $bis) . ' ' . $rue . ' ' . $ville;
|
||||
$url = 'https://api-adresse.data.gouv.fr/search/?q=' . urlencode($query);
|
||||
|
||||
LogService::info('[SectorService] Géocodage adresse', [
|
||||
'url' => $url,
|
||||
'adresse' => $query
|
||||
]);
|
||||
|
||||
// Appel à l'API
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
$json = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($json)) {
|
||||
LogService::warning('[SectorService] Erreur API géocodage', [
|
||||
'http_code' => $httpCode,
|
||||
'adresse' => $query
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($json);
|
||||
|
||||
if (empty($data->features)) {
|
||||
LogService::info('[SectorService] Aucun résultat de géocodage', [
|
||||
'adresse' => $query
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$score = $data->features[0]->properties->score ?? 0;
|
||||
|
||||
// Vérifier le score (> 0.7 = 70% de confiance)
|
||||
if (floatval($score) <= 0.7) {
|
||||
LogService::info('[SectorService] Score géocodage trop faible', [
|
||||
'score' => $score,
|
||||
'adresse' => $query
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier le département
|
||||
$cpTrouve = $data->features[0]->properties->postcode ?? '';
|
||||
$deptTrouve = substr($cpTrouve, 0, 2);
|
||||
|
||||
$cpAmicale = $cp;
|
||||
if (strlen($cpAmicale) == 4) {
|
||||
$cpAmicale = '0' . $cpAmicale;
|
||||
}
|
||||
$deptAmicale = substr($cpAmicale, 0, 2);
|
||||
|
||||
if ($deptTrouve !== $deptAmicale) {
|
||||
LogService::warning('[SectorService] Département différent', [
|
||||
'dept_trouve' => $deptTrouve,
|
||||
'dept_attendu' => $deptAmicale,
|
||||
'adresse' => $query
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extraire les coordonnées [lng, lat] -> [lat, lng]
|
||||
$coordinates = $data->features[0]->geometry->coordinates;
|
||||
$lat = (float)$coordinates[1];
|
||||
$lng = (float)$coordinates[0];
|
||||
|
||||
LogService::info('[SectorService] Géocodage réussi', [
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'score' => $score,
|
||||
'adresse' => $query
|
||||
]);
|
||||
|
||||
return ['lat' => $lat, 'lng' => $lng];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
LogService::error('[SectorService] Erreur géocodage', [
|
||||
'error' => $e->getMessage(),
|
||||
'adresse' => "$num$bis $rue $ville"
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le secteur contenant une position GPS pour une opération donnée
|
||||
*
|
||||
* @param int $operationId ID de l'opération
|
||||
* @param float $lat Latitude
|
||||
* @param float $lng Longitude
|
||||
* @return int|null ID du secteur trouvé ou null
|
||||
*/
|
||||
public function findSectorByGps(int $operationId, float $lat, float $lng): ?int
|
||||
{
|
||||
try {
|
||||
// Récupérer tous les secteurs de l'opération avec leur polygone
|
||||
$query = "SELECT id, sector FROM ope_sectors
|
||||
WHERE fk_operation = :operation_id
|
||||
AND chk_active = 1
|
||||
AND sector IS NOT NULL
|
||||
AND sector != ''";
|
||||
|
||||
$stmt = $this->db->prepare($query);
|
||||
$stmt->execute(['operation_id' => $operationId]);
|
||||
$sectors = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($sectors)) {
|
||||
LogService::info('[SectorService] Aucun secteur trouvé pour l\'opération', [
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tester chaque secteur
|
||||
foreach ($sectors as $sector) {
|
||||
$polygon = $this->parseSectorPolygon($sector['sector']);
|
||||
|
||||
if (empty($polygon)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isPointInsidePolygon($lat, $lng, $polygon)) {
|
||||
LogService::info('[SectorService] Secteur trouvé par GPS', [
|
||||
'sector_id' => $sector['id'],
|
||||
'operation_id' => $operationId,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng
|
||||
]);
|
||||
return (int)$sector['id'];
|
||||
}
|
||||
}
|
||||
|
||||
LogService::info('[SectorService] Aucun secteur ne contient ce point GPS', [
|
||||
'operation_id' => $operationId,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'nb_sectors_tested' => count($sectors)
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
LogService::error('[SectorService] Erreur findSectorByGps', [
|
||||
'error' => $e->getMessage(),
|
||||
'operation_id' => $operationId,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le secteur pour une adresse (géocodage + recherche GPS)
|
||||
*
|
||||
* @param int $operationId ID de l'opération
|
||||
* @param string $num Numéro de rue
|
||||
* @param string $bis Complément
|
||||
* @param string $rue Nom de la rue
|
||||
* @param string $ville Nom de la ville
|
||||
* @param string $cp Code postal
|
||||
* @return array|null ['sector_id' => int, 'gps_lat' => float, 'gps_lng' => float] ou null
|
||||
*/
|
||||
public function findSectorByAddress(int $operationId, string $num, string $bis, string $rue, string $ville, string $cp): ?array
|
||||
{
|
||||
// Étape 1 : Géocoder l'adresse
|
||||
$coords = $this->geocodeAddress($num, $bis, $rue, $ville, $cp);
|
||||
|
||||
if (!$coords) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Étape 2 : Chercher le secteur avec les coordonnées obtenues
|
||||
$sectorId = $this->findSectorByGps($operationId, $coords['lat'], $coords['lng']);
|
||||
|
||||
if (!$sectorId) {
|
||||
// Retourner quand même les coordonnées GPS trouvées (utiles pour mettre à jour le passage)
|
||||
return [
|
||||
'sector_id' => null,
|
||||
'gps_lat' => $coords['lat'],
|
||||
'gps_lng' => $coords['lng']
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'sector_id' => $sectorId,
|
||||
'gps_lat' => $coords['lat'],
|
||||
'gps_lng' => $coords['lng']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse le format de polygone stocké en base (lat/lng#lat/lng#...)
|
||||
*
|
||||
* @param string $sectorString Format "lat/lng#lat/lng#..."
|
||||
* @return array Array de ['lat' => float, 'lng' => float]
|
||||
*/
|
||||
private function parseSectorPolygon(string $sectorString): array
|
||||
{
|
||||
$polygon = [];
|
||||
$points = explode('#', rtrim($sectorString, '#'));
|
||||
|
||||
foreach ($points as $point) {
|
||||
if (!empty($point) && strpos($point, '/') !== false) {
|
||||
list($lat, $lng) = explode('/', $point);
|
||||
$polygon[] = [
|
||||
'lat' => (float)$lat,
|
||||
'lng' => (float)$lng
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $polygon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un point est à l'intérieur d'un polygone
|
||||
* Utilise l'algorithme de ray casting
|
||||
*
|
||||
* @param float $lat Latitude du point
|
||||
* @param float $lng Longitude du point
|
||||
* @param array $polygon Array de ['lat' => float, 'lng' => float]
|
||||
* @return bool
|
||||
*/
|
||||
private function isPointInsidePolygon(float $lat, float $lng, array $polygon): bool
|
||||
{
|
||||
$x = $lat;
|
||||
$y = $lng;
|
||||
$inside = false;
|
||||
$count = count($polygon);
|
||||
|
||||
for ($i = 0, $j = $count - 1; $i < $count; $j = $i++) {
|
||||
$xi = $polygon[$i]['lat'];
|
||||
$yi = $polygon[$i]['lng'];
|
||||
$xj = $polygon[$j]['lat'];
|
||||
$yj = $polygon[$j]['lng'];
|
||||
|
||||
$intersect = (($yi > $y) != ($yj > $y))
|
||||
&& ($x < ($xj - $xi) * ($y - $yi) / ($yj - $yi) + $xi);
|
||||
|
||||
if ($intersect) {
|
||||
$inside = !$inside;
|
||||
}
|
||||
}
|
||||
|
||||
return $inside;
|
||||
}
|
||||
}
|
||||
@@ -465,64 +465,68 @@ 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€");
|
||||
}
|
||||
|
||||
|
||||
// Récupérer le compte Stripe
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM stripe_accounts WHERE fk_entite = :fk_entite"
|
||||
);
|
||||
$stmt->execute(['fk_entite' => $entiteId]);
|
||||
$account = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
|
||||
if (!$account) {
|
||||
throw new Exception("Compte Stripe non trouvé");
|
||||
}
|
||||
|
||||
// Pas de commission plateforme - 100% pour l'amicale
|
||||
|
||||
// Créer le PaymentIntent sans commission
|
||||
$paymentIntent = $this->stripe->paymentIntents->create([
|
||||
|
||||
// Déterminer le mode : Tap to Pay (card_present) ou Payment Link (card)
|
||||
$isTapToPay = in_array('card_present', $paymentMethodTypes);
|
||||
|
||||
// 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
|
||||
'mode' => $isTapToPay ? 'tap_to_pay' : 'payment_link'
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
@@ -532,76 +536,7 @@ class StripeService {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la compatibilité Tap to Pay d'un appareil Android
|
||||
*/
|
||||
public function checkAndroidTapToPayCompatibility(string $manufacturer, string $model): array {
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM stripe_android_certified_devices
|
||||
WHERE manufacturer = :manufacturer
|
||||
AND model = :model
|
||||
AND tap_to_pay_certified = 1
|
||||
AND country = 'FR'"
|
||||
);
|
||||
$stmt->execute([
|
||||
'manufacturer' => $manufacturer,
|
||||
'model' => $model
|
||||
]);
|
||||
|
||||
$device = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($device) {
|
||||
return [
|
||||
'success' => true,
|
||||
'tap_to_pay_supported' => true,
|
||||
'message' => 'Tap to Pay disponible sur cet appareil',
|
||||
'min_android_version' => $device['min_android_version']
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'tap_to_pay_supported' => false,
|
||||
'message' => 'Appareil non certifié pour Tap to Pay en France',
|
||||
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 16.4+'
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Erreur: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les appareils Android certifiés
|
||||
*/
|
||||
public function getCertifiedAndroidDevices(): array {
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT manufacturer, model, model_identifier, min_android_version
|
||||
FROM stripe_android_certified_devices
|
||||
WHERE tap_to_pay_certified = 1 AND country = 'FR'
|
||||
ORDER BY manufacturer, model"
|
||||
);
|
||||
$stmt->execute();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'devices' => $stmt->fetchAll(PDO::FETCH_ASSOC)
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Erreur: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Créer un Payment Link Stripe pour paiement par QR Code
|
||||
*
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user