mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 18:27:40 +02:00
Fusion du module Produits (recherche, fiche, opportunités)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
6 files changed
+1463
-2
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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(¶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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user