"use server"; import type { ProductRow, GammeCode, GridFilters } from "@/types/grid"; import { computeProductScores } from "@/lib/score-engine"; import { buildLast12MonthsRange } from "@/lib/api-ff-client"; import { pgGetArticlesByFournisseur, pgGetMensuelByFournisseur, pgGetGammesByFournisseur, pgGetNomenclatureByFournisseur, pgGetStockByFournisseur, pgGetRankingByFournisseur, pgGetCommandesByFournisseur, type PgStockRow, } from "@/lib/pg-ff-client"; import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { eq, desc } from "drizzle-orm"; import { unstable_cache } from "next/cache"; // ─── Interfaces ─────────────────────────────────────────────────────────────── export interface GetProductRowsInput { codeFournisseur: string; magasin?: string; filters?: Partial; page?: number; pageSize?: number; offset?: number; search?: string; gamme?: string | null; code3?: string | null; } export interface GetProductRowsResult { rows: ProductRow[]; total: number; page: number; pageSize: number; totalPages: number; } // Fonction utilitaire cachée pour mémoiser le chargement par fournisseur const getCachedSupplierRows = (codeFournisseur: string) => { return unstable_cache( async () => _fetchAllProductRows(codeFournisseur), [`grid-rows-${codeFournisseur}`], { revalidate: 300, tags: [`grid-rows-${codeFournisseur}`] } )(); }; // ─── Fonction interne : fetch complet non-paginé (cachée) ─────────────────── async function _fetchAllProductRows(codeFournisseur: string): Promise { const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side console.log(`\n>>> [_fetchAllProductRows] (NO CACHE) supplier: ${codeFournisseur}, magasin: ${magasin}`); try { const { dateDebut, dateFin } = buildLast12MonthsRange(); // ─── Phase 1 : 6 requêtes SQL en parallèle ──────────────────────────── // Remplace des centaines/milliers d'appels HTTP per-article. const [ articles, mensuelRows, gammeMap, nomMap, stockMap, rankingResult, commandesMap, ] = await Promise.all([ pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }), pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }), pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map(); }), pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }), pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map(); }), pgGetRankingByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetRankingByFournisseur ERROR:", e); return { rankings: new Map(), totalRankedProducts: 0 }; }), pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map(); }), ]); const { rankings: rankingMap, totalRankedProducts } = rankingResult; console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes, ${rankingMap.size} rankings`); // ─── 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(); // ─── Phase 3 : Seed productMap depuis articles ──────────────────────── const productMap = new Map(); for (const art of articles) { 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 4 : Agréger mensuelRows → byPeriod par codein ───────────── // SQL a déjà fait l'agrégation par (codein, site, mois). // On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side. type PeriodData = { qty: number; ca: number; marge: number; stock: number; receptions: number }; const mensuelByCodein = new Map>(); const mensuelBySite = new Map>>(); // codein → site → periode → data const storeMonthsByCodein = new Map>>(); for (const row of mensuelRows) { const periode = row.mois.replace("-", ""); // "2026-02" → "202602" if (!allowedPeriods.has(periode)) continue; // Accumulation TOTAL (toujours tous sites) 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; // Accumulation par site (pour switch client-side) if (!mensuelBySite.has(row.codein)) mensuelBySite.set(row.codein, new Map()); const bySite = mensuelBySite.get(row.codein)!; if (!bySite.has(row.site)) bySite.set(row.site, new Map()); const siteByPeriod = bySite.get(row.site)!; if (!siteByPeriod.has(periode)) siteByPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 }); const sp = siteByPeriod.get(periode)!; sp.qty += Number(row.qte_vendue) || 0; sp.ca += Number(row.ca_ht) || 0; sp.marge += Number(row.marge) || 0; sp.stock = Number(row.stock_fin_mois) || 0; // SQL donne déjà le dernier mouvement sp.receptions += Number(row.qte_recue) || 0; // Suivi magasins actifs (pour workingStores) : 1 vente suffit if (Number(row.qte_vendue) > 0) { if (!storeMonthsByCodein.has(row.codein)) storeMonthsByCodein.set(row.codein, new Map()); storeMonthsByCodein.get(row.codein)!.set(row.site, new Set()); } } // ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ───────────── for (const [codein, byPeriod] of mensuelByCodein.entries()) { const product = productMap.get(codein); if (!product) continue; let lastStock = 0; for (const periode of sortedPeriods) { const p = byPeriod.get(periode); if (p) { product.sales12m[periode] = p.qty; product.stock12m[periode] = p.stock; product.receptions12m[periode] = p.receptions; product.totalQuantite += p.qty; product.totalCa += p.ca; product.totalMarge += p.marge; lastStock = p.stock; } else { product.sales12m[periode] = 0; product.stock12m[periode] = lastStock; // carry-forward product.receptions12m[periode] = 0; } } product.tauxMarge = product.totalCa > 0 ? (product.totalMarge / product.totalCa) * 100 : 0; // Breakdowns par site pour le switch client-side const siteMap = mensuelBySite.get(codein); if (siteMap) { product.sales12mByStore = {}; product.stock12mByStore = {}; product.receptions12mByStore = {}; product.caByStore = {}; product.quantiteByStore = {}; product.margeByStore = {}; for (const [site, siteByPeriod] of siteMap.entries()) { product.sales12mByStore[site] = {}; product.stock12mByStore[site] = {}; product.receptions12mByStore![site] = {}; let sQty = 0, sCa = 0, sMarge = 0; let lastSiteStock = 0; for (const periode of sortedPeriods) { const sp = siteByPeriod.get(periode); product.sales12mByStore[site][periode] = sp?.qty ?? 0; product.receptions12mByStore![site][periode] = sp?.receptions ?? 0; if (sp) { product.stock12mByStore[site][periode] = sp.stock; lastSiteStock = sp.stock; } else { product.stock12mByStore[site][periode] = lastSiteStock; // carry-forward } sQty += sp?.qty ?? 0; sCa += sp?.ca ?? 0; sMarge += sp?.marge ?? 0; } product.quantiteByStore[site] = sQty; product.caByStore[site] = sCa; product.margeByStore[site] = sMarge; } } // workingStores : sites avec au moins 1 vente sur les 12 derniers mois const storeMonths = storeMonthsByCodein.get(codein); if (storeMonths) { product.workingStores = [...storeMonths.keys()].sort(); } // Commandes en cours const cmdQty = commandesMap.get(codein); if (cmdQty) product.commandesEnCours = cmdQty; } // ─── 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 6b : Nomenclature (famille / sous-famille) ──────────────── for (const [codein, nom] of nomMap.entries()) { const product = productMap.get(codein); if (!product) continue; product.code3 = nom.code3 ?? ""; product.libelle3 = nom.libelle3 ?? ""; product.code2 = nom.code2 ?? ""; product.libelleNiveau2 = nom.libelle2 ?? ""; product.code1 = nom.code1 ?? ""; product.libelleNiveau1 = nom.libelle1nom ?? ""; } // ─── Phase 7 : Stock temps réel (cube_stock) ───────────────────────── for (const [codein, stocks] of stockMap.entries()) { const product = productMap.get(codein); if (!product) continue; const sitesStock = stocks; 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); const withPrmp = sitesStock.find(r => Number(r.prmp) > 0); if (withPrmp) { product.pa = Number(withPrmp.prmp); product.prixAchat = product.pa; } const dernVente = sitesStock.reduce((best, r) => r.dernierevente && r.dernierevente > best ? r.dernierevente : best, ""); if (dernVente) product.derniereVente = dernVente; const dernLiv = sitesStock.reduce((best, r) => r.dernierereception && r.dernierereception > best ? r.dernierereception : best, ""); if (dernLiv) product.derniereLivraison = dernLiv; } // ─── 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 ? 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 9 : Restaurer gammes depuis dernier snapshot ────────────── try { const snaps = await db .select() .from(sessionSnapshots) .where(eq(sessionSnapshots.codeFournisseur, codeFournisseur)) .orderBy(desc(sessionSnapshots.createdAt)) .limit(1); if (snaps.length > 0) { const changes = snaps[0].changes as Record; for (const [codein, change] of Object.entries(changes)) { const product = productMap.get(codein); if (product && change.after) { // codeGammeInit reste figé — seulement codeGamme est overridé product.codeGamme = change.after as GammeCode; } } console.log(`[getProductRows] Gammes restaurées depuis snapshot`); } } catch (snapErr) { console.error("[getProductRows] Snapshot restore error:", snapErr); } // ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ───────── const allRows = Array.from(productMap.values()); const rows = allRows.filter(p => p.codeGamme?.trim().toUpperCase() !== "Y" || p.totalQuantite > 0); const excludedY = allRows.length - rows.length; console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`); return computeProductScores(rows); } catch (error) { console.error(`[_fetchAllProductRows] Error for ${codeFournisseur}:`, error); return []; } } // ─── Fonction publique ───────────── /** * Point d'entrée unique pour la grille. * - Retourne les lignes paginées pour préserver le client. */ export async function getProductRows(input: GetProductRowsInput): Promise { const { codeFournisseur, page, pageSize = 200, offset, search, gamme, code3, } = input; // Récupérer toutes les lignes mises en cache pour éviter les 650 requêtes SQL const allRows = await getCachedSupplierRows(codeFournisseur); let filtered = allRows; if (search && search.trim() !== "") { const q = search.trim().toLowerCase(); filtered = filtered.filter(r => (r.libelle1 ?? "").toLowerCase().includes(q) || (r.codein ?? "").toLowerCase().includes(q) || (r.reference ?? "").toLowerCase().includes(q) || (r.gtin ?? "").toLowerCase().includes(q) ); } if (gamme !== undefined && gamme !== null && gamme !== "") { filtered = filtered.filter(r => { const g = r.codeGamme ?? ""; const norm = g.trim() === "" ? "Aucune" : g; return norm === gamme; }); } if (code3) { filtered = filtered.filter(r => r.code3 === code3); } const total = filtered.length; if (page === undefined) { return { rows: filtered, total, page: 0, pageSize: total, totalPages: 1 }; } const safePageSize = Math.max(1, Math.min(pageSize, 20000)); const totalPages = Math.ceil(total / safePageSize); const safePage = Math.max(0, Math.min(page ?? 0, Math.max(0, totalPages - 1))); // Priorité à l'offset s'il est fourni (pour la pagination côté client qui ne respecte pas les blocs de 10k au départ) const startIdx = offset !== undefined ? offset : safePage * safePageSize; if (startIdx >= total) { return { rows: [], total, page: safePage, pageSize: safePageSize, totalPages }; } const rows = filtered.slice(startIdx, startIdx + safePageSize); return { rows, total, page: safePage, pageSize: safePageSize, totalPages }; }