feat: create ScoringEngine for AI copilot product analysis and scoring.

This commit is contained in:
Michael committed 2026-02-27 11:05:25 +01:00
1 parent a053556a64
commit 5773ed9a26
2 files changed
+29 -14

No files matched your search

@@ -182,15 +182,19 @@ export class ContextProfiler {
const targetCaPerStore = normCa(target); const targetCaPerStore = normCa(target);
const targetQtyPerStore = normQty(target); const targetQtyPerStore = normQty(target);
// 1. Totaux fournisseur (valeurs brutes pour les poids de représentativité réseau) // 1. Totaux fournisseur (valeurs PONDÉRÉES pour comparaison équitable réseau)
const totalCaFournisseur = allProds.reduce((s, p) => s + (p.totalCa ?? 0), 0); // weightedTotalCa/Qty projettent les 1-magasin sur une base réseau (×2).
const totalQtyFournisseur = allProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0); const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0;
const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0;
const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0);
const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0);
// 2. Totaux du rayon (Niveau 2 de nomenclature) // 2. Totaux du rayon (Niveau 2 de nomenclature)
const targetRayonKey = getRayonKey(target); const targetRayonKey = getRayonKey(target);
const rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey); const rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey);
const totalCaRayon = rayonProds.reduce((s, p) => s + (p.totalCa ?? 0), 0); const totalCaRayon = rayonProds.reduce((s, p) => s + getWeightedCa(p), 0);
const totalQtyRayon = rayonProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0); const totalQtyRayon = rayonProds.reduce((s, p) => s + getWeightedQty(p), 0);
// 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant // 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant
const allCaPerStore = allProds.map(normCa); const allCaPerStore = allProds.map(normCa);
@@ -209,14 +213,14 @@ export class ContextProfiler {
const isTop20Qty = targetQtyPerStore >= top20QtyThreshold; const isTop20Qty = targetQtyPerStore >= top20QtyThreshold;
const isAboveMedianComposite = pComposite >= 50; const isAboveMedianComposite = pComposite >= 50;
// 5. Poids bruts (valeurs réseau pour représentativité commerciale réelle) // 5. Poids pondérés (valeurs réseau projetées pour représentativité réaliste)
const weightCaFournisseur = const weightCaFournisseur =
totalCaFournisseur > 0 totalCaFournisseur > 0
? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10 ? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10
: 0; : 0;
const weightQtyFournisseur = const weightQtyFournisseur =
totalQtyFournisseur > 0 totalQtyFournisseur > 0
? Math.round(((target.totalQuantite ?? 0) / totalQtyFournisseur) * 1000) / 10 ? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10
: 0; : 0;
const isLowContribution = weightCaFournisseur < 0.5 && weightQtyFournisseur < 0.5; const isLowContribution = weightCaFournisseur < 0.5 && weightQtyFournisseur < 0.5;
@@ -289,11 +293,11 @@ export class ContextProfiler {
weightQtyFournisseur, weightQtyFournisseur,
weightCaRayon: weightCaRayon:
totalCaRayon > 0 totalCaRayon > 0
? Math.round(((target.totalCa ?? 0) / totalCaRayon) * 1000) / 10 ? Math.round((getWeightedCa(target) / totalCaRayon) * 1000) / 10
: 0, : 0,
weightQtyRayon: weightQtyRayon:
totalQtyRayon > 0 totalQtyRayon > 0
? Math.round(((target.totalQuantite ?? 0) / totalQtyRayon) * 1000) / 10 ? Math.round((getWeightedQty(target) / totalQtyRayon) * 1000) / 10
: 0, : 0,
tauxMarge: target.tauxMarge ?? 0, tauxMarge: target.tauxMarge ?? 0,
@@ -131,12 +131,23 @@ export class ScoringEngine {
}; };
} }
/**
* Normalise les KPIs d'un produit pour comparaison équitable.
*
* Utilise les valeurs pondérées (weightedTotalCa / weightedTotalQuantite)
* qui projettent les produits 1-magasin sur une base réseau comparable
* (×2 si 1 magasin, ×1 si 2 magasins). Cela évite de pénaliser un
* produit vendu uniquement dans 1 magasin face à un produit vendu dans 2.
*
* Applique ensuite la projection Run Rate pour les produits récents
* (\< 12 mois d'historique) afin de comparer sur une base annuelle.
*/
private static preprocess(p: ProductAnalysisInput) { private static preprocess(p: ProductAnalysisInput) {
const coef = (p.totalMagasins || 1) / (p.storeCount || 1); // Valeurs pondérées réseau — si absentes, fallback sur les brutes
let caNorm = (p.totalCa || 0) * coef; let caNorm = p.weightedTotalCa ?? p.totalCa ?? 0;
let volNorm = (p.totalQuantite || 0) * coef; let volNorm = p.weightedTotalQuantite ?? p.totalQuantite ?? 0;
// Run Rate Projection // Run Rate Projection (produits avec 3-11 mois d'historique)
if ((p.regularityScore || 0) >= 3 && (p.regularityScore || 0) < 12) { if ((p.regularityScore || 0) >= 3 && (p.regularityScore || 0) < 12) {
caNorm = (caNorm / (p.regularityScore || 3)) * 12; caNorm = (caNorm / (p.regularityScore || 3)) * 12;
volNorm = (volNorm / (p.regularityScore || 3)) * 12; volNorm = (volNorm / (p.regularityScore || 3)) * 12;