diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index c4d08c6..6d7cea5 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -2,14 +2,15 @@ import type { ProductRow, GammeCode, GridFilters } from "@/types/grid"; import { computeProductScores } from "@/lib/score-engine"; +import { buildLast12MonthsRange } from "@/lib/api-ff-client"; import { - getArticlesByFournisseur, - getMensuelByArticles, - getReferentielByArticles, - getCommandesByFournisseur, - getRankingByArticles, - buildLast12MonthsRange, -} from "@/lib/api-ff-client"; + pgGetArticlesByFournisseur, + pgGetMensuelByFournisseur, + pgGetGammesByFournisseur, + pgGetStockByFournisseur, + pgGetRankingByFournisseur, + pgGetCommandesByFournisseur, +} from "@/lib/pg-ff-client"; import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { eq, desc } from "drizzle-orm"; @@ -25,141 +26,112 @@ export async function getProductRows(input: GetProductRowsInput): Promise>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`); try { - // ─── Phase 1 : Articles du fournisseur ─────────────────────────────── - const articles = await getArticlesByFournisseur(codeFournisseur); - console.log(`[getProductRows] ${articles.length} articles for ${codeFournisseur}`); - - // ─── 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, commandesMap] = await Promise.all([ - getMensuelByArticles(articles, dateDebut, dateFin), - getCommandesByFournisseur(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), + // ─── Phase 1 : 6 requêtes SQL en parallèle ──────────────────────────── + // Remplace des centaines/milliers d'appels HTTP per-article. + const [ + articles, + mensuelRows, + gammeMap, + stockMap, + rankingResult, + commandesMap, + ] = await Promise.all([ + pgGetArticlesByFournisseur(codeFournisseur), + pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin), + pgGetGammesByFournisseur(codeFournisseur), + pgGetStockByFournisseur(codeFournisseur), + pgGetRankingByFournisseur(codeFournisseur), + pgGetCommandesByFournisseur(codeFournisseur), ]); + const { rankings: rankingMap, totalRankedProducts } = rankingResult; + console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes, ${rankingMap.size} rankings`); - // ─── Phase 3 : Fenêtre temporelle (12 mois complets) ───────────────── + // ─── Phase 2 : Fenêtre temporelle 12 mois complets ─────────────────── const now = new Date(); const allowedPeriods = new Set(); for (let i = 12; i >= 1; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); } - const sortedPeriods = [...allowedPeriods].sort(); // "202503" … "202602" + const sortedPeriods = [...allowedPeriods].sort(); - // ─── Phase 4 : Seed productMap depuis articles ──────────────────────── + // ─── Phase 3 : Seed productMap depuis articles ──────────────────────── const productMap = new Map(); for (const art of articles) { - if (!productMap.has(art.codein)) { - productMap.set(art.codein, { - codein: art.codein, - codeFournisseur: art.codefou ?? codeFournisseur, - nomFournisseur: art.nomfou ?? "", - libelle1: art.libelle1 ?? "", - gtin: art.gtin ?? "", - reference: art.reference ?? "", - code1: "", libelleNiveau1: "", - code2: "", libelleNiveau2: "", - code3: "", libelle3: "", - codeGamme: null, - codeGammeInit: null, - codeGammeDraft: null, - sales12m: {}, - stock12m: {}, - receptions12m: {}, - totalQuantite: 0, - totalCa: 0, - totalMarge: 0, - tauxMarge: 0, - score: 0, - workingStores: [], - aiRecommendation: null, - noid: art.noid ? Number(art.noid) : undefined, - pcb: art.pcb ? Number(art.pcb) : undefined, - prixVente: art.pv_central ? Number(art.pv_central) : undefined, - }); - } + if (!art.codein || productMap.has(art.codein)) continue; + productMap.set(art.codein, { + codein: art.codein, + codeFournisseur: art.codefou ?? codeFournisseur, + nomFournisseur: art.nomfou ?? "", + libelle1: art.libelle1 ?? "", + gtin: art.gtin ?? "", + reference: art.reference ?? "", + code1: "", libelleNiveau1: "", + code2: "", libelleNiveau2: "", + code3: "", libelle3: "", + codeGamme: null, + codeGammeInit: null, + codeGammeDraft: null, + sales12m: {}, + stock12m: {}, + receptions12m: {}, + totalQuantite: 0, + totalCa: 0, + totalMarge: 0, + tauxMarge: 0, + score: 0, + workingStores: [], + aiRecommendation: null, + noid: art.no_id ? Number(art.no_id) : undefined, + pcb: art.pcb ? Number(art.pcb) : undefined, + prixVente: art.pv_central ? Number(art.pv_central) : undefined, + }); } - // ─── Phase 5 : Agréger données mensuelles → sales12m + stock12m ────── + // ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ───────────── + // SQL a déjà fait l'agrégation par (codein, site, mois). + // On somme ici les 2 sites (292 + 579) par période. const filterSite = magasin !== "TOTAL" ? magasin : null; - // Log de TOUS les sites distincts sur l'ensemble des articles (diagnostic double-comptage) - const allSites = new Set(); - for (const entries of mensuelMap.values()) { - for (const e of entries as import("@/lib/api-ff-client").FfMensuelEntry[]) allSites.add(e.site); - } - console.log(`[getProductRows] Tous les sites distincts dans mensuel: ${JSON.stringify([...allSites])}`); - // Compter combien d'entrées par site (pour détecter la centrale) - const siteCount: Record = {}; - for (const entries of mensuelMap.values()) { - for (const e of entries as import("@/lib/api-ff-client").FfMensuelEntry[]) { - siteCount[e.site] = (siteCount[e.site] ?? 0) + 1; + type PeriodData = { qty: number; ca: number; marge: number; stock: number; receptions: number }; + const mensuelByCodein = new Map>(); + const storeMonthsByCodein = new Map>>(); + + for (const row of mensuelRows) { + if (filterSite && row.site !== filterSite) continue; + + const periode = row.mois.replace("-", ""); // "2026-02" → "202602" + if (!allowedPeriods.has(periode)) continue; + + if (!mensuelByCodein.has(row.codein)) mensuelByCodein.set(row.codein, new Map()); + const byPeriod = mensuelByCodein.get(row.codein)!; + + if (!byPeriod.has(periode)) byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 }); + const p = byPeriod.get(periode)!; + p.stock += Number(row.stock_fin_mois) || 0; + p.qty += Number(row.qte_vendue) || 0; + p.ca += Number(row.ca_ht) || 0; + p.marge += Number(row.marge) || 0; + p.receptions += Number(row.qte_recue) || 0; + + // Suivi magasins actifs (pour workingStores) + if (Number(row.qte_vendue) > 0) { + if (!storeMonthsByCodein.has(row.codein)) storeMonthsByCodein.set(row.codein, new Map()); + const storeMonths = storeMonthsByCodein.get(row.codein)!; + if (!storeMonths.has(row.site)) storeMonths.set(row.site, new Set()); + storeMonths.get(row.site)!.add(periode); } } - console.log(`[getProductRows] Entrées par site: ${JSON.stringify(siteCount)}`); - for (const [codein, entries] of mensuelMap.entries()) { + // ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ───────────── + for (const [codein, byPeriod] of mensuelByCodein.entries()) { const product = productMap.get(codein); if (!product) continue; - // Agréger par période (toutes les entrées site × mois → 1 entrée par mois) - const byPeriod = new Map(); - const storeMonths = new Map>(); // site → Set - - for (const entry of entries) { - // Garder uniquement les sites physiques (292, 579) — exclure la centrale (agrégat) - if (filterSite) { - if (entry.site !== filterSite) continue; - } else { - if (entry.site !== "292" && entry.site !== "579") continue; - } - - const periode = entry.mois.replace("-", ""); // "2026-02" → "202602" - if (!allowedPeriods.has(periode)) continue; - - if (!byPeriod.has(periode)) { - byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, pa: 0, receptions: 0 }); - } - const p = byPeriod.get(periode)!; - - // Stock fin de mois : somme tous sites (ou site filtré) - p.stock += Math.abs(parseFloat(entry.stock_fin_mois ?? "0") || 0); - if (!p.pa && entry.prmp_fin_mois) p.pa = parseFloat(entry.prmp_fin_mois) || 0; - - // Réceptions - if (entry.receptions) { - p.receptions += Math.abs(parseFloat(entry.receptions.qte_recue ?? "0") || 0); - } - - // Ventes - if (entry.ventes) { - p.qty += Math.abs(parseFloat(entry.ventes.qte_vendue ?? "0") || 0); - p.ca += Math.abs(parseFloat(entry.ventes.ca_ht ?? "0") || 0); - p.marge += parseFloat(entry.ventes.marge ?? "0") || 0; - - // Suivi magasins actifs (pour workingStores) - if (entry.site && entry.site !== "TOTAL") { - if (!storeMonths.has(entry.site)) storeMonths.set(entry.site, new Set()); - storeMonths.get(entry.site)!.add(periode); - } - } - } - - // Remplir sales12m / stock12m avec carry-forward sur les mois sans données let lastStock = 0; - let lastPa = 0; for (const periode of sortedPeriods) { const p = byPeriod.get(periode); if (p) { @@ -170,7 +142,6 @@ export async function getProductRows(input: GetProductRowsInput): Promise 0 ? (product.totalMarge / product.totalCa) * 100 : 0; - product.pa = lastPa > 0 ? lastPa : undefined; - product.prixAchat = lastPa > 0 ? lastPa : undefined; - product.stockActuel = product.stock12m[sortedPeriods[sortedPeriods.length - 1]] ?? 0; + product.tauxMarge = product.totalCa > 0 ? (product.totalMarge / product.totalCa) * 100 : 0; - // workingStores : sites avec ventes sur >= 3 mois distincts - product.workingStores = [...storeMonths.entries()] - .filter(([, periods]) => periods.size >= 3) - .map(([site]) => site) - .sort(); + // workingStores : sites avec ventes sur ≥ 3 mois distincts + const storeMonths = storeMonthsByCodein.get(codein); + if (storeMonths) { + product.workingStores = [...storeMonths.entries()] + .filter(([, periods]) => periods.size >= 3) + .map(([site]) => site) + .sort(); + } // Commandes en cours const cmdQty = commandesMap.get(codein); if (cmdQty) product.commandesEnCours = cmdQty; } - // ─── Phase 6 : Classification depuis referentiel API (nomenclature + gamme) ─ - let refCount = 0; - for (const [codein, ref] of referentielMap.entries()) { + // ─── Phase 6 : Gammes (saison active) ──────────────────────────────── + for (const [codein, gammeCode] of gammeMap.entries()) { + const product = productMap.get(codein); + if (!product) continue; + product.codeGammeInit = gammeCode as GammeCode; + product.codeGamme = gammeCode as GammeCode; + } + + // ─── Phase 7 : Stock temps réel (cube_stock) ───────────────────────── + for (const [codein, stocks] of stockMap.entries()) { const product = productMap.get(codein); if (!product) continue; - // Nomenclature - product.code3 = ref.article.nom_code ?? ""; - product.libelle3 = ref.article.nom_libelle ?? ""; + const sitesStock = filterSite + ? stocks.filter(s => s.site === filterSite) + : stocks; - // Gamme initiale (figée) + gamme courante (sera overridée par snapshot si besoin) - const gammeCode = ref.gammes?.[0]?.gamme_code ?? null; - product.codeGammeInit = gammeCode; - product.codeGamme = gammeCode; + product.stockActuel = sitesStock.reduce((s, r) => s + (Number(r.stockdispo) || 0), 0); + product.stockTotal = sitesStock.reduce((s, r) => s + (Number(r.qte) || 0), 0); + product.stockValeur = sitesStock.reduce((s, r) => s + (Number(r.valstock) || 0), 0); - // PA et PCB depuis referentiel (plus précis que articles endpoint) - const pa = parseFloat(ref.prix?.achat ?? "0") || 0; - if (pa > 0) { product.pa = pa; product.prixAchat = pa; } - const pcb = parseFloat(ref.fournisseurs?.[0]?.pcb ?? "0") || 0; - if (pcb > 0) product.pcb = pcb; - - // Stock actuel agrégé tous sites (valeur temps réel) - if (ref.stock?.length) { - product.stockActuel = ref.stock.reduce((sum, s) => sum + (parseFloat(s.stockdispo ?? "0") || 0), 0); - product.stockTotal = ref.stock.reduce((sum, s) => sum + (parseFloat(s.qte ?? "0") || 0), 0); - product.stockValeur = ref.stock.reduce((sum, s) => sum + (parseFloat(s.valstock ?? "0") || 0), 0); - const firstSite = ref.stock.find(s => parseFloat(s.prmp ?? "0") > 0); - if (firstSite && !product.pa) { product.pa = parseFloat(firstSite.prmp); product.prixAchat = product.pa; } + const withPrmp = sitesStock.find(r => Number(r.prmp) > 0); + if (withPrmp) { + product.pa = Number(withPrmp.prmp); + product.prixAchat = product.pa; } - // Dernière vente depuis performance - if (ref.performance?.derniere_vente) product.derniereVente = ref.performance.derniere_vente; - if (ref.performance?.derniere_entree) product.derniereLivraison = ref.performance.derniere_entree; + const dernVente = sitesStock.reduce((best, r) => + r.dernierevente && r.dernierevente > best ? r.dernierevente : best, ""); + if (dernVente) product.derniereVente = dernVente; - refCount++; + const dernLiv = sitesStock.reduce((best, r) => + r.dernierereception && r.dernierereception > best ? r.dernierereception : best, ""); + if (dernLiv) product.derniereLivraison = dernLiv; } - console.log(`[getProductRows] Referentiel: ${refCount}/${articles.length} articles enrichis`); - // ─── Phase 6b : Ranking réseau + magasin ──────────────────────────── + // ─── Phase 8 : Ranking réseau ──────────────────────────────────────── for (const [codein, ranking] of rankingMap.entries()) { const product = productMap.get(codein); if (!product) continue; - product.rankingCa = ranking.ranking_ca; - product.rankingQte = ranking.ranking_qte; - product.rankingMagCa = ranking.ranking_mag_ca; - product.rankingMagQte = ranking.ranking_mag_qte; + product.rankingCa = ranking.ranking_ca ? Number(ranking.ranking_ca) : undefined; + product.rankingQte = ranking.ranking_qte ? Number(ranking.ranking_qte) : undefined; + product.rankingMagCa = ranking.ranking_mag_ca ? Number(ranking.ranking_mag_ca) : undefined; + product.rankingMagQte = ranking.ranking_mag_qte ? Number(ranking.ranking_mag_qte) : undefined; product.totalRankedProducts = totalRankedProducts; } - // ─── Phase 7 : Restaurer gammes depuis dernier snapshot ────────────── + // ─── Phase 9 : Restaurer gammes depuis dernier snapshot ────────────── try { const snaps = await db .select() @@ -257,7 +226,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise - p.codeGamme !== "Y" || p.totalQuantite > 0 - ); + const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0); const excludedY = allRows.length - rows.length; - console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelMap.size} avec données`); + console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`); + return computeProductScores(rows); } catch (error) { diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts new file mode 100644 index 0000000..c74da29 --- /dev/null +++ b/src/lib/pg-ff-client.ts @@ -0,0 +1,285 @@ +/** + * CollectFlow — Client PostgreSQL FF Nancy + * + * Remplace les appels HTTP per-article vers api.ffnancy.fr par des requêtes + * SQL bulk directes sur la base PostgreSQL FF Nancy. + * + * Chaque fonction fait 1 seule requête filtrée par codefou — O(1) au lieu de O(N). + */ + +import { db } from "@/db"; +import { sql } from "drizzle-orm"; + +// --------------------------------------------------------------------------- +// Types résultats SQL +// --------------------------------------------------------------------------- + +export interface PgArticle { + no_id: number; + codein: string; + codefou: string; + nomfou?: string; + libelle1?: string; + pcb?: number; + reference?: string; + ean13?: string; + gtin?: string; // art_gtin.gtin (préférentiel) + pv_central?: number; // article_infosup.prix_vente_mini + pa?: number; // cube_pa.pa +} + +export interface PgMensuelRow { + codein: string; + site: string; + mois: string; // "YYYY-MM" + qte_vendue: number; + ca_ht: number; // mntmvtttc (à vérifier si HT ou TTC) + marge: number; + stock_fin_mois: number; // MAX(qtestock) sur le mois + qte_recue: number; +} + +export interface PgStockRow { + codein: string; + site: string; + stockdispo: number; + qte: number; + valstock: number; + prmp: number; + dernierevente?: string; + dernierereception?: string; +} + +export interface PgRankingRow { + codein: string; + site: string; + ranking_ca?: number; + ranking_qte?: number; + ranking_mag_ca?: number; + ranking_mag_qte?: number; +} + +// --------------------------------------------------------------------------- +// 1. Articles du fournisseur +// --------------------------------------------------------------------------- + +/** + * Retourne tous les articles d'un fournisseur avec leurs métadonnées. + * 1 seule requête SQL — remplace getArticlesByFournisseur() + per-article fetches. + */ +export async function pgGetArticlesByFournisseur(codefou: string): Promise { + const result = await db.execute(sql` + SELECT + a.no_id, + a.codein, + af.code AS codefou, + fa.raisonsociale AS nomfou, + a.libelle1, + af.pcb, + af.reference, + af.ean13, + COALESCE(ag.gtin, af.ean13) AS gtin, + ai.prix_vente_mini AS pv_central, + pa.pa + FROM artfou1 af + JOIN articles a + ON a.no_id = af.art_no_id + LEFT JOIN fouadr1 fa + ON fa.code = af.code AND fa.sit_code = '000' + LEFT JOIN article_infosup ai + ON ai.artnoid = a.no_id + LEFT JOIN cube_pa pa + ON pa.artnoid = a.no_id + LEFT JOIN art_gtin ag + ON ag.idarticle = a.no_id AND ag.preferentiel = 1 + WHERE af.code = ${codefou} + AND a.codein IS NOT NULL + ORDER BY a.codein + `); + + return (result.rows as unknown as PgArticle[]).filter(r => r.codein); +} + +// --------------------------------------------------------------------------- +// 2. Données mensuelles (ventes + stock + réceptions) +// --------------------------------------------------------------------------- + +/** + * Retourne les données mensuelles agrégées par (codein, site, mois). + * 1 seule requête SQL — remplace getMensuelByArticles() (N appels HTTP). + * + * genremvt = 3 → ventes clients + * genremvt 1/2 → réceptions (à confirmer selon nomenclature FF Nancy) + * qtestock → stock après mouvement (MAX du mois ≈ stock fin de mois) + */ +export async function pgGetMensuelByFournisseur( + codefou: string, + dateDebut: string, + dateFin: string +): Promise { + const result = await db.execute(sql` + SELECT + a.codein, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + SUM(CASE WHEN m.genremvt = 3 THEN ABS(m.qtemvt) ELSE 0 END)::float AS qte_vendue, + SUM(CASE WHEN m.genremvt = 3 THEN ABS(m.mntmvtttc) ELSE 0 END)::float AS ca_ht, + SUM(CASE WHEN m.genremvt = 3 THEN m.margemvt ELSE 0 END)::float AS marge, + MAX(m.qtestock)::float AS stock_fin_mois, + SUM(CASE WHEN m.genremvt IN (1, 2) THEN ABS(m.qtemvt) ELSE 0 END)::float AS qte_recue + FROM mvtart m + JOIN articles a + ON a.no_id = m.artnoid + JOIN artfou1 af + ON af.art_no_id = a.no_id AND af.code = ${codefou} + WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date + AND m.site IN ('292', '579') + GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY a.codein, m.site, mois + `); + + return result.rows as unknown as PgMensuelRow[]; +} + +// --------------------------------------------------------------------------- +// 3. Gammes (saison active) +// --------------------------------------------------------------------------- + +/** + * Retourne la gamme courante (saison active) pour chaque article du fournisseur. + * 1 requête SQL — remplace getReferentielByArticles() pour la partie gammes. + */ +export async function pgGetGammesByFournisseur(codefou: string): Promise> { + const result = await db.execute(sql` + SELECT + a.codein, + g.code AS gamme_code + FROM art_gamme_saison ags + JOIN articles a + ON a.no_id = ags.artnoid + JOIN artfou1 af + ON af.art_no_id = a.no_id AND af.code = ${codefou} + JOIN gammes g + ON g.no_id = ags.idgamme + JOIN saisons s + ON s.no_id = ags.idsaison + WHERE s.actif = true + `); + + const map = new Map(); + for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) { + if (row.codein && row.gamme_code) { + map.set(row.codein, row.gamme_code); + } + } + return map; +} + +// --------------------------------------------------------------------------- +// 4. Stock temps réel (cube_stock) +// --------------------------------------------------------------------------- + +/** + * Retourne le stock actuel par site pour chaque article du fournisseur. + * 1 requête SQL — remplace la partie stock du référentiel. + */ +export async function pgGetStockByFournisseur(codefou: string): Promise> { + const result = await db.execute(sql` + SELECT + a.codein, + cs.site, + cs.stockdispo::float, + cs.qte::float, + cs.valstock::float, + cs.prmp::float, + cs.dernierevente::text, + cs.dernierereception::text + FROM cube_stock cs + JOIN articles a + ON a.no_id = cs.artnoid + JOIN artfou1 af + ON af.art_no_id = a.no_id AND af.code = ${codefou} + `); + + const map = new Map(); + for (const row of result.rows as unknown as PgStockRow[]) { + if (!map.has(row.codein)) map.set(row.codein, []); + map.get(row.codein)!.push(row); + } + return map; +} + +// --------------------------------------------------------------------------- +// 5. Ranking réseau +// --------------------------------------------------------------------------- + +/** + * Retourne le classement réseau pour chaque article du fournisseur. + * 1 requête SQL — remplace getRankingByArticles() (N appels HTTP per-article, + * capés à 500 par l'API). + */ +export async function pgGetRankingByFournisseur(codefou: string): Promise<{ + rankings: Map; + totalRankedProducts: number; +}> { + const [rankResult, totalResult] = await Promise.all([ + db.execute(sql` + SELECT DISTINCT ON (a.codein) + a.codein, + r.site, + r.ranking_ca, + r.ranking_qte, + r.ranking_mag_ca, + r.ranking_mag_qte + FROM ranking r + JOIN art_gtin ag + ON ag.gtin = r.gencod + JOIN articles a + ON a.no_id = ag.idarticle + JOIN artfou1 af + ON af.art_no_id = a.no_id AND af.code = ${codefou} + ORDER BY a.codein, r.site + `), + db.execute(sql` + SELECT COUNT(DISTINCT gencod)::int AS total FROM ranking + `), + ]); + + const rankings = new Map(); + for (const row of rankResult.rows as unknown as PgRankingRow[]) { + if (row.codein) rankings.set(row.codein, row); + } + + const totalRankedProducts = Number((totalResult.rows[0] as unknown as { total: number })?.total ?? 0); + console.log(`[pg-ff] Ranking: ${rankings.size}/${codefou} articles classés (réseau: ${totalRankedProducts})`); + + return { rankings, totalRankedProducts }; +} + +// --------------------------------------------------------------------------- +// 6. Commandes en cours +// --------------------------------------------------------------------------- + +/** + * Retourne les quantités commandées en cours pour chaque article du fournisseur. + * 1 requête SQL — remplace getCommandesByFournisseur(). + */ +export async function pgGetCommandesByFournisseur(codefou: string): Promise> { + const result = await db.execute(sql` + SELECT + a.codein, + SUM(cv.qtecde)::float AS qtecde + FROM cdefou_vivant cv + JOIN artfou1 af + ON af.no_id = cv.artfou1_no_id AND af.code = ${codefou} + JOIN articles a + ON a.no_id = af.art_no_id + GROUP BY a.codein + `); + + const map = new Map(); + for (const row of result.rows as unknown as { codein: string; qtecde: number }[]) { + if (row.codein) map.set(row.codein, Number(row.qtecde) || 0); + } + return map; +}