From d644043aa54a24ba8bff3c188100110ccca8c133 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Feb 2026 01:07:17 +0100 Subject: [PATCH] feat: Add batch AI product categorization API and a bulk analyzer UI component. --- .claude/settings.local.json | 7 + src/app/api/ai/batch-analyze/route.ts | 133 ++++++++++++------ .../grid/components/bulk-ai-analyzer.tsx | 94 +++++++++++-- 3 files changed, 179 insertions(+), 55 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3f582e9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc:*)" + ] + } +} diff --git a/src/app/api/ai/batch-analyze/route.ts b/src/app/api/ai/batch-analyze/route.ts index 246e471..608add3 100644 --- a/src/app/api/ai/batch-analyze/route.ts +++ b/src/app/api/ai/batch-analyze/route.ts @@ -7,17 +7,33 @@ const BatchAnalyzeSchema = z.object({ rayon: z.string(), supplierTotalCa: z.number().optional(), supplierTotalMarge: z.number().optional(), + supplierStats: z.object({ + totalProducts: z.number(), + medianScore: z.number(), + scoreDistribution: z.object({ + above70: z.number(), + between30and70: z.number(), + below30: z.number(), + }), + maxStoreCount: z.number(), + nomenclature2Count: z.number(), + }).optional(), products: z.array(z.object({ codein: z.string(), nom: z.string().nullable().optional(), ca: z.number().nullable().optional(), + caWeight: z.number().optional().default(0), + adjustedCaWeight: z.number().optional().default(0), ventes: z.number().nullable().optional(), marge: z.number().nullable().optional(), score: z.number().nullable().optional(), + scorePercentile: z.number().optional().default(0), codeGamme: z.string().nullable().optional(), sales12m: z.record(z.string(), z.number()).nullable().optional(), storeCount: z.number().optional().default(1), nomenclature: z.string().nullable().optional(), + nomenclature2Weight: z.number().optional().default(0), + weightInNomenclature2: z.number().optional().default(0), })), }); @@ -40,51 +56,83 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Format de données invalide." }, { status: 400 }); } - const { rayon, products, supplierTotalCa, supplierTotalMarge } = parsed.data; + const { rayon, products, supplierTotalCa, supplierTotalMarge, supplierStats } = parsed.data; - const supplierMetricsContext = supplierTotalCa && supplierTotalMarge - ? `CONTEXTE FOURNISSEUR TOTAL : -- CA Total Fournisseur : ${supplierTotalCa.toLocaleString('fr-FR')} € -- Marge Totale Fournisseur : ${supplierTotalMarge.toLocaleString('fr-FR')} € + // Build rich supplier context with distribution awareness + const supplierMetricsContext = (() => { + const parts: string[] = []; -Toute analyse de poids doit se faire par rapport à ce TOTAL FOURNISSEUR global, et non uniquement par rapport au lot envoyé.` - : "CONTEXTE : Analyse par rapport au lot fourni."; + if (supplierTotalCa && supplierTotalMarge) { + parts.push(`CONTEXTE FOURNISSEUR GLOBAL : +- CA Total Fournisseur : ${supplierTotalCa.toLocaleString('fr-FR')} EUR +- Marge Totale Fournisseur : ${supplierTotalMarge.toLocaleString('fr-FR')} EUR`); + } - // Context-Aware System Prompt: Strategic Purchase Expert - const systemPrompt = `Tu es un expert en stratégie d'achat retail et category manager. Ton rôle est de catégoriser les produits d'un fournisseur (A=Permanent, C=Saisonnier, Z=Sortie). + if (supplierStats) { + parts.push(`DISTRIBUTION DES SCORES (sur ${supplierStats.totalProducts} produits du fournisseur) : +- Score median : ${supplierStats.medianScore}/100 +- Produits score > 70 : ${supplierStats.scoreDistribution.above70} (${Math.round(supplierStats.scoreDistribution.above70 / supplierStats.totalProducts * 100)}%) +- Produits score 30-70 : ${supplierStats.scoreDistribution.between30and70} (${Math.round(supplierStats.scoreDistribution.between30and70 / supplierStats.totalProducts * 100)}%) +- Produits score < 30 : ${supplierStats.scoreDistribution.below30} (${Math.round(supplierStats.scoreDistribution.below30 / supplierStats.totalProducts * 100)}%) +- Nomenclatures N2 distinctes : ${supplierStats.nomenclature2Count} +- Magasins reseau : ${supplierStats.maxStoreCount} + +ATTENTION CRITIQUE : Le Score est RELATIF au meilleur produit du fournisseur (le top performer a ~100). Un score median de ${supplierStats.medianScore} signifie que la MOITIE des produits ont un score inferieur a cette valeur. Utilise le \`scorePercentile\` (rang centile) pour comparer les produits entre eux, PAS le score brut.`); + } + + return parts.join('\n\n'); + })(); + + const systemPrompt = `Tu es un expert en strategie d'achat retail et category manager. Ton role est de categoriser les produits d'un fournisseur (A=Permanent, C=Saisonnier, Z=Sortie). ${supplierMetricsContext} -MÉTHODOLOGIE D'ANALYSE (APPROCHE PERFORMANCE-FIRST) : +REGLE FONDAMENTALE : L'IMPORTANCE ECONOMIQUE PRIME SUR LE SCORE DE PERFORMANCE RELATIVE. -1. ANALYSE DU POTENTIEL (RÈGLE DU MULTI-MAGASIN) : - - Note le \`storeCount\` de chaque produit. - - La majorité des produits sont présents dans 2 magasins. Si un produit a \`storeCount = 1\`, ses données (CA, Ventes, Marge) sont mécaniquement plus faibles car il ne travaille que sur une partie du réseau. - - **RÈGLE CRITIQUE** : Pour un produit mono-magasin (\`storeCount = 1\`), multiplie mentalement ses indicateurs par **1.7x** pour évaluer son potentiel réel s'il était déployé partout. Compare-le aux autres produits APRES cette extrapolation. +COMPRENDRE LES DONNEES : +- \`score\` (0-100) : Performance RELATIVE au meilleur produit du fournisseur. Le top produit a ~100. Ce n'est PAS un score absolu de qualite. Un score de 25 peut etre parfaitement normal si le median est a 20. +- \`scorePercentile\` (0-100) : Rang centile du produit parmi TOUS les produits du fournisseur. 50 = median, 80 = top 20%. C'EST L'INDICATEUR A UTILISER pour comparer les produits. +- \`caWeight\` : Poids brut du CA du produit par rapport au total fournisseur (%). +- \`adjustedCaWeight\` : Poids CA extrapole au reseau complet (si un produit n'est present que dans 1 magasin sur 2, son poids est double). UTILISE CETTE VALEUR pour toute decision de poids global. +- \`weightInNomenclature2\` : Poids du produit dans sa propre nomenclature de niveau 2 (%). C'EST LE CRITERE LE PLUS IMPORTANT. Un produit qui pese 8% de sa nomenclature N2 est un pilier de cette categorie. +- \`nomenclature2Weight\` : Poids de la nomenclature N2 entiere dans le CA fournisseur (%). Donne le contexte strategique de la categorie. +- \`sales12m\` : Historique mensuel des ventes (cle YYYYMM). Regarde la REGULARITE, pas seulement le volume. -2. CRITÈRE PRINCIPAL - LA SANTÉ (SCORE) : - - Le Score (0-100) est ton indicateur de performance combiné (rotation, rentabilité relative, tendance). - - Score > 70 : Produit performant, Gamme A par défaut. - - Score < 30 : Produit en difficulté, cible prioritaire pour la Gamme Z (sauf si mono-magasin avec fort potentiel extrapolé). +METHODOLOGIE D'ANALYSE (HIERARCHIE STRICTE) : -3. LE POIDS COMME BOUCLIER (PROTECTION DES PILIERS) : - - Un produit avec un MAUVAIS SCORE (< 40) ne reste en Gamme A QUE s'il est un pilier indispensable : - - Son poids CA/Marge (extrapolé si mono-magasin) est substantiel par rapport au business du fournisseur. - - OU il génère un flux de volume massif indispensable. +1. CONTRIBUTION ECONOMIQUE PAR CATEGORIE (CRITERE PRINCIPAL) : + - Regarde d'abord \`weightInNomenclature2\` : c'est le poids du produit dans sa categorie N2. Un produit qui pese > 5% de sa nomenclature N2 est un contributeur important de cette categorie → forte protection. + - Puis regarde \`adjustedCaWeight\` : le poids global. Un produit dans le top 20% des contributeurs du fournisseur → Gamme A quasi certaine. + - \`nomenclature2Weight\` donne le contexte : si la nomenclature N2 pese > 15% du fournisseur, c'est une categorie strategique et ses contributeurs meritent une protection renforcee. -4. ARBITRAGE POUR LA SORTIE (GAMME Z) : - - Propose la Gamme Z si le produit cumule : - - Score faible (< 40). - - ET Contribution négligeable au business global (même après extrapolation 1.7x si mono-magasin). - - ET Ventes sporadiques. +2. REGULARITE DES VENTES (CRITERE SECONDAIRE) : + - Compte les mois avec ventes > 0 dans sales12m. + - 10-12 mois actifs = rotation reguliere = signal fort de Gamme A (produit de fond de rayon indispensable, effet de halo sur la categorie). + - 4-9 mois actifs = rotation moyenne, a croiser avec la contribution. + - 1-3 mois actifs = rotation faible, potentiel produit saisonnier (C) ou candidat sortie (Z). + - Un produit avec ventes regulieres sur 10+ mois est un produit de complement indispensable au rayon, meme si son poids individuel est faible. -DÉFINITION DES GAMMES : -- A (Permanent) : Produits sains (Score élevé) OU Piliers business indispensables (gros volume/CA malgré score moyen). -- C (Saisonnier) : Profil de ventes avec saisonnalité marquée. -- Z (Sortie) : Produits non performants et non stratégiques. +3. PERFORMANCE RELATIVE (CRITERE TERTIAIRE) : + - Utilise \`scorePercentile\` pour situer le produit. PAS le score brut. + - scorePercentile > 60 : Produit au-dessus de la moyenne → favorable a Gamme A. + - scorePercentile 30-60 : Produit moyen → decider selon contribution et regularite. + - scorePercentile < 20 : Produit dans le bas du classement → candidat Z, SAUF si contribution significative dans sa nomenclature ou rotation reguliere. -IMPORTANT : RÉPONDS UNIQUEMENT EN JSON VALIDE. -Ta justification doit expliquer POURQUOI le produit est maintenu ou sorti. Mentionne explicitement si le produit est pénalisé par sa présence dans un seul magasin (ex: "Mono-magasin : potentiel extrapolé de X€, pilier stratégique"). +4. REGLE D'OR DE COHERENCE : + - Un produit avec un meilleur scorePercentile ET un meilleur adjustedCaWeight qu'un autre DOIT avoir une recommandation egale ou superieure. + - Ne JAMAIS mettre en Z un produit qui est meilleur qu'un autre reste en A. + +5. PROTECTION DES PRODUITS DE COMPLEMENT (EFFET HALO) : + - Un produit avec une rotation reguliere (8+ mois actifs) et un weightInNomenclature2 > 2% est un produit de complement indispensable au rayon. Il doit rester en A. + - Avant de proposer Z, verifie que le produit n'est pas un complement naturel d'un produit A majeur du meme rayon/nomenclature. + +DEFINITION DES GAMMES : +- A (Permanent) : Produits contribuant significativement a leur categorie N2 (weightInNomenclature2 eleve), OU rotation reguliere (fond de rayon), OU forte performance relative. +- C (Saisonnier) : Profil de ventes avec saisonnalite marquee (pics sur 2-4 mois, quasi-absence le reste). +- Z (Sortie) : UNIQUEMENT si le produit cumule TOUS ces criteres negatifs : weightInNomenclature2 tres faible (marginal dans sa propre categorie) + adjustedCaWeight negligeable + rotation irreguliere (< 5 mois actifs) + scorePercentile dans le dernier quart (< 25). + +IMPORTANT : REPONDS UNIQUEMENT EN JSON VALIDE. +Ta justification doit etre factuelle. Mentionne le scorePercentile, le weightInNomenclature2, et le nombre de mois actifs. Format: { @@ -93,23 +141,26 @@ Format: "codein": "ID", "recommandationGamme": "A|C|Z", "isDuplicate": boolean, - "justificationCourte": "Justification incluant le poids relatif (ex: 'Top contributeur CA du lot, à protéger')." + "justificationCourte": "Poids N2: X%, Poids global: Y%, Percentile: Z, Mois actifs: N -> [Raison]" } ] } -Données : codein, nom, ca (€), ventes (unités), marge (%), score (0-100), codeGamme (actuel), sales12m (historique), nomenclature.`; +Donnees fournies par produit : codein, nom, ca (EUR), caWeight (% global), adjustedCaWeight (% extrapole), weightInNomenclature2 (% dans sa N2), nomenclature2Weight (% de la N2 dans le fournisseur), ventes (unites), marge (%), score (0-100, RELATIF), scorePercentile (0-100, rang centile), codeGamme (actuel), sales12m (historique), storeCount, nomenclature.`; - const userPrompt = `Analyse cette liste de produits: \n${JSON.stringify(products, null, 2)} `; + const userPrompt = `Ce lot contient ${products.length} produits du rayon "${rayon}" (sur ${supplierStats?.totalProducts ?? products.length} produits au total pour ce fournisseur). + +Analyse ces produits : +${JSON.stringify(products, null, 2)}`; const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { - "Authorization": `Bearer ${apiKey} `, + "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ - model, // uses the model configured in Settings (same as individual analysis) + model, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt } @@ -120,10 +171,9 @@ Données : codein, nom, ca (€), ventes (unités), marge (%), score (0-100), co }); if (response.status === 429) { - // Read the retry-after header and pass it back to the client const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests"); const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60; - console.warn(`[batch - analyze] Rate limited.Retry after ${waitSeconds} s.`); + console.warn(`[batch-analyze] Rate limited. Retry after ${waitSeconds}s.`); return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 }); } @@ -144,11 +194,10 @@ Données : codein, nom, ca (€), ventes (unités), marge (%), score (0-100), co // Try to extract JSON block from the response (handles markdown code blocks and raw JSON) let jsonStr = content; - const jsonBlock = content.match(/```json\s * ([\s\S] *?) \s * ```/); + const jsonBlock = content.match(/```json\s*([\s\S]*?)\s*```/); if (jsonBlock) { jsonStr = jsonBlock[1]; } else { - // Look for first { ... } block const start = content.indexOf("{"); const end = content.lastIndexOf("}"); if (start !== -1 && end !== -1) { diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index af41cd0..e8711b1 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -19,6 +19,50 @@ export function BulkAiAnalyzer() { console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows", { supplierTotalCa, supplierTotalMarge }); + // --- Statistiques globales pour contextualiser le score --- + const allScores = rows.map(r => r.score || 0).sort((a, b) => a - b); + const totalProducts = rows.length; + + const medianScore = totalProducts > 0 + ? (totalProducts % 2 === 0 + ? (allScores[totalProducts / 2 - 1] + allScores[totalProducts / 2]) / 2 + : allScores[Math.floor(totalProducts / 2)]) + : 0; + + const scoreDistribution = { + above70: allScores.filter(s => s > 70).length, + between30and70: allScores.filter(s => s >= 30 && s <= 70).length, + below30: allScores.filter(s => s < 30).length, + }; + + const maxStoreCount = Math.max(...rows.map(r => r.workingStores?.length || 1), 1); + + // Percentile par produit (rang centile 0-100) + const scorePercentileMap = new Map(); + rows.forEach(r => { + const productScore = r.score || 0; + const rank = allScores.filter(s => s <= productScore).length; + scorePercentileMap.set(r.codein, Math.round((rank / totalProducts) * 100)); + }); + + // --- Double poids par nomenclature niveau 2 --- + const nomenclature2CaMap = new Map(); + rows.forEach(r => { + const n2 = r.libelleNiveau2 || "Non classe"; + nomenclature2CaMap.set(n2, (nomenclature2CaMap.get(n2) || 0) + (r.totalCa || 0)); + }); + + const nomenclature2WeightMap = new Map(); + nomenclature2CaMap.forEach((ca, n2) => { + nomenclature2WeightMap.set(n2, supplierTotalCa > 0 + ? parseFloat(((ca / supplierTotalCa) * 100).toFixed(1)) + : 0); + }); + + const nomenclature2Count = nomenclature2CaMap.size; + + console.log("[BulkAiAnalyzer] Stats:", { totalProducts, medianScore, scoreDistribution, nomenclature2Count, maxStoreCount }); + setIsAnalyzing(true); let completed = 0; @@ -50,18 +94,35 @@ export function BulkAiAnalyzer() { const chunk = remainingChunks.shift(); if (!chunk) return; - const payloadProducts = chunk.items.map(r => ({ - codein: r.codein, - nom: r.libelle1, - ca: r.totalCa || 0, - ventes: r.totalQuantite || 0, - marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0, - score: r.score || 0, - codeGamme: r.codeGamme || "N/A", - sales12m: r.sales12m || {}, - storeCount: r.workingStores?.length || 1, - nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}` - })); + const payloadProducts = chunk.items.map(r => { + const rawCaWeight = supplierTotalCa > 0 + ? parseFloat(((r.totalCa / supplierTotalCa) * 100).toFixed(3)) + : 0; + const storeCount = r.workingStores?.length || 1; + const storeRatio = storeCount < maxStoreCount ? maxStoreCount / storeCount : 1; + const adjustedCaWeight = parseFloat((rawCaWeight * storeRatio).toFixed(3)); + const n2 = r.libelleNiveau2 || "Non classe"; + const n2Ca = nomenclature2CaMap.get(n2) || 1; + const weightInNomenclature2 = parseFloat((((r.totalCa || 0) / n2Ca) * 100).toFixed(2)); + + return { + codein: r.codein, + nom: r.libelle1, + ca: r.totalCa || 0, + caWeight: rawCaWeight, + adjustedCaWeight, + ventes: r.totalQuantite || 0, + marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0, + score: r.score || 0, + scorePercentile: scorePercentileMap.get(r.codein) ?? 0, + codeGamme: r.codeGamme || "N/A", + sales12m: r.sales12m || {}, + storeCount, + nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`, + nomenclature2Weight: nomenclature2WeightMap.get(n2) ?? 0, + weightInNomenclature2, + }; + }); try { let res: Response | null = null; @@ -75,7 +136,14 @@ export function BulkAiAnalyzer() { rayon: chunk.rayon, products: payloadProducts, supplierTotalCa, - supplierTotalMarge + supplierTotalMarge, + supplierStats: { + totalProducts, + medianScore: Math.round(medianScore * 10) / 10, + scoreDistribution, + maxStoreCount, + nomenclature2Count, + } }) });