Compare commits

..
Author SHA1 Message Date
Claude 5c7efa5540 Fournisseur : interroger la dernière entrée de chaque magasin
Le repli sur l'API FF n'a rien trouvé pour 503996 : /dernier-fournisseur
appelé sans magasin ne renvoie pas l'entrée de Houdemont. Seul
`?site=579` donne P024 PUCKATOR BV (entrée du 29/09/2026), et la fiche
de l'article n'a aucun fournisseur.

On interroge désormais l'appel sans magasin, `?site=292` et `?site=579`,
et on retient l'entrée la plus récente. On choisit aussi, parmi les
articles renvoyés par /api/articles?codein=, celui dont le codein est
bien celui demandé. Le tableau de bord en profite (même fonction).

Le test reproduit la réponse de l'API pour 503996 ; il échoue sur
l'ancien code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhSZx8CCuqoKrDw7unFqGq
2026-10-19 20:25:22 +00:00
LogiFlow d3624510eb Merge pull request #34 from R0m1k3/claude/affectionate-cori-9pitju
Fournisseur : repli sur l'API FF pour les articles absents de la base miroir
2026-10-19 22:13:44 +02:00
Claude 358cf0643f 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
2026-10-19 20:12:18 +00:00
LogiFlow 0f782d3fc9 Merge pull request #33 from R0m1k3/claude/affectionate-cori-9pitju
Fournisseur : les articles sans fournisseur principal ne disparaissent plus
2026-10-19 17:30:03 +02:00
Claude e93564d35c Fournisseur : les articles sans fournisseur principal ne disparaissent plus
Les pages Stock (stock négatif, stock sans vente, sans vente depuis 6 mois)
joignaient obligatoirement le fournisseur principal (artfou1.preference = 1)
et sa fiche fouident : un article sans principal — ou sans aucune ligne
artfou1, comme le codein 504202 — était purement retiré de la liste.

Les jointures deviennent facultatives et le fournisseur suit partout la même
règle : le principal, sinon le fournisseur référencé le plus récent, sinon
celui des données du réseau (cache Qlik, par code centrale), sinon
« Sans fournisseur ». Même repli pour le CA par fournisseur, la dernière
réception (cadencier), le Hit Parade, la recherche produit et les
opportunités. Dans la recherche, ce repli ne masque plus le fournisseur
que Qlik vient de renvoyer.

Test d'intégration opt-in (FF_FOURNISSEUR_TEST_DATABASE_URL) qui rejoue les
quatre cas ; il échoue sur l'ancien code (1 article listé sur 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DhSZx8CCuqoKrDw7unFqGq
2026-10-19 10:20:42 +00:00
LogiFlow 61d45f5857 Merge pull request #32 from R0m1k3/claude/lucid-franklin-fsd5n7
Paramètres : clé d'API FF Nancy (en-tête X-API-Key)
2026-10-17 15:28:26 +02:00
6 changed files with 367 additions and 63 deletions

No files matched your search

+2
View File
@@ -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;
+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 {
+182 -54
View File
@@ -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,
+10 -3
View File
@@ -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)
+148
View File
@@ -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}")
}
+8 -1
View File
@@ -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 }),