From 4a56584a27c2559f5177927e5d5a5b09a401df2d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:27:33 +0000 Subject: [PATCH] =?UTF-8?q?fix(qlik):=20les=20master=20measures=20ignorent?= =?UTF-8?q?=20la=20s=C3=A9lection=20Date=20=E2=80=94=20agr=C3=A9gation=20d?= =?UTF-8?q?irecte=20des=20faits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preuve arithmétique dans les logs : pour l'article 10000397784, qteReseau=164 et caReseau=2220,75 € sont EXACTEMENT la somme de janvier à juillet 2026 (12+19+24+27+22+31+29 = 164). Et la passe de rattrapage, avec août 2025 seul sélectionné, a renvoyé ces mêmes 164 / 2220,75 — puis les a recopiés dans les cinq mois manquants. « CA N » et « Quantité N » sont donc des mesures « cumul année en cours », insensibles au champ Date. Aucune sélection ne peut leur faire produire une fenêtre 12 mois glissants. D'où, simultanément : - des totaux réseau qui étaient un cumul année en cours, mois courant partiel inclus, et non 12 mois glissants ; - les mois de l'année précédente non couverts par « Quantité COMP » vides sur tous les articles ; - un rattrapage qui y recopiait le total de période. Le chemin nominal agrège désormais directement les champs de faits, qui eux respectent les sélections : Sum(quantite), Sum(ca_ht), Count(DISTINCT [Magasin Code]), Sum(marge) — tous surchargeables par QLIK_EXPR_QTE / _CA / _NBMAG / _MARGE. Un seul cube [Article Code, Mois] couvre les 12 mois : ni passe N-1 ni rattrapage, c'est ce qu'ils compensaient. Les totaux deviennent la somme des 12 mois de la fenêtre. Garde-fous : « Quantité N » reste dans le cube pour calibrage et le log compare les deux sur les mois de l'année en cours (seul périmètre où elle est juste) ; quantité totale nulle ou erreur Engine → repli automatique sur l'ancien chemin ; QLIK_USE_EXPR=0 le force. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 44 ++++++++- src/lib/qlik-playwright.ts | 196 ++++++++++++++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 5 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 54461d3..a8280d6 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -78,7 +78,49 @@ QLIK_FIELD_FOURNISSEUR= Sans champ libellé exploitable, la recherche fonctionne encore par code centrale. -## Mois vides de la fenêtre glissante (dimension Mois) +## ⚠️ Les master measures « N » ignorent la sélection Date + +**Constat de production, vérifié arithmétiquement.** Avec août 2025 seul +sélectionné, le cube renvoyait `qte=164` / `ca=2 220,75 €` — soit exactement la +somme de janvier à juillet 2026. « CA N », « Quantité N » et consorts sont des +mesures **cumul année en cours**, insensibles au champ `Date`. + +Trois conséquences, toutes observées : + +- les totaux réseau de la Grille étaient un **cumul année en cours** (mois + courant partiel inclus), pas 12 mois glissants ; +- les mois de l'année précédente non couverts par « Quantité COMP » (août à + décembre, en juillet) restaient **vides sur tous les articles** ; +- la passe de rattrapage y recopiait le **total de période**, d'où cinq mois + identiques à 164 dans `qteByMonth`. + +Aucune sélection ne corrige cela. Le chemin nominal agrège donc **directement les +champs de faits**, qui respectent les sélections : + +| Rôle | Expression (surchargeable) | +|------|----------------------------| +| Quantité | `QLIK_EXPR_QTE` — défaut `Sum(quantite)` | +| CA | `QLIK_EXPR_CA` — défaut `Sum(ca_ht)` | +| Magasins | `QLIK_EXPR_NBMAG` — défaut `Count(DISTINCT [Magasin Code])` | +| Marge | `QLIK_EXPR_MARGE` — défaut `Sum(marge)` | + +Un seul cube `[Article Code, Mois]` couvre les 12 mois : ni passe N-1 ni +rattrapage — c'est exactement ce qu'ils compensaient. Les totaux deviennent la +**somme des 12 mois de la fenêtre**. + +Garde-fous : « Quantité N » est incluse dans le cube pour **calibrage**, et le +log compare les deux sur les mois de l'année en cours, seul périmètre où la +master measure est juste : + +``` +[qlik-pw][expr] 485210 lignes, quantité totale=…, calibrage année 2026 : 4812/4830 mois conformes à « Quantité N » +``` + +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. + +## 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 12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 388bf32..1e33a2c 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -121,8 +121,19 @@ export async function fetchNetworkMetricsPlaywright( // Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente // → 12 mois glissants (Quantité N ne couvre que l'année en cours). const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim(); + // Agrégations directes des champs de faits. Les master measures « N » ignorent + // la sélection Date (mesures « cumul année en cours ») : elles ne peuvent pas + // produire une fenêtre 12 mois glissants. Surchargeables si le modèle change. + const qlikUseExpr = !["0", "false", "no", "off"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); + const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim(); + const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").trim(); + const qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim(); + const qlikExprMarge = (process.env.QLIK_EXPR_MARGE ?? "Sum(marge)").trim(); const yearN = new Date().getFullYear(); - console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim}`); + console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim} useExpr=${qlikUseExpr}`); + if (qlikUseExpr) { + console.log(`[qlik-pw] expressions mensuelles: qte="${qlikExprQte}" ca="${qlikExprCa}" nbMag="${qlikExprNbMag}" marge="${qlikExprMarge}"`); + } const browser = await getBrowser(); console.log(`[qlik-pw] chromium lancé`); @@ -175,7 +186,7 @@ export async function fetchNetworkMetricsPlaywright( }); const evaluer = () => page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN }: { + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge }: { 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[] }>; @@ -188,6 +199,11 @@ export async function fetchNetworkMetricsPlaywright( moisDimId: string; mqteComp: string; yearN: number; + useExpr: boolean; + exprQte: string; + exprCa: string; + exprNbMag: string; + exprMarge: string; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -468,6 +484,168 @@ export async function fetchNetworkMetricsPlaywright( } }; + // ─── Cube mensuel par EXPRESSIONS (respecte la sélection Date) ───── + // + // Constat de production : les master measures « CA N » / « Quantité N » + // **ignorent la sélection Date**. Vérifié arithmétiquement : avec août + // 2025 seul sélectionné, le cube renvoyait 164 unités / 2 220,75 € — + // soit exactement la somme de janvier à juillet 2026. Ce sont des + // mesures « cumul année en cours », pas des mesures de période. + // + // Conséquences : les totaux réseau étaient un cumul année en cours + // (mois courant partiel inclus) et non 12 mois glissants, les mois de + // l'année précédente non couverts par « Quantité COMP » restaient + // vides, et la passe de rattrapage y recopiait le total de période. + // + // Aucune sélection ne peut corriger cela : on agrège donc directement + // les champs de faits, qui eux respectent les sélections. + const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000 + const fetchMonthCubeExpr = async ( + onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, marge: number, qteMaster: number) => void, + timings?: Record, + onCode15Retry?: () => void, + ): Promise => { + const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], + qMeasures: [ + { qDef: { qDef: exprCa } }, + { qDef: { qDef: exprQte } }, + { qDef: { qDef: exprNbMag } }, + { qDef: { qDef: exprMarge } }, + // Master « Quantité N », uniquement pour le calibrage : sur les + // mois de l'année en cours les deux doivent coïncider. + { qLibraryId: rQte }, + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 7, qHeight: PAGEX }], + } } }, doc, onCode15Retry), timings); + const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const oh = cube.handle; + const process = (matrix: Array>): number => { + for (const r of matrix) { + onRow( + String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), + Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, + Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, + ); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0); + if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEX; } + for (; top < size; top += PAGEX) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 7, qHeight: PAGEX }] }, oh, onCode15Retry), timings); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (!matrix.length) break; + got += process(matrix); + if (matrix.length < PAGEX) break; + } + return got; + } finally { + await destroyCube(cube, timings); + } + }; + + /** + * Passe unique par expressions : sélectionne la fenêtre 12 mois et tous + * les codes, puis lit un seul cube [Article Code, Mois]. + * + * Renvoie false si le résultat est inexploitable (expression invalide, + * champ absent) — l'appelant repart alors sur les master measures. + */ + const moisParExpressions = async (): Promise => { + const timings: Record = {}; + const marqueOut = out.length; + try { + if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } + const allSerials = monthlyPayload.flatMap((m) => m.serials); + if (dfh !== -1 && allSerials.length) { + await rpc("Clear", {}, dfh); + await sleep(qlikSettleMs); + await timed("selectDate", "SelectValues", () => rpc("SelectValues", { + qFieldValues: allSerials.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: false, + qSoftLock: true, + }, dfh), timings); + await sleep(qlikSettleMs); + } + if (codes.length && fh !== -1) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timings); + await sleep(qlikSettleMs); + } + + // Seuls les mois de la fenêtre nous intéressent ; la dimension + // Mois peut en exposer d'autres si une sélection déborde. + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); + let totalQte = 0; + let calibrees = 0, calibrOk = 0; + let echantillon = false; + + const got = await timed("cube", "expr", () => fetchMonthCubeExpr((code, mois, ca, qte, nbMag, marge, qteMaster) => { + if (!code || code === "-") return; + const mm = normMois(mois); + if (!moisFenetre.has(mm)) return; + if (!echantillon) { + echantillon = true; + console.log("[qlik-pw][expr] échantillon: " + JSON.stringify({ code, mois: mm, ca, qte, nbMag, marge, "Quantité N (master)": qteMaster })); + } + // Calibrage sur l'année en cours, seul périmètre où la master + // measure est censée être juste. + if (parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { + calibrees++; + if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) calibrOk++; + } + totalQte += qte; + // Totaux = somme des mois de la fenêtre (et non un cumul année). + out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); + (monthlyByCode[code] ??= {}); + monthlyByCode[code][mm] = { + qte, ca, nbMag, + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: ca > 0 ? marge / ca : 0, + }; + }, timings), timings); + + aggMonths = monthlyPayload.length; + aggBatches++; + console.log( + "[qlik-pw][expr] " + got.value + " lignes, quantité totale=" + Math.round(totalQte) + + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »" + + " timings=" + timingSummary(timings), + ); + + if (totalQte <= 0) { + console.error("[qlik-pw][expr] quantité totale nulle → expressions inexploitables, repli sur les master measures"); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + if (calibrees > 20 && calibrOk / calibrees < 0.5) { + console.warn( + "[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + + " diverge de « Quantité N ». Vérifier l'expression (QLIK_EXPR_QTE).", + ); + } + return true; + } catch (e) { + console.error("[qlik-pw][expr] échec : " + String((e as Error)?.message || e) + " → repli sur les master measures"); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + }; + // Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par // lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup. // selectYear = null → passe principale (année N + N-1 dérivée via COMP). @@ -764,8 +942,13 @@ export async function fetchNetworkMetricsPlaywright( } }; - if (!noDateMode && monthDim) { - // Découpage mensuel réel (dimension Mois) — remplace l'itération par mois. + if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) { + // Chemin nominal : un seul cube, tous les mois de la fenêtre, des + // mesures qui respectent la sélection Date. Ni passe N-1 ni + // rattrapage nécessaires — c'est justement ce qu'ils compensaient. + await pousserCheckpoint("expressions"); + } else if (!noDateMode && monthDim) { + // Repli : master measures « année en cours » + passe N-1 + rattrapage. console.log("[qlik-pw] extraction via dimension Mois (" + monthlyPayload.length + " mois attendus, moisDim=" + moisDimId + ")"); await monthDimPath(null); await pousserCheckpoint("passe N"); @@ -941,6 +1124,11 @@ export async function fetchNetworkMetricsPlaywright( moisDimId: qlikMoisDimId, mqteComp: qlikMeasQteCompId, yearN, + useExpr: qlikUseExpr, + exprQte: qlikExprQte, + exprCa: qlikExprCa, + exprNbMag: qlikExprNbMag, + exprMarge: qlikExprMarge, }, );