From 649a1df267527b6dff3047252f03b393657df48a Mon Sep 17 00:00:00 2001 From: R0m1k3 <101972532+R0m1k3@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:32:51 +0000 Subject: [PATCH] fix(qlik): aggregate multi-month extraction correctly --- scripts/test-qlik-date-range.ts | 36 +++++- src/lib/qlik-date-range.ts | 96 +++++++++++++++ src/lib/qlik-playwright.ts | 199 +++++++++++++++++++++----------- 3 files changed, 261 insertions(+), 70 deletions(-) diff --git a/scripts/test-qlik-date-range.ts b/scripts/test-qlik-date-range.ts index 6efccbd..ff29eb3 100644 --- a/scripts/test-qlik-date-range.ts +++ b/scripts/test-qlik-date-range.ts @@ -18,6 +18,7 @@ import { qlikSerialToIsoDate, buildGridNetworkQlikDateFilter, chunkSerials, + getMonthRanges, QLIK_DATE_ANCHORS, } from "../src/lib/qlik-date-range"; @@ -107,8 +108,39 @@ eq("chunks[3][64]", chunks[3][64], 46173); console.log("\n[7] Robustesse"); throws("format invalide '2025/01/01'", () => isoDateToQlikSerial("2025/01/01"), /YYYY-MM-DD/); throws("format vide", () => isoDateToQlikSerial(""), /YYYY-MM-DD/); -truthy("label non vide pour fen\u00eatre valide", f.label && f.label.length > 0); +truthy("label non vide pour fenêtre valide", f.label && f.label.length > 0); truthy("setAnalysis commence par 'Date='", f.setAnalysis.startsWith("Date=")); -console.log(`\n=== ${pass} pass\u00e9s, ${fail} \u00e9chou\u00e9s ===`); +console.log("\n[8] getMonthRanges — fenêtre 12 mois (now=2026-06-21)"); +const months = getMonthRanges(f); +eq("12 mois exactement", months.length, 12); +eq("premier mois label", months[0].label, "2025-06"); +eq("premier mois dateDebut", months[0].dateDebut, "2025-06-01"); +eq("premier mois dateFin", months[0].dateFin, "2025-06-30"); +eq("premier mois serials.length = 30", months[0].dailySerials.length, 30); +eq("premier mois qStart", months[0].qStart, 45809); +eq("premier mois qEnd", months[0].qEnd, 45838); +eq("dernier mois label", months[11].label, "2026-05"); +eq("dernier mois dateDebut", months[11].dateDebut, "2026-05-01"); +eq("dernier mois dateFin", months[11].dateFin, "2026-05-31"); +eq("dernier mois serials.length = 31", months[11].dailySerials.length, 31); +// Couvre février d'une année non bissextile (2025) — window now=2025-06-15 → 2024-06..2025-05 +const fFeb25 = buildGridNetworkQlikDateFilter(new Date("2025-06-15T08:00:00Z")); +truthy("contient février 2025 (28j)", getMonthRanges(fFeb25).some((m) => m.label === "2025-02" && m.dailySerials.length === 28)); +// Couvre février d'une année bissextile (2024) +const fBiss = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z")); +const monthsBiss = getMonthRanges(fBiss); +truthy("contient février 2024 (29j)", monthsBiss.some((m) => m.label === "2024-02" && m.dailySerials.length === 29)); +// Total jours = 365 (somme des serials) +const totalDays = months.reduce((acc, m) => acc + m.dailySerials.length, 0); +eq("somme des jours = 365", totalDays, 365); + +console.log("\n[9] getMonthRanges — fenêtre courte partielle (troncature dernier mois)"); +const fShort = { ...f, dateDebut: "2025-06-01", dateFin: "2025-08-15", qStart: 45809, qEnd: 45884, dailySerials: [], setAnalysis: "" }; +const monthsShort = getMonthRanges(fShort); +eq("3 mois (juin, juillet, août)", monthsShort.length, 3); +eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15); +eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15"); + +console.log(`\n=== ${pass} passés, ${fail} échoués ===`); if (fail > 0) process.exit(1); diff --git a/src/lib/qlik-date-range.ts b/src/lib/qlik-date-range.ts index 6ada20b..e9a715c 100644 --- a/src/lib/qlik-date-range.ts +++ b/src/lib/qlik-date-range.ts @@ -135,3 +135,99 @@ export function chunkSerials(serials: number[], batchSize = 500): number[][] { } return out; } + +/** + * Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires + * **inclusives et continues**. + * + * But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N + * codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe, + * chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine. + * + * Règles : + * - Le 1er mois commence à `filter.dateDebut` (peut être un jour quelconque, + * pas forcément le 1er calendaire). + * - Chaque mois intermédiaire est un mois calendaire **complet** (du 1er + * au dernier jour du mois). + * - Le dernier mois se termine à `filter.dateFin` (tronqué si pas fin de mois). + * - Les mois sont contigus : fin d'un mois + 1 = début du suivant. + * - Si `filter.dateDebut` et `filter.dateFin` tombent dans le même mois, + * on renvoie un seul mois (identique à `filter` mais re-calculé). + * + * Chaque mois retourné est un `QlikDateFilter` autonome : `qStart`/`qEnd`, + * `dailySerials` recomptés, `setAnalysis` et `label` au format `YYYY-MM`. + * + * Pré-condition : `dateDebut <= dateFin` au sens calendaire (pas vérifié ici, + * la validation est faite par `buildGridNetworkQlikDateFilter`). + */ +export function getMonthRanges(filter: QlikDateFilter): QlikDateFilter[] { + const start = filter.dateDebut; // YYYY-MM-DD + const end = filter.dateFin; // YYYY-MM-DD + + // [yyyy, mm, dd] depuis YYYY-MM-DD (déjà validé par isoDateToQlikSerial). + const sy = Number(start.slice(0, 4)); + const sm = Number(start.slice(5, 7)); + // const sd = Number(start.slice(8, 10)); // non utilisé directement + const ey = Number(end.slice(0, 4)); + const em = Number(end.slice(5, 7)); + const ed = Number(end.slice(8, 10)); + + // Dernier jour du mois (0-ième du mois suivant en UTC local Date). + const lastDayOfMonth = (y: number, m: number): number => { + // m: 1..12 ; on prend le jour 0 du mois suivant, qui donne le dernier jour du mois courant. + return new Date(y, m, 0).getDate(); + }; + + const pad = (n: number): string => String(n).padStart(2, "0"); + + const out: QlikDateFilter[] = []; + + // Itère mois par mois du mois de début au mois de fin (inclus). + let curY = sy; + let curM = sm; + // Sécurité anti-boucle infinie (au cas où). + const MAX_MONTHS = 1200; // 100 ans, large. + let safety = 0; + while (safety++ < MAX_MONTHS) { + const isFirst = curY === sy && curM === sm; + const isLast = curY === ey && curM === em; + + // Début du mois courant : si c'est le premier mois, on respecte `dateDebut` ; + // sinon on prend le 1er calendaire. + const monthStartDay = isFirst ? Number(start.slice(8, 10)) : 1; + const monthStartDate = `${curY}-${pad(curM)}-${pad(monthStartDay)}`; + + // Fin du mois courant : si c'est le dernier mois, on respecte `dateFin` ; + // sinon on prend le dernier jour calendaire du mois. + const monthEndDay = isLast ? ed : lastDayOfMonth(curY, curM); + const monthEndDate = `${curY}-${pad(curM)}-${pad(monthEndDay)}`; + + const qStart = isoDateToQlikSerial(monthStartDate); + const qEnd = isoDateToQlikSerial(monthEndDate); + + const dailySerials: number[] = []; + for (let s = qStart; s <= qEnd; s++) dailySerials.push(s); + + const label = `${curY}-${pad(curM)}`; + const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`; + + out.push({ dateDebut: monthStartDate, dateFin: monthEndDate, qStart, qEnd, label, dailySerials, setAnalysis }); + + if (isLast) break; + + // Avance d'un mois calendaire (gère le passage décembre → janvier). + curM += 1; + if (curM > 12) { curM = 1; curY += 1; } + } + + if (out.length === 0) { + // Fenêtre vide : on retourne un seul mois vide aligné sur dateDebut. + const qStart = filter.qStart; + const qEnd = filter.qEnd; + const label = start.slice(0, 7); + const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`; + out.push({ dateDebut: start, dateFin: end, qStart, qEnd, label, dailySerials: [], setAnalysis }); + } + + return out; +} diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 35b9be1..fb47b47 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -14,7 +14,7 @@ import "server-only"; import { chromium, type Browser } from "playwright-core"; import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric } from "@/lib/qlik-client"; -import { buildGridNetworkQlikDateFilter, chunkSerials, type QlikDateFilter } from "@/lib/qlik-date-range"; +import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range"; let browserPromise: Promise | null = null; @@ -79,8 +79,22 @@ export async function fetchNetworkMetricsPlaywright( if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)"); console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`); + // Pré-calcul des mois côté Node (pour pouvoir logger X/Y et propager au script in-page). + const monthRanges: QlikDateFilter[] = dateFilter ? getMonthRanges(dateFilter) : []; + const monthlyPayload = monthRanges.map((m) => ({ + label: m.label, + dateDebut: m.dateDebut, + dateFin: m.dateFin, + serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois + })); + const result = (await page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, dateBatches, dateLabel }: { app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; codes: string[]; token: string; dateBatches: number[][]; dateLabel: string | null }) => + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode }: { + app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; + codes: string[]; token: string; + monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>; + noDateMode: boolean; + }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; @@ -101,6 +115,32 @@ export async function fetchNetworkMetricsPlaywright( ws.onerror = () => rej(new Error("ws error (403 ?)")); setTimeout(() => rej(new Error("ws timeout")), 30000); }); + + // Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes + // brutes lues (avant dédoublonnage côté Node). + const PAGE = 1400; + const fetchCubeForSelection = async (oh: number, sink: Array>): Promise => { + const layout = await rpc("GetLayout", {}, oh); + const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy; + let got = 0; + for (let top = 0; top < size; top += PAGE) { + const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (!matrix.length) break; + for (const r of matrix) sink.push([ + String(r[0]?.qText ?? ""), + Number(r[1]?.qNum) || 0, + Number(r[2]?.qNum) || 0, + Number(r[3]?.qNum) || 0, + Number(r[4]?.qNum) || 0, + Number(r[5]?.qNum) || 0, + ]); + got += matrix.length; + if (matrix.length < PAGE) break; + } + return got; + }; + (async () => { try { await ready; @@ -109,33 +149,16 @@ export async function fetchNetworkMetricsPlaywright( const doc = (open.qReturn as { qHandle: number }).qHandle; console.log("OpenDoc OK handle=" + doc); - // Aligne Qlik sur la timeline grille : 12 mois complets glissants, - // hors mois courant. La sélection du champ Date est héritée par les - // master measures du cube, sans dupliquer leurs expressions. - if (dateBatches.length) { - try { - const df = await rpc("GetField", { qFieldName: "Date" }, doc); - const dfh = (df.qReturn as { qHandle: number }).qHandle; - for (let i = 0; i < dateBatches.length; i++) { - const batch = dateBatches[i]; - await rpc( - "SelectValues", - { qFieldValues: batch.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, - dfh, - ); - console.log("filtre Date " + (i + 1) + "/" + dateBatches.length + " — " + batch.length + " jours — " + dateLabel); - } - } catch (e) { - console.log("filtre Date ignoré: " + String((e as Error)?.message || e)); - } - } - - // Handle du champ de sélection (réutilisé pour chaque lot). + // Handles des champs Date / Article Code. + let dfh = -1; let fh = -1; + const df = await rpc("GetField", { qFieldName: "Date" }, doc); + dfh = (df.qReturn as { qHandle: number }).qHandle; if (codes.length) { const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc); fh = (gf.qReturn as { qHandle: number }).qHandle; } + // Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS). const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } } }, doc); @@ -153,53 +176,65 @@ export async function fetchNetworkMetricsPlaywright( qInitialDataFetch: [], } } }, doc); const oh = (obj.qReturn as { qHandle: number }).qHandle; + const out: Array> = []; - const PAGE = 1400; - // Lots de sélection : avec le filtre Date actif (~365 jours), même ~400 codes - // forcés d'un coup déclenchent un recalcul de cube trop lourd → l'Engine - // annule (code 15 "Request aborted"). On extrait par petits lots, on vide la - // sélection "Article Code" entre chaque lot (le filtre Date est conservé car - // posé indépendamment), et on accumule les lignes côté client. - const BATCH = 100; - const batches: Array = codes.length - ? Array.from({ length: Math.ceil(codes.length / BATCH) }, (_, i) => codes.slice(i * BATCH, i * BATCH + BATCH)) - : [null]; - console.log("codes=" + codes.length + " → " + batches.length + " lot(s) de ≤ " + BATCH); - for (let b = 0; b < batches.length; b++) { - const batch = batches[b]; - const sample = batch ? batch.slice(0, 3).join(",") + (batch.length > 3 ? ",…" : "") : "∅"; - try { - if (batch && fh !== -1) { - const sel = await rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); - console.log("lot " + (b + 1) + "/" + batches.length + " — SelectValues " + batch.length + " codes [" + sample + "] → " + JSON.stringify(sel.qReturn)); - } - const layout = await rpc("GetLayout", {}, oh); - const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy; - let got = 0; - for (let top = 0; top < size; top += PAGE) { - const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - for (const r of matrix) out.push([String(r[0]?.qText ?? ""), Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0]); - got += matrix.length; - if (matrix.length < PAGE) break; - } - console.log("lot " + (b + 1) + "/" + batches.length + " — " + got + " ligne(s) (cube qcy=" + size + "), cumul=" + out.length); - } catch (batchErr) { - const msg = String((batchErr as Error)?.message || batchErr); - console.error("[qlik-pw] lot " + (b + 1) + "/" + batches.length + " abort [" + sample + "] — " + msg); - throw new Error("[qlik-pw] abort lot " + (b + 1) + "/" + batches.length + " (" + (batch?.length ?? 0) + " codes) — " + msg); + + if (noDateMode) { + // Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup). + // On sélectionne uniquement Article Code ; Date reste libre. + console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois"); + if (codes.length && fh !== -1) { + const sel = await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh); + console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn)); } - // Vide la sélection Article Code avant le lot suivant : le filtre Date - // reste posé sur l'app, on évite l'accumulation côté Engine. - if (batch && fh !== -1 && b < batches.length - 1) { + const got = await fetchCubeForSelection(oh, out); + console.log("extraction sans date — " + got + " ligne(s), cumul=" + out.length); + } else { + // Itération par mois : pour chaque mois, SelectValues Date (~30 serials) + + // SelectValues Article Code (tous les codes d'un coup) → cube mensuel léger. + // Clear Date + Clear Article Code avant le mois suivant. + const total = monthlyPayload.length; + console.log("itération par mois — " + total + " mois, " + codes.length + " codes"); + for (let mi = 0; mi < total; mi++) { + const m = monthlyPayload[mi]; + const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; try { - await rpc("Clear", {}, fh); - } catch (clearErr) { - console.log("[qlik-pw] Clear Article Code ignoré: " + String((clearErr as Error)?.message || clearErr)); + // 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs). + const dsel = await rpc("SelectValues", { + qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), + qToggleMode: false, + qSoftLock: true, + }, dfh); + console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); + // 2) Sélection Article Code (tous les codes d'un coup). + if (codes.length && fh !== -1) { + const csel = await rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh); + console.log(prefix + " — SelectValues Article Code " + codes.length + " codes → " + JSON.stringify(csel.qReturn)); + } + // 3) Lecture du cube mensuel. + const got = await fetchCubeForSelection(oh, out); + console.log(prefix + " — " + got + " ligne(s), cumul=" + out.length); + } catch (monthErr) { + const msg = String((monthErr as Error)?.message || monthErr); + console.error(prefix + " — ABORT — " + msg); + throw new Error(prefix + " — " + msg); + } + // 4) Clear Date + Clear Article Code avant le mois suivant (soulage l'Engine). + if (mi < total - 1) { + try { + await rpc("Clear", {}, dfh); + if (fh !== -1) await rpc("Clear", {}, fh); + } catch (clearErr) { + console.log(prefix + " — Clear ignoré: " + String((clearErr as Error)?.message || clearErr)); + } } } } + ws.close(); resolve({ ok: true, rows: out, size: out.length }); } catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); } @@ -215,8 +250,8 @@ export async function fetchNetworkMetricsPlaywright( mmarge: cfg.measMargePctId, codes: codeCentraux ?? [], token: csrfToken, - dateBatches: dateFilter ? chunkSerials(dateFilter.dailySerials, 500) : [], - dateLabel: dateFilter?.label ?? null, + monthlyPayload, + noDateMode: !dateFilter, }, )) as InPageResult; @@ -224,11 +259,39 @@ export async function fetchNetworkMetricsPlaywright( const wanted = codeCentraux ? new Set(codeCentraux) : null; const out = new Map(); + const margeWeight = new Map(); for (const r of result.rows ?? []) { const code = String(r[0]).trim(); if (!code || code === "-") continue; if (wanted && !wanted.has(code)) continue; - out.set(code, { codeCentrale: code, caReseau: Number(r[1]) || 0, qteReseau: Number(r[2]) || 0, nbMagasinsReseau: Number(r[3]) || 0, caParMagasinReseau: Number(r[4]) || 0, margePctReseau: Number(r[5]) || 0, periode: dateFilter?.label }); + + const ca = Number(r[1]) || 0; + const qte = Number(r[2]) || 0; + const nbMag = Number(r[3]) || 0; + const margePct = Number(r[5]) || 0; + const prev = out.get(code); + if (!prev) { + out.set(code, { + codeCentrale: code, + caReseau: ca, + qteReseau: qte, + nbMagasinsReseau: nbMag, + caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0, + margePctReseau: margePct, + periode: dateFilter?.label, + }); + margeWeight.set(code, ca); + continue; + } + + prev.caReseau += ca; + prev.qteReseau += qte; + prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag); + const weight = margeWeight.get(code) ?? 0; + const nextWeight = weight + ca; + prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct); + prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0; + margeWeight.set(code, nextWeight); } console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`); // Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs