mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Qlik : données contrôlées avant écriture et récupération plus fiable
Fiabilité des données - Contrôle de plausibilité avant d'écrire le cache (lib/qlik-validation.ts) : extraction refusée, cache inchangé, si le dernier mois pèse moins de 40 % des 3 précédents dans les 10 premiers jours du mois (mois pas encore chargé dans Qlik), ou si plus de la moitié des séries vendues ont la même valeur 12 mois (mesure insensible au mois). - Valeurs impossibles corrigées (NaN, infini, nombre de magasins négatif). Récupération - Chromium relancé s'il est tombé : un navigateur déconnecté faisait échouer toutes les extractions jusqu'au redémarrage du serveur. - Une seule session Qlik à la fois (extraction de nuit, synchro manuelle, recherche produit) : en parallèle, le serveur Qlik abandonnait les deux. - Planificateur : un échec est retenté la nuit suivante (au lieu de 7 jours) et les données d'avant le mois en cours sont rafraîchies sans attendre le délai de fraîcheur. Page Synchronisation mise à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
This commit is contained in:
6 files changed
+280
-37
No files matched your search
@@ -480,6 +480,8 @@ export function SynchronisationClient() {
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Browser> | 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<Browser> {
|
||||
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<unknown> = Promise.resolve();
|
||||
let extractionsEnAttente = 0;
|
||||
|
||||
export function dansLaFileQlik<T>(libelle: string, tache: () => Promise<T>): Promise<T> {
|
||||
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;
|
||||
}
|
||||
+27
-16
@@ -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<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
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<Map<string, NetworkMetric>> {
|
||||
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],
|
||||
|
||||
+14
-16
@@ -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<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
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<QlikSearchOutcome> {
|
||||
// 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<QlikSearchOutcome> {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null };
|
||||
|
||||
@@ -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<OptionsControle> = {
|
||||
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<NetworkMetric>,
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user