feat: Add batch AI product categorization API and a bulk analyzer UI component.

This commit is contained in:
Michael committed 2026-02-22 01:07:17 +01:00
1 parent 259fb60638
commit d644043aa5
3 files changed
+179 -55

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)"
]
}
}
+91 -42
View File
@@ -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) {
@@ -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<string, number>();
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<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);
});
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,
}
})
});