fix(qlik): les master measures ignorent la sélection Date — agrégation directe des faits

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-07-31 06:27:33 +00:00
1 parent b2a426287f
commit 4a56584a27
2 files changed
+235 -5

No files matched your search

+43 -1
View File
@@ -78,7 +78,49 @@ QLIK_FIELD_FOURNISSEUR=<nom exact du champ fournisseur>
Sans champ libellé exploitable, la recherche fonctionne encore par code centrale. 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 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 12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin
+192 -4
View File
@@ -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 // 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). // → 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(); 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(); 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(); const browser = await getBrowser();
console.log(`[qlik-pw] chromium lancé`); console.log(`[qlik-pw] chromium lancé`);
@@ -175,7 +186,7 @@ export async function fetchNetworkMetricsPlaywright(
}); });
const evaluer = () => page.evaluate( 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; app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string;
codes: string[]; token: string; codes: string[]; token: string;
monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>; monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>;
@@ -188,6 +199,11 @@ export async function fetchNetworkMetricsPlaywright(
moisDimId: string; moisDimId: string;
mqteComp: string; mqteComp: string;
yearN: number; yearN: number;
useExpr: boolean;
exprQte: string;
exprCa: string;
exprNbMag: string;
exprMarge: string;
}) => }) =>
new Promise<InPageResult>((resolve) => { new Promise<InPageResult>((resolve) => {
const loc = (window as unknown as { location: Location }).location; 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<string, number>,
onCode15Retry?: () => void,
): Promise<number> => {
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<Array<{ qText?: string; qNum?: number }>>): 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<Array<{ qText?: string; qNum?: number }>> }> } }).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<Array<{ qText?: string; qNum?: number }>> }>)?.[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<boolean> => {
const timings: Record<string, number> = {};
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 // 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. // 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). // 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) { if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) {
// Découpage mensuel réel (dimension Mois) — remplace l'itération par mois. // 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 + ")"); console.log("[qlik-pw] extraction via dimension Mois (" + monthlyPayload.length + " mois attendus, moisDim=" + moisDimId + ")");
await monthDimPath(null); await monthDimPath(null);
await pousserCheckpoint("passe N"); await pousserCheckpoint("passe N");
@@ -941,6 +1124,11 @@ export async function fetchNetworkMetricsPlaywright(
moisDimId: qlikMoisDimId, moisDimId: qlikMoisDimId,
mqteComp: qlikMeasQteCompId, mqteComp: qlikMeasQteCompId,
yearN, yearN,
useExpr: qlikUseExpr,
exprQte: qlikExprQte,
exprCa: qlikExprCa,
exprNbMag: qlikExprNbMag,
exprMarge: qlikExprMarge,
}, },
); );