fix Qlik rolling 12-month integrity

This commit is contained in:
Michael committed 2026-07-31 10:28:29 +02:00
1 parent db886cd8d9
commit 4ef0e9fdeb
8 files changed
+221 -100

No files matched your search

+34 -29
View File
@@ -117,8 +117,20 @@ master measure est juste :
``` ```
Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`, 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 `ca_ttc` plutôt que `ca_ht`…). Il n'existe plus de repli automatique vers les
automatique sur l'ancien chemin. `QLIK_USE_EXPR=0` le force. master measures annuelles : quantité totale nulle, champ Date invalide, erreur
Engine ou somme incohérente font échouer la synchronisation et laissent le cache
précédent intact.
Après sélection de la fenêtre, l'extracteur vérifie deux invariants avant tout
upsert :
1. la somme de toutes les lignes `[Article Code, Mois]` est égale au total Qlik
sans dimension calculé avec les mêmes sélections ;
2. pour chaque article, la quantité réseau totale est exactement la somme de ses
12 mois. Les mois sans ligne de faits sont alors seulement matérialisés à `0`.
Une extraction interrompue ou partielle n'est jamais publiée.
### Valider les expressions sur le vrai serveur ### Valider les expressions sur le vrai serveur
@@ -159,8 +171,10 @@ au total sans filtre — 0 % signifie que la sélection ne matche rien, 100 % qu
n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini), n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini),
`Date`, `Date calendrier`, `Date_Key` (celui-ci sélectionné au format `AAAAMMJJ`). `Date`, `Date calendrier`, `Date_Key` (celui-ci sélectionné au format `AAAAMMJJ`).
Si aucun ne filtre, l'extraction le dit et repart sur l'ancien chemin plutôt que Si aucun ne filtre, l'extraction échoue explicitement plutôt que de produire une
de produire une fenêtre fausse. fenêtre fausse. Chaque sélection d'un candidat rejeté est effacée avant l'essai
suivant ; autrement un premier candidat à zéro contaminait tous les contrôles
suivants.
## La passe de rattrapage a été supprimée ## La passe de rattrapage a été supprimée
@@ -173,7 +187,7 @@ qu'elle recopiait dans chaque mois manquant : d'où les plateaux identiques d'ao
Un mois qu'on ne sait pas extraire doit rester **absent**, jamais rempli d'une Un mois qu'on ne sait pas extraire doit rester **absent**, jamais rempli d'une
valeur plausible. valeur plausible.
## Mois vides de la fenêtre glissante (chemin de repli) ## Mois vides de la fenêtre glissante
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
@@ -183,22 +197,15 @@ que sur une seule année : les mois de l'année précédente ressortent vides.
cours — d'où, en juillet 2026, un trou observé d'août à décembre 2025 sur cours — d'où, en juillet 2026, un trou observé d'août à décembre 2025 sur
**tous** les articles. **tous** les articles.
Trois garde-fous, dans `qlik-playwright.ts` : Le chemin daté repose uniquement sur les expressions de faits et un cube unique
`[Article Code, Mois]`. Après validation de la fenêtre et du total, chaque
article reçoit exactement les 12 clés attendues. Une clé absente du cube signifie
alors réellement « aucun fait sur ce mois » et vaut `0`; avant cette validation,
aucun zéro n'est inventé.
1. La passe principale n'écrit plus de `0` pour un mois hors année N, et `QLIK_MONTH_DIM=0` ou `QLIK_USE_EXPR=0` désactive désormais un prérequis et fait
« Quantité COMP » n'écrit rien quand elle est vide. Un mois **absent** peut échouer la synchronisation datée : ces options ne peuvent plus réactiver un
être rattrapé ; un mois **à 0** se lit comme « pas de vente » et masque le trou. chemin connu comme incorrect.
2. `rattraperMoisVides()` : après les passes N et N-1, tout mois vide pour
**tous** les articles est ré-extrait en ne sélectionnant **que ses dates** —
la mesure se résout alors sur la bonne année, quelle que soit l'écriture de
son set analysis.
3. Les expressions des master measures sont dumpées au log
(`[qlik-pw][dump] expression « Quantité N » = …`) : c'est la seule façon de
savoir comment la mesure est bornée.
Contournement immédiat sans redéploiement : `QLIK_MONTH_DIM=0` repasse sur
l'itération mois par mois (une sélection de dates par mois, donc pas de trou),
au prix d'une extraction plus lente.
⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik ⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik
pour les corriger. pour les corriger.
@@ -219,21 +226,19 @@ sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre
La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection
complète des codes et des 365 jours pour rien. complète des codes et des 365 jours pour rien.
### Résultat partiel plutôt que rien ### Aucun résultat partiel dans le cache
Le script in-page pousse un point de contrôle (`cfCheckpoint`) après chaque passe. Le script in-page conserve des points de contrôle à des fins de diagnostic, mais
Si la session meurt en cours de route, l'extraction repart de ce point au lieu de une synchronisation datée interrompue est refusée intégralement. Le cache garde
tout perdre : les codes déjà traités ont des données **complètes** (la dimension sa dernière version complète jusqu'à la réussite d'une nouvelle extraction.
Mois livre tous leurs mois d'un coup), seul le reliquat manque. Le log signale
alors `⚠ résultat PARTIEL` — relancer la sync complète le reste.
## Tendance réseau = 12 mois glissants stricts ## Tendance réseau = 12 mois glissants stricts
`computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour : `computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour :
12 mois complets, **mois en cours exclu** (partiel, il tirait la pente vers le bas). 12 mois complets, **mois en cours exclu** (partiel, il tirait la pente vers le bas).
Les mois de la fenêtre absents du cache valent 0 (produit non vendu) ; ceux La tendance n'est affichée que si les 12 clés sont explicitement présentes dans
antérieurs au premier mois réellement extrait sont écartés au lieu d'être le cache. Les anciennes séries partielles de deux ou trois mois sont donc
inventés à 0 (extraction plus courte que 12 mois). refusées au lieu d'être présentées comme une tendance.
Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`, Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`,
dossier `ai-copilot`), score (`score-engine.ts`, colonne score). dossier `ai-copilot`), score (`score-engine.ts`, colonne score).
+5 -11
View File
@@ -5,10 +5,7 @@ import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
import { import {
buildGridNetworkQlikDateFilter, buildGridNetworkQlikDateFilter,
envMonthsBack,
QLIK_MONTHS_BACK_DEFAULT, QLIK_MONTHS_BACK_DEFAULT,
QLIK_MONTHS_BACK_MAX,
QLIK_MONTHS_BACK_MIN,
} from "@/lib/qlik-date-range"; } from "@/lib/qlik-date-range";
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé). // Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
@@ -235,16 +232,14 @@ async function runJob(job: QlikSyncJob): Promise<void> {
return; return;
} }
// Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via // Contrat métier strict : toujours 12 mois complets glissants.
// QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective // Une variable d'environnement ne doit pas pouvoir raccourcir la série.
// pour audit. const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
job.periode = dateFilter.label; job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut; job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin; job.dateFin = dateFilter.dateFin;
console.log( console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`, `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
); );
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
job.fetched = metrics.size; job.fetched = metrics.size;
@@ -289,8 +284,7 @@ async function runProductJob(job: QlikSyncJob): Promise<void> {
const code = job.codeCentrale!; const code = job.codeCentrale!;
job.requested = 1; job.requested = 1;
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
job.periode = dateFilter.label; job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut; job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin; job.dateFin = dateFilter.dateFin;
+11 -14
View File
@@ -62,10 +62,10 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
* - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit * - 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. * 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 * Une série n'est affichée que si les **12 clés sont explicitement présentes**.
* mois réellement extrait**. Ils traduisent une extraction plus courte que 12 * L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé
* mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0 * la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle
* simulerait une croissance qui n'existe pas. * ne peut donc plus être présentée comme une tendance réelle.
* *
* 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.
@@ -77,17 +77,14 @@ export function computeNetworkTrend(
): NetworkTrend { ): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty; if (!qteByMonth) return empty;
const presents = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement const labels = buildRolling12QlikMonths(now);
if (presents.length === 0) return empty; const complet = labels.every((label) =>
Object.prototype.hasOwnProperty.call(qteByMonth, label) &&
Number.isFinite(Number(qteByMonth[label])),
);
if (!complet) return empty;
const fenetre = buildRolling12QlikMonths(now); const values = labels.map((l) => Number(qteByMonth[l]));
// Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) :
// rien de fiable à tracer.
const premierMoisExtrait = presents[0];
const labels = fenetre.filter((m) => m >= premierMoisExtrait);
if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) return empty;
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length; const n = values.length;
let pct: number | null = null; let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat"; let direction: NetworkTrend["direction"] = "flat";
+49 -23
View File
@@ -8,7 +8,7 @@ import {
type NetworkMetricCached, type NetworkMetricCached,
} from "@/lib/qlik-network-cache"; } from "@/lib/qlik-network-cache";
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client"; import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau"; import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types"; import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
@@ -37,6 +37,19 @@ const CACHE_TTL_MS = 10 * 60 * 1000;
/** Durée de conservation d'un job terminé (le client a le temps de le relire). */ /** Durée de conservation d'un job terminé (le client a le temps de le relire). */
const JOB_TTL_MS = 5 * 60 * 1000; const JOB_TTL_MS = 5 * 60 * 1000;
/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */
function cacheReseauComplet(
cache: NetworkMetricCached | undefined,
periode: string | null,
now: Date = new Date(),
): cache is NetworkMetricCached {
if (!cache?.qteByMonth || !periode || cache.periode !== periode) return false;
return buildRolling12QlikMonths(now).every((mois) =>
Object.prototype.hasOwnProperty.call(cache.qteByMonth, mois) &&
Number.isFinite(Number(cache.qteByMonth[mois])),
);
}
/** /**
* Nombre maximum de recherches Qlik simultanées. * Nombre maximum de recherches Qlik simultanées.
* *
@@ -271,21 +284,29 @@ async function executerRecherche(
localPromise, localPromise,
]); ]);
// On persiste les mesures fraîches : la fiche produit ouverte depuis un // Le cube de recherche utilise encore les master measures annuelles pour
// résultat les affiche aussitôt, sans re-extraction. Le détail mensuel n'est // classer rapidement les résultats. Il ne doit jamais écraser une extraction
// pas fourni ici et n'est donc pas écrasé (upsert en COALESCE). // 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec
// leur mensuel. Sans cache complet, les valeurs de découverte restent
// temporaires jusqu'au bouton « Actualiser depuis Qlik ».
try { try {
const aPersister: NetworkMetric[] = qlik.matches.map((m) => ({ const aPersister: NetworkMetric[] = qlik.matches.map((m) => {
codeCentrale: m.codeCentrale, const cache = cacheReseau.get(m.codeCentrale);
caReseau: m.caReseau, const fiable = cacheReseauComplet(cache, qlik.periode);
qteReseau: m.qteReseau, return {
nbMagasinsReseau: m.nbMagasinsReseau, codeCentrale: m.codeCentrale,
caParMagasinReseau: m.caParMagasinReseau, caReseau: fiable ? cache.caReseau : m.caReseau,
margePctReseau: m.margePctReseau, qteReseau: fiable ? cache.qteReseau : m.qteReseau,
periode: qlik.periode ?? undefined, nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau,
libelleReseau: m.libelle || undefined, caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau,
fournisseurReseau: m.fournisseur || undefined, margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau,
})); periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined,
qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined,
metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined,
libelleReseau: m.libelle || undefined,
fournisseurReseau: m.fournisseur || undefined,
};
});
const n = await upsertNetworkMetrics(aPersister); const n = await upsertNetworkMetrics(aPersister);
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
} catch (e) { } catch (e) {
@@ -295,7 +316,12 @@ async function executerRecherche(
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => { const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined); const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined);
const cache = cacheReseau.get(m.codeCentrale); const cache = cacheReseau.get(m.codeCentrale);
const nbMag = m.nbMagasinsReseau; const fiable = cacheReseauComplet(cache, qlik.periode);
const qteReseau = fiable ? cache.qteReseau : m.qteReseau;
const caReseau = fiable ? cache.caReseau : m.caReseau;
const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau;
const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau;
const margePct = fiable ? cache.margePctReseau : m.margePctReseau;
return { return {
codeCentrale: m.codeCentrale, codeCentrale: m.codeCentrale,
libelle: local?.libelle1 || m.libelle || m.codeCentrale, libelle: local?.libelle1 || m.libelle || m.codeCentrale,
@@ -304,16 +330,16 @@ async function executerRecherche(
codein: local?.codein ?? null, codein: local?.codein ?? null,
nomenclature: local?.nomenclature ?? "", nomenclature: local?.nomenclature ?? "",
stockLocal: local ? Number(local.stock_total) || 0 : null, stockLocal: local ? Number(local.stock_total) || 0 : null,
qteReseau: m.qteReseau, qteReseau,
caReseau: m.caReseau, caReseau,
nbMagasinsReseau: nbMag, nbMagasinsReseau: nbMag,
tauxPresence: nbMag / NB_MAGASINS_RESEAU, tauxPresence: nbMag / NB_MAGASINS_RESEAU,
qteParMagasinReseau: nbMag > 0 ? m.qteReseau / nbMag : 0, qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
caParMagasinReseau: m.caParMagasinReseau, caParMagasinReseau: caParMagasin,
prixMoyenReseau: m.qteReseau > 0 ? m.caReseau / m.qteReseau : null, prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
margePctReseau: normalizeMargePct(m.margePctReseau), margePctReseau: normalizeMargePct(margePct),
// Le mensuel ne vient que du cache : le cube de recherche est agrégé. // Le mensuel ne vient que du cache : le cube de recherche est agrégé.
qteByMonth: cache?.qteByMonth ?? null, qteByMonth: fiable ? cache.qteByMonth : null,
periode: qlik.periode, periode: qlik.periode,
}; };
}); });
+3 -5
View File
@@ -94,11 +94,9 @@ export interface NetworkMetric {
/** /**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* *
* Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois) — * Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois).
* les conserver ne coûte aucune requête supplémentaire. Seule `qte` est * Lors d'une synchronisation datée validée, les 12 mois complets sont
* garantie : les mois N-1 dérivés de « Quantité COMP » n'ont que la quantité * toujours présents ; un mois sans fait est explicitement représenté à 0.
* tant que la passe complémentaire N-1 ne les a pas complétés (elle est
* sautée si le champ Qlik `Année` est absent).
*/ */
metricsByMonth?: Record<string, QlikMonthMetrics>; metricsByMonth?: Record<string, QlikMonthMetrics>;
/** /**
+8 -2
View File
@@ -144,8 +144,14 @@ export function buildGridNetworkQlikDateFilter(
// Début : `months` mois avant le mois courant (1er du mois) // Début : `months` mois avant le mois courant (1er du mois)
const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1); const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1);
const dateDebut = startMonth.toISOString().slice(0, 10); // Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris
const dateFin = endMonth.toISOString().slice(0, 10); // devient la veille en UTC et transformait par exemple 2025-07-01 en
// 2025-06-30, soit une extraction de 13 mois. Les bornes sont des dates
// calendaires Qlik, on les formate donc avec les composantes locales.
const localIso = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const dateDebut = localIso(startMonth);
const dateFin = localIso(endMonth);
const qStart = isoDateToQlikSerial(dateDebut); const qStart = isoDateToQlikSerial(dateDebut);
const qEnd = isoDateToQlikSerial(dateFin); const qEnd = isoDateToQlikSerial(dateFin);
+109 -14
View File
@@ -602,14 +602,22 @@ export async function fetchNetworkMetricsPlaywright(
} }
}; };
/** 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> => { * Sélectionne la fenêtre sur un champ date candidat.
*
* Renvoie le handle pour que l'appelant puisse impérativement
* effacer une sélection rejetée avant d'essayer le candidat
* suivant. Sans ce Clear, un premier champ qui ne matche aucune
* date maintient l'ensemble courant à zéro et condamne tous les
* essais suivants à renvoyer zéro eux aussi.
*/
const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise<number | null> => {
let h: number; let h: number;
try { try {
const gf = await rpc("GetField", { qFieldName: champ }, doc); const gf = await rpc("GetField", { qFieldName: champ }, doc);
h = (gf.qReturn as { qHandle: number }).qHandle; h = (gf.qReturn as { qHandle: number }).qHandle;
if (typeof h !== "number" || h < 0) return false; if (typeof h !== "number" || h < 0) return null;
} catch { return false; } } catch { return null; }
await rpc("Clear", {}, h); await rpc("Clear", {}, h);
await sleep(qlikSettleMs); await sleep(qlikSettleMs);
for (let i = 0; i < valeurs.length; i += 500) { for (let i = 0; i < valeurs.length; i += 500) {
@@ -620,7 +628,7 @@ export async function fetchNetworkMetricsPlaywright(
}, h); }, h);
} }
await sleep(qlikSettleMs); await sleep(qlikSettleMs);
return true; return h;
}; };
const moisParExpressions = async (): Promise<boolean> => { const moisParExpressions = async (): Promise<boolean> => {
@@ -658,9 +666,11 @@ export async function fetchNetworkMetricsPlaywright(
const allSerials = monthlyPayload.flatMap((m) => m.serials); const allSerials = monthlyPayload.flatMap((m) => m.serials);
const allYmd = monthlyPayload.flatMap((m) => m.ymd); const allYmd = monthlyPayload.flatMap((m) => m.ymd);
let champDate: string | null = null; let champDate: string | null = null;
let totalFenetre = 0;
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))) { const handleCand = await selectionnerFenetreSur(cand, valeurs);
if (handleCand == null) {
console.log("[qlik-pw][expr] champ « " + cand + " » : inexistant"); console.log("[qlik-pw][expr] champ « " + cand + " » : inexistant");
continue; continue;
} }
@@ -670,7 +680,15 @@ export async function fetchNetworkMetricsPlaywright(
"[qlik-pw][expr] champ « " + cand + " » → " + Math.round(filtre) + "[qlik-pw][expr] champ « " + cand + " » → " + Math.round(filtre) +
" (" + Math.round(ratio * 100) + "% du total sans 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;
totalFenetre = filtre;
break;
}
// Un candidat rejeté ne doit jamais polluer l'essai
// suivant (cas observé : Date=0 puis tous les autres=0).
await rpc("Clear", {}, handleCand);
await sleep(qlikSettleMs);
} }
if (!champDate) { if (!champDate) {
console.error( console.error(
@@ -714,10 +732,19 @@ export async function fetchNetworkMetricsPlaywright(
// Totaux = somme des mois de la fenêtre (et non un cumul année). // 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]); out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]);
(monthlyByCode[code] ??= {}); (monthlyByCode[code] ??= {});
// Agrège au lieu d'écraser : protège contre deux valeurs
// brutes de la dimension Mois qui se normaliseraient vers
// la même clé YYYY-MM.
const precedent = monthlyByCode[code][mm];
const qteCumulee = (precedent?.qte ?? 0) + qte;
const caCumule = (precedent?.ca ?? 0) + ca;
const margeMontant = ((precedent?.margePct ?? 0) * (precedent?.ca ?? 0)) + marge;
monthlyByCode[code][mm] = { monthlyByCode[code][mm] = {
qte, ca, nbMag, qte: qteCumulee,
ca: caCumule,
nbMag: Math.max(precedent?.nbMag ?? 0, nbMag),
caMag: nbMag > 0 ? ca / nbMag : 0, caMag: nbMag > 0 ? ca / nbMag : 0,
margePct: ca > 0 ? marge / ca : 0, margePct: caCumule > 0 ? margeMontant / caCumule : 0,
}; };
}, timings), timings); }, timings), timings);
@@ -739,6 +766,51 @@ export async function fetchNetworkMetricsPlaywright(
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
return false; return false;
} }
// Contrôle global indépendant : le cube sans dimension,
// calculé sur exactement les mêmes sélections, doit être
// égal à la somme de toutes les lignes mensuelles paginées.
const ecartGlobal = Math.abs(totalQte - totalFenetre);
const toleranceGlobale = Math.max(0.001, Math.abs(totalFenetre) * 1e-9);
if (ecartGlobal > toleranceGlobale) {
console.error(
"[qlik-pw][expr] INTÉGRITÉ REFUSÉE : somme mensuelle=" + totalQte +
" mais total Qlik fenêtre=" + totalFenetre + " (écart=" + ecartGlobal + ")",
);
out.length = marqueOut;
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
return false;
}
// Contrôle et normalisation PAR ARTICLE. Chaque article
// retourné possède ensuite exactement les 12 clés de la
// fenêtre ; une clé absente du cube Qlik signifie qu'aucun
// fait n'existe pour ce mois, donc une vraie valeur 0.
const totauxParCode: Record<string, number> = {};
for (let i = marqueOut; i < out.length; i++) {
const ligne = out[i];
const code = String(ligne[0] ?? "").trim();
if (code) totauxParCode[code] = (totauxParCode[code] ?? 0) + (Number(ligne[2]) || 0);
}
for (const [code, mois] of Object.entries(monthlyByCode)) {
for (const label of moisFenetre) {
mois[label] ??= { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 };
}
// Ne conserve aucune clé hors de la fenêtre glissante.
for (const label of Object.keys(mois)) {
if (!moisFenetre.has(label)) delete mois[label];
}
const somme12 = Array.from(moisFenetre).reduce((s, label) => s + (Number(mois[label]?.qte) || 0), 0);
const totalArticle = totauxParCode[code] ?? 0;
if (Math.abs(somme12 - totalArticle) > Math.max(0.001, Math.abs(totalArticle) * 1e-9)) {
console.error(
"[qlik-pw][expr] INTÉGRITÉ ARTICLE REFUSÉE code=" + code +
" somme12=" + somme12 + " total=" + totalArticle,
);
out.length = marqueOut;
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
return false;
}
}
if (calibrees > 20 && calibrOk / calibrees < 0.5) { if (calibrees > 20 && calibrOk / calibrees < 0.5) {
console.warn( console.warn(
"[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + "[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte +
@@ -995,11 +1067,22 @@ export async function fetchNetworkMetricsPlaywright(
} }
}; };
if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) { if (!noDateMode) {
// Chemin nominal : un seul cube, tous les mois de la fenêtre, des // Une synchronisation datée n'est valide qu'avec les expressions
// mesures qui respectent la sélection Date. Ni passe N-1 ni // de faits, la dimension Mois et les contrôles d'intégrité.
// rattrapage nécessaires — c'est justement ce qu'ils compensaient. // Les master measures « N » sont annuelles et ne peuvent jamais
await pousserCheckpoint("expressions"); // constituer un repli fiable pour 12 mois glissants.
if (!monthDim || !useExpr) {
throw new Error(
"extraction 12 mois impossible : QLIK_MONTH_DIM et QLIK_USE_EXPR doivent être activés",
);
}
if (!(await moisParExpressions())) {
throw new Error(
"extraction 12 mois refusée : champ Date/expression invalide ou somme mensuelle différente du total Qlik",
);
}
await pousserCheckpoint("expressions vérifiées");
} 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.
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 + ")");
@@ -1190,6 +1273,12 @@ export async function fetchNetworkMetricsPlaywright(
result = (await evaluer()) as InPageResult; result = (await evaluer()) as InPageResult;
} catch (e) { } catch (e) {
const msg = String((e as Error)?.message || e); const msg = String((e as Error)?.message || e);
// Une fenêtre 12 mois n'est publiable que si l'extraction est
// complète et a passé tous les contrôles. Conserver un checkpoint
// partiel fabriquerait précisément des graphiques à 2 ou 3 mois.
if (dateFilter) {
throw new Error(`[qlik-pw] extraction 12 mois interrompue, cache inchangé : ${msg}`);
}
const secours = checkpoint as { rows?: Array<Array<string | number>>; monthly?: InPageResult["monthly"] } | null; const secours = checkpoint as { rows?: Array<Array<string | number>>; monthly?: InPageResult["monthly"] } | null;
if (secours?.rows?.length) { if (secours?.rows?.length) {
console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`); console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`);
@@ -1200,6 +1289,9 @@ export async function fetchNetworkMetricsPlaywright(
} }
if (!result.ok) { if (!result.ok) {
if (dateFilter) {
throw new Error(`[qlik-pw] extraction 12 mois refusée, cache inchangé : ${result.error}`);
}
// Même logique qu'un crash : un résultat partiel vaut mieux que rien. // Même logique qu'un crash : un résultat partiel vaut mieux que rien.
if (result.rows?.length) { if (result.rows?.length) {
console.error(`[qlik-pw] extraction en échec (${String(result.error).slice(0, 200)}) — ${result.rows.length} lignes déjà extraites conservées`); console.error(`[qlik-pw] extraction en échec (${String(result.error).slice(0, 200)}) — ${result.rows.length} lignes déjà extraites conservées`);
@@ -1209,6 +1301,9 @@ export async function fetchNetworkMetricsPlaywright(
} }
} }
if (result.partiel) { if (result.partiel) {
if (dateFilter) {
throw new Error("[qlik-pw] extraction 12 mois partielle refusée, cache inchangé");
}
console.warn("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); console.warn("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter");
} }
+2 -2
View File
@@ -37,7 +37,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 } from "@/lib/qlik-client"; import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
/** /**
* Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**. * Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**.
@@ -170,7 +170,7 @@ export async function searchQlikArticles(
// Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu.
const dateFilter = buildGridNetworkQlikDateFilter( const dateFilter = buildGridNetworkQlikDateFilter(
new Date(), new Date(),
envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT), QLIK_MONTHS_BACK_DEFAULT,
); );
const sess = await qlikNtlmSession(cfg); const sess = await qlikNtlmSession(cfg);
const [cookieName, ...rest] = sess.cookie.split("="); const [cookieName, ...rest] = sess.cookie.split("=");