Compare commits

..
Author SHA1 Message Date
LogiFlow 0f782d3fc9 Merge pull request #33 from R0m1k3/claude/affectionate-cori-9pitju
Fournisseur : les articles sans fournisseur principal ne disparaissent plus
2026-10-19 17:30:03 +02:00
Claude e93564d35c Fournisseur : les articles sans fournisseur principal ne disparaissent plus
Les pages Stock (stock négatif, stock sans vente, sans vente depuis 6 mois)
joignaient obligatoirement le fournisseur principal (artfou1.preference = 1)
et sa fiche fouident : un article sans principal — ou sans aucune ligne
artfou1, comme le codein 504202 — était purement retiré de la liste.

Les jointures deviennent facultatives et le fournisseur suit partout la même
règle : le principal, sinon le fournisseur référencé le plus récent, sinon
celui des données du réseau (cache Qlik, par code centrale), sinon
« Sans fournisseur ». Même repli pour le CA par fournisseur, la dernière
réception (cadencier), le Hit Parade, la recherche produit et les
opportunités. Dans la recherche, ce repli ne masque plus le fournisseur
que Qlik vient de renvoyer.

Test d'intégration opt-in (FF_FOURNISSEUR_TEST_DATABASE_URL) qui rejoue les
quatre cas ; il échoue sur l'ancien code (1 article listé sur 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhSZx8CCuqoKrDw7unFqGq
2026-10-19 10:20:42 +00:00
LogiFlow 61d45f5857 Merge pull request #32 from R0m1k3/claude/lucid-franklin-fsd5n7
Paramètres : clé d'API FF Nancy (en-tête X-API-Key)
2026-10-17 15:28:26 +02:00
Claude 4142e7c73e Paramètres : clé d'API FF Nancy (en-tête X-API-Key)
L'API api.ffnancy.fr exige désormais une clé (401 « Clé d'API requise »).
- Nouveau champ « Clé d'API » dans Paramètres → API FF Nancy, stocké dans
  data/.db-config.json (ffApiKey), jamais renvoyé au navigateur ; vide =
  conserver la clé enregistrée. Repli sur la variable FF_API_KEY.
- Tous les appels à l'API FF envoient l'en-tête X-API-Key (ff_get, proxy
  commandes-auto, publicités, /api/diag, test de connexion).
- Le test de connexion signale explicitement une clé manquante ou refusée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdY28Lq8Q8xX4RZsAvWRQ7
2026-10-17 12:44:28 +00:00
LogiFlow 712174cd85 Merge pull request #31 from R0m1k3/claude/cool-albattani-5zalq5
Qlik : test de bout en bout de l'extraction contre un faux Qlik
2026-10-15 08:33:23 +02:00
Claude 6cba754e06 Merge remote-tracking branch 'origin/main' into claude/cool-albattani-5zalq5
# Conflicts:
#	server/src/qlik/browser.rs
2026-10-14 16:57:08 +00:00
Claude 02d9a776f5 Qlik : extraction réseau coupée à 30 s par chromiumoxide, corrigée
Depuis le passage au serveur Rust, la mise à jour réseau d'un fournisseur
(ex. Sovacom) échouait sur « Request timed out » : chromiumoxide coupe toute
commande de page au bout de 30 s, en dur (REQUEST_TIMEOUT de CommandFuture),
sans tenir compte du request_timeout du navigateur. Or toute l'extraction
Qlik tient dans un seul Runtime.evaluate de plusieurs minutes. Playwright,
côté TS, n'avait pas cette limite.

PageQlik::evaluer lance désormais le script sans l'attendre, puis relève son
résultat par des évaluations courtes (20 ms → 1 s). Aucune commande CDP
n'approche plus les 30 s, quelle que soit la durée de l'extraction ; la
recherche produit, qui passe par la même fonction, en profite aussi.

Tests :
- tests/faux-qlik : faux Qlik Sense (HTTPS, NTLM → ticket → cookie, page
  avec jeton CSRF, mini-moteur Engine) ;
- extraction_faux_qlik (ignoré par défaut) : extraction complète de bout en
  bout avec un vrai Chromium, y compris contre celui de l'image de prod ;
- fenetre_identique_au_ts : fenêtre 12 mois identique à l'ancien TS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTseDg4hw2m9tGUJsqPt57
2026-10-14 15:58:40 +00:00
LogiFlow beff75c4c7 Merge pull request #30 from R0m1k3/claude/project-thread-m5x8ia
Qlik : extraction coupée à 30 s par chromiumoxide (« Request timed out »)
2026-10-14 16:10:12 +02:00
Claude 9be48b111c Qlik : extraction coupée à 30 s par chromiumoxide (« Request timed out »)
Depuis le passage à Rust, toutes les extractions Qlik échouaient au bout
d'environ 32 s : chromiumoxide abandonne toute commande CDP après 30 s,
constante codée en dur dans CommandFuture, quel que soit request_timeout.
L'extraction tenait dans un seul Runtime.evaluate attendant la promesse.

Le script est désormais lancé sans être attendu, son résultat rangé dans
window, puis relevé toutes les 500 ms par des évaluations courtes. Les
erreurs in-page (pile comprise) et la perte du contexte remontent comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjmvaJ5VTadoufBQ8a57no
2026-10-14 14:08:52 +00:00
LogiFlow a26f9ebee3 Merge pull request #29 from R0m1k3/claude/project-thread-m5x8ia
Synchro Qlik : message d'échec visible et journal des extractions de nuit
2026-10-14 15:52:53 +02:00
Claude c98c71dc92 Synchro Qlik : message d'échec visible et journal des extractions de nuit
La page Synchronisation n'affichait qu'un badge « Échec » alors que l'erreur
est enregistrée par fournisseur : elle apparaît désormais dans la colonne
Remarque. Sur la Grille, l'erreur du bouton réseau reste affichée jusqu'à la
tentative suivante (elle s'effaçait après 4 s et était masquée hors très
grands écrans). L'extraction Qlik de nuit ouvre un journal téléchargeable
dans Paramètres, comme la synchro manuelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjmvaJ5VTadoufBQ8a57no
2026-10-14 13:51:35 +00:00
LogiFlow a80a6e724c Merge pull request #28 from R0m1k3/claude/busy-brown-0qxuyz
Fournisseur : repli quand aucun fournisseur n'est marqué principal
2026-10-13 00:17:37 +02:00
Claude 26123783eb Fournisseur : repli sur un fournisseur référencé quand aucun n'est marqué principal
Le Hit Parade, la recherche produit et les opportunités n'acceptaient que
artfou1.preference = 1 : un article dont la préférence n'est pas renseignée
dans FF (ou posée sur une ancienne fiche du même codein) s'affichait en
« Sans fournisseur ». On prend désormais le principal s'il existe, sinon le
fournisseur référencé le plus récent ; le Hit Parade cherche sur toutes les
fiches du codein.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WT49YnjumAVBjCB4EUXNL1
2026-10-12 22:08:34 +00:00
LogiFlow 02e258bf12 Merge pull request #27 from R0m1k3/claude/vigilant-goldberg-0pqg5x
Réécriture du serveur en Rust + image Docker mise à jour au démarrage
2026-10-12 23:28:51 +02:00
26 changed files with 899 additions and 134 deletions

No files matched your search

+2
View File
@@ -15,6 +15,8 @@ services:
- AUTH_SECRET=my-super-secret-key-that-should-be-changed-in-production
- DATABASE_URL=postgres://postgres:MOT_DE_PASSE_ICI@postgresbase:5432/collectflow
- FF_API_BASE_URL=https://api.ffnancy.fr
# Clé d'API FF Nancy (en-tête X-API-Key) — ou à saisir dans Paramètres.
# - FF_API_KEY=VOTRE_CLE_ICI
# La base, Qlik et l'API FF se règlent aussi depuis l'interface (Paramètres).
volumes:
- collectflow-data:/app/data
+2 -2
View File
@@ -22,8 +22,8 @@ pub fn reset_ff_api_base() {
}
/// Diagnostic de l'API FF Nancy.
pub async fn diagnostiquer_api_ff(base: Option<String>) -> Value {
serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base).await).unwrap_or(Value::Null)
pub async fn diagnostiquer_api_ff(base: Option<String>, cle: Option<String>) -> Value {
serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base, cle).await).unwrap_or(Value::Null)
}
/// Extraction Qlik d'un fournisseur sur la fenêtre de la synchro (`QLIK_SYNC_MONTHS_BACK`),
+11 -2
View File
@@ -116,6 +116,9 @@ pub async fn get_parametres_affichables(ctx: Ctx) -> AppResult<Value> {
"motDePasseEnregistre": config.qlik_password.as_deref().map(|p| !p.is_empty()).unwrap_or(false) || qlik_pwd_env,
},
"ffApiBaseUrl": config.ff_api_base_url.clone().unwrap_or_default(),
// La clé ne quitte pas le serveur : seulement l'indication qu'il y en a une
// (réglage enregistré, puis variable d'environnement `FF_API_KEY`).
"ffApiKeyEnregistree": crate::ff::api::resoudre_ff_api_key(config.ff_api_key.clone()).is_some(),
}))
}
@@ -219,12 +222,14 @@ pub async fn test_qlik_connection(ctx: Ctx) -> AppResult<Value> {
}
}
/// Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut.
/// Enregistre l'URL de l'API FF Nancy (chaîne vide = revenir au défaut) et sa
/// clé d'API (vide ou absente = conserver celle qui est enregistrée).
pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let brut: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let cle: Option<String> = ctx.arg::<Option<String>>(1)?.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let cleaned = brut.trim().trim_end_matches('/').to_string();
let lower = cleaned.to_ascii_lowercase();
if !cleaned.is_empty() && !(lower.starts_with("http://") || lower.starts_with("https://")) {
@@ -232,6 +237,9 @@ pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult<Value> {
}
let mut patch = Map::new();
patch.insert("ffApiBaseUrl".into(), if cleaned.is_empty() { Value::Null } else { Value::String(cleaned.clone()) });
if let Some(cle) = cle {
patch.insert("ffApiKey".into(), Value::String(cle));
}
match write_config(patch) {
Ok(()) => {
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
@@ -252,7 +260,8 @@ pub async fn test_ff_api_connection(ctx: Ctx) -> AppResult<Value> {
return Ok(j!({ "success": false, "url": "", "error": e.message }));
}
let base: Option<String> = ctx.arg(0)?;
Ok(super::qlik_bridge::diagnostiquer_api_ff(base).await)
let cle: Option<String> = ctx.arg::<Option<String>>(1)?;
Ok(super::qlik_bridge::diagnostiquer_api_ff(base, cle).await)
}
#[cfg(test)]
+9
View File
@@ -260,6 +260,10 @@ async fn traiter_qlik(code: &str, nom: &Option<String>) {
e.fournisseur_courant = Some(libelle(code, nom));
e.etape = Some("qlik");
}
// Journal téléchargeable depuis Paramètres, comme la synchro manuelle : la
// sortie du conteneur ne garde pas les milliers de lignes d'une extraction.
let journal = format!("sync-nuit_qlik_{}_{}", code, Local::now().format("%Y%m%d-%H%M%S"));
crate::logcapture::start_capture(&journal, Some(&format!("synchro de nuit Qlik fournisseur={}", libelle(code, nom))));
let res: anyhow::Result<Option<usize>> = async {
let articles = crate::ff::pg::pg_get_articles_by_fournisseur(code).await?;
let mut vus = HashSet::new();
@@ -276,6 +280,11 @@ async fn traiter_qlik(code: &str, nom: &Option<String>) {
Ok(Some(n))
}
.await;
let pied = match &res {
Ok(_) => "statut final=success".to_string(),
Err(e) => format!("statut final=error — {e:#}"),
};
crate::logcapture::stop_capture(&journal, Some(&pied));
let ms = t0.elapsed().as_millis() as i32;
let Ok(pool) = crate::db::pool() else { return };
match res {
+3
View File
@@ -43,6 +43,9 @@ pub struct DbConfig {
/// API REST FF Nancy (ex. https://api.ffnancy.fr).
#[serde(default)]
pub ff_api_base_url: Option<String>,
/// Clé d'API FF Nancy, envoyée dans l'en-tête `X-API-Key`.
#[serde(default)]
pub ff_api_key: Option<String>,
}
fn read_raw_sync() -> Map<String, Value> {
+58 -24
View File
@@ -42,9 +42,21 @@ pub fn ff_client() -> &'static reqwest::Client {
})
}
/// `fetch(url, ffFetchInit())`.
/// `fetch(url, ffFetchInit())`, clé d'API comprise.
pub async fn ff_get(url: &str) -> reqwest::Result<reqwest::Response> {
ff_client().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await
avec_cle_ff(ff_client().get(url).header(reqwest::header::CACHE_CONTROL, "no-store")).await.send().await
}
/// En-tête d'authentification attendu par l'API FF Nancy.
pub const FF_API_KEY_HEADER: &str = "X-API-Key";
/// Ajoute la clé d'API enregistrée (en-tête `X-API-Key`) à une requête vers l'API FF.
/// Sans clé configurée, la requête part telle quelle.
pub async fn avec_cle_ff(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match get_ff_api_key().await {
Some(cle) => req.header(FF_API_KEY_HEADER, cle),
None => req,
}
}
/// Libellé HTTP d'un statut (équivalent de `res.statusText`).
@@ -52,7 +64,43 @@ pub fn status_text(status: reqwest::StatusCode) -> &'static str {
status.canonical_reason().unwrap_or("")
}
static FF_API_BASE_CACHE: LazyLock<Mutex<Option<(String, Instant)>>> = LazyLock::new(|| Mutex::new(None));
/// Réglages de connexion à l'API FF résolus : adresse et clé.
#[derive(Clone)]
struct FfApiReglages {
base: String,
cle: Option<String>,
}
static FF_API_CACHE: LazyLock<Mutex<Option<(FfApiReglages, Instant)>>> = LazyLock::new(|| Mutex::new(None));
/// Chaîne non vide après `trim`, sinon `None`.
fn non_vide(s: Option<String>) -> Option<String> {
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
/// Clé d'API FF : réglage enregistré dans Paramètres → variable d'environnement
/// `FF_API_KEY` → aucune.
pub fn resoudre_ff_api_key(configured: Option<String>) -> Option<String> {
non_vide(configured).or_else(|| non_vide(std::env::var("FF_API_KEY").ok()))
}
async fn get_ff_api_reglages() -> FfApiReglages {
if let Some((value, at)) = FF_API_CACHE.lock().as_ref() {
if at.elapsed() < FF_API_BASE_TTL {
return value.clone();
}
}
// Fichier absent ou illisible : on retombe sur l'environnement.
let config = tokio::task::spawn_blocking(crate::config::read_config).await.unwrap_or_default();
let base = non_vide(config.ff_api_base_url)
.or_else(|| non_vide(std::env::var("FF_API_BASE_URL").ok()))
.unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string())
.trim_end_matches('/')
.to_string();
let value = FfApiReglages { base, cle: resoudre_ff_api_key(config.ff_api_key) };
*FF_API_CACHE.lock() = Some((value.clone(), Instant::now()));
value
}
/// URL de l'API FF Nancy, résolue **au moment de l'appel**.
///
@@ -63,31 +111,17 @@ static FF_API_BASE_CACHE: LazyLock<Mutex<Option<(String, Instant)>>> = LazyLock:
///
/// Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête.
pub async fn get_ff_api_base() -> String {
if let Some((value, at)) = FF_API_BASE_CACHE.lock().as_ref() {
if at.elapsed() < FF_API_BASE_TTL {
return value.clone();
}
}
// Fichier absent ou illisible : on retombe sur l'environnement.
let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url)
.await
.ok()
.flatten()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let env = std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty());
let value = configured
.or(env)
.unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string())
.trim_end_matches('/')
.to_string();
*FF_API_BASE_CACHE.lock() = Some((value.clone(), Instant::now()));
value
get_ff_api_reglages().await.base
}
/// Clé d'API FF Nancy (même cache que l'adresse).
pub async fn get_ff_api_key() -> Option<String> {
get_ff_api_reglages().await.cle
}
/// Vide le cache — appelé après un enregistrement dans Paramètres.
pub fn reset_ff_api_base_cache() {
*FF_API_BASE_CACHE.lock() = None;
*FF_API_CACHE.lock() = None;
}
// ---------------------------------------------------------------------------
+24 -3
View File
@@ -5,7 +5,7 @@
//! route `/api/ff-status` (tout utilisateur connecté). Chaque appelant fait son
//! propre contrôle d'accès.
use super::api::{normalize_sync_status, status_text, FfSyncStatus, FF_API_BASE_DEFAULT};
use super::api::{get_ff_api_key, normalize_sync_status, status_text, FfSyncStatus, FF_API_BASE_DEFAULT, FF_API_KEY_HEADER};
use super::js::truthy;
use serde::Serialize;
use serde_json::Value;
@@ -77,7 +77,13 @@ fn message_complet(e: &dyn std::error::Error) -> String {
/// L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
/// si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
/// donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
pub async fn diagnostiquer_api_ff(ff_api_base_url: Option<String>) -> DiagnosticApiFf {
///
/// `ff_api_key` : clé saisie à tester ; vide ou absente, on reprend la clé enregistrée.
pub async fn diagnostiquer_api_ff(ff_api_base_url: Option<String>, ff_api_key: Option<String>) -> DiagnosticApiFf {
let cle = match ff_api_key.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
Some(c) => Some(c),
None => get_ff_api_key().await,
};
let saisie = ff_api_base_url.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let base = match saisie {
Some(b) => b,
@@ -97,9 +103,24 @@ pub async fn diagnostiquer_api_ff(ff_api_base_url: Option<String>) -> Diagnostic
let started = Instant::now();
let client = reqwest::Client::builder().timeout(Duration::from_millis(8000)).build().unwrap_or_default();
match client.get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await {
let mut req = client.get(&url).header(reqwest::header::CACHE_CONTROL, "no-store");
if let Some(cle) = &cle {
req = req.header(FF_API_KEY_HEADER, cle);
}
match req.send().await {
Ok(res) => {
let ms = started.elapsed().as_millis() as u64;
// 401/403 : le serveur répond, c'est la clé qui manque ou qui est refusée.
if matches!(res.status().as_u16(), 401 | 403) {
return DiagnosticApiFf::echec(
&url,
if cle.is_some() {
format!("Clé d'API refusée (HTTP {}) — vérifiez la clé saisie.", res.status().as_u16())
} else {
format!("Clé d'API requise (HTTP {}) — renseignez la clé d'API.", res.status().as_u16())
},
);
}
if !res.status().is_success() {
let txt = status_text(res.status());
return DiagnosticApiFf::echec(
+2 -2
View File
@@ -247,8 +247,8 @@ async fn module_ff_sur_base_de_test() {
montrer("get_mensuel_by_articles", api::get_mensuel_by_articles(&ffarts, d, f, 50).await);
montrer("get_referentiel_by_articles", api::get_referentiel_by_articles(&ffarts, 50).await);
montrer("get_commandes_by_fournisseur", api::get_commandes_by_fournisseur("F001").await);
montrer("diagnostiquer_api_ff", diagnostic::diagnostiquer_api_ff(None).await);
montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into())).await);
montrer("diagnostiquer_api_ff", diagnostic::diagnostiquer_api_ff(None, None).await);
montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into()), None).await);
// ── cache.rs
montrer("get_derniere_reception_cached", cache::get_derniere_reception_cached(&codes(&["F002", "F001"])).await.unwrap());
+2
View File
@@ -13,6 +13,8 @@ pub mod routes;
#[cfg(test)]
mod fixture_tests;
#[cfg(test)]
mod tests_fournisseur_db;
use crate::handler;
use crate::rpc::Handler;
+7 -9
View File
@@ -3,7 +3,7 @@
//! commandes fournisseurs et publicités.
use super::actions::lister_cadences;
use super::api::get_ff_api_base;
use super::api::{avec_cle_ff, get_ff_api_base};
use super::cache::get_fournisseurs_cached;
use super::js::{get, js_number, js_number_to_string, message_erreur};
use super::pg::{
@@ -223,12 +223,11 @@ async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult
}
let base = get_ff_api_base().await;
let res = async {
let res = client_publicites()
let req = client_publicites()
.get(format!("{base}/api/publicites"))
.query(&params)
.header(reqwest::header::CACHE_CONTROL, "no-store")
.send()
.await?;
.header(reqwest::header::CACHE_CONTROL, "no-store");
let res = avec_cle_ff(req).await.send().await?;
if !res.status().is_success() {
return anyhow::Ok(PublicitesResult {
data: json_value!([]),
@@ -262,11 +261,10 @@ async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult
async fn fetch_historique_api() -> HistoriqueResult {
let base = get_ff_api_base().await;
let res = async {
let res = client_publicites()
let req = client_publicites()
.get(format!("{base}/api/publicites/historique"))
.header(reqwest::header::CACHE_CONTROL, "no-store")
.send()
.await?;
.header(reqwest::header::CACHE_CONTROL, "no-store");
let res = avec_cle_ff(req).await.send().await?;
if !res.status().is_success() {
return anyhow::Ok(HistoriqueResult {
data: json_value!([]),
+68 -48
View File
@@ -1109,7 +1109,8 @@ fn push_filtre_deux_mois(qb: &mut QueryBuilder<'_, Postgres>, mois: &str, mois_n
}
/// Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1).
/// Attribution via artfou1.preference = true (fournisseur principal de l'article).
/// Attribution au fournisseur principal de l'article (artfou1.preference = 1),
/// sinon au fournisseur référencé le plus récent.
/// CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention
/// que pg_get_mensuel_by_fournisseur / pg_get_hit_parade). Fournisseur préféré résolu
/// via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par
@@ -1128,8 +1129,7 @@ pub async fn pg_get_ca_by_fournisseur(mois: &str, mois_n1: &str) -> anyhow::Resu
LEFT JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
ORDER BY art_no_id, (preference = 1) DESC NULLS LAST, no_id DESC
) af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
WHERE ",
@@ -1226,6 +1226,14 @@ pub struct HitParadeRow {
/// mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que
/// par article vendu (et non par ligne de mouvement).
///
/// Fournisseur affiché : le principal (`artfou1.preference = 1`) s'il existe,
/// sinon n'importe quel fournisseur référencé — cherché sur toutes les fiches
/// portant le même codein (ré-créations d'article), la plus récente d'abord.
/// Exiger `preference = 1` laissait en « Sans fournisseur » les articles dont
/// la préférence n'est pas renseignée dans FF ou n'est posée que sur une
/// ancienne fiche du même codein. Sans aucune ligne `artfou1`, on prend le
/// fournisseur des données du réseau (cache Qlik, par code centrale).
///
/// Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes,
/// 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours
/// additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle.
@@ -1247,23 +1255,31 @@ pub async fn pg_get_hit_parade(date_debut: &str, date_fin: &str) -> anyhow::Resu
AND a.codein IS NOT NULL
GROUP BY TRIM(a.codein::text), m.site
),
fou AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
TRIM(a.codein::text) AS codein,
af1.code,
af1.reference
FROM articles a
JOIN artfou1 af1 ON af1.art_no_id = a.no_id
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
ORDER BY TRIM(a.codein::text),
(af1.preference = 1) DESC NULLS LAST,
a.no_id DESC,
af1.no_id DESC
),
attrs AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
TRIM(a.codein::text) AS codein,
a.libelle1::text AS libelle,
COALESCE(TRIM(af.reference::text), '') AS reference,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(fi.nom, af.code, qn.fournisseur_reseau, 'Sans fournisseur')::text AS fournisseur,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
FROM articles a
LEFT JOIN LATERAL (
SELECT af1.code, af1.reference
FROM artfou1 af1
WHERE af1.art_no_id = a.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fou af ON af.codein = TRIM(a.codein::text)
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN qlik_network_metrics qn ON qn.code_centrale = TRIM(a.artcentrale::text)
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
ORDER BY TRIM(a.codein::text), a.no_id DESC
@@ -1474,13 +1490,27 @@ fn last_entry_join(link: Option<MvtartFouLink>, artnoid_expr: &str, site_expr: &
)
}
/// Fournisseur d'un article `a`, en jointures FACULTATIVES : un article sans
/// fournisseur reste listé (en « Sans fournisseur ») au lieu de disparaître.
/// - `af` / `f` : le principal (`artfou1.preference = 1`) s'il existe, sinon
/// le fournisseur référencé le plus récent ;
/// - `qn` : le fournisseur indiqué par les données du réseau (cache Qlik),
/// pour l'article qu'aucune ligne `artfou1` ne rattache (ex. codein 504202).
const FOURNISSEUR_ARTICLE_JOIN: &str = "LEFT JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
ORDER BY art_no_id, (preference = 1) DESC NULLS LAST, no_id DESC
) af ON af.art_no_id = a.no_id
LEFT JOIN fouident f ON f.code = af.code
LEFT JOIN qlik_network_metrics qn ON qn.code_centrale = TRIM(a.artcentrale::text)";
/// Libellé fournisseur affiché : celui de la dernière entrée quand la base le
/// permet, sinon repli documenté sur `artfou1.preference = 1`.
/// permet, sinon celui de `FOURNISSEUR_ARTICLE_JOIN`.
fn fournisseur_expr(link: Option<MvtartFouLink>) -> &'static str {
if link.is_some() {
"COALESCE(flast.nom, lastentry.code, f.nom, af.code)"
"COALESCE(flast.nom, lastentry.code, f.nom, af.code, qn.fournisseur_reseau, 'Sans fournisseur')"
} else {
"COALESCE(f.nom, af.code)"
"COALESCE(f.nom, af.code, qn.fournisseur_reseau, 'Sans fournisseur')"
}
}
@@ -1590,13 +1620,7 @@ pub async fn pg_get_stock_sans_vente(site: Option<&str>) -> anyhow::Result<Vec<P
MAX(m.datmvt)::text AS derniere_entree
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code
{FOURNISSEUR_ARTICLE_JOIN}
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
{}
WHERE m.genremvt IN (1, 2)
@@ -1649,13 +1673,7 @@ pub async fn pg_get_stock_negatif(site: Option<&str>) -> anyhow::Result<Vec<PgSt
lastentry.datmvt::text AS derniereentree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code
{FOURNISSEUR_ARTICLE_JOIN}
{}
WHERE cs.qte < 0
AND cs.site IN ('292', '579')
@@ -1723,13 +1741,7 @@ pub async fn pg_get_sans_vente_6_mois(site: Option<&str>) -> anyhow::Result<Vec<
lastentry.datmvt::text AS derniere_entree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code
{FOURNISSEUR_ARTICLE_JOIN}
{}
WHERE cs.site IN ('292', '579')
AND cs.qte > 0
@@ -1981,7 +1993,8 @@ fn colonne_iso(row: &PgRow, col: &str) -> anyhow::Result<Option<String>> {
///
/// Source : mvtart (genremvt IN (1, 2) = réceptions, voir pg_get_mensuel_by_fournisseur).
/// La réception est rattachée à l'article ; on l'attribue au fournisseur
/// principal de l'article (artfou1.preference = 1), comme partout ailleurs.
/// principal de l'article (artfou1.preference = 1), sinon au fournisseur
/// référencé le plus récent, comme partout ailleurs.
///
/// Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur
/// (l'UI affichera alors « échéance inconnue »).
@@ -2000,7 +2013,11 @@ pub async fn pg_get_derniere_reception_par_fournisseur(codes: Option<&[String]>)
MAX(m.datmvt) AS derniere
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
ORDER BY art_no_id, (preference = 1) DESC NULLS LAST, no_id DESC
) af ON af.art_no_id = a.no_id
WHERE m.genremvt IN (1, 2)
AND m.site IN ('292', '579')
AND m.datmvt IS NOT NULL
@@ -2052,7 +2069,8 @@ pub struct PgProduitSearchRow {
pub codein: String,
pub code_centrale: String,
pub libelle1: String,
/// Nom du fournisseur principal (artfou1.preference = 1), sinon son code.
/// Nom du fournisseur principal (artfou1.preference = 1), sinon d'un autre
/// fournisseur référencé, sinon celui des données du réseau.
pub fournisseur: String,
pub codefou: String,
pub nomenclature_code: String,
@@ -2241,7 +2259,7 @@ pub async fn pg_search_produits(term: &str, limit: i64) -> Vec<PgProduitSearchRo
m.codein,
m.code_centrale,
m.libelle1,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(fi.nom, af.code, q.fournisseur_reseau, 'Sans fournisseur')::text AS fournisseur,
COALESCE(af.code, '')::text AS codefou,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, '')::text AS nomenclature,
@@ -2253,8 +2271,8 @@ pub async fn pg_search_produits(term: &str, limit: i64) -> Vec<PgProduitSearchRo
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
ORDER BY af1.code
WHERE af1.art_no_id = m.no_id
ORDER BY (af1.preference = 1) DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
@@ -2327,7 +2345,7 @@ pub async fn pg_get_produits_by_code_centrale(codes_centraux: &[String]) -> Hash
m.codein,
m.code_centrale,
m.libelle1,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(fi.nom, af.code, q.fournisseur_reseau, 'Sans fournisseur')::text AS fournisseur,
COALESCE(af.code, '')::text AS codefou,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, '')::text AS nomenclature,
@@ -2339,8 +2357,8 @@ pub async fn pg_get_produits_by_code_centrale(codes_centraux: &[String]) -> Hash
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
ORDER BY af1.code
WHERE af1.art_no_id = m.no_id
ORDER BY (af1.preference = 1) DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
@@ -2350,6 +2368,7 @@ pub async fn pg_get_produits_by_code_centrale(codes_centraux: &[String]) -> Hash
FROM cube_stock cs
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
) st ON TRUE
LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale
",
);
@@ -2866,7 +2885,8 @@ pub async fn pg_get_opportunites_famille(nom_no_id: i64, date_debut: &str, date_
q.qte_reseau::float AS qte_reseau,
q.nb_magasins_reseau AS nb_magasins_reseau,
COALESCE(q.ca_reseau, 0)::float AS ca_reseau,
COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau
COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau,
q.fournisseur_reseau
FROM famille f
JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale
WHERE q.nb_magasins_reseau > 0
@@ -2889,7 +2909,7 @@ pub async fn pg_get_opportunites_famille(nom_no_id: i64, date_debut: &str, date_
r.codein,
r.libelle1,
r.code_centrale,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(fi.nom, af.code, r.fournisseur_reseau, 'Sans fournisseur')::text AS fournisseur,
r.qte_reseau,
r.nb_magasins_reseau,
r.ca_reseau,
@@ -2907,8 +2927,8 @@ pub async fn pg_get_opportunites_famille(nom_no_id: i64, date_debut: &str, date_
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = r.no_id AND af1.preference = 1
ORDER BY af1.code
WHERE af1.art_no_id = r.no_id
ORDER BY (af1.preference = 1) DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
+15 -8
View File
@@ -1,7 +1,7 @@
//! Routes HTTP du module FF : `/api/ff-status`, `/api/diag`,
//! `/api/diag/stock-fournisseur`, `/api/test-db`, `/api/commandes-auto`.
use super::api::{ff_client, get_ff_api_base};
use super::api::{avec_cle_ff, ff_client, get_ff_api_base};
use super::diagnostic::diagnostiquer_api_ff;
use super::js::{encode, js_string, message_erreur};
use super::pg::{pg_diag_fournisseur_derniere_entree, pg_get_stock_for_codeins};
@@ -31,7 +31,7 @@ fn string_erreur(e: &dyn std::fmt::Display) -> String {
/// et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans
/// détail, impossible à distinguer d'une simple erreur d'URL.
pub async fn ff_status(_user: SessionUser) -> Response {
let res = diagnostiquer_api_ff(None).await;
let res = diagnostiquer_api_ff(None, None).await;
if !res.success {
return (
StatusCode::SERVICE_UNAVAILABLE,
@@ -72,11 +72,18 @@ pub async fn test_db(_admin: AdminUser, Query(q): Query<HashMap<String, String>>
r#"
SELECT
a.codein::text AS codein,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur
COALESCE(fi.nom, af.code, q.fournisseur_reseau, 'Sans fournisseur')::text AS fournisseur
FROM articles a
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = a.no_id
ORDER BY (af1.preference = 1) DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
WHERE a.codein::text = $1
LEFT JOIN qlik_network_metrics q ON q.code_centrale = TRIM(a.artcentrale::text)
WHERE TRIM(a.codein::text) = $1
"#,
)
.bind(&codein)
@@ -115,7 +122,7 @@ pub async fn commandes_auto(_user: SessionUser, Query(q): Query<HashMap<String,
}
let base = get_ff_api_base().await;
let url = format!("{base}/api/commandes-auto");
let res = ff_client().get(&url).query(&params).send().await?;
let res = avec_cle_ff(ff_client().get(&url).query(&params)).await.send().await?;
if !res.status().is_success() {
let status = res.status();
return anyhow::Ok(
@@ -142,7 +149,7 @@ pub async fn commandes_auto(_user: SessionUser, Query(q): Query<HashMap<String,
/// Résumé d'une sonde : statut HTTP et aperçu du contenu.
async fn probe(url: String) -> Value {
let res = async {
let res = reqwest::Client::new().get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?;
let res = avec_cle_ff(reqwest::Client::new().get(&url).header(reqwest::header::CACHE_CONTROL, "no-store")).await.send().await?;
let status = res.status().as_u16();
let text = res.text().await?;
anyhow::Ok((status, text))
@@ -189,7 +196,7 @@ fn premiere_liste(data: &Value) -> Vec<Value> {
}
async fn get_json(url: &str) -> anyhow::Result<Value> {
let res = reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?;
let res = avec_cle_ff(reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store")).await.send().await?;
Ok(res.json::<Value>().await?)
}
+104
View File
@@ -0,0 +1,104 @@
//! Test d'intégration contre une vraie base PostgreSQL : rattachement d'un
//! article à son fournisseur quand aucun n'est marqué principal, ou quand
//! aucune ligne `artfou1` n'existe (cas du codein 504202).
//!
//! Désactivé par défaut. Pour le lancer (base JETABLE : les tables FF de test
//! y sont recréées) :
//!
//! ```sh
//! FF_FOURNISSEUR_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/fftest cargo test ff::tests_fournisseur_db
//! ```
use super::pg::*;
const SCHEMA: &str = r#"
DROP TABLE IF EXISTS articles, artfou1, fouident, mvtart, cube_stock, nomenclature;
CREATE TABLE articles (no_id bigint PRIMARY KEY, codein char(10), artcentrale char(20), libelle1 text, nom_no_id bigint, suspendu text);
CREATE TABLE artfou1 (no_id bigint PRIMARY KEY, art_no_id bigint, code text, preference int, reference text, pcb numeric);
CREATE TABLE fouident (code text PRIMARY KEY, nom text);
CREATE TABLE nomenclature (no_id bigint PRIMARY KEY, code text, libelle text);
CREATE TABLE mvtart (no_id bigint PRIMARY KEY, artnoid bigint, site text, genremvt int, datmvt date, qtemvt numeric, mntmvtttc numeric, margemvt numeric);
CREATE TABLE cube_stock (artnoid bigint, site text, qte numeric, dernierevente date);
INSERT INTO fouident VALUES ('F1', 'ALPHA'), ('F2', 'BETA');
-- 1 : aucune ligne artfou1, fournisseur connu du réseau (cas 504202)
-- 2 : un seul fournisseur, non marqué principal
-- 3 : un principal (F2) et un secondaire (F1)
-- 4 : aucun fournisseur nulle part
INSERT INTO articles VALUES
(1, '504202', 'CC504202', 'Article sans artfou1', NULL, NULL),
(2, '100001', 'CC100001', 'Article sans principal', NULL, NULL),
(3, '100002', 'CC100002', 'Article avec principal', NULL, NULL),
(4, '100003', 'CC100003', 'Article orphelin', NULL, NULL);
INSERT INTO artfou1 VALUES (10, 2, 'F1', 0, 'R2', 1), (11, 3, 'F1', 0, 'R3a', 1), (12, 3, 'F2', 1, 'R3b', 1);
INSERT INTO cube_stock VALUES (1, '292', -2, NULL), (2, '292', -3, NULL), (3, '292', -4, NULL), (4, '292', -5, NULL);
INSERT INTO mvtart VALUES
(100, 1, '292', 1, DATE '2020-01-10', 10, 0, 0),
(101, 2, '292', 1, DATE '2020-01-10', 10, 0, 0),
(102, 3, '292', 1, DATE '2020-01-10', 10, 0, 0),
(103, 4, '292', 1, DATE '2020-01-10', 10, 0, 0),
(110, 1, '292', 3, DATE '2026-03-05', -1, -10, 3),
(111, 2, '292', 3, DATE '2026-03-05', -1, -10, 3),
(112, 3, '292', 3, DATE '2026-03-05', -1, -10, 3),
(113, 4, '292', 3, DATE '2026-03-05', -1, -10, 3);
DELETE FROM qlik_network_metrics WHERE code_centrale LIKE 'CC%';
INSERT INTO qlik_network_metrics (code_centrale, fournisseur_reseau) VALUES ('CC504202', 'FOURNISSEUR RESEAU');
"#;
fn attendu(codein: &str) -> &'static str {
match codein {
"504202" => "FOURNISSEUR RESEAU",
"100001" => "ALPHA",
"100002" => "BETA",
"100003" => "Sans fournisseur",
autre => panic!("codein inattendu : {autre}"),
}
}
#[tokio::test]
async fn article_sans_fournisseur_principal() {
let Ok(url) = std::env::var("FF_FOURNISSEUR_TEST_DATABASE_URL") else {
eprintln!("FF_FOURNISSEUR_TEST_DATABASE_URL absent : test d'intégration ignoré.");
return;
};
let _verrou = crate::grid::rows_cache::TEST_LOCK.lock();
let dir = std::env::temp_dir().join(format!("ff-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("DATA_DIR", &dir);
std::env::set_var("DATABASE_URL", &url);
crate::db::refresh();
let pool = crate::db::pool().unwrap();
sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap();
sqlx::raw_sql(SCHEMA).execute(&pool).await.unwrap();
// Stock négatif : les 4 articles sont listés, aucun n'est écarté faute de principal.
let neg = pg_get_stock_negatif(None).await.unwrap();
assert_eq!(neg.len(), 4, "{neg:?}");
for r in &neg {
assert_eq!(r.fournisseur, attendu(r.codein.trim()), "stock négatif {}", r.codein);
}
let hp = pg_get_hit_parade("2026-03-01", "2026-03-31").await.unwrap();
assert_eq!(hp.len(), 4, "{hp:?}");
for r in &hp {
assert_eq!(r.fournisseur, attendu(&r.codein), "hit parade {}", r.codein);
}
let ca = pg_get_ca_by_fournisseur("2026-03", "2025-03").await.unwrap();
let nom = |code: &str| ca.iter().find(|r| r.code == code).map(|r| r.nom.clone());
assert_eq!(nom("F1").as_deref(), Some("ALPHA"), "{ca:?}");
assert_eq!(nom("F2").as_deref(), Some("BETA"), "{ca:?}");
assert_eq!(nom("SANS_FOURNISSEUR").as_deref(), Some("Sans fournisseur"), "{ca:?}");
let receptions = pg_get_derniere_reception_par_fournisseur(None).await;
assert!(receptions.contains_key("F1|292"), "{receptions:?}");
let trouves = pg_search_produits("504202", 10).await;
assert_eq!(trouves.first().map(|r| r.fournisseur.as_str()), Some("FOURNISSEUR RESEAU"), "{trouves:?}");
let catalogue = pg_get_produits_by_code_centrale(&["CC504202".into(), "CC100001".into(), "CC100003".into()]).await;
assert_eq!(catalogue["CC504202"].fournisseur, "FOURNISSEUR RESEAU");
assert_eq!(catalogue["CC100001"].fournisseur, "ALPHA");
assert_eq!(catalogue["CC100003"].fournisseur, "Sans fournisseur");
assert!(catalogue["CC504202"].codefou.is_empty());
}
+8 -1
View File
@@ -502,7 +502,14 @@ async fn executer_recherche(term: &str, on_etape: impl Fn(&str)) -> ProduitReche
code_centrale: m.code_centrale.clone(),
libelle: ou(ou(local.map_or("", |l| l.libelle1.as_str()), &m.libelle), &m.code_centrale).to_string(),
libelle_reseau: m.libelle.clone(),
fournisseur: ou(local.map_or("", |l| l.fournisseur.as_str()), &m.fournisseur).to_string(),
// Fournisseur de notre catalogue, sinon celui que Qlik vient de
// renvoyer, sinon le repli du catalogue (cache réseau ou
// « Sans fournisseur ») : ce repli ne doit pas masquer Qlik.
fournisseur: ou(
ou(local.filter(|l| !l.codefou.is_empty()).map_or("", |l| l.fournisseur.as_str()), &m.fournisseur),
local.map_or("", |l| l.fournisseur.as_str()),
)
.to_string(),
codein: local.map(|l| l.codein.clone()),
nomenclature: local.map(|l| l.nomenclature.clone()).unwrap_or_default(),
stock_local: local.map(|l| if l.stock_total.is_nan() { 0.0 } else { l.stock_total }),
+61 -12
View File
@@ -67,9 +67,9 @@ async fn lancer() -> anyhow::Result<Navigateur> {
.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).
// Délai des commandes internes du gestionnaire CDP. Il ne couvre PAS
// `page.execute`, borné à 30 s par chromiumoxide : les évaluations
// longues passent par un relevé périodique (cf. `PageQlik::evaluer`).
.request_timeout(Duration::from_secs(24 * 3600));
if let Some(chemin) = chemin_chromium() {
builder = builder.chrome_executable(chemin);
@@ -116,6 +116,21 @@ pub async fn get_qlik_browser() -> anyhow::Result<Arc<Browser>> {
Ok(b)
}
/// Tests : pilote un Chromium déjà lancé (`--remote-debugging-port`), par
/// exemple celui de l'image Docker de production, au lieu d'en lancer un.
#[cfg(test)]
pub async fn utiliser_chromium_distant(url: &str) -> anyhow::Result<()> {
let (browser, mut handler) = Browser::connect(url).await.context("[qlik] connexion au Chromium distant")?;
let connecte = Arc::new(AtomicBool::new(true));
let drapeau = connecte.clone();
tokio::spawn(async move {
while handler.next().await.is_some() {}
drapeau.store(false, Ordering::SeqCst);
});
*NAVIGATEUR.lock().await = Some(Navigateur { browser: Arc::new(browser), connecte });
Ok(())
}
// ---------------------------------------------------------------------------
// File unique des extractions
// ---------------------------------------------------------------------------
@@ -312,14 +327,52 @@ impl PageQlik {
/// `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).
///
/// ⚠️ chromiumoxide abandonne toute commande CDP au bout de 30 s
/// (« Request timed out. »), quel que soit `request_timeout` : la constante
/// est codée en dur dans `CommandFuture`. Une extraction Qlik dure plusieurs
/// minutes : attendre la promesse dans un seul `Runtime.evaluate` la faisait
/// échouer à coup sûr. Le script est donc lancé sans être attendu, son
/// résultat rangé dans `window`, et relevé par des évaluations courtes.
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)?
static EVALUATIONS: AtomicUsize = AtomicUsize::new(0);
let cle = serde_json::to_string(&format!("__cf_eval_{}", EVALUATIONS.fetch_add(1, Ordering::SeqCst)))?;
let lancement = format!(
"(() => {{ const __k = {cle}; window[__k] = {{ fini: false }}; \
(async () => {{ const __f = (\n{source}\n); const __r = await __f({args}); return JSON.stringify(__r === undefined ? null : __r); }})()\
.then((json) => {{ window[__k] = {{ fini: true, json }}; }}, \
(e) => {{ window[__k] = {{ fini: true, erreur: (e && e.stack) ? String(e.stack) : String(e) }}; }}); \
return true; }})()",
args = serde_json::to_string(args)?
);
self.evaluer_court(lancement).await?;
let releve = format!(
"(() => {{ const __k = {cle}; const e = window[__k]; \
if (e === undefined) return JSON.stringify({{ perdu: true }}); \
if (!e.fini) return null; delete window[__k]; return JSON.stringify(e); }})()"
);
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
let Some(Value::String(etat)) = self.evaluer_court(releve.clone()).await? else { continue };
let etat: Value = serde_json::from_str(&etat)?;
if etat.get("perdu").is_some() {
return Err(anyhow!("Execution context was destroyed (page rechargée pendant l'évaluation)"));
}
if let Some(erreur) = etat.get("erreur").and_then(|e| e.as_str()) {
return Err(anyhow!("{erreur}"));
}
return match etat.get("json").and_then(|j| j.as_str()) {
Some(json) => Ok(serde_json::from_str(json)?),
None => Ok(Value::Null),
};
}
}
/// Un `Runtime.evaluate` qui rend la main aussitôt (valeur JSON brute).
async fn evaluer_court(&self, expression: String) -> anyhow::Result<Option<Value>> {
let params = EvaluateParams::builder()
.expression(expression)
.await_promise(true)
.return_by_value(true)
.build()
.map_err(|e| anyhow!("[qlik] paramètres d'évaluation : {e}"))?;
@@ -328,11 +381,7 @@ impl PageQlik {
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),
}
Ok(res.result.result.value.clone())
}
/// Ferme la page puis le contexte (`ctx.close()`).
+20
View File
@@ -298,6 +298,26 @@ mod tests {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
}
/// Valeurs de référence calculées par l'ancien `qlik-date-range.ts`
/// (`buildGridNetworkQlikDateFilter` + `getMonthRanges`).
#[test]
fn fenetre_identique_au_ts() {
for (jour, label, q_start, q_end, premier_mois) in [
("2026-10-10", "2025-10_2026-09", 45931, 46295, (45931, 45961)),
("2026-10-14", "2025-10_2026-09", 45931, 46295, (45931, 45961)),
("2026-01-05", "2025-01_2025-12", 45658, 46022, (45658, 45688)),
("2026-03-31", "2025-03_2026-02", 45717, 46081, (45717, 45747)),
] {
let f = build_grid_network_qlik_date_filter(d(jour), 12);
assert_eq!((f.label.as_str(), f.q_start, f.q_end, f.daily_serials.len()), (label, q_start, q_end, 365), "{jour}");
let mois = get_month_ranges(&f);
assert_eq!(mois.len(), 12, "{jour}");
assert_eq!((mois[0].q_start, mois[0].q_end), premier_mois, "{jour}");
assert_eq!(mois[11].q_end, q_end, "{jour}");
assert_eq!(mois.iter().map(|m| m.daily_serials.len()).sum::<usize>(), 365, "{jour}");
}
}
// [1] + [2] Repères Qlik et aller-retour.
#[test]
fn reperes_qlik() {
+54
View File
@@ -95,6 +95,20 @@ async fn plomberie_cdp() {
let err = page.evaluer("() => { throw new Error('boum'); }", &json!(null)).await.unwrap_err().to_string();
assert!(err.contains("boum"), "{err}");
// Une extraction dure plusieurs minutes : l'évaluation doit dépasser les
// 30 s après lesquelles chromiumoxide abandonne une commande CDP.
let r = page
.evaluer("async ({ ms }) => { await new Promise((r) => setTimeout(r, ms)); return { ok: true, ms }; }", &json!({ "ms": 35_000 }))
.await
.expect("évaluation de 35 s");
assert_eq!(r, json!({ "ok": true, "ms": 35000 }));
let err = page
.evaluer("async () => { await new Promise((r) => setTimeout(r, 50)); throw new Error('boum asynchrone'); }", &json!(null))
.await
.unwrap_err()
.to_string();
assert!(err.contains("boum asynchrone"), "{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!({
@@ -159,3 +173,43 @@ async fn file_qlik_sequentielle() {
let attendu: Vec<String> = (0..4).flat_map(|i| [format!("début {i}"), format!("fin {i}")]).collect();
assert_eq!(o, attendu);
}
/// Extraction complète (NTLM → ticket → cookie → page → jeton CSRF → websocket
/// Engine → 12 mois → contrôles) contre le faux Qlik de `tests/faux-qlik` :
///
/// ```text
/// tests/faux-qlik/lancer.sh NB_CODES=2000 DELAI_MS=40000 # extraction de plusieurs minutes
/// NB_CODES=2000 cargo test extraction_faux_qlik -- --ignored --nocapture
/// ```
///
/// `FOURNISSEUR_TEST` (défaut `SOVA`, connu du faux Qlik) : un autre code force
/// la sélection par codes articles. `CHROMIUM_DISTANT=http://127.0.0.1:9333` :
/// pilote un Chromium déjà lancé, par exemple celui de l'image de production.
#[tokio::test]
#[ignore]
async fn extraction_faux_qlik() {
let _ = tracing_subscriber::fmt().with_env_filter("info").with_test_writer().try_init();
if std::env::var("PLAYWRIGHT_CHROMIUM_PATH").is_err() {
std::env::set_var("PLAYWRIGHT_CHROMIUM_PATH", "/opt/pw-browsers/chromium-1194/chrome-linux/chrome");
}
if let Ok(url) = std::env::var("CHROMIUM_DISTANT") {
super::browser::utiliser_chromium_distant(&url).await.expect("Chromium distant");
}
let nb: usize = std::env::var("NB_CODES").ok().and_then(|v| v.parse().ok()).unwrap_or(400);
let codes: Vec<String> = (0..nb).map(|i| (10000100000u64 + i as u64).to_string()).collect();
let mut cfg = super::client::get_qlik_config();
cfg.host = "localhost".into();
cfg.user = "FFSCH".into();
cfg.password = "Mot2Passe!".into();
let df = super::date_range::build_grid_network_qlik_date_filter_now(12);
let t0 = std::time::Instant::now();
let r = super::playwright::fetch_network_metrics_playwright(Some(codes.clone()), Some(cfg), Some(df.clone()), Some(std::env::var("FOURNISSEUR_TEST").unwrap_or_else(|_| "SOVA".into()))).await;
let m = r.expect("extraction");
eprintln!("EXTRACTION OK : {} métriques en {:?}, fenêtre {}", m.len(), t0.elapsed(), df.label);
let premier = m.values().next().unwrap();
eprintln!("exemple {} : {:?}", premier.code_centrale, premier.qte_by_month);
assert!(m.len() > nb * 9 / 10);
for v in m.values() {
assert_eq!(v.qte_by_month.as_ref().unwrap().len(), 12, "{}", v.code_centrale);
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
package-lock.json
*.pem
pid
serveur.log
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Lance le faux Qlik (HTTPS :443, il faut donc les droits root) en arrière-plan.
#
# server/tests/faux-qlik/lancer.sh [NB_CODES=… NB_AUTRES=… DELAI_MS=… MOIS_MANQUANT=YYYY-MM]
#
# puis, depuis server/ :
# NB_CODES=… cargo test extraction_faux_qlik -- --ignored --nocapture
set -euo pipefail
D=$(cd "$(dirname "$0")" && pwd)
[ -d "$D/node_modules/ws" ] || npm install --prefix "$D" --no-audit --no-fund --silent
[ -f "$D/cert.pem" ] || openssl req -x509 -newkey rsa:2048 -nodes -keyout "$D/key.pem" -out "$D/cert.pem" -days 30 -subj "/CN=localhost" 2>/dev/null
[ -f "$D/pid" ] && kill "$(cat "$D/pid")" 2>/dev/null || true
sleep 0.5
env "$@" nohup node --max-old-space-size=4096 "$D/serveur.js" > "$D/serveur.log" 2>&1 &
echo $! > "$D/pid"
for _ in $(seq 1 60); do grep -q "écoute" "$D/serveur.log" && break; sleep 0.5; done
cat "$D/serveur.log"
+8
View File
@@ -0,0 +1,8 @@
{
"name": "collectflow-faux-qlik",
"private": true,
"description": "Faux serveur Qlik Sense pour le test de bout en bout de l'extraction réseau",
"dependencies": {
"ws": "8.18.0"
}
}
+337
View File
@@ -0,0 +1,337 @@
// Faux serveur Qlik Sense pour tester l'extraction CollectFlow de bout en bout
// (test `extraction_faux_qlik` de `src/qlik/tests_navigateur.rs`, lancement : `lancer.sh`).
//
// - HTTPS (certificat auto-signé) sur :443 ;
// - flux d'authentification : /hub/ → targetId → NTLM (type 1 → 2 → 3) → ticket → cookie ;
// - /sense/app/<id> : page qui exige le cookie et ouvre un websocket avec un jeton CSRF ;
// - /app/<id> (wss) : mini-moteur Engine (champs, sélections, hypercubes, list objects).
//
// Variables : NB_CODES (codes du fournisseur testé), NB_AUTRES, FOURNISSEUR,
// DELAI_MS (latence par GetLayout), MOIS_MANQUANT (YYYY-MM sans ventes).
"use strict";
const https = require("https");
const fs = require("fs");
const path = require("path");
const { WebSocketServer } = require("ws");
const ICI = __dirname;
const APP = process.env.APP || "9872ee6e-d64a-4b43-984a-076bf1f7f647";
const FOURNISSEUR = process.env.FOURNISSEUR || "SOVA";
const NB_CODES = Number(process.env.NB_CODES || 400);
const NB_AUTRES = Number(process.env.NB_AUTRES || 600);
const DELAI_MS = Number(process.env.DELAI_MS || 0);
const MOIS_MANQUANT = process.env.MOIS_MANQUANT || "";
const SESSION = "s3ss10n-" + Math.random().toString(36).slice(2);
const JETON = "Jt" + Math.random().toString(36).slice(2) + "_x-Y";
const TYPE2 = "TlRMTVNTUAACAAAADgAOADAAAAAVgoniASNFZ4mrze8AAAAAAAAAACQAJAA+AAAARABPAE0AQQBJAE4ARQACAA4ARABPAE0AQQBJAE4AAQAMAFMARQBSAFYARQBSAAAAAAA=";
const stats = { ntlm1: 0, ntlm3: 0, ticket: 0, page: 0, pageRefusee: 0, ws: 0, wsRefuse: 0, rpc: 0, methodes: {} };
const log = (...a) => console.log("[faux-qlik]", ...a);
// ─── Données ────────────────────────────────────────────────────────────────
const serial = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - Date.UTC(1899, 11, 30)) / 86400000);
const codesFou = Array.from({ length: NB_CODES }, (_, i) => String(10000100000 + i));
const codesAutres = Array.from({ length: NB_AUTRES }, (_, i) => String(10000900000 + i));
// Pseudo-aléa déterministe.
let graine = 42;
const alea = () => { graine = (graine * 1103515245 + 12345) % 2147483648; return graine / 2147483648; };
const faits = [];
const aujourdHui = new Date();
for (let y = 2024; y <= aujourdHui.getFullYear(); y++) {
for (let m = 1; m <= 12; m++) {
if (y === aujourdHui.getFullYear() && m > aujourdHui.getMonth() + 1) break;
const mois = `${y}${String(m).padStart(2, "0")}`;
if (MOIS_MANQUANT && MOIS_MANQUANT.replace("-", "") === mois) continue;
for (const [codes, fou] of [[codesFou, FOURNISSEUR], [codesAutres, "AUTRE"]]) {
for (const code of codes) {
// 2 magasins par mois, 1 fait chacun (jour variable dans le mois).
for (let k = 0; k < 2; k++) {
if (alea() < 0.15) continue;
const jour = 1 + Math.floor(alea() * 28);
const quantite = 1 + Math.floor(alea() * 20);
const ca = Math.round(quantite * (2 + alea() * 10) * 100) / 100;
faits.push({
"Article Code": code, code_fournisseur: fou, "Magasin Code": "M" + Math.floor(alea() * 270),
Date: serial(y, m, jour), "Année": y, Mois: mois,
quantite, ca_ht: ca, marge: Math.round(ca * 0.35 * 100) / 100,
});
}
}
}
}
}
log(`${faits.length} faits, ${codesFou.length} codes ${FOURNISSEUR}, ${codesAutres.length} autres`);
const CHAMPS_NUM = new Set(["Date", "Année"]);
const CHAMPS = ["Article Code", "code_fournisseur", "Magasin Code", "Date", "Année", "Mois", "Période"];
// Valeurs distinctes de chaque champ (ordre de chargement), pour les qElemNumber.
const valeursChamp = {};
for (const c of CHAMPS) valeursChamp[c] = [];
const indexValeur = {};
for (const c of CHAMPS) indexValeur[c] = new Map();
const ajouterValeur = (c, v) => { if (!indexValeur[c].has(v)) { indexValeur[c].set(v, valeursChamp[c].length); valeursChamp[c].push(v); } };
for (const f of faits) for (const c of CHAMPS) if (c in f) ajouterValeur(c, f[c]);
// « Période » : champ déconnecté des faits (comme un pont de calendrier sans effet ici).
for (const p of ["Année à date", "Mois à date", "12 mois glissants"]) ajouterValeur("Période", p);
// ─── Mesures ────────────────────────────────────────────────────────────────
const somme = (rows, k) => rows.reduce((t, r) => t + r[k], 0);
const distinct = (rows, k) => new Set(rows.map((r) => r[k])).size;
const EXPRESSIONS = {
"Sum(quantite)": (r) => somme(r, "quantite"),
"Sum(ca_ht)": (r) => somme(r, "ca_ht"),
"Sum(marge)": (r) => somme(r, "marge"),
"Count(DISTINCT [Magasin Code])": (r) => distinct(r, "Magasin Code"),
"0": () => 0,
};
const MAITRES = [
{ id: "JhqJ", titre: "CA N", expr: "Sum(ca_ht)", f: EXPRESSIONS["Sum(ca_ht)"] },
{ id: "41516861-5997-4635-8187-3643a2bde422", titre: "Quantité N", expr: "Sum(quantite)", f: EXPRESSIONS["Sum(quantite)"] },
{ id: "yNBLjc", titre: "Magasin Ventes Nb N", expr: "Count(DISTINCT [Magasin Code])", f: EXPRESSIONS["Count(DISTINCT [Magasin Code])"] },
{ id: "16778c00-a4e0-41c7-ab1c-179810d78172", titre: "CA par Magasin N", expr: "Sum(ca_ht)/Count(DISTINCT [Magasin Code])", f: (r) => { const n = distinct(r, "Magasin Code"); return n ? somme(r, "ca_ht") / n : 0; } },
{ id: "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222", titre: "Marge % N", expr: "Sum(marge)/Sum(ca_ht)", f: (r) => { const c = somme(r, "ca_ht"); return c ? somme(r, "marge") / c : 0; } },
{ id: "96862880-76cd-4957-bf25-b96901f2ac5f", titre: "Quantité COMP", expr: "0", f: () => 0 },
];
const DIMENSIONS = { yesCP: "Article Code", pfGAwTs: "Mois" };
const mesure = (m) => {
if (m.qLibraryId) {
const x = MAITRES.find((y) => y.id === m.qLibraryId);
if (!x) throw { code: 2, message: "Invalid measure library id " + m.qLibraryId };
return x.f;
}
const e = String(m.qDef?.qDef ?? "").trim();
const f = EXPRESSIONS[e];
if (!f) return () => NaN;
return f;
};
const dimension = (d) => {
if (d.qLibraryId) {
const c = DIMENSIONS[d.qLibraryId];
if (!c) throw { code: 2, message: "Invalid dimension library id " + d.qLibraryId };
return c;
}
return d.qDef?.qFieldDefs?.[0];
};
// ─── Session Engine ─────────────────────────────────────────────────────────
function nouvelleSession(ws) {
const selections = {}; // champ → Set de valeurs
const handles = new Map(); // handle → { type, ... }
const parId = new Map();
let prochainHandle = 2;
const filtre = () => {
const actifs = Object.entries(selections).filter(([c, s]) => s.size > 0 && c !== "Période");
return actifs.length === 0 ? faits : faits.filter((f) => actifs.every(([c, s]) => s.has(f[c])));
};
const nouveauHandle = (obj) => { const h = prochainHandle++; handles.set(h, obj); return h; };
const calculerCube = (def) => {
const dims = (def.qDimensions ?? []).map(dimension);
const mes = (def.qMeasures ?? []).map(mesure);
const rows = filtre();
const groupes = new Map();
if (dims.length === 0) groupes.set("", rows);
else for (const r of rows) {
const k = dims.map((d) => r[d]).join("\u0001");
let g = groupes.get(k);
if (!g) { g = []; groupes.set(k, g); }
g.push(r);
}
const lignes = [];
for (const g of groupes.values()) {
const cellules = dims.map((d) => ({ qText: String(g[0][d]), qNum: CHAMPS_NUM.has(d) ? g[0][d] : "NaN", qElemNumber: indexValeur[d].get(g[0][d]) ?? 0, qState: "O" }));
for (const f of mes) {
const v = f(g);
cellules.push({ qText: Number.isFinite(v) ? String(v) : "-", qNum: Number.isFinite(v) ? v : "NaN", qElemNumber: 0, qState: "L" });
}
lignes.push(cellules);
}
return { lignes, largeur: dims.length + mes.length };
};
const page = (lignes, p) => ({
qArea: { qTop: p.qTop, qLeft: p.qLeft ?? 0, qWidth: p.qWidth, qHeight: Math.min(p.qHeight, Math.max(0, lignes.length - p.qTop)) },
qMatrix: lignes.slice(p.qTop, p.qTop + p.qHeight).map((l) => l.slice(p.qLeft ?? 0, (p.qLeft ?? 0) + p.qWidth)),
});
const listeValeurs = (champ) => valeursChamp[champ].map((v, i) => [{ qText: String(v), qNum: CHAMPS_NUM.has(champ) ? v : "NaN", qElemNumber: i, qState: "O" }]);
const selectionner = (champ, valeurs, bascule) => {
const connues = valeursChamp[champ];
const trouvees = [];
for (const fv of valeurs) {
let v;
if (CHAMPS_NUM.has(champ) && typeof fv.qNum === "number") v = connues.find((x) => x === fv.qNum);
else v = connues.find((x) => String(x) === String(fv.qText));
if (v !== undefined) trouvees.push(v);
}
if (!bascule || !selections[champ]) selections[champ] = new Set();
for (const v of trouvees) {
if (bascule && selections[champ].has(v)) selections[champ].delete(v);
else selections[champ].add(v);
}
return trouvees.length > 0;
};
const methodes = {
OpenDoc: (h, p) => {
if (p.qDocName !== APP) throw { code: 1002, message: "App not found" };
return { qReturn: { qType: "Doc", qHandle: 1, qGenericId: APP } };
},
GetField: (h, p) => {
if (!CHAMPS.includes(p.qFieldName)) throw { code: 2, message: "Field not found " + p.qFieldName };
return { qReturn: { qType: "Field", qHandle: nouveauHandle({ type: "champ", champ: p.qFieldName }) } };
},
Clear: (h) => {
const o = handles.get(h);
if (o?.type !== "champ") throw { code: -32601, parameter: "Clear", message: "Method not found" };
delete selections[o.champ];
return { qReturn: true };
},
SelectValues: (h, p) => {
const o = handles.get(h);
if (o?.type !== "champ") throw { code: -32601, parameter: "SelectValues", message: "Method not found" };
return { qReturn: selectionner(o.champ, p.qFieldValues ?? [], !!p.qToggleMode) };
},
CreateSessionObject: (h, p) => {
const prop = p.qProp ?? {};
const id = "obj-" + prochainHandle;
const obj = { type: "objet", id, prop };
const handle = nouveauHandle(obj);
parId.set(id, handle);
return { qReturn: { qType: "GenericObject", qHandle: handle, qGenericType: prop.qInfo?.qType, qGenericId: id } };
},
DestroySessionObject: (h, p) => {
const handle = parId.get(p.qId);
if (handle == null) throw { code: 15, message: "Request aborted" };
parId.delete(p.qId);
handles.delete(handle);
return { qSuccess: true };
},
GetLayout: (h) => {
const o = handles.get(h);
if (o?.type !== "objet") throw { code: -32601, parameter: "GetLayout", message: "Method not found" };
const prop = o.prop;
if (prop.qMeasureListDef) {
return { qLayout: { qInfo: prop.qInfo, qMeasureList: { qItems: MAITRES.map((m) => ({ qInfo: { qId: m.id, qType: "measure" }, qMeta: { title: m.titre }, qData: { title: m.titre, expr: m.expr } })) } } };
}
if (prop.qDimensionListDef) {
return { qLayout: { qInfo: prop.qInfo, qDimensionList: { qItems: Object.entries(DIMENSIONS).map(([id, t]) => ({ qInfo: { qId: id, qType: "dimension" }, qMeta: { title: t }, qData: { title: t } })) } } };
}
if (prop.qListObjectDef) {
const champ = dimension(prop.qListObjectDef);
const lignes = listeValeurs(champ);
o.lignes = lignes;
o.champ = champ;
const pages = (prop.qListObjectDef.qInitialDataFetch ?? []).map((p) => page(lignes, p));
return { qLayout: { qInfo: prop.qInfo, qListObject: { qSize: { qcx: 1, qcy: lignes.length }, qDataPages: pages } } };
}
if (prop.qHyperCubeDef) {
const { lignes, largeur } = calculerCube(prop.qHyperCubeDef);
o.lignes = lignes;
if (largeur * Math.max(...(prop.qHyperCubeDef.qInitialDataFetch ?? [{ qHeight: 0 }]).map((p) => p.qHeight)) > 10000) {
throw { code: 7009, message: "Too many cells requested" };
}
const pages = (prop.qHyperCubeDef.qInitialDataFetch ?? []).map((p) => page(lignes, p));
return { qLayout: { qInfo: prop.qInfo, qHyperCube: { qSize: { qcx: largeur, qcy: lignes.length }, qDataPages: pages } } };
}
throw { code: 2, message: "Unsupported object" };
},
GetHyperCubeData: (h, p) => {
const o = handles.get(h);
if (!o?.lignes) throw { code: -32601, parameter: "GetHyperCubeData", message: "Method not found" };
for (const pg of p.qPages) if (pg.qWidth * pg.qHeight > 10000) throw { code: 7009, message: "Too many cells requested" };
return { qDataPages: p.qPages.map((pg) => page(o.lignes, pg)) };
},
GetListObjectData: (h, p) => {
const o = handles.get(h);
if (!o?.lignes) throw { code: -32601, parameter: "GetListObjectData", message: "Method not found" };
return { qDataPages: p.qPages.map((pg) => page(o.lignes, pg)) };
},
SelectListObjectValues: (h, p) => {
const o = handles.get(h);
if (!o?.champ) throw { code: -32601, parameter: "SelectListObjectValues", message: "Method not found" };
const vals = (p.qValues ?? []).map((i) => valeursChamp[o.champ][i]).filter((v) => v !== undefined);
if (!p.qToggleMode || !selections[o.champ]) selections[o.champ] = new Set();
for (const v of vals) selections[o.champ].add(v);
return { qReturn: vals.length > 0 };
},
};
ws.send(JSON.stringify({ jsonrpc: "2.0", method: "OnConnected", params: { qSessionState: "SESSION_CREATED" } }));
ws.on("message", async (raw) => {
let m;
try { m = JSON.parse(String(raw)); } catch { return; }
stats.rpc++;
stats.methodes[m.method] = (stats.methodes[m.method] ?? 0) + 1;
const f = methodes[m.method];
if (DELAI_MS && m.method === "GetLayout") await new Promise((r) => setTimeout(r, DELAI_MS));
try {
if (!f) throw { code: -32601, parameter: m.method, message: "Method not found" };
const result = f(m.handle, m.params ?? {});
ws.send(JSON.stringify({ jsonrpc: "2.0", id: m.id, result }));
} catch (e) {
const error = e && typeof e === "object" && "code" in e ? e : { code: -1, message: String(e?.message || e) };
ws.send(JSON.stringify({ jsonrpc: "2.0", id: m.id, error }));
}
});
}
// ─── HTTP ───────────────────────────────────────────────────────────────────
const cookies = (req) => Object.fromEntries(String(req.headers.cookie ?? "").split(/;\s*/).filter(Boolean).map((c) => { const i = c.indexOf("="); return [c.slice(0, i), c.slice(i + 1)]; }));
const pageApp = () => `<!doctype html><html><head><meta charset="utf-8"><title>Qlik Sense</title></head><body>
<div id="qv-page-container">faux client Qlik</div>
<script>
// Le vrai client ouvre son websocket Engine avec le jeton CSRF dans l'URL.
console.log("client Qlik : démarrage");
const ws = new WebSocket("wss://" + location.host + "/app/${APP}?reloadUri=" + encodeURIComponent(location.href) + "&qlik-csrf-token=${encodeURIComponent(JETON)}");
ws.onopen = () => console.log("client Qlik : websocket ouvert");
</script></body></html>`;
const serveur = https.createServer({ key: fs.readFileSync(path.join(ICI, "key.pem")), cert: fs.readFileSync(path.join(ICI, "cert.pem")) }, (req, res) => {
const url = new URL(req.url, "https://localhost");
const auth = String(req.headers.authorization ?? "");
if (url.pathname === "/hub/" && url.searchParams.has("qlikTicket")) {
stats.ticket++;
if (url.searchParams.get("qlikTicket") !== "TICKET-OK") { res.writeHead(403); return res.end("ticket"); }
res.writeHead(302, { Location: "/hub/", "Set-Cookie": `X-Qlik-Session=${SESSION}; Path=/; HttpOnly; Secure` });
return res.end();
}
if (url.pathname === "/hub/") {
res.writeHead(302, { Location: "https://localhost/forms/?targetId=1a2b3c4d-0000-1111-2222-333344445555" });
return res.end();
}
if (url.pathname === "/internal_windows_authentication/") {
if (auth.startsWith("NTLM TlRMTVNTUAAB")) {
stats.ntlm1++;
res.writeHead(401, { "WWW-Authenticate": ["Negotiate", "NTLM " + TYPE2], Connection: "keep-alive", "Content-Length": 0 });
return res.end();
}
if (auth.startsWith("NTLM TlRMTVNTUAAD")) {
stats.ntlm3++;
res.writeHead(302, { Location: "https://localhost/hub/?qlikTicket=TICKET-OK" });
return res.end();
}
res.writeHead(401, { "WWW-Authenticate": ["Negotiate", "NTLM"], "Content-Length": 0 });
return res.end();
}
if (url.pathname.startsWith("/sense/app/")) {
if (cookies(req)["X-Qlik-Session"] !== SESSION) { stats.pageRefusee++; res.writeHead(302, { Location: "/hub/" }); return res.end(); }
stats.page++;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end(pageApp());
}
if (url.pathname === "/__stats") { res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(stats)); }
res.writeHead(404); res.end("introuvable");
});
const wss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024 * 1024 });
serveur.on("upgrade", (req, socket, head) => {
const url = new URL(req.url, "https://localhost");
const ok = url.pathname === "/app/" + APP && cookies(req)["X-Qlik-Session"] === SESSION && url.searchParams.get("qlik-csrf-token") === JETON;
if (!ok) {
stats.wsRefuse++;
log("websocket refusé", url.pathname, "cookie=" + (cookies(req)["X-Qlik-Session"] === SESSION), "jeton=" + (url.searchParams.get("qlik-csrf-token") === JETON));
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
return socket.destroy();
}
stats.ws++;
wss.handleUpgrade(req, socket, head, (ws) => nouvelleSession(ws));
});
const PORT = Number(process.env.PORT || 443);
serveur.listen(PORT, "127.0.0.1", () => log(`à l'écoute sur https://localhost:${PORT}`));
@@ -50,9 +50,11 @@ interface FournisseurRow {
dernierSqlStatut: string | null;
dernierSqlLignes: number | null;
dernierSqlMs: number | null;
dernierSqlErreur: string | null;
dernierQlikAt: string | null;
dernierQlikStatut: string | null;
dernierQlikCodes: number | null;
dernierQlikErreur: string | null;
}
interface Resume {
@@ -357,9 +359,23 @@ export function SynchronisationClient() {
{
id: "remarque",
header: "Remarque",
cell: (r) => (r.desactiveMotif
? <span className="text-[13px] text-[var(--text-secondary)]">{r.desactiveMotif}</span>
: null),
// Le badge « Échec » seul ne dit pas quoi corriger : on affiche le
// message enregistré par la dernière tentative.
cell: (r) => {
const erreurs = [
r.dernierSqlStatut === "echec" && r.dernierSqlErreur ? `${VENTES} : ${r.dernierSqlErreur}` : null,
r.dernierQlikStatut === "echec" && r.dernierQlikErreur ? `${RESEAU} : ${r.dernierQlikErreur}` : null,
].filter((e): e is string => e !== null);
if (!r.desactiveMotif && erreurs.length === 0) return null;
return (
<div className="max-w-[480px] space-y-1 text-[13px]">
{r.desactiveMotif && <div className="text-[var(--text-secondary)]">{r.desactiveMotif}</div>}
{erreurs.map((e) => (
<div key={e} className="break-words text-[var(--accent-error)]">{e}</div>
))}
</div>
);
},
},
];
+39 -6
View File
@@ -292,22 +292,28 @@ function SectionFfApi() {
const [savedUrl, setSavedUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testedUrl, setTestedUrl] = useState<string | null>(null);
// Toujours vide au chargement : la clé enregistrée ne quitte pas le serveur.
const [cle, setCle] = useState("");
const [cleEnregistree, setCleEnregistree] = useState(false);
const [showCle, setShowCle] = useState(false);
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut)
// et l'indication qu'une clé est enregistrée.
useEffect(() => {
loadSavedConfig()
.then((cfg) => {
setSavedUrl(cfg.ffApiBaseUrl || null);
if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
setCleEnregistree(cfg.ffApiKeyEnregistree);
})
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ });
}, []);
const runTest = useCallback((candidate?: string) => {
const runTest = useCallback((candidate?: string, cleCandidate?: string) => {
setLoading(true);
setError(null);
setStatus(null);
testFfApiConnection(candidate)
testFfApiConnection(candidate, cleCandidate)
.then((res) => {
setTestedUrl(res.url);
if (res.success) setStatus(res.status as FfSyncStatus);
@@ -322,13 +328,17 @@ function SectionFfApi() {
const save = async () => {
setSaving(true);
try {
const res = await saveFfApiSettings(url);
const res = await saveFfApiSettings(url, cle || undefined);
if (!res.success) {
toast.erreur(res.error ?? "Enregistrement impossible.");
return;
}
setSavedUrl(url.trim() || null);
toast.succes(url.trim() ? "Adresse de l'API FF enregistrée." : "L'adresse par défaut de l'API FF sera utilisée.");
if (cle) {
setCle("");
setCleEnregistree(true);
}
toast.succes(url.trim() ? "Réglages de l'API FF enregistrés." : "Réglages de l'API FF enregistrés (adresse par défaut).");
runTest(url);
} catch (e) {
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
@@ -361,13 +371,36 @@ function SectionFfApi() {
className="w-full font-mono"
/>
</Champ>
<Champ
id="ff-cle"
label="Clé d'API"
aide={cleEnregistree ? "Laissez vide pour conserver la clé enregistrée." : "Clé fournie par l'administrateur de l'API FF Nancy, envoyée à chaque appel."}
>
<div className="relative">
<Input id="ff-cle" type={showCle ? "text" : "password"} placeholder={placeholderMotDePasse(cleEnregistree)} value={cle}
onChange={(e) => setCle(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
className="w-full pr-10 font-mono" autoComplete="new-password" />
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowCle((v) => !v)}
aria-label={showCle ? "Masquer la clé" : "Afficher la clé"}
title={showCle ? "Masquer la clé" : "Afficher la clé"}
className="absolute right-0.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
>
{showCle ? <EyeOff /> : <Eye />}
</Button>
</div>
</Champ>
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
<Button onClick={save} disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Enregistrer
</Button>
<Button variant="outline" onClick={() => runTest(url)} disabled={loading}>
<Button variant="outline" onClick={() => runTest(url, cle || undefined)} disabled={loading}>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Tester la connexion
</Button>
@@ -36,7 +36,7 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
return (
<div className="flex items-center gap-2">
<span
className="text-xs leading-tight text-right whitespace-nowrap hidden xl:block"
className={`text-xs leading-tight text-right ${status === "error" ? "max-w-[420px] line-clamp-2" : "whitespace-nowrap hidden xl:block"}`}
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message || undefined}
>
+9 -7
View File
@@ -110,9 +110,10 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
if (data.status === "success" || data.status === "error") {
trackingRef.current = false;
if (data.status === "success") onSuccessRef.current?.();
// Petit délai pour laisser l'utilisateur lire le message vert/rouge.
// Le succès s'efface après un court délai ; une erreur reste
// affichée jusqu'à la prochaine tentative, sinon elle passe inaperçue.
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
setStatus((cur) => (cur === "success" ? "idle" : cur));
}, 4000);
return false;
}
@@ -159,10 +160,10 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
applyJobToUi(data);
startPolling();
} else if (data.status === "success" || data.status === "error") {
// Job terminé avant le montage : on l'affiche brièvement.
// Job terminé avant le montage : succès affiché brièvement, erreur conservée.
applyJobToUi(data);
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
setStatus((cur) => (cur === "success" ? "idle" : cur));
}, 4000);
}
} catch {
@@ -208,12 +209,13 @@ export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: Use
}
// Statut terminal direct (cas rare : 0 code → success immédiat).
applyJobToUi(data);
if (data.status === "success") onSuccessRef.current?.();
setTimeout(() => setStatus("idle"), 4000);
if (data.status === "success") {
onSuccessRef.current?.();
setTimeout(() => setStatus("idle"), 4000);
}
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
}
}, [applyJobToUi, query, startPolling, status, target.mode]);
+14 -6
View File
@@ -32,6 +32,8 @@ export interface ParametresAffichables {
} | null;
qlik: { host: string; user: string; motDePasseEnregistre: boolean };
ffApiBaseUrl: string;
/** Une clé d'API FF est enregistrée (la clé elle-même ne quitte pas le serveur). */
ffApiKeyEnregistree: boolean;
}
export interface ResultatAction {
@@ -67,12 +69,18 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli
return callAction("testQlikConnection", [qlikHost, qlikUser, qlikPassword]);
}
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
export async function saveFfApiSettings(ffApiBaseUrl: string): Promise<ResultatAction> {
return callAction("saveFfApiSettings", [ffApiBaseUrl]);
/**
* Enregistre l'URL de l'API FF Nancy (chaîne vide = revenir au défaut) et sa clé
* d'API (vide ou absente : celle qui est enregistrée est conservée).
*/
export async function saveFfApiSettings(ffApiBaseUrl: string, ffApiKey?: string): Promise<ResultatAction> {
return callAction("saveFfApiSettings", [ffApiBaseUrl, ffApiKey]);
}
/** Teste l'API FF Nancy (diagnostic détaillé : DNS, refus, délai, code HTTP). */
export async function testFfApiConnection(ffApiBaseUrl?: string): Promise<DiagnosticApiFf> {
return callAction("testFfApiConnection", [ffApiBaseUrl]);
/**
* Teste l'API FF Nancy (diagnostic détaillé : DNS, refus, délai, code HTTP, clé).
* Clé vide ou absente : on teste avec celle qui est enregistrée.
*/
export async function testFfApiConnection(ffApiBaseUrl?: string, ffApiKey?: string): Promise<DiagnosticApiFf> {
return callAction("testFfApiConnection", [ffApiBaseUrl, ffApiKey]);
}