diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index f5b86a5..7da0eff 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -2,77 +2,38 @@ import { ProductAnalysisInput } from "../models/ai-analysis.types"; export class AnalysisEngine { static generateSystemPrompt(): string { - return `Tu es Mary, une experte Senior en Stratégie Retail et Category Management. Ton rôle est d'arbitrer l'assortiment avec une rigueur de consultant. -Tu analyses les performances d'un produit par rapport à son Fournisseur et son Rayon (Niveau 2). + return `Tu es Mary, experte Senior en Stratégie Retail. Ta mission est d'expliquer le verdict du moteur de scoring algorithmique. +Le scoring est basé sur 5 axes : CA normalisé (35%), Volume (25%), Marge (20%), Profil/Quadrant (10%) et Activité (10%). ---- HIÉRARCHIE DE DÉCISION MÉTIER (Priorité Décroissante) --- +--- TON RÔLE --- +1. JUSTIFIER : Explique pourquoi le produit a obtenu son score (Score / [100]) par rapport au seuil du rayon. +2. ÉVALUER LE PROFIL : Utilise le libellé du profil (Star, Contributeur Marge, Générateur Trafic, Sous-performant). +3. VÉRIFIER LES GARDES-FOUS : Si le verdict est [A] malgré un score faible, explique si c'est dû à sa position de Leader Fournisseur (Top 30%), à son statut de Produit Récent ou de Dernier Produit Fournisseur. +4. ÊTRE CONCISE : Maximum 2 phrases percutantes. -1. INDISCUTABLES (Poids Stratégique > 10% du CA Fournisseur) : - - Ce sont les "Piliers" du fournisseur. Ne JAMAIS suggérer [Z] (Sortie), sauf accident industriel (Marge < 0 ET Régularité < 3/12). - - Si Poids > 15%, la recommandation [A] est quasi-obligatoire pour protéger la relation fournisseur. - -2. PILIERS & VACHES À LAIT (Score > 65 + Régularité > 8/12) : - - Produits performants et stables. Recommandation [A] (Permanent). - - Justifie par la "Moyenne Rayon" : Si Volume > Moyenne Rayon, c'est un moteur de trafic. - -3. DILEMMES & SAISONNIERS (Score moyen OU Régularité faible OU Inactivité > 2) : - - [C] (À surveiller) : Réservé EXCLUSIVEMENT aux produits saisonniers. - - Un produit est considéré comme saisonnier SI ET SEULEMENT SI : - a) Sa nomenclature (Libellé) contient des indicateurs clairs (ex: "SAIS", "ETE", "NOEL", "HIVER", "PAQUES", "PROMO", "PERIPH"). - b) Son historique de vente montre des pics cycliques annuels évidents malgré une inactivité prolongée. - - Ne JAMAIS mettre un produit en [C] simplement à cause d'une régularité faible ou de ventes fluctuantes s'il n'y a pas d'indice de saisonnalité. Dans ce cas, préférer [A] (si stratégique) ou [Z] (si performance insuffisante). - -4. POIDS MORTS (Poids < 2% + Score < 30 + Volume < Moyenne Rayon) : - - [Z] (Sortie) : Produits marginaux qui encombrent le linéaire sans rentabilité ni débit suffisant. - ---- TON, STYLE & FORMAT --- -- Sois chirurgicale : Cite toujours le poids (%) et compare le volume au benchmark. -- Maximum 2-3 phrases. Pas de blabla, juste des faits. -- Format strict : "[Recommandation] : [Justification factuelle]" -- Exemple : "[A] : Produit pilier (12% du CA Fournisseur) avec un volume 1.5x supérieur à la moyenne du rayon."`; +--- FORMAT ATTENDU --- +"[Recommandation] : [Justification factuelle incluant le Score et les axes clés]"`; } static generateUserMessage(p: ProductAnalysisInput): string { const pmv = p.totalQuantite > 0 ? p.totalCa / p.totalQuantite : 0; - const monthlySummary = Object.entries(p.sales12m || {}) - .map(([k, v]) => `${k}: ${Math.round(v)}u`) - .join(", "); - - const volumeInfo = `VOLUMES : ${Math.round(p.totalQuantite)}u sur ${p.storeCount} mag.`; - - // Contexte comparatif - const avgRef = p.avgQtyRayon || p.avgQtyFournisseur || 0; - const refName = p.avgQtyRayon ? "rayon" : "fournisseur"; - const relativeVolume = avgRef > 0 ? ` (Moyenne du ${refName} : ${avgRef.toFixed(1)}u)` : ""; - - // Poids Stratégique const weights = p.shareCa !== undefined ? ` CONTRIBUTION (%) : - Chiffre d'Affaires : ${p.shareCa.toFixed(1)}% du total fournisseur -- Marge Brute : ${p.shareMarge?.toFixed(1)}% du total fournisseur -- Volumes : ${p.shareQty?.toFixed(1)}% du total fournisseur` : ""; +- Marge Brute : ${p.shareMarge?.toFixed(1)}% du total fournisseur` : ""; - const totalContext = p.totalFournisseurCa ? `\nVALEUR TOTALE FOURNISSEUR : ${p.totalFournisseurCa.toFixed(0)}€ CA` : ""; - - const projectionInfo = (p.regularityScore || 0) > 0 && (p.regularityScore || 0) < 12 ? ` ---- ANALYSE DE POTENTIEL (Produit récent : ${p.regularityScore}/12 mois) --- -Projection 12 mois (Run Rate) : ${Math.round(p.projectedTotalQuantite || 0)}u (${(p.projectedTotalCa || 0).toFixed(2)}€)` : ""; - - const activityAlert = (p.inactivityMonths || 0) > 2 - ? `\n⚠️ ALERTE INACTIVITÉ : ${p.inactivityMonths} mois sans vente.` - : ""; + const scoringInfo = p.scoring ? ` +--- RÉSULTATS SCORING ALGORIHTMIQUE --- +SCORE FINAL : ${p.scoring.compositeScore}/100 (Seuil Rayon 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"} +` : ""; return `PRODUIT : ${p.libelle1} (${p.codein}) -PERFORMANCE : Score ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€ -${volumeInfo}${relativeVolume}${weights}${totalContext} - -CYCLE DE VIE : ${p.regularityScore}/12 mois active${projectionInfo}${activityAlert} - ---- HISTORIQUE MENSUEL PONDÉRÉ --- -${monthlySummary} - -Recommandation actuelle : ${p.codeGamme || "Aucune"} -Verdict attendu (A, C ou Z) ?`; +PERFORMANCE : Score Global ${p.score.toFixed(1)}/100 | Marge ${p.tauxMarge.toFixed(1)}% | PMV ${pmv.toFixed(2)}€ +${weights}${scoringInfo} +Verdict algorithmique : ${p.scoring?.decision || "Non calculé"} +Justifie ce verdict auprès de l'utilisateur.`; } static extractRecommendation(content: string): "A" | "C" | "Z" | null { diff --git a/src/features/ai-copilot/business/scoring-engine.ts b/src/features/ai-copilot/business/scoring-engine.ts new file mode 100644 index 0000000..b05ed82 --- /dev/null +++ b/src/features/ai-copilot/business/scoring-engine.ts @@ -0,0 +1,177 @@ +import { ProductAnalysisInput } from "../models/ai-analysis.types"; + +export interface ScoringResult { + compositeScore: number; + percentiles: { + ca: number; + volume: number; + marge: number; + }; + scores: { + profil: number; + activite: number; + }; + decision: { + recommendation: "A" | "Z"; + threshold: number; + isTop30Supplier: boolean; + isLastProduct: boolean; + isRecent: boolean; + labelProfil: string; + }; + metrics: { + caNormalise: number; + volNormalise: number; + }; +} + +export class ScoringEngine { + /** + * Calcule le score et la décision pour un produit au sein de son rayon. + */ + static analyzeRayon(targetProduct: ProductAnalysisInput, allProducts: ProductAnalysisInput[]): ScoringResult { + // 1. Normalisation et Projection pour tous les produits du rayon + const processedProducts = allProducts.map(p => this.preprocess(p)); + const target = this.preprocess(targetProduct); + + // 2. Calcul des Percentiles (Rangs / Nb produits) + const pCa = this.calculatePercentile(target.caNormalise, processedProducts.map(p => p.caNormalise)); + const pVol = this.calculatePercentile(target.volNormalise, processedProducts.map(p => p.volNormalise)); + const pMarge = this.calculatePercentile(target.tauxMarge, processedProducts.map(p => p.tauxMarge)); + + // 3. Score Profil (Quadrant vs Médianes) + const medVol = this.calculateMedian(processedProducts.map(p => p.volNormalise)); + const medMarge = this.calculateMedian(processedProducts.map(p => p.tauxMarge)); + + let scoreProfil = 0.2; + let labelProfil = "Sous-performant ⚠️"; + + if (target.volNormalise > medVol && target.tauxMarge > medMarge) { + scoreProfil = 1.0; + labelProfil = "Star ⭐"; + } else if (target.volNormalise <= medVol && target.tauxMarge > medMarge) { + scoreProfil = 0.7; + labelProfil = "Contributeur Marge 💎"; + } else if (target.volNormalise > medVol && target.tauxMarge <= medMarge) { + scoreProfil = 0.7; + labelProfil = "Générateur Trafic 🚶"; + } + + // 4. Score Activité (Inactivité corrigée saisonnalité) + const rayonInactivityRate = processedProducts.filter(p => (p.inactivityMonths || 0) > 0).length / processedProducts.length; + const isSaisonnierRayon = rayonInactivityRate > 0.40; + + let scoreActivite = 1.0; + const inactif = target.inactivityMonths || 0; + + if (isSaisonnierRayon && inactif > 0) { + scoreActivite = 0.8; + } else if (inactif === 1) { + scoreActivite = 0.6; + } else if (inactif === 2) { + scoreActivite = 0.3; + } else if (inactif >= 3) { + scoreActivite = 0.0; + } + + // 5. Score Composite + const compositeScore = (pCa * 0.35) + (pVol * 0.25) + (pMarge * 0.20) + (scoreProfil * 0.10) + (scoreActivite * 0.10); + + // 6. Décision (Moyenne - Sigma) + const allScores = processedProducts.map(p => { + // On doit simuler le score composite pour tout le monde pour avoir la moyenne/sigma + // Pour simplifier ici, on recalculerait ça proprement + return this.calculateQuickScore(p, processedProducts, medVol, medMarge, isSaisonnierRayon); + }); + + const mean = allScores.reduce((a, b) => a + b, 0) / allScores.length; + const stdDev = Math.sqrt(allScores.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b, 0) / allScores.length); + const threshold = mean - stdDev; + + let recommendation: "A" | "Z" = compositeScore >= threshold ? "A" : "Z"; + + // 7. Gardes-fous + const isRecent = (target.regularityScore || 0) < 3; + const isTop30Supplier = this.checkTop30Supplier(target, processedProducts); + const isLastProduct = target.isLastProductOfSupplier || false; + + if (isRecent || isTop30Supplier || isLastProduct) { + recommendation = "A"; + } + + return { + compositeScore: Math.round(compositeScore * 100), + percentiles: { ca: pCa, volume: pVol, marge: pMarge }, + scores: { profil: scoreProfil, activite: scoreActivite }, + decision: { + recommendation, + threshold: Math.round(threshold * 100), + isTop30Supplier, + isLastProduct, + isRecent, + labelProfil + }, + metrics: { + caNormalise: target.caNormalise, + volNormalise: target.volNormalise + } + }; + } + + 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; + + // Run Rate Projection + if ((p.regularityScore || 0) >= 3 && (p.regularityScore || 0) < 12) { + caNorm = (caNorm / (p.regularityScore || 3)) * 12; + volNorm = (volNorm / (p.regularityScore || 3)) * 12; + } + + return { ...p, caNormalise: caNorm, volNormalise: volNorm }; + } + + private static calculatePercentile(value: number, distribution: number[]): number { + if (distribution.length <= 1) return 1; + const sorted = [...distribution].sort((a, b) => a - b); + const rank = sorted.indexOf(value); + return rank / (distribution.length - 1); + } + + private static calculateMedian(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + 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); + if (supplierProds.length === 0) return false; + 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); + 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)); + const pMarge = this.calculatePercentile(p.tauxMarge, rayon.map(x => x.tauxMarge)); + + let sProfil = 0.2; + if (p.volNormalise > medVol && p.tauxMarge > medMarge) sProfil = 1.0; + else if (p.volNormalise <= medVol && p.tauxMarge > medMarge) sProfil = 0.7; + else if (p.volNormalise > medVol && p.tauxMarge <= medMarge) sProfil = 0.7; + + let sActivite = 1.0; + const inactif = p.inactivityMonths || 0; + if (isSaisonnier && inactif > 0) sActivite = 0.8; + else if (inactif === 1) sActivite = 0.6; + else if (inactif === 2) sActivite = 0.3; + else if (inactif >= 3) sActivite = 0.0; + + return (pCa * 0.35) + (pVol * 0.25) + (pMarge * 0.20) + (sProfil * 0.10) + (sActivite * 0.10); + } +} diff --git a/src/features/ai-copilot/components/ai-insight-block.tsx b/src/features/ai-copilot/components/ai-insight-block.tsx index 411a8ee..95b0311 100644 --- a/src/features/ai-copilot/components/ai-insight-block.tsx +++ b/src/features/ai-copilot/components/ai-insight-block.tsx @@ -3,12 +3,14 @@ import React, { useState } from "react"; import { Bot, Loader2, AlertCircle, Sparkles, Maximize2 } from "lucide-react"; -import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store"; import { AiExplanationModal } from "./ai-explanation-modal"; +import { useAiCopilotStore } from "../store/use-ai-copilot-store"; +import { ProductAnalysisInput } from "../models/ai-analysis.types"; +import { ScoringEngine } from "../business/scoring-engine"; +import { useGridStore } from "@/features/grid/store/use-grid-store"; import type { ProductRow } from "@/types/grid"; import { cn } from "@/lib/utils"; -import { useGridStore } from "@/features/grid/store/use-grid-store"; interface AiInsightBlockProps { row: ProductRow; @@ -30,18 +32,17 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { setLoading(row.codein); setDraftGamme(row.codein, "Aucune"); - const regScore = Object.values(row.sales12m || {}).filter(v => v > 0).length; + const allRows = useGridStore.getState().rows; + const rayonRows = allRows.filter(r => r.libelleNiveau2 === row.libelleNiveau2); + + // Préparation du ScoringEngine + const regScore = Object.values(row.sales12m || {}).filter((v: any) => v > 0).length; const weight = (row.workingStores?.length || 1) === 1 ? 2 : 1; - // Calcul de l'inactivité (Récence) + // Calcul de l'inactivité const allMonths = Object.keys(row.sales12m || {}); const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : ""; - - const salesMonths = Object.entries(row.sales12m || {}) - .filter(([_, qty]) => qty > 0) - .map(([m]) => parseInt(m)) - .sort((a, b) => b - a); - + const salesMonths = Object.entries(row.sales12m || {}).filter(([_, qty]: [string, any]) => qty > 0).map(([m]) => parseInt(m)).sort((a, b) => b - a); const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : ""; let inactivity = 0; if (referenceMonth && lastMonth) { @@ -52,6 +53,27 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { inactivity = (refY - lastY) * 12 + (refM - lastM); } + // Mapping pour le moteur de scoring + const mapToInput = (r: ProductRow) => ({ + codein: r.codein, + libelle1: r.libelle1, + totalCa: r.totalCa, + tauxMarge: r.tauxMarge, + totalQuantite: r.totalQuantite, + codeFournisseur: r.codeFournisseur, + storeCount: r.workingStores?.length || 1, + totalMagasins: 2, + regularityScore: Object.values(r.sales12m || {}).filter((v: any) => v > 0).length, + inactivityMonths: 0, + isLastProductOfSupplier: false, + sales12m: r.sales12m, + codeGamme: r.codeGamme, + score: r.score + }); + + // Calcul du ScoringEngine + const scoringResult = ScoringEngine.analyzeRayon(mapToInput(row), rayonRows.map(mapToInput)); + analyzeProduct({ codein: row.codein, libelle1: row.libelle1, @@ -62,8 +84,6 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { codeGamme: row.codeGamme, score: row.score, regularityScore: regScore, - projectedTotalQuantite: regScore > 0 ? (row.totalQuantite * weight * (12 / regScore)) : (row.totalQuantite * weight), - projectedTotalCa: regScore > 0 ? (row.totalCa * weight * (12 / regScore)) : (row.totalCa * weight), lastMonthWithSale: lastMonth, inactivityMonths: inactivity, avgQtyFournisseur: row.avgQtyFournisseur, @@ -72,6 +92,17 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { shareMarge: row.shareMarge, shareQty: row.shareQty, totalFournisseurCa: row.totalFournisseurCa, + codeFournisseur: row.codeFournisseur, + totalMagasins: 2, + scoring: { + compositeScore: scoringResult.compositeScore, + decision: scoringResult.decision.recommendation, + labelProfil: scoringResult.decision.labelProfil, + isTop30Supplier: scoringResult.decision.isTop30Supplier, + isRecent: scoringResult.decision.isRecent, + isLastProduct: scoringResult.decision.isLastProduct, + threshold: scoringResult.decision.threshold + } }); }; diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 745ace6..9e87ee3 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -27,10 +27,25 @@ export interface ProductAnalysisInput { /** Analyse de saisonnalité */ lastMonthWithSale?: string; inactivityMonths?: number; + /** Contexte Fournisseur */ + codeFournisseur?: string; + totalMagasins?: number; + isLastProductOfSupplier?: boolean; + /** Résultats du ScoringEngine (optionnel pour l'IA) */ + scoring?: { + compositeScore: number; + decision: string; + labelProfil: string; + isTop30Supplier: boolean; + isRecent: boolean; + isLastProduct: boolean; + threshold: number; + }; } export interface AnalysisResult { insight: string; codein: string; recommandation: "A" | "C" | "Z" | null; + scoring?: any; // On peut typer plus finement si nécessaire }