diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index d45d18f..765f586 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -56,6 +56,12 @@ Si AUCUNE condition d'ajustement n'est remplie → CONFIRME le verdict pré-calc Note dans la justification (sans changer la décision) : • Si un magasin représente ≥ 80% des ventes → "porté par [magasin]" • Si produit protégé (nouveauté, dernière ref fournisseur) → le mentionner +• Si un RANKING est fourni, mentionne la position et le percentile du produit (ex: "classé 45e / 5 000 produits, top 1%") + → Le ranking est un signal contextuel qui RENFORCE le verdict : + - Top 5% du réseau → signal fort pour garder en A + - Top 20% du réseau → signal modéré pour garder en A + - Bottom 30% du réseau → signal défavorable, cohérent avec Z + → Le ranking seul ne change PAS le verdict, mais combiné avec d'autres signaux il peut justifier un ajustement --- FORMAT OBLIGATOIRE --- JSON uniquement, sans markdown. @@ -163,6 +169,25 @@ SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamai lines.push(`• Poids CA fournisseur : ${ctx.weightCaFournisseur.toFixed(1)}%`); lines.push(""); + // Ranking réseau & magasin (avec percentile contextuel) + if (p.rankingCa != null || p.rankingQte != null || p.rankingMagCa != null || p.rankingMagQte != null) { + const total = p.totalRankedProducts ?? 0; + const totalLabel = total > 0 ? ` / ${total.toLocaleString('fr-FR')} produits` : ""; + lines.push(`--- RANKING RÉSEAU (${total > 0 ? total.toLocaleString('fr-FR') : "?"} produits vendus sur la période) ---`); + if (p.rankingCa != null) { + const pctCa = total > 0 ? ((p.rankingCa / total) * 100).toFixed(1) : "?"; + lines.push(`• Classement CA réseau : ${p.rankingCa}e${totalLabel} (top ${pctCa}%)`); + } + if (p.rankingQte != null) { + const pctQte = total > 0 ? ((p.rankingQte / total) * 100).toFixed(1) : "?"; + lines.push(`• Classement Qté réseau : ${p.rankingQte}e${totalLabel} (top ${pctQte}%)`); + } + if (p.rankingMagCa != null) lines.push(`• Classement CA magasin : ${p.rankingMagCa}e`); + if (p.rankingMagQte != null) lines.push(`• Classement Qté magasin : ${p.rankingMagQte}e`); + lines.push(`• Contexte : ce fournisseur a ${ctx.lotSize} produits dans le lot analysé`); + lines.push(""); + } + // Signaux pré-calculés (si données mensuelles disponibles) if (p.siteMonthlyData && p.siteMonthlyData.length > 0) { const signals = AnalysisEngine.computeSignals(p.siteMonthlyData); @@ -291,7 +316,8 @@ Famille / Rayon : ${p.libelleNiveau2 ?? "N/A"} • Quantité : ${p.totalQuantite} unités • Marge : ${p.tauxMarge.toFixed(1)}% • PMV : ${pmv.toFixed(2)}€ -${p.shareCa !== undefined ? `• Poids CA Fournisseur : ${p.shareCa.toFixed(1)}%` : ""}${contextRules} +${p.shareCa !== undefined ? `• Poids CA Fournisseur : ${p.shareCa.toFixed(1)}%` : ""} +${p.rankingCa != null || p.rankingQte != null ? `\n--- RANKING ---\n${p.rankingCa != null ? `• Classement CA réseau : ${p.rankingCa}e${p.totalRankedProducts ? ` / ${p.totalRankedProducts} produits (top ${((p.rankingCa / p.totalRankedProducts) * 100).toFixed(1)}%)` : ""}\n` : ""}${p.rankingQte != null ? `• Classement Qté réseau : ${p.rankingQte}e${p.totalRankedProducts ? ` / ${p.totalRankedProducts} produits (top ${((p.rankingQte / p.totalRankedProducts) * 100).toFixed(1)}%)` : ""}\n` : ""}${p.rankingMagCa != null ? `• Classement CA magasin : ${p.rankingMagCa}e\n` : ""}${p.rankingMagQte != null ? `• Classement Qté magasin : ${p.rankingMagQte}e` : ""}` : ""}${contextRules} Génère UNIQUEMENT le JSON :`; } diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 82c4cc1..8dc39e6 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -78,6 +78,15 @@ export interface ProductAnalysisInput { nbJoursDerniereVente?: number; derniereVente?: string; + /** Ranking réseau (classement global tous magasins) */ + rankingCa?: number; + rankingQte?: number; + /** Ranking magasin (classement local) */ + rankingMagCa?: number; + rankingMagQte?: number; + /** Nombre total de produits classés (avec ventes sur la période) */ + totalRankedProducts?: number; + /** * Fiche de contexte enrichie générée par le ContextProfiler. * Transmise au prompt de l'IA pour une analyse multi-dimensionnelle. diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index e68aa00..c266542 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -31,12 +31,13 @@ export async function getProductRows(input: GetProductRowsInput): Promise r.codein)); + // 2c. Top 20 réseau → A direct, sans appel IA + const top20Rows = rows.filter(r => + (r.totalQuantite || 0) > 0 && + !deadStockCodes.has(r.codein) && + ((r.rankingCa != null && r.rankingCa <= 20) || (r.rankingQte != null && r.rankingQte <= 20)) + ); + if (top20Rows.length > 0) { + const aChanges: Record = {}; + top20Rows.forEach(r => { aChanges[r.codein] = "A"; }); + batchSetDraftGamme(aChanges); + top20Rows.forEach(r => { + const rkCa = r.rankingCa != null ? `${r.rankingCa}e CA` : ""; + const rkQte = r.rankingQte != null ? `${r.rankingQte}e Qté` : ""; + const total = r.totalRankedProducts ? ` / ${r.totalRankedProducts.toLocaleString("fr-FR")} produits` : ""; + setInsight(r.codein, `Top 20 réseau (${[rkCa, rkQte].filter(Boolean).join(", ")}${total}) — classé A automatiquement.`); + }); + } + const top20Codes = new Set(top20Rows.map(r => r.codein)); + // Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente et CA/quantité suffisants const payloadsWithSales = initialPayloads.filter(p => - (p.totalQuantite || 0) > 0 && !deadStockCodes.has(p.codein) + (p.totalQuantite || 0) > 0 && !deadStockCodes.has(p.codein) && !top20Codes.has(p.codein) ); // 2c. Context profiling en micro-batches asynchrones @@ -182,6 +201,11 @@ export function BulkAiAnalyzer() { commandesEnCours: row?.commandesEnCours, nbJoursDerniereVente: row?.nbJoursDerniereVente, derniereVente: row?.derniereVente, + rankingCa: row?.rankingCa, + rankingQte: row?.rankingQte, + rankingMagCa: row?.rankingMagCa, + rankingMagQte: row?.rankingMagQte, + totalRankedProducts: row?.totalRankedProducts, contextProfile, scoring: { score, diff --git a/src/lib/api-ff-client.ts b/src/lib/api-ff-client.ts index c32a296..6763f7b 100644 --- a/src/lib/api-ff-client.ts +++ b/src/lib/api-ff-client.ts @@ -444,17 +444,39 @@ function parseRankNum(val: string | null | undefined): number | undefined { return isNaN(n) ? undefined : n; } +export interface RankingResult { + rankings: Map; + /** Nombre total de produits dans le classement (produits avec ventes sur la période) */ + totalRankedProducts: number; +} + /** * Récupère le ranking (réseau + magasin) pour un lot d'articles via codein. - * Retourne Map + * Fait d'abord un appel pour connaître le nombre total de produits classés, + * puis récupère le ranking de chaque article. */ export async function getRankingByArticles( articles: { codein: string; gtin?: string }[], batchSize = 20 -): Promise> { - const result = new Map(); - if (articles.length === 0) return result; +): Promise { + const rankings = new Map(); + if (articles.length === 0) return { rankings, totalRankedProducts: 0 }; + // 1. Récupérer le nombre total de produits classés (1 seul appel) + let totalRankedProducts = 0; + try { + const countUrl = `${FF_API_BASE}/api/ranking?limit=1`; + const countRes = await fetch(countUrl, { cache: "no-store" }); + if (countRes.ok) { + const countData = await countRes.json(); + totalRankedProducts = countData?.count ?? 0; + console.log(`[api-ff] Ranking total produits classés : ${totalRankedProducts}`); + } + } catch (err) { + console.warn(`[api-ff] Failed to get ranking total count:`, err); + } + + // 2. Récupérer le ranking de chaque article for (let i = 0; i < articles.length; i += batchSize) { const batch = articles.slice(i, i + batchSize); await Promise.all( @@ -465,18 +487,15 @@ export async function getRankingByArticles( if (!res.ok) return; const data = await res.json(); - // Structure : { count: number, ranking: RankingEntry[] } - const rankings = data?.ranking; - if (!Array.isArray(rankings) || rankings.length === 0) return; + const rankingList = data?.ranking; + if (!Array.isArray(rankingList) || rankingList.length === 0) return; - // Log premier résultat pour diagnostic - if (result.size === 0) { - console.log(`[api-ff] Ranking sample keys: ${Object.keys(rankings[0]).join(",")}`); + if (rankings.size === 0) { + console.log(`[api-ff] Ranking sample keys: ${Object.keys(rankingList[0]).join(",")}`); } - // Prendre la première entrée - const entry = rankings[0]; - result.set(art.codein, { + const entry = rankingList[0]; + rankings.set(art.codein, { ranking_ca: parseRankNum(entry.ranking_ca), ranking_qte: parseRankNum(entry.ranking_qte), ranking_mag_ca: parseRankNum(entry.ranking_mag_ca), @@ -493,8 +512,8 @@ export async function getRankingByArticles( ); } - console.log(`[api-ff] Ranking: ${result.size}/${articles.length} articles enrichis`); - return result; + console.log(`[api-ff] Ranking: ${rankings.size}/${articles.length} articles enrichis`); + return { rankings, totalRankedProducts }; } // --------------------------------------------------------------------------- diff --git a/src/types/grid.ts b/src/types/grid.ts index f0e9582..fc8b4c4 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -99,6 +99,8 @@ export interface ProductRow { /** Ranking magasin */ rankingMagCa?: number; rankingMagQte?: number; + /** Nombre total de produits classés dans le réseau (produits avec ventes sur la période) */ + totalRankedProducts?: number; } /** Summary bar totals for the currently visible/filtered rows */