mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 09:46:56 +02:00
feat: Introduce AI analysis engine for product A/Z recommendations based on detailed business rules.
This commit is contained in:
1 parent
e96bfa818d
commit
95795ce7e3
6 files changed
+199
-90
No files matched your search
@@ -32,11 +32,17 @@ Si une règle manager est définie (section "RÈGLE MANAGER" dans le message) ET
|
||||
|
||||
Si AUCUNE règle manager n'est fournie → Passer directement aux règles 2 et 3.
|
||||
|
||||
⛔ RÈGLE 2 — STOCK MORT ABSOLU:
|
||||
⛔ RÈGLE 2 — STOCK MORT ABSOLU (NON NÉGOCIABLE, PRIORITÉ ABSOLUE APRÈS RÈGLE 1):
|
||||
SI CA réseau < 100€ ET Quantité réseau < 30 unités SIMULTANÉMENT → Z IMMÉDIAT, STOP.
|
||||
Les DEUX conditions doivent être vraies en même temps.
|
||||
Un produit à 30€ de CA sur 2 magasins (15€/magasin/an) coûte plus en gestion qu'il ne rapporte.
|
||||
|
||||
⚠️ CETTE RÈGLE ANNULE LES RÈGLES 3, 4, 5 ET 6 SANS EXCEPTION :
|
||||
→ Même si le produit semble "entrant" (H1=0, H2>0), même si le score relatif est élevé,
|
||||
même si réapprovisionnement récent, même si forte asymétrie magasin.
|
||||
→ 8€ de CA/an = Z. La tendance ne change RIEN à cette réalité économique.
|
||||
→ Si Règle 2 s'applique : recommendation = "Z", rule_applies = false, STOP.
|
||||
|
||||
📊 RÈGLE 3 — PERFORMANCE RELATIVE AU FOURNISSEUR:
|
||||
Le produit doit être évalué PAR RAPPORT à la moyenne de son lot fournisseur.
|
||||
|
||||
@@ -59,11 +65,12 @@ RATIONALE : Cette logique s'adapte automatiquement à TOUS les fournisseurs:
|
||||
- Même score relatif = même décision, indépendamment de la taille du fournisseur
|
||||
|
||||
📈 RÈGLE 4 — TENDANCE (si tableau mensuel disponible, sinon ignorer) :
|
||||
Si le tableau "COMPORTEMENT PAR MAGASIN" est fourni, calcule la tendance sur les 12 mois.
|
||||
⚠️ PRÉ-REQUIS : La RÈGLE 2 prime sur cette règle. Si CA < 100€ ET Qty < 30 → Z, ne pas appliquer Règle 4.
|
||||
Si le tableau "COMPORTEMENT PAR MAGASIN" est fourni ET que la Règle 2 ne s'applique pas :
|
||||
Compare les ventes des 6 premiers mois (H1) vs les 6 derniers mois (H2) sur l'ensemble des magasins.
|
||||
• H2 / H1 < 0.4 → effondrement des ventes → renforcer Z, même si score relatif est entre 50 et 100
|
||||
• H2 / H1 > 2.0 → accélération forte → protéger de Z, même si score relatif est entre 50 et 100
|
||||
• H1 = 0 et H2 > 0 → produit entrant (nouvelles ventes récentes) → ne pas sortir, voter A
|
||||
• H1 = 0 et H2 > 0 → produit entrant (nouvelles ventes récentes) → ne pas sortir, voter A — UNIQUEMENT si CA ≥ 100€ ET Qty ≥ 30
|
||||
• Sinon → tendance neutre, utiliser le score relatif (Règle 3) comme décision principale
|
||||
|
||||
📦 RÈGLE 5 — RÉAPPROVISIONNEMENT RÉCENT (si tableau mensuel disponible, sinon ignorer) :
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
*/
|
||||
|
||||
import type { ProductAnalysisInput } from "../models/ai-analysis.types";
|
||||
import type { ScoringResult } from "./scoring-engine";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constante : seuil minimal de produits dans un rayon pour activer les signaux
|
||||
@@ -266,7 +265,7 @@ export class ContextProfiler {
|
||||
static buildProfile(
|
||||
target: ProductAnalysisInput,
|
||||
allProds: ProductAnalysisInput[],
|
||||
scoring: ScoringResult,
|
||||
score: number,
|
||||
cache?: ContextProfilerCache
|
||||
): ProductContextProfile {
|
||||
if (allProds.length === 0) {
|
||||
@@ -298,7 +297,7 @@ export class ContextProfiler {
|
||||
const pCa = computePercentileFromSorted(targetCaPerStore, ctx.allCaPerStoreSorted);
|
||||
const pQty = computePercentileFromSorted(targetQtyPerStore, ctx.allQtyPerStoreSorted);
|
||||
const pMarge = computePercentileFromSorted(target.tauxMarge ?? 0, ctx.allMargeValuesSorted);
|
||||
const pComposite = scoring.compositeScore;
|
||||
const pComposite = score;
|
||||
|
||||
// 4. Tops 20%
|
||||
const isTop20Ca = targetCaPerStore >= ctx.top20CaThreshold;
|
||||
@@ -354,18 +353,23 @@ export class ContextProfiler {
|
||||
);
|
||||
|
||||
// 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final)
|
||||
const isProtected =
|
||||
scoring.decision.isRecent ||
|
||||
scoring.decision.isTop30Supplier ||
|
||||
scoring.decision.isLastProduct;
|
||||
// Les flags isRecent/isTop30Supplier/isLastProduct sont désormais
|
||||
// injectés directement sur ProductRow par score-engine.ts.
|
||||
// On les lit depuis le scoring payload transmis par le bulk analyzer.
|
||||
const targetExt = target as ProductAnalysisInput & { isRecent?: boolean; isTop30Supplier?: boolean; isLastProduct?: boolean };
|
||||
const isRecentFlag = targetExt.isRecent ?? false;
|
||||
const isTop30Flag = targetExt.isTop30Supplier ?? false;
|
||||
const isLastFlag = targetExt.isLastProduct ?? false;
|
||||
|
||||
const isProtected = isRecentFlag || isTop30Flag || isLastFlag;
|
||||
|
||||
let protectionReason = "";
|
||||
if (scoring.decision.isRecent) protectionReason = "Nouveauté (< 3 mois de données)";
|
||||
else if (scoring.decision.isTop30Supplier) protectionReason = "Top 30% CA Fournisseur";
|
||||
else if (scoring.decision.isLastProduct) protectionReason = "Dernière référence du fournisseur";
|
||||
if (isRecentFlag) protectionReason = "Nouveauté (< 3 mois de données)";
|
||||
else if (isTop30Flag) protectionReason = "Top 30% CA Fournisseur";
|
||||
else if (isLastFlag) protectionReason = "Dernière référence du fournisseur";
|
||||
|
||||
// 9. Règle absolue
|
||||
const scoreCritique = (target.score ?? 0) < 20;
|
||||
const scoreCritique = score < 20;
|
||||
|
||||
return {
|
||||
codein: target.codein,
|
||||
|
||||
@@ -35,6 +35,13 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stock mort absolu : CA < 100€ ET quantité < 30 → Z direct, aucune analyse IA
|
||||
if ((row.totalCa ?? 0) < 100 && (row.totalQuantite ?? 0) < 30) {
|
||||
setDraftGamme(row.codein, "Z");
|
||||
useAiCopilotStore.getState().setInsight(row.codein, `CA < 100€ (${(row.totalCa ?? 0).toFixed(0)}€) et quantité < 30 (${row.totalQuantite} uté) — stock mort absolu, classé Z automatiquement.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset Visuel Immédiat
|
||||
setLoading(row.codein);
|
||||
setDraftGamme(row.codein, "Aucune");
|
||||
|
||||
@@ -153,8 +153,25 @@ export function BulkAiAnalyzer() {
|
||||
});
|
||||
}
|
||||
|
||||
// Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente
|
||||
const payloadsWithSales = initialPayloads.filter(p => (p.totalQuantite || 0) > 0);
|
||||
// 2b. Stock mort absolu : CA < 100€ ET quantité < 30 → Z direct, sans appel IA
|
||||
const deadStockRows = rows.filter(r =>
|
||||
(r.totalQuantite || 0) > 0 &&
|
||||
(r.totalCa ?? 0) < 100 && (r.totalQuantite ?? 0) < 30
|
||||
);
|
||||
if (deadStockRows.length > 0) {
|
||||
const zChanges: Record<string, GammeCode> = {};
|
||||
deadStockRows.forEach(r => { zChanges[r.codein] = "Z"; });
|
||||
batchSetDraftGamme(zChanges);
|
||||
deadStockRows.forEach(r => {
|
||||
setInsight(r.codein, `CA < 100€ (${(r.totalCa ?? 0).toFixed(0)}€) et quantité < 30 (${r.totalQuantite} uté) — stock mort absolu, classé Z automatiquement.`);
|
||||
});
|
||||
}
|
||||
const deadStockCodes = new Set(deadStockRows.map(r => r.codein));
|
||||
|
||||
// Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente et CA/quantité suffisants
|
||||
const payloadsWithSales = initialPayloads.filter(p =>
|
||||
(p.totalQuantite || 0) > 0 && !deadStockCodes.has(p.codein)
|
||||
);
|
||||
|
||||
// 2b. Calculer scoring + context profiling en micro-batches asynchrones
|
||||
// pour ne pas bloquer le thread UI (Chrome "Page ne répondant pas").
|
||||
|
||||
+137
-73
@@ -1,32 +1,62 @@
|
||||
/**
|
||||
* CollectFlow — Score Engine v3 (Percentile Composite)
|
||||
* CollectFlow — Score Engine v4 (Hybride Absolu + Relatif)
|
||||
*
|
||||
* Calcule le score de performance d'un produit par rang percentile au sein
|
||||
* de son lot fournisseur sur 3 axes : CA, Quantité, Taux de Marge.
|
||||
* Calcule le score de performance d'un produit en combinant :
|
||||
* - Performance ABSOLUE : Volume (50pts), CA (30pts), Marge (20pts)
|
||||
* - Ajustement RELATIF : Position dans le lot fournisseur (±10pts)
|
||||
* - Pénalité de régularité : multiplicateur si inactivité prolongée
|
||||
*
|
||||
* Formule :
|
||||
* Score = (40% × Percentile_CA) + (35% × Percentile_Volume) + (25% × Percentile_Marge)
|
||||
*
|
||||
* Où chaque Percentile_Axe est le rang du produit dans son lot fournisseur (0-100).
|
||||
* Score final : 0-100, sensible au prix unitaire.
|
||||
*
|
||||
* Exemples :
|
||||
* - Score = 100 → Meilleur produit du fournisseur sur tous les axes
|
||||
* - Score = 50 → Produit médian du fournisseur
|
||||
* - Score = 0 → Dernier produit du fournisseur sur tous les axes
|
||||
*
|
||||
* Avantages :
|
||||
* - Borné 0-100 (plus de scores > 150 pour des petits fournisseurs)
|
||||
* - Robuste aux fournisseurs avec peu de références ou faibles volumes
|
||||
* - Cohérent avec le scoring IA (scoring-engine.ts) qui utilise aussi les percentiles
|
||||
* - 0.80€ × 100 unités (80€ CA) → ~53 (bon trafic malgré petit CA)
|
||||
* - 40€ × 2 unités (80€ CA) → ~19 (faible rotation)
|
||||
* - 500€ CA, 200u, 45% marge → ~90 (star)
|
||||
* - 8€ CA, 2 unités → ~13 (stock mort)
|
||||
*/
|
||||
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
/**
|
||||
* Calcule les rangs percentiles (0-100) pour un tableau de valeurs numériques.
|
||||
* Les ex-æquo reçoivent le rang moyen de leur groupe.
|
||||
* Cas limite : tableau à 1 élément → [50] (médiane).
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Interpolation linéaire entre deux paliers */
|
||||
function lerp(value: number, minVal: number, maxVal: number, minPts: number, maxPts: number): number {
|
||||
if (value >= maxVal) return maxPts;
|
||||
if (value <= minVal) return minPts;
|
||||
return minPts + ((value - minVal) / (maxVal - minVal)) * (maxPts - minPts);
|
||||
}
|
||||
|
||||
/** Score Volume (0-50) basé sur unités / magasin / mois */
|
||||
function computeVolumeScore(unitsPerStorePerMonth: number): number {
|
||||
if (unitsPerStorePerMonth >= 10) return 50;
|
||||
if (unitsPerStorePerMonth >= 5) return lerp(unitsPerStorePerMonth, 5, 10, 40, 50);
|
||||
if (unitsPerStorePerMonth >= 2) return lerp(unitsPerStorePerMonth, 2, 5, 30, 40);
|
||||
if (unitsPerStorePerMonth >= 1) return lerp(unitsPerStorePerMonth, 1, 2, 20, 30);
|
||||
if (unitsPerStorePerMonth >= 0.5) return lerp(unitsPerStorePerMonth, 0.5, 1, 10, 20);
|
||||
return lerp(unitsPerStorePerMonth, 0, 0.5, 0, 10);
|
||||
}
|
||||
|
||||
/** Score CA (0-30) basé sur CA € / magasin / an */
|
||||
function computeCaScore(caPerStorePerYear: number): number {
|
||||
if (caPerStorePerYear >= 500) return 30;
|
||||
if (caPerStorePerYear >= 200) return lerp(caPerStorePerYear, 200, 500, 22, 30);
|
||||
if (caPerStorePerYear >= 100) return lerp(caPerStorePerYear, 100, 200, 15, 22);
|
||||
if (caPerStorePerYear >= 50) return lerp(caPerStorePerYear, 50, 100, 8, 15);
|
||||
return lerp(caPerStorePerYear, 0, 50, 0, 8);
|
||||
}
|
||||
|
||||
/** Score Marge (0-20) basé sur taux de marge % */
|
||||
function computeMargeScore(tauxMarge: number): number {
|
||||
if (tauxMarge >= 50) return 20;
|
||||
if (tauxMarge >= 40) return lerp(tauxMarge, 40, 50, 16, 20);
|
||||
if (tauxMarge >= 30) return lerp(tauxMarge, 30, 40, 12, 16);
|
||||
if (tauxMarge >= 20) return lerp(tauxMarge, 20, 30, 8, 12);
|
||||
return lerp(tauxMarge, 0, 20, 0, 8);
|
||||
}
|
||||
|
||||
/** Rangs percentiles (0-100) pour un tableau de valeurs numériques. */
|
||||
function computePercentileRanks(values: number[]): number[] {
|
||||
const n = values.length;
|
||||
if (n === 0) return [];
|
||||
@@ -40,7 +70,6 @@ function computePercentileRanks(values: number[]): number[] {
|
||||
while (i < n) {
|
||||
let j = i;
|
||||
while (j < n && indexed[j].v === indexed[i].v) j++;
|
||||
// Rang moyen pour les ex-æquo, normalisé 0-100
|
||||
const avgRank = ((i + j - 1) / 2) / (n - 1) * 100;
|
||||
for (let k = i; k < j; k++) {
|
||||
ranks[indexed[k].i] = Math.round(avgRank);
|
||||
@@ -51,54 +80,62 @@ function computePercentileRanks(values: number[]): number[] {
|
||||
return ranks;
|
||||
}
|
||||
|
||||
export interface ScoreSettings {
|
||||
/** Pondération CA (défaut: 40%) */
|
||||
weightCA: number;
|
||||
/** Pondération Volume (défaut: 35%) */
|
||||
weightVolume: number;
|
||||
/** Pondération Marge (défaut: 25%) */
|
||||
weightMarge: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCORE_SETTINGS: ScoreSettings = {
|
||||
weightCA: 0.40,
|
||||
weightVolume: 0.35,
|
||||
weightMarge: 0.25,
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// API publique
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Calcule les scores pour un ensemble de produits d'un même fournisseur.
|
||||
* Les rows sont modifiées en place (champ `score`) et retournées.
|
||||
* Les rows sont modifiées en place et retournées.
|
||||
*/
|
||||
export function computeProductScores(
|
||||
rows: ProductRow[],
|
||||
settings: ScoreSettings = DEFAULT_SCORE_SETTINGS
|
||||
): ProductRow[] {
|
||||
export function computeProductScores(rows: ProductRow[]): ProductRow[] {
|
||||
if (rows.length === 0) return rows;
|
||||
|
||||
// 1. Normalisation multi-magasins : pondérer les produits en 1 magasin (×2)
|
||||
// pour comparer équitablement avec ceux en 2 magasins
|
||||
// 1. Données pondérées pour normalisation multi-magasins
|
||||
const weightedData = rows.map((r) => {
|
||||
const storeCount = r.workingStores?.length || 1;
|
||||
const storeCount = Math.max(1, r.workingStores?.length || 1);
|
||||
const weight = storeCount === 1 ? 2 : 1;
|
||||
|
||||
// Régularité : nombre de mois avec au moins 1 vente
|
||||
const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length;
|
||||
|
||||
// Inactivité : mois sans vente en fin de fenêtre
|
||||
const allMonths = Object.keys(r.sales12m || {});
|
||||
const refMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : "";
|
||||
const salesMonths = Object.entries(r.sales12m || {})
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([m]) => parseInt(m))
|
||||
.sort((a, b) => b - a);
|
||||
const lastSaleMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
|
||||
|
||||
let inactivity = 0;
|
||||
if (refMonth && lastSaleMonth) {
|
||||
const refY = parseInt(refMonth.substring(0, 4));
|
||||
const refM = parseInt(refMonth.substring(4, 6));
|
||||
const lastY = parseInt(lastSaleMonth.substring(0, 4));
|
||||
const lastM = parseInt(lastSaleMonth.substring(4, 6));
|
||||
inactivity = (refY - lastY) * 12 + (refM - lastM);
|
||||
}
|
||||
|
||||
return {
|
||||
row: r,
|
||||
storeCount,
|
||||
weight,
|
||||
wQty: (r.totalQuantite ?? 0) * weight,
|
||||
wCa: (r.totalCa ?? 0) * weight,
|
||||
wMarge: (r.totalMarge ?? 0) * weight,
|
||||
tauxMarge: Number.isFinite(r.tauxMarge) ? r.tauxMarge : 0, // Garde contre NaN
|
||||
tauxMarge: Number.isFinite(r.tauxMarge) ? r.tauxMarge : 0,
|
||||
regScore,
|
||||
inactivity,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Calculer les MOYENNES du fournisseur (pas les MAX !)
|
||||
// 2. Totaux fournisseur
|
||||
const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0);
|
||||
const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0);
|
||||
const totalMarge = weightedData.reduce((sum, d) => sum + d.tauxMarge, 0);
|
||||
|
||||
const avgQty = rows.length > 0 ? totalQty / rows.length : 0;
|
||||
|
||||
// 3. Calculer les totaux pour les poids (%)
|
||||
// 3. Totaux par rayon (code2)
|
||||
const rayonTotals = new Map<string, { total: number; count: number }>();
|
||||
weightedData.forEach((d) => {
|
||||
const r2 = d.row.code2 || "default";
|
||||
@@ -106,42 +143,69 @@ export function computeProductScores(
|
||||
rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 });
|
||||
});
|
||||
|
||||
// 4. Calcul des rangs percentiles par axe (0-100, borné)
|
||||
// 4. Percentiles dans le lot (pour le bonus relatif)
|
||||
const caPctRanks = computePercentileRanks(weightedData.map(d => d.wCa));
|
||||
const qtyPctRanks = computePercentileRanks(weightedData.map(d => d.wQty));
|
||||
const margePctRanks = computePercentileRanks(weightedData.map(d => d.tauxMarge));
|
||||
|
||||
// Validation des pondérations (garde contre undefined/NaN)
|
||||
const wCA = Number.isFinite(settings.weightCA) ? settings.weightCA : 0.40;
|
||||
const wVol = Number.isFinite(settings.weightVolume) ? settings.weightVolume : 0.35;
|
||||
const wMarge = Number.isFinite(settings.weightMarge) ? settings.weightMarge : 0.25;
|
||||
// 5. Seuil top 30% CA pour le flag isTop30Supplier
|
||||
const sortedCa = [...weightedData.map(d => d.wCa)].sort((a, b) => b - a);
|
||||
const top30Idx = Math.max(0, Math.ceil(sortedCa.length * 0.3) - 1);
|
||||
const top30CaThreshold = sortedCa[top30Idx] ?? 0;
|
||||
|
||||
// 5. Pour chaque produit : injecter contexte + score composite percentile
|
||||
// 6. Score de chaque produit
|
||||
for (let idx = 0; idx < weightedData.length; idx++) {
|
||||
const data = weightedData[idx];
|
||||
const d = weightedData[idx];
|
||||
const r = d.row;
|
||||
|
||||
// Métriques de contexte (pour l'IA et l'affichage)
|
||||
data.row.avgQtyFournisseur = avgQty;
|
||||
data.row.totalFournisseurCa = totalCa;
|
||||
// --- Métriques de contexte (inchangées, pour l'IA et l'affichage) ---
|
||||
r.avgQtyFournisseur = avgQty;
|
||||
r.totalFournisseurCa = totalCa;
|
||||
r.shareQty = totalQty > 0 ? (d.wQty / totalQty) * 100 : 0;
|
||||
r.shareCa = totalCa > 0 ? (d.wCa / totalCa) * 100 : 0;
|
||||
r.shareMarge = totalMarge > 0 ? (d.tauxMarge / totalMarge) * 100 : 0;
|
||||
|
||||
// Poids relatifs (%)
|
||||
data.row.shareQty = totalQty > 0 ? (data.wQty / totalQty) * 100 : 0;
|
||||
data.row.shareCa = totalCa > 0 ? (data.wCa / totalCa) * 100 : 0;
|
||||
data.row.shareMarge = totalMarge > 0 ? (data.tauxMarge / totalMarge) * 100 : 0;
|
||||
|
||||
const r2 = data.row.code2 || "default";
|
||||
const r2 = r.code2 || "default";
|
||||
const rayonStat = rayonTotals.get(r2);
|
||||
data.row.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
|
||||
r.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
|
||||
|
||||
// ================================================================
|
||||
// SCORE COMPOSITE PERCENTILE (0-100)
|
||||
// ================================================================
|
||||
const scoreComposite =
|
||||
wCA * caPctRanks[idx] +
|
||||
wVol * qtyPctRanks[idx] +
|
||||
wMarge * margePctRanks[idx];
|
||||
// --- KPIs normalisés (sensibles au prix) ---
|
||||
const activeMonths = Math.max(d.regScore, 3); // plancher 3 mois
|
||||
const unitsPerStorePerMonth = d.wQty / (d.storeCount * activeMonths);
|
||||
const caPerStorePerYear = d.wCa / d.storeCount;
|
||||
|
||||
data.row.score = Number.isFinite(scoreComposite) ? Math.round(scoreComposite * 10) / 10 : 0;
|
||||
r.unitsPerStorePerMonth = Math.round(unitsPerStorePerMonth * 100) / 100;
|
||||
r.caPerStorePerYear = Math.round(caPerStorePerYear * 100) / 100;
|
||||
|
||||
// --- Score absolu (Volume + CA + Marge = 0-100 pts) ---
|
||||
const volumeScore = computeVolumeScore(unitsPerStorePerMonth);
|
||||
const caScore = computeCaScore(caPerStorePerYear);
|
||||
const margeScore = computeMargeScore(d.tauxMarge);
|
||||
|
||||
// --- Bonus relatif (±10 pts) ---
|
||||
const pctComposite = (caPctRanks[idx] * 0.40 + qtyPctRanks[idx] * 0.35 + margePctRanks[idx] * 0.25);
|
||||
const relativeBonus = ((pctComposite - 50) / 50) * 10;
|
||||
|
||||
// --- Pénalité de régularité ---
|
||||
let regularityMultiplier = 1;
|
||||
if (d.regScore <= 2 && d.inactivity >= 6) {
|
||||
regularityMultiplier = 0.3;
|
||||
} else if (d.regScore <= 2 && d.inactivity >= 3) {
|
||||
regularityMultiplier = 0.5;
|
||||
} else if (d.inactivity >= 6) {
|
||||
regularityMultiplier = 0.5;
|
||||
} else if (d.inactivity >= 3) {
|
||||
regularityMultiplier = 0.7;
|
||||
}
|
||||
|
||||
// --- Score final ---
|
||||
const rawScore = (volumeScore + caScore + margeScore + relativeBonus) * regularityMultiplier;
|
||||
r.score = Math.round(Math.max(0, Math.min(100, rawScore)) * 10) / 10;
|
||||
|
||||
// --- Flags de protection ---
|
||||
r.isRecent = d.regScore <= 3;
|
||||
r.isLastProduct = rows.length === 1;
|
||||
r.isTop30Supplier = d.wCa >= top30CaThreshold && top30CaThreshold > 0;
|
||||
}
|
||||
|
||||
return rows;
|
||||
|
||||
+11
-1
@@ -56,9 +56,19 @@ export interface ProductRow {
|
||||
totalCa: number;
|
||||
totalMarge: number;
|
||||
tauxMarge: number;
|
||||
/** Combined performance score (0-10) */
|
||||
/** Score de performance hybride (0-100) */
|
||||
score: number;
|
||||
workingStores: string[];
|
||||
/** Rotation normalisée : unités vendues / magasin / mois */
|
||||
unitsPerStorePerMonth?: number;
|
||||
/** CA normalisé : € / magasin / an */
|
||||
caPerStorePerYear?: number;
|
||||
/** Produit récent (≤ 3 mois de données) */
|
||||
isRecent?: boolean;
|
||||
/** Seul produit du fournisseur dans le lot */
|
||||
isLastProduct?: boolean;
|
||||
/** Top 30% CA du lot fournisseur */
|
||||
isTop30Supplier?: boolean;
|
||||
aiRecommendation?: string | null;
|
||||
/** SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel */
|
||||
noid?: number;
|
||||
|
||||
Reference in new issue
Block a user