Portage du module Produits : recherche Qlik d'abord, fiche produit, opportunités

- /api/produits/search (POST démarre un job, GET suit l'avancement) : Qlik
  d'abord, repli catalogue FF (source: "db"), cache mémoire 10 min des seuls
  résultats Qlik exploitables, une recherche Qlik à la fois.
- /api/produits/opportunites : produits de la même famille, local vs réseau.
- Page produit-fiche (?codein= ou ?cc=) : getProduitFiche /
  getProduitFicheByCodeCentrale, report du stock de fin de mois.
- normalizeMargePct (compare-reseau) et types JSON identiques au client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 19:56:31 +00:00
1 parent 5a84faffb2
commit f232673138
6 files changed
+1463 -2

No files matched your search

+42
View File
@@ -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<f64>) -> Option<f64> {
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);
}
}
+329
View File
@@ -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<String> {
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<Item = &'a ProduitMois>) -> 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<String, SerieMensuelle>,
pub totaux: ProduitTotaux,
pub totaux_par_site: BTreeMap<String, ProduitTotaux>,
pub sites_actifs: Vec<String>,
}
/// 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<String, SerieMensuelle> = 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<String> = HashSet::new();
let mut mois_avec_donnees_par_site: HashMap<String, HashSet<String>> = 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<String>>| {
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<String, ProduitTotaux> =
mensuel_par_site.iter().map(|(site, by_site)| (site.clone(), totaux_serie(months, by_site))).collect();
let mut sites_actifs: Vec<String> = 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<Option<ProduitFiche>> {
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<Option<ProduitFiche>> {
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<f64>, 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<String> {
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<_>>(), 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 }));
}
}
+19 -2
View File
@@ -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))]
}
+132
View File
@@ -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<String, String>) -> Option<String> {
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=<terme>[&force=1]` démarre la recherche et rend la
/// main tout de suite. `GET /api/produits/search?q=<terme>` 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<HashMap<String, String>>) -> AppResult<Json<Value>> {
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=<terme>` — état de la recherche (polling client).
pub async fn search_get(_session: SessionUser, Query(q): Query<HashMap<String, String>>) -> AppResult<Json<Value>> {
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<HashMap<String, String>>) -> AppResult<Json<Value>> {
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<Value> {
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<String, String> {
HashMap::from([("q".to_string(), q.to_string())])
}
#[test]
fn terme_minimum_3_caracteres() {
assert_eq!(lire_terme(&params(" ab ")), None);
assert_eq!(lire_terme(&HashMap::new()), None);
assert_eq!(lire_terme(&params(" 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"));
}
}
+772
View File
@@ -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<String, QlikMonthMetrics>>) -> Option<BTreeMap<String, f64>> {
let metrics_by_month = metrics_by_month?;
let par_mois: BTreeMap<String, f64> = 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<Mutex<HashMap<String, EntreeCache>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
/// Résultat en cache et instant de sa mise en cache.
type EntreeCache = (Instant, Arc<ProduitRechercheResultat>);
/// 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<Mutex<HashMap<String, JobPartage>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
type JobPartage = Arc<Mutex<RechercheJob>>;
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Arc<ProduitRechercheResultat>>,
/// Instant de fin (purge), non sérialisé.
#[serde(skip)]
termine: Option<Instant>,
}
/// É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::<u8>() 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<String, JobPartage>) {
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<String, JobPartage>) -> usize {
jobs.values().filter(|j| j.lock().status == StatutRecherche::Running).count()
}
fn job_termine(terme: &str, result: Arc<ProduitRechercheResultat>) -> 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::<PgProduitSearchRow>::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<String> = 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<String> = 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<NetworkMetric> = 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<ProduitRechercheRow> = 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<PgProduitSearchRow> =
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<regex::Regex> = 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);
}
}
+169
View File
@@ -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<String>,
pub nomenclature: String,
/// Stock Nancy tous sites, `null` si produit non référencé.
#[serde(serialize_with = "jsnum::ser_opt")]
pub stock_local: Option<f64>,
// ─── 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<f64>,
/// 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<f64>,
/// Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance.
#[serde(serialize_with = "jsnum::ser_map")]
pub qte_by_month: Option<BTreeMap<String, f64>>,
/// 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<BTreeMap<String, f64>>,
pub periode: Option<String>,
}
/// 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<ProduitRechercheRow>,
/// `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<String>,
/// 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<String>,
/// 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<PgProduitSearchRow>,
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<String, ProduitMois>;
#[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<PgProduitDetailRow>,
/// 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<PgProduitFournisseurRow>,
pub stock: Vec<PgProduitStockRow>,
#[serde(serialize_with = "jsnum::ser")]
pub commandes_en_cours: f64,
pub gammes: Vec<PgGammeHistoryRow>,
/// Les 12 clés de mois "YYYYMM", du plus ancien au plus récent.
pub months: Vec<String>,
/// Cumul tous sites, par mois.
pub mensuel_total: SerieMensuelle,
/// site → mois → métriques.
pub mensuel_par_site: BTreeMap<String, SerieMensuelle>,
pub totaux: ProduitTotaux,
pub totaux_par_site: BTreeMap<String, ProduitTotaux>,
/// Sites ayant vendu au moins une unité sur les 12 mois.
pub sites_actifs: Vec<String>,
/// Métriques réseau Qlik, `null` si le produit n'est pas encore en cache.
pub reseau: Option<NetworkMetricCached>,
/// Fenêtre analysée, pour l'affichage.
pub periode: DateRange,
}