Files
CollectFlow/server/src/qlik/network_cache.rs
T
Claude dc53bb000d Serveur Rust : module qlik (NTLM, Chromium/CDP, extraction réseau, recherche, cache, /api/qlik/sync)
Port fidèle de l'intégration Qlik Sense :
- client.rs : configuration (réglages > env > défauts), types NetworkMetric /
  QlikMonthMetrics / NetworkProduct (JSON identique au TS), xrfkey et flux
  NTLM -> ticket -> cookie X-Qlik-Session, l'échange type 1 / type 3 sur une
  seule connexion HTTP/1.1 dédiée (TLS permissif si tlsInsecure).
- ntlm.rs : messages NTLM identiques octet pour octet à httpntlm/ntlm.js
  (NTLMv2, NTLM2 Session Response, v1 LM/NT), vérifiés contre Node.
- browser.rs : Chromium partagé relancé après déconnexion, file unique des
  extractions, page en contexte isolé pilotée en CDP (cookies, identifiants
  HTTP, console, erreurs, websocket -> qlik-csrf-token, liaison cfCheckpoint,
  navigation domcontentloaded, évaluation JSON).
- playwright.rs + js/extraction.js : extraction 12 mois (script in-page repris
  tel quel), reprises globales, agrégation, contrôles avant écriture.
- search.rs + js/recherche.js : recherche produit dans Qlik.
- date_range.rs, validation.rs : logique pure, tests portés.
- network_cache.rs : lecture / upsert de qlik_network_metrics.
- sync.rs : route /api/qlik/sync (jobs asynchrones, journaux capturés).

Dépendances ajoutées : des, md4, md-5, hyper, hyper-util, http-body-util,
rustls, tokio-rustls, webpki-roots, indexmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:25:54 +00:00

221 lines
11 KiB
Rust

//! 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();
}
}