mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: Introduce bulk AI analyzer component to pre-classify products and prepare data for AI analysis, including context profiling.
This commit is contained in:
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) :
|
||||
• Si un magasin représente ≥ 80% des ventes → "porté par [magasin]"
|
||||
• 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 ---
|
||||
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("");
|
||||
|
||||
// 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)
|
||||
if (p.siteMonthlyData && p.siteMonthlyData.length > 0) {
|
||||
const signals = AnalysisEngine.computeSignals(p.siteMonthlyData);
|
||||
@@ -291,7 +316,8 @@ Famille / Rayon : ${p.libelleNiveau2 ?? "N/A"}
|
||||
• Quantité : ${p.totalQuantite} unités
|
||||
• Marge : ${p.tauxMarge.toFixed(1)}%
|
||||
• 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 :`;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,15 @@ export interface ProductAnalysisInput {
|
||||
nbJoursDerniereVente?: number;
|
||||
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.
|
||||
* Transmise au prompt de l'IA pour une analyse multi-dimensionnelle.
|
||||
|
||||
@@ -31,12 +31,13 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
|
||||
// ─── Phase 2 : Mensuel + Commandes (en parallèle) ────────────────────
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const [mensuelMap, referentielMap, commandesMap, rankingMap] = await Promise.all([
|
||||
const [mensuelMap, referentielMap, commandesMap, rankingResult] = await Promise.all([
|
||||
getMensuelByArticles(articles, dateDebut, dateFin),
|
||||
getReferentielByArticles(articles),
|
||||
getCommandesByFournisseur(codeFournisseur),
|
||||
getRankingByArticles(articles),
|
||||
]);
|
||||
const { rankings: rankingMap, totalRankedProducts } = rankingResult;
|
||||
console.log(`[getProductRows] mensuel data for ${mensuelMap.size}/${articles.length} articles`);
|
||||
|
||||
// ─── 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.rankingMagCa = ranking.ranking_mag_ca;
|
||||
product.rankingMagQte = ranking.ranking_mag_qte;
|
||||
product.totalRankedProducts = totalRankedProducts;
|
||||
}
|
||||
|
||||
// ─── Phase 7 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
|
||||
@@ -134,9 +134,28 @@ export function BulkAiAnalyzer() {
|
||||
}
|
||||
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
|
||||
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
|
||||
@@ -182,6 +201,11 @@ export function BulkAiAnalyzer() {
|
||||
commandesEnCours: row?.commandesEnCours,
|
||||
nbJoursDerniereVente: row?.nbJoursDerniereVente,
|
||||
derniereVente: row?.derniereVente,
|
||||
rankingCa: row?.rankingCa,
|
||||
rankingQte: row?.rankingQte,
|
||||
rankingMagCa: row?.rankingMagCa,
|
||||
rankingMagQte: row?.rankingMagQte,
|
||||
totalRankedProducts: row?.totalRankedProducts,
|
||||
contextProfile,
|
||||
scoring: {
|
||||
score,
|
||||
|
||||
+34
-15
@@ -444,17 +444,39 @@ function parseRankNum(val: string | null | undefined): number | undefined {
|
||||
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.
|
||||
* 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(
|
||||
articles: { codein: string; gtin?: string }[],
|
||||
batchSize = 20
|
||||
): Promise<Map<string, FfRanking>> {
|
||||
const result = new Map<string, FfRanking>();
|
||||
if (articles.length === 0) return result;
|
||||
): Promise<RankingResult> {
|
||||
const rankings = new Map<string, FfRanking>();
|
||||
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) {
|
||||
const batch = articles.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
@@ -465,18 +487,15 @@ export async function getRankingByArticles(
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
// Structure : { count: number, ranking: RankingEntry[] }
|
||||
const rankings = data?.ranking;
|
||||
if (!Array.isArray(rankings) || rankings.length === 0) return;
|
||||
const rankingList = data?.ranking;
|
||||
if (!Array.isArray(rankingList) || rankingList.length === 0) return;
|
||||
|
||||
// Log premier résultat pour diagnostic
|
||||
if (result.size === 0) {
|
||||
console.log(`[api-ff] Ranking sample keys: ${Object.keys(rankings[0]).join(",")}`);
|
||||
if (rankings.size === 0) {
|
||||
console.log(`[api-ff] Ranking sample keys: ${Object.keys(rankingList[0]).join(",")}`);
|
||||
}
|
||||
|
||||
// Prendre la première entrée
|
||||
const entry = rankings[0];
|
||||
result.set(art.codein, {
|
||||
const entry = rankingList[0];
|
||||
rankings.set(art.codein, {
|
||||
ranking_ca: parseRankNum(entry.ranking_ca),
|
||||
ranking_qte: parseRankNum(entry.ranking_qte),
|
||||
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`);
|
||||
return result;
|
||||
console.log(`[api-ff] Ranking: ${rankings.size}/${articles.length} articles enrichis`);
|
||||
return { rankings, totalRankedProducts };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -99,6 +99,8 @@ export interface ProductRow {
|
||||
/** Ranking magasin */
|
||||
rankingMagCa?: 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 */
|
||||
|
||||
Reference in new issue
Block a user