mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
fix Qlik rolling 12-month integrity
This commit is contained in:
1 parent
db886cd8d9
commit
4ef0e9fdeb
8 files changed
+218
-97
No files matched your search
+34
-29
@@ -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,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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const cache = cacheReseau.get(m.codeCentrale);
|
||||||
|
const fiable = cacheReseauComplet(cache, qlik.periode);
|
||||||
|
return {
|
||||||
codeCentrale: m.codeCentrale,
|
codeCentrale: m.codeCentrale,
|
||||||
caReseau: m.caReseau,
|
caReseau: fiable ? cache.caReseau : m.caReseau,
|
||||||
qteReseau: m.qteReseau,
|
qteReseau: fiable ? cache.qteReseau : m.qteReseau,
|
||||||
nbMagasinsReseau: m.nbMagasinsReseau,
|
nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau,
|
||||||
caParMagasinReseau: m.caParMagasinReseau,
|
caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau,
|
||||||
margePctReseau: m.margePctReseau,
|
margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau,
|
||||||
periode: qlik.periode ?? undefined,
|
periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined,
|
||||||
|
qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined,
|
||||||
|
metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined,
|
||||||
libelleReseau: m.libelle || undefined,
|
libelleReseau: m.libelle || undefined,
|
||||||
fournisseurReseau: m.fournisseur || 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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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("=");
|
||||||
|
|||||||
Reference in new issue
Block a user