From 5773ed9a26869d35f59d35bf663470f34cc2cd81 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 27 Feb 2026 11:05:25 +0100 Subject: [PATCH] feat: create `ScoringEngine` for AI copilot product analysis and scoring. --- .../ai-copilot/business/context-profiler.ts | 24 +++++++++++-------- .../ai-copilot/business/scoring-engine.ts | 19 +++++++++++---- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts index d6f7645..d4d90ac 100644 --- a/src/features/ai-copilot/business/context-profiler.ts +++ b/src/features/ai-copilot/business/context-profiler.ts @@ -182,15 +182,19 @@ export class ContextProfiler { const targetCaPerStore = normCa(target); const targetQtyPerStore = normQty(target); - // 1. Totaux fournisseur (valeurs brutes pour les poids de représentativité réseau) - const totalCaFournisseur = allProds.reduce((s, p) => s + (p.totalCa ?? 0), 0); - const totalQtyFournisseur = allProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0); + // 1. Totaux fournisseur (valeurs PONDÉRÉES pour comparaison équitable réseau) + // weightedTotalCa/Qty projettent les 1-magasin sur une base réseau (×2). + 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) const targetRayonKey = getRayonKey(target); const rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey); - const totalCaRayon = rayonProds.reduce((s, p) => s + (p.totalCa ?? 0), 0); - const totalQtyRayon = rayonProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0); + const totalCaRayon = rayonProds.reduce((s, p) => s + getWeightedCa(p), 0); + const totalQtyRayon = rayonProds.reduce((s, p) => s + getWeightedQty(p), 0); // 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant const allCaPerStore = allProds.map(normCa); @@ -209,14 +213,14 @@ export class ContextProfiler { const isTop20Qty = targetQtyPerStore >= top20QtyThreshold; 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 = totalCaFournisseur > 0 - ? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10 + ? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10 : 0; const weightQtyFournisseur = totalQtyFournisseur > 0 - ? Math.round(((target.totalQuantite ?? 0) / totalQtyFournisseur) * 1000) / 10 + ? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10 : 0; const isLowContribution = weightCaFournisseur < 0.5 && weightQtyFournisseur < 0.5; @@ -289,11 +293,11 @@ export class ContextProfiler { weightQtyFournisseur, weightCaRayon: totalCaRayon > 0 - ? Math.round(((target.totalCa ?? 0) / totalCaRayon) * 1000) / 10 + ? Math.round((getWeightedCa(target) / totalCaRayon) * 1000) / 10 : 0, weightQtyRayon: totalQtyRayon > 0 - ? Math.round(((target.totalQuantite ?? 0) / totalQtyRayon) * 1000) / 10 + ? Math.round((getWeightedQty(target) / totalQtyRayon) * 1000) / 10 : 0, tauxMarge: target.tauxMarge ?? 0, diff --git a/src/features/ai-copilot/business/scoring-engine.ts b/src/features/ai-copilot/business/scoring-engine.ts index 50214e7..ae388b2 100644 --- a/src/features/ai-copilot/business/scoring-engine.ts +++ b/src/features/ai-copilot/business/scoring-engine.ts @@ -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) { - const coef = (p.totalMagasins || 1) / (p.storeCount || 1); - let caNorm = (p.totalCa || 0) * coef; - let volNorm = (p.totalQuantite || 0) * coef; + // Valeurs pondérées réseau — si absentes, fallback sur les brutes + let caNorm = p.weightedTotalCa ?? p.totalCa ?? 0; + 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) { caNorm = (caNorm / (p.regularityScore || 3)) * 12; volNorm = (volNorm / (p.regularityScore || 3)) * 12;