mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 10:48:09 +02:00
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:
16 files changed
+6601
-2
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
})();
|
||||
})
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(×tamp.to_le_bytes());
|
||||
temp.extend_from_slice(client_challenge);
|
||||
temp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
temp.extend_from_slice(target_info);
|
||||
temp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
let mut nt = hmac_md5(&response_key, &[server_challenge.as_slice(), &temp].concat());
|
||||
nt.extend_from_slice(&temp);
|
||||
(lm, nt)
|
||||
}
|
||||
|
||||
/// Message d'authentification (type 3), préfixé `NTLM ` — défi client aléatoire
|
||||
/// et horodatage courant, comme `ntlm.js`.
|
||||
pub fn create_type3_message(msg2: &Type2Message, username: &str, password: &str, domain: &str, workstation: &str) -> String {
|
||||
let client_challenge: [u8; 8] = rand::random();
|
||||
let now_ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
|
||||
create_type3_message_with(msg2, username, password, domain, workstation, client_challenge, now_ms)
|
||||
}
|
||||
|
||||
/// Variante déterministe de [`create_type3_message`] (tests).
|
||||
pub fn create_type3_message_with(
|
||||
msg2: &Type2Message,
|
||||
username: &str,
|
||||
password: &str,
|
||||
domain: &str,
|
||||
workstation: &str,
|
||||
client_challenge: [u8; 8],
|
||||
now_ms: u64,
|
||||
) -> String {
|
||||
let nonce = &msg2.server_challenge;
|
||||
let is_unicode = msg2.negotiate_flags & NEGOTIATE_UNICODE != 0;
|
||||
let extended = msg2.negotiate_flags & NEGOTIATE_EXTENDED_SECURITY != 0;
|
||||
const BODY_LENGTH: usize = 72;
|
||||
|
||||
let domain_name = js_escape(&domain.to_uppercase());
|
||||
let workstation = js_escape(&workstation.to_uppercase());
|
||||
let enc = |s: &str| if is_unicode { utf16le(s) } else { node_ascii(s) };
|
||||
let workstation_bytes = enc(&workstation);
|
||||
let domain_bytes = enc(&domain_name);
|
||||
let username_bytes = enc(username);
|
||||
let session_key_bytes: Vec<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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 ?)");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user