mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-23 06:58:10 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7efa5540 | ||
|
|
d3624510eb | ||
|
|
358cf0643f | ||
|
|
0f782d3fc9 | ||
|
|
e93564d35c | ||
|
|
61d45f5857 | ||
|
|
4142e7c73e | ||
|
|
712174cd85 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
+24
-14
@@ -3,11 +3,11 @@
|
||||
//! 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::{
|
||||
pg_get_ca_by_fournisseur, pg_get_ca_by_nomenclature, pg_get_commandes_auto, pg_get_dashboard_data, pg_get_hit_parade,
|
||||
completer_fournisseurs, pg_get_ca_by_fournisseur, pg_get_ca_by_nomenclature, pg_get_commandes_auto, pg_get_dashboard_data, pg_get_hit_parade,
|
||||
pg_get_sans_vente_6_mois, pg_get_stock_negatif, pg_get_stock_sans_vente, PgCommandeAutoRow,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
@@ -115,7 +115,9 @@ pub async fn page_hit_parade(ctx: Ctx) -> AppResult<Value> {
|
||||
|
||||
let (d, f) = (date_debut.clone(), date_fin.clone());
|
||||
match cached(&format!("hit-parade:{date_debut}:{date_fin}"), CacheOpts::default(), move || async move {
|
||||
pg_get_hit_parade(&d, &f).await
|
||||
let mut rows = pg_get_hit_parade(&d, &f).await?;
|
||||
completer_fournisseurs(&mut rows, |r| (r.codein.as_str(), &mut r.fournisseur)).await;
|
||||
anyhow::Ok(rows)
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -142,10 +144,20 @@ pub async fn page_stock_negatif(ctx: Ctx) -> AppResult<Value> {
|
||||
let (k1, k2, k3) =
|
||||
(format!("stock-negatif:{magasin}"), format!("stock-sans-vente:{magasin}"), format!("stock-sans-vente-6-mois:{magasin}"));
|
||||
let res = tokio::try_join!(
|
||||
cached(&k1, CacheOpts::default(), move || async move { pg_get_stock_negatif(s1.as_deref()).await }),
|
||||
cached(&k2, CacheOpts::default(), move || async move { pg_get_stock_sans_vente(s2.as_deref()).await }),
|
||||
cached(&k1, CacheOpts::default(), move || async move {
|
||||
let mut rows = pg_get_stock_negatif(s1.as_deref()).await?;
|
||||
completer_fournisseurs(&mut rows, |r| (r.codein.as_str(), &mut r.fournisseur)).await;
|
||||
anyhow::Ok(rows)
|
||||
}),
|
||||
cached(&k2, CacheOpts::default(), move || async move {
|
||||
let mut rows = pg_get_stock_sans_vente(s2.as_deref()).await?;
|
||||
completer_fournisseurs(&mut rows, |r| (r.codein.as_str(), &mut r.fournisseur)).await;
|
||||
anyhow::Ok(rows)
|
||||
}),
|
||||
cached(&k3, CacheOpts::default(), move || async move {
|
||||
pg_get_sans_vente_6_mois(s3.as_deref()).await
|
||||
let mut rows = pg_get_sans_vente_6_mois(s3.as_deref()).await?;
|
||||
completer_fournisseurs(&mut rows, |r| (r.codein.as_str(), &mut r.fournisseur)).await;
|
||||
anyhow::Ok(rows)
|
||||
}),
|
||||
);
|
||||
match res {
|
||||
@@ -223,12 +235,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(¶ms)
|
||||
.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 +273,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!([]),
|
||||
|
||||
+182
-54
@@ -780,8 +780,11 @@ async fn enrichir_article_dashboard(base: &str, codein: &str, out: &mut Enrichis
|
||||
return Ok(());
|
||||
}
|
||||
let art_data: Value = art_res.json().await?;
|
||||
// L'article dont le codein est bien celui demandé, sinon le premier renvoyé.
|
||||
let first = match get(&art_data, "articles") {
|
||||
Some(Value::Array(a)) if !a.is_empty() => a[0].clone(),
|
||||
Some(Value::Array(a)) if !a.is_empty() => {
|
||||
a.iter().find(|x| str_field(x, "codein").trim() == codein.trim()).unwrap_or(&a[0]).clone()
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let no_id = match get(&first, "no_id") {
|
||||
@@ -791,9 +794,8 @@ async fn enrichir_article_dashboard(base: &str, codein: &str, out: &mut Enrichis
|
||||
|
||||
let url_ref = format!("{base}/api/articles/{}/referentiel", encode(&no_id));
|
||||
let url_dernier = format!("{base}/api/articles/{}/dernier-fournisseur", encode(&no_id));
|
||||
let (ref_res, dernier_res) = tokio::join!(ff_get(&url_ref), ff_get(&url_dernier));
|
||||
let (ref_res, dernier) = tokio::join!(ff_get(&url_ref), fournisseur_derniere_entree(&url_dernier));
|
||||
let ref_res = ref_res?;
|
||||
let dernier_res = dernier_res?;
|
||||
|
||||
// Stock (toujours depuis referentiel)
|
||||
let mut ref_fournisseur_fallback = String::new();
|
||||
@@ -832,25 +834,145 @@ async fn enrichir_article_dashboard(base: &str, codein: &str, out: &mut Enrichis
|
||||
}
|
||||
}
|
||||
|
||||
// Fournisseur réel : /dernier-fournisseur (dernière entrée en stock par site)
|
||||
// Fallback : premier non-dépôt du référentiel
|
||||
if dernier_res.status().is_success() {
|
||||
let dernier_data: Value = dernier_res.json().await?;
|
||||
// Prendre le fournisseur du premier site disponible
|
||||
let first = arr(&dernier_data, "fournisseurs_par_site")
|
||||
.iter()
|
||||
.find(|e| get(e, "nom_fournisseur").map(truthy).unwrap_or(false));
|
||||
if let Some(e) = first {
|
||||
out.fournisseur = Some(str_field(e, "nom_fournisseur").trim().to_string());
|
||||
} else if !ref_fournisseur_fallback.is_empty() {
|
||||
out.fournisseur = Some(ref_fournisseur_fallback);
|
||||
}
|
||||
} else if !ref_fournisseur_fallback.is_empty() {
|
||||
out.fournisseur = Some(ref_fournisseur_fallback);
|
||||
// Fournisseur réel : /dernier-fournisseur (dernière entrée en stock),
|
||||
// sinon le fournisseur du référentiel.
|
||||
match dernier? {
|
||||
Some(f) => out.fournisseur = Some(f),
|
||||
None if !ref_fournisseur_fallback.is_empty() => out.fournisseur = Some(ref_fournisseur_fallback),
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fournisseur de la dernière entrée en stock d'un article, tous magasins
|
||||
/// confondus : la plus récente (`date_derniere_entree`), sinon la première.
|
||||
///
|
||||
/// Sans `?site=`, l'API ne renvoie pas toujours tous les magasins : pour le
|
||||
/// codein 503996, seule `?site=579` donne l'entrée (P024 PUCKATOR BV, reçue
|
||||
/// à Houdemont). On interroge donc aussi chaque magasin.
|
||||
async fn fournisseur_derniere_entree(url: &str) -> anyhow::Result<Option<String>> {
|
||||
let urls = [url.to_string(), format!("{url}?site=292"), format!("{url}?site=579")];
|
||||
let reponses = futures::future::join_all(urls.iter().map(|u| ff_get(u))).await;
|
||||
let mut meilleur: Option<(String, String)> = None; // (date, fournisseur)
|
||||
for res in reponses {
|
||||
let res = res?;
|
||||
if !res.status().is_success() {
|
||||
continue;
|
||||
}
|
||||
let data: Value = res.json().await?;
|
||||
// Entrées sous `fournisseurs_par_site`, ou l'objet lui-même.
|
||||
let entrees = arr(&data, "fournisseurs_par_site").iter().chain(std::iter::once(&data));
|
||||
for e in entrees {
|
||||
let nom = str_field(e, "nom_fournisseur").trim().to_string();
|
||||
if nom.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let date = str_field(e, "date_derniere_entree");
|
||||
if meilleur.as_ref().is_none_or(|(d, _)| date > *d) {
|
||||
meilleur = Some((date, nom));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(meilleur.map(|(_, f)| f))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fournisseur manquant — repli sur l'API FF en direct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Libellé posé par les requêtes quand aucun fournisseur n'est trouvé.
|
||||
pub const SANS_FOURNISSEUR: &str = "Sans fournisseur";
|
||||
|
||||
/// Au plus autant d'articles interrogés par appel (les premiers de la liste,
|
||||
/// donc les plus gros CA du Hit Parade) : chacun coûte 3 appels à l'API FF.
|
||||
const MAX_FOURNISSEURS_API: usize = 200;
|
||||
|
||||
/// Réponses de l'API déjà obtenues, y compris « aucun fournisseur », pour ne
|
||||
/// pas réinterroger l'API à chaque page ni à chaque onglet.
|
||||
type MemoFournisseurs = HashMap<String, (std::time::Instant, Option<String>)>;
|
||||
static FOURNISSEURS_API: LazyLock<parking_lot::Mutex<MemoFournisseurs>> = LazyLock::new(Default::default);
|
||||
|
||||
/// Fournisseur d'articles que la base miroir ne rattache à aucun fournisseur :
|
||||
/// aucune ligne `artfou1`, typiquement un article créé depuis la dernière
|
||||
/// recopie de FF (codeins 503996, 504022…, sans référence ni fournisseur).
|
||||
/// L'API FF répond en direct : fournisseur de la dernière entrée en stock,
|
||||
/// sinon celui du référentiel (même règle que le tableau de bord).
|
||||
/// Les codeins sans réponse sont absents de la map.
|
||||
pub async fn ff_fournisseurs_par_codein(codeins: &[String]) -> HashMap<String, String> {
|
||||
let ttl = crate::ff_cache::default_ttl();
|
||||
let mut out = HashMap::new();
|
||||
let mut a_lire = vec![];
|
||||
{
|
||||
let memo = FOURNISSEURS_API.lock();
|
||||
for c in codeins {
|
||||
match memo.get(c) {
|
||||
Some((t, f)) if t.elapsed() < ttl => out.extend(f.clone().map(|f| (c.clone(), f))),
|
||||
_ if a_lire.len() < MAX_FOURNISSEURS_API => a_lire.push(c.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if a_lire.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let base = get_ff_api_base().await;
|
||||
let lus: Vec<(String, Option<String>)> = futures::stream::iter(a_lire)
|
||||
.map(|codein| {
|
||||
let base = base.clone();
|
||||
async move {
|
||||
let mut e = EnrichissementDashboard::default();
|
||||
if let Err(err) = enrichir_article_dashboard(&base, &codein, &mut e).await {
|
||||
tracing::error!("[pg-ff] fournisseur API pour {codein} : {}", message_erreur(&err));
|
||||
}
|
||||
(codein, e.fournisseur.filter(|f| !f.is_empty()))
|
||||
}
|
||||
})
|
||||
.buffer_unordered(12)
|
||||
.collect()
|
||||
.await;
|
||||
tracing::info!(
|
||||
"[pg-ff] fournisseur via l'API FF : {}/{} article(s) résolu(s)",
|
||||
lus.iter().filter(|(_, f)| f.is_some()).count(),
|
||||
lus.len()
|
||||
);
|
||||
let mut memo = FOURNISSEURS_API.lock();
|
||||
let maintenant = std::time::Instant::now();
|
||||
for (codein, f) in lus {
|
||||
if let Some(f) = &f {
|
||||
out.insert(codein.clone(), f.clone());
|
||||
}
|
||||
memo.insert(codein, (maintenant, f));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Complète, via l'API FF, le fournisseur des lignes restées en
|
||||
/// « Sans fournisseur » après la requête SQL. `champs` donne le codein et le
|
||||
/// fournisseur d'une ligne.
|
||||
pub async fn completer_fournisseurs<T>(rows: &mut [T], champs: fn(&mut T) -> (&str, &mut String)) {
|
||||
let mut vus = HashSet::new();
|
||||
let manquants: Vec<String> = rows
|
||||
.iter_mut()
|
||||
.filter_map(|r| {
|
||||
let (codein, fou) = champs(r);
|
||||
(fou == SANS_FOURNISSEUR && !codein.trim().is_empty()).then(|| codein.trim().to_string())
|
||||
})
|
||||
.filter(|c| vus.insert(c.clone()))
|
||||
.collect();
|
||||
if manquants.is_empty() {
|
||||
return;
|
||||
}
|
||||
let trouves = ff_fournisseurs_par_codein(&manquants).await;
|
||||
for r in rows.iter_mut() {
|
||||
let (codein, fou) = champs(r);
|
||||
if let Some(f) = trouves.get(codein.trim()) {
|
||||
if fou == SANS_FOURNISSEUR {
|
||||
*fou = f.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne toutes les données nécessaires pour le dashboard quotidien.
|
||||
/// 100% via l'API REST api.ffnancy.fr — aucune requête PostgreSQL directe.
|
||||
/// N-1 : même jour de semaine (52 semaines = 364 jours avant hier).
|
||||
@@ -1109,7 +1231,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 +1251,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 ",
|
||||
@@ -1231,7 +1353,8 @@ pub struct HitParadeRow {
|
||||
/// 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.
|
||||
/// 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
|
||||
@@ -1272,12 +1395,13 @@ pub async fn pg_get_hit_parade(date_debut: &str, date_fin: &str) -> anyhow::Resu
|
||||
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 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
|
||||
@@ -1488,13 +1612,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')"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1604,13 +1742,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)
|
||||
@@ -1663,13 +1795,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')
|
||||
@@ -1737,13 +1863,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
|
||||
@@ -1995,7 +2115,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 »).
|
||||
@@ -2014,7 +2135,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
|
||||
@@ -2066,7 +2191,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,
|
||||
@@ -2255,7 +2381,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,
|
||||
@@ -2341,7 +2467,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,
|
||||
@@ -2364,6 +2490,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
|
||||
",
|
||||
);
|
||||
|
||||
@@ -2880,7 +3007,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
|
||||
@@ -2903,7 +3031,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,
|
||||
|
||||
+15
-8
@@ -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(¶ms).send().await?;
|
||||
let res = avec_cle_ff(ff_client().get(&url).query(¶ms)).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?)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! 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());
|
||||
|
||||
// Article absent de la base miroir (100003) : l'API FF en direct le connaît.
|
||||
std::env::set_var("FF_API_BASE_URL", fausse_api().await);
|
||||
super::api::reset_ff_api_base_cache();
|
||||
let mut hp = hp;
|
||||
completer_fournisseurs(&mut hp, |r| (r.codein.as_str(), &mut r.fournisseur)).await;
|
||||
for r in &hp {
|
||||
let voulu = if r.codein == "100003" { "PUCKATOR BV" } else { attendu(&r.codein) };
|
||||
assert_eq!(r.fournisseur, voulu, "hit parade complété {}", r.codein);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fausse API FF : seul l'article 4 (codein 100003) y a un fournisseur.
|
||||
async fn fausse_api() -> String {
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::routing::get;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
async fn articles(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
|
||||
match q.get("codein").map(|c| c.as_str()) {
|
||||
Some("100003") => Json(json!({ "articles": [{ "no_id": 4, "codein": "100003" }] })),
|
||||
_ => Json(json!({ "articles": [] })),
|
||||
}
|
||||
}
|
||||
// Comme pour le codein 503996 : fiche sans fournisseur, et seule la
|
||||
// dernière entrée de Houdemont (`?site=579`) donne le fournisseur.
|
||||
async fn dernier(Path(_id): Path<String>, Query(q): Query<HashMap<String, String>>) -> Json<Value> {
|
||||
match q.get("site").map(|s| s.as_str()) {
|
||||
Some("579") => Json(json!({ "fournisseurs_par_site": [{
|
||||
"site": "579", "codefou": "P024", "nom_fournisseur": "PUCKATOR BV ",
|
||||
"date_derniere_entree": "2026-09-29", "qte_entree": 240, "prmp": 1.16
|
||||
}] })),
|
||||
_ => Json(json!({ "fournisseurs_par_site": [] })),
|
||||
}
|
||||
}
|
||||
let app = axum::Router::new()
|
||||
.route("/api/articles", get(articles))
|
||||
.route("/api/articles/{id}/referentiel", get(|| async { Json(json!({ "stock": [], "fournisseurs": [] })) }))
|
||||
.route("/api/articles/{id}/dernier-fournisseur", get(dernier));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
format!("http://{addr}")
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in new issue
Block a user