Merge pull request #34 from R0m1k3/claude/affectionate-cori-9pitju

Fournisseur : repli sur l'API FF pour les articles absents de la base miroir
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-19 22:13:44 +02:00
commit d3624510eb
3 files changed
+150 -5

No files matched your search

+17 -5
View File
@@ -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<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 {
+97
View File
@@ -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<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).
+36
View File
@@ -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<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": [] })),
}
}
async fn referentiel(Path(_id): Path<String>) -> Json<Value> {
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}")
}