From 335cf15ffaaa86ffb133b44a817aacdb3c1ac123 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Feb 2026 12:28:50 +0100 Subject: [PATCH] feat: Introduce AI copilot for bulk product analysis and integrate a new product scoring engine. --- .../ai-copilot/business/analysis-engine.ts | 31 +++++++++------- .../ai-copilot/models/ai-analysis.types.ts | 2 + .../grid/components/bulk-ai-analyzer.tsx | 35 +++++++++++------- src/lib/score-engine.ts | 37 +++++++++++-------- 4 files changed, 62 insertions(+), 43 deletions(-) diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index a50d28f..4bd1343 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -9,14 +9,14 @@ En analysant les données de ventes fournies, génère une recommandation de gam - [C] - SAISONNIER : Produit dont les ventes sont concentrées sur des périodes spécifiques (pics saisonniers). - [Z] - SORTIE : Produit en fin de cycle de vie, sans rotation significative ou en chute libre, devant être retiré du référencement. -Critères de pondération et Règles Métier : -1. Volume Relatif (Flux de stock) : Si le volume est élevé (très supérieur à la moyenne), c'est un produit "fond de rayon" qui génère du trafic. ON LE GARDE (A ou C) même si son CA individuel est faible. -2. Mix CA/Marge (Rentabilité) : Si le volume est faible mais que le produit génère un CA conséquent et une BONNE MARGE, c'est une pépite. ON LE GARDE EN A. -3. Poids Relatif : Analyse toujours la performance du produit PAR RAPPORT AU RESTE (moyenne fournisseur). Un produit doit être Z uniquement s'il est faible sur TOUS les indicateurs (Volume, CA, Marge). -4. Contexte Magasins (Crucial) : Compare le produit à ses pairs. Si le produit est dans 1 magasin, compare son volume à la moyenne des produits à 1 magasin (avgQtyGroup1). Ne le pénalise pas parce qu'il vend moins qu'un produit présent dans 2 magasins ou plus. Un produit à 1 magasin qui performe mieux que la moyenne de son groupe est un excellent candidat A. -5. Régularité : La stabilité sur 12 mois est la signature du Permanent (A). +Critères de pondération et Règles Métier Strictes : +1. Normalisation Totale (Base 2 magasins) : TOUTES les statistiques fournies (Totaux et Historique Mensuel) sont PONDÉRÉES par 2 si le produit n'est présent que dans 1 magasin. L'objectif est de simuler une performance sur une base réseau de 2 magasins. +2. Seuil "Permanent" (A) : Pour être A, un produit doit avoir un volume PONDÉRÉ significatif (ex: > 30-50 unités/an). +3. Seuil "Sortie" (Z) : Un produit avec moins de 10 unités pondérées par an et un CA faible est un candidat naturel à la sortie (Z). +4. Volume/CA/Marge : Un produit "A" doit justifier sa place en rayon par son flux (volume) OU sa rentabilité brute (CA/Marge). S'il est faible sur les deux, c'est un Z. +5. Poids Relatif : Analyse la performance par rapport aux moyennes fournies. -Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable. +Ta réponse doit être courte, directe et sans complaisance. Format impératif : "[Recommandation]: [Justification courte]"`; } @@ -25,17 +25,20 @@ Format impératif : "[Recommandation]: [Justification courte]"`; .map(([k, v]) => `${k}: ${Math.round(v)}u`) .join(", "); - const volumeContext = p.avgTotalQuantite - ? `- Volume vs Moyenne Fournisseur : ${Math.round(p.totalQuantite)}u (Moyenne: ${Math.round(p.avgTotalQuantite)}u)` - : `- Volume total : ${Math.round(p.totalQuantite)} unités`; + const volumeInfo = `Stats Réelles : ${Math.round(p.totalQuantite)}u (${p.totalCa.toFixed(2)}€) sur ${p.storeCount} mag. +Stats Pondérées (Base 2 mag) : ${Math.round(p.weightedTotalQuantite || 0)}u (${(p.weightedTotalCa || 0).toFixed(2)}€)`; + + const benchmarks = p.avgQtyGroup1 !== undefined && p.avgQtyGroup2 !== undefined + ? `\nBenchmarks (Volumes moyens) : +- Moyenne mag unique : ${Math.round(p.avgQtyGroup1)}u +- Moyenne multi-magasins : ${Math.round(p.avgQtyGroup2)}u` + : ""; return `Produit : "${p.libelle1}" (Ref: ${p.codein}) Gamme actuelle : ${p.codeGamme ?? "Non définie"} -Contexte : Données basées sur ${p.storeCount} magasin(s). -Indicateurs 12m : -- CA : ${p.totalCa.toFixed(2)}€ +${volumeInfo}${benchmarks} +Indicateurs Clés : - Marge : ${p.tauxMarge.toFixed(1)}% -${volumeContext} - Historique mensuel : ${monthlySummary} Quelle est ta recommandation (A, C ou Z) et pourquoi ?`; diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 782c5b6..f997fee 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -4,6 +4,8 @@ export interface ProductAnalysisInput { totalCa: number; tauxMarge: number; totalQuantite: number; + weightedTotalQuantite?: number; + weightedTotalCa?: number; avgTotalQuantite?: number; avgQtyGroup1?: number; // Moyenne volume pour produits à 1 magasin avgQtyGroup2?: number; // Moyenne volume pour produits à 2 magasins+ diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 73bd347..af8b3c7 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -33,19 +33,28 @@ export function BulkAiAnalyzer() { const avgQty1 = rowsGroup1.length > 0 ? rowsGroup1.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup1.length : 0; const avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup2.length : 0; - const productPayloads: ProductAnalysisInput[] = rows.map(r => ({ - codein: r.codein, - libelle1: r.libelle1 || "", - totalCa: r.totalCa || 0, - tauxMarge: r.tauxMarge || 0, - totalQuantite: r.totalQuantite || 0, - avgTotalQuantite: avgQty, - avgQtyGroup1: avgQty1, - avgQtyGroup2: avgQty2, - storeCount: r.workingStores?.length || 1, - sales12m: r.sales12m || {}, - codeGamme: r.codeGamme || null, - })); + const productPayloads: ProductAnalysisInput[] = rows.map(r => { + const sc = r.workingStores?.length || 1; + const weight = sc === 1 ? 2 : 1; // On ramène tout sur une base 2 magasins + + return { + codein: r.codein, + libelle1: r.libelle1 || "", + totalCa: r.totalCa || 0, + tauxMarge: r.tauxMarge || 0, + totalQuantite: r.totalQuantite || 0, + weightedTotalQuantite: (r.totalQuantite || 0) * weight, + weightedTotalCa: (r.totalCa || 0) * weight, + avgTotalQuantite: avgQty, + avgQtyGroup1: avgQty1, + avgQtyGroup2: avgQty2, + storeCount: sc, + sales12m: Object.fromEntries( + Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight]) + ), + codeGamme: r.codeGamme || null, + }; + }); setIsAnalyzing(true); let completed = 0; diff --git a/src/lib/score-engine.ts b/src/lib/score-engine.ts index a0dc534..4da8277 100644 --- a/src/lib/score-engine.ts +++ b/src/lib/score-engine.ts @@ -35,26 +35,31 @@ export function computeProductScores( ): ProductRow[] { if (rows.length === 0) return rows; - // 1. Trouver les valeurs maximales (les "Top 1") pour indexer 0-100 - const maxQty = Math.max(...rows.map((r) => r.totalQuantite ?? 0)); - const maxCa = Math.max(...rows.map((r) => r.totalCa ?? 0)); - const maxMarge = Math.max(...rows.map((r) => r.totalMarge ?? 0)); + // 1. Calculer les valeurs pondérées pour tout le monde et trouver les max + const weightedData = rows.map((r) => { + const weight = (r.workingStores?.length || 1) === 1 ? 2 : 1; + return { + row: r, + wQty: (r.totalQuantite ?? 0) * weight, + wCa: (r.totalCa ?? 0) * weight, + wMarge: (r.totalMarge ?? 0) * weight, + }; + }); - // 2. Pour chaque produit, calculer le score relatif au Top 1 - for (const row of rows) { - // Score de 0 à 100 sur chaque axe (le Top 1 a 100) - const scoreQty = maxQty > 0 ? ((row.totalQuantite ?? 0) / maxQty) * 100 : 0; - const scoreCa = maxCa > 0 ? ((row.totalCa ?? 0) / maxCa) * 100 : 0; - const scoreMarge = maxMarge > 0 ? ((row.totalMarge ?? 0) / maxMarge) * 100 : 0; + const maxQty = Math.max(...weightedData.map((d) => d.wQty)); + const maxCa = Math.max(...weightedData.map((d) => d.wCa)); + const maxMarge = Math.max(...weightedData.map((d) => d.wMarge)); + + // 2. Pour chaque produit, calculer le score relatif aux max pondérés + for (const data of weightedData) { + // Score de 0 à 100 sur chaque axe pondéré (le Top 1 pondéré a 100) + const scoreQty = maxQty > 0 ? (data.wQty / maxQty) * 100 : 0; + const scoreCa = maxCa > 0 ? (data.wCa / maxCa) * 100 : 0; + const scoreMarge = maxMarge > 0 ? (data.wMarge / maxMarge) * 100 : 0; // Base = meilleur score d'axe const base = Math.max(scoreQty, scoreCa, scoreMarge); - // Bonus polyvalence : combien d'axes dépassent le seuil demandé (en %, c.a.d en score d'axe) - // Attention : settings.seuilAxeFort était pensé pour un % du Total (~2%). - // Maintenant le score max étant 100, un "axe fort" devrait plutôt être un paramètre indexé (ex: > 30% du max). - // On va garder la règle, mais l'utilisateur devra probablement ajuster le seuil dans les paramètres. - let axesForts = 0; if (scoreQty > settings.seuilAxeFort) axesForts++; if (scoreCa > settings.seuilAxeFort) axesForts++; @@ -62,7 +67,7 @@ export function computeProductScores( const bonus = axesForts * settings.bonusParAxe; - row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10; + data.row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10; } return rows;