feat: Add a percentile composite score engine to calculate product performance based on sales, volume, and margin.

This commit is contained in:
Michael committed 2026-03-18 21:41:29 +01:00
1 parent de768128e3
commit e96bfa818d
1 file changed
+61 -34
+61 -34
View File
@@ -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<number>(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<string, { total: number; count: number }>();
@@ -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;
}