417 lines
16 KiB
Java
417 lines
16 KiB
Java
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
|
|
*
|
|
* <p>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<KPITrendResponse.PointDonneeDTO> 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<KPITrendResponse.PointDonneeDTO> genererPointsDonnees(
|
|
TypeMetrique typeMetrique,
|
|
LocalDateTime dateDebut,
|
|
LocalDateTime dateFin,
|
|
UUID organisationId) {
|
|
List<KPITrendResponse.PointDonneeDTO> 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<KPITrendResponse.PointDonneeDTO> points) {
|
|
if (points.isEmpty()) {
|
|
return new StatistiquesDTO();
|
|
}
|
|
|
|
List<BigDecimal> 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<KPITrendResponse.PointDonneeDTO> 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<KPITrendResponse.PointDonneeDTO> 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<KPITrendResponse.PointDonneeDTO> 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<KPITrendResponse.PointDonneeDTO> 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;
|
|
}
|
|
}
|
|
}
|