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) :
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in new issue
Block a user