package dev.lions.unionflow.server.service; import dev.lions.unionflow.server.api.dto.analytics.KPITrendResponse; import dev.lions.unionflow.server.api.enums.analytics.PeriodeAnalyse; import dev.lions.unionflow.server.api.enums.analytics.TypeMetrique; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.UUID; import lombok.extern.slf4j.Slf4j; /** * Service d'analyse des tendances et prédictions pour les KPI * *

Ce service calcule les tendances, effectue des analyses statistiques et génère des prédictions * basées sur l'historique des données. * * @author UnionFlow Team * @version 1.0 * @since 2025-01-16 */ @ApplicationScoped @Slf4j public class TrendAnalysisService { @Inject AnalyticsService analyticsService; @Inject KPICalculatorService kpiCalculatorService; @Inject dev.lions.unionflow.server.repository.OrganisationRepository organisationRepository; /** * Calcule la tendance d'un KPI sur une période donnée * * @param typeMetrique Le type de métrique à analyser * @param periodeAnalyse La période d'analyse * @param organisationId L'ID de l'organisation (optionnel) * @return Les données de tendance du KPI */ public KPITrendResponse calculerTendance( TypeMetrique typeMetrique, PeriodeAnalyse periodeAnalyse, UUID organisationId) { log.info( "Calcul de la tendance pour {} sur la période {} et l'organisation {}", typeMetrique, periodeAnalyse, organisationId); LocalDateTime dateDebut = periodeAnalyse.getDateDebut(); LocalDateTime dateFin = periodeAnalyse.getDateFin(); // Génération des points de données historiques List pointsDonnees = genererPointsDonnees(typeMetrique, dateDebut, dateFin, organisationId); // Calculs statistiques StatistiquesDTO stats = calculerStatistiques(pointsDonnees); // Analyse de tendance (régression linéaire simple) TendanceDTO tendance = calculerTendanceLineaire(pointsDonnees); // Prédiction pour la prochaine période BigDecimal prediction = calculerPrediction(pointsDonnees, tendance); // Détection d'anomalies detecterAnomalies(pointsDonnees, stats); return KPITrendResponse.builder() .typeMetrique(typeMetrique) .periodeAnalyse(periodeAnalyse) .organisationId(organisationId) .nomOrganisation(obtenirNomOrganisation(organisationId)) .dateDebut(dateDebut) .dateFin(dateFin) .pointsDonnees(pointsDonnees) .valeurActuelle(stats.valeurActuelle) .valeurMinimale(stats.valeurMinimale) .valeurMaximale(stats.valeurMaximale) .valeurMoyenne(stats.valeurMoyenne) .ecartType(stats.ecartType) .coefficientVariation(stats.coefficientVariation) .tendanceGenerale(tendance.pente) .coefficientCorrelation(tendance.coefficientCorrelation) .pourcentageEvolutionGlobale(calculerEvolutionGlobale(pointsDonnees)) .predictionProchainePeriode(prediction) .margeErreurPrediction(calculerMargeErreur(tendance)) .seuilAlerteBas(calculerSeuilAlerteBas(stats)) .seuilAlerteHaut(calculerSeuilAlerteHaut(stats)) .alerteActive(verifierAlertes(stats.valeurActuelle, stats)) .intervalleRegroupement(periodeAnalyse.getIntervalleRegroupement()) .formatDate(periodeAnalyse.getFormatDate()) .dateDerniereMiseAJour(LocalDateTime.now()) .frequenceMiseAJourMinutes(determinerFrequenceMiseAJour(periodeAnalyse)) .build(); } /** Génère les points de données historiques pour la période */ private List genererPointsDonnees( TypeMetrique typeMetrique, LocalDateTime dateDebut, LocalDateTime dateFin, UUID organisationId) { List points = new ArrayList<>(); // Déterminer l'intervalle entre les points ChronoUnit unite = determinerUniteIntervalle(dateDebut, dateFin); long intervalleValeur = determinerValeurIntervalle(dateDebut, dateFin, unite); LocalDateTime dateCourante = dateDebut; int index = 0; while (!dateCourante.isAfter(dateFin)) { LocalDateTime dateFinIntervalle = dateCourante.plus(intervalleValeur, unite); if (dateFinIntervalle.isAfter(dateFin)) { dateFinIntervalle = dateFin; } // Calcul de la valeur pour cet intervalle BigDecimal valeur = calculerValeurPourIntervalle( typeMetrique, dateCourante, dateFinIntervalle, organisationId); KPITrendResponse.PointDonneeDTO point = KPITrendResponse.PointDonneeDTO.builder() .date(dateCourante) .valeur(valeur) .libelle(formaterLibellePoint(dateCourante, unite)) .anomalie(false) // Sera déterminé plus tard .prediction(false) .build(); points.add(point); dateCourante = dateCourante.plus(intervalleValeur, unite); index++; } log.info("Généré {} points de données pour la tendance", points.size()); return points; } /** Calcule les statistiques descriptives des points de données */ private StatistiquesDTO calculerStatistiques(List points) { if (points.isEmpty()) { return new StatistiquesDTO(); } List valeurs = points.stream().map(KPITrendResponse.PointDonneeDTO::getValeur).toList(); BigDecimal valeurActuelle = points.get(points.size() - 1).getValeur(); BigDecimal valeurMinimale = valeurs.stream().min(BigDecimal::compareTo).orElse(BigDecimal.ZERO); BigDecimal valeurMaximale = valeurs.stream().max(BigDecimal::compareTo).orElse(BigDecimal.ZERO); // Calcul de la moyenne BigDecimal somme = valeurs.stream().reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal moyenne = somme.divide(new BigDecimal(valeurs.size()), 4, RoundingMode.HALF_UP); // Calcul de l'écart-type BigDecimal sommeDifferencesCarrees = valeurs.stream() .map(v -> v.subtract(moyenne).pow(2)) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal variance = sommeDifferencesCarrees.divide(new BigDecimal(valeurs.size()), 4, RoundingMode.HALF_UP); BigDecimal ecartType = new BigDecimal(Math.sqrt(variance.doubleValue())).setScale(4, RoundingMode.HALF_UP); // Coefficient de variation BigDecimal coefficientVariation = moyenne.compareTo(BigDecimal.ZERO) != 0 ? ecartType.divide(moyenne, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO; return new StatistiquesDTO( valeurActuelle, valeurMinimale, valeurMaximale, moyenne, ecartType, coefficientVariation); } /** Calcule la tendance linéaire (régression linéaire simple) */ private TendanceDTO calculerTendanceLineaire(List points) { if (points.size() < 2) { return new TendanceDTO(BigDecimal.ZERO, BigDecimal.ZERO); } int n = points.size(); BigDecimal sommeX = BigDecimal.ZERO; BigDecimal sommeY = BigDecimal.ZERO; BigDecimal sommeXY = BigDecimal.ZERO; BigDecimal sommeX2 = BigDecimal.ZERO; BigDecimal sommeY2 = BigDecimal.ZERO; for (int i = 0; i < n; i++) { BigDecimal x = new BigDecimal(i); // Index comme variable X BigDecimal y = points.get(i).getValeur(); // Valeur comme variable Y sommeX = sommeX.add(x); sommeY = sommeY.add(y); sommeXY = sommeXY.add(x.multiply(y)); sommeX2 = sommeX2.add(x.multiply(x)); sommeY2 = sommeY2.add(y.multiply(y)); } // Calcul de la pente (coefficient directeur) BigDecimal nBD = new BigDecimal(n); BigDecimal numerateur = nBD.multiply(sommeXY).subtract(sommeX.multiply(sommeY)); BigDecimal denominateur = nBD.multiply(sommeX2).subtract(sommeX.multiply(sommeX)); BigDecimal pente = denominateur.compareTo(BigDecimal.ZERO) != 0 ? numerateur.divide(denominateur, 6, RoundingMode.HALF_UP) : BigDecimal.ZERO; // Calcul du coefficient de corrélation R² BigDecimal numerateurR = numerateur; BigDecimal denominateurR1 = nBD.multiply(sommeX2).subtract(sommeX.multiply(sommeX)); BigDecimal denominateurR2 = nBD.multiply(sommeY2).subtract(sommeY.multiply(sommeY)); BigDecimal coefficientCorrelation = BigDecimal.ZERO; if (denominateurR1.compareTo(BigDecimal.ZERO) != 0 && denominateurR2.compareTo(BigDecimal.ZERO) != 0) { BigDecimal denominateurR = new BigDecimal(Math.sqrt(denominateurR1.multiply(denominateurR2).doubleValue())); if (denominateurR.compareTo(BigDecimal.ZERO) != 0) { BigDecimal r = numerateurR.divide(denominateurR, 6, RoundingMode.HALF_UP); coefficientCorrelation = r.multiply(r); // R² } } return new TendanceDTO(pente, coefficientCorrelation); } /** Calcule une prédiction pour la prochaine période */ private BigDecimal calculerPrediction( List points, TendanceDTO tendance) { if (points.isEmpty()) return BigDecimal.ZERO; BigDecimal derniereValeur = points.get(points.size() - 1).getValeur(); BigDecimal prediction = derniereValeur.add(tendance.pente); // S'assurer que la prédiction est positive return prediction.max(BigDecimal.ZERO); } /** Détecte les anomalies dans les points de données */ private void detecterAnomalies(List points, StatistiquesDTO stats) { BigDecimal seuilAnomalie = stats.ecartType.multiply(new BigDecimal("2")); // 2 écarts-types for (KPITrendResponse.PointDonneeDTO point : points) { BigDecimal ecartMoyenne = point.getValeur().subtract(stats.valeurMoyenne).abs(); if (ecartMoyenne.compareTo(seuilAnomalie) > 0) { point.setAnomalie(true); } } } // === MÉTHODES UTILITAIRES === private ChronoUnit determinerUniteIntervalle(LocalDateTime dateDebut, LocalDateTime dateFin) { long joursTotal = ChronoUnit.DAYS.between(dateDebut, dateFin); if (joursTotal <= 7) return ChronoUnit.DAYS; if (joursTotal <= 90) return ChronoUnit.DAYS; if (joursTotal <= 365) return ChronoUnit.WEEKS; return ChronoUnit.MONTHS; } private long determinerValeurIntervalle( LocalDateTime dateDebut, LocalDateTime dateFin, ChronoUnit unite) { long dureeTotal = unite.between(dateDebut, dateFin); // Viser environ 10-20 points de données if (dureeTotal <= 20) return 1; if (dureeTotal <= 40) return 2; if (dureeTotal <= 100) return 5; return dureeTotal / 15; // Environ 15 points } private BigDecimal calculerValeurPourIntervalle( TypeMetrique typeMetrique, LocalDateTime dateDebut, LocalDateTime dateFin, UUID organisationId) { // Utiliser le service KPI pour calculer la valeur return switch (typeMetrique) { case NOMBRE_MEMBRES_ACTIFS -> { // Calcul direct via le service KPI var kpis = kpiCalculatorService.calculerTousLesKPI(organisationId, dateDebut, dateFin); yield kpis.getOrDefault(TypeMetrique.NOMBRE_MEMBRES_ACTIFS, BigDecimal.ZERO); } case TOTAL_COTISATIONS_COLLECTEES -> { var kpis = kpiCalculatorService.calculerTousLesKPI(organisationId, dateDebut, dateFin); yield kpis.getOrDefault(TypeMetrique.TOTAL_COTISATIONS_COLLECTEES, BigDecimal.ZERO); } case NOMBRE_EVENEMENTS_ORGANISES -> { var kpis = kpiCalculatorService.calculerTousLesKPI(organisationId, dateDebut, dateFin); yield kpis.getOrDefault(TypeMetrique.NOMBRE_EVENEMENTS_ORGANISES, BigDecimal.ZERO); } case NOMBRE_DEMANDES_AIDE -> { var kpis = kpiCalculatorService.calculerTousLesKPI(organisationId, dateDebut, dateFin); yield kpis.getOrDefault(TypeMetrique.NOMBRE_DEMANDES_AIDE, BigDecimal.ZERO); } default -> BigDecimal.ZERO; }; } private String formaterLibellePoint(LocalDateTime date, ChronoUnit unite) { return switch (unite) { case DAYS -> date.toLocalDate().toString(); case WEEKS -> "S" + date.get(java.time.temporal.WeekFields.ISO.weekOfYear()); case MONTHS -> date.getMonth().toString() + " " + date.getYear(); default -> date.toString(); }; } private BigDecimal calculerEvolutionGlobale(List points) { if (points.size() < 2) return BigDecimal.ZERO; BigDecimal premiereValeur = points.get(0).getValeur(); BigDecimal derniereValeur = points.get(points.size() - 1).getValeur(); if (premiereValeur.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO; return derniereValeur .subtract(premiereValeur) .divide(premiereValeur, 4, RoundingMode.HALF_UP) .multiply(new BigDecimal("100")); } private BigDecimal calculerMargeErreur(TendanceDTO tendance) { // Marge d'erreur basée sur le coefficient de corrélation BigDecimal precision = tendance.coefficientCorrelation; BigDecimal margeErreur = BigDecimal.ONE.subtract(precision).multiply(new BigDecimal("100")); return margeErreur.min(new BigDecimal("50")); // Plafonnée à 50% } private BigDecimal calculerSeuilAlerteBas(StatistiquesDTO stats) { return stats.valeurMoyenne.subtract(stats.ecartType.multiply(new BigDecimal("1.5"))); } private BigDecimal calculerSeuilAlerteHaut(StatistiquesDTO stats) { return stats.valeurMoyenne.add(stats.ecartType.multiply(new BigDecimal("1.5"))); } private Boolean verifierAlertes(BigDecimal valeurActuelle, StatistiquesDTO stats) { BigDecimal seuilBas = calculerSeuilAlerteBas(stats); BigDecimal seuilHaut = calculerSeuilAlerteHaut(stats); return valeurActuelle.compareTo(seuilBas) < 0 || valeurActuelle.compareTo(seuilHaut) > 0; } private Integer determinerFrequenceMiseAJour(PeriodeAnalyse periode) { return switch (periode) { case AUJOURD_HUI, HIER -> 15; // 15 minutes case CETTE_SEMAINE, SEMAINE_DERNIERE -> 60; // 1 heure case CE_MOIS, MOIS_DERNIER -> 240; // 4 heures default -> 1440; // 24 heures }; } private String obtenirNomOrganisation(UUID organisationId) { if (organisationId == null) return null; return organisationRepository.findByIdOptional(organisationId) .map(org -> org.getNom()) .orElse(null); } // === CLASSES INTERNES === private static class StatistiquesDTO { final BigDecimal valeurActuelle; final BigDecimal valeurMinimale; final BigDecimal valeurMaximale; final BigDecimal valeurMoyenne; final BigDecimal ecartType; final BigDecimal coefficientVariation; StatistiquesDTO() { this( BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO); } StatistiquesDTO( BigDecimal valeurActuelle, BigDecimal valeurMinimale, BigDecimal valeurMaximale, BigDecimal valeurMoyenne, BigDecimal ecartType, BigDecimal coefficientVariation) { this.valeurActuelle = valeurActuelle; this.valeurMinimale = valeurMinimale; this.valeurMaximale = valeurMaximale; this.valeurMoyenne = valeurMoyenne; this.ecartType = ecartType; this.coefficientVariation = coefficientVariation; } } private static class TendanceDTO { final BigDecimal pente; final BigDecimal coefficientCorrelation; TendanceDTO(BigDecimal pente, BigDecimal coefficientCorrelation) { this.pente = pente; this.coefficientCorrelation = coefficientCorrelation; } } }