/** * 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, type SQL } from "drizzle-orm"; const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; // Cache module-level pour éviter les requêtes information_schema répétées let _nomenclatureParentCol: string | null | undefined = undefined; // undefined = pas encore chargé // --------------------------------------------------------------------------- // Utilitaire : exécuter une requête sans workers parallèles PostgreSQL. // Les workers parallèles consomment /dev/shm (mémoire partagée Docker) et font // échouer les requêtes si le conteneur a un shm_size insuffisant. // SET LOCAL s'applique uniquement à la transaction courante. // --------------------------------------------------------------------------- async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> { let rows: unknown[] = []; await db.transaction(async (tx) => { await tx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`); const r = await tx.execute(query); rows = r.rows; }); return { rows }; } // --------------------------------------------------------------------------- // Types résultats SQL // --------------------------------------------------------------------------- export interface PgArticle { no_id: number; codein: string; /** Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik "Article Code". Vide pour les articles non référencés centralement. */ codeCentrale?: 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 codefou_principal?: string; // artfou1.code où preference=1 nomfou_principal?: string; // nom du fournisseur principal } 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; } // --------------------------------------------------------------------------- // 0. Liste des fournisseurs // --------------------------------------------------------------------------- /** * Retourne la liste des fournisseurs depuis fouident (vrais noms) + artfou1. */ export async function pgGetFournisseurs(search?: string): Promise<{ code: string; nom: string }[]> { try { const result = await db.execute(sql` SELECT DISTINCT fi.code, fi.nom FROM fouident fi INNER JOIN artfou1 af ON af.code = fi.code WHERE fi.nom IS NOT NULL AND fi.nom != '' ${search ? sql`AND (fi.nom ILIKE ${'%' + search + '%'} OR fi.code ILIKE ${'%' + search + '%'})` : sql``} ORDER BY fi.nom `); console.log(`[pg-ff] pgGetFournisseurs: ${result.rows.length} fournisseurs`); return (result.rows as unknown as { code: string; nom: string }[]).filter(r => r.code && r.nom); } catch (e) { console.error("[pg-ff] pgGetFournisseurs error:", (e as Error).message?.slice(0, 200)); return []; } } // --------------------------------------------------------------------------- // 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 { // DISTINCT ON (a.no_id) car artfou1 peut avoir plusieurs lignes par article/fournisseur. // Pas de filtre "actif" : on retourne tous les articles pour ne pas exclure les articles // d'une session précédente (snapshot) qui n'ont pas de gamme DB ni de ventes récentes. // Le filtrage gamme-Y sans ventes est fait en aval dans getProductRows (Phase 10). // afpref : fournisseur préférentiel (preference=1) de l'article — peut différer du fournisseur consulté. const result = await pgNoParallel(sql` SELECT DISTINCT ON (a.no_id) a.no_id, a.codein, a.artcentrale AS "codeCentrale", af.code AS codefou, fi.nom 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, last_fournisseur.codefou_dernier AS codefou_principal, last_fournisseur.nomfou_dernier AS nomfou_principal FROM artfou1 af JOIN articles a ON a.no_id = af.art_no_id LEFT JOIN fouident fi ON fi.code = af.code 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 LEFT JOIN LATERAL ( SELECT af2.code AS codefou_dernier, fi2.nom AS nomfou_dernier FROM artfou1 af2 LEFT JOIN fouident fi2 ON fi2.code = af2.code WHERE af2.art_no_id = a.no_id ORDER BY af2.no_id DESC LIMIT 1 ) last_fournisseur ON true WHERE af.code = ${codefou} AND a.codein IS NOT NULL AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) ORDER BY a.no_id, af.no_id `); console.log(`[pg-ff] Articles: ${result.rows.length} pour ${codefou}`); return (result.rows as unknown as PgArticle[]).filter(r => r.codein); } /** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */ export interface PgArticleLocal { codein: string; codeCentrale: string; libelle1?: string; codefou?: string; nomfou?: string; pa?: number; pv_central?: number; } /** * Résout des codes centraux vers NOS articles locaux. * * Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous * référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code * absent de la Map = produit inconnu de FF Nancy. */ export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise> { const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))]; if (unique.length === 0) return new Map(); const result = await pgNoParallel(sql` SELECT DISTINCT ON (a.artcentrale) TRIM(a.codein::text) AS codein, a.artcentrale AS "codeCentrale", a.libelle1, af.code AS codefou, fi.nom AS nomfou, pa.pa, ai.prix_vente_mini AS pv_central FROM articles a LEFT JOIN artfou1 af ON af.art_no_id = a.no_id LEFT JOIN fouident fi ON fi.code = af.code LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id WHERE TRIM(a.artcentrale::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)}) AND a.codein IS NOT NULL ORDER BY a.artcentrale, af.no_id `); const map = new Map(); for (const r of result.rows as unknown as PgArticleLocal[]) { if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r); } console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`); return map; } // --------------------------------------------------------------------------- // 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 { // IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements. // Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents), // ce qui multiplierait chaque mouvement et gonflerait les totaux. // // stock_fin_mois = qtestock du DERNIER mouvement du mois (tri par datmvt DESC). // MAX(qtestock) donnait le stock de début de mois (avant les premières ventes), ce qui était faux. const result = await pgNoParallel(sql` WITH base AS ( SELECT a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, m.qtemvt, m.mntmvtttc, m.margemvt, m.genremvt, m.qtestock, ROW_NUMBER() OVER ( PARTITION BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') ORDER BY m.datmvt DESC ) AS rn_last FROM mvtart m JOIN articles a ON a.no_id = m.artnoid JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af ON af.art_no_id = a.no_id WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date AND m.site IN ('292', '579') ) SELECT codein, site, mois, SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue FROM base GROUP BY codein, site, mois ORDER BY codein, 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> { // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier // saisons n'a PAS de colonne "actif" → pas de filtre actif const result = await pgNoParallel(sql` SELECT DISTINCT ON (a.codein) 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 ORDER BY a.codein, s.no_id DESC `); 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, String(row.gamme_code).trim()); } if (result.rows.length > 0) { console.log("[pg-ff] Gammes sample row:", JSON.stringify(result.rows[0])); } console.log(`[pg-ff] Gammes: ${map.size} articles avec gamme`); return map; } /** * Gammes **serveur** pour une liste de codein, tous fournisseurs confondus. * * Variante de `pgGetGammesByFournisseur` sans la jointure `artfou1` : la recherche * de l'API `/api/v1` est transversale, on ne connaît donc pas le fournisseur. * C'est la gamme telle qu'elle existe en base — non modifiée, indépendante des * snapshots de session qui peuvent surcharger `codeGamme` dans la Grille. */ export async function pgGetGammesByCodeins(codeins: string[]): Promise> { const unique = [...new Set(codeins.map(c => String(c ?? "").trim()).filter(Boolean))]; if (unique.length === 0) return new Map(); // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier. const result = await pgNoParallel(sql` SELECT DISTINCT ON (a.codein) TRIM(a.codein::text) AS codein, g.code AS gamme_code FROM art_gamme_saison ags JOIN articles a ON a.no_id = ags.artnoid JOIN gammes g ON g.no_id = ags.idgamme JOIN saisons s ON s.no_id = ags.idsaison WHERE TRIM(a.codein::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)}) ORDER BY a.codein, s.no_id DESC `); 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, String(row.gamme_code).trim()); } console.log(`[pg-ff] pgGetGammesByCodeins: ${map.size}/${unique.length} articles avec gamme`); return map; } // --------------------------------------------------------------------------- // 4. Nomenclature (famille / sous-famille) // --------------------------------------------------------------------------- export interface PgNomRow { codein: string; code3: string; // code feuille (ex: "360504") libelle3: string; // libellé feuille code2?: string; // famille (4 chiffres) libelle2?: string; code1?: string; // secteur (2 chiffres) libelle1nom?: string; } /** * Retourne la nomenclature (famille/sous-famille) pour chaque article du fournisseur. * FK confirmée : articles.nom_no_id → nomenclature.no_id * Colonnes confirmées : nomenclature.code, nomenclature.libelle * Colonne parent : découverte dynamiquement via information_schema */ export async function pgGetNomenclatureByFournisseur(codefou: string): Promise> { // Découverte de la colonne parent : mise en cache module-level (1 seule fois par process) if (_nomenclatureParentCol === undefined) { const metaResult = await db.execute(sql` SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'nomenclature' ORDER BY ordinal_position `); const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]); console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", ")); // Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent) // "chemin_pere" = chemin vers le père en français // On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé) const parentRow = nomCols.find(r => (/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name))) && /int|serial/i.test(r.data_type) ); _nomenclatureParentCol = parentRow?.column_name ?? null; console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol); } const parentCol = _nomenclatureParentCol; let result; if (parentCol) { result = await pgNoParallel(sql` SELECT a.codein, n3.code AS code3, n3.libelle AS libelle3, n2.code AS code2, n2.libelle AS libelle2, n1.code AS code1, n1.libelle AS libelle1nom FROM artfou1 af JOIN articles a ON a.no_id = af.art_no_id AND af.code = ${codefou} JOIN nomenclature n3 ON n3.no_id = a.nom_no_id LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)} LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)} WHERE a.codein IS NOT NULL `); } else { // Sans hiérarchie : juste le niveau feuille result = await pgNoParallel(sql` SELECT a.codein, n3.code AS code3, n3.libelle AS libelle3 FROM artfou1 af JOIN articles a ON a.no_id = af.art_no_id AND af.code = ${codefou} JOIN nomenclature n3 ON n3.no_id = a.nom_no_id WHERE a.codein IS NOT NULL `); } console.log(`[pg-ff] Nomenclature: ${result.rows.length} articles, parentCol="${parentCol ?? "none"}"`); if (result.rows.length > 0) console.log("[pg-ff] Nomenclature sample:", JSON.stringify(result.rows[0])); return buildNomMap(result.rows as unknown as PgNomRow[]); } function buildNomMap(rows: PgNomRow[]): Map { const map = new Map(); for (const r of rows) { if (r.codein) map.set(r.codein, r); } return map; } // --------------------------------------------------------------------------- // 5. 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 pgNoParallel(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 (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af ON af.art_no_id = a.no_id `); 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; } // --------------------------------------------------------------------------- // 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> { // cdefou_vivant est une vue dénormalisée : articles_codein et cdefou_ligne_qtecde disponibles directement const result = await pgNoParallel(sql` SELECT cv.articles_codein AS codein, SUM(cv.cdefou_ligne_qtecde)::float AS qtecde FROM cdefou_vivant cv JOIN artfou1 af ON af.no_id = cv.artfou1_no_id AND af.code = ${codefou} WHERE cv.articles_codein IS NOT NULL GROUP BY cv.articles_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; } // --------------------------------------------------------------------------- // 7. Dashboard quotidien (hit parade + évolution N/N-1) // --------------------------------------------------------------------------- export interface DashboardSiteStats { site: string; ca_hier: number; ca_n1: number; tickets_hier: number; tickets_n1: number; qte_hier: number; marge_hier: number; lignes_hier: number; } export interface DashboardTopItem { codein: string; libelle1: string; ca: number; qte: number; marge: number; stock: number; fournisseur: string; } export interface DashboardSiteTop10 { site: string; ca: DashboardTopItem[]; qte: DashboardTopItem[]; marge: DashboardTopItem[]; } export interface DashboardData { dateHier: string; dateN1: string; sites: DashboardSiteStats[]; top10BySite: DashboardSiteTop10[]; } // --------------------------------------------------------------------------- // Types internes API performance // --------------------------------------------------------------------------- interface ApiPerfSite { site: string; ca_ttc: number; ca_ttc_n1: number; trafic: number; trafic_n1: number; } interface ApiPerfTopItem { codein: string; libelle1: string; site: string; codefou_reel?: string; nom_fournisseur_reel?: string; ca_ttc: number; qte_vendue: number; marge: number; } interface ApiPerfDashboard { date: string; date_n1: string; sites: ApiPerfSite[]; top10_ca: ApiPerfTopItem[]; top10_qte: ApiPerfTopItem[]; top10_marge: ApiPerfTopItem[]; } /** * Retourne toutes les données nécessaires pour le dashboard quotidien. * 100% via l'API REST api.ffnancy.fr — aucune requête PostgreSQL directe. * N-1 : même jour de semaine (52 semaines = 364 jours avant hier). */ export async function pgGetDashboardData(): Promise { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const dateHier = yesterday.toISOString().split("T")[0]; // N-1 : même jour de semaine, même semaine N-1 → 364 jours = 52 semaines exactes const n1Date = new Date(yesterday); n1Date.setDate(n1Date.getDate() - 364); const dateN1Param = n1Date.toISOString().split("T")[0]; const apiBase = `${FF_API_BASE}/api/performance`; // Deux appels parallèles : données hier + données même jour N-1 const [apiResult, apiN1Result] = await Promise.all([ fetch(`${apiBase}/dashboard?date=${dateHier}`, { cache: "no-store" }) .then(r => { if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); return r.json() as Promise; }), fetch(`${apiBase}/dashboard?date=${dateN1Param}`, { cache: "no-store" }) .then(r => r.ok ? r.json() as Promise : null) .catch(() => null), ]); // Construire map N-1 par site depuis l'appel séparé const n1BySite = new Map(); for (const s of apiN1Result?.sites ?? []) { n1BySite.set(s.site, { ca: Number(s.ca_ttc) || 0, trafic: Number(s.trafic) || 0 }); } const sites: DashboardSiteStats[] = (apiResult.sites ?? []).map(s => { const n1 = n1BySite.get(s.site); // Priorité : même jour de semaine N-1 (si > 0), sinon fallback sur n1 calendaire de l'API const caN1 = (n1?.ca && n1.ca > 0) ? n1.ca : (Number(s.ca_ttc_n1) || 0); const trafficN1 = (n1?.trafic && n1.trafic > 0) ? n1.trafic : (Number(s.trafic_n1) || 0); return { site: s.site, ca_hier: Number(s.ca_ttc) || 0, ca_n1: caN1, tickets_hier: Number(s.trafic) || 0, tickets_n1: trafficN1, qte_hier: 0, marge_hier: 0, lignes_hier: 0, }; }); // Top 10 par magasin : grouper les items par site, trier, garder top 10 const allItems = [ ...(apiResult.top10_ca ?? []), ...(apiResult.top10_qte ?? []), ...(apiResult.top10_marge ?? []), ]; const allCodeins = [...new Set(allItems.map(i => i.codein).filter(Boolean))]; // Fournisseur réel : un seul appel sur les mouvements du jour (codefou_reel / nom_fournisseur_reel). // Enrichissement : fournisseur réel via /dernier-fournisseur (primaire) + /referentiel (stock + fallback fournisseur). // Les deux appels sont faits en parallèle par article. const fouMap = new Map(); // codein → nom_fournisseur résolu const stockMap = new Map(); if (allCodeins.length > 0) { try { for (let i = 0; i < allCodeins.length; i += 5) { const batch = allCodeins.slice(i, i + 5); await Promise.all(batch.map(async (codein) => { try { const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`); if (!artRes.ok) return; const artData = await artRes.json(); if (!artData.articles || artData.articles.length === 0) return; const noId = artData.articles[0].no_id; if (!noId) return; const [refRes, dernierRes] = await Promise.all([ fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`), fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`), ]); // Stock (toujours depuis referentiel) let refFournisseurFallback = ""; if (refRes.ok) { const refData = await refRes.json(); let s292 = 0, s579 = 0, sTot = 0; if (Array.isArray(refData.stock)) { for (const s of refData.stock) { const qte = Number(s.qte) || 0; if (s.site === '292') s292 += qte; else if (s.site === '579') s579 += qte; sTot += qte; } } stockMap.set(codein, { stock292: s292, stock579: s579, stockTotal: sTot }); // Fallback fournisseur (pas d'entrée en stock) : // - 1 seul fournisseur → on l'utilise directement // - plusieurs → celui avec preference = 1 if (Array.isArray(refData.fournisseurs) && refData.fournisseurs.length > 0) { type RefFou = { codefou: string; nom_fou: string | null; preference: number | null }; const fous = refData.fournisseurs as RefFou[]; const pick = fous.length === 1 ? fous[0] : (fous.find(f => f.preference === 1) ?? fous[0]); refFournisseurFallback = pick.nom_fou?.trim() || pick.codefou?.trim() || ""; } } // Fournisseur réel : /dernier-fournisseur (dernière entrée en stock par site) // Fallback : premier non-dépôt du référentiel if (dernierRes.ok) { const dernierData = await dernierRes.json(); const entries: Array<{ site: string; nom_fournisseur: string }> = dernierData.fournisseurs_par_site ?? []; // Prendre le fournisseur du premier site disponible const first = entries.find(e => e.nom_fournisseur); if (first) { fouMap.set(codein, String(first.nom_fournisseur).trim()); } else if (refFournisseurFallback) { fouMap.set(codein, refFournisseurFallback); } } else if (refFournisseurFallback) { fouMap.set(codein, refFournisseurFallback); } } catch (err) { console.error(`[Dashboard enrichment] Error for codein ${codein}:`, err); } })); } } catch (enrichErr: unknown) { console.warn("[Dashboard] Enrichment unavailable:", enrichErr); } } const siteSet = [...new Set(allItems.map(i => i.site).filter(Boolean))].sort(); const buildTop10 = (items: ApiPerfTopItem[], sortKey: "ca" | "qte" | "marge"): DashboardTopItem[] => items .map(i => { const stockEntry = stockMap.get(i.codein); const stock = i.site === "292" ? (stockEntry?.stock292 ?? 0) : i.site === "579" ? (stockEntry?.stock579 ?? 0) : (stockEntry?.stockTotal ?? 0); const fournisseur = fouMap.get(i.codein) ?? "—"; return { codein: i.codein, libelle1: i.libelle1, ca: Number(i.ca_ttc) || 0, qte: Number(i.qte_vendue) || 0, marge: Number(i.marge) || 0, stock, fournisseur, }; }) .sort((a, b) => b[sortKey] - a[sortKey]) .slice(0, 10); const top10BySite: DashboardSiteTop10[] = siteSet.map(site => { const byS = (arr: ApiPerfTopItem[]) => arr.filter(i => i.site === site); return { site, ca: buildTop10(byS(apiResult.top10_ca ?? []), "ca"), qte: buildTop10(byS(apiResult.top10_qte ?? []), "qte"), marge: buildTop10(byS(apiResult.top10_marge ?? []), "marge"), }; }); console.log(`[pg-ff] Dashboard: hier=${dateHier} n1=${dateN1Param} sites=${sites.length} n1_found=${n1BySite.size}`); return { dateHier: apiResult.date ?? dateHier, dateN1: apiN1Result?.date ?? dateN1Param, sites, top10BySite, }; } // --------------------------------------------------------------------------- // Analytics — CA TTC par fournisseur et nomenclature // --------------------------------------------------------------------------- export interface CaByFournisseurRow { code: string; nom: string; site: string; mois: string; ca_ttc: number; } export interface CaByNomenclatureRow { code: string; libelle: string; site: string; mois: string; ca_ttc: number; } /** * Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1). * Attribution via artfou1.preference = true (fournisseur principal de l'article). * CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention * que pgGetMensuelByFournisseur / pgGetHitParade). Fournisseur préféré résolu * via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par * article, aucune duplication possible des mouvements dans la somme. */ export async function pgGetCaByFournisseur( mois: string, moisN1: string ): Promise { const result = await pgNoParallel(sql` SELECT COALESCE(af.code, 'SANS_FOURNISSEUR')::text AS code, COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, SUM(-m.mntmvtttc)::float AS ca_ttc FROM mvtart m JOIN articles a ON a.no_id = m.artnoid LEFT JOIN ( SELECT DISTINCT ON (art_no_id) art_no_id, code FROM artfou1 WHERE preference = 1 ORDER BY art_no_id, code ) af ON af.art_no_id = a.no_id LEFT JOIN fouident fi ON fi.code = af.code WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1}) AND m.site IN ('292', '579') AND m.genremvt = 3 GROUP BY af.code, fi.nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') ORDER BY af.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') `); console.log(`[pg-ff] CaByFournisseur: ${result.rows.length} lignes pour ${mois}/${moisN1}`); return result.rows as unknown as CaByFournisseurRow[]; } /** * Retourne le CA TTC par nomenclature et site pour deux mois donnés. * CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune). */ export async function pgGetCaByNomenclature( mois: string, moisN1: string ): Promise { const result = await pgNoParallel(sql` SELECT COALESCE(n.code, 'SANS_NOM')::text AS code, COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, SUM(-m.mntmvtttc)::float AS ca_ttc FROM mvtart m JOIN articles a ON a.no_id = m.artnoid LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1}) AND m.site IN ('292', '579') AND m.genremvt = 3 GROUP BY n.no_id, n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') ORDER BY n.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') `); console.log(`[pg-ff] CaByNomenclature: ${result.rows.length} lignes pour ${mois}/${moisN1}`); return result.rows as unknown as CaByNomenclatureRow[]; } // --------------------------------------------------------------------------- // Hit Parade — produits les plus vendus sur un mois donné // --------------------------------------------------------------------------- export interface HitParadeRow { codein: string; libelle: string; fournisseur: string; nomenclature_code: string; nomenclature: string; site: string; qte_vendue: number; ca_ttc: number; marge: number; stock292: number; stock579: number; stockTotal: number; } /** * Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site. * * L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1), * exactement comme pgGetMensuelByFournisseur (le calcul canonique de la Grille) : * qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3 * (ventes stockées en négatif — vérifié sur les mouvements réels de l'API). * Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont * joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les * mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que * par article vendu (et non par ligne de mouvement). * * Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes, * 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours * additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle. */ export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise { const result = await pgNoParallel(sql` WITH ventes AS ( SELECT TRIM(a.codein::text) AS codein, m.site, SUM(-m.qtemvt)::float AS qte_vendue, SUM(-m.mntmvtttc)::float AS ca_ttc, SUM(m.margemvt)::float AS marge FROM mvtart m JOIN articles a ON a.no_id = m.artnoid WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date AND m.site IN ('292', '579') AND m.genremvt = 3 AND a.codein IS NOT NULL GROUP BY TRIM(a.codein::text), m.site ), attrs AS ( SELECT DISTINCT ON (TRIM(a.codein::text)) TRIM(a.codein::text) AS codein, a.libelle1::text AS libelle, COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, COALESCE(n.code, '')::text AS nomenclature_code, COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature FROM articles a LEFT JOIN LATERAL ( SELECT af1.code FROM artfou1 af1 WHERE af1.art_no_id = a.no_id AND af1.preference = 1 ORDER BY af1.code LIMIT 1 ) af ON TRUE LEFT JOIN fouident fi ON fi.code = af.code LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes) ORDER BY TRIM(a.codein::text), a.no_id DESC ), stock_agg AS ( SELECT TRIM(a2.codein::text) AS codein, SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292, SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579, SUM(cs.qte)::float AS stockTotal FROM cube_stock cs JOIN articles a2 ON a2.no_id = cs.artnoid WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes) GROUP BY TRIM(a2.codein::text) ) SELECT v.codein, COALESCE(ar.libelle, '') AS libelle, COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur, COALESCE(ar.nomenclature_code, '') AS nomenclature_code, COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature, v.site, v.qte_vendue, v.ca_ttc, v.marge, COALESCE(s.stock292, 0) AS stock292, COALESCE(s.stock579, 0) AS stock579, COALESCE(s.stockTotal, 0) AS "stockTotal" FROM ventes v LEFT JOIN attrs ar ON ar.codein = v.codein LEFT JOIN stock_agg s ON s.codein = v.codein ORDER BY v.ca_ttc DESC `); console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${dateDebut}→${dateFin}`); return result.rows as unknown as HitParadeRow[]; } // --------------------------------------------------------------------------- // Stock par liste de codeins (pour Hit Parade) // --------------------------------------------------------------------------- export interface StockBySite { stock292: number; stock579: number; stockTotal: number; } /** * Retourne le stock actuel (stockdispo) par codein pour une liste de codeins. * 1 seule requête bulk — utilisé pour enrichir le Hit Parade. * * On passe les codeins comme une SEULE chaîne CSV ($1) et on utilise * string_to_array côté PG — cela évite l'expansion Drizzle en $1,$2,...$N * qui déclencherait le PG error 54011 (ROW > 1664 entries). */ export async function pgGetStockForCodeins(codeins: string[]): Promise> { if (codeins.length === 0) return new Map(); const result = await pgNoParallel( sql`SELECT TRIM(a.codein::text) AS codein, cs.site, COALESCE(cs.qte, 0)::float AS stockdispo FROM cube_stock cs JOIN articles a ON a.no_id = cs.artnoid WHERE TRIM(a.codein::text) IN (${sql.join(codeins.map(c => sql`${c}`), sql`, `)})` ); const rows = result.rows as { codein: string; site: string; stockdispo: number }[]; const map = new Map(); for (const r of rows) { if (!map.has(r.codein)) map.set(r.codein, { stock292: 0, stock579: 0, stockTotal: 0 }); const entry = map.get(r.codein)!; if (r.site === "292") entry.stock292 += r.stockdispo; else if (r.site === "579") entry.stock579 += r.stockdispo; entry.stockTotal += r.stockdispo; } return map; } // --------------------------------------------------------------------------- // Stock négatif — tous magasins ou par site // --------------------------------------------------------------------------- export interface PgStockNegatifRow { codein: string; libelle1: string; fournisseur: string; site: string; stockdispo: number; dernierevente: string | null; derniereentree: string | null; } /** * Retourne tous les articles avec un stock disponible négatif. * Si `site` est fourni, filtre sur ce magasin uniquement. */ export interface PgStockSansVenteRow { codein: string; libelle1: string; fournisseur: string; site: string; stock_actuel: number; derniere_entree: string | null; } /** * Retourne les articles ayant des entrées en stock (hors mois courant) * mais aucune vente enregistrée sur ce site. */ export async function pgGetStockSansVente(site?: string): Promise { const siteFilter = site ? sql`AND m.site = ${site}` : sql``; const result = await pgNoParallel(sql` SELECT a.codein, COALESCE(a.libelle1, '') AS libelle1, COALESCE(f.nom, af.code) AS fournisseur, m.site, COALESCE(cs.qte::float, 0) AS stock_actuel, MAX(m.datmvt)::text AS derniere_entree 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.preference = 1 JOIN fouident f ON f.code = af.code LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND date_trunc('month', m.datmvt) < date_trunc('month', CURRENT_DATE) ${siteFilter} AND NOT EXISTS ( SELECT 1 FROM mvtart mv2 WHERE mv2.artnoid = m.artnoid AND mv2.site = m.site AND mv2.genremvt = 3 ) GROUP BY a.codein, a.libelle1, f.nom, af.code, m.site, cs.qte ORDER BY MAX(m.datmvt) DESC `); return result.rows as PgStockSansVenteRow[]; } export async function pgGetStockNegatif(site?: string): Promise { const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; const result = await pgNoParallel(sql` SELECT a.codein, COALESCE(a.libelle1, '') AS libelle1, COALESCE(f.nom, af.code) AS fournisseur, cs.site, cs.qte::float AS stockdispo, cs.dernierevente::text, ( SELECT MAX(m.datmvt)::text FROM mvtart m WHERE m.artnoid = cs.artnoid AND m.site = cs.site AND m.genremvt IN (1, 2) ) AS derniereentree FROM cube_stock cs JOIN articles a ON a.no_id = cs.artnoid JOIN ( SELECT DISTINCT ON (art_no_id) art_no_id, code FROM artfou1 WHERE preference = 1 ORDER BY art_no_id, code ) af ON af.art_no_id = a.no_id JOIN fouident f ON f.code = af.code WHERE cs.qte < 0 AND cs.site IN ('292', '579') ${siteFilter} ORDER BY cs.qte ASC `); return result.rows as PgStockNegatifRow[]; } // --------------------------------------------------------------------------- // Stock sans vente depuis 6 mois // --------------------------------------------------------------------------- export interface PgSansVente6MoisRow { codein: string; libelle1: string; fournisseur: string; site: string; stock_actuel: number; derniere_vente: string | null; derniere_entree: string | null; jours_sans_vente: number | null; } /** * Retourne les articles ayant du stock disponible (qte > 0) * dont la dernière vente remonte à plus de 6 mois (ou jamais vendus) * ET dont la dernière entrée en stock remonte aussi à plus de 6 mois. */ export async function pgGetSansVente6Mois(site?: string): Promise { const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; const result = await pgNoParallel(sql` SELECT a.codein, COALESCE(a.libelle1, '') AS libelle1, COALESCE(f.nom, af.code) AS fournisseur, cs.site, cs.qte::float AS stock_actuel, cs.dernierevente::text AS derniere_vente, CASE WHEN cs.dernierevente IS NULL THEN NULL ELSE (CURRENT_DATE - cs.dernierevente::date) END AS jours_sans_vente, ( SELECT MAX(m.datmvt)::text FROM mvtart m WHERE m.artnoid = cs.artnoid AND m.site = cs.site AND m.genremvt IN (1, 2) ) AS derniere_entree FROM cube_stock cs JOIN articles a ON a.no_id = cs.artnoid JOIN ( SELECT DISTINCT ON (art_no_id) art_no_id, code FROM artfou1 WHERE preference = 1 ORDER BY art_no_id, code ) af ON af.art_no_id = a.no_id JOIN fouident f ON f.code = af.code WHERE cs.site IN ('292', '579') AND cs.qte > 0 AND ( cs.dernierevente IS NULL OR cs.dernierevente < CURRENT_DATE - INTERVAL '6 months' ) AND NOT EXISTS ( SELECT 1 FROM mvtart m2 WHERE m2.artnoid = cs.artnoid AND m2.site = cs.site AND m2.genremvt IN (1, 2) AND m2.datmvt >= CURRENT_DATE - INTERVAL '6 months' ) ${siteFilter} ORDER BY cs.dernierevente ASC NULLS FIRST `); return result.rows as PgSansVente6MoisRow[]; } // --------------------------------------------------------------------------- // Commandes automatiques — par site + fournisseur avec franco // --------------------------------------------------------------------------- export interface PgCommandeAutoRow { site: string; codefou: string; nomfou: string; franco: number; nb_articles: number; montant_cde: number; franco_atteint: boolean | null; ecart_franco: number; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function mapCommandeAutoRow(r: any): PgCommandeAutoRow { return { site: String(r.site ?? ""), codefou: String(r.codefou ?? ""), nomfou: String(r.nom_fou ?? r.nomfou ?? ""), franco: Number(r.franco_ht ?? r.franco ?? 0), nb_articles: Number(r.nb_articles ?? 0), montant_cde: Number(r.montant_propo_ht ?? r.montant_cde ?? 0), franco_atteint: r.franco_atteint === "OUI" ? true : r.franco_atteint === "NON" ? false : r.franco_atteint === "N/A" ? null : r.franco_atteint != null ? Boolean(r.franco_atteint) : null, ecart_franco: Number(r.ecart_franco ?? 0), }; } /** * Retourne la liste des fournisseurs en commande automatique via l'API Hostinger. * GET https://api.ffnancy.fr/api/commandes-auto * * Pour les fournisseurs dont le franco est absent de la réponse globale (franco_ht null), * on enrichit via le detail endpoint /api/commandes-auto/:codefou?site= qui contient * toujours le résumé franco du fournisseur. */ export async function pgGetCommandesAuto(): Promise { try { const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); // L'API retourne { count: N, propositions: [...] } ou un tableau direct const rows: unknown[] = Array.isArray(data) ? data : (data?.propositions ?? data?.data ?? data?.items ?? data?.results ?? []); // Diagnostic : log les clés du premier row pour détecter les changements de format if (rows.length > 0) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const sample = rows[0] as any; console.log(`[pg-ff] pgGetCommandesAuto sample keys:`, Object.keys(sample)); console.log(`[pg-ff] pgGetCommandesAuto sample franco fields: franco_ht=${sample.franco_ht} franco=${sample.franco} franco_atteint=${sample.franco_atteint}`); } console.log(`[pg-ff] pgGetCommandesAuto: ${rows.length} lignes (API Hostinger)`); // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapped = (rows as any[]).map(mapCommandeAutoRow); // Enrichissement : pour les fournisseurs sans franco dans la réponse globale, // on appelle GET /api/commandes-auto/{codefou} (sans filtre site — franco est une // propriété du fournisseur, pas du site) et on lit resume[0].franco_ht. const missingFranco = mapped.filter(r => r.franco === 0 && r.codefou); if (missingFranco.length > 0) { console.log(`[pg-ff] pgGetCommandesAuto: ${missingFranco.length} lignes sans franco → enrichissement`); // Dédupliquer par codefou (franco identique quel que soit le site) const uniqueCodeFous = [...new Set(missingFranco.map(r => r.codefou))]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const extractFranco = (detail: any): number => { if (!detail || detail.error) return 0; const resumeRaw = detail?.resume ?? detail?.summary ?? detail; const resumeItem = Array.isArray(resumeRaw) ? resumeRaw[0] : resumeRaw; return Number(resumeItem?.franco_ht ?? resumeItem?.franco ?? 0); }; const details = await Promise.allSettled( uniqueCodeFous.map(async codefou => { // 1. Essai sans filtre site try { const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, { cache: "no-store" }); if (res2.ok) { const detail = await res2.json(); const f = extractFranco(detail); if (f > 0) return { codefou, franco: f }; } } catch { /* ignore */ } // 2. Fallback site=000 try { const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, { cache: "no-store" }); if (res3.ok) { const detail = await res3.json(); const f = extractFranco(detail); if (f > 0) return { codefou, franco: f }; } } catch { /* ignore */ } return { codefou, franco: 0 }; }) ); // Map codefou → franco enrichi const francoMap = new Map(); for (const result of details) { if (result.status === "fulfilled" && result.value.franco > 0) { francoMap.set(result.value.codefou, result.value.franco); } } console.log(`[pg-ff] pgGetCommandesAuto: francos enrichis:`, Object.fromEntries(francoMap)); return mapped.map(r => { if (r.franco === 0) { const enriched = francoMap.get(r.codefou); if (enriched && enriched > 0) return { ...r, franco: enriched }; } return r; }); } return mapped; } catch (e) { console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300)); return []; } } // --------------------------------------------------------------------------- // 12. Dernière réception par fournisseur ET par site (cadencier) // --------------------------------------------------------------------------- /** * Retourne la date de la dernière réception (entrée de marchandise) par * fournisseur et par site, pour piloter le cadencier de commande. * * Source : mvtart (genremvt IN (1, 2) = réceptions, voir pgGetMensuelByFournisseur). * La réception est rattachée à l'article ; on l'attribue au fournisseur * principal de l'article (artfou1.preference = 1), comme partout ailleurs. * * Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur * (l'UI affichera alors « échéance inconnue »). */ export async function pgGetDerniereReceptionParFournisseur(): Promise> { const map = new Map(); try { const result = await pgNoParallel(sql` SELECT af.code AS codefou, m.site AS site, MAX(m.datmvt) AS derniere 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.preference = 1 WHERE m.genremvt IN (1, 2) AND m.site IN ('292', '579') AND m.datmvt IS NOT NULL GROUP BY af.code, m.site `); for (const row of result.rows as { codefou: string; site: string; derniere: string | Date }[]) { if (!row.codefou || !row.derniere) continue; const iso = row.derniere instanceof Date ? row.derniere.toISOString() : new Date(row.derniere).toISOString(); map.set(`${String(row.codefou).trim()}|${String(row.site ?? "").trim()}`, iso); } console.log(`[pg-ff] pgGetDerniereReceptionParFournisseur: ${map.size} entrées (mvtart)`); return map; } catch (e) { console.error("[pg-ff] pgGetDerniereReceptionParFournisseur error:", (e as Error).message?.slice(0, 250)); return map; } }