mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 21:18:16 +02:00
feat: Introduce AI Copilot for bulk product analysis, including a dedicated store, UI components, and business logic.
This commit is contained in:
1 parent
03c5ab6d9e
commit
8c02771e46
7 files changed
+308
-121
No files matched your search
@@ -4,38 +4,37 @@ import React, { useState, useRef } from "react";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
|
||||
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { ProductAnalysisInput, AnalysisResult } from "@/features/ai-copilot/models/ai-analysis.types";
|
||||
import { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
|
||||
|
||||
export function BulkAiAnalyzer() {
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
|
||||
const isCancelledRef = useRef(false);
|
||||
|
||||
const setDraftGamme = useGridStore(state => state.setDraftGamme);
|
||||
const setInsight = useAiCopilotStore(state => state.setInsight);
|
||||
const setDraftGamme = useGridStore((s) => s.setDraftGamme);
|
||||
const { setInsight, setLoading, setError } = useAiCopilotStore();
|
||||
|
||||
const handleStop = () => {
|
||||
isCancelledRef.current = true;
|
||||
setProgress(prev => ({ ...prev, message: "Arrêt en cours..." }));
|
||||
setProgress((prev) => ({ ...prev, message: "Arrêt en cours..." }));
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
const { rows } = useGridStore.getState();
|
||||
isCancelledRef.current = false;
|
||||
|
||||
const totalQtySum = rows.reduce((sum: number, r: any) => sum + (r.totalQuantite || 0), 0);
|
||||
const totalQtySum = rows.reduce((sum: number, r: ProductRow) => sum + (r.totalQuantite || 0), 0);
|
||||
const avgQty = rows.length > 0 ? totalQtySum / rows.length : 0;
|
||||
|
||||
// Benchmarks par groupe de magasins
|
||||
const rowsGroup1 = rows.filter(r => (r.workingStores?.length || 1) === 1);
|
||||
const rowsGroup2 = rows.filter(r => (r.workingStores?.length || 1) >= 2);
|
||||
const rowsGroup1 = rows.filter((r) => (r.workingStores?.length || 1) === 1);
|
||||
const rowsGroup2 = rows.filter((r) => (r.workingStores?.length || 1) >= 2);
|
||||
|
||||
const avgQty1 = rowsGroup1.length > 0 ? rowsGroup1.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup1.length : 0;
|
||||
const avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup2.length : 0;
|
||||
const avgQty1 = rowsGroup1.length > 0 ? rowsGroup1.reduce((s: number, r: ProductRow) => s + (r.totalQuantite || 0), 0) / rowsGroup1.length : 0;
|
||||
const avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: ProductRow) => s + (r.totalQuantite || 0), 0) / rowsGroup2.length : 0;
|
||||
|
||||
// Benchmarks par Rayon (Nomenclature Niveau 2)
|
||||
const rayonStats = new Map<string, { qty1: number[], qty2: number[] }>();
|
||||
rows.forEach((r: any) => {
|
||||
const rayonStats = new Map<string, { qty1: number[]; qty2: number[] }>();
|
||||
rows.forEach((r: ProductRow) => {
|
||||
const rayon = r.libelleNiveau2 || "Général";
|
||||
if (!rayonStats.has(rayon)) rayonStats.set(rayon, { qty1: [], qty2: [] });
|
||||
const stats = rayonStats.get(rayon)!;
|
||||
@@ -44,23 +43,41 @@ export function BulkAiAnalyzer() {
|
||||
else stats.qty2.push(r.totalQuantite || 0);
|
||||
});
|
||||
|
||||
const rayonBenchmarks = new Map<string, { avg1: number, avg2: number }>();
|
||||
const rayonBenchmarks = new Map<string, { avg1: number; avg2: number }>();
|
||||
rayonStats.forEach((stats, rayon) => {
|
||||
const totalCount = stats.qty1.length + stats.qty2.length;
|
||||
// Seuil de 3 produits pour justifier un benchmark spécifique par rayon
|
||||
if (totalCount >= 3) {
|
||||
rayonBenchmarks.set(rayon, {
|
||||
avg1: stats.qty1.length > 0 ? stats.qty1.reduce((a, b) => a + b, 0) / stats.qty1.length : 0,
|
||||
avg2: stats.qty2.length > 0 ? stats.qty2.reduce((a, b) => a + b, 0) / stats.qty2.length : 0
|
||||
avg2: stats.qty2.length > 0 ? stats.qty2.reduce((a, b) => a + b, 0) / stats.qty2.length : 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const productPayloads: ProductAnalysisInput[] = rows.map(r => {
|
||||
const productPayloads: ProductAnalysisInput[] = rows.map((r) => {
|
||||
const sc = r.workingStores?.length || 1;
|
||||
const weight = sc === 1 ? 2 : 1; // On ramène tout sur une base 2 magasins
|
||||
const weight = sc === 1 ? 2 : 1;
|
||||
const rb = rayonBenchmarks.get(r.libelleNiveau2 || "Général");
|
||||
|
||||
const allMonths = Object.keys(r.sales12m || {});
|
||||
const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map((m) => parseInt(m))).toString() : "";
|
||||
const salesMonths = Object.entries(r.sales12m || {})
|
||||
.filter(([_, qty]) => qty > 0)
|
||||
.map(([m]) => parseInt(m))
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
|
||||
let inactivity = 0;
|
||||
if (referenceMonth && lastMonth) {
|
||||
const refY = parseInt(referenceMonth.substring(0, 4));
|
||||
const refM = parseInt(referenceMonth.substring(4, 6));
|
||||
const lastY = parseInt(lastMonth.substring(0, 4));
|
||||
const lastM = parseInt(lastMonth.substring(4, 6));
|
||||
inactivity = (refY - lastY) * 12 + (refM - lastM);
|
||||
}
|
||||
|
||||
const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length;
|
||||
|
||||
return {
|
||||
codein: r.codein,
|
||||
libelle1: r.libelle1 || "",
|
||||
@@ -76,124 +93,92 @@ export function BulkAiAnalyzer() {
|
||||
avgQtyRayon1: rb ? rb.avg1 : avgQty1,
|
||||
avgQtyRayon2: rb ? rb.avg2 : avgQty2,
|
||||
storeCount: sc,
|
||||
sales12m: Object.fromEntries(
|
||||
Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight])
|
||||
),
|
||||
sales12m: Object.fromEntries(Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight])),
|
||||
codeGamme: r.codeGamme || null,
|
||||
score: r.score || 0,
|
||||
regularityScore: Object.values(r.sales12m || {}).filter(v => v > 0).length,
|
||||
projectedTotalQuantite: (Object.values(r.sales12m || {}).filter(v => v > 0).length > 0)
|
||||
? ((r.totalQuantite || 0) * weight * (12 / Object.values(r.sales12m || {}).filter(v => v > 0).length))
|
||||
: ((r.totalQuantite || 0) * weight),
|
||||
projectedTotalCa: (Object.values(r.sales12m || {}).filter(v => v > 0).length > 0)
|
||||
? ((r.totalCa || 0) * weight * (12 / Object.values(r.sales12m || {}).filter(v => v > 0).length))
|
||||
: ((r.totalCa || 0) * weight),
|
||||
regularityScore: regScore,
|
||||
projectedTotalQuantite: regScore > 0 ? (r.totalQuantite || 0) * weight * (12 / regScore) : (r.totalQuantite || 0) * weight,
|
||||
projectedTotalCa: regScore > 0 ? (r.totalCa || 0) * weight * (12 / regScore) : (r.totalCa || 0) * weight,
|
||||
lastMonthWithSale: lastMonth,
|
||||
inactivityMonths: inactivity,
|
||||
};
|
||||
});
|
||||
|
||||
setIsAnalyzing(true);
|
||||
let completed = 0;
|
||||
let errors = 0;
|
||||
let errorsCount = 0;
|
||||
|
||||
setProgress({ current: 0, total: productPayloads.length, message: "Initialisation...", errors: 0 });
|
||||
const totalItems = productPayloads.length;
|
||||
setProgress({ current: 0, total: totalItems, message: "Initialisation...", errors: 0 });
|
||||
|
||||
try {
|
||||
const CONCURRENCY = 3; // Réduit pour éviter les 429 trop fréquents
|
||||
const remaining = [...productPayloads];
|
||||
const CONCURRENCY = 3;
|
||||
const remaining = [...productPayloads];
|
||||
|
||||
const processNext = async () => {
|
||||
if (isCancelledRef.current) return;
|
||||
const processBatch = async () => {
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, remaining.length) }, async () => {
|
||||
while (remaining.length > 0 && !isCancelledRef.current) {
|
||||
const payload = remaining.shift();
|
||||
if (!payload) break;
|
||||
|
||||
const payload = remaining.shift();
|
||||
if (!payload) return;
|
||||
setLoading(payload.codein);
|
||||
setDraftGamme(payload.codein, "Aucune");
|
||||
|
||||
// Marquer temporairement comme loading dans le store pour le feedback visuel individuel
|
||||
setInsight(payload.codein, "", false);
|
||||
// Vider aussi la recommandation (A, C, Z) pour montrer que c'est en cours de recalcul
|
||||
setDraftGamme(payload.codein, "Aucune");
|
||||
// Note: setInsight dans le store actuel met le status à "done".
|
||||
// Pour bien faire, il faudrait une action setStatusLoading dans le store.
|
||||
// Ici, on va au moins vider l'insight pour montrer que ça bouge.
|
||||
|
||||
try {
|
||||
let res: Response | null = null;
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (isCancelledRef.current) break;
|
||||
|
||||
res = await fetch("/api/ai/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.status === 429 && attempt < MAX_RETRIES) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
const waitSecs = errData.retryAfter ?? (15 * attempt);
|
||||
for (let t = waitSecs; t > 0; t--) {
|
||||
if (isCancelledRef.current) break;
|
||||
setProgress(prev => ({ ...prev, message: `⏳ ${completed}/${productPayloads.length} — Limite API, reprise dans ${t}s...` }));
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
try {
|
||||
let res: Response | null = null;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (isCancelledRef.current) break;
|
||||
try {
|
||||
res = await fetch("/api/ai/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) break;
|
||||
if (res.status === 429) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000 * (i + 1)));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Retry ${i} failed for ${payload.codein}`, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCancelledRef.current) return;
|
||||
|
||||
if (!res || !res.ok) {
|
||||
errors++;
|
||||
} else {
|
||||
const data: AnalysisResult = await res.json();
|
||||
// On met à jour l'insight dans tous les cas pour qu'il s'affiche
|
||||
setInsight(data.codein, data.insight, false);
|
||||
|
||||
// Si une recommandation est trouvée, on l'applique à la gamme
|
||||
if (data.recommandation) {
|
||||
setDraftGamme(data.codein, data.recommandation);
|
||||
if (!res || !res.ok) {
|
||||
setError(payload.codein, "Erreur API");
|
||||
errorsCount++;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setInsight(payload.codein, data.insight, data.isDuplicate);
|
||||
if (data.recommandation) {
|
||||
setDraftGamme(payload.codein, data.recommandation as GammeCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errors++;
|
||||
} finally {
|
||||
if (!isCancelledRef.current) {
|
||||
} catch (err) {
|
||||
console.error(`Error processing ${payload.codein}`, err);
|
||||
setError(payload.codein, "Erreur");
|
||||
errorsCount++;
|
||||
} finally {
|
||||
completed++;
|
||||
setProgress({
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
current: completed,
|
||||
total: productPayloads.length,
|
||||
message: completed === productPayloads.length
|
||||
? "Analyse terminée"
|
||||
: `Analyse: ${completed}/${productPayloads.length} produits`,
|
||||
errors,
|
||||
});
|
||||
await processNext();
|
||||
message: `Analyse: ${completed}/${totalItems}`,
|
||||
errors: errorsCount,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(CONCURRENCY, productPayloads.length) },
|
||||
() => processNext()
|
||||
);
|
||||
await Promise.all(workers);
|
||||
};
|
||||
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
current: completed,
|
||||
total: productPayloads.length,
|
||||
message: isCancelledRef.current
|
||||
? `Analyse interrompue (${completed} traités)`
|
||||
: `Analyse terminée ! (${errors > 0 ? errors + " erreurs" : "succès"})`,
|
||||
}));
|
||||
await processBatch();
|
||||
|
||||
setTimeout(() => setIsAnalyzing(false), 3000);
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
message: isCancelledRef.current ? `Analyse interrompue (${completed}/${totalItems})` : `Analyse terminée ! (${errorsCount > 0 ? errorsCount + " erreurs" : "succès"})`,
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erreur globale lors de l'analyse:", error);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
setTimeout(() => setIsAnalyzing(false), 3000);
|
||||
};
|
||||
|
||||
if (isAnalyzing) {
|
||||
@@ -227,10 +212,7 @@ export function BulkAiAnalyzer() {
|
||||
<div className="flex items-center gap-2 h-10 px-4 rounded-xl bg-[var(--accent-success-bg)] border border-[var(--accent-success)] shadow-sm cursor-default">
|
||||
<CheckCircle2 className="w-4 h-4 text-[var(--accent-success)]" />
|
||||
<span className="text-xs font-bold text-[var(--accent-success)]">
|
||||
{progress.errors > 0
|
||||
? `Terminé (${progress.current - progress.errors}/${progress.total})`
|
||||
: `Analyse Terminée`
|
||||
}
|
||||
{progress.errors > 0 ? `Terminé (${progress.current - progress.errors}/${progress.total})` : `Analyse Terminée`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user