mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 14:08:08 +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
|
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));
|
||||||
|
|||||||
Reference in new issue
Block a user