feat: Implement bulk AI product categorization through a new UI component and API route.

This commit is contained in:
Michael committed 2026-02-22 09:17:52 +01:00
1 parent 9c79b631cb
commit ce0085a5c7
2 files changed
+161 -130

No files matched your search

+88 -15
View File
@@ -11,10 +11,20 @@ const AnalyzeSchema = z.object({
sales12m: z.record(z.string(), z.number()), sales12m: z.record(z.string(), z.number()),
codeGamme: z.string().nullable(), codeGamme: z.string().nullable(),
score: z.number().nullable().optional(), score: z.number().nullable().optional(),
// Champs enrichis (optionnels, envoyés par le batch)
weightInNomenclature2: z.number().optional(),
adjustedCaWeight: z.number().optional(),
nomenclature2Weight: z.number().optional(),
scorePercentile: z.number().optional(),
moisActifs: z.number().optional(),
nomenclature: z.string().optional(),
// Mode batch : retourne du JSON structuré au lieu de texte libre
batchMode: z.boolean().optional(),
}); });
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const SYSTEM_PROMPT = `Tu es un expert en stratégie d'achat retail. Ton rôle est de catégoriser ce produit (A=Permanent, C=Saisonnier, Z=Sortie) via une analyse de VALEUR CONTEXTUELLE.
const SYSTEM_PROMPT_INDIVIDUAL = `Tu es un expert en stratégie d'achat retail. Ton rôle est de catégoriser ce produit (A=Permanent, C=Saisonnier, Z=Sortie) via une analyse de VALEUR CONTEXTUELLE.
L'IMPORTANCE ÉCONOMIQUE PRIME SUR LE SCORE DE PERFORMANCE RELATIVE. L'IMPORTANCE ÉCONOMIQUE PRIME SUR LE SCORE DE PERFORMANCE RELATIVE.
@@ -31,39 +41,78 @@ DÉFINITION :
Réponse courte, mentionnant la contribution économique et le score. Ne mentionne aucun seuil en euros. Réponse courte, mentionnant la contribution économique et le score. Ne mentionne aucun seuil en euros.
Format: "[Recommandation]: [Justification basée sur la valeur et le score]"`; Format: "[Recommandation]: [Justification basée sur la valeur et le score]"`;
const SYSTEM_PROMPT_BATCH = `Tu es un expert en assortiment retail. Categorise ce produit : A (garder en permanence), C (saisonnier), Z (sortir de la gamme).
L'IMPORTANCE ECONOMIQUE PRIME SUR LE SCORE.
DONNEES CLES :
- weightInNomenclature2 : % du CA du produit dans sa categorie. LE CRITERE PRINCIPAL. >= 5% = pilier de categorie → A.
- adjustedCaWeight : % du CA produit dans le total fournisseur (extrapole reseau).
- scorePercentile : rang du produit parmi le fournisseur (50 = median). Utilise CA POUR COMPARER, pas le score brut.
- moisActifs : mois avec ventes > 0 sur 12 mois. >= 8 = rotation reguliere → A. <= 3 = sporadique.
REGLES :
1. weightInNomenclature2 >= 5% → A (pilier de categorie)
2. moisActifs >= 8 → A (fond de rayon, rotation reguliere)
3. scorePercentile >= 50 ET moisActifs >= 4 → A (performeur regulier)
4. moisActifs 2-4 avec pics concentres → C (saisonnier)
5. Z seulement si TOUT est faible : weightInNomenclature2 bas + moisActifs < 5 + scorePercentile < 30
Reponds UNIQUEMENT en JSON : {"recommandationGamme":"A|C|Z","justificationCourte":"..."}`;
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
console.log("[AI] Analysis request received.");
const config = await getSavedDatabaseConfig(); const config = await getSavedDatabaseConfig();
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
const model = config?.openRouterModel || "google/gemini-flash-1.5"; const model = config?.openRouterModel || "google/gemini-flash-1.5";
if (!apiKey) { if (!apiKey) {
console.error("[AI] OPENROUTER_API_KEY is missing (neither in env nor in config file)."); console.error("[AI] OPENROUTER_API_KEY is missing.");
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured. Please set it in Settings." }, { status: 503 }); return NextResponse.json({ error: "OPENROUTER_API_KEY not configured. Please set it in Settings." }, { status: 503 });
} }
console.log(`[AI] Using model: ${model} with API key starting with ${apiKey.substring(0, 10)}...`);
const body = await req.json(); const body = await req.json();
const parsed = AnalyzeSchema.safeParse(body); const parsed = AnalyzeSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 }); return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 });
} }
const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme, score } = parsed.data; const p = parsed.data;
const isBatch = p.batchMode === true;
// Format monthly sales for the prompt // Format monthly sales for the prompt
const monthlySummary = Object.entries(sales12m) const monthlySummary = Object.entries(p.sales12m)
.map(([k, v]) => `${k}: ${Math.round(v)} unités`) .map(([k, v]) => `${k}: ${Math.round(v)} unités`)
.join(", "); .join(", ");
const userMessage = `Produit: "${libelle1}" (Code: ${codein}) let userMessage: string;
Gamme actuelle: ${codeGamme ?? "Non définie"} let systemPrompt: string;
Score de performance: ${score ?? "N/A"}/100
CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités if (isBatch) {
// Mode batch : prompt simplifié avec métriques enrichies, PAS de codeGamme
systemPrompt = SYSTEM_PROMPT_BATCH;
const enrichedLines = [
`Produit: "${p.libelle1}" (${p.codein})`,
p.nomenclature ? `Nomenclature: ${p.nomenclature}` : null,
`CA: ${p.totalCa.toFixed(0)} EUR | Marge: ${p.tauxMarge.toFixed(1)}% | Volume: ${Math.round(p.totalQuantite)} unites`,
p.weightInNomenclature2 != null ? `Poids dans sa categorie N2: ${p.weightInNomenclature2}%` : null,
p.adjustedCaWeight != null ? `Poids global fournisseur (ajuste): ${p.adjustedCaWeight}%` : null,
p.nomenclature2Weight != null ? `Poids de la categorie N2 dans le fournisseur: ${p.nomenclature2Weight}%` : null,
p.scorePercentile != null ? `Rang percentile: ${p.scorePercentile}/100` : null,
p.moisActifs != null ? `Mois actifs (ventes > 0): ${p.moisActifs}/12` : null,
`Historique: ${monthlySummary}`,
].filter(Boolean).join("\n");
userMessage = enrichedLines;
} else {
// Mode individuel : prompt original
systemPrompt = SYSTEM_PROMPT_INDIVIDUAL;
userMessage = `Produit: "${p.libelle1}" (Code: ${p.codein})
Gamme actuelle: ${p.codeGamme ?? "Non définie"}
Score de performance: ${p.score ?? "N/A"}/100
CA total 12m: ${p.totalCa.toFixed(0)}€ | Taux de marge: ${p.tauxMarge.toFixed(1)}% | Volume: ${Math.round(p.totalQuantite)} unités
Historique mensuel: ${monthlySummary} Historique mensuel: ${monthlySummary}
Quelle gamme recommandes-tu et pourquoi ?`; Quelle gamme recommandes-tu et pourquoi ?`;
}
try { try {
const response = await fetch(OPENROUTER_URL, { const response = await fetch(OPENROUTER_URL, {
@@ -77,23 +126,47 @@ Quelle gamme recommandes-tu et pourquoi ?`;
body: JSON.stringify({ body: JSON.stringify({
model, model,
messages: [ messages: [
{ role: "system", content: SYSTEM_PROMPT }, { role: "system", content: systemPrompt },
{ role: "user", content: userMessage }, { role: "user", content: userMessage },
], ],
...(isBatch ? { response_format: { type: "json_object" } } : {}),
max_tokens: 150, max_tokens: 150,
temperature: 0.3, temperature: 0.1,
}), }),
}); });
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 });
}
if (!response.ok) { if (!response.ok) {
const err = await response.text(); const err = await response.text();
return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status }); return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status });
} }
const data = await response.json(); const data = await response.json();
const insight = data.choices?.[0]?.message?.content ?? "Analyse indisponible."; const content: string = data.choices?.[0]?.message?.content ?? "";
return NextResponse.json({ insight, codein }); if (isBatch) {
// Parse JSON response for batch mode
let jsonStr = content;
const start = content.indexOf("{");
const end = content.lastIndexOf("}");
if (start !== -1 && end !== -1) {
jsonStr = content.slice(start, end + 1);
}
const result = JSON.parse(jsonStr);
return NextResponse.json({
codein: p.codein,
recommandationGamme: result.recommandationGamme || "A",
justificationCourte: result.justificationCourte || "",
isDuplicate: false,
});
} else {
return NextResponse.json({ insight: content, codein: p.codein });
}
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error"; const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 }); return NextResponse.json({ error: msg }, { status: 500 });
+73 -115
View File
@@ -4,7 +4,6 @@ import React, { useState } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store"; import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
import { Sparkles, Loader2, CheckCircle2 } from "lucide-react"; import { Sparkles, Loader2, CheckCircle2 } from "lucide-react";
import { ProductRow } from "@/types/grid";
export function BulkAiAnalyzer() { export function BulkAiAnalyzer() {
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
@@ -15,29 +14,16 @@ export function BulkAiAnalyzer() {
const handleAnalyze = async () => { const handleAnalyze = async () => {
const { rows } = useGridStore.getState(); const { rows } = useGridStore.getState();
const supplierTotalCa = rows.reduce((acc, row) => acc + (row.totalCa || 0), 0); const supplierTotalCa = rows.reduce((acc, row) => acc + (row.totalCa || 0), 0);
const supplierTotalMarge = rows.reduce((acc, row) => acc + (row.totalMarge || 0), 0);
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows", { supplierTotalCa, supplierTotalMarge }); console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows");
// --- Statistiques globales pour contextualiser le score --- // --- Statistiques globales pour contextualiser le score ---
const allScores = rows.map(r => r.score || 0).sort((a, b) => a - b); const allScores = rows.map(r => r.score || 0).sort((a, b) => a - b);
const totalProducts = rows.length; 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); const maxStoreCount = Math.max(...rows.map(r => r.workingStores?.length || 1), 1);
// Percentile par produit (rang centile 0-100) // Percentile par produit
const scorePercentileMap = new Map<string, number>(); const scorePercentileMap = new Map<string, number>();
rows.forEach(r => { rows.forEach(r => {
const productScore = r.score || 0; const productScore = r.score || 0;
@@ -45,7 +31,7 @@ export function BulkAiAnalyzer() {
scorePercentileMap.set(r.codein, Math.round((rank / totalProducts) * 100)); scorePercentileMap.set(r.codein, Math.round((rank / totalProducts) * 100));
}); });
// --- Double poids par nomenclature niveau 2 --- // Double poids par nomenclature niveau 2
const nomenclature2CaMap = new Map<string, number>(); const nomenclature2CaMap = new Map<string, number>();
rows.forEach(r => { rows.forEach(r => {
const n2 = r.libelleNiveau2 || "Non classe"; const n2 = r.libelleNiveau2 || "Non classe";
@@ -59,99 +45,69 @@ export function BulkAiAnalyzer() {
: 0); : 0);
}); });
const nomenclature2Count = nomenclature2CaMap.size; // Préparer les payloads enrichis pour chaque produit
const productPayloads = rows.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));
const moisActifs = Object.values(r.sales12m || {}).filter(v => v > 0).length;
console.log("[BulkAiAnalyzer] Stats:", { totalProducts, medianScore, scoreDistribution, nomenclature2Count, maxStoreCount }); return {
codein: r.codein,
libelle1: r.libelle1 || "",
totalCa: r.totalCa || 0,
tauxMarge: r.tauxMarge || 0,
totalQuantite: r.totalQuantite || 0,
sales12m: r.sales12m || {},
codeGamme: r.codeGamme || null,
score: r.score || 0,
// Champs enrichis
weightInNomenclature2,
adjustedCaWeight,
nomenclature2Weight: nomenclature2WeightMap.get(n2) ?? 0,
scorePercentile: scorePercentileMap.get(r.codein) ?? 0,
moisActifs,
nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`,
batchMode: true,
};
});
setIsAnalyzing(true); setIsAnalyzing(true);
let completed = 0; let completed = 0;
let errors = 0;
setProgress({ current: 0, total: productPayloads.length, message: "Initialisation...", errors: 0 });
try { try {
// Group by Nomenclature (libelle3 or a fallback) const CONCURRENCY = 5;
const groups: Record<string, ProductRow[]> = {}; const remaining = [...productPayloads];
rows.forEach(r => {
const rayon = r.libelle3 || "Non classifié";
if (!groups[rayon]) groups[rayon] = [];
groups[rayon].push(r);
});
const CHUNK_SIZE = 50;
const chunks: { rayon: string; items: ProductRow[] }[] = [];
for (const [rayon, items] of Object.entries(groups)) {
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
chunks.push({ rayon, items: items.slice(i, i + CHUNK_SIZE) });
}
}
setProgress({ current: 0, total: chunks.length, message: "Initialisation...", errors: 0 });
// Process with a continuous concurrency limit
const CONCURRENCY = 3;
const remainingChunks = [...chunks];
const processNext = async () => { const processNext = async () => {
const chunk = remainingChunks.shift(); const payload = remaining.shift();
if (!chunk) return; if (!payload) return;
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));
// Compter les mois actifs (ventes > 0) pré-calculé
const moisActifs = Object.values(r.sales12m || {}).filter(v => v > 0).length;
return {
codein: r.codein,
nom: r.libelle1,
ca: r.totalCa || 0,
adjustedCaWeight,
weightInNomenclature2,
nomenclature2Weight: nomenclature2WeightMap.get(n2) ?? 0,
ventes: r.totalQuantite || 0,
marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0,
scorePercentile: scorePercentileMap.get(r.codein) ?? 0,
moisActifs,
storeCount,
nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`,
};
});
try { try {
let res: Response | null = null; let res: Response | null = null;
const MAX_CHUNK_RETRIES = 3; const MAX_RETRIES = 3;
for (let attempt = 1; attempt <= MAX_CHUNK_RETRIES; attempt++) { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
res = await fetch("/api/ai/batch-analyze", { res = await fetch("/api/ai/analyze", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify(payload)
rayon: chunk.rayon,
products: payloadProducts,
supplierTotalCa,
supplierTotalMarge,
supplierStats: {
totalProducts,
medianScore: Math.round(medianScore * 10) / 10,
scoreDistribution,
maxStoreCount,
nomenclature2Count,
}
})
}); });
if (res.status === 429 && attempt < MAX_CHUNK_RETRIES) { if (res.status === 429 && attempt < MAX_RETRIES) {
const errData = await res.json().catch(() => ({})); const errData = await res.json().catch(() => ({}));
const waitSecs = errData.retryAfter ?? (30 * attempt); const waitSecs = errData.retryAfter ?? (15 * attempt);
for (let t = waitSecs; t > 0; t--) { for (let t = waitSecs; t > 0; t--) {
setProgress(prev => ({ ...prev, message: `⏳ Lot ${completed + 1}/${chunks.length} — Limite API, reprise dans ${t}s...` })); setProgress(prev => ({ ...prev, message: `⏳ ${completed}/${productPayloads.length} — Limite API, reprise dans ${t}s...` }));
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
} }
continue; continue;
@@ -160,41 +116,43 @@ export function BulkAiAnalyzer() {
} }
if (!res || !res.ok) { if (!res || !res.ok) {
const errText = await res?.text().catch(() => ""); errors++;
console.error(`[BulkAiAnalyzer] Erreur HTTP sur le lot ${chunk.rayon}:`, errText);
setProgress(prev => ({ ...prev, errors: prev.errors + 1 }));
} else { } else {
const data = await res.json(); const data = await res.json();
if (data && Array.isArray(data.results)) { if (data.recommandationGamme) {
data.results.forEach((reco: any) => { setDraftGamme(data.codein, data.recommandationGamme);
if (reco.codein && reco.recommandationGamme) { const justification = `Gamme ${data.recommandationGamme} — ${data.justificationCourte || "Analyse effectuée."}`;
setDraftGamme(reco.codein, reco.recommandationGamme); setInsight(data.codein, justification, data.isDuplicate ?? false);
const justification = `Gamme ${reco.recommandationGamme} — ${reco.justificationCourte || "Analyse effectuée."}`;
setInsight(reco.codein, justification, reco.isDuplicate ?? false);
}
});
} }
} }
} catch (e) { } catch {
console.error(`[BulkAiAnalyzer] Failed fetch for chunk ${chunk.rayon}:`, e); errors++;
setProgress(prev => ({ ...prev, errors: prev.errors + 1 }));
} finally { } finally {
completed++; completed++;
setProgress(prev => ({ setProgress({
...prev,
current: completed, current: completed,
message: completed === chunks.length ? "Analyse terminée" : `Analyse: ${completed}/${chunks.length} lots` total: productPayloads.length,
})); message: completed === productPayloads.length
// Process next chunk in the same worker ? "Analyse terminée"
: `Analyse: ${completed}/${productPayloads.length} produits`,
errors,
});
await processNext(); await processNext();
} }
}; };
// Start workers const workers = Array.from(
const workers = Array.from({ length: Math.min(CONCURRENCY, chunks.length) }, () => processNext()); { length: Math.min(CONCURRENCY, productPayloads.length) },
() => processNext()
);
await Promise.all(workers); await Promise.all(workers);
setProgress(prev => ({ ...prev, current: completed, total: chunks.length, message: `Analyse terminée ! (${prev.errors > 0 ? prev.errors + " erreurs" : "succès"})` })); setProgress(prev => ({
...prev,
current: completed,
total: productPayloads.length,
message: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`,
}));
setTimeout(() => setIsAnalyzing(false), 3000); setTimeout(() => setIsAnalyzing(false), 3000);
} catch (error) { } catch (error) {
@@ -227,7 +185,7 @@ export function BulkAiAnalyzer() {
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 cursor-default"> <div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 cursor-default">
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-500" /> <CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-500" />
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400"> <span className="text-xs font-bold text-emerald-700 dark:text-emerald-400">
Analyse terminée ({progress.total} lots analysés) Analyse terminée ({progress.total} produits analysés)
</span> </span>
</div> </div>
); );