mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-23 23:18:10 +02:00
feat: Add batch AI product categorization API and a bulk analyzer UI component.
This commit is contained in:
1 parent
259fb60638
commit
d644043aa5
3 files changed
+179
-55
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx tsc:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,33 @@ const BatchAnalyzeSchema = z.object({
|
|||||||
rayon: z.string(),
|
rayon: z.string(),
|
||||||
supplierTotalCa: z.number().optional(),
|
supplierTotalCa: z.number().optional(),
|
||||||
supplierTotalMarge: z.number().optional(),
|
supplierTotalMarge: z.number().optional(),
|
||||||
|
supplierStats: z.object({
|
||||||
|
totalProducts: z.number(),
|
||||||
|
medianScore: z.number(),
|
||||||
|
scoreDistribution: z.object({
|
||||||
|
above70: z.number(),
|
||||||
|
between30and70: z.number(),
|
||||||
|
below30: z.number(),
|
||||||
|
}),
|
||||||
|
maxStoreCount: z.number(),
|
||||||
|
nomenclature2Count: z.number(),
|
||||||
|
}).optional(),
|
||||||
products: z.array(z.object({
|
products: z.array(z.object({
|
||||||
codein: z.string(),
|
codein: z.string(),
|
||||||
nom: z.string().nullable().optional(),
|
nom: z.string().nullable().optional(),
|
||||||
ca: z.number().nullable().optional(),
|
ca: z.number().nullable().optional(),
|
||||||
|
caWeight: z.number().optional().default(0),
|
||||||
|
adjustedCaWeight: z.number().optional().default(0),
|
||||||
ventes: z.number().nullable().optional(),
|
ventes: z.number().nullable().optional(),
|
||||||
marge: z.number().nullable().optional(),
|
marge: z.number().nullable().optional(),
|
||||||
score: z.number().nullable().optional(),
|
score: z.number().nullable().optional(),
|
||||||
|
scorePercentile: z.number().optional().default(0),
|
||||||
codeGamme: z.string().nullable().optional(),
|
codeGamme: z.string().nullable().optional(),
|
||||||
sales12m: z.record(z.string(), z.number()).nullable().optional(),
|
sales12m: z.record(z.string(), z.number()).nullable().optional(),
|
||||||
storeCount: z.number().optional().default(1),
|
storeCount: z.number().optional().default(1),
|
||||||
nomenclature: z.string().nullable().optional(),
|
nomenclature: z.string().nullable().optional(),
|
||||||
|
nomenclature2Weight: z.number().optional().default(0),
|
||||||
|
weightInNomenclature2: z.number().optional().default(0),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,51 +56,83 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Format de données invalide." }, { status: 400 });
|
return NextResponse.json({ error: "Format de données invalide." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rayon, products, supplierTotalCa, supplierTotalMarge } = parsed.data;
|
const { rayon, products, supplierTotalCa, supplierTotalMarge, supplierStats } = parsed.data;
|
||||||
|
|
||||||
const supplierMetricsContext = supplierTotalCa && supplierTotalMarge
|
// Build rich supplier context with distribution awareness
|
||||||
? `CONTEXTE FOURNISSEUR TOTAL :
|
const supplierMetricsContext = (() => {
|
||||||
- CA Total Fournisseur : ${supplierTotalCa.toLocaleString('fr-FR')} €
|
const parts: string[] = [];
|
||||||
- Marge Totale Fournisseur : ${supplierTotalMarge.toLocaleString('fr-FR')} €
|
|
||||||
|
|
||||||
Toute analyse de poids doit se faire par rapport à ce TOTAL FOURNISSEUR global, et non uniquement par rapport au lot envoyé.`
|
if (supplierTotalCa && supplierTotalMarge) {
|
||||||
: "CONTEXTE : Analyse par rapport au lot fourni.";
|
parts.push(`CONTEXTE FOURNISSEUR GLOBAL :
|
||||||
|
- CA Total Fournisseur : ${supplierTotalCa.toLocaleString('fr-FR')} EUR
|
||||||
|
- Marge Totale Fournisseur : ${supplierTotalMarge.toLocaleString('fr-FR')} EUR`);
|
||||||
|
}
|
||||||
|
|
||||||
// Context-Aware System Prompt: Strategic Purchase Expert
|
if (supplierStats) {
|
||||||
const systemPrompt = `Tu es un expert en stratégie d'achat retail et category manager. Ton rôle est de catégoriser les produits d'un fournisseur (A=Permanent, C=Saisonnier, Z=Sortie).
|
parts.push(`DISTRIBUTION DES SCORES (sur ${supplierStats.totalProducts} produits du fournisseur) :
|
||||||
|
- Score median : ${supplierStats.medianScore}/100
|
||||||
|
- Produits score > 70 : ${supplierStats.scoreDistribution.above70} (${Math.round(supplierStats.scoreDistribution.above70 / supplierStats.totalProducts * 100)}%)
|
||||||
|
- Produits score 30-70 : ${supplierStats.scoreDistribution.between30and70} (${Math.round(supplierStats.scoreDistribution.between30and70 / supplierStats.totalProducts * 100)}%)
|
||||||
|
- Produits score < 30 : ${supplierStats.scoreDistribution.below30} (${Math.round(supplierStats.scoreDistribution.below30 / supplierStats.totalProducts * 100)}%)
|
||||||
|
- Nomenclatures N2 distinctes : ${supplierStats.nomenclature2Count}
|
||||||
|
- Magasins reseau : ${supplierStats.maxStoreCount}
|
||||||
|
|
||||||
|
ATTENTION CRITIQUE : Le Score est RELATIF au meilleur produit du fournisseur (le top performer a ~100). Un score median de ${supplierStats.medianScore} signifie que la MOITIE des produits ont un score inferieur a cette valeur. Utilise le \`scorePercentile\` (rang centile) pour comparer les produits entre eux, PAS le score brut.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n\n');
|
||||||
|
})();
|
||||||
|
|
||||||
|
const systemPrompt = `Tu es un expert en strategie d'achat retail et category manager. Ton role est de categoriser les produits d'un fournisseur (A=Permanent, C=Saisonnier, Z=Sortie).
|
||||||
|
|
||||||
${supplierMetricsContext}
|
${supplierMetricsContext}
|
||||||
|
|
||||||
MÉTHODOLOGIE D'ANALYSE (APPROCHE PERFORMANCE-FIRST) :
|
REGLE FONDAMENTALE : L'IMPORTANCE ECONOMIQUE PRIME SUR LE SCORE DE PERFORMANCE RELATIVE.
|
||||||
|
|
||||||
1. ANALYSE DU POTENTIEL (RÈGLE DU MULTI-MAGASIN) :
|
COMPRENDRE LES DONNEES :
|
||||||
- Note le \`storeCount\` de chaque produit.
|
- \`score\` (0-100) : Performance RELATIVE au meilleur produit du fournisseur. Le top produit a ~100. Ce n'est PAS un score absolu de qualite. Un score de 25 peut etre parfaitement normal si le median est a 20.
|
||||||
- La majorité des produits sont présents dans 2 magasins. Si un produit a \`storeCount = 1\`, ses données (CA, Ventes, Marge) sont mécaniquement plus faibles car il ne travaille que sur une partie du réseau.
|
- \`scorePercentile\` (0-100) : Rang centile du produit parmi TOUS les produits du fournisseur. 50 = median, 80 = top 20%. C'EST L'INDICATEUR A UTILISER pour comparer les produits.
|
||||||
- **RÈGLE CRITIQUE** : Pour un produit mono-magasin (\`storeCount = 1\`), multiplie mentalement ses indicateurs par **1.7x** pour évaluer son potentiel réel s'il était déployé partout. Compare-le aux autres produits APRES cette extrapolation.
|
- \`caWeight\` : Poids brut du CA du produit par rapport au total fournisseur (%).
|
||||||
|
- \`adjustedCaWeight\` : Poids CA extrapole au reseau complet (si un produit n'est present que dans 1 magasin sur 2, son poids est double). UTILISE CETTE VALEUR pour toute decision de poids global.
|
||||||
|
- \`weightInNomenclature2\` : Poids du produit dans sa propre nomenclature de niveau 2 (%). C'EST LE CRITERE LE PLUS IMPORTANT. Un produit qui pese 8% de sa nomenclature N2 est un pilier de cette categorie.
|
||||||
|
- \`nomenclature2Weight\` : Poids de la nomenclature N2 entiere dans le CA fournisseur (%). Donne le contexte strategique de la categorie.
|
||||||
|
- \`sales12m\` : Historique mensuel des ventes (cle YYYYMM). Regarde la REGULARITE, pas seulement le volume.
|
||||||
|
|
||||||
2. CRITÈRE PRINCIPAL - LA SANTÉ (SCORE) :
|
METHODOLOGIE D'ANALYSE (HIERARCHIE STRICTE) :
|
||||||
- Le Score (0-100) est ton indicateur de performance combiné (rotation, rentabilité relative, tendance).
|
|
||||||
- Score > 70 : Produit performant, Gamme A par défaut.
|
|
||||||
- Score < 30 : Produit en difficulté, cible prioritaire pour la Gamme Z (sauf si mono-magasin avec fort potentiel extrapolé).
|
|
||||||
|
|
||||||
3. LE POIDS COMME BOUCLIER (PROTECTION DES PILIERS) :
|
1. CONTRIBUTION ECONOMIQUE PAR CATEGORIE (CRITERE PRINCIPAL) :
|
||||||
- Un produit avec un MAUVAIS SCORE (< 40) ne reste en Gamme A QUE s'il est un pilier indispensable :
|
- Regarde d'abord \`weightInNomenclature2\` : c'est le poids du produit dans sa categorie N2. Un produit qui pese > 5% de sa nomenclature N2 est un contributeur important de cette categorie → forte protection.
|
||||||
- Son poids CA/Marge (extrapolé si mono-magasin) est substantiel par rapport au business du fournisseur.
|
- Puis regarde \`adjustedCaWeight\` : le poids global. Un produit dans le top 20% des contributeurs du fournisseur → Gamme A quasi certaine.
|
||||||
- OU il génère un flux de volume massif indispensable.
|
- \`nomenclature2Weight\` donne le contexte : si la nomenclature N2 pese > 15% du fournisseur, c'est une categorie strategique et ses contributeurs meritent une protection renforcee.
|
||||||
|
|
||||||
4. ARBITRAGE POUR LA SORTIE (GAMME Z) :
|
2. REGULARITE DES VENTES (CRITERE SECONDAIRE) :
|
||||||
- Propose la Gamme Z si le produit cumule :
|
- Compte les mois avec ventes > 0 dans sales12m.
|
||||||
- Score faible (< 40).
|
- 10-12 mois actifs = rotation reguliere = signal fort de Gamme A (produit de fond de rayon indispensable, effet de halo sur la categorie).
|
||||||
- ET Contribution négligeable au business global (même après extrapolation 1.7x si mono-magasin).
|
- 4-9 mois actifs = rotation moyenne, a croiser avec la contribution.
|
||||||
- ET Ventes sporadiques.
|
- 1-3 mois actifs = rotation faible, potentiel produit saisonnier (C) ou candidat sortie (Z).
|
||||||
|
- Un produit avec ventes regulieres sur 10+ mois est un produit de complement indispensable au rayon, meme si son poids individuel est faible.
|
||||||
|
|
||||||
DÉFINITION DES GAMMES :
|
3. PERFORMANCE RELATIVE (CRITERE TERTIAIRE) :
|
||||||
- A (Permanent) : Produits sains (Score élevé) OU Piliers business indispensables (gros volume/CA malgré score moyen).
|
- Utilise \`scorePercentile\` pour situer le produit. PAS le score brut.
|
||||||
- C (Saisonnier) : Profil de ventes avec saisonnalité marquée.
|
- scorePercentile > 60 : Produit au-dessus de la moyenne → favorable a Gamme A.
|
||||||
- Z (Sortie) : Produits non performants et non stratégiques.
|
- scorePercentile 30-60 : Produit moyen → decider selon contribution et regularite.
|
||||||
|
- scorePercentile < 20 : Produit dans le bas du classement → candidat Z, SAUF si contribution significative dans sa nomenclature ou rotation reguliere.
|
||||||
|
|
||||||
IMPORTANT : RÉPONDS UNIQUEMENT EN JSON VALIDE.
|
4. REGLE D'OR DE COHERENCE :
|
||||||
Ta justification doit expliquer POURQUOI le produit est maintenu ou sorti. Mentionne explicitement si le produit est pénalisé par sa présence dans un seul magasin (ex: "Mono-magasin : potentiel extrapolé de X€, pilier stratégique").
|
- Un produit avec un meilleur scorePercentile ET un meilleur adjustedCaWeight qu'un autre DOIT avoir une recommandation egale ou superieure.
|
||||||
|
- Ne JAMAIS mettre en Z un produit qui est meilleur qu'un autre reste en A.
|
||||||
|
|
||||||
|
5. PROTECTION DES PRODUITS DE COMPLEMENT (EFFET HALO) :
|
||||||
|
- Un produit avec une rotation reguliere (8+ mois actifs) et un weightInNomenclature2 > 2% est un produit de complement indispensable au rayon. Il doit rester en A.
|
||||||
|
- Avant de proposer Z, verifie que le produit n'est pas un complement naturel d'un produit A majeur du meme rayon/nomenclature.
|
||||||
|
|
||||||
|
DEFINITION DES GAMMES :
|
||||||
|
- A (Permanent) : Produits contribuant significativement a leur categorie N2 (weightInNomenclature2 eleve), OU rotation reguliere (fond de rayon), OU forte performance relative.
|
||||||
|
- C (Saisonnier) : Profil de ventes avec saisonnalite marquee (pics sur 2-4 mois, quasi-absence le reste).
|
||||||
|
- Z (Sortie) : UNIQUEMENT si le produit cumule TOUS ces criteres negatifs : weightInNomenclature2 tres faible (marginal dans sa propre categorie) + adjustedCaWeight negligeable + rotation irreguliere (< 5 mois actifs) + scorePercentile dans le dernier quart (< 25).
|
||||||
|
|
||||||
|
IMPORTANT : REPONDS UNIQUEMENT EN JSON VALIDE.
|
||||||
|
Ta justification doit etre factuelle. Mentionne le scorePercentile, le weightInNomenclature2, et le nombre de mois actifs.
|
||||||
|
|
||||||
Format:
|
Format:
|
||||||
{
|
{
|
||||||
@@ -93,23 +141,26 @@ Format:
|
|||||||
"codein": "ID",
|
"codein": "ID",
|
||||||
"recommandationGamme": "A|C|Z",
|
"recommandationGamme": "A|C|Z",
|
||||||
"isDuplicate": boolean,
|
"isDuplicate": boolean,
|
||||||
"justificationCourte": "Justification incluant le poids relatif (ex: 'Top contributeur CA du lot, à protéger')."
|
"justificationCourte": "Poids N2: X%, Poids global: Y%, Percentile: Z, Mois actifs: N -> [Raison]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Données : codein, nom, ca (€), ventes (unités), marge (%), score (0-100), codeGamme (actuel), sales12m (historique), nomenclature.`;
|
Donnees fournies par produit : codein, nom, ca (EUR), caWeight (% global), adjustedCaWeight (% extrapole), weightInNomenclature2 (% dans sa N2), nomenclature2Weight (% de la N2 dans le fournisseur), ventes (unites), marge (%), score (0-100, RELATIF), scorePercentile (0-100, rang centile), codeGamme (actuel), sales12m (historique), storeCount, nomenclature.`;
|
||||||
|
|
||||||
const userPrompt = `Analyse cette liste de produits: \n${JSON.stringify(products, null, 2)} `;
|
const userPrompt = `Ce lot contient ${products.length} produits du rayon "${rayon}" (sur ${supplierStats?.totalProducts ?? products.length} produits au total pour ce fournisseur).
|
||||||
|
|
||||||
|
Analyse ces produits :
|
||||||
|
${JSON.stringify(products, null, 2)}`;
|
||||||
|
|
||||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${apiKey} `,
|
"Authorization": `Bearer ${apiKey}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model, // uses the model configured in Settings (same as individual analysis)
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt }
|
{ role: "user", content: userPrompt }
|
||||||
@@ -120,10 +171,9 @@ Données : codein, nom, ca (€), ventes (unités), marge (%), score (0-100), co
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
// Read the retry-after header and pass it back to the client
|
|
||||||
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
|
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
|
||||||
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
||||||
console.warn(`[batch - analyze] Rate limited.Retry after ${waitSeconds} s.`);
|
console.warn(`[batch-analyze] Rate limited. Retry after ${waitSeconds}s.`);
|
||||||
return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 });
|
return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,11 +194,10 @@ Données : codein, nom, ca (€), ventes (unités), marge (%), score (0-100), co
|
|||||||
|
|
||||||
// Try to extract JSON block from the response (handles markdown code blocks and raw JSON)
|
// Try to extract JSON block from the response (handles markdown code blocks and raw JSON)
|
||||||
let jsonStr = content;
|
let jsonStr = content;
|
||||||
const jsonBlock = content.match(/```json\s * ([\s\S] *?) \s * ```/);
|
const jsonBlock = content.match(/```json\s*([\s\S]*?)\s*```/);
|
||||||
if (jsonBlock) {
|
if (jsonBlock) {
|
||||||
jsonStr = jsonBlock[1];
|
jsonStr = jsonBlock[1];
|
||||||
} else {
|
} else {
|
||||||
// Look for first { ... } block
|
|
||||||
const start = content.indexOf("{");
|
const start = content.indexOf("{");
|
||||||
const end = content.lastIndexOf("}");
|
const end = content.lastIndexOf("}");
|
||||||
if (start !== -1 && end !== -1) {
|
if (start !== -1 && end !== -1) {
|
||||||
|
|||||||
@@ -19,6 +19,50 @@ export function BulkAiAnalyzer() {
|
|||||||
|
|
||||||
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows", { supplierTotalCa, supplierTotalMarge });
|
console.log("[BulkAiAnalyzer] Starting analysis on", rows.length, "rows", { supplierTotalCa, supplierTotalMarge });
|
||||||
|
|
||||||
|
// --- Statistiques globales pour contextualiser le score ---
|
||||||
|
const allScores = rows.map(r => r.score || 0).sort((a, b) => a - b);
|
||||||
|
const totalProducts = rows.length;
|
||||||
|
|
||||||
|
const medianScore = totalProducts > 0
|
||||||
|
? (totalProducts % 2 === 0
|
||||||
|
? (allScores[totalProducts / 2 - 1] + allScores[totalProducts / 2]) / 2
|
||||||
|
: allScores[Math.floor(totalProducts / 2)])
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const scoreDistribution = {
|
||||||
|
above70: allScores.filter(s => s > 70).length,
|
||||||
|
between30and70: allScores.filter(s => s >= 30 && s <= 70).length,
|
||||||
|
below30: allScores.filter(s => s < 30).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxStoreCount = Math.max(...rows.map(r => r.workingStores?.length || 1), 1);
|
||||||
|
|
||||||
|
// Percentile par produit (rang centile 0-100)
|
||||||
|
const scorePercentileMap = new Map<string, number>();
|
||||||
|
rows.forEach(r => {
|
||||||
|
const productScore = r.score || 0;
|
||||||
|
const rank = allScores.filter(s => s <= productScore).length;
|
||||||
|
scorePercentileMap.set(r.codein, Math.round((rank / totalProducts) * 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Double poids par nomenclature niveau 2 ---
|
||||||
|
const nomenclature2CaMap = new Map<string, number>();
|
||||||
|
rows.forEach(r => {
|
||||||
|
const n2 = r.libelleNiveau2 || "Non classe";
|
||||||
|
nomenclature2CaMap.set(n2, (nomenclature2CaMap.get(n2) || 0) + (r.totalCa || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
const nomenclature2WeightMap = new Map<string, number>();
|
||||||
|
nomenclature2CaMap.forEach((ca, n2) => {
|
||||||
|
nomenclature2WeightMap.set(n2, supplierTotalCa > 0
|
||||||
|
? parseFloat(((ca / supplierTotalCa) * 100).toFixed(1))
|
||||||
|
: 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nomenclature2Count = nomenclature2CaMap.size;
|
||||||
|
|
||||||
|
console.log("[BulkAiAnalyzer] Stats:", { totalProducts, medianScore, scoreDistribution, nomenclature2Count, maxStoreCount });
|
||||||
|
|
||||||
setIsAnalyzing(true);
|
setIsAnalyzing(true);
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
|
|
||||||
@@ -50,18 +94,35 @@ export function BulkAiAnalyzer() {
|
|||||||
const chunk = remainingChunks.shift();
|
const chunk = remainingChunks.shift();
|
||||||
if (!chunk) return;
|
if (!chunk) return;
|
||||||
|
|
||||||
const payloadProducts = chunk.items.map(r => ({
|
const payloadProducts = chunk.items.map(r => {
|
||||||
codein: r.codein,
|
const rawCaWeight = supplierTotalCa > 0
|
||||||
nom: r.libelle1,
|
? parseFloat(((r.totalCa / supplierTotalCa) * 100).toFixed(3))
|
||||||
ca: r.totalCa || 0,
|
: 0;
|
||||||
ventes: r.totalQuantite || 0,
|
const storeCount = r.workingStores?.length || 1;
|
||||||
marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0,
|
const storeRatio = storeCount < maxStoreCount ? maxStoreCount / storeCount : 1;
|
||||||
score: r.score || 0,
|
const adjustedCaWeight = parseFloat((rawCaWeight * storeRatio).toFixed(3));
|
||||||
codeGamme: r.codeGamme || "N/A",
|
const n2 = r.libelleNiveau2 || "Non classe";
|
||||||
sales12m: r.sales12m || {},
|
const n2Ca = nomenclature2CaMap.get(n2) || 1;
|
||||||
storeCount: r.workingStores?.length || 1,
|
const weightInNomenclature2 = parseFloat((((r.totalCa || 0) / n2Ca) * 100).toFixed(2));
|
||||||
nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`
|
|
||||||
}));
|
return {
|
||||||
|
codein: r.codein,
|
||||||
|
nom: r.libelle1,
|
||||||
|
ca: r.totalCa || 0,
|
||||||
|
caWeight: rawCaWeight,
|
||||||
|
adjustedCaWeight,
|
||||||
|
ventes: r.totalQuantite || 0,
|
||||||
|
marge: r.totalMarge ? parseFloat(((r.totalMarge / (r.totalCa || 1)) * 100).toFixed(1)) : 0,
|
||||||
|
score: r.score || 0,
|
||||||
|
scorePercentile: scorePercentileMap.get(r.codein) ?? 0,
|
||||||
|
codeGamme: r.codeGamme || "N/A",
|
||||||
|
sales12m: r.sales12m || {},
|
||||||
|
storeCount,
|
||||||
|
nomenclature: `${r.libelleNiveau1 || ""} > ${r.libelleNiveau2 || ""} > ${r.libelle3 || ""}`,
|
||||||
|
nomenclature2Weight: nomenclature2WeightMap.get(n2) ?? 0,
|
||||||
|
weightInNomenclature2,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let res: Response | null = null;
|
let res: Response | null = null;
|
||||||
@@ -75,7 +136,14 @@ export function BulkAiAnalyzer() {
|
|||||||
rayon: chunk.rayon,
|
rayon: chunk.rayon,
|
||||||
products: payloadProducts,
|
products: payloadProducts,
|
||||||
supplierTotalCa,
|
supplierTotalCa,
|
||||||
supplierTotalMarge
|
supplierTotalMarge,
|
||||||
|
supplierStats: {
|
||||||
|
totalProducts,
|
||||||
|
medianScore: Math.round(medianScore * 10) / 10,
|
||||||
|
scoreDistribution,
|
||||||
|
maxStoreCount,
|
||||||
|
nomenclature2Count,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user