mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 13:38:08 +02:00
Fournisseur : repli sur l'API FF pour les articles absents de la base miroir
Les articles récents (503996, 504022, 504025, 505349…) n'ont aucune ligne artfou1 dans la recopie FF : ni référence, ni fournisseur. Le Hit Parade et les pages Stock les affichaient en « Sans fournisseur ». Les lignes restées « Sans fournisseur » après la requête SQL sont complétées par l'API FF en direct, avec la règle du tableau de bord : fournisseur de la dernière entrée en stock, sinon celui du référentiel. Au plus 200 articles par chargement (12 en parallèle), réponses gardées en mémoire le temps du cache FF pour ne pas réinterroger l'API à chaque onglet. Le test d'intégration sert une fausse API FF et vérifie que l'article absent de la base est complété ; il échoue sans le repli. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhSZx8CCuqoKrDw7unFqGq
This commit is contained in:
3 files changed
+150
-5
No files matched your search
+17
-5
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
Reference in new issue
Block a user