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
): string {
const lines: string[] = [];
const storeLabel = ctx.storeCount > 1 ? `${ctx.storeCount} magasins` : `1 magasin`;
lines.push(`PRODUIT : ${ctx.libelle1} (${ctx.codein})`);
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("");
// Quadrant — présenté comme un indice, sans prescription
@@ -87,10 +97,10 @@ JSON uniquement, sans markdown.
lines.push("");
// Position / poids — données brutes
lines.push(`--- POSITION DANS LE LOT FOURNISSEUR ---`);
lines.push(`• CA : ${ctx.percentileCa}e percentile | Poids fournisseur : ${ctx.weightCaFournisseur}% | Poids rayon N2 : ${ctx.weightCaRayon}%`);
lines.push(`• Quantité : ${ctx.percentileQty}e percentile | Poids fournisseur : ${ctx.weightQtyFournisseur}% | Poids rayon N2 : ${ctx.weightQtyRayon}%`);
lines.push(`• Marge : ${ctx.percentileMarge}e percentile`);
lines.push(`--- POSITION DANS LE LOT FOURNISSEUR (percentiles sur valeurs par magasin) ---`);
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(`• 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(`• Score composite : ${ctx.percentileComposite}/100`);
lines.push("");
@@ -99,17 +109,17 @@ JSON uniquement, sans markdown.
// Signal rouge prioritaire
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) {
lines.push(`[⛔ SCORE CRITIQUE] Score brut < 20 → sous-seuil absolu`);
}
// Signaux positifs
lines.push(`${ctx.isTop20Ca ? "[✓]" : "[ ]"} Top 20% CA fournisseur`);
lines.push(`${ctx.isTop20Qty ? "[✓]" : "[ ]"} Top 20% Quantités fournisseur`);
lines.push(`${ctx.isHighVolumeWithLowMargin ? "[✓]" : "[ ]"} Fort volume (> P60 lot) avec marge faible`);
lines.push(`${ctx.isMargePure ? "[✓]" : "[ ]"} Forte marge (> P70 lot) malgré volume faible`);
lines.push(`${ctx.isTop20Ca ? "[✓]" : "[ ]"} Top 20% CA/magasin fournisseur`);
lines.push(`${ctx.isTop20Qty ? "[✓]" : "[ ]"} Top 20% Quantités/magasin fournisseur`);
lines.push(`${ctx.isHighVolumeWithLowMargin ? "[✓]" : "[ ]"} Fort volume/magasin (>P60 lot) avec marge faible`);
lines.push(`${ctx.isMargePure ? "[✓]" : "[ ]"} Forte marge (>P70 lot) malgré volume/magasin faible`);
lines.push(`${ctx.isAboveMedianComposite ? "[✓]" : "[ ]"} Au-dessus de la médiane composite`);
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" : ""})`);
@@ -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
* AVANT de le soumettre à l'IA.
*
* v2 — Correctifs :
* - Ajout du signal `isLowContribution` (poids CA ET QTÉ < 0.5% du fournisseur)
* - Désactivation des signaux Trafic/Marge si rayonSize < MIN_RAYON_SIZE (évite
* les faux positifs dans les micro-rayons de 3-5 produits).
* - Le profiler ne prescrit plus de verdict : il produit des données brutes
* que le prompt de Mary interprète avec un guide de décision pondéré.
* v3 — Correctifs :
* - Normalisation par `storeCount` : tous les calculs de percentile, poids
* et quadrant utilisent les valeurs PAR MAGASIN (CA/store, QTÉ/store).
* Cela évite qu'un produit en 2 magasins soit mécaniquement favorisé
* dans les comparaisons par rapport à un produit en 1 seul magasin.
* - 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";
@@ -33,18 +34,31 @@ export interface ProductContextProfile {
libelle1: string;
libelleNiveau2: string;
// Profil Quadrant
// Profil Quadrant (basé sur valeurs PAR MAGASIN pour comparaison équitable)
quadrant: Quadrant;
quadrantLabel: 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)
// Calculés sur les valeurs normalisées par magasin
percentileCa: number;
percentileQty: number;
percentileMarge: number;
percentileComposite: number;
// 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
weightQtyFournisseur: number; // % des QTÉ totales fournisseur
@@ -63,11 +77,11 @@ export interface ProductContextProfile {
// Signaux positifs (calculés sur la distribution réelle)
isAboveMedianComposite: boolean;
isTop20Ca: boolean;
isTop20Qty: boolean;
isTop20Ca: boolean; // Top 20% sur valeur PAR MAGASIN
isTop20Qty: boolean; // Top 20% sur valeur PAR MAGASIN
/**
* 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;
/**
@@ -79,8 +93,6 @@ export interface ProductContextProfile {
// Signal négatif fort
/**
* 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;
@@ -89,7 +101,7 @@ export interface ProductContextProfile {
protectionReason: string;
// 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.");
}
// 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 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 rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey);
const totalCaRayon = rayonProds.reduce((s, p) => s + (p.totalCa ?? 0), 0);
const totalQtyRayon = rayonProds.reduce((s, p) => s + (p.totalQuantite ?? 0), 0);
// 3. Percentiles dans le lot fournisseur
const allCaValues = allProds.map(p => p.totalCa ?? 0);
const allQtyValues = allProds.map(p => p.totalQuantite ?? 0);
// 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant
const allCaPerStore = allProds.map(normCa);
const allQtyPerStore = allProds.map(normQty);
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0);
const pCa = computePercentile(target.totalCa ?? 0, allCaValues);
const pQty = computePercentile(target.totalQuantite ?? 0, allQtyValues);
const pCa = computePercentile(targetCaPerStore, allCaPerStore);
const pQty = computePercentile(targetQtyPerStore, allQtyPerStore);
const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues);
const pComposite = scoring.compositeScore;
// 4. Tops adaptatifs
const top20CaThreshold = valueAtPercentile(allCaValues, 80);
const top20QtyThreshold = valueAtPercentile(allQtyValues, 80);
const isTop20Ca = (target.totalCa ?? 0) >= top20CaThreshold;
const isTop20Qty = (target.totalQuantite ?? 0) >= top20QtyThreshold;
// 4. Tops 20% (sur valeurs PAR MAGASIN)
const top20CaThreshold = valueAtPercentile(allCaPerStore, 80);
const top20QtyThreshold = valueAtPercentile(allQtyPerStore, 80);
const isTop20Ca = targetCaPerStore >= top20CaThreshold;
const isTop20Qty = targetQtyPerStore >= top20QtyThreshold;
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 =
totalCaFournisseur > 0
? Math.round(((target.totalCa ?? 0) / totalCaFournisseur) * 1000) / 10
@@ -193,10 +219,9 @@ export class ContextProfiler {
? Math.round(((target.totalQuantite ?? 0) / totalQtyFournisseur) * 1000) / 10
: 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;
// 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 signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE;
@@ -206,29 +231,29 @@ export class ContextProfiler {
if (signalsActive) {
const marge40 = valueAtPercentile(allMargeValues, 40);
const marge70 = valueAtPercentile(allMargeValues, 70);
const qty60 = valueAtPercentile(allQtyValues, 60);
const medianQty = computeMedian(allQtyValues);
const qty60PerStore = valueAtPercentile(allQtyPerStore, 60);
const medianQtyPerStore = computeMedian(allQtyPerStore);
isHighVolumeWithLowMargin =
(target.totalQuantite ?? 0) >= qty60 &&
targetQtyPerStore >= qty60PerStore &&
(target.tauxMarge ?? 0) < marge40;
isMargePure =
(target.tauxMarge ?? 0) >= marge70 &&
(target.totalQuantite ?? 0) < medianQty;
targetQtyPerStore < medianQtyPerStore;
}
// 7. Quadrant (basé sur les médianes du lot fournisseur)
const medianQty = computeMedian(allQtyValues);
// 7. Quadrant (basé sur les médianes PAR MAGASIN)
const medianQtyPerStore = computeMedian(allQtyPerStore);
const medianMarge = computeMedian(allMargeValues);
const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant(
target.totalQuantite ?? 0,
targetQtyPerStore,
target.tauxMarge ?? 0,
medianQty,
medianQtyPerStore,
medianMarge
);
// 8. Gardes-fous (issus du ScoringEngine)
// 8. Gardes-fous
const isProtected =
scoring.decision.isRecent ||
scoring.decision.isTop30Supplier ||
@@ -247,9 +272,13 @@ export class ContextProfiler {
libelle1: target.libelle1,
libelleNiveau2: target.libelleNiveau2 ?? "Général",
quadrant,
quadrantLabel,
quadrantEmoji,
quadrant, quadrantLabel, quadrantEmoji,
storeCount: targetStoreCount,
totalCaRaw: target.totalCa ?? 0,
totalQtyRaw: target.totalQuantite ?? 0,
caPerStore: targetCaPerStore,
qtyPerStore: targetQtyPerStore,
percentileCa: pCa,
percentileQty: pQty,
@@ -283,7 +312,6 @@ export class ContextProfiler {
isProtected,
protectionReason,
scoreCritique,
};
}
@@ -91,7 +91,7 @@ export class ScoringEngine {
// 7. Gardes-fous et Labels
const isRecent = (target.regularityScore || 0) < 3;
const isTop30Supplier = this.checkTop30Supplier(target, processedProducts);
const isTop30Supplier = this.checkTop30Supplier(target, processedProducts, compositeScore);
const isLastProduct = target.isLastProductOfSupplier || false;
let finalLabel = labelProfil;
@@ -167,15 +167,40 @@ export class ScoringEngine {
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;
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 topIds = sorted.slice(0, topCount).map(p => p.codein);
const topIds = sorted.slice(0, topCount).map((p) => p.codein);
return topIds.includes(target.codein);
}
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 pVol = this.calculatePercentile(p.volNormalise, rayon.map(x => x.volNormalise));