mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 05:28:11 +02:00
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:
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