mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: Add bulk AI product analysis feature with a new UI component and API route.
This commit is contained in:
1 parent
4b3672e65a
commit
7f9c07b58e
2 files changed
+24
-140
No files matched your search
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user