diff --git a/server/src/produits/compare_reseau.rs b/server/src/produits/compare_reseau.rs new file mode 100644 index 0000000..c9f9100 --- /dev/null +++ b/server/src/produits/compare_reseau.rs @@ -0,0 +1,42 @@ +//! CollectFlow — Comparatif local vs réseau pour un produit — partie serveur de +//! `src/features/produits/lib/compare-reseau.ts`. +//! +//! Le calcul du comparatif lui-même (`computeComparatif`, `indiceVerdict`) reste +//! côté interface : seul `normalizeMargePct` sert au serveur (recherche produit). + +/// Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines +/// configurations renvoient déjà des points de pourcentage. Même normalisation +/// que la colonne « Marge % / Réseau » de la Grille. +pub fn normalize_marge_pct(raw: Option) -> Option { + let v = raw?; + if !v.is_finite() || v == 0.0 { + return None; + } + Some(if v.abs() <= 1.0 { v * 100.0 } else { v }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ratio_brut_converti_en_points() { + assert_eq!(normalize_marge_pct(Some(0.32)), Some(32.0)); + assert_eq!(normalize_marge_pct(Some(-0.5)), Some(-50.0)); + assert_eq!(normalize_marge_pct(Some(1.0)), Some(100.0)); + } + + #[test] + fn points_de_pourcentage_conserves() { + assert_eq!(normalize_marge_pct(Some(32.5)), Some(32.5)); + assert_eq!(normalize_marge_pct(Some(-12.0)), Some(-12.0)); + } + + #[test] + fn absent_nul_ou_non_fini() { + assert_eq!(normalize_marge_pct(None), None); + assert_eq!(normalize_marge_pct(Some(0.0)), None); + assert_eq!(normalize_marge_pct(Some(f64::NAN)), None); + assert_eq!(normalize_marge_pct(Some(f64::INFINITY)), None); + } +} diff --git a/server/src/produits/fiche.rs b/server/src/produits/fiche.rs new file mode 100644 index 0000000..0742139 --- /dev/null +++ b/server/src/produits/fiche.rs @@ -0,0 +1,329 @@ +//! CollectFlow — Fiche produit — port de +//! `src/features/produits/api/get-produit-fiche.ts` +//! (`getProduitFiche`, `getProduitFicheByCodeCentrale`). + +use super::types::{ProduitFiche, ProduitMois, ProduitTotaux, SerieMensuelle}; +use crate::ff::api::build_last12_months_range; +use crate::ff::pg::{ + pg_get_codein_by_code_centrale, pg_get_commandes_by_codein, pg_get_fournisseurs_by_codein, pg_get_gamme_history_by_codein, + pg_get_mensuel_by_codein, pg_get_produit_detail, pg_get_stock_by_codein, PgMensuelRow, +}; +use crate::grid::js::js_trim; +use crate::grid::months::{get_last12_months, today}; +use crate::qlik::get_network_metrics_by_code_centrale; +use std::collections::{BTreeMap, HashMap, HashSet}; + +/// Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. +fn build_month_keys() -> Vec { + get_last12_months(today()) +} + +fn empty_mois(mois: &str) -> ProduitMois { + ProduitMois { mois: mois.to_string(), qte: 0.0, ca: 0.0, marge: 0.0, stock_fin_mois: 0.0, qte_recue: 0.0 } +} + +fn totaux_from<'a>(rows: impl Iterator) -> ProduitTotaux { + let (mut qte, mut ca, mut marge) = (0.0, 0.0, 0.0); + for r in rows { + qte += r.qte; + ca += r.ca; + marge += r.marge; + } + ProduitTotaux { qte, ca, marge, taux_marge: if ca > 0.0 { (marge / ca) * 100.0 } else { 0.0 } } +} + +/// Totaux d'une série sur les mois de la fenêtre, dans l'ordre de `months`. +fn totaux_serie(months: &[String], serie: &SerieMensuelle) -> ProduitTotaux { + totaux_from(months.iter().filter_map(|m| serie.get(m))) +} + +/// `Number(x) || 0`. +fn ou_zero(x: f64) -> f64 { + if x.is_nan() { + 0.0 + } else { + x + } +} + +/// Partie mensuelle de la fiche, calculée à partir des lignes `mvtart` agrégées. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Mensuel { + pub mensuel_total: SerieMensuelle, + pub mensuel_par_site: BTreeMap, + pub totaux: ProduitTotaux, + pub totaux_par_site: BTreeMap, + pub sites_actifs: Vec, +} + +/// Agrège les lignes mensuelles (total et par site) sur la fenêtre `months`, +/// avec report du stock de fin de mois sur les mois sans mouvement. +pub fn assembler_mensuel(months: &[String], mensuel_rows: &[PgMensuelRow]) -> Mensuel { + let allowed: HashSet<&str> = months.iter().map(|m| m.as_str()).collect(); + + let mut mensuel_total: SerieMensuelle = months.iter().map(|m| (m.clone(), empty_mois(m))).collect(); + let mut mensuel_par_site: BTreeMap = BTreeMap::new(); + + // Mois réellement présents dans les mouvements. Indispensable pour distinguer + // « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock + // effectivement nul » — c'est la convention de la Grille. + let mut mois_avec_donnees: HashSet = HashSet::new(); + let mut mois_avec_donnees_par_site: HashMap> = HashMap::new(); + + for row in mensuel_rows { + // SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM". + let mois = row.mois.replacen('-', "", 1); + if !allowed.contains(mois.as_str()) { + continue; + } + let site = js_trim(&row.site).to_string(); + + let qte = ou_zero(row.qte_vendue); + let ca = ou_zero(row.ca_ht); + let marge = ou_zero(row.marge); + let stock_fin_mois = ou_zero(row.stock_fin_mois.unwrap_or(0.0)); + let qte_recue = ou_zero(row.qte_recue); + + mois_avec_donnees.insert(mois.clone()); + if let Some(tot) = mensuel_total.get_mut(&mois) { + tot.qte += qte; + tot.ca += ca; + tot.marge += marge; + tot.stock_fin_mois += stock_fin_mois; + tot.qte_recue += qte_recue; + } + + let par_site = + mensuel_par_site.entry(site.clone()).or_insert_with(|| months.iter().map(|m| (m.clone(), empty_mois(m))).collect()); + mois_avec_donnees_par_site.entry(site).or_default().insert(mois.clone()); + if let Some(cell) = par_site.get_mut(&mois) { + cell.qte += qte; + cell.ca += ca; + cell.marge += marge; + // Le SQL donne déjà le stock du dernier mouvement du mois pour ce site. + cell.stock_fin_mois = stock_fin_mois; + cell.qte_recue += qte_recue; + } + } + + // Report du stock de fin de mois sur les mois SANS mouvement (carry-forward), + // même convention que la Grille : un mois sans mouvement conserve le stock du + // mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0. + let carry_forward = |serie: &mut SerieMensuelle, avec_donnees: Option<&HashSet>| { + let mut last = 0.0; + for m in months { + let Some(cell) = serie.get_mut(m) else { continue }; + if avec_donnees.is_some_and(|s| s.contains(m)) { + last = cell.stock_fin_mois; + } else { + cell.stock_fin_mois = last; + } + } + }; + carry_forward(&mut mensuel_total, Some(&mois_avec_donnees)); + for (site, by_site) in mensuel_par_site.iter_mut() { + carry_forward(by_site, mois_avec_donnees_par_site.get(site)); + } + + let totaux = totaux_serie(months, &mensuel_total); + let totaux_par_site: BTreeMap = + mensuel_par_site.iter().map(|(site, by_site)| (site.clone(), totaux_serie(months, by_site))).collect(); + + let mut sites_actifs: Vec = totaux_par_site.iter().filter(|(_, t)| t.qte > 0.0).map(|(s, _)| s.clone()).collect(); + sites_actifs.sort(); + + Mensuel { mensuel_total, mensuel_par_site, totaux, totaux_par_site, sites_actifs } +} + +/// Assemble la fiche d'un produit **à partir de son code centrale** : c'est la +/// clé du réseau Qlik, seule identité disponible pour un produit que le réseau +/// travaille mais que Nancy ne référence pas. +/// +/// Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie +/// locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes). +/// Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal +/// depuis la recherche Qlik. +/// +/// Renvoie `None` uniquement si ni Qlik ni le catalogue ne connaissent le code. +pub async fn get_produit_fiche_by_code_centrale(code_centrale: &str) -> anyhow::Result> { + let cleaned = js_trim(code_centrale); + if cleaned.is_empty() { + return Ok(None); + } + + if let Some(codein) = pg_get_codein_by_code_centrale(cleaned).await { + return get_produit_fiche(&codein).await; + } + + // Produit réseau pur : aucune donnée locale à assembler. + let mut map = get_network_metrics_by_code_centrale(&[cleaned.to_string()]).await?; + let Some(reseau) = map.remove(cleaned) else { + return Ok(None); + }; + + let periode = build_last12_months_range(); + let months = build_month_keys(); + let mensuel_total: SerieMensuelle = months.iter().map(|m| (m.clone(), empty_mois(m))).collect(); + let totaux = totaux_serie(&months, &mensuel_total); + + Ok(Some(ProduitFiche { + detail: None, + code_centrale: cleaned.to_string(), + libelle_reseau: reseau.libelle_reseau.clone().unwrap_or_default(), + fournisseur_reseau: reseau.fournisseur_reseau.clone().unwrap_or_default(), + fournisseurs: vec![], + stock: vec![], + commandes_en_cours: 0.0, + gammes: vec![], + months, + mensuel_total, + mensuel_par_site: BTreeMap::new(), + totaux, + totaux_par_site: BTreeMap::new(), + sites_actifs: vec![], + reseau: Some(reseau), + periode, + })) +} + +/// Assemble la fiche complète d'un produit : identité, fournisseurs, stock, +/// gammes, 12 mois glissants de ventes (total et par magasin) et métriques +/// réseau Qlik. +/// +/// Renvoie `None` si le codein est inconnu du catalogue FF. +pub async fn get_produit_fiche(codein: &str) -> anyhow::Result> { + let cleaned = js_trim(codein); + if cleaned.is_empty() { + return Ok(None); + } + + let periode = build_last12_months_range(); + + // Tout part en même temps : l'identité du produit était lue seule d'abord, + // puis les cinq autres lectures, puis les métriques réseau — trois attentes + // successives. Les métriques réseau n'attendent que l'identité (code centrale). + let detail_et_reseau = async { + let detail = pg_get_produit_detail(cleaned).await; + let code = detail.as_ref().map(|d| d.code_centrale.clone()).filter(|c| !c.is_empty()); + let reseau = match code { + Some(code) => get_network_metrics_by_code_centrale(std::slice::from_ref(&code)).await?.remove(&code), + None => None, + }; + anyhow::Ok((detail, reseau)) + }; + let (detail_et_reseau, fournisseurs, stock, commandes_en_cours, gammes, mensuel_rows) = tokio::join!( + detail_et_reseau, + pg_get_fournisseurs_by_codein(cleaned), + pg_get_stock_by_codein(cleaned), + pg_get_commandes_by_codein(cleaned), + pg_get_gamme_history_by_codein(cleaned), + pg_get_mensuel_by_codein(cleaned, &periode.date_debut, &periode.date_fin), + ); + let (detail, reseau) = detail_et_reseau?; + let Some(detail) = detail else { + return Ok(None); + }; + + let months = build_month_keys(); + let Mensuel { mensuel_total, mensuel_par_site, totaux, totaux_par_site, sites_actifs } = assembler_mensuel(&months, &mensuel_rows); + + Ok(Some(ProduitFiche { + code_centrale: detail.code_centrale.clone(), + detail: Some(detail), + libelle_reseau: reseau.as_ref().and_then(|r| r.libelle_reseau.clone()).unwrap_or_default(), + fournisseur_reseau: reseau.as_ref().and_then(|r| r.fournisseur_reseau.clone()).unwrap_or_default(), + fournisseurs, + stock, + commandes_en_cours, + gammes, + months, + mensuel_total, + mensuel_par_site, + totaux, + totaux_par_site, + sites_actifs, + reseau, + periode, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ligne(site: &str, mois: &str, qte: f64, ca: f64, marge: f64, stock: Option, recu: f64) -> PgMensuelRow { + PgMensuelRow { + codein: "123".into(), + site: site.into(), + mois: mois.into(), + qte_vendue: qte, + ca_ht: ca, + marge, + stock_fin_mois: stock, + qte_recue: recu, + } + } + + fn mois() -> Vec { + get_last12_months(chrono::NaiveDate::from_ymd_opt(2026, 3, 15).unwrap()) + } + + #[test] + fn fenetre_12_mois_hors_mois_courant() { + let m = mois(); + assert_eq!(m.len(), 12); + assert_eq!(m.first().unwrap(), "202503"); + assert_eq!(m.last().unwrap(), "202602"); + } + + #[test] + fn agrege_total_et_sites_avec_report_de_stock() { + let m = mois(); + let rows = vec![ + ligne("292", "2025-04", 2.0, 20.0, 5.0, Some(10.0), 0.0), + ligne(" 579 ", "2025-04", 1.0, 10.0, 2.0, Some(4.0), 3.0), + // Mois avec mouvements et stock nul : reste à 0 (pas de report). + ligne("292", "2025-06", 0.0, 0.0, 0.0, Some(0.0), 0.0), + // Hors fenêtre : ignoré. + ligne("292", "2024-01", 99.0, 99.0, 99.0, Some(99.0), 99.0), + ]; + let r = assembler_mensuel(&m, &rows); + + assert_eq!(r.mensuel_total.len(), 12); + let avril = &r.mensuel_total["202504"]; + assert_eq!((avril.qte, avril.ca, avril.marge, avril.stock_fin_mois, avril.qte_recue), (3.0, 30.0, 7.0, 14.0, 3.0)); + // Mars : avant tout mouvement → 0 ; mai : report d'avril ; juin : 0 réel ; juillet : report de juin. + assert_eq!(r.mensuel_total["202503"].stock_fin_mois, 0.0); + assert_eq!(r.mensuel_total["202505"].stock_fin_mois, 14.0); + assert_eq!(r.mensuel_total["202506"].stock_fin_mois, 0.0); + assert_eq!(r.mensuel_total["202602"].stock_fin_mois, 0.0); + + // Le site est « trimé » ; chaque site a ses 12 mois. + assert_eq!(r.mensuel_par_site.keys().cloned().collect::>(), vec!["292", "579"]); + assert_eq!(r.mensuel_par_site["579"].len(), 12); + assert_eq!(r.mensuel_par_site["579"]["202602"].stock_fin_mois, 4.0); + assert_eq!(r.mensuel_par_site["292"]["202505"].stock_fin_mois, 10.0); + assert_eq!(r.mensuel_par_site["292"]["202507"].stock_fin_mois, 0.0); + + assert_eq!(r.totaux, ProduitTotaux { qte: 3.0, ca: 30.0, marge: 7.0, taux_marge: 7.0 / 30.0 * 100.0 }); + assert_eq!(r.totaux_par_site["292"].qte, 2.0); + assert_eq!(r.sites_actifs, vec!["292", "579"]); + } + + #[test] + fn site_sans_vente_non_actif_et_taux_nul_sans_ca() { + let m = mois(); + let rows = vec![ligne("579", "2025-05", 0.0, 0.0, 0.0, None, 6.0)]; + let r = assembler_mensuel(&m, &rows); + assert!(r.sites_actifs.is_empty()); + assert_eq!(r.totaux.taux_marge, 0.0); + assert_eq!(r.mensuel_total["202505"].qte_recue, 6.0); + assert_eq!(r.mensuel_total["202505"].stock_fin_mois, 0.0); + } + + #[test] + fn mois_vide_serialise_comme_le_ts() { + let v = serde_json::to_value(empty_mois("202501")).unwrap(); + assert_eq!(v, serde_json::json!({ "mois": "202501", "qte": 0, "ca": 0, "marge": 0, "stockFinMois": 0, "qteRecue": 0 })); + } +} diff --git a/server/src/produits/mod.rs b/server/src/produits/mod.rs index 05e86bf..2224a0e 100644 --- a/server/src/produits/mod.rs +++ b/server/src/produits/mod.rs @@ -1,11 +1,28 @@ -//! Module `produits` — à porter. +//! Module `produits` — recherche produit (Qlik d'abord, base FF Nancy en repli) +//! et fiche produit. +//! +//! - [`search`] : recherche asynchrone (jobs en mémoire, cache 10 min) ; +//! - [`fiche`] : assemblage de la fiche produit (local + réseau) ; +//! - [`compare_reseau`] : normalisation du taux de marge réseau ; +//! - [`types`] : formes JSON partagées avec l'interface ; +//! - [`routes`] : `/api/produits/search`, `/api/produits/opportunites`, page `produit-fiche`. +pub mod compare_reseau; +pub mod fiche; +pub mod routes; +pub mod search; +pub mod types; + +use crate::handler; use crate::rpc::Handler; +use axum::routing::get; use axum::Router; /// Routes HTTP du module. pub fn router() -> Router { Router::new() + .route("/api/produits/search", get(routes::search_get).post(routes::search_post)) + .route("/api/produits/opportunites", get(routes::opportunites)) } /// Actions (`POST /api/actions/{nom}`) du module. @@ -15,5 +32,5 @@ pub fn actions() -> Vec<(&'static str, Handler)> { /// Données de pages (`GET /api/pages/{nom}`) du module. pub fn pages() -> Vec<(&'static str, Handler)> { - vec![] + vec![("produit-fiche", handler!(routes::page_produit_fiche))] } diff --git a/server/src/produits/routes.rs b/server/src/produits/routes.rs new file mode 100644 index 0000000..30f2af4 --- /dev/null +++ b/server/src/produits/routes.rs @@ -0,0 +1,132 @@ +//! Routes HTTP et page du module `produits` : +//! - `/api/produits/search` (port de `src/app/api/produits/search/route.ts`) ; +//! - `/api/produits/opportunites` (port de `src/app/api/produits/opportunites/route.ts`) ; +//! - page `produit-fiche` (données de l'ancienne page serveur `/produits`). + +use super::fiche::{get_produit_fiche, get_produit_fiche_by_code_centrale}; +use super::search::{demarrer_recherche, etat_recherche, RechercheEtat}; +use crate::auth::SessionUser; +use crate::error::{AppError, AppResult}; +use crate::ff::api::build_last12_months_range; +use crate::ff::pg::pg_get_opportunites_famille; +use crate::grid::js::{js_number_from_str, js_trim}; +use crate::rpc::{json, Ctx}; +use axum::extract::Query; +use axum::Json; +use serde_json::{json, Value}; +use std::collections::HashMap; + +/// Nombre de produits de la famille renvoyés (30 dans le code d'origine). +const LIMITE_OPPORTUNITES: i64 = 30; + +const TERME_TROP_COURT: &str = "Saisissez au moins 3 caractères"; + +/// Terme de recherche (`q`), `None` s'il fait moins de 3 caractères. +fn lire_terme(q: &HashMap) -> Option { + let terme = js_trim(q.get("q").map(|s| s.as_str()).unwrap_or("")); + (terme.encode_utf16().count() >= 3).then(|| terme.to_string()) +} + +/// Recherche produit **Qlik d'abord** — API asynchrone. +/// +/// `POST /api/produits/search?q=[&force=1]` démarre la recherche et rend la +/// main tout de suite. `GET /api/produits/search?q=` renvoie l'avancement, +/// puis le résultat. +/// +/// Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une +/// extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue +/// aussi longtemps se fait couper par le reverse proxy, qui répond une page +/// d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is +/// not valid JSON ». Même schéma que `POST /api/qlik/sync`. +/// +/// Contrôle de session explicite : il répond ici en JSON, et relit le compte en +/// base (cf. `auth::authz`). +pub async fn search_post(_session: SessionUser, Query(q): Query>) -> AppResult> { + let Some(terme) = lire_terme(&q) else { + return Err(AppError::bad_request(TERME_TROP_COURT)); + }; + let force = q.get("force").map(|s| s.as_str()) == Some("1"); + let job = demarrer_recherche(&terme, force); + Ok(Json(RechercheEtat::Job(job).reponse())) +} + +/// `GET /api/produits/search?q=` — état de la recherche (polling client). +pub async fn search_get(_session: SessionUser, Query(q): Query>) -> AppResult> { + let Some(terme) = lire_terme(&q) else { + return Err(AppError::bad_request(TERME_TROP_COURT)); + }; + Ok(Json(etat_recherche(&terme).reponse())) +} + +/// GET /api/produits/opportunites?nomNoId=123 +/// +/// Produits de la même nomenclature, classés par écart entre performance réseau +/// et performance locale (quantités par magasin). Chargé à la demande depuis la +/// fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille, +/// on ne veut pas la payer au chargement de la page. +pub async fn opportunites(_session: SessionUser, Query(q): Query>) -> AppResult> { + let raw = q.get("nomNoId").map(|s| s.as_str()).unwrap_or(""); + let nom_no_id = js_number_from_str(raw); + if !nom_no_id_valide(raw, nom_no_id) { + return Err(AppError::bad_request("Param 'nomNoId' invalide")); + } + + // `pg_get_opportunites_famille` avale déjà ses erreurs (liste vide, journalisée). + let periode = build_last12_months_range(); + let rows = pg_get_opportunites_famille(nom_no_id as i64, &periode.date_debut, &periode.date_fin, LIMITE_OPPORTUNITES).await; + Ok(Json(json!({ "success": true, "rows": rows }))) +} + +/// `!raw || !Number.isInteger(nomNoId) || nomNoId <= 0` → invalide. +fn nom_no_id_valide(raw: &str, n: f64) -> bool { + !raw.is_empty() && n.is_finite() && n.fract() == 0.0 && n > 0.0 +} + +/// Page `produit-fiche` : `?codein=…` → `getProduitFiche(codein)`, +/// `?cc=…` → `getProduitFicheByCodeCentrale(cc)`, sinon `null`. +pub async fn page_produit_fiche(ctx: Ctx) -> AppResult { + ctx.session().await?; + let codein = js_trim(ctx.query.get("codein").map(|s| s.as_str()).unwrap_or("")); + let code_centrale = js_trim(ctx.query.get("cc").map(|s| s.as_str()).unwrap_or("")); + + let fiche = if !codein.is_empty() { + get_produit_fiche(codein).await? + } else if !code_centrale.is_empty() { + get_produit_fiche_by_code_centrale(code_centrale).await? + } else { + None + }; + json(fiche) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(q: &str) -> HashMap { + HashMap::from([("q".to_string(), q.to_string())]) + } + + #[test] + fn terme_minimum_3_caracteres() { + assert_eq!(lire_terme(¶ms(" ab ")), None); + assert_eq!(lire_terme(&HashMap::new()), None); + assert_eq!(lire_terme(¶ms(" poê ")), Some("poê".to_string())); + } + + #[test] + fn nom_no_id_comme_number_is_integer() { + let ok = |s: &str| nom_no_id_valide(s, js_number_from_str(s)); + assert!(ok("123")); + assert!(ok(" 12 ")); + assert!(ok("12.0")); + assert!(ok("0x10")); + assert!(!ok("")); + assert!(!ok(" ")); + assert!(!ok("0")); + assert!(!ok("-3")); + assert!(!ok("1.5")); + assert!(!ok("abc")); + assert!(!ok("Infinity")); + } +} diff --git a/server/src/produits/search.rs b/server/src/produits/search.rs new file mode 100644 index 0000000..98afdc6 --- /dev/null +++ b/server/src/produits/search.rs @@ -0,0 +1,772 @@ +//! CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite — +//! port de `src/features/produits/api/search-produits.ts`. +//! +//! Déroulé : +//! 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET +//! leurs mesures réseau sur 12 mois glissants (`search_qlik_articles`). +//! 2. Base FF Nancy : lesquels référençons-nous ? (`pg_get_produits_by_code_centrale`) +//! 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà +//! extrait (le cube de recherche est agrégé, il ne le fournit pas). +//! +//! Une seconde session Qlik pour les mesures faisait abandonner le moteur +//! (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app, +//! dont une avec une grosse sélection, et l'Engine coupe les requêtes. +//! +//! Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne +//! pas laisser l'utilisateur sans résultat — la réponse le signale explicitement +//! (`source: "db"`). + +use super::compare_reseau::normalize_marge_pct; +use super::types::{ProduitRechercheResultat, ProduitRechercheRow, SourceRecherche}; +use crate::ff::pg::{compare_fr, pg_get_produits_by_code_centrale, pg_search_produits, PgProduitSearchRow}; +use crate::grid::js::{js_trim, replace_whitespace_runs}; +use crate::grid::months::today; +use crate::grid::network_trend::{build_rolling12_qlik_months, NB_MAGASINS_RESEAU}; +use crate::qlik::network_cache::upsert_network_metrics; +use crate::qlik::search::{search_qlik_articles, QLIK_SEARCH_MAX_RESULTS}; +use crate::qlik::{get_network_metrics_by_code_centrale, NetworkMetric, NetworkMetricCached, QlikMonthMetrics}; +use chrono::NaiveDate; +use parking_lot::Mutex; +use serde::Serialize; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; + +/// Durée de validité d'un résultat de recherche en cache mémoire. +const CACHE_TTL: Duration = Duration::from_secs(10 * 60); + +/// Durée de conservation d'un job terminé (le client a le temps de le relire). +const JOB_TTL: Duration = Duration::from_secs(5 * 60); + +/// Nombre de lignes de la recherche catalogue local (50 dans le code d'origine). +const LIMITE_RECHERCHE_LOCALE: i64 = 50; + +/// Série « magasins vendeurs » extraite du détail mensuel du cache. +/// +/// Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est +/// qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la +/// série et priverait la tendance de son dénominateur. +fn nb_mag_par_mois(metrics_by_month: Option<&BTreeMap>) -> Option> { + let metrics_by_month = metrics_by_month?; + let par_mois: BTreeMap = metrics_by_month + .iter() + .map(|(mois, m)| (mois.clone(), m.nb_mag.filter(|nb| nb.is_finite()).unwrap_or(0.0))) + .collect(); + if par_mois.is_empty() { + None + } else { + Some(par_mois) + } +} + +/// Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. +fn cache_reseau_complet(cache: Option<&NetworkMetricCached>, periode: Option<&str>, now: NaiveDate) -> bool { + let Some(cache) = cache else { return false }; + let (Some(par_mois), Some(periode)) = (cache.qte_by_month.as_ref(), periode) else { + return false; + }; + if cache.periode.as_deref() != Some(periode) { + return false; + } + build_rolling12_qlik_months(now).iter().all(|mois| par_mois.get(mois).is_some_and(|v| v.is_finite())) +} + +/// Nombre maximum de recherches Qlik simultanées. +/// +/// Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on +/// plafonne plutôt que de lui envoyer N extractions en parallèle. +const MAX_RECHERCHES_SIMULTANEES: usize = 1; + +/// Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik +/// (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur +/// ou un rafraîchissement relancerait toute l'extraction. +static CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Résultat en cache et instant de sa mise en cache. +type EntreeCache = (Instant, Arc); + +/// Jobs de recherche, indexés par terme normalisé. +/// +/// Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours +/// Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps +/// se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le +/// client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail +/// tourne donc en tâche de fond et le client interroge l'état. +static JOBS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +type JobPartage = Arc>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum StatutRecherche { + Running, + Success, + Error, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RechercheJob { + pub job_id: String, + pub terme: String, + pub status: StatutRecherche, + /// Étape en cours, affichée pendant l'attente. + pub etape: String, + pub started_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option>, + /// Instant de fin (purge), non sérialisé. + #[serde(skip)] + termine: Option, +} + +/// État renvoyé au client : le job, ou `idle` si aucune recherche connue. +#[derive(Debug, Clone)] +pub enum RechercheEtat { + Job(RechercheJob), + Idle { terme: String }, +} + +impl RechercheEtat { + /// Corps de réponse `{ success: true, ...etat }`. + pub fn reponse(&self) -> Value { + let mut m = Map::new(); + m.insert("success".into(), Value::Bool(true)); + match self { + RechercheEtat::Job(job) => { + if let Ok(Value::Object(champs)) = serde_json::to_value(job) { + m.extend(champs); + } + } + RechercheEtat::Idle { terme } => { + m.insert("status".into(), Value::from("idle")); + m.insert("terme".into(), Value::from(terme.clone())); + } + } + Value::Object(m) + } +} + +fn cache_key(term: &str) -> String { + replace_whitespace_runs(&js_trim(term).to_lowercase(), " ") +} + +fn maintenant_iso() -> String { + crate::db::iso_utc(chrono::Utc::now()) +} + +/// `n.toString(36)`. +fn base36(mut n: u128) -> String { + const CHIFFRES: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if n == 0 { + return "0".into(); + } + let mut out = Vec::new(); + while n > 0 { + out.push(CHIFFRES[(n % 36) as usize]); + n /= 36; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + +fn horodatage_base36() -> String { + base36(chrono::Utc::now().timestamp_millis().max(0) as u128) +} + +/// `Math.random().toString(36).slice(2, 8)` : 6 caractères base 36. +fn alea_base36() -> String { + (0..6).map(|_| base36(rand::random::() as u128 % 36)).collect() +} + +/// `s.slice(0, n)` en JS (unités UTF-16). +fn slice_js(s: &str, n: usize) -> String { + let mut unites = 0; + let mut out = String::new(); + for c in s.chars() { + unites += c.len_utf16(); + if unites > n { + break; + } + out.push(c); + } + out +} + +/// Purge les entrées expirées (le volume reste faible, un balayage suffit). +fn purge(jobs: &mut HashMap) { + CACHE.lock().retain(|_, (at, _)| at.elapsed() <= CACHE_TTL); + jobs.retain(|_, j| { + let j = j.lock(); + !(j.status != StatutRecherche::Running && j.termine.is_some_and(|t| t.elapsed() > JOB_TTL)) + }); +} + +fn jobs_en_cours(jobs: &HashMap) -> usize { + jobs.values().filter(|j| j.lock().status == StatutRecherche::Running).count() +} + +fn job_termine(terme: &str, result: Arc) -> RechercheJob { + let maintenant = maintenant_iso(); + RechercheJob { + job_id: format!("cache_{}", cache_key(terme)), + terme: terme.to_string(), + status: StatutRecherche::Success, + etape: "Terminé".into(), + started_at: maintenant.clone(), + finished_at: Some(maintenant), + error: None, + result: Some(result), + termine: Some(Instant::now()), + } +} + +/// Démarre (ou réutilise) une recherche et rend la main **immédiatement**. +/// +/// - résultat déjà en cache et pas de `force` → job `success` directement ; +/// - recherche déjà en cours sur le même terme → on renvoie ce job ; +/// - sinon on lance le travail en tâche de fond. +pub fn demarrer_recherche(term: &str, force: bool) -> RechercheJob { + let cleaned = js_trim(term).to_string(); + let key = cache_key(&cleaned); + // Verrou tenu de bout en bout : la vérification « déjà en cours ? » et + // l'enregistrement du nouveau job doivent être atomiques (le TS, mono-thread, + // l'était par construction). + let mut jobs = JOBS.lock(); + purge(&mut jobs); + + if !force { + let hit = CACHE.lock().get(&key).map(|(_, r)| r.clone()); + if let Some(result) = hit { + tracing::info!("[produits/search] \"{cleaned}\" — servi depuis le cache mémoire"); + return job_termine(&cleaned, result); + } + } + + if let Some(existant) = jobs.get(&key) { + let j = existant.lock(); + if j.status == StatutRecherche::Running { + return j.clone(); + } + if !force && j.status == StatutRecherche::Success && j.result.is_some() { + return j.clone(); + } + } + + if jobs_en_cours(&jobs) >= MAX_RECHERCHES_SIMULTANEES { + let maintenant = maintenant_iso(); + return RechercheJob { + job_id: format!("refus_{}", horodatage_base36()), + terme: cleaned, + status: StatutRecherche::Error, + etape: "Refusé".into(), + started_at: maintenant.clone(), + finished_at: Some(maintenant), + error: Some("Trop de recherches Qlik en cours. Réessayez dans quelques instants.".into()), + result: None, + termine: Some(Instant::now()), + }; + } + + let job = RechercheJob { + job_id: format!("rech_{}_{}", horodatage_base36(), alea_base36()), + terme: cleaned.clone(), + status: StatutRecherche::Running, + etape: "Recherche des articles dans Qlik…".into(), + started_at: maintenant_iso(), + finished_at: None, + error: None, + result: None, + termine: None, + }; + let partage: JobPartage = Arc::new(Mutex::new(job.clone())); + jobs.insert(key.clone(), partage.clone()); + drop(jobs); + tracing::info!("[produits/search] job={} démarré pour \"{cleaned}\"", job.job_id); + + lancer(partage, cleaned, key); + job +} + +/// Fire-and-forget : le client suivra l'avancement via [`etat_recherche`]. +/// Filet de sécurité : une panique de la recherche ne doit jamais laisser le +/// job « running » (il bloquerait toutes les recherches suivantes). +fn lancer(job: JobPartage, terme: String, key: String) { + let suivi = job.clone(); + let tache = tokio::spawn(async move { + let etape = suivi.clone(); + executer_recherche(&terme, move |e| etape.lock().etape = e.to_string()).await + }); + tokio::spawn(async move { + match tache.await { + Ok(result) => { + let result = Arc::new(result); + let (job_id, n, duree) = { + let mut j = job.lock(); + j.result = Some(result.clone()); + j.status = StatutRecherche::Success; + j.etape = "Terminé".into(); + j.finished_at = Some(maintenant_iso()); + j.termine = Some(Instant::now()); + (j.job_id.clone(), result.rows.len(), result.duree_ms) + }; + // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un + // repli catalogue (panne Qlik passagère) le figerait 10 minutes + // alors qu'un simple « Relancer » aurait suffi. + if result.source == SourceRecherche::Qlik && result.qlik_error.is_none() { + CACHE.lock().insert(key, (Instant::now(), result)); + } + tracing::info!("[produits/search] job={job_id} terminé — {n} produit(s) en {duree} ms"); + } + Err(e) => { + let message = e.to_string(); + let job_id = { + let mut j = job.lock(); + j.status = StatutRecherche::Error; + j.etape = "Échec".into(); + j.error = Some(message_qlik_lisible(&message)); + j.finished_at = Some(maintenant_iso()); + j.termine = Some(Instant::now()); + j.job_id.clone() + }; + tracing::error!("[produits/search] job={job_id} échec : {}", slice_js(&message, 300)); + } + } + }); +} + +/// État courant de la recherche pour ce terme (pour le polling client). +pub fn etat_recherche(term: &str) -> RechercheEtat { + let cleaned = js_trim(term).to_string(); + let key = cache_key(&cleaned); + let mut jobs = JOBS.lock(); + purge(&mut jobs); + if let Some(job) = jobs.get(&key) { + return RechercheEtat::Job(job.lock().clone()); + } + drop(jobs); + let hit = CACHE.lock().get(&key).map(|(_, r)| r.clone()); + if let Some(result) = hit { + return RechercheEtat::Job(job_termine(&cleaned, result)); + } + RechercheEtat::Idle { terme: cleaned } +} + +/// `a || b` sur des chaînes (une chaîne vide est « fausse »). +fn ou<'a>(a: &'a str, b: &'a str) -> &'a str { + if a.is_empty() { + b + } else { + a + } +} + +async fn executer_recherche(term: &str, on_etape: impl Fn(&str)) -> ProduitRechercheResultat { + let started = Instant::now(); + let duree_ms = || started.elapsed().as_millis() as u64; + + // La recherche catalogue local tourne en parallèle : elle sert à la fois de + // repli si Qlik échoue, et de complément (produits Nancy sans code centrale, + // donc invisibles côté réseau). + let terme_local = term.to_string(); + let local = tokio::spawn(async move { pg_search_produits(&terme_local, LIMITE_RECHERCHE_LOCALE).await }); + let locaux = move || async move { + local.await.unwrap_or_else(|e| { + tracing::error!("[produits/search] recherche locale échouée: {e}"); + Vec::::new() + }) + }; + + on_etape("Recherche des articles dans Qlik…"); + let qlik = match search_qlik_articles(term, Some(QLIK_SEARCH_MAX_RESULTS), None).await { + Ok(q) => q, + Err(e) => { + let message = format!("{e:#}"); + tracing::error!("[produits/search] Qlik indisponible, repli catalogue local: {message}"); + let locaux = locaux().await; + return ProduitRechercheResultat { + rows: locaux.iter().map(row_depuis_catalogue_seul).collect(), + source: SourceRecherche::Db, + qlik_error: Some(message_qlik_lisible(&message)), + tronque: locaux.len() as i64 >= LIMITE_RECHERCHE_LOCALE, + champ_utilise: None, + locaux_hors_reseau: vec![], + duree_ms: duree_ms(), + }; + } + }; + + let codes: Vec = qlik.matches.iter().map(|m| m.code_centrale.clone()).collect(); + if codes.is_empty() { + let locaux = locaux().await; + return ProduitRechercheResultat { + rows: vec![], + source: SourceRecherche::Qlik, + // `avertissement` n'est renseigné que si Qlik a répondu sans appliquer + // le filtre : l'utilisateur doit savoir que ce n'est pas « pas de + // produit » mais « recherche non appliquée ». + qlik_error: qlik.avertissement, + tronque: false, + champ_utilise: qlik.champ_utilise, + locaux_hors_reseau: locaux, + duree_ms: duree_ms(), + }; + } + + // Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik). + // Les extraire dans une seconde session concurrente faisait abandonner le + // moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la + // recherche alors que la sync de la Grille, elle seule sur le serveur, + // fonctionnait. + // + // Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit + // dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche + // produit propose son bouton « Actualiser depuis Qlik ». + on_etape("Rapprochement avec notre catalogue…"); + // Deux clés candidates : la dimension « Article Code » et le champ + // `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et + // une seule joint avec `articles.artcentrale`. + let candidats: Vec = codes + .iter() + .cloned() + .chain(qlik.matches.iter().map(|m| m.code_centrale_alt.clone()).filter(|c| !c.is_empty())) + .collect(); + let (cache_reseau, catalogue, locaux) = tokio::join!( + async { + get_network_metrics_by_code_centrale(&codes).await.unwrap_or_else(|e| { + tracing::error!("[produits/search] lecture du cache réseau échouée: {e:#}"); + HashMap::new() + }) + }, + pg_get_produits_by_code_centrale(&candidats), + locaux(), + ); + + let now = today(); + let periode = qlik.periode.as_deref(); + + // Le cube de recherche utilise encore les master measures annuelles pour + // classer rapidement les résultats. Il ne doit jamais écraser une extraction + // 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec + // leur mensuel. Sans cache complet, les valeurs de découverte restent + // temporaires jusqu'au bouton « Actualiser depuis Qlik ». + let a_persister: Vec = qlik + .matches + .iter() + .map(|m| { + let cache = cache_reseau.get(&m.code_centrale); + let fiable = cache.filter(|c| cache_reseau_complet(Some(c), periode, now)); + NetworkMetric { + code_centrale: m.code_centrale.clone(), + ca_reseau: fiable.map_or(m.ca_reseau, |c| c.ca_reseau), + qte_reseau: fiable.map_or(m.qte_reseau, |c| c.qte_reseau), + nb_magasins_reseau: fiable.map_or(m.nb_magasins_reseau, |c| c.nb_magasins_reseau), + ca_par_magasin_reseau: fiable.map_or(m.ca_par_magasin_reseau, |c| c.ca_par_magasin_reseau), + marge_pct_reseau: fiable.map_or(m.marge_pct_reseau, |c| c.marge_pct_reseau), + periode: match fiable { + Some(c) => c.periode.clone(), + None => qlik.periode.clone(), + }, + qte_by_month: fiable.and_then(|c| c.qte_by_month.clone()), + metrics_by_month: fiable.and_then(|c| c.metrics_by_month.clone()), + libelle_reseau: Some(m.libelle.clone()).filter(|s| !s.is_empty()), + fournisseur_reseau: Some(m.fournisseur.clone()).filter(|s| !s.is_empty()), + } + }) + .collect(); + match upsert_network_metrics(&a_persister).await { + Ok(n) => tracing::info!("[produits/search] {n} ligne(s) réseau mises en cache"), + Err(e) => tracing::error!("[produits/search] mise en cache des mesures échouée: {e:#}"), + } + + let mut rows: Vec = qlik + .matches + .iter() + .map(|m| { + let local = catalogue + .get(&m.code_centrale) + .or_else(|| if m.code_centrale_alt.is_empty() { None } else { catalogue.get(&m.code_centrale_alt) }); + let cache = cache_reseau.get(&m.code_centrale); + let fiable = cache.filter(|c| cache_reseau_complet(Some(c), periode, now)); + let qte_reseau = fiable.map_or(m.qte_reseau, |c| c.qte_reseau); + let ca_reseau = fiable.map_or(m.ca_reseau, |c| c.ca_reseau); + let nb_mag = fiable.map_or(m.nb_magasins_reseau, |c| c.nb_magasins_reseau); + let ca_par_magasin = fiable.map_or(m.ca_par_magasin_reseau, |c| c.ca_par_magasin_reseau); + let marge_pct = fiable.map_or(m.marge_pct_reseau, |c| c.marge_pct_reseau); + ProduitRechercheRow { + 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(), + 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 }), + qte_reseau, + ca_reseau, + nb_magasins_reseau: nb_mag, + taux_presence: nb_mag / NB_MAGASINS_RESEAU, + qte_par_magasin_reseau: if nb_mag > 0.0 { qte_reseau / nb_mag } else { 0.0 }, + ca_par_magasin_reseau: ca_par_magasin, + prix_moyen_reseau: if qte_reseau > 0.0 { Some(ca_reseau / qte_reseau) } else { None }, + marge_pct_reseau: normalize_marge_pct(Some(marge_pct)), + // Le mensuel ne vient que du cache : le cube de recherche est agrégé. + qte_by_month: fiable.and_then(|c| c.qte_by_month.clone()), + nb_mag_by_month: fiable.and_then(|c| nb_mag_par_mois(c.metrics_by_month.as_ref())), + periode: qlik.periode.clone(), + } + }) + .collect(); + + // Les plus vendus du réseau d'abord : c'est l'information utile pour + // arbitrer, un produit que le réseau ne vend pas n'intéresse personne. + trier_lignes(&mut rows); + + // Les produits Nancy déjà couverts par un résultat réseau ne doivent pas + // réapparaître dans la liste secondaire. On compare sur les deux clés + // candidates, la jointure pouvant se faire par l'une ou par l'autre. + let mut codes_trouves: HashSet<&str> = codes.iter().map(|c| c.as_str()).collect(); + for m in &qlik.matches { + if !m.code_centrale_alt.is_empty() { + codes_trouves.insert(m.code_centrale_alt.as_str()); + } + } + let locaux_hors_reseau: Vec = + locaux.into_iter().filter(|l| l.code_centrale.is_empty() || !codes_trouves.contains(l.code_centrale.as_str())).collect(); + + // Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format + // de clé différent entre Qlik et `articles.artcentrale`. + if catalogue.is_empty() && !codes.is_empty() { + let echantillon: Vec<&String> = codes.iter().take(3).collect(); + let alt: Vec<&String> = qlik.matches.iter().take(3).map(|m| &m.code_centrale_alt).collect(); + tracing::warn!( + "[produits/search] aucun des {} codes Qlik ne joint avec articles.artcentrale — échantillon Article Code={} article_no_centrale={}", + codes.len(), + serde_json::to_string(&echantillon).unwrap_or_default(), + serde_json::to_string(&alt).unwrap_or_default(), + ); + } + + ProduitRechercheResultat { + rows, + source: SourceRecherche::Qlik, + qlik_error: qlik.avertissement, + tronque: qlik.tronque, + champ_utilise: qlik.champ_utilise, + locaux_hors_reseau, + duree_ms: duree_ms(), + } +} + +/// `rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr"))`. +/// +/// Une quantité `NaN` (mesure Qlik absente) rendait le comparateur JS +/// incohérent ; ici elle est classée en dernier pour garder un ordre total +/// (le tri Rust l'exige). +fn trier_lignes(rows: &mut [ProduitRechercheRow]) { + let qte = |r: &ProduitRechercheRow| if r.qte_reseau.is_nan() { f64::NEG_INFINITY } else { r.qte_reseau + 0.0 }; + rows.sort_by(|a, b| qte(b).total_cmp(&qte(a)).then_with(|| compare_fr(&a.libelle, &b.libelle))); +} + +/// Ligne de repli : catalogue Nancy seul, sans données réseau fiables. +fn row_depuis_catalogue_seul(r: &PgProduitSearchRow) -> ProduitRechercheRow { + let qte_reseau = if r.qte_reseau.is_nan() { 0.0 } else { r.qte_reseau }; + let nb_mag = r.nb_magasins_reseau as f64; + ProduitRechercheRow { + code_centrale: r.code_centrale.clone(), + libelle: ou(&r.libelle1, &r.codein).to_string(), + libelle_reseau: String::new(), + fournisseur: r.fournisseur.clone(), + codein: Some(r.codein.clone()), + nomenclature: r.nomenclature.clone(), + stock_local: Some(if r.stock_total.is_nan() { 0.0 } else { r.stock_total }), + qte_reseau, + ca_reseau: 0.0, + nb_magasins_reseau: nb_mag, + taux_presence: nb_mag / NB_MAGASINS_RESEAU, + qte_par_magasin_reseau: if nb_mag > 0.0 { qte_reseau / nb_mag } else { 0.0 }, + ca_par_magasin_reseau: 0.0, + prix_moyen_reseau: None, + marge_pct_reseau: None, + qte_by_month: None, + nb_mag_by_month: None, + periode: None, + } +} + +/// Traduit les pannes Qlik récurrentes en message actionnable. Les autres +/// erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables. +pub fn message_qlik_lisible(message: &str) -> String { + static CODE_15: LazyLock = LazyLock::new(|| regex::Regex::new(r#""code"\s*:\s*15\b"#).expect("regex")); + let lower = message.to_lowercase(); + if lower.contains("out of memory") || lower.contains("not enough memory") || lower.contains("\"code\":6") || lower.contains("\"code\":3002") + { + return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard.".into(); + } + if CODE_15.is_match(message) || lower.contains("request aborted") { + return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez.".into(); + } + if lower.contains("identifiants qlik manquants") || lower.contains("qlik_user") { + return "Identifiants Qlik non configurés (Paramètres → Qlik).".into(); + } + if lower.contains("timeout") || lower.contains("econnrefused") || lower.contains("enotfound") { + return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN).".into(); + } + slice_js(message, 300) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cache_complet(now: NaiveDate, periode: &str) -> NetworkMetricCached { + NetworkMetricCached { + qte_by_month: Some(build_rolling12_qlik_months(now).into_iter().map(|m| (m, 1.0)).collect()), + periode: Some(periode.into()), + ..Default::default() + } + } + + #[test] + fn cle_de_cache_normalisee() { + assert_eq!(cache_key(" Tapis ANTI\tpoussière "), "tapis anti poussière"); + } + + #[test] + fn cache_reseau_complet_exige_periode_et_12_mois() { + let now = NaiveDate::from_ymd_opt(2026, 3, 10).unwrap(); + let c = cache_complet(now, "2025-03_2026-02"); + assert!(cache_reseau_complet(Some(&c), Some("2025-03_2026-02"), now)); + // Période différente, absente, ou cache absent. + assert!(!cache_reseau_complet(Some(&c), Some("2025-02_2026-01"), now)); + assert!(!cache_reseau_complet(Some(&c), None, now)); + assert!(!cache_reseau_complet(None, Some("2025-03_2026-02"), now)); + // Un mois manquant ou non fini. + let mut trou = c.clone(); + trou.qte_by_month.as_mut().unwrap().remove("2025-07"); + assert!(!cache_reseau_complet(Some(&trou), Some("2025-03_2026-02"), now)); + let mut nan = c.clone(); + nan.qte_by_month.as_mut().unwrap().insert("2025-07".into(), f64::NAN); + assert!(!cache_reseau_complet(Some(&nan), Some("2025-03_2026-02"), now)); + // Sans qteByMonth. + let mut vide = c; + vide.qte_by_month = None; + assert!(!cache_reseau_complet(Some(&vide), Some("2025-03_2026-02"), now)); + } + + #[test] + fn nb_mag_par_mois_zero_si_absent() { + let mut m = BTreeMap::new(); + m.insert("2025-03".to_string(), QlikMonthMetrics { qte: 4.0, nb_mag: Some(3.0), ..Default::default() }); + m.insert("2025-04".to_string(), QlikMonthMetrics { qte: 0.0, nb_mag: None, ..Default::default() }); + let r = nb_mag_par_mois(Some(&m)).unwrap(); + assert_eq!(r["2025-03"], 3.0); + assert_eq!(r["2025-04"], 0.0); + assert_eq!(nb_mag_par_mois(Some(&BTreeMap::new())), None); + assert_eq!(nb_mag_par_mois(None), None); + } + + #[test] + fn messages_qlik_lisibles() { + assert!(message_qlik_lisible("Engine: Out of memory").starts_with("Serveur Qlik saturé")); + assert!(message_qlik_lisible(r#"{"code":3002}"#).starts_with("Serveur Qlik saturé")); + assert!(message_qlik_lisible(r#"{"code": 15, "message":"x"}"#).starts_with("Qlik a interrompu")); + assert!(message_qlik_lisible("Request aborted").starts_with("Qlik a interrompu")); + assert!(message_qlik_lisible("Identifiants Qlik manquants").starts_with("Identifiants Qlik non configurés")); + assert!(message_qlik_lisible("connect ECONNREFUSED 10.0.0.1").starts_with("Serveur Qlik injoignable")); + // `"code":150` n'est pas le code 15. + assert_eq!(message_qlik_lisible(r#"{"code":150}"#), r#"{"code":150}"#); + let long = "é".repeat(400); + assert_eq!(message_qlik_lisible(&long).chars().count(), 300); + } + + #[test] + fn tri_par_quantite_reseau_puis_libelle() { + let ligne = |libelle: &str, qte: f64| ProduitRechercheRow { libelle: libelle.into(), qte_reseau: qte, ..Default::default() }; + let mut rows = vec![ligne("b", 5.0), ligne("Éa", 10.0), ligne("a", 10.0), ligne("z", f64::NAN), ligne("c", 0.0)]; + trier_lignes(&mut rows); + let ordre: Vec<&str> = rows.iter().map(|r| r.libelle.as_str()).collect(); + assert_eq!(ordre, vec!["a", "Éa", "b", "c", "z"]); + } + + #[test] + fn ligne_catalogue_seul() { + let r = PgProduitSearchRow { + codein: "123".into(), + code_centrale: "10000167303".into(), + libelle1: String::new(), + fournisseur: "ACME".into(), + nomenclature: "Cuisine".into(), + stock_total: 7.0, + qte_reseau: 54.0, + nb_magasins_reseau: 27, + ..Default::default() + }; + let v = serde_json::to_value(row_depuis_catalogue_seul(&r)).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "codeCentrale": "10000167303", + "libelle": "123", + "libelleReseau": "", + "fournisseur": "ACME", + "codein": "123", + "nomenclature": "Cuisine", + "stockLocal": 7, + "qteReseau": 54, + "caReseau": 0, + "nbMagasinsReseau": 27, + "tauxPresence": 0.1, + "qteParMagasinReseau": 2, + "caParMagasinReseau": 0, + "prixMoyenReseau": null, + "margePctReseau": null, + "qteByMonth": null, + "nbMagByMonth": null, + "periode": null, + }) + ); + } + + #[test] + fn etat_idle_et_job_serialises() { + let idle = RechercheEtat::Idle { terme: "poêle".into() }.reponse(); + assert_eq!(idle, serde_json::json!({ "success": true, "status": "idle", "terme": "poêle" })); + + let job = RechercheJob { + job_id: "rech_x".into(), + terme: "poêle".into(), + status: StatutRecherche::Running, + etape: "Recherche des articles dans Qlik…".into(), + started_at: "2026-01-01T00:00:00.000Z".into(), + finished_at: None, + error: None, + result: None, + termine: None, + }; + let v = RechercheEtat::Job(job).reponse(); + assert_eq!( + v, + serde_json::json!({ + "success": true, + "jobId": "rech_x", + "terme": "poêle", + "status": "running", + "etape": "Recherche des articles dans Qlik…", + "startedAt": "2026-01-01T00:00:00.000Z", + }) + ); + } + + #[test] + fn base36_comme_js() { + assert_eq!(base36(0), "0"); + assert_eq!(base36(35), "z"); + assert_eq!(base36(36), "10"); + assert_eq!(alea_base36().len(), 6); + } +} diff --git a/server/src/produits/types.rs b/server/src/produits/types.rs new file mode 100644 index 0000000..c499080 --- /dev/null +++ b/server/src/produits/types.rs @@ -0,0 +1,169 @@ +//! CollectFlow — Types de la fiche produit (page /produits) — port de +//! `src/features/produits/types.ts`. +//! +//! Les formes JSON doivent rester **identiques** à la copie client +//! (`src/features/produits/types.ts`) : noms de champs camelCase, `null` +//! explicites là où le TS déclare `T | null`. + +use crate::ff::api::DateRange; +use crate::ff::pg::{PgGammeHistoryRow, PgProduitDetailRow, PgProduitFournisseurRow, PgProduitSearchRow, PgProduitStockRow}; +use crate::qlik::jsnum; +use crate::qlik::NetworkMetricCached; +use serde::Serialize; +use std::collections::BTreeMap; + +/// Une ligne de résultat de recherche produit. +/// +/// La recherche part de **Qlik** : chaque ligne est d'abord un produit du réseau +/// (identifié par son code centrale), éventuellement rapproché du catalogue FF +/// Nancy. `codein === null` = le réseau travaille ce produit, nous ne le +/// référençons pas. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitRechercheRow { + /// Dimension Qlik « Article Code » = `articles.artcentrale`. + pub code_centrale: String, + /// Libellé affiché : catalogue Nancy si référencé, sinon libellé réseau. + pub libelle: String, + /// Libellé tel que Qlik le renvoie (vide si l'app n'expose pas de champ libellé). + pub libelle_reseau: String, + /// Fournisseur : catalogue Nancy prioritaire (nom réel), sinon champ Qlik. + pub fournisseur: String, + /// `null` si le produit n'est pas au catalogue Nancy. + pub codein: Option, + pub nomenclature: String, + /// Stock Nancy tous sites, `null` si produit non référencé. + #[serde(serialize_with = "jsnum::ser_opt")] + pub stock_local: Option, + + // ─── Réseau Qlik, sur 12 mois glissants ───────────────────────────────── + #[serde(serialize_with = "jsnum::ser")] + pub qte_reseau: f64, + #[serde(serialize_with = "jsnum::ser")] + pub ca_reseau: f64, + #[serde(serialize_with = "jsnum::ser")] + pub nb_magasins_reseau: f64, + /// Part des magasins du réseau travaillant le produit (0..1). + #[serde(serialize_with = "jsnum::ser")] + pub taux_presence: f64, + /// Quantité vendue par magasin qui travaille le produit. + #[serde(serialize_with = "jsnum::ser")] + pub qte_par_magasin_reseau: f64, + #[serde(serialize_with = "jsnum::ser")] + pub ca_par_magasin_reseau: f64, + /// CA réseau / quantité réseau — panier moyen unitaire, `null` si pas de vente. + #[serde(serialize_with = "jsnum::ser_opt")] + pub prix_moyen_reseau: Option, + /// Taux de marge réseau en points de % (déjà normalisé), `null` si absent. + #[serde(serialize_with = "jsnum::ser_opt")] + pub marge_pct_reseau: Option, + /// Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. + #[serde(serialize_with = "jsnum::ser_map")] + pub qte_by_month: Option>, + /// Magasins vendeurs par mois `"YYYY-MM"`. La tendance porte sur la + /// qté/magasin : sans cette série, elle se rabat sur les quantités brutes. + #[serde(serialize_with = "jsnum::ser_map")] + pub nb_mag_by_month: Option>, + pub periode: Option, +} + +/// Source d'un résultat de recherche. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SourceRecherche { + /// Recherche nominale. + #[default] + Qlik, + /// Repli catalogue local (Qlik indisponible). + Db, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitRechercheResultat { + pub rows: Vec, + /// `qlik` = recherche nominale ; `db` = repli catalogue local (Qlik indisponible). + pub source: SourceRecherche, + /// Message d'erreur Qlik quand on a dû se rabattre sur la base locale. + pub qlik_error: Option, + /// true si Qlik a renvoyé plus d'articles que la limite affichée. + pub tronque: bool, + /// Champ Qlik réellement interrogé (diagnostic). + pub champ_utilise: Option, + /// Produits du catalogue Nancy qui matchent le terme mais qu'aucun article + /// Qlik ne couvre (pas de code centrale, ou non vendus par le réseau). + pub locaux_hors_reseau: Vec, + pub duree_ms: u64, +} + +/// Les métriques d'un mois, pour un périmètre donné (total ou un site). +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitMois { + /// Clé de mois FF, format "YYYYMM". + pub mois: String, + #[serde(serialize_with = "jsnum::ser")] + pub qte: f64, + /// Issu de mntmvtttc — TTC malgré le nom historique `ca_ht`. + #[serde(serialize_with = "jsnum::ser")] + pub ca: f64, + #[serde(serialize_with = "jsnum::ser")] + pub marge: f64, + #[serde(serialize_with = "jsnum::ser")] + pub stock_fin_mois: f64, + #[serde(serialize_with = "jsnum::ser")] + pub qte_recue: f64, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitTotaux { + #[serde(serialize_with = "jsnum::ser")] + pub qte: f64, + #[serde(serialize_with = "jsnum::ser")] + pub ca: f64, + #[serde(serialize_with = "jsnum::ser")] + pub marge: f64, + /// Taux de marge en % (marge / CA × 100). + #[serde(serialize_with = "jsnum::ser")] + pub taux_marge: f64, +} + +/// Série mensuelle d'un périmètre : mois "YYYYMM" → métriques. +/// +/// `BTreeMap` : les clés "YYYYMM" sont des indices de tableau pour JS, qui les +/// énumère par ordre croissant — même ordre que le tri lexicographique ici. +pub type SerieMensuelle = BTreeMap; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProduitFiche { + /// Identité catalogue FF Nancy — `null` pour un produit que le réseau + /// travaille mais que Nancy ne référence pas (fiche 100 % réseau). + pub detail: Option, + /// Code centrale = clé Qlik. Toujours renseigné pour une fiche ouverte depuis la recherche. + pub code_centrale: String, + /// Libellé de l'article côté Qlik (peut être vide selon l'app Qlik). + pub libelle_reseau: String, + /// Fournisseur de l'article côté Qlik (peut être vide selon l'app Qlik). + pub fournisseur_reseau: String, + pub fournisseurs: Vec, + pub stock: Vec, + #[serde(serialize_with = "jsnum::ser")] + pub commandes_en_cours: f64, + pub gammes: Vec, + /// Les 12 clés de mois "YYYYMM", du plus ancien au plus récent. + pub months: Vec, + /// Cumul tous sites, par mois. + pub mensuel_total: SerieMensuelle, + /// site → mois → métriques. + pub mensuel_par_site: BTreeMap, + pub totaux: ProduitTotaux, + pub totaux_par_site: BTreeMap, + /// Sites ayant vendu au moins une unité sur les 12 mois. + pub sites_actifs: Vec, + /// Métriques réseau Qlik, `null` si le produit n'est pas encore en cache. + pub reseau: Option, + /// Fenêtre analysée, pour l'affichage. + pub periode: DateRange, +}