fix(qlik): piloter le champ Période du modèle au lieu de lutter contre lui

Le dump des expressions donne enfin la cause de fond :

  « Quantité N »    = Sum({<Type_Cal={'N'}>} quantite)
  « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} quantite)
  « Magasin Ventes Nb N » = Count({<Type_Cal={'N'}>} Distinct ventes_code_site)

L'app n'est pas un modèle « faits + calendrier ». Un pont de périodes duplique
les lignes de faits : Type_Cal='N' marque celles de la période analysée, 'COMP'
celles de la période de comparaison, et le champ Période (4 valeurs, une
sélectionnée) décide de quelle période il s'agit. Tout en découle :

  - sélectionner Date ne change rien (mesuré : 100 % du total avant comme après),
    le périmètre venant du pont de périodes et non du champ date ;
  - « Quantité N » ne couvre que la période courante, d'où août→décembre vides ;
  - « Quantité COMP » ne couvre que les mois ayant un comparable, d'où
    janvier→juillet de l'année précédente ;
  - Sum(quantite) brut additionne les lignes N ET COMP : il double compte, il ne
    pouvait pas servir de substitut.

choisirPeriode() essaie donc chaque valeur de Période, mesure sur un cube
[Mois] × Quantité N combien de mois de la fenêtre elle rend réellement
disponibles, et garde la meilleure. Aucun libellé n'est deviné : c'est la
couverture mesurée qui décide, et elle est tracée. Si aucune valeur ne couvre
les 12 mois, les mois manquants restent absents du cache.

Aussi :
- Les mois réellement couverts reçoivent un zéro EXPLICITE quand l'article n'a
  pas vendu ; la tendance ne retient que les mois présents comme clés. Un article
  vendu depuis mai voyait sa tendance calculée sur deux points (« +4100 % »).
- Les diagnostics du chemin d'extraction sont répétés en fin de sync
  ([qlik-pw][diag]) : ils étaient au début d'un log de plusieurs milliers de
  lignes.
- QLIK_USE_EXPR passe à désactivé par défaut.

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 11:22:56 +00:00
1 parent db886cd8d9
commit 726fb8418d
3 files changed
+249 -46

No files matched your search

+44
View File
@@ -78,6 +78,50 @@ 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.
## Le modèle est piloté par `Période` / `Type_Cal` — pas par `Date`
**C'est l'explication de fond**, obtenue en dumpant les expressions réelles :
```
« Quantité N » = Sum({<Type_Cal={'N'}>} quantite)
« CA N » = Sum({<Type_Cal={'N'}>} $(vCA_vat))
« Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}$(vConstantCOMP)>} quantite)
« Magasin Ventes Nb N » = Count({<Type_Cal={'N'}>} Distinct ventes_code_site)
```
L'app n'est pas un modèle « faits + calendrier » classique. Un **pont de périodes**
duplique les lignes de faits : `Type_Cal='N'` marque celles de la période
analysée, `'COMP'` celles de la période de comparaison. Et c'est le champ
**`Période`** (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit.
Tout ce qu'on observait en découle :
| Symptôme | Cause |
|---|---|
| Sélectionner `Date` ne change rien (100 % du total avant/après) | Le périmètre vient du pont de périodes, pas du champ date |
| Août→décembre vides sur tous les articles | « Quantité N » ne couvre que la période courante (janvier→juillet) |
| 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 |
La solution n'est donc pas de contourner le modèle mais de **le piloter** :
`choisirPeriode()` essaie chaque valeur de `Période`, mesure sur un cube
`[Mois] × Quantité N` combien de mois de la fenêtre elle rend réellement
disponibles, et garde la meilleure. Aucun libellé n'est deviné — c'est la
couverture mesurée qui décide :
```
[qlik-pw][diag] valeurs de « Période » : ["Année en cours","12 mois glissants","Mois","Semaine"]
[qlik-pw][diag] Période « Année en cours » → 7/12 mois de la fenêtre [...]
[qlik-pw][diag] Période « 12 mois glissants » → 12/12 mois de la fenêtre [...]
[qlik-pw][diag] Période retenue : « 12 mois glissants » (12/12 mois)
```
Si aucune valeur ne couvre les 12 mois, l'extraction le dit et les mois manquants
restent **absents** du cache — jamais remplis d'une valeur plausible.
`QLIK_USE_EXPR=1` réactive l'ancien essai par agrégation directe des faits ; il
est désactivé par défaut puisque `Sum(quantite)` double compte.
## ⚠️ Les master measures « N » ignorent la sélection Date ## ⚠️ Les master measures « N » ignorent la sélection Date
**Constat de production, vérifié arithmétiquement.** Avec août 2025 seul **Constat de production, vérifié arithmétiquement.** Avec août 2025 seul
+15 -14
View File
@@ -56,16 +56,18 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
* *
* La fenêtre est reconstruite à partir de la date du jour (et non des clés * La fenêtre est reconstruite à partir de la date du jour (et non des clés
* présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance * présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance
* réellement glissante. Deux conséquences voulues : * réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois,
* - un mois en cours (partiel) ou un mois plus vieux que 12 mois présent dans * présent dans le cache est donc **ignoré** — sinon la pente est faussée par un
* le cache est **ignoré** — sinon la pente est faussée par un mois tronqué ; * mois tronqué.
* - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit
* non vendu sur un mois ne produit tout simplement pas de ligne.
* *
* Seule exception au remplissage par zéro : les mois **antérieurs au premier * Un mois **absent** du cache n'est PAS supposé nul : l'extraction ne couvre pas
* mois réellement extrait**. Ils traduisent une extraction plus courte que 12 * toujours les 12 mois (les mesures Qlik « N » ne savent pas remonter au-delà de
* mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0 * l'année en cours). La sync écrit un zéro **explicite** sur chaque mois qu'elle
* simulerait une croissance qui n'existe pas. * a réellement couvert sans vente ; la tendance ne retient donc que les mois
* présents comme clés, et ignore purement et simplement les autres.
*
* Sans cette distinction, un article vendu seulement depuis mai voyait sa
* tendance calculée sur deux points — « +4 100 % · Forte hausse ».
* *
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la * Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
* variation modélisée sur la période (pente × durée) rapportée à la moyenne. * variation modélisée sur la période (pente × durée) rapportée à la moyenne.
@@ -81,11 +83,10 @@ export function computeNetworkTrend(
if (presents.length === 0) return empty; if (presents.length === 0) return empty;
const fenetre = buildRolling12QlikMonths(now); const fenetre = buildRolling12QlikMonths(now);
// Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) : // Seuls les mois RÉELLEMENT extraits comptent : ceux que la sync n'a pas
// rien de fiable à tracer. // couverts sont absents des clés et sont écartés, plutôt que comptés à zéro.
const premierMoisExtrait = presents[0]; const labels = fenetre.filter((m) => qteByMonth[m] != null);
const labels = fenetre.filter((m) => m >= premierMoisExtrait); if (labels.length === 0) return empty;
if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) return empty;
const values = labels.map((l) => Number(qteByMonth[l]) || 0); const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length; const n = values.length;
+190 -32
View File
@@ -14,7 +14,7 @@
import "server-only"; import "server-only";
import { chromium, type Browser } from "playwright-core"; import { chromium, type Browser } from "playwright-core";
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric } from "@/lib/qlik-client"; import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client";
import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range"; import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range";
let browserPromise: Promise<Browser> | null = null; let browserPromise: Promise<Browser> | null = null;
@@ -46,7 +46,6 @@ interface InPageResult {
monthly?: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>>; monthly?: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>>;
size?: number; size?: number;
error?: string; error?: string;
diag?: Record<string, unknown>;
// Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright). // Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright).
months?: number; months?: number;
batches?: number; batches?: number;
@@ -54,6 +53,8 @@ interface InPageResult {
fallbackCount?: number; fallbackCount?: number;
/** true si le résultat vient d'un point de contrôle (extraction interrompue). */ /** true si le résultat vient d'un point de contrôle (extraction interrompue). */
partiel?: boolean; partiel?: boolean;
/** Diagnostics clés du chemin par expressions, répétés en fin de sync. */
diagExpr?: string[];
} }
function envNumber(name: string, defaultValue: number, min: number, max: number): number { function envNumber(name: string, defaultValue: number, min: number, max: number): number {
@@ -124,7 +125,11 @@ export async function fetchNetworkMetricsPlaywright(
// Agrégations directes des champs de faits. Les master measures « N » ignorent // 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 // 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. // 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()); // Désactivé par défaut : `Sum(quantite)` ignore `Type_Cal` et additionne donc
// les lignes de la période courante ET celles de la période de comparaison —
// il double compte. Les master measures, elles, portent le bon filtre ; il
// suffit de caler `Période` (cf. `choisirPeriode`). Réactivable pour essai.
const qlikUseExpr = ["1", "true", "yes", "on"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase());
const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim(); const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim();
const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").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 qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim();
@@ -295,6 +300,11 @@ export async function fetchNetworkMetricsPlaywright(
// Quantité réseau par code central et par mois (label "YYYY-MM"). // Quantité réseau par code central et par mois (label "YYYY-MM").
// Accumulée en parallèle du total, sans toucher l'agrégation existante. // Accumulée en parallèle du total, sans toucher l'agrégation existante.
const monthlyByCode: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>> = {}; const monthlyByCode: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>> = {};
// Diagnostics clés, RÉPÉTÉS en fin de sync : le log d'une sync fait
// des milliers de lignes de timings, et ce qui explique un échec se
// trouve tout au début — là où personne ne va le chercher.
const diagnostics: string[] = [];
const diag = (msg: string) => { diagnostics.push(msg); console.log("[qlik-pw][expr] " + msg); };
(async () => { (async () => {
try { try {
@@ -623,6 +633,127 @@ export async function fetchNetworkMetricsPlaywright(
return true; return true;
}; };
/**
* Cale le champ `Période` du modèle sur la fenêtre 12 mois glissants.
*
* L'app n'est pas un modèle « faits + calendrier » classique : TOUTES
* ses mesures sont bornées par un type de calendrier —
*
* « Quantité N » = Sum({<Type_Cal={'N'}>} quantite)
* « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} quantite)
*
* `Type_Cal` marque les lignes de la période analysée (`N`) et celles
* de la période de comparaison (`COMP`), et c'est le champ `Période`
* (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit.
*
* Cela explique tout ce qu'on observait :
* - sélectionner `Date` ne change RIEN (le périmètre vient du pont de
* périodes, pas du champ date) — mesuré : 100 % du total avant comme
* après ;
* - « Quantité N » ne renvoie que les mois de la période courante
* (janvier→juillet avec « année en cours »), d'où août→décembre
* vides ;
* - « Quantité COMP » ne couvre que les mois ayant un comparable dans
* cette période, d'où janvier→juillet de l'année précédente ;
* - `Sum(quantite)` brut mélange les lignes N et COMP : il double
* compte, et ne peut pas se substituer aux mesures.
*
* La solution n'est donc pas de contourner le modèle mais de le
* piloter : on essaie chaque valeur de `Période`, on mesure combien de
* mois de la fenêtre elle rend réellement disponibles, et on garde la
* meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée
* qui décide.
*/
const choisirPeriode = async (): Promise<string | null> => {
const fenetre = new Set(monthlyPayload.map((m) => m.label));
let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null;
try {
const obj = await rpc("CreateSessionObject", { qProp: {
qInfo: { qType: "cf-periodes" },
qListObjectDef: {
qDef: { qFieldDefs: ["Période"] },
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 50 }],
},
} }, doc);
lo = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const lay = await rpc("GetLayout", {}, lo.qHandle);
const matrix = ((lay.qLayout as { qListObject?: { qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string; qElemNumber?: number }>> }> } })
.qListObject?.qDataPages?.[0]?.qMatrix) ?? [];
const valeurs = matrix
.map((r) => ({ texte: String(r[0]?.qText ?? ""), elem: r[0]?.qElemNumber ?? -1 }))
.filter((v) => v.texte && v.elem >= 0);
diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte)));
if (valeurs.length === 0) return null;
let meilleure: { texte: string; elem: number; couverts: string[] } | null = null;
for (const v of valeurs) {
await rpc("SelectListObjectValues", {
qPath: "/qListObjectDef",
qValues: [v.elem],
qToggleMode: false,
qSoftLock: true,
}, 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<Array<{ qText?: string; qNum?: number }>> }> } })
.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()));
if (!meilleure || couverts.length > meilleure.couverts.length) {
meilleure = { texte: v.texte, elem: v.elem, couverts };
}
if (couverts.length >= fenetre.size) break; // couverture complète, inutile de continuer
}
if (!meilleure || meilleure.couverts.length === 0) {
diag("aucune valeur de « Période » ne couvre la fenêtre — extraction sur la période par défaut");
return null;
}
await rpc("SelectListObjectValues", {
qPath: "/qListObjectDef",
qValues: [meilleure.elem],
qToggleMode: false,
qSoftLock: true,
}, lo.qHandle);
await sleep(qlikSettleMs);
diag("Période retenue : « " + meilleure.texte + " » (" + meilleure.couverts.length + "/" + fenetre.size + " mois)");
if (meilleure.couverts.length < fenetre.size) {
diag(
"⚠ couverture incomplète : les mois manquants resteront ABSENTS du cache. " +
"Aucune valeur de « Période » ne porte 12 mois glissants dans cette app.",
);
}
return meilleure.texte;
} catch (e) {
diag("choix de « Période » impossible : " + String((e as Error)?.message || e));
return null;
} finally {
// Le list object est conservé jusqu'au bout : le détruire
// annulerait la sélection qu'il porte.
void lo;
}
};
const moisParExpressions = async (): Promise<boolean> => { const moisParExpressions = async (): Promise<boolean> => {
const timings: Record<string, number> = {}; const timings: Record<string, number> = {};
const marqueOut = out.length; const marqueOut = out.length;
@@ -649,9 +780,9 @@ export async function fetchNetworkMetricsPlaywright(
// ce soit. Un champ valide doit donner un total non nul ET // ce soit. Un champ valide doit donner un total non nul ET
// strictement inférieur au total sans filtre. // strictement inférieur au total sans filtre.
const sansFiltre = await totalDeControle(); const sansFiltre = await totalDeControle();
console.log("[qlik-pw][expr] contrôle " + exprQte + " sans filtre date = " + Math.round(sansFiltre)); diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre));
if (sansFiltre <= 0) { if (sansFiltre <= 0) {
console.error("[qlik-pw][expr] " + exprQte + " renvoie 0 même sans filtre → expression ou champ invalide (QLIK_EXPR_QTE)"); diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)");
return false; return false;
} }
@@ -661,26 +792,23 @@ export async function fetchNetworkMetricsPlaywright(
for (const cand of champsDateCandidats) { for (const cand of champsDateCandidats) {
const valeurs = /key/i.test(cand) ? allYmd : allSerials; const valeurs = /key/i.test(cand) ? allYmd : allSerials;
if (!(await selectionnerFenetreSur(cand, valeurs))) { if (!(await selectionnerFenetreSur(cand, valeurs))) {
console.log("[qlik-pw][expr] champ « " + cand + " » : inexistant"); diag(" champ « " + cand + " » : inexistant");
continue; continue;
} }
const filtre = await totalDeControle(); const filtre = await totalDeControle();
const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0;
console.log( diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)");
"[qlik-pw][expr] champ « " + cand + " » → " + Math.round(filtre) +
" (" + Math.round(ratio * 100) + "% du total sans filtre)",
);
if (filtre > 0 && ratio < 0.995) { champDate = cand; break; } if (filtre > 0 && ratio < 0.995) { champDate = cand; break; }
} }
if (!champDate) { if (!champDate) {
console.error( diag(
"[qlik-pw][expr] aucun champ date ne filtre les faits (essayés : " + "ÉCHEC : aucun champ date ne filtre les faits (essayés : " +
JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois. " + JSON.stringify(champsDateCandidats) + "). 0% = la sélection ne matche rien, " +
"Forcer le bon champ avec QLIK_DATE_FIELD.", "100% = elle n'a aucun effet. Forcer le bon champ avec QLIK_DATE_FIELD.",
); );
return false; return false;
} }
console.log("[qlik-pw][expr] champ date retenu : « " + champDate + " »"); diag("champ date retenu : « " + champDate + " »");
// Seuls les mois de la fenêtre nous intéressent ; la dimension // Seuls les mois de la fenêtre nous intéressent ; la dimension
// Mois peut en exposer d'autres si une sélection déborde. // Mois peut en exposer d'autres si une sélection déborde.
@@ -698,7 +826,7 @@ export async function fetchNetworkMetricsPlaywright(
// Échantillon AVANT filtrage : si le mois ne tombe pas dans // Échantillon AVANT filtrage : si le mois ne tombe pas dans
// la fenêtre, c'est le format de la dimension Mois qui est // la fenêtre, c'est le format de la dimension Mois qui est
// en cause, pas les mesures. // en cause, pas les mesures.
console.log("[qlik-pw][expr] échantillon brut: " + JSON.stringify({ diag("échantillon brut: " + JSON.stringify({
code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm),
ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, ca, qte, nbMag, marge, "Quantité N (master)": qteMaster,
})); }));
@@ -723,15 +851,15 @@ export async function fetchNetworkMetricsPlaywright(
aggMonths = monthlyPayload.length; aggMonths = monthlyPayload.length;
aggBatches++; aggBatches++;
console.log( diag(
"[qlik-pw][expr] " + got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) +
", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »" + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »",
" timings=" + timingSummary(timings),
); );
console.log("[qlik-pw][expr] timings=" + timingSummary(timings));
if (totalQte <= 0) { if (totalQte <= 0) {
console.error( diag(
"[qlik-pw][expr] quantité totale nulle sur la fenêtre alors que le contrôle global valait " + "ÉCHEC : quantité totale nulle sur la fenêtre alors que le contrôle global valait " +
Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " +
"Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.", "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.",
); );
@@ -747,7 +875,7 @@ export async function fetchNetworkMetricsPlaywright(
} }
return true; return true;
} catch (e) { } catch (e) {
console.error("[qlik-pw][expr] échec : " + String((e as Error)?.message || e) + " → repli sur les master measures"); diag("ÉCHEC : " + String((e as Error)?.message || e) + " → repli sur les master measures");
out.length = marqueOut; out.length = marqueOut;
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
return false; return false;
@@ -995,10 +1123,18 @@ export async function fetchNetworkMetricsPlaywright(
} }
}; };
if (!noDateMode && monthDim) {
// ÉTAPE 1, avant toute extraction : caler le champ `Période` du
// modèle sur la fenêtre. Toutes les mesures de l'app étant bornées
// par `Type_Cal` (lui-même piloté par `Période`), c'est cette
// sélection — et elle seule — qui détermine les mois accessibles.
await choisirPeriode();
}
if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) { if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) {
// Chemin nominal : un seul cube, tous les mois de la fenêtre, des // Chemin alternatif : agrégation directe des faits. Conservé
// mesures qui respectent la sélection Date. Ni passe N-1 ni // derrière QLIK_USE_EXPR mais désormais secondaire — `Sum(quantite)`
// rattrapage nécessaires — c'est justement ce qu'ils compensaient. // mélange les lignes N et COMP du pont de périodes et double compte.
await pousserCheckpoint("expressions"); await pousserCheckpoint("expressions");
} else if (!noDateMode && monthDim) { } else if (!noDateMode && monthDim) {
// Repli : master measures « année en cours » + passe N-1 + rattrapage. // Repli : master measures « année en cours » + passe N-1 + rattrapage.
@@ -1147,12 +1283,12 @@ export async function fetchNetworkMetricsPlaywright(
// mensuel est validé et en production depuis. // mensuel est validé et en production depuis.
ws.close(); ws.close();
resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount }); resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount, diagExpr: diagnostics });
} catch (e) { } catch (e) {
try { ws.close(); } catch { /* noop */ } try { ws.close(); } catch { /* noop */ }
// On remonte ce qui a été extrait : les codes déjà traités ont // On remonte ce qui a été extrait : les codes déjà traités ont
// toutes leurs données, seul le reliquat manque. // toutes leurs données, seul le reliquat manque.
resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length }); resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length, diagExpr: diagnostics });
} }
})(); })();
}), }),
@@ -1216,6 +1352,10 @@ export async function fetchNetworkMetricsPlaywright(
// Les logs détaillés par étape restent en place (timings batch/mois) ; // Les logs détaillés par étape restent en place (timings batch/mois) ;
// ici on consolide les compteurs agrégés et la durée totale. // ici on consolide les compteurs agrégés et la durée totale.
const totalMs = Date.now() - totalStart; const totalMs = Date.now() - totalStart;
if (result.diagExpr?.length) {
console.log("[qlik-pw][diag] ─── diagnostic du chemin par expressions ───");
for (const d of result.diagExpr) console.log("[qlik-pw][diag] " + d);
}
console.log( console.log(
`[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`, `[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`,
); );
@@ -1262,15 +1402,33 @@ export async function fetchNetworkMetricsPlaywright(
// colonne « Tendance / Réseau » de la Grille. Inchangé. // colonne « Tendance / Réseau » de la Grille. Inchangé.
// - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit. // - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit.
const monthly = result.monthly ?? {}; const monthly = result.monthly ?? {};
// Mois RÉELLEMENT couverts par l'extraction = union des mois vus sur
// l'ensemble des codes. Distinguer « couvert, zéro vente » de « non
// extrait » est indispensable : sans cela, un article vendu seulement
// depuis mai voyait sa tendance calculée sur deux points (« +4 100 % »),
// et un mois non extrait passait pour une absence de vente.
const couverts = new Set<string>();
for (const parMois of Object.values(monthly)) {
for (const mois of Object.keys(parMois)) couverts.add(mois);
}
const moisCouverts = [...couverts].sort();
console.log(`[qlik-pw] mois couverts par l'extraction (${moisCouverts.length}) : ${JSON.stringify(moisCouverts)}`);
for (const [code, metric] of out) { for (const [code, metric] of out) {
const mm = monthly[code]; const mm = monthly[code] ?? {};
if (!mm || Object.keys(mm).length === 0) continue;
const qteByMonth: Record<string, number> = {}; const qteByMonth: Record<string, number> = {};
for (const [mois, v] of Object.entries(mm)) { const metricsByMonth: Record<string, QlikMonthMetrics> = {};
for (const mois of moisCouverts) {
const v = mm[mois];
// Zéro EXPLICITE sur un mois couvert sans vente : c'est une
// information, au même titre qu'une vente. Un mois non couvert
// reste absent et sera ignoré par la tendance.
qteByMonth[mois] = Number(v?.qte) || 0; qteByMonth[mois] = Number(v?.qte) || 0;
metricsByMonth[mois] = v ?? { qte: 0 };
} }
metric.qteByMonth = qteByMonth; metric.qteByMonth = qteByMonth;
metric.metricsByMonth = mm; metric.metricsByMonth = metricsByMonth;
} }
// Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques. // Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques.
// Si presque tous varient → la mesure respecte le mois (feature OK). // Si presque tous varient → la mesure respecte le mois (feature OK).