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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user