/** * Service de détection d'anomalies dans les métriques Jira * Analyse les patterns et tendances pour identifier des problèmes potentiels */ import { JiraAnalytics, SprintVelocity, CycleTimeByType, AssigneeWorkload } from '@/lib/types'; export interface JiraAnomaly { id: string; type: 'velocity' | 'cycle_time' | 'workload' | 'completion' | 'blockers'; severity: 'low' | 'medium' | 'high' | 'critical'; title: string; description: string; value: number; threshold: number; recommendation: string; affectedItems: string[]; timestamp: string; } export interface AnomalyDetectionConfig { velocityVarianceThreshold: number; // % de variance acceptable cycleTimeThreshold: number; // multiplicateur du cycle time moyen workloadImbalanceThreshold: number; // ratio max entre assignees completionRateThreshold: number; // % minimum de completion stalledItemsThreshold: number; // jours sans changement } export class JiraAnomalyDetectionService { private readonly defaultConfig: AnomalyDetectionConfig = { velocityVarianceThreshold: 30, // 30% de variance cycleTimeThreshold: 2.0, // 2x le cycle time moyen workloadImbalanceThreshold: 3.0, // 3:1 ratio max completionRateThreshold: 70, // 70% completion minimum stalledItemsThreshold: 7 // 7 jours }; constructor(private config: Partial = {}) { this.config = { ...this.defaultConfig, ...config }; } /** * Analyse toutes les métriques et détecte les anomalies */ async detectAnomalies(analytics: JiraAnalytics): Promise { const anomalies: JiraAnomaly[] = []; const timestamp = new Date().toISOString(); // 1. Détection d'anomalies de vélocité const velocityAnomalies = this.detectVelocityAnomalies(analytics.velocityMetrics, timestamp); anomalies.push(...velocityAnomalies); // 2. Détection d'anomalies de cycle time const cycleTimeAnomalies = this.detectCycleTimeAnomalies(analytics.cycleTimeMetrics, timestamp); anomalies.push(...cycleTimeAnomalies); // 3. Détection de déséquilibres de charge const workloadAnomalies = this.detectWorkloadAnomalies(analytics.workInProgress.byAssignee, timestamp); anomalies.push(...workloadAnomalies); // 4. Détection de problèmes de completion const completionAnomalies = this.detectCompletionAnomalies(analytics.velocityMetrics, timestamp); anomalies.push(...completionAnomalies); // Trier par sévérité return anomalies.sort((a, b) => this.getSeverityWeight(b.severity) - this.getSeverityWeight(a.severity)); } /** * Détecte les anomalies de vélocité (variance excessive, tendance négative) */ private detectVelocityAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[]; averageVelocity: number }, timestamp: string): JiraAnomaly[] { const anomalies: JiraAnomaly[] = []; const { sprintHistory, averageVelocity } = velocityMetrics; if (sprintHistory.length < 3) return anomalies; // Calcul de la variance de vélocité const velocities = sprintHistory.map((s: SprintVelocity) => s.completedPoints); const variance = this.calculateVariance(velocities); const variancePercent = (Math.sqrt(variance) / averageVelocity) * 100; if (variancePercent > (this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold)) { anomalies.push({ id: `velocity-variance-${Date.now()}`, type: 'velocity', severity: variancePercent > 50 ? 'high' : 'medium', title: 'Vélocité très variable', description: `La vélocité de l'équipe varie de ${variancePercent.toFixed(1)}% autour de la moyenne`, value: variancePercent, threshold: this.config.velocityVarianceThreshold ?? this.defaultConfig.velocityVarianceThreshold, recommendation: 'Analysez les facteurs causant cette instabilité : estimation, complexité, blockers', affectedItems: sprintHistory.slice(-3).map((s: SprintVelocity) => s.sprintName), timestamp }); } // Détection de tendance décroissante const recentSprints = sprintHistory.slice(-3); const isDecreasing = recentSprints.every((sprint: SprintVelocity, i: number) => i === 0 || sprint.completedPoints < recentSprints[i - 1].completedPoints ); if (isDecreasing && recentSprints.length >= 3) { const decline = ((recentSprints[0].completedPoints - recentSprints[recentSprints.length - 1].completedPoints) / recentSprints[0].completedPoints) * 100; anomalies.push({ id: `velocity-decline-${Date.now()}`, type: 'velocity', severity: decline > 30 ? 'critical' : 'high', title: 'Vélocité en déclin', description: `La vélocité a diminué de ${decline.toFixed(1)}% sur les 3 derniers sprints`, value: decline, threshold: 0, recommendation: 'Identifiez les causes : technical debt, complexité croissante, ou problèmes d\'équipe', affectedItems: recentSprints.map((s: SprintVelocity) => s.sprintName), timestamp }); } return anomalies; } /** * Détecte les anomalies de cycle time (temps excessifs, types problématiques) */ private detectCycleTimeAnomalies(cycleTimeMetrics: { averageCycleTime: number; cycleTimeByType: CycleTimeByType[] }, timestamp: string): JiraAnomaly[] { const anomalies: JiraAnomaly[] = []; const { averageCycleTime, cycleTimeByType } = cycleTimeMetrics; // Détection des types avec cycle time excessif cycleTimeByType.forEach((typeMetrics: CycleTimeByType) => { const ratio = typeMetrics.averageDays / averageCycleTime; if (ratio > (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold)) { anomalies.push({ id: `cycle-time-${typeMetrics.issueType}-${Date.now()}`, type: 'cycle_time', severity: ratio > 3 ? 'high' : 'medium', title: `Cycle time excessif - ${typeMetrics.issueType}`, description: `Le type "${typeMetrics.issueType}" prend ${ratio.toFixed(1)}x plus de temps que la moyenne`, value: typeMetrics.averageDays, threshold: averageCycleTime * (this.config.cycleTimeThreshold ?? this.defaultConfig.cycleTimeThreshold), recommendation: 'Analysez les blockers spécifiques à ce type de ticket', affectedItems: [typeMetrics.issueType], timestamp }); } }); // Détection cycle time global excessif (> 14 jours) if (averageCycleTime > 14) { anomalies.push({ id: `global-cycle-time-${Date.now()}`, type: 'cycle_time', severity: averageCycleTime > 21 ? 'critical' : 'high', title: 'Cycle time global élevé', description: `Le cycle time moyen de ${averageCycleTime.toFixed(1)} jours est préoccupant`, value: averageCycleTime, threshold: 14, recommendation: 'Réduisez la taille des tâches et identifiez les goulots d\'étranglement', affectedItems: ['Projet global'], timestamp }); } return anomalies; } /** * Détecte les déséquilibres de charge de travail */ private detectWorkloadAnomalies(assigneeWorkloads: AssigneeWorkload[], timestamp: string): JiraAnomaly[] { const anomalies: JiraAnomaly[] = []; if (assigneeWorkloads.length < 2) return anomalies; const workloads = assigneeWorkloads.map(a => a.totalActive); const maxWorkload = Math.max(...workloads); const minWorkload = Math.min(...workloads.filter(w => w > 0)); if (minWorkload === 0) return anomalies; // Éviter division par zéro const imbalanceRatio = maxWorkload / minWorkload; if (imbalanceRatio > (this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold)) { const overloadedMember = assigneeWorkloads.find(a => a.totalActive === maxWorkload); const underloadedMember = assigneeWorkloads.find(a => a.totalActive === minWorkload); anomalies.push({ id: `workload-imbalance-${Date.now()}`, type: 'workload', severity: imbalanceRatio > 5 ? 'high' : 'medium', title: 'Déséquilibre de charge', description: `Ratio de ${imbalanceRatio.toFixed(1)}:1 entre membres les plus/moins chargés`, value: imbalanceRatio, threshold: this.config.workloadImbalanceThreshold ?? this.defaultConfig.workloadImbalanceThreshold, recommendation: 'Redistribuez les tâches pour équilibrer la charge de travail', affectedItems: [ `Surchargé: ${overloadedMember?.displayName} (${maxWorkload} tâches)`, `Sous-chargé: ${underloadedMember?.displayName} (${minWorkload} tâches)` ], timestamp }); } // Détection de membres avec trop de tâches en cours assigneeWorkloads.forEach(assignee => { if (assignee.inProgressCount > 5) { anomalies.push({ id: `wip-limit-${assignee.assignee}-${Date.now()}`, type: 'workload', severity: assignee.inProgressCount > 8 ? 'high' : 'medium', title: 'WIP limite dépassée', description: `${assignee.displayName} a ${assignee.inProgressCount} tâches en cours`, value: assignee.inProgressCount, threshold: 5, recommendation: 'Limitez le WIP à 3-5 tâches par personne pour améliorer le focus', affectedItems: [assignee.displayName], timestamp }); } }); return anomalies; } /** * Détecte les problèmes de completion rate */ private detectCompletionAnomalies(velocityMetrics: { sprintHistory: SprintVelocity[] }, timestamp: string): JiraAnomaly[] { const anomalies: JiraAnomaly[] = []; const { sprintHistory } = velocityMetrics; if (sprintHistory.length === 0) return anomalies; // Analyse des 3 derniers sprints const recentSprints = sprintHistory.slice(-3); const avgCompletionRate = recentSprints.reduce((sum: number, sprint: SprintVelocity) => sum + sprint.completionRate, 0) / recentSprints.length; if (avgCompletionRate < (this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold)) { anomalies.push({ id: `low-completion-rate-${Date.now()}`, type: 'completion', severity: avgCompletionRate < 50 ? 'critical' : 'high', title: 'Taux de completion faible', description: `Taux de completion moyen de ${avgCompletionRate.toFixed(1)}% sur les derniers sprints`, value: avgCompletionRate, threshold: this.config.completionRateThreshold ?? this.defaultConfig.completionRateThreshold, recommendation: 'Revoyez la planification et l\'estimation des sprints', affectedItems: recentSprints.map((s: SprintVelocity) => `${s.sprintName}: ${s.completionRate.toFixed(1)}%`), timestamp }); } return anomalies; } /** * Calcule la variance d'un tableau de nombres */ private calculateVariance(numbers: number[]): number { const mean = numbers.reduce((sum, num) => sum + num, 0) / numbers.length; const squaredDiffs = numbers.map(num => Math.pow(num - mean, 2)); return squaredDiffs.reduce((sum, diff) => sum + diff, 0) / numbers.length; } /** * Retourne le poids numérique d'une sévérité pour le tri */ private getSeverityWeight(severity: string): number { switch (severity) { case 'critical': return 4; case 'high': return 3; case 'medium': return 2; case 'low': return 1; default: return 0; } } /** * Met à jour la configuration de détection */ updateConfig(newConfig: Partial): void { this.config = { ...this.config, ...newConfig }; } /** * Retourne la configuration actuelle */ getConfig(): AnomalyDetectionConfig { return { ...this.defaultConfig, ...this.config }; } } export const jiraAnomalyDetection = new JiraAnomalyDetectionService();