From e7a387b7de9b640518535aaef3fd4f6191973557 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:22:19 +0000 Subject: [PATCH] =?UTF-8?q?fix(qlik):=20effacer=20P=C3=A9riode=20puis=20ex?= =?UTF-8?q?traire=20ann=C3=A9e=20par=20ann=C3=A9e=20=E2=80=94=20la=20m?= =?UTF-8?q?=C3=A9thode=20de=20l'app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux remarques de l'utilisateur ont débloqué le sujet : « sur ce tableau j'ai 2024, 2025, 2026 et je sors mois par mois », et « tu mets 2026 et tu as la comparaison N-1 ». Type_Cal='N' ne désigne donc pas « l'année civile en cours » mais L'ANNÉE SÉLECTIONNÉE. Il suffit d'enchaîner les années couvertes par la fenêtre (2025 puis 2026) pour reconstituer 12 mois glissants — chaque passe livrant les 5 mesures, et non la seule quantité comme « Quantité COMP ». Ce mécanisme existait déjà (monthDimPath(yearN - 1)) mais restait sans effet : [qlik-pw] (mois) passe N-1 terminée : 299905 → 299905 points mensuels La sélection « Période », héritée de l'ouverture de l'app et jamais choisie par nous (observé « Période:1/4 » dès le premier diagnostic de la session), épinglait le contexte sur l'année en cours et annulait la sélection d'année. - choisirPeriode() teste désormais EN PREMIER l'état sans aucune sélection de Période — l'état dans lequel un utilisateur voit 2024/2025/2026 — et efface la sélection dès que la couverture est incomplète. - L'orchestration boucle sur les années de la fenêtre au lieu d'une passe principale plus une passe N-1 dérivée de COMP. - Chaque passe n'écrit que les mois DE LA FENÊTRE et les totaux s'additionnent sur ces mois : deux années ne peuvent pas se doubler puisqu'elles ne partagent aucun mois. « Quantité COMP » n'est plus utilisée. - L'agrégation directe des faits reste en secours, et le garde-fou d'intégrité reste actif : sans les 12 mois, le cache n'est pas écrit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 31 ++++++- src/lib/qlik-playwright.ts | 186 ++++++++++++++++++++++--------------- 2 files changed, 141 insertions(+), 76 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 71df648..2f7fc6a 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -103,7 +103,36 @@ Tout ce qu'on observait en découle : | Janvier→juillet de l'année précédente renseignés | Ce sont les mois que « Quantité COMP » sait atteindre | | `Sum(quantite)` brut inexploitable | Il additionne les lignes N **et** COMP : il double compte | -### Mesuré : aucune `Période` ne porte 12 mois glissants +### La bonne méthode : effacer `Période`, puis extraire ANNÉE par ANNÉE + +C'est ce que fait un utilisateur dans l'app : il sélectionne **2026** et obtient +l'année plus sa comparaison N-1 ; il sélectionne 2025 et obtient 2025. Les +données de 2024, 2025 et 2026 sont toutes accessibles mois par mois. `Type_Cal='N'` +désigne simplement **l'année sélectionnée**. + +Il suffit donc d'enchaîner les années couvertes par la fenêtre (2025 puis 2026) +pour reconstituer les 12 mois glissants — chaque passe donnant les 5 mesures, et +non la seule quantité comme « Quantité COMP ». + +Ce mécanisme existait déjà dans le code (« passe N-1 ») mais restait **sans +effet** : + +``` +[qlik-pw] (mois) passe N-1 terminée : 299905 → 299905 points mensuels + ^^^^^^^^^^^^^^ aucun gain +``` + +La sélection `Période` **héritée de l'ouverture de l'app** (observé +« Période:1/4 » alors que rien n'avait été sélectionné) épinglait le contexte sur +l'année en cours et annulait la sélection d'année. `choisirPeriode()` teste donc +en **premier** l'état sans aucune sélection de `Période`, et l'efface dès que la +couverture est incomplète. + +Chaque passe n'écrit que les mois **de la fenêtre**, et les totaux s'additionnent +sur ces mois : deux années ne peuvent pas se doubler puisqu'elles ne partagent +aucun mois. + +### Mesuré : aucune valeur de `Période` ne porte à elle seule 12 mois `choisirPeriode()` essaie chaque valeur et mesure la couverture réelle sur un cube `[Mois] × Quantité N`. Résultat en production : diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 3dbcffa..1d42fa4 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -793,6 +793,48 @@ export async function fetchNetworkMetricsPlaywright( diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); if (valeurs.length === 0) return 0; + /** Couverture réelle : quels mois de la fenêtre « Quantité N » rend-elle vivants ? */ + const mesurerCouverture = async (): Promise => { + const sonde = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periode-sonde" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 60 }], + }, + } }, doc); + const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + try { + const sl = await rpc("GetLayout", {}, sRet.qHandle); + const sm = ((sl.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qHyperCube?.qDataPages?.[0]?.qMatrix) ?? []; + return sm + .filter((r) => (Number(r[1]?.qNum) || 0) > 0) + .map((r) => normMois(String(r[0]?.qText ?? ""))) + .filter((m) => fenetre.has(m)) + .sort(); + } finally { + const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); + if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } + } + }; + + // CANDIDAT ZÉRO : aucune sélection de « Période ». + // + // L'app pose une valeur par défaut à l'ouverture (observé : + // « Période:1/4 » alors que nous n'avions rien sélectionné). + // C'est cette valeur héritée qui bornait les mesures — et c'est + // sans elle qu'un utilisateur voit 2024, 2025 et 2026 mois par + // mois dans l'app. On teste donc l'état libre EN PREMIER. + await rpc("ClearField", { qFieldName: "Période" }, doc).catch(() => rpc("Clear", {}, lo!.qHandle)); + await sleep(qlikSettleMs); + const sansSelection = await mesurerCouverture(); + diag(" SANS sélection de Période → " + sansSelection.length + "/" + fenetre.size + " mois " + JSON.stringify(sansSelection)); + if (sansSelection.length >= fenetre.size) { + diag("Période laissée LIBRE : les mesures couvrent déjà les " + fenetre.size + " mois"); + return sansSelection.length; + } + let meilleure: { texte: string; elem: number; couverts: string[] } | null = null; for (const v of valeurs) { await rpc("SelectListObjectValues", { @@ -803,30 +845,8 @@ export async function fetchNetworkMetricsPlaywright( }, lo.qHandle); await sleep(qlikSettleMs); - // Cube [Mois] × « Quantité N » : quels mois cette période rend-elle vivants ? - const sonde = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-periode-sonde" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rQte }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 60 }], - }, - } }, doc); - const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - let couverts: string[] = []; - try { - const sl = await rpc("GetLayout", {}, sRet.qHandle); - const sm = ((sl.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) - .qHyperCube?.qDataPages?.[0]?.qMatrix) ?? []; - couverts = sm - .filter((r) => (Number(r[1]?.qNum) || 0) > 0) - .map((r) => normMois(String(r[0]?.qText ?? ""))) - .filter((m) => fenetre.has(m)); - } finally { - const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); - if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } - } - diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre " + JSON.stringify(couverts.sort())); + const couverts = await mesurerCouverture(); + diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre " + JSON.stringify(couverts)); if (!meilleure || couverts.length > meilleure.couverts.length) { meilleure = { texte: v.texte, elem: v.elem, couverts }; } @@ -1144,16 +1164,24 @@ export async function fetchNetworkMetricsPlaywright( // récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que // COMP ne peut pas fournir puisque l'année N ne va pas jusque-là). let loggedSampleRow = false; - const monthDimPath = async (selectYear: number | null): Promise => { - const isMainPass = selectYear === null; + const monthDimPath = async (selectYear: number): Promise => { + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); - /** Traitement d'une ligne du cube [Article Code, Mois] — partagé par les deux chemins. */ + /** + * Traitement d'une ligne du cube [Article Code, Mois]. + * + * Une passe = UNE année explicitement sélectionnée, exactement ce + * que fait un utilisateur dans l'app (« je sélectionne 2026 et + * j'obtiens N + la comparaison N-1 »). Chaque passe n'écrit que + * les mois DE LA FENÊTRE, et les totaux s'additionnent sur ces + * mois-là : deux années différentes ne peuvent donc pas se + * doubler, puisqu'elles ne partagent aucun mois. + */ const onRowMois = (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => { if (!code || code === "-") return; - // Les totaux réseau ne viennent QUE de la passe principale - // (sinon on doublerait CA/Qté avec la passe N-1). - if (isMainPass) out.push([code, ca, qte, nbMag, caMag, marge]); const mm = normMois(mois); + if (!moisFenetre.has(mm)) return; + out.push([code, ca, qte, nbMag, caMag, marge]); const y = parseInt(mm.slice(0, 4), 10); if (!loggedSampleRow) { loggedSampleRow = true; @@ -1166,38 +1194,19 @@ export async function fetchNetworkMetricsPlaywright( // Les 5 mesures du mois courant de la ligne. Le cube les renvoie // déjà toutes : les conserver ne coûte aucune requête Qlik en plus. const full = { qte, ca, nbMag, caMag, margePct: marge }; - if (isMainPass && y === yearN) { - // Mois de l'année N + son comparable N-1 (COMP = même mois, N-1). - monthlyByCode[code][mm] = full; - const mmPrev = String(y - 1) + mm.slice(4); // "2026-03" → "2025-03" - // COMP ne porte QUE la quantité : les autres mesures restent - // absentes tant que la passe N-1 ne les a pas complétées. - // On n'écrit rien si COMP est vide : un mois ABSENT peut être - // rattrapé plus tard, un mois à 0 se lit comme « pas de vente » - // et masque définitivement le trou d'extraction. - if (monthlyByCode[code][mmPrev] === undefined && qteComp > 0) { - monthlyByCode[code][mmPrev] = { qte: qteComp }; - } - } else if (!isMainPass) { - // Passe année sélectionnée : valeurs directes, prioritaires sur COMP. - monthlyByCode[code][mm] = full; - } else if (qte > 0 || ca > 0) { - // Passe principale, mois HORS année N : « Quantité N » n'est pas - // censée le couvrir. Écrire son 0 fabriquerait une absence de - // vente là où il n'y a qu'une mesure hors périmètre. - monthlyByCode[code][mm] = full; - } + // « Quantité COMP » n'est plus nécessaire : chaque année de la + // fenêtre est extraite directement par sa propre passe, ce qui + // donne les 5 mesures et non la seule quantité. + void y; void qteComp; + monthlyByCode[code][mm] = full; }; - if (yfh !== -1) { - await rpc("Clear", {}, yfh); - await sleep(qlikSettleMs); - if (selectYear !== null) { - const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); - console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn)); - await sleep(qlikSettleMs); - } - } + if (yfh === -1) throw new Error("champ « Année » introuvable : impossible d'extraire année par année"); + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); + console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn)); + await sleep(qlikSettleMs); const allSerials = monthlyPayload.flatMap((m) => m.serials); if (dfh !== -1 && allSerials.length) { await rpc("Clear", {}, dfh); @@ -1405,22 +1414,49 @@ export async function fetchNetworkMetricsPlaywright( return attendus.every((m) => vus.has(m)); }; - if (couvertureMaster >= attendus.length && monthDim) { - // Une valeur de `Période` couvre les 12 mois : les master - // measures sont alors exactes sur toute la fenêtre. - diag("extraction par master measures (Période couvrant " + couvertureMaster + " mois)"); - await monthDimPath(null); - await pousserCheckpoint("master measures"); - } else if (monthDim && useExpr && (await moisParExpressions())) { - // Aucune période ne couvre 12 mois : on retombe sur - // l'agrégation directe des faits. + // ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app. + // + // Dans l'interface, on sélectionne « 2026 » et on obtient l'année + // plus sa comparaison N-1. C'est le mécanisme natif du modèle : + // `Type_Cal='N'` désigne l'année sélectionnée. Il suffit donc + // d'enchaîner les années couvertes par la fenêtre — ici 2025 puis + // 2026 — pour reconstituer les 12 mois glissants. + // + // Ce mécanisme existait déjà (« passe N-1 ») mais restait sans + // effet : la sélection `Période` héritée de l'ouverture de l'app + // épinglait le contexte sur l'année en cours et annulait la + // sélection d'année. `choisirPeriode()` l'a effacée juste avant. + const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort(); + diag("extraction année par année : " + JSON.stringify(annees) + " (Période libre)"); + + let extraitParAnnee = false; + if (monthDim) { + try { + for (const annee of annees) { + const avant = Object.values(monthlyByCode).reduce((n, m) => n + Object.keys(m).length, 0); + await monthDimPath(annee); + const apres = Object.values(monthlyByCode).reduce((n, m) => n + Object.keys(m).length, 0); + diag(" année " + annee + " → " + (apres - avant) + " points mensuels ajoutés"); + await pousserCheckpoint("année " + annee); + } + extraitParAnnee = couvre12(); + if (!extraitParAnnee) { + diag("l'extraction année par année ne couvre pas les 12 mois — essai de l'agrégation directe"); + } + } catch (e) { + diag("extraction année par année interrompue : " + String((e as Error)?.message || e)); + } + } + + if (!extraitParAnnee) { + if (!(monthDim && useExpr && (await moisParExpressions()))) { + throw new Error( + "extraction 12 mois refusée : ni l'extraction année par année " + + "(Période libre, couverture master = " + couvertureMaster + "/" + attendus.length + ") " + + "ni l'agrégation directe des faits n'ont couvert la fenêtre", + ); + } await pousserCheckpoint("expressions vérifiées"); - } else { - throw new Error( - "extraction 12 mois refusée : aucune valeur de « Période » ne couvre la fenêtre " + - "(meilleure = " + couvertureMaster + "/" + attendus.length + " mois) et l'agrégation " + - "directe des faits n'a pas validé ses contrôles", - ); } if (!couvre12()) {