feat: introduce AI-powered bulk product analysis feature with dedicated engine, types, and UI component.

This commit is contained in:
Michael committed 2026-02-22 12:32:09 +01:00
1 parent 335cf15ffa
commit 8b9803fffc
3 files changed
+44 -13

No files matched your search

@@ -10,11 +10,11 @@ En analysant les données de ventes fournies, génère une recommandation de gam
- [Z] - SORTIE : Produit en fin de cycle de vie, sans rotation significative ou en chute libre, devant être retiré du référencement. - [Z] - SORTIE : Produit en fin de cycle de vie, sans rotation significative ou en chute libre, devant être retiré du référencement.
Critères de pondération et Règles Métier Strictes : Critères de pondération et Règles Métier Strictes :
1. Normalisation Totale (Base 2 magasins) : TOUTES les statistiques fournies (Totaux et Historique Mensuel) sont PONDÉRÉES par 2 si le produit n'est présent que dans 1 magasin. L'objectif est de simuler une performance sur une base réseau de 2 magasins. 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. Seuil "Permanent" (A) : Pour être A, un produit doit avoir un volume PONDÉRÉ significatif (ex: > 30-50 unités/an). 2. 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).
3. Seuil "Sortie" (Z) : Un produit avec moins de 10 unités pondérées par an et un CA faible est un candidat naturel à la sortie (Z). 3. Seuil "Permanent" (A) : Pour être A, un produit doit avoir un volume PONDÉRÉ significatif (ex: > 30-50 unités/an).
4. Volume/CA/Marge : Un produit "A" doit justifier sa place en rayon par son flux (volume) OU sa rentabilité brute (CA/Marge). S'il est faible sur les deux, c'est un Z. 4. Seuil "Sortie" (Z) : Un produit avec moins de 10 unités pondérées par an et un CA faible est un candidat à la sortie (Z).
5. Poids Relatif : Analyse la performance par rapport aux moyennes fournies. 5. Équilibre : Un produit "A" doit justifier sa place par son flux OU sa rentabilité brute.
Ta réponse doit être courte, directe et sans complaisance. Ta réponse doit être courte, directe et sans complaisance.
Format impératif : "[Recommandation]: [Justification courte]"`; Format impératif : "[Recommandation]: [Justification courte]"`;
@@ -28,18 +28,19 @@ Format impératif : "[Recommandation]: [Justification courte]"`;
const volumeInfo = `Stats Réelles : ${Math.round(p.totalQuantite)}u (${p.totalCa.toFixed(2)}€) sur ${p.storeCount} mag. const volumeInfo = `Stats Réelles : ${Math.round(p.totalQuantite)}u (${p.totalCa.toFixed(2)}€) sur ${p.storeCount} mag.
Stats Pondérées (Base 2 mag) : ${Math.round(p.weightedTotalQuantite || 0)}u (${(p.weightedTotalCa || 0).toFixed(2)}€)`; Stats Pondérées (Base 2 mag) : ${Math.round(p.weightedTotalQuantite || 0)}u (${(p.weightedTotalCa || 0).toFixed(2)}€)`;
const benchmarks = p.avgQtyGroup1 !== undefined && p.avgQtyGroup2 !== undefined const benchmarks = `
? `\nBenchmarks (Volumes moyens) : Benchmarks Fournisseur (Global) :
- Moyenne mag unique : ${Math.round(p.avgQtyGroup1)}u - Moyenne 1 mag: ${Math.round(p.avgQtyGroup1 || 0)}u | Multi-mag: ${Math.round(p.avgQtyGroup2 || 0)}u
- Moyenne multi-magasins : ${Math.round(p.avgQtyGroup2)}u` Benchmarks Rayon ("${p.libelleNiveau2}") :
: ""; - Moyenne 1 mag: ${Math.round(p.avgQtyRayon1 || 0)}u | Multi-mag: ${Math.round(p.avgQtyRayon2 || 0)}u`;
return `Produit : "${p.libelle1}" (Ref: ${p.codein}) return `Produit : "${p.libelle1}" (Ref: ${p.codein})
Rayon : ${p.libelleNiveau2 || "Non classé"}
Gamme actuelle : ${p.codeGamme ?? "Non définie"} Gamme actuelle : ${p.codeGamme ?? "Non définie"}
${volumeInfo}${benchmarks} ${volumeInfo}${benchmarks}
Indicateurs Clés : Indicateurs Clés :
- Marge : ${p.tauxMarge.toFixed(1)}% - Marge : ${p.tauxMarge.toFixed(1)}%
- Historique mensuel : ${monthlySummary} - Historique mensuel (Pondéré) : ${monthlySummary}
Quelle est ta recommandation (A, C ou Z) et pourquoi ?`; Quelle est ta recommandation (A, C ou Z) et pourquoi ?`;
} }
@@ -1,14 +1,17 @@
export interface ProductAnalysisInput { export interface ProductAnalysisInput {
codein: string; codein: string;
libelle1: string; libelle1: string;
libelleNiveau2?: string;
totalCa: number; totalCa: number;
tauxMarge: number; tauxMarge: number;
totalQuantite: number; totalQuantite: number;
weightedTotalQuantite?: number; weightedTotalQuantite?: number;
weightedTotalCa?: number; weightedTotalCa?: number;
avgTotalQuantite?: number; avgTotalQuantite?: number;
avgQtyGroup1?: number; // Moyenne volume pour produits à 1 magasin avgQtyGroup1?: number; // Moyenne volume pour produits à 1 magasin (Fournisseur)
avgQtyGroup2?: number; // Moyenne volume pour produits à 2 magasins+ avgQtyGroup2?: number; // Moyenne volume pour produits à 2 magasins+ (Fournisseur)
avgQtyRayon1?: number; // Moyenne volume pour produits à 1 magasin (Rayon)
avgQtyRayon2?: number; // Moyenne volume pour produits à 2 magasins+ (Rayon)
storeCount: number; storeCount: number;
sales12m: Record<string, number>; sales12m: Record<string, number>;
codeGamme: string | null; codeGamme: string | null;
@@ -33,13 +33,38 @@ export function BulkAiAnalyzer() {
const avgQty1 = rowsGroup1.length > 0 ? rowsGroup1.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup1.length : 0; 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 avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: any) => 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 rayon = r.libelleNiveau2 || "Général";
if (!rayonStats.has(rayon)) rayonStats.set(rayon, { qty1: [], qty2: [] });
const stats = rayonStats.get(rayon)!;
const sc = r.workingStores?.length || 1;
if (sc === 1) stats.qty1.push(r.totalQuantite || 0);
else stats.qty2.push(r.totalQuantite || 0);
});
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
});
}
});
const productPayloads: ProductAnalysisInput[] = rows.map(r => { const productPayloads: ProductAnalysisInput[] = rows.map(r => {
const sc = r.workingStores?.length || 1; 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; // On ramène tout sur une base 2 magasins
const rb = rayonBenchmarks.get(r.libelleNiveau2 || "Général");
return { return {
codein: r.codein, codein: r.codein,
libelle1: r.libelle1 || "", libelle1: r.libelle1 || "",
libelleNiveau2: r.libelleNiveau2 || "Général",
totalCa: r.totalCa || 0, totalCa: r.totalCa || 0,
tauxMarge: r.tauxMarge || 0, tauxMarge: r.tauxMarge || 0,
totalQuantite: r.totalQuantite || 0, totalQuantite: r.totalQuantite || 0,
@@ -48,6 +73,8 @@ export function BulkAiAnalyzer() {
avgTotalQuantite: avgQty, avgTotalQuantite: avgQty,
avgQtyGroup1: avgQty1, avgQtyGroup1: avgQty1,
avgQtyGroup2: avgQty2, avgQtyGroup2: avgQty2,
avgQtyRayon1: rb ? rb.avg1 : avgQty1,
avgQtyRayon2: rb ? rb.avg2 : avgQty2,
storeCount: sc, storeCount: sc,
sales12m: Object.fromEntries( sales12m: Object.fromEntries(
Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight]) Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight])