diff --git a/server/Cargo.lock b/server/Cargo.lock index e2a50a6..5a45699 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -395,15 +395,23 @@ dependencies = [ "base64 0.22.1", "chromiumoxide", "chrono", + "des", "futures", "hex", "hmac", + "http-body-util", + "hyper", + "hyper-util", + "indexmap", + "md-5", + "md4", "parking_lot", "rand 0.8.8", "regex", "reqwest 0.12.28", "rust_decimal", "rust_xlsxwriter", + "rustls", "scrypt", "serde", "serde_json", @@ -412,12 +420,14 @@ dependencies = [ "subtle", "thiserror", "tokio", + "tokio-rustls", "tower", "tower-http", "tracing", "tracing-subscriber", "urlencoding", "uuid", + "webpki-roots 1.0.9", ] [[package]] @@ -568,6 +578,15 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -1224,6 +1243,8 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -1359,6 +1380,15 @@ dependencies = [ "digest", ] +[[package]] +name = "md4" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" +dependencies = [ + "digest", +] + [[package]] name = "memchr" version = "2.8.3" diff --git a/server/Cargo.toml b/server/Cargo.toml index 314381d..dacd3d2 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -12,15 +12,23 @@ axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } base64 = "0.22" chromiumoxide = "0.9.1" chrono = { version = "0.4", features = ["serde"] } +des = "0.8" futures = "0.3" hex = "0.4" hmac = "0.12" +http-body-util = "0.1" +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +indexmap = { version = "2", features = ["serde"] } +md-5 = "0.10" +md4 = "0.10" parking_lot = "0.12" rand = "0.8" regex = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "cookies", "stream", "http2"] } rust_decimal = "1" rust_xlsxwriter = "0.99" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } scrypt = { version = "0.11", default-features = false, features = ["std"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } @@ -29,12 +37,14 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", subtle = "2" thiserror = "2" tokio = { version = "1", features = ["full"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } tower = "0.5" tower-http = { version = "0.6", features = ["fs", "compression-gzip", "trace", "set-header"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } +webpki-roots = "1" [profile.release] opt-level = 3 diff --git a/server/src/qlik/browser.rs b/server/src/qlik/browser.rs new file mode 100644 index 0000000..bd6bcb4 --- /dev/null +++ b/server/src/qlik/browser.rs @@ -0,0 +1,356 @@ +//! Chromium et file d'attente partagés par tous les accès Qlik +//! (port de `src/lib/qlik-browser.ts`) — extraction réseau (`playwright.rs`), +//! recherche produit (`search.rs`). +//! +//! Le pilotage passe par le protocole DevTools (chromiumoxide) ; [`PageQlik`] +//! regroupe l'équivalent des fonctions Playwright utilisées par le TS : +//! contexte isolé (`browser.newContext`), `ctx.addCookies`, `httpCredentials`, +//! `page.on("console" | "pageerror" | "websocket")`, `page.exposeFunction`, +//! `page.goto(…, domcontentloaded)` et `page.evaluate(fn, args)`. + +use anyhow::{anyhow, Context}; +use chromiumoxide::browser::BrowserConfig; +use chromiumoxide::cdp::browser_protocol::browser::BrowserContextId; +use chromiumoxide::cdp::browser_protocol::network::{CookieParam, EventWebSocketCreated, SetCookiesParams}; +use chromiumoxide::cdp::browser_protocol::page::{AddScriptToEvaluateOnNewDocumentParams, EventDomContentEventFired, NavigateParams}; +use chromiumoxide::cdp::browser_protocol::target::{CreateBrowserContextParams, CreateTargetParams}; +use chromiumoxide::cdp::js_protocol::runtime::{ + AddBindingParams, EvaluateParams, EventBindingCalled, EventConsoleApiCalled, EventExceptionThrown, RemoteObject, +}; +use chromiumoxide::{Browser, Page}; +use futures::StreamExt; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, LazyLock}; +use std::time::Duration; +use tokio::sync::{mpsc, watch, Mutex}; +use tokio::task::JoinHandle; + +// --------------------------------------------------------------------------- +// Chromium partagé +// --------------------------------------------------------------------------- + +struct Navigateur { + browser: Arc, + connecte: Arc, +} + +static NAVIGATEUR: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +static LANCEMENTS: AtomicUsize = AtomicUsize::new(0); + +/// Recherche un exécutable dans le PATH. +fn dans_le_path(nom: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).map(|d| d.join(nom)).find(|p| p.is_file()) +} + +/// Exécutable Chromium : `PLAYWRIGHT_CHROMIUM_PATH`, puis `CHROMIUM_PATH`, puis +/// `chromium` / `chromium-browser` dans le PATH ; à défaut, détection de chromiumoxide. +pub fn chemin_chromium() -> Option { + for var in ["PLAYWRIGHT_CHROMIUM_PATH", "CHROMIUM_PATH"] { + if let Some(p) = std::env::var(var).ok().filter(|v| !v.trim().is_empty()) { + return Some(PathBuf::from(p)); + } + } + ["chromium", "chromium-browser"].into_iter().find_map(dans_le_path) +} + +async fn lancer() -> anyhow::Result { + let n = LANCEMENTS.fetch_add(1, Ordering::SeqCst); + // Profil propre à chaque lancement : un Chromium tombé laisse son verrou + // (SingletonLock) dans le profil et empêcherait la relance. + let profil = std::env::temp_dir().join(format!("collectflow-chromium-{}-{n}", std::process::id())); + let mut builder = BrowserConfig::builder() + .no_sandbox() + .arg("disable-dev-shm-usage") + .arg("ignore-certificate-errors") + .user_data_dir(&profil) + .launch_timeout(Duration::from_secs(60)) + // Une extraction complète tient dans UN `page.evaluate` (plusieurs + // minutes, parfois plus de 15) : pas de délai par commande CDP, comme + // Playwright. Les étapes bornées le sont explicitement (tokio::time::timeout). + .request_timeout(Duration::from_secs(24 * 3600)); + if let Some(chemin) = chemin_chromium() { + builder = builder.chrome_executable(chemin); + } + let config = builder.build().map_err(|e| anyhow!("[qlik] configuration Chromium : {e}"))?; + let (browser, mut handler) = Browser::launch(config).await.context("[qlik] lancement de Chromium")?; + let connecte = Arc::new(AtomicBool::new(true)); + let drapeau = connecte.clone(); + tokio::spawn(async move { + // Le gestionnaire CDP doit être consommé en continu ; sa fin = navigateur + // déconnecté (plantage, mémoire, arrêt du processus enfant). + while let Some(evt) = handler.next().await { + if let Err(e) = evt { + tracing::debug!("[qlik] événement CDP ignoré : {e}"); + } + } + drapeau.store(false, Ordering::SeqCst); + tracing::warn!("[qlik] Chromium déconnecté — il sera relancé à la prochaine extraction"); + let _ = std::fs::remove_dir_all(&profil); + }); + Ok(Navigateur { browser: Arc::new(browser), connecte }) +} + +/// Chromium partagé entre les extractions. +/// +/// Un navigateur tombé (plantage, mémoire, arrêt du processus enfant) restait +/// mémorisé : toutes les extractions suivantes échouaient sur « Target closed » +/// jusqu'au redémarrage du serveur. On le relance donc dès qu'il est déconnecté. +pub async fn get_qlik_browser() -> anyhow::Result> { + let mut garde = NAVIGATEUR.lock().await; + if let Some(n) = garde.as_ref() { + if n.connecte.load(Ordering::SeqCst) { + return Ok(n.browser.clone()); + } + } + if let Some(ancien) = garde.take() { + if let Ok(mut b) = Arc::try_unwrap(ancien.browser) { + let _ = b.kill().await; + } + } + let n = lancer().await?; + let b = n.browser.clone(); + *garde = Some(n); + Ok(b) +} + +// --------------------------------------------------------------------------- +// File unique des extractions +// --------------------------------------------------------------------------- + +/// Le serveur Qlik abandonne les requêtes dès qu'il est trop sollicité. Une +/// synchro manuelle lancée pendant la synchro de nuit, ou deux recherches +/// produit simultanées, ouvraient deux sessions Engine en parallèle — et les +/// deux échouaient. Les extractions passent désormais l'une après l'autre +/// (le verrou tokio est équitable : premier arrivé, premier servi). +static FILE_EXTRACTIONS: Mutex<()> = Mutex::const_new(()); +static EXTRACTIONS_EN_ATTENTE: AtomicUsize = AtomicUsize::new(0); + +struct DecompteFile; + +impl Drop for DecompteFile { + fn drop(&mut self) { + EXTRACTIONS_EN_ATTENTE.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Exécute `tache` quand toutes les extractions Qlik précédentes sont terminées. +pub async fn dans_la_file_qlik(libelle: &str, tache: impl FnOnce() -> F) -> T +where + F: std::future::Future, +{ + let en_attente = EXTRACTIONS_EN_ATTENTE.fetch_add(1, Ordering::SeqCst); + let _decompte = DecompteFile; + if en_attente > 0 { + tracing::info!("[qlik] {libelle} : en attente de {en_attente} extraction(s) en cours"); + } + let _tour = FILE_EXTRACTIONS.lock().await; + tache().await +} + +// --------------------------------------------------------------------------- +// Page Qlik (contexte isolé + équivalents Playwright) +// --------------------------------------------------------------------------- + +/// Filtre/format d'une ligne de console de la page : `None` = ignorée. +pub type FiltreConsole = Box Option + Send + Sync>; + +/// Page Chromium dans un contexte de navigation isolé (≈ `browser.newContext()`). +pub struct PageQlik { + browser: Arc, + contexte: Option, + pub page: Page, + taches: Vec>, +} + +/// Texte d'un argument de console, comme `msg.text()` de Playwright. +fn texte_remote(o: &RemoteObject) -> String { + match &o.value { + Some(Value::String(s)) => s.clone(), + Some(v) if !v.is_null() => v.to_string(), + _ => o + .unserializable_value + .as_ref() + .map(|u| u.inner().to_string()) + .or_else(|| o.description.clone()) + .unwrap_or_else(|| format!("JSHandle@{:?}", o.r#type).to_lowercase()), + } +} + +impl PageQlik { + /// Ouvre une page vierge dans un nouveau contexte isolé et branche la + /// console (`filtre_console`) et les erreurs de page (`prefixe_erreur`). + pub async fn ouvrir(browser: Arc, filtre_console: FiltreConsole, prefixe_erreur: Option<&'static str>) -> anyhow::Result { + let contexte = browser.create_browser_context(CreateBrowserContextParams::default()).await.context("[qlik] création du contexte Chromium")?; + let params = CreateTargetParams::builder() + .url("about:blank") + .browser_context_id(contexte.clone()) + .build() + .map_err(|e| anyhow!("[qlik] paramètres de page : {e}"))?; + let page = match tokio::time::timeout(Duration::from_secs(60), browser.new_page(params)).await { + Ok(Ok(p)) => p, + Ok(Err(e)) => { + let _ = browser.dispose_browser_context(contexte).await; + return Err(anyhow!("[qlik] ouverture de page : {e}")); + } + Err(_) => { + let _ = browser.dispose_browser_context(contexte).await; + return Err(anyhow!("[qlik] ouverture de page : délai dépassé")); + } + }; + let mut moi = Self { browser, contexte: Some(contexte), page, taches: Vec::new() }; + + let mut console = moi.page.event_listener::().await?; + moi.taches.push(tokio::spawn(async move { + while let Some(evt) = console.next().await { + let texte = evt.args.iter().map(texte_remote).collect::>().join(" "); + if let Some(ligne) = filtre_console(&texte) { + tracing::info!("{ligne}"); + } + } + })); + if let Some(prefixe) = prefixe_erreur { + let mut erreurs = moi.page.event_listener::().await?; + moi.taches.push(tokio::spawn(async move { + while let Some(evt) = erreurs.next().await { + let d = &evt.exception_details; + let message = d.exception.as_ref().and_then(|e| e.description.clone()).unwrap_or_else(|| d.text.clone()); + tracing::info!("{prefixe} {message}"); + } + })); + } + Ok(moi) + } + + /// `ctx.addCookies([{ name, value, domain, path: "/", secure: true }])`. + pub async fn ajouter_cookie(&self, nom: &str, valeur: &str, domaine: &str) -> anyhow::Result<()> { + let cookie = CookieParam::builder() + .name(nom) + .value(valeur) + .domain(domaine) + .path("/") + .secure(true) + .build() + .map_err(|e| anyhow!("[qlik] cookie invalide : {e}"))?; + self.page.execute(SetCookiesParams::new(vec![cookie])).await.context("[qlik] Network.setCookies")?; + Ok(()) + } + + /// `httpCredentials` : identifiants fournis aux défis HTTP (NTLM / Basic) + /// que le navigateur rencontre. + pub async fn identifiants_http(&self, user: &str, password: &str) -> anyhow::Result<()> { + self.page.authenticate(chromiumoxide::auth::Credentials { username: user.to_string(), password: password.to_string() }).await?; + Ok(()) + } + + /// Surveille les websockets ouverts par la page (`page.on("websocket")`) et + /// publie la première valeur de `qlik-csrf-token` trouvée dans leur URL. + pub async fn capturer_jeton_csrf(&mut self) -> anyhow::Result>> { + let (tx, rx) = watch::channel(None); + let mut ws = self.page.event_listener::().await?; + let re = regex::Regex::new(r"qlik-csrf-token=([^&]+)").expect("regex"); + self.taches.push(tokio::spawn(async move { + while let Some(evt) = ws.next().await { + if tx.borrow().is_some() { + continue; + } + if let Some(c) = re.captures(&evt.url) { + let jeton = urlencoding::decode(&c[1]).map(|s| s.into_owned()).unwrap_or_else(|_| c[1].to_string()); + let _ = tx.send(Some(jeton)); + } + } + })); + Ok(rx) + } + + /// `page.exposeFunction(nom, …)` : `window[nom](payload)` transmet + /// `JSON.stringify(payload)` côté Rust (canal renvoyé) et se résout à `true`. + pub async fn exposer_fonction(&mut self, nom: &str) -> anyhow::Result> { + let liaison = format!("__cf_binding_{nom}"); + let (tx, rx) = mpsc::unbounded_channel(); + let mut appels = self.page.event_listener::().await?; + let attendu = liaison.clone(); + self.taches.push(tokio::spawn(async move { + while let Some(evt) = appels.next().await { + if evt.name == attendu { + let _ = tx.send(evt.payload.clone()); + } + } + })); + self.page.execute(AddBindingParams::new(liaison.clone())).await.context("[qlik] Runtime.addBinding")?; + let nom_js = serde_json::to_string(nom)?; + let liaison_js = serde_json::to_string(&liaison)?; + let shim = format!( + "(() => {{ const n = {nom_js}, b = {liaison_js}; window[n] = (p) => {{ const f = window[b]; \ + if (typeof f !== 'function') return Promise.reject(new Error('liaison ' + b + ' absente')); \ + f(JSON.stringify(p === undefined ? null : p)); return Promise.resolve(true); }}; }})()" + ); + // Documents futurs (navigations) puis document courant. + self.page.execute(AddScriptToEvaluateOnNewDocumentParams::new(shim.clone())).await?; + let _ = self.page.execute(EvaluateParams::new(shim)).await; + Ok(rx) + } + + /// `page.goto(url, { waitUntil: "domcontentloaded", timeout })`. + pub async fn aller(&self, url: &str, timeout: Duration) -> anyhow::Result<()> { + let mut dcl = self.page.event_listener::().await?; + let navigation = async { + let res = self.page.execute(NavigateParams::new(url)).await.context("[qlik] Page.navigate")?; + if let Some(err) = res.result.error_text.as_ref().filter(|e| !e.is_empty()) { + return Err(anyhow!("[qlik] navigation {url} : {err}")); + } + dcl.next().await; + Ok(()) + }; + tokio::time::timeout(timeout, navigation) + .await + .map_err(|_| anyhow!("[qlik] navigation {url} : délai de {} ms dépassé", timeout.as_millis()))? + } + + /// `page.evaluate(fn, args)` : `source` est le texte d'une fonction JS + /// (éventuellement async), appelée avec `args` ; la promesse est attendue et + /// le résultat rendu en JSON (`JSON.stringify` côté page, donc NaN → null). + pub async fn evaluer(&self, source: &str, args: &Value) -> anyhow::Result { + let expression = format!( + "(async () => {{ const __f = (\n{source}\n); const __r = await __f({}); return JSON.stringify(__r === undefined ? null : __r); }})()", + serde_json::to_string(args)? + ); + let params = EvaluateParams::builder() + .expression(expression) + .await_promise(true) + .return_by_value(true) + .build() + .map_err(|e| anyhow!("[qlik] paramètres d'évaluation : {e}"))?; + let res = self.page.execute(params).await.map_err(|e| anyhow!("{e}"))?; + if let Some(ex) = res.result.exception_details.as_ref() { + let message = ex.exception.as_ref().and_then(|e| e.description.clone()).unwrap_or_else(|| ex.text.clone()); + return Err(anyhow!("{message}")); + } + match &res.result.result.value { + Some(Value::String(s)) => Ok(serde_json::from_str(s)?), + Some(v) => Ok(v.clone()), + None => Ok(Value::Null), + } + } + + /// Ferme la page puis le contexte (`ctx.close()`). + pub async fn fermer(mut self) { + for t in self.taches.drain(..) { + t.abort(); + } + let _ = tokio::time::timeout(Duration::from_secs(10), self.page.clone().close()).await; + if let Some(c) = self.contexte.take() { + let _ = tokio::time::timeout(Duration::from_secs(10), self.browser.dispose_browser_context(c)).await; + } + } +} + +impl Drop for PageQlik { + fn drop(&mut self) { + for t in self.taches.drain(..) { + t.abort(); + } + } +} diff --git a/server/src/qlik/client.rs b/server/src/qlik/client.rs new file mode 100644 index 0000000..ff391eb --- /dev/null +++ b/server/src/qlik/client.rs @@ -0,0 +1,503 @@ +//! Client Qlik Sense — configuration, types partagés et authentification +//! (port de `src/lib/qlik-client.ts`). +//! +//! Récupère par produit (clé = code centrale via la dimension "Article Code") : +//! - CA réseau, Qté vendue réseau, Nb magasins travaillant le produit. +//! +//! Auth = NTLM (Qlik Sense Enterprise on Windows) via flux ticket SSO : +//! 1. GET /hub/ (non authentifié) -> 302 vers le proxy forms, on extrait le targetId +//! 2. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX +//! 3. GET /hub/?qlikTicket=XXX -> Qlik pose le cookie X-Qlik-Session +//! Le cookie + Xrfkey servent ensuite pour QRS (REST) et l'Engine API (websocket). +//! +//! L'extraction passe par Chromium (`playwright.rs`) : le proxy Qlik refuse un +//! websocket « raw » côté serveur (403). Le `fetchNetworkMetrics` par websocket +//! direct du TS n'était appelé nulle part (la Grille a sa propre fonction +//! homonyme, qui lit le cache) : il n'est pas porté. +//! +//! Tout est paramétrable par variables d'environnement (défauts = discovery 2026-06-20). + +use super::jsnum; +use super::ntlm; +use anyhow::{anyhow, bail, Context}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Config — défauts issus de la discovery +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikConfig { + pub host: String, + pub user: String, + #[serde(skip_serializing)] + pub password: String, + /// Vide par défaut (NTLM sans domaine, comme requests_ntlm). + pub domain: String, + pub workstation: String, + /// GUID app "Magasins Vision Consolidée" (article x réseau). + pub app_network: String, + /// Master dimension "Article Code". + pub dim_code_article_id: String, + /// Master measure "CA N". + pub meas_ca_id: String, + /// Master measure "Quantité N". + pub meas_qte_id: String, + /// Master measure "Magasin Ventes Nb N". + pub meas_nb_mag_id: String, + /// Master measure "CA par Magasin N". + pub meas_ca_mag_id: String, + /// Master measure "Marge % N". + pub meas_marge_pct_id: String, + pub tls_insecure: bool, + pub timeout_ms: u64, +} + +/// `process.env.X || défaut` (une valeur vide compte comme absente). +fn env_ou(nom: &str) -> Option { + std::env::var(nom).ok().filter(|v| !v.is_empty()) +} + +/// `process.env.X ?? défaut` (une valeur vide est conservée). +fn env_nul(nom: &str, defaut: &str) -> String { + std::env::var(nom).unwrap_or_else(|_| defaut.to_string()) +} + +/// Priorité : réglages UI (`data/.db-config.json`) > env > défaut. +pub fn get_qlik_config() -> QlikConfig { + let f = crate::config::read_config(); + let fichier = |v: Option| v.filter(|s| !s.is_empty()); + let timeout = super::date_range::js_number(&env_nul("QLIK_TIMEOUT_MS", "60000")); + QlikConfig { + host: fichier(f.qlik_host).or_else(|| env_ou("QLIK_HOST")).unwrap_or_else(|| "reporting-magasins.lafoirfouille.fr".into()), + user: fichier(f.qlik_user).or_else(|| env_ou("QLIK_USER")).unwrap_or_else(|| "FFSCH".into()), + password: fichier(f.qlik_password).or_else(|| env_ou("QLIK_PWD")).unwrap_or_default(), + domain: env_nul("QLIK_DOMAIN", ""), + workstation: env_nul("QLIK_WORKSTATION", ""), + app_network: env_nul("QLIK_APP_NETWORK", "9872ee6e-d64a-4b43-984a-076bf1f7f647"), + // qIds engine (master items de l'app "Magasins Vision Consolidée") + dim_code_article_id: env_nul("QLIK_DIM_CODE_ARTICLE_ID", "yesCP"), + meas_ca_id: env_nul("QLIK_MEAS_CA_ID", "JhqJ"), + meas_qte_id: env_nul("QLIK_MEAS_QTE_ID", "41516861-5997-4635-8187-3643a2bde422"), + meas_nb_mag_id: env_nul("QLIK_MEAS_NBMAG_ID", "yNBLjc"), + // Master GUIDs de l'app "Magasins Vision Consolidée" (9872ee6e). Si l'engine + // les refuse (qId court attendu), override via env après discovery. + meas_ca_mag_id: env_nul("QLIK_MEAS_CAMAG_ID", "16778c00-a4e0-41c7-ab1c-179810d78172"), + meas_marge_pct_id: env_nul("QLIK_MEAS_MARGEPCT_ID", "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222"), + tls_insecure: env_nul("QLIK_TLS_INSECURE", "true") == "true", + // `Number(...)` invalide donnait NaN côté TS : on retombe sur le défaut. + timeout_ms: if timeout.is_finite() && timeout > 0.0 { timeout as u64 } else { 60_000 }, + } +} + +// --------------------------------------------------------------------------- +// Types partagés +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkMetric { + pub code_centrale: String, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub qte_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub nb_magasins_reseau: f64, + /// CA moyen par magasin (productivité normalisée présence) + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_par_magasin_reseau: f64, + /// Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub marge_pct_reseau: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub periode: Option, + /// Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_map", deserialize_with = "jsnum::de_map")] + pub qte_by_month: Option>, + /// 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). + /// 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. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics_by_month: Option>, + /// Libellé de l'article côté Qlik. Renseigné uniquement par la recherche + /// produit, qui interroge la dimension libellé ; la sync fournisseur ne le + /// connaît pas et le laisse absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub libelle_reseau: Option, + /// Fournisseur de l'article côté Qlik, même origine que `libelle_reseau`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fournisseur_reseau: Option, +} + +/// Les mesures réseau Qlik pour un produit sur un mois donné. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikMonthMetrics { + /// Quantité vendue réseau — toujours présente. + /// Absente du JSON (objet mensuel incomplet) : 0, comme `fini(undefined)` côté TS. + #[serde(default, serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub qte: f64, + /// CA réseau du mois. + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_opt", deserialize_with = "jsnum::de_opt")] + pub ca: Option, + /// Nombre de magasins ayant vendu le produit ce mois-là. + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_opt", deserialize_with = "jsnum::de_opt")] + pub nb_mag: Option, + /// CA moyen par magasin sur le mois (mesure Qlik brute). + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_opt", deserialize_with = "jsnum::de_opt")] + pub ca_mag: Option, + /// Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "jsnum::ser_opt", deserialize_with = "jsnum::de_opt")] + pub marge_pct: Option, +} + +/// Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau). +/// +/// Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik +/// du réseau, pas de notre catalogue local. Ils peuvent donc être totalement +/// inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkProduct { + #[serde(flatten)] + pub metric: NetworkMetric, + /// Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). + pub libelle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fournisseur: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub famille: Option, +} + +/// Clé anti-CSRF Qlik : 16 caractères alphanumériques. +pub fn make_xrfkey() -> String { + let octets: [u8; 12] = rand::random(); + let mut k: String = base64::engine::general_purpose::STANDARD.encode(octets).chars().filter(|c| c.is_ascii_alphanumeric()).take(16).collect(); + while k.len() < 16 { + k.push('0'); + } + k +} + +// --------------------------------------------------------------------------- +// 1. Auth NTLM -> ticket SSO -> cookie de session +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct QlikSession { + /// `X-Qlik-Session=…` (paires `nom=valeur` jointes par `; `). + pub cookie: String, + pub xrfkey: String, +} + +/// Réponse HTTP minimale (statut + en-têtes) d'un échange NTLM. +struct ReponseNtlm { + status: u16, + headers: hyper::HeaderMap, +} + +/// Vérificateur TLS qui accepte tout certificat (`tlsInsecure`, défaut true : +/// le serveur Qlik interne présente un certificat non reconnu). +#[derive(Debug)] +struct SansVerification(Arc); + +impl rustls::client::danger::ServerCertVerifier for SansVerification { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.0.signature_verification_algorithms) + } + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms) + } + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + +fn config_tls(insecure: bool) -> rustls::ClientConfig { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let builder = + rustls::ClientConfig::builder_with_provider(provider.clone()).with_safe_default_protocol_versions().expect("versions TLS par défaut"); + let mut cfg = if insecure { + builder.dangerous().with_custom_certificate_verifier(Arc::new(SansVerification(provider))).with_no_client_auth() + } else { + let roots = rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() }; + builder.with_root_certificates(roots).with_no_client_auth() + }; + // HTTP/1.1 uniquement : NTLM authentifie la CONNEXION, pas la requête. + cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; + cfg +} + +/// Connexion HTTP/1.1 dédiée (TLS si https) : le défi NTLM type 2 n'est valable +/// que sur la connexion TCP qui l'a reçu, le type 3 doit donc partir sur la même. +/// (`httpntlm` utilisait pour cela un agent keep-alive.) +async fn connexion_dediee( + url: &reqwest::Url, + cfg: &QlikConfig, +) -> anyhow::Result>> { + let host = url.host_str().ok_or_else(|| anyhow!("[qlik] URL sans hôte : {url}"))?.to_string(); + let port = url.port_or_known_default().unwrap_or(443); + let tcp = tokio::net::TcpStream::connect((host.as_str(), port)).await.with_context(|| format!("[qlik] connexion {host}:{port}"))?; + let _ = tcp.set_nodelay(true); + macro_rules! demarrer { + ($io:expr) => {{ + let (sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new($io)).await?; + tokio::spawn(async move { + let _ = conn.await; + }); + Ok(sender) + }}; + } + if url.scheme() == "https" { + let connector = tokio_rustls::TlsConnector::from(Arc::new(config_tls(cfg.tls_insecure))); + let nom = rustls::pki_types::ServerName::try_from(host.clone()).map_err(|e| anyhow!("[qlik] nom d'hôte invalide {host} : {e}"))?; + let tls = connector.connect(nom, tcp).await.with_context(|| format!("[qlik] TLS {host}"))?; + demarrer!(tls) + } else { + demarrer!(tcp) + } +} + +async fn envoyer( + sender: &mut hyper::client::conn::http1::SendRequest>, + url: &reqwest::Url, + entetes: &[(&str, String)], +) -> anyhow::Result { + use http_body_util::BodyExt; + sender.ready().await?; + let chemin = match url.query() { + Some(q) => format!("{}?{q}", url.path()), + None => url.path().to_string(), + }; + let hote = match url.port() { + Some(p) => format!("{}:{p}", url.host_str().unwrap_or_default()), + None => url.host_str().unwrap_or_default().to_string(), + }; + let mut req = hyper::Request::builder().method("GET").uri(chemin).header("Host", hote); + for (k, v) in entetes { + req = req.header(*k, v.as_str()); + } + let res = sender.send_request(req.body(http_body_util::Empty::new())?).await?; + let status = res.status().as_u16(); + let headers = res.headers().clone(); + // Corps lu en entier : la connexion n'est réutilisable qu'ensuite. + let _ = res.into_body().collect().await; + Ok(ReponseNtlm { status, headers }) +} + +/// Équivalent de `httpntlm.get` : type 1 (keep-alive) → type 2 → type 3, sur +/// une seule connexion. Une redirection reçue en réponse au type 1 est suivie +/// (nouvelle négociation sur la nouvelle URL), comme dans `httpntlm`. +async fn ntlm_get(cfg: &QlikConfig, url: &str) -> anyhow::Result { + let mut url = reqwest::Url::parse(url).with_context(|| format!("[qlik] URL invalide {url}"))?; + let timeout = Duration::from_millis(cfg.timeout_ms); + for _ in 0..10 { + let mut sender = + tokio::time::timeout(timeout, connexion_dediee(&url, cfg)).await.map_err(|_| anyhow!("[qlik] délai dépassé (connexion NTLM)"))??; + // Type 1 : seuls Connection et Authorization (httpntlm ne transmet les + // autres en-têtes qu'au dernier appel, où le serveur les consomme). + let type1 = ntlm::create_type1_message(&cfg.domain, &cfg.workstation); + let r1 = tokio::time::timeout(timeout, envoyer(&mut sender, &url, &[("Connection", "keep-alive".into()), ("Authorization", type1)])) + .await + .map_err(|_| anyhow!("[qlik] délai dépassé (NTLM type 1)"))??; + if let Some(loc) = r1.headers.get("location").and_then(|v| v.to_str().ok()) { + url = url.join(loc).with_context(|| format!("[qlik] redirection invalide {loc}"))?; + continue; + } + let defis: Vec = r1.headers.get_all("www-authenticate").iter().filter_map(|v| v.to_str().ok().map(String::from)).collect(); + if defis.is_empty() { + bail!("www-authenticate not found on response of second request"); + } + let defi = defis.iter().find(|d| d.contains("NTLM ")).cloned().unwrap_or_else(|| defis.join(", ")); + let msg2 = ntlm::parse_type2_message(&defi)?; + let type3 = ntlm::create_type3_message(&msg2, &cfg.user, &cfg.password, &cfg.domain, &cfg.workstation); + let entetes = [ + ("Connection", "Close".to_string()), + ("Authorization", type3), + ("User-Agent", "Mozilla/5.0 CollectFlow".to_string()), + ("X-Qlik-Xrfkey", String::new()), + ]; + return tokio::time::timeout(timeout, envoyer(&mut sender, &url, &entetes)) + .await + .map_err(|_| anyhow!("[qlik] délai dépassé (NTLM type 3)"))?; + } + bail!("[qlik] trop de redirections pendant l'authentification NTLM") +} + +fn extract_session_cookie(headers: &hyper::HeaderMap) -> Option { + headers + .get_all("set-cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .map(|c| c.split(';').next().unwrap_or("").to_string()) + .find(|c| c.to_ascii_lowercase().contains("x-qlik-session")) +} + +/// Client HTTP sans redirection automatique (`redirect: "manual"`). +fn client_manuel(cfg: &QlikConfig) -> anyhow::Result { + Ok(reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .danger_accept_invalid_certs(cfg.tls_insecure) + .timeout(Duration::from_millis(cfg.timeout_ms)) + .build()?) +} + +/// Ouvre une session Qlik : cookie `X-Qlik-Session` + xrfkey. +pub async fn qlik_ntlm_session(cfg: &QlikConfig) -> anyhow::Result { + if cfg.user.is_empty() || cfg.password.is_empty() { + bail!("[qlik] QLIK_USER / QLIK_PWD manquants dans l'environnement"); + } + let xrfkey = make_xrfkey(); + let http = client_manuel(cfg)?; + + // 1. targetId + let hub = http.get(format!("https://{}/hub/", cfg.host)).send().await?; + let loc1 = hub.headers().get("location").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + let target_id = regex::Regex::new(r"(?i)targetId=([0-9a-f-]+)").expect("regex").captures(&loc1).map(|c| c[1].to_string()).unwrap_or_default(); + + // 2. NTLM -> ticket. Interne (réseau corp), /hub/ peut déjà renvoyer 401 NTLM + // et poser le cookie directement ; on gère les 2 cas. + let win_path = if !target_id.is_empty() { + format!("/internal_windows_authentication/?targetId={target_id}&xrfkey={xrfkey}") + } else { + format!("/hub/?xrfkey={xrfkey}") + }; + let ntlm = ntlm_get(cfg, &format!("https://{}{win_path}", cfg.host)).await?; + + // Cookie posé directement (cas interne) ? + if let Some(cookie) = extract_session_cookie(&ntlm.headers) { + return Ok(QlikSession { cookie, xrfkey }); + } + + // 3. Échange ticket -> cookie + let ticket_loc = ntlm.headers.get("location").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + if !ticket_loc.contains("qlikTicket=") { + bail!("[qlik] Auth NTLM sans ticket (HTTP {}) — verifier mot de passe", ntlm.status); + } + let ticket_url = reqwest::Url::parse(&format!("https://{}/", cfg.host))?.join(&ticket_loc)?; + let exch = http.get(ticket_url).send().await?; + let cookie = exch + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .map(|c| c.split(';').next().unwrap_or("").to_string()) + .filter(|c| c.to_ascii_lowercase().contains("x-qlik-session")) + .collect::>() + .join("; "); + if cookie.is_empty() { + bail!("[qlik] ticket non echange contre un cookie de session"); + } + Ok(QlikSession { cookie, xrfkey }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xrfkey_16_alphanumeriques() { + for _ in 0..50 { + let k = make_xrfkey(); + assert_eq!(k.len(), 16); + assert!(k.chars().all(|c| c.is_ascii_alphanumeric())); + } + } + + #[test] + fn json_identique_au_ts() { + let mut m = + NetworkMetric { code_centrale: "10000167303".into(), ca_reseau: 1234.5, qte_reseau: 12.0, nb_magasins_reseau: 3.0, ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert_eq!( + v.to_string(), + r#"{"codeCentrale":"10000167303","caReseau":1234.5,"qteReseau":12,"nbMagasinsReseau":3,"caParMagasinReseau":0,"margePctReseau":0}"# + ); + m.metrics_by_month = Some(BTreeMap::from([("2026-01".to_string(), QlikMonthMetrics { qte: 2.0, nb_mag: Some(1.0), ..Default::default() })])); + m.qte_by_month = Some(BTreeMap::from([("2026-01".to_string(), 2.0)])); + m.periode = Some("2025-02_2026-01".into()); + let texte = serde_json::to_string(&m).unwrap(); + assert!(texte.contains(r#""qteByMonth":{"2026-01":2}"#), "{texte}"); + assert!(texte.contains(r#""metricsByMonth":{"2026-01":{"qte":2,"nbMag":1}}"#), "{texte}"); + let relu: NetworkMetric = serde_json::from_str(&texte).unwrap(); + assert_eq!(relu.metrics_by_month, m.metrics_by_month); + } + + /// Lit une requête HTTP/1.1 (en-têtes seuls, pas de corps en GET). + async fn lire_requete(flux: &mut tokio::net::TcpStream) -> String { + use tokio::io::AsyncReadExt; + let mut buf = Vec::new(); + let mut octet = [0u8; 1024]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + let n = flux.read(&mut octet).await.unwrap(); + if n == 0 { + break; + } + buf.extend_from_slice(&octet[..n]); + } + String::from_utf8_lossy(&buf).to_string() + } + + /// Faux serveur NTLM : le type 3 DOIT arriver sur la connexion du type 1. + #[tokio::test] + async fn echange_ntlm_sur_une_seule_connexion() { + use tokio::io::AsyncWriteExt; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let serveur = tokio::spawn(async move { + let (mut c, _) = listener.accept().await.unwrap(); + let r1 = lire_requete(&mut c).await; + assert!(r1.starts_with("GET /internal_windows_authentication/?targetId=abc HTTP/1.1"), "{r1}"); + assert!(r1.contains("authorization: NTLM TlRMTVNTUAABAAAAB6IIogAAAAAoAAAAAAAAACgAAAAFASgKAAAADw=="), "{r1}"); + assert!(!r1.to_lowercase().contains("user-agent"), "{r1}"); + let type2 = "TlRMTVNTUAACAAAADgAOADAAAAAVgoniASNFZ4mrze8AAAAAAAAAACQAJAA+AAAARABPAE0AQQBJAE4ARQACAA4ARABPAE0AQQBJAE4AAQAMAFMARQBSAFYARQBSAAAAAAA="; + let rep = format!("HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Negotiate\r\nWWW-Authenticate: NTLM {type2}\r\nContent-Length: 5\r\nConnection: keep-alive\r\n\r\nnon !"); + c.write_all(rep.as_bytes()).await.unwrap(); + let r2 = lire_requete(&mut c).await; + assert!(r2.contains("authorization: NTLM TlRMTVNTUAADAAAA"), "{r2}"); + assert!(r2.contains("user-agent: Mozilla/5.0 CollectFlow"), "{r2}"); + assert!(r2.contains("x-qlik-xrfkey: \r\n"), "{r2}"); + assert!(r2.contains("connection: Close"), "{r2}"); + let rep = "HTTP/1.1 302 Found\r\nLocation: https://qlik.local/hub/?qlikTicket=TICKET\r\nSet-Cookie: X-Qlik-Session=s3ss10n; Path=/; HttpOnly\r\nContent-Length: 0\r\n\r\n"; + c.write_all(rep.as_bytes()).await.unwrap(); + }); + let mut cfg = get_qlik_config(); + cfg.user = "FFSCH".into(); + cfg.password = "Mot2Passe!".into(); + cfg.domain = String::new(); + cfg.workstation = String::new(); + let r = ntlm_get(&cfg, &format!("http://127.0.0.1:{port}/internal_windows_authentication/?targetId=abc")).await.unwrap(); + serveur.await.unwrap(); + assert_eq!(r.status, 302); + assert_eq!(r.headers.get("location").unwrap(), "https://qlik.local/hub/?qlikTicket=TICKET"); + assert_eq!(extract_session_cookie(&r.headers).as_deref(), Some("X-Qlik-Session=s3ss10n")); + } +} diff --git a/server/src/qlik/date_range.rs b/server/src/qlik/date_range.rs new file mode 100644 index 0000000..3d805f5 --- /dev/null +++ b/server/src/qlik/date_range.rs @@ -0,0 +1,460 @@ +//! Helper de fenêtre temporelle pour Qlik réseau (port de `src/lib/qlik-date-range.ts`). +//! +//! Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille : +//! - `months_back` mois complets glissants (défaut : 12) +//! - exclut systématiquement le mois courant +//! +//! Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts` +//! (même source que la grille). Si la source officielle change, mettre à jour +//! ici aussi. +//! +//! Qlik référence les dates par un sérial numérique (jours écoulés depuis +//! l'epoch Qlik 1899-12-30). Repères validés : +//! 2025-01-01 → 45658 +//! 2025-06-19 → 45827 +//! 2026-01-01 → 46023 +//! 2026-06-19 → 46192 +//! +//! Le filtre côté Qlik s'applique via une sélection de champ `Date` : +//! - valeurs numériques = serials Qlik +//! - la sélection par défaut (`{}`) est héritée par toutes +//! les master measures qui référencent le champ `Date`. +//! +//! Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ? +//! - les master items sont référencés par `qLibraryId` (réutilisation sans +//! devoir dupliquer l'expression) +//! - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX +//! - sélectionner toutes les dates de la fenêtre via `SelectValues` revient +//! fonctionnellement à `{=…<=…"}>}` pour les mesures qui touchent +//! ce champ, sans dépendre de l'expression interne des master items +//! +//! Pas de secrets ici. Fonctions pures, testées par `cargo test`. + +use chrono::{Datelike, Duration, NaiveDate}; +use serde::{Deserialize, Serialize}; + +/// Bornes acceptées pour `months_back`. On reste sur 1..12 pour ne pas casser +/// la sémantique "12 mois glissants alignés sur la grille" : une valeur plus +/// grande n'apporterait pas de signal réseau supplémentaire (la grille +/// n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait +/// vide (mois courant exclu) ou incohérente. +pub const QLIK_MONTHS_BACK_MIN: i64 = 1; +pub const QLIK_MONTHS_BACK_MAX: i64 = 12; +pub const QLIK_MONTHS_BACK_DEFAULT: i64 = 12; + +/// Conversion d'une chaîne en nombre avec la sémantique de `Number(s)` en JS : +/// espaces ignorés, chaîne vide → 0, préfixes `0x` / `0o` / `0b`, sinon NaN. +pub fn js_number(raw: &str) -> f64 { + let s = raw.trim(); + if s.is_empty() { + return 0.0; + } + let lower = s.to_ascii_lowercase(); + for (prefixe, base) in [("0x", 16), ("0o", 8), ("0b", 2)] { + if let Some(reste) = lower.strip_prefix(prefixe) { + return u64::from_str_radix(reste, base).map(|v| v as f64).unwrap_or(f64::NAN); + } + } + match s { + "Infinity" | "+Infinity" => return f64::INFINITY, + "-Infinity" => return f64::NEG_INFINITY, + _ => {} + } + // Rust accepte « inf », « nan »… que JS refuse : on les écarte. + if lower.contains("inf") || lower.contains("nan") { + return f64::NAN; + } + s.parse::().unwrap_or(f64::NAN) +} + +/// Lit la variable d'environnement `name` et renvoie un entier borné entre +/// [`QLIK_MONTHS_BACK_MIN`] et [`QLIK_MONTHS_BACK_MAX`]. Renvoie `default_value` +/// si la variable est absente / vide / non numérique. +pub fn env_months_back(name: &str, default_value: i64) -> i64 { + months_back_from(std::env::var(name).ok().as_deref(), default_value) +} + +/// Cœur testable de [`env_months_back`]. +pub fn months_back_from(raw: Option<&str>, default_value: i64) -> i64 { + let Some(raw) = raw else { return default_value }; + if raw.trim().is_empty() { + return default_value; + } + let parsed = js_number(raw); + if !parsed.is_finite() { + return default_value; + } + (parsed.trunc() as i64).clamp(QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX) +} + +/// Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). +fn qlik_epoch() -> NaiveDate { + NaiveDate::from_ymd_opt(1899, 12, 30).expect("epoch Qlik") +} + +/// Repères de validation. +pub const QLIK_DATE_ANCHORS: [(&str, i64); 4] = [("2025-01-01", 45658), ("2025-06-19", 45827), ("2026-01-01", 46023), ("2026-06-19", 46192)]; + +fn parse_iso(iso_date: &str) -> Option<(i32, u32, u32)> { + let b = iso_date.as_bytes(); + if b.len() != 10 || b[4] != b'-' || b[7] != b'-' { + return None; + } + let chiffres = |r: std::ops::Range| -> Option { + let part = &iso_date[r]; + if part.bytes().all(|c| c.is_ascii_digit()) { + part.parse().ok() + } else { + None + } + }; + Some((chiffres(0..4)? as i32, chiffres(5..7)?, chiffres(8..10)?)) +} + +/// Jours depuis l'epoch Qlik pour une date (y, m, d), avec le débordement de +/// `Date.UTC` (mois 13 → janvier suivant, jour 32 → mois suivant…). +fn serial_ymd(y: i32, mo: u32, d: u32) -> i64 { + let base = NaiveDate::from_ymd_opt(y, 1, 1).expect("année valide"); + let premier_du_mois = base.checked_add_months(chrono::Months::new(mo.saturating_sub(1))).unwrap_or(base); + let date = premier_du_mois + Duration::days(d as i64 - 1); + // Date.UTC(y, -1, …) n'arrive pas ici : le format impose deux chiffres ≥ 00. + let date = if mo == 0 { date - chrono::Months::new(1) } else { date }; + (date - qlik_epoch()).num_days() +} + +/// Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique. +/// Inverse documenté : Qlik epoch = 1899-12-30 (jour 0). +/// +/// Erreur si l'entrée n'est pas au format `YYYY-MM-DD`. +pub fn iso_date_to_qlik_serial(iso_date: &str) -> anyhow::Result { + let Some((y, mo, d)) = parse_iso(iso_date) else { + anyhow::bail!("isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu \"{iso_date}\""); + }; + Ok(serial_ymd(y, mo, d)) +} + +/// Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics). +pub fn qlik_serial_to_iso_date(serial: i64) -> String { + (qlik_epoch() + Duration::days(serial)).format("%Y-%m-%d").to_string() +} + +/// Sérial Qlik → entier `AAAAMMJJ` (format de `Date_Key` sur certains modèles). +pub fn qlik_serial_to_ymd(serial: i64) -> i64 { + let d = qlik_epoch() + Duration::days(serial); + d.year() as i64 * 10000 + d.month() as i64 * 100 + d.day() as i64 +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikDateFilter { + /// Date ISO du 1er jour de la fenêtre (inclus). + pub date_debut: String, + /// Date ISO du dernier jour de la fenêtre (inclus). + pub date_fin: String, + /// Sérial Qlik du 1er jour (= `Date >= …`). + pub q_start: i64, + /// Sérial Qlik du dernier jour (= `Date <= …`). + pub q_end: i64, + /// Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache. + pub label: String, + /// Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues. + pub daily_serials: Vec, + /// Expression set analysis prête à intégrer dans un `qDef` de mesure. + pub set_analysis: String, +} + +/// Dernier jour du mois (m: 1..12). +fn last_day_of_month(y: i32, m: u32) -> u32 { + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + NaiveDate::from_ymd_opt(ny, nm, 1).expect("mois valide").pred_opt().expect("veille").day() +} + +/// Calcule la fenêtre `months_back` mois complets (excluant le mois courant). +/// +/// `now` est la date LOCALE du jour (`chrono::Local::now().date_naive()`) : les +/// bornes sont des dates calendaires Qlik, jamais converties en UTC — minuit +/// local en Europe/Paris devenait la veille en UTC et transformait par exemple +/// 2025-07-01 en 2025-06-30, soit une extraction de 13 mois. +/// +/// Bornes : `months_back` est ramené dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]` +/// (1..12) sans erreur — on veut que la sync reste possible même si l'env est +/// mal configuré. +pub fn build_grid_network_qlik_date_filter(now: NaiveDate, months_back: i64) -> QlikDateFilter { + let months = months_back.clamp(QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX) as u32; + let premier_du_mois = NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("date valide"); + // Fin : dernier jour du mois précédent le mois courant + let end_month = premier_du_mois.pred_opt().expect("veille"); + // Début : `months` mois avant le mois courant (1er du mois) + let start_month = premier_du_mois - chrono::Months::new(months); + + let date_debut = start_month.format("%Y-%m-%d").to_string(); + let date_fin = end_month.format("%Y-%m-%d").to_string(); + let q_start = (start_month - qlik_epoch()).num_days(); + let q_end = (end_month - qlik_epoch()).num_days(); + // qEnd < qStart est impossible ici (au moins un mois complet) : le TS levait + // une exception pour ce cas, inatteignable avec des bornes ramenées à 1..12. + let daily_serials: Vec = (q_start..=q_end).collect(); + let label = format!("{}_{}", &date_debut[..7], &date_fin[..7]); + let set_analysis = format!("Date={{\">={q_start}<={q_end}\"}}"); + QlikDateFilter { date_debut, date_fin, q_start, q_end, label, daily_serials, set_analysis } +} + +/// Fenêtre du jour (date locale) — `buildGridNetworkQlikDateFilter()` sans argument. +pub fn build_grid_network_qlik_date_filter_now(months_back: i64) -> QlikDateFilter { + build_grid_network_qlik_date_filter(chrono::Local::now().date_naive(), months_back) +} + +/// Découpe les serials en lots pour appels `SelectValues` paginés. +/// Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête). +pub fn chunk_serials(serials: &[i64], batch_size: usize) -> Vec> { + serials.chunks(batch_size.max(1)).map(|c| c.to_vec()).collect() +} + +/// Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires +/// **inclusives et continues**. +/// +/// But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N +/// codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe, +/// chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine. +/// +/// Règles : +/// - Le 1er mois commence à `filter.date_debut` (peut être un jour quelconque). +/// - Chaque mois intermédiaire est un mois calendaire **complet**. +/// - Le dernier mois se termine à `filter.date_fin` (tronqué si pas fin de mois). +/// - Les mois sont contigus : fin d'un mois + 1 = début du suivant. +/// - Si début et fin tombent dans le même mois, on renvoie un seul mois. +/// +/// Chaque mois retourné est un `QlikDateFilter` autonome (`label` au format `YYYY-MM`). +pub fn get_month_ranges(filter: &QlikDateFilter) -> Vec { + let start = filter.date_debut.as_str(); + let end = filter.date_fin.as_str(); + let num = |s: &str, r: std::ops::Range| -> i64 { s.get(r).and_then(|x| x.parse().ok()).unwrap_or(0) }; + let sy = num(start, 0..4) as i32; + let sm = num(start, 5..7) as u32; + let ey = num(end, 0..4) as i32; + let em = num(end, 5..7) as u32; + let ed = num(end, 8..10) as u32; + + let mut out = Vec::new(); + let (mut cur_y, mut cur_m) = (sy, sm); + // Sécurité anti-boucle infinie (au cas où) : 100 ans, large. + const MAX_MONTHS: usize = 1200; + let mut safety = 0; + while safety < MAX_MONTHS && (1..=12).contains(&cur_m) { + safety += 1; + let is_first = cur_y == sy && cur_m == sm; + let is_last = cur_y == ey && cur_m == em; + + // Début : si c'est le premier mois, on respecte `date_debut` ; sinon le 1er. + let start_day = if is_first { num(start, 8..10) as u32 } else { 1 }; + let month_start = format!("{cur_y}-{cur_m:02}-{start_day:02}"); + // Fin : si c'est le dernier mois, on respecte `date_fin` ; sinon le dernier jour. + let end_day = if is_last { ed } else { last_day_of_month(cur_y, cur_m) }; + let month_end = format!("{cur_y}-{cur_m:02}-{end_day:02}"); + + let q_start = serial_ymd(cur_y, cur_m, start_day); + let q_end = serial_ymd(cur_y, cur_m, end_day); + let daily_serials: Vec = (q_start..=q_end).collect(); + out.push(QlikDateFilter { + date_debut: month_start, + date_fin: month_end, + q_start, + q_end, + label: format!("{cur_y}-{cur_m:02}"), + daily_serials, + set_analysis: format!("Date={{\">={q_start}<={q_end}\"}}"), + }); + if is_last { + break; + } + // Avance d'un mois calendaire (gère le passage décembre → janvier). + cur_m += 1; + if cur_m > 12 { + cur_m = 1; + cur_y += 1; + } + } + + if out.is_empty() { + // Fenêtre vide : on retourne un seul mois vide aligné sur date_debut. + out.push(QlikDateFilter { + date_debut: filter.date_debut.clone(), + date_fin: filter.date_fin.clone(), + q_start: filter.q_start, + q_end: filter.q_end, + label: start.get(0..7).unwrap_or(start).to_string(), + daily_serials: Vec::new(), + set_analysis: format!("Date={{\">={}<={}\"}}", filter.q_start, filter.q_end), + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() + } + + // [1] + [2] Repères Qlik et aller-retour. + #[test] + fn reperes_qlik() { + for (iso, serial) in QLIK_DATE_ANCHORS { + assert_eq!(iso_date_to_qlik_serial(iso).unwrap(), serial, "{iso}"); + assert_eq!(qlik_serial_to_iso_date(serial), iso); + } + } + + // [3] Fenêtre grille (now=2026-06-21) → 2025-06-01 .. 2026-05-31 + #[test] + fn fenetre_juin_2026() { + let f = build_grid_network_qlik_date_filter(d("2026-06-21"), QLIK_MONTHS_BACK_DEFAULT); + assert_eq!(f.date_debut, "2025-06-01"); + assert_eq!(f.date_fin, "2026-05-31"); + assert_eq!(f.q_start, 45809); + assert_eq!(f.q_end, 46173); + assert_eq!(f.label, "2025-06_2026-05"); + assert_eq!(f.set_analysis, r#"Date={">=45809<=46173"}"#); + assert_eq!(f.daily_serials.len(), 365); + assert_eq!(f.daily_serials[0], 45809); + assert_eq!(f.daily_serials[364], 46173); + } + + // [4] et [5] + #[test] + fn fenetres_mars_et_bissextile() { + let f2 = build_grid_network_qlik_date_filter(d("2026-03-15"), 12); + assert_eq!((f2.date_debut.as_str(), f2.date_fin.as_str()), ("2025-03-01", "2026-02-28")); + assert_eq!((f2.q_start, f2.q_end), (45717, 46081)); + let f3 = build_grid_network_qlik_date_filter(d("2025-01-05"), 12); + assert_eq!((f3.date_debut.as_str(), f3.date_fin.as_str()), ("2024-01-01", "2024-12-31")); + assert_eq!(f3.daily_serials.len(), 366); + } + + // [6] chunkSerials + #[test] + fn decoupe_serials() { + let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12); + let chunks = chunk_serials(&f.daily_serials, 100); + assert_eq!(chunks.len(), 4); + assert_eq!(chunks[3].len(), 65); + assert_eq!(chunks[0][0], 45809); + assert_eq!(chunks[3][64], 46173); + } + + // [7] Robustesse + #[test] + fn formats_invalides() { + for bad in ["2025/01/01", ""] { + let e = iso_date_to_qlik_serial(bad).unwrap_err().to_string(); + assert!(e.contains("YYYY-MM-DD"), "{e}"); + } + let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12); + assert!(!f.label.is_empty()); + assert!(f.set_analysis.starts_with("Date=")); + } + + // [8] getMonthRanges — fenêtre 12 mois + #[test] + fn mois_de_la_fenetre() { + let f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12); + let months = get_month_ranges(&f); + assert_eq!(months.len(), 12); + assert_eq!(months[0].label, "2025-06"); + assert_eq!(months[0].date_debut, "2025-06-01"); + assert_eq!(months[0].date_fin, "2025-06-30"); + assert_eq!(months[0].daily_serials.len(), 30); + assert_eq!(months[0].q_start, 45809); + assert_eq!(months[0].q_end, 45838); + assert_eq!(months[11].label, "2026-05"); + assert_eq!(months[11].date_debut, "2026-05-01"); + assert_eq!(months[11].date_fin, "2026-05-31"); + assert_eq!(months[11].daily_serials.len(), 31); + let feb25 = get_month_ranges(&build_grid_network_qlik_date_filter(d("2025-06-15"), 12)); + assert!(feb25.iter().any(|m| m.label == "2025-02" && m.daily_serials.len() == 28)); + let biss = get_month_ranges(&build_grid_network_qlik_date_filter(d("2025-01-05"), 12)); + assert!(biss.iter().any(|m| m.label == "2024-02" && m.daily_serials.len() == 29)); + assert_eq!(months.iter().map(|m| m.daily_serials.len()).sum::(), 365); + } + + // [9] fenêtre courte partielle + #[test] + fn fenetre_partielle() { + let mut f = build_grid_network_qlik_date_filter(d("2026-06-21"), 12); + f.date_debut = "2025-06-01".into(); + f.date_fin = "2025-08-15".into(); + f.q_start = 45809; + f.q_end = 45884; + f.daily_serials.clear(); + f.set_analysis.clear(); + let m = get_month_ranges(&f); + assert_eq!(m.len(), 3); + assert_eq!(m[2].daily_serials.len(), 15); + assert_eq!(m[2].date_fin, "2025-08-15"); + } + + // [10] monthsBack=6 + #[test] + fn six_mois() { + let f6 = build_grid_network_qlik_date_filter(d("2026-06-21"), 6); + assert_eq!(f6.date_debut, "2025-12-01"); + assert_eq!(f6.date_fin, "2026-05-31"); + assert_eq!((f6.q_start, f6.q_end), (45992, 46173)); + assert_eq!(f6.label, "2025-12_2026-05"); + assert_eq!(f6.daily_serials.len(), 182); + let m6 = get_month_ranges(&f6); + assert_eq!(m6.len(), 6); + assert_eq!(m6[0].label, "2025-12"); + assert_eq!(m6[5].label, "2026-05"); + assert_eq!(m6.iter().map(|m| m.daily_serials.len()).sum::(), 182); + } + + // [11] bornes et clamp + #[test] + fn bornes_months_back() { + let now = d("2026-06-21"); + let x = build_grid_network_qlik_date_filter(now, 1); + assert_eq!((x.date_debut.as_str(), x.date_fin.as_str()), ("2026-05-01", "2026-05-31")); + assert_eq!(build_grid_network_qlik_date_filter(now, 0).date_debut, "2026-05-01"); + let x = build_grid_network_qlik_date_filter(now, 24); + assert_eq!((x.date_debut.as_str(), x.date_fin.as_str()), ("2025-06-01", "2026-05-31")); + assert_eq!(build_grid_network_qlik_date_filter(now, -5).date_debut, "2026-05-01"); + assert_eq!((QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX, QLIK_MONTHS_BACK_DEFAULT), (1, 12, 12)); + } + + // [12] envMonthsBack — lecture env (cœur pur, sans toucher l'environnement du processus) + #[test] + fn lecture_env_months_back() { + let def = QLIK_MONTHS_BACK_DEFAULT; + assert_eq!(months_back_from(None, def), def); + assert_eq!(months_back_from(Some(""), def), def); + assert_eq!(months_back_from(Some("6"), def), 6); + assert_eq!(months_back_from(Some("abc"), def), def); + assert_eq!(months_back_from(Some("24"), def), QLIK_MONTHS_BACK_MAX); + assert_eq!(months_back_from(Some("-3"), def), QLIK_MONTHS_BACK_MIN); + assert_eq!(months_back_from(Some("0"), def), QLIK_MONTHS_BACK_MIN); + assert_eq!(months_back_from(Some("3.7"), def), 3); + assert_eq!(months_back_from(Some(" 8 "), def), 8); + assert_eq!(months_back_from(Some("NaN"), def), def); + assert_eq!(months_back_from(Some("Infinity"), def), def); + // Sur le vrai environnement : variable absente → défaut. + assert_eq!(env_months_back("QLIK_TEST_MONTHS_BACK_ABSENTE_XYZ", def), def); + } + + #[test] + fn nombres_facon_js() { + assert_eq!(js_number(""), 0.0); + assert_eq!(js_number(" 12 "), 12.0); + assert_eq!(js_number("0x10"), 16.0); + assert_eq!(js_number("1e2"), 100.0); + assert!(js_number("inf").is_nan()); + assert!(js_number("12abc").is_nan()); + } + + #[test] + fn serial_vers_aaaammjj() { + assert_eq!(qlik_serial_to_ymd(46192), 20260619); + } +} diff --git a/server/src/qlik/js/extraction.js b/server/src/qlik/js/extraction.js new file mode 100644 index 0000000..19d995d --- /dev/null +++ b/server/src/qlik/js/extraction.js @@ -0,0 +1,2036 @@ +// Script exécuté DANS la page Qlik (Chromium) par `playwright.rs`. +// Copie conforme de la fonction passée à `page.evaluate` dans +// `src/lib/qlik-playwright.ts` (types TypeScript retirés, code inchangé). +// Appelée avec un unique objet d'arguments ; résout un `InPageResult`. +({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats, fournisseurCode, champsFournisseurCandidats, couvertureFournisseurMin }) => new Promise((resolve) => { + const loc = window.location; + const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; + const ws = new WebSocket(url); + let id = 0; + const pend = new Map(); + const rpc = (method, params, handle = -1) => new Promise((res, rej) => { + const i = ++id; + pend.set(i, { res, rej }); + ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); + }); + const ready = new Promise((res, rej) => { + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.method === "OnConnected") + return res(); + if (m.id && pend.has(m.id)) { + const x = pend.get(m.id); + pend.delete(m.id); + if (m.error) + x.rej(new Error(JSON.stringify(m.error))); + else + x.res(m.result); + } + }; + ws.onerror = () => rej(new Error("ws error (403 ?)")); + setTimeout(() => rej(new Error("ws timeout")), 30000); + }); + // Qlik Engine code 15 = "Request aborted" : déclenché quand une requête + // (GetLayout / GetHyperCubeData / CreateSessionObject) entre en collision + // avec une évaluation de sélection encore en cours côté Engine. On retente + // quelques fois avec un court backoff et un log explicite de l'étape. + const isEngineCode15 = (e) => { + const msg = String(e?.message ?? e); + return /"code"\s*:\s*15\b/.test(msg); + }; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); + const timed = async (etape, method, fn, timings) => { + const start = nowMs(); + const value = await fn(); + const ms = Math.round(nowMs() - start); + const label = `${etape}:${method}`; + if (timings) + timings[label] = (timings[label] ?? 0) + ms; + console.log(`[qlik-pw][timing] ${label} ms=${ms}`); + return { value, ms }; + }; + const timingSummary = (timings) => JSON.stringify(timings); + const rpcWithRetry = async (etape, method, params, handle, onCode15Retry) => { + let lastErr; + for (let attempt = 1; attempt <= qlikCode15MaxAttempts; attempt++) { + try { + return await rpc(method, params, handle); + } + catch (e) { + lastErr = e; + if (!isEngineCode15(e) || attempt === qlikCode15MaxAttempts) + throw e; + onCode15Retry?.(); + const backoff = qlikSettleMs * attempt; + console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${qlikCode15MaxAttempts} backoff=${backoff}ms`); + await sleep(backoff); + } + } + throw lastErr; + }; + // Accumulateurs déclarés au niveau de l'IIFE (et non dans le `try`) : + // le `catch` doit pouvoir remonter ce qui a déjà été extrait. + const out = []; + // Quantité réseau par code central et par mois (label "YYYY-MM"). + // Accumulée en parallèle du total, sans toucher l'agrégation existante. + const monthlyByCode = {}; + // Diagnostics clés, RÉPÉTÉS en fin de sync : le log d'une sync fait + // des milliers de lignes de timings, et ce qui explique un échec se + // trouve tout au début — là où personne ne va le chercher. + const diagnostics = []; + const diag = (msg) => { diagnostics.push(msg); console.log("[qlik-pw][expr] " + msg); }; + (async () => { + try { + await ready; + console.log("ws connecté (OnConnected)"); + const open = await rpc("OpenDoc", { qDocName: app }); + const doc = open.qReturn.qHandle; + console.log("OpenDoc OK handle=" + doc); + // Handles des champs Date / Article Code. + let dfh = -1; + let fh = -1; + let yfh = -1; + /** + * Handle du CHAMP « Période ». + * + * ⚠️ `Clear` et `SelectValues` sont des méthodes de **champ**. Les + * appeler sur un objet de session renvoie + * `{"code":-32601,"parameter":"Clear","message":"Method not found"}`, + * ce qui a fait avorter en production la cartographie des périodes ET + * le chemin d'extraction par date brute. Le list object ne sert donc + * plus qu'à ÉNUMÉRER les valeurs ; les sélections passent par ici. + */ + let pfh = -1; + const df = await rpc("GetField", { qFieldName: "Date" }, doc); + dfh = df.qReturn.qHandle; + try { + const yf = await rpc("GetField", { qFieldName: "Année" }, doc); + yfh = yf.qReturn.qHandle; + } + catch { + yfh = -1; + } + try { + const pf = await rpc("GetField", { qFieldName: "Période" }, doc); + pfh = pf.qReturn.qHandle; + } + catch { + pfh = -1; + } + if (codes.length) { + const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc); + fh = gf.qReturn.qHandle; + } + // Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS). + const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase(); + const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } } }, doc); + const mll = await rpc("GetLayout", {}, ml.qReturn.qHandle); + const items = ((mll.qLayout.qMeasureList?.qItems) ?? []); + const byTitle = new Map(); + for (const it of items) { + const t = it.qMeta?.title ?? it.qData?.title ?? ""; + if (t) + byTitle.set(norm(t), it.qInfo.qId); + } + const pickMeasure = (title, fallback) => byTitle.get(norm(title)) ?? fallback; + const rCamag = pickMeasure("CA par Magasin N", mcamag); + const rMarge = pickMeasure("Marge % N", mmarge); + // Résolution par TITRE aussi pour CA / Quantité / Nb magasins : évite tout + // décalage si un qId d'env ne pointe pas sur la mesure attendue. + const rCa = pickMeasure("CA N", mca); + const rQte = pickMeasure("Quantité N", mqte); + const rNbMag = pickMeasure("Magasin Ventes Nb N", mnb); + console.log("[qlik-pw] mesures utilisées: " + JSON.stringify({ + "CA N": rCa, "Quantité N": rQte, "Magasin Ventes Nb N": rNbMag, + "CA par Magasin N": rCamag, "Marge % N": rMarge, + env: { mca, mqte, mnb }, + })); + console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rMarge }) + " (sur " + items.length + " mesures)"); + // DUMP diagnostic : inventaire des mesures + dimensions (titre + id) pour + // identifier une mesure "Quantité" NON annuelle et/ou une dimension "Mois/Période" + // permettant un vrai découpage mensuel du réseau. + // L'EXPRESSION est indispensable au diagnostic : c'est elle qui dit + // si la mesure est bornée à une année (et comment), donc pourquoi des + // mois de la fenêtre glissante peuvent ressortir vides. + console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); + for (const it of items) { + const titre = it.qMeta?.title ?? it.qData?.title ?? ""; + if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(titre.trim())) { + console.log("[qlik-pw][dump] expression « " + titre + " » = " + (it.qData?.expr ?? "?")); + } + } + try { + const dl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "DimensionList" }, qDimensionListDef: { qType: "dimension", qData: { title: "/qMetaDef/title" } } } }, doc); + const dll = await rpc("GetLayout", {}, dl.qReturn.qHandle); + const ditems = ((dll.qLayout.qDimensionList?.qItems) ?? []); + console.log("[qlik-pw][dump] DIMENSIONS (" + ditems.length + "): " + JSON.stringify(ditems.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); + } + catch (e) { + console.log("[qlik-pw][dump] DimensionList error: " + String(e?.message || e)); + } + // L'hypercube session object est désormais créé JETABLE dans fetchCubeForSelection + // (un objet par lot) puis DestroySessionObject — corrige "Request aborted" (code 15) + // causé par la réutilisation du même objet sur des centaines de recalculs. + // (déclaré au niveau de l'IIFE — voir plus haut) + // (monthlyByCode est déclaré au niveau de l'IIFE — voir plus haut) + const accMonthly = (batchRows, monthLabel) => { + for (const r of batchRows) { + const c = String(r[0] ?? "").trim(); + if (!c || c === "-") + continue; + const q = Number(r[2]) || 0; + (monthlyByCode[c] ??= {}); + const cur = monthlyByCode[c][monthLabel]; + monthlyByCode[c][monthLabel] = { + qte: (cur?.qte ?? 0) + q, + ca: (cur?.ca ?? 0) + (Number(r[1]) || 0), + nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0), + caMag: Number(r[4]) || 0, + margePct: Number(r[5]) || 0, + }; + } + }; + // Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync). + let aggMonths = 0; + let aggBatches = 0; + let aggCode15Retries = 0; + let aggFallbackCount = 0; + // Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes + // brutes lues (avant dédoublonnage côté Node). + // + // IMPORTANT : crée un hypercube session object JETABLE par lot (Date + Article Code + // déjà sélectionnés) puis le détruit. Réutiliser le même objet sur des centaines de + // lots force Qlik à recalculer en boucle et finit par "Request aborted" (code 15). + const PAGE = 1400; + // Petite latence après chaque mutation de sélection : laisser l'Engine + // finaliser l'évaluation de la sélection avant la prochaine requête layout/cube. + const createCubeForBatch = async (timings, onCode15Retry) => { + const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }], + qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: PAGE }], + } } }, doc, onCode15Retry), timings); + const qReturn = obj.qReturn; + return { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + }; + const destroyCube = async (cube, timings) => { + if (!cube.id) + return; + try { + await timed("destroyCube", "DestroySessionObject", () => rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc), timings); + } + catch (e) { + // Non fatal : l'objet est de toute façon scopé à la session websocket. + // Un code 15 ici signifie que l'Engine l'a déjà libéré après un abort. + console.log("[qlik-pw] DestroySessionObject ignoré: " + String(e?.message || e)); + } + }; + const appendMatrix = (matrix, sink) => { + for (const r of matrix) + sink.push([ + String(r[0]?.qText ?? ""), + Number(r[1]?.qNum) || 0, + Number(r[2]?.qNum) || 0, + Number(r[3]?.qNum) || 0, + Number(r[4]?.qNum) || 0, + Number(r[5]?.qNum) || 0, + ]); + return matrix.length; + }; + const fetchCubeForSelection = async (sink, timings, onCode15Retry) => { + const cube = await createCubeForBatch(timings, onCode15Retry); + const oh = cube.handle; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hyperCube = layout.qLayout.qHyperCube; + const size = hyperCube.qSize.qcy; + const firstPage = hyperCube.qDataPages?.find((page) => (page.qArea?.qTop ?? 0) === 0); + let got = 0; + let top = 0; + if (firstPage?.qMatrix?.length) { + got += appendMatrix(firstPage.qMatrix, sink); + top = PAGE; + } + for (; top < size; top += PAGE) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh, onCode15Retry), timings); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (!matrix.length) + break; + got += appendMatrix(matrix, sink); + if (matrix.length < PAGE) + break; + } + return got; + } + finally { + await destroyCube(cube, timings); + } + }; + // ─── Découpage mensuel réel via dimension "Mois" ──────────────────── + // Cube [Article Code, Mois] × 5 mesures. Chaque ligne = (code, mois, ca, + // qte, nbMag, caMag, marge). Le "Mois" est au format "YYYYMM" → "YYYY-MM". + const normMois = (m) => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m); + const PAGEM = 1200; // 8 colonnes × 1200 = 9600 cellules < 10000 (limite Qlik) + const fetchMonthCube = async (onRow, timings, onCode15Retry) => { + const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: PAGEM }], + } } }, doc, onCode15Retry), timings); + const qReturn = obj.qReturn; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const oh = cube.handle; + const process = (matrix) => { + for (const r of matrix) { + onRow(String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hc = layout.qLayout.qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); + if (firstPage?.qMatrix?.length) { + got += process(firstPage.qMatrix); + top = PAGEM; + } + for (; top < size; top += PAGEM) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGEM }] }, oh, onCode15Retry), timings); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (!matrix.length) + break; + got += process(matrix); + if (matrix.length < PAGEM) + break; + } + return got; + } + finally { + await destroyCube(cube, timings); + } + }; + // ─── Cube mensuel par EXPRESSIONS (respecte la sélection Date) ───── + // + // Constat de production : les master measures « CA N » / « Quantité N » + // **ignorent la sélection Date**. Vérifié arithmétiquement : avec août + // 2025 seul sélectionné, le cube renvoyait 164 unités / 2 220,75 € — + // soit exactement la somme de janvier à juillet 2026. Ce sont des + // mesures « cumul année en cours », pas des mesures de période. + // + // Conséquences : les totaux réseau étaient un cumul année en cours + // (mois courant partiel inclus) et non 12 mois glissants, les mois de + // l'année précédente non couverts par « Quantité COMP » restaient + // vides, et la passe de rattrapage y recopiait le total de période. + // + // Aucune sélection ne peut corriger cela : on agrège donc directement + // les champs de faits, qui eux respectent les sélections. + const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000 + const fetchMonthCubeExpr = async (eCa, eQte, eNbMag, eMarge, onRow, timings, onCode15Retry, + /** + * Inclure la master « Quantité N » (colonne de calibrage) ? + * + * ⚠️ Elle ne coûte rien quand elle vit, mais **annule tout le cube** + * quand elle est morte : mesuré en production, 4 expressions valides + * à 6 mois chacune et le cube complet à **0 ligne** après 25 s de + * calcul. L'appelant ne l'ajoute donc qu'après l'avoir sondée. + */ + avecMaster = true) => { + const largeur = avecMaster ? 7 : 6; + const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], + qMeasures: [ + { qDef: { qDef: eCa } }, + { qDef: { qDef: eQte } }, + { qDef: { qDef: eNbMag } }, + { qDef: { qDef: eMarge } }, + // Master « Quantité N », uniquement pour le calibrage : sur les + // mois de l'année en cours les deux doivent coïncider. + ...(avecMaster ? [{ qLibraryId: rQte }] : []), + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: PAGEX }], + } } }, doc, onCode15Retry), timings); + const qReturn = obj.qReturn; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const oh = cube.handle; + const process = (matrix) => { + for (const r of matrix) { + onRow(String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, avecMaster ? Number(r[6]?.qNum) || 0 : 0); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); + const hc = layout.qLayout.qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0); + if (firstPage?.qMatrix?.length) { + got += process(firstPage.qMatrix); + top = PAGEX; + } + for (; top < size; top += PAGEX) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: largeur, qHeight: PAGEX }] }, oh, onCode15Retry), timings); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (!matrix.length) + break; + got += process(matrix); + if (matrix.length < PAGEX) + break; + } + return got; + } + finally { + await destroyCube(cube, timings); + } + }; + /** + * Passe unique par expressions : sélectionne la fenêtre 12 mois et tous + * les codes, puis lit un seul cube [Article Code, Mois]. + * + * Renvoie false si le résultat est inexploitable (expression invalide, + * champ absent) — l'appelant repart alors sur les master measures. + */ + /** + * Total de contrôle : un cube 1 cellule, sans dimension, sur + * l'expression quantité. Sert à savoir si une sélection de dates + * FILTRE réellement les faits — ce que les master measures, qui + * l'ignorent, ne permettaient pas de vérifier. + */ + const totalDeControle = async () => { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-ctrl" }, + qHyperCubeDef: { + qDimensions: [], + qMeasures: [{ qDef: { qDef: exprQte } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], + }, + } }, doc); + const ret = o.qReturn; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const cell = lay.qLayout + .qHyperCube?.qDataPages?.[0]?.qMatrix?.[0]?.[0]; + return Number(cell?.qNum) || 0; + } + finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + /** + * Sélectionne la fenêtre sur un champ date candidat. + * + * Renvoie 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, valeurs) => { + let h; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = gf.qReturn.qHandle; + 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) { + await rpc("SelectValues", { + qFieldValues: valeurs.slice(i, i + 500).map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: i > 0, + qSoftLock: true, + }, h); + } + await sleep(qlikSettleMs); + return 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) => { + let objet = 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; + objet = { + handle: ret.qHandle, + id: String(ret.qGenericId ?? ret.qId ?? ""), + }; + const layout = await rpcWithRetry("layoutMonthList", "GetLayout", {}, objet.handle); + const lo = layout.qLayout.qListObject; + const size = Number(lo?.qSize?.qcy) || 0; + const cellules = []; + 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?.[0]?.qMatrix ?? []; + for (const row of matrix) + if (row[0]) + cellules.push(row[0]); + if (matrix.length === 0) + break; + } + const parMois = new Map(); + 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?.message || e)); + return null; + } + finally { + if (objet?.id) { + try { + await rpc("DestroySessionObject", { qId: objet.id }, doc); + } + catch { /* objet limité à la session */ } + } + } + }; + /** + * Cale le champ `Période` du modèle sur la fenêtre 12 mois glissants. + * + * L'app n'est pas un modèle « faits + calendrier » classique : TOUTES + * ses mesures sont bornées par un type de calendrier — + * + * « Quantité N » = Sum({} quantite) + * « Quantité COMP » = Sum({} quantite) + * + * `Type_Cal` marque les lignes de la période analysée (`N`) et celles + * de la période de comparaison (`COMP`), et c'est le champ `Période` + * (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. + * + * Cela explique tout ce qu'on observait : + * - sélectionner `Date` ne change RIEN (le périmètre vient du pont de + * périodes, pas du champ date) — mesuré : 100 % du total avant comme + * après ; + * - « Quantité N » ne renvoie que les mois de la période courante + * (janvier→juillet avec « année en cours »), d'où août→décembre + * vides ; + * - « Quantité COMP » ne couvre que les mois ayant un comparable dans + * cette période, d'où janvier→juillet de l'année précédente ; + * - `Sum(quantite)` brut mélange les lignes N et COMP : il double + * compte, et ne peut pas se substituer aux mesures. + * + * La solution n'est donc pas de contourner le modèle mais de le + * piloter : on essaie chaque valeur de `Période`, on mesure combien de + * mois de la fenêtre elle rend réellement disponibles, et on garde la + * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée + * qui décide. + */ + /** + * Vrai quand le périmètre articles est posé par une sélection + * fournisseur : dans ce cas, plus aucun `SelectValues` sur + * « Article Code » n'est émis (ni par passe annuelle, ni par + * l'agrégation directe) — c'est tout l'intérêt de la bascule. + */ + let parFournisseur = false; + /** + * Sélectionne LE fournisseur plutôt que ses dizaines de milliers de + * codes articles. + * + * `SelectValues` sur « Article Code » coûte 8 à 100 s **par appel**, + * quasi indépendamment du nombre de valeurs — le moteur balaie un + * symbole de plus d'un million d'entrées — et il faut le refaire à + * chaque passe annuelle. Une valeur de fournisseur produit le même + * périmètre en un appel trivial. + * + * La bascule n'est retenue QUE si la sélection couvre les codes + * demandés : un article rattaché à plusieurs fournisseurs, ou un + * référencement Qlik différent du référencement FF, doit faire + * revenir à la sélection par codes plutôt que perdre des articles. + */ + const selectionnerParFournisseur = async () => { + if (!fournisseurCode || codes.length === 0) + return false; + const attendus = new Set(codes.map((c) => c.trim())); + /** + * Au-delà de ce nombre de codes visibles, la sélection fournisseur + * ne restreint rien (elle laisse tout le catalogue) : inutile de + * finir la lecture, la réponse est déjà connue. + */ + const plafond = attendus.size * 3; + /** Codes articles visibles sous la sélection courante. */ + const lireCodesVisibles = async () => { + const vus = new Set(); + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-codes-fou" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim, qNullSuppression: true }], + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 5000 }], + }, + } }, doc); + const ret = o.qReturn; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = lay.qLayout.qHyperCube; + const total = Number(hc?.qSize?.qcy) || 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") + vus.add(c); + } + for (let top = vus.size ? 5000 : 0; top < total; top += 5000) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(5000, total - top) }], + }, ret.qHandle); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (matrix.length === 0) + break; + for (const r of matrix) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") + vus.add(c); + } + // Inutile de lire tout le catalogue pour conclure : au-delà + // du plafond, la sélection ne restreint manifestement rien. + // Mesuré : 1 207 205 codes lus en 240 pages, ×3 champs = + // 87 s perdues avant un rejet joué d'avance. + if (vus.size > plafond) + break; + } + return vus; + } + finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + for (const champ of champsFournisseurCandidats) { + let h = -1; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = gf.qReturn.qHandle; + if (typeof h !== "number" || h < 0) + continue; + } + catch { + continue; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: fournisseurCode }], + qToggleMode: false, + qSoftLock: true, + }, h); + await sleep(qlikSettleMs); + if (sel.qReturn === false) { + diag(" champ « " + champ + " » : valeur « " + fournisseurCode + " » inconnue"); + await rpc("Clear", {}, h); + continue; + } + const visibles = await lireCodesVisibles(); + // Un vrai périmètre fournisseur est de l'ordre de grandeur des + // codes demandés. Mesuré en production : « J009 » laissait + // 1 207 205 articles visibles, soit tout le catalogue — la + // sélection n'avait rien restreint, et la « couverture » de 97 % + // ne faisait que constater que le catalogue contient nos codes. + if (visibles.size > plafond) { + diag(" champ « " + champ + " » = « " + fournisseurCode + " » → plus de " + plafond + + " articles visibles pour " + attendus.size + " demandés : la sélection ne restreint rien, ignorée"); + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + continue; + } + let couverts = 0; + for (const c of attendus) + if (visibles.has(c)) + couverts++; + const couverture = attendus.size > 0 ? couverts / attendus.size : 0; + diag(" champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + + " articles Qlik, couvre " + couverts + "/" + attendus.size + + " codes demandés (" + Math.round(couverture * 100) + "%)"); + if (couverture >= couvertureFournisseurMin) { + diag("sélection PAR FOURNISSEUR retenue sur « " + champ + " » — " + attendus.size + " SelectValues économisés par passe"); + return true; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + diag("aucun champ fournisseur ne couvre les codes demandés → sélection par codes articles"); + return false; + }; + /** Valeur de `Période` à poser avant la passe d'une année donnée. */ + const periodeParAnnee = new Map(); + /** + * Pose (ou efface, si `texte` est nul) la sélection `Période`. + * + * Passe par le HANDLE DU CHAMP, comme pour `Année` et `Date` : c'est la + * seule API qui expose `Clear` et `SelectValues`. Ne propage jamais + * d'erreur — une `Période` qu'on n'arrive pas à poser doit dégrader le + * résultat, pas interrompre l'extraction (leçon de production : un + * `Clear` en échec a fait avorter deux chemins entiers). + */ + const appliquerPeriode = async (texte) => { + if (pfh === -1) + return texte == null; + try { + await rpc("Clear", {}, pfh); + await sleep(qlikSettleMs); + if (texte == null) + return true; + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: texte }], + qToggleMode: false, + qSoftLock: true, + }, pfh); + await sleep(qlikSettleMs); + return sel.qReturn !== false; + } + catch (e) { + diag(" sélection Période « " + (texte ?? "(aucune)") + " » impossible : " + String(e?.message || e)); + return false; + } + }; + /** + * Choisit la valeur de `Période` sous laquelle l'extraction aura lieu. + * + * CE QUE DIT LA MESURE (production, 2026-08-01, sonde paginée) : + * + * Période « (aucune) » → master exposée : 2024-01…2026-07 (31 mois) + * + * Sans aucune `Période` sélectionnée, « Quantité N » couvre **31 mois**, + * et une seule passe (`Année=2026`) sert les 12 mois de la fenêtre. + * L'état libre est donc le bon — encore fallait-il pouvoir le voir. + * + * Deux erreurs successives l'avaient masqué, toutes deux dans l'outil + * de mesure et non dans le modèle Qlik : + * 1. la sonde ne lisait que les 60 premières lignes du cube `[Mois]`, + * donc jamais les mois récents → « 0/12 » pour toute valeur ; + * 2. `Clear` était appelée sur le list object au lieu du champ, ce qui + * faisait avorter la cartographie entière avant son premier essai. + * + * D'où le principe retenu ici : **ne rien présumer, tout mesurer**. La + * couverture est évaluée par année et dans le contexte exact où elle + * sera exploitée (`monthDimPath` pose la même valeur), l'état sans + * `Période` étant un candidat comme un autre — et, en l'occurrence, le + * gagnant. + */ + const choisirPeriode = async (annees) => { + const fenetre = new Set(monthlyPayload.map((m) => m.label)); + let lo = null; + try { + const obj = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periodes" }, + qListObjectDef: { + qDef: { qFieldDefs: ["Période"] }, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 50 }], + }, + } }, doc); + lo = obj.qReturn; + const lay = await rpc("GetLayout", {}, lo.qHandle); + const matrix = (lay.qLayout + .qListObject?.qDataPages?.[0]?.qMatrix) ?? []; + const valeurs = matrix + .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 0; + /** + * Couverture réelle : quels mois « Quantité N » rend-elle vivants ? + * + * ⚠️ Le cube est PAGINÉ intégralement. La version précédente ne + * lisait que les 60 premières lignes : la dimension Mois porte + * tout l'historique de l'app, les mois récents tombaient hors + * de la première page, et la sonde annonçait 0/12 quelle que + * soit la `Période` — alors que la master vivait (mesuré : + * total 9 721 153). C'est ce faux zéro qui faisait effacer + * `Période` et condamnait toute l'extraction. + * + * Renvoie les mois vivants DE LA FENÊTRE, et journalise aussi + * ce que la dimension expose hors fenêtre : sans cela on ne + * distingue pas « le modèle ne donne rien » de « le modèle + * donne autre chose que ce qu'on attend ». + */ + const mesurerCouverture = async () => { + const sonde = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periode-sonde" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 500 }], + }, + } }, doc); + const sRet = sonde.qReturn; + try { + const sl = await rpc("GetLayout", {}, sRet.qHandle); + const hc = sl.qLayout.qHyperCube; + const lignes = [...(hc?.qDataPages?.[0]?.qMatrix ?? [])]; + const total = Number(hc?.qSize?.qcy) || 0; + for (let top = lignes.length; top < total; top += 500) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 2, qHeight: Math.min(500, total - top) }], + }, sRet.qHandle); + const m = d.qDataPages?.[0]?.qMatrix ?? []; + if (m.length === 0) + break; + lignes.push(...m); + } + const vivants = lignes + .filter((r) => (Number(r[1]?.qNum) || 0) > 0) + .map((r) => normMois(String(r[0]?.qText ?? ""))) + .sort(); + return { fenetre: vivants.filter((m) => fenetre.has(m)), tous: vivants }; + } + finally { + const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); + if (sid) { + try { + await rpc("DestroySessionObject", { qId: sid }, doc); + } + catch { /* noop */ } + } + } + }; + /** Résumé compact d'une liste de mois : « 2025-01…2025-12 (12) ». */ + const resume = (mois) => mois.length === 0 ? "aucun" : mois[0] + "…" + mois[mois.length - 1] + " (" + mois.length + ")"; + /** + * Couverture d'une `Période` telle qu'elle sera RÉELLEMENT + * exploitée : une passe par année, comme `monthDimPath`, puis + * union. C'est le seul chiffre qui a un sens ici — mesurer + * sans année revient à juger « Année à date » sur la seule + * année en cours (6/12) et à la rejeter à tort. + */ + // ─── Une `Période` PAR ANNÉE ───────────────────────────── + // + // C'est le point que la version globale manquait : la bonne + // valeur dépend de l'année analysée. « Année à date » convient + // à 2026 (janvier → mois courant) mais pas à 2025, dont on veut + // les 12 mois. On cartographie donc, pour chaque année de la + // fenêtre, la valeur qui sert le PLUS de mois attendus de cette + // année-là — l'état sans `Période` étant un candidat comme un + // autre. + const couvertsGlobal = new Set(); + for (const annee of annees) { + const attendusAnnee = [...fenetre].filter((m) => m.startsWith(String(annee) + "-")); + diag(" année " + annee + " — mois attendus " + JSON.stringify(attendusAnnee.sort())); + if (yfh !== -1) { + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { + qFieldValues: [{ qNum: annee, qText: String(annee) }], + qToggleMode: false, + qSoftLock: true, + }, yfh); + await sleep(qlikSettleMs); + } + // `null` = état sans `Période`, candidat comme un autre. + const candidats = [null, ...valeurs.map((v) => v.texte)]; + let meilleure = null; + for (const c of candidats) { + const libelle = c ?? "(aucune)"; + // Un candidat qui échoue ne doit pas emporter toute la + // cartographie : on le note et on passe au suivant. + let servis = []; + try { + const pose = await appliquerPeriode(c); + if (!pose && c !== null) { + diag(" Période « " + libelle + " » → non sélectionnable, ignorée"); + continue; + } + const mois = await mesurerCouverture(); + servis = mois.fenetre.filter((m) => attendusAnnee.includes(m)); + diag(" Période « " + libelle + " » → sert " + servis.length + "/" + attendusAnnee.length + + " mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous)); + } + catch (e) { + diag(" Période « " + libelle + " » → erreur " + String(e?.message || e)); + continue; + } + if (!meilleure || servis.length > meilleure.servis.length) { + meilleure = { texte: c, servis }; + } + if (servis.length >= attendusAnnee.length) + break; // année complète + } + if (meilleure) { + periodeParAnnee.set(annee, { texte: meilleure.texte }); + for (const m of meilleure.servis) + couvertsGlobal.add(m); + diag(" → année " + annee + " sera extraite sous Période « " + (meilleure.texte ?? "(aucune)") + " » (" + + meilleure.servis.length + "/" + attendusAnnee.length + " mois)"); + } + } + if (yfh !== -1) { + // Chaque passe pose sa propre année. + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + } + return couvertsGlobal.size; + } + catch (e) { + diag("choix de « Période » impossible : " + String(e?.message || e)); + return 0; + } + finally { + // Le list object est conservé jusqu'au bout : le détruire + // annulerait la sélection qu'il porte. + void lo; + } + }; + /** + * Teste UNE expression isolément sur un petit cube `[Mois]`. + * + * Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à + * tout un hypercube — observé en production : le cube à 5 mesures + * calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)` seul + * donnait 35 424 324. Impossible de savoir laquelle sans les isoler. + * + * Renvoie l'expression si elle produit des lignes, sinon `"0"` — une + * colonne neutre qui préserve les indices sans casser le cube. + */ + const validerExpression = async (nom, expr) => { + let ret = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-test-expr" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qDef: { qDef: expr } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], + }, + } }, doc); + ret = o.qReturn; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = lay.qLayout.qHyperCube; + const matrix = hc?.qDataPages?.[0]?.qMatrix ?? []; + const lignes = Number(hc?.qSize?.qcy) || 0; + let total = 0; + let texteErreur = ""; + for (const r of matrix) { + total += Number(r[1]?.qNum) || 0; + const t = String(r[1]?.qText ?? ""); + if (!texteErreur && /error|erreur|garbage|^-$/i.test(t)) + texteErreur = t; + } + if (hc?.qError) + texteErreur = texteErreur || JSON.stringify(hc.qError); + if (lignes === 0 || texteErreur) { + diag(" mesure « " + nom + " » = " + expr + " → INVALIDE (" + (texteErreur || "cube vide") + ")"); + return "0"; + } + diag(" mesure « " + nom + " » = " + expr + " → " + lignes + " mois, total=" + Math.round(total)); + return expr; + } + catch (e) { + diag(" mesure « " + nom + " » = " + expr + " → ERREUR " + String(e?.message || e)); + return "0"; + } + finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + /** + * La master « Quantité N » produit-elle la moindre ligne sur `[Mois]` + * dans l'état de sélection courant ? Même sonde que + * `validerExpression`, mais par `qLibraryId`. + */ + const masterQteVivante = async () => { + let ret = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-test-master" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], + }, + } }, doc); + ret = o.qReturn; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = lay.qLayout.qHyperCube; + let total = 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) + total += Number(r[1]?.qNum) || 0; + diag(" master « Quantité N » (calibrage) → total=" + Math.round(total)); + return total > 0; + } + catch (e) { + diag(" master « Quantité N » → ERREUR " + String(e?.message || e)); + return false; + } + finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + /** + * Somme de `expr` VENTILÉE par la dimension Mois. + * + * Comparée au total sans dimension, elle dit si la dimension Mois + * démultiplie les faits à travers le pont de périodes — auquel cas + * l'agrégation directe est inexploitable. + */ + const totalVentileParMois = async (expr) => { + let ret = null; + try { + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-ventil-mois" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qDef: { qDef: expr } }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 200 }], + }, + } }, doc); + ret = o.qReturn; + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = lay.qLayout.qHyperCube; + let total = 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) + total += Number(r[1]?.qNum) || 0; + return total; + } + catch { + return 0; + } + finally { + const id = String(ret?.qGenericId ?? ret?.qId ?? ""); + if (id) { + try { + await rpc("DestroySessionObject", { qId: id }, doc); + } + catch { /* noop */ } + } + } + }; + /** + * Cube `[Article Code] × 4 expressions`, SANS dimension Mois. + * + * C'est tout l'intérêt : la dimension Mois passe par le pont de + * périodes et rattache une même ligne de fait à plusieurs contextes + * (mesuré : ×5,3). Sans elle, `Sum(quantite)` porte une fois et une + * seule sur les faits visibles — donc sur le mois sélectionné. + */ + const fetchCodeCubeExpr = async (eCa, eQte, eNbMag, eMarge, onRow, timings) => { + const LARGEUR = 5; + const PAGE = 1800; // 5 × 1800 = 9000 cellules < 10000 + const { value: obj } = await timed("createCubeCode", "CreateSessionObject", () => rpcWithRetry("createCubeCode", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-code-expr" }, qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim }], + qMeasures: [ + { qDef: { qDef: eCa } }, + { qDef: { qDef: eQte } }, + { qDef: { qDef: eNbMag } }, + { qDef: { qDef: eMarge } }, + ], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }], + } } }, doc), timings); + const qReturn = obj.qReturn; + const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; + const traiter = (matrix) => { + for (const r of matrix) { + onRow(String(r[0]?.qText ?? ""), Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0); + } + return matrix.length; + }; + try { + const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, cube.handle), timings); + const hc = layout.qLayout.qHyperCube; + const size = hc.qSize.qcy; + let got = 0, top = 0; + const p0 = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); + if (p0?.qMatrix?.length) { + got += traiter(p0.qMatrix); + top = PAGE; + } + for (; top < size; top += PAGE) { + const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }] }, cube.handle), timings); + const matrix = d.qDataPages?.[0]?.qMatrix ?? []; + if (!matrix.length) + break; + got += traiter(matrix); + if (matrix.length < PAGE) + break; + } + return got; + } + finally { + await destroyCube(cube, timings); + } + }; + /** + * EXTRACTION MOIS PAR MOIS SUR LE CHAMP DATE BRUT. + * + * Le chemin que rien n'avait encore essayé, et le seul qui n'emprunte + * ni le pont de périodes ni les master measures : + * + * - on sélectionne les jours d'UN mois sur le champ date ; + * - on lit un cube `[Article Code]` sans dimension Mois, donc sans + * démultiplication ; + * - `Type_Cal`, `Période` et `Année` sont effacés : les expressions + * brutes n'ont pas besoin du pont, elles agrègent les faits visibles. + * + * Il n'y a donc plus de « mois inatteignable » : chaque mois est un + * filtre de dates, pas un contexte de calendrier. + * + * CONTRÔLE D'INTÉGRITÉ. La somme des 12 mois ne doit pas dépasser le + * total sans filtre de date : si la dimension ou le champ démultipliait + * les faits, elle exploserait (le pont donne ×5,3). Et chaque mois doit + * être servi. Sinon rien n'est écrit. + */ + const moisParDateBrute = async () => { + const timings = {}; + // Aucun contexte de calendrier : on veut les faits nus. + if (yfh !== -1) { + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + } + await appliquerPeriode(null); + if (dfh !== -1) { + await rpc("Clear", {}, dfh); + await sleep(qlikSettleMs); + } + if (codes.length && fh !== -1 && !parFournisseur) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh, () => { aggCode15Retries++; }), timings); + await sleep(qlikSettleMs); + } + const sansFiltre = await totalDeControle(); + diag("[date brute] contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); + if (sansFiltre <= 0) { + diag("[date brute] ÉCHEC : l'expression quantité ne renvoie rien (corriger QLIK_EXPR_QTE)"); + return false; + } + // ─── Quel champ date filtre les faits ? ────────────────── + // + // ⚠️ On teste avec UN SEUL MOIS, pas avec la fenêtre entière. + // Toute la table de faits tient dans les 12 mois (mesuré : 100 % + // du total) : sélectionner la fenêtre ne réduit donc RIEN, et + // l'ancien test « le total doit baisser » rejetait tous les + // champs, y compris les bons. + const moisTest = monthlyPayload[Math.floor(monthlyPayload.length / 2)]; + let champDate = null; + let handleDate = -1; + for (const cand of champsDateCandidats) { + const valeurs = /key/i.test(cand) ? moisTest.ymd : moisTest.serials; + const h = await selectionnerFenetreSur(cand, valeurs); + if (h == null) { + diag("[date brute] champ « " + cand + " » : inexistant"); + continue; + } + const t = await totalDeControle(); + const part = sansFiltre > 0 ? t / sansFiltre : 0; + diag("[date brute] champ « " + cand + " » sur le seul mois " + moisTest.label + " → " + + Math.round(t) + " (" + (part * 100).toFixed(1) + "% du total)"); + // Un mois isolé doit peser une fraction du total : ni zéro + // (le champ ne touche pas les faits) ni la totalité (il ne + // filtre pas). + if (t > 0 && part < 0.5) { + champDate = cand; + handleDate = h; + break; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + if (!champDate || handleDate === -1) { + diag("[date brute] ÉCHEC : aucun champ date ne filtre les faits mois par mois " + + "(essayés : " + JSON.stringify(champsDateCandidats) + ")"); + return false; + } + diag("[date brute] champ date retenu : « " + champDate + " »"); + // À partir d'ici la série sera reconstruite entièrement : les + // passes annuelles (master measures) et la série brute n'ont pas + // la même sémantique, on ne les mélange pas. + out.length = 0; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + const totauxMois = {}; + let sommeMois = 0; + for (let i = 0; i < monthlyPayload.length; i++) { + const m = monthlyPayload[i]; + const valeurs = /key/i.test(champDate) ? m.ymd : m.serials; + await rpc("Clear", {}, handleDate); + await sleep(qlikSettleMs); + await rpcWithRetry("selectDate", "SelectValues", { + qFieldValues: valeurs.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: false, + qSoftLock: true, + }, handleDate, () => { aggCode15Retries++; }); + await sleep(qlikSettleMs); + let totalDuMois = 0; + const lignes = await fetchCodeCubeExpr(exprCa, exprQte, exprNbMag, exprMarge, (code, ca, qte, nbMag, marge) => { + if (!code || code === "-") + return; + if (qte === 0 && ca === 0) + return; + totalDuMois += qte; + out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); + (monthlyByCode[code] ??= {}); + monthlyByCode[code][m.label] = { + qte, ca, nbMag, + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: ca > 0 ? marge / ca : 0, + }; + }, timings); + totauxMois[m.label] = totalDuMois; + sommeMois += totalDuMois; + aggBatches++; + console.log("[qlik-pw][date brute] mois " + (i + 1) + "/" + monthlyPayload.length + " " + m.label + + " → " + lignes + " articles, quantité=" + Math.round(totalDuMois)); + await pousserCheckpoint("date brute " + m.label); + } + aggMonths = monthlyPayload.length; + diag("[date brute] quantités par mois : " + JSON.stringify(Object.fromEntries(Object.entries(totauxMois).map(([k, v]) => [k, Math.round(v)])))); + // ─── Intégrité ─────────────────────────────────────────── + const vides = Object.entries(totauxMois).filter(([, v]) => v <= 0).map(([k]) => k); + if (vides.length) { + diag("[date brute] ÉCHEC : mois sans aucune vente " + JSON.stringify(vides)); + return false; + } + if (sommeMois > sansFiltre * 1.02) { + diag("[date brute] ÉCHEC : la somme des 12 mois (" + Math.round(sommeMois) + + ") dépasse le total sans filtre (" + Math.round(sansFiltre) + ") — les faits sont " + + "démultipliés, les ventes réseau seraient fausses"); + return false; + } + diag("[date brute] OK — somme des 12 mois = " + Math.round(sommeMois) + " pour un total sans filtre de " + + Math.round(sansFiltre) + " (" + Math.round((sommeMois / sansFiltre) * 100) + "%), " + + Object.keys(monthlyByCode).length + " articles"); + console.log("[qlik-pw][date brute] timings=" + timingSummary(timings)); + return true; + }; + const moisParExpressions = async () => { + const timings = {}; + const marqueOut = out.length; + try { + if (yfh !== -1) { + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + } + // Les codes d'abord : les totaux de contrôle portent alors sur + // le même périmètre que l'extraction. Sauf si le fournisseur + // pose déjà ce périmètre — le re-sélectionner par codes ne + // changerait rien au résultat et coûterait une minute. + if (codes.length && fh !== -1 && !parFournisseur) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh, () => { aggCode15Retries++; }), timings); + await sleep(qlikSettleMs); + } + // ─── Quel champ date filtre RÉELLEMENT les faits ? ─────── + // + // Les master measures ignorant la sélection Date, personne + // n'avait jamais pu vérifier que « Date » filtrait quoi que + // ce soit. Un champ valide doit donner un total non nul ET + // strictement inférieur au total sans filtre. + const sansFiltre = await totalDeControle(); + diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); + if (sansFiltre <= 0) { + diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)"); + return false; + } + 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 = null; + let totalFenetre = 0; + for (const cand of champsDateCandidats) { + const valeurs = /key/i.test(cand) ? allYmd : allSerials; + 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)"); + // ⚠️ 100 % ne veut PAS dire « ce champ ne filtre rien » : toute + // la table de faits tient dans les 12 mois (mesuré), donc + // sélectionner la fenêtre entière ne retire rien. Exiger une + // baisse rejetait donc les champs valides. Ici la sélection + // ne sert qu'à borner la fenêtre : un total non nul suffit, + // et on préfère simplement un champ qui filtre s'il existe. + if (filtre > 0) { + champDate = cand; + totalFenetre = filtre; + if (ratio < 0.995) + break; + diag(" (la fenêtre couvre 100 % des faits — normal si l'app ne porte que ces 12 mois)"); + 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 : 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; + } + diag("champ date retenu : « " + champDate + " »"); + // Chaque expression est validée SÉPARÉMENT : une seule mesure + // invalide fait rendre zéro ligne à tout l'hypercube, sans + // qu'aucun message ne dise laquelle. + diag("validation des expressions, une par une :"); + const vQte = await validerExpression("quantité", exprQte); + if (vQte === "0") { + diag("ÉCHEC : l'expression quantité est inutilisable, rien à extraire"); + return false; + } + const vCa = await validerExpression("CA", exprCa); + const vNbMag = await validerExpression("magasins", exprNbMag); + const vMarge = await validerExpression("marge", exprMarge); + // La master « Quantité N » n'est là que pour le calibrage, mais + // une master morte suffit à vider tout l'hypercube. On la sonde + // donc comme les autres, et on l'omet plutôt que de perdre + // l'extraction entière pour une colonne de contrôle. + const avecMaster = await masterQteVivante(); + if (!avecMaster) { + diag(" master « Quantité N » morte sous cette sélection → colonne de calibrage retirée du cube"); + } + // ─── La dimension Mois démultiplie-t-elle les faits ? ──── + // + // Le pont de périodes rattache une même ligne de fait à + // plusieurs contextes. Ventilé par `Mois`, `Sum(quantite)` peut + // donc dépasser le total sans dimension — mesuré : 179 574 616 + // contre 33 945 285, soit ×5,3. Écrire cela dans le cache + // donnerait des ventes réseau cinq fois trop hautes : on refuse. + const totalVentile = await totalVentileParMois(vQte); + if (totalVentile > totalFenetre * 1.05) { + diag("ÉCHEC : ventilée par Mois, « " + vQte + " » totalise " + Math.round(totalVentile) + + " contre " + Math.round(totalFenetre) + " sans dimension (×" + + (totalFenetre > 0 ? (totalVentile / totalFenetre).toFixed(1) : "?") + + "). La dimension Mois passe par le pont de périodes et démultiplie les faits — " + + "l'agrégation directe donnerait des ventes réseau fausses, elle est refusée."); + return false; + } + // Seuls les mois de la fenêtre nous intéressent ; la dimension + // Mois peut en exposer d'autres si une sélection déborde. + let totalQte = 0; + let calibrees = 0, calibrOk = 0; + let horsFenetre = 0; + let echantillon = false; + const got = await timed("cube", "expr", () => fetchMonthCubeExpr(vCa, vQte, vNbMag, vMarge, (code, mois, ca, qte, nbMag, marge, qteMaster) => { + if (!code || code === "-") + return; + const mm = normMois(mois); + if (!echantillon) { + echantillon = true; + // Échantillon AVANT filtrage : si le mois ne tombe pas dans + // la fenêtre, c'est le format de la dimension Mois qui est + // en cause, pas les mesures. + diag("échantillon brut: " + JSON.stringify({ + code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), + ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, + })); + } + if (!moisFenetre.has(mm)) { + horsFenetre++; + return; + } + // Calibrage sur l'année en cours, seul périmètre où la master + // measure est censée être juste. + if (avecMaster && parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { + calibrees++; + if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) + calibrOk++; + } + totalQte += qte; + // 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: qteCumulee, + ca: caCumule, + nbMag: Math.max(precedent?.nbMag ?? 0, nbMag), + caMag: nbMag > 0 ? ca / nbMag : 0, + margePct: caCumule > 0 ? margeMontant / caCumule : 0, + }; + }, timings, undefined, avecMaster), timings); + aggMonths = monthlyPayload.length; + aggBatches++; + diag(got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »"); + console.log("[qlik-pw][expr] timings=" + timingSummary(timings)); + if (totalQte <= 0) { + diag("ÉCHEC : quantité totale nulle sur la fenêtre alors que le contrôle global valait " + + Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + + "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus."); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + return false; + } + // 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 = {}; + 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 + + " diverge de « Quantité N ». Vérifier l'expression (QLIK_EXPR_QTE)."); + } + return true; + } + catch (e) { + diag("ÉCHEC : " + String(e?.message || e) + " → repli sur les master measures"); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + return false; + } + }; + // Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par + // lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup. + // selectYear = null → passe principale (année N + N-1 dérivée via COMP). + // selectYear = 2025 → passe complémentaire : on sélectionne l'année pour + // récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que + // COMP ne peut pas fournir puisque l'année N ne va pas jusque-là). + let loggedSampleRow = false; + const monthDimPath = async (selectYear) => { + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); + /** + * Traitement d'une ligne du cube [Article Code, Mois]. + * + * Une passe = UNE année explicitement sélectionnée, exactement ce + * que fait un utilisateur dans l'app (« je sélectionne 2026 et + * j'obtiens N + la comparaison N-1 »). Chaque passe n'écrit que + * les mois DE LA FENÊTRE, et les totaux s'additionnent sur ces + * mois-là : deux années différentes ne peuvent donc pas se + * doubler, puisqu'elles ne partagent aucun mois. + */ + const onRowMois = (code, mois, ca, qte, nbMag, caMag, marge, qteComp) => { + if (!code || code === "-") + return; + const mm = normMois(mois); + if (!moisFenetre.has(mm)) + return; + out.push([code, ca, qte, nbMag, caMag, marge]); + const y = parseInt(mm.slice(0, 4), 10); + if (!loggedSampleRow) { + loggedSampleRow = true; + console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({ + code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte, + "Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp, + })); + } + (monthlyByCode[code] ??= {}); + // Les 5 mesures du mois courant de la ligne. Le cube les renvoie + // déjà toutes : les conserver ne coûte aucune requête Qlik en plus. + const full = { qte, ca, nbMag, caMag, margePct: marge }; + // « Quantité COMP » n'est plus nécessaire : chaque année de la + // fenêtre est extraite directement par sa propre passe, ce qui + // donne les 5 mesures et non la seule quantité. + void y; + void qteComp; + // Une passe ultérieure ne doit JAMAIS écraser un mois servi par + // un zéro : hors de son année, la passe voit la combinaison + // (article, mois) mais toutes ses mesures sont nulles. + const precedent = monthlyByCode[code][mm]; + if (precedent && (precedent.qte ?? 0) > 0 && qte <= 0) + return; + monthlyByCode[code][mm] = full; + }; + if (yfh === -1) + throw new Error("champ « Année » introuvable : impossible d'extraire année par année"); + // La `Période` qui sert cette année-là — pas la même pour toutes : + // « Année à date » convient à l'année en cours, une autre valeur à + // une année révolue (cf. choisirPeriode). + const periodeAnnee = periodeParAnnee.get(selectYear); + if (periodeAnnee) { + console.log("[qlik-pw] (mois) Période « " + (periodeAnnee.texte ?? "(aucune)") + " » pour l'année " + selectYear); + await appliquerPeriode(periodeAnnee.texte); + } + await rpc("Clear", {}, yfh); + await sleep(qlikSettleMs); + const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); + console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn)); + await sleep(qlikSettleMs); + const allSerials = monthlyPayload.flatMap((m) => m.serials); + if (dfh !== -1 && allSerials.length) { + await rpc("Clear", {}, dfh); + await sleep(qlikSettleMs); + await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh); + await sleep(qlikSettleMs); + } + aggMonths = monthlyPayload.length; + // ─── Chemin rapide : UNE seule sélection Article Code ──── + // + // Mesuré en production : `SelectValues` sur « Article Code » + // coûte 8 à 100 s **par appel**, quasi indépendamment du + // nombre de valeurs (le moteur balaie un symbole de plus d'un + // million d'entrées). Avec 7 200 codes en lots de 300, cela + // faisait 24 appels, ~15 min de sync, et la session Qlik + // mourait avant la fin (« Socket closed » puis « Execution + // context was destroyed »). + // + // La dimension Mois donne déjà tous les mois d'un coup : rien + // n'oblige à découper les codes. On tente donc un seul appel + // pour tous les codes et un seul cube paginé — la lecture des + // pages, elle, coûte ~50 ms. Repli sur les lots si l'Engine + // refuse (cube trop gros, code 15, saturation mémoire). + const marqueOut = out.length; + if ((codes.length || parFournisseur) && fh !== -1) { + const timingsAll = {}; + try { + // Le périmètre fournisseur est posé une fois pour toutes + // avant la première passe : il survit aux sélections + // Année/Date, qui portent sur d'autres champs. + if (!parFournisseur) { + await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timingsAll); + await sleep(qlikSettleMs); + } + const got = await timed("cube", "all", () => fetchMonthCube(onRowMois, timingsAll), timingsAll); + aggBatches++; + console.log("[qlik-pw][timing] (mois) chemin rapide — " + + (parFournisseur ? "périmètre fournisseur (0 sélection code)" : codes.length + " codes en 1 sélection") + + ", rows=" + got.value + + " cumul=" + out.length + " timings=" + timingSummary(timingsAll)); + return; + } + catch (e) { + // Les lignes déjà lues seraient recomptées par le repli : + // on les retire (les écritures mensuelles, elles, sont + // des affectations idempotentes). + out.length = marqueOut; + console.log("[qlik-pw] (mois) chemin rapide échoué → repli par lots : " + String(e?.message || e)); + } + } + // Lots plus petits = cubes plus légers (moins de pression mémoire côté + // moteur Qlik, qui a tendance à saturer "Out of memory" sur les gros cubes). + const fallbackSizes = [300, 150, 75]; + const totalCodes = codes.length; + let codeIndex = 0, batchNumber = 0, fallbackIndex = 0; + while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { + const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + fallbackSizes[fallbackIndex], totalCodes)) : []; + const timings = {}; + let code15Retries = 0; + batchNumber++; + const bp = "[qlik-pw] (mois) lot " + batchNumber + " [" + codeIndex + "," + (codeIndex + batch.length) + ") size=" + batch.length; + try { + if (batch.length && fh !== -1) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + await sleep(qlikSettleMs); + await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); + await sleep(qlikSettleMs); + } + const got = await timed("cube", "batch", () => fetchMonthCube(onRowMois, timings, () => { code15Retries++; }), timings); + aggBatches++; + aggCode15Retries += code15Retries; + console.log("[qlik-pw][timing] " + bp + " — rows=" + got.value + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + if (!codes.length) + break; + codeIndex += batch.length; + fallbackIndex = 0; + } + catch (batchErr) { + const nextSize = fallbackSizes[fallbackIndex + 1]; + if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { + console.log("[qlik-pw] " + bp + " — fallback code15 " + batch.length + "→" + nextSize); + fallbackIndex += 1; + aggFallbackCount++; + continue; + } + throw new Error(bp + " — " + String(batchErr?.message || batchErr)); + } + } + }; + // Chemin rapide (flag QLIK_SELECT_ALL_CODES) : sélectionne TOUS les + // Article Code une seule fois, puis n'itère que la sélection Date par + // mois avec UN cube paginé (au lieu de re-sélectionner les codes par lots + // de 150 à chaque mois — la SelectValues répétée coûte ~22 s × 120). + // Renvoie true si le mois complet a réussi ; false si repli code 15 (les + // résultats partiels et sélections sont réinitialisés avant le chemin par lots). + const trySelectAllCodesPath = async () => { + const savedLen = out.length; + try { + if (codes.length && fh !== -1) { + await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), {}); + await sleep(qlikSettleMs); + const { value: sel } = await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), {}); + console.log("[qlik-pw] select-all — SelectValues Article Code (" + codes.length + " codes) → " + JSON.stringify(sel.qReturn)); + await sleep(qlikSettleMs); + } + const total = monthlyPayload.length; + console.log("[qlik-pw] chemin rapide select-all — " + total + " mois, 1 sélection Article Code globale"); + for (let mi = 0; mi < total; mi++) { + const m = monthlyPayload[mi]; + const prefix = "[qlik-pw] (rapide) mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; + const monthStart = nowMs(); + const monthTimings = {}; + aggMonths = mi + 1; + let code15Retries = 0; + // Sélection Date du mois uniquement (la sélection Article Code reste active). + const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { + qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), + qToggleMode: false, + qSoftLock: true, + }, dfh), monthTimings); + console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); + await sleep(qlikSettleMs); + // Un seul cube paginé pour tous les articles du mois. + const batchRows = []; + const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings); + out.push(...batchRows); + accMonthly(batchRows, m.label); + aggBatches++; + aggCode15Retries += code15Retries; + console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings)); + // Clear Date uniquement — on garde la sélection Article Code pour le mois suivant. + if (mi < total - 1) { + try { + await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); + } + catch (e) { + console.log(prefix + " — Clear Date ignoré: " + String(e?.message || e)); + } + } + console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " timings=" + timingSummary(monthTimings)); + } + return true; + } + catch (e) { + if (isEngineCode15(e)) { + console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots"); + out.length = savedLen; + for (const k of Object.keys(monthlyByCode)) + delete monthlyByCode[k]; + aggMonths = 0; + aggBatches = 0; + aggCode15Retries = 0; + try { + await rpc("Clear", {}, dfh); + if (fh !== -1) + await rpc("Clear", {}, fh); + } + catch { /* noop */ } + await sleep(qlikSettleMs); + return false; + } + throw e; + } + }; + // La passe de « rattrapage des mois vides » a été SUPPRIMÉE. + // + // Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. + // Or les master measures ignorent la sélection Date : le cube lui + // renvoyait le total de période, qu'elle recopiait dans chacun des + // mois manquants. D'où les plateaux identiques d'août à décembre + // (87 648 sur douze mois pour un article, etc.) et les tendances + // « -162 % » qu'ils fabriquaient. + // + // Un mois qu'on ne sait pas extraire doit rester ABSENT. + /** Pousse l'état courant vers Node (voir `cfCheckpoint`). */ + const pousserCheckpoint = async (etape) => { + try { + const fn = window.cfCheckpoint; + if (!fn) + return; + await fn({ rows: out, monthly: monthlyByCode }); + console.log("[qlik-pw] point de contrôle « " + etape + " » : " + out.length + " lignes"); + } + catch (e) { + console.log("[qlik-pw] point de contrôle « " + etape + " » ignoré : " + String(e?.message || e)); + } + }; + if (!noDateMode) { + // ÉTAPE 0 — poser le périmètre articles, une fois pour toutes. + // + // Le code fournisseur FF (base SQL / API) existe aussi dans Qlik : + // une seule valeur y désigne le même périmètre que les dizaines de + // milliers de codes articles qu'on envoyait jusqu'ici. La bascule + // n'est retenue que si la couverture mesurée le justifie, sinon on + // retombe sur la sélection par codes (cf. selectionnerParFournisseur). + parFournisseur = await selectionnerParFournisseur(); + const attendus = monthlyPayload.map((m) => m.label); + // Années de la fenêtre, la PLUS RÉCENTE D'ABORD — c'est l'usage de + // l'app (« je sélectionne 2026 »), et cela met les mois les plus + // utiles en tête des points de contrôle. + const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort((a, b) => b - a); + // É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({} 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(annees); + // É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 moisManquants = () => { + const vus = new Set(); + for (const parMois of Object.values(monthlyByCode)) { + for (const [mois, v] of Object.entries(parMois)) { + if ((v?.qte ?? 0) > 0) + vus.add(mois); + } + } + return attendus.filter((m) => !vus.has(m)); + }; + const couvre12 = () => moisManquants().length === 0; + // ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app. + // + // Dans l'interface, on sélectionne « 2026 » et on obtient l'année + // plus sa comparaison N-1. C'est le mécanisme natif du modèle : + // `Type_Cal='N'` désigne l'année sélectionnée. Il suffit donc + // d'enchaîner les années couvertes par la fenêtre — ici 2025 puis + // 2026 — pour reconstituer les 12 mois glissants. + // + // Chaque passe est faite SOUS la `Période` retenue : c'est elle qui + // donne son sens à `Type_Cal='N'`. Avec `Année=2025`, « Année à + // date » vaut l'année 2025 entière (elle est passée) ; avec + // `Année=2026`, elle s'arrête au mois courant. L'union des deux + // passes reconstitue donc bien les 12 mois glissants. + diag("extraction année par année : " + JSON.stringify(annees)); + let extraitParAnnee = false; + if (monthDim) { + try { + for (const annee of annees) { + const manquantsAvant = moisManquants(); + await monthDimPath(annee); + const manquantsApres = moisManquants(); + const gagnes = manquantsAvant.filter((m) => !manquantsApres.includes(m)); + diag(" année " + annee + " → mois de la fenêtre désormais servis : " + JSON.stringify(gagnes) + + " ; encore manquants : " + JSON.stringify(manquantsApres)); + await pousserCheckpoint("année " + annee); + if (manquantsApres.length === 0) + break; // fenêtre complète, inutile de continuer + } + extraitParAnnee = couvre12(); + if (!extraitParAnnee) { + diag("l'extraction année par année ne couvre pas les 12 mois (manquants : " + + JSON.stringify(moisManquants()) + ") — essai de l'agrégation directe"); + } + } + catch (e) { + diag("extraction année par année interrompue : " + String(e?.message || e)); + } + } + // REPLI 1 — mois par mois sur le champ date brut. + // + // Passe en premier car c'est le seul chemin sans pont de périodes : + // aucun mois n'y est « inatteignable », puisqu'un mois y est un + // filtre de dates et non un contexte de calendrier. + if (!extraitParAnnee && useExpr) { + try { + if (await moisParDateBrute()) { + extraitParAnnee = couvre12(); + if (extraitParAnnee) { + diag("extraction par date brute retenue — les " + attendus.length + " mois sont servis"); + await pousserCheckpoint("date brute complète"); + } + else { + diag("extraction par date brute incomplète (manquants : " + JSON.stringify(moisManquants()) + ")"); + } + } + } + catch (e) { + diag("extraction par date brute interrompue : " + String(e?.message || e)); + } + } + if (!extraitParAnnee) { + if (!(monthDim && useExpr && (await moisParExpressions()))) { + throw new Error("extraction 12 mois refusée : ni l'extraction année par année " + + "(couverture master annoncée = " + couvertureMaster + "/" + attendus.length + ") " + + "ni l'agrégation directe des faits n'ont couvert la fenêtre — mois manquants : " + + JSON.stringify(moisManquants())); + } + await pousserCheckpoint("expressions vérifiées"); + } + if (!couvre12()) { + throw new Error("extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) + + " n'est pas entièrement couverte (manquants : " + JSON.stringify(moisManquants()) + + ") — cache laissé inchangé"); + } + } + 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. + const timings = {}; + let code15Retries = 0; + console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois"); + if (codes.length && fh !== -1) { + const { value: sel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); + console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn)); + } + const batchRows = []; + const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); + out.push(...batchRows); + aggMonths = 1; + aggBatches = 1; + aggCode15Retries = code15Retries; + console.log("[qlik-pw][timing] noDate rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + } + else if (selectAllCodes && (await trySelectAllCodesPath())) { + // Chemin rapide terminé avec succès (voir trySelectAllCodesPath). + // Si false (repli code 15), on tombe dans le chemin par lots ci-dessous. + } + else { + // Itération par mois × lots de codes : pour chaque mois, SelectValues Date + // (~30 serials), puis SelectValues Article Code par petits lots. Le cube + // reste borné à ~30 jours × batch contrôlé, sans lire tout le catalogue Qlik. + const total = monthlyPayload.length; + const fallbackSizes = qlikArticleBatchFallbacks.length ? qlikArticleBatchFallbacks : [150, 75, 50]; + const fallbackLabel = fallbackSizes.join(">"); + const estimatedBatches = codes.length ? Math.ceil(codes.length / fallbackSizes[0]) : 1; + console.log("itération par mois — " + total + " mois × ~" + estimatedBatches + " lot(s), batchFallbacks=" + fallbackLabel); + for (let mi = 0; mi < total; mi++) { + const m = monthlyPayload[mi]; + const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; + const monthStart = nowMs(); + const monthTimings = {}; + aggMonths = mi + 1; + try { + // 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs). + const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { + qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), + qToggleMode: false, + qSoftLock: true, + }, dfh), monthTimings); + console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); + // Laisser l'Engine finaliser l'évaluation de la sélection Date + // avant la première SelectValues Article Code du 1er lot. + await sleep(qlikSettleMs); + // 2) Sélection Article Code par lots contrôlés. + let codeIndex = 0; + let batchNumber = 0; + let fallbackIndex = 0; + const totalCodes = codes.length || 0; + while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { + const batchSize = codes.length ? fallbackSizes[fallbackIndex] : 0; + const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + batchSize, totalCodes)) : []; + const batchStart = codeIndex; + const batchEnd = codeIndex + batch.length; + const timings = {}; + let code15Retries = 0; + batchNumber++; + const batchPrefix = prefix + " — lot " + batchNumber + " [" + batchStart + "," + batchEnd + ") size=" + batch.length; + try { + if (fh !== -1) { + await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); + // Laisser l'Engine appliquer le Clear avant la nouvelle sélection. + await sleep(qlikSettleMs); + const { value: csel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { + qFieldValues: batch.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timings); + console.log(batchPrefix + " — SelectValues Article Code → " + JSON.stringify(csel.qReturn)); + // Laisser l'Engine finaliser l'évaluation de la sélection + // avant de créer / interroger le cube (évite code 15 sur le 1er lot). + await sleep(qlikSettleMs); + } + const batchRows = []; + const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); + out.push(...batchRows); + accMonthly(batchRows, m.label); + aggBatches++; + aggCode15Retries += code15Retries; + console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + if (!codes.length) + break; + codeIndex = batchEnd; + fallbackIndex = 0; + } + catch (batchErr) { + const msg = String(batchErr?.message || batchErr); + const nextFallbackIndex = fallbackIndex + 1; + const nextSize = fallbackSizes[nextFallbackIndex]; + if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { + console.log("[qlik-pw][timing] " + batchPrefix + " — fallback code15 " + batch.length + "→" + nextSize + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); + fallbackIndex = nextFallbackIndex; + aggFallbackCount++; + continue; + } + console.error(batchPrefix + " — ABORT — " + msg); + throw new Error(batchPrefix + " — " + msg); + } + } + } + catch (monthErr) { + const msg = String(monthErr?.message || monthErr); + console.error(prefix + " — ABORT — " + msg); + throw new Error(prefix + " — " + msg); + } + // 3) Clear Date + Article Code avant le mois suivant (soulage l'Engine). + if (mi < total - 1) { + try { + await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); + if (fh !== -1) + await timed("clearCodeMonth", "Clear", () => rpc("Clear", {}, fh), monthTimings); + } + catch (clearErr) { + console.log(prefix + " — Clear ignoré: " + String(clearErr?.message || clearErr)); + } + } + console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " cumul=" + out.length + " timings=" + timingSummary(monthTimings)); + } + } + // La sonde de diagnostic du cube [Article Code, Mois] a été retirée : + // elle refaisait une sélection complète des codes et des 365 jours en + // fin de sync (des dizaines de secondes) alors que le découpage + // mensuel est validé et en production depuis. + ws.close(); + resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount, diagExpr: diagnostics }); + } + catch (e) { + try { + ws.close(); + } + catch { /* noop */ } + // On remonte ce qui a été extrait : les codes déjà traités ont + // toutes leurs données, seul le reliquat manque. + resolve({ ok: false, error: String(e?.message || e), rows: out, monthly: monthlyByCode, size: out.length, diagExpr: diagnostics }); + } + })(); +}) diff --git a/server/src/qlik/js/recherche.js b/server/src/qlik/js/recherche.js new file mode 100644 index 0000000..1848efb --- /dev/null +++ b/server/src/qlik/js/recherche.js @@ -0,0 +1,412 @@ +// Script exécuté DANS la page Qlik (Chromium) par `search.rs`. +// Copie conforme de la fonction passée à `page.evaluate` dans +// `src/lib/qlik-search.ts` (types TypeScript retirés, code inchangé). +// Appelée avec un unique objet d'arguments ; résout un `InPageSearchResult`. +({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge, champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }) => new Promise((resolve) => { + /** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */ + const MAX_VALEURS_LUES = 10000; + /** + * Valeurs sélectionnées au maximum. + * + * Mesuré en production : sélectionner les 13 446 libellés contenant + * « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube + * (code 15). Le cube étant trié par quantité réseau décroissante et + * l'affichage plafonné à quelques dizaines de lignes, 300 libellés + * suffisent largement. + */ + const MAX_VALEURS_SELECTION = 300; + const PAGE_VALEURS = 5000; + const loc = window.location; + const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; + const ws = new WebSocket(url); + const trace = []; + let id = 0; + const pend = new Map(); + const rpc = (method, params, handle = -1) => new Promise((res, rej) => { + const i = ++id; + pend.set(i, { res, rej }); + ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); + }); + const ready = new Promise((res, rej) => { + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.method === "OnConnected") + return res(); + if (m.id && pend.has(m.id)) { + const x = pend.get(m.id); + pend.delete(m.id); + if (m.error) + x.rej(new Error(JSON.stringify(m.error))); + else + x.res(m.result); + } + }; + ws.onerror = () => rej(new Error("ws error (403 ?)")); + setTimeout(() => rej(new Error("ws timeout")), 30000); + }); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + /** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */ + const estCode15 = (e) => /"code"\s*:\s*15\b/.test(String(e?.message ?? e)); + const rpcRetry = async (method, params, handle, tentatives = 3) => { + let derniere; + for (let i = 1; i <= tentatives; i++) { + try { + return await rpc(method, params, handle); + } + catch (e) { + derniere = e; + if (!estCode15(e) || i === tentatives) + throw e; + trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives); + await sleep(settleMs * (i + 1)); + } + } + throw derniere; + }; + const normaliser = (s) => s + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const mots = normaliser(terme).split(" ").filter(Boolean); + (async () => { + try { + await ready; + const open = await rpc("OpenDoc", { qDocName: app }); + const doc = open.qReturn.qHandle; + // ─── 1. Inventaire des champs ──────────────────────────── + const fl = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "FieldList" }, + qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true }, + }, + }, doc); + const fll = await rpc("GetLayout", {}, fl.qReturn.qHandle); + const champs = (((fll.qLayout.qFieldList?.qItems) ?? []) + .map((f) => f.qName)); + const has = (nom, re) => re.test(nom); + const champCode = champs.find((c) => c === "Article Code") + ?? champs.find((c) => has(c, /article/i) && has(c, /code/i)) + ?? "Article Code"; + // Candidats libellé : surcharge d'env, puis noms connus de + // l'app FF, puis heuristique. On essaiera chacun dans l'ordre + // jusqu'à en trouver un qui ramène des valeurs. + const candidatsLibelle = [ + champLibelleForce, + ...champsLibellePreferes, + ...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)), + ...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)), + ].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i); + const champFournisseur = [ + champFournisseurForce, + ...champsFournisseurPreferes, + ...champs.filter((c) => has(c, /fournisseur/i)), + ...champs.filter((c) => has(c, /(fabricant|supplier)/i)), + ].find((c) => c && champs.includes(c)) ?? null; + trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur); + // ─── 2. Recherche + sélection sur le champ le plus adapté ─ + // + // `SearchListObjectFor` applique un OU entre les mots. On + // cherche donc mot par mot pour retenir le plus sélectif, + // puis on filtre en ET côté client avant de sélectionner. + const chercherEtSelectionner = async (champ) => { + const lo = await rpcRetry("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-search-lb" }, + qListObjectDef: { + qDef: { qFieldDefs: [champ] }, + qInitialDataFetch: [], + }, + }, + }, doc); + const ret = lo.qReturn; + const loId = String(ret.qGenericId ?? ret.qId ?? ""); + const lh = ret.qHandle; + try { + // 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur). + let meilleurMot = terme; + let meilleurNb = Number.POSITIVE_INFINITY; + for (const mot of (mots.length > 1 ? mots : [terme])) { + await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh); + const lay = await rpcRetry("GetLayout", {}, lh); + const nb = (lay.qLayout.qListObject?.qSize?.qcy) ?? 0; + trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)"); + if (nb > 0 && nb < meilleurNb) { + meilleurNb = nb; + meilleurMot = mot; + } + } + if (!Number.isFinite(meilleurNb) || meilleurNb === 0) + return { retenues: 0, brut: 0, tropLarge: false }; + // 2b. Relecture des valeurs du mot retenu. On relance la + // recherche systématiquement : la boucle ci-dessus a pu + // laisser le list object sur un autre mot. + await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh); + const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES); + const elems = []; + let lues = 0; + for (let top = 0; top < aLire; top += PAGE_VALEURS) { + const d = await rpcRetry("GetListObjectData", { + qPath: "/qListObjectDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }], + }, lh); + const matrix = (d.qDataPages?.[0]?.qMatrix) ?? []; + if (matrix.length === 0) + break; + lues += matrix.length; + for (const ligne of matrix) { + const cell = ligne[0]; + const texte = String(cell?.qText ?? ""); + const elem = cell?.qElemNumber; + if (elem == null || elem < 0) + continue; + // Filtre ET : tous les mots du terme présents. + const n = normaliser(texte); + if (!mots.every((m) => n.includes(m))) + continue; + elems.push(elem); + if (elems.length >= MAX_VALEURS_SELECTION) + break; + } + if (elems.length >= MAX_VALEURS_SELECTION) + break; + } + trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET"); + if (elems.length === 0) { + return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; + } + // 2c. Sélection par numéro d'élément : exact, aucun + // ré-appariement de texte possible. + await rpcRetry("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: elems, + qToggleMode: false, + qSoftLock: true, + }, lh); + await sleep(settleMs); + return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; + } + finally { + if (loId) { + try { + await rpc("DestroySessionObject", { qId: loId }, doc); + } + catch { /* noop */ } + } + } + }; + await rpc("ClearAll", { qLockedAlso: false }, doc); + await sleep(settleMs); + let champChoisi = null; + let tropLarge = false; + for (const candidat of candidatsLibelle) { + const r = await chercherEtSelectionner(candidat); + tropLarge = tropLarge || r.tropLarge; + if (r.retenues > 0) { + champChoisi = candidat; + break; + } + await rpc("ClearAll", { qLockedAlso: false }, doc); + await sleep(settleMs); + } + if (!champChoisi) { + // Repli code centrale (terme saisi = un code). + const r = await chercherEtSelectionner(champCode); + if (r.retenues > 0) + champChoisi = champCode; + tropLarge = tropLarge || r.tropLarge; + } + if (!champChoisi) { + ws.close(); + return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace }); + } + trace.push("champ retenu = " + champChoisi); + // Trace des sélections réellement actives : si elle est vide, + // la sélection n'a pas pris côté Engine. + try { + const cs = await rpc("CreateSessionObject", { + qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} }, + }, doc); + const csl = await rpc("GetLayout", {}, cs.qReturn.qHandle); + const sel = ((csl.qLayout + .qSelectionObject?.qSelections) ?? []) + .map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`); + trace.push("sélections actives=" + JSON.stringify(sel)); + } + catch (e) { + trace.push("sélections actives: lecture impossible (" + String(e?.message || e) + ")"); + } + // ─── 3. Fenêtre 12 mois glissants ──────────────────────── + // Sélectionnée APRÈS les libellés : restreindre les dates avant + // la recherche masquerait les articles non vendus sur la période. + if (serialsDate.length > 0) { + try { + const gd = await rpc("GetField", { qFieldName: "Date" }, doc); + const dh = gd.qReturn.qHandle; + for (let i = 0; i < serialsDate.length; i += 500) { + const lot = serialsDate.slice(i, i + 500); + await rpcRetry("SelectValues", { + qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })), + qToggleMode: i > 0, + qSoftLock: true, + }, dh); + } + await sleep(settleMs); + trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)"); + } + catch (e) { + trace.push("sélection Date impossible, mesures sur toute la période : " + String(e?.message || e)); + } + } + // ─── 4. Identité + mesures réseau, dans UN SEUL cube ───── + // + // Les mesures sont dans le même cube que l'identité, et donc + // dans la même session Qlik : une seconde session concurrente + // pour les extraire faisait abandonner le moteur (code 15). + // + // Les master measures sont résolues par TITRE : le qLibraryId + // fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même + // approche que l'extraction de la Grille. + const norm = (x) => x.replace(/\s+/g, " ").trim().toLowerCase(); + const ml = await rpcRetry("CreateSessionObject", { + qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } }, + }, doc); + const mll = await rpcRetry("GetLayout", {}, ml.qReturn.qHandle); + const items = ((mll.qLayout.qMeasureList?.qItems) ?? []); + const parTitre = new Map(); + for (const it of items) { + const t = it.qMeta?.title ?? it.qData?.title ?? ""; + if (t) + parTitre.set(norm(t), it.qInfo.qId); + } + const mesure = (titre, repli) => parTitre.get(norm(titre)) ?? repli; + const idCa = mesure("CA N", mCa); + const idQte = mesure("Quantité N", mQte); + const idNbMag = mesure("Magasin Ventes Nb N", mNbMag); + const idCaMag = mesure("CA par Magasin N", mCaMag); + const idMarge = mesure("Marge % N", mMarge); + const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; + const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null; + const dims = [{ qLibraryId: dim }]; + const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1; + const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1; + const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1; + const nbDims = dims.length; + // Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %. + const idxQteCol = nbDims + 1; + const largeur = nbDims + 5; + // Cube trié par quantité réseau décroissante : la première page + // contient donc directement les meilleures ventes du réseau, et + // on n'a pas besoin de lire tout l'ensemble sélectionné. + const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur))); + const defCube = (trie) => ({ + qProp: { + qInfo: { qType: "cf-search" }, + qHyperCubeDef: { + qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })), + qMeasures: [ + { qLibraryId: idCa }, + trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte }, + { qLibraryId: idNbMag }, + { qLibraryId: idCaMag }, + { qLibraryId: idMarge }, + ], + ...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}), + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }], + qSuppressZero: false, + qSuppressMissing: true, + }, + }, + }); + let cube; + try { + cube = await rpcRetry("CreateSessionObject", defCube(true), doc); + } + catch (e) { + // Le tri par mesure est un confort (meilleures ventes en + // tête) : si l'Engine refuse cette forme, on repart sans. + if (estCode15(e)) + throw e; + trace.push("tri par quantité refusé, cube non trié : " + String(e?.message || e)); + cube = await rpcRetry("CreateSessionObject", defCube(false), doc); + } + const ch = cube.qReturn.qHandle; + const layout = await rpcRetry("GetLayout", {}, ch); + const hc = layout.qLayout.qHyperCube; + const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; + // ─── 4. Garde-fou : le résultat DOIT correspondre au terme ── + const termeCode = terme.trim().toLowerCase(); + const correspond = (code, libelle) => { + if (mots.length === 0) + return true; + const c = code.toLowerCase(); + if (c === termeCode || c.startsWith(termeCode)) + return true; + if (!libelle) + return champChoisi === champCode; + const l = normaliser(libelle); + return mots.every((m) => l.includes(m)); + }; + const texte = (r, i) => { + if (i < 0) + return ""; + const v = String(r[i]?.qText ?? "").trim(); + return v === "-" ? "" : v; + }; + const parCode = new Map(); + let rejetees = 0; + for (const r of matrix) { + const code = String(r[0]?.qText ?? "").trim(); + if (!code || code === "-") + continue; + const codeAlt = texte(r, idxAlt); + const libelle = texte(r, idxLibelle); + const fournisseur = texte(r, idxFournisseur); + if (!correspond(code, libelle)) { + rejetees++; + continue; + } + const nombre = (i) => Number(r[nbDims + i]?.qNum) || 0; + const ligne = [ + code, codeAlt, libelle, fournisseur, + nombre(0), nombre(1), nombre(2), nombre(3), nombre(4), + ]; + const existant = parCode.get(code); + if (!existant) { + parCode.set(code, ligne); + continue; + } + // Même article chez plusieurs fournisseurs : une seule ligne. + // Les mesures sont identiques (elles ne dépendent pas de la + // dimension fournisseur), on complète juste les libellés. + if (!existant[1] && codeAlt) + existant[1] = codeAlt; + if (!existant[2] && libelle) + existant[2] = libelle; + if (!existant[3] && fournisseur) + existant[3] = fournisseur; + } + const uniques = [...parCode.values()]; + const rows = uniques.slice(0, limite); + trace.push("cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length + + " rejetées(hors terme)=" + rejetees + + " articles uniques=" + uniques.length + " retenus=" + rows.length); + if (rows.length > 0) { + trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3))); + } + ws.close(); + resolve({ + ok: true, rows, champUtilise: champChoisi, total: uniques.length, + lues: matrix.length, rejetees, tropLarge, trace, + }); + } + catch (e) { + try { + ws.close(); + } + catch { /* noop */ } + resolve({ ok: false, error: String(e?.message || e), trace }); + } + })(); +}) diff --git a/server/src/qlik/jsnum.rs b/server/src/qlik/jsnum.rs new file mode 100644 index 0000000..50448aa --- /dev/null +++ b/server/src/qlik/jsnum.rs @@ -0,0 +1,110 @@ +//! Nombres « à la JavaScript » pour la (dé)sérialisation des métriques Qlik. +//! +//! `JSON.stringify(12)` donne `12`, là où serde_json écrit `12.0` pour un `f64` ; +//! `NaN` / `Infinity` deviennent `null`. Les métriques partent telles quelles +//! vers l'interface et dans des colonnes `jsonb` : on garde l'écriture du TS. +//! En lecture, on tolère `null` (NaN côté page) et les chaînes numériques. + +use serde::{Deserialize, Deserializer, Serializer}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Valeur JSON d'un nombre, écrite comme `JSON.stringify`. +pub fn valeur(v: f64) -> Value { + if !v.is_finite() { + Value::Null + } else if v.fract() == 0.0 && v.abs() < 9.007_199_254_740_992e15 { + Value::from(v as i64) + } else { + serde_json::Number::from_f64(v).map(Value::Number).unwrap_or(Value::Null) + } +} + +/// `String(v)` en JS (journaux et messages). +pub fn texte(v: f64) -> String { + if v.is_nan() { + "NaN".into() + } else if v.is_infinite() { + if v > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + } + } else { + valeur(v).to_string() + } +} + +/// `Number(v)` sur une valeur JSON (NaN si non convertible). +pub fn nombre(v: &Value) -> f64 { + match v { + Value::Number(n) => n.as_f64().unwrap_or(f64::NAN), + Value::String(s) => super::date_range::js_number(s), + Value::Bool(b) => { + if *b { + 1.0 + } else { + 0.0 + } + } + Value::Null => 0.0, + _ => f64::NAN, + } +} + +/// `Number(v) || 0`. +pub fn nombre_ou_0(v: Option<&Value>) -> f64 { + let n = v.map(nombre).unwrap_or(f64::NAN); + if n.is_nan() { + 0.0 + } else { + n + } +} + +pub fn ser(v: &f64, s: S) -> Result { + serde::Serialize::serialize(&valeur(*v), s) +} + +pub fn ser_opt(v: &Option, s: S) -> Result { + match v { + Some(x) => ser(x, s), + None => s.serialize_none(), + } +} + +pub fn ser_map(v: &Option>, s: S) -> Result { + match v { + Some(m) => { + let obj: serde_json::Map = m.iter().map(|(k, x)| (k.clone(), valeur(*x))).collect(); + serde::Serialize::serialize(&obj, s) + } + None => s.serialize_none(), + } +} + +/// `null` → NaN (une valeur NaN de la page devient `null` en JSON). +fn depuis_valeur(v: &Value) -> f64 { + match v { + Value::Null => f64::NAN, + autre => nombre(autre), + } +} + +pub fn de<'de, D: Deserializer<'de>>(d: D) -> Result { + Ok(depuis_valeur(&Value::deserialize(d)?)) +} + +pub fn de_opt<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(match Value::deserialize(d)? { + Value::Null => None, + v => Some(nombre(&v)), + }) +} + +pub fn de_map<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + Ok(match Value::deserialize(d)? { + Value::Object(m) => Some(m.iter().map(|(k, v)| (k.clone(), depuis_valeur(v))).collect()), + _ => None, + }) +} diff --git a/server/src/qlik/mod.rs b/server/src/qlik/mod.rs index c29a7c9..3e9c138 100644 --- a/server/src/qlik/mod.rs +++ b/server/src/qlik/mod.rs @@ -1,11 +1,52 @@ -//! Module `qlik` — à porter. +//! Module `qlik` — intégration Qlik Sense : données réseau (~270 magasins). +//! +//! - [`client`] : configuration, types partagés (`NetworkMetric`…), session NTLM ; +//! - [`ntlm`] : messages NTLM identiques à `httpntlm` ; +//! - [`browser`] : Chromium partagé, file d'extraction unique, page pilotée en CDP ; +//! - [`playwright`] : extraction réseau 12 mois (script in-page `js/extraction.js`) ; +//! - [`search`] : recherche produit dans Qlik (script in-page `js/recherche.js`) ; +//! - [`date_range`], [`validation`] : logique pure (fenêtre, contrôles) ; +//! - [`network_cache`] : table `qlik_network_metrics` ; +//! - [`sync`] : route `/api/qlik/sync` (jobs asynchrones). + +pub mod browser; +pub mod client; +pub mod date_range; +pub mod jsnum; +pub mod network_cache; +pub mod ntlm; +pub mod playwright; +pub mod search; +pub mod sync; +pub mod validation; + +#[cfg(test)] +mod tests_navigateur; + +// Points d'entrée pour les autres modules (grid, produits, admin…). +#[allow(unused_imports)] +pub use client::{get_qlik_config, make_xrfkey, qlik_ntlm_session, NetworkMetric, NetworkProduct, QlikConfig, QlikMonthMetrics, QlikSession}; +#[allow(unused_imports)] +pub use date_range::{ + build_grid_network_qlik_date_filter, build_grid_network_qlik_date_filter_now, chunk_serials, env_months_back, get_month_ranges, + iso_date_to_qlik_serial, qlik_serial_to_iso_date, QlikDateFilter, QLIK_MONTHS_BACK_DEFAULT, QLIK_MONTHS_BACK_MAX, QLIK_MONTHS_BACK_MIN, +}; +#[allow(unused_imports)] +pub use network_cache::{get_network_metrics_by_code_centrale, upsert_network_metrics, NetworkMetricCached}; +#[allow(unused_imports)] +pub use playwright::fetch_network_metrics_playwright; +#[allow(unused_imports)] +pub use search::{search_qlik_articles, QlikArticleMatch, QlikSearchOutcome, QLIK_SEARCH_MAX_RESULTS}; +#[allow(unused_imports)] +pub use validation::{assainir_metrique, controler_extraction, ControleExtraction, OptionsControle}; use crate::rpc::Handler; +use axum::routing::get; use axum::Router; /// Routes HTTP du module. pub fn router() -> Router { - Router::new() + Router::new().route("/api/qlik/sync", get(sync::get).post(sync::post)) } /// Actions (`POST /api/actions/{nom}`) du module. diff --git a/server/src/qlik/network_cache.rs b/server/src/qlik/network_cache.rs new file mode 100644 index 0000000..1766119 --- /dev/null +++ b/server/src/qlik/network_cache.rs @@ -0,0 +1,220 @@ +//! Cache des métriques réseau Qlik, table `qlik_network_metrics` +//! (port de `src/lib/qlik-network-cache.ts`). +//! +//! Lecture par lot (jointure grille) + upsert en masse (route de sync). + +use super::client::{NetworkMetric, QlikMonthMetrics}; +use super::jsnum; +use crate::db; +use serde::{Deserialize, Serialize}; +use sqlx::{Postgres, QueryBuilder, Row}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkMetricCached { + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub qte_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub nb_magasins_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_par_magasin_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub marge_pct_reseau: f64, + /// Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. + #[serde(serialize_with = "jsnum::ser_map", deserialize_with = "jsnum::de_map")] + pub qte_by_month: Option>, + /// Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. + /// null pour les lignes synchronisées avant l'ajout de la colonne — l'UI + /// retombe alors sur `qteByMonth`. + pub metrics_by_month: Option>, + /// Période couverte par l'extraction (ex "2025-03_2026-02"). + pub periode: Option, + /// Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. + pub libelle_reseau: Option, + /// Fournisseur de l'article côté Qlik, même origine que `libelle_reseau`. + pub fournisseur_reseau: Option, + pub fetched_at: Option, +} + +/// Lit les métriques réseau pour une liste de codes centraux. +/// +/// La `HashMap` se sérialise comme l'objet `Object.fromEntries(map)` du TS. +pub async fn get_network_metrics_by_code_centrale(codes: &[String]) -> anyhow::Result> { + let mut out = HashMap::new(); + let mut vus = HashSet::new(); + let uniques: Vec = codes.iter().filter(|c| !c.is_empty() && vus.insert(c.as_str())).cloned().collect(); + if uniques.is_empty() { + return Ok(out); + } + let pool = db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + + // Découpage conservé du TS : `inArray` y produisait un paramètre lié par + // valeur, or le protocole PostgreSQL en accepte 65535 au maximum — un gros + // fournisseur dépasse 130 000 codes. (Ici un seul tableau lié par lot.) + const CHUNK: usize = 2000; + for lot in uniques.chunks(CHUNK) { + let rows = sqlx::query( + r#"SELECT "code_centrale", "ca_reseau", "qte_reseau", "nb_magasins_reseau", "ca_par_magasin_reseau", + "marge_pct_reseau", "periode", "qte_by_month", "metrics_by_month", "libelle_reseau", + "fournisseur_reseau", "fetched_at" + FROM "qlik_network_metrics" WHERE "code_centrale" = ANY($1)"#, + ) + .bind(lot) + .fetch_all(&pool) + .await?; + for r in rows { + let code: String = r.try_get("code_centrale")?; + let json = |col: &str| r.try_get::, _>(col).ok().flatten(); + let qte_by_month = json("qte_by_month").and_then(|v| match v { + serde_json::Value::Object(m) => Some(m.iter().map(|(k, x)| (k.clone(), jsnum::nombre(x))).collect()), + _ => None, + }); + let metrics_by_month = json("metrics_by_month").and_then(|v| serde_json::from_value(v).ok()); + let fetched_at = r.try_get::, _>("fetched_at").ok().flatten().map(db::iso); + out.insert( + code, + NetworkMetricCached { + ca_reseau: db::f64_or0(&r, "ca_reseau"), + qte_reseau: db::f64_or0(&r, "qte_reseau"), + nb_magasins_reseau: db::f64_or0(&r, "nb_magasins_reseau"), + ca_par_magasin_reseau: db::f64_or0(&r, "ca_par_magasin_reseau"), + marge_pct_reseau: db::f64_or0(&r, "marge_pct_reseau"), + qte_by_month, + metrics_by_month, + periode: r.try_get("periode").ok().flatten(), + libelle_reseau: r.try_get("libelle_reseau").ok().flatten(), + fournisseur_reseau: r.try_get("fournisseur_reseau").ok().flatten(), + fetched_at, + }, + ); + } + } + Ok(out) +} + +/// Upsert en masse des métriques réseau (depuis Qlik). Renvoie le nombre de lignes envoyées. +pub async fn upsert_network_metrics(metrics: &[NetworkMetric]) -> anyhow::Result { + if metrics.is_empty() { + return Ok(0); + } + let pool = db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + let now = chrono::Utc::now().naive_utc(); + + // Upsert par paquets pour éviter les requêtes trop volumineuses. + const CHUNK: usize = 500; + let mut count = 0; + for lot in metrics.chunks(CHUNK) { + let mut qb: QueryBuilder = QueryBuilder::new( + r#"INSERT INTO "qlik_network_metrics" ("code_centrale", "ca_reseau", "qte_reseau", "nb_magasins_reseau", + "ca_par_magasin_reseau", "marge_pct_reseau", "periode", "qte_by_month", "metrics_by_month", + "libelle_reseau", "fournisseur_reseau", "fetched_at") "#, + ); + qb.push_values(lot, |mut b, m| { + let qte_by_month = + m.qte_by_month.as_ref().map(|q| serde_json::Value::Object(q.iter().map(|(k, v)| (k.clone(), jsnum::valeur(*v))).collect())); + let metrics_by_month = m.metrics_by_month.as_ref().and_then(|x| serde_json::to_value(x).ok()); + // Les nombres passent en texte vers `numeric`, comme `String(m.caReseau)` côté TS. + b.push_bind(m.code_centrale.clone()) + .push_bind(jsnum::texte(m.ca_reseau)) + .push_unseparated("::numeric") + .push_bind(jsnum::texte(m.qte_reseau)) + .push_unseparated("::numeric") + .push_bind(m.nb_magasins_reseau) + .push_unseparated("::float8::integer") + .push_bind(jsnum::texte(m.ca_par_magasin_reseau)) + .push_unseparated("::numeric") + .push_bind(jsnum::texte(m.marge_pct_reseau)) + .push_unseparated("::numeric") + .push_bind(m.periode.clone()) + .push_bind(qte_by_month) + .push_unseparated("::jsonb") + .push_bind(metrics_by_month) + .push_unseparated("::jsonb") + .push_bind(m.libelle_reseau.clone()) + .push_bind(m.fournisseur_reseau.clone()) + .push_bind(now); + }); + qb.push( + r#" ON CONFLICT ("code_centrale") DO UPDATE SET + "ca_reseau" = excluded.ca_reseau, + "qte_reseau" = excluded.qte_reseau, + "nb_magasins_reseau" = excluded.nb_magasins_reseau, + "ca_par_magasin_reseau" = excluded.ca_par_magasin_reseau, + "marge_pct_reseau" = excluded.marge_pct_reseau, + "periode" = excluded.periode, + "qte_by_month" = COALESCE(excluded.qte_by_month, "qlik_network_metrics"."qte_by_month"), + "metrics_by_month" = COALESCE(excluded.metrics_by_month, "qlik_network_metrics"."metrics_by_month"), + "libelle_reseau" = COALESCE(excluded.libelle_reseau, "qlik_network_metrics"."libelle_reseau"), + "fournisseur_reseau" = COALESCE(excluded.fournisseur_reseau, "qlik_network_metrics"."fournisseur_reseau"), + "fetched_at" = excluded.fetched_at"#, + ); + // Le détail mensuel n'est produit que par l'extraction complète + // (dimension Mois). La recherche produit, elle, n'a que les agrégats : + // elle ne doit pas effacer un mensuel déjà extrait (COALESCE ci-dessus). + // Seule la recherche produit connaît le libellé / le fournisseur réseau : + // une sync fournisseur ne doit pas les effacer. + qb.build().execute(&pool).await?; + count += lot.len(); + } + Ok(count) +} + +#[cfg(test)] +mod tests { + //! Contre la base locale : `DATABASE_URL=postgres://postgres:postgres@localhost/collectflow + //! cargo test -- --ignored cache_reseau`. + use super::*; + + #[tokio::test] + #[ignore] + async fn cache_reseau_upsert_et_lecture() { + if std::env::var("DATABASE_URL").is_err() { + std::env::set_var("DATABASE_URL", "postgres://postgres:postgres@localhost/collectflow"); + } + let code = format!("TEST{}", rand::random::() % 1_000_000); + let mut m = NetworkMetric { + code_centrale: code.clone(), + ca_reseau: 1234.567, + qte_reseau: 42.0, + nb_magasins_reseau: 12.0, + ca_par_magasin_reseau: 102.88, + marge_pct_reseau: 0.3215, + periode: Some("2025-10_2026-09".into()), + qte_by_month: Some(BTreeMap::from([("2026-08".into(), 20.0), ("2026-09".into(), 22.0)])), + metrics_by_month: Some(BTreeMap::from([( + "2026-09".into(), + QlikMonthMetrics { qte: 22.0, ca: Some(600.5), nb_mag: Some(7.0), ca_mag: Some(85.78), marge_pct: Some(0.31) }, + )])), + libelle_reseau: Some("TAPIS ANTIDERAPANT".into()), + fournisseur_reseau: None, + }; + assert_eq!(upsert_network_metrics(std::slice::from_ref(&m)).await.unwrap(), 1); + + // Une sync sans mensuel ni libellé ne doit pas effacer ceux déjà connus. + m.qte_by_month = None; + m.metrics_by_month = None; + m.libelle_reseau = None; + m.qte_reseau = 50.0; + upsert_network_metrics(std::slice::from_ref(&m)).await.unwrap(); + + let lu = get_network_metrics_by_code_centrale(&[code.clone(), code.clone(), String::new()]).await.unwrap(); + let c = &lu[&code]; + assert_eq!(c.ca_reseau, 1234.57); + assert_eq!(c.qte_reseau, 50.0); + assert_eq!(c.nb_magasins_reseau, 12.0); + assert_eq!(c.marge_pct_reseau, 0.3215); + assert_eq!(c.qte_by_month.as_ref().unwrap()["2026-09"], 22.0); + assert_eq!(c.metrics_by_month.as_ref().unwrap()["2026-09"].nb_mag, Some(7.0)); + assert_eq!(c.libelle_reseau.as_deref(), Some("TAPIS ANTIDERAPANT")); + assert!(c.fetched_at.as_deref().unwrap().ends_with('Z')); + let json = serde_json::to_string(c).unwrap(); + assert!(json.contains(r#""qteByMonth":{"2026-08":20,"2026-09":22}"#), "{json}"); + assert!(json.contains(r#""fournisseurReseau":null"#), "{json}"); + + let pool = db::pool().unwrap(); + sqlx::query("DELETE FROM qlik_network_metrics WHERE code_centrale = $1").bind(&code).execute(&pool).await.unwrap(); + } +} diff --git a/server/src/qlik/ntlm.rs b/server/src/qlik/ntlm.rs new file mode 100644 index 0000000..b30e095 --- /dev/null +++ b/server/src/qlik/ntlm.rs @@ -0,0 +1,435 @@ +//! Messages NTLM — traduction octet pour octet de `httpntlm/ntlm.js` (v1.8.13), +//! le module npm qu'utilisait `qlik-client.ts`. +//! +//! Le serveur Qlik a été validé avec exactement ces messages (login `FFSCH`, +//! domaine VIDE, comme `requests_ntlm("FFSCH", pwd)` ; forcer `FOIRFOUILLE` → 401). +//! On reproduit donc à l'identique : +//! - type 1 : drapeaux `NTLM_TYPE1_FLAGS` (moins « OEM domain supplied » sans +//! domaine), version 5.1 build 2600, domaine et poste passés par `escape()` +//! puis mis en majuscules ; +//! - type 3 : NTLMv2 dès que le serveur annonce « extended security » ET +//! fournit un `targetInfo` ; NTLM2 Session Response si « extended security » +//! sans `targetInfo` ; sinon réponses LM/NT v1 (DES). +//! +//! Les tests comparent la sortie aux messages produits par `ntlm.js` sous Node +//! avec aléa et horloge figés. + +use base64::Engine as _; +use des::cipher::{BlockEncrypt, KeyInit}; +use hmac::{Hmac, Mac}; +use md4::{Digest, Md4}; + +const NEGOTIATE_UNICODE: u32 = 0x0000_0001; +const NEGOTIATE_OEM: u32 = 0x0000_0002; +const REQUEST_TARGET: u32 = 0x0000_0004; +const NEGOTIATE_NTLM: u32 = 0x0000_0200; +const NEGOTIATE_OEM_DOMAIN_SUPPLIED: u32 = 0x0000_1000; +const NEGOTIATE_OEM_WORKSTATION_SUPPLIED: u32 = 0x0000_2000; +const NEGOTIATE_ALWAYS_SIGN: u32 = 0x0000_8000; +const NEGOTIATE_EXTENDED_SECURITY: u32 = 0x0008_0000; +const NEGOTIATE_TARGET_INFO: u32 = 0x0080_0000; +const NEGOTIATE_VERSION: u32 = 0x0200_0000; +const NEGOTIATE_128: u32 = 0x2000_0000; +const NEGOTIATE_56: u32 = 0x8000_0000; + +const TYPE1_FLAGS: u32 = NEGOTIATE_UNICODE + + NEGOTIATE_OEM + + REQUEST_TARGET + + NEGOTIATE_NTLM + + NEGOTIATE_OEM_DOMAIN_SUPPLIED + + NEGOTIATE_OEM_WORKSTATION_SUPPLIED + + NEGOTIATE_ALWAYS_SIGN + + NEGOTIATE_EXTENDED_SECURITY + + NEGOTIATE_VERSION + + NEGOTIATE_128 + + NEGOTIATE_56; + +const TYPE2_FLAGS: u32 = NEGOTIATE_UNICODE + + REQUEST_TARGET + + NEGOTIATE_NTLM + + NEGOTIATE_ALWAYS_SIGN + + NEGOTIATE_EXTENDED_SECURITY + + NEGOTIATE_TARGET_INFO + + NEGOTIATE_VERSION + + NEGOTIATE_128 + + NEGOTIATE_56; + +/// Version annoncée par `ntlm.js` : Windows XP (5.1 build 2600), révision NTLM 15. +const VERSION: [u8; 8] = [5, 1, 0x28, 0x0a, 0, 0, 0, 15]; + +/// `escape()` JavaScript (unités UTF-16, `%XX` / `%uXXXX`). +pub fn js_escape(s: &str) -> String { + let mut out = String::new(); + for u in s.encode_utf16() { + let c = char::from_u32(u as u32).filter(|c| c.is_ascii_alphanumeric() || "@*_+-./".contains(*c)); + match c { + Some(c) => out.push(c), + None if u < 256 => out.push_str(&format!("%{u:02X}")), + None => out.push_str(&format!("%u{u:04X}")), + } + } + out +} + +fn utf16le(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() +} + +/// Encodage `'ascii'` de Node : octet de poids faible de chaque unité UTF-16. +fn node_ascii(s: &str) -> Vec { + s.encode_utf16().map(|u| (u & 0xff) as u8).collect() +} + +fn push_u16(buf: &mut Vec, v: usize) { + buf.extend_from_slice(&(v as u16).to_le_bytes()); +} + +fn push_u32(buf: &mut Vec, v: usize) { + buf.extend_from_slice(&(v as u32).to_le_bytes()); +} + +/// Message de négociation (type 1), préfixé `NTLM `. +pub fn create_type1_message(domain: &str, workstation: &str) -> String { + let domain = js_escape(&domain.to_uppercase()); + let workstation = js_escape(&workstation.to_uppercase()); + const BODY_LENGTH: usize = 40; + let mut flags = TYPE1_FLAGS; + if domain.is_empty() { + flags -= NEGOTIATE_OEM_DOMAIN_SUPPLIED; + } + let mut buf = Vec::with_capacity(BODY_LENGTH + domain.len() + workstation.len()); + buf.extend_from_slice(b"NTLMSSP\0"); + push_u32(&mut buf, 1); + buf.extend_from_slice(&flags.to_le_bytes()); + push_u16(&mut buf, domain.len()); + push_u16(&mut buf, domain.len()); + push_u32(&mut buf, BODY_LENGTH + workstation.len()); + push_u16(&mut buf, workstation.len()); + push_u16(&mut buf, workstation.len()); + push_u32(&mut buf, BODY_LENGTH); + buf.extend_from_slice(&VERSION); + buf.extend_from_slice(workstation.as_bytes()); + buf.extend_from_slice(domain.as_bytes()); + format!("NTLM {}", base64::engine::general_purpose::STANDARD.encode(buf)) +} + +/// Message de défi (type 2) envoyé par le serveur. +#[derive(Debug, Clone)] +pub struct Type2Message { + pub negotiate_flags: u32, + pub server_challenge: [u8; 8], + /// Présent dès que le serveur annonce `NegotiateTargetInfo` (même vide : + /// un `Buffer` vide est « vrai » en JS, d'où le choix de NTLMv2). + pub target_info: Option>, +} + +fn slice_clamp(buf: &[u8], debut: usize, fin: usize) -> Vec { + let debut = debut.min(buf.len()); + let fin = fin.clamp(debut, buf.len()); + buf[debut..fin].to_vec() +} + +fn decode_base64_tolerant(s: &str) -> Vec { + // Node ignore les caractères hors alphabet et le remplissage manquant. + let propre: String = s.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '+' || *c == '/').collect(); + base64::engine::general_purpose::STANDARD_NO_PAD.decode(propre.as_bytes()).unwrap_or_default() +} + +/// Lit le message type 2 d'un en-tête `WWW-Authenticate` (`NTLM `). +pub fn parse_type2_message(raw: &str) -> anyhow::Result { + let Some(pos) = raw.find("NTLM ") else { + anyhow::bail!("Couldn't find NTLM in the message type2 coming from the server"); + }; + // Plusieurs défis joints par « , » : on s'arrête au premier séparateur. + let b64 = raw[pos + 5..].split([',', ' ']).next().unwrap_or("").trim(); + if b64.is_empty() { + anyhow::bail!("Couldn't find NTLM in the message type2 coming from the server"); + } + let buf = decode_base64_tolerant(b64); + let lire_u16 = |o: usize| buf.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize).unwrap_or(0); + let lire_u32 = |o: usize| buf.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0); + if buf.len() < 32 || lire_u16(8) != 2 { + anyhow::bail!("Server didn't return a type 2 message"); + } + let negotiate_flags = lire_u32(20); + let mut server_challenge = [0u8; 8]; + server_challenge.copy_from_slice(&buf[24..32]); + let target_info = if negotiate_flags & NEGOTIATE_TARGET_INFO != 0 { + let len = lire_u16(40); + let off = lire_u32(44) as usize; + Some(slice_clamp(&buf, off, off + len)) + } else { + None + }; + Ok(Type2Message { negotiate_flags, server_challenge, target_info }) +} + +/// 7 octets → clé DES de 8 octets (un bit nul inséré tous les 7 bits). +fn insert_zeros_every_7_bits(src: &[u8]) -> [u8; 8] { + let mut bits = Vec::with_capacity(64); + for b in src.iter().take(7) { + for i in (0..8).rev() { + bits.push((b >> i) & 1); + } + } + let mut out = [0u8; 8]; + for (k, octet) in out.iter_mut().enumerate() { + let mut v = 0u8; + for j in 0..7 { + v = (v << 1) | bits.get(k * 7 + j).copied().unwrap_or(0); + } + *octet = v << 1; + } + out +} + +fn des_encrypt(key7: &[u8], data: &[u8]) -> [u8; 8] { + let key = insert_zeros_every_7_bits(key7); + let cipher = des::Des::new_from_slice(&key).expect("clé DES de 8 octets"); + let mut block = des::cipher::generic_array::GenericArray::clone_from_slice(&data[..8]); + cipher.encrypt_block(&mut block); + block.into() +} + +fn create_lm_hashed_password_v1(password: &str) -> Vec { + let bytes = node_ascii(&password.to_uppercase()); + let mut padded = [0u8; 14]; + let n = bytes.len().min(14); + padded[..n].copy_from_slice(&bytes[..n]); + let mut out = Vec::with_capacity(16); + out.extend_from_slice(&des_encrypt(&padded[0..7], b"KGS!@#$%")); + out.extend_from_slice(&des_encrypt(&padded[7..14], b"KGS!@#$%")); + out +} + +fn create_nt_hashed_password_v1(password: &str) -> Vec { + Md4::digest(utf16le(password)).to_vec() +} + +fn calc_resp(password_hash: &[u8], server_challenge: &[u8]) -> Vec { + let mut padded = [0u8; 21]; + let n = password_hash.len().min(21); + padded[..n].copy_from_slice(&password_hash[..n]); + let mut out = Vec::with_capacity(24); + for k in 0..3 { + out.extend_from_slice(&des_encrypt(&padded[k * 7..k * 7 + 7], &server_challenge[..8])); + } + out +} + +fn hmac_md5(key: &[u8], data: &[u8]) -> Vec { + let mut mac = as Mac>::new_from_slice(key).expect("HMAC accepte toute clé"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +fn ntlm2sr_calc_resp(response_key_nt: &[u8], server_challenge: &[u8; 8], client_challenge: &[u8; 8]) -> (Vec, Vec) { + let mut lm = vec![0u8; 24]; + lm[..8].copy_from_slice(client_challenge); + let mut h = md5::Md5::new(); + h.update(server_challenge); + h.update(client_challenge); + let sess = h.finalize(); + let nt = calc_resp(response_key_nt, &sess[..8]); + (lm, nt) +} + +fn calc_ntlmv2_resp( + pwhash: &[u8], + username: &str, + domain: &str, + target_info: &[u8], + server_challenge: &[u8; 8], + client_challenge: &[u8; 8], + now_ms: u64, +) -> (Vec, Vec) { + // NTOWFv2 + let response_key = hmac_md5(pwhash, &utf16le(&format!("{}{}", username.to_uppercase(), domain))); + let mut lm = hmac_md5(&response_key, &[server_challenge.as_slice(), client_challenge.as_slice()].concat()); + lm.extend_from_slice(client_challenge); + + // 11644473600000 = écart en ms entre 1601 et 1970. + let timestamp = (now_ms + 11_644_473_600_000) * 10_000; + let mut temp = vec![0x01, 0x01, 0x00, 0x00, 0, 0, 0, 0]; + temp.extend_from_slice(×tamp.to_le_bytes()); + temp.extend_from_slice(client_challenge); + temp.extend_from_slice(&[0, 0, 0, 0]); + temp.extend_from_slice(target_info); + temp.extend_from_slice(&[0, 0, 0, 0]); + let mut nt = hmac_md5(&response_key, &[server_challenge.as_slice(), &temp].concat()); + nt.extend_from_slice(&temp); + (lm, nt) +} + +/// Message d'authentification (type 3), préfixé `NTLM ` — défi client aléatoire +/// et horodatage courant, comme `ntlm.js`. +pub fn create_type3_message(msg2: &Type2Message, username: &str, password: &str, domain: &str, workstation: &str) -> String { + let client_challenge: [u8; 8] = rand::random(); + let now_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + create_type3_message_with(msg2, username, password, domain, workstation, client_challenge, now_ms) +} + +/// Variante déterministe de [`create_type3_message`] (tests). +pub fn create_type3_message_with( + msg2: &Type2Message, + username: &str, + password: &str, + domain: &str, + workstation: &str, + client_challenge: [u8; 8], + now_ms: u64, +) -> String { + let nonce = &msg2.server_challenge; + let is_unicode = msg2.negotiate_flags & NEGOTIATE_UNICODE != 0; + let extended = msg2.negotiate_flags & NEGOTIATE_EXTENDED_SECURITY != 0; + const BODY_LENGTH: usize = 72; + + let domain_name = js_escape(&domain.to_uppercase()); + let workstation = js_escape(&workstation.to_uppercase()); + let enc = |s: &str| if is_unicode { utf16le(s) } else { node_ascii(s) }; + let workstation_bytes = enc(&workstation); + let domain_bytes = enc(&domain_name); + let username_bytes = enc(username); + let session_key_bytes: Vec = Vec::new(); + + let nt_hash = create_nt_hashed_password_v1(password); + let (lm_resp, nt_resp) = if extended { + // NTLMv2 dès que le serveur fournit le targetInfo (comme curl) ; sinon + // NTLMv1 avec « extended security » (NTLM2 Session Response). + match &msg2.target_info { + Some(ti) => calc_ntlmv2_resp(&nt_hash, username, &domain_name, ti, nonce, &client_challenge, now_ms), + None => ntlm2sr_calc_resp(&nt_hash, nonce, &client_challenge), + } + } else { + (calc_resp(&create_lm_hashed_password_v1(password), nonce), calc_resp(&nt_hash, nonce)) + }; + + let base = BODY_LENGTH + domain_bytes.len() + username_bytes.len() + workstation_bytes.len(); + let mut buf = Vec::with_capacity(base + lm_resp.len() + nt_resp.len()); + buf.extend_from_slice(b"NTLMSSP\0"); + push_u32(&mut buf, 3); + push_u16(&mut buf, lm_resp.len()); + push_u16(&mut buf, lm_resp.len()); + push_u32(&mut buf, base); + push_u16(&mut buf, nt_resp.len()); + push_u16(&mut buf, nt_resp.len()); + push_u32(&mut buf, base + lm_resp.len()); + push_u16(&mut buf, domain_bytes.len()); + push_u16(&mut buf, domain_bytes.len()); + push_u32(&mut buf, BODY_LENGTH); + push_u16(&mut buf, username_bytes.len()); + push_u16(&mut buf, username_bytes.len()); + push_u32(&mut buf, BODY_LENGTH + domain_bytes.len()); + push_u16(&mut buf, workstation_bytes.len()); + push_u16(&mut buf, workstation_bytes.len()); + push_u32(&mut buf, BODY_LENGTH + domain_bytes.len() + username_bytes.len()); + push_u16(&mut buf, session_key_bytes.len()); + push_u16(&mut buf, session_key_bytes.len()); + push_u32(&mut buf, base + lm_resp.len() + nt_resp.len()); + let flags = if is_unicode { TYPE2_FLAGS } else { TYPE2_FLAGS - NEGOTIATE_UNICODE }; + buf.extend_from_slice(&flags.to_le_bytes()); + buf.extend_from_slice(&VERSION); + buf.extend_from_slice(&domain_bytes); + buf.extend_from_slice(&username_bytes); + buf.extend_from_slice(&workstation_bytes); + buf.extend_from_slice(&lm_resp); + buf.extend_from_slice(&nt_resp); + buf.extend_from_slice(&session_key_bytes); + format!("NTLM {}", base64::engine::general_purpose::STANDARD.encode(buf)) +} + +#[cfg(test)] +mod tests { + //! Références produites par `node_modules/httpntlm/ntlm.js` (v1.8.13) avec + //! `Math.random` figé (octets 37·k mod 256) et `Date.now() = 1781600000123`. + use super::*; + + const CLIENT: [u8; 8] = [0x25, 0x4a, 0x6f, 0x94, 0xb9, 0xde, 0x03, 0x28]; + const NOW: u64 = 1_781_600_000_123; + + #[test] + fn type1_identique_a_ntlm_js() { + assert_eq!(create_type1_message("", ""), "NTLM TlRMTVNTUAABAAAAB6IIogAAAAAoAAAAAAAAACgAAAAFASgKAAAADw=="); + assert_eq!( + create_type1_message("foirfouille", "poste-1"), + "NTLM TlRMTVNTUAABAAAAB7IIogsACwAvAAAABwAHACgAAAAFASgKAAAAD1BPU1RFLTFGT0lSRk9VSUxMRQ==" + ); + } + + fn type3(type2: &str, user: &str, pwd: &str, dom: &str, ws: &str) -> String { + let msg2 = parse_type2_message(type2).unwrap(); + create_type3_message_with(&msg2, user, pwd, dom, ws, CLIENT, NOW) + } + + const T2_V2: &str = + "NTLM TlRMTVNTUAACAAAADgAOADAAAAAVgoniASNFZ4mrze8AAAAAAAAAACQAJAA+AAAARABPAE0AQQBJAE4ARQACAA4ARABPAE0AQQBJAE4AAQAMAFMARQBSAFYARQBSAAAAAAA="; + + #[test] + fn type3_ntlmv2_identique_a_ntlm_js() { + assert_eq!( + type3(T2_V2, "FFSCH", "Mot2Passe!", "", ""), + "NTLM TlRMTVNTUAADAAAAGAAYAFIAAABUAFQAagAAAAAAAABIAAAACgAKAEgAAAAAAAAAUgAAAAAAAAC+AAAABYKIogUBKAoAAAAPRgBGAFMAQwBIAMebHM0wuVW53i9NaYVnDM0lSm+Uud4DKFKnk/6apESm+PHJ3XiP8I8BAQAAAAAAALDEopRt/dwBJUpvlLneAygAAAAAAgAOAEQATwBNAEEASQBOAAEADABTAEUAUgBWAEUAUgAAAAAAAAAAAA==" + ); + assert_eq!( + type3(T2_V2, "ffsch", "pässwörd", "foirFouille", "ws é"), + "NTLM TlRMTVNTUAADAAAAGAAYAHgAAABUAFQAkAAAABYAFgBIAAAACgAKAF4AAAAQABAAaAAAAAAAAADkAAAABYKIogUBKAoAAAAPRgBPAEkAUgBGAE8AVQBJAEwATABFAGYAZgBzAGMAaABXAFMAJQAyADAAJQBDADkA2c8BUzjri/Vbm9o3osFxDiVKb5S53gMoWbFIumwCV7VyC/+QpeFgkwEBAAAAAAAAsMSilG393AElSm+Uud4DKAAAAAACAA4ARABPAE0AQQBJAE4AAQAMAFMARQBSAFYARQBSAAAAAAAAAAAA" + ); + } + + #[test] + fn type3_ntlm2_session_identique_a_ntlm_js() { + assert_eq!( + type3("NTLM TlRMTVNTUAACAAAADgAOADAAAAAVggjiASNFZ4mrze8AAAAAAAAAAAAAAAAAAAAARABPAE0AQQBJAE4ARQA=", "FFSCH", "Mot2Passe!", "", ""), + "NTLM TlRMTVNTUAADAAAAGAAYAFIAAAAYABgAagAAAAAAAABIAAAACgAKAEgAAAAAAAAAUgAAAAAAAACCAAAABYKIogUBKAoAAAAPRgBGAFMAQwBIACVKb5S53gMoAAAAAAAAAAAAAAAAAAAAAEkBa2FWIubP7jtR0YUCzhOojjrIJ/b0zg==" + ); + } + + #[test] + fn type3_ntlmv1_identique_a_ntlm_js() { + assert_eq!( + type3("NTLM TlRMTVNTUAACAAAADgAOADAAAAACAgAAASNFZ4mrze8AAAAAAAAAAAAAAAAAAAAARABPAE0AQQBJAE4ARQA=", "FFSCH", "Mot2Passe!", "dom", "ws"), + "NTLM TlRMTVNTUAADAAAAGAAYAFIAAAAYABgAagAAAAMAAwBIAAAABQAFAEsAAAACAAIAUAAAAAAAAACCAAAABIKIogUBKAoAAAAPRE9NRkZTQ0hXUxOnemDuiKIblbqFa0gPlHCgLteOCjDT8ybUs0Z210bpm9YSI+44nltXbx25eedv1g==" + ); + assert_eq!( + type3("NTLM TlRMTVNTUAACAAAADgAOADAAAAABAgAAASNFZ4mrze8AAAAAAAAAAAAAAAAAAAAARABPAE0AQQBJAE4ARQA=", "Utilisateur", "un mot de passe tres long", "", ""), + "NTLM TlRMTVNTUAADAAAAGAAYAF4AAAAYABgAdgAAAAAAAABIAAAAFgAWAEgAAAAAAAAAXgAAAAAAAACOAAAABYKIogUBKAoAAAAPVQB0AGkAbABpAHMAYQB0AGUAdQByAApLfYq11cQZCEfoqsrdSUT3L9f12IEFMjyUawPg7lXzmV17ourU8IkTxBWsCur8CA==" + ); + } + + #[test] + fn type2_invalide() { + assert!(parse_type2_message("Negotiate").is_err()); + assert!(parse_type2_message("NTLM TlRMTVNTUAABAAAAB6IIogAAAAAoAAAAAAAAACgAAAAFASgKAAAADw==").is_err()); + // Plusieurs défis dans le même en-tête (Node les joint par « , »). + assert!(parse_type2_message(&format!("Negotiate, {T2_V2}")).is_ok()); + } + + #[test] + fn escape_facon_js() { + assert_eq!(js_escape("WS É"), "WS%20%C9"); + assert_eq!(js_escape("a€b"), "a%u20ACb"); + assert_eq!(js_escape("@*_+-./"), "@*_+-./"); + } + + /// Recalcule les références avec Node quand il est disponible + /// (`cargo test -- --ignored ntlm_js_en_direct`). + #[test] + #[ignore] + fn ntlm_js_en_direct() { + let script = r#" + const ntlm = require(process.argv[1]); + let k = 0; Math.random = () => { k++; return ((k * 37) % 256) / 256 + 0.0001; }; + Date.now = () => 1781600000123; + const t2 = process.argv[2]; + const m = ntlm.parseType2Message(t2, (e) => { throw e; }); + console.log(ntlm.createType1Message({ domain: 'foirfouille', workstation: 'poste-1' })); + console.log(ntlm.createType3Message(m, { username: 'FFSCH', password: 'Mot2Passe!', domain: 'Dom', workstation: 'PC' })); + "#; + let module = std::env::var("HTTPNTLM_JS").unwrap_or_else(|_| "/home/user/CollectFlow/node_modules/httpntlm/ntlm.js".into()); + let out = std::process::Command::new("node").args(["-e", script, &module, T2_V2]).output().expect("node"); + let texte = String::from_utf8(out.stdout).unwrap(); + let lignes: Vec<&str> = texte.lines().collect(); + assert_eq!(lignes[0], create_type1_message("foirfouille", "poste-1")); + assert_eq!(lignes[1], type3(T2_V2, "FFSCH", "Mot2Passe!", "Dom", "PC")); + } +} diff --git a/server/src/qlik/playwright.rs b/server/src/qlik/playwright.rs new file mode 100644 index 0000000..6b86ae0 --- /dev/null +++ b/server/src/qlik/playwright.rs @@ -0,0 +1,669 @@ +//! Extraction Qlik via Chromium headless (port de `src/lib/qlik-playwright.ts`). +//! +//! Le proxy Qlik refuse un websocket "raw" côté serveur (403). On ouvre donc +//! l'app dans Chromium avec une session authentifiée (cookie X-Qlik-Session +//! injecté via le flux ticket NTLM), on récupère le **qlik-csrf-token** que le +//! client Qlik utilise pour SON websocket, puis on ouvre notre propre websocket +//! Engine **in-page** avec ce token (le proxy l'accepte). +//! +//! Toute la logique Engine (sélections, cubes, passes mensuelles, retries +//! code 15, sélection par fournisseur, contrôles de fenêtre…) vit dans le +//! script in-page `js/extraction.js`, copie conforme du TS. Ce fichier-ci garde +//! la partie serveur : réglages d'environnement, session, reprises globales, +//! agrégation finale et contrôles de plausibilité avant écriture. + +use super::browser::{dans_la_file_qlik, get_qlik_browser, PageQlik}; +use super::client::{get_qlik_config, qlik_ntlm_session, NetworkMetric, QlikConfig, QlikMonthMetrics}; +use super::date_range::{get_month_ranges, js_number, qlik_serial_to_ymd, QlikDateFilter}; +use super::jsnum; +use super::validation::{assainir_metrique, controler_extraction, OptionsControle}; +use anyhow::{anyhow, bail}; +use chrono::Datelike; +use indexmap::IndexMap; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Script exécuté dans la page (fonction de `page.evaluate` du TS). +const SCRIPT_EXTRACTION: &str = include_str!("js/extraction.js"); + +/// Résultat du script in-page. +#[derive(Debug, Default, Clone)] +struct InPageResult { + ok: bool, + rows: Vec>, + /// Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }. + monthly: BTreeMap>, + size: Option, + error: Option, + // Compteurs agrégés pour le résumé timing final. + months: Option, + batches: Option, + code15_retries: Option, + fallback_count: Option, + /// true si le résultat vient d'un point de contrôle (extraction interrompue). + partiel: bool, + /// Diagnostics clés du chemin par expressions, répétés en fin de sync. + diag_expr: Vec, +} + +fn lire_monthly(v: Option<&Value>) -> BTreeMap> { + let mut out = BTreeMap::new(); + if let Some(Value::Object(par_code)) = v { + for (code, par_mois) in par_code { + let mut mois_map = BTreeMap::new(); + if let Value::Object(pm) = par_mois { + for (mois, m) in pm { + if let Ok(mm) = serde_json::from_value::(m.clone()) { + mois_map.insert(mois.clone(), mm); + } + } + } + out.insert(code.clone(), mois_map); + } + } + out +} + +fn lire_rows(v: Option<&Value>) -> Vec> { + v.and_then(|r| r.as_array()).map(|a| a.iter().map(|l| l.as_array().cloned().unwrap_or_default()).collect()).unwrap_or_default() +} + +impl InPageResult { + fn depuis(v: &Value) -> Self { + let nombre = |k: &str| v.get(k).filter(|x| !x.is_null()).map(jsnum::nombre); + Self { + ok: v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false), + rows: lire_rows(v.get("rows")), + monthly: lire_monthly(v.get("monthly")), + size: nombre("size"), + error: v.get("error").map(|e| match e { + Value::String(s) => s.clone(), + autre => autre.to_string(), + }), + months: nombre("months"), + batches: nombre("batches"), + code15_retries: nombre("code15Retries"), + fallback_count: nombre("fallbackCount"), + partiel: v.get("partiel").and_then(|x| x.as_bool()).unwrap_or(false), + diag_expr: v + .get("diagExpr") + .and_then(|d| d.as_array()) + .map(|a| a.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()) + .unwrap_or_default(), + } + } +} + +/// `envNumber` : entier borné, défaut si absent / vide / non numérique. +fn env_number(name: &str, default: i64, min: i64, max: i64) -> i64 { + crate::config::env_number(name, default, min, max) +} + +/// `envNumberCsv` : liste d'entiers bornés (`Number("")` = 0, comme en JS). +fn env_number_csv(name: &str, min: i64, max: i64) -> Vec { + let Some(raw) = std::env::var(name).ok().filter(|v| !v.is_empty()) else { return Vec::new() }; + raw.split(',').map(|part| js_number(part.trim())).filter(|v| v.is_finite()).map(|v| (v.trunc() as i64).clamp(min, max)).collect() +} + +fn unique_ordered(values: &[i64]) -> Vec { + let mut seen = HashSet::new(); + values.iter().copied().filter(|v| seen.insert(*v)).collect() +} + +/// Option booléenne activée par défaut, coupée par `0` / `false` / `no` / `off`. +fn env_actif(name: &str) -> bool { + let v = std::env::var(name).unwrap_or_default().trim().to_lowercase(); + !["0", "false", "no", "off"].contains(&v.as_str()) +} + +fn env_trim(name: &str, defaut: &str) -> String { + std::env::var(name).unwrap_or_else(|_| defaut.to_string()).trim().to_string() +} + +/// `String(x)` sur une cellule JSON. +fn texte_js(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => jsnum::texte(n.as_f64().unwrap_or(f64::NAN)), + Value::Null => "null".into(), + autre => autre.to_string(), + } +} + +/// Extraction des métriques réseau (CA, quantités, magasins, marge et détail +/// mensuel) des codes centraux demandés. +/// +/// - `codes` : codes centraux (`None` = tous ceux que renvoie le cube). +/// - `cfg` : `None` = [`get_qlik_config`]. +/// - `date_filter` : fenêtre à extraire ; `None` = mode sans date (le `null` du TS). +/// Les appelants passent toujours une fenêtre (12 mois glissants). +/// - `fournisseur` : code fournisseur FF (ex. `J009`). Quand il est fourni, on +/// sélectionne LE fournisseur côté Qlik au lieu des dizaines de milliers de +/// codes articles : `SelectValues` sur « Article Code » coûte 8 à 100 s par +/// appel quel que soit le nombre de valeurs (le moteur balaie un symbole de +/// plus d'un million d'entrées), et il faut le refaire à chaque passe annuelle. +/// La bascule n'est retenue que si la sélection fournisseur **couvre** les +/// codes demandés — sinon on revient à la sélection par codes. +/// +/// Passe par la file Qlik unique : une seule session Engine à la fois. +pub async fn fetch_network_metrics_playwright( + codes: Option>, + cfg: Option, + date_filter: Option, + fournisseur: Option, +) -> anyhow::Result> { + let libelle = match &fournisseur { + Some(f) if !f.is_empty() => format!("fournisseur {f}"), + _ => format!("{} code(s)", codes.as_ref().map(|c| c.len()).unwrap_or(0)), + }; + let cfg = cfg.unwrap_or_else(get_qlik_config); + dans_la_file_qlik(&libelle, || extraire(codes, cfg, date_filter, fournisseur)).await +} + +async fn extraire( + codes: Option>, + cfg: QlikConfig, + date_filter: Option, + fournisseur: Option, +) -> anyhow::Result> { + if cfg.app_network.is_empty() { + bail!("[qlik-pw] QLIK_APP_NETWORK manquant"); + } + if cfg.user.is_empty() || cfg.password.is_empty() { + bail!("[qlik-pw] identifiants Qlik manquants"); + } + + // Démarre l'horloge pour le résumé timing final (cf. bloc [qlik-pw][summary]). + let total_start = Instant::now(); + let nb_codes = codes.as_ref().map(|c| c.len()).unwrap_or(0); + tracing::info!("[qlik-pw] host={} app={} — {nb_codes} codes à sélectionner", cfg.host, cfg.app_network); + if let Some(df) = &date_filter { + tracing::info!("[qlik-pw] filtre Date={} ({} → {}, {})", df.label, df.date_debut, df.date_fin, df.set_analysis); + } + let sess = qlik_ntlm_session(&cfg).await?; + let cookie_name = sess.cookie.split('=').next().unwrap_or("").to_string(); + tracing::info!("[qlik-pw] session NTLM OK, cookie={cookie_name}=…"); + let cookie_value = sess.cookie.splitn(2, '=').nth(1).unwrap_or("").to_string(); + let qlik_settle_ms = env_number("QLIK_SETTLE_MS", 150, 0, 2000); + let qlik_code15_max_attempts = env_number("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8); + let qlik_article_batch_size = env_number("QLIK_ARTICLE_BATCH_SIZE", 150, 1, 500); + let csv_fallbacks = env_number_csv("QLIK_ARTICLE_BATCH_FALLBACKS", 1, 500); + let mut tailles = vec![qlik_article_batch_size]; + tailles.extend(if csv_fallbacks.is_empty() { vec![75, 50] } else { csv_fallbacks }); + tailles.extend([75, 50]); + let qlik_article_batch_fallbacks = unique_ordered(&tailles); + // Chemin rapide : sélectionner TOUS les Article Code une seule fois, puis ne + // changer que la Date par mois (au lieu de re-sélectionner par lots de 150 chaque + // mois). Repli automatique sur le chemin par lots en cas de code 15. + // ACTIVÉ PAR DÉFAUT (validé fiable) — désactivable seulement avec QLIK_SELECT_ALL_CODES=0. + let qlik_select_all_codes = env_actif("QLIK_SELECT_ALL_CODES"); + // Découpage mensuel réel via la dimension "Mois" (cube [Article Code, Mois]) : donne + // les vraies quantités par mois ET le vrai total (au lieu de 12× la valeur annuelle). + // Désactiver (QLIK_MONTH_DIM=0) fait désormais échouer la sync datée. + let qlik_month_dim = env_actif("QLIK_MONTH_DIM"); + let qlik_mois_dim_id = env_trim("QLIK_MOIS_DIM_ID", "pfGAwTs"); + // Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente. + let qlik_meas_qte_comp_id = env_trim("QLIK_MEAS_QTE_COMP_ID", "96862880-76cd-4957-bf25-b96901f2ac5f"); + // 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. + let qlik_use_expr = env_actif("QLIK_USE_EXPR"); + let qlik_expr_qte = env_trim("QLIK_EXPR_QTE", "Sum(quantite)"); + let qlik_expr_ca = env_trim("QLIK_EXPR_CA", "Sum(ca_ht)"); + let qlik_expr_nb_mag = env_trim("QLIK_EXPR_NBMAG", "Count(DISTINCT [Magasin Code])"); + let qlik_expr_marge = env_trim("QLIK_EXPR_MARGE", "Sum(marge)"); + // Champs Qlik susceptibles de porter le CODE fournisseur (et non son libellé) : + // c'est `code_fournisseur` qui remontait des valeurs de la forme « F005 », + // « A0491 » — même format que les codes de la base FF. + let mut qlik_supplier_fields: Vec = Vec::new(); + for c in [env_trim("QLIK_FIELD_CODE_FOURNISSEUR", ""), "code_fournisseur".into(), "Fournisseur".into(), "fournisseur_code_centrale".into()] { + if !c.is_empty() && !qlik_supplier_fields.contains(&c) { + qlik_supplier_fields.push(c); + } + } + // Part des codes demandés que la sélection fournisseur doit couvrir pour être retenue. + let couverture = js_number(&std::env::var("QLIK_SUPPLIER_MIN_COVERAGE").unwrap_or_else(|_| "0.99".into())); + let couverture = if couverture.is_nan() || couverture == 0.0 { 0.99 } else { couverture }; + let qlik_supplier_min_coverage = couverture.clamp(0.5, 1.0); + + // Champs date candidats, essayés dans l'ordre. Un champ n'est retenu que s'il + // FILTRE réellement les faits (total non nul et strictement inférieur au total + // sans filtre). + let mut qlik_date_fields: Vec = Vec::new(); + for c in [env_trim("QLIK_DATE_FIELD", ""), "Date".into(), "Date calendrier".into(), "Date_Key".into()] { + if !c.is_empty() && !qlik_date_fields.contains(&c) { + qlik_date_fields.push(c); + } + } + let year_n = chrono::Local::now().year(); + tracing::info!( + "[qlik-pw] tuning settle={qlik_settle_ms}ms code15Attempts={qlik_code15_max_attempts} batchFallbacks={} selectAllCodes={qlik_select_all_codes} monthDim={qlik_month_dim} useExpr={qlik_use_expr}", + qlik_article_batch_fallbacks.iter().map(|v| v.to_string()).collect::>().join(">") + ); + if qlik_use_expr { + tracing::info!("[qlik-pw] expressions mensuelles: qte=\"{qlik_expr_qte}\" ca=\"{qlik_expr_ca}\" nbMag=\"{qlik_expr_nb_mag}\" marge=\"{qlik_expr_marge}\""); + tracing::info!("[qlik-pw] champs date candidats: {}", serde_json::to_string(&qlik_date_fields)?); + } + + let browser = get_qlik_browser().await?; + tracing::info!("[qlik-pw] chromium lancé"); + let mut page = PageQlik::ouvrir(browser, Box::new(|t: &str| Some(format!("[qlik-pw][page] {t}"))), Some("[qlik-pw][pageerror]")).await?; + let resultat = async { + page.identifiants_http(&cfg.user, &cfg.password).await?; + page.ajouter_cookie(&cookie_name, &cookie_value, &cfg.host).await?; + extraire_dans_la_page( + &mut page, + &cfg, + codes, + date_filter, + fournisseur, + total_start, + json!({ + "qlikSettleMs": qlik_settle_ms, + "qlikCode15MaxAttempts": qlik_code15_max_attempts, + "qlikArticleBatchFallbacks": qlik_article_batch_fallbacks, + "selectAllCodes": qlik_select_all_codes, + "monthDim": qlik_month_dim, + "moisDimId": qlik_mois_dim_id, + "mqteComp": qlik_meas_qte_comp_id, + "yearN": year_n, + "useExpr": qlik_use_expr, + "exprQte": qlik_expr_qte, + "exprCa": qlik_expr_ca, + "exprNbMag": qlik_expr_nb_mag, + "exprMarge": qlik_expr_marge, + "champsDateCandidats": qlik_date_fields, + "champsFournisseurCandidats": qlik_supplier_fields, + "couvertureFournisseurMin": qlik_supplier_min_coverage, + }), + ) + .await + } + .await; + page.fermer().await; + resultat +} + +/// Jeton CSRF du client Qlik : attendu au plus 15 s après le chargement. +pub(super) async fn attendre_jeton(rx: &mut tokio::sync::watch::Receiver>) -> Option { + let _ = tokio::time::timeout(Duration::from_secs(15), rx.wait_for(|j| j.is_some())).await; + let jeton = rx.borrow().clone(); + jeton +} + +async fn extraire_dans_la_page( + page: &mut PageQlik, + cfg: &QlikConfig, + codes: Option>, + date_filter: Option, + fournisseur: Option, + total_start: Instant, + reglages: Value, +) -> anyhow::Result> { + // Capture le qlik-csrf-token depuis le websocket que le client Qlik ouvre. + let mut jeton_rx = page.capturer_jeton_csrf().await?; + // Le client ouvre son ws ensuite : une navigation lente ou en erreur n'est pas bloquante. + let _ = page.aller(&format!("https://{}/sense/app/{}", cfg.host, cfg.app_network), Duration::from_millis(cfg.timeout_ms)).await; + tracing::info!("[qlik-pw] page chargée, attente du qlik-csrf-token…"); + let Some(jeton) = attendre_jeton(&mut jeton_rx).await else { + bail!("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)"); + }; + tracing::info!("[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…"); + + // Pré-calcul des mois côté serveur (pour pouvoir logger X/Y et propager au script in-page). + let month_ranges: Vec = date_filter.as_ref().map(get_month_ranges).unwrap_or_default(); + let monthly_payload: Vec = month_ranges + .iter() + .map(|m| { + json!({ + "label": m.label, + "dateDebut": m.date_debut, + "dateFin": m.date_fin, + "serials": m.daily_serials, // ~30 valeurs → SelectValues en une fois + // Même fenêtre au format entier AAAAMMJJ : certains modèles Qlik + // n'exposent la date que par une clé numérique (`Date_Key`), pas par + // un champ date sérialisé. + "ymd": m.daily_serials.iter().map(|s| qlik_serial_to_ymd(*s)).collect::>(), + }) + }) + .collect(); + + // Point de contrôle : le script in-page pousse ses résultats intermédiaires + // à chaque passe. Si la session Qlik meurt en cours de route (« Socket + // closed », « Execution context was destroyed »), on repart de là au lieu + // de tout perdre — les codes déjà traités ont des données complètes, la + // dimension Mois livrant tous leurs mois d'un coup. + let checkpoint: Arc>> = Arc::new(parking_lot::Mutex::new(None)); + let mut appels = page.exposer_fonction("cfCheckpoint").await?; + let cible = checkpoint.clone(); + let recepteur = tokio::spawn(async move { + while let Some(payload) = appels.recv().await { + if let Ok(v) = serde_json::from_str::(&payload) { + *cible.lock() = Some(v); + } + } + }); + + let mut args = Map::new(); + args.insert("app".into(), json!(cfg.app_network)); + args.insert("dim".into(), json!(cfg.dim_code_article_id)); + args.insert("mca".into(), json!(cfg.meas_ca_id)); + args.insert("mqte".into(), json!(cfg.meas_qte_id)); + args.insert("mnb".into(), json!(cfg.meas_nb_mag_id)); + args.insert("mcamag".into(), json!(cfg.meas_ca_mag_id)); + args.insert("mmarge".into(), json!(cfg.meas_marge_pct_id)); + args.insert("codes".into(), json!(codes.clone().unwrap_or_default())); + args.insert("token".into(), json!(jeton)); + args.insert("monthlyPayload".into(), Value::Array(monthly_payload)); + args.insert("noDateMode".into(), json!(date_filter.is_none())); + args.insert("fournisseurCode".into(), json!(fournisseur.as_deref().unwrap_or("").trim())); + if let Value::Object(r) = reglages { + args.extend(r); + } + let args = Value::Object(args); + + // Reprise globale sur erreur transitoire. + // + // Le moteur Qlik abandonne les requêtes longues quand il est chargé + // (« code 15 / Request aborted »). Observé sur un même fournisseur, avec + // le même code : réussite à 4 h du matin (OpenDoc en 78 s), échec à 6 h + // (OpenDoc en 233 s, puis abandon). Les reprises par requête de + // `rpcWithRetry` ne suffisent pas — c'est toute l'extraction qui tombe. + // + // La pause est volontairement longue : relancer aussitôt ne ferait + // qu'ajouter de la charge au serveur qui vient déjà de renoncer. + let extraction_essais = 1 + env_number("QLIK_EXTRACTION_RETRIES", 1, 0, 3); + let extraction_pause_ms = env_number("QLIK_EXTRACTION_RETRY_PAUSE_MS", 60_000, 0, 600_000) as u64; + let re15 = regex::Regex::new(r#""code"\s*:\s*15\b"#).expect("regex"); + let re_abort = regex::Regex::new(r"(?i)request aborted").expect("regex"); + let est_transitoire = |m: &str| re15.is_match(m) || re_abort.is_match(m); + let tronque = |s: &str, n: usize| s.chars().take(n).collect::(); + + let mut essai = 0; + let mut result = loop { + essai += 1; + let reste = essai < extraction_essais; + match page.evaluer(SCRIPT_EXTRACTION, &args).await { + Ok(v) => { + let candidat = InPageResult::depuis(&v); + let err = candidat.error.clone().unwrap_or_else(|| "undefined".into()); + if candidat.ok || !reste || !est_transitoire(&err) { + break candidat; + } + tracing::warn!( + "[qlik-pw] tentative {essai}/{extraction_essais} abandonnée par le moteur ({}) — nouvelle tentative dans {} s", + tronque(&err, 160), + (extraction_pause_ms as f64 / 1000.0).round() + ); + } + Err(e) => { + let msg = format!("{e:#}"); + if reste && est_transitoire(&msg) { + tracing::warn!( + "[qlik-pw] tentative {essai}/{extraction_essais} interrompue ({}) — nouvelle tentative dans {} s", + tronque(&msg, 160), + (extraction_pause_ms as f64 / 1000.0).round() + ); + } else { + recepteur.abort(); + // Comportement d'origine, une fois les reprises épuisées. + // 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 date_filter.is_some() { + bail!("[qlik-pw] extraction 12 mois interrompue, cache inchangé : {msg}"); + } + let secours = checkpoint.lock().take(); + let rows = lire_rows(secours.as_ref().and_then(|s| s.get("rows"))); + if !rows.is_empty() { + tracing::error!( + "[qlik-pw] extraction interrompue ({}) — reprise du dernier point de contrôle : {} lignes", + tronque(&msg, 200), + rows.len() + ); + let n = rows.len() as f64; + break InPageResult { + ok: true, + monthly: lire_monthly(secours.as_ref().and_then(|s| s.get("monthly"))), + rows, + size: Some(n), + partiel: true, + ..Default::default() + }; + } + return Err(e); + } + } + } + *checkpoint.lock() = None; // la nouvelle tentative repart d'une extraction vierge + tokio::time::sleep(Duration::from_millis(extraction_pause_ms)).await; + }; + recepteur.abort(); + + if !result.ok { + let err = result.error.clone().unwrap_or_else(|| "undefined".into()); + if date_filter.is_some() { + bail!("[qlik-pw] extraction 12 mois refusée, cache inchangé : {err}"); + } + // Même logique qu'un crash : un résultat partiel vaut mieux que rien. + if !result.rows.is_empty() { + tracing::error!("[qlik-pw] extraction en échec ({}) — {} lignes déjà extraites conservées", tronque(&err, 200), result.rows.len()); + result.ok = true; + result.partiel = true; + } else { + bail!("[qlik-pw] {err}"); + } + } + if result.partiel { + if date_filter.is_some() { + bail!("[qlik-pw] extraction 12 mois partielle refusée, cache inchangé"); + } + tracing::warn!("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); + } + + // Résumé timing final : synthèse compacte de la sync pour audit/debug. + // Les logs détaillés par étape restent en place (timings batch/mois) ; + // ici on consolide les compteurs agrégés et la durée totale. + let total_ms = total_start.elapsed().as_millis(); + if !result.diag_expr.is_empty() { + tracing::info!("[qlik-pw][diag] ─── diagnostic du chemin par expressions ───"); + for d in &result.diag_expr { + tracing::info!("[qlik-pw][diag] {d}"); + } + } + let n = |v: Option| jsnum::texte(v.unwrap_or(0.0)); + tracing::info!( + "[qlik-pw][summary] rows={} months={} batches={} code15Retries={} fallbackCount={} totalMs={total_ms}", + n(result.size), + n(result.months), + n(result.batches), + n(result.code15_retries), + n(result.fallback_count) + ); + + let wanted: Option> = codes.as_ref().map(|c| c.iter().map(|s| s.as_str()).collect()); + let mut out: IndexMap = IndexMap::new(); + let mut marge_weight: std::collections::HashMap = std::collections::HashMap::new(); + for r in &result.rows { + let code = r.first().map(texte_js).unwrap_or_else(|| "undefined".into()).trim().to_string(); + if code.is_empty() || code == "-" { + continue; + } + if let Some(w) = &wanted { + if !w.contains(code.as_str()) { + continue; + } + } + let col = |i: usize| jsnum::nombre_ou_0(r.get(i)); + let (ca, qte, nb_mag, marge_pct) = (col(1), col(2), col(3), col(5)); + let Some(prev) = out.get_mut(&code) else { + out.insert( + code.clone(), + NetworkMetric { + code_centrale: code.clone(), + ca_reseau: ca, + qte_reseau: qte, + nb_magasins_reseau: nb_mag, + ca_par_magasin_reseau: if nb_mag > 0.0 { ca / nb_mag } else { 0.0 }, + marge_pct_reseau: marge_pct, + periode: date_filter.as_ref().map(|d| d.label.clone()), + ..Default::default() + }, + ); + marge_weight.insert(code, ca); + continue; + }; + prev.ca_reseau += ca; + prev.qte_reseau += qte; + prev.nb_magasins_reseau = prev.nb_magasins_reseau.max(nb_mag); + let weight = marge_weight.get(&code).copied().unwrap_or(0.0); + let next_weight = weight + ca; + prev.marge_pct_reseau = + if next_weight > 0.0 { (prev.marge_pct_reseau * weight + marge_pct * ca) / next_weight } else { prev.marge_pct_reseau.max(marge_pct) }; + prev.ca_par_magasin_reseau = if prev.nb_magasins_reseau > 0.0 { prev.ca_reseau / prev.nb_magasins_reseau } else { 0.0 }; + marge_weight.insert(code, next_weight); + } + + // Rattache le détail mensuel (par code central) au métrique agrégé. + // On produit DEUX représentations : + // - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la + // colonne « Tendance / Réseau » de la Grille. Inchangé. + // - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit. + // + // Mois RÉELLEMENT couverts par l'extraction = union des mois vus sur + // l'ensemble des codes. Distinguer « couvert, zéro vente » de « non + // extrait » est indispensable : sans cela, un article vendu seulement + // depuis mai voyait sa tendance calculée sur deux points (« +4 100 % »), + // et un mois non extrait passait pour une absence de vente. + let mois_couverts: Vec = result.monthly.values().flat_map(|pm| pm.keys().cloned()).collect::>().into_iter().collect(); + tracing::info!("[qlik-pw] mois couverts par l'extraction ({}) : {}", mois_couverts.len(), serde_json::to_string(&mois_couverts)?); + + let vide = BTreeMap::new(); + for (code, metric) in out.iter_mut() { + let mm = result.monthly.get(code).unwrap_or(&vide); + let mut qte_by_month = BTreeMap::new(); + let mut metrics_by_month = BTreeMap::new(); + for mois in &mois_couverts { + let v = mm.get(mois); + // Zéro EXPLICITE sur un mois couvert sans vente : c'est une + // information, au même titre qu'une vente. Un mois non couvert + // reste absent et sera ignoré par la tendance. + let q = v.map(|x| x.qte).unwrap_or(0.0); + qte_by_month.insert(mois.clone(), if q.is_nan() { 0.0 } else { q }); + // ⚠️ Toutes les mesures à zéro, pas seulement la quantité. Écrire + // `{ qte: 0 }` seul laissait `nbMag` absent sur les mois sans + // vente : la courbe « magasins vendeurs » exigeant les 12 mois, + // elle disparaissait dès qu'un produit avait un mois creux. + metrics_by_month.insert( + mois.clone(), + v.cloned().unwrap_or(QlikMonthMetrics { qte: 0.0, ca: Some(0.0), nb_mag: Some(0.0), ca_mag: Some(0.0), marge_pct: Some(0.0) }), + ); + } + metric.qte_by_month = Some(qte_by_month); + metric.metrics_by_month = Some(metrics_by_month); + } + + // Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques. + // Si presque tous varient → la mesure respecte le mois (feature OK). + // Si tous identiques → la mesure Qlik ignore la sélection Date. + let (mut n_var, mut n_flat) = (0, 0); + let (mut ex_var, mut ex_flat): (Option<&NetworkMetric>, Option<&NetworkMetric>) = (None, None); + for m in out.values() { + let Some(bm) = &m.qte_by_month else { continue }; + let vals: Vec = bm.values().copied().collect(); + if vals.len() < 2 { + continue; + } + if vals.iter().all(|v| *v == vals[0]) { + n_flat += 1; + ex_flat.get_or_insert(m); + } else { + n_var += 1; + ex_var.get_or_insert(m); + } + } + let qbm = |m: &NetworkMetric| { + let v: Map = m.qte_by_month.iter().flatten().map(|(k, x)| (k.clone(), jsnum::valeur(*x))).collect(); + Value::Object(v).to_string() + }; + tracing::info!("[qlik-pw] qteByMonth distribution: {n_var} codes VARIENT / {n_flat} identiques (total {})", n_var + n_flat); + if let Some(m) = ex_var { + tracing::info!("[qlik-pw] exemple VARIE {}: {}", m.code_centrale, qbm(m)); + } + if let Some(m) = ex_flat { + tracing::info!("[qlik-pw] exemple IDENTIQUE {}: {}", m.code_centrale, qbm(m)); + } + tracing::info!("[qlik-pw] {} produits réseau (cube {})", out.len(), n(result.size)); + + // Contrôle de plausibilité AVANT toute écriture : des données complètes + // mais fausses (dernier mois pas encore chargé, mesure insensible au + // mois) laisseraient le cache faux jusqu'à la synchro suivante. + let corrections: Vec = out.values_mut().flat_map(assainir_metrique).collect(); + if !corrections.is_empty() { + tracing::warn!( + "[qlik-pw] {} valeur(s) impossible(s) corrigée(s) : {}", + corrections.len(), + corrections.iter().take(5).cloned().collect::>().join(" ; ") + ); + } + if date_filter.is_some() { + let fenetre: Vec = month_ranges.iter().map(|m| m.label.clone()).collect(); + let controle = controler_extraction(out.values(), &fenetre, chrono::Local::now().date_naive(), OptionsControle::default()); + for a in &controle.avertissements { + tracing::warn!("[qlik-pw][contrôle] {a}"); + } + if !controle.ok { + return Err(anyhow!("[qlik-pw] {} — cache inchangé", controle.motif.unwrap_or_default())); + } + tracing::info!("[qlik-pw][contrôle] extraction validée ({} codes, {} mois)", out.len(), month_ranges.len()); + } + // Échantillon brut pour vérifier que les mesures (cols 1-5) renvoient des valeurs + let sample: Vec = result + .rows + .iter() + .take(3) + .map(|r| { + let c = |i: usize| r.get(i).cloned().unwrap_or(Value::Null); + json!({ "code": c(0), "ca": c(1), "qte": c(2), "nbMag": c(3), "caMag": c(4), "margePct": c(5) }) + }) + .collect(); + let echantillon = Value::Array(sample).to_string(); + tracing::info!("[qlik-pw] échantillon lignes: {echantillon}"); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tailles_de_lots() { + assert_eq!(unique_ordered(&[150, 75, 50, 75, 50]), vec![150, 75, 50]); + assert_eq!(unique_ordered(&[100, 100, 25, 75, 50]), vec![100, 25, 75, 50]); + } + + #[test] + fn resultat_in_page() { + let v = json!({ + "ok": true, + "rows": [["C1", 10.5, 3, 2, 5.25, 0.3], ["C1", 1, 1, 4, null, 0.1]], + "monthly": { "C1": { "2026-01": { "qte": 3, "ca": 10.5 }, "2026-02": { "qte": null } } }, + "size": 2, "months": 12, "diagExpr": ["a", "b"] + }); + let r = InPageResult::depuis(&v); + assert!(r.ok); + assert_eq!(r.rows.len(), 2); + assert_eq!(r.monthly["C1"]["2026-01"].ca, Some(10.5)); + assert!(r.monthly["C1"]["2026-02"].qte.is_nan()); + assert_eq!(r.diag_expr, vec!["a", "b"]); + assert_eq!(texte_js(&json!(12.0)), "12"); + } + + #[test] + fn script_embarque() { + assert!(SCRIPT_EXTRACTION.contains("cfCheckpoint")); + assert!(SCRIPT_EXTRACTION.contains("qlik-csrf-token")); + } +} diff --git a/server/src/qlik/search.rs b/server/src/qlik/search.rs new file mode 100644 index 0000000..1ee4fa4 --- /dev/null +++ b/server/src/qlik/search.rs @@ -0,0 +1,305 @@ +//! Recherche produit **dans Qlik Sense** (source primaire) — port de +//! `src/lib/qlik-search.ts`. +//! +//! La recherche produit part du réseau, pas du catalogue Nancy : on interroge +//! d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles +//! qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant +//! rapproche les codes centraux trouvés de la base FF Nancy. +//! +//! Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de +//! produits que Nancy. Chercher dans la base locale d'abord masquait tous les +//! produits que le réseau travaille et que nous ne référençons pas — c'est +//! précisément ce qu'on veut voir. +//! +//! Technique : même plomberie que `playwright.rs` (Chromium headless + +//! websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse +//! un websocket « raw » côté serveur. Le script in-page est `js/recherche.js`. +//! +//! ## Comment la sélection est construite +//! +//! La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU** +//! entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont +//! l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs +//! faisait ensuite exploser le cube (`code 15 — Request aborted`). +//! +//! On procède donc en trois temps : +//! 1. une recherche par mot, pour retenir le **plus sélectif** ; +//! 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots +//! présents, insensible casse/accents) ; +//! 3. sélection des seules valeurs retenues, **par numéro d'élément** +//! (`SelectListObjectValues`) — exact, sans ré-appariement de texte. +//! +//! Le résultat final est revérifié une dernière fois avant d'être renvoyé : une +//! liste sans rapport avec la recherche est pire que pas de résultat du tout. + +use super::browser::{dans_la_file_qlik, get_qlik_browser, PageQlik}; +use super::client::{get_qlik_config, qlik_ntlm_session, QlikConfig}; +use super::date_range::{build_grid_network_qlik_date_filter_now, js_number, QLIK_MONTHS_BACK_DEFAULT}; +use super::jsnum; +use anyhow::bail; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::time::{Duration, Instant}; + +const SCRIPT_RECHERCHE: &str = include_str!("js/recherche.js"); + +/// Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**. +/// +/// Les mesures sortent du même cube que l'identité : une seule session Qlik pour +/// toute la recherche. Les extraire dans une seconde session concurrente faisait +/// abandonner le moteur (`code 15 — Request aborted`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikArticleMatch { + /// Valeur de la dimension « Article Code » — clé de jointure de la Grille. + pub code_centrale: String, + /// Champ `article_no_centrale` quand l'app l'expose : second candidat pour la + /// jointure avec `articles.artcentrale`, les deux ne portant pas toujours le + /// même format. + pub code_centrale_alt: String, + /// Libellé réseau, si l'app expose un champ libellé article. + pub libelle: String, + /// Fournisseur réseau, si l'app expose un champ fournisseur. + pub fournisseur: String, + /// CA réseau sur la fenêtre 12 mois glissants. + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub qte_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub nb_magasins_reseau: f64, + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub ca_par_magasin_reseau: f64, + /// Taux de marge réseau, ratio brut Qlik (0.32 = 32 %). + #[serde(serialize_with = "jsnum::ser", deserialize_with = "jsnum::de")] + pub marge_pct_reseau: f64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QlikSearchOutcome { + pub matches: Vec, + /// Champ Qlik effectivement interrogé (diagnostic UI). + pub champ_utilise: Option, + /// true si Qlik a renvoyé plus de résultats que la limite demandée. + pub tronque: bool, + /// Message de diagnostic quand la recherche aboutit à zéro article alors que + /// Qlik a bien répondu (filtre non appliqué, terme trop large…). `None` en + /// fonctionnement nominal. + pub avertissement: Option, + /// Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`). + pub periode: Option, +} + +/// Nombre maximum d'articles renvoyés par une recherche. +/// +/// Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà +/// de quelques dizaines, la seconde passe devient longue et le résultat +/// illisible. L'utilisateur affine son terme. +pub const QLIK_SEARCH_MAX_RESULTS: usize = 40; + +/// Champs candidats pour le libellé article, par ordre de préférence. +/// +/// Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le +/// libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des +/// résultats : si un candidat ne matche rien, on passe au suivant. +const CHAMPS_LIBELLE_PREFERES: [&str; 2] = ["Article", "article_libelle_ticket"]; + +/// Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). +const CHAMPS_FOURNISSEUR_PREFERES: [&str; 2] = ["Fournisseur", "code_fournisseur"]; + +/// Champ portant le code centrale « brut » de l'article, quand il existe. +/// Second candidat de jointure avec `articles.artcentrale` : sur l'app FF, la +/// dimension « Article Code » ne renvoie pas le même format. +const CHAMP_CODE_CENTRALE_ALT: &str = "article_no_centrale"; + +/// Longueur JS (unités UTF-16). +fn longueur_js(s: &str) -> usize { + s.encode_utf16().count() +} + +/// Cherche dans Qlik les articles correspondant à `term`. +/// +/// `limit` : `None` = [`QLIK_SEARCH_MAX_RESULTS`] ; `cfg` : `None` = [`get_qlik_config`]. +pub async fn search_qlik_articles(term: &str, limit: Option, cfg: Option) -> anyhow::Result { + let limit = limit.unwrap_or(QLIK_SEARCH_MAX_RESULTS); + let cfg = cfg.unwrap_or_else(get_qlik_config); + // Terme trop court : réponse vide immédiate, sans attendre la file. + if longueur_js(term.trim()) < 3 { + return rechercher(term, limit, &cfg).await; + } + // Même file que les extractions : deux sessions Engine simultanées font + // abandonner le serveur Qlik, et les deux requêtes échouent. + dans_la_file_qlik(&format!("recherche « {} »", term.trim()), || rechercher(term, limit, &cfg)).await +} + +async fn rechercher(term: &str, limit: usize, cfg: &QlikConfig) -> anyhow::Result { + let cleaned = term.trim().to_string(); + if longueur_js(&cleaned) < 3 { + return Ok(QlikSearchOutcome::default()); + } + if cfg.app_network.is_empty() { + bail!("[qlik-search] QLIK_APP_NETWORK manquant"); + } + if cfg.user.is_empty() || cfg.password.is_empty() { + bail!("[qlik-search] identifiants Qlik manquants"); + } + + let started = Instant::now(); + // Latence laissée à l'Engine après chaque mutation de sélection. Même réglage + // que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après + // est calculé sur l'état de sélection précédent. + let settle = js_number(&std::env::var("QLIK_SETTLE_MS").unwrap_or_else(|_| "250".into())); + let settle_ms = if settle.is_nan() || settle == 0.0 { 250.0 } else { settle }.clamp(0.0, 2000.0); + // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. + let date_filter = build_grid_network_qlik_date_filter_now(QLIK_MONTHS_BACK_DEFAULT); + let sess = qlik_ntlm_session(cfg).await?; + let cookie_name = sess.cookie.split('=').next().unwrap_or("").to_string(); + let cookie_value = sess.cookie.splitn(2, '=').nth(1).unwrap_or("").to_string(); + + let browser = get_qlik_browser().await?; + let mut page = PageQlik::ouvrir( + browser, + // Le client Qlik est très bavard au chargement (thèmes, extensions) : + // on ne relaie que ce qui vient de notre script. + Box::new(|t: &str| t.starts_with("[cf]").then(|| format!("[qlik-search][page] {t}"))), + None, + ) + .await?; + let resultat = async { + page.identifiants_http(&cfg.user, &cfg.password).await?; + page.ajouter_cookie(&cookie_name, &cookie_value, &cfg.host).await?; + let mut jeton_rx = page.capturer_jeton_csrf().await?; + // Le client Qlik ouvre son ws juste après : une navigation en erreur n'est pas bloquante. + let _ = page.aller(&format!("https://{}/sense/app/{}", cfg.host, cfg.app_network), Duration::from_millis(cfg.timeout_ms)).await; + let Some(jeton) = super::playwright::attendre_jeton(&mut jeton_rx).await else { + bail!("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); + }; + + let args = json!({ + "app": cfg.app_network, + "dim": cfg.dim_code_article_id, + "token": jeton, + "terme": cleaned, + "limite": limit, + "settleMs": settle_ms, + "serialsDate": date_filter.daily_serials, + "mCa": cfg.meas_ca_id, + "mQte": cfg.meas_qte_id, + "mNbMag": cfg.meas_nb_mag_id, + "mCaMag": cfg.meas_ca_mag_id, + "mMarge": cfg.meas_marge_pct_id, + "champCodeAlt": CHAMP_CODE_CENTRALE_ALT, + "champsLibellePreferes": CHAMPS_LIBELLE_PREFERES, + "champsFournisseurPreferes": CHAMPS_FOURNISSEUR_PREFERES, + "champLibelleForce": std::env::var("QLIK_FIELD_ARTICLE_LIBELLE").unwrap_or_default().trim(), + "champFournisseurForce": std::env::var("QLIK_FIELD_FOURNISSEUR").unwrap_or_default().trim(), + }); + let result = page.evaluer(SCRIPT_RECHERCHE, &args).await?; + Ok(interpreter(result, &cleaned, started, date_filter.label.clone())) + } + .await; + page.fermer().await; + resultat? +} + +/// Traduit le résultat du script in-page en [`QlikSearchOutcome`]. +fn interpreter(result: Value, cleaned: &str, started: Instant, periode: String) -> anyhow::Result { + for t in result.get("trace").and_then(|t| t.as_array()).into_iter().flatten() { + tracing::info!("[qlik-search] {}", t.as_str().map(String::from).unwrap_or_else(|| t.to_string())); + } + if !result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let err = match result.get("error") { + Some(Value::String(s)) => s.clone(), + Some(v) => v.to_string(), + None => "undefined".into(), + }; + bail!("[qlik-search] {err}"); + } + + let chaine = |v: Option<&Value>| match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Null) | None => String::new(), + Some(autre) => autre.to_string(), + }; + let nombre = |v: Option<&Value>| v.map(jsnum::nombre).unwrap_or(f64::NAN); + // [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] + let matches: Vec = result + .get("rows") + .and_then(|r| r.as_array()) + .into_iter() + .flatten() + .filter_map(|l| l.as_array()) + .map(|l| QlikArticleMatch { + code_centrale: chaine(l.first()), + code_centrale_alt: chaine(l.get(1)), + libelle: chaine(l.get(2)), + fournisseur: chaine(l.get(3)), + ca_reseau: nombre(l.get(4)), + qte_reseau: nombre(l.get(5)), + nb_magasins_reseau: nombre(l.get(6)), + ca_par_magasin_reseau: nombre(l.get(7)), + marge_pct_reseau: nombre(l.get(8)), + }) + .collect(); + let total = result.get("total").map(jsnum::nombre).unwrap_or(0.0); + let champ_utilise = result.get("champUtilise").and_then(|c| c.as_str()).map(String::from); + tracing::info!( + "[qlik-search] \"{cleaned}\" → {} article(s) (total {}, champ={}) en {} ms", + matches.len(), + jsnum::texte(total), + champ_utilise.as_deref().unwrap_or("null"), + started.elapsed().as_millis() + ); + + // Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que + // d'afficher un « aucun résultat » qui laisserait croire à une absence + // réelle dans le réseau. + let rejetees = result.get("rejetees").map(jsnum::nombre).unwrap_or(0.0); + let trop_large = result.get("tropLarge").and_then(|v| v.as_bool()).unwrap_or(false); + let avertissement = if matches.is_empty() && rejetees > 0.0 { + Some("Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search].".to_string()) + } else if matches.is_empty() && trop_large { + Some("Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis.".to_string()) + } else { + None + }; + if let Some(a) = &avertissement { + tracing::warn!("[qlik-search] {a}"); + } + + Ok(QlikSearchOutcome { tronque: total > matches.len() as f64, matches, champ_utilise, avertissement, periode: Some(periode) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn terme_trop_court() { + let r = search_qlik_articles(" ab ", None, None).await.unwrap(); + assert!(r.matches.is_empty()); + assert_eq!(serde_json::to_string(&r).unwrap(), r#"{"matches":[],"champUtilise":null,"tronque":false,"avertissement":null,"periode":null}"#); + } + + #[test] + fn interpretation_du_resultat() { + let v = json!({ + "ok": true, "champUtilise": "Article", "total": 3, "rejetees": 0, "tropLarge": false, + "rows": [["10000167303", "", "TAPIS BAIN", "F005", 1500.5, 120, 80, 18.75, 0.32]], + "trace": ["code=Article Code"] + }); + let r = interpreter(v, "tapis", Instant::now(), "2025-10_2026-09".into()).unwrap(); + assert_eq!(r.matches.len(), 1); + assert!(r.tronque); + let j = serde_json::to_string(&r.matches[0]).unwrap(); + assert_eq!( + j, + r#"{"codeCentrale":"10000167303","codeCentraleAlt":"","libelle":"TAPIS BAIN","fournisseur":"F005","caReseau":1500.5,"qteReseau":120,"nbMagasinsReseau":80,"caParMagasinReseau":18.75,"margePctReseau":0.32}"# + ); + let vide = json!({ "ok": true, "rows": [], "rejetees": 4, "total": 0 }); + assert!(interpreter(vide, "x", Instant::now(), String::new()).unwrap().avertissement.unwrap().starts_with("Qlik a répondu sans appliquer")); + let ko = json!({ "ok": false, "error": "ws error (403 ?)" }); + assert_eq!(interpreter(ko, "x", Instant::now(), String::new()).unwrap_err().to_string(), "[qlik-search] ws error (403 ?)"); + } +} diff --git a/server/src/qlik/sync.rs b/server/src/qlik/sync.rs new file mode 100644 index 0000000..c97bea1 --- /dev/null +++ b/server/src/qlik/sync.rs @@ -0,0 +1,579 @@ +//! Route `/api/qlik/sync` (port de `src/app/api/qlik/sync/route.ts`). +//! +//! Extraction Qlik potentiellement très longue (plusieurs minutes) : la requête +//! HTTP rend la main tout de suite avec l'état du job, et le client interroge +//! `GET` toutes les 2 s. Une requête maintenue pendant toute l'extraction se +//! faisait couper par le reverse proxy. +//! +//! - Mode fournisseur (`?fournisseur=XXX`) : administrateurs uniquement. +//! - Mode produit (`?codeCentrale=YYY`, fiche produit) : tout utilisateur connecté. + +use super::date_range::{build_grid_network_qlik_date_filter_now, QLIK_MONTHS_BACK_DEFAULT}; +use super::network_cache::upsert_network_metrics; +use super::playwright::fetch_network_metrics_playwright; +use crate::auth::{require_admin, require_session, Token}; +use crate::error::{AppError, AppResult}; +use crate::logcapture::{start_capture, stop_capture}; +use axum::extract::Query; +use axum::http::StatusCode; +use axum::Json; +use parking_lot::Mutex; +use serde_json::{Map, Value}; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; + +/// État d'un job de sync Qlik. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QlikSyncStatus { + Running, + Success, + Error, +} + +impl QlikSyncStatus { + fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Success => "success", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone)] +struct QlikSyncJob { + job_id: String, + /// Code fournisseur (mode fournisseur) — chaîne vide en mode produit. + fournisseur: String, + /// Code centrale (mode produit) — absent en mode fournisseur. + code_centrale: Option, + status: QlikSyncStatus, + requested: usize, + fetched: usize, + upserted: usize, + periode: Option, + date_debut: Option, + date_fin: Option, + started_at: String, + finished_at: Option, + error: Option, + message: Option, +} + +type JobPartage = Arc>; + +/// Registre en mémoire, une entrée par cible (on ne lance qu'une extraction à +/// la fois par fournisseur / par produit). Volontairement simple : si le +/// processus redémarre, les jobs en cours sont perdus → GET renverra idle et +/// l'utilisateur pourra relancer. +static JOBS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur +/// brut) ; le mode produit est préfixé pour ne jamais entrer en collision. +fn job_key_for_product(code_centrale: &str) -> String { + format!("cc:{}", code_centrale.to_uppercase()) +} + +/// Nombre maximum d'extractions produit simultanées. +/// +/// Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au +/// mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très +/// légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne +/// la concurrence plutôt que de lui laisser prendre N requêtes en parallèle. +const MAX_CONCURRENT_PRODUCT_JOBS: usize = 3; + +fn running_product_jobs() -> usize { + JOBS.lock().iter().filter(|(k, j)| k.starts_with("cc:") && j.lock().status == QlikSyncStatus::Running).count() +} + +fn base36(mut n: u128) -> String { + const CHIFFRES: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if n == 0 { + return "0".into(); + } + let mut out = Vec::new(); + while n > 0 { + out.push(CHIFFRES[(n % 36) as usize]); + n /= 36; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + +/// Construit un jobId court et lisible (`qlik__`). +fn new_job_id() -> String { + let ms = chrono::Utc::now().timestamp_millis().max(0) as u128; + let alea: String = (0..6).map(|_| base36(rand::random::() as u128 % 36)).collect(); + format!("qlik_{}_{alea}", base36(ms)) +} + +fn maintenant_iso() -> String { + crate::db::iso_utc(chrono::Utc::now()) +} + +/// Sérialise un job pour la réponse HTTP (champs absents omis, comme `undefined`). +fn public_job(job: &QlikSyncJob) -> Value { + let mut m = Map::new(); + m.insert("success".into(), Value::Bool(true)); + m.insert("jobId".into(), Value::from(job.job_id.clone())); + m.insert("fournisseur".into(), Value::from(job.fournisseur.clone())); + if let Some(c) = &job.code_centrale { + m.insert("codeCentrale".into(), Value::from(c.clone())); + } + m.insert("status".into(), Value::from(job.status.as_str())); + m.insert("requested".into(), Value::from(job.requested)); + m.insert("fetched".into(), Value::from(job.fetched)); + m.insert("upserted".into(), Value::from(job.upserted)); + for (k, v) in [("periode", &job.periode), ("dateDebut", &job.date_debut), ("dateFin", &job.date_fin)] { + if let Some(v) = v { + m.insert(k.into(), Value::from(v.clone())); + } + } + m.insert("startedAt".into(), Value::from(job.started_at.clone())); + for (k, v) in [("finishedAt", &job.finished_at), ("error", &job.error), ("message", &job.message)] { + if let Some(v) = v { + m.insert(k.into(), Value::from(v.clone())); + } + } + Value::Object(m) +} + +/// État "idle" sérialisé (pas de job connu pour cette cible). +fn idle_job(fournisseur: &str, code_centrale: Option<&str>) -> Value { + let mut m = Map::new(); + m.insert("success".into(), Value::Bool(true)); + m.insert("jobId".into(), Value::Null); + m.insert("fournisseur".into(), Value::from(fournisseur)); + if let Some(c) = code_centrale { + m.insert("codeCentrale".into(), Value::from(c)); + } + m.insert("status".into(), Value::from("idle")); + m.insert("requested".into(), Value::from(0)); + m.insert("fetched".into(), Value::from(0)); + m.insert("upserted".into(), Value::from(0)); + Value::Object(m) +} + +/// Codes centraux retenus et compteurs de rejet. +#[derive(Debug, Default, PartialEq)] +pub struct CodesFiltres { + pub accepted: Vec, + pub rejected: usize, + pub deduped: usize, +} + +/// Filtre strict des codes centraux avant d'envoyer à Qlik. +/// +/// Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`). +/// Pour rester large et ne pas casser d'éventuels codes alphanumériques +/// fournisseur, on autorise longueur 2..30, jeu `[A-Z0-9_-]`, premier caractère +/// alphanumérique. But : ne jamais envoyer à Qlik des chaînes vides, des `-`, +/// des espaces parasites ou des entrées manifestement invalides, qui +/// déclenchent des erreurs Engine. On ne touche pas au mapping. +/// +/// - trim + rejet des vides, de `-` et `_` +/// - doublons supprimés (ordre préservé, comparaison insensible à la casse) +/// - rejet des codes hors format +/// +/// Les journaux n'exposent que les compteurs, jamais les valeurs. +pub fn filter_central_codes(raw_codes: I) -> CodesFiltres +where + I: IntoIterator>, + S: AsRef, +{ + let re = regex::Regex::new(r"(?i)^[A-Z0-9][A-Z0-9_-]{1,29}$").expect("regex"); + let mut vus = std::collections::HashSet::new(); + let mut out = CodesFiltres::default(); + for raw in raw_codes { + let Some(raw) = raw else { + out.rejected += 1; + continue; + }; + let code = raw.as_ref().trim().to_string(); + let key = code.to_uppercase(); + if code.is_empty() || code == "-" || code == "_" { + out.rejected += 1; + continue; + } + if vus.contains(&key) { + out.deduped += 1; + continue; + } + if !re.is_match(&code) { + out.rejected += 1; + continue; + } + vus.insert(key); + out.accepted.push(code); + } + out +} + +/// Inscrit une erreur dans le job, en traduisant la saturation mémoire du +/// serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow : +/// l'app Qlik ne se charge même pas). +fn apply_job_error(job: &JobPartage, msg: &str) { + let mut j = job.lock(); + tracing::error!("[api/qlik/sync] job={} {msg}", j.job_id); + j.status = QlikSyncStatus::Error; + let lower = msg.to_lowercase(); + j.error = Some( + if lower.contains("out of memory") + || lower.contains("not enough memory") + || lower.contains("file corrupted") + || lower.contains("\"code\":6") + || lower.contains("\"code\":3002") + { + "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).".to_string() + } else { + msg.to_string() + }, + ); + j.finished_at = Some(maintenant_iso()); +} + +fn pied_capture(job: &JobPartage) -> String { + let j = job.lock(); + format!("statut final={}{}", j.status.as_str(), j.error.as_ref().map(|e| format!(" — {e}")).unwrap_or_default()) +} + +/// Extraction + upsert pour un fournisseur, en mettant à jour le job au fur et +/// à mesure (compteur fetched, puis upserted, puis finishedAt). +async fn run_job(job: JobPartage) { + let (job_id, fournisseur, started_at) = { + let j = job.lock(); + (j.job_id.clone(), j.fournisseur.clone(), j.started_at.clone()) + }; + // Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du + // job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) — + // c'est précisément la partie qu'un terminal tronque. + start_capture(&job_id, Some(&format!("sync fournisseur={fournisseur} démarrée le {started_at}"))); + let res: anyhow::Result<()> = async { + let articles = crate::ff::pg::pg_get_articles_by_fournisseur(&fournisseur).await?; + // Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects). + let filtres = filter_central_codes(articles.iter().map(|a| a.code_centrale.as_deref())); + let codes = filtres.accepted; + job.lock().requested = codes.len(); + tracing::info!( + "[api/qlik/sync] job={job_id} fournisseur={fournisseur} — {} articles, {} codes centraux retenus, {} rejetés (vide/-/anormaux), {} doublons supprimés", + articles.len(), + codes.len(), + filtres.rejected, + filtres.deduped + ); + if codes.is_empty() { + let mut j = job.lock(); + j.status = QlikSyncStatus::Success; + j.message = Some("Aucun code centrale valide pour ce fournisseur".into()); + j.finished_at = Some(maintenant_iso()); + return Ok(()); + } + + // Contrat métier strict : toujours 12 mois complets glissants. + // Une variable d'environnement ne doit pas pouvoir raccourcir la série. + let date_filter = build_grid_network_qlik_date_filter_now(QLIK_MONTHS_BACK_DEFAULT); + { + let mut j = job.lock(); + j.periode = Some(date_filter.label.clone()); + j.date_debut = Some(date_filter.date_debut.clone()); + j.date_fin = Some(date_filter.date_fin.clone()); + } + tracing::info!( + "[api/qlik/sync] job={job_id} → extraction Qlik pour {} codes — 12 mois complets {} ({} → {})…", + codes.len(), + date_filter.label, + date_filter.date_debut, + date_filter.date_fin + ); + // Le code fournisseur est transmis en plus des codes : s'il désigne dans + // Qlik le même périmètre (couverture vérifiée côté extracteur), une seule + // sélection remplace les dizaines de milliers de `SelectValues` par code. + // Les codes restent le repli et le contrôle de couverture. + let metrics = fetch_network_metrics_playwright(Some(codes), None, Some(date_filter), Some(fournisseur.clone())).await?; + job.lock().fetched = metrics.len(); + tracing::info!("[api/qlik/sync] job={job_id} ← Qlik a renvoyé {} produits réseau", metrics.len()); + let valeurs: Vec<_> = metrics.into_values().collect(); + let count = upsert_network_metrics(&valeurs).await?; + tracing::info!("[api/qlik/sync] job={job_id} {count} lignes upsert dans le cache"); + let mut j = job.lock(); + j.upserted = count; + j.status = QlikSyncStatus::Success; + j.finished_at = Some(maintenant_iso()); + Ok(()) + } + .await; + if let Err(e) = res { + apply_job_error(&job, &format!("{e:#}")); + } + stop_capture(&job_id, Some(&pied_capture(&job))); +} + +/// Extraction Qlik pour UN seul code centrale (fiche produit). +/// +/// Beaucoup plus légère que la sync fournisseur : un seul code au lieu de +/// plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise +/// exactement le même extracteur, donc le même détail mensuel. +async fn run_product_job(job: JobPartage) { + let (job_id, code, started_at) = { + let j = job.lock(); + (j.job_id.clone(), j.code_centrale.clone().unwrap_or_default(), j.started_at.clone()) + }; + start_capture(&job_id, Some(&format!("sync produit={code} démarrée le {started_at}"))); + let res: anyhow::Result<()> = async { + let date_filter = build_grid_network_qlik_date_filter_now(QLIK_MONTHS_BACK_DEFAULT); + { + let mut j = job.lock(); + j.requested = 1; + j.periode = Some(date_filter.label.clone()); + j.date_debut = Some(date_filter.date_debut.clone()); + j.date_fin = Some(date_filter.date_fin.clone()); + } + tracing::info!( + "[api/qlik/sync] job={job_id} → extraction Qlik produit (1 code) — fenêtre {} ({} → {})…", + date_filter.label, + date_filter.date_debut, + date_filter.date_fin + ); + let metrics = fetch_network_metrics_playwright(Some(vec![code.clone()]), None, Some(date_filter), None).await?; + job.lock().fetched = metrics.len(); + if metrics.is_empty() { + let mut j = job.lock(); + j.status = QlikSyncStatus::Success; + j.message = Some("Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)".into()); + j.finished_at = Some(maintenant_iso()); + return Ok(()); + } + let valeurs: Vec<_> = metrics.into_values().collect(); + let count = upsert_network_metrics(&valeurs).await?; + { + let mut j = job.lock(); + j.upserted = count; + j.status = QlikSyncStatus::Success; + j.message = Some("Données réseau récupérées".into()); + j.finished_at = Some(maintenant_iso()); + } + tracing::info!("[api/qlik/sync] job={job_id} produit {code} — {count} ligne(s) upsert"); + Ok(()) + } + .await; + if let Err(e) = res { + apply_job_error(&job, &format!("{e:#}")); + } + stop_capture(&job_id, Some(&pied_capture(&job))); +} + +/// Lancement « fire-and-forget » : on ne bloque pas la réponse HTTP. Filet de +/// sécurité : une panique du job ne doit jamais laisser le job « running ». +fn lancer(job: JobPartage, produit: bool) { + let suivi = job.clone(); + let tache = if produit { tokio::spawn(run_product_job(job)) } else { tokio::spawn(run_job(job)) }; + tokio::spawn(async move { + if let Err(e) = tache.await { + let msg = format!("crash non géré : {e}"); + tracing::error!("[api/qlik/sync] job={} {msg}", suivi.lock().job_id); + let mut j = suivi.lock(); + j.status = QlikSyncStatus::Error; + j.error = Some(msg); + j.finished_at = Some(maintenant_iso()); + } + }); +} + +fn param<'a>(q: &'a HashMap, nom: &str) -> Option<&'a str> { + q.get(nom).map(|s| s.as_str()).filter(|s| !s.is_empty()) +} + +const PARAM_REQUIS: &str = "Param 'fournisseur' ou 'codeCentrale' requis"; + +/// GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY +/// +/// Retourne l'état courant du job pour cette cible ; « idle » si aucun job +/// n'est connu. Permet au client de reprendre l'état après un rechargement et +/// de suivre l'avancement en arrière-plan. +pub async fn get(Token(token): Token, Query(q): Query>) -> AppResult> { + // Mode produit : ouvert à tout utilisateur connecté. + if let Some(code_centrale) = param(&q, "codeCentrale") { + require_session(token.as_ref()).await?; + let job = JOBS.lock().get(&job_key_for_product(code_centrale)).cloned(); + return Ok(Json(match job { + Some(j) => public_job(&j.lock()), + None => idle_job("", Some(code_centrale)), + })); + } + + // Mode fournisseur : admin uniquement. + require_admin(token.as_ref()).await?; + let Some(fournisseur) = param(&q, "fournisseur") else { + return Err(AppError::bad_request(PARAM_REQUIS)); + }; + let job = JOBS.lock().get(fournisseur).cloned(); + Ok(Json(match job { + Some(j) => public_job(&j.lock()), + None => idle_job(fournisseur, None), + })) +} + +/// POST /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY +/// +/// Démarre (ou réutilise s'il tourne déjà) un job d'extraction en arrière-plan +/// et retourne *immédiatement* son état. Le client suit ensuite via GET. +pub async fn post(Token(token): Token, Query(q): Query>) -> AppResult> { + // ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ─── + if let Some(code_param) = param(&q, "codeCentrale") { + require_session(token.as_ref()).await?; + // Même validation que la sync fournisseur (trim, rejet des vides / "-" / + // codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik. + let filtres = filter_central_codes([Some(code_param)]); + let Some(code) = filtres.accepted.into_iter().next() else { + return Err(AppError::bad_request("Code centrale invalide")); + }; + let key = job_key_for_product(&code); + if let Some(existant) = JOBS.lock().get(&key).cloned() { + let j = existant.lock(); + if j.status == QlikSyncStatus::Running { + return Ok(Json(public_job(&j))); + } + } + if running_product_jobs() >= MAX_CONCURRENT_PRODUCT_JOBS { + return Err(AppError::new(StatusCode::TOO_MANY_REQUESTS, "Trop d'extractions Qlik en cours. Réessayez dans quelques instants.")); + } + let job = Arc::new(Mutex::new(QlikSyncJob { + job_id: new_job_id(), + fournisseur: String::new(), + code_centrale: Some(code.clone()), + status: QlikSyncStatus::Running, + requested: 0, + fetched: 0, + upserted: 0, + periode: None, + date_debut: None, + date_fin: None, + started_at: maintenant_iso(), + finished_at: None, + error: None, + message: None, + })); + JOBS.lock().insert(key, job.clone()); + let corps = public_job(&job.lock()); + tracing::info!("[api/qlik/sync] job={} démarré pour codeCentrale={code}", job.lock().job_id); + lancer(job, true); + return Ok(Json(corps)); + } + + // ─── Mode fournisseur : admin uniquement ────────────────────────────────── + require_admin(token.as_ref()).await?; + let Some(fournisseur) = param(&q, "fournisseur") else { + return Err(AppError::bad_request(PARAM_REQUIS)); + }; + if let Some(existant) = JOBS.lock().get(fournisseur).cloned() { + let j = existant.lock(); + if j.status == QlikSyncStatus::Running { + // Un job tourne déjà pour ce fournisseur : on le réutilise. + return Ok(Json(public_job(&j))); + } + } + let job = Arc::new(Mutex::new(QlikSyncJob { + job_id: new_job_id(), + fournisseur: fournisseur.to_string(), + code_centrale: None, + status: QlikSyncStatus::Running, + requested: 0, + fetched: 0, + upserted: 0, + periode: None, + date_debut: None, + date_fin: None, + started_at: maintenant_iso(), + finished_at: None, + error: None, + message: None, + })); + JOBS.lock().insert(fournisseur.to_string(), job.clone()); + let corps = public_job(&job.lock()); + tracing::info!("[api/qlik/sync] job={} démarré pour fournisseur={fournisseur}", job.lock().job_id); + // Lancement fire-and-forget : on renvoie tout de suite au client, qui suit + // via GET. Les erreurs sont capturées dans le job. + lancer(job, false); + Ok(Json(corps)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filtre_des_codes_centraux() { + let long = "X".repeat(31); + let r = filter_central_codes([ + Some(" 10000167303 "), + None, + Some(""), + Some("-"), + Some("_"), + Some("10000167303"), + Some("abc"), + Some("ABC"), + Some("a"), + Some("_x1"), + Some("code avec espace"), + Some(long.as_str()), + ]); + assert_eq!(r.accepted, vec!["10000167303".to_string(), "abc".to_string()]); + assert_eq!(r.deduped, 2); + assert_eq!(r.rejected, 8); + } + + #[test] + fn formes_json() { + let idle = idle_job("", Some("100")); + assert_eq!( + idle.to_string(), + r#"{"success":true,"jobId":null,"fournisseur":"","codeCentrale":"100","status":"idle","requested":0,"fetched":0,"upserted":0}"# + ); + let job = QlikSyncJob { + job_id: "qlik_x_y".into(), + fournisseur: "J009".into(), + code_centrale: None, + status: QlikSyncStatus::Error, + requested: 3, + fetched: 0, + upserted: 0, + periode: Some("2025-10_2026-09".into()), + date_debut: Some("2025-10-01".into()), + date_fin: Some("2026-09-30".into()), + started_at: "2026-10-12T08:00:00.000Z".into(), + finished_at: Some("2026-10-12T08:01:00.000Z".into()), + error: Some("boom".into()), + message: None, + }; + assert_eq!( + public_job(&job).to_string(), + r#"{"success":true,"jobId":"qlik_x_y","fournisseur":"J009","status":"error","requested":3,"fetched":0,"upserted":0,"periode":"2025-10_2026-09","dateDebut":"2025-10-01","dateFin":"2026-09-30","startedAt":"2026-10-12T08:00:00.000Z","finishedAt":"2026-10-12T08:01:00.000Z","error":"boom"}"# + ); + assert!(new_job_id().starts_with("qlik_")); + assert_eq!(base36(35), "z"); + } + + #[test] + fn saturation_memoire_traduite() { + let job = Arc::new(Mutex::new(QlikSyncJob { + job_id: "j".into(), + fournisseur: "F".into(), + code_centrale: None, + status: QlikSyncStatus::Running, + requested: 0, + fetched: 0, + upserted: 0, + periode: None, + date_debut: None, + date_fin: None, + started_at: String::new(), + finished_at: None, + error: None, + message: None, + })); + apply_job_error(&job, r#"[qlik-pw] {"code":6,"message":"Not enough memory"}"#); + assert!(job.lock().error.as_deref().unwrap().starts_with("Serveur Qlik saturé")); + assert_eq!(job.lock().status, QlikSyncStatus::Error); + } +} diff --git a/server/src/qlik/tests_navigateur.rs b/server/src/qlik/tests_navigateur.rs new file mode 100644 index 0000000..ccffb0e --- /dev/null +++ b/server/src/qlik/tests_navigateur.rs @@ -0,0 +1,161 @@ +//! Tests de la plomberie Chromium/CDP (sans serveur Qlik). +//! +//! Le test principal est ignoré par défaut (il lance un vrai Chromium) : +//! `PLAYWRIGHT_CHROMIUM_PATH=/opt/pw-browsers/chromium-1194/chrome-linux/chrome \ +//! cargo test -- --ignored plomberie_cdp` + +use super::browser::{dans_la_file_qlik, get_qlik_browser, PageQlik}; +use axum::http::HeaderMap; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; + +const PAGE: &str = r#"faux Qlik + +"#; + +/// Défi HTTP Basic : la page doit répondre avec les identifiants (`httpCredentials`). +async fn protege(headers: HeaderMap) -> axum::response::Response { + use axum::response::IntoResponse; + match headers.get("authorization").and_then(|v| v.to_str().ok()) { + Some(a) => format!("autorisé {a}").into_response(), + None => (axum::http::StatusCode::UNAUTHORIZED, [("www-authenticate", "Basic realm=\"qlik\"")], "refusé").into_response(), + } +} + +async fn racine(headers: HeaderMap) -> Html { + let cookie = headers.get("cookie").and_then(|v| v.to_str().ok()).unwrap_or("(aucun)").to_string(); + Html(PAGE.replace("COOKIE", &cookie)) +} + +#[tokio::test] +#[ignore] +async fn plomberie_cdp() { + if std::env::var("PLAYWRIGHT_CHROMIUM_PATH").is_err() { + std::env::set_var("PLAYWRIGHT_CHROMIUM_PATH", "/opt/pw-browsers/chromium-1194/chrome-linux/chrome"); + } + // Petit serveur HTTP local. + let app = Router::new().route("/", get(racine)).route("/app/abc", get(|| async { "pas un websocket" })).route("/protege", get(protege)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let browser = get_qlik_browser().await.expect("lancement de Chromium"); + // Même navigateur tant qu'il est connecté. + assert!(Arc::ptr_eq(&browser, &get_qlik_browser().await.unwrap())); + + let lignes = Arc::new(parking_lot::Mutex::new(Vec::::new())); + let copie = lignes.clone(); + let mut page = PageQlik::ouvrir( + browser, + Box::new(move |t: &str| { + copie.lock().push(t.to_string()); + t.starts_with("[cf]").then(|| format!("[test][page] {t}")) + }), + Some("[test][pageerror]"), + ) + .await + .expect("page"); + + page.identifiants_http("user", "secret").await.unwrap(); + page.ajouter_cookie("X-Qlik-Session", "valeur-de-session", "localhost").await.unwrap(); + let mut jeton = page.capturer_jeton_csrf().await.unwrap(); + let mut appels = page.exposer_fonction("cfCheckpoint").await.unwrap(); + + page.aller(&format!("http://localhost:{port}/"), Duration::from_secs(20)).await.expect("navigation"); + let j = super::playwright::attendre_jeton(&mut jeton).await; + assert_eq!(j.as_deref(), Some("jeton/secret"), "jeton CSRF capturé depuis l'URL du websocket"); + + // Cookie posé avant navigation (Network.setCookies) et renvoyé au serveur. + let texte = page.evaluer("() => document.getElementById('cookie').textContent", &json!(null)).await.unwrap(); + assert_eq!(texte, json!("X-Qlik-Session=valeur-de-session")); + + // Liaison exposée : appelée par la page, reçue côté Rust ; la promesse se résout à true. + let r = page + .evaluer( + "async ({ n }) => { const ok = await window.cfCheckpoint({ rows: [[\"C1\", n]] }); return { ok, n: n * 2, nan: NaN }; }", + &json!({ "n": 21 }), + ) + .await + .unwrap(); + assert_eq!(r, json!({ "ok": true, "n": 42, "nan": null })); + let payload = tokio::time::timeout(Duration::from_secs(5), appels.recv()).await.unwrap().unwrap(); + assert_eq!(serde_json::from_str::(&payload).unwrap(), json!({ "rows": [["C1", 21]] })); + + // Exception in-page → erreur. + let err = page.evaluer("() => { throw new Error('boum'); }", &json!(null)).await.unwrap_err().to_string(); + assert!(err.contains("boum"), "{err}"); + + // Script embarqué de la recherche, avec ses vrais arguments JSON : le + // websocket Engine (wss) échoue ici faute de Qlik, et le script le dit. + let args = json!({ + "app": "abc", "dim": "yesCP", "token": "jeton", "terme": "tapis bain", "limite": 40, "settleMs": 10, + "serialsDate": [46000, 46001], "mCa": "a", "mQte": "b", "mNbMag": "c", "mCaMag": "d", "mMarge": "e", + "champCodeAlt": "article_no_centrale", "champsLibellePreferes": ["Article"], "champsFournisseurPreferes": ["Fournisseur"], + "champLibelleForce": "", "champFournisseurForce": "" + }); + let res = tokio::time::timeout(Duration::from_secs(40), page.evaluer(include_str!("js/recherche.js"), &args)).await.unwrap().unwrap(); + assert_eq!(res["ok"], json!(false), "{res}"); + assert!(res["error"].as_str().unwrap().starts_with("ws "), "{res}"); + + // Script d'extraction : même chose, avec un jeu d'arguments complet. + let args = json!({ + "app": "abc", "dim": "yesCP", "mca": "a", "mqte": "b", "mnb": "c", "mcamag": "d", "mmarge": "e", + "codes": ["10000167303"], "token": "jeton", "monthlyPayload": [], "noDateMode": true, + "qlikSettleMs": 10, "qlikCode15MaxAttempts": 1, "qlikArticleBatchFallbacks": [150, 75, 50], + "selectAllCodes": true, "monthDim": true, "moisDimId": "pfGAwTs", "mqteComp": "x", "yearN": 2026, + "useExpr": true, "exprQte": "Sum(quantite)", "exprCa": "Sum(ca_ht)", "exprNbMag": "Count(DISTINCT [Magasin Code])", + "exprMarge": "Sum(marge)", "champsDateCandidats": ["Date"], "fournisseurCode": "", "champsFournisseurCandidats": ["code_fournisseur"], + "couvertureFournisseurMin": 0.99 + }); + let res = tokio::time::timeout(Duration::from_secs(40), page.evaluer(include_str!("js/extraction.js"), &args)).await.unwrap().unwrap(); + assert_eq!(res["ok"], json!(false), "{res}"); + assert_eq!(res["rows"], json!([])); + + tokio::time::sleep(Duration::from_millis(300)).await; + let vues = lignes.lock().clone(); + assert!(vues.iter().any(|l| l == "[cf] bonjour depuis la page 42"), "{vues:?}"); + assert!(vues.iter().any(|l| l == "bruit du client Qlik"), "{vues:?}"); + + // Défi d'authentification HTTP : identifiants fournis automatiquement. + page.aller(&format!("http://localhost:{port}/protege"), Duration::from_secs(20)).await.expect("navigation protégée"); + let corps = page.evaluer("() => document.body.textContent", &json!(null)).await.unwrap(); + assert_eq!(corps, json!("autorisé Basic dXNlcjpzZWNyZXQ=")); + + page.fermer().await; +} + +#[tokio::test] +async fn file_qlik_sequentielle() { + let ordre = Arc::new(parking_lot::Mutex::new(Vec::new())); + let mut taches = Vec::new(); + for i in 0..4 { + let ordre = ordre.clone(); + taches.push(tokio::spawn(async move { + dans_la_file_qlik(&format!("tâche {i}"), || async move { + ordre.lock().push(format!("début {i}")); + tokio::time::sleep(Duration::from_millis(20)).await; + ordre.lock().push(format!("fin {i}")); + i + }) + .await + })); + tokio::time::sleep(Duration::from_millis(2)).await; + } + for (i, t) in taches.into_iter().enumerate() { + assert_eq!(t.await.unwrap(), i); + } + let o = ordre.lock().clone(); + // Jamais deux tâches en même temps, dans l'ordre d'arrivée. + let attendu: Vec = (0..4).flat_map(|i| [format!("début {i}"), format!("fin {i}")]).collect(); + assert_eq!(o, attendu); +} diff --git a/server/src/qlik/validation.rs b/server/src/qlik/validation.rs new file mode 100644 index 0000000..9d6d7c8 --- /dev/null +++ b/server/src/qlik/validation.rs @@ -0,0 +1,272 @@ +//! Contrôles de plausibilité d'une extraction réseau Qlik +//! (port de `src/lib/qlik-validation.ts`). +//! +//! L'extracteur vérifie déjà que les 12 mois de la fenêtre sont servis. Il ne +//! peut pas voir deux pièges, observés ou attendus en production, qui donnent +//! des chiffres faux mais complets : +//! +//! 1. **Dernier mois pas encore chargé dans Qlik.** Les premiers jours du mois, +//! le mois qui vient de se terminer peut n'être chargé qu'en partie. Il est +//! « couvert » (quelques ventes), donc accepté — et toutes les tendances du +//! fournisseur plongent d'un coup, jusqu'à la synchro suivante, une semaine +//! ou plus après. +//! 2. **Mesure insensible au mois.** Quand une mesure ignore la sélection de +//! dates, chaque mois reçoit la même valeur (le total de l'année). Le +//! diagnostic existait dans les journaux ; rien n'empêchait l'écriture. +//! +//! Dans les deux cas, garder le cache précédent vaut mieux qu'écrire des +//! données fausses : l'extraction est refusée, et le planificateur la reprend +//! une nuit suivante. +//! +//! Module pur : testé par `cargo test`. + +use super::client::{NetworkMetric, QlikMonthMetrics}; +use super::jsnum::texte; +use chrono::{Datelike, NaiveDate}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct ControleExtraction { + pub ok: bool, + /// Raison du refus, lisible par l'administrateur (statut de synchro). + #[serde(skip_serializing_if = "Option::is_none")] + pub motif: Option, + /// Anomalies corrigées ou tolérées, à journaliser. + pub avertissements: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct OptionsControle { + /// Jusqu'à quel jour du mois le dernier mois est soupçonné d'être incomplet. + /// Au-delà, une baisse est prise pour ce qu'elle est : une baisse. + pub jour_limite_dernier_mois: u32, + /// Part minimale du dernier mois par rapport à la moyenne des 3 précédents. + pub ratio_dernier_mois_min: f64, + /// Volume mensuel moyen en dessous duquel le contrôle n'a pas de sens. + pub volume_minimal: f64, + /// Part maximale de séries plates (même valeur 12 mois) parmi les séries vendues. + pub part_series_plates_max: f64, +} + +impl Default for OptionsControle { + fn default() -> Self { + Self { jour_limite_dernier_mois: 10, ratio_dernier_mois_min: 0.4, volume_minimal: 30.0, part_series_plates_max: 0.5 } + } +} + +fn fini(v: Option) -> f64 { + match v { + Some(n) if n.is_finite() => n, + _ => 0.0, + } +} + +/// `Math.round` JS (demi vers +∞). +fn arrondi_js(v: f64) -> f64 { + (v + 0.5).floor() +} + +/// Corrige sur place les valeurs impossibles d'une métrique (NaN, infini, +/// nombre de magasins négatif) et renvoie la liste des corrections. +pub fn assainir_metrique(m: &mut NetworkMetric) -> Vec { + let mut corrections = Vec::new(); + let code = m.code_centrale.clone(); + for (champ, valeur) in [ + ("caReseau", &mut m.ca_reseau), + ("qteReseau", &mut m.qte_reseau), + ("nbMagasinsReseau", &mut m.nb_magasins_reseau), + ("caParMagasinReseau", &mut m.ca_par_magasin_reseau), + ("margePctReseau", &mut m.marge_pct_reseau), + ] { + if !valeur.is_finite() { + corrections.push(format!("{code} : {champ} non numérique ({}) remplacé par 0", texte(*valeur))); + *valeur = 0.0; + } + } + if m.nb_magasins_reseau < 0.0 { + corrections.push(format!("{code} : nombre de magasins négatif ({}) remplacé par 0", texte(m.nb_magasins_reseau))); + m.nb_magasins_reseau = 0.0; + } + m.nb_magasins_reseau = arrondi_js(m.nb_magasins_reseau); + m.ca_par_magasin_reseau = if m.nb_magasins_reseau > 0.0 { m.ca_reseau / m.nb_magasins_reseau } else { 0.0 }; + + if let Some(q) = m.qte_by_month.as_mut() { + for (mois, v) in q.iter_mut() { + if !v.is_finite() { + corrections.push(format!("{code} : quantité {mois} non numérique remplacée par 0")); + *v = 0.0; + } + } + } + if let Some(mm) = m.metrics_by_month.as_mut() { + for v in mm.values_mut() { + *v = QlikMonthMetrics { + qte: fini(Some(v.qte)), + ca: Some(fini(v.ca)), + nb_mag: Some(arrondi_js(fini(v.nb_mag)).max(0.0)), + ca_mag: Some(fini(v.ca_mag)), + marge_pct: Some(fini(v.marge_pct)), + }; + } + } + corrections +} + +/// Contrôle d'ensemble d'une extraction 12 mois, AVANT écriture du cache. +/// +/// `fenetre` : les mois attendus (« YYYY-MM »), du plus ancien au plus récent. +/// `now` : date locale du jour. +pub fn controler_extraction<'a>( + metriques: impl IntoIterator, + fenetre: &[String], + now: NaiveDate, + o: OptionsControle, +) -> ControleExtraction { + let mut avertissements = Vec::new(); + let liste: Vec<&NetworkMetric> = metriques.into_iter().collect(); + if liste.is_empty() || fenetre.len() < 4 { + return ControleExtraction { ok: true, motif: None, avertissements }; + } + let qte = |m: &NetworkMetric, mois: &str| fini(m.qte_by_month.as_ref().and_then(|q| q.get(mois).copied())); + + // ── 1. Séries plates : la mesure a ignoré la sélection de mois ────────── + let mut vendues = 0usize; + let mut plates = 0usize; + for m in &liste { + let valeurs: Vec = fenetre.iter().map(|mois| qte(m, mois)).collect(); + if !valeurs.iter().any(|v| *v != 0.0) { + continue; + } + vendues += 1; + if valeurs.iter().all(|v| *v == valeurs[0]) { + plates += 1; + } + } + if vendues >= 5 && plates as f64 / vendues as f64 > o.part_series_plates_max { + return ControleExtraction { + ok: false, + motif: Some(format!("{plates} séries sur {vendues} ont la même quantité les 12 mois : la mesure Qlik ignore le mois, données refusées")), + avertissements, + }; + } + if plates > 0 { + avertissements.push(format!("{plates} série(s) identique(s) sur 12 mois parmi {vendues} vendues")); + } + + // ── 2. Dernier mois manifestement incomplet ───────────────────────────── + let totaux: Vec = fenetre.iter().map(|mois| liste.iter().map(|m| qte(m, mois)).sum()).collect(); + let dernier = totaux[totaux.len() - 1]; + let precedents = &totaux[totaux.len() - 4..totaux.len() - 1]; + let reference = precedents.iter().sum::() / precedents.len() as f64; + if now.day() <= o.jour_limite_dernier_mois && reference >= o.volume_minimal && dernier < reference * o.ratio_dernier_mois_min { + let pct = arrondi_js(dernier / reference * 100.0); + return ControleExtraction { + ok: false, + motif: Some(format!( + "le dernier mois ({}) ne pèse que {} % des 3 mois précédents : probablement pas encore entièrement chargé dans Qlik, données refusées (nouvel essai une prochaine nuit)", + fenetre[fenetre.len() - 1], + texte(pct) + )), + avertissements, + }; + } + + ControleExtraction { ok: true, motif: None, avertissements } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn fenetre() -> Vec { + (1..=12).map(|m| format!("2025-{m:02}")).collect() + } + + fn metrique(code: &str, qtes: &[f64]) -> NetworkMetric { + let f = fenetre(); + NetworkMetric { + code_centrale: code.into(), + qte_by_month: Some(f.iter().cloned().zip(qtes.iter().copied()).collect::>()), + ..Default::default() + } + } + + fn d(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() + } + + #[test] + fn assainit_les_valeurs_impossibles() { + let mut m = NetworkMetric { + code_centrale: "C1".into(), + ca_reseau: 100.0, + qte_reseau: f64::NAN, + nb_magasins_reseau: -3.0, + marge_pct_reseau: f64::INFINITY, + qte_by_month: Some(BTreeMap::from([("2025-01".into(), f64::NAN), ("2025-02".into(), 4.0)])), + metrics_by_month: Some(BTreeMap::from([( + "2025-01".into(), + QlikMonthMetrics { qte: f64::NAN, ca: None, nb_mag: Some(2.5), ca_mag: Some(1.0), marge_pct: None }, + )])), + ..Default::default() + }; + let c = assainir_metrique(&mut m); + assert_eq!(c.len(), 4, "{c:?}"); + assert!(c[0].contains("qteReseau non numérique (NaN)")); + assert!(c[1].contains("margePctReseau non numérique (Infinity)")); + assert!(c[2].contains("nombre de magasins négatif (-3)")); + assert_eq!(m.qte_reseau, 0.0); + assert_eq!(m.nb_magasins_reseau, 0.0); + assert_eq!(m.ca_par_magasin_reseau, 0.0); + assert_eq!(m.qte_by_month.as_ref().unwrap()["2025-01"], 0.0); + let mm = &m.metrics_by_month.as_ref().unwrap()["2025-01"]; + assert_eq!(mm, &QlikMonthMetrics { qte: 0.0, ca: Some(0.0), nb_mag: Some(3.0), ca_mag: Some(1.0), marge_pct: Some(0.0) }); + + let mut m2 = NetworkMetric { code_centrale: "C2".into(), ca_reseau: 90.0, nb_magasins_reseau: 2.6, ..Default::default() }; + assert!(assainir_metrique(&mut m2).is_empty()); + assert_eq!(m2.nb_magasins_reseau, 3.0); + assert_eq!(m2.ca_par_magasin_reseau, 30.0); + } + + #[test] + fn refuse_les_series_plates() { + let liste: Vec = (0..6).map(|i| metrique(&format!("C{i}"), &[10.0; 12])).collect(); + let r = controler_extraction(&liste, &fenetre(), d("2026-01-20"), OptionsControle::default()); + assert!(!r.ok); + assert!(r.motif.unwrap().starts_with("6 séries sur 6 ont la même quantité")); + } + + #[test] + fn tolere_quelques_series_plates() { + let mut liste: Vec = + (0..5).map(|i| metrique(&format!("V{i}"), &[1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12.])).collect(); + liste.push(metrique("P", &[5.0; 12])); + let r = controler_extraction(&liste, &fenetre(), d("2026-01-20"), OptionsControle::default()); + assert!(r.ok); + assert_eq!(r.avertissements, vec!["1 série(s) identique(s) sur 12 mois parmi 6 vendues".to_string()]); + } + + #[test] + fn refuse_un_dernier_mois_incomplet_en_debut_de_mois() { + let mut q = [100.0; 12]; + q[11] = 20.0; + q[0] = 99.0; // pas plate + let liste = vec![metrique("C", &q)]; + let r = controler_extraction(&liste, &fenetre(), d("2026-01-05"), OptionsControle::default()); + assert!(!r.ok); + assert!(r.motif.unwrap().starts_with("le dernier mois (2025-12) ne pèse que 20 % des 3 mois précédents")); + // Après le 10 du mois : la baisse est réelle. + assert!(controler_extraction(&liste, &fenetre(), d("2026-01-11"), OptionsControle::default()).ok); + // Volume trop faible : pas de contrôle. + let petit = vec![metrique("C", &[10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 11.0, 1.0])]; + assert!(controler_extraction(&petit, &fenetre(), d("2026-01-05"), OptionsControle::default()).ok); + } + + #[test] + fn fenetre_courte_ou_vide_acceptee() { + assert!(controler_extraction(&[], &fenetre(), d("2026-01-05"), OptionsControle::default()).ok); + let liste = vec![metrique("C", &[1.0; 12])]; + assert!(controler_extraction(&liste, &fenetre()[..3], d("2026-01-05"), OptionsControle::default()).ok); + } +}