feat: Introduce bulk AI analyzer component to pre-classify products and prepare data for AI analysis, including context profiling.

This commit is contained in:
Michael committed 2026-03-19 13:19:49 +01:00
1 parent a4528c6d14
commit aa69637f98
6 files changed
+100 -18

No files matched your search

@@ -56,6 +56,12 @@ Si AUCUNE condition d'ajustement n'est remplie → CONFIRME le verdict pré-calc
Note dans la justification (sans changer la décision) : Note dans la justification (sans changer la décision) :
• Si un magasin représente ≥ 80% des ventes → "porté par [magasin]" • Si un magasin représente ≥ 80% des ventes → "porté par [magasin]"
• Si produit protégé (nouveauté, dernière ref fournisseur) → le mentionner • Si produit protégé (nouveauté, dernière ref fournisseur) → le mentionner
• Si un RANKING est fourni, mentionne la position et le percentile du produit (ex: "classé 45e / 5 000 produits, top 1%")
→ Le ranking est un signal contextuel qui RENFORCE le verdict :
- Top 5% du réseau → signal fort pour garder en A
- Top 20% du réseau → signal modéré pour garder en A
- Bottom 30% du réseau → signal défavorable, cohérent avec Z
→ Le ranking seul ne change PAS le verdict, mais combiné avec d'autres signaux il peut justifier un ajustement
--- FORMAT OBLIGATOIRE --- --- FORMAT OBLIGATOIRE ---
JSON uniquement, sans markdown. JSON uniquement, sans markdown.
@@ -163,6 +169,25 @@ SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamai
lines.push(`• Poids CA fournisseur : ${ctx.weightCaFournisseur.toFixed(1)}%`); lines.push(`• Poids CA fournisseur : ${ctx.weightCaFournisseur.toFixed(1)}%`);
lines.push(""); lines.push("");
// Ranking réseau & magasin (avec percentile contextuel)
if (p.rankingCa != null || p.rankingQte != null || p.rankingMagCa != null || p.rankingMagQte != null) {
const total = p.totalRankedProducts ?? 0;
const totalLabel = total > 0 ? ` / ${total.toLocaleString('fr-FR')} produits` : "";
lines.push(`--- RANKING RÉSEAU (${total > 0 ? total.toLocaleString('fr-FR') : "?"} produits vendus sur la période) ---`);
if (p.rankingCa != null) {
const pctCa = total > 0 ? ((p.rankingCa / total) * 100).toFixed(1) : "?";
lines.push(`• Classement CA réseau : ${p.rankingCa}e${totalLabel} (top ${pctCa}%)`);
}
if (p.rankingQte != null) {
const pctQte = total > 0 ? ((p.rankingQte / total) * 100).toFixed(1) : "?";
lines.push(`• Classement Qté réseau : ${p.rankingQte}e${totalLabel} (top ${pctQte}%)`);
}
if (p.rankingMagCa != null) lines.push(`• Classement CA magasin : ${p.rankingMagCa}e`);
if (p.rankingMagQte != null) lines.push(`• Classement Qté magasin : ${p.rankingMagQte}e`);
lines.push(`• Contexte : ce fournisseur a ${ctx.lotSize} produits dans le lot analysé`);
lines.push("");
}
// Signaux pré-calculés (si données mensuelles disponibles) // Signaux pré-calculés (si données mensuelles disponibles)
if (p.siteMonthlyData && p.siteMonthlyData.length > 0) { if (p.siteMonthlyData && p.siteMonthlyData.length > 0) {
const signals = AnalysisEngine.computeSignals(p.siteMonthlyData); const signals = AnalysisEngine.computeSignals(p.siteMonthlyData);
@@ -291,7 +316,8 @@ Famille / Rayon : ${p.libelleNiveau2 ?? "N/A"}
• Quantité : ${p.totalQuantite} unités • Quantité : ${p.totalQuantite} unités
• Marge : ${p.tauxMarge.toFixed(1)}% • Marge : ${p.tauxMarge.toFixed(1)}%
• PMV : ${pmv.toFixed(2)}€ • PMV : ${pmv.toFixed(2)}€
${p.shareCa !== undefined ? `• Poids CA Fournisseur : ${p.shareCa.toFixed(1)}%` : ""}${contextRules} ${p.shareCa !== undefined ? `• Poids CA Fournisseur : ${p.shareCa.toFixed(1)}%` : ""}
${p.rankingCa != null || p.rankingQte != null ? `\n--- RANKING ---\n${p.rankingCa != null ? `• Classement CA réseau : ${p.rankingCa}e${p.totalRankedProducts ? ` / ${p.totalRankedProducts} produits (top ${((p.rankingCa / p.totalRankedProducts) * 100).toFixed(1)}%)` : ""}\n` : ""}${p.rankingQte != null ? `• Classement Qté réseau : ${p.rankingQte}e${p.totalRankedProducts ? ` / ${p.totalRankedProducts} produits (top ${((p.rankingQte / p.totalRankedProducts) * 100).toFixed(1)}%)` : ""}\n` : ""}${p.rankingMagCa != null ? `• Classement CA magasin : ${p.rankingMagCa}e\n` : ""}${p.rankingMagQte != null ? `• Classement Qté magasin : ${p.rankingMagQte}e` : ""}` : ""}${contextRules}
Génère UNIQUEMENT le JSON :`; Génère UNIQUEMENT le JSON :`;
} }
@@ -78,6 +78,15 @@ export interface ProductAnalysisInput {
nbJoursDerniereVente?: number; nbJoursDerniereVente?: number;
derniereVente?: string; derniereVente?: string;
/** Ranking réseau (classement global tous magasins) */
rankingCa?: number;
rankingQte?: number;
/** Ranking magasin (classement local) */
rankingMagCa?: number;
rankingMagQte?: number;
/** Nombre total de produits classés (avec ventes sur la période) */
totalRankedProducts?: number;
/** /**
* Fiche de contexte enrichie générée par le ContextProfiler. * Fiche de contexte enrichie générée par le ContextProfiler.
* Transmise au prompt de l'IA pour une analyse multi-dimensionnelle. * Transmise au prompt de l'IA pour une analyse multi-dimensionnelle.
+3 -1
View File
@@ -31,12 +31,13 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
// ─── Phase 2 : Mensuel + Commandes (en parallèle) ──────────────────── // ─── Phase 2 : Mensuel + Commandes (en parallèle) ────────────────────
const { dateDebut, dateFin } = buildLast12MonthsRange(); const { dateDebut, dateFin } = buildLast12MonthsRange();
const [mensuelMap, referentielMap, commandesMap, rankingMap] = await Promise.all([ const [mensuelMap, referentielMap, commandesMap, rankingResult] = await Promise.all([
getMensuelByArticles(articles, dateDebut, dateFin), getMensuelByArticles(articles, dateDebut, dateFin),
getReferentielByArticles(articles), getReferentielByArticles(articles),
getCommandesByFournisseur(codeFournisseur), getCommandesByFournisseur(codeFournisseur),
getRankingByArticles(articles), getRankingByArticles(articles),
]); ]);
const { rankings: rankingMap, totalRankedProducts } = rankingResult;
console.log(`[getProductRows] mensuel data for ${mensuelMap.size}/${articles.length} articles`); console.log(`[getProductRows] mensuel data for ${mensuelMap.size}/${articles.length} articles`);
// ─── Phase 3 : Fenêtre temporelle (12 mois complets) ───────────────── // ─── Phase 3 : Fenêtre temporelle (12 mois complets) ─────────────────
@@ -230,6 +231,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
product.rankingQte = ranking.ranking_qte; product.rankingQte = ranking.ranking_qte;
product.rankingMagCa = ranking.ranking_mag_ca; product.rankingMagCa = ranking.ranking_mag_ca;
product.rankingMagQte = ranking.ranking_mag_qte; product.rankingMagQte = ranking.ranking_mag_qte;
product.totalRankedProducts = totalRankedProducts;
} }
// ─── Phase 7 : Restaurer gammes depuis dernier snapshot ────────────── // ─── Phase 7 : Restaurer gammes depuis dernier snapshot ──────────────
@@ -134,9 +134,28 @@ export function BulkAiAnalyzer() {
} }
const deadStockCodes = new Set(deadStockRows.map(r => r.codein)); const deadStockCodes = new Set(deadStockRows.map(r => r.codein));
// 2c. Top 20 réseau → A direct, sans appel IA
const top20Rows = rows.filter(r =>
(r.totalQuantite || 0) > 0 &&
!deadStockCodes.has(r.codein) &&
((r.rankingCa != null && r.rankingCa <= 20) || (r.rankingQte != null && r.rankingQte <= 20))
);
if (top20Rows.length > 0) {
const aChanges: Record<string, GammeCode> = {};
top20Rows.forEach(r => { aChanges[r.codein] = "A"; });
batchSetDraftGamme(aChanges);
top20Rows.forEach(r => {
const rkCa = r.rankingCa != null ? `${r.rankingCa}e CA` : "";
const rkQte = r.rankingQte != null ? `${r.rankingQte}e Qté` : "";
const total = r.totalRankedProducts ? ` / ${r.totalRankedProducts.toLocaleString("fr-FR")} produits` : "";
setInsight(r.codein, `Top 20 réseau (${[rkCa, rkQte].filter(Boolean).join(", ")}${total}) — classé A automatiquement.`);
});
}
const top20Codes = new Set(top20Rows.map(r => r.codein));
// Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente et CA/quantité suffisants // Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente et CA/quantité suffisants
const payloadsWithSales = initialPayloads.filter(p => const payloadsWithSales = initialPayloads.filter(p =>
(p.totalQuantite || 0) > 0 && !deadStockCodes.has(p.codein) (p.totalQuantite || 0) > 0 && !deadStockCodes.has(p.codein) && !top20Codes.has(p.codein)
); );
// 2c. Context profiling en micro-batches asynchrones // 2c. Context profiling en micro-batches asynchrones
@@ -182,6 +201,11 @@ export function BulkAiAnalyzer() {
commandesEnCours: row?.commandesEnCours, commandesEnCours: row?.commandesEnCours,
nbJoursDerniereVente: row?.nbJoursDerniereVente, nbJoursDerniereVente: row?.nbJoursDerniereVente,
derniereVente: row?.derniereVente, derniereVente: row?.derniereVente,
rankingCa: row?.rankingCa,
rankingQte: row?.rankingQte,
rankingMagCa: row?.rankingMagCa,
rankingMagQte: row?.rankingMagQte,
totalRankedProducts: row?.totalRankedProducts,
contextProfile, contextProfile,
scoring: { scoring: {
score, score,
+34 -15
View File
@@ -444,17 +444,39 @@ function parseRankNum(val: string | null | undefined): number | undefined {
return isNaN(n) ? undefined : n; return isNaN(n) ? undefined : n;
} }
export interface RankingResult {
rankings: Map<string, FfRanking>;
/** Nombre total de produits dans le classement (produits avec ventes sur la période) */
totalRankedProducts: number;
}
/** /**
* Récupère le ranking (réseau + magasin) pour un lot d'articles via codein. * Récupère le ranking (réseau + magasin) pour un lot d'articles via codein.
* Retourne Map<codein, FfRanking> * Fait d'abord un appel pour connaître le nombre total de produits classés,
* puis récupère le ranking de chaque article.
*/ */
export async function getRankingByArticles( export async function getRankingByArticles(
articles: { codein: string; gtin?: string }[], articles: { codein: string; gtin?: string }[],
batchSize = 20 batchSize = 20
): Promise<Map<string, FfRanking>> { ): Promise<RankingResult> {
const result = new Map<string, FfRanking>(); const rankings = new Map<string, FfRanking>();
if (articles.length === 0) return result; if (articles.length === 0) return { rankings, totalRankedProducts: 0 };
// 1. Récupérer le nombre total de produits classés (1 seul appel)
let totalRankedProducts = 0;
try {
const countUrl = `${FF_API_BASE}/api/ranking?limit=1`;
const countRes = await fetch(countUrl, { cache: "no-store" });
if (countRes.ok) {
const countData = await countRes.json();
totalRankedProducts = countData?.count ?? 0;
console.log(`[api-ff] Ranking total produits classés : ${totalRankedProducts}`);
}
} catch (err) {
console.warn(`[api-ff] Failed to get ranking total count:`, err);
}
// 2. Récupérer le ranking de chaque article
for (let i = 0; i < articles.length; i += batchSize) { for (let i = 0; i < articles.length; i += batchSize) {
const batch = articles.slice(i, i + batchSize); const batch = articles.slice(i, i + batchSize);
await Promise.all( await Promise.all(
@@ -465,18 +487,15 @@ export async function getRankingByArticles(
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
// Structure : { count: number, ranking: RankingEntry[] } const rankingList = data?.ranking;
const rankings = data?.ranking; if (!Array.isArray(rankingList) || rankingList.length === 0) return;
if (!Array.isArray(rankings) || rankings.length === 0) return;
// Log premier résultat pour diagnostic if (rankings.size === 0) {
if (result.size === 0) { console.log(`[api-ff] Ranking sample keys: ${Object.keys(rankingList[0]).join(",")}`);
console.log(`[api-ff] Ranking sample keys: ${Object.keys(rankings[0]).join(",")}`);
} }
// Prendre la première entrée const entry = rankingList[0];
const entry = rankings[0]; rankings.set(art.codein, {
result.set(art.codein, {
ranking_ca: parseRankNum(entry.ranking_ca), ranking_ca: parseRankNum(entry.ranking_ca),
ranking_qte: parseRankNum(entry.ranking_qte), ranking_qte: parseRankNum(entry.ranking_qte),
ranking_mag_ca: parseRankNum(entry.ranking_mag_ca), ranking_mag_ca: parseRankNum(entry.ranking_mag_ca),
@@ -493,8 +512,8 @@ export async function getRankingByArticles(
); );
} }
console.log(`[api-ff] Ranking: ${result.size}/${articles.length} articles enrichis`); console.log(`[api-ff] Ranking: ${rankings.size}/${articles.length} articles enrichis`);
return result; return { rankings, totalRankedProducts };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2
View File
@@ -99,6 +99,8 @@ export interface ProductRow {
/** Ranking magasin */ /** Ranking magasin */
rankingMagCa?: number; rankingMagCa?: number;
rankingMagQte?: number; rankingMagQte?: number;
/** Nombre total de produits classés dans le réseau (produits avec ventes sur la période) */
totalRankedProducts?: number;
} }
/** Summary bar totals for the currently visible/filtered rows */ /** Summary bar totals for the currently visible/filtered rows */