diff --git a/src/lib/score-engine.ts b/src/lib/score-engine.ts index 2ed4e4b..b8ef276 100644 --- a/src/lib/score-engine.ts +++ b/src/lib/score-engine.ts @@ -1,29 +1,56 @@ /** - * CollectFlow — Score Engine v2 (Simplifié - Score de Contribution Composite) + * CollectFlow — Score Engine v3 (Percentile Composite) * - * Calcule le score de performance d'un produit basé sur sa contribution RELATIVE - * à la moyenne de son lot fournisseur sur 3 axes : CA, Quantité, Marge. + * Calcule le score de performance d'un produit par rang percentile au sein + * de son lot fournisseur sur 3 axes : CA, Quantité, Taux de Marge. * * Formule : - * Score = (40% × Score_CA) + (35% × Score_Volume) + (25% × Score_Marge) + * Score = (40% × Percentile_CA) + (35% × Percentile_Volume) + (25% × Percentile_Marge) * - * Où chaque score d'axe est : - * Score_Axe = (Valeur Produit / Moyenne Fournisseur) × 100 + * Où chaque Percentile_Axe est le rang du produit dans son lot fournisseur (0-100). * * Exemples : - * - Score = 100 → Le produit fait exactement sa part - * - Score = 150 → Le produit surperforme de 50% - * - Score = 50 → Le produit sous-performe de 50% + * - Score = 100 → Meilleur produit du fournisseur sur tous les axes + * - Score = 50 → Produit médian du fournisseur + * - Score = 0 → Dernier produit du fournisseur sur tous les axes * * Avantages : - * - Simple et intuitif - * - Auto-normalisé (fonctionne avec 5 ou 50 produits) - * - Reflète la VRAIE contribution du produit - * - Aligné avec l'analyse IA v5 + * - Borné 0-100 (plus de scores > 150 pour des petits fournisseurs) + * - Robuste aux fournisseurs avec peu de références ou faibles volumes + * - Cohérent avec le scoring IA (scoring-engine.ts) qui utilise aussi les percentiles */ import type { ProductRow } from "@/types/grid"; +/** + * Calcule les rangs percentiles (0-100) pour un tableau de valeurs numériques. + * Les ex-æquo reçoivent le rang moyen de leur groupe. + * Cas limite : tableau à 1 élément → [50] (médiane). + */ +function computePercentileRanks(values: number[]): number[] { + const n = values.length; + if (n === 0) return []; + if (n === 1) return [50]; + + const indexed = values.map((v, i) => ({ v, i })); + indexed.sort((a, b) => a.v - b.v); + + const ranks = new Array(n); + let i = 0; + while (i < n) { + let j = i; + while (j < n && indexed[j].v === indexed[i].v) j++; + // Rang moyen pour les ex-æquo, normalisé 0-100 + const avgRank = ((i + j - 1) / 2) / (n - 1) * 100; + for (let k = i; k < j; k++) { + ranks[indexed[k].i] = Math.round(avgRank); + } + i = j; + } + + return ranks; +} + export interface ScoreSettings { /** Pondération CA (défaut: 40%) */ weightCA: number; @@ -69,9 +96,7 @@ export function computeProductScores( const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0); const totalMarge = weightedData.reduce((sum, d) => sum + d.tauxMarge, 0); - const avgCa = rows.length > 0 ? totalCa / rows.length : 0; const avgQty = rows.length > 0 ? totalQty / rows.length : 0; - const avgMarge = rows.length > 0 ? totalMarge / rows.length : 0; // 3. Calculer les totaux pour les poids (%) const rayonTotals = new Map(); @@ -81,13 +106,25 @@ export function computeProductScores( rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 }); }); - // 4. Pour chaque produit : calculer le score composite - for (const data of weightedData) { - // Injection des métriques de contexte (pour l'IA et l'affichage) + // 4. Calcul des rangs percentiles par axe (0-100, borné) + const caPctRanks = computePercentileRanks(weightedData.map(d => d.wCa)); + const qtyPctRanks = computePercentileRanks(weightedData.map(d => d.wQty)); + const margePctRanks = computePercentileRanks(weightedData.map(d => d.tauxMarge)); + + // Validation des pondérations (garde contre undefined/NaN) + const wCA = Number.isFinite(settings.weightCA) ? settings.weightCA : 0.40; + const wVol = Number.isFinite(settings.weightVolume) ? settings.weightVolume : 0.35; + const wMarge = Number.isFinite(settings.weightMarge) ? settings.weightMarge : 0.25; + + // 5. Pour chaque produit : injecter contexte + score composite percentile + for (let idx = 0; idx < weightedData.length; idx++) { + const data = weightedData[idx]; + + // Métriques de contexte (pour l'IA et l'affichage) data.row.avgQtyFournisseur = avgQty; data.row.totalFournisseurCa = totalCa; - // Calcul des poids (%) + // Poids relatifs (%) data.row.shareQty = totalQty > 0 ? (data.wQty / totalQty) * 100 : 0; data.row.shareCa = totalCa > 0 ? (data.wCa / totalCa) * 100 : 0; data.row.shareMarge = totalMarge > 0 ? (data.tauxMarge / totalMarge) * 100 : 0; @@ -97,23 +134,13 @@ export function computeProductScores( data.row.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0; // ================================================================ - // SCORE DE CONTRIBUTION COMPOSITE + // SCORE COMPOSITE PERCENTILE (0-100) // ================================================================ + const scoreComposite = + wCA * caPctRanks[idx] + + wVol * qtyPctRanks[idx] + + wMarge * margePctRanks[idx]; - // Score de chaque axe = (Valeur Produit / Moyenne Fournisseur) × 100 - const scoreCa = avgCa > 0 ? (data.wCa / avgCa) * 100 : 0; - const scoreVolume = avgQty > 0 ? (data.wQty / avgQty) * 100 : 0; - const scoreMarge = avgMarge > 0 ? (data.tauxMarge / avgMarge) * 100 : 0; - - // Validation des pondérations (garde contre undefined/NaN) - const wCA = Number.isFinite(settings.weightCA) ? settings.weightCA : 0.40; - const wVol = Number.isFinite(settings.weightVolume) ? settings.weightVolume : 0.35; - const wMarge = Number.isFinite(settings.weightMarge) ? settings.weightMarge : 0.25; - - // Score final = moyenne pondérée - const scoreComposite = (wCA * scoreCa) + (wVol * scoreVolume) + (wMarge * scoreMarge); - - // Arrondi à 1 décimale, garde contre NaN data.row.score = Number.isFinite(scoreComposite) ? Math.round(scoreComposite * 10) / 10 : 0; }