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