diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 91a7a95..f5f67de 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -1,9 +1,11 @@ /** - * CollectFlow — Analysis Engine (v3 — Prompt Contextuel Multi-Dimensionnel) + * CollectFlow — Analysis Engine (v4 — Anti sur-classement) * - * Mary ne reçoit plus le verdict algorithmique pré-mâché. Elle reçoit une - * fiche de contexte normalisée (ProductContextProfile) qui lui permet de - * raisonner de façon autonome et cohérente sur la vraie valeur d'un produit. + * v4 — Correctifs : + * - Suppression des prescriptions directes "→ A" dans le guide des quadrants. + * - Ajout du seuil plancher absolu : contribution insignifiante + score faible → Z. + * - Affichage du signal isLowContribution en rouge dans le message. + * - Les quadrants sont des INDICES, pas des verdicts. Mary doit croiser avec les poids. */ import type { ProductAnalysisInput } from "../models/ai-analysis.types"; @@ -11,49 +13,53 @@ import type { ProductContextProfile } from "./context-profiler"; export class AnalysisEngine { // ----------------------------------------------------------------------- - // SYSTEM PROMPT + // SYSTEM PROMPT v4 // ----------------------------------------------------------------------- static generateSystemPrompt(): string { return `Tu es Mary, Senior Retail Strategist. Tu analyses des produits pour recommander A (garder), C (saisonnier), ou Z (sortir). ---- PHILOSOPHIE --- -Un produit avec un score faible peut être VITAL s'il génère du trafic ou des marges. -Un produit "moyen" peut être un pilier discret de son rayon. -Ne jamais classer Z sans vérifier sa contribution réelle au chiffre d'affaires et aux volumes. +--- ORDRE DE PRIORITÉ (STRICT) --- ---- ORDRE DE PRIORITÉ --- -1. RÈGLE ABSOLUE : Score critique (< 20 sur 100) + aucun signal positif → TOUJOURS Z. Pas de discussion. -2. GARDE-FOU : Si isProtected = true (Nouveauté / Dernier Produit / Top30) → TOUJOURS A. Priorité absolue. -3. RÈGLE MANAGER : Si le manager a défini une consigne ET que le produit est concerné → Appliquer la consigne (rule_applies = true). -4. ANALYSE CONTEXTUELLE : Utiliser la fiche de positionnement (percentiles, poids, signaux) pour raisonner. +1. GARDE-FOU PROTECTION : Si isProtected = true (Nouveauté / Dernier Produit / Top30) → A obligatoire. ---- GUIDE D'ANALYSE CONTEXTUELLE --- -• Quadrant STAR ⭐ : Volume ET Marge > médiane → A sauf cas exceptionnel -• Quadrant TRAFIC 🚶 : Fort volume, marge faible → Rôle de locomotive → A, justifier le rôle de trafic -• Quadrant MARGE 💎 : Volume faible, forte marge → Capital rentabilité → A, justifier la contribution marge -• Quadrant WATCH ⚠️ : Volume ET Marge < médiane → Analyser la santé (inactivité, poids CA, poids QTÉ) - - Si poids CA ou poids QTÉ rayon > 5% → A ou C selon l'inactivité - - Si poids faibles ET inactivité ≥ 2 mois → Z +2. SEUIL PLANCHER ABSOLU (règle critique) : + Si isLowContribution = true [poids CA < 0.5% ET poids QTÉ < 0.5% du fournisseur] + ET score composite < 35 + ET scoreCritique = true [score brut < 20] + → Z DIRECT. Ce produit est marginal et sous-performant. Aucun signal ne peut l'annuler. ---- COHÉRENCE INTER-PRODUITS (OBLIGATOIRE) --- -Ne mets JAMAIS Z un produit dont le percentile CA et le percentile QTÉ sont tous les deux supérieurs à un autre produit déjà recommandé en A. +3. RÈGLE MANAGER : Si le manager a défini une consigne ET que le produit est concerné → Appliquer. ---- FORMAT DE RÉPONSE OBLIGATOIRE --- -Réponds UNIQUEMENT avec un JSON valide, sans markdown, sans \`\`\`json. +4. ANALYSE CONTEXTUELLE (si aucune règle ci-dessus ne s'applique) : + Utilise les données de la fiche pour raisonner. Les quadrants sont des INDICES, pas des verdicts. + + — Quadrant STAR ⭐ : Fort signal positif. A sauf inactivité ≥ 3 mois. + — Quadrant TRAFIC 🚶 : Signal positif SEULEMENT si poids QTÉ fournisseur > 1%. + Si poids < 1% et score < 40 → tendance Z ou C. + — Quadrant MARGE 💎 : Signal positif SEULEMENT si poids CA fournisseur > 0.5%. + Si poids < 0.5% et score < 35 → tendance Z. + — Quadrant WATCH ⚠️ : Signal négatif par défaut. + Si poids CA rayon > 5% ou poids QTÉ rayon > 5% → A ou C selon inactivité. + Sinon → Z si inactivité ≥ 2 mois, C si 1 mois, A si actif mais surveiller. + +--- COHÉRENCE INTER-PRODUITS --- +Ne mets jamais Z un produit si son percentile CA ET son percentile QTÉ sont tous les deux supérieurs à un autre produit déjà classé A dans ce lot. + +--- FORMAT OBLIGATOIRE --- +JSON uniquement, sans markdown. { "rule_applies": boolean, "recommendation": "A" | "C" | "Z", - "justification": "2 phrases max. Cite les données clés : percentile, poids, quadrant, signal." + "justification": "2 phrases max. Cite poids, percentile, quadrant, score." }`; } // ----------------------------------------------------------------------- - // USER MESSAGE — avec la fiche contextuelle + // USER MESSAGE // ----------------------------------------------------------------------- static generateUserMessage(p: ProductAnalysisInput): string { - // Utilise la fiche ContextProfile si disponible, sinon fallback sur le mode legacy if (p.contextProfile) { return AnalysisEngine.buildContextualMessage(p, p.contextProfile); } @@ -61,7 +67,7 @@ Réponds UNIQUEMENT avec un JSON valide, sans markdown, sans \`\`\`json. } // ----------------------------------------------------------------------- - // Message contextuel enrichi (nouveau mode MPC) + // Message contextuel enrichi (v4) // ----------------------------------------------------------------------- private static buildContextualMessage( @@ -70,56 +76,66 @@ Réponds UNIQUEMENT avec un JSON valide, sans markdown, sans \`\`\`json. ): string { const lines: string[] = []; - // --- En-tête produit --- lines.push(`PRODUIT : ${ctx.libelle1} (${ctx.codein})`); - lines.push(`CATÉGORIE : ${ctx.libelleNiveau2} (${ctx.rayonSize} produits dans ce rayon sur ${ctx.lotSize} au total)`); + lines.push(`CATÉGORIE : ${ctx.libelleNiveau2} (rayon: ${ctx.rayonSize} produits | lot total: ${ctx.lotSize} produits)`); lines.push(""); - // --- Quadrant --- - lines.push(`--- PROFIL QUADRANT ---`); + // Quadrant — présenté comme un indice, sans prescription + lines.push(`--- PROFIL QUADRANT (indice, pas un verdict) ---`); lines.push(`${ctx.quadrantEmoji} ${ctx.quadrantLabel}`); lines.push(`Santé : ${ctx.regularityScore}/12 mois actifs | ${ctx.inactivityMonths} mois sans vente | Marge : ${ctx.tauxMarge.toFixed(1)}%`); lines.push(""); - // --- Positionnement dans le lot --- - lines.push(`--- POSITION DANS LE LOT FOURNISSEUR (${ctx.lotSize} produits) ---`); - lines.push(`• CA : ${ctx.percentileCa}e percentile | Poids fournisseur : ${ctx.weightCaFournisseur}% | Poids rayon : ${ctx.weightCaRayon}%`); - lines.push(`• Quantité : ${ctx.percentileQty}e percentile | Poids fournisseur : ${ctx.weightQtyFournisseur}% | Poids rayon : ${ctx.weightQtyRayon}%`); + // 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(`• Score composite : ${ctx.percentileComposite}/100`); lines.push(""); - // --- Signaux --- + // Signaux — tous présentés factuellement, sans recommandation inline lines.push(`--- SIGNAUX ---`); + + // Signal rouge prioritaire + if (ctx.isLowContribution) { + lines.push(`[⛔ CONTRIBUTION FAIBLE] Poids CA : ${ctx.weightCaFournisseur}% et Poids QTÉ : ${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 ? "[✓]" : "[ ]"} Signal Trafic : fort volume ET marge < P40 du lot → rôle de locomotive`); - lines.push(`${ctx.isMargePure ? "[✓]" : "[ ]"} Signal Marge : marge > P70 du lot → capital rentabilité`); + 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.isAboveMedianComposite ? "[✓]" : "[ ]"} Au-dessus de la médiane composite`); - lines.push(`${ctx.scoreCritique ? "[✗ CRITIQUE]" : "[ ]"} Score brut critique (< 20) — candidat Z direct si aucun signal positif`); + 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" : ""})`); + } lines.push(""); - // --- Protection --- + // Protection if (ctx.isProtected) { - lines.push(`🛡️ GARDE-FOU ACTIF : ${ctx.protectionReason} → Recommandation A obligatoire`); + lines.push(`🛡️ GARDE-FOU : ${ctx.protectionReason} → A obligatoire`); lines.push(""); } - // --- Règles manager --- + // Règle manager if (p.supplierContext) { lines.push(`--- RÈGLE MANAGER ---`); lines.push(`"${p.supplierContext}"`); - lines.push(`→ Évalue si ce produit ("${ctx.libelle1}") est concerné par cette consigne.`); + lines.push(`→ Ce produit ("${ctx.libelle1}") est-il concerné ? rule_applies = true/false.`); lines.push(""); } lines.push(`Génère UNIQUEMENT le JSON :`); - return lines.join("\n"); } // ----------------------------------------------------------------------- - // Fallback legacy (si contextProfile absent — compatibilité ascendante) + // Fallback legacy // ----------------------------------------------------------------------- private static buildLegacyMessage(p: ProductAnalysisInput): string { @@ -128,33 +144,26 @@ Réponds UNIQUEMENT avec un JSON valide, sans markdown, sans \`\`\`json. let contextStats = ""; if (p.shareCa !== undefined && p.shareQty !== undefined) { contextStats += `\nPOIDS DU PRODUIT : -- Poids Fournisseur (CA) : ${p.shareCa.toFixed(1)}% du CA de son fournisseur -- Poids Secteur/Rayon (Quantité) : ${p.shareQty.toFixed(1)}% des ventes du rayon`; +- Poids Fournisseur (CA) : ${p.shareCa.toFixed(1)}% +- Poids Secteur/Rayon (Quantité) : ${p.shareQty.toFixed(1)}%`; } const scoringInfo = p.scoring ? `\n--- RÉSULTATS DÉCISION RAYON --- INDICE RAYON: ${p.scoring.compositeScore}/100 (Seuil Z : ${p.scoring.threshold}) PROFIL: ${p.scoring.labelProfil} - GARDES-FOUS : ${p.scoring.isTop30Supplier ? "Oui (Top 30% Fournisseur)" : "Non"} | Récent : ${p.scoring.isRecent ? "Oui" : "Non"} | Dernier Prod: ${p.scoring.isLastProduct ? "Oui" : "Non"} + GARDES-FOUS : Top30: ${p.scoring.isTop30Supplier ? "Oui" : "Non"} | Récent: ${p.scoring.isRecent ? "Oui" : "Non"} | Dernier: ${p.scoring.isLastProduct ? "Oui" : "Non"} ` : ""; const contextRules = p.supplierContext - ? `\n--- RÈGLES MÉTIER SPÉCIFIQUES --- -Le manager a défini cette consigne pour ce fournisseur : -"${p.supplierContext}" - -Attention : Évalue d'abord si le produit ("${p.libelle1}") est concerné par cette consigne. Si oui, \`rule_applies\` doit être \`true\`. Sinon, \`false\`. -` + ? `\n--- RÈGLE MANAGER ---\n"${p.supplierContext}"\n→ Évalue si le produit ("${p.libelle1}") est concerné. rule_applies = true/false.\n` : ""; return `PRODUIT : ${p.libelle1} (${p.codein}) Famille / Rayon : ${p.libelleNiveau2} Score Algorithmique : ${p.score.toFixed(1)}/100 -KPIs : CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${scoringInfo} -Verdict purement algorithmique : ${p.scoring?.decision || "Non calculé"}${contextRules} - +KPIs : CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${scoringInfo}${contextRules} Génère UNIQUEMENT le JSON :`; } @@ -164,9 +173,7 @@ Génère UNIQUEMENT le JSON :`; static extractRecommendation(content: string): "A" | "C" | "Z" | null { const match = content.match(/\b([ACZ])\b/i); - if (match) { - return match[1].toUpperCase() as "A" | "C" | "Z"; - } + if (match) return match[1].toUpperCase() as "A" | "C" | "Z"; return null; } diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts index 1c91ea2..80200fa 100644 --- a/src/features/ai-copilot/business/context-profiler.ts +++ b/src/features/ai-copilot/business/context-profiler.ts @@ -1,19 +1,26 @@ /** - * CollectFlow — Context Profiler + * CollectFlow — Context Profiler (v2 — Anti sur-classement) * * Génère une fiche de contexte normalisée et adaptative pour chaque produit - * AVANT de le soumettre à l'IA. Ce profiler se base sur la distribution RÉELLE - * du lot (fournisseur × rayon) et ne contient aucune règle fixe de seuil. + * AVANT de le soumettre à l'IA. * - * L'objectif est de donner à Mary un contexte statistique riche qui lui permet - * de raisonner de manière autonome et cohérente, y compris pour les produits - * à fort volume/faible marge (Générateurs de Trafic) ou à faible volume/forte - * marge (Contributeurs de Marge). + * 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é. */ import type { ProductAnalysisInput } from "../models/ai-analysis.types"; import type { ScoringResult } from "./scoring-engine"; +// --------------------------------------------------------------------------- +// Constante : seuil minimal de produits dans un rayon pour activer les signaux +// Trafic/Marge. En dessous = statistiques non significatives. +// --------------------------------------------------------------------------- +const MIN_RAYON_SIZE = 6; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -26,7 +33,7 @@ export interface ProductContextProfile { libelle1: string; libelleNiveau2: string; - // Profil Quadrant (issu de la position Quantité vs Marge dans le lot) + // Profil Quadrant quadrant: Quadrant; quadrantLabel: string; quadrantEmoji: string; @@ -41,39 +48,54 @@ export interface ProductContextProfile { weightCaFournisseur: number; // % du CA total fournisseur weightQtyFournisseur: number; // % des QTÉ totales fournisseur - // Poids réels dans le rayon (N2) - weightCaRayon: number; // % du CA de sa catégorie N2 - weightQtyRayon: number; // % des QTÉ de sa catégorie N2 + // Poids réels dans le rayon (Niveau 2 de nomenclature) + weightCaRayon: number; + weightQtyRayon: number; // Santé temporelle tauxMarge: number; inactivityMonths: number; - regularityScore: number; // Nombre de mois actifs sur 12 + regularityScore: number; // Contexte du lot lotSize: number; rayonSize: number; - // Signaux booléens (calculés sans seuil fixe — relatifs à la distribution) + // Signaux positifs (calculés sur la distribution réelle) isAboveMedianComposite: boolean; - isTop20Ca: boolean; // Top 20% CA du fournisseur - isTop20Qty: boolean; // Top 20% QTÉ du fournisseur - isHighVolumeWithLowMargin: boolean; // Vol > P60 ET marge < P40 → Trafic - isMargePure: boolean; // Marge > P70 MÊME si vol < médiane → Marge + isTop20Ca: boolean; + isTop20Qty: boolean; + /** + * Fort volume ET marge < P40 du lot → rôle de "locomotive". + * Désactivé si rayonSize < MIN_RAYON_SIZE (percentiles non significatifs). + */ + isHighVolumeWithLowMargin: boolean; + /** + * Marge > P70 du lot même si volume faible → capital rentabilité. + * Désactivé si rayonSize < MIN_RAYON_SIZE. + */ + isMargePure: boolean; + + // 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; // Gardes-fous (issus du ScoringEngine) isProtected: boolean; protectionReason: string; - // Règle absolue (seule règle fixe du système) - scoreCritique: boolean; // score brut < 20 = candidat Z direct + // Règle absolue + scoreCritique: boolean; // score brut < 20 } // --------------------------------------------------------------------------- -// Helpers statistiques (purs, sans effet de bord) +// Helpers statistiques (purs) // --------------------------------------------------------------------------- -/** Calcule le percentile d'une valeur dans une distribution (0 à 100). */ function computePercentile(value: number, distribution: number[]): number { if (distribution.length <= 1) return 100; const sorted = [...distribution].sort((a, b) => a - b); @@ -84,7 +106,6 @@ function computePercentile(value: number, distribution: number[]): number { return Math.round((avgRank / (sorted.length - 1)) * 100); } -/** Calcule la médiane d'une liste de nombres. */ function computeMedian(values: number[]): number { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); @@ -94,7 +115,6 @@ function computeMedian(values: number[]): number { : (sorted[mid - 1] + sorted[mid]) / 2; } -/** Retourne la valeur au Pième percentile d'une distribution. */ function valueAtPercentile(values: number[], p: number): number { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); @@ -103,21 +123,15 @@ function valueAtPercentile(values: number[], p: number): number { } /** - * Extrait la clé de groupement pour le rayon de niveau 2. - * - * Règles de priorité : - * 1. Utilise `codeNomenclatureN2` si disponible (4 premiers chiffres du code à 6 chiffres). - * 2. Sinon, tente d'extraire les 4 premiers chiffres de `libelleNiveau2` - * (fallback si les codes ne sont pas transmis). - * 3. Sinon, retourne la valeur brute de `libelleNiveau2` pour ne pas perdre le groupement. - * - * Cela évite les "faux petits rayons" (groupement trop fin sur N3). + * Extrait la clé de groupement pour le rayon au niveau 2 de nomenclature. + * Priorité : `codeNomenclatureN2` (4 premiers chiffres) > extraction numérique + * depuis libelleNiveau2 > valeur brute de libelleNiveau2. */ function getRayonKey(p: ProductAnalysisInput): string { if (p.codeNomenclatureN2) { return p.codeNomenclatureN2; } - // Fallback : si libelleNiveau2 commence par 4 chiffres, les extraire + // Fallback : extraire les 4 premiers chiffres si le libellé commence par un code numérique const numericPrefix = p.libelleNiveau2?.match(/^(\d{4})/); if (numericPrefix) { return numericPrefix[1]; @@ -132,10 +146,6 @@ function getRayonKey(p: ProductAnalysisInput): string { export class ContextProfiler { /** * Génère le profil contextuel d'un produit au sein de son lot fournisseur. - * - * @param target - Le produit à profiler - * @param allProds - Tous les produits du fournisseur (lot complet) - * @param scoring - Résultat du ScoringEngine pour ce produit */ static buildProfile( target: ProductAnalysisInput, @@ -146,19 +156,17 @@ export class ContextProfiler { throw new Error("[ContextProfiler] Le lot de produits est vide."); } - // --- 1. Totaux fournisseur --- + // 1. Totaux fournisseur 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) --- - // On groupe sur le code N2 pour éviter les "faux petits rayons" issus d'un - // groupement trop fin sur le niveau 3 (code à 6 chiffres). + // 2. Totaux du rayon (Niveau 2 de nomenclature = 4 premiers chiffres du code) 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 --- + // 3. Percentiles dans le lot fournisseur const allCaValues = allProds.map(p => p.totalCa ?? 0); const allQtyValues = allProds.map(p => p.totalQuantite ?? 0); const allMargeValues = allProds.map(p => p.tauxMarge ?? 0); @@ -166,37 +174,52 @@ export class ContextProfiler { const pCa = computePercentile(target.totalCa ?? 0, allCaValues); const pQty = computePercentile(target.totalQuantite ?? 0, allQtyValues); const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues); - const pComposite = scoring.compositeScore; // Déjà 0-100 + const pComposite = scoring.compositeScore; - // --- 4. Tops (seuils adaptatifs sur la distribution réelle) --- + // 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; - - // --- 5. Médiane du lot (pour les signaux) --- - const medianComposite = computeMedian( - allProds.map((_, i) => i) // Approximation : le ScoringEngine ne donne pas tous les composites - ); - // On utilise directement le percentile composite pour isAboveMedian const isAboveMedianComposite = pComposite >= 50; - // --- 6. Signaux Trafic / Marge (seuils P40/P60/P70 sur la distribution) --- - const qty60 = valueAtPercentile(allQtyValues, 60); - const qty40 = valueAtPercentile(allQtyValues, 40); - const marge40 = valueAtPercentile(allMargeValues, 40); - const marge70 = valueAtPercentile(allMargeValues, 70); + // 5. Signal négatif fort : contribution insignifiante au fournisseur + const weightCaFournisseur = + totalCaFournisseur > 0 + ? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10 + : 0; + const weightQtyFournisseur = + totalQtyFournisseur > 0 + ? 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) + const rayonSizeForSignals = rayonProds.length; + const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE; + + let isHighVolumeWithLowMargin = false; + let isMargePure = false; + + if (signalsActive) { + const marge40 = valueAtPercentile(allMargeValues, 40); + const marge70 = valueAtPercentile(allMargeValues, 70); + const qty60 = valueAtPercentile(allQtyValues, 60); + const medianQty = computeMedian(allQtyValues); + + isHighVolumeWithLowMargin = + (target.totalQuantite ?? 0) >= qty60 && + (target.tauxMarge ?? 0) < marge40; + + isMargePure = + (target.tauxMarge ?? 0) >= marge70 && + (target.totalQuantite ?? 0) < medianQty; + } + + // 7. Quadrant (basé sur les médianes du lot fournisseur) const medianQty = computeMedian(allQtyValues); - - const isHighVolumeWithLowMargin = - (target.totalQuantite ?? 0) >= qty60 && - (target.tauxMarge ?? 0) < marge40; - - const isMargePure = - (target.tauxMarge ?? 0) >= marge70 && - (target.totalQuantite ?? 0) < medianQty; - - // --- 7. Quadrant (basé sur médiane QTÉ et médiane Marge du lot) --- const medianMarge = computeMedian(allMargeValues); const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant( target.totalQuantite ?? 0, @@ -205,7 +228,7 @@ export class ContextProfiler { medianMarge ); - // --- 8. Gardes-fous (issus du ScoringEngine) --- + // 8. Gardes-fous (issus du ScoringEngine) const isProtected = scoring.decision.isRecent || scoring.decision.isTop30Supplier || @@ -216,7 +239,7 @@ export class ContextProfiler { else if (scoring.decision.isTop30Supplier) protectionReason = "Top 30% CA Fournisseur"; else if (scoring.decision.isLastProduct) protectionReason = "Dernière référence du fournisseur"; - // --- 9. Règle absolue : score brut < 20 --- + // 9. Règle absolue const scoreCritique = (target.score ?? 0) < 20; return { @@ -233,14 +256,8 @@ export class ContextProfiler { percentileMarge: pMarge, percentileComposite: pComposite, - weightCaFournisseur: - totalCaFournisseur > 0 - ? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10 - : 0, - weightQtyFournisseur: - totalQtyFournisseur > 0 - ? Math.round(((target.totalQuantite ?? 0) / totalQtyFournisseur) * 1000) / 10 - : 0, + weightCaFournisseur, + weightQtyFournisseur, weightCaRayon: totalCaRayon > 0 ? Math.round(((target.totalCa ?? 0) / totalCaRayon) * 1000) / 10 @@ -255,13 +272,14 @@ export class ContextProfiler { regularityScore: target.regularityScore ?? 0, lotSize: allProds.length, - rayonSize: rayonProds.length, + rayonSize: rayonSizeForSignals, isAboveMedianComposite, isTop20Ca, isTop20Qty, isHighVolumeWithLowMargin, isMargePure, + isLowContribution, isProtected, protectionReason, @@ -270,7 +288,6 @@ export class ContextProfiler { }; } - /** Résout le quadrant en fonction des médianes du lot. */ private static resolveQuadrant( qty: number, marge: number,