feat: Introduce AI analysis engine for product A/Z recommendations based on detailed business rules.

This commit is contained in:
Michael committed 2026-03-18 22:15:14 +01:00
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
View File
@@ -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
View File
@@ -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;