mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
feat: Add a percentile composite score engine to calculate product performance based on sales, volume, and margin.
This commit is contained in:
1 parent
de768128e3
commit
e96bfa818d
1 file changed
+61
-34
+61
-34
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user