perf: referentiel+ranking only for articles with sales, per-article ranking

The ranking bulk endpoint is capped at 500 by the API server, making
it useless for full supplier matching. Per-article ranking (codein filter)
works correctly and is now called only for articles WITH sales data.

getProductRows restructured:
- Phase 2A: mensuel + commandes in parallel (all articles)
- Phase 2B: referentiel + ranking in parallel, only for articlesWithData
  (articles that appear in mensuelMap)

For a 45060-article supplier with 5446 with sales:
- Referentiel: 45060 → 5446 calls (88% reduction)
- Ranking: 45060 → 5446 calls (88% reduction)

totalRankedProducts fetched from GET /api/ranking?limit=500 count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-03-19 22:02:12 +01:00
1 parent 383b8ccac7
commit 65f455b4e2
2 files changed
+34 -53

No files matched your search

+14 -5
View File
@@ -29,16 +29,25 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
const articles = await getArticlesByFournisseur(codeFournisseur);
console.log(`[getProductRows] ${articles.length} articles for ${codeFournisseur}`);
// ─── Phase 2 : Mensuel + Commandes (en parallèle) ────────────────────
// ─── Phase 2A : Mensuel + Commandes (en parallèle) ──────────────────
// Mensuel d'abord pour identifier quels articles ont des ventes.
// Referentiel et Ranking ensuite, seulement pour ces articles.
const { dateDebut, dateFin } = buildLast12MonthsRange();
const [mensuelMap, referentielMap, commandesMap, rankingResult] = await Promise.all([
const [mensuelMap, commandesMap] = await Promise.all([
getMensuelByArticles(articles, dateDebut, dateFin),
getReferentielByArticles(articles),
getCommandesByFournisseur(codeFournisseur),
getRankingByArticles(articles, codeFournisseur),
]);
console.log(`[getProductRows] mensuel data for ${mensuelMap.size}/${articles.length} articles`);
// ─── Phase 2B : Referentiel + Ranking uniquement pour articles avec ventes ──
// Réduit drastiquement les appels API pour les gros fournisseurs.
const articlesWithData = articles.filter(a => mensuelMap.has(a.codein));
console.log(`[getProductRows] articles avec ventes: ${articlesWithData.length}/${articles.length}`);
const [referentielMap, rankingResult] = await Promise.all([
getReferentielByArticles(articlesWithData),
getRankingByArticles(articlesWithData, codeFournisseur),
]);
const { rankings: rankingMap, totalRankedProducts } = rankingResult;
console.log(`[getProductRows] mensuel data for ${mensuelMap.size}/${articles.length} articles`);
// ─── Phase 3 : Fenêtre temporelle (12 mois complets) ─────────────────
const now = new Date();
+20 -48
View File
@@ -500,6 +500,11 @@ export interface RankingResult {
*
* Fallback par article individuel si le bulk échoue.
*/
/**
* Récupère le ranking par article individuel (GET /api/ranking?codein=<codein>).
* Appelé seulement pour les articles AVEC ventes — taille raisonnable même pour grands fournisseurs.
* Le bulk endpoint est capé à 500 résultats par l'API → inutilisable pour matching complet.
*/
export async function getRankingByArticles(
articles: { codein: string; gtin?: string }[],
_codeFournisseur?: string,
@@ -507,52 +512,6 @@ export async function getRankingByArticles(
const rankings = new Map<string, FfRanking>();
if (articles.length === 0) return { rankings, totalRankedProducts: 0 };
const articleCodeins = new Set(articles.map(a => a.codein));
// Map gtin → codein pour matcher par gencod si le bulk response utilise gencod
const gtinToCodein = new Map<string, string>();
for (const a of articles) {
if (a.gtin) gtinToCodein.set(a.gtin, a.codein);
}
// Stratégie : télécharger tout le classement réseau en 1 appel, puis matcher par codein ou gencod
const RANKING_FULL_LIMIT = 30000;
try {
const url = `${FF_API_BASE}/api/ranking?limit=${RANKING_FULL_LIMIT}`;
const res = await fetch(url, { cache: "no-store" });
if (res.ok) {
const data = await res.json();
const rankingList: Record<string, string | null>[] = data?.ranking ?? [];
const totalRankedProducts = rankingList.length;
console.log(`[api-ff] Ranking full fetch: ${rankingList.length} entrées`);
if (rankingList.length > 0) {
console.log(`[api-ff] Ranking entry keys: ${Object.keys(rankingList[0]).join(",")}`);
console.log(`[api-ff] Ranking entry sample: ${JSON.stringify(rankingList[0])}`);
}
for (const entry of rankingList) {
// Essaie de trouver le codein via codein direct OU via gencod→gtin→codein
let codein = entry.codein ?? null;
if (!codein && entry.gencod) codein = gtinToCodein.get(entry.gencod) ?? null;
if (!codein || !articleCodeins.has(codein)) continue;
rankings.set(codein, {
ranking_ca: parseRankNum(entry.ranking_ca),
ranking_qte: parseRankNum(entry.ranking_qte),
ranking_mag_ca: parseRankNum(entry.ranking_mag_ca),
ranking_mag_qte: parseRankNum(entry.ranking_mag_qte),
ranking_mag_marge: parseRankNum(entry.ranking_mag_marge),
pv_calcule: parseRankNum(entry.pv_calcule),
pv_mag: parseRankNum(entry.pv_mag),
pv_cen: parseRankNum(entry.pv_cen),
});
}
console.log(`[api-ff] Ranking: ${rankings.size}/${articles.length} articles classés`);
return { rankings, totalRankedProducts };
}
} catch (err) {
console.warn(`[api-ff] Full ranking fetch failed, falling back to per-article:`, err);
}
// Fallback : 1 appel par article
const batchSize = 50;
for (let i = 0; i < articles.length; i += batchSize) {
const batch = articles.slice(i, i + batchSize);
@@ -583,8 +542,21 @@ export async function getRankingByArticles(
);
}
console.log(`[api-ff] Ranking fallback: ${rankings.size}/${articles.length} articles enrichis`);
return { rankings, totalRankedProducts: 0 };
// totalRankedProducts = nombre d'articles ayant un classement réseau
const totalRankedProducts = rankings.size > 0
? await fetchTotalRankedProducts()
: 0;
console.log(`[api-ff] Ranking: ${rankings.size}/${articles.length} articles classés (réseau: ${totalRankedProducts})`);
return { rankings, totalRankedProducts };
}
async function fetchTotalRankedProducts(): Promise<number> {
try {
const res = await fetch(`${FF_API_BASE}/api/ranking?limit=500`, { cache: "no-store" });
if (!res.ok) return 0;
const data = await res.json();
return (data?.ranking as unknown[])?.length ?? 0;
} catch { return 0; }
}
// ---------------------------------------------------------------------------