mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 02:37:55 +02:00
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
221 lines
11 KiB
Rust
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();
|
|
}
|
|
}
|