mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 12:46:32 +02:00
feat: Introduce AI analysis engine for product A/Z recommendations based on detailed business rules.
This commit is contained in:
1 parent
e96bfa818d
commit
95795ce7e3
6 files changed
+199
-90
No files matched your search
+137
-73
@@ -1,32 +1,62 @@
|
||||
/**
|
||||
* CollectFlow — Score Engine v3 (Percentile Composite)
|
||||
* CollectFlow — Score Engine v4 (Hybride Absolu + Relatif)
|
||||
*
|
||||
* Calcule le score de performance d'un produit par rang percentile au sein
|
||||
* de son lot fournisseur sur 3 axes : CA, Quantité, Taux de Marge.
|
||||
* Calcule le score de performance d'un produit en combinant :
|
||||
* - Performance ABSOLUE : Volume (50pts), CA (30pts), Marge (20pts)
|
||||
* - Ajustement RELATIF : Position dans le lot fournisseur (±10pts)
|
||||
* - Pénalité de régularité : multiplicateur si inactivité prolongée
|
||||
*
|
||||
* Formule :
|
||||
* Score = (40% × Percentile_CA) + (35% × Percentile_Volume) + (25% × Percentile_Marge)
|
||||
*
|
||||
* Où chaque Percentile_Axe est le rang du produit dans son lot fournisseur (0-100).
|
||||
* Score final : 0-100, sensible au prix unitaire.
|
||||
*
|
||||
* Exemples :
|
||||
* - Score = 100 → Meilleur produit du fournisseur sur tous les axes
|
||||
* - Score = 50 → Produit médian du fournisseur
|
||||
* - Score = 0 → Dernier produit du fournisseur sur tous les axes
|
||||
*
|
||||
* Avantages :
|
||||
* - Borné 0-100 (plus de scores > 150 pour des petits fournisseurs)
|
||||
* - Robuste aux fournisseurs avec peu de références ou faibles volumes
|
||||
* - Cohérent avec le scoring IA (scoring-engine.ts) qui utilise aussi les percentiles
|
||||
* - 0.80€ × 100 unités (80€ CA) → ~53 (bon trafic malgré petit CA)
|
||||
* - 40€ × 2 unités (80€ CA) → ~19 (faible rotation)
|
||||
* - 500€ CA, 200u, 45% marge → ~90 (star)
|
||||
* - 8€ CA, 2 unités → ~13 (stock mort)
|
||||
*/
|
||||
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
/**
|
||||
* Calcule les rangs percentiles (0-100) pour un tableau de valeurs numériques.
|
||||
* Les ex-æquo reçoivent le rang moyen de leur groupe.
|
||||
* Cas limite : tableau à 1 élément → [50] (médiane).
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Interpolation linéaire entre deux paliers */
|
||||
function lerp(value: number, minVal: number, maxVal: number, minPts: number, maxPts: number): number {
|
||||
if (value >= maxVal) return maxPts;
|
||||
if (value <= minVal) return minPts;
|
||||
return minPts + ((value - minVal) / (maxVal - minVal)) * (maxPts - minPts);
|
||||
}
|
||||
|
||||
/** Score Volume (0-50) basé sur unités / magasin / mois */
|
||||
function computeVolumeScore(unitsPerStorePerMonth: number): number {
|
||||
if (unitsPerStorePerMonth >= 10) return 50;
|
||||
if (unitsPerStorePerMonth >= 5) return lerp(unitsPerStorePerMonth, 5, 10, 40, 50);
|
||||
if (unitsPerStorePerMonth >= 2) return lerp(unitsPerStorePerMonth, 2, 5, 30, 40);
|
||||
if (unitsPerStorePerMonth >= 1) return lerp(unitsPerStorePerMonth, 1, 2, 20, 30);
|
||||
if (unitsPerStorePerMonth >= 0.5) return lerp(unitsPerStorePerMonth, 0.5, 1, 10, 20);
|
||||
return lerp(unitsPerStorePerMonth, 0, 0.5, 0, 10);
|
||||
}
|
||||
|
||||
/** Score CA (0-30) basé sur CA € / magasin / an */
|
||||
function computeCaScore(caPerStorePerYear: number): number {
|
||||
if (caPerStorePerYear >= 500) return 30;
|
||||
if (caPerStorePerYear >= 200) return lerp(caPerStorePerYear, 200, 500, 22, 30);
|
||||
if (caPerStorePerYear >= 100) return lerp(caPerStorePerYear, 100, 200, 15, 22);
|
||||
if (caPerStorePerYear >= 50) return lerp(caPerStorePerYear, 50, 100, 8, 15);
|
||||
return lerp(caPerStorePerYear, 0, 50, 0, 8);
|
||||
}
|
||||
|
||||
/** Score Marge (0-20) basé sur taux de marge % */
|
||||
function computeMargeScore(tauxMarge: number): number {
|
||||
if (tauxMarge >= 50) return 20;
|
||||
if (tauxMarge >= 40) return lerp(tauxMarge, 40, 50, 16, 20);
|
||||
if (tauxMarge >= 30) return lerp(tauxMarge, 30, 40, 12, 16);
|
||||
if (tauxMarge >= 20) return lerp(tauxMarge, 20, 30, 8, 12);
|
||||
return lerp(tauxMarge, 0, 20, 0, 8);
|
||||
}
|
||||
|
||||
/** Rangs percentiles (0-100) pour un tableau de valeurs numériques. */
|
||||
function computePercentileRanks(values: number[]): number[] {
|
||||
const n = values.length;
|
||||
if (n === 0) return [];
|
||||
@@ -40,7 +70,6 @@ function computePercentileRanks(values: number[]): number[] {
|
||||
while (i < n) {
|
||||
let j = i;
|
||||
while (j < n && indexed[j].v === indexed[i].v) j++;
|
||||
// Rang moyen pour les ex-æquo, normalisé 0-100
|
||||
const avgRank = ((i + j - 1) / 2) / (n - 1) * 100;
|
||||
for (let k = i; k < j; k++) {
|
||||
ranks[indexed[k].i] = Math.round(avgRank);
|
||||
@@ -51,54 +80,62 @@ function computePercentileRanks(values: number[]): number[] {
|
||||
return ranks;
|
||||
}
|
||||
|
||||
export interface ScoreSettings {
|
||||
/** 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 = {
|
||||
weightCA: 0.40,
|
||||
weightVolume: 0.35,
|
||||
weightMarge: 0.25,
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// API publique
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Calcule les scores pour un ensemble de produits d'un même fournisseur.
|
||||
* Les rows sont modifiées en place (champ `score`) et retournées.
|
||||
* Les rows sont modifiées en place et retournées.
|
||||
*/
|
||||
export function computeProductScores(
|
||||
rows: ProductRow[],
|
||||
settings: ScoreSettings = DEFAULT_SCORE_SETTINGS
|
||||
): ProductRow[] {
|
||||
export function computeProductScores(rows: ProductRow[]): ProductRow[] {
|
||||
if (rows.length === 0) return rows;
|
||||
|
||||
// 1. Normalisation multi-magasins : pondérer les produits en 1 magasin (×2)
|
||||
// pour comparer équitablement avec ceux en 2 magasins
|
||||
// 1. Données pondérées pour normalisation multi-magasins
|
||||
const weightedData = rows.map((r) => {
|
||||
const storeCount = r.workingStores?.length || 1;
|
||||
const storeCount = Math.max(1, r.workingStores?.length || 1);
|
||||
const weight = storeCount === 1 ? 2 : 1;
|
||||
|
||||
// Régularité : nombre de mois avec au moins 1 vente
|
||||
const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length;
|
||||
|
||||
// Inactivité : mois sans vente en fin de fenêtre
|
||||
const allMonths = Object.keys(r.sales12m || {});
|
||||
const refMonth = 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 lastSaleMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
|
||||
|
||||
let inactivity = 0;
|
||||
if (refMonth && lastSaleMonth) {
|
||||
const refY = parseInt(refMonth.substring(0, 4));
|
||||
const refM = parseInt(refMonth.substring(4, 6));
|
||||
const lastY = parseInt(lastSaleMonth.substring(0, 4));
|
||||
const lastM = parseInt(lastSaleMonth.substring(4, 6));
|
||||
inactivity = (refY - lastY) * 12 + (refM - lastM);
|
||||
}
|
||||
|
||||
return {
|
||||
row: r,
|
||||
storeCount,
|
||||
weight,
|
||||
wQty: (r.totalQuantite ?? 0) * weight,
|
||||
wCa: (r.totalCa ?? 0) * weight,
|
||||
wMarge: (r.totalMarge ?? 0) * weight,
|
||||
tauxMarge: Number.isFinite(r.tauxMarge) ? r.tauxMarge : 0, // Garde contre NaN
|
||||
tauxMarge: Number.isFinite(r.tauxMarge) ? r.tauxMarge : 0,
|
||||
regScore,
|
||||
inactivity,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Calculer les MOYENNES du fournisseur (pas les MAX !)
|
||||
// 2. Totaux fournisseur
|
||||
const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0);
|
||||
const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0);
|
||||
const totalMarge = weightedData.reduce((sum, d) => sum + d.tauxMarge, 0);
|
||||
|
||||
const avgQty = rows.length > 0 ? totalQty / rows.length : 0;
|
||||
|
||||
// 3. Calculer les totaux pour les poids (%)
|
||||
// 3. Totaux par rayon (code2)
|
||||
const rayonTotals = new Map<string, { total: number; count: number }>();
|
||||
weightedData.forEach((d) => {
|
||||
const r2 = d.row.code2 || "default";
|
||||
@@ -106,42 +143,69 @@ export function computeProductScores(
|
||||
rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 });
|
||||
});
|
||||
|
||||
// 4. Calcul des rangs percentiles par axe (0-100, borné)
|
||||
// 4. Percentiles dans le lot (pour le bonus relatif)
|
||||
const caPctRanks = computePercentileRanks(weightedData.map(d => d.wCa));
|
||||
const qtyPctRanks = computePercentileRanks(weightedData.map(d => d.wQty));
|
||||
const margePctRanks = computePercentileRanks(weightedData.map(d => d.tauxMarge));
|
||||
|
||||
// Validation des pondérations (garde contre undefined/NaN)
|
||||
const wCA = Number.isFinite(settings.weightCA) ? settings.weightCA : 0.40;
|
||||
const wVol = Number.isFinite(settings.weightVolume) ? settings.weightVolume : 0.35;
|
||||
const wMarge = Number.isFinite(settings.weightMarge) ? settings.weightMarge : 0.25;
|
||||
// 5. Seuil top 30% CA pour le flag isTop30Supplier
|
||||
const sortedCa = [...weightedData.map(d => d.wCa)].sort((a, b) => b - a);
|
||||
const top30Idx = Math.max(0, Math.ceil(sortedCa.length * 0.3) - 1);
|
||||
const top30CaThreshold = sortedCa[top30Idx] ?? 0;
|
||||
|
||||
// 5. Pour chaque produit : injecter contexte + score composite percentile
|
||||
// 6. Score de chaque produit
|
||||
for (let idx = 0; idx < weightedData.length; idx++) {
|
||||
const data = weightedData[idx];
|
||||
const d = weightedData[idx];
|
||||
const r = d.row;
|
||||
|
||||
// Métriques de contexte (pour l'IA et l'affichage)
|
||||
data.row.avgQtyFournisseur = avgQty;
|
||||
data.row.totalFournisseurCa = totalCa;
|
||||
// --- Métriques de contexte (inchangées, pour l'IA et l'affichage) ---
|
||||
r.avgQtyFournisseur = avgQty;
|
||||
r.totalFournisseurCa = totalCa;
|
||||
r.shareQty = totalQty > 0 ? (d.wQty / totalQty) * 100 : 0;
|
||||
r.shareCa = totalCa > 0 ? (d.wCa / totalCa) * 100 : 0;
|
||||
r.shareMarge = totalMarge > 0 ? (d.tauxMarge / totalMarge) * 100 : 0;
|
||||
|
||||
// Poids relatifs (%)
|
||||
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.tauxMarge / totalMarge) * 100 : 0;
|
||||
|
||||
const r2 = data.row.code2 || "default";
|
||||
const r2 = r.code2 || "default";
|
||||
const rayonStat = rayonTotals.get(r2);
|
||||
data.row.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
|
||||
r.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
|
||||
|
||||
// ================================================================
|
||||
// SCORE COMPOSITE PERCENTILE (0-100)
|
||||
// ================================================================
|
||||
const scoreComposite =
|
||||
wCA * caPctRanks[idx] +
|
||||
wVol * qtyPctRanks[idx] +
|
||||
wMarge * margePctRanks[idx];
|
||||
// --- KPIs normalisés (sensibles au prix) ---
|
||||
const activeMonths = Math.max(d.regScore, 3); // plancher 3 mois
|
||||
const unitsPerStorePerMonth = d.wQty / (d.storeCount * activeMonths);
|
||||
const caPerStorePerYear = d.wCa / d.storeCount;
|
||||
|
||||
data.row.score = Number.isFinite(scoreComposite) ? Math.round(scoreComposite * 10) / 10 : 0;
|
||||
r.unitsPerStorePerMonth = Math.round(unitsPerStorePerMonth * 100) / 100;
|
||||
r.caPerStorePerYear = Math.round(caPerStorePerYear * 100) / 100;
|
||||
|
||||
// --- Score absolu (Volume + CA + Marge = 0-100 pts) ---
|
||||
const volumeScore = computeVolumeScore(unitsPerStorePerMonth);
|
||||
const caScore = computeCaScore(caPerStorePerYear);
|
||||
const margeScore = computeMargeScore(d.tauxMarge);
|
||||
|
||||
// --- Bonus relatif (±10 pts) ---
|
||||
const pctComposite = (caPctRanks[idx] * 0.40 + qtyPctRanks[idx] * 0.35 + margePctRanks[idx] * 0.25);
|
||||
const relativeBonus = ((pctComposite - 50) / 50) * 10;
|
||||
|
||||
// --- Pénalité de régularité ---
|
||||
let regularityMultiplier = 1;
|
||||
if (d.regScore <= 2 && d.inactivity >= 6) {
|
||||
regularityMultiplier = 0.3;
|
||||
} else if (d.regScore <= 2 && d.inactivity >= 3) {
|
||||
regularityMultiplier = 0.5;
|
||||
} else if (d.inactivity >= 6) {
|
||||
regularityMultiplier = 0.5;
|
||||
} else if (d.inactivity >= 3) {
|
||||
regularityMultiplier = 0.7;
|
||||
}
|
||||
|
||||
// --- Score final ---
|
||||
const rawScore = (volumeScore + caScore + margeScore + relativeBonus) * regularityMultiplier;
|
||||
r.score = Math.round(Math.max(0, Math.min(100, rawScore)) * 10) / 10;
|
||||
|
||||
// --- Flags de protection ---
|
||||
r.isRecent = d.regScore <= 3;
|
||||
r.isLastProduct = rows.length === 1;
|
||||
r.isTop30Supplier = d.wCa >= top30CaThreshold && top30CaThreshold > 0;
|
||||
}
|
||||
|
||||
return rows;
|
||||
|
||||
Reference in new issue
Block a user