feat: Add bulk AI product analysis feature with a new UI component and API route.

This commit is contained in:
Michael committed 2026-02-22 11:00:44 +01:00
1 parent 4b3672e65a
commit 7f9c07b58e
2 files changed
+24 -140

No files matched your search

+4 -75
View File
@@ -11,20 +11,11 @@ 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_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. 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.
L'IMPORTANCE ÉCONOMIQUE PRIME SUR LE SCORE DE PERFORMANCE RELATIVE. L'IMPORTANCE ÉCONOMIQUE PRIME SUR LE SCORE DE PERFORMANCE RELATIVE.
@@ -41,27 +32,6 @@ 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. Decide si ce produit doit rester en gamme (A) ou sortir (Z).
REPONDS UNIQUEMENT A ou Z. Jamais C. La saisonnalite est geree manuellement par l'acheteur.
DONNEES CLES :
- weightInNomenclature2 : % du CA du produit dans sa categorie. LE CRITERE PRINCIPAL.
- adjustedCaWeight : % du CA produit dans le total fournisseur (extrapole reseau).
- scorePercentile : rang du produit parmi le fournisseur (50 = median).
- moisActifs : mois avec ventes > 0 sur 12 mois.
REGLES (dans l'ordre, la premiere qui s'applique gagne) :
1. weightInNomenclature2 >= 5% → A
2. moisActifs >= 8 → A
3. scorePercentile >= 50 ET moisActifs >= 4 → A
4. En cas de doute → A
5. Z seulement si TOUT est faible : weightInNomenclature2 < 3% ET moisActifs < 5 ET scorePercentile < 30
Ne tiens PAS compte du nom du produit pour ta decision. Base-toi UNIQUEMENT sur les chiffres.
Reponds UNIQUEMENT en JSON : {"recommandationGamme":"A|Z","justificationCourte":"..."}`;
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
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;
@@ -79,42 +49,19 @@ export async function POST(req: NextRequest) {
} }
const p = 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(p.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(", ");
let userMessage: string; const userMessage = `Produit: "${p.libelle1}" (Code: ${p.codein})
let systemPrompt: string;
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"} Gamme actuelle: ${p.codeGamme ?? "Non définie"}
Score de performance: ${p.score ?? "N/A"}/100 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 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, {
@@ -128,10 +75,9 @@ Quelle gamme recommandes-tu et pourquoi ?`;
body: JSON.stringify({ body: JSON.stringify({
model, model,
messages: [ messages: [
{ role: "system", content: systemPrompt }, { role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage }, { role: "user", content: userMessage },
], ],
...(isBatch ? { response_format: { type: "json_object" } } : {}),
max_tokens: 150, max_tokens: 150,
temperature: 0.1, temperature: 0.1,
}), }),
@@ -151,24 +97,7 @@ Quelle gamme recommandes-tu et pourquoi ?`;
const data = await response.json(); const data = await response.json();
const content: string = data.choices?.[0]?.message?.content ?? ""; const content: string = data.choices?.[0]?.message?.content ?? "";
if (isBatch) { return NextResponse.json({ insight: content, codein: p.codein });
// 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 });
@@ -13,70 +13,20 @@ 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);
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows"); console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows");
// --- Statistiques globales pour contextualiser le score --- // Payload identique à l'analyse individuelle — aucun enrichissement
const allScores = rows.map(r => r.score || 0).sort((a, b) => a - b); const productPayloads = rows.map(r => ({
const totalProducts = rows.length; codein: r.codein,
libelle1: r.libelle1 || "",
const maxStoreCount = Math.max(...rows.map(r => r.workingStores?.length || 1), 1); totalCa: r.totalCa || 0,
tauxMarge: r.tauxMarge || 0,
// Percentile par produit totalQuantite: r.totalQuantite || 0,
const scorePercentileMap = new Map<string, number>(); sales12m: r.sales12m || {},
rows.forEach(r => { codeGamme: r.codeGamme || null,
const productScore = r.score || 0; score: 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<string, number>();
rows.forEach(r => {
const n2 = r.libelleNiveau2 || "Non classe";
nomenclature2CaMap.set(n2, (nomenclature2CaMap.get(n2) || 0) + (r.totalCa || 0));
});
const nomenclature2WeightMap = new Map<string, number>();
nomenclature2CaMap.forEach((ca, n2) => {
nomenclature2WeightMap.set(n2, supplierTotalCa > 0
? parseFloat(((ca / supplierTotalCa) * 100).toFixed(1))
: 0);
});
// 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;
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;
@@ -119,10 +69,15 @@ export function BulkAiAnalyzer() {
errors++; errors++;
} else { } else {
const data = await res.json(); const data = await res.json();
if (data.recommandationGamme) { if (data.insight) {
setDraftGamme(data.codein, data.recommandationGamme); // Extraire la recommandation (première lettre A, C ou Z)
const justification = `Gamme ${data.recommandationGamme} — ${data.justificationCourte || "Analyse effectuée."}`; const match = data.insight.match(/^[^A-Z]*([ACZ])\b/i);
setInsight(data.codein, justification, data.isDuplicate ?? false); const gamme = match ? match[1].toUpperCase() : null;
if (gamme) {
setDraftGamme(data.codein, gamme);
setInsight(data.codein, data.insight, false);
}
} }
} }
} catch { } catch {