mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
fix(qlik): supprime la passe de rattrapage et vérifie que la sélection Date filtre
Le log de la dernière sync montre batches=3 et des lignes « (rattrapage) » : le chemin par expressions a bien tourné mais a été rejeté (quantité totale nulle), et c'est le rattrapage qui a de nouveau recopié le total de période dans les mois manquants — d'où les plateaux à 87 648 d'août à décembre et les « -162 % ». - Passe de rattrapage SUPPRIMÉE. Elle ré-extrayait un mois vide en ne sélectionnant que ses dates ; les master measures ignorant la sélection Date, le cube lui renvoyait le total de période. Un mois qu'on ne sait pas extraire doit rester absent, jamais rempli d'une valeur plausible. - Le chemin par expressions vérifie maintenant qu'un champ date filtre RÉELLEMENT les faits, ce que personne n'avait pu contrôler tant que seules des mesures insensibles à la sélection étaient utilisées. Un total de contrôle (Sum(quantite) sans dimension) est comparé avec et sans filtre ; un champ n'est retenu que si le total est non nul ET strictement inférieur au total global. Candidats : QLIK_DATE_FIELD, Date, Date calendrier, Date_Key (ce dernier sélectionné au format AAAAMMJJ). Si aucun ne filtre, l'extraction le dit clairement au lieu de produire une fenêtre fausse. - Diagnostic : échantillon BRUT du cube loggué avant tout filtrage (mois brut, mois normalisé, appartenance à la fenêtre, valeurs des cinq mesures), et compteur de lignes écartées comme hors fenêtre. Ces deux traces distinguent une expression invalide d'un format de dimension Mois inattendu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
2 files changed
+170
-84
No files matched your search
+137
-84
@@ -129,10 +129,19 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
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();
|
||||
// Champs date candidats, essayés dans l'ordre. Un champ n'est retenu que s'il
|
||||
// FILTRE réellement les faits (total non nul et strictement inférieur au total
|
||||
// sans filtre) : personne n'avait pu le vérifier tant que seules des mesures
|
||||
// insensibles à la sélection étaient utilisées.
|
||||
const qlikDateFields = [
|
||||
(process.env.QLIK_DATE_FIELD ?? "").trim(),
|
||||
"Date", "Date calendrier", "Date_Key",
|
||||
].filter((c, i, arr) => c && arr.indexOf(c) === i);
|
||||
const yearN = new Date().getFullYear();
|
||||
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}"`);
|
||||
console.log(`[qlik-pw] champs date candidats: ${JSON.stringify(qlikDateFields)}`);
|
||||
}
|
||||
|
||||
const browser = await getBrowser();
|
||||
@@ -172,6 +181,13 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
dateDebut: m.dateDebut,
|
||||
dateFin: m.dateFin,
|
||||
serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois
|
||||
// Même fenêtre au format entier AAAAMMJJ : certains modèles Qlik
|
||||
// n'exposent la date que par une clé numérique (`Date_Key`), pas par
|
||||
// un champ date sérialisé.
|
||||
ymd: m.dailySerials.map((serial) => {
|
||||
const d = new Date(Date.UTC(1899, 11, 30) + serial * 86_400_000);
|
||||
return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate();
|
||||
}),
|
||||
}));
|
||||
|
||||
// Point de contrôle : le script in-page pousse ses résultats intermédiaires
|
||||
@@ -186,10 +202,10 @@ 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, useExpr, exprQte, exprCa, exprNbMag, exprMarge }: {
|
||||
({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats }: {
|
||||
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[] }>;
|
||||
monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[]; ymd: number[] }>;
|
||||
noDateMode: boolean;
|
||||
qlikSettleMs: number;
|
||||
qlikCode15MaxAttempts: number;
|
||||
@@ -204,6 +220,7 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
exprCa: string;
|
||||
exprNbMag: string;
|
||||
exprMarge: string;
|
||||
champsDateCandidats: string[];
|
||||
}) =>
|
||||
new Promise<InPageResult>((resolve) => {
|
||||
const loc = (window as unknown as { location: Location }).location;
|
||||
@@ -558,22 +575,62 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
* Renvoie false si le résultat est inexploitable (expression invalide,
|
||||
* champ absent) — l'appelant repart alors sur les master measures.
|
||||
*/
|
||||
/**
|
||||
* Total de contrôle : un cube 1 cellule, sans dimension, sur
|
||||
* l'expression quantité. Sert à savoir si une sélection de dates
|
||||
* FILTRE réellement les faits — ce que les master measures, qui
|
||||
* l'ignorent, ne permettaient pas de vérifier.
|
||||
*/
|
||||
const totalDeControle = async (): Promise<number> => {
|
||||
const o = await rpc("CreateSessionObject", { qProp: {
|
||||
qInfo: { qType: "cf-ctrl" },
|
||||
qHyperCubeDef: {
|
||||
qDimensions: [],
|
||||
qMeasures: [{ qDef: { qDef: exprQte } }],
|
||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }],
|
||||
},
|
||||
} }, doc);
|
||||
const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||
try {
|
||||
const lay = await rpc("GetLayout", {}, ret.qHandle);
|
||||
const cell = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array<Array<{ qNum?: number; qText?: string }>> }> } })
|
||||
.qHyperCube?.qDataPages?.[0]?.qMatrix?.[0]?.[0];
|
||||
return Number(cell?.qNum) || 0;
|
||||
} finally {
|
||||
const id = String(ret.qGenericId ?? ret.qId ?? "");
|
||||
if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } }
|
||||
}
|
||||
};
|
||||
|
||||
/** Sélectionne la fenêtre sur un champ date candidat. Renvoie false si le champ n'existe pas. */
|
||||
const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise<boolean> => {
|
||||
let h: number;
|
||||
try {
|
||||
const gf = await rpc("GetField", { qFieldName: champ }, doc);
|
||||
h = (gf.qReturn as { qHandle: number }).qHandle;
|
||||
if (typeof h !== "number" || h < 0) return false;
|
||||
} catch { return false; }
|
||||
await rpc("Clear", {}, h);
|
||||
await sleep(qlikSettleMs);
|
||||
for (let i = 0; i < valeurs.length; i += 500) {
|
||||
await rpc("SelectValues", {
|
||||
qFieldValues: valeurs.slice(i, i + 500).map((v) => ({ qNum: v, qText: String(v) })),
|
||||
qToggleMode: i > 0,
|
||||
qSoftLock: true,
|
||||
}, h);
|
||||
}
|
||||
await sleep(qlikSettleMs);
|
||||
return true;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Les codes d'abord : les totaux de contrôle portent alors sur
|
||||
// le même périmètre que l'extraction.
|
||||
if (codes.length && fh !== -1) {
|
||||
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
|
||||
await sleep(qlikSettleMs);
|
||||
@@ -585,21 +642,68 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
await sleep(qlikSettleMs);
|
||||
}
|
||||
|
||||
// ─── Quel champ date filtre RÉELLEMENT les faits ? ───────
|
||||
//
|
||||
// Les master measures ignorant la sélection Date, personne
|
||||
// n'avait jamais pu vérifier que « Date » filtrait quoi que
|
||||
// ce soit. Un champ valide doit donner un total non nul ET
|
||||
// strictement inférieur au total sans filtre.
|
||||
const sansFiltre = await totalDeControle();
|
||||
console.log("[qlik-pw][expr] contrôle " + exprQte + " sans filtre date = " + Math.round(sansFiltre));
|
||||
if (sansFiltre <= 0) {
|
||||
console.error("[qlik-pw][expr] " + exprQte + " renvoie 0 même sans filtre → expression ou champ invalide (QLIK_EXPR_QTE)");
|
||||
return false;
|
||||
}
|
||||
|
||||
const allSerials = monthlyPayload.flatMap((m) => m.serials);
|
||||
const allYmd = monthlyPayload.flatMap((m) => m.ymd);
|
||||
let champDate: string | null = null;
|
||||
for (const cand of champsDateCandidats) {
|
||||
const valeurs = /key/i.test(cand) ? allYmd : allSerials;
|
||||
if (!(await selectionnerFenetreSur(cand, valeurs))) {
|
||||
console.log("[qlik-pw][expr] champ « " + cand + " » : inexistant");
|
||||
continue;
|
||||
}
|
||||
const filtre = await totalDeControle();
|
||||
const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0;
|
||||
console.log(
|
||||
"[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 (!champDate) {
|
||||
console.error(
|
||||
"[qlik-pw][expr] aucun champ date ne filtre les faits (essayés : " +
|
||||
JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois. " +
|
||||
"Forcer le bon champ avec QLIK_DATE_FIELD.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
console.log("[qlik-pw][expr] champ date retenu : « " + champDate + " »");
|
||||
|
||||
// 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 horsFenetre = 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 }));
|
||||
// Échantillon AVANT filtrage : si le mois ne tombe pas dans
|
||||
// la fenêtre, c'est le format de la dimension Mois qui est
|
||||
// en cause, pas les mesures.
|
||||
console.log("[qlik-pw][expr] échantillon brut: " + JSON.stringify({
|
||||
code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm),
|
||||
ca, qte, nbMag, marge, "Quantité N (master)": qteMaster,
|
||||
}));
|
||||
}
|
||||
if (!moisFenetre.has(mm)) { horsFenetre++; return; }
|
||||
// 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)) {
|
||||
@@ -620,13 +724,17 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
aggMonths = monthlyPayload.length;
|
||||
aggBatches++;
|
||||
console.log(
|
||||
"[qlik-pw][expr] " + got.value + " lignes, quantité totale=" + Math.round(totalQte) +
|
||||
"[qlik-pw][expr] " + got.value + " lignes (" + horsFenetre + " hors fenêtre), 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");
|
||||
console.error(
|
||||
"[qlik-pw][expr] 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. " +
|
||||
"Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.",
|
||||
);
|
||||
out.length = marqueOut;
|
||||
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
|
||||
return false;
|
||||
@@ -864,71 +972,16 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Rattrapage des mois RESTÉS VIDES pour tous les articles.
|
||||
*
|
||||
* Les mesures « N » de l'app sont bornées à une année : quand la
|
||||
* fenêtre 12 mois glissants chevauche deux années civiles, les mois
|
||||
* de l'année précédente non couverts par « Quantité COMP » ressortent
|
||||
* vides — cinq mois d'affilée à 0 sur TOUS les articles, ce qui n'a
|
||||
* aucun sens métier.
|
||||
*
|
||||
* La parade fiable est de ne sélectionner QUE les dates du mois
|
||||
* concerné : la mesure se résout alors sur la bonne année, quelle que
|
||||
* soit la façon dont son set analysis est écrit. On ne le fait que
|
||||
* pour les mois réellement vides — au pire quelques cubes de plus.
|
||||
*/
|
||||
const rattraperMoisVides = async (): Promise<void> => {
|
||||
const moisRenseignes = new Set<string>();
|
||||
for (const parMois of Object.values(monthlyByCode)) {
|
||||
for (const [mois, v] of Object.entries(parMois)) {
|
||||
if ((v?.qte ?? 0) > 0) moisRenseignes.add(mois);
|
||||
}
|
||||
}
|
||||
const manquants = monthlyPayload.filter((m) => !moisRenseignes.has(m.label));
|
||||
if (manquants.length === 0) return;
|
||||
console.log(
|
||||
"[qlik-pw] (mois) " + manquants.length + " mois vide(s) sur tous les articles : " +
|
||||
JSON.stringify(manquants.map((m) => m.label)) + " → passe de rattrapage mois par mois",
|
||||
);
|
||||
|
||||
// Une seule sélection Article Code pour tout le rattrapage :
|
||||
// `SelectValues` sur ce champ coûte des dizaines de secondes
|
||||
// par appel, on ne le refait pas à chaque mois.
|
||||
if (yfh !== -1) { try { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } catch { /* noop */ } }
|
||||
if (codes.length && fh !== -1) {
|
||||
await rpc("Clear", {}, fh);
|
||||
await sleep(qlikSettleMs);
|
||||
await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh);
|
||||
await sleep(qlikSettleMs);
|
||||
}
|
||||
|
||||
for (const m of manquants) {
|
||||
const prefix = "[qlik-pw] (rattrapage) " + m.label;
|
||||
try {
|
||||
// Seule la fenêtre de dates change d'un mois à l'autre :
|
||||
// c'est elle qui détermine l'année vue par la mesure.
|
||||
await rpc("Clear", {}, dfh);
|
||||
await sleep(qlikSettleMs);
|
||||
await rpc("SelectValues", {
|
||||
qFieldValues: m.serials.map((s2) => ({ qNum: s2, qText: String(s2) })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
}, dfh);
|
||||
await sleep(qlikSettleMs);
|
||||
|
||||
const batchRows: Array<Array<string | number>> = [];
|
||||
await fetchCubeForSelection(batchRows);
|
||||
// Uniquement le mensuel : les totaux réseau restent ceux
|
||||
// de la passe principale, sinon on les doublerait.
|
||||
accMonthly(batchRows, m.label);
|
||||
const remplis = Object.values(monthlyByCode).filter((x) => (x[m.label]?.qte ?? 0) > 0).length;
|
||||
console.log(prefix + " — " + remplis + " article(s) renseigné(s)");
|
||||
} catch (e) {
|
||||
console.error(prefix + " — échec : " + String((e as Error)?.message || e));
|
||||
}
|
||||
}
|
||||
};
|
||||
// La passe de « rattrapage des mois vides » a été SUPPRIMÉE.
|
||||
//
|
||||
// Elle ré-extrayait un mois vide en ne sélectionnant que ses dates.
|
||||
// Or les master measures ignorent la sélection Date : le cube lui
|
||||
// renvoyait le total de période, qu'elle recopiait dans chacun des
|
||||
// mois manquants. D'où les plateaux identiques d'août à décembre
|
||||
// (87 648 sur douze mois pour un article, etc.) et les tendances
|
||||
// « -162 % » qu'ils fabriquaient.
|
||||
//
|
||||
// Un mois qu'on ne sait pas extraire doit rester ABSENT.
|
||||
|
||||
/** Pousse l'état courant vers Node (voir `cfCheckpoint`). */
|
||||
const pousserCheckpoint = async (etape: string) => {
|
||||
@@ -969,8 +1022,7 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
} else {
|
||||
console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1");
|
||||
}
|
||||
await rattraperMoisVides();
|
||||
await pousserCheckpoint("rattrapage");
|
||||
await pousserCheckpoint("passes master");
|
||||
} else if (noDateMode) {
|
||||
// Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup).
|
||||
// On sélectionne uniquement Article Code ; Date reste libre.
|
||||
@@ -1129,6 +1181,7 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
exprCa: qlikExprCa,
|
||||
exprNbMag: qlikExprNbMag,
|
||||
exprMarge: qlikExprMarge,
|
||||
champsDateCandidats: qlikDateFields,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user