diff --git a/server/src/ff/mod.rs b/server/src/ff/mod.rs index d3ccdf3..3f883c9 100644 --- a/server/src/ff/mod.rs +++ b/server/src/ff/mod.rs @@ -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; diff --git a/server/src/ff/pg.rs b/server/src/ff/pg.rs index 4907915..e6f4fee 100644 --- a/server/src/ff/pg.rs +++ b/server/src/ff/pg.rs @@ -1109,7 +1109,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 +1129,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 +1231,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 +1273,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 +1490,27 @@ fn last_entry_join(link: Option, 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) -> &'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 +1620,7 @@ pub async fn pg_get_stock_sans_vente(site: Option<&str>) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result 0 @@ -1995,7 +1993,8 @@ fn colonne_iso(row: &PgRow, col: &str) -> anyhow::Result> { /// /// 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 +2013,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 +2069,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 +2259,7 @@ pub async fn pg_search_produits(term: &str, limit: i64) -> Vec 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 +2368,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 +2885,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 +2909,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, diff --git a/server/src/ff/routes.rs b/server/src/ff/routes.rs index 099b01f..a596cae 100644 --- a/server/src/ff/routes.rs +++ b/server/src/ff/routes.rs @@ -72,11 +72,18 @@ pub async fn test_db(_admin: AdminUser, Query(q): Query> 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) diff --git a/server/src/ff/tests_fournisseur_db.rs b/server/src/ff/tests_fournisseur_db.rs new file mode 100644 index 0000000..3e8bcec --- /dev/null +++ b/server/src/ff/tests_fournisseur_db.rs @@ -0,0 +1,104 @@ +//! 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()); +} diff --git a/server/src/produits/search.rs b/server/src/produits/search.rs index 98afdc6..96647c3 100644 --- a/server/src/produits/search.rs +++ b/server/src/produits/search.rs @@ -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 }),