feat: implement product scoring engine to calculate composite scores and product recommendations based on various metrics and business rules.

This commit is contained in:
Michael committed 2026-02-27 10:11:13 +01:00
1 parent c95eb0ecb6
commit a3600d7e05
3 files changed
+119 -56

No files matched your search

@@ -75,9 +75,19 @@ JSON uniquement, sans markdown.
ctx: ProductContextProfile ctx: ProductContextProfile
): string { ): string {
const lines: string[] = []; const lines: string[] = [];
const storeLabel = ctx.storeCount > 1 ? `${ctx.storeCount} magasins` : `1 magasin`;
lines.push(`PRODUIT : ${ctx.libelle1} (${ctx.codein})`); lines.push(`PRODUIT : ${ctx.libelle1} (${ctx.codein})`);
lines.push(`CATÉGORIE : ${ctx.libelleNiveau2} (rayon: ${ctx.rayonSize} produits | lot total: ${ctx.lotSize} produits)`); lines.push(`CATÉGORIE : ${ctx.libelleNiveau2} (rayon: ${ctx.rayonSize} produits | lot total: ${ctx.lotSize} produits)`);
lines.push(`DISTRIBUTION : ${storeLabel} référençant ce produit`);
lines.push("");
// KPIs bruts réseau + valeurs normalisées par magasin
lines.push(`--- PERFORMANCE (brute réseau / par magasin) ---`);
lines.push(`• CA réseau : ${ctx.totalCaRaw.toLocaleString('fr-FR')}€ | CA par magasin : ${ctx.caPerStore.toFixed(0)}€`);
lines.push(`• Quantité réseau : ${ctx.totalQtyRaw} unités | QTÉ par magasin : ${ctx.qtyPerStore.toFixed(0)} unités`);
lines.push(`• Marge : ${ctx.tauxMarge.toFixed(1)}%`);
lines.push(`⚠️ Les percentiles ci-dessous sont calculés sur les valeurs PAR MAGASIN pour comparer équitablement les produits 1-magasin et 2-magasins.`);
lines.push(""); lines.push("");
// Quadrant — présenté comme un indice, sans prescription // Quadrant — présenté comme un indice, sans prescription
@@ -87,10 +97,10 @@ JSON uniquement, sans markdown.
lines.push(""); lines.push("");
// Position / poids — données brutes // Position / poids — données brutes
lines.push(`--- POSITION DANS LE LOT FOURNISSEUR ---`); lines.push(`--- POSITION DANS LE LOT FOURNISSEUR (percentiles sur valeurs par magasin) ---`);
lines.push(`• CA : ${ctx.percentileCa}e percentile | Poids fournisseur : ${ctx.weightCaFournisseur}% | Poids rayon N2 : ${ctx.weightCaRayon}%`); lines.push(`• CA/magasin : ${ctx.percentileCa}e percentile | Poids CA réseau fournisseur : ${ctx.weightCaFournisseur}% | Poids CA réseau rayon N2 : ${ctx.weightCaRayon}%`);
lines.push(`• Quantité : ${ctx.percentileQty}e percentile | Poids fournisseur : ${ctx.weightQtyFournisseur}% | Poids rayon N2 : ${ctx.weightQtyRayon}%`); lines.push(`• QTÉ/magasin : ${ctx.percentileQty}e percentile | Poids QTÉ réseau fournisseur : ${ctx.weightQtyFournisseur}% | Poids QTÉ réseau rayon N2 : ${ctx.weightQtyRayon}%`);
lines.push(`• Marge : ${ctx.percentileMarge}e percentile`); lines.push(`• Marge : ${ctx.percentileMarge}e percentile`);
lines.push(`• Score composite : ${ctx.percentileComposite}/100`); lines.push(`• Score composite : ${ctx.percentileComposite}/100`);
lines.push(""); lines.push("");
@@ -99,17 +109,17 @@ JSON uniquement, sans markdown.
// Signal rouge prioritaire // Signal rouge prioritaire
if (ctx.isLowContribution) { if (ctx.isLowContribution) {
lines.push(`[⛔ CONTRIBUTION FAIBLE] Poids CA : ${ctx.weightCaFournisseur}% et Poids QTÉ : ${ctx.weightQtyFournisseur}% → produit marginal pour le fournisseur`); lines.push(`[⛔ CONTRIBUTION FAIBLE] Poids CA réseau : ${ctx.weightCaFournisseur}% et Poids QTÉ réseau : ${ctx.weightQtyFournisseur}% → produit marginal pour le fournisseur`);
} }
if (ctx.scoreCritique) { if (ctx.scoreCritique) {
lines.push(`[⛔ SCORE CRITIQUE] Score brut < 20 → sous-seuil absolu`); lines.push(`[⛔ SCORE CRITIQUE] Score brut < 20 → sous-seuil absolu`);
} }
// Signaux positifs // Signaux positifs
lines.push(`${ctx.isTop20Ca ? "[✓]" : "[ ]"} Top 20% CA fournisseur`); lines.push(`${ctx.isTop20Ca ? "[✓]" : "[ ]"} Top 20% CA/magasin fournisseur`);
lines.push(`${ctx.isTop20Qty ? "[✓]" : "[ ]"} Top 20% Quantités fournisseur`); lines.push(`${ctx.isTop20Qty ? "[✓]" : "[ ]"} Top 20% Quantités/magasin fournisseur`);
lines.push(`${ctx.isHighVolumeWithLowMargin ? "[✓]" : "[ ]"} Fort volume (> P60 lot) avec marge faible`); lines.push(`${ctx.isHighVolumeWithLowMargin ? "[✓]" : "[ ]"} Fort volume/magasin (>P60 lot) avec marge faible`);
lines.push(`${ctx.isMargePure ? "[✓]" : "[ ]"} Forte marge (> P70 lot) malgré volume faible`); lines.push(`${ctx.isMargePure ? "[✓]" : "[ ]"} Forte marge (>P70 lot) malgré volume/magasin faible`);
lines.push(`${ctx.isAboveMedianComposite ? "[✓]" : "[ ]"} Au-dessus de la médiane composite`); lines.push(`${ctx.isAboveMedianComposite ? "[✓]" : "[ ]"} Au-dessus de la médiane composite`);
if (!ctx.isHighVolumeWithLowMargin && !ctx.isMargePure) { if (!ctx.isHighVolumeWithLowMargin && !ctx.isMargePure) {
lines.push(`[i] Signaux Trafic/Marge non activés (rayon de ${ctx.rayonSize} produits${ctx.rayonSize < 6 ? " — trop petit pour stats fiables" : ""})`); lines.push(`[i] Signaux Trafic/Marge non activés (rayon de ${ctx.rayonSize} produits${ctx.rayonSize < 6 ? " — trop petit pour stats fiables" : ""})`);
@@ -1,15 +1,16 @@
/** /**
* CollectFlow — Context Profiler (v2 — Anti sur-classement) * CollectFlow — Context Profiler (v3 — Normalisation multi-magasin)
* *
* Génère une fiche de contexte normalisée et adaptative pour chaque produit * Génère une fiche de contexte normalisée et adaptative pour chaque produit
* AVANT de le soumettre à l'IA. * AVANT de le soumettre à l'IA.
* *
* v2 — Correctifs : * v3 — Correctifs :
* - Ajout du signal `isLowContribution` (poids CA ET QTÉ < 0.5% du fournisseur) * - Normalisation par `storeCount` : tous les calculs de percentile, poids
* - Désactivation des signaux Trafic/Marge si rayonSize < MIN_RAYON_SIZE (évite * et quadrant utilisent les valeurs PAR MAGASIN (CA/store, QTÉ/store).
* les faux positifs dans les micro-rayons de 3-5 produits). * Cela évite qu'un produit en 2 magasins soit mécaniquement favorisé
* - Le profiler ne prescrit plus de verdict : il produit des données brutes * dans les comparaisons par rapport à un produit en 1 seul magasin.
* que le prompt de Mary interprète avec un guide de décision pondéré. * - Le profil expose caPerStore et qtyPerStore pour que Mary voie les
* deux dimensions : réeau brut ET performance par magasin.
*/ */
import type { ProductAnalysisInput } from "../models/ai-analysis.types"; import type { ProductAnalysisInput } from "../models/ai-analysis.types";
@@ -33,18 +34,31 @@ export interface ProductContextProfile {
libelle1: string; libelle1: string;
libelleNiveau2: string; libelleNiveau2: string;
// Profil Quadrant // Profil Quadrant (basé sur valeurs PAR MAGASIN pour comparaison équitable)
quadrant: Quadrant; quadrant: Quadrant;
quadrantLabel: string; quadrantLabel: string;
quadrantEmoji: string; quadrantEmoji: string;
// Nombre de magasins référençant le produit
storeCount: number;
// Valeurs brutes réseau
totalCaRaw: number;
totalQtyRaw: number;
// Valeurs normalisées PAR MAGASIN (pour comparaisons justes)
caPerStore: number;
qtyPerStore: number;
// Percentiles dans le lot fournisseur (0 = plus faible, 100 = meilleur) // Percentiles dans le lot fournisseur (0 = plus faible, 100 = meilleur)
// Calculés sur les valeurs normalisées par magasin
percentileCa: number; percentileCa: number;
percentileQty: number; percentileQty: number;
percentileMarge: number; percentileMarge: number;
percentileComposite: number; percentileComposite: number;
// Poids réels dans le lot fournisseur // Poids réels dans le lot fournisseur
// Calculés sur les valeurs brutes réseau (représentativité réelle du chiffre)
weightCaFournisseur: number; // % du CA total fournisseur weightCaFournisseur: number; // % du CA total fournisseur
weightQtyFournisseur: number; // % des QTÉ totales fournisseur weightQtyFournisseur: number; // % des QTÉ totales fournisseur
@@ -63,11 +77,11 @@ export interface ProductContextProfile {
// Signaux positifs (calculés sur la distribution réelle) // Signaux positifs (calculés sur la distribution réelle)
isAboveMedianComposite: boolean; isAboveMedianComposite: boolean;
isTop20Ca: boolean; isTop20Ca: boolean; // Top 20% sur valeur PAR MAGASIN
isTop20Qty: boolean; isTop20Qty: boolean; // Top 20% sur valeur PAR MAGASIN
/** /**
* Fort volume ET marge < P40 du lot → rôle de "locomotive". * Fort volume ET marge < P40 du lot → rôle de "locomotive".
* Désactivé si rayonSize < MIN_RAYON_SIZE (percentiles non significatifs). * Désactivé si rayonSize < MIN_RAYON_SIZE.
*/ */
isHighVolumeWithLowMargin: boolean; isHighVolumeWithLowMargin: boolean;
/** /**
@@ -79,8 +93,6 @@ export interface ProductContextProfile {
// Signal négatif fort // Signal négatif fort
/** /**
* Le produit pèse moins de 0.5% du CA ET des QTÉ du fournisseur. * Le produit pèse moins de 0.5% du CA ET des QTÉ du fournisseur.
* Même un quadrant TRAFIC ne justifie pas un A si ce signal est actif
* et que le score est faible.
*/ */
isLowContribution: boolean; isLowContribution: boolean;
@@ -89,7 +101,7 @@ export interface ProductContextProfile {
protectionReason: string; protectionReason: string;
// Règle absolue // Règle absolue
scoreCritique: boolean; // score brut < 20 scoreCritique: boolean;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -156,34 +168,48 @@ export class ContextProfiler {
throw new Error("[ContextProfiler] Le lot de produits est vide."); throw new Error("[ContextProfiler] Le lot de produits est vide.");
} }
// 1. Totaux fournisseur // ---------------------------------------------------------------------------
// 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 targetCaPerStore = normCa(target);
const targetQtyPerStore = normQty(target);
// 1. Totaux fournisseur (valeurs brutes pour les poids de représentativité réseau)
const totalCaFournisseur = allProds.reduce((s, p) => s + (p.totalCa ?? 0), 0); const totalCaFournisseur = allProds.reduce((s, p) => s + (p.totalCa ?? 0), 0);
const totalQtyFournisseur = allProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0); const totalQtyFournisseur = allProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0);
// 2. Totaux du rayon (Niveau 2 de nomenclature = 4 premiers chiffres du code) // 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 rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey);
const totalCaRayon = rayonProds.reduce((s, p) => s + (p.totalCa ?? 0), 0); const totalCaRayon = rayonProds.reduce((s, p) => s + (p.totalCa ?? 0), 0);
const totalQtyRayon = rayonProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0); const totalQtyRayon = rayonProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0);
// 3. Percentiles dans le lot fournisseur // 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant
const allCaValues = allProds.map(p => p.totalCa ?? 0); const allCaPerStore = allProds.map(normCa);
const allQtyValues = allProds.map(p => p.totalQuantite ?? 0); const allQtyPerStore = allProds.map(normQty);
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0); const allMargeValues = allProds.map(p => p.tauxMarge ?? 0);
const pCa = computePercentile(target.totalCa ?? 0, allCaValues); const pCa = computePercentile(targetCaPerStore, allCaPerStore);
const pQty = computePercentile(target.totalQuantite ?? 0, allQtyValues); const pQty = computePercentile(targetQtyPerStore, allQtyPerStore);
const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues); const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues);
const pComposite = scoring.compositeScore; const pComposite = scoring.compositeScore;
// 4. Tops adaptatifs // 4. Tops 20% (sur valeurs PAR MAGASIN)
const top20CaThreshold = valueAtPercentile(allCaValues, 80); const top20CaThreshold = valueAtPercentile(allCaPerStore, 80);
const top20QtyThreshold = valueAtPercentile(allQtyValues, 80); const top20QtyThreshold = valueAtPercentile(allQtyPerStore, 80);
const isTop20Ca = (target.totalCa ?? 0) >= top20CaThreshold; const isTop20Ca = targetCaPerStore >= top20CaThreshold;
const isTop20Qty = (target.totalQuantite ?? 0) >= top20QtyThreshold; const isTop20Qty = targetQtyPerStore >= top20QtyThreshold;
const isAboveMedianComposite = pComposite >= 50; const isAboveMedianComposite = pComposite >= 50;
// 5. Signal négatif fort : contribution insignifiante au fournisseur // 5. Poids bruts (valeurs réseau pour représentativité commerciale réelle)
const weightCaFournisseur = const weightCaFournisseur =
totalCaFournisseur > 0 totalCaFournisseur > 0
? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10 ? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10
@@ -193,10 +219,9 @@ export class ContextProfiler {
? Math.round(((target.totalQuantite ?? 0) / totalQtyFournisseur) * 1000) / 10 ? Math.round(((target.totalQuantite ?? 0) / totalQtyFournisseur) * 1000) / 10
: 0; : 0;
// isLowContribution = vrai si le produit représente < 0.5% du CA ET < 0.5% des QTÉ
const isLowContribution = weightCaFournisseur < 0.5 && weightQtyFournisseur < 0.5; const isLowContribution = weightCaFournisseur < 0.5 && weightQtyFournisseur < 0.5;
// 6. Signaux Trafic / Marge — désactivés si rayon trop petit (bruit statistique) // 6. Signaux Trafic / Marge (sur valeurs PAR MAGASIN)
const rayonSizeForSignals = rayonProds.length; const rayonSizeForSignals = rayonProds.length;
const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE; const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE;
@@ -206,29 +231,29 @@ export class ContextProfiler {
if (signalsActive) { if (signalsActive) {
const marge40 = valueAtPercentile(allMargeValues, 40); const marge40 = valueAtPercentile(allMargeValues, 40);
const marge70 = valueAtPercentile(allMargeValues, 70); const marge70 = valueAtPercentile(allMargeValues, 70);
const qty60 = valueAtPercentile(allQtyValues, 60); const qty60PerStore = valueAtPercentile(allQtyPerStore, 60);
const medianQty = computeMedian(allQtyValues); const medianQtyPerStore = computeMedian(allQtyPerStore);
isHighVolumeWithLowMargin = isHighVolumeWithLowMargin =
(target.totalQuantite ?? 0) >= qty60 && targetQtyPerStore >= qty60PerStore &&
(target.tauxMarge ?? 0) < marge40; (target.tauxMarge ?? 0) < marge40;
isMargePure = isMargePure =
(target.tauxMarge ?? 0) >= marge70 && (target.tauxMarge ?? 0) >= marge70 &&
(target.totalQuantite ?? 0) < medianQty; targetQtyPerStore < medianQtyPerStore;
} }
// 7. Quadrant (basé sur les médianes du lot fournisseur) // 7. Quadrant (basé sur les médianes PAR MAGASIN)
const medianQty = computeMedian(allQtyValues); const medianQtyPerStore = computeMedian(allQtyPerStore);
const medianMarge = computeMedian(allMargeValues); const medianMarge = computeMedian(allMargeValues);
const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant( const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant(
target.totalQuantite ?? 0, targetQtyPerStore,
target.tauxMarge ?? 0, target.tauxMarge ?? 0,
medianQty, medianQtyPerStore,
medianMarge medianMarge
); );
// 8. Gardes-fous (issus du ScoringEngine) // 8. Gardes-fous
const isProtected = const isProtected =
scoring.decision.isRecent || scoring.decision.isRecent ||
scoring.decision.isTop30Supplier || scoring.decision.isTop30Supplier ||
@@ -247,9 +272,13 @@ export class ContextProfiler {
libelle1: target.libelle1, libelle1: target.libelle1,
libelleNiveau2: target.libelleNiveau2 ?? "Général", libelleNiveau2: target.libelleNiveau2 ?? "Général",
quadrant, quadrant, quadrantLabel, quadrantEmoji,
quadrantLabel,
quadrantEmoji, storeCount: targetStoreCount,
totalCaRaw: target.totalCa ?? 0,
totalQtyRaw: target.totalQuantite ?? 0,
caPerStore: targetCaPerStore,
qtyPerStore: targetQtyPerStore,
percentileCa: pCa, percentileCa: pCa,
percentileQty: pQty, percentileQty: pQty,
@@ -283,7 +312,6 @@ export class ContextProfiler {
isProtected, isProtected,
protectionReason, protectionReason,
scoreCritique, scoreCritique,
}; };
} }
@@ -91,7 +91,7 @@ export class ScoringEngine {
// 7. Gardes-fous et Labels // 7. Gardes-fous et Labels
const isRecent = (target.regularityScore || 0) < 3; const isRecent = (target.regularityScore || 0) < 3;
const isTop30Supplier = this.checkTop30Supplier(target, processedProducts); const isTop30Supplier = this.checkTop30Supplier(target, processedProducts, compositeScore);
const isLastProduct = target.isLastProductOfSupplier || false; const isLastProduct = target.isLastProductOfSupplier || false;
let finalLabel = labelProfil; let finalLabel = labelProfil;
@@ -167,15 +167,40 @@ export class ScoringEngine {
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
} }
private static checkTop30Supplier(target: any, allProducts: any[]): boolean { /**
const supplierProds = allProducts.filter(p => p.codeFournisseur === target.codeFournisseur); * Vérifie si le produit est dans le Top 30% CA de son lot fournisseur.
*
* IMPORTANT : La protection n'est accordée que si le compositeScore est
* suffisamment solide (>= 30/100). Sans ce garde-fou, on protège des
* produits "Top 30% CA" qui ont en réalité un score catastrophique —
* par exemple parce que leur CA est gonflé par un seul mois exceptionnel
* ou parce que le lot fournisseur est très petit.
*
* Le seuil de 30% s'applique sur les valeurs normalisées (projetées
* sur 12 mois et pondérées par réseau) pour éviter qu'un produit
* avec beaucoup de mois de présence soit défavorisé.
*/
private static checkTop30Supplier(
target: any,
allProducts: any[],
compositeScore: number
): boolean {
// Condition minimale de performance — si le score composite est < 30,
// la protection CA seule ne suffit pas à classer en A.
if (compositeScore < 30) return false;
// Tous les produits du lot sont du même fournisseur en pratique,
// mais on garde le filtre pour être robuste.
const supplierProds = allProducts.length > 0 ? allProducts : [];
if (supplierProds.length === 0) return false; if (supplierProds.length === 0) return false;
const sorted = supplierProds.sort((a, b) => b.caNormalise - a.caNormalise);
const sorted = [...supplierProds].sort((a, b) => b.caNormalise - a.caNormalise);
const topCount = Math.ceil(supplierProds.length * 0.30); const topCount = Math.ceil(supplierProds.length * 0.30);
const topIds = sorted.slice(0, topCount).map(p => p.codein); const topIds = sorted.slice(0, topCount).map((p) => p.codein);
return topIds.includes(target.codein); return topIds.includes(target.codein);
} }
private static calculateQuickScore(p: any, rayon: any[], medVol: number, medMarge: number, isSaisonnier: boolean): number { private static calculateQuickScore(p: any, rayon: any[], medVol: number, medMarge: number, isSaisonnier: boolean): number {
const pCa = this.calculatePercentile(p.caNormalise, rayon.map(x => x.caNormalise)); const pCa = this.calculatePercentile(p.caNormalise, rayon.map(x => x.caNormalise));
const pVol = this.calculatePercentile(p.volNormalise, rayon.map(x => x.volNormalise)); const pVol = this.calculatePercentile(p.volNormalise, rayon.map(x => x.volNormalise));