mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: add bulk AI analyzer component for product scoring and context profiling.
This commit is contained in:
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;
|
||||||
|
|||||||
Reference in new issue
Block a user