From a3600d7e05d6aa40f20a59ffecb253623de7cc3b Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 27 Feb 2026 10:11:13 +0100 Subject: [PATCH] feat: implement product scoring engine to calculate composite scores and product recommendations based on various metrics and business rules. --- .../ai-copilot/business/analysis-engine.ts | 28 +++-- .../ai-copilot/business/context-profiler.ts | 112 +++++++++++------- .../ai-copilot/business/scoring-engine.ts | 35 +++++- 3 files changed, 119 insertions(+), 56 deletions(-) diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index f5f67de..a02c290 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -75,9 +75,19 @@ JSON uniquement, sans markdown. ctx: ProductContextProfile ): string { const lines: string[] = []; + const storeLabel = ctx.storeCount > 1 ? `${ctx.storeCount} magasins` : `1 magasin`; lines.push(`PRODUIT : ${ctx.libelle1} (${ctx.codein})`); lines.push(`CATÉGORIE : ${ctx.libelleNiveau2} (rayon: ${ctx.rayonSize} produits | lot total: ${ctx.lotSize} produits)`); + lines.push(`DISTRIBUTION : ${storeLabel} référençant ce produit`); + lines.push(""); + + // KPIs bruts réseau + valeurs normalisées par magasin + lines.push(`--- PERFORMANCE (brute réseau / par magasin) ---`); + lines.push(`• CA réseau : ${ctx.totalCaRaw.toLocaleString('fr-FR')}€ | CA par magasin : ${ctx.caPerStore.toFixed(0)}€`); + lines.push(`• Quantité réseau : ${ctx.totalQtyRaw} unités | QTÉ par magasin : ${ctx.qtyPerStore.toFixed(0)} unités`); + lines.push(`• Marge : ${ctx.tauxMarge.toFixed(1)}%`); + lines.push(`⚠️ Les percentiles ci-dessous sont calculés sur les valeurs PAR MAGASIN pour comparer équitablement les produits 1-magasin et 2-magasins.`); lines.push(""); // Quadrant — présenté comme un indice, sans prescription @@ -87,10 +97,10 @@ JSON uniquement, sans markdown. lines.push(""); // Position / poids — données brutes - lines.push(`--- POSITION DANS LE LOT FOURNISSEUR ---`); - lines.push(`• CA : ${ctx.percentileCa}e percentile | Poids fournisseur : ${ctx.weightCaFournisseur}% | Poids rayon N2 : ${ctx.weightCaRayon}%`); - lines.push(`• Quantité : ${ctx.percentileQty}e percentile | Poids fournisseur : ${ctx.weightQtyFournisseur}% | Poids rayon N2 : ${ctx.weightQtyRayon}%`); - lines.push(`• Marge : ${ctx.percentileMarge}e percentile`); + lines.push(`--- POSITION DANS LE LOT FOURNISSEUR (percentiles sur valeurs par magasin) ---`); + lines.push(`• CA/magasin : ${ctx.percentileCa}e percentile | Poids CA réseau fournisseur : ${ctx.weightCaFournisseur}% | Poids CA réseau rayon N2 : ${ctx.weightCaRayon}%`); + lines.push(`• QTÉ/magasin : ${ctx.percentileQty}e percentile | Poids QTÉ réseau fournisseur : ${ctx.weightQtyFournisseur}% | Poids QTÉ réseau rayon N2 : ${ctx.weightQtyRayon}%`); + lines.push(`• Marge : ${ctx.percentileMarge}e percentile`); lines.push(`• Score composite : ${ctx.percentileComposite}/100`); lines.push(""); @@ -99,17 +109,17 @@ JSON uniquement, sans markdown. // Signal rouge prioritaire if (ctx.isLowContribution) { - lines.push(`[⛔ CONTRIBUTION FAIBLE] Poids CA : ${ctx.weightCaFournisseur}% et Poids QTÉ : ${ctx.weightQtyFournisseur}% → produit marginal pour le fournisseur`); + lines.push(`[⛔ CONTRIBUTION FAIBLE] Poids CA réseau : ${ctx.weightCaFournisseur}% et Poids QTÉ réseau : ${ctx.weightQtyFournisseur}% → produit marginal pour le fournisseur`); } if (ctx.scoreCritique) { lines.push(`[⛔ SCORE CRITIQUE] Score brut < 20 → sous-seuil absolu`); } // Signaux positifs - lines.push(`${ctx.isTop20Ca ? "[✓]" : "[ ]"} Top 20% CA fournisseur`); - lines.push(`${ctx.isTop20Qty ? "[✓]" : "[ ]"} Top 20% Quantités fournisseur`); - lines.push(`${ctx.isHighVolumeWithLowMargin ? "[✓]" : "[ ]"} Fort volume (> P60 lot) avec marge faible`); - lines.push(`${ctx.isMargePure ? "[✓]" : "[ ]"} Forte marge (> P70 lot) malgré volume faible`); + lines.push(`${ctx.isTop20Ca ? "[✓]" : "[ ]"} Top 20% CA/magasin fournisseur`); + lines.push(`${ctx.isTop20Qty ? "[✓]" : "[ ]"} Top 20% Quantités/magasin fournisseur`); + lines.push(`${ctx.isHighVolumeWithLowMargin ? "[✓]" : "[ ]"} Fort volume/magasin (>P60 lot) avec marge faible`); + lines.push(`${ctx.isMargePure ? "[✓]" : "[ ]"} Forte marge (>P70 lot) malgré volume/magasin faible`); lines.push(`${ctx.isAboveMedianComposite ? "[✓]" : "[ ]"} Au-dessus de la médiane composite`); if (!ctx.isHighVolumeWithLowMargin && !ctx.isMargePure) { lines.push(`[i] Signaux Trafic/Marge non activés (rayon de ${ctx.rayonSize} produits${ctx.rayonSize < 6 ? " — trop petit pour stats fiables" : ""})`); diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts index 80200fa..d6f7645 100644 --- a/src/features/ai-copilot/business/context-profiler.ts +++ b/src/features/ai-copilot/business/context-profiler.ts @@ -1,15 +1,16 @@ /** - * CollectFlow — Context Profiler (v2 — Anti sur-classement) + * CollectFlow — Context Profiler (v3 — Normalisation multi-magasin) * * Génère une fiche de contexte normalisée et adaptative pour chaque produit * AVANT de le soumettre à l'IA. * - * v2 — Correctifs : - * - Ajout du signal `isLowContribution` (poids CA ET QTÉ < 0.5% du fournisseur) - * - Désactivation des signaux Trafic/Marge si rayonSize < MIN_RAYON_SIZE (évite - * les faux positifs dans les micro-rayons de 3-5 produits). - * - Le profiler ne prescrit plus de verdict : il produit des données brutes - * que le prompt de Mary interprète avec un guide de décision pondéré. + * v3 — Correctifs : + * - Normalisation par `storeCount` : tous les calculs de percentile, poids + * et quadrant utilisent les valeurs PAR MAGASIN (CA/store, QTÉ/store). + * Cela évite qu'un produit en 2 magasins soit mécaniquement favorisé + * dans les comparaisons par rapport à un produit en 1 seul magasin. + * - Le profil expose caPerStore et qtyPerStore pour que Mary voie les + * deux dimensions : réeau brut ET performance par magasin. */ import type { ProductAnalysisInput } from "../models/ai-analysis.types"; @@ -33,18 +34,31 @@ export interface ProductContextProfile { libelle1: string; libelleNiveau2: string; - // Profil Quadrant + // Profil Quadrant (basé sur valeurs PAR MAGASIN pour comparaison équitable) quadrant: Quadrant; quadrantLabel: string; quadrantEmoji: string; + // Nombre de magasins référençant le produit + storeCount: number; + + // Valeurs brutes réseau + totalCaRaw: number; + totalQtyRaw: number; + + // Valeurs normalisées PAR MAGASIN (pour comparaisons justes) + caPerStore: number; + qtyPerStore: number; + // Percentiles dans le lot fournisseur (0 = plus faible, 100 = meilleur) + // Calculés sur les valeurs normalisées par magasin percentileCa: number; percentileQty: number; percentileMarge: number; percentileComposite: number; // Poids réels dans le lot fournisseur + // Calculés sur les valeurs brutes réseau (représentativité réelle du chiffre) weightCaFournisseur: number; // % du CA total fournisseur weightQtyFournisseur: number; // % des QTÉ totales fournisseur @@ -63,11 +77,11 @@ export interface ProductContextProfile { // Signaux positifs (calculés sur la distribution réelle) isAboveMedianComposite: boolean; - isTop20Ca: boolean; - isTop20Qty: boolean; + isTop20Ca: boolean; // Top 20% sur valeur PAR MAGASIN + isTop20Qty: boolean; // Top 20% sur valeur PAR MAGASIN /** * Fort volume ET marge < P40 du lot → rôle de "locomotive". - * Désactivé si rayonSize < MIN_RAYON_SIZE (percentiles non significatifs). + * Désactivé si rayonSize < MIN_RAYON_SIZE. */ isHighVolumeWithLowMargin: boolean; /** @@ -79,8 +93,6 @@ export interface ProductContextProfile { // Signal négatif fort /** * Le produit pèse moins de 0.5% du CA ET des QTÉ du fournisseur. - * Même un quadrant TRAFIC ne justifie pas un A si ce signal est actif - * et que le score est faible. */ isLowContribution: boolean; @@ -89,7 +101,7 @@ export interface ProductContextProfile { protectionReason: string; // Règle absolue - scoreCritique: boolean; // score brut < 20 + scoreCritique: boolean; } // --------------------------------------------------------------------------- @@ -156,34 +168,48 @@ export class ContextProfiler { throw new Error("[ContextProfiler] Le lot de produits est vide."); } - // 1. Totaux fournisseur + // --------------------------------------------------------------------------- + // Normalisation par magasin — cœur de la v3 + // Raison : un produit en 2 magasins a mécaniquement 2x plus de CA/QTÉ + // qu'un produit identique en 1 magasin. Sans normalisation, les percentiles + // et le quadrant sont fausss par le réseau de distribution, pas la perf. + // --------------------------------------------------------------------------- + const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1); + const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p); + const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p); + + const targetStoreCount = getNormStoreCount(target); + 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); - // 2. Totaux du rayon (Niveau 2 de nomenclature = 4 premiers chiffres du code) + // 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); - // 3. Percentiles dans le lot fournisseur - const allCaValues = allProds.map(p => p.totalCa ?? 0); - const allQtyValues = allProds.map(p => p.totalQuantite ?? 0); + // 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant + const allCaPerStore = allProds.map(normCa); + const allQtyPerStore = allProds.map(normQty); const allMargeValues = allProds.map(p => p.tauxMarge ?? 0); - const pCa = computePercentile(target.totalCa ?? 0, allCaValues); - const pQty = computePercentile(target.totalQuantite ?? 0, allQtyValues); + const pCa = computePercentile(targetCaPerStore, allCaPerStore); + const pQty = computePercentile(targetQtyPerStore, allQtyPerStore); const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues); const pComposite = scoring.compositeScore; - // 4. Tops adaptatifs - const top20CaThreshold = valueAtPercentile(allCaValues, 80); - const top20QtyThreshold = valueAtPercentile(allQtyValues, 80); - const isTop20Ca = (target.totalCa ?? 0) >= top20CaThreshold; - const isTop20Qty = (target.totalQuantite ?? 0) >= top20QtyThreshold; + // 4. Tops 20% (sur valeurs PAR MAGASIN) + const top20CaThreshold = valueAtPercentile(allCaPerStore, 80); + const top20QtyThreshold = valueAtPercentile(allQtyPerStore, 80); + const isTop20Ca = targetCaPerStore >= top20CaThreshold; + const isTop20Qty = targetQtyPerStore >= top20QtyThreshold; const isAboveMedianComposite = pComposite >= 50; - // 5. Signal négatif fort : contribution insignifiante au fournisseur + // 5. Poids bruts (valeurs réseau pour représentativité commerciale réelle) const weightCaFournisseur = totalCaFournisseur > 0 ? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10 @@ -193,10 +219,9 @@ export class ContextProfiler { ? Math.round(((target.totalQuantite ?? 0) / totalQtyFournisseur) * 1000) / 10 : 0; - // isLowContribution = vrai si le produit représente < 0.5% du CA ET < 0.5% des QTÉ const isLowContribution = weightCaFournisseur < 0.5 && weightQtyFournisseur < 0.5; - // 6. Signaux Trafic / Marge — désactivés si rayon trop petit (bruit statistique) + // 6. Signaux Trafic / Marge (sur valeurs PAR MAGASIN) const rayonSizeForSignals = rayonProds.length; const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE; @@ -206,29 +231,29 @@ export class ContextProfiler { if (signalsActive) { const marge40 = valueAtPercentile(allMargeValues, 40); const marge70 = valueAtPercentile(allMargeValues, 70); - const qty60 = valueAtPercentile(allQtyValues, 60); - const medianQty = computeMedian(allQtyValues); + const qty60PerStore = valueAtPercentile(allQtyPerStore, 60); + const medianQtyPerStore = computeMedian(allQtyPerStore); isHighVolumeWithLowMargin = - (target.totalQuantite ?? 0) >= qty60 && + targetQtyPerStore >= qty60PerStore && (target.tauxMarge ?? 0) < marge40; isMargePure = (target.tauxMarge ?? 0) >= marge70 && - (target.totalQuantite ?? 0) < medianQty; + targetQtyPerStore < medianQtyPerStore; } - // 7. Quadrant (basé sur les médianes du lot fournisseur) - const medianQty = computeMedian(allQtyValues); + // 7. Quadrant (basé sur les médianes PAR MAGASIN) + const medianQtyPerStore = computeMedian(allQtyPerStore); const medianMarge = computeMedian(allMargeValues); const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant( - target.totalQuantite ?? 0, + targetQtyPerStore, target.tauxMarge ?? 0, - medianQty, + medianQtyPerStore, medianMarge ); - // 8. Gardes-fous (issus du ScoringEngine) + // 8. Gardes-fous const isProtected = scoring.decision.isRecent || scoring.decision.isTop30Supplier || @@ -247,9 +272,13 @@ export class ContextProfiler { libelle1: target.libelle1, libelleNiveau2: target.libelleNiveau2 ?? "Général", - quadrant, - quadrantLabel, - quadrantEmoji, + quadrant, quadrantLabel, quadrantEmoji, + + storeCount: targetStoreCount, + totalCaRaw: target.totalCa ?? 0, + totalQtyRaw: target.totalQuantite ?? 0, + caPerStore: targetCaPerStore, + qtyPerStore: targetQtyPerStore, percentileCa: pCa, percentileQty: pQty, @@ -283,7 +312,6 @@ export class ContextProfiler { isProtected, protectionReason, - scoreCritique, }; } diff --git a/src/features/ai-copilot/business/scoring-engine.ts b/src/features/ai-copilot/business/scoring-engine.ts index 95aa1b6..50214e7 100644 --- a/src/features/ai-copilot/business/scoring-engine.ts +++ b/src/features/ai-copilot/business/scoring-engine.ts @@ -91,7 +91,7 @@ export class ScoringEngine { // 7. Gardes-fous et Labels const isRecent = (target.regularityScore || 0) < 3; - const isTop30Supplier = this.checkTop30Supplier(target, processedProducts); + const isTop30Supplier = this.checkTop30Supplier(target, processedProducts, compositeScore); const isLastProduct = target.isLastProductOfSupplier || false; let finalLabel = labelProfil; @@ -167,15 +167,40 @@ export class ScoringEngine { return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } - private static checkTop30Supplier(target: any, allProducts: any[]): boolean { - const supplierProds = allProducts.filter(p => p.codeFournisseur === target.codeFournisseur); + /** + * Vérifie si le produit est dans le Top 30% CA de son lot fournisseur. + * + * IMPORTANT : La protection n'est accordée que si le compositeScore est + * suffisamment solide (>= 30/100). Sans ce garde-fou, on protège des + * produits "Top 30% CA" qui ont en réalité un score catastrophique — + * par exemple parce que leur CA est gonflé par un seul mois exceptionnel + * ou parce que le lot fournisseur est très petit. + * + * Le seuil de 30% s'applique sur les valeurs normalisées (projetées + * sur 12 mois et pondérées par réseau) pour éviter qu'un produit + * avec beaucoup de mois de présence soit défavorisé. + */ + private static checkTop30Supplier( + target: any, + allProducts: any[], + compositeScore: number + ): boolean { + // Condition minimale de performance — si le score composite est < 30, + // la protection CA seule ne suffit pas à classer en A. + if (compositeScore < 30) return false; + + // Tous les produits du lot sont du même fournisseur en pratique, + // mais on garde le filtre pour être robuste. + const supplierProds = allProducts.length > 0 ? allProducts : []; if (supplierProds.length === 0) return false; - const sorted = supplierProds.sort((a, b) => b.caNormalise - a.caNormalise); + + const sorted = [...supplierProds].sort((a, b) => b.caNormalise - a.caNormalise); const topCount = Math.ceil(supplierProds.length * 0.30); - const topIds = sorted.slice(0, topCount).map(p => p.codein); + const topIds = sorted.slice(0, topCount).map((p) => p.codein); return topIds.includes(target.codein); } + private static calculateQuickScore(p: any, rayon: any[], medVol: number, medMarge: number, isSaisonnier: boolean): number { const pCa = this.calculatePercentile(p.caNormalise, rayon.map(x => x.caNormalise)); const pVol = this.calculatePercentile(p.volNormalise, rayon.map(x => x.volNormalise));