mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: introduce AI-powered bulk product analysis feature with dedicated engine, types, and UI component.
This commit is contained in:
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.
|
||||
|
||||
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.
|
||||
2. Seuil "Permanent" (A) : Pour être A, un produit doit avoir un volume PONDÉRÉ significatif (ex: > 30-50 unités/an).
|
||||
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).
|
||||
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.
|
||||
5. Poids Relatif : Analyse la performance par rapport aux moyennes fournies.
|
||||
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. 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 "Permanent" (A) : Pour être A, un produit doit avoir un volume PONDÉRÉ significatif (ex: > 30-50 unités/an).
|
||||
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. É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.
|
||||
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.
|
||||
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
|
||||
? `\nBenchmarks (Volumes moyens) :
|
||||
- Moyenne mag unique : ${Math.round(p.avgQtyGroup1)}u
|
||||
- Moyenne multi-magasins : ${Math.round(p.avgQtyGroup2)}u`
|
||||
: "";
|
||||
const benchmarks = `
|
||||
Benchmarks Fournisseur (Global) :
|
||||
- Moyenne 1 mag: ${Math.round(p.avgQtyGroup1 || 0)}u | Multi-mag: ${Math.round(p.avgQtyGroup2 || 0)}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})
|
||||
Rayon : ${p.libelleNiveau2 || "Non classé"}
|
||||
Gamme actuelle : ${p.codeGamme ?? "Non définie"}
|
||||
${volumeInfo}${benchmarks}
|
||||
Indicateurs Clés :
|
||||
- Marge : ${p.tauxMarge.toFixed(1)}%
|
||||
- Historique mensuel : ${monthlySummary}
|
||||
- Historique mensuel (Pondéré) : ${monthlySummary}
|
||||
|
||||
Quelle est ta recommandation (A, C ou Z) et pourquoi ?`;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
export interface ProductAnalysisInput {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
libelleNiveau2?: string;
|
||||
totalCa: number;
|
||||
tauxMarge: number;
|
||||
totalQuantite: number;
|
||||
weightedTotalQuantite?: number;
|
||||
weightedTotalCa?: number;
|
||||
avgTotalQuantite?: number;
|
||||
avgQtyGroup1?: number; // Moyenne volume pour produits à 1 magasin
|
||||
avgQtyGroup2?: number; // Moyenne volume pour produits à 2 magasins+
|
||||
avgQtyGroup1?: number; // Moyenne volume pour produits à 1 magasin (Fournisseur)
|
||||
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;
|
||||
sales12m: Record<string, number>;
|
||||
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 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 sc = r.workingStores?.length || 1;
|
||||
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 {
|
||||
codein: r.codein,
|
||||
libelle1: r.libelle1 || "",
|
||||
libelleNiveau2: r.libelleNiveau2 || "Général",
|
||||
totalCa: r.totalCa || 0,
|
||||
tauxMarge: r.tauxMarge || 0,
|
||||
totalQuantite: r.totalQuantite || 0,
|
||||
@@ -48,6 +73,8 @@ export function BulkAiAnalyzer() {
|
||||
avgTotalQuantite: avgQty,
|
||||
avgQtyGroup1: avgQty1,
|
||||
avgQtyGroup2: avgQty2,
|
||||
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])
|
||||
|
||||
Reference in new issue
Block a user