merge: piloter Période + garde-fou d'intégrité + sélection par dimension Mois

Fusion de deux lignes de travail parallèles sur main, complémentaires et non
concurrentes :

  cd2e77e  selectionnerFenetreViaDimensionMois() — sélectionne les 12 mois par
           qElemNumber sur la dimension maître Mois. C'est ce qui marche : les
           champs Date exposés sont dissociés des faits (100 % du total avant
           comme après), la dimension maître porte les vraies valeurs.
  4ef0e9f  garde-fou d'intégrité — refuse d'écrire le cache si la fenêtre n'est
           pas entièrement couverte. C'est pourquoi le cache est resté inchangé
           au lieu d'être corrompu.
  726fb84  choisirPeriode() — le maillon manquant.

Le dump des expressions donne la cause de fond :

  « Quantité N »    = Sum({<Type_Cal={'N'}>} quantite)
  « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} quantite)

Un pont de périodes duplique les lignes de faits ; Type_Cal marque la période
analysée (N) ou de comparaison (COMP), et le champ Période (4 valeurs) décide
de quelle période il s'agit. Sélectionner correctement les mois ne suffit donc
pas : sous la période par défaut, « Quantité N » rend 0 sur tout mois hors
période courante, même parfaitement sélectionné.

Orchestration fusionnée :
  1. choisirPeriode() essaie chaque valeur de Période et MESURE, sur un cube
     [Mois] × Quantité N, combien de mois de la fenêtre elle rend disponibles.
     Aucun libellé n'est deviné.
  2. Si une période couvre les 12 mois → master measures (exactes sur toute la
     fenêtre). Sinon → agrégation directe des faits, en secours.
  3. Dans les deux cas, la couverture des 12 mois est revérifiée avant
     persistance ; sinon l'extraction est refusée et le cache laissé intact.

La tendance conserve la règle stricte de 4ef0e9f (les 12 clés explicitement
présentes, ou pas de tendance), qui écarte aussi le « +4100 % » calculé sur deux
points pour un article vendu depuis mai.

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:27:51 +00:00
commit 5f3760f03e
8 files changed
+389 -145

No files matched your search

+42 -29
View File
@@ -152,6 +152,14 @@ 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**.
Sur l'app de production, les champs `Date`, `Date calendrier` et `Date_Key`
existent mais leur sélection ne réduit pas `Sum(quantite)` : ils restent à
100 % du total historique. L'extracteur essaie d'abord ces champs puis utilise
la dimension maître `Mois` comme chemin fiable. Il crée un list object, retrouve
les 12 valeurs `YYYY-MM`, les sélectionne par `qElemNumber`, puis exécute le
cube mensuel. Cette méthode ne dépend ni du champ sous-jacent ni de son format
dual et conserve le chemin rapide (une sélection articles + un cube paginé).
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 :
@@ -161,8 +169,20 @@ master measure est juste :
```
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.
`ca_ttc` plutôt que `ca_ht`…). Il n'existe plus de repli automatique vers les
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
@@ -203,8 +223,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),
`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
de produire une fenêtre fausse.
Si aucun ne filtre, l'extraction échoue explicitement plutôt que de produire une
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. Avant cet échec, la dimension maître `Mois` est testée elle aussi.
## La passe de rattrapage a été supprimée
@@ -217,7 +239,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
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
12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin
@@ -227,22 +249,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
**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
« Quantité COMP » n'écrit rien quand elle est vide. Un mois **absent** peut
être rattrapé ; un mois **à 0** se lit comme « pas de vente » et masque le trou.
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.
`QLIK_MONTH_DIM=0` ou `QLIK_USE_EXPR=0` désactive désormais un prérequis et fait
échouer la synchronisation datée : ces options ne peuvent plus réactiver un
chemin connu comme incorrect.
⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik
pour les corriger.
@@ -263,21 +278,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
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.
Si la session meurt en cours de route, l'extraction repart de ce point au lieu de
tout perdre : les codes déjà traités ont des données **complètes** (la dimension
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.
Le script in-page conserve des points de contrôle à des fins de diagnostic, mais
une synchronisation datée interrompue est refusée intégralement. Le cache garde
sa dernière version complète jusqu'à la réussite d'une nouvelle extraction.
## Tendance réseau = 12 mois glissants stricts
`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).
Les mois de la fenêtre absents du cache valent 0 (produit non vendu) ; ceux
antérieurs au premier mois réellement extrait sont écartés au lieu d'être
inventés à 0 (extraction plus courte que 12 mois).
La tendance n'est affichée que si les 12 clés sont explicitement présentes dans
le cache. Les anciennes séries partielles de deux ou trois mois sont donc
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`,
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 {
buildGridNetworkQlikDateFilter,
envMonthsBack,
QLIK_MONTHS_BACK_DEFAULT,
QLIK_MONTHS_BACK_MAX,
QLIK_MONTHS_BACK_MIN,
} from "@/lib/qlik-date-range";
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
@@ -235,16 +232,14 @@ async function runJob(job: QlikSyncJob): Promise<void> {
return;
}
// Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via
// QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective
// pour audit.
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
// Contrat métier strict : toujours 12 mois complets glissants.
// Une variable d'environnement ne doit pas pouvoir raccourcir la série.
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
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);
job.fetched = metrics.size;
@@ -289,8 +284,7 @@ async function runProductJob(job: QlikSyncJob): Promise<void> {
const code = job.codeCentrale!;
job.requested = 1;
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
+15 -16
View File
@@ -60,14 +60,15 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
* présent dans le cache est donc **ignoré** — sinon la pente est faussée par un
* mois tronqué.
*
* Un mois **absent** du cache n'est PAS supposé nul : l'extraction ne couvre pas
* toujours les 12 mois (les mesures Qlik « N » ne savent pas remonter au-delà de
* l'année en cours). La sync écrit un zéro **explicite** sur chaque mois qu'elle
* 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.
* Une série n'est affichée que si les **12 clés sont explicitement présentes**.
* L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé
* la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle
* ne peut donc plus être présentée comme une tendance réelle.
*
* Sans cette distinction, un article vendu seulement depuis mai voyait sa
* tendance calculée sur deux points — « +4 100 % · Forte hausse ».
* Cette exigence du « tout ou rien » évite deux écueils observés en production :
* une tendance calculée sur deux points (« +4 100 % · Forte hausse » pour un
* article vendu depuis mai), et des mois non extraits comptés comme des mois
* sans vente.
*
* 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.
@@ -79,16 +80,14 @@ export function computeNetworkTrend(
): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
const presents = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (presents.length === 0) return empty;
const labels = buildRolling12QlikMonths(now);
const complet = labels.every((label) =>
Object.prototype.hasOwnProperty.call(qteByMonth, label) &&
Number.isFinite(Number(qteByMonth[label])),
);
if (!complet) return empty;
const fenetre = buildRolling12QlikMonths(now);
// Seuls les mois RÉELLEMENT extraits comptent : ceux que la sync n'a pas
// couverts sont absents des clés et sont écartés, plutôt que comptés à zéro.
const labels = fenetre.filter((m) => qteByMonth[m] != null);
if (labels.length === 0) return empty;
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const values = labels.map((l) => Number(qteByMonth[l]));
const n = values.length;
let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat";
+50 -23
View File
@@ -8,7 +8,7 @@ import {
type NetworkMetricCached,
} from "@/lib/qlik-network-cache";
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 type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
@@ -37,6 +37,20 @@ const CACHE_TTL_MS = 10 * 60 * 1000;
/** Durée de conservation d'un job terminé (le client a le temps de le relire). */
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 {
const parMois = cache?.qteByMonth;
if (!parMois || !periode || cache.periode !== periode) return false;
return buildRolling12QlikMonths(now).every((mois) =>
Object.prototype.hasOwnProperty.call(parMois, mois) &&
Number.isFinite(Number(parMois[mois])),
);
}
/**
* Nombre maximum de recherches Qlik simultanées.
*
@@ -271,21 +285,29 @@ async function executerRecherche(
localPromise,
]);
// On persiste les mesures fraîches : la fiche produit ouverte depuis un
// résultat les affiche aussitôt, sans re-extraction. Le détail mensuel n'est
// pas fourni ici et n'est donc pas écrasé (upsert en COALESCE).
// Le cube de recherche utilise encore les master measures annuelles pour
// classer rapidement les résultats. Il ne doit jamais écraser une extraction
// 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 {
const aPersister: NetworkMetric[] = qlik.matches.map((m) => ({
codeCentrale: m.codeCentrale,
caReseau: m.caReseau,
qteReseau: m.qteReseau,
nbMagasinsReseau: m.nbMagasinsReseau,
caParMagasinReseau: m.caParMagasinReseau,
margePctReseau: m.margePctReseau,
periode: qlik.periode ?? undefined,
libelleReseau: m.libelle || undefined,
fournisseurReseau: m.fournisseur || undefined,
}));
const aPersister: NetworkMetric[] = qlik.matches.map((m) => {
const cache = cacheReseau.get(m.codeCentrale);
const fiable = cacheReseauComplet(cache, qlik.periode);
return {
codeCentrale: m.codeCentrale,
caReseau: fiable ? cache.caReseau : m.caReseau,
qteReseau: fiable ? cache.qteReseau : m.qteReseau,
nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau,
caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau,
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);
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
} catch (e) {
@@ -295,7 +317,12 @@ async function executerRecherche(
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined);
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 {
codeCentrale: m.codeCentrale,
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
@@ -304,16 +331,16 @@ async function executerRecherche(
codein: local?.codein ?? null,
nomenclature: local?.nomenclature ?? "",
stockLocal: local ? Number(local.stock_total) || 0 : null,
qteReseau: m.qteReseau,
caReseau: m.caReseau,
qteReseau,
caReseau,
nbMagasinsReseau: nbMag,
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
qteParMagasinReseau: nbMag > 0 ? m.qteReseau / nbMag : 0,
caParMagasinReseau: m.caParMagasinReseau,
prixMoyenReseau: m.qteReseau > 0 ? m.caReseau / m.qteReseau : null,
margePctReseau: normalizeMargePct(m.margePctReseau),
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
caParMagasinReseau: caParMagasin,
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
margePctReseau: normalizeMargePct(margePct),
// 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,
};
});
+3 -5
View File
@@ -94,11 +94,9 @@ export interface NetworkMetric {
/**
* 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) —
* les conserver ne coûte aucune requête supplémentaire. Seule `qte` est
* garantie : les mois N-1 dérivés de « Quantité COMP » n'ont que la quantité
* 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).
* Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois).
* Lors d'une synchronisation datée validée, les 12 mois complets sont
* toujours présents ; un mois sans fait est explicitement représenté à 0.
*/
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)
const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1);
const dateDebut = startMonth.toISOString().slice(0, 10);
const dateFin = endMonth.toISOString().slice(0, 10);
// Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris
// 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 qEnd = isoDateToQlikSerial(dateFin);
+264 -57
View File
@@ -125,11 +125,12 @@ export async function fetchNetworkMetricsPlaywright(
// 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.
// 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());
// Agrégation directe des faits : chemin de SECOURS, utilisé quand aucune valeur
// de `Période` ne couvre les 12 mois. Activé par défaut, mais second choix :
// `Sum(quantite)` ignore `Type_Cal` et additionne les lignes de la période
// courante ET de la période de comparaison, alors que les master measures
// portent le bon filtre dès que `Période` est bien calé (cf. `choisirPeriode`).
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();
@@ -612,14 +613,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;
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; }
if (typeof h !== "number" || h < 0) return null;
} catch { return null; }
await rpc("Clear", {}, h);
await sleep(qlikSettleMs);
for (let i = 0; i < valeurs.length; i += 500) {
@@ -630,7 +639,102 @@ export async function fetchNetworkMetricsPlaywright(
}, h);
}
await sleep(qlikSettleMs);
return true;
return h;
};
/**
* Sélectionne les 12 mois via la dimension maître « Mois ».
*
* Dans l'app Magasins Vision Consolidée, les champs Date exposés
* existent mais sont dissociés des faits de vente : leur sélection
* laisse Sum(quantite) à 100 %. La dimension maître utilisée par le
* cube, elle, porte les vraies valeurs associées. Un list object
* permet de sélectionner ses qElemNumber sans connaître le nom du
* champ sous-jacent ni le format dual exact.
*/
const selectionnerFenetreViaDimensionMois = async (
moisAttendus: Set<string>,
): Promise<{ total: number; trouves: string[] } | null> => {
let objet: { handle: number; id: string } | null = null;
try {
const cree = await rpcWithRetry("createMonthList", "CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-month-list" },
qListObjectDef: {
qLibraryId: moisDimId,
qShowAlternatives: true,
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 100 }],
},
},
}, doc);
const ret = cree.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
objet = {
handle: ret.qHandle,
id: String(ret.qGenericId ?? ret.qId ?? ""),
};
const layout = await rpcWithRetry("layoutMonthList", "GetLayout", {}, objet.handle);
const lo = (layout.qLayout as {
qListObject?: {
qSize?: { qcy?: number };
qDataPages?: Array<{
qArea?: { qTop?: number };
qMatrix?: Array<Array<{ qText?: string; qElemNumber?: number }>>;
}>;
};
}).qListObject;
const size = Number(lo?.qSize?.qcy) || 0;
const cellules: Array<{ qText?: string; qElemNumber?: number }> = [];
for (const page of lo?.qDataPages ?? []) {
for (const row of page.qMatrix ?? []) if (row[0]) cellules.push(row[0]);
}
for (let top = cellules.length; top < size; top += 500) {
const data = await rpcWithRetry("monthListData", "GetListObjectData", {
qPath: "/qListObjectDef",
qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(500, size - top) }],
}, objet.handle);
const matrix = (data.qDataPages as Array<{
qMatrix?: Array<Array<{ qText?: string; qElemNumber?: number }>>;
}>)?.[0]?.qMatrix ?? [];
for (const row of matrix) if (row[0]) cellules.push(row[0]);
if (matrix.length === 0) break;
}
const parMois = new Map<string, number>();
for (const cellule of cellules) {
const mois = normMois(String(cellule.qText ?? "").trim());
const elem = Number(cellule.qElemNumber);
if (moisAttendus.has(mois) && Number.isInteger(elem) && elem >= 0) {
parMois.set(mois, elem);
}
}
const trouves = [...parMois.keys()].sort();
console.log(
"[qlik-pw][expr] dimension Mois : " + trouves.length + "/" + moisAttendus.size +
" valeurs trouvées " + JSON.stringify(trouves),
);
if (trouves.length !== moisAttendus.size) return null;
const selection = await rpcWithRetry("selectMonthList", "SelectListObjectValues", {
qPath: "/qListObjectDef",
qValues: [...parMois.values()],
qToggleMode: false,
qSoftLock: true,
}, objet.handle);
console.log("[qlik-pw][expr] sélection dimension Mois → " + JSON.stringify(selection.qReturn));
await sleep(qlikSettleMs);
const total = await totalDeControle();
return { total, trouves };
} catch (e) {
console.log("[qlik-pw][expr] sélection dimension Mois impossible : " + String((e as Error)?.message || e));
return null;
} finally {
if (objet?.id) {
try {
await rpc("DestroySessionObject", { qId: objet.id }, doc);
} catch { /* objet limité à la session */ }
}
}
};
/**
@@ -664,7 +768,7 @@ export async function fetchNetworkMetricsPlaywright(
* meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée
* qui décide.
*/
const choisirPeriode = async (): Promise<string | null> => {
const choisirPeriode = async (): Promise<number> => {
const fenetre = new Set(monthlyPayload.map((m) => m.label));
let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null;
try {
@@ -683,7 +787,7 @@ export async function fetchNetworkMetricsPlaywright(
.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;
if (valeurs.length === 0) return 0;
let meilleure: { texte: string; elem: number; couverts: string[] } | null = null;
for (const v of valeurs) {
@@ -727,7 +831,7 @@ export async function fetchNetworkMetricsPlaywright(
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;
return 0;
}
await rpc("SelectListObjectValues", {
qPath: "/qListObjectDef",
@@ -743,10 +847,10 @@ export async function fetchNetworkMetricsPlaywright(
"Aucune valeur de « Période » ne porte 12 mois glissants dans cette app.",
);
}
return meilleure.texte;
return meilleure.couverts.length;
} catch (e) {
diag("choix de « Période » impossible : " + String((e as Error)?.message || e));
return null;
return 0;
} finally {
// Le list object est conservé jusqu'au bout : le détruire
// annulerait la sélection qu'il porte.
@@ -765,11 +869,11 @@ export async function fetchNetworkMetricsPlaywright(
if (codes.length && fh !== -1) {
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
await sleep(qlikSettleMs);
await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", {
await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", {
qFieldValues: codes.map((c) => ({ qText: c })),
qToggleMode: false,
qSoftLock: true,
}, fh), timings);
}, fh, () => { aggCode15Retries++; }), timings);
await sleep(qlikSettleMs);
}
@@ -788,23 +892,46 @@ export async function fetchNetworkMetricsPlaywright(
const allSerials = monthlyPayload.flatMap((m) => m.serials);
const allYmd = monthlyPayload.flatMap((m) => m.ymd);
const moisFenetre = new Set(monthlyPayload.map((m) => m.label));
let champDate: string | null = null;
let totalFenetre = 0;
for (const cand of champsDateCandidats) {
const valeurs = /key/i.test(cand) ? allYmd : allSerials;
if (!(await selectionnerFenetreSur(cand, valeurs))) {
const handleCand = await selectionnerFenetreSur(cand, valeurs);
if (handleCand == null) {
diag(" champ « " + cand + " » : inexistant");
continue;
}
const filtre = await totalDeControle();
const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0;
diag(" 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;
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) {
// Les champs date exposés sont dissociés des faits : la
// dimension maître « Mois », elle, porte les vraies valeurs.
const selectionMois = await selectionnerFenetreViaDimensionMois(moisFenetre);
const ratio = selectionMois && sansFiltre > 0 ? selectionMois.total / sansFiltre : 0;
if (selectionMois) {
diag(" dimension « Mois » → " + Math.round(selectionMois.total) + " (" + Math.round(ratio * 100) + "% du total sans filtre)");
}
if (selectionMois && selectionMois.total > 0 && ratio < 0.995) {
champDate = "dimension Mois";
totalFenetre = selectionMois.total;
}
}
if (!champDate) {
diag(
"ÉCHEC : aucun champ date ne filtre les faits (essayés : " +
JSON.stringify(champsDateCandidats) + "). 0% = la sélection ne matche rien, " +
"100% = elle n'a aucun effet. Forcer le bon champ avec QLIK_DATE_FIELD.",
"ÉCHEC : ni les champs date ni la dimension Mois ne filtrent les faits " +
"(champs essayés : " + JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois.",
);
return false;
}
@@ -812,7 +939,6 @@ export async function fetchNetworkMetricsPlaywright(
// 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;
@@ -842,10 +968,19 @@ export async function fetchNetworkMetricsPlaywright(
// 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] ??= {});
// 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] = {
qte, ca, nbMag,
qte: qteCumulee,
ca: caCumule,
nbMag: Math.max(precedent?.nbMag ?? 0, nbMag),
caMag: nbMag > 0 ? ca / nbMag : 0,
margePct: ca > 0 ? marge / ca : 0,
margePct: caCumule > 0 ? margeMontant / caCumule : 0,
};
}, timings), timings);
@@ -867,6 +1002,51 @@ export async function fetchNetworkMetricsPlaywright(
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
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) {
console.warn(
"[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte +
@@ -1123,42 +1303,57 @@ 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) {
// ÉTAPE 1 — caler le champ `Période` du modèle sur la fenêtre.
//
// Toutes les mesures de l'app sont bornées par `Type_Cal`
// (`Sum({<Type_Cal={'N'}>} quantite)`), lui-même piloté par
// `Période`. C'est cette sélection — et elle seule — qui détermine
// quels mois sont atteignables. Sous la période par défaut,
// « Quantité N » ne couvre que l'année en cours : d'où les mois
// d'août à décembre vides quelle que soit la sélection de dates.
const couvertureMaster = await choisirPeriode();
if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) {
// Chemin alternatif : agrégation directe des faits. Conservé
// derrière QLIK_USE_EXPR mais désormais secondaire — `Sum(quantite)`
// mélange les lignes N et COMP du pont de périodes et double compte.
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 + ")");
await monthDimPath(null);
await pousserCheckpoint("passe N");
// Passe complémentaire : mois de l'année N-1 absents de l'année N
// (ex. août→déc), impossibles à obtenir via COMP. On sélectionne
// l'année N-1 pour que les mesures "N" portent sur cette année.
if (yfh !== -1) {
const before = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0);
try {
await monthDimPath(yearN - 1);
const after = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0);
console.log("[qlik-pw] (mois) passe N-1 terminée : " + before + " → " + after + " points mensuels");
} catch (e2) {
console.log("[qlik-pw] (mois) passe N-1 ignorée: " + String((e2 as Error)?.message || e2));
// ÉTAPE 2 — extraire, puis REFUSER tout résultat incomplet.
//
// Un cache partiel est pire que pas de cache : il se lit comme des
// ventes nulles. Les deux chemins sont donc validés sur le même
// critère — les 12 mois de la fenêtre, ou rien.
const attendus = monthlyPayload.map((m) => m.label);
const couvre12 = () => {
const vus = new Set<string>();
for (const parMois of Object.values(monthlyByCode)) {
for (const [mois, v] of Object.entries(parMois)) {
if ((v?.qte ?? 0) > 0) vus.add(mois);
}
}
try { await rpc("Clear", {}, yfh); } catch { /* noop */ }
await pousserCheckpoint("passe N-1");
return attendus.every((m) => vus.has(m));
};
if (couvertureMaster >= attendus.length && monthDim) {
// Une valeur de `Période` couvre les 12 mois : les master
// measures sont alors exactes sur toute la fenêtre.
diag("extraction par master measures (Période couvrant " + couvertureMaster + " mois)");
await monthDimPath(null);
await pousserCheckpoint("master measures");
} else if (monthDim && useExpr && (await moisParExpressions())) {
// Aucune période ne couvre 12 mois : on retombe sur
// l'agrégation directe des faits.
await pousserCheckpoint("expressions vérifiées");
} else {
console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1");
throw new Error(
"extraction 12 mois refusée : aucune valeur de « Période » ne couvre la fenêtre " +
"(meilleure = " + couvertureMaster + "/" + attendus.length + " mois) et l'agrégation " +
"directe des faits n'a pas validé ses contrôles",
);
}
if (!couvre12()) {
throw new Error(
"extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) +
" n'est pas entièrement couverte — cache laissé inchangé",
);
}
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.
@@ -1326,6 +1521,12 @@ export async function fetchNetworkMetricsPlaywright(
result = (await evaluer()) as InPageResult;
} catch (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;
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`);
@@ -1336,6 +1537,9 @@ export async function fetchNetworkMetricsPlaywright(
}
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.
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`);
@@ -1345,6 +1549,9 @@ export async function fetchNetworkMetricsPlaywright(
}
}
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");
}
+2 -2
View File
@@ -37,7 +37,7 @@
import "server-only";
import { chromium, type Browser } from "playwright-core";
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**.
@@ -170,7 +170,7 @@ export async function searchQlikArticles(
// Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu.
const dateFilter = buildGridNetworkQlikDateFilter(
new Date(),
envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT),
QLIK_MONTHS_BACK_DEFAULT,
);
const sess = await qlikNtlmSession(cfg);
const [cookieName, ...rest] = sess.cookie.split("=");