Files
geo/api/src/Services/EventStatsService.php
Pierre 232940b1eb feat: Version 3.6.2 - Correctifs tâches #17-20
- #17: Amélioration gestion des secteurs et statistiques
- #18: Optimisation services API et logs
- #19: Corrections Flutter widgets et repositories
- #20: Fix création passage - détection automatique ope_users.id vs users.id

Suppression dossier web/ (migration vers app Flutter)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 14:11:15 +01:00

536 lines
18 KiB
PHP

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