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").