From 7dd5919264adcd6afc7585ec39c5cc9cb8a77197 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:24:28 +0000 Subject: [PATCH] Serveur Rust : portage du module FF Nancy (base miroir, API, routes, actions, pages) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ff::pg : portage complet de pg-ff-client.ts (mêmes requêtes SQL, fragments dynamiques via QueryBuilder, lectures tolérantes, caches process de la colonne parent de nomenclature et du lien mvtart → fournisseur). - ff::api : client de l'API FF Nancy (URL résolue Paramètres → env → défaut, mémorisée 30 s ; délai FF_API_TIMEOUT_MS), pagination, mensuel, référentiel, commandes, normalisation du statut de synchro. - ff::cache : getFournisseursCached / getDerniereReceptionCached. - ff::diagnostic : diagnostiquer_api_ff (DNS, refus, délai, code HTTP). - Routes /api/ff-status, /api/diag, /api/diag/stock-fournisseur, /api/test-db, /api/commandes-auto ; actions de la Grille et du cadencier ; pages dashboard, analytics, hit-parade, stock-negatif, commandes-auto, publicites. - Test d'intégration (FF_TEST_DATABASE_URL) avec fausse API FF : toutes les requêtes et tous les appels HTTP joués sur une base de test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/ff/actions.rs | 311 ++++ server/src/ff/api.rs | 714 ++++++++ server/src/ff/cache.rs | 34 + server/src/ff/diagnostic.rs | 149 ++ server/src/ff/fixture_tests.rs | 292 ++++ server/src/ff/js.rs | 172 ++ server/src/ff/mod.rs | 40 +- server/src/ff/pages.rs | 341 ++++ server/src/ff/pg.rs | 2988 +++++++++++++++++++++++++++++++- server/src/ff/routes.rs | 516 ++++++ 10 files changed, 5547 insertions(+), 10 deletions(-) create mode 100644 server/src/ff/actions.rs create mode 100644 server/src/ff/api.rs create mode 100644 server/src/ff/cache.rs create mode 100644 server/src/ff/diagnostic.rs create mode 100644 server/src/ff/fixture_tests.rs create mode 100644 server/src/ff/js.rs create mode 100644 server/src/ff/pages.rs create mode 100644 server/src/ff/routes.rs diff --git a/server/src/ff/actions.rs b/server/src/ff/actions.rs new file mode 100644 index 0000000..397bbb5 --- /dev/null +++ b/server/src/ff/actions.rs @@ -0,0 +1,311 @@ +//! Actions du module FF : `getFournisseurs`, `getMagasins` +//! (`src/features/grid/actions.ts`) et le cadencier de commande +//! (`src/features/commandes-auto/actions.ts`). + +use super::api::get_sites_from_api; +use super::cache::{get_derniere_reception_cached, get_fournisseurs_cached}; +use super::js::{js_number, message_erreur}; +use crate::db::pool; +use crate::error::AppResult; +use crate::rpc::{json, Ctx}; +use chrono::{DateTime, Local, Utc}; +use serde::Serialize; +use serde_json::Value; +use sqlx::Row; + +// --------------------------------------------------------------------------- +// Grille +// --------------------------------------------------------------------------- + +/// Liste de tous les fournisseurs (PostgreSQL FF). +pub async fn get_fournisseurs(ctx: Ctx) -> AppResult { + ctx.session().await?; + // Relu à chaque navigation dans la Grille (changement de fournisseur, de + // magasin…) : le référentiel ne change qu'avec la recopie nocturne. + json(get_fournisseurs_cached().await?) +} + +/// Liste des magasins (sites). +pub async fn get_magasins(ctx: Ctx) -> AppResult { + ctx.session().await?; + json(get_sites_from_api().await) +} + +// --------------------------------------------------------------------------- +// Cadencier +// --------------------------------------------------------------------------- + +/// Statut d'une cadence : "a_commander" | "bientot" | "ok" | "inconnu". +pub type CadenceStatut = &'static str; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CadenceView { + pub id: i32, + pub codefou: String, + pub nomfou: String, + pub site: String, + pub intervalle_semaines: i32, + pub actif: bool, + /// Date ISO de la dernière réception, ou null si inconnue + pub derniere_reception: Option, + /// Date ISO d'échéance (dernière réception + X semaines), ou null + pub echeance: Option, + /// Jours restants avant l'échéance (négatif = en retard), ou null + pub jours_restants: Option, + pub statut: CadenceStatut, +} + +const MS_PAR_JOUR: i64 = 24 * 60 * 60 * 1000; +/// Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander" +const SEUIL_BIENTOT_JOURS: i64 = 7; + +fn calc_statut(jours_restants: Option) -> CadenceStatut { + match jours_restants { + None => "inconnu", + Some(j) if j <= 0 => "a_commander", + Some(j) if j <= SEUIL_BIENTOT_JOURS => "bientot", + Some(_) => "ok", + } +} + +/// Liste des fournisseurs (référentiel) pour le sélecteur de cadence. +pub async fn get_fournisseurs_pour_cadence(ctx: Ctx) -> AppResult { + ctx.session().await?; + json(get_fournisseurs_cached().await?) +} + +struct CadenceRow { + id: i32, + code_fournisseur: String, + nom_fournisseur: Option, + site: String, + intervalle_semaines: i32, + actif: bool, +} + +/// Toutes les cadences enrichies du calcul d'échéance (sans contrôle d'accès). +/// Liste vide en cas d'erreur. +pub async fn lister_cadences() -> Vec { + let res = async { + let rows = sqlx::query( + "SELECT id, code_fournisseur, nom_fournisseur, site, intervalle_semaines, actif FROM commande_cadences", + ) + .fetch_all(&pool()?) + .await?; + let rows: Vec = rows + .iter() + .map(|r| CadenceRow { + id: r.try_get("id").unwrap_or_default(), + code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(), + nom_fournisseur: r.try_get("nom_fournisseur").unwrap_or_default(), + site: r.try_get("site").unwrap_or_default(), + intervalle_semaines: r.try_get("intervalle_semaines").unwrap_or_default(), + actif: r.try_get("actif").unwrap_or_default(), + }) + .collect(); + // Réceptions des seuls fournisseurs du cadencier : la lecture sur tout + // l'historique de tous les fournisseurs était la plus lourde de la page. + let codes: Vec = rows.iter().map(|r| r.code_fournisseur.clone()).collect(); + let receptions = get_derniere_reception_cached(&codes).await?; + anyhow::Ok((rows, receptions)) + } + .await; + let (rows, receptions) = match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[cadence] listCadences error: {}", message_erreur(&e)); + return vec![]; + } + }; + + let now = Local::now().with_timezone(&Utc).timestamp_millis(); + + rows.into_iter() + .map(|r| { + let reception_iso = receptions.get(&format!("{}|{}", r.code_fournisseur, r.site)).cloned(); + + let mut echeance_iso = None; + let mut jours_restants = None; + if let Some(iso) = reception_iso.as_deref() { + if let Ok(d) = DateTime::parse_from_rfc3339(iso) { + let echeance = d.timestamp_millis() + r.intervalle_semaines as i64 * 7 * MS_PAR_JOUR; + echeance_iso = DateTime::::from_timestamp_millis(echeance).map(crate::db::iso_utc); + jours_restants = Some(((echeance - now) as f64 / MS_PAR_JOUR as f64).ceil() as i64); + } + } + + CadenceView { + id: r.id, + nomfou: r.nom_fournisseur.clone().unwrap_or_else(|| r.code_fournisseur.clone()), + codefou: r.code_fournisseur, + site: r.site, + intervalle_semaines: r.intervalle_semaines, + actif: r.actif, + derniere_reception: reception_iso, + echeance: echeance_iso, + jours_restants, + statut: if r.actif { calc_statut(jours_restants) } else { "inconnu" }, + } + }) + .collect() +} + +/// Retourne toutes les cadences enrichies du calcul d'échéance à partir +/// de la dernière réception (FF Nancy). +pub async fn list_cadences(ctx: Ctx) -> AppResult { + ctx.session().await?; + json(lister_cadences().await) +} + +/// Réponse des actions d'écriture du cadencier. +#[derive(Debug, Clone, Serialize)] +pub struct ResultatCadence { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ResultatCadence { + fn ok() -> Self { + Self { ok: true, error: None } + } + fn ko(error: Option) -> Self { + Self { ok: false, error } + } +} + +/// `Math.round` (arrondi vers +∞ pour les demis). +fn math_round(x: f64) -> f64 { + (x + 0.5).floor() +} + +/// Crée ou met à jour la cadence d'un fournisseur sur un site. +/// Entrée : `{ codefou, nomfou?, site, intervalleSemaines, actif? }`. +pub async fn upsert_cadence(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.session().await { + return json(ResultatCadence::ko(Some(e.message))); + } + let input = ctx.raw(0); + if !input.is_object() { + return Err(crate::error::AppError::internal("Cannot read properties of undefined (reading 'codefou')")); + } + + let texte = |k: &str| input.get(k).and_then(|v| v.as_str()).map(|s| s.trim().to_string()); + let codefou = texte("codefou").unwrap_or_default(); + let site = texte("site").unwrap_or_default(); + let intervalle = math_round(js_number(input.get("intervalleSemaines").unwrap_or(&Value::Null))); + // `input.intervalleSemaines` absent → Number(undefined) = NaN. + let intervalle = if input.get("intervalleSemaines").is_none() { f64::NAN } else { intervalle }; + + if codefou.is_empty() || site.is_empty() { + return json(ResultatCadence::ko(Some("Fournisseur et magasin requis.".into()))); + } + if !intervalle.is_finite() || !(1.0..=104.0).contains(&intervalle) { + return json(ResultatCadence::ko(Some("L'intervalle doit être compris entre 1 et 104 semaines.".into()))); + } + let nomfou: Option = input.get("nomfou").and_then(|v| v.as_str()).map(|s| s.to_string()); + let actif = input.get("actif").and_then(|v| v.as_bool()).unwrap_or(true); + + let res = async { + sqlx::query( + r#"INSERT INTO commande_cadences (code_fournisseur, nom_fournisseur, site, intervalle_semaines, actif) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (code_fournisseur, site) DO UPDATE SET + nom_fournisseur = $2, + intervalle_semaines = $4, + actif = $5, + updated_at = $6"#, + ) + .bind(&codefou) + .bind(&nomfou) + .bind(&site) + .bind(intervalle as i32) + .bind(actif) + .bind(Local::now().naive_local()) + .execute(&pool()?) + .await?; + anyhow::Ok(()) + } + .await; + match res { + Ok(()) => json(ResultatCadence::ok()), + Err(e) => { + tracing::error!("[cadence] upsertCadence error: {}", message_erreur(&e)); + json(ResultatCadence::ko(Some("Échec de l'enregistrement.".into()))) + } + } +} + +/// Active / désactive une cadence sans la supprimer. +/// Arguments : `(codefou, site, actif)`. +pub async fn toggle_cadence(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.session().await { + return json(ResultatCadence::ko(Some(e.message))); + } + let codefou: String = ctx.arg(0)?; + let site: String = ctx.arg(1)?; + let actif: bool = ctx.arg(2)?; + + let res = async { + sqlx::query("UPDATE commande_cadences SET actif = $1, updated_at = $2 WHERE code_fournisseur = $3 AND site = $4") + .bind(actif) + .bind(Local::now().naive_local()) + .bind(&codefou) + .bind(&site) + .execute(&pool()?) + .await?; + anyhow::Ok(()) + } + .await; + match res { + Ok(()) => json(ResultatCadence::ok()), + Err(e) => { + tracing::error!("[cadence] toggleCadence error: {}", message_erreur(&e)); + json(ResultatCadence::ko(None)) + } + } +} + +/// Supprime définitivement une cadence. +/// Arguments : `(codefou, site)`. +pub async fn remove_cadence(ctx: Ctx) -> AppResult { + if let Err(e) = ctx.session().await { + return json(ResultatCadence::ko(Some(e.message))); + } + let codefou: String = ctx.arg(0)?; + let site: String = ctx.arg(1)?; + + let res = async { + sqlx::query("DELETE FROM commande_cadences WHERE code_fournisseur = $1 AND site = $2") + .bind(&codefou) + .bind(&site) + .execute(&pool()?) + .await?; + anyhow::Ok(()) + } + .await; + match res { + Ok(()) => json(ResultatCadence::ok()), + Err(e) => { + tracing::error!("[cadence] removeCadence error: {}", message_erreur(&e)); + json(ResultatCadence::ko(None)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn statuts() { + assert_eq!(calc_statut(None), "inconnu"); + assert_eq!(calc_statut(Some(0)), "a_commander"); + assert_eq!(calc_statut(Some(-3)), "a_commander"); + assert_eq!(calc_statut(Some(7)), "bientot"); + assert_eq!(calc_statut(Some(8)), "ok"); + assert_eq!(math_round(2.5), 3.0); + assert_eq!(math_round(-1.5), -1.0); + } +} diff --git a/server/src/ff/api.rs b/server/src/ff/api.rs new file mode 100644 index 0000000..99554bc --- /dev/null +++ b/server/src/ff/api.rs @@ -0,0 +1,714 @@ +//! CollectFlow — client de l'API FF Nancy (port de `src/lib/api-ff-client.ts`). +//! +//! Client HTTP centralisé pour l'API REST https://api.ffnancy.fr +//! Sync nuit SQL Server → données fraîches J-1 +//! +//! Stratégie mouvements : appeler SANS filtre genremvt (tous types) +//! - genremvt = 3 → alimenter sales12m (ventes) +//! - Tous types → lire qtestock pour reconstruire stock12m + +use super::js::{coalesce, encode, get, js_number, js_number_or0, js_string, message_erreur, opt_string, truthy}; +use chrono::{Datelike, Local, NaiveDate}; +use parking_lot::Mutex; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{LazyLock, OnceLock}; +use std::time::{Duration, Instant}; + +pub const FF_API_BASE_DEFAULT: &str = "https://api.ffnancy.fr"; +const FF_API_BASE_TTL: Duration = Duration::from_secs(30); + +/// Délai maximal d'un appel à l'API FF (`FF_API_TIMEOUT_MS`, 30 s par défaut). +/// Sans délai, une API muette figeait indéfiniment la page qui l'attendait. +pub fn ff_fetch_timeout() -> Duration { + let ms = std::env::var("FF_API_TIMEOUT_MS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| v.is_finite() && *v > 0.0) + .unwrap_or(30_000.0); + Duration::from_millis(ms as u64) +} + +/// Client HTTP de tout appel à l'API FF (équivalent de `ffFetchInit()`) : +/// jamais de cache HTTP, et un délai maximal. +pub fn ff_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(ff_fetch_timeout()) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} + +/// `fetch(url, ffFetchInit())`. +pub async fn ff_get(url: &str) -> reqwest::Result { + ff_client().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await +} + +/// Libellé HTTP d'un statut (équivalent de `res.statusText`). +pub fn status_text(status: reqwest::StatusCode) -> &'static str { + status.canonical_reason().unwrap_or("") +} + +static FF_API_BASE_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// URL de l'API FF Nancy, résolue **au moment de l'appel**. +/// +/// Ordre de priorité : réglage enregistré dans Paramètres → variable +/// d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était +/// figée au chargement du module depuis l'environnement seul : impossible de la +/// corriger depuis l'application quand le serveur changeait d'adresse. +/// +/// Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête. +pub async fn get_ff_api_base() -> String { + if let Some((value, at)) = FF_API_BASE_CACHE.lock().as_ref() { + if at.elapsed() < FF_API_BASE_TTL { + return value.clone(); + } + } + // Fichier absent ou illisible : on retombe sur l'environnement. + let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url) + .await + .ok() + .flatten() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let env = std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty()); + let value = configured + .or(env) + .unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string()) + .trim_end_matches('/') + .to_string(); + *FF_API_BASE_CACHE.lock() = Some((value.clone(), Instant::now())); + value +} + +/// Vide le cache — appelé après un enregistrement dans Paramètres. +pub fn reset_ff_api_base_cache() { + *FF_API_BASE_CACHE.lock() = None; +} + +// --------------------------------------------------------------------------- +// Types API (shapes des réponses brutes) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfFournisseur { + pub codefou: String, + pub nomfou: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfArticle { + pub codein: String, + pub libelle1: String, + pub codefou: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nomfou: Option, + /// conditionnement + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pcb: Option, + /// prix de vente central + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pv_central: Option, + /// SQL Server internal ID (pour endpoint /mensuel) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub noid: Option, + /// référence fournisseur (ref_fou_principale) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, + /// code-barres EAN/GTIN + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gtin: Option, + /// article actif + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actif: Option, + /// article suspendu + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suspendu: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfMouvement { + pub codein: String, + /// 3 = vente client + pub genremvt: f64, + /// ISO date (YYYY-MM-DD) + pub datemvt: String, + pub qte: f64, + /// montant HT + pub montant: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub marge: Option, + /// stock APRÈS ce mouvement — clé pour stock12m + pub qtestock: f64, + pub site: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codefou_reel: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nom_fournisseur_reel: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfStock { + pub codein: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub site: Option, + pub stockdispo: f64, + pub qte: f64, + pub valstock: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prmp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pv: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dernieresventes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dernierereception: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nbjoursdernsventes: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfCommande { + pub codein: String, + pub qtecde: f64, +} + +/// Une table de `/api/sync/status`, forme normalisée. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FfSyncTable { + pub nom: String, + pub derniere_sync: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nb_lignes: Option, + /// "ok" ou état d'erreur renvoyé par la synchronisation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub statut: Option, + #[serde(default)] + pub erreur: Option, +} + +/// Statut de synchronisation. `tables` reste en JSON brut : la forme déjà +/// attendue (`{ lastSync, tables }`) était renvoyée telle quelle par le TS. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FfSyncStatus { + pub last_sync: String, + pub tables: Vec, +} + +/// Normalise la réponse de `/api/sync/status`. +/// +/// L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`, +/// alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`. +/// Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une +/// coquille vide sans la moindre erreur, puisque la requête HTTP réussissait. +/// +/// Les deux formes sont acceptées ici, pour rester compatible si l'API évolue. +pub fn normalize_sync_status(raw: &Value) -> Option { + let d = raw.as_object()?; + + // Forme déjà attendue par l'application. + if let Some(Value::Array(tables)) = d.get("tables") { + let last = d.get("lastSync").filter(|v| !v.is_null()).map(js_string).unwrap_or_default(); + return Some(FfSyncStatus { last_sync: last, tables: tables.clone() }); + } + + // Forme réelle de l'API FF Nancy. + let sync = match d.get("sync") { + Some(Value::Array(a)) => a, + _ => return None, + }; + + let tables: Vec = sync + .iter() + .map(|t| FfSyncTable { + nom: coalesce(t, &["table_name", "nom"]).map(js_string).unwrap_or_else(|| "—".into()), + derniere_sync: coalesce(t, &["last_sync", "derniereSync"]).map(js_string).unwrap_or_default(), + nb_lignes: Some(coalesce(t, &["rows_synced", "nbLignes"]).map(js_number_or0).unwrap_or(0.0)), + statut: get(t, "status").filter(|v| !v.is_null()).map(js_string), + erreur: get(t, "error_msg").filter(|v| !v.is_null()).map(js_string), + }) + .collect(); + + // Pas de date globale dans la réponse : on prend la plus récente des tables. + let mut dates: Vec<&String> = tables.iter().map(|t| &t.derniere_sync).filter(|s| !s.is_empty()).collect(); + dates.sort(); + let last_sync = dates.last().map(|s| s.to_string()).unwrap_or_default(); + + let tables = tables.into_iter().map(|t| serde_json::to_value(t).unwrap_or(Value::Null)).collect(); + Some(FfSyncStatus { last_sync, tables }) +} + +/// Stock agrégé sur tous les sites pour un codein +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AggregatedStock { + /// sum stockdispo + pub stock_actuel: f64, + /// sum qte + pub stock_total: f64, + /// sum valstock + pub stock_valeur: f64, + /// prmp (premier site non-zéro) + pub pa: f64, + /// pv premier site + pub prix_vente: f64, + /// max date tous sites + pub derniere_vente: String, + /// max dernierereception tous sites + pub derniere_livraison: String, + /// min tous sites + pub nb_jours_derniere_vente: f64, +} + +// --------------------------------------------------------------------------- +// Pagination générique +// --------------------------------------------------------------------------- + +/// Liste contenue dans une réponse : tableau direct, ou enveloppe courante. +pub fn extract_list(data: &Value) -> Vec { + if let Value::Array(a) = data { + return a.clone(); + } + let Some(d) = data.as_object() else { return vec![] }; + // Essaie les wrappers courants + for key in [ + "items", "data", "result", "results", "records", "list", "mouvements", "articles", "fournisseurs", "ventes", "stocks", + "commandes", + ] { + if let Some(Value::Array(a)) = d.get(key) { + return a.clone(); + } + } + // Cherche la première propriété qui est un tableau + for val in d.values() { + if let Value::Array(a) = val { + return a.clone(); + } + } + vec![] +} + +fn first_keys(items: &[Value]) -> String { + match items.first().and_then(|v| v.as_object()) { + Some(o) => o.keys().cloned().collect::>().join(","), + None => { + if items.is_empty() { + "empty".into() + } else { + String::new() + } + } + } +} + +async fn fetch_json(url: &str) -> anyhow::Result> { + let res = ff_get(url).await?; + if !res.status().is_success() { + return Ok(None); + } + Ok(Some(res.json::().await?)) +} + +/// Toutes les pages d'une liste paginée. Une erreur réseau est propagée ; une +/// réponse HTTP en échec donne une page vide. +pub async fn fetch_all_pages(build_url: impl Fn(u32) -> String, page_size: usize) -> anyhow::Result> { + // Page 1 — récupère les items et tente d'obtenir le total pour paralléliser + let url1 = build_url(1); + tracing::info!("[api-ff] GET {url1}"); + let res1 = ff_get(&url1).await?; + if !res1.status().is_success() { + tracing::error!("[api-ff] HTTP {} — {url1}", res1.status().as_u16()); + return Ok(vec![]); + } + let data1: Value = res1.json().await?; + let items1 = extract_list(&data1); + tracing::info!("[api-ff] page 1: {} items, keys: {}", items1.len(), first_keys(&items1)); + + // Si la première page est incomplète, pas besoin de continuer + if items1.len() < page_size { + return Ok(items1); + } + + // Cherche un total dans la réponse pour paralléliser les pages suivantes + let total = js_number(coalesce(&data1, &["count", "total", "nb_total", "totalCount"]).unwrap_or(&json!(0))); + + if total > page_size as f64 { + let total_pages = (total / page_size as f64).ceil() as u32; + let remaining: Vec = (2..=total_pages).collect(); + // Fetch en chunks de 10 pages en parallèle + let mut all = items1; + for chunk in remaining.chunks(10) { + let results = futures::future::try_join_all(chunk.iter().map(|&p| { + let url = build_url(p); + async move { + tracing::info!("[api-ff] GET {url}"); + anyhow::Ok(fetch_json(&url).await?.map(|d| extract_list(&d)).unwrap_or_default()) + } + })) + .await?; + for r in results { + all.extend(r); + } + } + return Ok(all); + } + + // Fallback parallèle sans total : fetch 10 pages à la fois, arrêt dès qu'une page est incomplète + let mut all = items1; + let mut start_page = 2u32; + loop { + let results = futures::future::try_join_all((0..10).map(|i| { + let p = start_page + i; + let url = build_url(p); + async move { + tracing::info!("[api-ff] GET {url}"); + anyhow::Ok(fetch_json(&url).await?.map(|d| extract_list(&d)).unwrap_or_default()) + } + })) + .await?; + let mut done = false; + for items in results { + let n = items.len(); + all.extend(items); + if n < page_size { + done = true; + break; + } + } + if done { + break; + } + start_page += 10; + } + Ok(all) +} + +// --------------------------------------------------------------------------- +// Helpers date +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DateRange { + pub date_debut: String, + pub date_fin: String, +} + +/// Retourne la fenêtre des 12 mois complets précédant le mois en cours. +/// En mars 2026 → { dateDebut: "2025-03-01", dateFin: "2026-02-28" } +pub fn build_last12_months_range() -> DateRange { + build_last12_months_range_at(Local::now().date_naive()) +} + +pub fn build_last12_months_range_at(today: NaiveDate) -> DateRange { + let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today); + // Fin : dernier jour du mois précédent le mois courant + let end_month = first_of_month.pred_opt().unwrap_or(first_of_month); + // Début : 12 mois avant le mois courant + let start_month = first_of_month.checked_sub_months(chrono::Months::new(12)).unwrap_or(first_of_month); + DateRange { + date_debut: start_month.format("%Y-%m-%d").to_string(), + date_fin: end_month.format("%Y-%m-%d").to_string(), + } +} + +// --------------------------------------------------------------------------- +// Articles +// --------------------------------------------------------------------------- + +fn opt_f64(v: Option<&Value>) -> Option { + v.filter(|x| !x.is_null()).map(js_number).filter(|f| !f.is_nan()) +} + +pub async fn get_articles_by_fournisseur(codefou: &str) -> anyhow::Result> { + let base = get_ff_api_base().await; + let enc = encode(codefou); + let raw = fetch_all_pages(|page| format!("{base}/api/articles?codefou={enc}&page={page}&limit=500"), 500).await?; + Ok(raw + .iter() + .map(|r| FfArticle { + codein: coalesce(r, &["codein", "Codein", "code_article", "codeArticle"]) + .map(js_string) + .unwrap_or_else(|| get(r, "id").filter(|v| !v.is_null()).map(js_string).unwrap_or_default()), + libelle1: coalesce(r, &["libelle1", "Libelle1", "libelle", "designation", "nom"]).map(js_string).unwrap_or_default(), + codefou: coalesce(r, &["codefou_principal", "codefou", "Codefou", "code_fournisseur", "codeFournisseur"]) + .map(js_string) + .unwrap_or_else(|| codefou.to_string()), + nomfou: Some( + coalesce(r, &["nom_fou_principal", "nomfou", "Nomfou", "nom_fournisseur", "nomFournisseur"]) + .map(js_string) + .unwrap_or_default(), + ), + pcb: opt_f64(coalesce(r, &["pcb_principal", "pcb", "Pcb", "conditionnement", "colisage"])), + pv_central: opt_f64(coalesce(r, &["pv_central", "PvCentral", "prix_vente_mini", "pv", "prixVente"])), + noid: opt_f64(coalesce(r, &["no_id", "noid", "NoId"])).map(|f| f as i64), + reference: opt_string(coalesce(r, &["ref_fou_principale", "reference", "ref_fournisseur"])), + gtin: opt_string(coalesce(r, &["gtin", "Gtin", "ean", "barcode"])), + actif: get(r, "actif").filter(|v| !v.is_null()).map(|v| { + let n = js_number(v); + n != 0.0 && !n.is_nan() + }), + suspendu: get(r, "suspendu").filter(|v| !v.is_null()).map(|v| { + let n = js_number(v); + n != 0.0 && !n.is_nan() + }), + }) + .filter(|a| !a.codein.is_empty()) + .collect()) +} + +// --------------------------------------------------------------------------- +// Mensuel — stock fin de mois + ventes + réceptions par article et par site +// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin= +// --------------------------------------------------------------------------- + +/// Chaîne tolérante : un nombre devient sa forme texte, `null` une chaîne vide. +fn de_string_lenient<'de, D: Deserializer<'de>>(d: D) -> Result { + let v = Value::deserialize(d)?; + Ok(if v.is_null() { String::new() } else { js_string(&v) }) +} + +/// Ventes du mois. Valeurs telles que renvoyées par l'API (chaînes numériques, +/// ex. `"-2.000"`) : lire avec [`super::js::js_number`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfMensuelVentes { + #[serde(default)] + pub nb_passages: Value, + /// négatif (ex: "-2.000") + #[serde(default)] + pub qte_vendue: Value, + /// négatif (ex: "-26.6600") + #[serde(default)] + pub ca_ht: Value, + #[serde(default)] + pub ca_ttc: Value, + /// positif (ex: "12.7600") + #[serde(default)] + pub marge: Value, + #[serde(default)] + pub taux_marge: Value, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfMensuelReceptions { + #[serde(default)] + pub nb_receptions: Value, + #[serde(default)] + pub qte_recue: Value, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FfMensuelEntry { + /// "YYYY-MM" + #[serde(default, deserialize_with = "de_string_lenient")] + pub mois: String, + /// code site ("292", "579") + #[serde(default, deserialize_with = "de_string_lenient")] + pub site: String, + /// stock fin de mois + #[serde(default)] + pub stock_fin_mois: Value, + /// PRMP fin de mois + #[serde(default)] + pub prmp_fin_mois: Value, + #[serde(default)] + pub ventes: Option, + #[serde(default)] + pub receptions: Option, + /// Autres champs de la réponse, conservés tels quels. + #[serde(flatten, default)] + pub autres: serde_json::Map, +} + +/// Récupère les données mensuelles (stock + ventes + réceptions) pour un lot d'articles. +/// Utilise no_id comme identifiant (obligatoire pour l'API). +/// Retourne Map +pub async fn get_mensuel_by_articles( + articles: &[FfArticle], + date_debut: &str, + date_fin: &str, + batch_size: usize, +) -> HashMap> { + let mut result = HashMap::new(); + let base = get_ff_api_base().await; + + for batch in articles.chunks(batch_size.max(1)) { + let found = futures::future::join_all(batch.iter().map(|art| { + let base = base.clone(); + async move { + let no_id = art.noid.filter(|n| *n != 0)?; + let url = format!("{base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", encode(&no_id.to_string())); + let res = async { + let res = ff_get(&url).await?; + if !res.status().is_success() { + return anyhow::Ok(None); + } + let data: Value = res.json().await?; + let entries: Vec = match get(&data, "data") { + Some(Value::Array(a)) => a.iter().filter_map(|e| serde_json::from_value(e.clone()).ok()).collect(), + _ => vec![], + }; + Ok(Some(entries)) + } + .await; + match res { + Ok(Some(entries)) if !entries.is_empty() => Some((art.codein.clone(), entries)), + Ok(_) => None, + Err(err) => { + tracing::error!("[api-ff] getMensuel error for {}: {}", art.codein, message_erreur(&err)); + None + } + } + } + })) + .await; + result.extend(found.into_iter().flatten()); + } + + result +} + +// --------------------------------------------------------------------------- +// Referentiel — article complet : nomenclature, gammes, stock, prix, fournisseurs +// GET /api/articles/:no_id/referentiel +// --------------------------------------------------------------------------- + +/// Référentiel complet d'un article, JSON brut de l'API : +/// `{ article: { no_id, codein, nom_code, nom_libelle, nom_niveau, nom_chemin_pere }, +/// gammes: [{ gamme_code, gamme_libelle, saison_code, saison_libelle }], +/// stock: [{ site, stockdispo, qte, prmp, valstock, pv, dernierevente, dernierereception, stockmort }], +/// prix: { achat, vente_par_site: [{ site, pv }] } | null, +/// fournisseurs: [{ codefou, ref_fou, pcb, prixachat }], +/// performance: { derniere_vente, derniere_entree, qte_totale_vendue, ca_ttc_total, marge_totale } | null }` +pub type FfReferentiel = Value; + +/// Récupère le référentiel complet (nomenclature, gamme, stock, prix) pour un lot d'articles. +/// Utilise no_id comme identifiant. +/// Retourne Map +pub async fn get_referentiel_by_articles(articles: &[FfArticle], batch_size: usize) -> HashMap { + let mut result = HashMap::new(); + let base = get_ff_api_base().await; + + for batch in articles.chunks(batch_size.max(1)) { + let found = futures::future::join_all(batch.iter().map(|art| { + let base = base.clone(); + async move { + let no_id = art.noid.filter(|n| *n != 0)?; + let url = format!("{base}/api/articles/{}/referentiel", encode(&no_id.to_string())); + let res = async { + let res = ff_get(&url).await?; + if !res.status().is_success() { + return anyhow::Ok(None); + } + let data: Value = res.json().await?; + Ok(get(&data, "article").filter(|a| truthy(a)).is_some().then_some(data)) + } + .await; + match res { + Ok(Some(data)) => Some((art.codein.clone(), data)), + Ok(None) => None, + Err(err) => { + tracing::error!("[api-ff] getReferentiel error for {}: {}", art.codein, message_erreur(&err)); + None + } + } + } + })) + .await; + result.extend(found.into_iter().flatten()); + } + + result +} + +// --------------------------------------------------------------------------- +// Commandes en cours +// --------------------------------------------------------------------------- + +pub async fn get_commandes_by_fournisseur(codefou: &str) -> HashMap { + let mut result: HashMap = HashMap::new(); + let base = get_ff_api_base().await; + let res = async { + let res = ff_get(&format!("{base}/api/commandes/articles?codefou={}", encode(codefou))).await?; + if !res.status().is_success() { + return anyhow::Ok(()); + } + let data: Value = res.json().await?; + let list = if data.is_array() { Some(&data) } else { coalesce(&data, &["items", "data"]) }; + let list = match list { + None => return Ok(()), + Some(Value::Array(a)) => a, + Some(_) => anyhow::bail!("list is not iterable"), + }; + for c in list { + let codein = get(c, "codein").map(js_string).unwrap_or_else(|| "undefined".into()); + let qte = get(c, "qtecde").filter(|v| !v.is_null()).map(js_number).unwrap_or(0.0); + *result.entry(codein).or_insert(0.0) += qte; + } + Ok(()) + } + .await; + if let Err(err) = res { + tracing::error!("[api-ff] getCommandesByFournisseur error: {}", message_erreur(&err)); + } + result +} + +// --------------------------------------------------------------------------- +// Sites (magasins) +// --------------------------------------------------------------------------- + +pub async fn get_sites_from_api() -> Vec { + vec![ + super::pg::CodeNom { code: "292".into(), nom: "Frouard (Nancy)".into() }, + super::pg::CodeNom { code: "579".into(), nom: "Houdemont".into() }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn last12_range() { + let r = build_last12_months_range_at(NaiveDate::from_ymd_opt(2026, 3, 15).unwrap()); + assert_eq!(r, DateRange { date_debut: "2025-03-01".into(), date_fin: "2026-02-28".into() }); + let r = build_last12_months_range_at(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()); + assert_eq!(r, DateRange { date_debut: "2025-01-01".into(), date_fin: "2025-12-31".into() }); + } + + #[test] + fn sync_status_forms() { + let api = json!({ "sync": [ + { "table_name": "mvtart", "last_sync": "2026-01-02T03:00:00Z", "rows_synced": "12", "status": "ok", "error_msg": null }, + { "table_name": "articles", "last_sync": "2026-01-03T03:00:00Z", "rows_synced": null, "status": null, "error_msg": "x" } + ]}); + let s = serde_json::to_value(normalize_sync_status(&api).unwrap()).unwrap(); + assert_eq!( + s, + json!({ "lastSync": "2026-01-03T03:00:00Z", "tables": [ + { "nom": "mvtart", "derniereSync": "2026-01-02T03:00:00Z", "nbLignes": 12.0, "statut": "ok", "erreur": null }, + { "nom": "articles", "derniereSync": "2026-01-03T03:00:00Z", "nbLignes": 0.0, "erreur": "x" } + ]}) + ); + let deja = json!({ "lastSync": "a", "tables": [{ "nom": "t" }] }); + let s = serde_json::to_value(normalize_sync_status(&deja).unwrap()).unwrap(); + assert_eq!(s, json!({ "lastSync": "a", "tables": [{ "nom": "t" }] })); + assert!(normalize_sync_status(&json!({ "x": 1 })).is_none()); + assert!(normalize_sync_status(&json!(null)).is_none()); + } + + #[test] + fn extract_list_wrappers() { + assert_eq!(extract_list(&json!([1, 2])).len(), 2); + assert_eq!(extract_list(&json!({ "articles": [1] })).len(), 1); + assert_eq!(extract_list(&json!({ "foo": 1, "bar": [1, 2, 3] })).len(), 3); + assert!(extract_list(&json!("x")).is_empty()); + } +} diff --git a/server/src/ff/cache.rs b/server/src/ff/cache.rs new file mode 100644 index 0000000..2b94f25 --- /dev/null +++ b/server/src/ff/cache.rs @@ -0,0 +1,34 @@ +//! Lectures FF partagées par plusieurs pages, mises en cache +//! (port de `getFournisseursCached` / `getDerniereReceptionCached` de `src/lib/ff-cache.ts`). + +use super::pg::{pg_get_derniere_reception_par_fournisseur, pg_get_fournisseurs, CodeNom}; +use crate::ff_cache::{cached, CacheOpts}; +use std::collections::{BTreeSet, HashMap}; + +/// Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé. +pub async fn get_fournisseurs_cached() -> anyhow::Result> { + cached( + "fournisseurs", + CacheOpts { ttl: None, cache_if: Some(|rows: &Vec| !rows.is_empty()) }, + || async { anyhow::Ok(pg_get_fournisseurs(None).await) }, + ) + .await +} + +/// Dernière réception par fournisseur et magasin, pour les fournisseurs du +/// cadencier seulement : de loin la lecture la plus lourde de la page Commandes. +/// Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée. +/// Vide = erreur avalée (ou aucune réception), non gardé. +pub async fn get_derniere_reception_cached(codes: &[String]) -> anyhow::Result> { + let liste: Vec = codes.iter().map(|c| c.trim().to_string()).collect::>().into_iter().collect(); + if liste.is_empty() { + return Ok(HashMap::new()); + } + let key = format!("derniere-reception:{}", liste.join(",")); + cached( + &key, + CacheOpts { ttl: None, cache_if: Some(|map: &HashMap| !map.is_empty()) }, + move || async move { anyhow::Ok(pg_get_derniere_reception_par_fournisseur(Some(&liste)).await) }, + ) + .await +} diff --git a/server/src/ff/diagnostic.rs b/server/src/ff/diagnostic.rs new file mode 100644 index 0000000..ca42fc2 --- /dev/null +++ b/server/src/ff/diagnostic.rs @@ -0,0 +1,149 @@ +//! CollectFlow — Diagnostic de l'API FF Nancy +//! (port de `src/features/settings/ff-api-diagnostic.ts`). +//! +//! Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la +//! route `/api/ff-status` (tout utilisateur connecté). Chaque appelant fait son +//! propre contrôle d'accès. + +use super::api::{normalize_sync_status, status_text, FfSyncStatus, FF_API_BASE_DEFAULT}; +use super::js::truthy; +use serde::Serialize; +use serde_json::Value; +use std::time::{Duration, Instant}; + +/// Résultat du diagnostic : `{ success: true, url, ms, status }` ou +/// `{ success: false, url, error }`. +#[derive(Debug, Clone, Serialize)] +pub struct DiagnosticApiFf { + pub success: bool, + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl DiagnosticApiFf { + fn echec(url: &str, error: String) -> Self { + Self { success: false, url: url.to_string(), ms: None, status: None, error: Some(error) } + } +} + +/// Cause réseau d'une erreur `reqwest` : (code façon Node, message). +fn cause_reseau(e: &reqwest::Error) -> Option<(&'static str, String)> { + let mut source: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(e); + let mut messages = vec![]; + while let Some(s) = source { + if let Some(io) = s.downcast_ref::() { + match io.kind() { + std::io::ErrorKind::ConnectionRefused => return Some(("ECONNREFUSED", io.to_string())), + std::io::ErrorKind::ConnectionReset => return Some(("ECONNRESET", io.to_string())), + std::io::ErrorKind::HostUnreachable => return Some(("EHOSTUNREACH", io.to_string())), + std::io::ErrorKind::NetworkUnreachable => return Some(("ENETUNREACH", io.to_string())), + _ => {} + } + } + messages.push(s.to_string()); + source = s.source(); + } + let tout = messages.join(" ").to_lowercase(); + if tout.contains("dns error") || tout.contains("failed to lookup address") || tout.contains("name or service not known") { + return Some(("ENOTFOUND", messages.last().cloned().unwrap_or_default())); + } + if tout.contains("connection refused") { + return Some(("ECONNREFUSED", messages.last().cloned().unwrap_or_default())); + } + None +} + +/// Message complet d'une erreur et de ses causes. +fn message_complet(e: &dyn std::error::Error) -> String { + let mut parts = vec![e.to_string()]; + let mut source = e.source(); + while let Some(s) = source { + let m = s.to_string(); + if !parts.iter().any(|p| p.contains(&m)) { + parts.push(m); + } + source = s.source(); + } + parts.join(": ") +} + +/// Teste l'API FF Nancy et renvoie un diagnostic **exploitable**. +/// +/// L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir +/// si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue +/// donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP. +pub async fn diagnostiquer_api_ff(ff_api_base_url: Option) -> DiagnosticApiFf { + let saisie = ff_api_base_url.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let base = match saisie { + Some(b) => b, + None => { + let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url) + .await + .ok() + .flatten() + .filter(|s| !s.is_empty()); + configured + .or_else(|| std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty())) + .unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string()) + } + }; + let base = base.trim_end_matches('/').to_string(); + let url = format!("{base}/api/sync/status"); + let started = Instant::now(); + + let client = reqwest::Client::builder().timeout(Duration::from_millis(8000)).build().unwrap_or_default(); + match client.get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await { + Ok(res) => { + let ms = started.elapsed().as_millis() as u64; + if !res.status().is_success() { + let txt = status_text(res.status()); + return DiagnosticApiFf::echec( + &url, + format!( + "Le serveur a répondu HTTP {} ({}) en {ms} ms.", + res.status().as_u16(), + if txt.is_empty() { "sans message" } else { txt } + ), + ); + } + let body = res.json::().await.ok().filter(truthy); + let Some(body) = body else { + return DiagnosticApiFf::echec(&url, "Réponse HTTP 200 mais corps illisible (JSON attendu).".into()); + }; + // La réponse brute de l'API n'a pas la forme attendue par l'interface : + // sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200. + match normalize_sync_status(&body) { + None => DiagnosticApiFf::echec(&url, "Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).".into()), + Some(status) => DiagnosticApiFf { success: true, url, ms: Some(ms), status: Some(status), error: None }, + } + } + Err(e) => { + let ms = started.elapsed().as_millis() as u64; + let raw = message_complet(&e); + // La cause réelle se cache derrière l'erreur de haut niveau : on la déplie. + let hint = if e.is_timeout() { + format!("Aucune réponse en {ms} ms — serveur injoignable ou trop lent.") + } else { + match cause_reseau(&e) { + Some(("ENOTFOUND", _)) => "Nom d'hôte introuvable (DNS) — vérifiez l'URL.".to_string(), + Some(("ECONNREFUSED", _)) => "Connexion refusée — le service n'écoute pas sur cette adresse.".to_string(), + Some((code, msg)) => { + if msg.is_empty() { + code.to_string() + } else { + format!("{code} — {msg}") + } + } + None => raw.clone(), + } + }; + tracing::error!("[Settings] FF API test KO ({url}): {raw}"); + DiagnosticApiFf::echec(&url, hint) + } + } +} diff --git a/server/src/ff/fixture_tests.rs b/server/src/ff/fixture_tests.rs new file mode 100644 index 0000000..9efe6a8 --- /dev/null +++ b/server/src/ff/fixture_tests.rs @@ -0,0 +1,292 @@ +//! Test d'intégration du module FF sur une base de test. +//! +//! Ne s'exécute que si `FF_TEST_DATABASE_URL` désigne une base contenant les +//! tables de l'application et des tables FF minimales (articles, artfou1, +//! mvtart, cube_stock…). Une fausse API FF Nancy est servie localement : toutes +//! les requêtes SQL et tous les appels HTTP du module sont joués au moins une +//! fois (erreurs de syntaxe SQL, de décodage de types, formes JSON). +//! +//! FF_TEST_DATABASE_URL=postgres://…/ff_fixture cargo test ff::fixture_tests -- --nocapture + +use super::*; +use crate::auth::Claims; +use crate::rpc::Ctx; +use axum::body::Body; +use axum::extract::{Path, Query}; +use axum::http::Request; +use axum::Json; +use serde_json::{json, Value}; +use std::collections::HashMap; +use tower::ServiceExt; + +/// Fausse API FF Nancy (formes de réponse reprises du code d'origine). +async fn fausse_api() -> String { + async fn dashboard(Query(q): Query>) -> Json { + let date = q.get("date").cloned().unwrap_or_default(); + Json(json!({ + "date": date, + "sites": [ + { "site": "292", "ca_ttc": "1500.50", "ca_ttc_n1": 1200, "trafic": 80, "trafic_n1": "70" }, + { "site": "579", "ca_ttc": 900, "ca_ttc_n1": 0, "trafic": 40, "trafic_n1": 35 } + ], + "top10_ca": [ + { "codein": "100001", "libelle1": "TAPIS", "site": "292", "ca_ttc": "45.5", "qte_vendue": 3, "marge": 12 }, + { "codein": "100002", "libelle1": "CREME", "site": "292", "ca_ttc": 60, "qte_vendue": 1, "marge": 2 }, + { "codein": "100003", "libelle1": "BOUGIE", "site": "579", "ca_ttc": 10, "qte_vendue": 1, "marge": 1 } + ], + "top10_qte": [{ "codein": "100001", "libelle1": "TAPIS", "site": "292", "ca_ttc": 45.5, "qte_vendue": 3, "marge": 12 }], + "top10_marge": [] + })) + } + async fn articles(Query(q): Query>) -> Json { + if let Some(c) = q.get("codein") { + if c == "100003" { + return Json(json!({ "articles": [] })); + } + return Json(json!({ "articles": [{ "no_id": if c == "100001" { 10 } else { 11 }, "codein": c }] })); + } + let page: u32 = q.get("page").and_then(|p| p.parse().ok()).unwrap_or(1); + if page > 1 { + return Json(json!({ "articles": [] })); + } + Json(json!({ "count": 2, "articles": [ + { "codein": "100001", "libelle1": "TAPIS", "codefou_principal": "F001", "pcb_principal": "6", "no_id": 10, "actif": "1", "suspendu": 0 }, + { "Codein": "100002", "libelle": "CREME", "pv": 3.5, "noid": "11", "gtin": "300" } + ]})) + } + async fn referentiel(Path(id): Path) -> Json { + Json(json!({ + "article": { "no_id": id, "codein": "x" }, + "stock": [{ "site": "292", "qte": "4" }, { "site": "579", "qte": 2 }], + "fournisseurs": [{ "codefou": "F001", "nom_fou": " Fournisseur Un ", "preference": 0 }, { "codefou": "F002", "nom_fou": null, "preference": 1 }] + })) + } + async fn dernier(Path(id): Path) -> Json { + if id == "10" { + Json(json!({ "fournisseurs_par_site": [{ "site": "292", "nom_fournisseur": "" }, { "site": "579", "nom_fournisseur": " Dernier Fou " }] })) + } else { + Json(json!({ "fournisseurs_par_site": [] })) + } + } + async fn mensuel(Path(id): Path) -> Json { + Json(json!({ "data": [{ "mois": "2026-01", "site": 292, "stock_fin_mois": "5.000", "prmp_fin_mois": "1", + "ventes": { "nb_passages": "2", "qte_vendue": "-2.000", "ca_ht": "-26.66", "ca_ttc": "-30", "marge": "12.76", "taux_marge": "40" }, + "receptions": null, "id": id }] })) + } + async fn commandes_auto(Query(q): Query>) -> Json { + Json(json!({ "count": 3, "query": q, "propositions": [ + { "site": "292", "codefou": "F001", "nom_fou": "Fournisseur Un", "franco_ht": "150", "nb_articles": 4, "montant_propo_ht": 99.5, "franco_atteint": "NON", "ecart_franco": -50.5 }, + { "site": "579", "codefou": "F002", "nom_fou": "Fournisseur Deux", "franco_ht": null, "nb_articles": 1, "montant_propo_ht": 10, "franco_atteint": "N/A", "ecart_franco": 0 }, + { "site": "292", "codefou": "F009", "nomfou": "Sans franco", "franco": 0, "nb_articles": 1, "montant_cde": 5, "franco_atteint": 1 } + ]})) + } + async fn commandes_auto_detail(Path(codefou): Path, Query(q): Query>) -> Json { + match (codefou.as_str(), q.get("site").map(|s| s.as_str())) { + ("F002", None) => Json(json!({ "resume": [{ "franco_ht": "300" }] })), + ("F009", Some("000")) => Json(json!({ "summary": { "franco": 75 } })), + _ => Json(json!({ "error": "inconnu" })), + } + } + async fn commandes_articles() -> Json { + Json(json!([{ "codein": "100001", "qtecde": 5 }, { "codein": "100001", "qtecde": 3 }, { "codein": "100002" }])) + } + async fn sync_status() -> Json { + Json(json!({ "sync": [{ "table_name": "mvtart", "last_sync": "2026-10-12T02:00:00Z", "rows_synced": 42, "status": "ok", "error_msg": null }] })) + } + async fn publicites(Query(q): Query>) -> Json { + Json(json!({ "publicites": [{ "tcr_code": "P1", "intitule": "Promo", "query": q }], "total": "1", "pages": 1 })) + } + async fn historique() -> axum::http::StatusCode { + axum::http::StatusCode::BAD_GATEWAY + } + + use axum::routing::get; + let app = axum::Router::new() + .route("/api/performance/dashboard", get(dashboard)) + .route("/api/articles", get(articles)) + .route("/api/articles/{id}/referentiel", get(referentiel)) + .route("/api/articles/{id}/dernier-fournisseur", get(dernier)) + .route("/api/articles/{id}/mensuel", get(mensuel)) + .route("/api/commandes-auto", get(commandes_auto)) + .route("/api/commandes-auto/{codefou}", get(commandes_auto_detail)) + .route("/api/commandes/articles", get(commandes_articles)) + .route("/api/sync/status", get(sync_status)) + .route("/api/fournisseurs", get(|| async { Json(json!({ "fournisseurs": [{ "codefou": "F001" }] })) })) + .route("/api/publicites", get(publicites)) + .route("/api/publicites/historique", get(historique)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") +} + +fn montrer(nom: &str, v: impl serde::Serialize) -> Value { + let v = serde_json::to_value(v).unwrap(); + println!("── {nom}\n{}", serde_json::to_string_pretty(&v).unwrap()); + v +} + +async fn appel_route(token: &str, uri: &str) -> (u16, Value) { + let req = Request::builder() + .uri(uri) + .header("cookie", format!("{}={token}", crate::auth::session::COOKIE_NAME)) + .body(Body::empty()) + .unwrap(); + let res = router().oneshot(req).await.unwrap(); + let status = res.status().as_u16(); + let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) +} + +fn ctx(claims: &Claims, query: &[(&str, &str)], args: Vec) -> Ctx { + Ctx { + token: Some(claims.clone()), + query: query.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + args, + } +} + +#[tokio::test] +async fn module_ff_sur_base_de_test() { + let Ok(url) = std::env::var("FF_TEST_DATABASE_URL") else { + eprintln!("FF_TEST_DATABASE_URL absent : test d'intégration FF ignoré."); + return; + }; + let data_dir = std::env::temp_dir().join(format!("cf-ff-test-{}", std::process::id())); + std::fs::create_dir_all(&data_dir).unwrap(); + std::env::set_var("DATA_DIR", &data_dir); + std::env::set_var("DATABASE_URL", &url); + std::env::set_var("AUTH_SECRET", "secret-de-test"); + let base = fausse_api().await; + std::env::set_var("FF_API_BASE_URL", &base); + api::reset_ff_api_base_cache(); + + let p = crate::db::pool().unwrap(); + sqlx::query("INSERT INTO users (id, username, password_hash, role) VALUES (9001, 'test-ff', 'x', 'admin') ON CONFLICT DO NOTHING") + .execute(&p) + .await + .unwrap(); + let claims = Claims { id: "9001".into(), name: "test-ff".into(), role: "admin".into(), exp: chrono::Utc::now().timestamp() + 3600 }; + let token = crate::auth::session::encode(&claims); + let r12 = api::build_last12_months_range(); + let (d, f) = (r12.date_debut.as_str(), r12.date_fin.as_str()); + let codes = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + + // ── pg.rs + let fous = montrer("pg_get_fournisseurs", pg::pg_get_fournisseurs(None).await); + assert_eq!(fous.as_array().unwrap().len(), 2, "F003 (nom vide) écarté"); + montrer("pg_get_fournisseurs(search)", pg::pg_get_fournisseurs(Some("deux")).await); + let arts = pg::pg_get_articles_by_fournisseur("F001").await.unwrap(); + montrer("pg_get_articles_by_fournisseur", &arts); + assert!(arts.iter().all(|a| a.codein.trim() != "100004"), "article suspendu exclu"); + assert!(arts.iter().any(|a| a.code_centrale.as_deref() == Some("10000111111") && a.pa == Some(4.5))); + montrer("pg_get_mensuel_by_fournisseur", pg::pg_get_mensuel_by_fournisseur("F001", d, f).await.unwrap()); + montrer("pg_get_gammes_by_fournisseur", pg::pg_get_gammes_by_fournisseur("F001").await.unwrap()); + let g = pg::pg_get_gammes_by_codeins(&codes(&["100001", " 100002 ", "", "100001"])).await.unwrap(); + montrer("pg_get_gammes_by_codeins", &g); + assert_eq!(g.get("100001").map(|s| s.as_str()), Some("C")); + montrer("pg_get_nomenclature_by_fournisseur", pg::pg_get_nomenclature_by_fournisseur("F001").await.unwrap()); + montrer("pg_get_stock_by_fournisseur", pg::pg_get_stock_by_fournisseur("F001").await.unwrap()); + let pv = pg::pg_get_prix_vente_by_fournisseur("F001").await.unwrap(); + montrer("pg_get_prix_vente_by_fournisseur", &pv); + assert_eq!(pv.values().next().and_then(|m| m.get("292")).copied(), Some(12.5), "site élagué"); + montrer("pg_get_commandes_by_fournisseur", pg::pg_get_commandes_by_fournisseur("F001").await.unwrap()); + let mois = chrono::Local::now().date_naive().format("%Y-%m").to_string(); + let n1 = pages::get_mois_n1(&mois); + montrer("pg_get_ca_by_fournisseur", pg::pg_get_ca_by_fournisseur(&mois, &n1).await.unwrap()); + montrer("pg_get_ca_by_nomenclature", pg::pg_get_ca_by_nomenclature(&r12.date_fin[..7], &mois).await.unwrap()); + let hp = pg::pg_get_hit_parade(d, &chrono::Local::now().date_naive().to_string()).await.unwrap(); + montrer("pg_get_hit_parade", &hp); + assert!(!hp.is_empty()); + montrer("pg_get_stock_for_codeins", pg::pg_get_stock_for_codeins(&codes(&["100001", "100003"])).await.unwrap()); + montrer("pg_diag_fournisseur_derniere_entree", pg::pg_diag_fournisseur_derniere_entree().await.unwrap()); + montrer("pg_get_stock_sans_vente", pg::pg_get_stock_sans_vente(None).await.unwrap()); + montrer("pg_get_stock_sans_vente(292)", pg::pg_get_stock_sans_vente(Some("292")).await.unwrap()); + let neg = pg::pg_get_stock_negatif(None).await.unwrap(); + montrer("pg_get_stock_negatif", &neg); + assert!(!neg.is_empty()); + montrer("pg_get_stock_negatif(579)", pg::pg_get_stock_negatif(Some("579")).await.unwrap()); + montrer("pg_get_sans_vente_6_mois", pg::pg_get_sans_vente_6_mois(None).await.unwrap()); + montrer("pg_get_sans_vente_6_mois(579)", pg::pg_get_sans_vente_6_mois(Some("579")).await.unwrap()); + let rec = pg::pg_get_derniere_reception_par_fournisseur(None).await; + montrer("pg_get_derniere_reception_par_fournisseur(None)", &rec); + assert!(!rec.is_empty()); + montrer("pg_get_derniere_reception_par_fournisseur(F001)", pg::pg_get_derniere_reception_par_fournisseur(Some(&codes(&["F001 "]))).await); + assert!(pg::pg_get_derniere_reception_par_fournisseur(Some(&[])).await.is_empty()); + let search = pg::pg_search_produits("tapis poussiere", 50).await; + montrer("pg_search_produits", &search); + assert_eq!(search.len(), 1); + assert!(search[0].has_reseau); + montrer("pg_search_produits(numérique)", pg::pg_search_produits("100003", 50).await); + montrer("pg_search_produits(code centrale)", pg::pg_search_produits("10000222", 50).await); + montrer("pg_search_produits(!!!)", pg::pg_search_produits("!!!", 50).await); + montrer("pg_get_produits_by_code_centrale", pg::pg_get_produits_by_code_centrale(&codes(&["10000222222", "inconnu"])).await); + assert_eq!(pg::pg_get_codein_by_code_centrale("10000222222").await.as_deref(), Some("100002")); + let det = pg::pg_get_produit_detail("100001").await; + montrer("pg_get_produit_detail", &det); + assert_eq!(det.as_ref().and_then(|d| d.code1.clone()).as_deref(), Some("33"), "hiérarchie remontée"); + montrer("pg_get_fournisseurs_by_codein", pg::pg_get_fournisseurs_by_codein("100003").await); + montrer("pg_get_stock_by_codein", pg::pg_get_stock_by_codein("100001").await); + assert_eq!(pg::pg_get_commandes_by_codein("100001").await, 30.0); + montrer("pg_get_gamme_history_by_codein", pg::pg_get_gamme_history_by_codein("100001").await); + montrer("pg_get_mensuel_by_codein", pg::pg_get_mensuel_by_codein("100001", d, f).await); + let opp = pg::pg_get_opportunites_famille(3, d, f, 30).await; + montrer("pg_get_opportunites_famille", &opp); + assert_eq!(opp.len(), 2); + + // ── API FF (fausse) + let dash = pg::pg_get_dashboard_data().await.unwrap(); + let dv = montrer("pg_get_dashboard_data", &dash); + assert_eq!(dv["top10BySite"][0]["ca"][1]["fournisseur"], json!("Dernier Fou")); + assert_eq!(dv["top10BySite"][0]["ca"][0]["fournisseur"], json!("F002")); + let ca = montrer("pg_get_commandes_auto", pg::pg_get_commandes_auto().await); + assert_eq!(ca[1]["franco"], json!(300.0)); + assert_eq!(ca[2]["franco"], json!(75.0)); + let ffarts = api::get_articles_by_fournisseur("F001").await.unwrap(); + montrer("get_articles_by_fournisseur", &ffarts); + montrer("get_mensuel_by_articles", api::get_mensuel_by_articles(&ffarts, d, f, 50).await); + montrer("get_referentiel_by_articles", api::get_referentiel_by_articles(&ffarts, 50).await); + montrer("get_commandes_by_fournisseur", api::get_commandes_by_fournisseur("F001").await); + montrer("diagnostiquer_api_ff", diagnostic::diagnostiquer_api_ff(None).await); + montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into())).await); + + // ── cache.rs + montrer("get_derniere_reception_cached", cache::get_derniere_reception_cached(&codes(&["F002", "F001"])).await.unwrap()); + + // ── Routes + for uri in ["/api/ff-status", "/api/diag/stock-fournisseur", "/api/test-db?codein=100001", "/api/commandes-auto?site=292", "/api/diag"] { + let (status, body) = appel_route(&token, uri).await; + montrer(&format!("GET {uri} → {status}"), &body); + assert_eq!(status, 200, "{uri}"); + } + + // ── Actions + let c = |args: Vec| ctx(&claims, &[], args); + montrer("getFournisseurs", actions::get_fournisseurs(c(vec![])).await.unwrap()); + montrer("getMagasins", actions::get_magasins(c(vec![])).await.unwrap()); + montrer("getFournisseursPourCadence", actions::get_fournisseurs_pour_cadence(c(vec![])).await.unwrap()); + montrer("upsertCadence", actions::upsert_cadence(c(vec![json!({ "codefou": " F002 ", "site": "292", "intervalleSemaines": "2.5" })])).await.unwrap()); + montrer("upsertCadence(KO)", actions::upsert_cadence(c(vec![json!({ "codefou": "F002", "site": "292", "intervalleSemaines": 0 })])).await.unwrap()); + montrer("toggleCadence", actions::toggle_cadence(c(vec![json!("F002"), json!("292"), json!(false)])).await.unwrap()); + let cad = montrer("listCadences", actions::list_cadences(c(vec![])).await.unwrap()); + assert!(cad.as_array().unwrap().iter().any(|x| x["derniereReception"].is_string() && x["joursRestants"].is_number())); + montrer("removeCadence", actions::remove_cadence(c(vec![json!("F002"), json!("292")])).await.unwrap()); + let sans = Ctx::default(); + assert_eq!(actions::get_fournisseurs(sans.clone()).await.unwrap_err().status.as_u16(), 401); + assert_eq!(actions::remove_cadence(sans.clone()).await.unwrap(), json!({ "ok": false, "error": "Session expirée : reconnectez-vous." })); + + // ── Pages + montrer("page dashboard", pages::page_dashboard(ctx(&claims, &[], vec![])).await.unwrap()); + montrer("page analytics", pages::page_analytics(ctx(&claims, &[("mode", "fournisseur")], vec![])).await.unwrap()); + montrer("page analytics (nomenclature)", pages::page_analytics(ctx(&claims, &[("mode", "x"), ("mois", &mois)], vec![])).await.unwrap()); + montrer("page hit-parade", pages::page_hit_parade(ctx(&claims, &[], vec![])).await.unwrap()); + montrer("page stock-negatif", pages::page_stock_negatif(ctx(&claims, &[("magasin", "292")], vec![])).await.unwrap()); + montrer("page commandes-auto", pages::page_commandes_auto(ctx(&claims, &[], vec![])).await.unwrap()); + let pubs = montrer("page publicites", pages::page_publicites(ctx(&claims, &[("statut", "en_cours"), ("page", "2")], vec![])).await.unwrap()); + assert_eq!(pubs["pageSize"], json!(50)); + assert!(pubs["erreurHistorique"].is_string()); + assert!(pubs.get("erreurPublicites").is_none()); + assert_eq!(pages::page_dashboard(sans).await.unwrap_err().status.as_u16(), 401); + + sqlx::query("DELETE FROM users WHERE id = 9001").execute(&p).await.unwrap(); +} diff --git a/server/src/ff/js.rs b/server/src/ff/js.rs new file mode 100644 index 0000000..f2a2ed9 --- /dev/null +++ b/server/src/ff/js.rs @@ -0,0 +1,172 @@ +//! Conversions « à la JavaScript » sur des valeurs JSON brutes de l'API FF. +//! +//! Le code TS d'origine lisait les réponses de l'API sans schéma et convertissait +//! au fil de l'eau (`Number(x) || 0`, `String(x)`, tests de vérité). Ces aides +//! reproduisent ces conversions pour garder exactement les mêmes résultats. + +use serde_json::Value; + +/// `Number(v)` : `NaN` si la valeur n'est pas convertible. +pub fn js_number(v: &Value) -> f64 { + match v { + Value::Null => 0.0, + Value::Bool(b) => { + if *b { + 1.0 + } else { + 0.0 + } + } + Value::Number(n) => n.as_f64().unwrap_or(f64::NAN), + Value::String(s) => { + let t = s.trim(); + if t.is_empty() { + 0.0 + } else { + // Rust accepte « inf » / « nan », pas JS (seul « Infinity » l'est, cas ignoré). + let l = t.to_ascii_lowercase(); + if l.contains("inf") || l.contains("nan") { + f64::NAN + } else { + t.parse::().unwrap_or(f64::NAN) + } + } + } + Value::Array(a) => match a.len() { + 0 => 0.0, + 1 => js_number(&a[0]), + _ => f64::NAN, + }, + Value::Object(_) => f64::NAN, + } +} + +/// `Number(v) || 0` (NaN et 0 ramenés à 0). +pub fn js_number_or0(v: &Value) -> f64 { + let n = js_number(v); + if n.is_nan() { + 0.0 + } else { + n + } +} + +/// `Number(v)` sur une valeur éventuellement absente (`undefined` → NaN). +pub fn js_number_opt(v: Option<&Value>) -> f64 { + match v { + None => f64::NAN, + Some(v) => js_number(v), + } +} + +/// Formatage d'un nombre comme `String(n)` en JS (12 → "12", 1.5 → "1.5"). +pub fn js_number_to_string(f: f64) -> String { + if f.is_nan() { + "NaN".into() + } else if f.is_infinite() { + if f > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + } + } else if f.fract() == 0.0 && f.abs() < 1e21 { + format!("{}", f as i128) + } else { + format!("{f}") + } +} + +/// `String(v)`. +pub fn js_string(v: &Value) -> String { + match v { + Value::Null => "null".into(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => match n.as_f64() { + Some(f) if n.is_f64() => js_number_to_string(f), + _ => n.to_string(), + }, + Value::String(s) => s.clone(), + Value::Array(a) => a + .iter() + .map(|x| if x.is_null() { String::new() } else { js_string(x) }) + .collect::>() + .join(","), + Value::Object(_) => "[object Object]".into(), + } +} + +/// Valeur « vraie » au sens JS (`if (v)`). +pub fn truthy(v: &Value) -> bool { + match v { + Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(true), + Value::String(s) => !s.is_empty(), + Value::Array(_) | Value::Object(_) => true, + } +} + +/// `v?.[key]` : `None` si absent (undefined). +pub fn get<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { + v.as_object().and_then(|o| o.get(key)) +} + +/// `a ?? b ?? …` sur les clés d'un objet : première valeur ni absente ni `null`. +pub fn coalesce<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a Value> { + keys.iter().filter_map(|k| get(v, k)).find(|x| !x.is_null()) +} + +/// Chaîne d'une valeur JSON, `None` si absente ou `null` (`x ?? undefined`). +pub fn opt_string(v: Option<&Value>) -> Option { + match v { + None | Some(Value::Null) => None, + Some(x) => Some(js_string(x)), + } +} + +/// Message d'une erreur et de ses causes, sans répéter une cause déjà incluse +/// dans le message précédent (les erreurs SQL recopient leur cause). +pub fn message_erreur(e: &anyhow::Error) -> String { + let mut parts: Vec = vec![]; + for cause in e.chain() { + let m = cause.to_string(); + if !parts.iter().any(|p| p.contains(&m)) { + parts.push(m); + } + } + parts.join(": ") +} + +/// `encodeURIComponent(String(v))`. +pub fn encode(v: &str) -> String { + urlencoding::encode(v).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn number_conversions() { + assert_eq!(js_number(&json!("12.5")), 12.5); + assert_eq!(js_number(&json!(" ")), 0.0); + assert!(js_number(&json!("abc")).is_nan()); + assert_eq!(js_number_or0(&json!("abc")), 0.0); + assert_eq!(js_number(&json!(true)), 1.0); + assert_eq!(js_number(&Value::Null), 0.0); + assert!(js_number_opt(None).is_nan()); + } + + #[test] + fn string_conversions() { + assert_eq!(js_string(&json!(12)), "12"); + assert_eq!(js_string(&json!(12.0)), "12"); + assert_eq!(js_string(&json!(1.5)), "1.5"); + assert_eq!(js_string(&json!("x")), "x"); + assert_eq!(js_number_to_string(2.0), "2"); + assert!(truthy(&json!("a"))); + assert!(!truthy(&json!(""))); + assert!(!truthy(&json!(0))); + } +} diff --git a/server/src/ff/mod.rs b/server/src/ff/mod.rs index fd1f2fc..d3ccdf3 100644 --- a/server/src/ff/mod.rs +++ b/server/src/ff/mod.rs @@ -1,21 +1,55 @@ -//! Module `ff` — données FF Nancy (base miroir PostgreSQL et API REST) — à porter. +//! Module `ff` — données FF Nancy : base miroir PostgreSQL (`pg`), API REST +//! (`api`), lectures partagées en cache (`cache`), diagnostic de l'API +//! (`diagnostic`), routes, actions et données de pages. +pub mod actions; +pub mod api; +pub mod cache; +pub mod diagnostic; +pub mod js; +pub mod pages; pub mod pg; +pub mod routes; +#[cfg(test)] +mod fixture_tests; + +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/ff-status", get(routes::ff_status)) + .route("/api/diag", get(routes::diag)) + .route("/api/diag/stock-fournisseur", get(routes::diag_stock_fournisseur)) + .route("/api/test-db", get(routes::test_db)) + .route("/api/commandes-auto", get(routes::commandes_auto)) } /// Actions (`POST /api/actions/{nom}`) du module. pub fn actions() -> Vec<(&'static str, Handler)> { - vec![] + vec![ + ("getFournisseurs", handler!(actions::get_fournisseurs)), + ("getMagasins", handler!(actions::get_magasins)), + ("getFournisseursPourCadence", handler!(actions::get_fournisseurs_pour_cadence)), + ("listCadences", handler!(actions::list_cadences)), + ("upsertCadence", handler!(actions::upsert_cadence)), + ("toggleCadence", handler!(actions::toggle_cadence)), + ("removeCadence", handler!(actions::remove_cadence)), + ] } /// Données de pages (`GET /api/pages/{nom}`) du module. pub fn pages() -> Vec<(&'static str, Handler)> { - vec![] + vec![ + ("dashboard", handler!(pages::page_dashboard)), + ("analytics", handler!(pages::page_analytics)), + ("hit-parade", handler!(pages::page_hit_parade)), + ("stock-negatif", handler!(pages::page_stock_negatif)), + ("commandes-auto", handler!(pages::page_commandes_auto)), + ("publicites", handler!(pages::page_publicites)), + ] } diff --git a/server/src/ff/pages.rs b/server/src/ff/pages.rs new file mode 100644 index 0000000..a21ad5f --- /dev/null +++ b/server/src/ff/pages.rs @@ -0,0 +1,341 @@ +//! Données des anciennes pages serveur du module FF (`GET /api/pages/{nom}`) : +//! accueil, ventes par mois, meilleures ventes, stocks à surveiller, +//! commandes fournisseurs et publicités. + +use super::actions::lister_cadences; +use super::api::get_ff_api_base; +use super::cache::get_fournisseurs_cached; +use super::js::{get, js_number, js_number_to_string, message_erreur}; +use super::pg::{ + pg_get_ca_by_fournisseur, pg_get_ca_by_nomenclature, pg_get_commandes_auto, pg_get_dashboard_data, pg_get_hit_parade, + pg_get_sans_vente_6_mois, pg_get_stock_negatif, pg_get_stock_sans_vente, PgCommandeAutoRow, +}; +use crate::error::{AppError, AppResult}; +use crate::ff_cache::{cached, CacheOpts}; +use crate::rpc::{json, Ctx}; +use chrono::{Datelike, Local, NaiveDate}; +use serde::Serialize; +use serde_json::{json as json_value, Value}; +use std::time::Duration; + +/// Message d'erreur affiché par l'ancien `ErrorState`. +fn erreur_page(e: anyhow::Error) -> AppError { + AppError::internal(message_erreur(&e)) +} + +// --------------------------------------------------------------------------- +// Accueil +// --------------------------------------------------------------------------- + +/// `pgGetDashboardData()`, gardé 30 minutes : ce sont les ventes de la veille, +/// elles ne changent pas dans la journée. +pub async fn page_dashboard(ctx: Ctx) -> AppResult { + ctx.session().await?; + let opts = CacheOpts { ttl: Some(Duration::from_secs(30 * 60)), cache_if: None }; + match cached("dashboard-data", opts, || async { pg_get_dashboard_data().await }).await { + Ok(d) => json(d), + Err(e) => { + tracing::error!("[Dashboard] pgGetDashboardData failed: {}", message_erreur(&e)); + let msg: String = message_erreur(&e).chars().take(500).collect(); + Err(AppError::internal(msg)) + } + } +} + +// --------------------------------------------------------------------------- +// Ventes par mois +// --------------------------------------------------------------------------- + +/// « YYYY-MM » d'une date. +fn format_mois(d: NaiveDate) -> String { + format!("{}-{:02}", d.year(), d.month()) +} + +/// Calcul du mois N-1 (même mois année précédente). +pub fn get_mois_n1(mois: &str) -> String { + let mut parts = mois.split('-').map(|p| p.trim().parse::().ok()); + match (parts.next().flatten(), parts.next().flatten()) { + (Some(year), Some(month)) => { + // new Date(year, month - 13) : -13 mois, débordements normalisés. + let total = year * 12 + (month - 13); + let (y, m0) = (total.div_euclid(12), total.rem_euclid(12)); + format!("{y}-{:02}", m0 + 1) + } + _ => "NaN-NaN".to_string(), + } +} + +/// Lignes de `pgGetCaByFournisseur` (mode `fournisseur`) ou +/// `pgGetCaByNomenclature` (toute autre valeur), mises en cache. +pub async fn page_analytics(ctx: Ctx) -> AppResult { + ctx.session().await?; + // Toute autre valeur que « fournisseur » affichait déjà la vue par famille. + let mode_fournisseur = ctx.q("mode").unwrap_or("fournisseur") == "fournisseur"; + + // Mois par défaut = dernier mois complet (le mois précédent) : le mois en + // cours, incomplet, se comparerait mal à un mois N-1 entier. + let today = Local::now().date_naive(); + let premier = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today); + let dernier_mois_complet = format_mois(premier.pred_opt().unwrap_or(premier)); + let mois = ctx.q("mois").map(|s| s.to_string()).unwrap_or(dernier_mois_complet); + let mois_n1 = ctx.q("moisN1").map(|s| s.to_string()).unwrap_or_else(|| get_mois_n1(&mois)); + + let res = if mode_fournisseur { + let (m, n1) = (mois.clone(), mois_n1.clone()); + cached(&format!("ca-fournisseur:{mois}:{mois_n1}"), CacheOpts::default(), move || async move { + pg_get_ca_by_fournisseur(&m, &n1).await + }) + .await + .and_then(|rows| Ok(serde_json::to_value(rows)?)) + } else { + let (m, n1) = (mois.clone(), mois_n1.clone()); + cached(&format!("ca-nomenclature:{mois}:{mois_n1}"), CacheOpts::default(), move || async move { + pg_get_ca_by_nomenclature(&m, &n1).await + }) + .await + .and_then(|rows| Ok(serde_json::to_value(rows)?)) + }; + res.map_err(|e| { + tracing::error!("[analytics] chargement impossible : {}", message_erreur(&e)); + erreur_page(e) + }) +} + +// --------------------------------------------------------------------------- +// Meilleures ventes +// --------------------------------------------------------------------------- + +/// Lignes de `pgGetHitParade(debut, fin)` (par défaut : du 1er du mois à aujourd'hui). +pub async fn page_hit_parade(ctx: Ctx) -> AppResult { + ctx.session().await?; + let today = Local::now().date_naive(); + let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today); + let date_debut = ctx.q("debut").map(|s| s.to_string()).unwrap_or_else(|| first_of_month.format("%Y-%m-%d").to_string()); + let date_fin = ctx.q("fin").map(|s| s.to_string()).unwrap_or_else(|| today.format("%Y-%m-%d").to_string()); + + let (d, f) = (date_debut.clone(), date_fin.clone()); + match cached(&format!("hit-parade:{date_debut}:{date_fin}"), CacheOpts::default(), move || async move { + pg_get_hit_parade(&d, &f).await + }) + .await + { + Ok(rows) => json(rows), + Err(e) => { + tracing::error!("[hit-parade] chargement impossible : {}", message_erreur(&e)); + Err(erreur_page(e)) + } + } +} + +// --------------------------------------------------------------------------- +// Stocks à surveiller +// --------------------------------------------------------------------------- + +/// `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` pour un magasin (ou tous). +pub async fn page_stock_negatif(ctx: Ctx) -> AppResult { + ctx.session().await?; + let magasin = ctx.q("magasin").unwrap_or("").to_string(); + + // Données FF recopiées chaque nuit : mises en cache (cf. ff_cache). + let site = if magasin.is_empty() { None } else { Some(magasin.clone()) }; + let (s1, s2, s3) = (site.clone(), site.clone(), site); + let (k1, k2, k3) = + (format!("stock-negatif:{magasin}"), format!("stock-sans-vente:{magasin}"), format!("stock-sans-vente-6-mois:{magasin}")); + let res = tokio::try_join!( + cached(&k1, CacheOpts::default(), move || async move { pg_get_stock_negatif(s1.as_deref()).await }), + cached(&k2, CacheOpts::default(), move || async move { pg_get_stock_sans_vente(s2.as_deref()).await }), + cached(&k3, CacheOpts::default(), move || async move { + pg_get_sans_vente_6_mois(s3.as_deref()).await + }), + ); + match res { + Ok((rows_negatif, rows_sans_vente, rows_sans_vente_6_mois)) => Ok(json_value!({ + "rowsNegatif": rows_negatif, + "rowsSansVente": rows_sans_vente, + "rowsSansVente6Mois": rows_sans_vente_6_mois, + })), + Err(e) => { + tracing::error!("[stock-negatif] chargement impossible : {}", message_erreur(&e)); + Err(erreur_page(e)) + } + } +} + +// --------------------------------------------------------------------------- +// Commandes fournisseurs +// --------------------------------------------------------------------------- + +/// `{ rows, cadences, fournisseurs }`. +pub async fn page_commandes_auto(ctx: Ctx) -> AppResult { + ctx.session().await?; + let (rows, cadences, fournisseurs) = tokio::join!( + // Propositions de l'API FF (un appel, plus un par fournisseur sans franco) : + // gardées 2 minutes, pour que chaque modification du cadencier — qui + // recharge la page — n'attende pas de nouveau toute l'API. + cached( + "commandes-auto", + CacheOpts { ttl: Some(Duration::from_secs(2 * 60)), cache_if: Some(|rows: &Vec| !rows.is_empty()) }, + || async { anyhow::Ok(pg_get_commandes_auto().await) }, + ), + lister_cadences(), + get_fournisseurs_cached(), + ); + Ok(json_value!({ + "rows": rows.map_err(erreur_page)?, + "cadences": cadences, + "fournisseurs": fournisseurs.map_err(erreur_page)?, + })) +} + +// --------------------------------------------------------------------------- +// Publicités +// --------------------------------------------------------------------------- + +const PAGE_SIZE: u32 = 50; +/// Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page. +const FF_API_TIMEOUT: Duration = Duration::from_millis(15_000); +/// Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste +/// et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page. +const PUB_CACHE_TTL: Duration = Duration::from_secs(10 * 60); + +#[derive(Debug, Clone, Serialize)] +struct PublicitesResult { + data: Value, + total: f64, + pages: f64, + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct HistoriqueResult { + data: Value, + error: Option, +} + +fn client_publicites() -> reqwest::Client { + reqwest::Client::builder().timeout(FF_API_TIMEOUT).build().unwrap_or_default() +} + +async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult { + let mut params: Vec<(&str, String)> = vec![("page", page), ("limit", PAGE_SIZE.to_string())]; + if !statut.is_empty() && statut != "toutes" { + params.push(("statut", statut)); + } + let base = get_ff_api_base().await; + let res = async { + let res = client_publicites() + .get(format!("{base}/api/publicites")) + .query(¶ms) + .header(reqwest::header::CACHE_CONTROL, "no-store") + .send() + .await?; + if !res.status().is_success() { + return anyhow::Ok(PublicitesResult { + data: json_value!([]), + total: 0.0, + pages: 0.0, + error: Some(format!( + "Erreur API: {} {}", + res.status().as_u16(), + super::api::status_text(res.status()) + )), + }); + } + let body: Value = res.json().await?; + let champ = |k: &str| get(&body, k).filter(|v| !v.is_null()); + Ok(PublicitesResult { + data: champ("publicites").cloned().unwrap_or_else(|| json_value!([])), + total: champ("total").map(js_number).unwrap_or(0.0), + pages: champ("pages").map(js_number).unwrap_or(0.0), + error: None, + }) + } + .await; + res.unwrap_or_else(|e| PublicitesResult { + data: json_value!([]), + total: 0.0, + pages: 0.0, + error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))), + }) +} + +async fn fetch_historique_api() -> HistoriqueResult { + let base = get_ff_api_base().await; + let res = async { + let res = client_publicites() + .get(format!("{base}/api/publicites/historique")) + .header(reqwest::header::CACHE_CONTROL, "no-store") + .send() + .await?; + if !res.status().is_success() { + return anyhow::Ok(HistoriqueResult { + data: json_value!([]), + error: Some(format!("Erreur historique: {}", res.status().as_u16())), + }); + } + let body: Value = res.json().await?; + Ok(HistoriqueResult { + data: get(&body, "publicites").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json_value!([])), + error: None, + }) + } + .await; + res.unwrap_or_else(|e| HistoriqueResult { data: json_value!([]), error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))) }) +} + +/// `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }`. +pub async fn page_publicites(ctx: Ctx) -> AppResult { + ctx.session().await?; + let statut = ctx.query.get("statut").cloned().unwrap_or_else(|| "toutes".into()); + // Math.max(1, Number(page) || 1) + let page_num = { + let n = ctx.query.get("page").map(|p| js_number(&Value::String(p.clone()))).unwrap_or(f64::NAN); + let n = if n.is_nan() || n == 0.0 { 1.0 } else { n }; + n.max(1.0) + }; + let page = js_number_to_string(page_num); + + let (s, p) = (statut.clone(), page.clone()); + let cle = format!("publicites:{statut}:{page}"); + let (pub_result, histo_result) = tokio::join!( + cached( + &cle, + CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &PublicitesResult| r.error.is_none()) }, + move || async move { anyhow::Ok(fetch_publicites_api(s, p).await) }, + ), + cached( + "publicites-historique", + CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &HistoriqueResult| r.error.is_none()) }, + || async { anyhow::Ok(fetch_historique_api().await) }, + ), + ); + let pub_result = pub_result.map_err(erreur_page)?; + let histo_result = histo_result.map_err(erreur_page)?; + + let mut out = serde_json::Map::new(); + out.insert("publicites".into(), pub_result.data); + out.insert("total".into(), json_value!(pub_result.total)); + out.insert("pages".into(), json_value!(pub_result.pages)); + if let Some(e) = pub_result.error { + out.insert("erreurPublicites".into(), Value::String(e)); + } + out.insert("historique".into(), histo_result.data); + if let Some(e) = histo_result.error { + out.insert("erreurHistorique".into(), Value::String(e)); + } + out.insert("pageSize".into(), json_value!(PAGE_SIZE)); + Ok(Value::Object(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mois_n1() { + assert_eq!(get_mois_n1("2026-03"), "2025-03"); + assert_eq!(get_mois_n1("2026-12"), "2025-12"); + assert_eq!(get_mois_n1("2026-01"), "2025-01"); + assert_eq!(get_mois_n1("abc"), "NaN-NaN"); + } +} diff --git a/server/src/ff/pg.rs b/server/src/ff/pg.rs index fc481d7..574d37b 100644 --- a/server/src/ff/pg.rs +++ b/server/src/ff/pg.rs @@ -1,22 +1,2996 @@ -//! Client PostgreSQL FF Nancy (port de `src/lib/pg-ff-client.ts`) — À PORTER. +//! CollectFlow — Client PostgreSQL FF Nancy (port de `src/lib/pg-ff-client.ts`). //! -//! Contrat minimal fixé d'avance (utilisé par le module `qlik`) : la structure -//! [`PgArticle`] et [`pg_get_articles_by_fournisseur`]. Le reste du fichier est -//! à porter ; garder ces deux signatures (champs ajoutables). +//! Remplace les appels HTTP per-article vers api.ffnancy.fr par des requêtes +//! SQL bulk directes sur la base PostgreSQL FF Nancy. +//! +//! Chaque fonction fait 1 seule requête filtrée par codefou — O(1) au lieu de O(N). +//! +//! Le pilote Node rendait les `numeric`/`bigint` en chaînes : toutes les colonnes +//! sont lues ici de façon tolérante (`db::get_f64`, `db::get_string`), quel que +//! soit leur type SQL réel dans la recopie FF. +use super::api::{ff_get, get_ff_api_base, status_text}; +use super::js::{coalesce, encode, get, js_number, js_number_or0, js_string, message_erreur, truthy}; +use crate::db::{get_f64, get_string, pool, rows_to_json, string_or_empty}; +use crate::error::short; +use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, Utc}; +use futures::StreamExt; use serde::Serialize; +use serde_json::{json, Map, Value}; +use sqlx::postgres::PgRow; +use sqlx::{Postgres, QueryBuilder, Row, TypeInfo, ValueRef}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::LazyLock; +use tokio::sync::OnceCell; + +// --------------------------------------------------------------------------- +// Lecture tolérante des colonnes +// --------------------------------------------------------------------------- + +/// `String(r.col ?? "")`. +fn s(row: &PgRow, col: &str) -> String { + string_or_empty(row, col) +} + +/// Texte ou `null`. +fn os(row: &PgRow, col: &str) -> Option { + get_string(row, col) +} + +/// `Number(r.col) || 0`. +fn f(row: &PgRow, col: &str) -> f64 { + get_f64(row, col).unwrap_or(0.0) +} + +/// Nombre ou `null`. +fn of(row: &PgRow, col: &str) -> Option { + get_f64(row, col) +} + +/// Entier ou `null`. +fn oi(row: &PgRow, col: &str) -> Option { + get_f64(row, col).map(|v| v as i64) +} + +/// Booléen ou `null` (accepte aussi un entier 0/1). +fn ob(row: &PgRow, col: &str) -> Option { + match row.try_get::, _>(col) { + Ok(v) => v, + Err(_) => get_f64(row, col).map(|v| v != 0.0), + } +} + +/// `parLots` : `f` sur chaque élément, au plus `simultanes` à la fois, +/// résultats dans l'ordre. Évite de lancer des centaines d'appels à l'API FF +/// d'un coup, qui la saturent et finissent en délais dépassés. +async fn par_lots(items: Vec, simultanes: usize, f: F) -> Vec +where + F: Fn(T) -> Fut, + Fut: std::future::Future, +{ + futures::stream::iter(items.into_iter().map(f)).buffered(simultanes.max(1)).collect().await +} + +// --------------------------------------------------------------------------- +// Types résultats SQL +// --------------------------------------------------------------------------- + +/// Paire `{ code, nom }` (fournisseurs, magasins). +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct CodeNom { + pub code: String, + pub nom: String, +} #[derive(Debug, Clone, Default, Serialize)] pub struct PgArticle { pub no_id: i64, pub codein: String, /// Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik « Article Code ». + /// Vide pour les articles non référencés centralement. #[serde(rename = "codeCentrale", skip_serializing_if = "Option::is_none")] pub code_centrale: Option, pub codefou: String, + pub nomfou: Option, + pub libelle1: Option, + pub pcb: Option, + pub reference: Option, + pub ean13: Option, + /// art_gtin.gtin (préférentiel) + pub gtin: Option, + /// article_infosup.prix_vente_mini + pub pv_central: Option, + /// cube_pa.pa + pub pa: Option, + /// artfou1.code où preference=1 + pub codefou_principal: Option, + /// nom du fournisseur principal + pub nomfou_principal: Option, } -/// Tous les articles d'un fournisseur (une requête). Erreur SQL propagée. -pub async fn pg_get_articles_by_fournisseur(_codefou: &str) -> anyhow::Result> { - anyhow::bail!("pg_get_articles_by_fournisseur : pas encore porté") +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgMensuelRow { + pub codein: String, + pub site: String, + /// "YYYY-MM" + pub mois: String, + pub qte_vendue: f64, + /// mntmvtttc (à vérifier si HT ou TTC) + pub ca_ht: f64, + pub marge: f64, + /// qtestock du dernier mouvement du mois + pub stock_fin_mois: Option, + pub qte_recue: f64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgStockRow { + pub codein: String, + pub site: String, + pub stockdispo: Option, + pub qte: Option, + pub valstock: Option, + pub prmp: Option, + /// Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. + pub pv: Option, + pub dernierevente: Option, + pub dernierereception: Option, +} + +// --------------------------------------------------------------------------- +// 0. Liste des fournisseurs +// --------------------------------------------------------------------------- + +/// Retourne la liste des fournisseurs depuis fouident (vrais noms) + artfou1. +/// Liste vide en cas d'erreur. +pub async fn pg_get_fournisseurs(search: Option<&str>) -> Vec { + let res = async { + let mut qb = QueryBuilder::::new( + " + SELECT DISTINCT fi.code, fi.nom + FROM fouident fi + INNER JOIN artfou1 af ON af.code = fi.code + WHERE fi.nom IS NOT NULL + AND fi.nom != '' + ", + ); + if let Some(search) = search.filter(|s| !s.is_empty()) { + qb.push("AND (fi.nom ILIKE ") + .push_bind(format!("%{search}%")) + .push(" OR fi.code ILIKE ") + .push_bind(format!("%{search}%")) + .push(")"); + } + qb.push( + " + ORDER BY fi.nom + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + anyhow::Ok( + rows.iter() + .map(|r| CodeNom { code: s(r, "code"), nom: s(r, "nom") }) + .filter(|r| !r.code.is_empty() && !r.nom.is_empty()) + .collect(), + ) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetFournisseurs error: {}", short(&e, 200)); + vec![] + } + } +} + +// --------------------------------------------------------------------------- +// 1. Articles du fournisseur +// --------------------------------------------------------------------------- + +/// Tous les articles d'un fournisseur avec leurs métadonnées (une requête). +/// 1 seule requête SQL — remplace getArticlesByFournisseur() + per-article fetches. +/// Erreur SQL propagée. +pub async fn pg_get_articles_by_fournisseur(codefou: &str) -> anyhow::Result> { + // DISTINCT ON (a.no_id) car artfou1 peut avoir plusieurs lignes par article/fournisseur. + // Pas de filtre "actif" : on retourne tous les articles pour ne pas exclure les articles + // d'une session précédente (snapshot) qui n'ont pas de gamme DB ni de ventes récentes. + // Le filtrage gamme-Y sans ventes est fait en aval dans getProductRows (Phase 10). + // afpref : fournisseur préférentiel (preference=1) de l'article — peut différer du fournisseur consulté. + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (a.no_id) + a.no_id, + a.codein, + a.artcentrale AS "codeCentrale", + af.code AS codefou, + fi.nom AS nomfou, + a.libelle1, + af.pcb, + af.reference, + af.ean13, + COALESCE(ag.gtin, af.ean13) AS gtin, + ai.prix_vente_mini AS pv_central, + pa.pa, + last_fournisseur.codefou_dernier AS codefou_principal, + last_fournisseur.nomfou_dernier AS nomfou_principal + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id + LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id + LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1 + LEFT JOIN LATERAL ( + SELECT af2.code AS codefou_dernier, fi2.nom AS nomfou_dernier + FROM artfou1 af2 + LEFT JOIN fouident fi2 ON fi2.code = af2.code + WHERE af2.art_no_id = a.no_id + ORDER BY af2.no_id DESC + LIMIT 1 + ) last_fournisseur ON true + WHERE af.code = $1 + AND a.codein IS NOT NULL + AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) + AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) + ORDER BY a.no_id, af.no_id + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + tracing::info!("[pg-ff] Articles: {} pour {codefou}", rows.len()); + Ok(rows + .iter() + .map(|r| PgArticle { + no_id: oi(r, "no_id").unwrap_or(0), + codein: s(r, "codein"), + code_centrale: os(r, "codeCentrale"), + codefou: s(r, "codefou"), + nomfou: os(r, "nomfou"), + libelle1: os(r, "libelle1"), + pcb: of(r, "pcb"), + reference: os(r, "reference"), + ean13: os(r, "ean13"), + gtin: os(r, "gtin"), + pv_central: of(r, "pv_central"), + pa: of(r, "pa"), + codefou_principal: os(r, "codefou_principal"), + nomfou_principal: os(r, "nomfou_principal"), + }) + .filter(|a| !a.codein.is_empty()) + .collect()) +} + +// --------------------------------------------------------------------------- +// 2. Données mensuelles (ventes + stock + réceptions) +// --------------------------------------------------------------------------- + +fn mensuel_row(r: &PgRow) -> PgMensuelRow { + PgMensuelRow { + codein: s(r, "codein"), + site: s(r, "site"), + mois: s(r, "mois"), + qte_vendue: f(r, "qte_vendue"), + ca_ht: f(r, "ca_ht"), + marge: f(r, "marge"), + stock_fin_mois: of(r, "stock_fin_mois"), + qte_recue: f(r, "qte_recue"), + } +} + +/// Retourne les données mensuelles agrégées par (codein, site, mois). +/// 1 seule requête SQL — remplace getMensuelByArticles() (N appels HTTP). +/// +/// genremvt = 3 → ventes clients +/// genremvt 1/2 → réceptions (à confirmer selon nomenclature FF Nancy) +/// qtestock → stock après mouvement (MAX du mois ≈ stock fin de mois) +pub async fn pg_get_mensuel_by_fournisseur(codefou: &str, date_debut: &str, date_fin: &str) -> anyhow::Result> { + // IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements. + // Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents), + // ce qui multiplierait chaque mouvement et gonflerait les totaux. + // + // stock_fin_mois = qtestock du DERNIER mouvement du mois (tri par datmvt DESC). + // MAX(qtestock) donnait le stock de début de mois (avant les premières ventes), ce qui était faux. + let rows = sqlx::query( + r#" + WITH base AS ( + SELECT + a.codein, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + m.qtemvt, + m.mntmvtttc, + m.margemvt, + m.genremvt, + m.qtestock, + ROW_NUMBER() OVER ( + PARTITION BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY m.datmvt DESC + ) AS rn_last + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af + ON af.art_no_id = a.no_id + WHERE m.datmvt BETWEEN $2::date AND $3::date + AND m.site IN ('292', '579') + ) + SELECT + codein, + site, + mois, + SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, + SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, + SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, + MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, + SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue + FROM base + GROUP BY codein, site, mois + ORDER BY codein, site, mois + "#, + ) + .bind(codefou) + .bind(date_debut) + .bind(date_fin) + .fetch_all(&pool()?) + .await?; + + Ok(rows.iter().map(mensuel_row).collect()) +} + +// --------------------------------------------------------------------------- +// 3. Gammes (saison active) +// --------------------------------------------------------------------------- + +/// Retourne la gamme courante (saison active) pour chaque article du fournisseur. +/// 1 requête SQL — remplace getReferentielByArticles() pour la partie gammes. +pub async fn pg_get_gammes_by_fournisseur(codefou: &str) -> anyhow::Result> { + // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier + // saisons n'a PAS de colonne "actif" → pas de filtre actif + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (a.codein) + a.codein, + g.code AS gamme_code + FROM art_gamme_saison ags + JOIN articles a ON a.no_id = ags.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id AND af.code = $1 + JOIN gammes g ON g.no_id = ags.idgamme + JOIN saisons s ON s.no_id = ags.idsaison + ORDER BY a.codein, s.no_id DESC + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + let mut map = HashMap::new(); + for r in &rows { + let codein = s(r, "codein"); + let gamme = s(r, "gamme_code"); + if !codein.is_empty() && !gamme.is_empty() { + map.insert(codein, gamme.trim().to_string()); + } + } + Ok(map) +} + +/// Gammes **serveur** pour une liste de codein, tous fournisseurs confondus. +/// +/// Variante de `pg_get_gammes_by_fournisseur` sans la jointure `artfou1` : la recherche +/// de l'API `/api/v1` est transversale, on ne connaît donc pas le fournisseur. +/// C'est la gamme telle qu'elle existe en base — non modifiée, indépendante des +/// snapshots de session qui peuvent surcharger `codeGamme` dans la Grille. +pub async fn pg_get_gammes_by_codeins(codeins: &[String]) -> anyhow::Result> { + let mut seen = HashSet::new(); + let unique: Vec = + codeins.iter().map(|c| c.trim().to_string()).filter(|c| !c.is_empty() && seen.insert(c.clone())).collect(); + if unique.is_empty() { + return Ok(HashMap::new()); + } + + // Découpage obligatoire : chaque codein devient un paramètre lié, or PostgreSQL + // en accepte 65535 au maximum. Un gros fournisseur dépasse 130 000 articles — + // la requête échouait alors purement et simplement. + const CHUNK: usize = 2000; + let mut map = HashMap::new(); + let pool = pool()?; + + for batch in unique.chunks(CHUNK) { + // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier. + let mut qb = QueryBuilder::::new( + " + SELECT DISTINCT ON (a.codein) + TRIM(a.codein::text) AS codein, + g.code AS gamme_code + FROM art_gamme_saison ags + JOIN articles a ON a.no_id = ags.artnoid + JOIN gammes g ON g.no_id = ags.idgamme + JOIN saisons s ON s.no_id = ags.idsaison + WHERE TRIM(a.codein::text) IN (", + ); + let mut sep = qb.separated(", "); + for c in batch { + sep.push_bind(c.clone()); + } + qb.push( + ") + ORDER BY a.codein, s.no_id DESC + ", + ); + let rows = qb.build().fetch_all(&pool).await?; + for r in &rows { + let codein = s(r, "codein"); + let gamme = s(r, "gamme_code"); + if !codein.is_empty() && !gamme.is_empty() { + map.insert(codein, gamme.trim().to_string()); + } + } + } + tracing::info!("[pg-ff] pgGetGammesByCodeins: {}/{} articles avec gamme", map.len(), unique.len()); + Ok(map) +} + +// --------------------------------------------------------------------------- +// 4. Nomenclature (famille / sous-famille) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgNomRow { + pub codein: String, + /// code feuille (ex: "360504") + pub code3: String, + /// libellé feuille + pub libelle3: String, + /// famille (4 chiffres) + #[serde(skip_serializing_if = "Option::is_none")] + pub code2: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub libelle2: Option, + /// secteur (2 chiffres) + #[serde(skip_serializing_if = "Option::is_none")] + pub code1: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub libelle1nom: Option, +} + +/// Cache process : colonne « parent » de `nomenclature` (`None` = aucune). +/// Une erreur de lecture n'est pas mémorisée (nouvel essai au prochain appel). +static NOMENCLATURE_PARENT_COL: OnceCell> = OnceCell::const_new(); + +/// Découvre (une seule fois par process) le nom de la colonne "parent" de la +/// table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur. +/// +/// On ne peut pas la coder en dur : le nom varie selon les installations +/// ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK), +/// d'où le filtre explicite sur `data_type` entier. +async fn get_nomenclature_parent_col() -> anyhow::Result> { + NOMENCLATURE_PARENT_COL + .get_or_try_init(|| async { + let rows = sqlx::query( + r#" + SELECT column_name, data_type FROM information_schema.columns + WHERE table_name = 'nomenclature' ORDER BY ordinal_position + "#, + ) + .fetch_all(&pool()?) + .await?; + let nom_cols: Vec<(String, String)> = rows.iter().map(|r| (s(r, "column_name"), s(r, "data_type"))).collect(); + tracing::info!( + "[pg-ff] Nomenclature colonnes: {}", + nom_cols.iter().map(|(c, t)| format!("{c}({t})")).collect::>().join(", ") + ); + // Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent) + // "chemin_pere" = chemin vers le père en français + // On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé) + static RE_PARENT: LazyLock = LazyLock::new(|| regex::Regex::new(r"(?i)parent|pere|father").unwrap()); + static RE_NOID: LazyLock = LazyLock::new(|| regex::Regex::new(r"(?i)no_id$").unwrap()); + static RE_INT: LazyLock = LazyLock::new(|| regex::Regex::new(r"(?i)int|serial").unwrap()); + let parent = nom_cols + .iter() + .find(|(c, t)| (RE_PARENT.is_match(c) || (c != "no_id" && RE_NOID.is_match(c))) && RE_INT.is_match(t)) + .map(|(c, _)| c.clone()); + tracing::info!("[pg-ff] Nomenclature parentCol: {}", parent.as_deref().unwrap_or("null")); + anyhow::Ok(parent) + }) + .await + .cloned() +} + +/// Identifiant SQL entre guillemets (nom issu d'information_schema). +fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +/// Retourne la nomenclature (famille/sous-famille) pour chaque article du fournisseur. +/// FK confirmée : articles.nom_no_id → nomenclature.no_id +/// Colonnes confirmées : nomenclature.code, nomenclature.libelle +/// Colonne parent : découverte dynamiquement via information_schema +pub async fn pg_get_nomenclature_by_fournisseur(codefou: &str) -> anyhow::Result> { + let parent_col = get_nomenclature_parent_col().await?; + + let rows = if let Some(pc) = parent_col.as_deref() { + let pc = quote_ident(pc); + let sql = format!( + r#" + SELECT + a.codein, + n3.code AS code3, + n3.libelle AS libelle3, + n2.code AS code2, + n2.libelle AS libelle2, + n1.code AS code1, + n1.libelle AS libelle1nom + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id AND af.code = $1 + JOIN nomenclature n3 ON n3.no_id = a.nom_no_id + LEFT JOIN nomenclature n2 ON n2.no_id = n3.{pc} + LEFT JOIN nomenclature n1 ON n1.no_id = n2.{pc} + WHERE a.codein IS NOT NULL + "# + ); + sqlx::query(&sql).bind(codefou).fetch_all(&pool()?).await? + } else { + // Sans hiérarchie : juste le niveau feuille + sqlx::query( + r#" + SELECT + a.codein, + n3.code AS code3, + n3.libelle AS libelle3 + FROM artfou1 af + JOIN articles a ON a.no_id = af.art_no_id AND af.code = $1 + JOIN nomenclature n3 ON n3.no_id = a.nom_no_id + WHERE a.codein IS NOT NULL + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await? + }; + + tracing::info!("[pg-ff] Nomenclature: {} articles, parentCol=\"{}\"", rows.len(), parent_col.as_deref().unwrap_or("none")); + let mut map = HashMap::new(); + for r in &rows { + let row = PgNomRow { + codein: s(r, "codein"), + code3: s(r, "code3"), + libelle3: s(r, "libelle3"), + code2: os(r, "code2"), + libelle2: os(r, "libelle2"), + code1: os(r, "code1"), + libelle1nom: os(r, "libelle1nom"), + }; + if !row.codein.is_empty() { + map.insert(row.codein.clone(), row); + } + } + Ok(map) +} + +// --------------------------------------------------------------------------- +// 5. Stock temps réel (cube_stock) +// --------------------------------------------------------------------------- + +/// Retourne le stock actuel par site pour chaque article du fournisseur. +/// 1 requête SQL — remplace la partie stock du référentiel. +pub async fn pg_get_stock_by_fournisseur(codefou: &str) -> anyhow::Result>> { + // `site` est élagué pour la même raison que dans pg_get_prix_vente_by_fournisseur : + // il sert de clé de recherche (derniereLivraisonByStore, prix de repli). + let rows = sqlx::query( + r#" + SELECT + a.codein, + TRIM(cs.site::text) AS site, + cs.stockdispo::float, + cs.qte::float, + cs.valstock::float, + cs.prmp::float, + cs.pv::float, + cs.dernierevente::text, + cs.dernierereception::text + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af + ON af.art_no_id = a.no_id + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + let mut map: HashMap> = HashMap::new(); + for r in &rows { + let row = PgStockRow { + codein: s(r, "codein"), + site: s(r, "site"), + stockdispo: of(r, "stockdispo"), + qte: of(r, "qte"), + valstock: of(r, "valstock"), + prmp: of(r, "prmp"), + pv: of(r, "pv"), + dernierevente: os(r, "dernierevente"), + dernierereception: os(r, "dernierereception"), + }; + map.entry(row.codein.clone()).or_default().push(row); + } + Ok(map) +} + +/// Prix de vente par article et par site (`cube_pv`). +/// +/// Pendant exact de `cube_pa` pour l'achat, rafraîchi chaque nuit depuis le cube +/// MSSQL `Cube_PV`. C'est LA source du prix de vente : `article_infosup`, seule +/// table lue jusqu'ici pour cette information, ne porte qu'un prix plancher de +/// paramétrage (`prix_vente_mini`) qui reste vide en pratique. +/// +/// Retour : codein → { site → PV }. Les prix nuls sont écartés — un article non +/// tarifé n'est pas un article à 0 €. +pub async fn pg_get_prix_vente_by_fournisseur(codefou: &str) -> anyhow::Result>> { + // Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où + // l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer + // par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur + // fixe remonterait complétée d'espaces, et la recherche échouerait en silence — + // le prix resterait visible en « tous magasins » et vide magasin par magasin. + let rows = sqlx::query( + r#" + SELECT + a.codein, + TRIM(cpv.site::text) AS site, + cpv.pv::float AS pv + FROM cube_pv cpv + JOIN articles a ON a.no_id = cpv.artnoid + JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = $1) af + ON af.art_no_id = a.no_id + WHERE cpv.pv IS NOT NULL AND cpv.pv > 0 + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + let mut map: HashMap> = HashMap::new(); + for r in &rows { + let codein = s(r, "codein"); + let site = s(r, "site"); + if codein.is_empty() || site.is_empty() { + continue; + } + map.entry(codein).or_default().insert(site, of(r, "pv").unwrap_or(f64::NAN)); + } + tracing::info!("[pg-ff] Prix de vente : {} articles tarifés pour {codefou}", map.len()); + Ok(map) +} + +// --------------------------------------------------------------------------- +// 6. Commandes en cours +// --------------------------------------------------------------------------- + +/// Retourne les quantités commandées en cours pour chaque article du fournisseur. +/// 1 requête SQL — remplace getCommandesByFournisseur(). +pub async fn pg_get_commandes_by_fournisseur(codefou: &str) -> anyhow::Result> { + // cdefou_vivant est une vue dénormalisée : articles_codein et cdefou_ligne_qtecde disponibles directement + let rows = sqlx::query( + r#" + SELECT + cv.articles_codein AS codein, + SUM(cv.cdefou_ligne_qtecde)::float AS qtecde + FROM cdefou_vivant cv + JOIN artfou1 af ON af.no_id = cv.artfou1_no_id AND af.code = $1 + WHERE cv.articles_codein IS NOT NULL + GROUP BY cv.articles_codein + "#, + ) + .bind(codefou) + .fetch_all(&pool()?) + .await?; + + let mut map = HashMap::new(); + for r in &rows { + let codein = s(r, "codein"); + if !codein.is_empty() { + map.insert(codein, f(r, "qtecde")); + } + } + Ok(map) +} + +// --------------------------------------------------------------------------- +// 7. Dashboard quotidien (hit parade + évolution N/N-1) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DashboardSiteStats { + pub site: String, + pub ca_hier: f64, + pub ca_n1: f64, + pub tickets_hier: f64, + pub tickets_n1: f64, + pub qte_hier: f64, + pub marge_hier: f64, + pub lignes_hier: f64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DashboardTopItem { + pub codein: String, + pub libelle1: String, + pub ca: f64, + pub qte: f64, + pub marge: f64, + pub stock: f64, + pub fournisseur: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DashboardSiteTop10 { + pub site: String, + pub ca: Vec, + pub qte: Vec, + pub marge: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DashboardData { + pub date_hier: String, + #[serde(rename = "dateN1")] + pub date_n1: String, + pub sites: Vec, + #[serde(rename = "top10BySite")] + pub top10_by_site: Vec, +} + +/// Éléments d'un tableau de la réponse (`x ?? []`). +fn arr<'a>(v: &'a Value, key: &str) -> &'a [Value] { + match get(v, key) { + Some(Value::Array(a)) => a, + _ => &[], + } +} + +/// Chaîne d'un champ texte (`null`/absent → vide). +fn str_field(v: &Value, key: &str) -> String { + match get(v, key) { + None | Some(Value::Null) => String::new(), + Some(x) => js_string(x), + } +} + +#[derive(Default, Clone, Copy)] +struct StockSites { + stock292: f64, + stock579: f64, + stock_total: f64, +} + +/// Enrichissement d'un article du dashboard : stock (référentiel) et +/// fournisseur réel (dernière entrée, sinon référentiel). Comme dans le TS, ce +/// qui a été lu avant une erreur reste acquis (le stock est enregistré avant la +/// lecture de `/dernier-fournisseur`). +#[derive(Default)] +struct EnrichissementDashboard { + stock: Option, + fournisseur: Option, +} + +async fn enrichir_article_dashboard(base: &str, codein: &str, out: &mut EnrichissementDashboard) -> anyhow::Result<()> { + let art_res = ff_get(&format!("{base}/api/articles?codein={}", encode(codein))).await?; + if !art_res.status().is_success() { + return Ok(()); + } + let art_data: Value = art_res.json().await?; + let first = match get(&art_data, "articles") { + Some(Value::Array(a)) if !a.is_empty() => a[0].clone(), + _ => return Ok(()), + }; + let no_id = match get(&first, "no_id") { + Some(v) if truthy(v) => js_string(v), + _ => return Ok(()), + }; + + let url_ref = format!("{base}/api/articles/{}/referentiel", encode(&no_id)); + let url_dernier = format!("{base}/api/articles/{}/dernier-fournisseur", encode(&no_id)); + let (ref_res, dernier_res) = tokio::join!(ff_get(&url_ref), ff_get(&url_dernier)); + let ref_res = ref_res?; + let dernier_res = dernier_res?; + + // Stock (toujours depuis referentiel) + let mut ref_fournisseur_fallback = String::new(); + if ref_res.status().is_success() { + let ref_data: Value = ref_res.json().await?; + let mut st = StockSites::default(); + if let Some(Value::Array(lignes)) = get(&ref_data, "stock") { + for l in lignes { + let qte = js_number_or0(get(l, "qte").unwrap_or(&Value::Null)); + match get(l, "site").and_then(|v| v.as_str()) { + Some("292") => st.stock292 += qte, + Some("579") => st.stock579 += qte, + _ => {} + } + st.stock_total += qte; + } + } + out.stock = Some(st); + + // Fallback fournisseur (pas d'entrée en stock) : + // - 1 seul fournisseur → on l'utilise directement + // - plusieurs → celui avec preference = 1 + if let Some(Value::Array(fous)) = get(&ref_data, "fournisseurs") { + if !fous.is_empty() { + let pick = if fous.len() == 1 { + &fous[0] + } else { + fous.iter() + .find(|f| get(f, "preference").and_then(|p| p.as_f64()) == Some(1.0)) + .unwrap_or(&fous[0]) + }; + let nom = get(pick, "nom_fou").and_then(|v| v.as_str()).map(|s| s.trim()).unwrap_or(""); + let code = get(pick, "codefou").and_then(|v| v.as_str()).map(|s| s.trim()).unwrap_or(""); + ref_fournisseur_fallback = if !nom.is_empty() { nom.to_string() } else { code.to_string() }; + } + } + } + + // Fournisseur réel : /dernier-fournisseur (dernière entrée en stock par site) + // Fallback : premier non-dépôt du référentiel + if dernier_res.status().is_success() { + let dernier_data: Value = dernier_res.json().await?; + // Prendre le fournisseur du premier site disponible + let first = arr(&dernier_data, "fournisseurs_par_site") + .iter() + .find(|e| get(e, "nom_fournisseur").map(truthy).unwrap_or(false)); + if let Some(e) = first { + out.fournisseur = Some(str_field(e, "nom_fournisseur").trim().to_string()); + } else if !ref_fournisseur_fallback.is_empty() { + out.fournisseur = Some(ref_fournisseur_fallback); + } + } else if !ref_fournisseur_fallback.is_empty() { + out.fournisseur = Some(ref_fournisseur_fallback); + } + Ok(()) +} + +/// Retourne toutes les données nécessaires pour le dashboard quotidien. +/// 100% via l'API REST api.ffnancy.fr — aucune requête PostgreSQL directe. +/// N-1 : même jour de semaine (52 semaines = 364 jours avant hier). +pub async fn pg_get_dashboard_data() -> anyhow::Result { + let yesterday = Local::now() - chrono::Duration::days(1); + let date_hier = yesterday.with_timezone(&Utc).format("%Y-%m-%d").to_string(); + + // N-1 : même jour de semaine, même semaine N-1 → 364 jours = 52 semaines exactes + let n1_date = yesterday - chrono::Duration::days(364); + let date_n1_param = n1_date.with_timezone(&Utc).format("%Y-%m-%d").to_string(); + + let ff_api_base = get_ff_api_base().await; + let api_base = format!("{ff_api_base}/api/performance"); + + // Deux appels parallèles : données hier + données même jour N-1 + let (api_result, api_n1_result) = tokio::join!( + async { + let r = ff_get(&format!("{api_base}/dashboard?date={date_hier}")).await?; + if !r.status().is_success() { + anyhow::bail!("API dashboard {}: {}", r.status().as_u16(), status_text(r.status())); + } + anyhow::Ok(r.json::().await?) + }, + async { + match ff_get(&format!("{api_base}/dashboard?date={date_n1_param}")).await { + Ok(r) if r.status().is_success() => r.json::().await.ok(), + _ => None, + } + }, + ); + let api_result = api_result?; + if !api_result.is_object() { + anyhow::bail!("API dashboard : réponse inattendue"); + } + + // Construire map N-1 par site depuis l'appel séparé + let mut n1_by_site: HashMap = HashMap::new(); + if let Some(n1) = api_n1_result.as_ref() { + for s in arr(n1, "sites") { + n1_by_site.insert( + str_field(s, "site"), + (js_number_or0(get(s, "ca_ttc").unwrap_or(&Value::Null)), js_number_or0(get(s, "trafic").unwrap_or(&Value::Null))), + ); + } + } + + let num = |v: &Value, k: &str| js_number_or0(get(v, k).unwrap_or(&Value::Null)); + + let sites: Vec = arr(&api_result, "sites") + .iter() + .map(|s| { + let site = str_field(s, "site"); + let n1 = n1_by_site.get(&site); + // Priorité : même jour de semaine N-1 (si > 0), sinon fallback sur n1 calendaire de l'API + let ca_n1 = match n1 { + Some((ca, _)) if *ca > 0.0 => *ca, + _ => num(s, "ca_ttc_n1"), + }; + let traffic_n1 = match n1 { + Some((_, t)) if *t > 0.0 => *t, + _ => num(s, "trafic_n1"), + }; + DashboardSiteStats { + site, + ca_hier: num(s, "ca_ttc"), + ca_n1, + tickets_hier: num(s, "trafic"), + tickets_n1: traffic_n1, + qte_hier: 0.0, + marge_hier: 0.0, + lignes_hier: 0.0, + } + }) + .collect(); + + // Top 10 par magasin : grouper les items par site, trier, garder top 10 + let all_items: Vec<&Value> = arr(&api_result, "top10_ca") + .iter() + .chain(arr(&api_result, "top10_qte")) + .chain(arr(&api_result, "top10_marge")) + .collect(); + let mut seen = HashSet::new(); + let all_codeins: Vec = all_items + .iter() + .filter_map(|i| get(i, "codein").filter(|v| truthy(v)).map(js_string)) + .filter(|c| seen.insert(c.clone())) + .collect(); + + // Fournisseur réel : un seul appel sur les mouvements du jour (codefou_reel / nom_fournisseur_reel). + // Enrichissement : fournisseur réel via /dernier-fournisseur (primaire) + /referentiel (stock + fallback fournisseur). + // Les deux appels sont faits en parallèle par article. + let mut fou_map: HashMap = HashMap::new(); // codein → nom_fournisseur résolu + let mut stock_map: HashMap = HashMap::new(); + + if !all_codeins.is_empty() { + // Vagues de 12 articles au lieu de 5 : chaque article coûte deux allers- + // retours successifs, et la page d'accueil attendait une douzaine de vagues. + const VAGUE: usize = 12; + for batch in all_codeins.chunks(VAGUE) { + let results = futures::future::join_all(batch.iter().map(|codein| { + let base = ff_api_base.clone(); + async move { + let mut out = EnrichissementDashboard::default(); + if let Err(err) = enrichir_article_dashboard(&base, codein, &mut out).await { + tracing::error!("[Dashboard enrichment] Error for codein {codein}: {}", message_erreur(&err)); + } + (codein.clone(), out) + } + })) + .await; + for (codein, out) in results { + if let Some(st) = out.stock { + stock_map.insert(codein.clone(), st); + } + if let Some(f) = out.fournisseur { + fou_map.insert(codein, f); + } + } + } + } + + let mut site_set: Vec = all_items + .iter() + .filter_map(|i| get(i, "site").filter(|v| truthy(v)).map(js_string)) + .collect::>() + .into_iter() + .collect(); + site_set.sort(); + + #[derive(Clone, Copy)] + enum Cle { + Ca, + Qte, + Marge, + } + let build_top10 = |items: Vec<&Value>, cle: Cle| -> Vec { + let mut v: Vec = items + .into_iter() + .map(|i| { + let codein = str_field(i, "codein"); + let site = get(i, "site").and_then(|v| v.as_str()); + let entry = stock_map.get(&codein); + let stock = match site { + Some("292") => entry.map(|e| e.stock292).unwrap_or(0.0), + Some("579") => entry.map(|e| e.stock579).unwrap_or(0.0), + _ => entry.map(|e| e.stock_total).unwrap_or(0.0), + }; + let fournisseur = fou_map.get(&codein).cloned().unwrap_or_else(|| "—".into()); + DashboardTopItem { + codein, + libelle1: str_field(i, "libelle1"), + ca: num(i, "ca_ttc"), + qte: num(i, "qte_vendue"), + marge: num(i, "marge"), + stock, + fournisseur, + } + }) + .collect(); + let key = |x: &DashboardTopItem| match cle { + Cle::Ca => x.ca, + Cle::Qte => x.qte, + Cle::Marge => x.marge, + }; + v.sort_by(|a, b| key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal)); + v.truncate(10); + v + }; + + let top10_by_site: Vec = site_set + .iter() + .map(|site| { + let by_s = |key: &str| -> Vec<&Value> { + arr(&api_result, key).iter().filter(|i| get(i, "site").and_then(|v| v.as_str()) == Some(site.as_str())).collect() + }; + DashboardSiteTop10 { + site: site.clone(), + ca: build_top10(by_s("top10_ca"), Cle::Ca), + qte: build_top10(by_s("top10_qte"), Cle::Qte), + marge: build_top10(by_s("top10_marge"), Cle::Marge), + } + }) + .collect(); + + tracing::info!( + "[pg-ff] Dashboard: hier={date_hier} n1={date_n1_param} sites={} n1_found={}", + sites.len(), + n1_by_site.len() + ); + + let date_of = |v: Option<&Value>, defaut: &str| match v.and_then(|v| get(v, "date")) { + None | Some(Value::Null) => defaut.to_string(), + Some(x) => js_string(x), + }; + Ok(DashboardData { + date_hier: date_of(Some(&api_result), &date_hier), + date_n1: date_of(api_n1_result.as_ref(), &date_n1_param), + sites, + top10_by_site, + }) +} + +// --------------------------------------------------------------------------- +// Analytics — CA TTC par fournisseur et nomenclature +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CaByFournisseurRow { + pub code: String, + pub nom: String, + pub site: String, + pub mois: String, + pub ca_ttc: f64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CaByNomenclatureRow { + pub code: String, + pub libelle: String, + pub site: String, + pub mois: String, + pub ca_ttc: f64, +} + +/// Bornes d'un mois « YYYY-MM » : [premier jour, premier jour du mois suivant[. +/// +/// Filtrer `datmvt` sur ces bornes plutôt que sur `TO_CHAR(datmvt, 'YYYY-MM')` +/// donne exactement les mêmes lignes, mais sans formater en texte la date de +/// chaque mouvement de l'historique (et reste utilisable par un index sur datmvt). +pub fn bornes_mois(mois: &str) -> (String, String) { + let mut parts = mois.split('-').map(|p| p.trim().parse::().ok()); + let annee = parts.next().flatten(); + let m = parts.next().flatten(); + let num = |v: Option| v.map(|x| x.to_string()).unwrap_or_else(|| "NaN".into()); + let suivant = if m == Some(12) { + format!("{}-01", num(annee.map(|a| a + 1))) + } else { + format!("{}-{:0>2}", num(annee), num(m.map(|x| x + 1))) + }; + (format!("{mois}-01"), format!("{suivant}-01")) +} + +/// Mouvements du mois OU du mois N-1 (cf. `bornes_mois`). +fn push_filtre_deux_mois(qb: &mut QueryBuilder<'_, Postgres>, mois: &str, mois_n1: &str) { + let (a_debut, a_fin) = bornes_mois(mois); + let (b_debut, b_fin) = bornes_mois(mois_n1); + qb.push("((m.datmvt >= ") + .push_bind(a_debut) + .push("::date AND m.datmvt < ") + .push_bind(a_fin) + .push("::date)\n OR (m.datmvt >= ") + .push_bind(b_debut) + .push("::date AND m.datmvt < ") + .push_bind(b_fin) + .push("::date))"); +} + +/// Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1). +/// Attribution via artfou1.preference = true (fournisseur principal de l'article). +/// CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention +/// que pg_get_mensuel_by_fournisseur / pg_get_hit_parade). Fournisseur préféré résolu +/// via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par +/// article, aucune duplication possible des mouvements dans la somme. +pub async fn pg_get_ca_by_fournisseur(mois: &str, mois_n1: &str) -> anyhow::Result> { + let mut qb = QueryBuilder::::new( + " + SELECT + COALESCE(af.code, 'SANS_FOURNISSEUR')::text AS code, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + SUM(-m.mntmvtttc)::float AS ca_ttc + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + LEFT JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + LEFT JOIN fouident fi ON fi.code = af.code + WHERE ", + ); + push_filtre_deux_mois(&mut qb, mois, mois_n1); + qb.push( + " + AND m.site IN ('292', '579') + AND m.genremvt = 3 + GROUP BY af.code, fi.nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY af.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + + tracing::info!("[pg-ff] CaByFournisseur: {} lignes pour {mois}/{mois_n1}", rows.len()); + Ok(rows + .iter() + .map(|r| CaByFournisseurRow { code: s(r, "code"), nom: s(r, "nom"), site: s(r, "site"), mois: s(r, "mois"), ca_ttc: f(r, "ca_ttc") }) + .collect()) +} + +/// Retourne le CA TTC par nomenclature et site pour deux mois donnés. +/// CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune). +pub async fn pg_get_ca_by_nomenclature(mois: &str, mois_n1: &str) -> anyhow::Result> { + let mut qb = QueryBuilder::::new( + " + SELECT + COALESCE(n.code, 'SANS_NOM')::text AS code, + COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + SUM(-m.mntmvtttc)::float AS ca_ttc + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id + WHERE ", + ); + push_filtre_deux_mois(&mut qb, mois, mois_n1); + qb.push( + " + AND m.site IN ('292', '579') + AND m.genremvt = 3 + GROUP BY n.no_id, n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY n.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + + tracing::info!("[pg-ff] CaByNomenclature: {} lignes pour {mois}/{mois_n1}", rows.len()); + Ok(rows + .iter() + .map(|r| CaByNomenclatureRow { + code: s(r, "code"), + libelle: s(r, "libelle"), + site: s(r, "site"), + mois: s(r, "mois"), + ca_ttc: f(r, "ca_ttc"), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Hit Parade — produits les plus vendus sur un mois donné +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct HitParadeRow { + pub codein: String, + pub libelle: String, + /// Référence fournisseur de l'article (artfou1.reference du fournisseur principal). + pub reference: String, + pub fournisseur: String, + pub nomenclature_code: String, + pub nomenclature: String, + pub site: String, + pub qte_vendue: f64, + pub ca_ttc: f64, + pub marge: f64, + pub stock292: f64, + pub stock579: f64, + #[serde(rename = "stockTotal")] + pub stock_total: f64, +} + +/// Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site. +/// +/// L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1), +/// exactement comme pg_get_mensuel_by_fournisseur (le calcul canonique de la Grille) : +/// qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3 +/// (ventes stockées en négatif — vérifié sur les mouvements réels de l'API). +/// Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont +/// joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les +/// mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que +/// par article vendu (et non par ligne de mouvement). +/// +/// Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes, +/// 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours +/// additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle. +pub async fn pg_get_hit_parade(date_debut: &str, date_fin: &str) -> anyhow::Result> { + let rows = sqlx::query( + r#" + WITH ventes AS ( + SELECT + TRIM(a.codein::text) AS codein, + m.site, + SUM(-m.qtemvt)::float AS qte_vendue, + SUM(-m.mntmvtttc)::float AS ca_ttc, + SUM(m.margemvt)::float AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.datmvt BETWEEN $1::date AND $2::date + AND m.site IN ('292', '579') + AND m.genremvt = 3 + AND a.codein IS NOT NULL + GROUP BY TRIM(a.codein::text), m.site + ), + attrs AS ( + SELECT DISTINCT ON (TRIM(a.codein::text)) + TRIM(a.codein::text) AS codein, + a.libelle1::text AS libelle, + COALESCE(TRIM(af.reference::text), '') AS reference, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + COALESCE(n.code, '')::text AS nomenclature_code, + COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature + FROM articles a + LEFT JOIN LATERAL ( + SELECT af1.code, af1.reference + FROM artfou1 af1 + WHERE af1.art_no_id = a.no_id AND af1.preference = 1 + ORDER BY af1.code + LIMIT 1 + ) af ON TRUE + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id + WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes) + ORDER BY TRIM(a.codein::text), a.no_id DESC + ), + stock_agg AS ( + SELECT + TRIM(a2.codein::text) AS codein, + SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292, + SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579, + SUM(cs.qte)::float AS stockTotal + FROM cube_stock cs + JOIN articles a2 ON a2.no_id = cs.artnoid + WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes) + GROUP BY TRIM(a2.codein::text) + ) + SELECT + v.codein, + COALESCE(ar.libelle, '') AS libelle, + COALESCE(ar.reference, '') AS reference, + COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur, + COALESCE(ar.nomenclature_code, '') AS nomenclature_code, + COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature, + v.site, + v.qte_vendue, + v.ca_ttc, + v.marge, + COALESCE(s.stock292, 0) AS stock292, + COALESCE(s.stock579, 0) AS stock579, + COALESCE(s.stockTotal, 0) AS "stockTotal" + FROM ventes v + LEFT JOIN attrs ar ON ar.codein = v.codein + LEFT JOIN stock_agg s ON s.codein = v.codein + ORDER BY v.ca_ttc DESC + "#, + ) + .bind(date_debut) + .bind(date_fin) + .fetch_all(&pool()?) + .await?; + + tracing::info!("[pg-ff] HitParade: {} lignes pour {date_debut}→{date_fin}", rows.len()); + Ok(rows + .iter() + .map(|r| HitParadeRow { + codein: s(r, "codein"), + libelle: s(r, "libelle"), + reference: s(r, "reference"), + fournisseur: s(r, "fournisseur"), + nomenclature_code: s(r, "nomenclature_code"), + nomenclature: s(r, "nomenclature"), + site: s(r, "site"), + qte_vendue: f(r, "qte_vendue"), + ca_ttc: f(r, "ca_ttc"), + marge: f(r, "marge"), + stock292: f(r, "stock292"), + stock579: f(r, "stock579"), + stock_total: f(r, "stockTotal"), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Stock par liste de codeins (pour Hit Parade) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct StockBySite { + pub stock292: f64, + pub stock579: f64, + #[serde(rename = "stockTotal")] + pub stock_total: f64, +} + +/// Retourne le stock actuel (stockdispo) par codein pour une liste de codeins. +/// 1 seule requête bulk — utilisé pour enrichir le Hit Parade. +pub async fn pg_get_stock_for_codeins(codeins: &[String]) -> anyhow::Result> { + if codeins.is_empty() { + return Ok(HashMap::new()); + } + + let mut qb = QueryBuilder::::new( + "SELECT TRIM(a.codein::text) AS codein, cs.site, COALESCE(cs.qte, 0)::float AS stockdispo + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + WHERE TRIM(a.codein::text) IN (", + ); + let mut sep = qb.separated(", "); + for c in codeins { + sep.push_bind(c.clone()); + } + qb.push(")"); + let rows = qb.build().fetch_all(&pool()?).await?; + + let mut map: HashMap = HashMap::new(); + for r in &rows { + let entry = map.entry(s(r, "codein")).or_default(); + let dispo = f(r, "stockdispo"); + match os(r, "site").as_deref() { + Some("292") => entry.stock292 += dispo, + Some("579") => entry.stock579 += dispo, + _ => {} + } + entry.stock_total += dispo; + } + Ok(map) +} + +// --------------------------------------------------------------------------- +// Stock — fournisseur de la dernière entrée +// --------------------------------------------------------------------------- + +/// Colonne de `mvtart` reliant un mouvement de stock à un fournisseur. +/// - target "artfou1" : la colonne est une FK vers `artfou1.no_id` +/// - target "code" : la colonne contient directement le code fournisseur (`fouident.code`) +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct MvtartFouLink { + pub column: &'static str, + pub target: &'static str, +} + +/// Colonnes candidates de `mvtart` reliant un mouvement de stock à un fournisseur. +/// `mvtart` provient d'une réplication FF : le nom exact n'est pas garanti, on le +/// détecte une fois par process (même principe que `get_nomenclature_parent_col`). +pub const MVTART_FOU_CANDIDATES: [MvtartFouLink; 7] = [ + MvtartFouLink { column: "artfou1_no_id", target: "artfou1" }, + MvtartFouLink { column: "artfou_no_id", target: "artfou1" }, + MvtartFouLink { column: "afou_no_id", target: "artfou1" }, + MvtartFouLink { column: "codefou", target: "code" }, + MvtartFouLink { column: "code_fou", target: "code" }, + MvtartFouLink { column: "fou_code", target: "code" }, + MvtartFouLink { column: "fournisseur", target: "code" }, +]; + +static MVTART_FOU_LINK: OnceCell> = OnceCell::const_new(); + +async fn get_mvtart_fou_link() -> Option { + *MVTART_FOU_LINK + .get_or_init(|| async { + let res = async { + let rows = sqlx::query( + r#" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'mvtart' + "#, + ) + .fetch_all(&pool()?) + .await?; + anyhow::Ok(rows.iter().map(|r| s(r, "column_name").to_lowercase()).collect::>()) + } + .await; + // Le nom vient d'information_schema ET doit figurer dans la liste blanche + // ci-dessus : aucune valeur utilisateur n'atteint le SQL. + let link = match res { + Ok(cols) => MVTART_FOU_CANDIDATES.iter().find(|c| cols.contains(c.column)).copied(), + Err(e) => { + tracing::error!("[pg-ff] Détection mvtart.fournisseur impossible: {}", message_erreur(&e)); + None + } + }; + match link { + Some(l) => tracing::info!("[pg-ff] mvtart → fournisseur: {{ column: '{}', target: '{}' }}", l.column, l.target), + None => tracing::info!("[pg-ff] mvtart → fournisseur: aucune colonne (repli artfou1.preference=1)"), + } + link + }) + .await +} + +/// Jointure latérale sur la DERNIÈRE entrée en stock (`genremvt IN (1,2)`) du +/// couple article/site : elle expose `lastentry.datmvt` (date) et +/// `lastentry.code` (code fournisseur, `NULL` si la base ne porte pas +/// l'information). +/// +/// Règle métier : un article n'est rattaché qu'au fournisseur de sa dernière +/// entrée. S'il est rentré en stock chez un autre fournisseur, il ne doit plus +/// apparaître sous le précédent. +/// +/// La `LEFT JOIN artfou1` (et non `JOIN`) garantit qu'on retient bien la vraie +/// dernière entrée, même si son fournisseur est inconnu. +/// +/// Les expressions passées sont des fragments SQL constants du code, et la +/// colonne vient de la liste blanche : aucune valeur utilisateur ici. +fn last_entry_join(link: Option, artnoid_expr: &str, site_expr: &str) -> String { + let code_expr = match link { + Some(l) if l.target == "artfou1" => "afe.code".to_string(), + Some(l) => format!("me.{}", l.column), + None => "NULL::text".to_string(), + }; + let join_artfou = match link { + Some(l) if l.target == "artfou1" => format!("LEFT JOIN artfou1 afe ON afe.no_id = me.{}", l.column), + _ => String::new(), + }; + let flast = if link.is_some() { "LEFT JOIN fouident flast ON flast.code = lastentry.code" } else { "" }; + format!( + " + LEFT JOIN LATERAL ( + SELECT me.datmvt, {code_expr} AS code + FROM mvtart me + {join_artfou} + WHERE me.artnoid = {artnoid_expr} + AND me.site = {site_expr} + AND me.genremvt IN (1, 2) + ORDER BY me.datmvt DESC, me.no_id DESC + LIMIT 1 + ) lastentry ON TRUE + {flast} + " + ) +} + +/// Libellé fournisseur affiché : celui de la dernière entrée quand la base le +/// permet, sinon repli documenté sur `artfou1.preference = 1`. +fn fournisseur_expr(link: Option) -> &'static str { + if link.is_some() { + "COALESCE(flast.nom, lastentry.code, f.nom, af.code)" + } else { + "COALESCE(f.nom, af.code)" + } +} + +/// Résultat du diagnostic « fournisseur de la dernière entrée ». +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiagFournisseurDerniereEntree { + pub colonne_detectee: Option, + pub candidats_testes: Vec<&'static str>, + pub colonnes_mvtart: Vec, + pub echantillon: Vec, +} + +/// Diagnostic (`/api/diag/stock-fournisseur`) : expose la colonne détectée, les +/// colonnes réellement présentes sur `mvtart` et un échantillon comparant le +/// fournisseur principal au fournisseur de la dernière entrée. +pub async fn pg_diag_fournisseur_derniere_entree() -> anyhow::Result { + let link = get_mvtart_fou_link().await; + let pool = pool()?; + let meta = sqlx::query( + r#" + SELECT column_name, data_type FROM information_schema.columns + WHERE table_name = 'mvtart' ORDER BY ordinal_position + "#, + ) + .fetch_all(&pool) + .await?; + let mut echantillon = vec![]; + if link.is_some() { + let sql = format!( + " + SELECT + a.codein, + cs.site, + COALESCE(f.nom, af.code) AS fournisseur_preference, + COALESCE(flast.nom, lastentry.code) AS fournisseur_derniere_entree, + lastentry.datmvt::text AS derniere_entree + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + LEFT JOIN fouident f ON f.code = af.code + {} + WHERE cs.qte < 0 + AND cs.site IN ('292', '579') + LIMIT 20 + ", + last_entry_join(link, "cs.artnoid", "cs.site") + ); + let r = sqlx::query(&sql).fetch_all(&pool).await?; + echantillon = rows_to_json(&r); + } + Ok(DiagFournisseurDerniereEntree { + colonne_detectee: link, + candidats_testes: MVTART_FOU_CANDIDATES.iter().map(|c| c.column).collect(), + colonnes_mvtart: meta + .iter() + .map(|r| json!({ "column_name": s(r, "column_name"), "data_type": s(r, "data_type") })) + .collect(), + echantillon, + }) +} + +// --------------------------------------------------------------------------- +// Stock négatif — tous magasins ou par site +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgStockNegatifRow { + pub codein: String, + pub libelle1: String, + pub fournisseur: String, + pub site: String, + pub stockdispo: f64, + pub dernierevente: Option, + pub derniereentree: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgStockSansVenteRow { + pub codein: String, + pub libelle1: String, + pub fournisseur: String, + pub site: String, + pub stock_actuel: f64, + pub derniere_entree: Option, +} + +/// Retourne les articles ayant des entrées en stock (hors mois courant) +/// mais aucune vente enregistrée sur ce site. +/// Les articles dont le stock actuel est nul sont exclus (rien à régulariser). +pub async fn pg_get_stock_sans_vente(site: Option<&str>) -> anyhow::Result> { + let link = get_mvtart_fou_link().await; + let fexpr = fournisseur_expr(link); + let mut qb = QueryBuilder::::new(format!( + " + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + {fexpr} AS fournisseur, + m.site, + COALESCE(cs.qte::float, 0) AS stock_actuel, + MAX(m.datmvt)::text AS derniere_entree + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + JOIN fouident f ON f.code = af.code + LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site + {} + WHERE m.genremvt IN (1, 2) + AND m.site IN ('292', '579') + AND m.datmvt < date_trunc('month', CURRENT_DATE) + ", + last_entry_join(link, "a.no_id", "m.site") + )); + if let Some(site) = site.filter(|s| !s.is_empty()) { + qb.push("AND m.site = ").push_bind(site.to_string()); + } + qb.push(format!( + " + AND NOT EXISTS ( + SELECT 1 FROM mvtart mv2 + WHERE mv2.artnoid = m.artnoid + AND mv2.site = m.site + AND mv2.genremvt = 3 + ) + GROUP BY a.codein, a.libelle1, {fexpr}, m.site, cs.qte + HAVING COALESCE(cs.qte::float, 0) <> 0 + ORDER BY MAX(m.datmvt) DESC + " + )); + let rows = qb.build().fetch_all(&pool()?).await?; + Ok(rows + .iter() + .map(|r| PgStockSansVenteRow { + codein: s(r, "codein"), + libelle1: s(r, "libelle1"), + fournisseur: s(r, "fournisseur"), + site: s(r, "site"), + stock_actuel: f(r, "stock_actuel"), + derniere_entree: os(r, "derniere_entree"), + }) + .collect()) +} + +pub async fn pg_get_stock_negatif(site: Option<&str>) -> anyhow::Result> { + let link = get_mvtart_fou_link().await; + let mut qb = QueryBuilder::::new(format!( + " + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + {} AS fournisseur, + cs.site, + cs.qte::float AS stockdispo, + cs.dernierevente::text, + lastentry.datmvt::text AS derniereentree + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + JOIN fouident f ON f.code = af.code + {} + WHERE cs.qte < 0 + AND cs.site IN ('292', '579') + ", + fournisseur_expr(link), + last_entry_join(link, "cs.artnoid", "cs.site") + )); + if let Some(site) = site.filter(|s| !s.is_empty()) { + qb.push("AND cs.site = ").push_bind(site.to_string()); + } + qb.push( + " + ORDER BY cs.qte ASC + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + Ok(rows + .iter() + .map(|r| PgStockNegatifRow { + codein: s(r, "codein"), + libelle1: s(r, "libelle1"), + fournisseur: s(r, "fournisseur"), + site: s(r, "site"), + stockdispo: f(r, "stockdispo"), + dernierevente: os(r, "dernierevente"), + derniereentree: os(r, "derniereentree"), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Stock sans vente depuis 6 mois +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgSansVente6MoisRow { + pub codein: String, + pub libelle1: String, + pub fournisseur: String, + pub site: String, + pub stock_actuel: f64, + pub derniere_vente: Option, + pub derniere_entree: Option, + pub jours_sans_vente: Option, +} + +/// Retourne les articles ayant du stock disponible (qte > 0) +/// dont la dernière vente remonte à plus de 6 mois (ou jamais vendus) +/// ET dont la dernière entrée en stock remonte aussi à plus de 6 mois. +pub async fn pg_get_sans_vente_6_mois(site: Option<&str>) -> anyhow::Result> { + let link = get_mvtart_fou_link().await; + let mut qb = QueryBuilder::::new(format!( + " + SELECT + a.codein, + COALESCE(a.libelle1, '') AS libelle1, + {} AS fournisseur, + cs.site, + cs.qte::float AS stock_actuel, + cs.dernierevente::text AS derniere_vente, + CASE + WHEN cs.dernierevente IS NULL THEN NULL + ELSE (CURRENT_DATE - cs.dernierevente::date) + END AS jours_sans_vente, + lastentry.datmvt::text AS derniere_entree + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + JOIN ( + SELECT DISTINCT ON (art_no_id) art_no_id, code + FROM artfou1 + WHERE preference = 1 + ORDER BY art_no_id, code + ) af ON af.art_no_id = a.no_id + JOIN fouident f ON f.code = af.code + {} + WHERE cs.site IN ('292', '579') + AND cs.qte > 0 + AND ( + cs.dernierevente IS NULL + OR cs.dernierevente < CURRENT_DATE - INTERVAL '6 months' + ) + AND NOT EXISTS ( + SELECT 1 FROM mvtart m2 + WHERE m2.artnoid = cs.artnoid + AND m2.site = cs.site + AND m2.genremvt IN (1, 2) + AND m2.datmvt >= CURRENT_DATE - INTERVAL '6 months' + ) + ", + fournisseur_expr(link), + last_entry_join(link, "cs.artnoid", "cs.site") + )); + if let Some(site) = site.filter(|s| !s.is_empty()) { + qb.push("AND cs.site = ").push_bind(site.to_string()); + } + qb.push( + " + ORDER BY cs.dernierevente ASC NULLS FIRST + ", + ); + let rows = qb.build().fetch_all(&pool()?).await?; + Ok(rows + .iter() + .map(|r| PgSansVente6MoisRow { + codein: s(r, "codein"), + libelle1: s(r, "libelle1"), + fournisseur: s(r, "fournisseur"), + site: s(r, "site"), + stock_actuel: f(r, "stock_actuel"), + derniere_vente: os(r, "derniere_vente"), + derniere_entree: os(r, "derniere_entree"), + jours_sans_vente: oi(r, "jours_sans_vente"), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Commandes automatiques — par site + fournisseur avec franco +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgCommandeAutoRow { + pub site: String, + pub codefou: String, + pub nomfou: String, + pub franco: f64, + pub nb_articles: f64, + pub montant_cde: f64, + pub franco_atteint: Option, + pub ecart_franco: f64, +} + +fn map_commande_auto_row(r: &Value) -> PgCommandeAutoRow { + let s_or = |keys: &[&str]| coalesce(r, keys).map(js_string).unwrap_or_default(); + let n_or = |keys: &[&str]| coalesce(r, keys).map(js_number).unwrap_or(0.0); + PgCommandeAutoRow { + site: s_or(&["site"]), + codefou: s_or(&["codefou"]), + nomfou: s_or(&["nom_fou", "nomfou"]), + franco: n_or(&["franco_ht", "franco"]), + nb_articles: n_or(&["nb_articles"]), + montant_cde: n_or(&["montant_propo_ht", "montant_cde"]), + franco_atteint: match get(r, "franco_atteint") { + Some(Value::String(v)) if v == "OUI" => Some(true), + Some(Value::String(v)) if v == "NON" => Some(false), + Some(Value::String(v)) if v == "N/A" => None, + Some(v) if !v.is_null() => Some(truthy(v)), + _ => None, + }, + ecart_franco: n_or(&["ecart_franco"]), + } +} + +/// Franco lu dans le détail d'un fournisseur (`resume[0].franco_ht`). +fn extract_franco(detail: &Value) -> f64 { + if !truthy(detail) || get(detail, "error").map(truthy).unwrap_or(false) { + return 0.0; + } + let resume_raw = coalesce(detail, &["resume", "summary"]).unwrap_or(detail); + let resume_item = match resume_raw { + Value::Array(a) => a.first(), + v => Some(v), + }; + match resume_item.and_then(|i| coalesce(i, &["franco_ht", "franco"])) { + Some(v) => js_number(v), + None => 0.0, + } +} + +/// Retourne la liste des fournisseurs en commande automatique via l'API Hostinger. +/// GET https://api.ffnancy.fr/api/commandes-auto +/// +/// Pour les fournisseurs dont le franco est absent de la réponse globale (franco_ht null), +/// on enrichit via le detail endpoint /api/commandes-auto/:codefou?site= qui contient +/// toujours le résumé franco du fournisseur. +/// +/// Liste vide en cas d'erreur. +pub async fn pg_get_commandes_auto() -> Vec { + let res = async { + let base = get_ff_api_base().await; + let res = ff_get(&format!("{base}/api/commandes-auto")).await?; + if !res.status().is_success() { + anyhow::bail!("HTTP {}", res.status().as_u16()); + } + let data: Value = res.json().await?; + // L'API retourne { count: N, propositions: [...] } ou un tableau direct + let rows: Vec = match &data { + Value::Array(a) => a.clone(), + d => match coalesce(d, &["propositions", "data", "items", "results"]) { + None => vec![], + Some(Value::Array(a)) => a.clone(), + Some(_) => anyhow::bail!("rows.map is not a function"), + }, + }; + + // Diagnostic : log les clés du premier row pour détecter les changements de format + if let Some(sample) = rows.first() { + let keys: Vec<&String> = sample.as_object().map(|o| o.keys().collect()).unwrap_or_default(); + tracing::info!("[pg-ff] pgGetCommandesAuto sample keys: {keys:?}"); + let champ = |k: &str| match get(sample, k) { + None => "undefined".to_string(), + Some(v) => js_string(v), + }; + tracing::info!( + "[pg-ff] pgGetCommandesAuto sample franco fields: franco_ht={} franco={} franco_atteint={}", + champ("franco_ht"), + champ("franco"), + champ("franco_atteint") + ); + } + + tracing::info!("[pg-ff] pgGetCommandesAuto: {} lignes (API Hostinger)", rows.len()); + let mapped: Vec = rows.iter().map(map_commande_auto_row).collect(); + + // Enrichissement : pour les fournisseurs sans franco dans la réponse globale, + // on appelle GET /api/commandes-auto/{codefou} (sans filtre site — franco est une + // propriété du fournisseur, pas du site) et on lit resume[0].franco_ht. + let missing: Vec<&PgCommandeAutoRow> = mapped.iter().filter(|r| r.franco == 0.0 && !r.codefou.is_empty()).collect(); + if missing.is_empty() { + return anyhow::Ok(mapped); + } + tracing::info!("[pg-ff] pgGetCommandesAuto: {} lignes sans franco → enrichissement", missing.len()); + // Dédupliquer par codefou (franco identique quel que soit le site) + let mut seen = HashSet::new(); + let unique_code_fous: Vec = + missing.iter().map(|r| r.codefou.clone()).filter(|c| seen.insert(c.clone())).collect(); + + // 6 fournisseurs à la fois : chacun peut coûter deux appels successifs. + let details = par_lots(unique_code_fous, 6, |codefou| { + let base = base.clone(); + async move { + let essai = |url: String| async move { + match ff_get(&url).await { + Ok(r) if r.status().is_success() => match r.json::().await { + Ok(detail) => extract_franco(&detail), + Err(_) => 0.0, + }, + _ => 0.0, + } + }; + // 1. Essai sans filtre site + let f1 = essai(format!("{base}/api/commandes-auto/{}", encode(&codefou))).await; + if f1 > 0.0 { + return (codefou, f1); + } + // 2. Fallback site=000 + let f2 = essai(format!("{base}/api/commandes-auto/{}?site=000", encode(&codefou))).await; + if f2 > 0.0 { + return (codefou, f2); + } + (codefou, 0.0) + } + }) + .await; + + // Map codefou → franco enrichi + let mut franco_map: Map = Map::new(); + for (codefou, franco) in details { + if franco > 0.0 { + franco_map.insert(codefou, json!(franco)); + } + } + let enrichis = serde_json::Value::Object(franco_map.clone()).to_string(); + tracing::info!("[pg-ff] pgGetCommandesAuto: francos enrichis: {enrichis}"); + + Ok(mapped + .into_iter() + .map(|mut r| { + if r.franco == 0.0 { + if let Some(enriched) = franco_map.get(&r.codefou).and_then(|v| v.as_f64()) { + if enriched > 0.0 { + r.franco = enriched; + } + } + } + r + }) + .collect()) + } + .await; + match res { + Ok(rows) => rows, + Err(e) => { + tracing::error!("[pg-ff] pgGetCommandesAuto error: {}", short(&message_erreur(&e), 300)); + vec![] + } + } +} + +// --------------------------------------------------------------------------- +// 12. Dernière réception par fournisseur ET par site (cadencier) +// --------------------------------------------------------------------------- + +/// Date ISO (`toISOString()`) d'une colonne date/horodatage, ou d'un texte de date. +fn colonne_iso(row: &PgRow, col: &str) -> anyhow::Result> { + let raw = row.try_get_raw(col)?; + if raw.is_null() { + return Ok(None); + } + let ty = raw.type_info().name().to_ascii_uppercase(); + if matches!(ty.as_str(), "DATE" | "TIMESTAMP" | "TIMESTAMPTZ") { + return Ok(get_string(row, col)); + } + // Texte : `new Date(texte).toISOString()`. + let txt = string_or_empty(row, col); + let t = txt.trim(); + if let Ok(d) = DateTime::parse_from_rfc3339(t) { + return Ok(Some(crate::db::iso_utc(d.with_timezone(&Utc)))); + } + for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] { + if let Ok(d) = NaiveDateTime::parse_from_str(t, fmt) { + return Ok(Some(crate::db::iso(d))); + } + } + if let Ok(d) = NaiveDate::parse_from_str(t, "%Y-%m-%d") { + return Ok(Some(crate::db::iso(d.and_hms_opt(0, 0, 0).unwrap_or_default()))); + } + anyhow::bail!("Invalid time value") +} + +/// Retourne la date de la dernière réception (entrée de marchandise) par +/// fournisseur et par site, pour piloter le cadencier de commande. +/// +/// Source : mvtart (genremvt IN (1, 2) = réceptions, voir pg_get_mensuel_by_fournisseur). +/// La réception est rattachée à l'article ; on l'attribue au fournisseur +/// principal de l'article (artfou1.preference = 1), comme partout ailleurs. +/// +/// Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur +/// (l'UI affichera alors « échéance inconnue »). +pub async fn pg_get_derniere_reception_par_fournisseur(codes: Option<&[String]>) -> HashMap { + let mut map = HashMap::new(); + // Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles, + // au lieu de tout l'historique de réceptions de tous les fournisseurs. + if codes.map(|c| c.is_empty()).unwrap_or(false) { + return map; + } + let mut qb = QueryBuilder::::new( + " + SELECT + af.code AS codefou, + m.site AS site, + MAX(m.datmvt) AS derniere + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + WHERE m.genremvt IN (1, 2) + AND m.site IN ('292', '579') + AND m.datmvt IS NOT NULL + ", + ); + if let Some(codes) = codes { + qb.push("AND TRIM(af.code) IN ("); + let mut sep = qb.separated(", "); + for c in codes { + sep.push_bind(c.trim().to_string()); + } + qb.push(")"); + } + qb.push( + " + GROUP BY af.code, m.site + ", + ); + let res = async { + let rows = qb.build().fetch_all(&pool()?).await?; + for r in &rows { + let codefou = s(r, "codefou"); + if codefou.is_empty() { + continue; + } + let Some(iso) = colonne_iso(r, "derniere")? else { continue }; + map.insert(format!("{}|{}", codefou.trim(), s(r, "site").trim()), iso); + } + tracing::info!("[pg-ff] pgGetDerniereReceptionParFournisseur: {} entrées (mvtart)", map.len()); + anyhow::Ok(()) + } + .await; + if let Err(e) = res { + tracing::error!("[pg-ff] pgGetDerniereReceptionParFournisseur error: {}", short(&message_erreur(&e), 250)); + } + map +} + +// --------------------------------------------------------------------------- +// 13. Recherche & fiche produit (page /produits) +// --------------------------------------------------------------------------- + +/// Une ligne de résultat de recherche produit. Volontairement **légère** : +/// aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût). +/// Le détail lourd n'est calculé qu'à l'ouverture de la fiche. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgProduitSearchRow { + pub no_id: i64, + pub codein: String, + pub code_centrale: String, + pub libelle1: String, + /// Nom du fournisseur principal (artfou1.preference = 1), sinon son code. + pub fournisseur: String, + pub codefou: String, + pub nomenclature_code: String, + pub nomenclature: String, + pub stock_total: f64, + /// true si le produit a déjà des métriques réseau Qlik en cache. + pub has_reseau: bool, + pub qte_reseau: f64, + pub nb_magasins_reseau: i64, +} + +fn produit_search_row(r: &PgRow) -> PgProduitSearchRow { + PgProduitSearchRow { + no_id: oi(r, "no_id").unwrap_or(0), + codein: s(r, "codein"), + code_centrale: s(r, "code_centrale"), + libelle1: s(r, "libelle1"), + fournisseur: s(r, "fournisseur"), + codefou: s(r, "codefou"), + nomenclature_code: s(r, "nomenclature_code"), + nomenclature: s(r, "nomenclature"), + stock_total: f(r, "stock_total"), + has_reseau: ob(r, "has_reseau").unwrap_or(false), + qte_reseau: f(r, "qte_reseau"), + nb_magasins_reseau: oi(r, "nb_magasins_reseau").unwrap_or(0), + } +} + +/// Caractères accentués et leur équivalent non accentué, pour `translate()`. +/// Les deux chaînes DOIVENT rester de même longueur, caractère par caractère. +/// Postgres n'a pas `unaccent()` sans l'extension : on ne peut pas compter dessus +/// sur la base FF, d'où cette table de correspondance explicite. +const SQL_ACCENTS_FROM: &str = "àâäãáåéèêëíìîïóòôöõúùûüçñýÿ"; +const SQL_ACCENTS_TO: &str = "aaaaaaeeeeiiiiooooouuuucnyy"; + +/// Lettre de base d'un caractère latin accentué (équivalent de la +/// décomposition NFD suivie du retrait des diacritiques). +fn sans_accent(c: char) -> Option { + Some(match c { + 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' | 'ǎ' => 'a', + 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' | 'Ā' | 'Ă' | 'Ą' | 'Ǎ' => 'A', + 'ç' | 'ć' | 'ĉ' | 'ċ' | 'č' => 'c', + 'Ç' | 'Ć' | 'Ĉ' | 'Ċ' | 'Č' => 'C', + 'ď' => 'd', + 'Ď' => 'D', + 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => 'e', + 'È' | 'É' | 'Ê' | 'Ë' | 'Ē' | 'Ĕ' | 'Ė' | 'Ę' | 'Ě' => 'E', + 'ĝ' | 'ğ' | 'ġ' | 'ģ' => 'g', + 'Ĝ' | 'Ğ' | 'Ġ' | 'Ģ' => 'G', + 'ĥ' => 'h', + 'Ĥ' => 'H', + 'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' | 'ǐ' => 'i', + 'Ì' | 'Í' | 'Î' | 'Ï' | 'Ĩ' | 'Ī' | 'Ĭ' | 'Į' | 'İ' | 'Ǐ' => 'I', + 'ĵ' => 'j', + 'Ĵ' => 'J', + 'ķ' => 'k', + 'Ķ' => 'K', + 'ĺ' | 'ļ' | 'ľ' => 'l', + 'Ĺ' | 'Ļ' | 'Ľ' => 'L', + 'ñ' | 'ń' | 'ņ' | 'ň' => 'n', + 'Ñ' | 'Ń' | 'Ņ' | 'Ň' => 'N', + 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ō' | 'ŏ' | 'ő' | 'ǒ' => 'o', + 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ō' | 'Ŏ' | 'Ő' | 'Ǒ' => 'O', + 'ŕ' | 'ŗ' | 'ř' => 'r', + 'Ŕ' | 'Ŗ' | 'Ř' => 'R', + 'ś' | 'ŝ' | 'ş' | 'š' | 'ș' => 's', + 'Ś' | 'Ŝ' | 'Ş' | 'Š' | 'Ș' => 'S', + 'ţ' | 'ť' | 'ț' => 't', + 'Ţ' | 'Ť' | 'Ț' => 'T', + 'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' | 'ǔ' => 'u', + 'Ù' | 'Ú' | 'Û' | 'Ü' | 'Ũ' | 'Ū' | 'Ŭ' | 'Ů' | 'Ű' | 'Ų' | 'Ǔ' => 'U', + 'ŵ' => 'w', + 'Ŵ' => 'W', + 'ý' | 'ÿ' | 'ŷ' => 'y', + 'Ý' | 'Ÿ' | 'Ŷ' => 'Y', + 'ź' | 'ż' | 'ž' => 'z', + 'Ź' | 'Ż' | 'Ž' => 'Z', + _ => return None, + }) +} + +/// Retire les diacritiques (lettres latines précomposées et marques combinantes). +fn retirer_accents(s: &str) -> String { + s.chars() + .filter(|c| !('\u{0300}'..='\u{036f}').contains(c)) + .map(|c| sans_accent(c).unwrap_or(c)) + .collect() +} + +/// Normalise un terme de recherche, de la même façon que +/// `normalized_libelle_sql()` le fait côté SQL : minuscules, accents retirés, +/// toute ponctuation ramenée à un espace. +pub fn normalize_search_term(term: &str) -> String { + static RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"[^a-z0-9]+").unwrap()); + let base = retirer_accents(&term.to_lowercase()); + RE.replace_all(&base, " ").trim().to_string() +} + +/// Comparaison de chaînes approchant `localeCompare(…, "fr")` : casse et accents +/// ignorés au premier niveau. +pub fn compare_fr(a: &str, b: &str) -> std::cmp::Ordering { + let pa = retirer_accents(&a.to_lowercase()); + let pb = retirer_accents(&b.to_lowercase()); + pa.cmp(&pb).then_with(|| a.to_lowercase().cmp(&b.to_lowercase())).then_with(|| b.cmp(a)) +} + +/// Recherche un produit par **libellé** ou par **code centrale** (préfixe). +/// Si le terme est entièrement numérique, on tente aussi une correspondance +/// exacte sur le `codein`. +/// +/// La recherche par libellé est **insensible aux accents et à la ponctuation**, +/// et fonctionne **mot à mot** : « tapis anti-poussière » retrouve +/// « TAPIS ANTI POUSSIERE », et « tapis poussiere » aussi (tous les mots doivent +/// être présents, dans n'importe quel ordre). Sans cette normalisation, un +/// `ILIKE` brut exigeait l'accent ET le tiret exacts et ne remontait rien. +/// +/// ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée +/// chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc +/// un seq-scan avec normalisation par ligne : l'appelant impose un minimum de +/// 3 caractères et une soumission explicite, jamais une recherche à la frappe. +/// +/// `limit` : 50 dans le code d'origine. Liste vide en cas d'erreur. +pub async fn pg_search_produits(term: &str, limit: i64) -> Vec { + let cleaned = term.trim(); + if cleaned.chars().count() < 3 { + return vec![]; + } + + let prefix = format!("{cleaned}%"); + // Mots de la recherche, normalisés. Tous doivent être présents (AND). + let normalized = normalize_search_term(cleaned); + let tokens: Vec<&str> = normalized.split(' ').filter(|t| !t.is_empty()).collect(); + + let mut qb = QueryBuilder::::new( + " + WITH raw AS ( + SELECT DISTINCT ON (TRIM(a.codein::text)) + a.no_id, + TRIM(a.codein::text) AS codein, + COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1, + a.nom_no_id + FROM articles a + WHERE a.codein IS NOT NULL + AND ( + ", + ); + if tokens.is_empty() { + qb.push("FALSE"); + } else { + qb.push("("); + for (i, t) in tokens.iter().enumerate() { + if i > 0 { + qb.push(" AND "); + } + // Expression SQL renvoyant le libellé normalisé (minuscules, sans accent ni ponctuation). + qb.push("regexp_replace(translate(lower(COALESCE(a.libelle1, '')), ") + .push_bind(SQL_ACCENTS_FROM) + .push(", ") + .push_bind(SQL_ACCENTS_TO) + .push("), '[^a-z0-9]+', ' ', 'g') LIKE ") + .push_bind(format!("%{t}%")); + } + qb.push(")"); + } + qb.push("\n OR TRIM(a.artcentrale::text) ILIKE ").push_bind(prefix).push("\n "); + // Un terme purement numérique peut être un codein : on l'ajoute au filtre + // (correspondance exacte, la colonne est space-padded d'où le TRIM). + if cleaned.chars().all(|c| c.is_ascii_digit()) { + qb.push("OR TRIM(a.codein::text) = ").push_bind(cleaned.to_string()); + } + qb.push( + " + ) + ORDER BY TRIM(a.codein::text), a.no_id DESC + ), + matched AS ( + SELECT * FROM raw ORDER BY libelle1 LIMIT ", + ); + qb.push_bind(limit); + qb.push( + " + ) + SELECT + m.no_id, + m.codein, + m.code_centrale, + m.libelle1, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + COALESCE(af.code, '')::text AS codefou, + COALESCE(n.code, '')::text AS nomenclature_code, + COALESCE(n.libelle, '')::text AS nomenclature, + COALESCE(st.stock_total, 0)::float AS stock_total, + (q.code_centrale IS NOT NULL) AS has_reseau, + COALESCE(q.qte_reseau, 0)::float AS qte_reseau, + COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau + FROM matched m + LEFT JOIN LATERAL ( + SELECT af1.code + FROM artfou1 af1 + WHERE af1.art_no_id = m.no_id AND af1.preference = 1 + ORDER BY af1.code + LIMIT 1 + ) af ON TRUE + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id + LEFT JOIN LATERAL ( + SELECT SUM(cs.qte)::float AS stock_total + FROM cube_stock cs + WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579') + ) st ON TRUE + LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale + ORDER BY m.libelle1 + ", + ); + + let res = async { anyhow::Ok(qb.build().fetch_all(&pool()?).await?) }.await; + match res { + Ok(rows) => { + tracing::info!("[pg-ff] pgSearchProduits(\"{cleaned}\"): {} résultats", rows.len()); + rows.iter().map(produit_search_row).collect() + } + Err(e) => { + tracing::error!("[pg-ff] pgSearchProduits error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy. +/// +/// C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau +/// travaille, cette requête dit lesquels nous référençons — et avec quel +/// `codein`, quel fournisseur et quel stock. Un code absent du résultat est un +/// produit réseau que Nancy ne référence pas (cas normal et intéressant). +/// +/// Les codeins sont renvoyés indexés par code centrale ; un même code centrale +/// peut porter plusieurs codeins (ré-créations d'article), on ne garde que le +/// `no_id` le plus récent. Map vide en cas d'erreur. +pub async fn pg_get_produits_by_code_centrale(codes_centraux: &[String]) -> HashMap { + let mut out = HashMap::new(); + let mut seen = HashSet::new(); + let codes: Vec = + codes_centraux.iter().map(|c| c.trim().to_string()).filter(|c| !c.is_empty() && seen.insert(c.clone())).collect(); + if codes.is_empty() { + return out; + } + + let mut qb = QueryBuilder::::new( + " + WITH matched AS ( + SELECT DISTINCT ON (TRIM(a.artcentrale::text)) + a.no_id, + TRIM(a.codein::text) AS codein, + TRIM(a.artcentrale::text) AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1, + a.nom_no_id + FROM articles a + WHERE TRIM(a.artcentrale::text) IN (", + ); + let mut sep = qb.separated(", "); + for c in &codes { + sep.push_bind(c.clone()); + } + qb.push( + ") + AND a.codein IS NOT NULL + ORDER BY TRIM(a.artcentrale::text), a.no_id DESC + ) + SELECT + m.no_id, + m.codein, + m.code_centrale, + m.libelle1, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + COALESCE(af.code, '')::text AS codefou, + COALESCE(n.code, '')::text AS nomenclature_code, + COALESCE(n.libelle, '')::text AS nomenclature, + COALESCE(st.stock_total, 0)::float AS stock_total, + TRUE AS has_reseau, + 0::float AS qte_reseau, + 0 AS nb_magasins_reseau + FROM matched m + LEFT JOIN LATERAL ( + SELECT af1.code + FROM artfou1 af1 + WHERE af1.art_no_id = m.no_id AND af1.preference = 1 + ORDER BY af1.code + LIMIT 1 + ) af ON TRUE + LEFT JOIN fouident fi ON fi.code = af.code + LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id + LEFT JOIN LATERAL ( + SELECT SUM(cs.qte)::float AS stock_total + FROM cube_stock cs + WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579') + ) st ON TRUE + ", + ); + + let res = async { anyhow::Ok(qb.build().fetch_all(&pool()?).await?) }.await; + match res { + Ok(rows) => { + for r in &rows { + let row = produit_search_row(r); + out.insert(row.code_centrale.clone(), row); + } + tracing::info!( + "[pg-ff] pgGetProduitsByCodeCentrale: {} codes Qlik → {} trouvés dans le catalogue Nancy", + codes.len(), + out.len() + ); + out + } + Err(e) => { + tracing::error!("[pg-ff] pgGetProduitsByCodeCentrale error: {}", short(&message_erreur(&e), 250)); + out + } + } +} + +/// `codein` de l'article portant ce code centrale, ou `None` si Nancy ne le +/// référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik +/// (URL `?cc=…`), qui n'a que le code centrale. `None` en cas d'erreur. +pub async fn pg_get_codein_by_code_centrale(code_centrale: &str) -> Option { + let cleaned = code_centrale.trim(); + if cleaned.is_empty() { + return None; + } + let res = async { + let rows = sqlx::query( + r#" + SELECT TRIM(a.codein::text) AS codein + FROM articles a + WHERE TRIM(a.artcentrale::text) = $1 + AND a.codein IS NOT NULL + ORDER BY a.no_id DESC + LIMIT 1 + "#, + ) + .bind(cleaned) + .fetch_all(&pool()?) + .await?; + anyhow::Ok(rows.first().and_then(|r| os(r, "codein"))) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetCodeinByCodeCentrale error: {}", short(&message_erreur(&e), 250)); + None + } + } +} + +/// Identité, prix et nomenclature d'un produit (une seule ligne). +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgProduitDetailRow { + pub no_id: i64, + pub codein: String, + pub code_centrale: String, + pub libelle1: String, + pub nom_no_id: Option, + pub gtin: String, + pub reference: String, + pub pcb: Option, + /// article_infosup.prix_vente_mini + pub pv_central: Option, + /// cube_pa.pa + pub pa: Option, + pub code3: Option, + pub libelle3: Option, + pub code2: Option, + pub libelle2: Option, + pub code1: Option, + pub libelle1nom: Option, +} + +/// Fiche d'identité complète d'un produit à partir de son `codein`. +/// Renvoie `None` si le codein est inconnu (ou en cas d'erreur). +pub async fn pg_get_produit_detail(codein: &str) -> Option { + let res = async { + let parent_col = get_nomenclature_parent_col().await?; + // Hiérarchie nomenclature — deux stratégies : + // + // 1. Colonne parent entière si elle existe (FK vers le no_id du niveau au-dessus). + // 2. Sinon, remontée par **préfixe de code** : la nomenclature FF est encodée + // hiérarchiquement, secteur = 2 chiffres, famille = 4, sous-famille = 6 + // (ex "330702" → "3307" → "33"). En production `nomenclature` n'expose que + // no_id / code / libelle / niveau / chemin_pere : aucune FK entière, et + // `chemin_pere` est un chemin texte. Sans ce repli, Secteur et Famille + // restaient vides sur la fiche. + let hierarchy = match parent_col.as_deref() { + Some(pc) => { + let pc = quote_ident(pc); + format!( + " + LEFT JOIN nomenclature n2 ON n2.no_id = n3.{pc} + LEFT JOIN nomenclature n1 ON n1.no_id = n2.{pc} + " + ) + } + None => " + LEFT JOIN LATERAL ( + SELECT nx.code, nx.libelle + FROM nomenclature nx + WHERE LENGTH(TRIM(n3.code)) >= 4 + AND TRIM(nx.code) = LEFT(TRIM(n3.code), 4) + LIMIT 1 + ) n2 ON TRUE + LEFT JOIN LATERAL ( + SELECT nx.code, nx.libelle + FROM nomenclature nx + WHERE LENGTH(TRIM(n3.code)) >= 2 + AND TRIM(nx.code) = LEFT(TRIM(n3.code), 2) + LIMIT 1 + ) n1 ON TRUE + " + .to_string(), + }; + // Les colonnes sont identiques dans les deux stratégies (alias n1 / n2). + let hierarchy_cols = ", + n2.code AS code2, + n2.libelle AS libelle2, + n1.code AS code1, + n1.libelle AS libelle1nom"; + + let sql = format!( + r#" + SELECT + a.no_id, + TRIM(a.codein::text) AS codein, + COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1, + a.nom_no_id, + COALESCE(ag.gtin, af.ean13, '')::text AS gtin, + COALESCE(af.reference, '')::text AS reference, + af.pcb::float AS pcb, + ai.prix_vente_mini::float AS pv_central, + pa.pa::float AS pa, + n3.code AS code3, + n3.libelle AS libelle3 + {hierarchy_cols} + FROM articles a + LEFT JOIN LATERAL ( + SELECT af1.code, af1.pcb, af1.reference, af1.ean13 + FROM artfou1 af1 + WHERE af1.art_no_id = a.no_id + ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC + LIMIT 1 + ) af ON TRUE + LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1 + LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id + LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id + LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id + {hierarchy} + WHERE TRIM(a.codein::text) = $1 + ORDER BY a.no_id DESC + LIMIT 1 + "# + ); + let rows = sqlx::query(&sql).bind(codein).fetch_all(&pool()?).await?; + anyhow::Ok(rows.first().map(|r| PgProduitDetailRow { + no_id: oi(r, "no_id").unwrap_or(0), + codein: s(r, "codein"), + code_centrale: s(r, "code_centrale"), + libelle1: s(r, "libelle1"), + nom_no_id: oi(r, "nom_no_id"), + gtin: s(r, "gtin"), + reference: s(r, "reference"), + pcb: of(r, "pcb"), + pv_central: of(r, "pv_central"), + pa: of(r, "pa"), + code3: os(r, "code3"), + libelle3: os(r, "libelle3"), + code2: os(r, "code2"), + libelle2: os(r, "libelle2"), + code1: os(r, "code1"), + libelle1nom: os(r, "libelle1nom"), + })) + } + .await; + match res { + Ok(row) => { + tracing::info!("[pg-ff] pgGetProduitDetail({codein}): {}", if row.is_some() { "trouvé" } else { "introuvable" }); + row + } + Err(e) => { + tracing::error!("[pg-ff] pgGetProduitDetail error: {}", short(&message_erreur(&e), 250)); + None + } + } +} + +/// Un fournisseur référencé pour l'article. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgProduitFournisseurRow { + pub codefou: String, + pub nomfou: String, + pub reference: String, + pub pcb: Option, + /// true si artfou1.preference = 1 (`null` si la préférence est vide) + pub principal: Option, +} + +/// Tous les fournisseurs référencés pour un article (le principal en premier). +/// Liste vide en cas d'erreur. +pub async fn pg_get_fournisseurs_by_codein(codein: &str) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (af.code) + af.code::text AS codefou, + COALESCE(fi.nom, af.code)::text AS nomfou, + COALESCE(af.reference, '')::text AS reference, + af.pcb::float AS pcb, + (af.preference = 1) AS principal + FROM articles a + JOIN artfou1 af ON af.art_no_id = a.no_id + LEFT JOIN fouident fi ON fi.code = af.code + WHERE TRIM(a.codein::text) = $1 + ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC + "#, + ) + .bind(codein) + .fetch_all(&pool()?) + .await?; + let mut rows: Vec = rows + .iter() + .map(|r| PgProduitFournisseurRow { + codefou: s(r, "codefou"), + nomfou: s(r, "nomfou"), + reference: s(r, "reference"), + pcb: of(r, "pcb"), + principal: ob(r, "principal"), + }) + .collect(); + // Le principal remonte en tête (le DISTINCT ON impose un tri par code). + let p = |x: &PgProduitFournisseurRow| if x.principal == Some(true) { 1 } else { 0 }; + rows.sort_by(|x, y| p(y).cmp(&p(x)).then_with(|| compare_fr(&x.nomfou, &y.nomfou))); + anyhow::Ok(rows) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetFournisseursByCodein error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Stock temps réel par site pour un article. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgProduitStockRow { + pub site: String, + pub stockdispo: f64, + pub qte: f64, + pub valstock: f64, + pub prmp: f64, + pub dernierevente: Option, + pub dernierereception: Option, +} + +/// Stock par site d'un article. Liste vide en cas d'erreur. +pub async fn pg_get_stock_by_codein(codein: &str) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + SELECT + cs.site, + COALESCE(cs.stockdispo, 0)::float AS stockdispo, + COALESCE(cs.qte, 0)::float AS qte, + COALESCE(cs.valstock, 0)::float AS valstock, + COALESCE(cs.prmp, 0)::float AS prmp, + cs.dernierevente::text AS dernierevente, + cs.dernierereception::text AS dernierereception + FROM cube_stock cs + JOIN articles a ON a.no_id = cs.artnoid + WHERE TRIM(a.codein::text) = $1 + AND cs.site IN ('292', '579') + ORDER BY cs.site + "#, + ) + .bind(codein) + .fetch_all(&pool()?) + .await?; + anyhow::Ok( + rows.iter() + .map(|r| PgProduitStockRow { + site: s(r, "site"), + stockdispo: f(r, "stockdispo"), + qte: f(r, "qte"), + valstock: f(r, "valstock"), + prmp: f(r, "prmp"), + dernierevente: os(r, "dernierevente"), + dernierereception: os(r, "dernierereception"), + }) + .collect(), + ) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetStockByCodein error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Quantité totale en commande fournisseur pour un article (0 en cas d'erreur). +pub async fn pg_get_commandes_by_codein(codein: &str) -> f64 { + let res = async { + let rows = sqlx::query( + r#" + SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde + FROM cdefou_vivant cv + WHERE TRIM(cv.articles_codein::text) = $1 + "#, + ) + .bind(codein) + .fetch_all(&pool()?) + .await?; + anyhow::Ok(rows.first().map(|r| f(r, "qtecde")).unwrap_or(0.0)) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetCommandesByCodein error: {}", short(&message_erreur(&e), 250)); + 0.0 + } + } +} + +/// Une gamme affectée à l'article pour une saison donnée. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgGammeHistoryRow { + pub saison_no_id: i64, + pub saison_code: Option, + pub saison_libelle: Option, + pub gamme_code: String, + pub gamme_libelle: Option, +} + +/// Historique complet des gammes de l'article, saison par saison (la plus +/// récente en premier). Le reste de l'application ne lit que la saison active +/// (`pg_get_gammes_by_fournisseur`) ; ici on veut la trajectoire d'arbitrage. +/// Liste vide en cas d'erreur. +pub async fn pg_get_gamme_history_by_codein(codein: &str) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + SELECT + s.no_id AS saison_no_id, + s.code::text AS saison_code, + s.libelle::text AS saison_libelle, + g.code::text AS gamme_code, + g.libelle::text AS gamme_libelle + FROM art_gamme_saison ags + JOIN articles a ON a.no_id = ags.artnoid + JOIN gammes g ON g.no_id = ags.idgamme + JOIN saisons s ON s.no_id = ags.idsaison + WHERE TRIM(a.codein::text) = $1 + ORDER BY s.no_id DESC + "#, + ) + .bind(codein) + .fetch_all(&pool()?) + .await?; + anyhow::Ok( + rows.iter() + .map(|r| PgGammeHistoryRow { + saison_no_id: oi(r, "saison_no_id").unwrap_or(0), + saison_code: os(r, "saison_code"), + saison_libelle: os(r, "saison_libelle"), + gamme_code: s(r, "gamme_code"), + gamme_libelle: os(r, "gamme_libelle"), + }) + .collect(), + ) + } + .await; + match res { + Ok(v) => v, + Err(e) => { + tracing::error!("[pg-ff] pgGetGammeHistoryByCodein error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions) +/// d'UN article, par site et par mois. +/// +/// Reprend **exactement** le calcul canonique de `pg_get_mensuel_by_fournisseur` +/// (la source de vérité de la Grille) : seule la sélection change — filtre sur +/// le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la +/// fiche produit sont donc identiques à ceux de la Grille, par construction. +/// Liste vide en cas d'erreur. +pub async fn pg_get_mensuel_by_codein(codein: &str, date_debut: &str, date_fin: &str) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + WITH base AS ( + SELECT + TRIM(a.codein::text) AS codein, + m.site, + TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, + m.qtemvt, + m.mntmvtttc, + m.margemvt, + m.genremvt, + m.qtestock, + ROW_NUMBER() OVER ( + PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM') + ORDER BY m.datmvt DESC + ) AS rn_last + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE TRIM(a.codein::text) = $1 + AND m.datmvt BETWEEN $2::date AND $3::date + AND m.site IN ('292', '579') + ) + SELECT + codein, + site, + mois, + SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, + SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, + SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, + MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, + SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue + FROM base + GROUP BY codein, site, mois + ORDER BY site, mois + "#, + ) + .bind(codein) + .bind(date_debut) + .bind(date_fin) + .fetch_all(&pool()?) + .await?; + anyhow::Ok(rows.iter().map(mensuel_row).collect::>()) + } + .await; + match res { + Ok(v) => { + tracing::info!("[pg-ff] pgGetMensuelByCodein({codein}): {} lignes", v.len()); + v + } + Err(e) => { + tracing::error!("[pg-ff] pgGetMensuelByCodein error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +/// Un produit de la même famille, comparé local vs réseau. +#[derive(Debug, Clone, Default, Serialize)] +pub struct PgOpportuniteRow { + pub codein: String, + pub libelle1: String, + pub code_centrale: String, + pub fournisseur: String, + pub qte_reseau: f64, + pub nb_magasins_reseau: f64, + pub ca_reseau: f64, + pub ca_par_magasin_reseau: f64, + pub qte_locale: f64, + pub nb_sites_locaux: f64, + pub qte_reseau_par_magasin: f64, + pub qte_locale_par_magasin: f64, + /// Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. + pub ecart_par_magasin: f64, +} + +/// Produits de la même nomenclature, classés par écart entre la performance +/// réseau et la performance locale (quantités **par magasin**, pour neutraliser +/// la différence d'échelle entre ~270 magasins et nos 2 sites). +/// +/// Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà +/// des métriques réseau en cache : la dimension Qlik interrogée est +/// `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas. +/// +/// `limit` : 30 dans le code d'origine. Liste vide en cas d'erreur. +pub async fn pg_get_opportunites_famille(nom_no_id: i64, date_debut: &str, date_fin: &str, limit: i64) -> Vec { + let res = async { + let rows = sqlx::query( + r#" + WITH famille AS ( + SELECT DISTINCT ON (TRIM(a.codein::text)) + a.no_id, + TRIM(a.codein::text) AS codein, + TRIM(a.artcentrale::text) AS code_centrale, + COALESCE(a.libelle1, '') AS libelle1 + FROM articles a + WHERE a.nom_no_id = $1 + AND a.codein IS NOT NULL + AND a.artcentrale IS NOT NULL + AND TRIM(a.artcentrale::text) <> '' + ORDER BY TRIM(a.codein::text), a.no_id DESC + ), + avec_reseau AS ( + SELECT + f.no_id, + f.codein, + f.code_centrale, + f.libelle1, + q.qte_reseau::float AS qte_reseau, + q.nb_magasins_reseau AS nb_magasins_reseau, + COALESCE(q.ca_reseau, 0)::float AS ca_reseau, + COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau + FROM famille f + JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale + WHERE q.nb_magasins_reseau > 0 + AND q.qte_reseau > 0 + ), + ventes AS ( + SELECT + TRIM(a.codein::text) AS codein, + SUM(-m.qtemvt)::float AS qte_locale, + COUNT(DISTINCT m.site) AS nb_sites_locaux + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.genremvt = 3 + AND m.site IN ('292', '579') + AND m.datmvt BETWEEN $2::date AND $3::date + AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau) + GROUP BY TRIM(a.codein::text) + ) + SELECT + r.codein, + r.libelle1, + r.code_centrale, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, + r.qte_reseau, + r.nb_magasins_reseau, + r.ca_reseau, + r.ca_par_magasin_reseau, + COALESCE(v.qte_locale, 0)::float AS qte_locale, + COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux, + (r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin, + (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin, + ( + (r.qte_reseau / r.nb_magasins_reseau) + - (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1)) + )::float AS ecart_par_magasin + FROM avec_reseau r + LEFT JOIN ventes v ON v.codein = r.codein + LEFT JOIN LATERAL ( + SELECT af1.code + FROM artfou1 af1 + WHERE af1.art_no_id = r.no_id AND af1.preference = 1 + ORDER BY af1.code + LIMIT 1 + ) af ON TRUE + LEFT JOIN fouident fi ON fi.code = af.code + ORDER BY ecart_par_magasin DESC + LIMIT $4 + "#, + ) + .bind(nom_no_id) + .bind(date_debut) + .bind(date_fin) + .bind(limit) + .fetch_all(&pool()?) + .await?; + anyhow::Ok( + rows.iter() + .map(|r| PgOpportuniteRow { + codein: s(r, "codein"), + libelle1: s(r, "libelle1"), + code_centrale: s(r, "code_centrale"), + fournisseur: s(r, "fournisseur"), + qte_reseau: f(r, "qte_reseau"), + nb_magasins_reseau: f(r, "nb_magasins_reseau"), + ca_reseau: f(r, "ca_reseau"), + ca_par_magasin_reseau: f(r, "ca_par_magasin_reseau"), + qte_locale: f(r, "qte_locale"), + nb_sites_locaux: f(r, "nb_sites_locaux"), + qte_reseau_par_magasin: f(r, "qte_reseau_par_magasin"), + qte_locale_par_magasin: f(r, "qte_locale_par_magasin"), + ecart_par_magasin: f(r, "ecart_par_magasin"), + }) + .collect::>(), + ) + } + .await; + match res { + Ok(v) => { + tracing::info!("[pg-ff] pgGetOpportunitesFamille(nom={nom_no_id}): {} lignes", v.len()); + v + } + Err(e) => { + tracing::error!("[pg-ff] pgGetOpportunitesFamille error: {}", short(&message_erreur(&e), 250)); + vec![] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bornes() { + assert_eq!(bornes_mois("2026-03"), ("2026-03-01".into(), "2026-04-01".into())); + assert_eq!(bornes_mois("2025-12"), ("2025-12-01".into(), "2026-01-01".into())); + assert_eq!(bornes_mois("2025-09"), ("2025-09-01".into(), "2025-10-01".into())); + } + + #[test] + fn normalisation_recherche() { + assert_eq!(normalize_search_term("Tapis anti-POUSSIÈRE !"), "tapis anti poussiere"); + assert_eq!(normalize_search_term(" Crème brûlée "), "creme brulee"); + assert_eq!(SQL_ACCENTS_FROM.chars().count(), SQL_ACCENTS_TO.chars().count()); + } + + #[test] + fn tri_fr() { + let mut v = vec!["Zèbre", "école", "Abeille", "Éclair"]; + v.sort_by(|a, b| compare_fr(a, b)); + assert_eq!(v, vec!["Abeille", "Éclair", "école", "Zèbre"]); + } + + #[test] + fn commande_auto_mapping() { + let r = json!({ "site": 292, "codefou": "N009", "nom_fou": "X", "franco_ht": "150.5", "nb_articles": 3, + "montant_propo_ht": null, "montant_cde": "12", "franco_atteint": "NON", "ecart_franco": -3 }); + let m = map_commande_auto_row(&r); + assert_eq!(m.site, "292"); + assert_eq!(m.franco, 150.5); + assert_eq!(m.montant_cde, 12.0); + assert_eq!(m.franco_atteint, Some(false)); + assert_eq!(extract_franco(&json!({ "resume": [{ "franco_ht": "200" }] })), 200.0); + assert_eq!(extract_franco(&json!({ "error": "x" })), 0.0); + assert_eq!(extract_franco(&json!({ "resume": [] })), 0.0); + } } diff --git a/server/src/ff/routes.rs b/server/src/ff/routes.rs new file mode 100644 index 0000000..67e0e63 --- /dev/null +++ b/server/src/ff/routes.rs @@ -0,0 +1,516 @@ +//! Routes HTTP du module FF : `/api/ff-status`, `/api/diag`, +//! `/api/diag/stock-fournisseur`, `/api/test-db`, `/api/commandes-auto`. + +use super::api::{ff_client, get_ff_api_base}; +use super::diagnostic::diagnostiquer_api_ff; +use super::js::{encode, js_string, message_erreur}; +use super::pg::{pg_diag_fournisseur_derniere_entree, pg_get_stock_for_codeins}; +use crate::auth::{AdminUser, SessionUser}; +use crate::db::{pool, rows_to_json}; +use axum::extract::Query; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use chrono::{Datelike, Local, NaiveDate}; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; + +/// `String(e)` d'une erreur JS (`Error: message`). +fn string_erreur(e: &dyn std::fmt::Display) -> String { + format!("Error: {e}") +} + +// --------------------------------------------------------------------------- +// GET /api/ff-status +// --------------------------------------------------------------------------- + +/// GET /api/ff-status — état de l'API REST FF Nancy. +/// +/// S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin +/// qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP) +/// et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans +/// détail, impossible à distinguer d'une simple erreur d'URL. +pub async fn ff_status(_user: SessionUser) -> Response { + let res = diagnostiquer_api_ff(None).await; + if !res.success { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "API FF Nancy non disponible", "url": res.url, "detail": res.error })), + ) + .into_response(); + } + Json(res.status).into_response() +} + +// --------------------------------------------------------------------------- +// GET /api/diag/stock-fournisseur +// --------------------------------------------------------------------------- + +/// Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par +/// la page Gestion de Stock. +/// +/// `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes +/// fournisseur connues : la page retombe alors sur `artfou1.preference = 1`. +/// `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste +/// blanche `MVTART_FOU_CANDIDATES` si le nom diffère. +pub async fn diag_stock_fournisseur(_admin: AdminUser) -> Response { + match pg_diag_fournisseur_derniere_entree().await { + Ok(d) => Json(d).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": string_erreur(&message_erreur(&e)) }))).into_response(), + } +} + +// --------------------------------------------------------------------------- +// GET /api/test-db +// --------------------------------------------------------------------------- + +pub async fn test_db(_admin: AdminUser, Query(q): Query>) -> Response { + let codein = q.get("codein").filter(|c| !c.is_empty()).cloned().unwrap_or_else(|| "334152".into()); + let res = async { + let map = pg_get_stock_for_codeins(std::slice::from_ref(&codein)).await?; + let fou = sqlx::query( + r#" + SELECT + a.codein::text AS codein, + COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur + FROM articles a + LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 + LEFT JOIN fouident fi ON fi.code = af.code + WHERE a.codein::text = $1 + "#, + ) + .bind(&codein) + .fetch_all(&pool()?) + .await?; + let stock: Map = map.into_iter().map(|(k, v)| (k, serde_json::to_value(v).unwrap_or(Value::Null))).collect(); + anyhow::Ok(json!({ "stock": stock, "fou": rows_to_json(&fou) })) + } + .await; + match res { + Ok(v) => Json(v).into_response(), + Err(e) => { + // Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur. + tracing::error!("[api/test-db] {}", message_erreur(&e)); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": message_erreur(&e) }))).into_response() + } + } +} + +// --------------------------------------------------------------------------- +// GET /api/commandes-auto +// --------------------------------------------------------------------------- + +/// GET /api/commandes-auto[?site=292&codefou=N009] +/// +/// Proxy vers https://api.ffnancy.fr/api/commandes-auto +/// Transfère les query params site et codefou si présents. +pub async fn commandes_auto(_user: SessionUser, Query(q): Query>) -> Response { + let res = async { + let mut params: Vec<(&str, &str)> = vec![]; + if let Some(site) = q.get("site").filter(|s| !s.is_empty()) { + params.push(("site", site)); + } + if let Some(codefou) = q.get("codefou").filter(|s| !s.is_empty()) { + params.push(("codefou", codefou)); + } + let base = get_ff_api_base().await; + let url = format!("{base}/api/commandes-auto"); + let res = ff_client().get(&url).query(¶ms).send().await?; + if !res.status().is_success() { + let status = res.status(); + return anyhow::Ok( + (status, Json(json!({ "error": format!("API upstream HTTP {}", status.as_u16()) }))).into_response(), + ); + } + let data: Value = res.json().await?; + Ok(Json(data).into_response()) + } + .await; + match res { + Ok(r) => r, + Err(e) => { + tracing::error!("[api/commandes-auto] {}", message_erreur(&e)); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Erreur serveur" }))).into_response() + } + } +} + +// --------------------------------------------------------------------------- +// GET /api/diag +// --------------------------------------------------------------------------- + +/// Résumé d'une sonde : statut HTTP et aperçu du contenu. +async fn probe(url: String) -> Value { + let res = async { + let res = reqwest::Client::new().get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?; + let status = res.status().as_u16(); + let text = res.text().await?; + anyhow::Ok((status, text)) + } + .await; + let (status, text) = match res { + Ok(v) => v, + Err(e) => return json!({ "error": string_erreur(&message_erreur(&e)), "url": url }), + }; + let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.chars().take(800).collect())); + let keys_of = |v: Option<&Value>| -> Value { + match v.and_then(|x| x.as_object()) { + Some(o) => Value::from(o.keys().cloned().collect::>()), + None => json!([]), + } + }; + let summary = match &parsed { + Value::Array(a) => json!({ "count": a.len(), "firstKeys": keys_of(a.first()), "sample": a.iter().take(2).cloned().collect::>() }), + Value::Object(o) => { + let keys: Vec = o.keys().cloned().collect(); + match o.iter().find(|(_, v)| v.is_array()) { + Some((k, Value::Array(arr))) => json!({ + "wrapper": k, + "count": arr.len(), + "firstKeys": keys_of(arr.first()), + "sample": arr.iter().take(2).cloned().collect::>(), + }), + // Objet simple : montrer toutes les clés et le contenu complet + _ => json!({ "keys": keys, "full": parsed }), + } + } + other => other.clone(), + }; + json!({ "status": status, "url": url, "summary": summary }) +} + +/// Première liste d'une réponse : tableau direct ou première propriété tableau. +fn premiere_liste(data: &Value) -> Vec { + match data { + Value::Array(a) => a.clone(), + Value::Object(o) => o.values().find_map(|v| v.as_array().cloned()).unwrap_or_default(), + _ => vec![], + } +} + +async fn get_json(url: &str) -> anyhow::Result { + let res = reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?; + Ok(res.json::().await?) +} + +/// Champ texte d'un élément (`(x as Record).champ as string ?? null`). +fn champ(v: Option<&Value>, key: &str) -> Option { + v.and_then(|x| x.get(key)).filter(|x| !x.is_null()).map(js_string) +} + +pub async fn diag(_admin: AdminUser, Query(q): Query>) -> Response { + let ff_api_base = get_ff_api_base().await; + + // Date range: 12 derniers mois + let today = Local::now().date_naive(); + let first = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today); + let date_fin = first.pred_opt().unwrap_or(first).format("%Y-%m-%d").to_string(); + let date_debut = first.checked_sub_months(chrono::Months::new(12)).unwrap_or(first).format("%Y-%m-%d").to_string(); + + // Récupérer un codefou réel + un codein réel depuis l'API + let mut codefou: Option = q.get("codefou").filter(|s| !s.is_empty()).cloned(); + let mut first_codein: Option = q.get("codein").filter(|s| !s.is_empty()).cloned(); + let forced_noid: Option = q.get("noid").filter(|s| !s.is_empty()).cloned(); // ex: ?noid=129632 + + if codefou.is_none() { + if let Ok(data) = get_json(&format!("{ff_api_base}/api/fournisseurs?limit=1")).await { + let list = premiere_liste(&data); + codefou = champ(list.first(), "codefou"); + } + if codefou.is_none() { + codefou = Some("D005".into()); + } + } + let codefou = codefou.unwrap_or_default(); + let codefou_enc = encode(&codefou); + + if first_codein.is_none() { + if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=1")).await { + let list = premiere_liste(&data); + first_codein = champ(list.first(), "codein"); + } + } + + // Si un noid est forcé, on probe directement mensuel + referentiel pour cet article + let mensuel_noid_forced = async { + match &forced_noid { + Some(n) => { + probe(format!("{ff_api_base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", encode(n))).await + } + None => json!({ "skipped": "no ?noid= param" }), + } + }; + let referentiel_noid_forced = async { + match &forced_noid { + Some(n) => probe(format!("{ff_api_base}/api/articles/{}/referentiel", encode(n))).await, + None => json!({ "skipped": "no ?noid= param" }), + } + }; + // Test mensuel avec no_id auto-résolu + let mensuel_noid = async { + if first_codein.is_none() { + return json!({ "skipped": "no article" }); + } + if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=1")).await { + let list = premiere_liste(&data); + if let Some(noid) = champ(list.first(), "no_id").filter(|s| !s.is_empty()) { + return probe(format!( + "{ff_api_base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", + encode(&noid) + )) + .await; + } + } + json!({ "skipped": "no no_id available" }) + }; + // Referentiel complet (nomenclature + gamme + stock + prix) + let referentiel = async { + if first_codein.is_none() { + return json!({ "skipped": "no codefou" }); + } + if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=5")).await { + let list = premiere_liste(&data); + let art = list.get(4).or(list.first()); + if let Some(no_id) = champ(art, "no_id").filter(|s| !s.is_empty()) { + return probe(format!("{ff_api_base}/api/articles/{}/referentiel", encode(&no_id))).await; + } + } + json!({ "skipped": "no no_id" }) + }; + + let (fournisseurs, articles, mvt_date_debut, mensuel_noid, referentiel, mensuel_forced, referentiel_forced) = tokio::join!( + probe(format!("{ff_api_base}/api/fournisseurs?limit=2")), + probe(format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=2")), + probe(format!( + "{ff_api_base}/api/mouvements/articles?codefou={codefou_enc}&dateDebut={date_debut}&dateFin={date_fin}&limit=2" + )), + mensuel_noid, + referentiel, + mensuel_noid_forced, + referentiel_noid_forced, + ); + + // Test PostgreSQL + diagnostic ventesProduits + let (postgresql, ventes_produits_diag) = match async { + let pool = pool()?; + let r = sqlx::query("SELECT COUNT(*) as count FROM session_snapshots").fetch_all(&pool).await?; + + // Diagnostic ventesProduits : cherche par les 2 codes possibles d'AUXENCE + let vp_by_name = sqlx::query( + r#" + SELECT code_fournisseur, COUNT(*) as nb_rows, + COUNT(code_gamme) as nb_gamme, + COUNT(code_gamme_init) as nb_gamme_init, + COUNT(code3) as nb_code3 + FROM ventes_produits + WHERE code_fournisseur IN ('AUXENCE', 'A025', $1) + GROUP BY code_fournisseur + "#, + ) + .bind(&codefou) + .fetch_all(&pool) + .await?; + + // Sample de quelques codeins dans ventesProduits pour ce fournisseur + let vp_sample = sqlx::query( + r#" + SELECT codein, code_fournisseur, code_gamme, code_gamme_init, code3, libelle3 + FROM ventes_produits + WHERE code_fournisseur IN ('AUXENCE', 'A025', $1) + LIMIT 3 + "#, + ) + .bind(&codefou) + .fetch_all(&pool) + .await?; + + let snapshots = r.first().map(|row| crate::db::f64_or0(row, "count")).unwrap_or(0.0); + anyhow::Ok(( + json!({ "ok": true, "snapshots": snapshots }), + json!({ "byFournisseur": rows_to_json(&vp_by_name), "sample": rows_to_json(&vp_sample) }), + )) + } + .await + { + Ok(v) => v, + Err(e) => { + let msg = string_erreur(&message_erreur(&e)); + (json!({ "ok": false, "error": msg }), json!({ "error": msg })) + } + }; + + // Diagnostic analytics CA TTC + let mvtart_diag = match async { + let pool = pool()?; + // 1. Totaux mars 2025 avec mntmvtht et mntmvtttc pour comparer avec API (HT=225k, TTC=269k) + let totaux = sqlx::query( + r#" + SELECT site, + ABS(SUM(mntmvtht))::float AS sum_ht, + ABS(SUM(mntmvtttc))::float AS sum_ttc, + COUNT(*) AS nb_lignes + FROM mvtart + WHERE genremvt = 3 + AND TO_CHAR(datmvt, 'YYYY-MM') = '2025-03' + AND site IN ('292', '579') + GROUP BY site + "#, + ) + .fetch_all(&pool) + .await?; + // 2. Distribution des valeurs de preference dans artfou1 + let pref_dist = sqlx::query( + r#" + SELECT preference, COUNT(*) AS nb + FROM artfou1 + GROUP BY preference + ORDER BY preference + "#, + ) + .fetch_all(&pool) + .await?; + // 3. Ventes mars 2025 non attribuées (aucune ligne artfou1 avec preference=1) + let sans_preference = sqlx::query( + r#" + SELECT COUNT(DISTINCT m.artnoid) AS nb_articles_sans_pref + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.genremvt = 3 + AND TO_CHAR(m.datmvt, 'YYYY-MM') = '2025-03' + AND m.site IN ('292','579') + AND NOT EXISTS ( + SELECT 1 FROM artfou1 af + WHERE af.art_no_id = a.no_id AND af.preference = 1 + ) + "#, + ) + .fetch_all(&pool) + .await?; + anyhow::Ok(json!({ + "totaux_202503": rows_to_json(&totaux), + "artfou1_preference_distribution": rows_to_json(&pref_dist), + "articles_vendus_sans_pref1": rows_to_json(&sans_preference), + })) + } + .await + { + Ok(v) => v, + Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }), + }; + + // Diagnostic franco : colonnes DB avec "franco" + sample fouident + raw commandes-auto + let franco_diag = match async { + let pool = pool()?; + let franco_columns = sqlx::query( + r#" + SELECT table_name, column_name, data_type + FROM information_schema.columns + WHERE column_name ILIKE '%franco%' + OR column_name ILIKE '%min_cmd%' + OR column_name ILIKE '%mincde%' + OR column_name ILIKE '%min_commande%' + ORDER BY table_name, column_name + "#, + ) + .fetch_all(&pool) + .await?; + // Sample des 3 premières lignes de fouident pour voir toutes les colonnes + let fouident_sample = rows_to_json(&sqlx::query("SELECT * FROM fouident LIMIT 3").fetch_all(&pool).await?); + let foucad_sample = rows_to_json(&sqlx::query("SELECT * FROM foucad LIMIT 5").fetch_all(&pool).await?); + let cols = |rows: &[Value]| -> Value { + match rows.first().and_then(|r| r.as_object()) { + Some(o) => Value::from(o.keys().cloned().collect::>()), + None => json!([]), + } + }; + anyhow::Ok(json!({ + "francoColumns": rows_to_json(&franco_columns), + "fouidentColumns": cols(&fouident_sample), + "fouidentSample": fouident_sample, + "foucadColumns": cols(&foucad_sample), + "foucadSample": foucad_sample, + })) + } + .await + { + Ok(v) => v, + Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }), + }; + + // Raw commandes-auto : récupère toutes les lignes et filtre celles avec franco_ht manquant + let commandes_auto_franco_audit = match get_json(&format!("{ff_api_base}/api/commandes-auto")).await { + Ok(ca_data) => { + let ca_rows: Vec = match &ca_data { + Value::Array(a) => a.clone(), + d => d + .get("propositions") + .filter(|v| !v.is_null()) + .or_else(|| d.get("data").filter(|v| !v.is_null())) + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(), + }; + let franco = |r: &Value| r.get("franco_ht").filter(|v| !v.is_null()).map(super::js::js_number); + let with_franco = ca_rows.iter().filter(|r| franco(r).map(|f| f > 0.0).unwrap_or(false)).count(); + let without: Vec<&Value> = ca_rows.iter().filter(|r| franco(r).map(|f| f == 0.0).unwrap_or(true)).collect(); + json!({ + "total": ca_rows.len(), + "avecFranco": with_franco, + "sansFranco": without.len(), + "lignesSansFranco": without.iter().map(|r| { + let mut o = Map::new(); + for k in ["site", "codefou", "nom_fou", "franco_ht", "franco_atteint"] { + if let Some(v) = r.get(k) { + o.insert(k.to_string(), v.clone()); + } + } + Value::Object(o) + }).collect::>(), + }) + } + Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }), + }; + + // Probe des fournisseurs N/A pour trouver où est leur franco + let (detail_m036_292, detail_m030_579, fournisseur_m036, fournisseur_m030) = tokio::join!( + probe(format!("{ff_api_base}/api/commandes-auto/M036?site=292")), + probe(format!("{ff_api_base}/api/commandes-auto/M030?site=579")), + probe(format!("{ff_api_base}/api/fournisseurs/M036")), + probe(format!("{ff_api_base}/api/fournisseurs/M030")), + ); + let franco_na_probe = json!({ + "detailM036_292": detail_m036_292, + "detailM030_579": detail_m030_579, + "fournisseurM036": fournisseur_m036, + "fournisseurM030": fournisseur_m030, + }); + + // Raw commandes-auto (premier fournisseur) pour voir les champs réels retournés par l'API + let raw_commandes_auto = probe(format!("{ff_api_base}/api/commandes-auto")).await; + + Json(json!({ + "timestamp": crate::db::iso_utc(chrono::Utc::now()), + "dateRange": { "dateDebut": date_debut, "dateFin": date_fin }, + "codefouTested": codefou, + "firstCodeinTested": first_codein, + "forcedNoid": forced_noid.unwrap_or_else(|| "(none — pass ?noid=129632 to test a specific article)".into()), + "fournisseurs": fournisseurs, + "articles": articles, + "mouvements_dateDebut": mvt_date_debut, + "endpoints_auto": { + "articles/:no_id/mensuel": mensuel_noid, + "articles/:no_id/referentiel": referentiel, + }, + "endpoints_forced_noid": { + "articles/:noid/mensuel": mensuel_forced, + "articles/:noid/referentiel": referentiel_forced, + }, + "postgresql": postgresql, + "ventesProduitsDiag": ventes_produits_diag, + "mvtartDiag": mvtart_diag, + "francoDiag": franco_diag, + "rawCommandesAuto": raw_commandes_auto, + "commandesAutoFrancoAudit": commandes_auto_franco_audit, + "francoNaProbe": franco_na_probe, + })) + .into_response() +}