mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-15 11:18:42 +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").
|
||||
|
||||
Reference in new issue
Block a user