mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +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
+295
-108
No files matched your search
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, TrendingUp, Calendar, Zap, AlertTriangle, ShieldCheck, Scale, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AiAnalysisDocPage() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-10 px-6 space-y-12">
|
||||
{/* Header */}
|
||||
<header className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[var(--accent)] flex items-center justify-center shadow-lg shadow-brand-500/20">
|
||||
<Bot className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-[var(--text-primary)]">
|
||||
Intelligence Artificielle Copilot
|
||||
</h1>
|
||||
<p className="text-[var(--text-secondary)] font-medium">
|
||||
Comprendre les algorithmes et règles métiers de notre assistant d'achat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Grid of Rules */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 1. Équité Territoriale */}
|
||||
<DocCard
|
||||
icon={ShieldCheck}
|
||||
title="Normalisation Magasins (x2)"
|
||||
description="Équilibre les chances entre les produits mono-magasin et multi-magasins."
|
||||
accent="teal"
|
||||
>
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
Pour éviter qu'un produit présent dans un seul magasin ne soit pénalisé par son volume brut, le système
|
||||
<strong> double automatiquement</strong> toutes ses statistiques (Ventes, CA, Marge).
|
||||
Cela simule sa performance s'il était présent sur les deux points de vente de référence.
|
||||
</p>
|
||||
</DocCard>
|
||||
|
||||
{/* 2. Run Rate */}
|
||||
<DocCard
|
||||
icon={TrendingUp}
|
||||
title="Potentiel Annuel (Run Rate)"
|
||||
description="Évalue les lancements sur leur dynamique réelle, pas sur leur cumul."
|
||||
accent="indigo"
|
||||
>
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
Si un produit a moins de 12 mois de présence, l'IA calcule un <strong>Run Rate 12 mois</strong>.
|
||||
<br /><br />
|
||||
<code className="bg-[var(--bg-muted)] px-2 py-0.5 rounded text-indigo-600 dark:text-indigo-400 font-mono text-[11px]">
|
||||
Projection = Volume_Réel * (12 / Mois_Présence)
|
||||
</code>
|
||||
<br /><br />
|
||||
Cela permet de confirmer un produit <strong>Permanent [A]</strong> dès ses premiers mois s'il a une forte vélocité.
|
||||
</p>
|
||||
</DocCard>
|
||||
|
||||
{/* 3. Saisonnalité */}
|
||||
<DocCard
|
||||
icon={Calendar}
|
||||
title="Détection Saisonnalité"
|
||||
description="Distingue les ruptures temporaires des fins de vie ou saisons."
|
||||
accent="amber"
|
||||
>
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
L'IA analyse la <strong>récence</strong> des ventes. Si aucune vente n'est constatée depuis plus de
|
||||
<strong> 2 mois</strong> :
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-[13px]">
|
||||
<li className="flex items-center gap-2">
|
||||
<Zap className="w-3 h-3 text-emerald-500" />
|
||||
<span>{"< 2 mois : Lancement probable (actif)"}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-500" />
|
||||
<span>{"> 2 mois : Alerte Saison [C] ou Sortie [Z]"}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</DocCard>
|
||||
|
||||
{/* 4. Score Global */}
|
||||
<DocCard
|
||||
icon={Scale}
|
||||
title="Score de Performance"
|
||||
description="La source de vérité mathématique (0 à 100)."
|
||||
accent="rose"
|
||||
>
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
Le score affiché dans la grille combine la Quantité, le CA et la Marge.
|
||||
L'IA utilise ce score comme garde-fou :
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-[13px]">
|
||||
<li>• <strong>{"> 70"}</strong> : Candidat naturel au maintien.</li>
|
||||
<li>• <strong>{"< 30"}</strong> : Signal fort de déréférencement.</li>
|
||||
</ul>
|
||||
</DocCard>
|
||||
</div>
|
||||
|
||||
{/* Footer / Tip */}
|
||||
<div className="p-6 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex gap-4">
|
||||
<Info className="w-6 h-6 text-[var(--accent)] shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-bold text-[var(--accent)]">Conseil d'expert</p>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
L'IA est un outil d'aide à la décision. Elle combine ces règles pour vous proposer une recommandation
|
||||
brute, mais votre expertise terrain reste essentielle pour valider les cas particuliers (ruptures fournisseurs prolongées, promotions, etc.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
accent
|
||||
}: {
|
||||
icon: any,
|
||||
title: string,
|
||||
description: string,
|
||||
children: React.ReactNode,
|
||||
accent: "teal" | "indigo" | "amber" | "rose"
|
||||
}) {
|
||||
const colors = {
|
||||
teal: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 shadow-emerald-500/5",
|
||||
indigo: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20 shadow-indigo-500/5",
|
||||
amber: "bg-amber-500/10 text-amber-600 border-amber-500/20 shadow-amber-500/5",
|
||||
rose: "bg-rose-500/10 text-rose-600 border-rose-500/20 shadow-rose-500/5",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group p-6 rounded-2xl bg-white dark:bg-slate-900 border border-[var(--border)] shadow-sm hover:shadow-md transition-all">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className={cn("w-10 h-10 rounded-xl flex items-center justify-center shadow-sm border", colors[accent])}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[15px] font-bold text-[var(--text-primary)]">{title}</h3>
|
||||
<p className="text-[12px] text-[var(--text-muted)] font-medium leading-tight">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[var(--text-secondary)] leading-relaxed">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3, LogOut, User as UserIcon, Loader2 } from "lucide-react";
|
||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3, LogOut, User as UserIcon, Loader2, Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,6 +13,7 @@ const NAV_ITEMS = [
|
||||
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
||||
{ icon: FileDown, label: "Exports", href: "/exports" },
|
||||
{ icon: BarChart3, label: "Score", href: "/score" },
|
||||
{ icon: Bot, label: "Aide IA", href: "/docs/ai-analysis" },
|
||||
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ En analysant les données de ventes fournies, génère une recommandation de gam
|
||||
Critères de pondération et Règles Métier Strictes :
|
||||
1. Normalisation Totale (Base 2 magasins) : TOUTES les statistiques fournies sont PONDÉRÉES par 2 si le produit n'est au catalogue que de 1 magasin.
|
||||
2. Potentiel Annuel (Run Rate) : Si un produit est récent (Régularité < 12 mois), base ton jugement sur sa "Projection 12m" plutôt que sur son volume brut cumulé.
|
||||
3. Segmentation par Rayon (Univers) : Privilégie la comparaison "Intra-Rayon". Un produit doit être performant par rapport aux standards de son propre Rayon (Niveau 2).
|
||||
4. Score de Performance Global (0-100) : Un score > 70 est un indicateur fort pour "Permanent" (A). Un score < 30 est un indicateur fort pour "Sortie" (Z).
|
||||
5. Équilibre : Un produit "A" doit justifier sa place par son flux, sa rentabilité brute OU son score global.
|
||||
3. Détection Saisonnalité/Fin de vie : Si un produit est inactif depuis plus de 2 mois (Inactivité > 2), la projection 12 mois devient INCERTAINE.
|
||||
- Si Inactivité > 2 et Régularité < 6 : Probable SAISONNIER [C] ou SORTIE [Z].
|
||||
- Si Inactivité <= 2 et Régularité < 12 : Probable LANCEMENT (Permanent [A]).
|
||||
4. Segmentation par Rayon (Univers) : Privilégie la comparaison "Intra-Rayon". Un produit doit être performant par rapport aux standards de son propre Rayon (Niveau 2).
|
||||
5. Score de Performance Global (0-100) : Un score > 70 est un indicateur fort pour "Permanent" (A). Un score < 30 est un indicateur fort pour "Sortie" (Z).
|
||||
|
||||
Ta réponse doit être courte, directe et sans complaisance.
|
||||
Format impératif : "[Recommandation] : [Texte brut de l'explication sans aucun préfixe du type 'Justification:' ou 'Pourquoi:']"
|
||||
@@ -22,7 +24,7 @@ Exemple : "A : Volume de vente et score élevés justifiant le maintien en rayon
|
||||
}
|
||||
|
||||
static generateUserMessage(p: ProductAnalysisInput): string {
|
||||
const monthlySummary = Object.entries(p.sales12m)
|
||||
const monthlySummary = Object.entries(p.sales12m || {})
|
||||
.map(([k, v]) => `${k}: ${Math.round(v)}u`)
|
||||
.join(", ");
|
||||
|
||||
@@ -33,6 +35,10 @@ Stats Pondérées (Base 2 mag) : ${Math.round(p.weightedTotalQuantite || 0)}u ($
|
||||
--- ANALYSE DE POTENTIEL (Produit récent : ${p.regularityScore}/12 mois active) ---
|
||||
Projection 12 mois (Run Rate) : ${Math.round(p.projectedTotalQuantite || 0)}u (${(p.projectedTotalCa || 0).toFixed(2)}€)` : "";
|
||||
|
||||
const activityAlert = (p.inactivityMonths || 0) > 2
|
||||
? `\n⚠️ ALERTE : Inactif depuis ${p.inactivityMonths} mois (Dernière vente: ${p.lastMonthWithSale || "Inconnue"})`
|
||||
: "";
|
||||
|
||||
const benchmarks = `
|
||||
Benchmarks Fournisseur (Global) :
|
||||
- Moyenne 1 mag: ${Math.round(p.avgQtyGroup1 || 0)}u | Multi-mag: ${Math.round(p.avgQtyGroup2 || 0)}u
|
||||
@@ -42,7 +48,7 @@ Benchmarks Rayon ("${p.libelleNiveau2}") :
|
||||
return `Produit : "${p.libelle1}" (Ref: ${p.codein})
|
||||
Rayon : ${p.libelleNiveau2 || "Non classé"}
|
||||
Gamme actuelle : ${p.codeGamme ?? "Non définie"}
|
||||
${volumeInfo}${projectionInfo}${benchmarks}
|
||||
${volumeInfo}${projectionInfo}${activityAlert}${benchmarks}
|
||||
Indicateurs de Performance :
|
||||
- Score Global App : ${p.score.toFixed(1)}/100
|
||||
- Régularité des ventes : ${p.regularityScore}/12 mois
|
||||
|
||||
@@ -26,6 +26,25 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
|
||||
const regScore = Object.values(row.sales12m || {}).filter(v => v > 0).length;
|
||||
const weight = (row.workingStores?.length || 1) === 1 ? 2 : 1;
|
||||
|
||||
// Calcul de l'inactivité (Récence)
|
||||
const allMonths = Object.keys(row.sales12m || {});
|
||||
const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : "";
|
||||
|
||||
const salesMonths = Object.entries(row.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);
|
||||
}
|
||||
|
||||
analyzeProduct({
|
||||
codein: row.codein,
|
||||
libelle1: row.libelle1,
|
||||
@@ -38,6 +57,8 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
|
||||
regularityScore: regScore,
|
||||
projectedTotalQuantite: regScore > 0 ? (row.totalQuantite * weight * (12 / regScore)) : (row.totalQuantite * weight),
|
||||
projectedTotalCa: regScore > 0 ? (row.totalCa * weight * (12 / regScore)) : (row.totalCa * weight),
|
||||
lastMonthWithSale: lastMonth,
|
||||
inactivityMonths: inactivity,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface ProductAnalysisInput {
|
||||
/** Projection sur 12 mois si le produit est récent (Run Rate) */
|
||||
projectedTotalQuantite?: number;
|
||||
projectedTotalCa?: number;
|
||||
/** Analyse de saisonnalité */
|
||||
lastMonthWithSale?: string;
|
||||
inactivityMonths?: number;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
|
||||
@@ -13,6 +13,8 @@ interface AiInsight {
|
||||
interface AiCopilotState {
|
||||
insights: Record<string, AiInsight>;
|
||||
setInsight: (codein: string, insight: string, isDuplicate?: boolean) => void;
|
||||
setLoading: (codein: string) => void;
|
||||
setError: (codein: string, error: string) => void;
|
||||
analyzeProduct: (payload: {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
@@ -25,6 +27,8 @@ interface AiCopilotState {
|
||||
regularityScore?: number;
|
||||
projectedTotalQuantite?: number;
|
||||
projectedTotalCa?: number;
|
||||
lastMonthWithSale?: string;
|
||||
inactivityMonths?: number;
|
||||
}) => Promise<void>;
|
||||
resetInsights: () => void;
|
||||
}
|
||||
@@ -43,6 +47,24 @@ export const useAiCopilotStore = create<AiCopilotState>()(
|
||||
}));
|
||||
},
|
||||
|
||||
setLoading: (codein) => {
|
||||
set((s) => ({
|
||||
insights: {
|
||||
...s.insights,
|
||||
[codein]: { codein, insight: "", status: "loading" },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
setError: (codein, error) => {
|
||||
set((s) => ({
|
||||
insights: {
|
||||
...s.insights,
|
||||
[codein]: { codein, insight: error, status: "error" },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
resetInsights: () => {
|
||||
set({ insights: {} });
|
||||
},
|
||||
|
||||
@@ -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 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) return;
|
||||
if (!payload) break;
|
||||
|
||||
// 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
|
||||
setLoading(payload.codein);
|
||||
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++) {
|
||||
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)
|
||||
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));
|
||||
if (res.ok) break;
|
||||
if (res.status === 429) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000 * (i + 1)));
|
||||
}
|
||||
continue;
|
||||
} catch (e) {
|
||||
console.error(`Retry ${i} failed for ${payload.codein}`, e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCancelledRef.current) return;
|
||||
|
||||
if (!res || !res.ok) {
|
||||
errors++;
|
||||
setError(payload.codein, "Erreur API");
|
||||
errorsCount++;
|
||||
} 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
|
||||
const data = await res.json();
|
||||
setInsight(payload.codein, data.insight, data.isDuplicate);
|
||||
if (data.recommandation) {
|
||||
setDraftGamme(data.codein, data.recommandation);
|
||||
setDraftGamme(payload.codein, data.recommandation as GammeCode);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errors++;
|
||||
} catch (err) {
|
||||
console.error(`Error processing ${payload.codein}`, err);
|
||||
setError(payload.codein, "Erreur");
|
||||
errorsCount++;
|
||||
} finally {
|
||||
if (!isCancelledRef.current) {
|
||||
completed++;
|
||||
setProgress({
|
||||
current: completed,
|
||||
total: productPayloads.length,
|
||||
message: completed === productPayloads.length
|
||||
? "Analyse terminée"
|
||||
: `Analyse: ${completed}/${productPayloads.length} produits`,
|
||||
errors,
|
||||
});
|
||||
await processNext();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(CONCURRENCY, productPayloads.length) },
|
||||
() => processNext()
|
||||
);
|
||||
await Promise.all(workers);
|
||||
|
||||
setProgress(prev => ({
|
||||
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"})`,
|
||||
message: `Analyse: ${completed}/${totalItems}`,
|
||||
errors: errorsCount,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
};
|
||||
|
||||
await processBatch();
|
||||
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
message: isCancelledRef.current ? `Analyse interrompue (${completed}/${totalItems})` : `Analyse terminée ! (${errorsCount > 0 ? errorsCount + " erreurs" : "succès"})`,
|
||||
}));
|
||||
|
||||
setTimeout(() => setIsAnalyzing(false), 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erreur globale lors de l'analyse:", error);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
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