Fusion du module Qlik

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 19:26:43 +00:00
commit b6f8f586f2
16 files changed
+6601 -2

No files matched your search

+30
View File
@@ -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"
+10
View File
@@ -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
+356
View File
@@ -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<Browser>,
connecte: Arc<AtomicBool>,
}
static NAVIGATEUR: LazyLock<Mutex<Option<Navigateur>>> = 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<PathBuf> {
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<PathBuf> {
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<Navigateur> {
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<Arc<Browser>> {
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<T, F>(libelle: &str, tache: impl FnOnce() -> F) -> T
where
F: std::future::Future<Output = T>,
{
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<dyn Fn(&str) -> Option<String> + Send + Sync>;
/// Page Chromium dans un contexte de navigation isolé (≈ `browser.newContext()`).
pub struct PageQlik {
browser: Arc<Browser>,
contexte: Option<BrowserContextId>,
pub page: Page,
taches: Vec<JoinHandle<()>>,
}
/// 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<Browser>, filtre_console: FiltreConsole, prefixe_erreur: Option<&'static str>) -> anyhow::Result<Self> {
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::<EventConsoleApiCalled>().await?;
moi.taches.push(tokio::spawn(async move {
while let Some(evt) = console.next().await {
let texte = evt.args.iter().map(texte_remote).collect::<Vec<_>>().join(" ");
if let Some(ligne) = filtre_console(&texte) {
tracing::info!("{ligne}");
}
}
}));
if let Some(prefixe) = prefixe_erreur {
let mut erreurs = moi.page.event_listener::<EventExceptionThrown>().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<watch::Receiver<Option<String>>> {
let (tx, rx) = watch::channel(None);
let mut ws = self.page.event_listener::<EventWebSocketCreated>().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<mpsc::UnboundedReceiver<String>> {
let liaison = format!("__cf_binding_{nom}");
let (tx, rx) = mpsc::unbounded_channel();
let mut appels = self.page.event_listener::<EventBindingCalled>().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::<EventDomContentEventFired>().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<Value> {
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();
}
}
}
+503
View File
@@ -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<String> {
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<String>| 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<String>,
/// 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<BTreeMap<String, f64>>,
/// 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<BTreeMap<String, QlikMonthMetrics>>,
/// 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<String>,
/// 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<String>,
}
/// 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<f64>,
/// 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<f64>,
/// 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<f64>,
/// 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<f64>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub famille: Option<String>,
}
/// 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<rustls::crypto::CryptoProvider>);
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<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
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::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
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<hyper::client::conn::http1::SendRequest<http_body_util::Empty<hyper::body::Bytes>>> {
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<http_body_util::Empty<hyper::body::Bytes>>,
url: &reqwest::Url,
entetes: &[(&str, String)],
) -> anyhow::Result<ReponseNtlm> {
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<ReponseNtlm> {
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<String> = 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<String> {
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<reqwest::Client> {
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<QlikSession> {
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::<Vec<_>>()
.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"));
}
}
+460
View File
@@ -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 (`{<Date={...}>}`) 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 à `{<Date={">=…<=…"}>}` 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::<f64>().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<usize>| -> Option<u32> {
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<i64> {
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<i64>,
/// 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<i64> = (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<Vec<i64>> {
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<QlikDateFilter> {
let start = filter.date_debut.as_str();
let end = filter.date_fin.as_str();
let num = |s: &str, r: std::ops::Range<usize>| -> 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<i64> = (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::<usize>(), 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::<usize>(), 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);
}
}
File diff suppressed because it is too large. Load diff
+412
View File
@@ -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 });
}
})();
})
+110
View File
@@ -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<S: Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
serde::Serialize::serialize(&valeur(*v), s)
}
pub fn ser_opt<S: Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(x) => ser(x, s),
None => s.serialize_none(),
}
}
pub fn ser_map<S: Serializer>(v: &Option<BTreeMap<String, f64>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(m) => {
let obj: serde_json::Map<String, Value> = 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<f64, D::Error> {
Ok(depuis_valeur(&Value::deserialize(d)?))
}
pub fn de_opt<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, 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<Option<BTreeMap<String, f64>>, D::Error> {
Ok(match Value::deserialize(d)? {
Value::Object(m) => Some(m.iter().map(|(k, v)| (k.clone(), depuis_valeur(v))).collect()),
_ => None,
})
}
+43 -2
View File
@@ -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.
+220
View File
@@ -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<BTreeMap<String, f64>>,
/// 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<BTreeMap<String, QlikMonthMetrics>>,
/// Période couverte par l'extraction (ex "2025-03_2026-02").
pub periode: Option<String>,
/// Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné.
pub libelle_reseau: Option<String>,
/// Fournisseur de l'article côté Qlik, même origine que `libelle_reseau`.
pub fournisseur_reseau: Option<String>,
pub fetched_at: Option<String>,
}
/// 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<HashMap<String, NetworkMetricCached>> {
let mut out = HashMap::new();
let mut vus = HashSet::new();
let uniques: Vec<String> = 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::<Option<serde_json::Value>, _>(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::<Option<chrono::NaiveDateTime>, _>("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<usize> {
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<Postgres> = 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::<u32>() % 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();
}
}
+435
View File
@@ -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<u8> {
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<u8> {
s.encode_utf16().map(|u| (u & 0xff) as u8).collect()
}
fn push_u16(buf: &mut Vec<u8>, v: usize) {
buf.extend_from_slice(&(v as u16).to_le_bytes());
}
fn push_u32(buf: &mut Vec<u8>, 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<Vec<u8>>,
}
fn slice_clamp(buf: &[u8], debut: usize, fin: usize) -> Vec<u8> {
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<u8> {
// 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 <base64>`).
pub fn parse_type2_message(raw: &str) -> anyhow::Result<Type2Message> {
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<u8> {
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<u8> {
Md4::digest(utf16le(password)).to_vec()
}
fn calc_resp(password_hash: &[u8], server_challenge: &[u8]) -> Vec<u8> {
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<u8> {
let mut mac = <Hmac<md5::Md5> 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<u8>, Vec<u8>) {
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<u8>, Vec<u8>) {
// 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(&timestamp.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<u8> = 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"));
}
}
+669
View File
@@ -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<Vec<Value>>,
/// Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }.
monthly: BTreeMap<String, BTreeMap<String, QlikMonthMetrics>>,
size: Option<f64>,
error: Option<String>,
// Compteurs agrégés pour le résumé timing final.
months: Option<f64>,
batches: Option<f64>,
code15_retries: Option<f64>,
fallback_count: Option<f64>,
/// 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<String>,
}
fn lire_monthly(v: Option<&Value>) -> BTreeMap<String, BTreeMap<String, QlikMonthMetrics>> {
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::<QlikMonthMetrics>(m.clone()) {
mois_map.insert(mois.clone(), mm);
}
}
}
out.insert(code.clone(), mois_map);
}
}
out
}
fn lire_rows(v: Option<&Value>) -> Vec<Vec<Value>> {
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<i64> {
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<i64> {
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<Vec<String>>,
cfg: Option<QlikConfig>,
date_filter: Option<QlikDateFilter>,
fournisseur: Option<String>,
) -> anyhow::Result<IndexMap<String, NetworkMetric>> {
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<Vec<String>>,
cfg: QlikConfig,
date_filter: Option<QlikDateFilter>,
fournisseur: Option<String>,
) -> anyhow::Result<IndexMap<String, NetworkMetric>> {
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<String> = 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<String> = 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::<Vec<_>>().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<String>>) -> Option<String> {
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<Vec<String>>,
date_filter: Option<QlikDateFilter>,
fournisseur: Option<String>,
total_start: Instant,
reglages: Value,
) -> anyhow::Result<IndexMap<String, NetworkMetric>> {
// 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<QlikDateFilter> = date_filter.as_ref().map(get_month_ranges).unwrap_or_default();
let monthly_payload: Vec<Value> = 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::<Vec<_>>(),
})
})
.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<parking_lot::Mutex<Option<Value>>> = 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::<Value>(&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::<String>();
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<f64>| 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<HashSet<&str>> = codes.as_ref().map(|c| c.iter().map(|s| s.as_str()).collect());
let mut out: IndexMap<String, NetworkMetric> = IndexMap::new();
let mut marge_weight: std::collections::HashMap<String, f64> = 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<String> = result.monthly.values().flat_map(|pm| pm.keys().cloned()).collect::<BTreeSet<_>>().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<f64> = 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<String, Value> = 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<String> = 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::<Vec<_>>().join(" ; ")
);
}
if date_filter.is_some() {
let fenetre: Vec<String> = 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<Value> = 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"));
}
}
+305
View File
@@ -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<QlikArticleMatch>,
/// Champ Qlik effectivement interrogé (diagnostic UI).
pub champ_utilise: Option<String>,
/// 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<String>,
/// Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`).
pub periode: Option<String>,
}
/// 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<usize>, cfg: Option<QlikConfig>) -> anyhow::Result<QlikSearchOutcome> {
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<QlikSearchOutcome> {
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<QlikSearchOutcome> {
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<QlikArticleMatch> = 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 ?)");
}
}
+579
View File
@@ -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<String>,
status: QlikSyncStatus,
requested: usize,
fetched: usize,
upserted: usize,
periode: Option<String>,
date_debut: Option<String>,
date_fin: Option<String>,
started_at: String,
finished_at: Option<String>,
error: Option<String>,
message: Option<String>,
}
type JobPartage = Arc<Mutex<QlikSyncJob>>;
/// 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<Mutex<HashMap<String, JobPartage>>> = 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_<horodatage base 36>_<aléa>`).
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::<u8>() 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<String>,
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<I, S>(raw_codes: I) -> CodesFiltres
where
I: IntoIterator<Item = Option<S>>,
S: AsRef<str>,
{
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<String, String>, 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<HashMap<String, String>>) -> AppResult<Json<Value>> {
// 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<HashMap<String, String>>) -> AppResult<Json<Value>> {
// ─── 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);
}
}
+161
View File
@@ -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#"<!doctype html><html><head><meta charset="utf-8"><title>faux Qlik</title></head><body>
<p id="cookie">COOKIE</p>
<script>
console.log("[cf] bonjour depuis la page", 42);
console.log("bruit du client Qlik");
// Le vrai client Qlik ouvre son websocket Engine avec le jeton CSRF dans l'URL.
try { new WebSocket("ws://" + location.host + "/app/abc?qlik-csrf-token=jeton%2Fsecret&x=1"); } catch (e) {}
setTimeout(() => { throw new Error("erreur volontaire"); }, 10);
</script></body></html>"#;
/// 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<String> {
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::<String>::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::<serde_json::Value>(&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<String> = (0..4).flat_map(|i| [format!("début {i}"), format!("fin {i}")]).collect();
assert_eq!(o, attendu);
}
+272
View File
@@ -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<String>,
/// Anomalies corrigées ou tolérées, à journaliser.
pub avertissements: Vec<String>,
}
#[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>) -> 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<String> {
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<Item = &'a NetworkMetric>,
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<f64> = 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<f64> = 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::<f64>() / 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<String> {
(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::<BTreeMap<_, _>>()),
..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<NetworkMetric> = (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<NetworkMetric> =
(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);
}
}