diff --git a/src/app/(dashboard)/admin/synchronisation/client.tsx b/src/app/(dashboard)/admin/synchronisation/client.tsx index 0f6c649..8171871 100644 --- a/src/app/(dashboard)/admin/synchronisation/client.tsx +++ b/src/app/(dashboard)/admin/synchronisation/client.tsx @@ -480,6 +480,8 @@ export function SynchronisationClient() {

Ce sont des chiffres mensuels : les refaire chaque nuit coûterait du temps pour rien. + Sont aussi refaits, sans attendre ce délai : les données d'avant le mois en cours + (le dernier mois leur manque) et une extraction en échec, retentée la nuit suivante.

diff --git a/src/features/admin/api/sync-scheduler.ts b/src/features/admin/api/sync-scheduler.ts index b1b2e3b..7d80a44 100644 --- a/src/features/admin/api/sync-scheduler.ts +++ b/src/features/admin/api/sync-scheduler.ts @@ -140,19 +140,36 @@ async function prochainSql(depuis: Date) { return row ?? null; } +/** Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante. */ +const QLIK_REPRISE_ECHEC_MS = 18 * 3600 * 1000; + /** - * Le plus ancien fournisseur Qlik dont les données dépassent `qlikMinJours`. - * Le seuil en jours, plutôt que la fenêtre courante, évite de relancer chaque - * nuit une extraction de plusieurs minutes pour des agrégats mensuels. + * Le plus ancien fournisseur Qlik à rafraîchir. Il l'est si : + * - ses données dépassent `qlikMinJours` (agrégats mensuels : inutile de + * relancer chaque nuit une extraction de plusieurs minutes) ; + * - ou elles datent d'avant le mois en cours : la fenêtre de 12 mois a glissé, + * son dernier mois manque, sa tendance n'est plus à jour ; + * - ou sa dernière extraction a échoué il y a plus de 18 h. Un échec attendait + * jusqu'ici `qlikMinJours` (7 jours) avant un nouvel essai, alors que la + * plupart sont passagers (serveur Qlik chargé, dernier mois pas encore + * chargé). Le plus ancien passant en premier, un fournisseur qui échoue à + * chaque fois repart en fin de file et ne bloque personne. */ async function prochainQlik(qlikMinJours: number, maintenant: Date) { - const seuil = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000); + const seuilAge = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000); + const debutMois = new Date(maintenant.getFullYear(), maintenant.getMonth(), 1); + const seuil = seuilAge > debutMois ? seuilAge : debutMois; + const seuilEchec = new Date(maintenant.getTime() - QLIK_REPRISE_ECHEC_MS); const [row] = await db .select() .from(syncFournisseurs) .where(and( eq(syncFournisseurs.actifQlik, true), - or(isNull(syncFournisseurs.dernierQlikAt), lt(syncFournisseurs.dernierQlikAt, seuil)) as SQL, + or( + isNull(syncFournisseurs.dernierQlikAt), + lt(syncFournisseurs.dernierQlikAt, seuil), + and(eq(syncFournisseurs.dernierQlikStatut, "echec"), lt(syncFournisseurs.dernierQlikAt, seuilEchec)), + ) as SQL, )) .orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`)) .limit(1); diff --git a/src/lib/qlik-browser.ts b/src/lib/qlik-browser.ts new file mode 100644 index 0000000..c91e360 --- /dev/null +++ b/src/lib/qlik-browser.ts @@ -0,0 +1,59 @@ +/** + * CollectFlow — Chromium et file d'attente partagés par tous les accès Qlik + * (extraction réseau `qlik-playwright.ts`, recherche produit `qlik-search.ts`). + */ + +import "server-only"; +import { chromium, type Browser } from "playwright-core"; + +let browserPromise: Promise | null = null; + +/** + * Chromium partagé entre les extractions. + * + * Un navigateur tombé (plantage, mémoire, arrêt du processus enfant) restait + * mémorisé : toutes les extractions suivantes échouaient sur « Target closed » + * jusqu'au redémarrage du serveur. On le relance donc dès qu'il est déconnecté. + */ +export async function getQlikBrowser(): Promise { + if (browserPromise) { + const existant = await browserPromise.catch(() => null); + if (existant?.isConnected()) return existant; + browserPromise = null; + } + const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH; + const lancement = chromium.launch({ + headless: true, + executablePath: execPath || undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], + }); + browserPromise = lancement; + lancement + .then((b) => b.on("disconnected", () => { + console.warn("[qlik] Chromium déconnecté — il sera relancé à la prochaine extraction"); + if (browserPromise === lancement) browserPromise = null; + })) + .catch(() => { if (browserPromise === lancement) browserPromise = null; }); + return lancement; +} + +/** + * File unique des extractions. + * + * Le serveur Qlik abandonne les requêtes dès qu'il est trop sollicité. Une + * synchro manuelle lancée pendant la synchro de nuit, ou deux recherches + * produit simultanées, ouvraient deux sessions Engine en parallèle — et les + * deux échouaient. Les extractions passent désormais l'une après l'autre. + */ +let fileExtractions: Promise = Promise.resolve(); +let extractionsEnAttente = 0; + +export function dansLaFileQlik(libelle: string, tache: () => Promise): Promise { + if (extractionsEnAttente > 0) { + console.log(`[qlik] ${libelle} : en attente de ${extractionsEnAttente} extraction(s) en cours`); + } + extractionsEnAttente++; + const resultat = fileExtractions.then(tache, tache); + fileExtractions = resultat.then(() => undefined, () => undefined).finally(() => { extractionsEnAttente--; }); + return resultat; +} diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index e6d9a9d..3ac6926 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -13,23 +13,10 @@ */ import "server-only"; -import { chromium, type Browser } from "playwright-core"; +import { getQlikBrowser as getBrowser, dansLaFileQlik as dansLaFile } from "@/lib/qlik-browser"; import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client"; import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range"; - -let browserPromise: Promise | null = null; - -async function getBrowser(): Promise { - if (!browserPromise) { - const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH; - browserPromise = chromium.launch({ - headless: true, - executablePath: execPath || undefined, - args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], - }).catch((e) => { browserPromise = null; throw e; }); - } - return browserPromise; -} +import { assainirMetrique, controlerExtraction } from "@/lib/qlik-validation"; interface InPageResult { ok: boolean; @@ -86,7 +73,17 @@ function uniqueOrdered(values: number[]): number[] { return out; } -export async function fetchNetworkMetricsPlaywright( +export function fetchNetworkMetricsPlaywright( + codeCentraux?: string[], + cfg: QlikConfig = getQlikConfig(), + dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), + fournisseur?: string, +): Promise> { + const libelle = fournisseur ? `fournisseur ${fournisseur}` : `${(codeCentraux ?? []).length} code(s)`; + return dansLaFile(libelle, () => extraire(codeCentraux, cfg, dateFilter, fournisseur)); +} + +async function extraire( codeCentraux?: string[], cfg: QlikConfig = getQlikConfig(), dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), @@ -2477,6 +2474,20 @@ export async function fetchNetworkMetricsPlaywright( if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth)); if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth)); console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`); + + // Contrôle de plausibilité AVANT toute écriture : des données complètes + // mais fausses (dernier mois pas encore chargé, mesure insensible au + // mois) laisseraient le cache faux jusqu'à la synchro suivante. + const corrections = [...out.values()].flatMap(assainirMetrique); + if (corrections.length) { + console.warn(`[qlik-pw] ${corrections.length} valeur(s) impossible(s) corrigée(s) : ${corrections.slice(0, 5).join(" ; ")}`); + } + if (dateFilter) { + const controle = controlerExtraction(out.values(), monthRanges.map((m) => m.label)); + for (const a of controle.avertissements) console.warn(`[qlik-pw][contrôle] ${a}`); + if (!controle.ok) throw new Error(`[qlik-pw] ${controle.motif} — cache inchangé`); + console.log(`[qlik-pw][contrôle] extraction validée (${out.size} codes, ${monthRanges.length} mois)`); + } // Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs const sample = (result.rows ?? []).slice(0, 3).map((r) => ({ code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5], diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts index 0161cb5..5502798 100644 --- a/src/lib/qlik-search.ts +++ b/src/lib/qlik-search.ts @@ -35,7 +35,7 @@ */ import "server-only"; -import { chromium, type Browser } from "playwright-core"; +import { getQlikBrowser as getBrowser, dansLaFileQlik } from "@/lib/qlik-browser"; import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client"; import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; @@ -112,20 +112,6 @@ const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"]; */ const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale"; -let browserPromise: Promise | null = null; - -async function getBrowser(): Promise { - if (!browserPromise) { - const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH; - browserPromise = chromium.launch({ - headless: true, - executablePath: execPath || undefined, - args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], - }).catch((e) => { browserPromise = null; throw e; }); - } - return browserPromise; -} - interface InPageSearchResult { ok: boolean; error?: string; @@ -152,10 +138,22 @@ interface InPageSearchResult { * ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire * le détail mensuel sur 12 mois glissants. */ -export async function searchQlikArticles( +export function searchQlikArticles( term: string, limit: number = QLIK_SEARCH_MAX_RESULTS, cfg: QlikConfig = getQlikConfig(), +): Promise { + // Terme trop court : réponse vide immédiate, sans attendre la file. + if (term.trim().length < 3) return rechercher(term, limit, cfg); + // Même file que les extractions : deux sessions Engine simultanées font + // abandonner le serveur Qlik, et les deux requêtes échouent. + return dansLaFileQlik(`recherche « ${term.trim()} »`, () => rechercher(term, limit, cfg)); +} + +async function rechercher( + term: string, + limit: number, + cfg: QlikConfig, ): Promise { const cleaned = term.trim(); if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null }; diff --git a/src/lib/qlik-validation.ts b/src/lib/qlik-validation.ts new file mode 100644 index 0000000..6140bcd --- /dev/null +++ b/src/lib/qlik-validation.ts @@ -0,0 +1,156 @@ +/** + * CollectFlow — Contrôles de plausibilité d'une extraction réseau Qlik. + * + * L'extracteur vérifie déjà que les 12 mois de la fenêtre sont servis. Il ne + * peut pas voir deux pièges, observés ou attendus en production, qui donnent + * des chiffres faux mais complets : + * + * 1. **Dernier mois pas encore chargé dans Qlik.** Les premiers jours du mois, + * le mois qui vient de se terminer peut n'être chargé qu'en partie. Il est + * « couvert » (quelques ventes), donc accepté — et toutes les tendances du + * fournisseur plongent d'un coup, jusqu'à la synchro suivante, une semaine + * ou plus après. + * 2. **Mesure insensible au mois.** Quand une mesure ignore la sélection de + * dates, chaque mois reçoit la même valeur (le total de l'année). Le + * diagnostic existait dans les journaux ; rien n'empêchait l'écriture. + * + * Dans les deux cas, garder le cache précédent vaut mieux qu'écrire des + * données fausses : l'extraction est refusée, et le planificateur la reprend + * une nuit suivante. + * + * Module pur (aucune dépendance d'exécution) : testable hors serveur. + */ + +import type { NetworkMetric } from "@/lib/qlik-client"; + +export interface ControleExtraction { + ok: boolean; + /** Raison du refus, lisible par l'administrateur (statut de synchro). */ + motif?: string; + /** Anomalies corrigées ou tolérées, à journaliser. */ + avertissements: string[]; +} + +export interface OptionsControle { + /** + * Jusqu'à quel jour du mois le dernier mois est soupçonné d'être incomplet. + * Au-delà, une baisse est prise pour ce qu'elle est : une baisse. + */ + jourLimiteDernierMois?: number; + /** Part minimale du dernier mois par rapport à la moyenne des 3 précédents. */ + ratioDernierMoisMin?: number; + /** Volume mensuel moyen en dessous duquel le contrôle n'a pas de sens. */ + volumeMinimal?: number; + /** Part maximale de séries plates (même valeur 12 mois) parmi les séries vendues. */ + partSeriesPlatesMax?: number; +} + +const DEFAUTS: Required = { + jourLimiteDernierMois: 10, + ratioDernierMoisMin: 0.4, + volumeMinimal: 30, + partSeriesPlatesMax: 0.5, +}; + +const fini = (v: unknown): number => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +/** + * Corrige sur place les valeurs impossibles d'une métrique (NaN, infini, + * nombre de magasins négatif) et renvoie la liste des corrections. + */ +export function assainirMetrique(m: NetworkMetric): string[] { + const corrections: string[] = []; + const champs = ["caReseau", "qteReseau", "nbMagasinsReseau", "caParMagasinReseau", "margePctReseau"] as const; + for (const champ of champs) { + if (!Number.isFinite(Number(m[champ]))) { + corrections.push(`${m.codeCentrale} : ${champ} non numérique (${String(m[champ])}) remplacé par 0`); + m[champ] = 0; + } + } + if (m.nbMagasinsReseau < 0) { + corrections.push(`${m.codeCentrale} : nombre de magasins négatif (${m.nbMagasinsReseau}) remplacé par 0`); + m.nbMagasinsReseau = 0; + } + m.nbMagasinsReseau = Math.round(m.nbMagasinsReseau); + m.caParMagasinReseau = m.nbMagasinsReseau > 0 ? m.caReseau / m.nbMagasinsReseau : 0; + + if (m.qteByMonth) { + for (const [mois, v] of Object.entries(m.qteByMonth)) { + if (!Number.isFinite(Number(v))) { + corrections.push(`${m.codeCentrale} : quantité ${mois} non numérique remplacée par 0`); + m.qteByMonth[mois] = 0; + } + } + } + if (m.metricsByMonth) { + for (const [mois, v] of Object.entries(m.metricsByMonth)) { + m.metricsByMonth[mois] = { + qte: fini(v?.qte), + ca: fini(v?.ca), + nbMag: Math.max(0, Math.round(fini(v?.nbMag))), + caMag: fini(v?.caMag), + margePct: fini(v?.margePct), + }; + } + } + return corrections; +} + +/** + * Contrôle d'ensemble d'une extraction 12 mois, AVANT écriture du cache. + * + * `fenetre` : les mois attendus (« YYYY-MM »), du plus ancien au plus récent. + */ +export function controlerExtraction( + metriques: Iterable, + fenetre: string[], + now: Date = new Date(), + options: OptionsControle = {}, +): ControleExtraction { + const o = { ...DEFAUTS, ...options }; + const avertissements: string[] = []; + const liste = [...metriques]; + if (liste.length === 0 || fenetre.length < 4) return { ok: true, avertissements }; + + // ── 1. Séries plates : la mesure a ignoré la sélection de mois ────────── + let vendues = 0; + let plates = 0; + for (const m of liste) { + const valeurs = fenetre.map((mois) => fini(m.qteByMonth?.[mois])); + if (!valeurs.some((v) => v !== 0)) continue; + vendues++; + if (valeurs.every((v) => v === valeurs[0])) plates++; + } + if (vendues >= 5 && plates / vendues > o.partSeriesPlatesMax) { + return { + ok: false, + motif: `${plates} séries sur ${vendues} ont la même quantité les 12 mois : la mesure Qlik ignore le mois, données refusées`, + avertissements, + }; + } + if (plates > 0) avertissements.push(`${plates} série(s) identique(s) sur 12 mois parmi ${vendues} vendues`); + + // ── 2. Dernier mois manifestement incomplet ───────────────────────────── + const totaux = fenetre.map((mois) => liste.reduce((t, m) => t + fini(m.qteByMonth?.[mois]), 0)); + const dernier = totaux[totaux.length - 1]; + const precedents = totaux.slice(-4, -1); + const reference = precedents.reduce((t, v) => t + v, 0) / precedents.length; + if ( + now.getDate() <= o.jourLimiteDernierMois && + reference >= o.volumeMinimal && + dernier < reference * o.ratioDernierMoisMin + ) { + const pct = Math.round((dernier / reference) * 100); + return { + ok: false, + motif: `le dernier mois (${fenetre[fenetre.length - 1]}) ne pèse que ${pct} % des 3 mois précédents : ` + + `probablement pas encore entièrement chargé dans Qlik, données refusées (nouvel essai une prochaine nuit)`, + avertissements, + }; + } + + return { ok: true, avertissements }; +}