diff --git a/server/src/ff/pages.rs b/server/src/ff/pages.rs index 70c60df..c56261d 100644 --- a/server/src/ff/pages.rs +++ b/server/src/ff/pages.rs @@ -7,7 +7,7 @@ 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 { 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 { 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 { diff --git a/server/src/ff/pg.rs b/server/src/ff/pg.rs index e6f4fee..0ef6be4 100644 --- a/server/src/ff/pg.rs +++ b/server/src/ff/pg.rs @@ -851,6 +851,103 @@ async fn enrichir_article_dashboard(base: &str, codein: &str, out: &mut Enrichis Ok(()) } +// --------------------------------------------------------------------------- +// 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)>; +static FOURNISSEURS_API: LazyLock> = 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 { + 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)> = 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(rows: &mut [T], champs: fn(&mut T) -> (&str, &mut String)) { + let mut vus = HashSet::new(); + let manquants: Vec = 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). diff --git a/server/src/ff/tests_fournisseur_db.rs b/server/src/ff/tests_fournisseur_db.rs index 3e8bcec..b3e6da0 100644 --- a/server/src/ff/tests_fournisseur_db.rs +++ b/server/src/ff/tests_fournisseur_db.rs @@ -101,4 +101,40 @@ async fn article_sans_fournisseur_principal() { 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" { "FOURNISSEUR API" } 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>) -> Json { + match q.get("codein").map(|c| c.as_str()) { + Some("100003") => Json(json!({ "articles": [{ "no_id": 4, "codein": "100003" }] })), + _ => Json(json!({ "articles": [] })), + } + } + async fn referentiel(Path(_id): Path) -> Json { + Json(json!({ "stock": [], "fournisseurs": [{ "codefou": "F9", "nom_fou": " FOURNISSEUR API ", "preference": 0 }] })) + } + let app = axum::Router::new() + .route("/api/articles", get(articles)) + .route("/api/articles/{id}/referentiel", get(referentiel)) + .route("/api/articles/{id}/dernier-fournisseur", get(|| async { Json(json!({ "fournisseurs_par_site": [] })) })); + 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}") }