mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-16 19:59:30 +02:00
feat: implement AI analysis engine for generating retail product recommendation prompts.
This commit is contained in:
1 parent
2d3a90d6c7
commit
38e8fc1eac
4 files changed
+166
-86
No files matched your search
+59
-41
@@ -1,28 +1,42 @@
|
||||
/**
|
||||
* CollectFlow — Score Engine (Proposition C: Score Composite Pondéré)
|
||||
* CollectFlow — Score Engine v2 (Simplifié - Score de Contribution Composite)
|
||||
*
|
||||
* Calcule le score de performance d'un produit en fonction de son poids
|
||||
* relatif chez le fournisseur sur 3 axes : CA, Quantité, Marge.
|
||||
* Calcule le score de performance d'un produit basé sur sa contribution RELATIVE
|
||||
* à la moyenne de son lot fournisseur sur 3 axes : CA, Quantité, Marge.
|
||||
*
|
||||
* Formule :
|
||||
* score_axe = produit.metric / max_metric_fournisseur × 100
|
||||
* base = (scoreCa × 45%) + (scoreQty × 35%) + (scoreMarge × 20%)
|
||||
* bonus = count(poids > seuil) × bonusParAxe
|
||||
* score = MIN(base + bonus, 100)
|
||||
* Score = (40% × Score_CA) + (35% × Score_Volume) + (25% × Score_Marge)
|
||||
*
|
||||
* Où chaque score d'axe est :
|
||||
* Score_Axe = (Valeur Produit / Moyenne Fournisseur) × 100
|
||||
*
|
||||
* Exemples :
|
||||
* - Score = 100 → Le produit fait exactement sa part
|
||||
* - Score = 150 → Le produit surperforme de 50%
|
||||
* - Score = 50 → Le produit sous-performe de 50%
|
||||
*
|
||||
* Avantages :
|
||||
* - Simple et intuitif
|
||||
* - Auto-normalisé (fonctionne avec 5 ou 50 produits)
|
||||
* - Reflète la VRAIE contribution du produit
|
||||
* - Aligné avec l'analyse IA v5
|
||||
*/
|
||||
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
export interface ScoreSettings {
|
||||
/** Seuil en % pour considérer un axe comme "fort" (défaut: 2.0) */
|
||||
seuilAxeFort: number;
|
||||
/** Points bonus par axe dépassant le seuil (défaut: 5) */
|
||||
bonusParAxe: number;
|
||||
/** Pondération CA (défaut: 40%) */
|
||||
weightCA: number;
|
||||
/** Pondération Volume (défaut: 35%) */
|
||||
weightVolume: number;
|
||||
/** Pondération Marge (défaut: 25%) */
|
||||
weightMarge: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCORE_SETTINGS: ScoreSettings = {
|
||||
seuilAxeFort: 30.0,
|
||||
bonusParAxe: 10,
|
||||
weightCA: 0.40,
|
||||
weightVolume: 0.35,
|
||||
weightMarge: 0.25,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,66 +49,70 @@ export function computeProductScores(
|
||||
): ProductRow[] {
|
||||
if (rows.length === 0) return rows;
|
||||
|
||||
// 1. Calculer les valeurs pondérées pour tout le monde et trouver les max
|
||||
// 1. Normalisation multi-magasins : pondérer les produits en 1 magasin (×2)
|
||||
// pour comparer équitablement avec ceux en 2 magasins
|
||||
const weightedData = rows.map((r) => {
|
||||
const weight = (r.workingStores?.length || 1) === 1 ? 2 : 1;
|
||||
const storeCount = r.workingStores?.length || 1;
|
||||
const weight = storeCount === 1 ? 2 : 1;
|
||||
|
||||
return {
|
||||
row: r,
|
||||
wQty: (r.totalQuantite ?? 0) * weight,
|
||||
wCa: (r.totalCa ?? 0) * weight,
|
||||
wMarge: (r.totalMarge ?? 0) * weight,
|
||||
tauxMarge: r.tauxMarge ?? 0, // Taux de marge en % (pas pondéré)
|
||||
};
|
||||
});
|
||||
|
||||
const maxQty = weightedData.reduce((max, d) => Math.max(max, d.wQty), 0);
|
||||
const maxCa = weightedData.reduce((max, d) => Math.max(max, d.wCa), 0);
|
||||
const maxMarge = weightedData.reduce((max, d) => Math.max(max, d.wMarge), 0);
|
||||
|
||||
// 2. Calculer les moyennes et totaux globaux
|
||||
// 2. Calculer les MOYENNES du fournisseur (pas les MAX !)
|
||||
const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0);
|
||||
const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0);
|
||||
const totalMarge = weightedData.reduce((acc, d) => acc + d.wMarge, 0);
|
||||
const totalMarge = weightedData.reduce((sum, d) => sum + d.tauxMarge, 0);
|
||||
|
||||
const avgQtyFournisseur = rows.length > 0 ? totalQty / rows.length : 0;
|
||||
const avgCa = rows.length > 0 ? totalCa / rows.length : 0;
|
||||
const avgQty = rows.length > 0 ? totalQty / rows.length : 0;
|
||||
const avgMarge = rows.length > 0 ? totalMarge / rows.length : 0;
|
||||
|
||||
const rayonTotals = new Map<string, { total: number, count: number }>();
|
||||
weightedData.forEach(d => {
|
||||
// 3. Calculer les totaux pour les poids (%)
|
||||
const rayonTotals = new Map<string, { total: number; count: number }>();
|
||||
weightedData.forEach((d) => {
|
||||
const r2 = d.row.code2 || "default";
|
||||
const current = rayonTotals.get(r2) || { total: 0, count: 0 };
|
||||
rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 });
|
||||
});
|
||||
|
||||
// 3. Pour chaque produit, calculer le score relatif et injecter les contextes (poids, moyennes)
|
||||
// 4. Pour chaque produit : calculer le score composite
|
||||
for (const data of weightedData) {
|
||||
// Injection des métriques de poids et contextes
|
||||
data.row.avgQtyFournisseur = avgQtyFournisseur;
|
||||
// Injection des métriques de contexte (pour l'IA et l'affichage)
|
||||
data.row.avgQtyFournisseur = avgQty;
|
||||
data.row.totalFournisseurCa = totalCa;
|
||||
|
||||
// Calcul des poids (%)
|
||||
data.row.shareQty = totalQty > 0 ? (data.wQty / totalQty) * 100 : 0;
|
||||
data.row.shareCa = totalCa > 0 ? (data.wCa / totalCa) * 100 : 0;
|
||||
data.row.shareMarge = totalMarge > 0 ? (data.wMarge / totalMarge) * 100 : 0;
|
||||
data.row.shareMarge = totalMarge > 0 ? (data.tauxMarge / totalMarge) * 100 : 0;
|
||||
|
||||
const r2 = data.row.code2 || "default";
|
||||
const rayonStat = rayonTotals.get(r2);
|
||||
data.row.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
|
||||
|
||||
// Score de 0 à 100 sur chaque axe pondéré
|
||||
const scoreQty = maxQty > 0 ? (data.wQty / maxQty) * 100 : 0;
|
||||
const scoreCa = maxCa > 0 ? (data.wCa / maxCa) * 100 : 0;
|
||||
const scoreMarge = maxMarge > 0 ? (data.wMarge / maxMarge) * 100 : 0;
|
||||
// ================================================================
|
||||
// SCORE DE CONTRIBUTION COMPOSITE
|
||||
// ================================================================
|
||||
|
||||
// Base = Moyenne pondérée (CA: 45%, Qty: 35%, Marge: 20%)
|
||||
const base = (scoreCa * 0.45) + (scoreQty * 0.35) + (scoreMarge * 0.20);
|
||||
// Score de chaque axe = (Valeur Produit / Moyenne Fournisseur) × 100
|
||||
const scoreCa = avgCa > 0 ? (data.wCa / avgCa) * 100 : 0;
|
||||
const scoreVolume = avgQty > 0 ? (data.wQty / avgQty) * 100 : 0;
|
||||
const scoreMarge = avgMarge > 0 ? (data.tauxMarge / avgMarge) * 100 : 0;
|
||||
|
||||
let axesForts = 0;
|
||||
if (scoreQty > settings.seuilAxeFort) axesForts++;
|
||||
if (scoreCa > settings.seuilAxeFort) axesForts++;
|
||||
if (scoreMarge > settings.seuilAxeFort) axesForts++;
|
||||
// Score final = moyenne pondérée
|
||||
const scoreComposite =
|
||||
settings.weightCA * scoreCa +
|
||||
settings.weightVolume * scoreVolume +
|
||||
settings.weightMarge * scoreMarge;
|
||||
|
||||
const bonus = axesForts * settings.bonusParAxe;
|
||||
|
||||
data.row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10;
|
||||
// Arrondi à 1 décimale
|
||||
data.row.score = Math.round(scoreComposite * 10) / 10;
|
||||
}
|
||||
|
||||
return rows;
|
||||
|
||||
Reference in new issue
Block a user