mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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 :cd2e77eselectionnerFenetreViaDimensionMois() — 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.4ef0e9fgarde-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.726fb84choisirPeriode() — 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 de4ef0e9f(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:
8 files changed
+389
-145
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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("=");
|
||||
|
||||
Reference in new issue
Block a user