mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: implement product scoring engine to calculate composite scores and product recommendations based on various metrics and business rules.
This commit is contained in:
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));
|
||||
|
||||
Reference in new issue
Block a user