From f0e36308f238b960901a52e42a27e390861dfa66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:43:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20qlik-validate.mjs=20=E2=80=94?= =?UTF-8?q?=20valider=20les=20expressions=20et=20la=20cl=C3=A9=20de=20join?= =?UTF-8?q?ture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur Qlik est filtré par IP : injoignable depuis l'extérieur du réseau FF (la passerelle d'egress tombe en connection timeout sur 443 comme sur 80, alors qu'elle atteint le reste d'Internet). Impossible donc de valider à distance les expressions par défaut ni la clé de jointure. Ce script répond aux deux questions en une exécution, à lancer depuis le conteneur de l'app : - expressions réelles des master measures (CA N, Quantité N, COMP…) ; - tableau mois par mois comparant Sum(quantite) / Sum(ca_ht) / Sum(ca_ttc) / Count(DISTINCT [Magasin Code]) aux mesures « N », sur l'année en cours (où elles doivent coïncider) et sur l'année précédente (où les « N » sont censées être à 0) ; - Article Code, article_no_centrale et article_codein côte à côte, pour trancher la jointure avec articles.artcentrale. Lecture seule : sélections en soft lock, objets de session détruits. Le mot de passe vient de l'environnement, rien n'est écrit ni affiché. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 20 +++ scripts/qlik-validate.mjs | 354 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 scripts/qlik-validate.mjs diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index a8280d6..0f4c7b0 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -120,6 +120,26 @@ Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`, `ca_ttc` plutôt que `ca_ht`…). Quantité totale nulle ou erreur Engine → repli automatique sur l'ancien chemin. `QLIK_USE_EXPR=0` le force. +### Valider les expressions sur le vrai serveur + +`scripts/qlik-validate.mjs` répond aux deux questions ouvertes en une exécution, +**depuis un réseau qui atteint Qlik** (le serveur est filtré par IP : injoignable +depuis l'extérieur, la passerelle d'egress tombe en `connection timeout`) : + +``` +docker exec -e QLIK_PWD='' -it node scripts/qlik-validate.mjs +``` + +Il affiche les expressions réelles des master measures, puis un tableau mois par +mois comparant `Sum(quantite)` / `Sum(ca_ht)` / `Sum(ca_ttc)` / +`Count(DISTINCT [Magasin Code])` aux mesures « N » — sur les mois de l'année en +cours (où elles doivent coïncider) comme sur ceux de l'année précédente (où les +mesures « N » sont censées être à 0). Il affiche enfin `Article Code`, +`article_no_centrale` et `article_codein` côte à côte, pour trancher la clé de +jointure avec `articles.artcentrale`. + +Lecture seule : sélections en soft lock, objets de session détruits. + ## Mois vides de la fenêtre glissante (chemin de repli) Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre diff --git a/scripts/qlik-validate.mjs b/scripts/qlik-validate.mjs new file mode 100644 index 0000000..86e3a46 --- /dev/null +++ b/scripts/qlik-validate.mjs @@ -0,0 +1,354 @@ +/** + * CollectFlow — Validation des expressions Qlik et de la clé de jointure. + * + * À lancer DEPUIS le réseau qui atteint Qlik (typiquement le conteneur de l'app) : + * + * docker exec -e QLIK_PWD='' -it node scripts/qlik-validate.mjs + * + * Répond aux deux questions encore ouvertes : + * + * 1. Les master measures « CA N » / « Quantité N » ignorent-elles la sélection + * Date, et les agrégations directes (`Sum(quantite)`, `Sum(ca_ht)`…) sont-elles + * un substitut fidèle ? Le script affiche, pour quelques articles réellement + * vendus, un tableau mois par mois comparant les deux — dont les mois d'août à + * décembre qui ressortaient vides. + * + * 2. Quelle valeur joint avec `articles.artcentrale` : la dimension « Article + * Code » ou le champ `article_no_centrale` ? Le script affiche les deux + * côte à côte. + * + * Aucun secret n'est écrit ni affiché : le mot de passe vient de l'environnement. + * Le script est en LECTURE SEULE (sélections en soft lock, objets de session + * détruits) — il ne modifie rien dans Qlik. + */ + +import httpntlm from "httpntlm"; +import { randomBytes } from "node:crypto"; +import { chromium } from "playwright-core"; + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certificat interne + +const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr"; +const USER = process.env.QLIK_USER || "FFSCH"; +const PWD = process.env.QLIK_PWD || ""; +const DOMAIN = process.env.QLIK_DOMAIN ?? ""; +const APP = process.env.QLIK_APP_NETWORK || "9872ee6e-d64a-4b43-984a-076bf1f7f647"; +const DIM_ARTICLE = process.env.QLIK_DIM_CODE_ARTICLE_ID || "yesCP"; +const DIM_MOIS = process.env.QLIK_MOIS_DIM_ID || "pfGAwTs"; +const NB_ARTICLES = Number(process.env.QLIK_VALIDATE_ARTICLES || "3"); + +if (!PWD) { + console.error("ERREUR : définir QLIK_PWD dans l'environnement."); + console.error(" docker exec -e QLIK_PWD='…' -it node scripts/qlik-validate.mjs"); + process.exit(1); +} + +const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0"); + +// ─── Fenêtre 12 mois glissants, mois courant exclu (même règle que l'app) ──── + +const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); +const serialDe = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - QLIK_EPOCH_MS) / 86_400_000); + +function fenetre12Mois(now = new Date()) { + const debut = new Date(now.getFullYear(), now.getMonth() - 12, 1); + const fin = new Date(now.getFullYear(), now.getMonth(), 0); + const serials = []; + const s0 = serialDe(debut.getFullYear(), debut.getMonth() + 1, debut.getDate()); + const s1 = serialDe(fin.getFullYear(), fin.getMonth() + 1, fin.getDate()); + for (let s = s0; s <= s1; s++) serials.push(s); + const labels = []; + for (let i = 12; i >= 1; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + labels.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); + } + return { serials, labels, debut: debut.toISOString().slice(0, 10), fin: fin.toISOString().slice(0, 10) }; +} + +// ─── Auth NTLM → cookie de session ────────────────────────────────────────── + +function ntlmGet(path) { + return new Promise((resolve, reject) => { + httpntlm.get( + { + url: `https://${HOST}${path}`, + username: USER, + password: PWD, + domain: DOMAIN, + workstation: "", + rejectUnauthorized: false, + headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": xrfkey }, + }, + (err, res) => (err ? reject(err) : resolve(res)), + ); + }); +} + +function cookieDepuis(setCookie) { + const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : []; + return arr.map((c) => String(c).split(";")[0]).find((c) => /X-Qlik-Session/i.test(c)) ?? null; +} + +async function session() { + const hub = await fetch(`https://${HOST}/hub/`, { redirect: "manual" }); + const targetId = ((hub.headers.get("location") ?? "").match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? ""; + const chemin = targetId + ? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}` + : `/hub/?xrfkey=${xrfkey}`; + const res = await ntlmGet(chemin); + + const direct = cookieDepuis(res.headers["set-cookie"]); + if (direct) return direct; + + const loc = String(res.headers["location"] ?? ""); + if (!/qlikTicket=/.test(loc)) throw new Error(`auth NTLM sans ticket (HTTP ${res.statusCode})`); + const ech = await fetch(loc, { redirect: "manual" }); + const sc = typeof ech.headers.getSetCookie === "function" ? ech.headers.getSetCookie() : []; + const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; "); + if (!cookie) throw new Error("ticket non échangé contre un cookie de session"); + return cookie; +} + +// ─── Script exécuté dans la page (websocket Engine accepté par le proxy) ──── + +function scriptInPage({ app, token, dimArticle, dimMois, serials, labels, nbArticles }) { + return new Promise((resolve) => { + const url = `wss://${location.host}/app/${app}?reloadUri=${encodeURIComponent(location.href)}&qlik-csrf-token=${token}`; + const ws = new WebSocket(url); + const sortie = []; + const log = (l) => { sortie.push(l); console.log("[cf] " + l); }; + let id = 0; + const pend = new Map(); + const rpc = (method, params, handle = -1) => new Promise((res, rej) => { + const i = ++id; pend.set(i, { res, rej }); + ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); + }); + const pret = new Promise((res, rej) => { + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.method === "OnConnected") return res(); + if (m.id && pend.has(m.id)) { + const p = pend.get(m.id); pend.delete(m.id); + if (m.error) p.rej(new Error(JSON.stringify(m.error))); + else p.res(m.result); + } + }; + ws.onerror = () => rej(new Error("websocket refusé (403 ?)")); + setTimeout(() => rej(new Error("timeout websocket")), 30000); + }); + const dormir = (ms) => new Promise((r) => setTimeout(r, ms)); + + (async () => { + try { + await pret; + const doc = (await rpc("OpenDoc", { qDocName: app })).qReturn.qHandle; + + // ─── 1. Expressions des master measures ────────────────────── + const ml = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "MeasureList" }, + qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } }, + }, + }, doc); + const items = (await rpc("GetLayout", {}, ml.qReturn.qHandle)).qLayout.qMeasureList?.qItems ?? []; + const parTitre = new Map(); + log("=== EXPRESSIONS DES MASTER MEASURES ==="); + for (const it of items) { + const t = (it.qMeta?.title ?? it.qData?.title ?? "").trim(); + if (!t) continue; + parTitre.set(t.toLowerCase(), it.qInfo.qId); + if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(t)) { + log(` « ${t} » = ${it.qData?.expr ?? "?"}`); + } + } + const mesure = (t) => parTitre.get(t.toLowerCase()) ?? null; + + // ─── 2. Sélection de la fenêtre 12 mois ────────────────────── + const champDate = await rpc("GetField", { qFieldName: "Date" }, doc); + const dh = champDate.qReturn.qHandle; + await rpc("Clear", {}, dh); + await dormir(300); + for (let i = 0; i < serials.length; i += 500) { + await rpc("SelectValues", { + qFieldValues: serials.slice(i, i + 500).map((s) => ({ qNum: s, qText: String(s) })), + qToggleMode: i > 0, + qSoftLock: true, + }, dh); + } + await dormir(500); + log(""); + log(`=== FENÊTRE SÉLECTIONNÉE : ${labels[0]} → ${labels[labels.length - 1]} (${serials.length} jours) ===`); + + // ─── 3. Articles les plus vendus (par agrégation directe) ──── + const cubeTop = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-val-top" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dimArticle }], + qMeasures: [{ qDef: { qDef: "Sum(quantite)" }, qSortBy: { qSortByNumeric: -1 } }], + qInterColumnSortOrder: [1, 0], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: nbArticles }], + }, + }, + }, doc); + const topMatrix = (await rpc("GetLayout", {}, cubeTop.qReturn.qHandle)) + .qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? []; + const codes = topMatrix.map((r) => String(r[0]?.qText ?? "")).filter(Boolean); + log(`articles testés (top ventes) : ${JSON.stringify(codes)}`); + if (codes.length === 0) { ws.close(); return resolve({ ok: false, error: "aucun article vendu sur la fenêtre", sortie }); } + + const champArticle = await rpc("GetField", { qFieldName: "Article Code" }, doc); + const ah = champArticle.qReturn.qHandle; + await rpc("Clear", {}, ah); + await dormir(300); + await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, ah); + await dormir(500); + + // ─── 4. Comparaison mois par mois ──────────────────────────── + const mesures = [ + { qDef: { qDef: "Sum(quantite)" } }, + { qDef: { qDef: "Sum(ca_ht)" } }, + { qDef: { qDef: "Sum(ca_ttc)" } }, + { qDef: { qDef: "Count(DISTINCT [Magasin Code])" } }, + { qDef: { qDef: "Sum(marge)" } }, + ]; + const idQte = mesure("Quantité N"); + const idCa = mesure("CA N"); + const idNbMag = mesure("Magasin Ventes Nb N"); + if (idQte) mesures.push({ qLibraryId: idQte }); + if (idCa) mesures.push({ qLibraryId: idCa }); + if (idNbMag) mesures.push({ qLibraryId: idNbMag }); + const largeur = 2 + mesures.length; + + const cube = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-val-mois" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dimArticle }, { qLibraryId: dimMois }], + qMeasures: mesures, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: 400 }], + }, + }, + }, doc); + const hc = (await rpc("GetLayout", {}, cube.qReturn.qHandle)).qLayout.qHyperCube; + const lignes = hc.qDataPages?.[0]?.qMatrix ?? []; + + log(""); + log("=== COMPARAISON MOIS PAR MOIS (agrégation directe vs master measure) ==="); + log("article mois Sum(quantite) Quantité N | Sum(ca_ht) Sum(ca_ttc) CA N | Count(mag) Mag Nb N"); + const pad = (v, n) => String(v).padStart(n); + for (const r of lignes) { + const code = String(r[0]?.qText ?? ""); + const moisBrut = String(r[1]?.qText ?? ""); + const mois = /^\d{6}$/.test(moisBrut) ? moisBrut.slice(0, 4) + "-" + moisBrut.slice(4) : moisBrut; + const n = (i) => Number(r[2 + i]?.qNum ?? 0) || 0; + log( + code.padEnd(13) + mois.padEnd(9) + + pad(Math.round(n(0)), 13) + pad(Math.round(n(5)), 12) + " |" + + pad(Math.round(n(1)), 11) + pad(Math.round(n(2)), 13) + pad(Math.round(n(6)), 9) + " |" + + pad(Math.round(n(3)), 11) + pad(Math.round(n(7)), 10), + ); + } + log(""); + log("LECTURE : si « Quantité N » vaut 0 sur les mois de l'année précédente alors que"); + log(" Sum(quantite) est renseigné, la master measure est bien inutilisable"); + log(" pour une fenêtre glissante — et l'agrégation directe la remplace."); + log(" Si Sum(quantite) == Quantité N sur les mois de l'année en cours,"); + log(" l'expression par défaut de CollectFlow est validée."); + log(" Comparer aussi Sum(ca_ht) et Sum(ca_ttc) à « CA N » pour choisir"); + log(" QLIK_EXPR_CA."); + + // ─── 5. Clé de jointure : Article Code vs article_no_centrale ─ + const cubeCle = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-val-cle" }, + qHyperCubeDef: { + qDimensions: [ + { qLibraryId: dimArticle }, + { qDef: { qFieldDefs: ["article_no_centrale"] } }, + { qDef: { qFieldDefs: ["article_codein"] } }, + ], + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 20 }], + }, + }, + }, doc); + const cles = (await rpc("GetLayout", {}, cubeCle.qReturn.qHandle)) + .qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? []; + log(""); + log("=== CLÉ DE JOINTURE ==="); + log("Article Code article_no_centrale article_codein"); + for (const r of cles) { + log( + String(r[0]?.qText ?? "").padEnd(20) + + String(r[1]?.qText ?? "").padEnd(22) + + String(r[2]?.qText ?? ""), + ); + } + log(""); + log("LECTURE : la bonne clé est celle qui a le format de `articles.artcentrale`"); + log(" côté FF Nancy (ex. 10000167303 — préfixe 10000 + 6 chiffres)."); + + ws.close(); + resolve({ ok: true, sortie }); + } catch (e) { + try { ws.close(); } catch { /* noop */ } + resolve({ ok: false, error: String(e?.message || e), sortie }); + } + })(); + }); +} + +// ─── Orchestration ────────────────────────────────────────────────────────── + +(async () => { + const f = fenetre12Mois(); + console.log(`Qlik ${HOST} — app ${APP}`); + console.log(`Fenêtre : ${f.debut} → ${f.fin}\n`); + + const cookie = await session(); + console.log("✓ authentifié (NTLM + ticket)\n"); + const [nom, ...reste] = cookie.split("="); + + const navigateur = await chromium.launch({ + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], + }); + const ctx = await navigateur.newContext({ + httpCredentials: { username: USER, password: PWD, origin: `https://${HOST}` }, + ignoreHTTPSErrors: true, + }); + await ctx.addCookies([{ name: nom, value: reste.join("="), domain: HOST, path: "/", secure: true }]); + + try { + const page = await ctx.newPage(); + let token = null; + let pret; + const attente = new Promise((r) => { pret = r; }); + page.on("websocket", (ws) => { + const m = ws.url().match(/qlik-csrf-token=([^&]+)/); + if (m && !token) { token = decodeURIComponent(m[1]); pret(); } + }); + await page.goto(`https://${HOST}/sense/app/${APP}`, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {}); + await Promise.race([attente, page.waitForTimeout(20000)]); + if (!token) throw new Error("qlik-csrf-token introuvable (client Qlik non chargé ?)"); + + const res = await page.evaluate(scriptInPage, { + app: APP, token, dimArticle: DIM_ARTICLE, dimMois: DIM_MOIS, + serials: f.serials, labels: f.labels, nbArticles: NB_ARTICLES, + }); + + console.log(res.sortie.join("\n")); + if (!res.ok) { + console.error("\nÉCHEC : " + res.error); + process.exitCode = 1; + } + } finally { + await ctx.close(); + await navigateur.close(); + } +})().catch((e) => { + console.error("ERREUR :", e?.message || e); + process.exit(1); +});