feat: add bulk AI analyzer component for product scoring and context profiling.

This commit is contained in:
Michael committed 2026-03-06 16:00:42 +01:00
1 parent 34e8f4a767
commit 54a4e7f4d1
2 files changed
+129 -75

No files matched your search

@@ -28,6 +28,26 @@ const MIN_RAYON_SIZE = 6;
export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH"; export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH";
export interface ContextProfilerCache {
allCaPerStoreSorted: number[];
allQtyPerStoreSorted: number[];
allMargeValuesSorted: number[];
totalCaFournisseur: number;
totalQtyFournisseur: number;
top20CaThreshold: number;
top20QtyThreshold: number;
medianQtyPerStore: number;
medianMarge: number;
marge40: number;
qty60PerStore: number;
rayonStats: Map<string, { totalCa: number; totalQty: number; count: number }>;
}
export interface ProductContextProfile { export interface ProductContextProfile {
// Identité // Identité
codein: string; codein: string;
@@ -108,28 +128,41 @@ export interface ProductContextProfile {
// Helpers statistiques (purs) // Helpers statistiques (purs)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function computePercentile(value: number, distribution: number[]): number { function computePercentileFromSorted(value: number, sortedDistribution: number[]): number {
if (distribution.length <= 1) return 100; if (sortedDistribution.length <= 1) return 100;
const sorted = [...distribution].sort((a, b) => a - b);
const first = sorted.indexOf(value); let first = -1;
const last = sorted.lastIndexOf(value); let last = -1;
if (first === -1) return 0;
const avgRank = (first + last) / 2; for (let i = 0; i < sortedDistribution.length; i++) {
return Math.round((avgRank / (sorted.length - 1)) * 100); const current = sortedDistribution[i];
if (Math.abs(current - value) < 0.00001) { // Floating point protection
if (first === -1) first = i;
last = i;
} else if (current > value) {
if (first === -1) return Math.round((i / Math.max(1, sortedDistribution.length - 1)) * 100);
break;
}
}
if (first !== -1) {
const avgRank = (first + last) / 2;
return Math.round((avgRank / (sortedDistribution.length - 1)) * 100);
}
return 100;
} }
function computeMedian(values: number[]): number { function computeMedianFromSorted(sorted: number[]): number {
if (values.length === 0) return 0; if (sorted.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2); const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 return sorted.length % 2 !== 0
? sorted[mid] ? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2; : (sorted[mid - 1] + sorted[mid]) / 2;
} }
function valueAtPercentile(values: number[], p: number): number { function valueAtPercentileFromSorted(sorted: number[], p: number): number {
if (values.length === 0) return 0; if (sorted.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1); const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
return sorted[idx]; return sorted[idx];
} }
@@ -151,69 +184,107 @@ function getRayonKey(p: ProductAnalysisInput): string {
return p.libelleNiveau2 ?? "default"; return p.libelleNiveau2 ?? "default";
} }
const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1);
const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p);
const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p);
const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0;
const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Profiler principal // Profiler principal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export class ContextProfiler { export class ContextProfiler {
/**
* Prépare un cache contextuel basé sur tous les produits pour éviter des tris
* répétés en mode "Bulk Analyze". Réduit la latence de manière dramatique (O(N) au lieu de O(N^2)).
*/
static prepareCache(allProds: ProductAnalysisInput[]): ContextProfilerCache {
const allCaPerStore = allProds.map(normCa);
const allQtyPerStore = allProds.map(normQty);
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0);
// On trie une seule et unique fois
const allCaPerStoreSorted = [...allCaPerStore].sort((a, b) => a - b);
const allQtyPerStoreSorted = [...allQtyPerStore].sort((a, b) => a - b);
const allMargeValuesSorted = [...allMargeValues].sort((a, b) => a - b);
const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0);
const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0);
const rayonStats = new Map<string, { totalCa: number; totalQty: number; count: number }>();
allProds.forEach(p => {
const key = getRayonKey(p);
if (!rayonStats.has(key)) rayonStats.set(key, { totalCa: 0, totalQty: 0, count: 0 });
const s = rayonStats.get(key)!;
s.totalCa += getWeightedCa(p);
s.totalQty += getWeightedQty(p);
s.count += 1;
});
return {
allCaPerStoreSorted,
allQtyPerStoreSorted,
allMargeValuesSorted,
totalCaFournisseur,
totalQtyFournisseur,
top20CaThreshold: valueAtPercentileFromSorted(allCaPerStoreSorted, 80),
top20QtyThreshold: valueAtPercentileFromSorted(allQtyPerStoreSorted, 80),
medianQtyPerStore: computeMedianFromSorted(allQtyPerStoreSorted),
medianMarge: computeMedianFromSorted(allMargeValuesSorted),
marge40: valueAtPercentileFromSorted(allMargeValuesSorted, 40),
qty60PerStore: valueAtPercentileFromSorted(allQtyPerStoreSorted, 60),
rayonStats,
};
}
/** /**
* Génère le profil contextuel d'un produit au sein de son lot fournisseur. * Génère le profil contextuel d'un produit au sein de son lot fournisseur.
*/ */
static buildProfile( static buildProfile(
target: ProductAnalysisInput, target: ProductAnalysisInput,
allProds: ProductAnalysisInput[], allProds: ProductAnalysisInput[],
scoring: ScoringResult scoring: ScoringResult,
cache?: ContextProfilerCache
): ProductContextProfile { ): ProductContextProfile {
if (allProds.length === 0) { if (allProds.length === 0) {
throw new Error("[ContextProfiler] Le lot de produits est vide."); throw new Error("[ContextProfiler] Le lot de produits est vide.");
} }
// Utiliser le cache s'il est fourni (mode Bulk) pour éviter de recalculer,
// sinon le générer à la volée (mode single item)
const ctx: ContextProfilerCache = cache ?? ContextProfiler.prepareCache(allProds);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Normalisation par magasin — cœur de la v3 // Normalisation par magasin — cœur de la v3
// Raison : un produit en 2 magasins a mécaniquement 2x plus de CA/QTÉ
// qu'un produit identique en 1 magasin. Sans normalisation, les percentiles
// et le quadrant sont fausss par le réseau de distribution, pas la perf.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1);
const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p);
const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p);
const targetStoreCount = getNormStoreCount(target); const targetStoreCount = getNormStoreCount(target);
const targetCaPerStore = normCa(target); const targetCaPerStore = normCa(target);
const targetQtyPerStore = normQty(target); const targetQtyPerStore = normQty(target);
// 1. Totaux fournisseur (valeurs PONDÉRÉES pour comparaison équitable réseau) // 1. Totaux fournisseur
// weightedTotalCa/Qty projettent les 1-magasin sur une base réseau (×2). const { totalCaFournisseur, totalQtyFournisseur } = ctx;
const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0;
const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0;
const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0);
const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0);
// 2. Totaux du rayon (Niveau 2 de nomenclature) // 2. Totaux du rayon (Niveau 2 de nomenclature)
const targetRayonKey = getRayonKey(target); const targetRayonKey = getRayonKey(target);
const rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey); const rayonStat = ctx.rayonStats.get(targetRayonKey) ?? { totalCa: 0, totalQty: 0, count: 0 };
const totalCaRayon = rayonProds.reduce((s, p) => s + getWeightedCa(p), 0); const totalCaRayon = rayonStat.totalCa;
const totalQtyRayon = rayonProds.reduce((s, p) => s + getWeightedQty(p), 0); const totalQtyRayon = rayonStat.totalQty;
const rayonSizeForSignals = rayonStat.count;
// 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant // 3. Percentiles (0-100) — sur distribution PAR MAGASIN
const allCaPerStore = allProds.map(normCa); const pCa = computePercentileFromSorted(targetCaPerStore, ctx.allCaPerStoreSorted);
const allQtyPerStore = allProds.map(normQty); const pQty = computePercentileFromSorted(targetQtyPerStore, ctx.allQtyPerStoreSorted);
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0); const pMarge = computePercentileFromSorted(target.tauxMarge ?? 0, ctx.allMargeValuesSorted);
const pCa = computePercentile(targetCaPerStore, allCaPerStore);
const pQty = computePercentile(targetQtyPerStore, allQtyPerStore);
const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues);
const pComposite = scoring.compositeScore; const pComposite = scoring.compositeScore;
// 4. Tops 20% (sur valeurs PAR MAGASIN) // 4. Tops 20%
const top20CaThreshold = valueAtPercentile(allCaPerStore, 80); const isTop20Ca = targetCaPerStore >= ctx.top20CaThreshold;
const top20QtyThreshold = valueAtPercentile(allQtyPerStore, 80); const isTop20Qty = targetQtyPerStore >= ctx.top20QtyThreshold;
const isTop20Ca = targetCaPerStore >= top20CaThreshold;
const isTop20Qty = targetQtyPerStore >= top20QtyThreshold;
const isAboveMedianComposite = pComposite >= 50; const isAboveMedianComposite = pComposite >= 50;
// 5. Poids pondérés (valeurs réseau projetées pour représentativité réaliste) // 5. Poids pondérés
const weightCaFournisseur = const weightCaFournisseur =
totalCaFournisseur > 0 totalCaFournisseur > 0
? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10 ? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10
@@ -223,52 +294,32 @@ export class ContextProfiler {
? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10 ? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10
: 0; : 0;
// Bug fix : un produit dans le top 30% de marge du lot apporte une valeur
// réelle en rentabilité. L'exclure du signal isLowContribution évite de le
// pénaliser uniquement parce que son volume/CA est faible (profil 💎 pur).
// Le seuil de faible contribution utilise désormais des percentiles plutôt
// que des valeurs absolues pour ne pas pénaliser les gros fournisseurs.
const isLowContribution = pCa <= 30 && pQty <= 30 && pMarge <= 70; const isLowContribution = pCa <= 30 && pQty <= 30 && pMarge <= 70;
// 6. Signaux Trafic / Marge + Quadrant (sur valeurs PAR MAGASIN) // 6. Signaux Trafic / Marge + Quadrant
// Médianes calculées ici pour être disponibles dans les sections 6 ET 7.
const medianQtyPerStore = computeMedian(allQtyPerStore);
const medianMarge = computeMedian(allMargeValues);
const rayonSizeForSignals = rayonProds.length;
const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE; const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE;
let isHighVolumeWithLowMargin = false; let isHighVolumeWithLowMargin = false;
let isMargePure = false; let isMargePure = false;
// Bug fix : isMargePure est désormais calculé sur tous les rayons, même petits.
// Raison : la marge absolue est une donnée fiable quelle que soit la taille du lot.
// Le signal isHighVolumeWithLowMargin (trafic) garde le seuil MIN_RAYON_SIZE car
// il nécessite une distribution volumique suffisante pour être statistiquement valide.
{ {
const marge40 = valueAtPercentile(allMargeValues, 40);
const qty60PerStore = valueAtPercentile(allQtyPerStore, 60);
// Signal trafic : nécessite un rayon suffisamment large
if (signalsActive) { if (signalsActive) {
isHighVolumeWithLowMargin = isHighVolumeWithLowMargin =
targetQtyPerStore >= qty60PerStore && targetQtyPerStore >= ctx.qty60PerStore &&
(target.tauxMarge ?? 0) < marge40; (target.tauxMarge ?? 0) < ctx.marge40;
} }
// Signal marge pure : au-dessus de la médiane marge + volume faible.
// Utilise la médiane (P50) plutôt que P70 pour être robuste sur petits lots.
isMargePure = isMargePure =
(target.tauxMarge ?? 0) > medianMarge && (target.tauxMarge ?? 0) > ctx.medianMarge &&
targetQtyPerStore < medianQtyPerStore; targetQtyPerStore < ctx.medianQtyPerStore;
} }
// 7. Quadrant (basé sur les médianes PAR MAGASIN — déjà calculées en section 6) // 7. Quadrant
const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant( const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant(
targetQtyPerStore, targetQtyPerStore,
target.tauxMarge ?? 0, target.tauxMarge ?? 0,
medianQtyPerStore, ctx.medianQtyPerStore,
medianMarge ctx.medianMarge
); );
// 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final) // 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final)
@@ -150,6 +150,9 @@ export function BulkAiAnalyzer() {
const scoringResults = new Map<string, ReturnType<typeof ScoringEngine.analyzeRayon>>(); const scoringResults = new Map<string, ReturnType<typeof ScoringEngine.analyzeRayon>>();
const productPayloads: (ProductAnalysisInput & { scoring: any })[] = []; const productPayloads: (ProductAnalysisInput & { scoring: any })[] = [];
// Pre-compute context thresholds to prevent O(N^2) and O(N^2 log N) performance freezes
const contextCache = ContextProfiler.prepareCache(initialPayloads);
for (let i = 0; i < initialPayloads.length; i += SCORING_BATCH_SIZE) { for (let i = 0; i < initialPayloads.length; i += SCORING_BATCH_SIZE) {
if (isCancelledRef.current) break; if (isCancelledRef.current) break;
@@ -161,7 +164,7 @@ export function BulkAiAnalyzer() {
let contextProfile: ProductAnalysisInput["contextProfile"]; let contextProfile: ProductAnalysisInput["contextProfile"];
try { try {
contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes); contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes, contextCache);
} catch (err) { } catch (err) {
console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err); console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err);
contextProfile = undefined; contextProfile = undefined;