fix(qlik): aggregate multi-month extraction correctly

This commit is contained in:
R0m1k3 committed 2026-06-21 14:32:51 +00:00
1 parent 6036ea5600
commit 649a1df267
3 files changed
+261 -70

No files matched your search

+34 -2
View File
@@ -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);
+96
View File
@@ -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;
}
+131 -68
View File
@@ -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<Browser> | 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<InPageResult>((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<Array<string | number>>): Promise<number> => {
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<Array<{ qText?: string; qNum?: number }>> }>)?.[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<Array<string | number>> = [];
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<string[] | null> = 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<Array<{ qText?: string; qNum?: number }>> }>)?.[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<string, NetworkMetric>();
const margeWeight = new Map<string, number>();
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