Compare commits

..
Author SHA1 Message Date
Claude 26123783eb Fournisseur : repli sur un fournisseur référencé quand aucun n'est marqué principal
Le Hit Parade, la recherche produit et les opportunités n'acceptaient que
artfou1.preference = 1 : un article dont la préférence n'est pas renseignée
dans FF (ou posée sur une ancienne fiche du même codein) s'affichait en
« Sans fournisseur ». On prend désormais le principal s'il existe, sinon le
fournisseur référencé le plus récent ; le Hit Parade cherche sur toutes les
fiches du codein.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WT49YnjumAVBjCB4EUXNL1
2026-10-12 22:08:34 +00:00
LogiFlow 02e258bf12 Merge pull request #27 from R0m1k3/claude/vigilant-goldberg-0pqg5x
Réécriture du serveur en Rust + image Docker mise à jour au démarrage
2026-10-12 23:28:51 +02:00
Claude 31303a078a Documentation : structure de l'interface et contrat du serveur à jour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 20:03:04 +00:00
Claude d28b3f12c7 Serveur : avertissements de code mort neutralisés (utilitaires portés sans appelant)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 20:01:23 +00:00
Claude 3b47241f89 Fusion du module Produits (recherche, fiche, opportunités)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:57:50 +00:00
Claude 1dce73afaa Synchro nocturne et préchauffage de la grille branchés (/api/admin/sync, /api/admin/grid-warmup, minuteur)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:57:50 +00:00
Claude f232673138 Portage du module Produits : recherche Qlik d'abord, fiche produit, opportunités
- /api/produits/search (POST démarre un job, GET suit l'avancement) : Qlik
  d'abord, repli catalogue FF (source: "db"), cache mémoire 10 min des seuls
  résultats Qlik exploitables, une recherche Qlik à la fois.
- /api/produits/opportunites : produits de la même famille, local vs réseau.
- Page produit-fiche (?codein= ou ?cc=) : getProduitFiche /
  getProduitFicheByCodeCentrale, report du stock de fin de mois.
- normalizeMargePct (compare-reseau) et types JSON identiques au client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:56:31 +00:00
Claude d52a942812 Fusion du module Grille (phase 2 : calcul, /api/grid/rows, /api/v1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:55:24 +00:00
Claude 97c1dea849 Serveur Rust : module grid, phase 2 (calcul de la grille, routes /api/grid et /api/v1)
- product_rows.rs : getProductRows complet (7 requêtes FF en parallèle,
  métriques réseau Qlik, report du dernier snapshot, filtre gamme Y,
  rattrapage d'un magasin par l'API FF), branché sur rows_cache (calculs en
  cours partagés, compteur d'enregistrements, compléments par magasin),
  instantané grid_rows resservi s'il date du mois (rafraîchi au-delà de 20 h),
  persistance non bloquante ; getStorePatch ; get_product_rows_for_sync pour la
  synchro nocturne et le préchauffage.
- store_patch.rs : buildStorePatchEntries.
- enrich.rs (api-enrich) et api_gammes.rs (affecterGammes, lireCorpsJson).
- Routes /api/grid/rows (NDJSON), /api/grid/rows/store-patch, /api/v1/grid
  (diffusion complète sans limit), /products/search, /products/{codein},
  /products/{codein}/gamme, /gammes, /fournisseurs, /network/{codeCentrale}.
- store.rs : upsert_grid_rows_shared, écriture par lots depuis les lignes
  partagées du cache (sans copie intégrale).

Écart volontaire : après un calcul à la demande, /api/v1 attend la fin de
l'écriture de l'instantané avant de le relire (le TS ne l'attendait pas et
pouvait répondre à tort « aucun article »).

Test d'intégration sur la base ff_fixture (GRID_FF_TEST_DATABASE_URL) : calcul,
cache, instantané, complément magasin via une fausse API FF, enrichissement et
toutes les routes (session et clé d'API).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:54:54 +00:00
Claude 46ecd1dccc Docker : niveau de journalisation par défaut du serveur (sans le bruit de sqlx)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:48:42 +00:00
Claude 8db3fe0335 Synchro nocturne : extraction Qlik branchée, fraîcheur du préchauffage lue en ISO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:37:23 +00:00
Claude 5a84faffb2 Fusion du module Grille (phase 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:34:45 +00:00
Claude 22a6c59488 Serveur Rust : module grid, phase 1 (instantané, snapshots, gammes, exports, API)
Portage de tout ce qui, dans la Grille, ne dépend ni de la base FF ni de Qlik :
- types.rs : ProductRow et types associés, JSON identique à JSON.stringify
  (camelCase, champs facultatifs omis, entiers sans décimale, NaN → null) et
  relecture sans perte des payloads grid_rows écrits par la version Node ;
- store.rs : grid_rows (upsert par lots de 200 + purge, instantané, report des
  gammes, requêtes filtrées/triées/paginées via QueryBuilder et liste blanche
  de tri, fiche, fournisseurs, fraîcheur, nomenclatures) ;
- rows_cache.rs : cache mémoire 10 min des lignes calculées (clé, LRU,
  patchGridRowsCache, invalidation, calculs en cours, compléments par magasin) ;
- network_trend.rs, months.rs, store_patch.rs : logique pure testée ;
- gammes_a_valider.rs, enregistrer.rs, snapshots.rs : actions getSnapshots,
  deleteSnapshot, saveSnapshot, saveDraftChanges, getGammesAValider,
  rejeterGammesAValider (validation zod reproduite, mêmes messages) ;
- exports.rs : /api/export/excel et /api/export/modified-gammes
  (rust_xlsxwriter, mêmes feuilles, styles, largeurs et formats) ;
- api_schemas.rs, zod.rs : schémas /api/v1, issues zod 4 à l'identique ;
- api_v1.rs, openapi.rs : /api/v1/nomenclatures et /api/v1/openapi.json
  (document identique octet pour octet à celui de la version Node).

Écart volontaire : listGridNomenclatures groupe par position (GROUP BY 1) ; la
requête drizzle répétait left(code3, $n) avec un autre paramètre et échouait
systématiquement sous PostgreSQL.

Test d'intégration (grid_rows, snapshots, gammes à valider) activé par
GRID_TEST_DATABASE_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:33:53 +00:00
Claude 56bbfaebe0 Nettoyage : scripts Node et ressources Next.js obsolètes supprimés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:33:00 +00:00
Claude 520e6131f8 Fusion de l'interface Vite (SPA React)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:30:57 +00:00
Claude 1294824863 Paramètres : test de connexion Qlik branché sur le module qlik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:30:42 +00:00
Claude f104719054 Interface : passage de Next.js à une SPA React (Vite + React Router)
Le serveur étant réécrit en Rust, l'interface devient une application
React 19 côté navigateur, compilée par Vite dans dist/ et servie par le
serveur Rust (STATIC_DIR). Composants, styles et logique cliente
inchangés : seule la plomberie Next.js est remplacée.

- Outillage : Vite 8, @vitejs/plugin-react, Tailwind 4 via PostCSS,
  React Router 7 (mode déclaratif), index.html, src/main.tsx, alias @/,
  proxy /api → localhost:5643 en développement, ESLint sans plugin Next.
- Adaptations (src/shims) : next/link, next/navigation (useRouter avec
  refresh, usePathname/useSearchParams suivant aussi les replaceState de
  l'interface), next-auth/react (session lue sur /api/auth/session,
  déconnexion par /api/auth/logout).
- Actions serveur → POST /api/actions/{nom} (src/lib/rpc.ts) : mêmes noms,
  mêmes signatures, mêmes types. La connexion passe par /api/auth/login.
- Pages → table des routes (src/router.tsx) : données lues sur
  /api/pages/{nom} avec Suspense (loading.tsx au premier chargement,
  page conservée pendant une navigation ou un router.refresh()),
  error.tsx en frontière d'erreur, not-found.tsx, titres d'onglet,
  pages d'administration réservées aux administrateurs.
- Suppression du code serveur Next.js (routes /api, accès base, Qlik,
  authentification, middleware…) ; types utiles à l'interface recopiés
  dans src/types/ff.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:30:20 +00:00
Claude b6f8f586f2 Fusion du module Qlik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:26:43 +00:00
Claude e3890c00d0 Paramètres : diagnostic et URL de l'API FF branchés sur le module ff
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:26:40 +00:00
Claude dc53bb000d Serveur Rust : module qlik (NTLM, Chromium/CDP, extraction réseau, recherche, cache, /api/qlik/sync)
Port fidèle de l'intégration Qlik Sense :
- client.rs : configuration (réglages > env > défauts), types NetworkMetric /
  QlikMonthMetrics / NetworkProduct (JSON identique au TS), xrfkey et flux
  NTLM -> ticket -> cookie X-Qlik-Session, l'échange type 1 / type 3 sur une
  seule connexion HTTP/1.1 dédiée (TLS permissif si tlsInsecure).
- ntlm.rs : messages NTLM identiques octet pour octet à httpntlm/ntlm.js
  (NTLMv2, NTLM2 Session Response, v1 LM/NT), vérifiés contre Node.
- browser.rs : Chromium partagé relancé après déconnexion, file unique des
  extractions, page en contexte isolé pilotée en CDP (cookies, identifiants
  HTTP, console, erreurs, websocket -> qlik-csrf-token, liaison cfCheckpoint,
  navigation domcontentloaded, évaluation JSON).
- playwright.rs + js/extraction.js : extraction 12 mois (script in-page repris
  tel quel), reprises globales, agrégation, contrôles avant écriture.
- search.rs + js/recherche.js : recherche produit dans Qlik.
- date_range.rs, validation.rs : logique pure, tests portés.
- network_cache.rs : lecture / upsert de qlik_network_metrics.
- sync.rs : route /api/qlik/sync (jobs asynchrones, journaux capturés).

Dépendances ajoutées : des, md4, md-5, hyper, hyper-util, http-body-util,
rustls, tokio-rustls, webpki-roots, indexmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:25:54 +00:00
Claude 7373996c19 Fusion du module FF Nancy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:25:09 +00:00
Claude 7dd5919264 Serveur Rust : portage du module FF Nancy (base miroir, API, routes, actions, pages)
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:24:28 +00:00
Claude b80e8f6f42 Synchro nocturne, préchauffage de la grille (branchement à venir) et README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:06:06 +00:00
Claude 87d5803b55 Docker : image Rust + SPA publiée sur GHCR, tirée au démarrage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:02:45 +00:00
Claude a878270081 Serveur Rust : comptes, clés d'API, Paramètres, réglages de synchro, journaux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:02:08 +00:00
Claude 559e6ed714 Contrat minimal ff::pg pour le module qlik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 18:57:16 +00:00
Claude 3f69b1904e Serveur Rust : socle (config, base, auth, sessions, journaux, cache FF, SPA)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 18:56:23 +00:00
LogiFlow d2c3dc7f8e Merge pull request #26 from R0m1k3/claude/fervent-cerf-5a027i
Export des gammes découpé en fichiers de 5 000 lignes maximum
2026-10-11 10:01:47 +02:00
Claude 1474c0cbb4 Export des gammes découpé en fichiers de 5 000 lignes maximum
Le logiciel de mise à jour des gammes refuse les fichiers trop gros
(ex. 28 590 lignes). L'export est désormais découpé en plusieurs
fichiers (_partie_1_sur_6, …) à intégrer un par un.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jxxh5X1FHMUPFu2S2JpyT
2026-10-11 07:56:43 +00:00
LogiFlow 60a1d38b6f Merge pull request #25 from R0m1k3/claude/loving-fermi-qdh0qy
Tableaux : largeur du contenu au lieu de s'étirer sur les écrans très larges
2026-10-06 14:07:09 +02:00
Claude 1f329b8595 Tableaux : largeur du contenu au lieu de s'étirer sur les écrans très larges
Sur un écran très large, la colonne de libellé absorbait tout l'espace en
trop : le nom du fournisseur se retrouvait à un millier de pixels de ses
chiffres. Le tableau prend désormais la largeur de son contenu (défilement
au-delà de l'écran) et l'option « grow » devient « label » : largeur
naturelle, retour à la ligne au-delà de 28rem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
2026-10-06 10:14:55 +00:00
LogiFlow a252636fc1 Merge pull request #24 from R0m1k3/claude/loving-fermi-qdh0qy
Tableaux plus lisibles : colonne extensible, lignes alternées, groupes par magasin, colonnes figées
2026-10-06 11:49:38 +02:00
Claude 4c67fb7171 Tableaux plus lisibles : colonne extensible, lignes alternées, groupes par magasin, colonnes figées
DataTable gagne quatre options de colonne :
- grow : la désignation prend la largeur restante, les chiffres restent
  groupés au lieu de s'étirer sur un écran large ;
- sticky : Code et Désignation restent visibles au défilement horizontal
  (désignation bornée à 45 % de la largeur sur mobile) ;
- group : en-tête commun par magasin et trait de séparation entre groupes ;
- highlight : fond teinté pour le groupe « Total nos magasins ».
Toutes les lignes alternent désormais de couleur, survol teinté.

Appliqué à Meilleures ventes (Référence déplacée après la Désignation),
Ventes par mois et Publicités.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
2026-10-06 09:38:35 +00:00
LogiFlow 4340e2999b Merge pull request #23 from R0m1k3/claude/loving-fermi-qdh0qy
Gammes de l'API à valider dans la Grille, analyses pleine largeur, référence dans Meilleures ventes
2026-10-06 11:28:59 +02:00
Claude b373dabeaa Gammes de l'API à valider dans la Grille, analyses pleine largeur, référence dans Meilleures ventes
- API /api/v1 : PUT /products/:codein/gamme et POST /gammes déposent une
  proposition (table gammes_a_valider) au lieu d'enregistrer. La Grille la
  charge comme modification non enregistrée (badge « API ») : Enregistrer
  la valide, Annuler la rejette. Lecture : champ gammeAValider, statut
  a_valider dans la réponse ; OpenAPI mise à jour.
- Meilleures ventes, Ventes par mois, Publicités : plus de largeur max
  centrée, les pages occupent toute la largeur.
- Meilleures ventes : colonne Référence (référence fournisseur), recherche
  et export Excel compris.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
2026-10-06 09:27:07 +00:00
LogiFlow efd6c00cf0 Merge pull request #21 from R0m1k3/claude/loving-ride-epppkv
Révision d'assortiment : CA par nomenclature, par magasin et face au réseau
2026-10-05 13:29:13 +02:00
Claude 3519597fb7 Révision d'assortiment : CA par nomenclature, par magasin et face au réseau
Nouveau bouton « Nomenclatures » dans la barre d'outils de la Grille : il
ouvre une modale qui regroupe les produits affichés par nomenclature (code3)
et donne, sur les 12 mois de la Grille, le CA de chaque magasin, le CA de nos
magasins, le CA réseau, le poids de la nomenclature chez nous et dans le
réseau (écart en points) et l'indice de chaque magasin face au magasin moyen
du réseau. Tri par colonne, filtre texte, ligne de total et export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KvNrtCGyjYgPHFoiUkJzhM
2026-10-05 10:12:49 +00:00
LogiFlow 3a0e3bad50 Merge pull request #20 from R0m1k3/claude/eloquent-cannon-2mm04o
API : affecter ou changer la gamme d'un produit
2026-10-05 10:27:23 +02:00
Claude 9448e68509 API : affecter ou changer la gamme d'un produit
Deux endpoints d'écriture dans /api/v1 :
- PUT /products/{codein}/gamme { gamme, fournisseur? } pour un article ;
- POST /gammes { fournisseur, changes[] } pour plusieurs articles d'un
  fournisseur, en tout ou rien.

Ils passent par le même enregistrement que la Grille (extrait de
saveDraftChanges dans enregistrerGammes) : snapshot du fournisseur, cache
de la Grille et instantané grid_rows. Gamme validée contre la liste des
gammes (A, B, C, Y, Z) ; un article déjà dans la gamme demandée n'est pas
réécrit. Spec OpenAPI et page de connexion à l'API mises à jour ; la
légende des gammes de la spec, qui citait une gamme D inexistante, suit
désormais lib/gammes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
2026-10-05 08:19:00 +00:00
LogiFlow 540ff7cf29 Merge pull request #19 from R0m1k3/claude/eloquent-cannon-2mm04o
Afficher les montants au centime dans toute l'application
2026-10-05 09:52:04 +02:00
Claude 4a11bdc3b0 Afficher les montants au centime dans toute l'application
Les CA, marges et valeurs de stock étaient arrondis à l'euro (fmtEur0,
fmtEntier suivi de « € », Math.round dans les exports). Tous les montants
sont désormais affichés avec deux décimales : accueil, grille, fiche
produit, hit-parade, publicités, analytics, snapshots, export PDF et
export Excel des manques d'assortiment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEXT61tGbsXHUDNBeghCXQ
2026-10-05 07:50:59 +00:00
Claude ad5da8f95d Qlik : données contrôlées avant écriture et récupération plus fiable
Fiabilité des données
- Contrôle de plausibilité avant d'écrire le cache (lib/qlik-validation.ts) :
  extraction refusée, cache inchangé, si le dernier mois pèse moins de 40 %
  des 3 précédents dans les 10 premiers jours du mois (mois pas encore
  chargé dans Qlik), ou si plus de la moitié des séries vendues ont la même
  valeur 12 mois (mesure insensible au mois).
- Valeurs impossibles corrigées (NaN, infini, nombre de magasins négatif).

Récupération
- Chromium relancé s'il est tombé : un navigateur déconnecté faisait échouer
  toutes les extractions jusqu'au redémarrage du serveur.
- Une seule session Qlik à la fois (extraction de nuit, synchro manuelle,
  recherche produit) : en parallèle, le serveur Qlik abandonnait les deux.
- Planificateur : un échec est retenté la nuit suivante (au lieu de 7 jours)
  et les données d'avant le mois en cours sont rafraîchies sans attendre le
  délai de fraîcheur. Page Synchronisation mise à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
2026-10-04 08:05:08 +00:00
Claude 8e37940040 Carte tendance réseau : nos magasins et une lecture en clair
- Phrase de lecture en tête : la tendance du réseau avec ses deux chiffres
  (qté par magasin, 4 derniers mois contre 4 premiers), puis notre
  situation (ventes 12 mois, stock, couverture, commandes en cours).
- Nouvelle section « Nos magasins » : stock fin de mois, ventes 12 mois,
  rythme mensuel, couverture, dernière entrée et écart au magasin moyen
  du réseau, par magasin et pour l'ensemble.
- Réseau : période affichée en clair, tuile CA réseau 12 mois, part du
  réseau dans la tuile des magasins vendeurs.
- Modal sorti de heatmap-grid.tsx (network-monthly-modal.tsx).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
2026-10-04 08:05:08 +00:00
Claude 48860271a5 Tendance réseau : de retour dans la Grille après le changement de mois
- Grille servie depuis l'instantané (lot 4) : les colonnes réseau sont
  relues dans le cache Qlik, comme le calcul en direct. Elles restaient
  figées à la date du calcul de l'instantané.
- Tendance : si la fenêtre du jour est incomplète mais que les 12 mois
  précédents le sont (cache Qlik pas encore resynchronisé depuis le
  changement de mois), la tendance porte sur ces 12 mois au lieu de
  disparaître. Le modal le signale.
- Courbe des magasins vendeurs alignée sur les mois de la tendance
  (modal Grille et fiche produit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
2026-10-04 07:29:29 +00:00
Claude acd51ecb88 Lot 7 : script de diagnostic de la base FF
scripts/diagnostic-ff.js (lecture seule) relève les réglages PostgreSQL, la
taille des tables, les types des colonnes de jointure, les index, les codes
article avec espaces et les plans d'exécution de six requêtes représentatives
(EXPLAIN, ou EXPLAIN ANALYZE avec --analyze). Il prépare les réécritures SQL
qui demandent la vraie base. Mode d'emploi dans docs/plan-optimisation-webui.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-04 06:49:07 +00:00
Claude af5933b5a7 Lot 6 : Grille plus légère, Commandes allégées et nettoyage
- Grille : réglages écrits dans le localStorage au plus toutes les 500 ms
  (immédiatement à la fermeture), tendance réseau et texte de recherche
  calculés une fois par ligne, courbe de tendance mémorisée, barre du bas
  abonnée au seul nombre de modifications.
- Commandes : appels « franco » 6 par 6 ; dernière réception limitée aux
  fournisseurs du cadencier (résultats identiques sur la base de test).
- Dépendances inutilisées retirées (react-query, @google/genai, ai,
  react-markdown, remark-gfm, xlsx, @radix-ui/react-dropdown-menu) ;
  pnpm-lock.yaml supprimé (npm est utilisé par Docker et le README).
- Code mort et journaux de build retirés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-04 06:48:43 +00:00
Claude 26b9364232 Lot 5 : changement de magasin instantané et contrôle des droits
Grille
- Corrige le lot 4 : au changement de magasin, les chiffres et le tri
  affichés suivent tout de suite (nouvelle liste), la sélection est gardée.
- Lignes toujours chargées « Nos 2 magasins » ; changement de magasin sans
  aller-retour serveur (URL mise à jour par l'historique du navigateur).
- Complément de l'API FF par magasin servi à part (/api/grid/rows/store-patch),
  appliqué à son arrivée, calcul identique à l'ancien (lib/store-patch.ts).
- La recherche ne reconstruit plus toutes les lignes à chaque frappe ; la
  sélection n'est remise à zéro qu'au changement de fournisseur.

Sécurité
- lib/authz.ts : session relue en base (cache 1 min), rôle de la base
  prioritaire ; variantes pour actions, routes et pages.
- Paramètres réservés aux administrateurs ; session vérifiée dans les actions
  Grille, Commandes, Historique et les routes ; diagnostics réservés admin.
- Mots de passe et clés d'IA plus envoyés au navigateur ; champ vide = mot de
  passe conservé ; plus de mot de passe dans le localStorage ; l'enregistrement
  de la base n'efface plus les clés d'IA.
- admin/admin créé seulement si aucun utilisateur ; fichier de secours utilisé
  uniquement si la base est injoignable ; data/ ignoré par git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-04 06:39:49 +00:00
Claude b7ee413d67 Lot 4 : Grille servie depuis l'instantané et requêtes allégées
- Grille : lecture de l'instantané grid_rows (gamme INIT et dernières gammes
  enregistrées reportées), recalcul en arrière-plan au-delà de 20 h, version de
  format dans chaque ligne pour écarter les anciens instantanés.
- Navigateur : lignes réutilisées au retour sur la Grille (< 10 min), Grille
  conservée pendant un changement de magasin ou une actualisation, React.memo
  sur la Grille, les filtres et la barre du bas, en-tête mémorisé.
- Fiche produit : toutes les lectures en parallèle.
- Ventes par mois et stock sans vente : plages de dates au lieu de TO_CHAR.
- Accueil : vagues de 12 appels, cache de 30 min.
- API v1 : comptage et page en parallèle, last_used_at au plus une fois par
  minute, version interne non exposée.
- Plan : suivi du lot 4, avec ce qui reste à valider sur la base réelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-03 21:15:33 +00:00
Claude 220ca2263a feat(ui): lot 3 — toutes les pages lisibles, cohérentes et en français clair
Chaque page reprend le socle du lot 2 : en-tête avec une phrase
d'explication, couleurs du thème (plus de pages figées en blanc), textes
de 12 px minimum, noms de magasins, infobulles sur le jargon, retours par
toast / confirmation, états vides et d'erreur expliqués.

- Accueil : CA, tickets et panier moyen d'hier vs N-1, par magasin, top 10
  avec lien vers la fiche produit.
- Révision d'assortiment : un seul endroit pour enregistrer et exporter
  (menu Export unique, mêmes droits pour tous), annulation confirmée, sens
  des gammes affiché partout, magasin choisi en un clic, colonnes nommées
  en clair, bandeau de progression au lieu de la fenêtre floue, droits lus
  côté serveur, ligne sélectionnée opaque sur les colonnes figées.
- Recherche produit, Stocks à surveiller (export FF inchangé), Meilleures
  ventes, Ventes par mois (dernier mois complet par défaut), Publicités,
  Commandes fournisseurs, Historique, Paramètres (onglets), Synchronisation
  et Connexion migrés sur DataTable / Tabs / Card.
- Le nom de l'utilisateur est transmis à la session (affiché dans
  l'en-tête).
- Composants devenus inutiles supprimés (anciennes fenêtres modales,
  export-dropdown, financial-cell, supplier-selection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-03 21:05:46 +00:00
Claude 0c890c84c5 feat(ui): lot 2 — socle visuel, menu regroupé et composants communs
Thème
- Clair par défaut ; les classes dark: suivent le bouton de thème (et non
  plus le réglage du système) ; animations tw-animate-css enfin importées.
- Couleurs shadcn branchées sur les variables (survols des menus visibles),
  libellés gris au contraste AA dans les deux thèmes, fond plein pour le
  menu et l'en-tête.

Composants partagés (src/components/ui)
- PageHeader, Card/Field, StatCard, Badge/DeltaBadge/StoreBadge/GammeBadge,
  Tabs/Segmented/useUrlTab, Select/SearchInput, EmptyState/ErrorState/
  Skeleton, Tooltip/Terme (glossaire), toast et confirmer, Pagination,
  DataTable (recherche, filtres, tri, pagination, export, totaux).
- Sources uniques : magasins, gammes (avec leur sens), glossaire, seuils de
  marge, export Excel.

Navigation
- Menu groupé (Au quotidien / Analyses / Suivi / Administration), déplié
  par défaut, libellés en français.
- En-tête : nom de la page, thème, menu du compte avec le vrai nom, la
  hauteur des lignes de la Grille et la déconnexion. La recherche globale,
  qui ne filtrait que la Grille, est retirée.
- Page Historique (sessions + exports) ; /snapshots et /exports y
  redirigent.
- Squelettes de chargement au lieu de la fenêtre plein écran, pages
  d'erreur et « introuvable » en français, arrivée sur l'Accueil après
  connexion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-03 20:45:03 +00:00
Claude 7da4c6d5fd perf: lot 1 — chargements plus rapides et corrections, sans changement visuel
Grille (navigateur)
- « Rafraîchir » ne passe plus par l'URL : le paramètre _refresh restait
  mémorisé et chaque retour par le menu forçait un recalcul serveur complet
  (migration du store pour nettoyer les valeurs déjà enregistrées).
- Lignes reçues regroupées toutes les 300 ms au lieu de reconstruire tout le
  tableau à chaque paquet de 150 (chargement quadratique).
- Sélection indexée par code article (getRowId) : après un filtre, l'action
  groupée visait d'autres produits. Seules les lignes affichées comptent.
- Recherche : un passage par ligne au lieu d'un par colonne ; formateurs de
  nombres partagés (lib/format.ts) ; colonnes mensuelles mémorisées ;
  plus de transition-all sur les lignes positionnées par transform.
- exceljs, jspdf et xlsx chargés au clic seulement.

Grille (serveur)
- Requêtes Qlik et snapshot lancées en parallèle de la phase SQL.
- Cache mémoire borné (LRU) ; calcul en cours réutilisé même en forcé ;
  flux interrompu quand le client part.
- Après un enregistrement, les gammes sont reportées dans le cache et dans
  grid_rows au lieu d'invalider (recalcul de ~40 s évité).

Données et API
- lib/ff-cache.ts : cache mémoire 30 min des lectures FF (stock, hit-parade,
  CA mensuel, fournisseurs, dernière réception, publicités), vidé en fin de
  synchro nocturne ; erreurs jamais gardées.
- Pool PostgreSQL unique (globalThis), réglage sans workers parallèles posé
  une fois par connexion au lieu d'une transaction autour de chaque requête.
- Délai maximal sur tous les appels à l'API FF.
- Index session_snapshots ; commandes : plus de double rechargement ;
  synchro : saisies regroupées ; paramètres : configuration lue une fois ;
  journal de capture en ajout seul, rien de formaté sans capture ouverte.

Corrections
- Historique : l'échec de chargement s'affiche (au lieu de « Aucun snapshot »).
- Sessions et validations enregistrent le magasin affiché, plus « TOTAL ».
- Publicités : libellé du statut « passées ».

Plan complet : docs/plan-optimisation-webui.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-03 20:28:14 +00:00
Claude 2c84cfda53 perf(stock): onglets instantanés, pagination et fournisseur de dernière entrée
Le changement d'onglet déclenchait un router.push : la page serveur était
rejouée et les 3 requêtes SQL relancées à chaque clic, alors que les données
étaient déjà côté client. Sans aucun retour visuel, l'onglet semblait bloqué.

Interface (stock-negatif/client.tsx)
- Changement d'onglet purement client (useTransition + history.replaceState) :
  plus aucune requête SQL rejouée, l'URL reste partageable.
- Modal de chargement pendant le changement de magasin (seule action qui
  interroge vraiment le serveur), pendant un changement d'onglet lent et
  pendant la génération de l'export Excel. Affichage différé de 120 ms pour
  éviter tout clignotement.
- Pagination 50 lignes : l'onglet « Sans vente 6 mois » rendait jusqu'à 17 000
  lignes d'un coup, ce qui figeait le navigateur. L'export Excel continue de
  reprendre l'intégralité des lignes filtrées.
- Tri : ordre de la base conservé tant qu'aucune colonne n'est cliquée (le tri
  alphabétique sur codein écrasait le classement métier), collateur Intl
  réutilisé au lieu d'un localeCompare par comparaison, tri numérique robuste
  aux valeurs nulles.
- Les 3 onglets partagent désormais un composant de tableau unique piloté par
  une configuration de colonnes, au lieu de 3 copies quasi identiques.
- Select magasin et onglets désactivés pendant le chargement, aria-busy posé.

Données (pg-ff-client.ts)
- Un article n'est plus rattaché à son fournisseur principal mais au
  fournisseur de sa dernière entrée en stock : s'il est rentré chez un autre
  fournisseur, il n'apparaît plus sous le précédent. La colonne de mvtart qui
  porte ce lien est détectée une fois par process parmi une liste blanche
  (même principe que getNomenclatureParentCol), avec repli documenté sur
  artfou1.preference = 1 si la base ne porte pas l'information.
- La jointure latérale sur la dernière entrée remplace la sous-requête
  corrélée MAX(datmvt) : même coût qu'avant pour une information de plus.
- pgGetStockSansVente dédoublonne artfou1 via DISTINCT ON comme ses deux
  requêtes sœurs, et exclut les stocks nuls en SQL (HAVING) au lieu de les
  filtrer côté client : le compteur d'onglet correspond enfin aux lignes
  réellement transmises.

Diagnostic
- GET /api/diag/stock-fournisseur : colonne détectée, colonnes réelles de
  mvtart et échantillon comparant fournisseur principal et fournisseur de
  dernière entrée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174nm1nWPaypWBNHioSTJ8X
2026-09-22 10:29:00 +00:00
Claude 585e171708 fix(grid): aligner la fenêtre 12 mois sur les données et tabuler les ventes du modal
Le total « Tot. 12m » est figé côté serveur sur SA fenêtre de 12 mois, tandis
que la Grille et le modal recalculaient les 12 mois depuis l'horloge du
navigateur. Dès que les deux divergeaient (onglet ouvert au changement de
mois, lignes servies depuis le cache), un mois de ventes disparaissait des
colonnes tout en restant compté dans le total : 28 sur la ligne, 20 dans le
modal.

- months.ts : `getMonthsFromRows()` lit la fenêtre dans les clés des séries
  reçues ; `getLast12Months()` ne sert plus que de repli avant chargement.
- heatmap-grid : MONTHS_12 dérivé des lignes chargées.
- product-monthly-modal : le tableau par magasin devient générique et sert
  aussi aux ventes, sous le graphique, avec une colonne de cumul « 12 m »
  qui retombe sur le total de la Grille.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177HLQz6w2hgoXpx6t1Rkn9
2026-09-02 10:43:26 +00:00
Claude 2404b22628 feat(grille): fige les colonnes d'identité à gauche
Sur 2600 px de tableau pour ~1150 px visibles, on voit 8 colonnes sur 18. Dès
qu'on défilait vers les totaux ou les mois, la Désignation sortait de l'écran
et plus rien ne disait quelle ligne on lisait.

Case à cocher, Code interne et Désignation restent désormais collés au bord
gauche pendant le défilement horizontal, en-tête compris.

Deux conséquences de conception :

- La Désignation a été rapprochée du Code interne dans l'ordre des colonnes.
  Le figeage exige des colonnes contiguës depuis la gauche ; en la laissant
  après Référence et GTIN, il aurait fallu geler ces deux-là aussi, soit 636 px
  immobilisés au lieu de 406. L'ordre « code, désignation, puis codes
  secondaires » est de toute façon celui qu'on lit.

- Les cellules figées doivent être OPAQUES, le reste de la ligne glissant
  dessous. Le survol passe donc par une teinte pleine et non par le voile
  translucide des cellules ordinaires, et l'en-tête figé reprend le dégradé du
  thead pour que la bande reste d'un seul tenant. Un filet marque la limite de
  la zone figée.

Les décalages sont calculés par cumul des largeurs réelles et s'arrêtent à la
première colonne non figée : masquer le Code interne réajuste tout seul, et une
colonne figée ne peut jamais se retrouver à flotter au milieu du tableau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-21 09:45:17 +00:00
Claude 7f5ef93469 fix(grille): défilement horizontal visible et barre du bas dans le flux
Les deux symptômes n'en faisaient qu'un. L'ascenseur horizontal EXISTAIT déjà
(conteneur en overflow-auto, table en min-width ~2600 px), mais il était caché
sous la carte de synthèse.

Celle-ci était en `position: fixed` et ne réservait donc aucune hauteur : seul
un `pb-12` posé sur la page compensait, trop court d'une quarantaine de pixels
— exactement la bande où se trouvent la barre de défilement et la dernière
ligne. Son `left-[264px]` était en prime calé sur une barre latérale dépliée,
alors qu'elle est repliée par défaut : 200 px de vide à gauche, qui écrasaient
ses statistiques en hauteur. Elle est remise dans le flux : la grille lui cède
exactement la place nécessaire, et il n'y a plus de valeur magique à tenir en
phase avec la sidebar.

L'ascenseur devient visible : la règle générale (8 px, rail transparent, pouce
à 12 % d'opacité) convient aux panneaux où le défilement est accessoire, pas à
la Grille où il est l'outil de navigation principal. Classe `.grid-scroll`
dédiée, avec rail dessiné, pouce saisissable, et prise en charge de Firefox que
les sélecteurs -webkit- ignorent.

Corrigé aussi :
- totalWidth lisait `columnDef.size` au lieu de `getSize()`. Le redimensionnement
  de colonnes étant actif et persisté, la largeur plancher restait figée sur
  l'ancienne somme : dernières colonnes rognées, course de défilement trop courte.
- Conteneur non focusable : flèches, Origine et Fin ne défilaient pas la grille.
- Barre de filtres qui passait sur deux lignes sous ~1200 px, volant 48 px au
  tableau : largeurs réduites en dessous de xl.
- Barre d'actions groupées sans flex-wrap, qui s'écrasait sur petit écran.
- Double croix dans la recherche : `type="search"` ajoutait la croix native du
  navigateur par-dessus le bouton maison. Masquage local — la recherche de
  l'en-tête n'a pas de bouton propre et garde donc le sien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-21 08:52:28 +00:00
LogiFlow 60ce3b8d21 Merge pull request #13 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-14 10:45:43 +02:00
Claude 828a50bfc0 Trous d'assortiment : la référence à côté du code interne
Le code interne identifie l'article chez nous, la référence l'identifie chez
le fournisseur : c'est elle qu'on recopie sur une commande. La fenêtre ne
donnait que le premier, obligeant à revenir à la Grille pour retrouver le
second — l'export, lui, portait déjà les deux.

Référence absente affichée « — » plutôt que laissée vide : une cellule vide
se lit comme une colonne qui n'a pas fini de charger.

Vérifié sur build de production, avec des références longues et absentes :
colonne lisible, aucun débordement horizontal de la fenêtre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 08:42:29 +00:00
Claude f75d51f6f4 Trous d'assortiment : extraction Excel de la liste affichée
Un bouton « Export Excel » dans la fenêtre des produits non travaillés. Il
reprend EXACTEMENT ce qui est à l'écran — même magasin, même profondeur,
même classement — plutôt que de recalculer une liste : un fichier qui ne
correspond pas à la fenêtre d'où il sort ne se vérifie plus.

Deux feuilles. Les données seules d'abord : en-têtes en ligne 1, filtre
automatique, volet figé, et surtout des valeurs NUMÉRIQUES — le tableur doit
pouvoir trier et sommer, ce qu'une chaîne « 89 863 € » lui interdit. La mise
en forme des nombres suit le critère de tri (euros, unités, pourcentage). Le
contexte ensuite, sur sa propre feuille : fournisseur, magasin examiné,
classement retenu, définition du « non travaillé », date. Mêler les deux
dans une feuille unique aurait condamné filtre et tri.

`exceljs` est chargé à la demande plutôt qu'importé en tête : il pèse lourd,
et la Grille n'a pas à le transporter pour tous ceux qui n'exportent jamais.
Un échec de génération s'affiche dans la fenêtre — un export qui échoue en
silence laisse croire au téléchargement.

Le nom du fournisseur descend jusqu'à la fenêtre pour nommer et documenter
le fichier.

Vérifié sur build de production, en téléchargeant le fichier puis en le
relisant : « Non_travailles_Fournisseur_Test_Houdemont_Top200_2026-08-14 »,
deux feuilles, 15 lignes conformes au décompte affiché, filtre automatique
sur A1:J1, valeurs lues comme des nombres et format « #,##0 "€" » sur la
colonne du critère.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 08:36:20 +00:00
Claude beefb07ba0 Grille : les produits qu'un magasin ne travaille pas
Un bouton « Non travaillés » ouvre, sur le haut du classement AFFICHÉ, la
liste des produits qu'un magasin ne travaille pas. Le classement de
référence est celui du tableau à l'instant où l'on ouvre : le tri en cours —
CA réseau, quantité, marge, peu importe — fixe l'ordre, et la valeur qui a
servi à classer est relue sur la colonne triée pour être affichée en regard.
Recalculer un ordre maison aurait répondu à une autre question que celle
qu'on vient de poser à l'écran.

« Non travaillé » = aucune vente sur 12 mois glissants ET aucun stock au
dernier mois connu. Les deux conditions comptent : un produit sans vente
mais en stock est détenu par le magasin — c'est un invendu, pas un trou
d'assortiment, et le mélanger aux vrais trous ferait commander ce qui dort
déjà en rayon.

Chaque ligne porte son rang, la valeur du critère de tri, et ce que les
AUTRES magasins en font : sans les ventes d'à côté, un trou ne se distingue
pas d'un produit que personne ne vend. Une dernière colonne sépare « jamais
détenu » de « déjà détenu, sans vente » — le premier est une piste, le
second une tentative déjà faite.

Profondeur réglable (100 / 200 / 500), magasin au choix parmi ceux
réellement présents dans les données. Le classement n'est extrait que
fenêtre ouverte, et tronqué à la profondeur maximale : sans quoi on
copierait 130 000 lignes pour en regarder 500.

Vérifié sur build de production, quatre profils de produits : à Houdemont
seuls remontent les jamais détenus, à Frouard seuls les déjà détenus sans
vente, et le profil « stock sans vente » n'apparaît dans aucun des deux. Les
rangs affichés correspondent aux positions dans le classement trié par CA
réseau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 07:04:54 +00:00
LogiFlow dc7f6bbf9f Merge pull request #12 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-14 08:16:49 +02:00
Claude 06f0bd0513 Prix de vente : tenir compte d'un prix non ventilé par magasin
`cube_pv` a une clé `(artnoid, site)`, mais la base n'en porte qu'une ligne
par article — 428 530 lignes pour 427 857 articles, là où deux magasins
tarifés en donneraient le double. Le prix n'est donc pas ventilé : la
colonne serait restée vide dès qu'on consulte le magasin qui n'a pas la
ligne, alors même que l'article a un prix.

La cellule couvre maintenant les quatre cas : le prix du magasin consulté
quand il existe ; le prix unique quand il n'y a qu'un tarif, avec la mention
« prix unique, non ventilé par magasin » ; rien quand plusieurs prix
coexistent sans concerner ce magasin — celui du voisin n'est pas le sien ;
et, en « tous magasins », le prix commun ou le plus élevé assorti du « ≠ ».

Vérifié sur build de production, quatre articles couvrant les quatre cas,
dans les trois modes de magasin. Le cas décisif : prix porté par le seul
site 292, consulté depuis Houdemont — 12,90 € et la mention, au lieu du
« - » d'avant.

La route de diagnostic est retirée : `cube_pv` est confirmé alimenté et
synchronisé, elle a fait son office.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude 8158eda11b Prix de vente : élaguer le code magasin, qui sert de clé
Le code magasin de `cube_pv` sert de clé de recherche côté Grille
(`prixVenteByStore["292"]`). Or la synchronisation Apiflow l'écrit BRUT
(`site: r.Site`), là où elle fait passer celui de `cube_stock` par `safeStr`
— et aucune des deux ne l'élague. Une colonne MSSQL de largeur fixe remonte
complétée d'espaces : la recherche échouerait alors sans rien signaler, et
de la pire façon qui soit, le prix restant visible en « tous magasins » et
vide magasin par magasin.

`TRIM` des deux côtés, plus l'élagage du repli lu depuis le cube de stock.
L'opération ne coûte rien quand il n'y a pas de remplissage, et le doute
n'avait pas de raison de subsister.

La route de diagnostic affiche désormais les valeurs de `site` telles
qu'elles sont stockées, avec leur longueur : de quoi vérifier d'un coup
d'œil que les clés sont bien « 292 » et « 579 », sur trois caractères.

Commentaires SQL déplacés hors du littéral gabarit, comme partout ailleurs
dans le fichier : un accent grave dans un commentaire refermait la chaîne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude c9e1934aae Grille : le prix de vente vient de cube_pv, pas d'article_infosup
La colonne PV s'affichait vide sur toutes les lignes. Le schéma du SaaS
Postgres (dépôt Apiflow, postgres/init.sql) explique pourquoi : la seule
source lue jusqu'ici, `article_infosup.prix_vente_mini`, est une borne de
PARAMÉTRAGE — un prix plancher, à côté de `prix_vente_maxi` et
`pv_conseille` — et non le prix pratiqué.

Le prix de vente vit dans `cube_pv (artnoid, site, pv)`, pendant exact du
`cube_pa` déjà utilisé pour l'achat, rafraîchi chaque nuit depuis le cube
MSSQL `Cube_PV`. `cube_stock` porte le même PV et sert de filet : il ne
couvre que les articles ayant une ligne de stock, mais il est déjà interrogé
par la Grille, une colonne de plus dans le SELECT suffit.

Le prix est propre à chaque MAGASIN. La colonne suit donc le magasin
consulté ; en « tous magasins » elle montre le prix commun, et s'ils
divergent le plus élevé assorti d'un « ≠ » et de l'infobulle qui donne les
deux. Une moyenne afficherait un prix qu'aucune caisse ne pratique, et
retenir silencieusement l'un des deux ferait passer le prix d'un magasin
pour celui des deux.

L'en-tête devient « PV / Magasin » : « PV central » décrivait la fiche
article, ce n'est plus la source.

Vérifié sur build de production, prix identiques, divergents et absents :
en « tous magasins » 14,90 € ≠ avec l'infobulle « Frouard (Nancy) : 13,90 €
· Houdemont : 14,90 € » ; sur Frouard 13,90 €, sur Houdemont 14,90 €, sans
marqueur ; « - » quand aucune source n'a de prix.

La route de diagnostic compte désormais les trois sources côte à côte, de
quoi confirmer sur la base réelle laquelle est renseignée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
Claude 9729b00dc7 Diagnostic : d'où vient (ou ne vient pas) le prix de vente
La colonne « PV central » s'affiche vide sur toutes les lignes. Le chemin du
code est pourtant intact : `getProductRows` renseigne `prixVente` depuis
`article_infosup.prix_vente_mini` (phase 3), aucune phase suivante ne
l'efface, et la route de streaming diffuse la ligne entière sans filtrer ses
champs. La valeur est donc absente à la source.

Deux causes possibles, que seule la base peut départager : la colonne existe
mais n'est pas renseignée, ou le prix de vente vit ailleurs sous un autre
nom. `article_infosup.prix_vente_mini` est la seule source de prix de vente
câblée dans l'application — la Grille et la fiche produit la partagent, si
bien qu'une fiche produit affichant « — » sur PV central confirmerait la
première hypothèse.

Cette route de diagnostic répond aux deux questions : remplissage réel de la
colonne actuelle (absente / à zéro / renseignée), globalement puis pour un
fournisseur donné, et énumération des colonnes candidates du schéma AVEC
leur remplissage — une colonne bien nommée mais vide ne servirait à rien.

À supprimer une fois la source établie. Elle est derrière l'authentification
et ne renvoie que des comptages et quelques exemples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-14 06:12:25 +00:00
LogiFlow 1e7c1ba4cd Merge pull request #11 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Grille : colonnes de prix, et menu « Vues » pour les blocs de colonnes
2026-08-13 22:25:43 +02:00
Claude 52b663579f Grille : colonnes de prix, et menu « Vues » pour les blocs de colonnes
Deux colonnes s'ajoutent à la suite de la tendance réseau :

  · PV moyen (Réseau) — calculé par l'application, CA réseau ÷ quantité
    vendue, exactement la définition déjà retenue par la fiche produit.
    Sans quantité sur la période, la colonne affiche « - » : le rapport
    n'existe pas, et un prix à 0 € se lirait comme un article donné.
  · PV central (Article) — le prix de vente de la fiche article, lu en base
    (article_infosup.prix_vente_mini), sans retraitement.

Les deux restent deux colonnes. En dériver un écart chiffré supposerait une
assiette commune — HT/TTC, remises — que les deux sources ne garantissent
pas ; le rapprochement se fait à l'œil, en connaissance de cause.

Le bouton de repli du bloc mensuel devient un menu « Vues » à deux entrées,
ventes mensuelles et prix, chacune repliable. Il agit par BLOCS, là où le
menu « Colonnes » voisin agit colonne par colonne : douze cellules
mensuelles ou deux prix ne se masquent pas une par une. Les deux états sont
persistés.

Au passage, le tri des colonnes réseau était faux. La sentinelle ±Infinity
choisie d'après le sens de tri courant ne pouvait pas fonctionner : TanStack
mémorise le résultat de l'accesseur dans `row._valuesCache` et ne le
réévalue jamais, si bien que la valeur calculée au premier tri restait figée
et remontait les lignes sans donnée en tête dès qu'on inversait le sens.
`sortUndefined: "last"`, traité avant l'inversion, les garde en bas dans les
deux sens. Les cinq colonnes réseau existantes en bénéficient, et `sorting`
quitte les dépendances des colonnes : plus de reconstruction complète à
chaque clic d'en-tête.

Vérifié sur build de production : les deux colonnes se placent bien après la
tendance, « - » sur quantité réseau nulle comme sur PV central absent, et le
tri place les absents en bas au premier clic (décroissant) comme au deuxième
(croissant). Menu « Vues » : 31 colonnes avec les deux blocs, 29 sans les
prix, 17 sans rien, 19 avec les prix seuls — chaque fois sur toutes les
lignes montées, sans rechargement, et l'état survit au rechargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 20:16:36 +00:00
LogiFlow 89d573634a Merge pull request #10 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Claude/grille modal ventes stock qlxg9i
2026-08-13 21:18:31 +02:00
Claude 0a2bc8318b Grille : remonter le corps du tableau au changement de colonnes
Le repli du bloc mensuel ne repeignait toujours pas les lignes déjà
montées : l'en-tête perdait ses douze colonnes, le corps les gardait, et il
fallait recharger la page pour retrouver une grille cohérente.

Le signal passé aux lignes (`columnsKey`) reposait sur la comparaison de
props de `React.memo`. La clé du `<tbody>` porte désormais cette même
signature : un changement de colonnes remonte le corps, sans plus dépendre
d'une comparaison qu'une prop oubliée suffit à mettre en défaut. Le
mécanisme existait déjà pour la densité d'affichage.

Le coût est celui des seules lignes virtualisées (une trentaine), et le
redimensionnement d'une colonne ne déclenche rien : les identifiants de
colonnes, eux, ne changent pas.

Vérifié cette fois sur un BUILD DE PRODUCTION, 2 000 lignes : au repli,
17 en-têtes et 17 cellules pour chaque ligne montée ; toujours 17 après
défilement vers des lignes virtualisées ensuite ; 29 partout au retour, et
17 au repli suivant — sans rechargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 19:15:15 +00:00
Claude c532386385 Grille : repeindre les lignes montées quand le jeu de colonnes change
Replier le bloc mensuel ne vidait que l'en-tête. Les lignes déjà montées
gardaient leurs douze cellules, seules celles virtualisées ensuite
adoptaient le nouveau jeu — une grille à deux vitesses, en-tête d'un côté,
corps de l'autre.

TanStack met ses objets `Row` en cache sur la seule identité des DONNÉES :
changer les colonnes ne leur donne aucune référence neuve, et `React.memo`
concluait qu'il n'y avait rien à repeindre. Le contournement existait déjà
pour `columnVisibility` et `columnSizing`, passés en props sans jamais être
lus ; le drapeau du bloc mensuel, lui, n'y figurait pas.

`columnVisibility` cède la place à `columnsKey`, la signature des colonnes
réellement visibles : elle change pour TOUT changement de colonnes, case
décochée dans le menu comme bloc replié, là où l'ancien objet ne voyait que
le menu. Calculée une fois par rendu de grille, pas une fois par ligne.

Vérifié en rendant la Grille sur données de démonstration : 29 cellules par
ligne et 29 en-têtes, 17 et 17 une fois le bloc replié, 29 et 29 au retour.
Avec le signal figé, l'en-tête tombe à 17 quand les lignes restent à 29 —
le défaut signalé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 11:13:39 +00:00
LogiFlow 1f4571bd7a Merge pull request #9 from R0m1k3/claude/grille-modal-ventes-stock-qlxg9i
Grille : détail mensuel au clic sur le total, bloc mensuel repliable
2026-08-13 13:00:24 +02:00
Claude 2ae523a804 Grille : détail mensuel au clic sur le total, bloc mensuel repliable
La colonne « Tot. 12m » dit combien, jamais comment. Un clic sur la case
ouvre désormais le détail des 12 mois glissants, empilé dans l'ordre où on
le lit : les ventes mensuelles, puis les entrées en stock, puis le stock de
fin de mois magasin par magasin — un stock de 40 réparti sur deux sites et
un stock de 40 bloqué sur un seul ne se pilotent pas pareil.

Les deux premières séries suivent le magasin actif de la Grille, par
cohérence avec la case cliquée ; la ventilation par magasin reste entière,
c'est son objet. Les barres se dessinent de part et d'autre d'un zéro
explicite : une quantité mensuelle peut être négative (retours supérieurs
aux ventes) et sortirait sinon du cadre par le bas.

Le bloc des douze colonnes mensuelles se replie d'un bouton. L'état est un
drapeau à part, et non douze entrées de `columnVisibility` : masquer douze
colonnes une par une n'est pas une manipulation, et les identifiants
`month_YYYYMM` changent à chaque nouveau mois alors que le drapeau, lui,
survit à la fenêtre glissante. Les colonnes sont retirées du modèle plutôt
que cachées — inutile de faire calculer douze cellules par ligne à TanStack
pour ne rien peindre.

`TuileStat` sort de heatmap-grid.tsx vers un module partagé, la modale de
tendance réseau et la nouvelle modale s'en servant toutes deux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYGgoQCG1VCe42HzbzhAXR
2026-08-13 10:54:24 +00:00
Claude 92769535dc style(grille): agrandit la carte de tendance, illisible en l'état
Les textes du graphique tournaient entre 8,5 et 10 px : titres de séries,
« max N », valeurs annotées, mois de l'axe, infobulle, ainsi que les légendes
des trois tuiles. Difficile à lire.

Typographie relevée d'un cran partout : titres de séries 9,5 → 13, maximum
9 → 11,5, valeurs sur les points 9 → 12,5, mois 8,5 → 11, infobulle 9 → 11,5,
tuiles 10 → 12 avec la valeur à 26 px, tableau 11 → 13.

Le dessin suit la typographie plutôt que de la subir : bandeau de facette
15 → 21, axe 18 → 24, écart 12 → 14, infobulle élargie à 196 avec un interligne
de 17 — sans quoi titres et courbes se seraient chevauchés. La carte passe à
max-w-2xl pour absorber le tout sans comprimer le graphique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 11:43:18 +00:00
Claude a4e3c53dcc feat(grille): détail par magasin dans la carte, en mode tous magasins
En « tous magasins », les trois tuiles de la carte sont des cumuls : savoir que
le stock est de 40 ne dit pas s'il est réparti entre Frouard et Houdemont ou
entièrement sur un seul site — or c'est précisément ce qu'on vient vérifier.

La carte affiche désormais, sous les cumuls, une ligne par magasin avec ses
ventes, ses entrées et son stock de fin de mois. Uniquement en mode tous
magasins : sur un magasin unique, les tuiles sont déjà ce détail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 09:05:47 +00:00
Claude 6e59458403 fix(grille): grille vide au changement de magasin, et stock TOTAL incomplet
Trois défauts distincts, dont un que je venais d'introduire.

1. RÉGRESSION — grille vide. Le store zustand est persisté dans le navigateur
   sans version ni migration. Le passage de filters.code3 à `string[] | null`
   laissait donc une CHAÎNE dans le localStorage des utilisateurs :
   `new Set("320211")` produit un ensemble de caractères, plus aucune ligne ne
   correspond, et la Grille apparaît vide sans le moindre message. Ajout de
   `version: 1` + `migrate`, plus une garde dans le filtre pour ne jamais
   redevenir muet sur un état inattendu.

2. STOCK « tous magasins » incomplet. Le stock est un NIVEAU, pas un flux : un
   magasin sans mouvement dans le mois détient toujours sa marchandise. Or le
   TOTAL était sommé depuis les lignes mensuelles brutes, donc n'incluait que
   les sites ayant bougé ce mois-là — sur un produit à faible rotation, il
   n'affichait que Frouard. Il est désormais recalculé depuis les séries par
   site, qui sont reportées d'un mois sur l'autre.

3. CHANGEMENT DE MAGASIN qui se fige. Hors « tous magasins », un rattrapage
   interroge l'API FF à raison d'UNE requête HTTP par article : sur un gros
   fournisseur, cela fait des milliers d'appels. Borné à 300 (réglable via
   GRID_STORE_RECONCILE_MAX), avec un avertissement explicite sur ce qui n'a
   pas été rattrapé — pas de troncature silencieuse.

Enfin, getProductRows ne renvoie plus [] en cas d'erreur : une liste vide est
indiscernable d'un fournisseur sans article. La Grille affichait une page
blanche sans explication, et la synchro nocturne prenait la panne pour un
fournisseur vide — qu'elle désactivait automatiquement. L'erreur remonte
désormais jusqu'au bandeau rouge et au statut « echec ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 09:04:09 +00:00
Claude 720cf3c7a1 feat(grille): filtre nomenclature en multi-sélection
Le filtre était un <select> à valeur unique : impossible de retenir plusieurs
nomenclatures, ni de partir de tout pour retirer les postes non voulus — ce qui
est la manière naturelle de travailler quand un fournisseur en compte des
dizaines.

Remplacé par une liste déroulante à cases, avec recherche, « Tout cocher »,
« Tout décocher », « Ne garder que ces N » sur le résultat d'une recherche, et
une croix pour tout réafficher. Décocher depuis l'état « tout » matérialise la
liste complète puis en retire le poste, sinon le premier décochage n'aurait
rien fait.

filters.code3 passe de `string | null` à `string[] | null` : `null` = aucun
filtre, `[]` = rien de coché. Distinguer les deux évite la valeur sentinelle
qu'imposerait un simple tableau, et rend « Tout décocher » sans ambiguïté.
L'URL accepte une liste séparée par des virgules.

Au passage : la Grille rechargeait tout depuis le serveur à chaque changement
de nomenclature, alors que getProductRows ignore ce filtre et que le tri se
fait en local. Le paramètre n'est plus transmis ni dans les dépendances de
l'effet — le filtrage est désormais instantané, ce qui compte d'autant plus
avec plusieurs cases à cocher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 08:46:39 +00:00
Claude 0bd7ec9798 feat(admin): synchronisation nocturne des fournisseurs, paramétrable
Page /admin/synchronisation : choisir quels fournisseurs sont cadencés, pour
quelle source, et suivre l'avancement.

ORDRE DE PASSAGE. La file est « le plus anciennement synchronisé d'abord », en
ignorant ceux déjà traités depuis l'ouverture de la fenêtre. Il en découle,
sans curseur ni compteur de tour : la nuit 2 reprend là où la nuit 1 s'est
arrêtée, le tour recommence quand tout le monde est passé, et un fournisseur
en échec garde une date ancienne donc repasse en tête.

DEUX FILES. SQL (~43 s sur le plus gros) et Qlik (jusqu'à 8 min, sur un serveur
qui abandonne quand on le sollicite trop) ont leur propre activation et leur
propre rythme. Une extraction Qlik est intercalée tous les N fournisseurs SQL,
sinon elle ne démarrerait jamais avant que la file SQL soit vide. Un plafond
par nuit et un âge minimal évitent de refaire chaque nuit des agrégats
mensuels.

Détails qui comptent :
- La fenêtre signifie « ne plus DÉMARRER après l'heure de fin » : une
  extraction en cours n'est jamais coupée. Le passage par minuit est géré.
- L'horodatage est écrit même en cas d'échec, sans quoi le fournisseur fautif
  bloquerait la file derrière lui.
- Garde anti-boucle : l'avancement repose sur cette écriture, volontairement
  non bloquante ; si elle échoue, le round s'arrête au lieu de reprendre le
  même fournisseur indéfiniment.
- Un fournisseur sans article est désactivé automatiquement, motif affiché,
  réactivable — plutôt que réessayé chaque nuit.
- Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
  Qlik la nuit suivante sans décision explicite.

Déclenchement par minuteur interne (instrumentation.ts), battement d'une
minute. Aucune configuration côté hôte, se réarme au démarrage ; en
contrepartie rien ne tourne si l'application est arrêtée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 07:28:11 +00:00
Claude be8d576af5 chore: supprime les derniers restes de l'IA dans les Paramètres
La section « IA Copilot — OpenRouter » annonçait « pour les analyses de
gammes », mais aucun code ne consommait cette clé : elle était décorative.
Supprimée, ainsi que les routes devenues orphelines /api/admin/ai-config,
/api/google-ai/models et /api/openrouter/models.

Nettoyage de l'état correspondant dans la page : clé, liste de modèles,
fournisseur d'IA et modèle Google ne faisaient plus qu'un aller-retour entre le
chargement et l'enregistrement, sans aucune interface pour les modifier.
handleSave n'enregistre donc plus que l'URL de la base.

La règle set-state-in-effect s'est mise à signaler le `setIsMounted` du montage,
inchangé mais devenu analysable une fois le code IA retiré : dérogation locale,
comme dans api-connection-info.tsx, puisque poser l'indicateur au montage est
précisément ce qui évite l'écart d'hydratation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 07:06:23 +00:00
Claude fff4cd9641 chore: retire la section Score des Paramètres et supprime la page Chat IA
- Section « Score Produit » retirée : elle ne faisait que décrire une formule,
  sans rien paramétrer.
- Page /admin/ai-chat et sa route /api/admin/ai-chat supprimées, ainsi que
  l'entrée de menu et la section « Admin AI Chat — Fournisseur » des
  Paramètres, qui n'existait que pour la configurer.
- Nettoyage de l'état devenu mort dans la page Paramètres : la liste des
  modèles Google et son chargement n'avaient plus d'interface.

Effet de bord notable : tsc passe de 23 à 4 erreurs préexistantes — la page de
chat en portait 19 à elle seule (flags de regex exigeant une cible es2018).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:56:24 +00:00
Claude 310165151d fix(qlik): reprise globale quand le moteur abandonne l'extraction (code 15)
Deux exécutions du même fournisseur D005, même code, même jour :
  04:17 → OpenDoc en 78 s,  succès en 479 s
  06:13 → OpenDoc en 233 s, « code 15 / Request aborted » après 333 s

Le moteur Qlik est trois fois plus lent à 6 h qu'à 4 h et finit par abandonner
la requête. Les reprises par requête de rpcWithRetry ne servent à rien ici :
c'est toute l'extraction qui tombe, et la sync repartait à zéro sans réessayer.

L'extraction est désormais relancée entièrement sur erreur transitoire (code 15
ou « Request aborted »), une fois par défaut, après une pause de 60 s — pas
immédiatement : relancer aussitôt ne ferait qu'ajouter de la charge au serveur
qui vient de renoncer. Réglable via QLIK_EXTRACTION_RETRIES et
QLIK_EXTRACTION_RETRY_PAUSE_MS.

Le reste du comportement est préservé : le refus de publier une fenêtre 12 mois
incomplète, et la reprise sur point de contrôle hors fenêtre datée, s'appliquent
comme avant une fois les reprises épuisées. Le checkpoint est vidé entre deux
tentatives pour ne pas mélanger deux extractions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:24:09 +00:00
Claude 0a57a9a707 fix(api): nomenclatures dérivées du préfixe de code3, pas de code1/code2
Les codes de nomenclature FF font 6 chiffres et sont hiérarchiques par préfixe
(31xxxx à 40xxxx) : « 32 » univers, « 3202 » famille, « 320211 » sous-famille.

L'endpoint groupait sur les colonnes code1/code2, qui ne sont renseignées que
si pgGetNomenclatureByFournisseur a su remonter la hiérarchie — ce qui dépend
d'une détection heuristique de la colonne parent de la table `nomenclature`.
Quand elle échoue, seul code3 est rempli et l'endpoint aurait renvoyé une liste
vide aux niveaux 1 et 2, sans rien signaler.

Les niveaux sont désormais dérivés de left(code3, 2|4|6), toujours disponible.

En conséquence, /grid gagne un filtre `nomenclature` par préfixe, qui fonctionne
aux trois niveaux et ne dépend pas non plus de code1/code2 : c'est lui que
meta.filtreGrid désigne. Les filtres code1..code3 exacts restent inchangés.

Les libellés viennent toujours du payload et peuvent être null aux niveaux 1 et
2 pour la même raison ; codes et compteurs, eux, sont exacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:08:02 +00:00
Claude 60aa2ce120 feat(api): inventaire des nomenclatures, pour découper un gros fournisseur
Les filtres code1/code2/code3 existaient déjà sur /grid, mais un appelant ne
pouvait pas savoir quelles valeurs existent chez un fournisseur : il aurait dû
tout télécharger pour les découvrir, ce qui annulait l'intérêt du découpage.

GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=… renvoie les
postes avec leur nombre d'articles et leur CA, agrégés en SQL — quelques
dizaines de lignes, même pour un fournisseur de 130 000 articles. `parent`
permet de descendre l'arborescence sans tout charger, et meta.filtreGrid
indique le paramètre de /grid correspondant au niveau demandé.

Le parcours devient : lister les postes, puis /grid?fournisseur=…&code1=… poste
par poste. Bien plus praticable pour une IA que 132 000 lignes d'un bloc, et
plus pertinent métier que le découpage par gamme.

Les libellés sont lus dans le payload jsonb (ils n'ont pas de colonne dédiée).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 06:00:54 +00:00
Claude 8fe294924b fix(api): tient les très gros fournisseurs (132k articles)
Le journal du fournisseur D005 montre 132 388 lignes persistées pour un seul
fournisseur. À cette échelle, /api/v1/grid sans limit ne tenait pas.

Deux plantages francs, indépendants de la taille de la réponse :
- getNetworkMetricsByCodeCentrale : inArray avec 132 000 valeurs, soit autant
  de paramètres liés, alors que le protocole PostgreSQL en accepte 65535.
- pgGetGammesByCodeins : même problème sur son IN (…).
Les deux lectures sont désormais découpées par lots de 2000. Le bug existait
pour tout appel dépassant ~65 000 codes, indépendamment de l'API.

Volume : charger 132k lignes complètes puis les sérialiser d'un bloc représente
plusieurs centaines de Mo en mémoire. Sans `limit`, la réponse est maintenant
diffusée en flux, par lots internes de 1000, à mémoire constante. La forme
{ data, pagination, meta } est inchangée — un client existant ne voit que des
octets arrivant progressivement.

Une erreur survenant après les premiers octets ne peut plus changer le code
HTTP : le document est alors fermé avec meta.erreur et meta.complet=false, pour
qu'une réponse tronquée ne passe pas pour complète.

`fields` reste le levier décisif à cette échelle : une ligne complète porte les
séries mensuelles et les ventilations par magasin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 05:45:49 +00:00
Claude 33b30dc8bd fix(api): supprime le plafond de 500 lignes, qui tronquait en silence
Le passage à limit=5000 du commit précédent était sans effet : queryGridRows
re-plafonnait à 500 en dur (Math.min(500, …)). Pire, la pagination était
calculée sur le limit *demandé*, donc hasMore et meta.complet annonçaient une
réponse complète alors qu'elle était tronquée — exactement la troncature
silencieuse que ces champs devaient éliminer.

- Plus aucun plafond. Sur /grid, omettre `limit` renvoie toutes les lignes du
  fournisseur : plus de pagination à dérouler, ce que les agents font mal.
- La pagination reflète le mode sans limite (une page couvrant le total), donc
  hasMore et meta.complet disent la vérité.
- Le défaut reste 100 sur les endpoints de recherche, non bornés par un
  fournisseur — mais sans maximum imposé.
- meta.avertissement explique désormais que la troncature vient du `limit`
  fourni, et qu'il suffit de l'omettre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:46:32 +00:00
Claude 73accd6d60 feat(api): un seul appel suffit pour tout un fournisseur
Le plafond de pagination était de 500 lignes. Un lot fournisseur dépasse
souvent ce seuil, si bien qu'une app ou un agent ne récupérait qu'une partie
des données sans forcément s'en rendre compte — et les agents paginent mal,
concluant volontiers qu'ils ont tout lu.

- /grid accepte désormais limit jusqu'à 5000. La borne reste à 500 sur la
  recherche transversale, qui n'est pas bornée par un fournisseur.
- pagination.hasMore, et meta.complet sur /grid : l'appelant sait s'il a tout,
  sans avoir à comparer page et totalPages.
- Réponse partielle : meta.avertissement indique le nombre de lignes obtenues
  sur le total et comment obtenir le reste. Pas de troncature silencieuse.
- Doc des Paramètres et openapi.json mis à jour, avec un exemple curl qui
  récupère un fournisseur entier.

`fields` reste vivement conseillé : une ligne complète porte les séries
mensuelles et les ventilations par magasin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:30:25 +00:00
Claude 3fa92e891b merge: intègre main — main prioritaire, abandon de la page en doublon
main a beaucoup avancé (53 commits) pendant que cette branche construisait
l'API. La fusion a révélé que /produits, sur main, fait la même chose que la
page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités,
URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits).

Résolution, main prioritaire :
- sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son
  travail sur la tendance et Qlik est postérieur et plus complet.
- get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel
  upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir.
- settings : les deux côtés (ServerLogs de main + les sections API d'ici).

Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et
composants. searchNetworkProductsPlaywright disparaît avec la version main de
qlik-playwright ; rien d'autre ne l'utilisait.

api-enrich se branche désormais sur le module de tendance de main
(features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure
est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue
« vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi
exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et
nouveau.

Apport conservé de la branche : l'API /api/v1, absente de main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:13:44 +00:00
Claude 94e82aa404 fix(ff): le panneau API FF Nancy affichait une coquille vide + URL configurable
Deux problèmes distincts, dont un mal diagnostiqué au départ.

1. FORMAT DE RÉPONSE — cause réelle du « test ne donne rien ». Le serveur
   fonctionne (vérifié : HTTP 200, synchro du jour, 33 tables), mais il renvoie
   { sync: [{ table_name, last_sync, rows_synced, status, error_msg }] } alors
   que l'application lisait { lastSync, tables: [{ nom, derniereSync, nbLignes }] }.
   Les champs n'existaient pas : le panneau s'affichait vide sans erreur, la
   requête HTTP ayant réussi. normalizeSyncStatus() traduit désormais les deux
   formes, et le tableau expose l'état par table (une table en erreur est
   justement l'information qu'on vient chercher).

2. URL NON CONFIGURABLE. FF_API_BASE était figée au chargement du module depuis
   process.env, sans champ dans l'interface : impossible de corriger l'adresse
   sans redéployer. Elle est maintenant résolue à l'appel — réglage enregistré,
   puis variable d'environnement, puis défaut — avec un champ, un bouton
   Enregistrer et un test dans Paramètres.

Le test distingue désormais DNS, connexion refusée, délai dépassé et code HTTP,
et affiche l'adresse réellement appelée : un « HTTP 503 » nu ne permettait pas
de séparer une mauvaise URL d'une panne. /api/ff-status renvoie le même détail.

Ajout au passage du paramètre `compute` dans la doc API des Paramètres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:56:29 +00:00
Claude 18edda7167 feat(api): expose la tendance réseau calculée dans /api/v1
La colonne « Tendance » de la Grille était la seule information visible que
l'API ne fournissait pas : elle est calculée dans le navigateur par
computeNetworkTrend(), qui vivait dans un composant "use client" donc
inaccessible côté serveur. L'API livrait la série mensuelle brute et laissait
chaque appelant refaire la régression — au risque de diverger de l'affichage.

- Le calcul part dans src/lib/network-trend.ts (module pur, sans React) ;
  le composant le réexporte, aucun import existant ne change.
- enrichRows ajoute `trend` : { direction, pct, label, monthsUsed }, calculé
  sur la même série que la colonne de l'application.
- openapi.json : schéma Trend documenté, et il est dit explicitement que la
  liste des propriétés de Product n'est pas exhaustive — la réponse porte la
  ligne de grille entière.

Vérifié au passage que rien n'est perdu en chemin : upsertGridRows stocke le
ProductRow complet dans la colonne jsonb `payload` (les colonnes scalaires ne
servent qu'aux index), ne filtre que les lignes sans codein, et pickFields
renvoie tout tant que `fields` n'est pas précisé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:36:35 +00:00
Claude 3a825866d2 feat(api): /api/v1/grid calcule le fournisseur à la demande
L'API lisait uniquement la table grid_rows, écrite en effet de bord quand un
humain ouvre la page Grille. Un fournisseur jamais ouvert renvoyait donc
202 not_ready : une app ou un agent externe ne pouvait consulter que ce qui
avait déjà été parcouru dans l'interface, ce qui vide l'API de son intérêt.

/api/v1/grid déclenche désormais getProductRows() quand l'instantané manque.
Le calcul est déjà protégé en amont (cache 10 min + verrou anti-concurrence)
et persiste l'instantané, donc seul le premier appel paie le coût ; les
suivants repassent par le chemin SQL rapide.

- compute=1 (défaut) : calcul à la demande, meta.computedOnDemand signale
  quand il a eu lieu. compute=0 : comportement strict d'avant.
- Fournisseur inconnu ou sans article : 404 explicite, distinct d'une panne
  (500) et de « pas encore calculé » (202).
- /api/v1/products/{codein} fait de même lorsque `fournisseur` est fourni,
  le calcul se faisant par lot fournisseur.
- openapi.json mis à jour : c'est ce document que lit un agent externe, il
  annonçait « aucun endpoint ne déclenche de recalcul ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:29:26 +00:00
Claude 54d51e8eab fix(reseau): cast texte sur artcentrale pour la jointure code centrale
Le type réel de articles.artcentrale varie selon l'installation FF Nancy
(varchar ou numérique). La comparaison avec des codes centraux (chaînes)
passe désormais par TRIM(...::text), comme ailleurs dans ce fichier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 11:25:42 +00:00
Claude c565672259 feat(reseau): page Recherche réseau — chercher dans le catalogue Qlik
La Grille part de NOTRE catalogue : on extrait nos codes centraux puis on
demande à Qlik les métriques de ces codes. Un produit vendu par le réseau
mais absent de FF Nancy était donc invisible par construction.

Cette page inverse le sens : elle interroge Qlik d'abord, par libellé ou par
code centrale, et affiche aussi les produits que nous ne référençons pas.

- searchNetworkProductsPlaywright() : sélection par SelectValues sur
  « Article Code » (mode code) ou par SearchListObjectFor +
  AcceptListObjectSearch sur la dimension « Article » (mode plein texte,
  avec repli sur des noms de champs si la master dimension échoue).
  Cube [Article Code, Article, Mois] × 6 mesures, passe N-1 incluse pour
  obtenir 12 mois glissants contigus. Garde-fou à 300 produits/recherche.
- /api/qlik/search : job asynchrone (POST + polling GET) comme la sync,
  une seule recherche à la fois pour ménager le moteur Qlik. Met les
  résultats en cache dans qlik_network_metrics et enrichit avec le
  catalogue local (badge « Chez moi » / « Réseau uniquement », stock).
- Page /recherche-reseau : tableau triable, filtres, fiche produit avec la
  courbe 12 mois, export Excel.
- Composants de tendance extraits de heatmap-grid vers features/network
  pour être partagés (comportement inchangé).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 11:22:53 +00:00
Claude 4b229acc9b feat(api): API prête pour une IA externe type ChatGPT
Adapte l'API CollectFlow aux exigences des Actions ChatGPT et garantit que
les données soient réellement disponibles pour un consommateur externe.

Schéma OpenAPI :
- openapi.json devient PUBLIC (sans clé). Il ne décrit que la structure, sans
  aucune donnée, or ChatGPT importe le schéma par URL avant que la clé ne soit
  configurée : l'exiger rendait l'import impossible.
- servers[0].url est désormais ABSOLUE (une URL relative est rejetée à
  l'import), construite depuis l'hôte appelant ou COLLECTFLOW_PUBLIC_URL.
- operationId sur chaque opération (requis par les Actions), schémas de
  réponse typés, et descriptions rédigées pour le modèle : quelle gamme
  utiliser pour raisonner, pourquoi préférer caParMagasinReseau au CA brut,
  que faire d'un 202 not_ready.

Disponibilité des données :
- Nouveau préchauffage /api/admin/grid-warmup + bouton dans Paramètres.
  Sans lui, l'API ne sert que les fournisseurs déjà ouverts à la main dans la
  Grille — une IA externe n'aurait presque rien vu. Le job calcule tous les
  fournisseurs séquentiellement (paralléliser saturerait PostgreSQL), saute
  ceux à jour depuis moins de 24 h et suit son avancement.

Documentation :
- Paramètres → marche à suivre pas à pas pour brancher un GPT (import du
  schéma, auth par clé personnalisée X-API-Key), et mention de
  COLLECTFLOW_PUBLIC_URL quand le domaine public diffère.

L'assistant interne et son API api.ffnancy.fr ne sont pas touchés.

Vérifié sur PostgreSQL local : schéma servi sans clé en 200, URL absolue,
5 operationId, auth apiKey/X-API-Key, surcharge COLLECTFLOW_PUBLIC_URL
effective, 5 endpoints en 200 avec la clé, 401 JSON sans clé, et recherche
transversale renvoyant bien plusieurs fournisseurs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:21:31 +00:00
Claude a45e09777b feat(api): métriques Qlik + gamme serveur à jour, et doc de connexion dans Paramètres
L'instantané grid_rows fige la ligne au moment du calcul, mais deux données
évoluent indépendamment et doivent refléter l'état courant :

- Métriques réseau Qlik : qlik_network_metrics est alimentée par les syncs et
  les recherches réseau, hors du calcul de grille. Relues à chaque appel et
  exposées dans `network` (null quand le produit n'en a pas). Les colonnes
  caReseau/qteReseau/... de la ligne sont réalignées dessus.
- Gamme serveur : codeGamme peut être surchargée par un snapshot de session.
  L'API expose `codeGammeServeur`, la gamme NON modifiée telle qu'elle est en
  base PostgreSQL, relue à chaque appel. codeGammeInit est gardé aligné.

Ajout de pgGetGammesByCodeins() : variante sans jointure artfou1, nécessaire
car la recherche de l'API est transversale (pas de fournisseur connu).

Ce n'est pas un recalcul : deux lectures indexées bornées à la page courante
(500 lignes max). Mesuré à ~20 ms, soit le même coût que sans enrichissement.
Paramètre enrich=0 pour servir l'instantané brut.

Paramètres → nouvelle section « API CollectFlow — Connexion » : URL de base
déduite de l'origine, en-têtes d'authentification, liste des endpoints et des
paramètres, exemples curl copiables, lien vers openapi.json.

Vérifié sur PostgreSQL local avec un instantané volontairement périmé :
gamme snapshot A → serveur C, caReseau 111 → 990000, produit sans données
réseau → network null, produit sans gamme → codeGammeServeur null, enrich=0
redonnant bien les valeurs figées, et toujours zéro recalcul.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:14:21 +00:00
Claude e79d7e51da feat(api): API CollectFlow /api/v1 — lecture de la grille et recherche
Les données de la grille (ventes 12 mois, stock, marges, gammes, métriques
réseau Qlik) n'étaient accessibles par aucun moyen programmatique, et
getProductRows() exige un fournisseur : chercher un produit sans le connaître
était impossible.

Contrainte de conception : l'API ne recalcule jamais rien. La grille était
reconstruite en direct et gardée seulement 10 min en mémoire ; une API qui
appellerait getProductRows() serait lente et imprévisible. On persiste donc
le résultat d'un calcul qui a déjà lieu, et on le sert.

- Table grid_rows : colonnes scalaires (filtre/tri/recherche en SQL) +
  payload jsonb du ProductRow complet. Remplie en effet de bord NON bloquant
  par getProductRows(), purge des articles disparus via computed_at. Survit
  aux redémarrages, contrairement au cache mémoire.
- Endpoints /api/v1 : fournisseurs, grid, products/search (transversale, tous
  fournisseurs), products/:codein, network/:codeCentrale, openapi.json.
  Pagination, tri sur liste blanche, projection de champs, validation zod.
  202 not_ready si un fournisseur n'a pas encore d'instantané.
- Authentification double : clé d'API (X-API-Key ou Bearer, SHA-256 en base,
  révocable) ou session existante. Le middleware exempte /api/v1 — sans quoi
  un script recevait une redirection 302 vers /login au lieu d'un 401 JSON.
- Gestion des clés dans /settings (server actions, clé affichée une seule fois).

Vérifié contre une base PostgreSQL locale : 401 JSON sans clé, 401 sur clé
révoquée, recherche renvoyant plusieurs fournisseurs, upsert + purge, et
24 appels /api/v1 sans déclencher un seul recalcul (l'ancienne route
/api/grid/rows en déclenche un à chaque appel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:14:21 +00:00
304 changed files with 44849 additions and 42021 deletions

No files matched your search

+14 -38
View File
@@ -1,38 +1,14 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# BMad and Antigravity
_bmad-output/
.gemini/
.antigravityignore
.git
.github
.claude
.agent
.agents
_bmad
_bmad-output
node_modules
dist
data
server/target
*.md
!README.md
.env*
+36
View File
@@ -0,0 +1,36 @@
name: CI
# Vérifie le serveur Rust et l'interface sur chaque pull request.
on:
pull_request:
push:
branches: [main]
jobs:
server:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: server
- run: cargo build --locked
- run: cargo test --locked
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run build
+44
View File
@@ -0,0 +1,44 @@
name: Image Docker
# Construit l'image à chaque mise à jour de main et la publie sur GHCR :
# le serveur la récupère au prochain `docker compose up -d` (pull_policy: always).
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: docker-publish
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Nom de l'image (minuscules)
id: image
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.image.outputs.name }}:latest
${{ steps.image.outputs.name }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
+9
View File
@@ -41,3 +41,12 @@ yarn-error.log*
next-env.d.ts
.playwright-mcp/
# données locales (configuration avec mots de passe, comptes de secours)
/data/
# Rust
/server/target/
# Vite
/dist/
/.claude/worktrees/
-229
View File
@@ -1,229 +0,0 @@
// <stdin>
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
// src/features/grid/components/network-charts.tsx
import { useState } from "react";
// src/features/grid/lib/months.ts
function fmtMonthShort(lab) {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
// src/features/grid/components/network-charts.tsx
import { jsx, jsxs } from "react/jsx-runtime";
var SERIE_COULEURS = ["var(--viz-1)", "var(--viz-2)", "var(--viz-3)"];
function NetworkLineChart({
labels: labels2,
values: values2,
stores: stores2,
perStore: perStore2
}) {
const [survol, setSurvol] = useState(null);
const n = values2.length;
const memeTaille = (s) => Array.isArray(s) && s.length === n && n > 0;
const avecParMag = memeTaille(perStore2);
const avecMagasins = memeTaille(stores2);
const fmt = (v, d = 0) => v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
const facettes = [];
if (avecParMag) {
facettes.push({
titre: "Qt\xE9 / magasin",
valeurs: perStore2,
couleur: SERIE_COULEURS[0],
hauteur: 92,
aire: true,
decimales: perStore2.some((v) => v > 0 && v < 10) ? 1 : 0
});
facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[1], hauteur: 58 });
} else {
facettes.push({ titre: "Quantit\xE9s vendues", valeurs: values2, couleur: SERIE_COULEURS[0], hauteur: 110, aire: true });
}
if (avecMagasins) {
facettes.push({ titre: "Magasins vendeurs", valeurs: stores2, couleur: SERIE_COULEURS[2], hauteur: 46 });
}
const W = 520;
const padL = 16, padR = 16, axeH = 18, enteteH = 15, ecart = 12;
const plotW = W - padL - padR;
const H = facettes.reduce((t, f) => t + enteteH + f.hauteur + ecart, 0) - ecart + axeH;
const x = (i) => n > 1 ? padL + i / (n - 1) * plotW : padL + plotW / 2;
const ancrage = (i) => i === 0 ? "start" : i === n - 1 ? "end" : "middle";
let curseur = 0;
const disposees = facettes.map((f) => {
const hautEntete = curseur;
const haut = curseur + enteteH;
curseur = haut + f.hauteur + ecart;
const min = Math.min(0, ...f.valeurs);
const max = Math.max(...f.valeurs, min + 1);
const etendue = max - min || 1;
const y = (v) => haut + f.hauteur - (v - min) / etendue * f.hauteur;
const iMax = f.valeurs.indexOf(Math.max(...f.valeurs));
return { ...f, hautEntete, haut, base: y(min), max, y, iMax };
});
const basPlots = curseur - ecart;
return /* @__PURE__ */ jsxs("div", { className: "mt-3 w-full overflow-x-auto", children: [
/* @__PURE__ */ jsxs(
"svg",
{
viewBox: `0 0 ${W} ${H}`,
className: "w-full",
style: { minWidth: 420 },
role: "img",
"aria-label": "Ventes r\xE9seau mensuelles : " + disposees.map((f) => f.titre).join(", "),
onMouseLeave: () => setSurvol(null),
children: [
disposees.map((f) => {
const pts = f.valeurs.map((v, i) => `${x(i).toFixed(1)},${f.y(v).toFixed(1)}`).join(" ");
const aEtiqueter = [.../* @__PURE__ */ new Set([f.iMax, n - 1])];
return /* @__PURE__ */ jsxs("g", { children: [
/* @__PURE__ */ jsx("rect", { x: padL, y: f.hautEntete + 3, width: 12, height: 3, rx: 1.5, fill: f.couleur }),
/* @__PURE__ */ jsx("text", { x: padL + 18, y: f.hautEntete + 7, fontSize: 9.5, fontWeight: 600, fill: "var(--text-secondary)", dominantBaseline: "middle", children: f.titre }),
/* @__PURE__ */ jsxs("text", { x: padL + plotW, y: f.hautEntete + 7, textAnchor: "end", fontSize: 9, fill: "var(--text-muted)", dominantBaseline: "middle", children: [
"max ",
fmt(f.max, f.decimales ?? 0),
f.unite ?? ""
] }),
/* @__PURE__ */ jsx("line", { x1: padL, y1: f.base, x2: padL + plotW, y2: f.base, stroke: "var(--border)", strokeWidth: 1 }),
f.aire && n > 1 && /* @__PURE__ */ jsx(
"polygon",
{
points: `${padL},${f.base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${f.base.toFixed(1)}`,
fill: f.couleur,
opacity: 0.1
}
),
n > 1 && /* @__PURE__ */ jsx("polyline", { points: pts, fill: "none", stroke: f.couleur, strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round" }),
aEtiqueter.map((i) => /* @__PURE__ */ jsx(
"circle",
{
cx: x(i),
cy: f.y(f.valeurs[i]),
r: 4,
fill: f.couleur,
stroke: "var(--bg-surface)",
strokeWidth: 2
},
`pt-${f.titre}-${i}`
)),
aEtiqueter.map((i) => {
const yPoint = f.y(f.valeurs[i]);
const dessus = yPoint - 8 >= f.haut + 8;
return /* @__PURE__ */ jsx(
"text",
{
x: x(i),
y: dessus ? yPoint - 8 : yPoint + 13,
textAnchor: ancrage(i),
fontSize: 9,
fontWeight: 700,
fill: "var(--text-primary)",
children: fmt(f.valeurs[i], f.decimales ?? 0)
},
`lab-${f.titre}-${i}`
);
}),
survol != null && /* @__PURE__ */ jsx(
"circle",
{
cx: x(survol),
cy: f.y(f.valeurs[survol]),
r: 3.5,
fill: f.couleur,
stroke: "var(--bg-surface)",
strokeWidth: 2
}
)
] }, f.titre);
}),
survol != null && /* @__PURE__ */ jsx("line", { x1: x(survol), y1: 0, x2: x(survol), y2: basPlots, stroke: "var(--border-strong)", strokeWidth: 1 }),
labels2.map((lab, i) => /* @__PURE__ */ jsx(
"text",
{
x: x(i),
y: H - 5,
textAnchor: ancrage(i),
fontSize: 8.5,
fill: survol === i ? "var(--text-primary)" : "var(--text-muted)",
fontWeight: survol === i ? 700 : 400,
children: fmtMonthShort(lab)
},
`mois-${lab}`
)),
survol != null && (() => {
const largeur = 158;
const hauteur = 16 + disposees.length * 13 + 8;
const gauche = x(survol) + 10 + largeur > W - 4;
const bx = gauche ? x(survol) - 10 - largeur : x(survol) + 10;
return /* @__PURE__ */ jsxs("g", { pointerEvents: "none", children: [
/* @__PURE__ */ jsx(
"rect",
{
x: bx,
y: 4,
width: largeur,
height: hauteur,
rx: 8,
fill: "var(--bg-elevated)",
stroke: "var(--border)",
strokeWidth: 1
}
),
/* @__PURE__ */ jsx("text", { x: bx + 9, y: 17, fontSize: 9.5, fontWeight: 700, fill: "var(--text-primary)", children: fmtMonthShort(labels2[survol]) }),
disposees.map((f, k) => /* @__PURE__ */ jsxs("g", { children: [
/* @__PURE__ */ jsx("rect", { x: bx + 9, y: 26 + k * 13, width: 8, height: 3, rx: 1.5, fill: f.couleur }),
/* @__PURE__ */ jsx("text", { x: bx + 22, y: 30 + k * 13, fontSize: 9, fill: "var(--text-secondary)", children: f.titre }),
/* @__PURE__ */ jsx(
"text",
{
x: bx + largeur - 9,
y: 30 + k * 13,
textAnchor: "end",
fontSize: 9,
fontWeight: 700,
fill: "var(--text-primary)",
children: fmt(f.valeurs[survol], f.decimales ?? 0)
}
)
] }, `bulle-${f.titre}`))
] });
})(),
labels2.map((lab, i) => /* @__PURE__ */ jsx(
"rect",
{
x: x(i) - plotW / (2 * Math.max(1, n - 1)),
y: 0,
width: plotW / Math.max(1, n - 1),
height: H,
fill: "transparent",
onMouseEnter: () => setSurvol(i)
},
`survol-${lab}`
))
]
}
),
/* @__PURE__ */ jsxs("details", { className: "mt-2", children: [
/* @__PURE__ */ jsx("summary", { className: "text-[11px] cursor-pointer select-none", style: { color: "var(--text-muted)" }, children: "Voir les valeurs mois par mois" }),
/* @__PURE__ */ jsx("div", { className: "mt-1.5 overflow-x-auto", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-[11px] tabular-nums", style: { borderCollapse: "collapse" }, children: [
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { style: { color: "var(--text-muted)" }, children: [
/* @__PURE__ */ jsx("th", { className: "text-left font-medium py-1 pr-2", children: "Mois" }),
disposees.map((f) => /* @__PURE__ */ jsx("th", { className: "text-right font-medium py-1 pl-2", children: f.titre }, `th-${f.titre}`))
] }) }),
/* @__PURE__ */ jsx("tbody", { children: labels2.map((lab, i) => /* @__PURE__ */ jsxs("tr", { style: { borderTop: "1px solid var(--border)" }, children: [
/* @__PURE__ */ jsx("td", { className: "py-1 pr-2", style: { color: "var(--text-secondary)" }, children: fmtMonthShort(lab) }),
disposees.map((f) => /* @__PURE__ */ jsx("td", { className: "text-right py-1 pl-2", style: { color: "var(--text-primary)" }, children: fmt(f.valeurs[i], f.decimales ?? 0) }, `td-${f.titre}-${lab}`))
] }, `tr-${lab}`)) })
] }) })
] })
] });
}
// <stdin>
var labels = ["2025-08", "2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07"];
var values = [9953, 13258, 6353, 5429, 3741, 6522, 8211, 4437, 4035, 4066, 2936, 4470];
var stores = [208, 228, 196, 177, 175, 193, 206, 190, 179, 179, 167, 170];
var perStore = values.map((v, i) => stores[i] > 0 ? v / stores[i] : 0);
globalThis.__HTML__ = renderToStaticMarkup(
React.createElement(NetworkLineChart, { labels, values, stores, perStore })
);
+44 -57
View File
@@ -1,70 +1,57 @@
FROM node:20-alpine AS base
# CollectFlow — image de production.
#
# 1. web : compile l'interface React (Vite) en fichiers statiques.
# 2. server : compile le serveur Rust (binaire unique, optimisé).
# 3. runner : Debian minimale + Chromium (extraction Qlik) + les deux livrables.
#
# L'image est construite et publiée par GitHub Actions à chaque push sur main
# (ghcr.io/r0m1k3/collectflow:latest) ; docker-compose la tire au démarrage.
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
# ---------------------------------------------------------------------------
FROM node:22-alpine AS web
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
# Next.js telemetry is disabled
ENV NEXT_TELEMETRY_DISABLED=1
# Limit workers and increase memory to prevent OOM
ENV NODE_OPTIONS="--max-old-space-size=4096"
ENV NEXT_PRIVATE_WORKER_THREADS=0
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
# ---------------------------------------------------------------------------
FROM rust:1-bookworm AS server
WORKDIR /build
# Dépendances d'abord : couche réutilisée tant que Cargo.lock ne change pas.
COPY server/Cargo.toml server/Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src target/release/deps/collectflow* target/release/collectflow*
COPY server/ ./
RUN cargo build --release --locked && cp target/release/collectflow /collectflow
# ---------------------------------------------------------------------------
FROM debian:bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Next.js telemetry is disabled
ENV NEXT_TELEMETRY_DISABLED=1
# Chromium pour l'extraction Qlik (websocket Engine ouvert dans la page) + polices.
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium ca-certificates fonts-liberation fonts-freefont-ttf tzdata \
&& rm -rf /var/lib/apt/lists/*
# Chromium pour l'extraction Qlik via Playwright (ws in-page) + dépendances headless
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium-browser
# Même UID/GID que l'ancienne image Node : le volume /app/data existant reste lisible.
RUN groupadd --system --gid 1001 collectflow \
&& useradd --system --uid 1001 --gid collectflow --home-dir /app collectflow \
&& mkdir -p /app/data \
&& chown -R collectflow:collectflow /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=server /collectflow /app/collectflow
COPY --from=web --chown=collectflow:collectflow /app/dist /app/web
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache and data storage
RUN mkdir .next data
RUN chown nextjs:nodejs .next data
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# playwright-core n'est pas toujours tracé par le standalone — on le copie explicitement
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/playwright-core ./node_modules/playwright-core
# Grant write access to /app for settings persistence
RUN chown -R nextjs:nodejs /app
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
USER nextjs
ENV PORT=5643 \
HOSTNAME=0.0.0.0 \
DATA_DIR=/app/data \
STATIC_DIR=/app/web \
PLAYWRIGHT_CHROMIUM_PATH=/usr/bin/chromium
USER collectflow
EXPOSE 5643
VOLUME ["/app/data"]
ENV PORT=5643
# set hostname to localhost
ENV HOSTNAME="0.0.0.0"
# Run the DB init script, then start the standalone server
CMD node scripts/db-init.js && node server.js
CMD ["/app/collectflow"]
+39 -35
View File
@@ -2,56 +2,60 @@
CollectFlow est une application de révision d'assortiment et d'analyse de performances des produits en point de vente, conçue pour consolider les données issues de plusieurs fournisseurs et magasins.
## Architecture
| Partie | Technologie | Dossier |
|---|---|---|
| Serveur (API, base, FF Nancy, Qlik, synchro nocturne) | **Rust** — Axum, sqlx, chromiumoxide | `server/` |
| Interface | React 19 + Tailwind 4, compilée en fichiers statiques par Vite | `src/` |
Un seul binaire Rust sert l'API JSON (`/api/...`, dont l'API publique `/api/v1`) et l'interface compilée. Plus de Node.js à l'exécution.
## Prérequis
- **Node.js** 18+ (pour le développement local)
- **Docker** et **Docker Compose** (pour le déploiement conteneurisé)
- **PostgreSQL** 16+
- **Docker** et **Docker Compose** (déploiement)
- **PostgreSQL** 16+ (base séparée, non fournie par le conteneur)
- Pour développer : **Rust** stable (`rustup`) et **Node.js** 22+
## Lancer avec Docker
Le projet est configuré pour tourner dans un conteneur Docker optimisé (mode standalone de Next.js).
**Note :** Vous devez disposer d'une base de données PostgreSQL séparée (le conteneur ne lance que l'application web).
L'image est construite par GitHub Actions à chaque mise à jour de `main` et publiée sur `ghcr.io/r0m1k3/collectflow:latest`. Le `docker-compose.yml` la récupère à chaque démarrage (`pull_policy: always`) : **une nouvelle version s'installe au prochain `docker compose up -d`**, sans rien compiler sur le serveur.
### 1. Démarrer l'application
À la racine du projet, lancez :
```bash
npm run docker:build
# ou directement
docker-compose up -d --build
```
L'application sera accessible sur [http://localhost:5643](http://localhost:5643).
# Une seule fois, si le paquet GHCR est privé (jeton GitHub avec le droit read:packages) :
echo <JETON> | docker login ghcr.io -u <utilisateur-github> --password-stdin
*L'application sera attachée au réseau Docker externe `nginx_default` afin d'être exposée derrière votre reverse proxy Nginx. Assurez-vous que ce réseau existe (`docker network create nginx_default`).*
### 2. Arrêter l'application
```bash
npm run docker:down
# ou directement
docker-compose down
# Démarrer / mettre à jour :
docker compose up -d
```
## Développement Local (Sans Docker)
L'application est accessible sur [http://localhost:5643](http://localhost:5643). Elle est attachée au réseau Docker externe `nginx_default` pour être exposée derrière le reverse proxy Nginx (`docker network create nginx_default` s'il n'existe pas).
Si vous préférez développer en local, vous devrez configurer votre propre base de données PostgreSQL.
Construire l'image localement plutôt que la télécharger : `docker compose build`.
### Données et réglages
Le volume `collectflow-data` (`/app/data`) conserve les réglages saisis dans **Paramètres** (`.db-config.json` : base, Qlik, API FF, synchro nocturne), les comptes de secours (`users.json`) et le secret de session (`.auth-secret`, si `AUTH_SECRET` n'est pas défini). Il est repris tel quel depuis l'ancienne version Node : comptes et mots de passe restent valables (seules les sessions ouvertes sont à refaire).
Variables d'environnement utiles : `DATABASE_URL`, `AUTH_SECRET`, `FF_API_BASE_URL`, `QLIK_HOST` / `QLIK_USER` / `QLIK_PWD` (et les réglages fins `QLIK_*`), `PG_STATEMENT_TIMEOUT_MS`, `FF_CACHE_TTL_S`, `RUST_LOG` (niveau de journalisation ; par défaut `info`, avec sqlx et Chromium limités aux avertissements).
## Développement local
1. Installer les dépendances :
```bash
# Serveur (port 5643) — tables créées et compte admin/admin initialisé au premier démarrage
cd server
DATABASE_URL=postgres://postgres:postgres@localhost/collectflow cargo run
# Interface (port 5173, /api relayé vers le serveur)
npm install
```
2. Configurer la connexion DB :
Allez sur la page des Paramètres (`/settings`) dans l'application pour configurer l'accès à votre PostgreSQL local.
3. Lancer le serveur de développement :
```bash
npm run dev
```
## Structure du Projet (BMAD)
Vérifications : `cargo test` (dans `server/`), `npm run lint`, `npm run build`.
Ce projet respecte l'architecture **BMAD** (Business, Model, Application/API, Data) :
- `src/features/*` : Logique métier (Business) isolée par feature (ex: `grid`, `snapshots`)
- `src/types/*` : Interfaces TypeScript (Model)
- `src/app/*` : Routeurs Next.js UI et endpoints d'API (Application/API)
- `src/db/*` : Schémas Drizzle ORM et connexions (Data)
## Structure
- `server/src/` — serveur Rust, un module par domaine : `auth` (sessions, clés d'API), `ff` (base miroir et API FF Nancy), `qlik` (données réseau), `grid` (révision d'assortiment, API `/api/v1`), `produits`, `admin` (comptes, paramètres, synchronisation nocturne).
- `server/sql/init.sql` — schéma joué (idempotent) à chaque démarrage.
- `src/` — interface React : `app/` (écrans, mêmes dossiers que les URL), `features/*` par domaine, `components/` partagés, `router.tsx` (table des routes), `lib/rpc.ts` (appels au serveur).
- `server/PORTING.md` — contrat entre l'interface et le serveur (actions, données de pages, routes).
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-31
View File
@@ -1,31 +0,0 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json");
let url = "";
if (fs.existsSync(CONFIG_PATH)) {
url = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")).url;
}
const pool = new Pool({ connectionString: url || process.env.DATABASE_URL });
async function check() {
try {
const resSuppliers = await pool.query(\`SELECT DISTINCT code_fournisseur FROM ventes_produits LIMIT 5\`);
console.log("Suppliers in DB:", resSuppliers.rows.map(r => r.code_fournisseur));
for (const row of resSuppliers.rows) {
const supplier = row.code_fournisseur;
const countRes = await pool.query(\`SELECT COUNT(*) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]);
const distinctCodein = await pool.query(\`SELECT COUNT(DISTINCT codein) FROM ventes_produits WHERE code_fournisseur = $1\`, [supplier]);
console.log(\`Supplier \${supplier}: Total Rows = \${countRes.rows[0].count}, Distinct Products = \${distinctCodein.rows[0].count}\`);
}
} catch (e) {
console.error("Error:", e);
} finally {
await pool.end();
}
}
check();
-19
View File
@@ -1,19 +0,0 @@
import { db } from "./src/db";
import { sql } from "drizzle-orm";
async function check() {
try {
const res = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits`);
console.log("Total rows in ventes_produits:", res.rows[0].count);
const resDistinct = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits`);
console.log("Total distinct codein in ventes_produits:", resDistinct.rows[0].count);
const resSupplier = await db.execute(sql`SELECT code_fournisseur, COUNT(*) as row_count, COUNT(DISTINCT codein) as prod_count FROM ventes_produits GROUP BY code_fournisseur`);
console.log("Counts per supplier:", resSupplier.rows);
} catch (e) {
console.error("Error:", e);
}
}
check();
+7 -6
View File
@@ -1,20 +1,21 @@
version: "3.8"
services:
web:
# Image publiée par GitHub Actions à chaque mise à jour de main.
# `pull_policy: always` : chaque `docker compose up -d` récupère la
# dernière version avant de (re)démarrer le conteneur.
image: ghcr.io/r0m1k3/collectflow:latest
pull_policy: always
# Construction locale possible : `docker compose build` (utilise le Dockerfile).
build:
context: .
dockerfile: Dockerfile
ports:
- "5643:5643"
environment:
- NODE_ENV=production
- AUTH_TRUST_HOST=true
- AUTH_SECRET=my-super-secret-key-that-should-be-changed-in-production
- DATABASE_URL=postgres://postgres:MOT_DE_PASSE_ICI@postgresbase:5432/collectflow
- FF_API_BASE_URL=https://api.ffnancy.fr
# Mais comme CollectFlow permet de le configurer depuis l'interface (settings), ce n'est pas strictement obligatoire au lancement.
# La base, Qlik et l'API FF se règlent aussi depuis l'interface (Paramètres).
volumes:
- collectflow-data:/app/data
restart: unless-stopped
+466
View File
@@ -0,0 +1,466 @@
# Plan — CollectFlow : interface simple, lisible et rapide
## Contexte
Analyse des 13 pages (+ layout, menu, en-tête, API, accès BDD). Constats principaux :
- **Lisibilité** : plus de 160 textes entre 8,5 et 11 px. Libellés en MAJUSCULES grasses gris pâle, sous le seuil de contraste AA. Jargon partout (Init., PCB, PRMP, Franco, Sync Qlik, CODEIN…). Libellés mi-anglais, mi-français (Dashboard, Analytics, Snapshots).
- **Incohérence** :
- **Thème** : 5 pages sont figées en blanc (`bg-gray-50`) dans une application sombre. Les classes `dark:` suivent le réglage de l'OS, pas le bouton de thème.
- **Composants** : 7 styles d'en-tête de page, 13 tableaux codés à la main, formateurs de nombres copiés dans 12 fichiers.
- **Vocabulaire** : magasins nommés de 8 façons. « Réseau » désigne tantôt les ~270 magasins, tantôt nos 2.
- **Ergonomie** :
- La recherche de l'en-tête ne filtre que la Grille, même depuis les autres pages.
- L'utilisateur « Michael » est codé en dur dans l'en-tête.
- La Grille affiche deux boutons « Valider » et deux exports, avec des droits différents.
- Chaque chargement de page bloque tout l'écran (modale plein écran), et aucune page d'erreur n'existe.
- **Lenteur** :
- Hors tableau de bord, aucune donnée n'est mise en cache, alors que les données FF ne changent qu'une fois par nuit.
- Une Grille froide se reconstruit en 10 à 43 s. Ce calcul a lieu après chaque enregistrement, après chaque changement de magasin, et à chaque retour par le menu après un clic sur « Rafraîchir » : le paramètre `_refresh` reste collé à l'URL.
- La Grille rend tout le tableau à chaque paquet de 150 lignes, donc un coût quadratique.
- `exceljs` et `jspdf` sont chargés pour tous les utilisateurs.
- Plusieurs requêtes SQL ne peuvent pas utiliser d'index : `TRIM(codein)`, `TO_CHAR(datmvt)`.
- **Bugs relevés** :
- Après un filtre, la sélection multiple de la Grille vise d'autres produits.
- Le filtre « passées » de Publicités ne s'applique pas (clé `passees` dans le filtre, `passee` dans les libellés).
- L'erreur de chargement des Snapshots n'est jamais affichée.
- La carte KPI du tableau de bord répète sa valeur principale.
**Choix validés** : thème **clair par défaut** ; menu **regroupé et renommé**, avec Snapshots et Exports fusionnés en « Historique » ; livraison **par lots, une PR par lot**, sur la branche `claude/keen-maxwell-hbtho6`.
Conventions du dépôt :
- préfixe `rtk` sur toutes les commandes ;
- commits en français ;
- plan de chaque lot rangé selon BMAD (Business / Model / API / Data, `.agents/rules/general.md`).
---
## Lot 1 — Vitesse et bugs, sans changement visuel (PR 1)
Risque faible, gain immédiat.
**Grille (client)**
- `src/features/grid/components/grid-client.tsx` :
- retirer `_refresh` de la requête mémorisée dans `activeGridQuery` (l.86-91), puis le supprimer de l'URL après usage avec `router.replace` ;
- sortir `code1` et `code2` des dépendances du chargement : le serveur ne les lit pas.
- `grid-client.tsx:152-156` : accumuler les paquets reçus et ne mettre à jour le store que toutes les ~250 ms, plus une fois à la fin, au lieu de reconstruire à chaque paquet.
- `heatmap-grid.tsx` :
- ajouter `getRowId: r => r.codein` (corrige le bug de sélection) ;
- mémoriser `MONTHS_12` sur sa clé texte (l.628) ;
- retirer `transition-all` sur les lignes et le flou du bandeau d'en-tête.
- `export-dropdown.tsx` :
- `exceljs`, `jspdf` et `jspdf-autotable` passent en `await import()` dans les gestionnaires ;
- le composant est chargé via `next/dynamic` ;
- remplacer `useGridStore()` sans sélecteur (l.12).
- Créer `src/lib/format.ts` : instances `Intl.NumberFormat` créées une seule fois, plus `fmtEur`, `fmtQte`, `fmtPct`, `fmtDate`. Les cellules de la Grille l'utilisent ; les autres pages au lot 3.
**Grille (serveur)** — `src/features/grid/api/get-product-rows.ts`
- Lancer la requête des métriques Qlik et celle du dernier snapshot dans le même `Promise.all` que la phase 1 (l.130-146, 410, 416).
- `qlik-network-cache.ts` : ne sélectionner que les colonnes utiles, sans le JSONB `metricsByMonth`.
- Cache mémoire :
- le borner (LRU d'environ 30 entrées) ;
- garder la déduplication des calculs en cours même en `forceRefresh` ;
- transmettre `request.signal` depuis `src/app/api/grid/rows/route.ts`.
- Après un enregistrement, **mettre à jour en place** le `codeGamme` des lignes en cache au lieu d'invalider tout le cache (`save-draft-changes.ts:73`). Nouvelle fonction `patchGridRowsCache()` à côté de `invalidateGridRowsCache()`.
- Supprimer les `console.log` des chemins chauds : `get-product-rows.ts` (l.83, 123, 148, 433, 444, 622) et `pg-ff-client.ts` (l.99, 300-303, 431, 516).
**Cache des données FF jusqu'à la synchro nocturne**
- Créer `src/lib/ff-cache.ts` : enveloppes `unstable_cache` étiquetées `ff-data`, durée 6 h, sur le modèle déjà en place dans `dashboard/page.tsx:4-8`. Elles couvrent :
- les 3 requêtes de stock ;
- le hit-parade (clé : dates) ;
- les CA par fournisseur et par nomenclature (clé : mois et mode) ;
- `pgGetFournisseurs` ;
- `getFournisseurs` de la Grille ;
- la dernière réception par fournisseur ;
- `pgGetCommandesAuto`.
- Publicités : `fetch` avec `next: { revalidate, tags }` au lieu de `no-store`.
- À la fin du cycle nocturne (`src/features/admin/api/sync-scheduler.ts`) : invalider l'étiquette `ff-data`, puis préchauffer le tableau de bord et le stock.
- Points à vérifier :
- la signature de `revalidateTag` en Next 16 ;
- que les valeurs mises en cache passent en JSON (les `Date` deviennent des chaînes).
**Base de données et API**
- `src/db/index.ts` :
- pool rangé dans `globalThis` ;
- `options: "-c statement_timeout=60000 -c max_parallel_workers_per_gather=0"` et `application_name`.
- `pgNoParallel` (`pg-ff-client.ts:25-33`) n'a alors plus besoin de BEGIN/SET/COMMIT : 3 allers-retours de moins par requête.
- `AbortSignal.timeout` sur tous les appels à l'API FF : `api-ff-client.ts`, `publicites/page.tsx`, appels du tableau de bord.
- Index des tables applicatives, dans `scripts/db-init.js` et `src/db/schema.ts` :
- `session_snapshots(code_fournisseur, created_at DESC)` ;
- `session_snapshots(user_id, type, created_at DESC)`.
- Divers :
- Commandes auto : supprimer le double rechargement, en retirant `router.refresh()` puisque `revalidatePath` est déjà appelé (`cadencier-client.tsx:172-177`).
- Synchronisation : PATCH envoyé à la sortie du champ, plus à chaque frappe (`admin/synchronisation/client.tsx:209-237`).
- Paramètres : un seul appel à `getSavedDatabaseConfig`.
- `log-capture.ts` : sortie immédiate si aucune capture n'est ouverte.
**Bugs fonctionnels**
- Publicités : aligner la clé `passees`/`passee` (`publicites/client.tsx:141, 256`).
- Historique : afficher l'état d'erreur (`snapshot-list.tsx:133`).
- Tableau de bord : la carte KPI répète sa valeur (`dashboard/page.tsx:183-185`).
- Barre de résumé de la Grille : `filters.magasin` n'est jamais renseigné, donc les snapshots enregistrent toujours « TOTAL » (`floating-summary-bar.tsx:104`).
---
## Lot 2 — Socle visuel, menu et vocabulaire (PR 2)
**Thème et typographie** — `src/app/globals.css`, `theme-provider.tsx`
- `defaultTheme="light"`.
- Ajouter `@custom-variant dark (&:where(.dark, .dark *));` pour que les classes `dark:` suivent le bouton de thème.
- Ajouter `@import "tw-animate-css";`.
- Faire correspondre les jetons shadcn (`--color-background`, `--color-muted-foreground`, `--color-accent`…) aux variables de l'application.
- Contraste AA (≥ 4,5:1) pour `--text-muted` en clair et en sombre.
- Échelle typographique :
- rien sous **12 px** ;
- cellules de tableau à 13-14 px ;
- libellés en casse normale, sans MAJUSCULES `font-black tracking-widest`.
- Une seule famille de boutons (fusion de `.apple-btn-*` et `.btn-action-*`) ; définir ou retirer les classes inexistantes (`apple-btn`, `--border-subtle`, `--surface-tertiary`).
**Composants partagés** — dans `src/components/ui/`
- `PageHeader` : titre, phrase d'explication, actions.
- `Card` / `Section`, `StatCard` (KPI avec écart N-1).
- `Badge`, `DeltaBadge`, `StoreBadge`, `GammeBadge`.
- `Tabs`, avec l'onglet actif dans l'URL.
- `Select`, `SearchInput`.
- `EmptyState`, `ErrorState`, `Skeleton`.
- `ConfirmDialog` sur `ui/dialog`. Il remplace `window.confirm`, `alert()` et les 3 modales faites à la main.
- Notifications discrètes (`sonner`, standard shadcn) à la place de `SuccessModal` pour les succès.
- **`DataTable<T>`** : généralisé depuis `StockTable<T>` (`stock-negatif/client.tsx:168-384`), qui gère déjà colonnes, tri, recherche, pagination et export. Ajouter en-tête collant, état vide, et infobulle sur les en-têtes.
**Sources uniques**
- `src/lib/magasins.ts` : 292 → « Frouard (Nancy) », 579 → « Houdemont », TOTAL → « Nos 2 magasins ». Il remplace `SITE_NAMES` (dashboard), `months.ts:91-94` et les 3 copies de `SITES`.
- `src/lib/glossaire.ts` : libellé clair et infobulle pour chaque terme métier. Exemples :
- Gamme A–Z avec son sens, à reprendre de `bulk-action-toolbar.tsx:8-12` ;
- Réseau = environ `NB_MAGASINS_RESEAU` magasins (`network-trend.ts:13`) ;
- Franco, PCB, PRMP, GTIN, N-1, Init.
- Règles de vocabulaire :
- « Réseau » = les ~270 magasins uniquement ; nos 2 magasins = « Nos magasins » ;
- Famille et Sous-famille identiques partout ;
- vouvoiement partout.
- Seuils de marge : une seule constante, au lieu de 25/15 % sur le tableau de bord et 40/25 % sur la fiche.
**Menu** — `src/components/shared/sidebar.tsx`
- Menu déplié par défaut, en groupes :
- **Au quotidien** : Accueil, Révision d'assortiment, Recherche produit, Stocks à surveiller, Commandes fournisseurs.
- **Analyses** : Meilleures ventes, Ventes par mois, Publicités.
- **Historique** : nouvelle page `/historique` qui fusionne Snapshots et Exports, avec un onglet par type ; l'onglet Exports est réservé aux admins. `/snapshots` et `/exports` y redirigent.
- **Administration** : Synchronisation, Paramètres.
- Icônes distinctes pour chaque entrée.
- `src/middleware.ts` : protéger aussi côté serveur la partie admin de l'historique.
**En-tête et coquille**
- `header.tsx` :
- retirer la recherche globale, qui ne filtrait que la Grille ; la Grille garde la sienne ;
- afficher le vrai utilisateur ;
- réglages personnels (thème, densité de la Grille) dans un menu utilisateur accessible à tous.
- Remplacer les 11 `loading.tsx` (modale plein écran) par des squelettes de page non bloquants.
- Ajouter `app/(dashboard)/error.tsx` (message clair et bouton « Réessayer ») et `not-found.tsx`.
- Titre de l'application en français (`app/layout.tsx`).
---
## Lot 3 — Migration des pages, une à une (PR 3, découpable en 3a/3b)
Règle commune à chaque page :
- `PageHeader` avec une phrase « à quoi sert cette page » ;
- `DataTable` et composants partagés, jetons du thème ;
- retrait des enveloppes `min-h-screen bg-gray-50 p-6` (double marge) ;
- noms de magasins depuis `magasins.ts`, infobulles depuis `glossaire.ts` ;
- états vide et erreur explicites.
| Page | Changements spécifiques |
|---|---|
| Accueil (`dashboard`) | Cartes `StatCard`. Section « Hit Parade » renommée « Top 10 d'hier ». Chaque produit mène à sa fiche. Couleurs codées en dur remplacées par les jetons. Message d'erreur compréhensible, sans trace technique. |
| Révision d'assortiment (`grid`) | **Barre d'outils** : un seul « Enregistrer » (dans la barre de résumé), un seul menu Export, un seul « Actualiser », avec les mêmes droits partout (`isAdmin` calculé côté serveur via `auth()` dans `grid/page.tsx`). Les actions rares (Non travaillés, Vues, Colonnes, Synchro Qlik) passent dans « Plus d'options ». **Libellés** : gammes affichées avec leur sens (« A — Cœur ») ; menu Colonnes avec de vrais libellés, pas les identifiants ; « / 270 » remplacé par `NB_MAGASINS_RESEAU`. **Chargement** : la superposition floue est remplacée par une barre de progression légère. **Accueil fournisseur** : une phrase-guide. |
| Recherche produit (`produits`) | Tailles ≥ 12 px dans `fiche.tsx`, `results.tsx` et `opportunites.tsx`. Infobulles PCB, PRMP, GTIN. Famille et Sous-famille alignées. Message de chargement sans « Qlik Sense ». |
| Meilleures ventes (`hit-parade`) | `DataTable` paginé ; état vide ; « % Marge » triable. Export via `exceljs` en import dynamique, ce qui permet de retirer `xlsx`. |
| Stocks à surveiller (`stock-negatif`) | Base du `DataTable`. En-têtes Excel lisibles (« Code article » au lieu de CODEIN, etc.). Noms de magasins. |
| Publicités | Le tri et le filtre magasin s'appliquent à l'ensemble des données, pas seulement à la page de 50. Infobulles « Taux de sortie » et « % CA total ». |
| Commandes fournisseurs (`commandes-auto`) | Onglets dans l'URL. Confirmation avant suppression. Erreurs affichées en notification. Retour visuel à l'enregistrement d'une fréquence. Infobulle « Franco ». |
| Ventes par mois (`analytics`) | Dernier **mois complet** par défaut. Mois en clair (« oct. 2026 vs oct. 2025 »). Recherche, tri, export, état vide. |
| Historique | Page fusionnée (lot 2), libellés de session lisibles. |
| Paramètres | Onglets Connexions / Utilisateurs / Clés API / Journal. Un bouton « Enregistrer » par section (le bouton collant trompeur disparaît). Thème et densité déplacés dans le menu utilisateur. |
| Synchronisation | Unités affichées (« jours », « heures »). Filtres en casse normale. Libellés sans jargon (« Tour bouclé » → « Tous les fournisseurs traités »). |
| Connexion | Libellés lisibles. Slogan « Propulsée par l'IA » retiré. Rendu clair. |
---
## Lot 4 — Optimisations lourdes (PR 4)
**Grille servie depuis l'instantané `grid_rows`** (le plus gros gain)
- La table est déjà remplie chaque nuit avec exactement les `ProductRow` du magasin TOTAL : `persistGridSnapshot` dans `get-product-rows.ts`, et `sync-scheduler.ts:173`.
- Ajouter `readGridRowsPayload(codeFournisseur)` dans `src/lib/grid-store.ts`, qui lit `payload::text` et l'envoie tel quel, sans analyse ni réencodage.
- Superposer ensuite la gamme INIT à jour (`refreshGammeInit`) et les gammes du dernier snapshot.
- Si l'instantané est trop ancien, relancer le calcul en arrière-plan (stale-while-revalidate).
- Ouverture à froid : de 10-43 s à environ 1-2 s.
**Changement de magasin instantané**
- Toujours charger TOTAL : les lignes portent déjà le détail par magasin.
- Bascule côté client avec `setActiveMagasin` et `history.replaceState`, sans aller-retour serveur (`grid-filter-bar.tsx:100-105`).
- Le rattrapage via l'API FF (`reconcileSelectedStoreFromMensuelApi`) devient une petite requête séparée, `/api/grid/rows/store-patch`, appliquée quand elle arrive.
**Rendu de la Grille**
- `React.memo` sur `HeatmapGrid`, `GridFilterBar` et `FloatingSummaryBar`.
- `useSaveDrafts` déplacé dans le bouton Enregistrer.
- En-tête de tableau mémorisé ; `useFlushSync: false` sur le virtualiseur.
- Largeurs de colonnes en variables CSS.
- `persist` de zustand sans les lignes, et limité en fréquence.
- Tendance réseau et texte de recherche calculés une seule fois par ligne à la réception ; le filtre global ne porte plus que sur ce texte.
- Lignes gardées en mémoire au retour sur la Grille (même fournisseur, moins de 10 min) : pas de `setRows([])`.
**SQL sans index utilisable**
À valider par `EXPLAIN ANALYZE` sur la base réelle avant fusion : le schéma des tables FF n'est pas dans le dépôt.
- Fiche produit (`get-produit-fiche.ts`, `pg-ff-client.ts:1737-2045`) : résoudre `no_id` une fois, puis filtrer sur les clés ; lecture du cache Qlik dans le même `Promise.all` ; supprimer la double recherche du chemin `?cc=`.
- Ventes par mois (`pg-ff-client.ts:827-885`) : plages de dates au lieu de `TO_CHAR`.
- Stocks : réécrire `pgGetStockSansVente` (l.1198-1235) en agrégeant d'abord par (article, site). Charger seulement l'onglet actif.
- Meilleures ventes (l.922-991) : jointure sur `artnoid` / `no_id`.
- Commandes : la dernière réception est limitée aux fournisseurs du cadencier et à une fenêtre de dates ; appels « franco » à concurrence bornée, avec délai maximal ; un `Suspense` par onglet.
- Tableau de bord (l.623-797) : une seule requête SQL sur `cube_stock`, en réutilisant `pgGetStockForCodeins`, au lieu de ~60 appels HTTP. Cache par date, préchauffé après la synchro.
- `/api/v1/grid` : pagination par curseur (keyset) au lieu d'OFFSET ; COUNT une seule fois.
- `api-auth.ts` : `last_used_at` mis à jour au plus une fois par minute.
**Nettoyage**
- Dépendances inutilisées : `@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, et `xlsx` après le lot 3.
- Code mort : `supplier-selection.tsx`, `financial-cell.tsx`, `ui/textarea.tsx`, `getGridData` et `getAvailableNomenclature` (`grid/actions.ts`).
---
## Hors périmètre, à signaler
`src/features/settings/actions.ts` ne vérifie aucun droit. Tout utilisateur connecté peut lire l'URL de la base, le mot de passe Qlik et les clés IA, et réécrire la configuration. Ce point est proposé en tâche séparée.
## Vérification (à chaque lot)
1. **Contrôles automatiques** :
- `rtk tsc` : aucune nouvelle erreur par rapport à `tsc_errors.log`, car `ignoreBuildErrors` est activé.
- `rtk lint`.
- `rtk next build` : comparer le JS de premier chargement de `/grid` avant et après (lot 1 : − `exceljs` / `jspdf`).
2. **Visuel** :
- `rtk npm run dev`, puis Playwright (Chromium préinstallé) pour capturer chaque page en clair et en sombre, en 1920 et 1366 px de large.
- Un script dans la page signale tout texte calculé sous 12 px et tout élément `bg-white` en mode sombre.
3. **Vitesse** :
- mesurer le temps de réponse de `/api/grid/rows` (froid, chaud, après enregistrement), et de `/stock-negatif` et `/hit-parade` au 1er puis au 2e chargement ;
- objectif : 2e visite en moins de 200 ms, Grille froide en moins de 2 s au lot 4.
4. **Recette manuelle sur l'environnement réel** (ce conteneur n'a pas accès à PostgreSQL FF, à l'API FF ni à Qlik) :
- gros fournisseur ;
- changement de magasin instantané ;
- enregistrer puis rouvrir sans attente ;
- sélection multiple après filtre, sur les bons produits ;
- après « Actualiser », un retour par le menu ne force plus le recalcul ;
- exports Excel identiques.
---
## Suivi — Lot 1 réalisé
Écarts par rapport au plan, décidés pendant l'implémentation :
- **Cache FF** : un cache mémoire dédié (`src/lib/ff-cache.ts`) remplace `unstable_cache`. Deux raisons :
- `unstable_cache` sérialise en JSON, ce qui casse les `Map` et les `Date` ;
- `revalidateTag` ne peut pas être appelé depuis la synchro nocturne, qui tourne hors requête.
Fonctionnement :
- durée de vie de 30 min (`FF_CACHE_TTL_S`), et 10 min pour les publicités ;
- les appels simultanés partagent une seule requête ;
- les erreurs ne sont jamais gardées ;
- le cache est vidé en fin de synchro nocturne.
- **`statement_timeout`** : activé seulement si `PG_STATEMENT_TIMEOUT_MS` est défini, pour ne pas couper les longs calculs nocturnes.
- **Colonnes Qlik** : non modifié. `metricsByMonth` sert à la courbe « nombre de magasins vendeurs ».
- **Carte KPI du tableau de bord** : non modifiée. La ligne « Hier / N-1 » est une comparaison voulue, pas un doublon.
- **Préchauffage après la synchro** : reporté. L'invalidation suffit, la première visite recharge.
## Suivi — Lot 2 réalisé
- **Thème** :
- clair par défaut ; les classes `dark:` suivent le bouton de thème ;
- animations (`tw-animate-css`) réellement importées ;
- couleurs shadcn branchées sur les variables ;
- libellés gris au contraste AA dans les deux thèmes ;
- fond plein pour le menu et l'en-tête (le flou ne servait à rien).
- **Composants partagés**, dans `src/components/ui/` :
- `PageHeader`, `Card`, `Field`, `StatCard` ;
- `Badge`, `DeltaBadge`, `StoreBadge`, `GammeBadge` ;
- `Tabs`, `Segmented`, `useUrlTab` ;
- `Select`, `SearchInput`, `Input` ;
- `EmptyState`, `ErrorState`, `Skeleton`, `PageSkeleton` ;
- `Tooltip`, `Terme` (glossaire) ;
- `toast` et `confirmer` (`feedback.tsx`) ;
- `Pagination`, `DataTable`.
- **Sources uniques** :
- `lib/magasins.ts` (noms des magasins) ;
- `lib/gammes.ts` (A à Z, avec leur sens et leurs couleurs) ;
- `lib/glossaire.ts` ;
- `lib/marge.ts` (seuils 40 / 25 %, ceux de la Grille) ;
- `lib/export-excel.ts` (export unique, via exceljs).
- **Navigation** :
- menu groupé (Au quotidien / Analyses / Suivi / Administration), déplié par défaut, libellés en français ;
- en-tête : nom de la page, thème, menu du compte (vrai nom, hauteur des lignes de la Grille pour tous, déconnexion) ;
- la recherche globale, qui ne filtrait que la Grille, est retirée.
- **Historique** : nouvelle page `/historique` (sessions et exports), et redirection des anciennes adresses `/snapshots` et `/exports`. Les deux onglets sont visibles par tous : chacun n'y voit que ses propres enregistrements.
- **Chargements et erreurs** :
- squelettes de page au lieu de la fenêtre plein écran ;
- pages d'erreur (`error.tsx`) et « page introuvable » en français ;
- après connexion, arrivée sur l'Accueil, comme pour `/`.
- **Écart** : les en-têtes Excel de la page Stocks (CODEIN, GENCODE, QTE, CODMV, avec les codes 503 / 412) **ne seront pas renommés**. C'est vraisemblablement un format d'import de régularisation dans FF.
## Suivi — Lot 3 réalisé
Toutes les pages utilisent désormais :
- `PageHeader` avec une phrase d'explication ;
- les couleurs du thème (plus de pages figées en blanc) ;
- des textes d'au moins 12 px ;
- les noms de magasins, `toast` et `confirmer`.
Vérifié dans un navigateur : sur les pages visitées, en clair et en sombre, aucun texte n'est sous 12 px.
| Page | Changements principaux |
|---|---|
| Accueil | Cartes « Nos 2 magasins » : CA, tickets et panier moyen, avec l'évolution N-1. Une carte par magasin. Top 10 lisible, avec un lien vers la fiche produit. |
| Révision d'assortiment | **Barre du bas** : seul endroit pour enregistrer et exporter, avec les mêmes droits pour tous. Le menu Export regroupe les deux anciens. Bouton « Annuler » avec confirmation. **Filtres** : sens des gammes affiché (A Cœur…), magasin choisi en un clic, menu « Colonnes » en clair. **Chargement** : bandeau de progression au lieu de la fenêtre floue. **Droits** : lus côté serveur. |
| Recherche produit | Fiche lisible (Secteur / Rayon / Famille), infobulles PCB, PRMP, code-barres, réseau. Plus de mention technique de Qlik. |
| Stocks à surveiller | `DataTable` avec trois onglets et une explication pour chacun. **Export Excel inchangé** (format d'import FF). |
| Meilleures ventes | `DataTable` avec un tri sur toutes les colonnes et une ligne de totaux. Export via exceljs (`xlsx` n'est plus utilisé). |
| Ventes par mois | Dernier mois complet par défaut, mois en clair, bascule Fournisseur / Famille, recherche, tri, export. |
| Publicités | Onglets, statuts lisibles, part du CA calculée en pied de tableau. Le tri ne porte que sur la page affichée, et la page le signale. |
| Commandes fournisseurs | Onglets dans l'URL, confirmation avant suppression, retours de toutes les actions. |
| Historique | « Reprendre dans la Grille », chiffres visibles, états vides et d'erreur. |
| Paramètres | Onglets Connexions / Utilisateurs / Accès API / Journal serveur, un bouton Enregistrer par section. |
| Synchronisation | Vocabulaire clair, unités affichées, résumé en indicateurs. |
| Connexion | Libellés lisibles, fond sobre. |
Autres changements :
- **Composants retirés** : `SuccessModal`, `ConfirmModal`, `ChangePasswordModal`, `LoadingModal`, `export-dropdown`, `financial-cell` et `supplier-selection`, qui n'étaient plus utilisés.
- **Correction** : le nom de l'utilisateur est maintenant transmis à la session, et l'en-tête l'affiche.
## Suivi — Lot 4 réalisé
**Grille servie depuis l'instantané `grid_rows`**
- `getProductRows` lit d'abord l'instantané du fournisseur (`readGridSnapshot`), puis y reporte :
- la gamme FF du moment (INIT) ;
- les gammes du dernier enregistrement.
- Hors « Nos 2 magasins », le rattrapage du magasin choisi est appliqué comme après un calcul complet.
- L'instantané n'est pas utilisé dans trois cas, et la Grille est alors recalculée comme avant :
- il est absent ;
- il date d'un autre mois (la fenêtre des 12 mois a changé) ;
- il est d'un ancien format. Un numéro de version (`payloadVersion`) est désormais écrit dans chaque ligne, donc les instantanés existants seront recalculés une fois, à la première ouverture ou par la synchro de la nuit.
- Instantané de plus de 20 h : il est servi tout de suite, et un recalcul part en arrière-plan.
- « Actualiser » force toujours le calcul complet.
- Mesure locale (30 lignes) : 43 ms depuis l'instantané, 1 ms depuis le cache mémoire.
**Grille côté navigateur**
- Retour sur la Grille (même fournisseur et même magasin, moins de 10 min) : les lignes en mémoire sont réutilisées, sans nouveau téléchargement.
- Changement de magasin ou « Actualiser » sur le même fournisseur : la Grille reste affichée et utilisable. Les chiffres complétés remplacent l'affichage d'un bloc, une fois reçus.
- `React.memo` sur la Grille, la barre de filtres et la barre du bas. En-tête du tableau mémorisé. Virtualiseur sans `flushSync`.
**Requêtes**
- **Fiche produit** : les sept lectures, y compris le réseau Qlik, partent en même temps au lieu de deux vagues successives.
- **Ventes par mois** : filtre sur des plages de dates au lieu de `TO_CHAR(datmvt)`, ce qui permet à PostgreSQL d'utiliser un index sur `datmvt`. Résultats identiques vérifiés sur une base de test (colonnes `date` et `timestamp`, bornes de mois).
- **Stock sans vente** : même principe pour la borne « avant le mois en cours ».
- **Accueil** :
- enrichissement des produits par vagues de 12 appels au lieu de 5 ;
- cache porté à 30 min, puisque ce sont les ventes de la veille.
- **API `/api/v1`** :
- le comptage et la page sont lus en parallèle ;
- `last_used_at` d'une clé est écrit au plus une fois par minute ;
- la version interne des lignes n'est pas exposée.
**Non fait volontairement** (à reprendre avec un accès à la base réelle)
- Réécritures SQL lourdes : Meilleures ventes par `artnoid`, Stock sans vente agrégé d'abord, tableau de bord en une requête SQL au lieu des appels HTTP. Sans le schéma ni les volumes de la base FF, impossible de vérifier l'équivalence des résultats et le gain par `EXPLAIN ANALYZE`.
- Index sur les tables FF (`mouvements.datmvt`, `codein`) : ces tables ne sont pas gérées par CollectFlow. À proposer à l'administrateur de la base après `EXPLAIN ANALYZE`.
- Pagination par curseur de `/api/v1/grid` : elle changerait le contrat public (`page`) utilisé par des appelants externes.
- Suppression des dépendances inutilisées (`@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, `xlsx`) : le dépôt a deux fichiers de verrouillage (npm et pnpm). À faire avec le gestionnaire de paquets réellement utilisé en production.
**À vérifier en production**
- Première ouverture d'un gros fournisseur après la synchro de la nuit : elle doit être quasi immédiate.
- Changement de magasin : la Grille reste affichée, puis les chiffres se mettent à jour.
- Enregistrer, quitter la Grille, puis y revenir : les gammes enregistrées sont bien là.
## Suivi — Lot 5 réalisé (correctif Grille et sécurité)
**Grille : changement de magasin**
- **Corrige une régression du lot 4** : les lignes déjà affichées gardaient les chiffres de l'ancien magasin, et un tri actif n'était pas réappliqué, jusqu'à la fin du rechargement. Désormais, la liste est reconstruite à chaque changement de magasin : les chiffres et le tri sont justes tout de suite, et la sélection est conservée.
- **Plus d'aller-retour serveur** : la Grille charge toujours « Nos 2 magasins », dont les lignes portent déjà le détail de chaque magasin. Changer de magasin met seulement à jour l'URL, qui reste partageable.
- **Complément de l'API FF demandé à part** (`/api/grid/rows/store-patch`) et appliqué à son arrivée. Il couvre les articles sans vente SQL dans le magasin, comme avant.
- Le calcul est identique à l'ancien : comparaison automatique sur un jeu d'essai.
- Il ne modifie que les chiffres du magasin, pas les totaux « Nos 2 magasins ».
- Il n'est demandé qu'une fois par magasin et par chargement.
- **Sélection** : remise à zéro seulement au changement de fournisseur.
- **La recherche ne reconstruit plus toutes les lignes** à chaque frappe.
**Sécurité**
- **Contrôle commun `lib/authz.ts`** :
- le compte est relu en base, avec un cache d'une minute ; un compte supprimé ou rétrogradé perd ses droits sans attendre l'expiration de sa session ;
- si la base est injoignable, le jeton de session fait foi.
- **Paramètres** : actions réservées aux administrateurs, avec la même vérification sur les pages Paramètres et Synchronisation.
- **Toutes les autres actions et routes vérifient la session** : Grille, Commandes, Historique, exports, statut FF.
- **Routes de diagnostic** (`/api/diag*`, `/api/test-db`) : réservées aux administrateurs, sans trace d'erreur technique.
- **Mots de passe et clés d'IA** :
- ils ne sont plus envoyés au navigateur ; un champ laissé vide garde le mot de passe enregistré ;
- le mot de passe PostgreSQL n'est plus conservé dans le `localStorage`, et l'ancien est effacé ;
- enregistrer la connexion PostgreSQL n'efface plus les clés d'IA.
- **Compte admin/admin** :
- il n'est créé qu'au premier démarrage (aucun utilisateur en base) ;
- le fichier de secours `data/users.json` ne sert que si la base est injoignable ;
- `data/` est ignoré par git.
**À savoir après la mise en ligne**
- Un utilisateur promu administrateur doit se reconnecter pour accéder à Paramètres.
- Après avoir configuré la base depuis le compte de secours, il faut se reconnecter.
- Un mot de passe enregistré ne peut plus être effacé depuis l'écran ; seul son remplacement est possible.
## Suivi — Lot 6 réalisé (performances restantes et nettoyage)
**Grille**
- **Écriture des réglages dans le navigateur** : au plus une par demi-seconde, et immédiate quand la page est quittée. Avant, chaque changement d'état en déclenchait une. Mesure : 8 changements de gamme donnent 1 écriture au lieu de 16.
- **Calculs faits une seule fois par ligne** : la tendance réseau (au lieu d'un calcul par case et par rendu) et le texte de recherche (au lieu d'un calcul par ligne et par frappe). La petite courbe de tendance est mémorisée.
- **Barre du bas** : elle ne suit plus que le nombre de modifications ; le détail est lu au moment d'enregistrer.
**Commandes fournisseurs**
- **Montants de franco** : demandés à l'API FF 6 fournisseurs à la fois, au lieu de tous en même temps.
- **Dernière réception** : calculée pour les seuls fournisseurs du cadencier. Sur la base de test, les résultats sont identiques au calcul complet.
**Nettoyage**
- **Dépendances retirées** : `@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, `remark-gfm`, `xlsx`, `@radix-ui/react-dropdown-menu`. Un `npm ci` complet a été vérifié.
- **`pnpm-lock.yaml` supprimé** : Docker et le README utilisent npm.
- **Code mort retiré** :
- `ui/textarea`, `debug/api/init-db`, `score/store` ;
- `getGridData` et `getAvailableNomenclature` ;
- 5 fonctions de l'ancien client HTTP FF, `pgGetArticlesByCodeCentrale` et `libelleGamme`.
- **Journaux à la racine supprimés** : `build.log`, `build_logs.txt`, `error.txt`, `tsc_errors.log`.
**Non fait volontairement**
- **Enrichissement de l'Accueil en SQL** :
- le cache de la page sert la version précédente pendant son recalcul, donc le gain serait faible ;
- et rien ne garantit que la base FF relie chaque mouvement à son fournisseur (colonne détectée au cas par cas) : le fournisseur affiché pourrait changer.
- À reprendre après le diagnostic du lot 7.
- **Largeurs de colonnes en variables CSS** : peu de gain maintenant que l'écriture des réglages est différée.
## Suivi — Lot 7 réalisé (diagnostic de la base FF)
Les réécritures SQL restantes demandent de connaître la vraie base FF : types des colonnes, index, volumes. Ce dépôt ne les contient pas. Elles concernent :
- `TRIM(a.codein::text)` dans une quinzaine de requêtes ;
- `TO_CHAR(datmvt)` dans les regroupements ;
- la jointure par code plutôt que par identifiant dans Meilleures ventes.
Le script `scripts/diagnostic-ff.js` les relève, en lecture seule :
- version et réglages de PostgreSQL ;
- taille des tables et date de dernière analyse ;
- types des colonnes de jointure et de filtre ;
- index existants ;
- nombre de codes article contenant des espaces (pour savoir si `TRIM()` est utile) ;
- plans d'exécution de six requêtes représentatives : recherche d'un article par code avec et sans `TRIM`, Meilleures ventes sur 7 jours, dernière réception, ventes mensuelles d'un fournisseur, stock sans vente.
**À lancer sur le serveur de production** (conteneur `web` du docker-compose) :
```bash
docker compose exec web node scripts/diagnostic-ff.js > diagnostic-ff.txt
# Avec les durées réelles (exécute les requêtes, à faire hors heures d'ouverture) :
docker compose exec web node scripts/diagnostic-ff.js --analyze > diagnostic-ff-analyze.txt
```
Le fichier produit ne contient aucun mot de passe, seulement des noms de tables, de colonnes et d'index, et des plans d'exécution. Une fois transmis, il permet de décider :
- quels `TRIM()` retirer ;
- quels index proposer à l'administrateur de la base FF ;
- s'il faut remplacer l'enrichissement HTTP de l'Accueil.
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from "drizzle-kit";
import * as dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
BIN
View File
Binary file not shown.
+30 -12
View File
@@ -1,18 +1,36 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
/**
* Lint de l'interface (SPA Vite). Seul `src/` est vérifié : `server/` est en
* Rust.
*/
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
globalIgnores(["dist/**", "server/**", ".claude/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]),
{
files: ["src/**/*.{ts,tsx}", "vite.config.mts"],
extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended],
plugins: { "react-refresh": reactRefresh },
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
rules: {
// Exports utilitaires historiques des composants (rechargement à chaud
// seulement moins fin pour ces fichiers).
"react-refresh/only-export-components": [
"warn",
{
allowConstantExport: true,
allowExportNames: ["buttonVariants", "toast", "confirmer", "useUrlTab", "PROFONDEUR_MAX"],
},
],
},
},
]);
export default eslintConfig;
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CollectFlow</title>
<meta name="description" content="Révision d'assortiment et suivi des ventes des magasins" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<!-- Inter chargé côté navigateur (et non au build) : aucune dépendance réseau
à Google Fonts pendant la compilation (Docker). -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet" />
<script>
// Thème appliqué avant le premier rendu (sans flash), comme le script
// que next-themes injectait dans la page rendue par le serveur.
try {
var theme = localStorage.getItem("collectflow-theme") === "dark" ? "dark" : "light";
document.documentElement.classList.add(theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {}
</script>
</head>
<body class="font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"],
experimental: {
serverActions: {
bodySizeLimit: "50mb",
},
},
typescript: {
ignoreBuildErrors: true,
},
};
export default nextConfig;
+1003 -10755
View File
File diff suppressed because it is too large. Load diff
+16 -27
View File
@@ -3,58 +3,47 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node scripts/db-init.js && next start",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:build": "docker-compose up -d --build"
"typecheck": "tsc --noEmit",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:build": "docker compose build"
},
"dependencies": {
"@google/genai": "^1.49.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"ai": "^6.0.97",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"exceljs": "^4.4.0",
"httpntlm": "^1.8.13",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"playwright-core": "^1.61.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"react-router": "^7.18.4",
"tailwind-merge": "^3.5.0",
"ws": "^8.21.0",
"xlsx": "^0.18.5",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.9",
"@vitejs/plugin-react": "^6.1.2",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.13.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3"
"typescript": "5.9.3",
"typescript-eslint": "^8.71.2",
"vite": "^8.3.4"
}
}
-11081
View File
File diff suppressed because it is too large. Load diff
File renamed without changes.
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

-15
View File
@@ -1,15 +0,0 @@
const fs = require('fs');
async function test() {
try {
const res = await fetch("https://api.ffnancy.fr/api/articles?search=334152");
const data = await res.json();
console.log("ARTICLES SEARCH:", JSON.stringify(data, null, 2));
const res2 = await fetch("https://api.ffnancy.fr/api/articles/334152/referentiel");
const data2 = await res2.json();
console.log("ARTICLES REFERENTIEL BY CODEIN:", JSON.stringify(data2, null, 2));
} catch(e) {
console.log(e.message);
}
}
test();
-7
View File
@@ -1,7 +0,0 @@
const fs = require('fs');
async function test() {
const res = await fetch("https://api.ffnancy.fr/api/performance/dashboard?date=2024-04-20");
const data = await res.json();
console.log(JSON.stringify(data.top10_ca?.[0] || {}, null, 2));
}
test();
-135
View File
@@ -1,135 +0,0 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const cwd = path.resolve(__dirname, '..'); // get app root when inside /scripts
async function main() {
console.log("[DB Init] Starting database migration & initialization...");
let connectionString = "";
try {
const CONFIG_PATH = path.join(cwd, "data", ".db-config.json");
if (fs.existsSync(CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
if (config.url) {
connectionString = config.url;
console.log("[DB Init] Found connection string in data/.db-config.json");
}
}
} catch (e) {
console.error("[DB Init] Error reading .db-config.json", e);
}
// Use environment variable as default IF NOT already loaded from config
if (!connectionString && process.env.DATABASE_URL) {
connectionString = process.env.DATABASE_URL;
console.log("[DB Init] Using environment variable DATABASE_URL as fallback.");
}
if (!connectionString) {
console.warn("[DB Init] No DATABASE_URL found in env or config. Skipping push.");
process.exit(0);
}
try {
const tempPool = new Pool({ connectionString, max: 1 });
console.log("[DB Init] Connection established. Running migrations via raw SQL...");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "ai_supplier_context" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"context" text NOT NULL,
"updated_at" timestamp DEFAULT now()
);
`);
console.log("[DB Init] Table ai_supplier_context is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "session_snapshots" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"magasin" varchar(20) NOT NULL,
"changes" jsonb NOT NULL,
"summary_json" jsonb,
"label" text,
"type" varchar(20) DEFAULT 'snapshot',
"created_at" timestamp DEFAULT now()
);
`);
console.log("[DB Init] Table session_snapshots is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "users" (
"id" serial PRIMARY KEY NOT NULL,
"username" varchar(50) NOT NULL UNIQUE,
"password_hash" text NOT NULL,
"role" varchar(20) NOT NULL DEFAULT 'user',
"created_at" timestamp DEFAULT now()
);
`);
console.log("[DB Init] Table users is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "commande_cadences" (
"id" serial PRIMARY KEY NOT NULL,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"site" varchar(20) NOT NULL,
"intervalle_semaines" integer NOT NULL,
"actif" boolean NOT NULL DEFAULT true,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
await tempPool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site"
ON "commande_cadences" ("code_fournisseur", "site");
`);
await tempPool.query(`
CREATE INDEX IF NOT EXISTS "idx_cadence_site"
ON "commande_cadences" ("site");
`);
console.log("[DB Init] Table commande_cadences is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "qlik_network_metrics" (
"code_centrale" varchar(20) PRIMARY KEY NOT NULL,
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"periode" varchar(20),
"fetched_at" timestamp DEFAULT now()
);
`);
// Colonnes ajoutées après coup (DB existante) — CREATE IF NOT EXISTS ne les pose pas.
await tempPool.query(`
ALTER TABLE "qlik_network_metrics"
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255),
ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255);
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
`);
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
await tempPool.end();
console.log("[DB Init] Initialization successful. Exiting.");
process.exit(0);
} catch (error) {
console.error("[DB Init] Initialization failed:", error);
// Do not crash the app, just let it run (it uses fallback JSON anyway)
process.exit(0);
}
}
main();
-48
View File
@@ -1,48 +0,0 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
async function main() {
console.log("--- DB DIAGNOSIS Start (Node) ---");
let connectionString = process.env.DATABASE_URL;
try {
const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json");
if (fs.existsSync(CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
if (config.url) {
connectionString = config.url;
}
}
} catch (err) {}
if (!connectionString) {
console.error("No connection string found.");
return;
}
const pool = new Pool({ connectionString });
try {
const res = await pool.query(`
SELECT
code_fournisseur,
nom_fournisseur,
COUNT(*) as total_rows,
COUNT(DISTINCT codein) as distinct_products
FROM ventes_produits
GROUP BY code_fournisseur, nom_fournisseur
ORDER BY total_rows DESC
`);
console.table(res.rows);
} catch (error) {
console.error("Diagnosis failed:", error);
} finally {
await pool.end();
}
}
main();
-27
View File
@@ -1,27 +0,0 @@
import { db } from "@/db";
import { ventesProduits } from "@/db/schema";
import { sql } from "drizzle-orm";
async function main() {
console.log("--- DB DIAGNOSIS Start ---");
try {
const results = await db.execute(sql`
SELECT
code_fournisseur,
nom_fournisseur,
COUNT(*) as total_rows,
COUNT(DISTINCT codein) as distinct_products
FROM ventes_produits
GROUP BY code_fournisseur, nom_fournisseur
ORDER BY total_rows DESC
`);
console.table(results.rows);
} catch (error) {
console.error("Diagnosis failed:", error);
}
console.log("--- DB DIAGNOSIS End ---");
}
main();
-190
View File
@@ -1,190 +0,0 @@
/**
* Discovery Qlik Sense — autonome, à lancer à la main :
*
* QLIK_PWD='<mot de passe>' node scripts/qlik-discover.mjs # liste les apps
* QLIK_PWD='<mot de passe>' node scripts/qlik-discover.mjs <appGUID> # + champs & mesures de l'app
*
* Env (défauts adaptés à La Foir'Fouille) :
* QLIK_HOST=reporting-magasins.lafoirfouille.fr
* QLIK_USER=FFSCH
* QLIK_DOMAIN=FOIRFOUILLE
* QLIK_PWD=<requis>
*
* But : récupérer le GUID de l'app réseau (~270 magasins) + les noms exacts du champ
* "code article" et des mesures (CA / Qté / nb magasins) pour remplir .env.local :
* QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_CA / QLIK_MEAS_QTE / QLIK_MEAS_NB_MAG
*/
import httpntlm from "httpntlm";
import { WebSocket } from "ws";
import { randomBytes } from "node:crypto";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certif Qlik interne
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
const USER = process.env.QLIK_USER || "FFSCH";
// Domaine vide par défaut : requests_ntlm("FFSCH", pwd) marche sans domaine explicite.
const DOMAIN = process.env.QLIK_DOMAIN || "";
const PWD = process.env.QLIK_PWD || "";
const APP = process.argv[2] || process.env.QLIK_APP_NETWORK || "";
if (!PWD) {
console.error("ERREUR: définis QLIK_PWD. Ex: QLIK_PWD='xxx' node scripts/qlik-discover.mjs");
process.exit(1);
}
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
function ntlmGet(path) {
return new Promise((resolve, reject) => {
httpntlm.get(
{
url: `https://${HOST}${path}`,
username: USER,
password: PWD,
domain: DOMAIN,
workstation: "",
rejectUnauthorized: false,
headers: { "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0 CollectFlow" },
},
(err, res) => (err ? reject(err) : resolve(res)),
);
});
}
// Récupère un targetId en initiant une requête non authentifiée vers /hub/.
async function getTargetId() {
const res = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
const loc = res.headers.get("location") || "";
const m = loc.match(/targetId=([0-9a-f-]+)/i);
return m ? m[1] : "";
}
// Flux SSO ticket Qlik :
// 1. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX
// 2. GET /hub/?qlikTicket=XXX -> Qlik valide le ticket et pose le cookie X-Qlik-Session
async function authCookie() {
const tid = await getTargetId();
const res = await ntlmGet(`/internal_windows_authentication/?targetId=${tid}&xrfkey=${xrfkey}`);
const loc = res.headers.location || "";
const ticket = (loc.match(/qlikTicket=([^&\s]+)/) || [])[1];
console.log(` [auth] NTLM HTTP ${res.statusCode}, ticket=${ticket ? "oui" : "non"}`);
if (!ticket) throw new Error(`pas de qlikTicket (HTTP ${res.statusCode})`);
// Échange ticket -> cookies de session (on garde TOUS les cookies posés)
const r2 = await fetch(loc, { redirect: "manual" });
const sc = typeof r2.headers.getSetCookie === "function"
? r2.headers.getSetCookie()
: (r2.headers.get("set-cookie") ? [r2.headers.get("set-cookie")] : []);
const pairs = sc.map((c) => c.split(";")[0]);
const hasSession = pairs.some((c) => /X-Qlik-Session/i.test(c));
console.log(` [auth] échange ticket HTTP ${r2.status}, cookies=${JSON.stringify(pairs.map(p=>p.split("=")[0]))}`);
if (!hasSession) throw new Error("ticket non échangé contre un cookie de session");
return pairs.join("; ");
}
// QRS via cookie de session + Xrfkey.
async function qrsGet(path, cookie) {
const sep = path.includes("?") ? "&" : "?";
const res = await fetch(`https://${HOST}${path}${sep}xrfkey=${xrfkey}`, {
headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, Accept: "application/json" },
});
if (res.status !== 200) throw new Error(`QRS ${path} HTTP ${res.status}`);
return res.json();
}
async function qrsApps(cookie) {
const apps = await qrsGet("/qrs/app", cookie);
return apps.map((a) => ({ id: a.id, name: a.name, published: a.published, stream: a.stream?.name }));
}
function engine(appGuid, cookie) {
const ident = randomBytes(8).toString("hex");
const url = `wss://${HOST}/app/${appGuid}/identity/${ident}?Xrfkey=${xrfkey}`;
const ws = new WebSocket(url, {
headers: { Cookie: cookie, "X-Qlik-Xrfkey": xrfkey, "User-Agent": "Mozilla/5.0" },
rejectUnauthorized: false,
});
let id = 1;
const pending = new Map();
const ready = new Promise((resolve, reject) => {
ws.on("message", (raw) => {
const m = JSON.parse(raw.toString());
if (m.method === "OnConnected") return resolve();
if (m.id != null && pending.has(m.id)) {
const p = pending.get(m.id);
pending.delete(m.id);
m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result);
}
});
ws.on("unexpected-response", (_req, res) => {
let body = "";
res.on("data", (d) => (body += d));
res.on("end", () => reject(new Error(`ws ${res.statusCode} server=${res.headers.server} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 120)}`)));
});
ws.on("error", reject);
});
const rpc = (method, params, handle = -1) =>
new Promise((resolve, reject) => {
const i = id++;
pending.set(i, { resolve, reject });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
return { ready, rpc, close: () => ws.close() };
}
async function dumpFieldsMeasures(appGuid, cookie) {
const e = engine(appGuid, cookie);
await e.ready;
const open = await e.rpc("OpenDoc", { qDocName: appGuid });
const doc = open.qReturn.qHandle;
// Liste des champs
const fl = await e.rpc("CreateSessionObject", {
qProp: { qInfo: { qType: "FieldList" }, qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true } },
}, doc);
const flLayout = await e.rpc("GetLayout", {}, fl.qReturn.qHandle);
const fields = (flLayout.qLayout.qFieldList.qItems || []).map((f) => f.qName);
// Liste des mesures (master items)
const ml = await e.rpc("CreateSessionObject", {
qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } },
}, doc);
const mlLayout = await e.rpc("GetLayout", {}, ml.qReturn.qHandle);
const measures = (mlLayout.qLayout.qMeasureList.qItems || []).map((m) => ({
title: m.qMeta?.title ?? m.qData?.title,
expr: m.qData?.expr,
}));
e.close();
return { fields, measures };
}
(async () => {
try {
console.log(`Auth NTLM ${DOMAIN}\\${USER} @ ${HOST} ...`);
const cookie = await authCookie();
console.log("✓ Authentifié.\n");
const apps = await qrsApps(cookie);
console.log(`=== ${apps.length} apps ===`);
for (const a of apps) console.log(` ${a.id} [${a.stream || "-"}] ${a.name}`);
if (APP) {
console.log(`\n=== Champs & mesures de l'app ${APP} ===`);
const { fields, measures } = await dumpFieldsMeasures(APP, cookie);
console.log(`\n-- Champs (${fields.length}) --`);
for (const f of fields) console.log(` ${f}`);
console.log(`\n-- Mesures master (${measures.length}) --`);
for (const m of measures) console.log(` ${m.title} = ${m.expr}`);
console.log(`\n→ Repère le champ "code article", et les expressions CA / Qté / nb magasins,`);
console.log(` puis remplis QLIK_APP_NETWORK / QLIK_DIM_CODE_ARTICLE / QLIK_MEAS_* dans .env.local.`);
} else {
console.log(`\n→ Relance avec le GUID de l'app réseau pour lister ses champs & mesures :`);
console.log(` QLIK_PWD='...' node scripts/qlik-discover.mjs <appGUID>`);
}
} catch (e) {
console.error("ERREUR:", e.message);
process.exit(1);
}
})();
-354
View File
@@ -1,354 +0,0 @@
/**
* CollectFlow — Validation des expressions Qlik et de la clé de jointure.
*
* À lancer DEPUIS le réseau qui atteint Qlik (typiquement le conteneur de l'app) :
*
* docker exec -e QLIK_PWD='<mot de passe>' -it <conteneur> node scripts/qlik-validate.mjs
*
* Répond aux deux questions encore ouvertes :
*
* 1. Les master measures « CA N » / « Quantité N » ignorent-elles la sélection
* Date, et les agrégations directes (`Sum(quantite)`, `Sum(ca_ht)`…) sont-elles
* un substitut fidèle ? Le script affiche, pour quelques articles réellement
* vendus, un tableau mois par mois comparant les deux — dont les mois d'août à
* décembre qui ressortaient vides.
*
* 2. Quelle valeur joint avec `articles.artcentrale` : la dimension « Article
* Code » ou le champ `article_no_centrale` ? Le script affiche les deux
* côte à côte.
*
* Aucun secret n'est écrit ni affiché : le mot de passe vient de l'environnement.
* Le script est en LECTURE SEULE (sélections en soft lock, objets de session
* détruits) — il ne modifie rien dans Qlik.
*/
import httpntlm from "httpntlm";
import { randomBytes } from "node:crypto";
import { chromium } from "playwright-core";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; // certificat interne
const HOST = process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr";
const USER = process.env.QLIK_USER || "FFSCH";
const PWD = process.env.QLIK_PWD || "";
const DOMAIN = process.env.QLIK_DOMAIN ?? "";
const APP = process.env.QLIK_APP_NETWORK || "9872ee6e-d64a-4b43-984a-076bf1f7f647";
const DIM_ARTICLE = process.env.QLIK_DIM_CODE_ARTICLE_ID || "yesCP";
const DIM_MOIS = process.env.QLIK_MOIS_DIM_ID || "pfGAwTs";
const NB_ARTICLES = Number(process.env.QLIK_VALIDATE_ARTICLES || "3");
if (!PWD) {
console.error("ERREUR : définir QLIK_PWD dans l'environnement.");
console.error(" docker exec -e QLIK_PWD='…' -it <conteneur> node scripts/qlik-validate.mjs");
process.exit(1);
}
const xrfkey = randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
// ─── Fenêtre 12 mois glissants, mois courant exclu (même règle que l'app) ────
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30);
const serialDe = (y, m, d) => Math.round((Date.UTC(y, m - 1, d) - QLIK_EPOCH_MS) / 86_400_000);
function fenetre12Mois(now = new Date()) {
const debut = new Date(now.getFullYear(), now.getMonth() - 12, 1);
const fin = new Date(now.getFullYear(), now.getMonth(), 0);
const serials = [];
const s0 = serialDe(debut.getFullYear(), debut.getMonth() + 1, debut.getDate());
const s1 = serialDe(fin.getFullYear(), fin.getMonth() + 1, fin.getDate());
for (let s = s0; s <= s1; s++) serials.push(s);
const labels = [];
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
labels.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return { serials, labels, debut: debut.toISOString().slice(0, 10), fin: fin.toISOString().slice(0, 10) };
}
// ─── Auth NTLM → cookie de session ──────────────────────────────────────────
function ntlmGet(path) {
return new Promise((resolve, reject) => {
httpntlm.get(
{
url: `https://${HOST}${path}`,
username: USER,
password: PWD,
domain: DOMAIN,
workstation: "",
rejectUnauthorized: false,
headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": xrfkey },
},
(err, res) => (err ? reject(err) : resolve(res)),
);
});
}
function cookieDepuis(setCookie) {
const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : [];
return arr.map((c) => String(c).split(";")[0]).find((c) => /X-Qlik-Session/i.test(c)) ?? null;
}
async function session() {
const hub = await fetch(`https://${HOST}/hub/`, { redirect: "manual" });
const targetId = ((hub.headers.get("location") ?? "").match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? "";
const chemin = targetId
? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}`
: `/hub/?xrfkey=${xrfkey}`;
const res = await ntlmGet(chemin);
const direct = cookieDepuis(res.headers["set-cookie"]);
if (direct) return direct;
const loc = String(res.headers["location"] ?? "");
if (!/qlikTicket=/.test(loc)) throw new Error(`auth NTLM sans ticket (HTTP ${res.statusCode})`);
const ech = await fetch(loc, { redirect: "manual" });
const sc = typeof ech.headers.getSetCookie === "function" ? ech.headers.getSetCookie() : [];
const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; ");
if (!cookie) throw new Error("ticket non échangé contre un cookie de session");
return cookie;
}
// ─── Script exécuté dans la page (websocket Engine accepté par le proxy) ────
function scriptInPage({ app, token, dimArticle, dimMois, serials, labels, nbArticles }) {
return new Promise((resolve) => {
const url = `wss://${location.host}/app/${app}?reloadUri=${encodeURIComponent(location.href)}&qlik-csrf-token=${token}`;
const ws = new WebSocket(url);
const sortie = [];
const log = (l) => { sortie.push(l); console.log("[cf] " + l); };
let id = 0;
const pend = new Map();
const rpc = (method, params, handle = -1) => new Promise((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const pret = new Promise((res, rej) => {
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const p = pend.get(m.id); pend.delete(m.id);
if (m.error) p.rej(new Error(JSON.stringify(m.error)));
else p.res(m.result);
}
};
ws.onerror = () => rej(new Error("websocket refusé (403 ?)"));
setTimeout(() => rej(new Error("timeout websocket")), 30000);
});
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
try {
await pret;
const doc = (await rpc("OpenDoc", { qDocName: app })).qReturn.qHandle;
// ─── 1. Expressions des master measures ──────────────────────
const ml = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "MeasureList" },
qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } },
},
}, doc);
const items = (await rpc("GetLayout", {}, ml.qReturn.qHandle)).qLayout.qMeasureList?.qItems ?? [];
const parTitre = new Map();
log("=== EXPRESSIONS DES MASTER MEASURES ===");
for (const it of items) {
const t = (it.qMeta?.title ?? it.qData?.title ?? "").trim();
if (!t) continue;
parTitre.set(t.toLowerCase(), it.qInfo.qId);
if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(t)) {
log(` « ${t} » = ${it.qData?.expr ?? "?"}`);
}
}
const mesure = (t) => parTitre.get(t.toLowerCase()) ?? null;
// ─── 2. Sélection de la fenêtre 12 mois ──────────────────────
const champDate = await rpc("GetField", { qFieldName: "Date" }, doc);
const dh = champDate.qReturn.qHandle;
await rpc("Clear", {}, dh);
await dormir(300);
for (let i = 0; i < serials.length; i += 500) {
await rpc("SelectValues", {
qFieldValues: serials.slice(i, i + 500).map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: i > 0,
qSoftLock: true,
}, dh);
}
await dormir(500);
log("");
log(`=== FENÊTRE SÉLECTIONNÉE : ${labels[0]} → ${labels[labels.length - 1]} (${serials.length} jours) ===`);
// ─── 3. Articles les plus vendus (par agrégation directe) ────
const cubeTop = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-top" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: dimArticle }],
qMeasures: [{ qDef: { qDef: "Sum(quantite)" }, qSortBy: { qSortByNumeric: -1 } }],
qInterColumnSortOrder: [1, 0],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: nbArticles }],
},
},
}, doc);
const topMatrix = (await rpc("GetLayout", {}, cubeTop.qReturn.qHandle))
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
const codes = topMatrix.map((r) => String(r[0]?.qText ?? "")).filter(Boolean);
log(`articles testés (top ventes) : ${JSON.stringify(codes)}`);
if (codes.length === 0) { ws.close(); return resolve({ ok: false, error: "aucun article vendu sur la fenêtre", sortie }); }
const champArticle = await rpc("GetField", { qFieldName: "Article Code" }, doc);
const ah = champArticle.qReturn.qHandle;
await rpc("Clear", {}, ah);
await dormir(300);
await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, ah);
await dormir(500);
// ─── 4. Comparaison mois par mois ────────────────────────────
const mesures = [
{ qDef: { qDef: "Sum(quantite)" } },
{ qDef: { qDef: "Sum(ca_ht)" } },
{ qDef: { qDef: "Sum(ca_ttc)" } },
{ qDef: { qDef: "Count(DISTINCT [Magasin Code])" } },
{ qDef: { qDef: "Sum(marge)" } },
];
const idQte = mesure("Quantité N");
const idCa = mesure("CA N");
const idNbMag = mesure("Magasin Ventes Nb N");
if (idQte) mesures.push({ qLibraryId: idQte });
if (idCa) mesures.push({ qLibraryId: idCa });
if (idNbMag) mesures.push({ qLibraryId: idNbMag });
const largeur = 2 + mesures.length;
const cube = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-mois" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: dimArticle }, { qLibraryId: dimMois }],
qMeasures: mesures,
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: 400 }],
},
},
}, doc);
const hc = (await rpc("GetLayout", {}, cube.qReturn.qHandle)).qLayout.qHyperCube;
const lignes = hc.qDataPages?.[0]?.qMatrix ?? [];
log("");
log("=== COMPARAISON MOIS PAR MOIS (agrégation directe vs master measure) ===");
log("article mois Sum(quantite) Quantité N | Sum(ca_ht) Sum(ca_ttc) CA N | Count(mag) Mag Nb N");
const pad = (v, n) => String(v).padStart(n);
for (const r of lignes) {
const code = String(r[0]?.qText ?? "");
const moisBrut = String(r[1]?.qText ?? "");
const mois = /^\d{6}$/.test(moisBrut) ? moisBrut.slice(0, 4) + "-" + moisBrut.slice(4) : moisBrut;
const n = (i) => Number(r[2 + i]?.qNum ?? 0) || 0;
log(
code.padEnd(13) + mois.padEnd(9) +
pad(Math.round(n(0)), 13) + pad(Math.round(n(5)), 12) + " |" +
pad(Math.round(n(1)), 11) + pad(Math.round(n(2)), 13) + pad(Math.round(n(6)), 9) + " |" +
pad(Math.round(n(3)), 11) + pad(Math.round(n(7)), 10),
);
}
log("");
log("LECTURE : si « Quantité N » vaut 0 sur les mois de l'année précédente alors que");
log(" Sum(quantite) est renseigné, la master measure est bien inutilisable");
log(" pour une fenêtre glissante — et l'agrégation directe la remplace.");
log(" Si Sum(quantite) == Quantité N sur les mois de l'année en cours,");
log(" l'expression par défaut de CollectFlow est validée.");
log(" Comparer aussi Sum(ca_ht) et Sum(ca_ttc) à « CA N » pour choisir");
log(" QLIK_EXPR_CA.");
// ─── 5. Clé de jointure : Article Code vs article_no_centrale ─
const cubeCle = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-val-cle" },
qHyperCubeDef: {
qDimensions: [
{ qLibraryId: dimArticle },
{ qDef: { qFieldDefs: ["article_no_centrale"] } },
{ qDef: { qFieldDefs: ["article_codein"] } },
],
qMeasures: [],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 3, qHeight: 20 }],
},
},
}, doc);
const cles = (await rpc("GetLayout", {}, cubeCle.qReturn.qHandle))
.qLayout.qHyperCube.qDataPages?.[0]?.qMatrix ?? [];
log("");
log("=== CLÉ DE JOINTURE ===");
log("Article Code article_no_centrale article_codein");
for (const r of cles) {
log(
String(r[0]?.qText ?? "").padEnd(20) +
String(r[1]?.qText ?? "").padEnd(22) +
String(r[2]?.qText ?? ""),
);
}
log("");
log("LECTURE : la bonne clé est celle qui a le format de `articles.artcentrale`");
log(" côté FF Nancy (ex. 10000167303 — préfixe 10000 + 6 chiffres).");
ws.close();
resolve({ ok: true, sortie });
} catch (e) {
try { ws.close(); } catch { /* noop */ }
resolve({ ok: false, error: String(e?.message || e), sortie });
}
})();
});
}
// ─── Orchestration ──────────────────────────────────────────────────────────
(async () => {
const f = fenetre12Mois();
console.log(`Qlik ${HOST} — app ${APP}`);
console.log(`Fenêtre : ${f.debut} → ${f.fin}\n`);
const cookie = await session();
console.log("✓ authentifié (NTLM + ticket)\n");
const [nom, ...reste] = cookie.split("=");
const navigateur = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
});
const ctx = await navigateur.newContext({
httpCredentials: { username: USER, password: PWD, origin: `https://${HOST}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: nom, value: reste.join("="), domain: HOST, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
let token = null;
let pret;
const attente = new Promise((r) => { pret = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !token) { token = decodeURIComponent(m[1]); pret(); }
});
await page.goto(`https://${HOST}/sense/app/${APP}`, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
await Promise.race([attente, page.waitForTimeout(20000)]);
if (!token) throw new Error("qlik-csrf-token introuvable (client Qlik non chargé ?)");
const res = await page.evaluate(scriptInPage, {
app: APP, token, dimArticle: DIM_ARTICLE, dimMois: DIM_MOIS,
serials: f.serials, labels: f.labels, nbArticles: NB_ARTICLES,
});
console.log(res.sortie.join("\n"));
if (!res.ok) {
console.error("\nÉCHEC : " + res.error);
process.exitCode = 1;
}
} finally {
await ctx.close();
await navigateur.close();
}
})().catch((e) => {
console.error("ERREUR :", e?.message || e);
process.exit(1);
});
-39
View File
@@ -1,39 +0,0 @@
const { computeProductScores } = require('./src/lib/score-engine');
// Simulate 50,000 rows
const largeDataset = Array.from({ length: 50000 }, (_, i) => ({
codein: `item-${i}`,
codeFournisseur: 'A032',
nomFournisseur: 'ACCESS DECO',
libelle1: `Product ${i}`,
gtin: '123456789',
reference: 'REF',
code1: '20',
libelleNiveau1: 'LIQUIDES',
code2: '2001',
libelleNiveau2: 'EAUX',
code3: '200101',
libelle3: 'EAU SOURCE',
codeGamme: 'A',
codeGammeInit: 'A',
codeGammeDraft: null,
sales12m: {},
totalQuantite: Math.random() * 1000,
totalCa: Math.random() * 5000,
totalMarge: Math.random() * 1000,
tauxMarge: 20,
score: 0,
workingStores: ['MAG1', 'MAG2']
}));
console.log(`Starting test with ${largeDataset.length} rows...`);
try {
const start = Date.now();
const result = computeProductScores(largeDataset);
const end = Date.now();
console.log(`Success! Processed ${result.length} rows in ${end - start}ms.`);
} catch (error) {
console.error('Failed!', error);
process.exit(1);
}
-25
View File
@@ -1,25 +0,0 @@
require('dotenv').config({ path: 'c:/GIT/CollectFlow/.env.local' });
const { Pool } = require('pg');
async function test() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
try {
const res = await pool.query("SELECT * FROM cube_stock WHERE cs.site = '292' LIMIT 5");
console.log("CUBE_STOCK:", res.rows);
} catch(e) {
console.log("ERROR cube_stock:", e.message);
}
try {
const res2 = await pool.query("SELECT a.codein, a.no_id FROM articles a LIMIT 5");
console.log("ARTICLES:", res2.rows);
} catch(e) {
console.log("ERROR articles:", e.message);
}
pool.end();
}
test();
-194
View File
@@ -1,194 +0,0 @@
/**
* Test CLI pour `src/lib/qlik-date-range.ts` (aucune dépendance Next.js).
* Usage : npx tsx scripts/test-qlik-date-range.ts
*
* Vérifie :
* - les repères Qlik (2025-01-01→45658, etc.)
* - la fenêtre 12 mois complets pour `now=2026-06-21` (doit donner
* dateDebut=2025-06-01, dateFin=2026-05-31, qStart=45809, qEnd=46173,
* label=2025-06_2026-05, 365 serials)
* - exemples supplémentaires (1er du mois, fin d'année, année bissextile)
* - erreurs attendues sur entrée mal formée
* - option `monthsBack` : 6 mois, bornes (clamp 1..12)
* - helper `envMonthsBack` : valeur absente, valide, hors bornes, non numérique
*
* Aucun framework : assertions manuelles, exit code 0/1.
*/
import {
isoDateToQlikSerial,
qlikSerialToIsoDate,
buildGridNetworkQlikDateFilter,
chunkSerials,
getMonthRanges,
envMonthsBack,
QLIK_DATE_ANCHORS,
QLIK_MONTHS_BACK_MIN,
QLIK_MONTHS_BACK_MAX,
QLIK_MONTHS_BACK_DEFAULT,
} from "../src/lib/qlik-date-range";
let pass = 0;
let fail = 0;
function eq(label: string, got: unknown, want: unknown): void {
const ok = JSON.stringify(got) === JSON.stringify(want);
if (ok) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`);
}
}
function truthy(label: string, got: unknown): void {
if (got) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label} \u2014 falsy: ${JSON.stringify(got)}`);
}
}
function throws(label: string, fn: () => unknown, re?: RegExp): void {
try {
fn();
fail++;
console.log(` \u2717 ${label} \u2014 n'a pas lanc\u00e9 d'exception`);
} catch (e) {
const msg = String((e as Error)?.message ?? e);
if (!re || re.test(msg)) {
pass++;
console.log(` \u2713 ${label}`);
} else {
fail++;
console.log(` \u2717 ${label} \u2014 exception inattendue: ${msg}`);
}
}
}
console.log("\n[1] Rep\u00e8res Qlik (skill qlik)");
for (const a of QLIK_DATE_ANCHORS) {
eq(`isoDateToQlikSerial(${a.iso}) = ${a.serial}`, isoDateToQlikSerial(a.iso), a.serial);
}
console.log("\n[2] Round-trip s\u00e9rial \u2194 ISO");
for (const a of QLIK_DATE_ANCHORS) {
eq(`qlikSerialToIsoDate(${a.serial}) = ${a.iso}`, qlikSerialToIsoDate(a.serial), a.iso);
}
console.log("\n[3] Fen\u00eatre grille (now=2026-06-21) \u2192 2025-06-01 .. 2026-05-31");
const f = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"));
eq("dateDebut", f.dateDebut, "2025-06-01");
eq("dateFin", f.dateFin, "2026-05-31");
eq("qStart", f.qStart, 45809);
eq("qEnd", f.qEnd, 46173);
eq("label", f.label, "2025-06_2026-05");
eq("setAnalysis", f.setAnalysis, `Date={">=45809<=46173"}`);
eq("dailySerials.length = 365", f.dailySerials.length, 365);
eq("dailySerials[0]", f.dailySerials[0], 45809);
eq("dailySerials[364]", f.dailySerials[364], 46173);
console.log("\n[4] Fen\u00eatre grille (now=2026-03-15) \u2192 2025-03-01 .. 2026-02-28");
const f2 = buildGridNetworkQlikDateFilter(new Date("2026-03-15T08:00:00Z"));
eq("dateDebut", f2.dateDebut, "2025-03-01");
eq("dateFin", f2.dateFin, "2026-02-28");
eq("qStart", f2.qStart, 45717);
eq("qEnd", f2.qEnd, 46081);
console.log("\n[5] Fen\u00eatre grille (now=2025-01-05) \u2192 2024-01-01 .. 2024-12-31 (ann\u00e9e bissextile, 366 j)");
const f3 = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
eq("dateDebut", f3.dateDebut, "2024-01-01");
eq("dateFin", f3.dateFin, "2024-12-31");
eq("dailySerials.length = 366", f3.dailySerials.length, 366);
console.log("\n[6] chunkSerials");
const chunks = chunkSerials(f.dailySerials, 100);
eq("chunks.length pour 365/100", chunks.length, 4);
eq("chunks[3].length = 65", chunks[3].length, 65);
eq("chunks[0][0]", chunks[0][0], 45809);
eq("chunks[3][64]", chunks[3][64], 46173);
console.log("\n[7] Robustesse");
throws("format invalide '2025/01/01'", () => isoDateToQlikSerial("2025/01/01"), /YYYY-MM-DD/);
throws("format vide", () => isoDateToQlikSerial(""), /YYYY-MM-DD/);
truthy("label non vide pour fenêtre valide", f.label && f.label.length > 0);
truthy("setAnalysis commence par 'Date='", f.setAnalysis.startsWith("Date="));
console.log("\n[8] getMonthRanges — fenêtre 12 mois (now=2026-06-21)");
const months = getMonthRanges(f);
eq("12 mois exactement", months.length, 12);
eq("premier mois label", months[0].label, "2025-06");
eq("premier mois dateDebut", months[0].dateDebut, "2025-06-01");
eq("premier mois dateFin", months[0].dateFin, "2025-06-30");
eq("premier mois serials.length = 30", months[0].dailySerials.length, 30);
eq("premier mois qStart", months[0].qStart, 45809);
eq("premier mois qEnd", months[0].qEnd, 45838);
eq("dernier mois label", months[11].label, "2026-05");
eq("dernier mois dateDebut", months[11].dateDebut, "2026-05-01");
eq("dernier mois dateFin", months[11].dateFin, "2026-05-31");
eq("dernier mois serials.length = 31", months[11].dailySerials.length, 31);
// Couvre février d'une année non bissextile (2025) — window now=2025-06-15 → 2024-06..2025-05
const fFeb25 = buildGridNetworkQlikDateFilter(new Date("2025-06-15T08:00:00Z"));
truthy("contient février 2025 (28j)", getMonthRanges(fFeb25).some((m) => m.label === "2025-02" && m.dailySerials.length === 28));
// Couvre février d'une année bissextile (2024)
const fBiss = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
const monthsBiss = getMonthRanges(fBiss);
truthy("contient février 2024 (29j)", monthsBiss.some((m) => m.label === "2024-02" && m.dailySerials.length === 29));
// Total jours = 365 (somme des serials)
const totalDays = months.reduce((acc, m) => acc + m.dailySerials.length, 0);
eq("somme des jours = 365", totalDays, 365);
console.log("\n[9] getMonthRanges — fenêtre courte partielle (troncature dernier mois)");
const fShort = { ...f, dateDebut: "2025-06-01", dateFin: "2025-08-15", qStart: 45809, qEnd: 45884, dailySerials: [], setAnalysis: "" };
const monthsShort = getMonthRanges(fShort);
eq("3 mois (juin, juillet, août)", monthsShort.length, 3);
eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15);
eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15");
console.log("\n[10] monthsBack=6 (now=2026-06-21) → 2025-12-01 .. 2026-05-31");
const f6 = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 6);
eq("dateDebut", f6.dateDebut, "2025-12-01");
eq("dateFin", f6.dateFin, "2026-05-31");
eq("qStart", f6.qStart, 45992);
eq("qEnd", f6.qEnd, 46173);
eq("label", f6.label, "2025-12_2026-05");
eq("dailySerials.length = 182 (déc 2025:31 + jan:31 + fév:28 + mar:31 + avr:30 + mai:31)", f6.dailySerials.length, 182);
const months6 = getMonthRanges(f6);
eq("getMonthRanges: 6 mois exactement", months6.length, 6);
eq("premier mois label", months6[0].label, "2025-12");
eq("dernier mois label", months6[5].label, "2026-05");
const totalDays6 = months6.reduce((acc, m) => acc + m.dailySerials.length, 0);
eq("somme des jours = 182", totalDays6, 182);
console.log("\n[11] monthsBack — bornes et clamp");
eq("monthsBack=1 (now=2026-06-21) → 2026-05-01 .. 2026-05-31 (1 mois complet)",
(() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 1); return x.dateDebut === "2026-05-01" && x.dateFin === "2026-05-31"; })(),
true);
eq("monthsBack=0 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 0); return x.dateDebut === "2026-05-01"; })(), true);
eq("monthsBack=24 clampé à 12 (max)", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), 24); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("monthsBack=-5 clampé à 1", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), -5); return x.dateDebut === "2026-05-01"; })(), true);
eq("monthsBack=NaN ramené au défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z"), Number.NaN); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("monthsBack=undefined (par défaut) = défaut 12", (() => { const x = buildGridNetworkQlikDateFilter(new Date("2026-06-21T12:00:00Z")); return x.dateDebut === "2025-06-01" && x.dateFin === "2026-05-31"; })(), true);
eq("bornes exportées MIN=1", QLIK_MONTHS_BACK_MIN, 1);
eq("bornes exportées MAX=12", QLIK_MONTHS_BACK_MAX, 12);
eq("défaut exporté = 12", QLIK_MONTHS_BACK_DEFAULT, 12);
console.log("\n[12] envMonthsBack — lecture env");
const PREV = process.env.QLIK_TEST_MONTHS_BACK;
delete process.env.QLIK_TEST_MONTHS_BACK;
eq("env absent → défaut", envMonthsBack("QLIK_TEST_MONTHS_BACK"), QLIK_MONTHS_BACK_DEFAULT);
eq("env vide → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = ""; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT);
eq("env '6' → 6", (() => { process.env.QLIK_TEST_MONTHS_BACK = "6"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 6);
eq("env 'abc' → défaut", (() => { process.env.QLIK_TEST_MONTHS_BACK = "abc"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_DEFAULT);
eq("env '24' clampé à MAX=12", (() => { process.env.QLIK_TEST_MONTHS_BACK = "24"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MAX);
eq("env '-3' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "-3"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN);
eq("env '0' clampé à MIN=1", (() => { process.env.QLIK_TEST_MONTHS_BACK = "0"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), QLIK_MONTHS_BACK_MIN);
eq("env '3.7' troncé à 3", (() => { process.env.QLIK_TEST_MONTHS_BACK = "3.7"; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 3);
eq("env ' 8 ' trimé puis parsé → 8", (() => { process.env.QLIK_TEST_MONTHS_BACK = " 8 "; return envMonthsBack("QLIK_TEST_MONTHS_BACK"); })(), 8);
if (PREV === undefined) delete process.env.QLIK_TEST_MONTHS_BACK; else process.env.QLIK_TEST_MONTHS_BACK = PREV;
console.log(`\n=== ${pass} passés, ${fail} échoués ===`);
if (fail > 0) process.exit(1);
+3477
View File
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
[package]
name = "collectflow"
version = "0.2.0"
edition = "2021"
description = "CollectFlow — serveur Rust (API, base, FF Nancy, Qlik, synchronisation)"
publish = false
[dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie", "typed-header"] }
base64 = "0.22"
chromiumoxide = "0.9.1"
chrono = { version = "0.4", features = ["serde"] }
des = "0.8"
futures = "0.3"
hex = "0.4"
hmac = "0.12"
http-body-util = "0.1"
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
indexmap = { version = "2", features = ["serde"] }
md-5 = "0.10"
md4 = "0.10"
parking_lot = "0.12"
rand = "0.8"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "cookies", "stream", "http2"] }
rust_decimal = "1"
rust_xlsxwriter = "0.99"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
scrypt = { version = "0.11", default-features = false, features = ["std"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
sha2 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "postgres", "json", "chrono", "rust_decimal", "macros"] }
subtle = "2"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "compression-gzip", "trace", "set-header"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }
webpki-roots = "1"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 4
strip = true
# scrypt est lent sans optimisation : la connexion prenait plusieurs secondes en debug.
[profile.dev.package.scrypt]
opt-level = 3
[profile.dev.package.salsa20]
opt-level = 3
[profile.dev.package.pbkdf2]
opt-level = 3
[profile.dev.package.sha2]
opt-level = 3
+116
View File
@@ -0,0 +1,116 @@
# Serveur Rust — contrat avec l'interface et conventions
Ce document fixe le contrat entre le serveur Rust (`server/`) et l'interface
React (`src/`, compilée en SPA par Vite). Le serveur est la traduction fidèle de
l'ancienne version Next.js (mêmes requêtes SQL, mêmes règles, mêmes messages,
mêmes formes JSON) ; son code TypeScript reste consultable dans l'historique
git (`git show d2c3dc7:src/...`, dernier état avant la réécriture).
## Socle commun
| Besoin | Rust |
|---|---|
| Pool PostgreSQL (base app + recopie FF) | `crate::db::pool()? -> PgPool` (remplaçable à chaud, `db::refresh()`) |
| Ligne → JSON générique | `db::row_to_json`, `db::rows_to_json` |
| Lecture tolérante (`Number(r.x) \|\| 0`, `String(r.x ?? "")`) | `db::get_f64`, `db::f64_or0`, `db::get_string`, `db::string_or_empty` |
| Date ISO façon `toISOString()` | `db::iso(NaiveDateTime)`, `db::iso_utc` |
| Réglages `data/.db-config.json` | `config::read_config()`, `config::write_config(patch)`, `config::data_dir()`, `config::env_number(...)` |
| Cache FF (`cachedFF`) | `ff_cache::cached(key, CacheOpts { ttl, cache_if }, \|\| async { … }).await`, `ff_cache::invalidate()` |
| Journaux de job (`startCapture`…) | `logcapture::{start_capture, stop_capture, read_capture, list_captures}` |
| Session (routes internes) | extracteurs `auth::SessionUser`, `auth::AdminUser` (401/403 `{ error }`) ; `auth::Token` (jeton brut) ; `auth::require_session(token)` / `require_admin(token)` |
| Auth `/api/v1` | extracteur `auth::ApiAuth` (clé d'API ou session, erreurs au format v1) ; `ctx.require_admin()` |
| Enveloppe `/api/v1` | `api_v1_response::{ok, fail, build_pagination, ApiErrorCode}` |
| Erreurs | `error::{AppError, AppResult}` — `AppError` implémente `IntoResponse` (`{ error }`) et `From<anyhow::Error / sqlx::Error / reqwest::Error>` |
| Magasins / gammes | `domain::{MAGASINS, CODE_TOUS_MAGASINS, nom_magasin, est_gamme_valide}` |
| Actions / pages | `rpc::{Ctx, Handler, json}` + macro `handler!(fonction)` |
## Organisation
Chaque module possède son répertoire `server/src/<module>/` et expose dans son
`mod.rs` :
```rust
pub fn router() -> axum::Router; // routes HTTP (chemins identiques à Next.js)
pub fn actions() -> Vec<(&'static str, rpc::Handler)>; // ex. ("getUsers", handler!(get_users))
pub fn pages() -> Vec<(&'static str, rpc::Handler)>; // ex. ("dashboard", handler!(page_dashboard))
```
Le routeur Axum n'a pas d'état (`Router<()>`) : tout passe par les accès globaux
ci-dessus. Syntaxe des chemins Axum 0.8 : `/api/v1/products/{codein}`.
## Actions (ex-« server actions »)
`POST /api/actions/{nom}` — corps : tableau JSON des arguments positionnels,
réponse : la valeur de retour de la fonction TS, sérialisée à l'identique.
Une exception TS (ex. `requireSession()` qui lève) devient une `AppError`
(statut 401/403/500, corps `{ error }`) ; le client la relève en `Error`.
Une action s'écrit :
```rust
async fn get_users(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?; // = await requireAdmin()
let page: i64 = ctx.arg(0)?; // argument n°0 (null si absent → Option<T>)
…
json(resultat)
}
```
| Action | Source TS | Module Rust |
|---|---|---|
| `getFournisseurs`, `getMagasins` | `features/grid/actions.ts` | `ff` |
| `getFournisseursPourCadence`, `listCadences`, `upsertCadence`, `toggleCadence`, `removeCadence` | `features/commandes-auto/actions.ts` | `ff` |
| `getUsers`, `createUser`, `deleteUser`, `updatePassword` | `features/admin/api/user-actions.ts` | `admin` |
| `getApiKeys`, `createApiKey`, `revokeApiKey`, `deleteApiKey` | `features/admin/api/api-key-actions.ts` | `admin` |
| `getParametresAffichables`, `testDatabaseConnection`, `saveDatabaseSettings`, `saveQlikSettings`, `testQlikConnection`, `saveFfApiSettings`, `testFfApiConnection` | `features/settings/actions.ts` | `admin` |
| `readSyncSettings`, `saveSyncSettings` | `features/admin/api/sync-settings.ts` | `admin` |
| `getSnapshots`, `deleteSnapshot`, `saveSnapshot` | `features/snapshots/api/*.ts` | `grid` |
| `saveDraftChanges` | `features/grid/api/save-draft-changes.ts` | `grid` |
| `getGammesAValider`, `rejeterGammesAValider` | `features/grid/api/gammes-a-valider-actions.ts` | `grid` |
(`loginAction` disparaît : `POST /api/auth/login`.)
## Pages (données des anciennes pages serveur)
`GET /api/pages/{nom}?…` renvoie **uniquement les données** que la page serveur
obtenait de ses appels d'accès aux données (fonctions `pg*`, API FF, actions),
sous la forme exacte de leur valeur de retour TS. Toute la mise en forme pure
(pivots, listes de mois, totaux…) reste côté interface. En cas d'échec là où la
page affichait un `ErrorState` : statut 500 `{ error: message }`.
| Page | Paramètres | Réponse | Module |
|---|---|---|---|
| `dashboard` | — | `pgGetDashboardData()` (cache 30 min) | `ff` |
| `analytics` | `mode` (`fournisseur`\|`nomenclature`), `mois`, `moisN1` | lignes de `pgGetCaByFournisseur` ou `pgGetCaByNomenclature` (cachedFF, mêmes clés) | `ff` |
| `hit-parade` | `debut`, `fin` | lignes de `pgGetHitParade` (cachedFF) | `ff` |
| `stock-negatif` | `magasin` | `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` (cachedFF) | `ff` |
| `commandes-auto` | — | `{ rows, cadences, fournisseurs }` | `ff` |
| `publicites` | `statut`, `page` | `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }` | `ff` |
| `produit-fiche` | `codein` ou `cc` | `getProduitFiche(codein)` / `getProduitFicheByCodeCentrale(cc)` (ou `null`) | `produits` |
## Routes HTTP conservées (mêmes chemins, mêmes réponses)
`/api/ff-status`, `/api/diag`, `/api/diag/stock-fournisseur`, `/api/test-db`,
`/api/commandes-auto` → `ff` ; `/api/qlik/sync` → `qlik` ;
`/api/grid/rows`, `/api/grid/rows/store-patch`, `/api/export/excel`,
`/api/export/modified-gammes`, `/api/v1/*` → `grid` ;
`/api/produits/search`, `/api/produits/opportunites` → `produits` ;
`/api/admin/sync`, `/api/admin/sync/fournisseurs`, `/api/admin/grid-warmup`,
`/api/logs` → `admin`. Authentification : `/api/auth/{login,logout,session}`.
## Règles de traduction
- **Formes JSON identiques** : noms de champs exacts (`#[serde(rename_all = "camelCase")]`
ou `rename` explicite selon l'interface TS), champs optionnels TS absents ⇒
`#[serde(skip_serializing_if = "Option::is_none")]` (JSON.stringify omet `undefined`,
mais garde `null`). Une `Map` TS renvoyée au client se sérialise comme le faisait le TS
(souvent `Object.fromEntries`).
- **Même SQL** : recopier les requêtes telles quelles (paramètres `$1…`, `QueryBuilder`
ou `format!` contrôlé pour les fragments dynamiques — jamais de valeur utilisateur
interpolée sans échappement). Les colonnes `numeric` se lisent avec `db::get_f64`.
- **Même tolérance aux pannes** : une fonction TS qui avale l'erreur et rend `[]`
fait de même ; une qui lève propage une erreur.
- **Journaux** : `tracing::info!/warn!/error!` avec les mêmes préfixes (`[pg-ff]`, `[qlik]`…).
- **Dates** : le conteneur tourne en UTC ; `new Date()` ⇒ `chrono::Local::now()`.
- **Commentaires** en français, comme le code d'origine : garder les « pourquoi ».
- `cargo build` sans avertissement dans son module ; `cargo test` pour la logique pure.
+150
View File
@@ -0,0 +1,150 @@
-- Schéma CollectFlow : joué à chaque démarrage (idempotent).
-- Porté depuis scripts/db-init.js.
CREATE TABLE IF NOT EXISTS "ai_supplier_context" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"context" text NOT NULL,
"updated_at" timestamp DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "session_snapshots" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"magasin" varchar(20) NOT NULL,
"changes" jsonb NOT NULL,
"summary_json" jsonb,
"label" text,
"type" varchar(20) DEFAULT 'snapshot',
"created_at" timestamp DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_snapshots_fournisseur_date"
ON "session_snapshots" ("code_fournisseur", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "idx_snapshots_user_type_date"
ON "session_snapshots" ("user_id", "type", "created_at" DESC);
CREATE TABLE IF NOT EXISTS "users" (
"id" serial PRIMARY KEY NOT NULL,
"username" varchar(50) NOT NULL UNIQUE,
"password_hash" text NOT NULL,
"role" varchar(20) NOT NULL DEFAULT 'user',
"created_at" timestamp DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "commande_cadences" (
"id" serial PRIMARY KEY NOT NULL,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"site" varchar(20) NOT NULL,
"intervalle_semaines" integer NOT NULL,
"actif" boolean NOT NULL DEFAULT true,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site"
ON "commande_cadences" ("code_fournisseur", "site");
CREATE INDEX IF NOT EXISTS "idx_cadence_site"
ON "commande_cadences" ("site");
CREATE TABLE IF NOT EXISTS "qlik_network_metrics" (
"code_centrale" varchar(20) PRIMARY KEY NOT NULL,
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"periode" varchar(20),
"fetched_at" timestamp DEFAULT now()
);
ALTER TABLE "qlik_network_metrics"
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255),
ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255);
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
CREATE TABLE IF NOT EXISTS "grid_rows" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"libelle1" varchar(500),
"gtin" varchar(30),
"reference" varchar(100),
"code_centrale" varchar(20),
"code1" varchar(20),
"code2" varchar(20),
"code3" varchar(20),
"code_gamme" varchar(20),
"code_gamme_init" varchar(20),
"total_ca" numeric(16, 2),
"total_quantite" numeric(14, 2),
"total_marge" numeric(16, 2),
"taux_marge" numeric(8, 4),
"stock_actuel" numeric(14, 2),
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"payload" jsonb NOT NULL,
"computed_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein"
ON "grid_rows" ("code_fournisseur", "codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1");
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"key_prefix" varchar(20) NOT NULL,
"key_hash" text NOT NULL UNIQUE,
"role" varchar(20) DEFAULT 'user' NOT NULL,
"created_by" varchar(50),
"created_at" timestamp DEFAULT now(),
"last_used_at" timestamp,
"revoked_at" timestamp
);
CREATE TABLE IF NOT EXISTS "sync_fournisseurs" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"nom_fournisseur" varchar(255),
"actif_sql" boolean NOT NULL DEFAULT true,
"actif_qlik" boolean NOT NULL DEFAULT true,
"desactive_motif" text,
"dernier_sql_at" timestamp,
"dernier_sql_statut" varchar(20),
"dernier_sql_lignes" integer,
"dernier_sql_erreur" text,
"dernier_sql_ms" integer,
"dernier_qlik_at" timestamp,
"dernier_qlik_statut" varchar(20),
"dernier_qlik_codes" integer,
"dernier_qlik_erreur" text,
"dernier_qlik_ms" integer,
"updated_at" timestamp DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at");
CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at");
CREATE TABLE IF NOT EXISTS "gammes_a_valider" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"gamme" varchar(20) NOT NULL,
"gamme_precedente" varchar(20),
"auteur" varchar(100),
"created_at" timestamp DEFAULT now() NOT NULL,
PRIMARY KEY ("code_fournisseur", "codein")
);
+115
View File
@@ -0,0 +1,115 @@
//! Gestion des clés de l'API `/api/v1` (administrateurs) — port de `api-key-actions.ts`.
//!
//! La clé en clair n'existe qu'une fois : renvoyée par `createApiKey`, jamais
//! restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
//! conserve sa ligne, pour garder la trace de son usage passé.
use crate::auth::api_key::generate_api_key;
use crate::error::AppResult;
use crate::rpc::{json, Ctx};
use chrono::NaiveDateTime;
use serde::Serialize;
use serde_json::{json as j, Value};
use sqlx::Row;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ApiKeyRow {
id: i32,
name: String,
key_prefix: String,
role: String,
created_by: Option<String>,
created_at: Option<String>,
last_used_at: Option<String>,
revoked_at: Option<String>,
}
fn ts(r: &sqlx::postgres::PgRow, col: &str) -> Option<String> {
r.try_get::<Option<NaiveDateTime>, _>(col).ok().flatten().map(crate::db::iso)
}
/// Liste les clés. Jamais de secret — seulement le préfixe lisible.
pub async fn get_api_keys(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let pool = crate::db::pool()?;
let rows = sqlx::query(
"SELECT id, name, key_prefix, role, created_by, created_at, last_used_at, revoked_at FROM api_keys ORDER BY created_at DESC",
)
.fetch_all(&pool)
.await?;
let out: Vec<ApiKeyRow> = rows
.iter()
.map(|r| ApiKeyRow {
id: r.try_get("id").unwrap_or_default(),
name: r.try_get("name").unwrap_or_default(),
key_prefix: r.try_get("key_prefix").unwrap_or_default(),
role: r.try_get("role").unwrap_or_default(),
created_by: r.try_get("created_by").ok().flatten(),
created_at: ts(r, "created_at"),
last_used_at: ts(r, "last_used_at"),
revoked_at: ts(r, "revoked_at"),
})
.collect();
json(out)
}
/// Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
pub async fn create_api_key(ctx: Ctx) -> AppResult<Value> {
let moi = ctx.admin().await?;
let name: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let role: String = ctx.arg::<Option<String>>(1)?.unwrap_or_else(|| "user".into());
let trimmed = name.trim();
if trimmed.is_empty() {
return Ok(j!({ "success": false, "error": "Le nom de la clé est obligatoire." }));
}
if trimmed.chars().count() > 100 {
return Ok(j!({ "success": false, "error": "Nom trop long (100 caractères max)." }));
}
let k = generate_api_key();
let pool = crate::db::pool()?;
let created_by = Some(moi.username.clone()).filter(|s| !s.is_empty());
match sqlx::query("INSERT INTO api_keys (name, key_prefix, key_hash, role, created_by) VALUES ($1, $2, $3, $4, $5)")
.bind(trimmed)
.bind(&k.key_prefix)
.bind(&k.key_hash)
.bind(&role)
.bind(created_by)
.execute(&pool)
.await
{
Ok(_) => Ok(j!({ "success": true, "key": k.key, "keyPrefix": k.key_prefix })),
Err(e) => {
tracing::error!("[api-keys] création KO: {e}");
Ok(j!({ "success": false, "error": "Erreur technique lors de la création de la clé." }))
}
}
}
/// Révoque une clé : refusée dès l'appel suivant, mais toujours listée.
pub async fn revoke_api_key(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let id: i64 = ctx.arg(0)?;
let pool = crate::db::pool()?;
match sqlx::query("UPDATE api_keys SET revoked_at = now() WHERE id = $1").bind(id as i32).execute(&pool).await {
Ok(_) => Ok(j!({ "success": true })),
Err(e) => {
tracing::error!("[api-keys] révocation KO: {e}");
Ok(j!({ "success": false, "error": "Erreur lors de la révocation." }))
}
}
}
/// Supprime définitivement une clé (nettoyage de la liste).
pub async fn delete_api_key(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let id: i64 = ctx.arg(0)?;
let pool = crate::db::pool()?;
match sqlx::query("DELETE FROM api_keys WHERE id = $1").bind(id as i32).execute(&pool).await {
Ok(_) => Ok(j!({ "success": true })),
Err(e) => {
tracing::error!("[api-keys] suppression KO: {e}");
Ok(j!({ "success": false, "error": "Erreur lors de la suppression." }))
}
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Préchauffage de l'instantané de grille (`grid_rows`).
//!
//! L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la
//! Grille est ouverte. Sans préchauffage, un consommateur externe ne verrait que
//! les fournisseurs déjà consultés à la main. Ce job calcule chaque fournisseur
//! une fois, séquentiellement (le calcul est lourd en SQL).
use crate::auth::AdminUser;
use axum::extract::Query;
use axum::Json;
use parking_lot::Mutex;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct WarmupJob {
/// "idle" | "running" | "success" | "error"
status: &'static str,
total: usize,
done: usize,
skipped: usize,
failed: usize,
#[serde(skip_serializing_if = "Option::is_none")]
current_fournisseur: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
started_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
finished_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
last_errors: Vec<String>,
}
impl WarmupJob {
fn idle() -> Self {
Self {
status: "idle",
total: 0,
done: 0,
skipped: 0,
failed: 0,
current_fournisseur: None,
started_at: None,
finished_at: None,
error: None,
last_errors: vec![],
}
}
}
static JOB: LazyLock<Arc<Mutex<Option<WarmupJob>>>> = LazyLock::new(|| Arc::new(Mutex::new(None)));
fn public_job() -> Value {
let job = JOB.lock().clone().unwrap_or_else(WarmupJob::idle);
let mut v = serde_json::to_value(job).unwrap_or(json!({}));
v["success"] = json!(true);
v
}
fn maintenant() -> String {
crate::db::iso_utc(chrono::Utc::now())
}
fn maj(f: impl FnOnce(&mut WarmupJob)) {
if let Some(j) = JOB.lock().as_mut() {
f(j);
}
}
async fn run_warmup(stale_hours: f64) {
let res: anyhow::Result<()> = async {
let fournisseurs = crate::ff::pg::pg_get_fournisseurs(None).await;
// Les fournisseurs calculés récemment sont sautés : un second passage ne
// coûte presque rien, le bouton est rejouable sans crainte.
let existing = crate::grid::store::list_grid_suppliers().await?;
let fresh_limit = chrono::Utc::now().naive_utc() - chrono::Duration::milliseconds((stale_hours * 3_600_000.0) as i64);
let fresh: HashSet<String> = existing
.iter()
.filter(|s| {
s.computed_at
.as_deref()
.and_then(|c| chrono::DateTime::parse_from_rfc3339(c).ok())
.map(|d| d.naive_utc() > fresh_limit)
.unwrap_or(false)
})
.map(|s| s.code_fournisseur.clone())
.collect();
maj(|j| j.total = fournisseurs.len());
tracing::info!("[grid-warmup] {} fournisseurs, {} déjà à jour (< {}h)", fournisseurs.len(), fresh.len(), stale_hours);
for f in &fournisseurs {
if fresh.contains(&f.code) {
maj(|j| j.skipped += 1);
continue;
}
maj(|j| j.current_fournisseur = Some(format!("{} — {}", f.code, f.nom)));
match crate::grid::product_rows::get_product_rows_for_sync(&f.code).await {
Ok(_) => maj(|j| j.done += 1),
Err(e) => {
let msg = format!("{}: {}", f.code, crate::error::short(&e, 120));
tracing::error!("[grid-warmup] {msg}");
maj(|j| {
j.failed += 1;
if j.last_errors.len() < 10 {
j.last_errors.push(msg);
}
});
}
}
}
Ok(())
}
.await;
match res {
Ok(()) => {
maj(|j| {
j.current_fournisseur = None;
j.status = "success";
j.finished_at = Some(maintenant());
});
if let Some(j) = JOB.lock().as_ref() {
tracing::info!("[grid-warmup] terminé — {} calculés, {} à jour, {} en échec", j.done, j.skipped, j.failed);
}
}
Err(e) => {
let msg = format!("{e:#}");
tracing::error!("[grid-warmup] échec global: {msg}");
maj(|j| {
j.status = "error";
j.error = Some(msg);
j.finished_at = Some(maintenant());
});
}
}
}
/// GET /api/admin/grid-warmup — avancement du préchauffage.
pub async fn get(_admin: AdminUser) -> Json<Value> {
Json(public_job())
}
/// POST /api/admin/grid-warmup?staleHours=24 — démarre en arrière-plan.
pub async fn post(_admin: AdminUser, Query(q): Query<HashMap<String, String>>) -> Json<Value> {
{
let mut job = JOB.lock();
if job.as_ref().map(|j| j.status == "running").unwrap_or(false) {
drop(job);
return Json(public_job());
}
// Comme `Number(param)` : absent ou vide → 0, illisible → défaut 24 h.
let raw = q
.get("staleHours")
.map(|s| if s.trim().is_empty() { 0.0 } else { s.trim().parse::<f64>().unwrap_or(f64::NAN) })
.unwrap_or(0.0);
let stale_hours = if raw.is_finite() { raw.clamp(0.0, 720.0) } else { 24.0 };
*job = Some(WarmupJob { status: "running", started_at: Some(maintenant()), ..WarmupJob::idle() });
tracing::info!("[grid-warmup] démarré (staleHours={stale_hours})");
tokio::spawn(run_warmup(stale_hours));
}
Json(public_job())
}
+41
View File
@@ -0,0 +1,41 @@
//! Journaux serveur des jobs (liste + téléchargement).
//!
//! `GET /api/logs` → liste des journaux disponibles (JSON)
//! `GET /api/logs?id=<jobId>` → le journal complet en pièce jointe (text/plain)
//!
//! Réservé aux admins : un journal d'extraction contient les diagnostics du
//! modèle Qlik et les codes articles manipulés (jamais de secret).
use crate::auth::AdminUser;
use crate::logcapture::{list_captures, read_capture};
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
pub struct LogsQuery {
id: Option<String>,
}
pub async fn get_logs(_admin: AdminUser, Query(q): Query<LogsQuery>) -> Response {
let Some(id) = q.id.filter(|s| !s.is_empty()) else {
return Json(json!({ "success": true, "logs": list_captures() })).into_response();
};
let Some(contenu) = read_capture(&id) else {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "Journal introuvable ou expiré" }))).into_response();
};
// Nom de fichier assaini : l'id finit dans un en-tête HTTP.
let nom: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect();
(
[
(header::CONTENT_TYPE, "text/plain; charset=utf-8".to_string()),
(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{nom}.log\"")),
(header::CACHE_CONTROL, "no-store".to_string()),
],
contenu,
)
.into_response()
}
+72
View File
@@ -0,0 +1,72 @@
//! Module `admin` — comptes, clés d'API, Paramètres, synchronisation nocturne.
pub mod api_keys;
pub mod grid_warmup;
pub mod logs;
mod qlik_bridge;
pub mod settings;
pub mod sync_fournisseurs;
pub mod sync_routes;
pub mod sync_scheduler;
pub mod sync_settings;
pub mod users;
use crate::error::AppResult;
use crate::handler;
use crate::rpc::{json, Ctx, Handler};
use axum::routing::get;
use axum::Router;
use serde_json::{Map, Value};
/// Routes HTTP du module.
pub fn router() -> Router {
Router::new()
.route("/api/logs", get(logs::get_logs))
.route("/api/admin/sync", get(sync_routes::get).patch(sync_routes::patch).post(sync_routes::post))
.route("/api/admin/grid-warmup", get(grid_warmup::get).post(grid_warmup::post))
.route("/api/admin/sync/fournisseurs", get(sync_fournisseurs::get).patch(sync_fournisseurs::patch))
}
async fn read_sync_settings_action(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
json(sync_settings::read_sync_settings())
}
async fn save_sync_settings_action(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let patch: Map<String, Value> = ctx.arg::<Option<Map<String, Value>>>(0)?.unwrap_or_default();
json(sync_settings::save_sync_settings(&patch)?)
}
/// Actions (`POST /api/actions/{nom}`) du module.
pub fn actions() -> Vec<(&'static str, Handler)> {
vec![
("getUsers", handler!(users::get_users)),
("createUser", handler!(users::create_user)),
("deleteUser", handler!(users::delete_user)),
("updatePassword", handler!(users::update_password)),
("getApiKeys", handler!(api_keys::get_api_keys)),
("createApiKey", handler!(api_keys::create_api_key)),
("revokeApiKey", handler!(api_keys::revoke_api_key)),
("deleteApiKey", handler!(api_keys::delete_api_key)),
("getParametresAffichables", handler!(settings::get_parametres_affichables)),
("testDatabaseConnection", handler!(settings::test_database_connection)),
("saveDatabaseSettings", handler!(settings::save_database_settings)),
("saveQlikSettings", handler!(settings::save_qlik_settings)),
("testQlikConnection", handler!(settings::test_qlik_connection)),
("saveFfApiSettings", handler!(settings::save_ff_api_settings)),
("testFfApiConnection", handler!(settings::test_ff_api_connection)),
("readSyncSettings", handler!(read_sync_settings_action)),
("saveSyncSettings", handler!(save_sync_settings_action)),
]
}
/// Données de pages (`GET /api/pages/{nom}`) du module.
pub fn pages() -> Vec<(&'static str, Handler)> {
vec![]
}
/// Tâches de fond lancées au démarrage (minuteur de la synchro nocturne).
pub fn start_background_tasks() {
sync_scheduler::start_sync_timer();
}
+37
View File
@@ -0,0 +1,37 @@
//! Points d'appel vers les modules `qlik` et `ff` utilisés par les Paramètres.
//! Branchés après l'intégration de ces modules.
use serde_json::Value;
/// Ouvre une session Qlik (NTLM → ticket → cookie) avec les identifiants donnés.
pub async fn tester_session(host: String, user: String, password: Option<String>) -> anyhow::Result<()> {
let enregistre = crate::qlik::client::get_qlik_config();
let mut cfg = enregistre.clone();
cfg.user = user;
cfg.password = password.unwrap_or(enregistre.password);
if !host.is_empty() {
cfg.host = host;
}
crate::qlik::client::qlik_ntlm_session(&cfg).await?;
Ok(())
}
/// Oublie l'URL mémorisée de l'API FF Nancy.
pub fn reset_ff_api_base() {
crate::ff::api::reset_ff_api_base_cache();
}
/// Diagnostic de l'API FF Nancy.
pub async fn diagnostiquer_api_ff(base: Option<String>) -> Value {
serde_json::to_value(crate::ff::diagnostic::diagnostiquer_api_ff(base).await).unwrap_or(Value::Null)
}
/// Extraction Qlik d'un fournisseur sur la fenêtre de la synchro (`QLIK_SYNC_MONTHS_BACK`),
/// puis enregistrement dans le cache réseau. Renvoie le nombre de codes enregistrés.
pub async fn extraire_et_enregistrer(codes: Vec<String>, fournisseur: &str) -> anyhow::Result<usize> {
use crate::qlik::date_range::{build_grid_network_qlik_date_filter_now, env_months_back, QLIK_MONTHS_BACK_DEFAULT};
let filtre = build_grid_network_qlik_date_filter_now(env_months_back("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT));
let metrics = crate::qlik::fetch_network_metrics_playwright(Some(codes), None, Some(filtre), Some(fournisseur.to_string())).await?;
let liste: Vec<_> = metrics.into_values().collect();
crate::qlik::upsert_network_metrics(&liste).await
}
+279
View File
@@ -0,0 +1,279 @@
//! Actions des Paramètres — réservées aux administrateurs.
//!
//! Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik)
//! et les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé
//! vide dans le formulaire signifie « conserver celui qui est enregistré ».
use crate::config::{read_config, write_config};
use crate::error::AppResult;
use crate::rpc::{json, Ctx};
use serde::{Deserialize, Serialize};
use serde_json::{json as j, Map, Value};
use std::time::Duration;
#[derive(Debug, Clone, Deserialize)]
pub struct ChampsBaseDeDonnees {
#[serde(default)]
pub host: String,
#[serde(default)]
pub port: String,
#[serde(default)]
pub database: String,
#[serde(default)]
pub user: String,
/// Vide ou absent : le mot de passe enregistré est conservé.
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub ssl: bool,
}
struct UrlPostgres {
host: String,
port: String,
database: String,
user: String,
password: String,
ssl: bool,
}
fn decoder(v: &str) -> String {
urlencoding::decode(v).map(|c| c.into_owned()).unwrap_or_else(|_| v.to_string())
}
/// Découpe l'URL `postgres://` enregistrée en champs du formulaire.
fn lire_url_postgres(url: &str) -> Option<UrlPostgres> {
let re = regex::Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*://(?:([^:@/]*)(?::([^@/]*))?@)?(\[[^\]]+\]|[^:/?#]*)(?::(\d+))?(?:/([^?#]*))?").unwrap();
let Some(c) = re.captures(url) else {
tracing::error!("[Settings] URL de base enregistrée illisible");
return None;
};
Some(UrlPostgres {
host: c.get(3).map(|m| m.as_str().to_string()).unwrap_or_default(),
port: c.get(4).map(|m| m.as_str().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "5432".into()),
database: decoder(c.get(5).map(|m| m.as_str()).unwrap_or_default()),
user: decoder(c.get(1).map(|m| m.as_str()).unwrap_or_default()),
password: decoder(c.get(2).map(|m| m.as_str()).unwrap_or_default()),
ssl: url.contains("sslmode=require"),
})
}
fn construire_url_postgres(champs: &ChampsBaseDeDonnees, mot_de_passe: &str) -> String {
let user = urlencoding::encode(champs.user.trim()).into_owned();
let identifiants = if mot_de_passe.is_empty() {
user
} else {
format!("{user}:{}", urlencoding::encode(mot_de_passe))
};
let port = Some(champs.port.trim()).filter(|p| !p.is_empty()).unwrap_or("5432");
format!(
"postgres://{identifiants}@{}:{port}/{}{}",
champs.host.trim(),
champs.database.trim(),
if champs.ssl { "?sslmode=require" } else { "" }
)
}
/// Mot de passe saisi, sinon celui de l'URL enregistrée.
fn mot_de_passe_base(champs: &ChampsBaseDeDonnees) -> String {
if let Some(p) = champs.password.as_ref().filter(|p| !p.is_empty()) {
return p.clone();
}
read_config().url.as_deref().and_then(lire_url_postgres).map(|u| u.password).unwrap_or_default()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DbAffichable {
host: String,
port: String,
database: String,
user: String,
ssl: bool,
mot_de_passe_enregistre: bool,
}
/// Réglages enregistrés, sans aucun secret.
pub async fn get_parametres_affichables(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let config = read_config();
let base = config.url.as_deref().and_then(lire_url_postgres);
let db = base.map(|b| DbAffichable {
host: b.host,
port: b.port,
database: b.database,
user: b.user,
ssl: b.ssl,
mot_de_passe_enregistre: !b.password.is_empty(),
});
let qlik_pwd_env = std::env::var("QLIK_PWD").map(|v| !v.is_empty()).unwrap_or(false);
json(j!({
"db": db,
"qlik": {
"host": config.qlik_host.clone().unwrap_or_default(),
"user": config.qlik_user.clone().unwrap_or_default(),
// Même ordre de priorité que la config Qlik : réglage enregistré, puis variable d'environnement.
"motDePasseEnregistre": config.qlik_password.as_deref().map(|p| !p.is_empty()).unwrap_or(false) || qlik_pwd_env,
},
"ffApiBaseUrl": config.ff_api_base_url.clone().unwrap_or_default(),
}))
}
fn refus(e: crate::error::AppError) -> Value {
j!({ "success": false, "error": e.message })
}
pub async fn test_database_connection(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let champs: ChampsBaseDeDonnees = ctx.arg(0)?;
let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs));
tracing::info!("Testing connection to: {}", crate::db::mask_url(&url));
let essai = async {
let opts = crate::db::connect_options(&url)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.acquire_timeout(Duration::from_secs(5))
.connect_with(opts)
.await?;
sqlx::query("SELECT 1").execute(&pool).await?;
pool.close().await;
Ok::<_, sqlx::Error>(())
};
match tokio::time::timeout(Duration::from_secs(8), essai).await {
Ok(Ok(())) => Ok(j!({ "success": true })),
Ok(Err(e)) => {
tracing::error!("Database connection test failed: {e}");
Ok(j!({ "success": false, "error": e.to_string() }))
}
Err(_) => Ok(j!({ "success": false, "error": "timeout exceeded when trying to connect" })),
}
}
pub async fn save_database_settings(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let champs: ChampsBaseDeDonnees = ctx.arg(0)?;
let url = construire_url_postgres(&champs, &mot_de_passe_base(&champs));
// Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés.
tracing::info!("[Settings] Saving config to {}", crate::config::config_file().display());
let mut patch = Map::new();
patch.insert("url".into(), Value::String(url));
match write_config(patch) {
Ok(()) => {
tracing::info!("[Settings] Database configuration saved successfully.");
crate::db::refresh();
// Nouvelle base : schéma et compte admin à (re)vérifier.
tokio::spawn(async {
crate::migrations::run().await;
crate::auth::ensure_admin_exists().await;
});
Ok(j!({ "success": true }))
}
Err(e) => {
tracing::error!("Failed to save database configuration: {e}");
Ok(j!({ "success": false, "error": e.to_string() }))
}
}
}
/// Mot de passe vide ou absent : celui qui est enregistré est conservé.
pub async fn save_qlik_settings(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let host: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let user: String = ctx.arg::<Option<String>>(1)?.unwrap_or_default();
let password: Option<String> = ctx.arg::<Option<String>>(2)?.filter(|p| !p.is_empty());
let mut patch = Map::new();
patch.insert("qlikHost".into(), Value::String(host));
patch.insert("qlikUser".into(), Value::String(user));
if let Some(p) = password {
patch.insert("qlikPassword".into(), Value::String(p));
}
match write_config(patch) {
Ok(()) => {
tracing::info!("[Settings] Qlik configuration saved.");
Ok(j!({ "success": true }))
}
Err(e) => {
tracing::error!("[Settings] Failed to save Qlik config: {e}");
Ok(j!({ "success": false, "error": e.to_string() }))
}
}
}
/// Mot de passe vide ou absent : on teste avec celui qui est enregistré.
pub async fn test_qlik_connection(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let host: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let user: String = ctx.arg::<Option<String>>(1)?.unwrap_or_default();
let password: Option<String> = ctx.arg::<Option<String>>(2)?.filter(|p| !p.is_empty());
match super::qlik_bridge::tester_session(host, user, password).await {
Ok(()) => Ok(j!({ "success": true })),
Err(e) => Ok(j!({ "success": false, "error": format!("{e:#}") })),
}
}
/// Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut.
pub async fn save_ff_api_settings(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(refus(e));
}
let brut: String = ctx.arg::<Option<String>>(0)?.unwrap_or_default();
let cleaned = brut.trim().trim_end_matches('/').to_string();
let lower = cleaned.to_ascii_lowercase();
if !cleaned.is_empty() && !(lower.starts_with("http://") || lower.starts_with("https://")) {
return Ok(j!({ "success": false, "error": "L'URL doit commencer par http:// ou https://" }));
}
let mut patch = Map::new();
patch.insert("ffApiBaseUrl".into(), if cleaned.is_empty() { Value::Null } else { Value::String(cleaned.clone()) });
match write_config(patch) {
Ok(()) => {
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
super::qlik_bridge::reset_ff_api_base();
tracing::info!("[Settings] FF API base URL saved: {}", if cleaned.is_empty() { "(défaut)" } else { &cleaned });
Ok(j!({ "success": true }))
}
Err(e) => {
tracing::error!("[Settings] Failed to save FF API config: {e}");
Ok(j!({ "success": false, "error": e.to_string() }))
}
}
}
/// Teste l'API FF Nancy.
pub async fn test_ff_api_connection(ctx: Ctx) -> AppResult<Value> {
if let Err(e) = ctx.admin().await {
return Ok(j!({ "success": false, "url": "", "error": e.message }));
}
let base: Option<String> = ctx.arg(0)?;
Ok(super::qlik_bridge::diagnostiquer_api_ff(base).await)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_roundtrip() {
let champs = ChampsBaseDeDonnees {
host: "db".into(),
port: "".into(),
database: "cf".into(),
user: "u@x".into(),
password: Some("p:ss/w".into()),
ssl: true,
};
let url = construire_url_postgres(&champs, "p:ss/w");
assert_eq!(url, "postgres://u%40x:p%3Ass%2Fw@db:5432/cf?sslmode=require");
let u = lire_url_postgres(&url).unwrap();
assert_eq!((u.host.as_str(), u.port.as_str(), u.database.as_str(), u.user.as_str(), u.password.as_str(), u.ssl), ("db", "5432", "cf", "u@x", "p:ss/w", true));
let u = lire_url_postgres("postgres://postgres:secret@postgresbase:5432/collectflow").unwrap();
assert_eq!((u.host.as_str(), u.user.as_str(), u.password.as_str(), u.database.as_str()), ("postgresbase", "postgres", "secret", "collectflow"));
}
}
+148
View File
@@ -0,0 +1,148 @@
//! `/api/admin/sync/fournisseurs` — paramétrage par fournisseur de la synchro nocturne.
use crate::auth::AdminUser;
use crate::error::{AppError, AppResult};
use axum::http::StatusCode;
use axum::Json;
use chrono::NaiveDateTime;
use serde::Serialize;
use serde_json::{json, Value};
use sqlx::postgres::PgRow;
use sqlx::Row;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncFournisseurRow {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub actif_sql: bool,
pub actif_qlik: bool,
pub desactive_motif: Option<String>,
pub dernier_sql_at: Option<String>,
pub dernier_sql_statut: Option<String>,
pub dernier_sql_lignes: Option<i32>,
pub dernier_sql_erreur: Option<String>,
pub dernier_sql_ms: Option<i32>,
pub dernier_qlik_at: Option<String>,
pub dernier_qlik_statut: Option<String>,
pub dernier_qlik_codes: Option<i32>,
pub dernier_qlik_erreur: Option<String>,
pub dernier_qlik_ms: Option<i32>,
pub updated_at: Option<String>,
}
fn ts(r: &PgRow, col: &str) -> Option<String> {
r.try_get::<Option<NaiveDateTime>, _>(col).ok().flatten().map(crate::db::iso)
}
pub fn from_row(r: &PgRow) -> SyncFournisseurRow {
SyncFournisseurRow {
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
actif_sql: r.try_get("actif_sql").unwrap_or(true),
actif_qlik: r.try_get("actif_qlik").unwrap_or(true),
desactive_motif: r.try_get("desactive_motif").ok().flatten(),
dernier_sql_at: ts(r, "dernier_sql_at"),
dernier_sql_statut: r.try_get("dernier_sql_statut").ok().flatten(),
dernier_sql_lignes: r.try_get("dernier_sql_lignes").ok().flatten(),
dernier_sql_erreur: r.try_get("dernier_sql_erreur").ok().flatten(),
dernier_sql_ms: r.try_get("dernier_sql_ms").ok().flatten(),
dernier_qlik_at: ts(r, "dernier_qlik_at"),
dernier_qlik_statut: r.try_get("dernier_qlik_statut").ok().flatten(),
dernier_qlik_codes: r.try_get("dernier_qlik_codes").ok().flatten(),
dernier_qlik_erreur: r.try_get("dernier_qlik_erreur").ok().flatten(),
dernier_qlik_ms: r.try_get("dernier_qlik_ms").ok().flatten(),
updated_at: ts(r, "updated_at"),
}
}
/// GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
/// dernière synchronisation (quelques centaines de lignes : pas de pagination).
pub async fn get(_admin: AdminUser) -> AppResult<Json<Value>> {
let pool = crate::db::pool()?;
let rows = sqlx::query("SELECT * FROM sync_fournisseurs ORDER BY coalesce(nom_fournisseur, code_fournisseur) ASC")
.fetch_all(&pool)
.await?;
let rows: Vec<SyncFournisseurRow> = rows.iter().map(from_row).collect();
let resume = json!({
"total": rows.len(),
"actifsSql": rows.iter().filter(|r| r.actif_sql).count(),
"actifsQlik": rows.iter().filter(|r| r.actif_qlik).count(),
"desactivesAuto": rows.iter().filter(|r| r.desactive_motif.as_deref().map(|m| !m.is_empty()).unwrap_or(false)).count(),
"enEchecSql": rows.iter().filter(|r| r.dernier_sql_statut.as_deref() == Some("echec")).count(),
"enEchecQlik": rows.iter().filter(|r| r.dernier_qlik_statut.as_deref() == Some("echec")).count(),
"jamaisSql": rows.iter().filter(|r| r.dernier_sql_at.is_none()).count(),
});
Ok(Json(json!({ "success": true, "fournisseurs": rows, "resume": resume })))
}
fn invalide(details: Vec<String>) -> AppError {
AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Paramètres invalides", "details": details }))
}
/// PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source.
///
/// Réactiver lève le motif de désactivation automatique : un fournisseur remis
/// en service par l'admin ne doit pas garder un « aucun article » trompeur.
pub async fn patch(_admin: AdminUser, body: axum::body::Bytes) -> AppResult<Json<Value>> {
let body: Value = serde_json::from_slice(&body)
.map_err(|_| AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Corps JSON invalide" })))?;
let mut details = Vec::new();
let codes: Vec<String> = match body.get("codes").and_then(|v| v.as_array()) {
Some(a) => {
let codes: Vec<String> = a.iter().filter_map(|v| v.as_str().map(String::from)).collect();
if codes.len() != a.len() || codes.iter().any(|c| c.is_empty()) {
details.push("Invalid input".to_string());
}
if codes.is_empty() {
details.push("Too small: expected array to have >=1 items".to_string());
}
if codes.len() > 2000 {
details.push("Too big: expected array to have <=2000 items".to_string());
}
codes
}
None => {
details.push("Invalid input: expected array".to_string());
vec![]
}
};
let bool_opt = |k: &str, details: &mut Vec<String>| match body.get(k) {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => {
details.push("Invalid input: expected boolean".to_string());
None
}
};
let actif_sql = bool_opt("actifSql", &mut details);
let actif_qlik = bool_opt("actifQlik", &mut details);
if details.is_empty() && actif_sql.is_none() && actif_qlik.is_none() {
details.push("Préciser au moins actifSql ou actifQlik".to_string());
}
if !details.is_empty() {
return Err(invalide(details));
}
let reactivation = actif_sql == Some(true) || actif_qlik == Some(true);
let pool = crate::db::pool()?;
let mut qb = sqlx::QueryBuilder::<sqlx::Postgres>::new("UPDATE sync_fournisseurs SET updated_at = now()");
if let Some(v) = actif_sql {
qb.push(", actif_sql = ").push_bind(v);
}
if let Some(v) = actif_qlik {
qb.push(", actif_qlik = ").push_bind(v);
}
if reactivation {
qb.push(", desactive_motif = NULL");
}
qb.push(" WHERE code_fournisseur = ANY(").push_bind(codes).push(") RETURNING code_fournisseur");
let rows = qb.build().fetch_all(&pool).await?;
tracing::info!(
"[api/admin/sync] {} fournisseur(s) mis à jour (sql={} qlik={})",
rows.len(),
actif_sql.map(|b| b.to_string()).unwrap_or_else(|| "undefined".into()),
actif_qlik.map(|b| b.to_string()).unwrap_or_else(|| "undefined".into())
);
Ok(Json(json!({ "success": true, "misAJour": rows.len() })))
}
+126
View File
@@ -0,0 +1,126 @@
//! `/api/admin/sync` — réglages, état et pilotage de la synchronisation nocturne.
use super::sync_scheduler::{debut_fenetre, demander_arret, get_sync_scheduler_state, lancer_round, seed_sync_fournisseurs};
use super::sync_settings::{read_sync_settings, save_sync_settings};
use crate::auth::AdminUser;
use crate::error::{AppError, AppResult};
use axum::http::StatusCode;
use axum::Json;
use chrono::Local;
use serde_json::{json, Map, Value};
/// GET — réglages, état courant, et ouverture de fenêtre.
pub async fn get(_admin: AdminUser) -> AppResult<Json<Value>> {
let settings = read_sync_settings();
let maintenant = Local::now();
let debut = debut_fenetre(&settings, maintenant);
Ok(Json(json!({
"success": true,
"settings": settings,
"state": get_sync_scheduler_state(),
"dansLaFenetre": debut.is_some(),
"fenetreDebutAt": debut.map(|d| crate::db::iso_utc(d.with_timezone(&chrono::Utc))),
"maintenant": crate::db::iso_utc(maintenant.with_timezone(&chrono::Utc)),
})))
}
fn corps_json(body: &[u8]) -> AppResult<Value> {
serde_json::from_slice(body).map_err(|_| AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Corps JSON invalide" })))
}
/// Entier borné « coercé » comme `z.coerce.number().int().min().max()`.
fn coerce_int(v: &Value, min: i64, max: i64) -> Result<i64, String> {
let n = match v {
Value::Number(n) => n.as_f64(),
Value::String(s) if s.trim().is_empty() => Some(0.0),
Value::String(s) => s.trim().parse::<f64>().ok(),
Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
Value::Null => Some(0.0),
_ => None,
};
let Some(n) = n.filter(|n| n.is_finite()) else {
return Err("Invalid input: expected number, received NaN".into());
};
if n.fract() != 0.0 {
return Err("Invalid input: expected int, received number".into());
}
let n = n as i64;
if n < min {
return Err(format!("Too small: expected number to be >={min}"));
}
if n > max {
return Err(format!("Too big: expected number to be <={max}"));
}
Ok(n)
}
/// PATCH — met à jour les réglages.
pub async fn patch(_admin: AdminUser, body: axum::body::Bytes) -> AppResult<Json<Value>> {
let body = corps_json(&body)?;
let Some(obj) = body.as_object() else {
return Err(AppError::with_body(
StatusCode::BAD_REQUEST,
json!({ "error": "Paramètres invalides", "details": [": Invalid input: expected object"] }),
));
};
let mut patch = Map::new();
let mut details = Vec::new();
match obj.get("actif") {
None => {}
Some(Value::Bool(b)) => {
patch.insert("actif".into(), Value::Bool(*b));
}
Some(_) => details.push("actif: Invalid input: expected boolean".to_string()),
}
for (k, min, max) in [("heureDebut", 0, 23), ("heureFin", 0, 23), ("qlikParNuit", 0, 200), ("sqlParQlik", 1, 500), ("qlikMinJours", 0, 365)] {
if let Some(v) = obj.get(k) {
match coerce_int(v, min, max) {
Ok(n) => {
patch.insert(k.into(), Value::from(n));
}
Err(m) => details.push(format!("{k}: {m}")),
}
}
}
if !details.is_empty() {
return Err(AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Paramètres invalides", "details": details })));
}
let settings = save_sync_settings(&patch)?;
Ok(Json(json!({ "success": true, "settings": settings })))
}
/// POST — démarre un round manuellement, l'arrête, ou resynchronise la liste.
pub async fn post(_admin: AdminUser, body: axum::body::Bytes) -> AppResult<(StatusCode, Json<Value>)> {
let body = corps_json(&body)?;
let action = body.get("action").and_then(|v| v.as_str()).unwrap_or_default();
let forcer = match body.get("forcer") {
None => Some(false),
Some(Value::Bool(b)) => Some(*b),
Some(_) => None,
};
if !matches!(action, "start" | "stop" | "seed") || forcer.is_none() {
return Err(AppError::with_body(StatusCode::BAD_REQUEST, json!({ "error": "Action inconnue (start, stop ou seed)" })));
}
match action {
"seed" => {
let n = seed_sync_fournisseurs().await?;
Ok((StatusCode::OK, Json(json!({ "success": true, "fournisseurs": n }))))
}
"stop" => {
demander_arret();
Ok((StatusCode::OK, Json(json!({ "success": true, "state": get_sync_scheduler_state() }))))
}
_ => {
let etat = get_sync_scheduler_state();
if etat.en_cours {
return Ok((StatusCode::CONFLICT, Json(json!({ "error": "Un round est déjà en cours.", "state": etat }))));
}
// Lancé en tâche de fond : le round dure des minutes, le client suit via GET.
let forcer = forcer.unwrap_or(false);
tokio::spawn(async move { lancer_round("manuel", forcer).await });
// Laisse au round le temps de passer « en cours » avant de répondre.
tokio::task::yield_now().await;
Ok((StatusCode::OK, Json(json!({ "success": true, "state": get_sync_scheduler_state() }))))
}
}
}
+501
View File
@@ -0,0 +1,501 @@
//! Synchronisation nocturne des fournisseurs.
//!
//! Deux sources aux coûts très différents, donc deux files distinctes :
//! • **SQL** — l'instantané de grille (`get_product_rows`), ~43 s sur le plus
//! gros fournisseur. Alimente la Grille et `/api/v1`.
//! • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un
//! serveur externe qui abandonne quand on le sollicite trop. Agrégats
//! mensuels : les rafraîchir chaque nuit n'apporterait rien.
//!
//! ORDRE DE PASSAGE — la file est « le plus ancien d'abord », en ignorant ceux
//! déjà traités depuis l'ouverture de la fenêtre courante. Sans curseur ni
//! compteur de tour :
//! • la nuit 2 reprend là où la nuit 1 s'est arrêtée ;
//! • quand plus personne n'est en attente, le tour est fini ; la nuit
//! suivante tout le monde redevient éligible ;
//! • un fournisseur en échec garde une date ancienne : il repasse en tête.
//!
//! La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie
//! « ne plus en **démarrer** après l'heure de fin ».
use super::sync_settings::{read_sync_settings, SyncSettings};
use chrono::{DateTime, Datelike, Duration, Local, NaiveDateTime, TimeZone, Timelike};
use parking_lot::Mutex;
use regex::Regex;
use serde::Serialize;
use sqlx::Row;
use std::collections::HashSet;
use std::sync::LazyLock;
use std::time::Instant;
// ─── État courant, en mémoire ────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncSchedulerState {
pub en_cours: bool,
/// "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin.
pub origine: Option<&'static str>,
pub debut_at: Option<String>,
pub fin_at: Option<String>,
pub fournisseur_courant: Option<String>,
pub etape: Option<&'static str>,
pub sql_faits: u32,
pub sql_echecs: u32,
pub qlik_faits: u32,
pub qlik_echecs: u32,
/// Renseigné quand la file s'est vidée : le tour est bouclé.
pub tour_termine: bool,
pub arret_demande: bool,
pub derniere_erreur: Option<String>,
}
static ETAT: LazyLock<Mutex<SyncSchedulerState>> = LazyLock::new(|| Mutex::new(SyncSchedulerState::default()));
pub fn get_sync_scheduler_state() -> SyncSchedulerState {
ETAT.lock().clone()
}
pub fn demander_arret() {
let mut e = ETAT.lock();
if e.en_cours {
e.arret_demande = true;
}
}
fn iso_local(d: DateTime<Local>) -> String {
crate::db::iso_utc(d.with_timezone(&chrono::Utc))
}
// ─── Fenêtre horaire ─────────────────────────────────────────────────────────
fn a_heure(maintenant: DateTime<Local>, heure: i64) -> DateTime<Local> {
let naive = maintenant.date_naive().and_hms_opt(heure.clamp(0, 23) as u32, 0, 0).unwrap();
Local.from_local_datetime(&naive).earliest().unwrap_or(maintenant)
}
/// Début de la fenêtre en cours, ou `None` si l'heure courante est en dehors.
///
/// Gère le passage par minuit (ex. 22h → 5h) : entre 0h et l'heure de fin, la
/// fenêtre a commencé **la veille**.
pub fn debut_fenetre(s: &SyncSettings, maintenant: DateTime<Local>) -> Option<DateTime<Local>> {
let h = maintenant.hour() as i64;
let debut_du_jour = a_heure(maintenant, s.heure_debut);
if s.heure_debut < s.heure_fin {
return (h >= s.heure_debut && h < s.heure_fin).then_some(debut_du_jour);
}
// Fenêtre à cheval sur minuit.
if h >= s.heure_debut {
return Some(debut_du_jour);
}
if h < s.heure_fin {
return Some(debut_du_jour - Duration::days(1));
}
None
}
// ─── Référentiel des fournisseurs ────────────────────────────────────────────
/// Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux
/// fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien.
pub async fn seed_sync_fournisseurs() -> anyhow::Result<usize> {
let fournisseurs = crate::ff::pg::pg_get_fournisseurs(None).await;
if fournisseurs.is_empty() {
return Ok(0);
}
let pool = crate::db::pool()?;
for lot in fournisseurs.chunks(500) {
let mut qb = sqlx::QueryBuilder::<sqlx::Postgres>::new("INSERT INTO sync_fournisseurs (code_fournisseur, nom_fournisseur) ");
qb.push_values(lot, |mut b, f| {
b.push_bind(&f.code).push_bind(&f.nom);
});
// Seul le nom est réaligné : les choix d'activation appartiennent à l'admin.
qb.push(" ON CONFLICT (code_fournisseur) DO UPDATE SET nom_fournisseur = excluded.nom_fournisseur");
qb.build().execute(&pool).await?;
}
Ok(fournisseurs.len())
}
// ─── Files d'attente ─────────────────────────────────────────────────────────
struct Cible {
code: String,
nom: Option<String>,
}
fn cible(r: &sqlx::postgres::PgRow) -> Cible {
Cible { code: r.try_get("code_fournisseur").unwrap_or_default(), nom: r.try_get("nom_fournisseur").ok().flatten() }
}
fn naive(d: DateTime<Local>) -> NaiveDateTime {
// Colonnes `timestamp` sans fuseau, écrites par `now()` du serveur (UTC) :
// on compare en UTC.
d.with_timezone(&chrono::Utc).naive_utc()
}
/// Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre.
async fn prochain_sql(depuis: DateTime<Local>) -> anyhow::Result<Option<Cible>> {
let pool = crate::db::pool()?;
let row = sqlx::query(
"SELECT code_fournisseur, nom_fournisseur FROM sync_fournisseurs
WHERE actif_sql = true AND (dernier_sql_at IS NULL OR dernier_sql_at < $1)
ORDER BY dernier_sql_at ASC NULLS FIRST LIMIT 1",
)
.bind(naive(depuis))
.fetch_optional(&pool)
.await?;
Ok(row.as_ref().map(cible))
}
/// Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante.
const QLIK_REPRISE_ECHEC_H: i64 = 18;
/// Le plus ancien fournisseur Qlik à rafraîchir : données plus vieilles que
/// `qlikMinJours`, ou d'avant le mois en cours (la fenêtre de 12 mois a glissé),
/// ou dernière extraction en échec il y a plus de 18 h.
async fn prochain_qlik(qlik_min_jours: i64, maintenant: DateTime<Local>) -> anyhow::Result<Option<Cible>> {
let seuil_age = maintenant - Duration::days(qlik_min_jours);
let debut_mois = Local
.from_local_datetime(&maintenant.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap())
.earliest()
.unwrap_or(maintenant);
let seuil = if seuil_age > debut_mois { seuil_age } else { debut_mois };
let seuil_echec = maintenant - Duration::hours(QLIK_REPRISE_ECHEC_H);
let pool = crate::db::pool()?;
let row = sqlx::query(
"SELECT code_fournisseur, nom_fournisseur FROM sync_fournisseurs
WHERE actif_qlik = true AND (
dernier_qlik_at IS NULL
OR dernier_qlik_at < $1
OR (dernier_qlik_statut = 'echec' AND dernier_qlik_at < $2)
)
ORDER BY dernier_qlik_at ASC NULLS FIRST LIMIT 1",
)
.bind(naive(seuil))
.bind(naive(seuil_echec))
.fetch_optional(&pool)
.await?;
Ok(row.as_ref().map(cible))
}
// ─── Traitement d'un fournisseur ─────────────────────────────────────────────
fn libelle(code: &str, nom: &Option<String>) -> String {
match nom {
Some(n) if !n.is_empty() => format!("{code} — {n}"),
_ => code.to_string(),
}
}
fn tronque(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
/// Instantané SQL. `dernier_sql_at` est renseigné **même en cas d'échec** :
/// sans cela, un fournisseur en échec resterait le plus ancien et bloquerait la file.
async fn traiter_sql(code: &str, nom: &Option<String>) {
let t0 = Instant::now();
{
let mut e = ETAT.lock();
e.fournisseur_courant = Some(libelle(code, nom));
e.etape = Some("sql");
}
let res = crate::grid::product_rows::get_product_rows_for_sync(code).await;
let ms = t0.elapsed().as_millis() as i32;
let pool = match crate::db::pool() {
Ok(p) => p,
Err(_) => return,
};
match res {
Ok(n) => {
let vide = n == 0;
// Un fournisseur sans article n'a rien à donner : on le sort des deux
// files plutôt que de le réessayer chaque nuit (motif visible par l'admin).
let sql = if vide {
"UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'vide', dernier_sql_lignes = $2,
dernier_sql_erreur = NULL, dernier_sql_ms = $3, actif_sql = false, actif_qlik = false,
desactive_motif = 'Aucun article — désactivé automatiquement', updated_at = now() WHERE code_fournisseur = $1"
} else {
"UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'succes', dernier_sql_lignes = $2,
dernier_sql_erreur = NULL, dernier_sql_ms = $3, updated_at = now() WHERE code_fournisseur = $1"
};
if let Err(e) = sqlx::query(sql).bind(code).bind(n as i32).bind(ms).execute(&pool).await {
tracing::error!("[sync-nuit] suivi SQL {code} non écrit : {e}");
}
if vide {
tracing::info!("[sync-nuit] {code} sans article — désactivé automatiquement");
} else {
ETAT.lock().sql_faits += 1;
}
}
Err(err) => {
let msg = format!("{err:#}");
{
let mut e = ETAT.lock();
e.sql_echecs += 1;
e.derniere_erreur = Some(format!("{code} (SQL) : {msg}"));
}
tracing::error!("[sync-nuit] SQL {code} en échec : {msg}");
let _ = sqlx::query(
"UPDATE sync_fournisseurs SET dernier_sql_at = now(), dernier_sql_statut = 'echec', dernier_sql_erreur = $2,
dernier_sql_ms = $3, updated_at = now() WHERE code_fournisseur = $1",
)
.bind(code)
.bind(tronque(&msg, 500))
.bind(ms)
.execute(&pool)
.await;
}
}
}
/// Format d'un code centrale plausible — même filtre que la synchro manuelle.
static CODE_CENTRALE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^[A-Z0-9][A-Z0-9_-]{1,29}$").unwrap());
/// Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec.
async fn traiter_qlik(code: &str, nom: &Option<String>) {
let t0 = Instant::now();
{
let mut e = ETAT.lock();
e.fournisseur_courant = Some(libelle(code, nom));
e.etape = Some("qlik");
}
let res: anyhow::Result<Option<usize>> = async {
let articles = crate::ff::pg::pg_get_articles_by_fournisseur(code).await?;
let mut vus = HashSet::new();
let codes: Vec<String> = articles
.iter()
.map(|a| a.code_centrale.clone().unwrap_or_default().trim().to_string())
.filter(|c| !c.is_empty() && c != "-" && CODE_CENTRALE.is_match(c))
.filter(|c| vus.insert(c.clone()))
.collect();
if codes.is_empty() {
return Ok(None);
}
let n = super::qlik_bridge::extraire_et_enregistrer(codes, code).await?;
Ok(Some(n))
}
.await;
let ms = t0.elapsed().as_millis() as i32;
let Ok(pool) = crate::db::pool() else { return };
match res {
Ok(None) => {
let _ = sqlx::query(
"UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'vide', dernier_qlik_codes = 0,
dernier_qlik_erreur = NULL, dernier_qlik_ms = $2, updated_at = now() WHERE code_fournisseur = $1",
)
.bind(code)
.bind(ms)
.execute(&pool)
.await;
}
Ok(Some(n)) => {
let _ = sqlx::query(
"UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'succes', dernier_qlik_codes = $2,
dernier_qlik_erreur = NULL, dernier_qlik_ms = $3, updated_at = now() WHERE code_fournisseur = $1",
)
.bind(code)
.bind(n as i32)
.bind(ms)
.execute(&pool)
.await;
ETAT.lock().qlik_faits += 1;
tracing::info!("[sync-nuit] Qlik {code} — {n} codes en {} s", t0.elapsed().as_secs());
}
Err(err) => {
let msg = format!("{err:#}");
{
let mut e = ETAT.lock();
e.qlik_echecs += 1;
e.derniere_erreur = Some(format!("{code} (Qlik) : {msg}"));
}
tracing::error!("[sync-nuit] Qlik {code} en échec : {msg}");
let _ = sqlx::query(
"UPDATE sync_fournisseurs SET dernier_qlik_at = now(), dernier_qlik_statut = 'echec', dernier_qlik_erreur = $2,
dernier_qlik_ms = $3, updated_at = now() WHERE code_fournisseur = $1",
)
.bind(code)
.bind(tronque(&msg, 500))
.bind(ms)
.execute(&pool)
.await;
}
}
}
// ─── Boucle de travail ───────────────────────────────────────────────────────
/// Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt.
///
/// Les deux files avancent en parallèle : une extraction Qlik est intercalée
/// tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne
/// démarrerait jamais tant que la file SQL n'est pas vide.
pub async fn lancer_round(origine: &'static str, forcer_hors_fenetre: bool) {
let s = read_sync_settings();
let maintenant = Local::now();
let debut = debut_fenetre(&s, maintenant);
if !forcer_hors_fenetre && debut.is_none() {
return;
}
{
let mut e = ETAT.lock();
if e.en_cours {
return;
}
*e = SyncSchedulerState {
en_cours: true,
origine: Some(origine),
debut_at: Some(iso_local(maintenant)),
..Default::default()
};
}
// Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ».
let depuis = debut.unwrap_or_else(|| a_heure(maintenant, 0));
tracing::info!("[sync-nuit] round {origine} démarré (fenêtre {}h-{}h, Qlik ≤ {})", s.heure_debut, s.heure_fin, s.qlik_par_nuit);
let res: anyhow::Result<()> = async {
seed_sync_fournisseurs().await?;
// Garde anti-boucle : l'avancement de la file repose sur l'écriture de
// `dernier*_at`, volontairement non bloquante en cas d'erreur.
let mut tentes: HashSet<String> = HashSet::new();
let mut depuis_dernier_qlik: i64 = 0;
loop {
if ETAT.lock().arret_demande {
break;
}
if !forcer_hors_fenetre && debut_fenetre(&s, Local::now()).is_none() {
tracing::info!("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours");
break;
}
let qlik_possible = {
let e = ETAT.lock();
((e.qlik_faits + e.qlik_echecs) as i64) < s.qlik_par_nuit
};
// Qlik prioritaire quand le quota d'entrelacement est atteint.
if qlik_possible && depuis_dernier_qlik >= s.sql_par_qlik {
if let Some(c) = prochain_qlik(s.qlik_min_jours, Local::now()).await? {
if tentes.insert(format!("qlik:{}", c.code)) {
traiter_qlik(&c.code, &c.nom).await;
depuis_dernier_qlik = 0;
continue;
}
}
}
if let Some(c) = prochain_sql(depuis).await? {
if !tentes.insert(format!("sql:{}", c.code)) {
tracing::error!(
"[sync-nuit] {} ressort de la file après avoir été traité : le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.",
c.code
);
break;
}
traiter_sql(&c.code, &c.nom).await;
depuis_dernier_qlik += 1;
continue;
}
// File SQL vide : on consacre le temps restant à Qlik.
if qlik_possible {
if let Some(c) = prochain_qlik(s.qlik_min_jours, Local::now()).await? {
if tentes.insert(format!("qlik:{}", c.code)) {
traiter_qlik(&c.code, &c.nom).await;
depuis_dernier_qlik = 0;
continue;
}
}
}
ETAT.lock().tour_termine = true;
tracing::info!("[sync-nuit] plus rien en attente — tour terminé");
break;
}
Ok(())
}
.await;
if let Err(e) = res {
let msg = format!("{e:#}");
tracing::error!("[sync-nuit] round interrompu : {msg}");
ETAT.lock().derniere_erreur = Some(msg);
}
// Les pages (stock, hit-parade, CA…) relisent la base fraîche.
crate::ff_cache::invalidate();
let e = {
let mut e = ETAT.lock();
e.en_cours = false;
e.fournisseur_courant = None;
e.etape = None;
e.fin_at = Some(iso_local(Local::now()));
e.clone()
};
tracing::info!(
"[sync-nuit] round terminé — SQL {} ok / {} en échec, Qlik {} ok / {} en échec, tour bouclé={}",
e.sql_faits,
e.sql_echecs,
e.qlik_faits,
e.qlik_echecs,
e.tour_termine
);
}
/// Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais
/// deux rounds à la fois. Silencieux hors fenêtre — appelé chaque minute.
pub async fn tick_scheduler() {
let s = read_sync_settings();
if !s.actif || ETAT.lock().en_cours {
return;
}
if debut_fenetre(&s, Local::now()).is_none() {
return;
}
lancer_round("auto", false).await;
}
/// Minuteur de la synchronisation nocturne : bat chaque minute et laisse
/// `tick_scheduler` décider s'il y a quelque chose à faire. Rien ne tourne si
/// l'application est arrêtée ; il se réarme tout seul au redémarrage.
pub fn start_sync_timer() {
tracing::info!("[sync-nuit] minuteur démarré (battement 60 s)");
tokio::spawn(async {
let mut intervalle = tokio::time::interval(std::time::Duration::from_secs(60));
intervalle.tick().await;
loop {
intervalle.tick().await;
// Un round dure longtemps : il tourne dans sa propre tâche pour qu'une
// panique éventuelle n'arrête pas le minuteur.
if let Err(e) = tokio::spawn(tick_scheduler()).await {
tracing::error!("[sync-nuit] battement en erreur : {e}");
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::sync_settings::SYNC_SETTINGS_DEFAUT;
fn at(h: u32) -> DateTime<Local> {
Local.with_ymd_and_hms(2026, 3, 10, h, 30, 0).unwrap()
}
#[test]
fn fenetre_simple() {
let s = SyncSettings { heure_debut: 1, heure_fin: 5, ..SYNC_SETTINGS_DEFAUT };
assert!(debut_fenetre(&s, at(0)).is_none());
assert_eq!(debut_fenetre(&s, at(3)).unwrap().hour(), 1);
assert!(debut_fenetre(&s, at(5)).is_none());
}
#[test]
fn fenetre_minuit() {
let s = SyncSettings { heure_debut: 22, heure_fin: 5, ..SYNC_SETTINGS_DEFAUT };
assert_eq!(debut_fenetre(&s, at(23)).unwrap().day(), 10);
let d = debut_fenetre(&s, at(2)).unwrap();
assert_eq!((d.day(), d.hour()), (9, 22));
assert!(debut_fenetre(&s, at(12)).is_none());
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Réglages de la synchronisation nocturne, rangés dans `data/.db-config.json`
//! (clé `syncNuit`) comme les autres réglages de l'application.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SyncSettings {
/// Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
/// Qlik et la base la nuit suivante sans que personne l'ait décidé.
pub actif: bool,
/// Heure d'ouverture de la fenêtre (0-23).
pub heure_debut: i64,
/// Heure de fermeture. Aucun fournisseur n'est **démarré** après.
pub heure_fin: i64,
/// Nombre maximum d'extractions Qlik par nuit (chacune peut prendre 8 min).
pub qlik_par_nuit: i64,
/// Une extraction Qlik est intercalée tous les N fournisseurs SQL.
pub sql_par_qlik: i64,
/// Âge minimal des données Qlik avant de les refaire.
pub qlik_min_jours: i64,
}
pub const SYNC_SETTINGS_DEFAUT: SyncSettings =
SyncSettings { actif: false, heure_debut: 0, heure_fin: 5, qlik_par_nuit: 6, sql_par_qlik: 20, qlik_min_jours: 7 };
fn borne(v: Option<&Value>, min: i64, max: i64, defaut: i64) -> i64 {
let n = match v {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => s.trim().parse::<f64>().ok(),
Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }),
Some(Value::Null) => Some(0.0),
_ => None,
};
match n {
Some(f) if f.is_finite() => (f.trunc() as i64).clamp(min, max),
_ => defaut,
}
}
fn truthy(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
Some(Value::String(s)) => !s.is_empty(),
Some(_) => true,
}
}
/// Normalise ce qui vient du fichier : il est éditable à la main.
pub fn normaliser(brut: Option<&Map<String, Value>>) -> SyncSettings {
let d = SYNC_SETTINGS_DEFAUT;
let g = |k: &str| brut.and_then(|m| m.get(k));
let mut s = SyncSettings {
actif: truthy(g("actif")),
heure_debut: borne(g("heureDebut"), 0, 23, d.heure_debut),
heure_fin: borne(g("heureFin"), 0, 23, d.heure_fin),
qlik_par_nuit: borne(g("qlikParNuit"), 0, 200, d.qlik_par_nuit),
sql_par_qlik: borne(g("sqlParQlik"), 1, 500, d.sql_par_qlik),
qlik_min_jours: borne(g("qlikMinJours"), 0, 365, d.qlik_min_jours),
};
// Une fenêtre de durée nulle ne s'ouvrirait jamais : retour au défaut.
if s.heure_debut == s.heure_fin {
s.heure_debut = d.heure_debut;
s.heure_fin = d.heure_fin;
}
s
}
fn lire_fichier() -> Option<Map<String, Value>> {
let raw = std::fs::read_to_string(crate::config::config_file()).ok()?;
match serde_json::from_str::<Value>(&raw).ok()? {
Value::Object(m) => Some(m),
_ => None,
}
}
pub fn read_sync_settings() -> SyncSettings {
match lire_fichier() {
Some(cfg) => normaliser(cfg.get("syncNuit").and_then(|v| v.as_object())),
None => SYNC_SETTINGS_DEFAUT,
}
}
/// Fusionne `patch` (champs camelCase partiels) dans les réglages enregistrés.
pub fn save_sync_settings(patch: &Map<String, Value>) -> std::io::Result<SyncSettings> {
let existant = lire_fichier().unwrap_or_default();
let courant = normaliser(existant.get("syncNuit").and_then(|v| v.as_object()));
let mut fusion = match serde_json::to_value(courant) {
Ok(Value::Object(m)) => m,
_ => Map::new(),
};
for (k, v) in patch {
fusion.insert(k.clone(), v.clone());
}
let suivant = normaliser(Some(&fusion));
let mut p = Map::new();
p.insert("syncNuit".into(), serde_json::to_value(suivant).unwrap_or(Value::Null));
crate::config::write_config(p)?;
tracing::info!("[sync-nuit] réglages enregistrés : {}", serde_json::to_string(&suivant).unwrap_or_default());
Ok(suivant)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn normalise() {
let m = json!({ "actif": 1, "heureDebut": "2", "heureFin": 30, "qlikParNuit": -4, "sqlParQlik": 0 });
let s = normaliser(m.as_object());
assert!(s.actif);
assert_eq!((s.heure_debut, s.heure_fin, s.qlik_par_nuit, s.sql_par_qlik, s.qlik_min_jours), (2, 23, 0, 1, 7));
let m = json!({ "heureDebut": 4, "heureFin": 4 });
let s = normaliser(m.as_object());
assert_eq!((s.heure_debut, s.heure_fin), (0, 5));
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Gestion des comptes (administrateurs) — port de `user-actions.ts`.
use crate::auth::password::hash_password;
use crate::error::AppResult;
use crate::rpc::{json, Ctx};
use serde::Serialize;
use serde_json::{json as j, Value};
use sqlx::Row;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UserRow {
id: i32,
username: String,
role: String,
created_at: Option<String>,
}
/// Tous les utilisateurs.
pub async fn get_users(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let pool = crate::db::pool()?;
let rows = sqlx::query("SELECT id, username, role, created_at FROM users").fetch_all(&pool).await?;
let users: Vec<UserRow> = rows
.iter()
.map(|r| UserRow {
id: r.try_get("id").unwrap_or_default(),
username: r.try_get("username").unwrap_or_default(),
role: r.try_get("role").unwrap_or_default(),
created_at: r.try_get::<Option<chrono::NaiveDateTime>, _>("created_at").ok().flatten().map(crate::db::iso),
})
.collect();
json(users)
}
/// Crée un utilisateur.
pub async fn create_user(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let username: String = ctx.arg(0)?;
let password: String = ctx.arg(1)?;
let role: Option<String> = ctx.arg(2)?;
let role = role.unwrap_or_else(|| "user".into());
let pool = crate::db::pool()?;
match sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)")
.bind(&username)
.bind(hash_password(&password))
.bind(&role)
.execute(&pool)
.await
{
Ok(_) => Ok(j!({ "success": true })),
Err(e) => {
tracing::error!("CreateUser Error: {e}");
Ok(j!({ "success": false, "error": "L'utilisateur existe déjà ou une erreur technique est survenue." }))
}
}
}
/// Supprime un utilisateur (jamais son propre compte).
pub async fn delete_user(ctx: Ctx) -> AppResult<Value> {
let moi = ctx.admin().await?;
let id: i64 = ctx.arg(0)?;
if moi.id.parse::<i64>().ok() == Some(id) {
return Ok(j!({ "success": false, "error": "Vous ne pouvez pas supprimer votre propre compte." }));
}
let pool = crate::db::pool()?;
match sqlx::query("DELETE FROM users WHERE id = $1").bind(id as i32).execute(&pool).await {
Ok(_) => {
crate::auth::authz::invalider(&id.to_string());
Ok(j!({ "success": true }))
}
Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la suppression." })),
}
}
/// Change le mot de passe d'un utilisateur.
pub async fn update_password(ctx: Ctx) -> AppResult<Value> {
ctx.admin().await?;
let id: i64 = ctx.arg(0)?;
let new_password: String = ctx.arg(1)?;
let pool = crate::db::pool()?;
match sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(hash_password(&new_password))
.bind(id as i32)
.execute(&pool)
.await
{
Ok(_) => Ok(j!({ "success": true })),
Err(_) => Ok(j!({ "success": false, "error": "Erreur lors de la mise à jour du mot de passe." })),
}
}
+81
View File
@@ -0,0 +1,81 @@
//! Enveloppe de réponse de l'API publique `/api/v1`.
//!
//! Une forme unique : `{ data, pagination?, meta }` en succès,
//! `{ error: { code, message, details? } }` en échec, avec de vrais statuts HTTP.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use serde_json::{json, Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorCode {
Unauthorized,
Forbidden,
BadRequest,
NotFound,
/// Donnée pas encore calculée : 202, ce n'est pas un échec.
NotReady,
InternalError,
}
impl ApiErrorCode {
pub fn as_str(self) -> &'static str {
match self {
Self::Unauthorized => "unauthorized",
Self::Forbidden => "forbidden",
Self::BadRequest => "bad_request",
Self::NotFound => "not_found",
Self::NotReady => "not_ready",
Self::InternalError => "internal_error",
}
}
pub fn status(self) -> StatusCode {
match self {
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
Self::BadRequest => StatusCode::BAD_REQUEST,
Self::NotFound => StatusCode::NOT_FOUND,
Self::NotReady => StatusCode::ACCEPTED,
Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Pagination {
pub page: i64,
pub limit: i64,
pub total: i64,
pub total_pages: i64,
/// `true` s'il reste des pages à lire.
pub has_more: bool,
}
pub fn build_pagination(page: i64, limit: i64, total: i64) -> Pagination {
let limit = limit.max(1);
let total_pages = ((total as f64) / (limit as f64)).ceil().max(1.0) as i64;
Pagination { page, limit, total, total_pages, has_more: page < total_pages }
}
/// Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`).
pub fn ok<T: Serialize>(data: T, pagination: Option<Pagination>, meta: Option<Map<String, Value>>) -> Response {
let mut body = Map::new();
body.insert("data".into(), serde_json::to_value(data).unwrap_or(Value::Null));
if let Some(p) = pagination {
body.insert("pagination".into(), serde_json::to_value(p).unwrap_or(Value::Null));
}
body.insert("meta".into(), Value::Object(meta.unwrap_or_default()));
Json(Value::Object(body)).into_response()
}
/// Réponse d'erreur. `details` sert notamment aux erreurs de validation.
pub fn fail(code: ApiErrorCode, message: impl Into<String>, details: Option<Value>) -> Response {
let mut err = json!({ "code": code.as_str(), "message": message.into() });
if let Some(d) = details {
err["details"] = d;
}
(code.status(), Json(json!({ "error": err }))).into_response()
}
+149
View File
@@ -0,0 +1,149 @@
//! Authentification de l'API publique `/api/v1`.
//!
//! Deux voies : clé d'API (`X-API-Key` ou `Authorization: Bearer …`) pour les
//! appelants non-navigateur, ou session du navigateur (l'interface utilise les
//! mêmes routes). Échec → 401 JSON au format `/api/v1`, jamais de redirection.
//!
//! Seul le hachage SHA-256 des clés est stocké (table `api_keys`).
use crate::api_v1_response::{fail, ApiErrorCode};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::HeaderMap;
use axum::response::Response;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use parking_lot::Mutex;
use rand::RngCore;
use sha2::{Digest, Sha256};
use sqlx::Row;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use subtle::ConstantTimeEq;
const KEY_PREFIX: &str = "cf_";
const TRACE_USAGE: Duration = Duration::from_secs(60);
static DERNIERE_TRACE: LazyLock<Mutex<HashMap<i32, Instant>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
pub struct AuthContext {
/// "api_key" | "session"
pub via: &'static str,
pub role: String,
/// Nom de la clé, ou nom de l'utilisateur connecté.
pub subject: String,
}
impl AuthContext {
/// Restreint un endpoint aux appelants administrateurs.
pub fn require_admin(&self) -> Result<(), Response> {
if self.role != "admin" {
return Err(fail(ApiErrorCode::Forbidden, "Rôle administrateur requis pour cette opération.", None));
}
Ok(())
}
}
pub fn hash_api_key(key: &str) -> String {
hex::encode(Sha256::digest(key.as_bytes()))
}
pub struct GeneratedKey {
pub key: String,
pub key_hash: String,
pub key_prefix: String,
}
/// Génère une clé : en clair (affichée une seule fois), son hachage, et un préfixe lisible.
pub fn generate_api_key() -> GeneratedKey {
let mut buf = [0u8; 24];
rand::thread_rng().fill_bytes(&mut buf);
let key = format!("{KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf));
GeneratedKey { key_hash: hash_api_key(&key), key_prefix: key.chars().take(11).collect(), key }
}
fn hashes_equal(a: &str, b: &str) -> bool {
match (hex::decode(a), hex::decode(b)) {
(Ok(x), Ok(y)) => !x.is_empty() && x.len() == y.len() && bool::from(x.as_slice().ct_eq(y.as_slice())),
_ => false,
}
}
fn read_key(headers: &HeaderMap) -> Option<String> {
if let Some(v) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
let authz = headers.get("authorization").and_then(|v| v.to_str().ok())?;
let re = regex::Regex::new(r"(?i)^Bearer\s+(.+)$").unwrap();
re.captures(authz).map(|c| c[1].trim().to_string())
}
/// Authentifie une requête `/api/v1`.
pub async fn require_api_auth(headers: &HeaderMap) -> Result<AuthContext, Response> {
if let Some(presented) = read_key(headers) {
let presented_hash = hash_api_key(&presented);
let pool = crate::db::pool().map_err(|e| fail(ApiErrorCode::InternalError, e.message, None))?;
let row = sqlx::query("SELECT id, name, role, key_hash, revoked_at FROM api_keys WHERE key_hash = $1 LIMIT 1")
.bind(&presented_hash)
.fetch_optional(&pool)
.await
.map_err(|e| fail(ApiErrorCode::InternalError, e.to_string(), None))?;
let Some(row) = row else {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None));
};
let key_hash: String = row.try_get("key_hash").unwrap_or_default();
if !hashes_equal(&key_hash, &presented_hash) {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None));
}
let revoked: Option<chrono::NaiveDateTime> = row.try_get("revoked_at").ok().flatten();
if revoked.is_some() {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API révoquée.", None));
}
let id: i32 = row.try_get("id").unwrap_or_default();
// Trace d'usage — jamais bloquante, au plus une écriture par minute et par clé.
let tracer = {
let mut m = DERNIERE_TRACE.lock();
let due = m.get(&id).map(|t| t.elapsed() >= TRACE_USAGE).unwrap_or(true);
if due {
m.insert(id, Instant::now());
}
due
};
if tracer {
tokio::spawn(async move {
if let Err(e) = sqlx::query("UPDATE api_keys SET last_used_at = now() WHERE id = $1").bind(id).execute(&pool).await {
tracing::error!("[api-auth] lastUsedAt KO: {}", crate::error::short(&e, 120));
}
});
}
return Ok(AuthContext {
via: "api_key",
role: row.try_get("role").unwrap_or_else(|_| "user".into()),
subject: row.try_get("name").unwrap_or_default(),
});
}
// Repli sur la session du navigateur.
if let Some(c) = super::session::from_headers(headers) {
return Ok(AuthContext { via: "session", role: c.role, subject: c.name });
}
Err(fail(
ApiErrorCode::Unauthorized,
"Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.",
None,
))
}
/// Extracteur pour les routes `/api/v1`.
pub struct ApiAuth(pub AuthContext);
impl<S: Send + Sync> FromRequestParts<S> for ApiAuth {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(ApiAuth(require_api_auth(&parts.headers).await?))
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Contrôle d'accès commun aux actions, routes et pages.
//!
//! Le jeton de session seul ne suffit pas : il reste valide jusqu'à son
//! expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On
//! relit donc l'utilisateur dans `users` (cache court), et le rôle en base
//! l'emporte sur celui du jeton.
//!
//! Base injoignable : on se contente du jeton (mode dégradé), sans quoi le
//! compte de secours `data/users.json` ne pourrait plus rien faire — y compris
//! configurer la base.
use super::session::{from_headers, Claims};
use crate::error::AppError;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use parking_lot::Mutex;
use serde::Serialize;
use sqlx::Row;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
pub const MESSAGE_SESSION: &str = "Session expirée : reconnectez-vous.";
pub const MESSAGE_ADMIN: &str = "Action réservée aux administrateurs.";
const CACHE_TTL: Duration = Duration::from_secs(60);
const PAUSE_BASE_INJOIGNABLE: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, Serialize)]
pub struct UtilisateurCourant {
pub id: String,
pub username: String,
pub role: String,
}
impl UtilisateurCourant {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
/// Identifiant numérique (`users.id`), `None` pour le compte de secours.
pub fn id_numerique(&self) -> Option<i32> {
self.id.parse::<i32>().ok().filter(|v| *v > 0)
}
}
#[derive(Clone)]
struct Fiche {
exists: bool,
role: String,
username: String,
at: Instant,
}
struct CacheState {
fiches: HashMap<String, Fiche>,
injoignable_depuis: Option<Instant>,
}
static CACHE: LazyLock<Mutex<CacheState>> =
LazyLock::new(|| Mutex::new(CacheState { fiches: HashMap::new(), injoignable_depuis: None }));
/// Oublie la fiche d'un utilisateur (après modification de son compte).
pub fn invalider(id: &str) {
CACHE.lock().fiches.remove(id);
}
/// Fiche de l'utilisateur en base, ou `None` si la base n'a pas pu répondre.
async fn lire_fiche(id: &str) -> Option<Fiche> {
{
let c = CACHE.lock();
if let Some(f) = c.fiches.get(id) {
if f.at.elapsed() < CACHE_TTL {
return Some(f.clone());
}
}
if c.injoignable_depuis.map(|t| t.elapsed() < PAUSE_BASE_INJOIGNABLE).unwrap_or(false) {
return None;
}
}
let id_num: i32 = if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() {
id.parse::<i32>().unwrap_or(-1)
} else {
-1
};
let res = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("SELECT username, role FROM users WHERE id = $1 LIMIT 1")
.bind(id_num)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row)
}
.await;
match res {
Ok(row) => {
let fiche = match row {
Some(r) => Fiche {
exists: true,
username: r.try_get("username").unwrap_or_default(),
role: r.try_get("role").unwrap_or_default(),
at: Instant::now(),
},
None => Fiche { exists: false, username: String::new(), role: String::new(), at: Instant::now() },
};
CACHE.lock().fiches.insert(id.to_string(), fiche.clone());
Some(fiche)
}
Err(e) => {
CACHE.lock().injoignable_depuis = Some(Instant::now());
tracing::warn!("[authz] Base injoignable, contrôle sur le seul jeton de session : {}", crate::error::short(&e, 200));
None
}
}
}
/// Utilisateur connecté, relu en base. 401 sinon.
pub async fn require_session(token: Option<&Claims>) -> Result<UtilisateurCourant, AppError> {
let jeton = token.ok_or_else(|| AppError::unauthorized(MESSAGE_SESSION))?;
match lire_fiche(&jeton.id).await {
None => Ok(UtilisateurCourant { id: jeton.id.clone(), username: jeton.name.clone(), role: jeton.role.clone() }),
Some(f) if !f.exists => Err(AppError::unauthorized(MESSAGE_SESSION)),
Some(f) => Ok(UtilisateurCourant { id: jeton.id.clone(), username: f.username, role: f.role }),
}
}
/// Administrateur connecté. 401 ou 403 sinon.
pub async fn require_admin(token: Option<&Claims>) -> Result<UtilisateurCourant, AppError> {
let u = require_session(token).await?;
if !u.is_admin() {
return Err(AppError::forbidden(MESSAGE_ADMIN));
}
Ok(u)
}
/// Extracteur : utilisateur connecté (sinon 401 JSON `{ error }`).
pub struct SessionUser(pub UtilisateurCourant);
impl<S: Send + Sync> FromRequestParts<S> for SessionUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let claims = from_headers(&parts.headers);
Ok(SessionUser(require_session(claims.as_ref()).await?))
}
}
/// Extracteur : administrateur connecté (sinon 401/403 JSON `{ error }`).
pub struct AdminUser(pub UtilisateurCourant);
impl<S: Send + Sync> FromRequestParts<S> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let claims = from_headers(&parts.headers);
Ok(AdminUser(require_admin(claims.as_ref()).await?))
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Comptes de secours `data/users.json`, utilisés seulement quand la base est
//! injoignable (première installation, base pas encore configurée).
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserJson {
pub id: String,
pub username: String,
pub password_hash: String,
pub role: String,
}
fn path() -> PathBuf {
crate::config::data_dir().join("users.json")
}
fn default_admin() -> UserJson {
UserJson {
id: "0".into(),
username: "admin".into(),
password_hash: super::password::hash_password("admin"),
role: "admin".into(),
}
}
/// Comptes de secours (créés avec admin/admin si le fichier n'existe pas).
pub fn get_fallback_users() -> Vec<UserJson> {
let p = path();
if !p.exists() {
let admin = default_admin();
save_fallback_users(&[admin.clone()]);
return vec![admin];
}
match std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok()) {
Some(users) => users,
None => {
tracing::error!("[AUTH] Error reading users.json");
vec![]
}
}
}
pub fn save_fallback_users(users: &[UserJson]) {
let p = path();
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Err(e) = std::fs::write(&p, serde_json::to_string_pretty(users).unwrap_or_default()) {
tracing::error!("[AUTH] Error writing users.json: {e}");
}
}
/// Base injoignable : le fichier de secours doit contenir au moins un administrateur.
pub fn assurer_admin_de_secours() {
if get_fallback_users().is_empty() {
tracing::info!("[AUTH] Initializing local JSON fallback with admin/admin...");
save_fallback_users(&[default_admin()]);
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Authentification : comptes, sessions, clés d'API.
pub mod api_key;
pub mod authz;
pub mod fallback;
pub mod password;
pub mod session;
pub use api_key::ApiAuth;
pub use authz::{require_admin, require_session, AdminUser, SessionUser, UtilisateurCourant};
pub use session::{Claims, Token};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use sqlx::Row;
use tokio::sync::Mutex;
// ---------------------------------------------------------------------------
// Amorçage du compte admin
// ---------------------------------------------------------------------------
/// `true` une fois l'amorçage réussi pour ce processus.
static AMORCE: Mutex<bool> = Mutex::const_new(false);
/// Prépare les comptes au premier démarrage, une seule fois par processus.
///
/// - Base joignable : crée la table `users` si besoin, puis admin/admin
/// **uniquement si la table est vide**.
/// - Base injoignable : s'assure que le fichier de secours contient un
/// administrateur ; on retentera la base à la prochaine connexion.
pub async fn ensure_admin_exists() -> bool {
let mut done = AMORCE.lock().await;
if *done {
return false;
}
match amorcer().await {
Ok(cree) => {
*done = true;
cree
}
Err(e) => {
tracing::warn!("[AUTH] Database seeding failed (expected if DB not configured yet): {e:#}");
false
}
}
}
async fn amorcer() -> anyhow::Result<bool> {
let pool = match crate::db::pool() {
Ok(p) => p,
Err(e) => {
fallback::assurer_admin_de_secours();
anyhow::bail!(e.message);
}
};
if let Err(e) = sqlx::query("SELECT 1").execute(&pool).await {
fallback::assurer_admin_de_secours();
return Err(e.into());
}
sqlx::raw_sql(
r#"CREATE TABLE IF NOT EXISTS "users" (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT NOW()
)"#,
)
.execute(&pool)
.await?;
let nb: i64 = sqlx::query_scalar("SELECT count(*) FROM users").fetch_one(&pool).await?;
if nb > 0 {
return Ok(false);
}
tracing::info!("[AUTH] Table users vide : création du compte admin/admin.");
let res = sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ('admin', $1, 'admin') ON CONFLICT DO NOTHING")
.bind(password::hash_password("admin"))
.execute(&pool)
.await?;
Ok(res.rows_affected() > 0)
}
// ---------------------------------------------------------------------------
// Connexion / déconnexion
// ---------------------------------------------------------------------------
struct CompteTrouve {
id: String,
username: String,
password_hash: String,
role: String,
}
/// Vérifie les identifiants. `None` si refusés.
async fn authorize(username: &str, password: &str) -> Option<CompteTrouve> {
ensure_admin_exists().await;
if username.is_empty() || password.is_empty() {
return None;
}
tracing::info!("[AUTH] Checking user \"{username}\"...");
let db_lookup = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("SELECT id, username, password_hash, role FROM users WHERE username = $1")
.bind(username)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row.map(|r| CompteTrouve {
id: r.try_get::<i32, _>("id").unwrap_or_default().to_string(),
username: r.try_get("username").unwrap_or_default(),
password_hash: r.try_get("password_hash").unwrap_or_default(),
role: r.try_get("role").unwrap_or_else(|_| "user".into()),
}))
};
let user = match db_lookup.await {
Ok(u) => {
tracing::info!("[AUTH] DB check: User found: {}", u.is_some());
u
}
Err(_) => {
// Base injoignable uniquement : un compte absent de la base ne doit
// pas pouvoir se connecter avec le fichier de secours.
tracing::warn!("[AUTH] DB unreachable, falling back to JSON.");
fallback::get_fallback_users().into_iter().find(|u| u.username == username).map(|u| CompteTrouve {
id: u.id,
username: u.username,
password_hash: u.password_hash,
role: u.role,
})
}
}?;
let ok = password::verify_password(password, &user.password_hash);
tracing::info!("[AUTH] Password valid for \"{username}\": {ok}");
ok.then_some(user)
}
#[derive(Deserialize)]
struct LoginBody {
#[serde(default)]
username: String,
#[serde(default)]
password: String,
}
async fn login(headers: HeaderMap, Json(body): Json<LoginBody>) -> Response {
let Some(user) = authorize(body.username.trim(), &body.password).await else {
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Utilisateur ou mot de passe incorrect." }))).into_response();
};
let claims = Claims {
id: user.id.clone(),
name: user.username.clone(),
role: user.role.clone(),
exp: chrono::Utc::now().timestamp() + session::MAX_AGE_SECS,
};
(
[(header::SET_COOKIE, session::set_cookie(&claims, &headers)), (header::CACHE_CONTROL, "no-store".into())],
Json(json!({ "ok": true, "user": { "id": user.id, "name": user.username, "role": user.role } })),
)
.into_response()
}
async fn logout() -> Response {
([(header::SET_COOKIE, session::clear_cookie())], Json(json!({ "ok": true }))).into_response()
}
/// Session courante pour l'interface (`useSession`). Relue en base : un compte
/// supprimé n'a plus de session.
async fn current_session(Token(token): Token) -> Response {
let user = match token.as_ref() {
Some(_) => authz::require_session(token.as_ref()).await.ok(),
None => None,
};
let body = match user {
Some(u) => json!({ "user": { "id": u.id, "name": u.username, "role": u.role } }),
None => json!({ "user": null }),
};
([(header::CACHE_CONTROL, "no-store")], Json(body)).into_response()
}
pub fn router() -> Router {
Router::new()
.route("/api/auth/login", post(login))
.route("/api/auth/logout", post(logout))
.route("/api/auth/session", get(current_session))
}
+60
View File
@@ -0,0 +1,60 @@
//! Mots de passe : scrypt au format `sel:hachage` (hex), compatible avec les
//! comptes créés par l'ancienne version Node (`scryptSync(pwd, sel, 64)`,
//! N=16384, r=8, p=1, le sel hexadécimal étant utilisé tel quel comme octets).
use rand::RngCore;
use subtle::ConstantTimeEq;
fn derive(password: &str, salt: &str) -> Option<[u8; 64]> {
let params = scrypt::Params::new(14, 8, 1, 64).ok()?;
let mut out = [0u8; 64];
scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut out).ok()?;
Some(out)
}
pub fn hash_password(password: &str) -> String {
let mut salt = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt);
let salt = hex::encode(salt);
let hash = derive(password, &salt).map(hex::encode).unwrap_or_default();
format!("{salt}:{hash}")
}
pub fn verify_password(password: &str, stored: &str) -> bool {
let Some((salt, hash)) = stored.split_once(':') else { return false };
if salt.is_empty() || hash.is_empty() {
return false;
}
let Ok(expected) = hex::decode(hash) else { return false };
let Some(actual) = derive(password, salt) else { return false };
expected.len() == actual.len() && bool::from(actual.as_slice().ct_eq(expected.as_slice()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let h = hash_password("admin");
assert!(verify_password("admin", &h));
assert!(!verify_password("nope", &h));
}
#[test]
fn compatible_node() {
// require("crypto").scryptSync("admin", "00112233445566778899aabbccddeeff", 64).toString("hex")
let stored = "00112233445566778899aabbccddeeff:".to_string()
+ &hex::encode(derive("admin", "00112233445566778899aabbccddeeff").unwrap());
assert!(verify_password("admin", &stored));
}
}
#[cfg(test)]
mod node_compat {
#[test]
fn hash_from_node() {
assert!(super::verify_password("s3cret", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa"));
assert!(!super::verify_password("s3cre", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa"));
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Jeton de session signé (cookie HttpOnly), remplaçant le JWT NextAuth.
//!
//! Contenu : identifiant, nom et rôle de l'utilisateur + expiration, encodés en
//! base64url et signés HMAC-SHA256 avec `AUTH_SECRET` (ou un secret aléatoire
//! conservé dans `data/.auth-secret`).
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::HeaderMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::convert::Infallible;
use std::sync::LazyLock;
pub const COOKIE_NAME: &str = "cf_session";
/// Durée de vie d'une session : 30 jours (comme NextAuth).
pub const MAX_AGE_SECS: i64 = 30 * 24 * 3600;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
/// Identifiant (`users.id`, ou « 0 » pour le compte de secours).
pub id: String,
pub name: String,
pub role: String,
/// Expiration (secondes Unix).
pub exp: i64,
}
static SECRET: LazyLock<Vec<u8>> = LazyLock::new(|| {
if let Ok(s) = std::env::var("AUTH_SECRET") {
if !s.trim().is_empty() {
return s.into_bytes();
}
}
let path = crate::config::data_dir().join(".auth-secret");
if let Ok(s) = std::fs::read_to_string(&path) {
if !s.trim().is_empty() {
return s.trim().as_bytes().to_vec();
}
}
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
let s = hex::encode(buf);
let _ = std::fs::create_dir_all(crate::config::data_dir());
if let Err(e) = std::fs::write(&path, &s) {
tracing::warn!("[auth] secret de session non persisté ({e}) : les sessions ne survivront pas au redémarrage");
}
s.into_bytes()
});
fn sign(payload: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).expect("hmac");
mac.update(payload.as_bytes());
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
pub fn encode(claims: &Claims) -> String {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap_or_default());
let sig = sign(&payload);
format!("{payload}.{sig}")
}
pub fn decode(token: &str) -> Option<Claims> {
let (payload, sig) = token.split_once('.')?;
let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).ok()?;
mac.update(payload.as_bytes());
let sig_bytes = URL_SAFE_NO_PAD.decode(sig).ok()?;
mac.verify_slice(&sig_bytes).ok()?;
let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
if claims.exp < chrono::Utc::now().timestamp() {
return None;
}
Some(claims)
}
/// Jeton lu dans l'en-tête `Cookie`, s'il est présent et valide.
pub fn from_headers(headers: &HeaderMap) -> Option<Claims> {
for value in headers.get_all(axum::http::header::COOKIE) {
let Ok(s) = value.to_str() else { continue };
for part in s.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix(COOKIE_NAME).and_then(|r| r.strip_prefix('=')) {
if let Some(c) = decode(v) {
return Some(c);
}
}
}
}
None
}
fn is_https(headers: &HeaderMap) -> bool {
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
/// En-tête `Set-Cookie` qui ouvre la session.
pub fn set_cookie(claims: &Claims, req_headers: &HeaderMap) -> String {
let secure = if is_https(req_headers) { "; Secure" } else { "" };
format!("{COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={MAX_AGE_SECS}{secure}", encode(claims))
}
/// En-tête `Set-Cookie` qui ferme la session.
pub fn clear_cookie() -> String {
format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")
}
/// Jeton de la requête (jamais de rejet : `None` si absent ou invalide).
#[derive(Debug, Clone)]
pub struct Token(pub Option<Claims>);
impl<S: Send + Sync> FromRequestParts<S> for Token {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Token(from_headers(&parts.headers)))
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Fichier de configuration `data/.db-config.json` (réglages saisis dans Paramètres).
//!
//! Le fichier contient des mots de passe et des clés d'API : il ne repart jamais
//! tel quel vers le navigateur. Les clés inconnues sont conservées à l'écriture.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::path::PathBuf;
/// Répertoire des données persistantes (volume Docker `/app/data`).
pub fn data_dir() -> PathBuf {
std::env::var("DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_default().join("data"))
}
pub fn config_file() -> PathBuf {
data_dir().join(".db-config.json")
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DbConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub open_router_key: Option<String>,
#[serde(default)]
pub open_router_model: Option<String>,
#[serde(default)]
pub ai_provider: Option<String>,
#[serde(default)]
pub google_ai_key: Option<String>,
#[serde(default)]
pub google_ai_model: Option<String>,
/// Qlik Sense — données réseau.
#[serde(default)]
pub qlik_host: Option<String>,
#[serde(default)]
pub qlik_user: Option<String>,
#[serde(default)]
pub qlik_password: Option<String>,
/// API REST FF Nancy (ex. https://api.ffnancy.fr).
#[serde(default)]
pub ff_api_base_url: Option<String>,
}
fn read_raw_sync() -> Map<String, Value> {
std::fs::read_to_string(config_file())
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.and_then(|v| match v {
Value::Object(m) => Some(m),
_ => None,
})
.unwrap_or_default()
}
/// Lit la config existante (vide si absente ou illisible).
pub fn read_config() -> DbConfig {
serde_json::from_value(Value::Object(read_raw_sync())).unwrap_or_default()
}
/// Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont
/// conservées ; une clé passée à `null` est retirée.
pub fn write_config(patch: Map<String, Value>) -> std::io::Result<()> {
let mut existing = read_raw_sync();
for (k, v) in patch {
if v.is_null() {
existing.remove(&k);
} else {
existing.insert(k, v);
}
}
std::fs::create_dir_all(data_dir())?;
let text = serde_json::to_string_pretty(&Value::Object(existing)).unwrap_or_else(|_| "{}".into());
std::fs::write(config_file(), text)
}
/// Chaîne non vide, sinon `None`.
pub fn non_empty(s: Option<String>) -> Option<String> {
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}
/// URL de connexion PostgreSQL : réglage enregistré, sinon `DATABASE_URL`.
pub fn database_url() -> Option<String> {
non_empty(read_config().url).or_else(|| non_empty(std::env::var("DATABASE_URL").ok()))
}
/// Variable d'environnement numérique bornée (équivalent de `envNumber` côté TS).
pub fn env_number(name: &str, default: i64, min: i64, max: i64) -> i64 {
match std::env::var(name) {
Ok(raw) if !raw.trim().is_empty() => match raw.trim().parse::<f64>() {
Ok(v) if v.is_finite() => (v.trunc() as i64).clamp(min, max),
_ => default,
},
_ => default,
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Pool PostgreSQL unique par processus, remplaçable à chaud.
//!
//! L'URL vient de `data/.db-config.json` (Paramètres), sinon de `DATABASE_URL`.
//! Enregistrer de nouveaux réglages appelle [`refresh`] : le pool suivant est
//! recréé à la première requête.
//!
//! La même base porte les tables de l'application et la recopie nocturne des
//! tables FF Nancy (`articles`, `mvtart`, `cube_stock`, `fouident`…).
use crate::error::{AppError, AppResult};
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use parking_lot::RwLock;
use serde_json::{Map, Value};
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgRow};
use sqlx::{Column, Executor, PgPool, Row, TypeInfo, ValueRef};
use std::str::FromStr;
use std::sync::LazyLock;
use std::time::Duration;
static POOL: LazyLock<RwLock<Option<PgPool>>> = LazyLock::new(|| RwLock::new(None));
/// Masque le mot de passe d'une URL de connexion pour les journaux.
pub fn mask_url(url: &str) -> String {
let re = regex::Regex::new(r":([^@/]+)@").unwrap();
re.replace(url, ":****@").into_owned()
}
/// Options de connexion communes (pool applicatif et tests de connexion).
pub fn connect_options(url: &str) -> Result<PgConnectOptions, sqlx::Error> {
let mut opts = PgConnectOptions::from_str(url)?.application_name("collectflow");
// Délai maximal par requête : opt-in (les calculs nocturnes des plus gros
// fournisseurs peuvent être longs).
if let Some(ms) = std::env::var("PG_STATEMENT_TIMEOUT_MS").ok().and_then(|v| v.parse::<u64>().ok()).filter(|v| *v > 0) {
opts = opts.options([("statement_timeout", ms.to_string())]);
}
Ok(opts)
}
fn build_pool(url: &str) -> Result<PgPool, sqlx::Error> {
let opts = connect_options(url)?;
Ok(PgPoolOptions::new()
.max_connections(20)
.idle_timeout(Duration::from_secs(30))
.acquire_timeout(Duration::from_secs(10))
// Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop
// petit dans le conteneur, et font échouer les grosses agrégations.
.after_connect(|conn, _meta| {
Box::pin(async move {
if let Err(e) = conn.execute("SET max_parallel_workers_per_gather = 0").await {
tracing::error!("[DB] SET max_parallel_workers_per_gather failed: {e}");
}
Ok(())
})
})
.connect_lazy_with(opts))
}
/// Pool courant, créé à la demande. Erreur 500 si aucune base n'est configurée.
pub fn pool() -> AppResult<PgPool> {
if let Some(p) = POOL.read().as_ref() {
return Ok(p.clone());
}
let mut guard = POOL.write();
if let Some(p) = guard.as_ref() {
return Ok(p.clone());
}
let url = crate::config::database_url().ok_or_else(|| {
tracing::error!("[DB] CRITICAL: No database connection string available!");
AppError::internal("Base de données non configurée (Paramètres → Base de données).")
})?;
tracing::info!("[DB] Initializing connection with: {}", mask_url(&url));
let p = build_pool(&url).map_err(|e| AppError::internal(format!("URL de base invalide : {e}")))?;
*guard = Some(p.clone());
Ok(p)
}
/// Oublie le pool courant (réglages modifiés dans Paramètres).
pub fn refresh() {
tracing::info!("[DB] Refreshing database connection...");
if let Some(old) = POOL.write().take() {
tokio::spawn(async move { old.close().await });
}
}
// ---------------------------------------------------------------------------
// Conversion générique d'une ligne en JSON
// ---------------------------------------------------------------------------
/// Horodatage au format JS `toISOString()` (`2026-01-31T12:34:56.789Z`).
pub fn iso(dt: NaiveDateTime) -> String {
dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
pub fn iso_utc(dt: DateTime<Utc>) -> String {
iso(dt.naive_utc())
}
/// Convertit une ligne en objet JSON, colonne par colonne, selon le type SQL.
///
/// Les `numeric` deviennent des nombres (le pilote Node les rendait en chaînes,
/// que le code convertissait avec `Number()`), les horodatages des chaînes ISO.
pub fn row_to_json(row: &PgRow) -> Value {
let mut obj = Map::new();
for (i, col) in row.columns().iter().enumerate() {
obj.insert(col.name().to_string(), cell_to_json(row, i));
}
Value::Object(obj)
}
pub fn rows_to_json(rows: &[PgRow]) -> Vec<Value> {
rows.iter().map(row_to_json).collect()
}
fn cell_to_json(row: &PgRow, i: usize) -> Value {
let raw = match row.try_get_raw(i) {
Ok(r) => r,
Err(_) => return Value::Null,
};
if raw.is_null() {
return Value::Null;
}
let ty = raw.type_info().name().to_ascii_uppercase();
macro_rules! get {
($t:ty) => {
row.try_get::<$t, _>(i).ok()
};
}
let num = |f: Option<f64>| f.and_then(serde_json::Number::from_f64).map(Value::Number).unwrap_or(Value::Null);
match ty.as_str() {
"BOOL" => get!(bool).map(Value::Bool).unwrap_or(Value::Null),
"INT2" => get!(i16).map(|v| Value::from(v)).unwrap_or(Value::Null),
"INT4" => get!(i32).map(|v| Value::from(v)).unwrap_or(Value::Null),
"INT8" => get!(i64).map(|v| Value::from(v)).unwrap_or(Value::Null),
"FLOAT4" => num(get!(f32).map(|v| v as f64)),
"FLOAT8" => num(get!(f64)),
"NUMERIC" => num(get!(rust_decimal::Decimal).and_then(|d| d.to_string().parse::<f64>().ok())),
"JSON" | "JSONB" => get!(Value).unwrap_or(Value::Null),
"TIMESTAMP" => get!(NaiveDateTime).map(|d| Value::String(iso(d))).unwrap_or(Value::Null),
"TIMESTAMPTZ" => get!(DateTime<Utc>).map(|d| Value::String(iso_utc(d))).unwrap_or(Value::Null),
"DATE" => get!(NaiveDate)
.map(|d| Value::String(iso(d.and_hms_opt(0, 0, 0).unwrap())))
.unwrap_or(Value::Null),
"TEXT[]" | "VARCHAR[]" | "_TEXT" | "_VARCHAR" => get!(Vec<String>).map(Value::from).unwrap_or(Value::Null),
_ => get!(String).map(Value::String).unwrap_or(Value::Null),
}
}
// ---------------------------------------------------------------------------
// Lecture tolérante de colonnes (équivalent des `Number(r.x) || 0` du code TS)
// ---------------------------------------------------------------------------
/// Valeur numérique d'une colonne quel que soit son type SQL (int, numeric,
/// float, texte numérique). `None` si NULL ou illisible.
pub fn get_f64(row: &PgRow, col: &str) -> Option<f64> {
let raw = row.try_get_raw(col).ok()?;
if raw.is_null() {
return None;
}
let ty = raw.type_info().name().to_ascii_uppercase();
match ty.as_str() {
"INT2" => row.try_get::<i16, _>(col).ok().map(|v| v as f64),
"INT4" => row.try_get::<i32, _>(col).ok().map(|v| v as f64),
"INT8" => row.try_get::<i64, _>(col).ok().map(|v| v as f64),
"FLOAT4" => row.try_get::<f32, _>(col).ok().map(|v| v as f64),
"FLOAT8" => row.try_get::<f64, _>(col).ok(),
"NUMERIC" => row
.try_get::<rust_decimal::Decimal, _>(col)
.ok()
.and_then(|d| d.to_string().parse::<f64>().ok()),
_ => row.try_get::<String, _>(col).ok().and_then(|s| s.trim().parse::<f64>().ok()),
}
.filter(|v| v.is_finite())
}
/// `Number(r.col) || 0`.
pub fn f64_or0(row: &PgRow, col: &str) -> f64 {
get_f64(row, col).unwrap_or(0.0)
}
/// Valeur texte d'une colonne quel que soit son type (nombres et dates convertis).
pub fn get_string(row: &PgRow, col: &str) -> Option<String> {
let idx = row.columns().iter().position(|c| c.name() == col)?;
match cell_to_json(row, idx) {
Value::Null => None,
Value::String(s) => Some(s),
Value::Number(n) => {
// 12.0 → "12", comme String(12) en JS.
let f = n.as_f64().unwrap_or_default();
if f.fract() == 0.0 && f.abs() < 1e15 {
Some(format!("{}", f as i64))
} else {
Some(n.to_string())
}
}
other => Some(other.to_string()),
}
}
/// `String(r.col ?? "")`.
pub fn string_or_empty(row: &PgRow, col: &str) -> String {
get_string(row, col).unwrap_or_default()
}
+52
View File
@@ -0,0 +1,52 @@
//! Référentiels partagés : magasins et gammes (miroir de `src/lib/magasins.ts`
//! et `src/lib/gammes.ts` côté interface).
pub struct Magasin {
/// Code FF du site (clé de toutes les requêtes).
pub code: &'static str,
/// Nom complet.
pub nom: &'static str,
/// Nom court.
pub court: &'static str,
}
pub const MAGASINS: [Magasin; 2] = [
Magasin { code: "292", nom: "Frouard (Nancy)", court: "Frouard" },
Magasin { code: "579", nom: "Houdemont", court: "Houdemont" },
];
/// Code utilisé par la Grille et l'API pour « tous nos magasins ».
pub const CODE_TOUS_MAGASINS: &str = "TOTAL";
pub const LIBELLE_TOUS_MAGASINS: &str = "Nos 2 magasins";
/// Anciens codes encore présents dans certaines réponses de l'API FF.
fn alias(code: &str) -> &str {
match code {
"F01" => "292",
"F02" => "579",
c => c,
}
}
pub fn trouver_magasin(code: &str) -> Option<&'static Magasin> {
let c = alias(code.trim());
MAGASINS.iter().find(|m| m.code == c)
}
/// Nom lisible d'un magasin. `TOTAL` (ou vide) → « Nos 2 magasins ».
pub fn nom_magasin(code: Option<&str>, court: bool) -> String {
match code {
None | Some("") | Some(CODE_TOUS_MAGASINS) => LIBELLE_TOUS_MAGASINS.to_string(),
Some(c) => match trouver_magasin(c) {
Some(m) => (if court { m.court } else { m.nom }).to_string(),
None => c.to_string(),
},
}
}
/// Codes de gamme valides.
pub const CODES_GAMME: [&str; 5] = ["A", "B", "C", "Y", "Z"];
pub fn est_gamme_valide(code: &str) -> bool {
CODES_GAMME.contains(&code)
}
+105
View File
@@ -0,0 +1,105 @@
//! Erreurs HTTP communes.
//!
//! Les routes « internes » (appelées par l'interface) répondent `{ "error": "…" }`
//! avec le statut adéquat ; l'API publique `/api/v1` a sa propre enveloppe
//! (`crate::api_v1_response`).
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{json, Value};
#[derive(Debug)]
pub struct AppError {
pub status: StatusCode,
pub message: String,
/// Corps JSON complet à renvoyer à la place de `{ error }`.
pub body: Option<Value>,
}
pub type AppResult<T> = Result<T, AppError>;
impl AppError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, message: message.into(), body: None }
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, message)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
}
/// Réponse au corps libre (ex. `{ success: false, error }` avec un statut 500).
pub fn with_body(status: StatusCode, body: Value) -> Self {
let message = body.get("error").and_then(|v| v.as_str()).unwrap_or_default().to_string();
Self { status, message, body: Some(body) }
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.status, self.message)
}
}
impl std::error::Error for AppError {}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let body = self.body.unwrap_or_else(|| json!({ "error": self.message }));
(self.status, Json(body)).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
// Une AppError enveloppée dans anyhow garde son statut.
match e.downcast::<AppError>() {
Ok(app) => app,
Err(e) => {
tracing::error!("[error] {e:#}");
Self::internal(format!("{e:#}"))
}
}
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
tracing::error!("[db] {e}");
Self::internal(e.to_string())
}
}
impl From<serde_json::Error> for AppError {
fn from(e: serde_json::Error) -> Self {
Self::bad_request(e.to_string())
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
Self::internal(e.to_string())
}
}
impl From<reqwest::Error> for AppError {
fn from(e: reqwest::Error) -> Self {
Self::internal(e.to_string())
}
}
/// Message d'une erreur quelconque tronqué à `n` caractères (pour les journaux).
pub fn short(e: &dyn std::fmt::Display, n: usize) -> String {
let s = e.to_string();
s.chars().take(n).collect()
}
+311
View File
@@ -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<Value> {
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<Value> {
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<String>,
/// Date ISO d'échéance (dernière réception + X semaines), ou null
pub echeance: Option<String>,
/// Jours restants avant l'échéance (négatif = en retard), ou null
pub jours_restants: Option<i64>,
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<i64>) -> 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<Value> {
ctx.session().await?;
json(get_fournisseurs_cached().await?)
}
struct CadenceRow {
id: i32,
code_fournisseur: String,
nom_fournisseur: Option<String>,
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<CadenceView> {
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<CadenceRow> = 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<String> = 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::<Utc>::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<Value> {
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<String>,
}
impl ResultatCadence {
fn ok() -> Self {
Self { ok: true, error: None }
}
fn ko(error: Option<String>) -> 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<Value> {
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<String> = 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<Value> {
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<Value> {
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);
}
}
+714
View File
@@ -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::<f64>().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<reqwest::Client> = 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<reqwest::Response> {
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<Mutex<Option<(String, Instant)>>> = 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<String>,
/// conditionnement
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pcb: Option<f64>,
/// prix de vente central
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pv_central: Option<f64>,
/// SQL Server internal ID (pour endpoint /mensuel)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub noid: Option<i64>,
/// référence fournisseur (ref_fou_principale)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
/// code-barres EAN/GTIN
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gtin: Option<String>,
/// article actif
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actif: Option<bool>,
/// article suspendu
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suspendu: Option<bool>,
}
#[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<f64>,
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nom_fournisseur_reel: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FfStock {
pub codein: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site: Option<String>,
pub stockdispo: f64,
pub qte: f64,
pub valstock: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prmp: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pv: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dernieresventes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dernierereception: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nbjoursdernsventes: Option<f64>,
}
#[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<f64>,
/// "ok" ou état d'erreur renvoyé par la synchronisation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub statut: Option<String>,
#[serde(default)]
pub erreur: Option<String>,
}
/// 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<Value>,
}
/// 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<FfSyncStatus> {
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<FfSyncTable> = 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<Value> {
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::<Vec<_>>().join(","),
None => {
if items.is_empty() {
"empty".into()
} else {
String::new()
}
}
}
}
async fn fetch_json(url: &str) -> anyhow::Result<Option<Value>> {
let res = ff_get(url).await?;
if !res.status().is_success() {
return Ok(None);
}
Ok(Some(res.json::<Value>().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<Vec<Value>> {
// 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<u32> = (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<f64> {
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<Vec<FfArticle>> {
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<String, D::Error> {
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<FfMensuelVentes>,
#[serde(default)]
pub receptions: Option<FfMensuelReceptions>,
/// Autres champs de la réponse, conservés tels quels.
#[serde(flatten, default)]
pub autres: serde_json::Map<String, Value>,
}
/// 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<codein, FfMensuelEntry[]>
pub async fn get_mensuel_by_articles(
articles: &[FfArticle],
date_debut: &str,
date_fin: &str,
batch_size: usize,
) -> HashMap<String, Vec<FfMensuelEntry>> {
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<FfMensuelEntry> = 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<codein, FfReferentiel>
pub async fn get_referentiel_by_articles(articles: &[FfArticle], batch_size: usize) -> HashMap<String, FfReferentiel> {
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<String, f64> {
let mut result: HashMap<String, f64> = 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<super::pg::CodeNom> {
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());
}
}
+34
View File
@@ -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<Vec<CodeNom>> {
cached(
"fournisseurs",
CacheOpts { ttl: None, cache_if: Some(|rows: &Vec<CodeNom>| !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<HashMap<String, String>> {
let liste: Vec<String> = codes.iter().map(|c| c.trim().to_string()).collect::<BTreeSet<_>>().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<String, String>| !map.is_empty()) },
move || async move { anyhow::Ok(pg_get_derniere_reception_par_fournisseur(Some(&liste)).await) },
)
.await
}
+149
View File
@@ -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<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<FfSyncStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
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::<std::io::Error>() {
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<String>) -> 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::<Value>().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)
}
}
}
+292
View File
@@ -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<HashMap<String, String>>) -> Json<Value> {
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<HashMap<String, String>>) -> Json<Value> {
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<String>) -> Json<Value> {
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<String>) -> Json<Value> {
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<String>) -> Json<Value> {
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<HashMap<String, String>>) -> Json<Value> {
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<String>, Query(q): Query<HashMap<String, String>>) -> Json<Value> {
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<Value> {
Json(json!([{ "codein": "100001", "qtecde": 5 }, { "codein": "100001", "qtecde": 3 }, { "codein": "100002" }]))
}
async fn sync_status() -> Json<Value> {
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<HashMap<String, String>>) -> Json<Value> {
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<Value>) -> 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::<Vec<_>>();
// ── 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<Value>| 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();
}
+172
View File
@@ -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::<f64>().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::<Vec<_>>()
.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<String> {
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<String> = 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)));
}
}
+55
View File
@@ -0,0 +1,55 @@
//! 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![
("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![
("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)),
]
}
+341
View File
@@ -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<Value> {
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::<i32>().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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<PgCommandeAutoRow>| !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<String>,
}
#[derive(Debug, Clone, Serialize)]
struct HistoriqueResult {
data: Value,
error: Option<String>,
}
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(&params)
.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<Value> {
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");
}
}
+3010
View File
File diff suppressed because it is too large. Load diff
+516
View File
@@ -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<HashMap<String, String>>) -> 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<String, Value> = 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<HashMap<String, String>>) -> 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(&params).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::<Vec<_>>()),
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::<Vec<_>>() }),
Value::Object(o) => {
let keys: Vec<String> = 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::<Vec<_>>(),
}),
// 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<Value> {
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<Value> {
let res = reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?;
Ok(res.json::<Value>().await?)
}
/// Champ texte d'un élément (`(x as Record).champ as string ?? null`).
fn champ(v: Option<&Value>, key: &str) -> Option<String> {
v.and_then(|x| x.get(key)).filter(|x| !x.is_null()).map(js_string)
}
pub async fn diag(_admin: AdminUser, Query(q): Query<HashMap<String, String>>) -> 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<String> = q.get("codefou").filter(|s| !s.is_empty()).cloned();
let mut first_codein: Option<String> = q.get("codein").filter(|s| !s.is_empty()).cloned();
let forced_noid: Option<String> = 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::<Vec<_>>()),
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<Value> = 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::<Vec<_>>(),
})
}
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()
}
+156
View File
@@ -0,0 +1,156 @@
//! Cache mémoire des lectures « FF » (base miroir et API FF Nancy).
//!
//! Les tables FF ne sont recopiées qu'une fois par nuit : recalculer à chaque
//! visite les mêmes agrégations lourdes coûtait plusieurs secondes par page.
//! - durée de vie bornée (`FF_CACHE_TTL_S`, 30 min par défaut) ;
//! - les appels simultanés pour la même clé partagent une seule requête ;
//! - une erreur n'est jamais mise en cache ;
//! - [`invalidate`] vide tout (fin de synchro nocturne).
use futures::future::{BoxFuture, FutureExt, Shared};
use parking_lot::Mutex;
use std::any::Any;
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
const MAX_ENTRIES: usize = 100;
type AnyArc = Arc<dyn Any + Send + Sync>;
type Pending = Shared<BoxFuture<'static, Result<AnyArc, Arc<String>>>>;
enum Entry {
Ready { value: AnyArc, expires_at: Instant, inserted: u64 },
Pending(Pending),
}
struct Store {
map: HashMap<String, Entry>,
seq: u64,
}
static STORE: LazyLock<Mutex<Store>> = LazyLock::new(|| Mutex::new(Store { map: HashMap::new(), seq: 0 }));
static GENERATION: AtomicU64 = AtomicU64::new(0);
pub fn default_ttl() -> Duration {
let s = std::env::var("FF_CACHE_TTL_S").ok().and_then(|v| v.parse::<u64>().ok()).filter(|v| *v > 0).unwrap_or(30 * 60);
Duration::from_secs(s)
}
/// Options d'une lecture mise en cache.
pub struct CacheOpts<T> {
/// Durée de vie propre à cette clé.
pub ttl: Option<Duration>,
/// Ne garder le résultat que s'il passe ce test (ex. liste non vide : une
/// lecture qui avale ses erreurs ne doit pas figer une panne passagère).
pub cache_if: Option<fn(&T) -> bool>,
}
impl<T> Default for CacheOpts<T> {
fn default() -> Self {
Self { ttl: None, cache_if: None }
}
}
/// Lecture mise en cache sous `key`.
pub async fn cached<T, F, Fut>(key: &str, opts: CacheOpts<T>, load: F) -> anyhow::Result<T>
where
T: Clone + Send + Sync + 'static,
F: FnOnce() -> Fut,
Fut: Future<Output = anyhow::Result<T>> + Send + 'static,
{
let pending = {
let store = STORE.lock();
match store.map.get(key) {
Some(Entry::Ready { value, expires_at, .. }) if *expires_at > Instant::now() => {
if let Some(v) = value.downcast_ref::<T>() {
return Ok(v.clone());
}
None
}
Some(Entry::Pending(p)) => Some(p.clone()),
_ => None,
}
};
let pending = match pending {
Some(p) => p,
None => {
let generation = GENERATION.load(Ordering::SeqCst);
let key_owned = key.to_string();
let ttl = opts.ttl.unwrap_or_else(default_ttl);
let cache_if = opts.cache_if;
let fut = load();
let shared: Pending = async move {
let res = fut.await;
let mut store = STORE.lock();
match res {
Ok(value) => {
let keep = cache_if.map(|f| f(&value)).unwrap_or(true);
let any: AnyArc = Arc::new(value);
if generation != GENERATION.load(Ordering::SeqCst) {
// Invalidation pendant le chargement : résultat rendu mais pas gardé.
} else if keep {
store.seq += 1;
let inserted = store.seq;
store.map.insert(
key_owned,
Entry::Ready { value: any.clone(), expires_at: Instant::now() + ttl, inserted },
);
evict(&mut store);
} else {
store.map.remove(&key_owned);
}
Ok(any)
}
Err(e) => {
if generation == GENERATION.load(Ordering::SeqCst) {
store.map.remove(&key_owned);
}
Err(Arc::new(format!("{e:#}")))
}
}
}
.boxed()
.shared();
STORE.lock().map.insert(key.to_string(), Entry::Pending(shared.clone()));
shared
}
};
match pending.await {
Ok(any) => any
.downcast_ref::<T>()
.cloned()
.ok_or_else(|| anyhow::anyhow!("ff_cache: type inattendu pour la clé {key}")),
Err(msg) => Err(anyhow::anyhow!("{msg}")),
}
}
fn evict(store: &mut Store) {
while store.map.len() > MAX_ENTRIES {
let oldest = store
.map
.iter()
.filter_map(|(k, e)| match e {
Entry::Ready { inserted, .. } => Some((k.clone(), *inserted)),
_ => None,
})
.min_by_key(|(_, i)| *i)
.map(|(k, _)| k);
match oldest {
Some(k) => {
store.map.remove(&k);
}
None => break,
}
}
}
/// Vide tout le cache FF (fin de synchro nocturne, données fraîches).
pub fn invalidate() {
GENERATION.fetch_add(1, Ordering::SeqCst);
STORE.lock().map.clear();
}
+143
View File
@@ -0,0 +1,143 @@
//! CollectFlow — Affectation de gammes via l'API `/api/v1`
//! (port de `src/lib/api-gammes.ts`).
//!
//! L'API n'enregistre pas la gamme : elle la **propose**. La proposition est
//! rangée dans `gammes_a_valider` et apparaît dans la Grille comme une
//! modification non enregistrée — on y voit donc quels produits changent de gamme
//! avant de valider (« Enregistrer ») ou de rejeter (« Annuler les modifications »).
//! Tant qu'elle n'est pas validée, `codeGamme` reste inchangée et la proposition
//! est exposée dans `gammeAValider`.
//!
//! Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est
//! proposé et la réponse liste les articles en cause.
use super::gammes_a_valider::{proposer_gammes, PropositionGamme};
use super::product_rows::{attendre_persistance, get_product_rows, GetProductRowsInput};
use super::store::{get_grid_freshness, get_grid_rows_gammes};
use crate::api_v1_response::{fail, ApiErrorCode};
use axum::response::Response;
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResultatGamme {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de l'appel (modifications validées comprises).
pub gamme_precedente: Option<String>,
/// Gamme présente en base FF, non modifiée par l'API.
pub code_gamme_serveur: Option<String>,
/// `true` = la gamme change : proposition déposée, à valider dans la Grille.
/// `false` = l'article a déjà cette gamme (une proposition antérieure en
/// attente pour lui est retirée).
pub modifie: bool,
/// `a_valider` quand `modifie`, sinon `inchangee`.
pub statut: &'static str,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AffectationGammes {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub resultats: Vec<ResultatGamme>,
}
/// Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée.
pub fn lire_corps_json(body: &[u8]) -> Result<Value, Response> {
serde_json::from_slice(body)
.map_err(|_| fail(ApiErrorCode::BadRequest, "Corps de requête JSON attendu (Content-Type: application/json).", None))
}
/// Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille).
///
/// Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la
/// demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les
/// articles lui appartiennent.
///
/// `auteur` : nom de la clé d'API ou de l'utilisateur — affiché dans la Grille.
pub async fn affecter_gammes(
code_fournisseur: &str,
changes: &[(String, String)],
compute: bool,
auteur: &str,
) -> Result<AffectationGammes, Response> {
// Un même article cité deux fois : la dernière valeur l'emporte (à la place
// de sa première occurrence, comme une Map JS).
let mut demandes: Vec<(String, String)> = Vec::new();
for (codein, gamme) in changes {
match demandes.iter_mut().find(|(c, _)| c == codein) {
Some(d) => d.1 = gamme.clone(),
None => demandes.push((codein.clone(), gamme.clone())),
}
}
let codeins: Vec<String> = demandes.iter().map(|(c, _)| c.clone()).collect();
let interne = |e: crate::error::AppError| fail(ApiErrorCode::InternalError, e.message, None);
if compute && get_grid_freshness(code_fournisseur).await.map_err(interne)?.is_none() {
tracing::info!("[api/v1/gammes] instantané absent pour {code_fournisseur} — calcul à la demande");
let calcul = get_product_rows(GetProductRowsInput::total(code_fournisseur, false)).await;
attendre_persistance(code_fournisseur).await;
if let Err(e) = calcul {
tracing::error!("[api/v1/gammes] calcul KO pour {code_fournisseur}: {e:#}");
return Err(fail(
ApiErrorCode::InternalError,
format!("Le calcul de la grille a échoué pour le fournisseur « {code_fournisseur} »."),
None,
));
}
}
let actuelles = get_grid_rows_gammes(code_fournisseur, &codeins).await.map_err(interne)?;
let inconnus: Vec<&String> = codeins.iter().filter(|c| !actuelles.contains_key(*c)).collect();
if !inconnus.is_empty() {
return Err(fail(
ApiErrorCode::NotFound,
format!(
"{} article(s) introuvable(s) chez le fournisseur « {code_fournisseur} » : aucune gamme n'a été enregistrée.",
inconnus.len()
),
Some(json!({ "fournisseur": code_fournisseur, "inconnus": inconnus })),
));
}
let mut resultats = Vec::with_capacity(demandes.len());
let mut propositions = Vec::new();
let mut annules = Vec::new();
let mut nom_fournisseur: Option<String> = None;
for (codein, gamme) in demandes {
let actuelle = &actuelles[&codein];
if nom_fournisseur.is_none() {
nom_fournisseur = actuelle.nom_fournisseur.clone();
}
let modifie = actuelle.code_gamme.as_deref() != Some(gamme.as_str());
if modifie {
propositions.push(PropositionGamme {
codein: codein.clone(),
gamme: gamme.clone(),
gamme_precedente: actuelle.code_gamme.clone(),
});
} else {
annules.push(codein.clone());
}
resultats.push(ResultatGamme {
codein,
gamme,
gamme_precedente: actuelle.code_gamme.clone(),
code_gamme_serveur: actuelle.code_gamme_init.clone(),
modifie,
statut: if modifie { "a_valider" } else { "inchangee" },
});
}
if let Err(e) = proposer_gammes(code_fournisseur, &propositions, &annules, auteur).await {
tracing::error!("[api/v1/gammes] proposition KO pour {code_fournisseur}: {}", e.message);
return Err(fail(ApiErrorCode::InternalError, "L'enregistrement des gammes à valider a échoué.", None));
}
tracing::info!("[api/v1/gammes] {code_fournisseur} — {} gamme(s) à valider proposée(s) par {auteur}", propositions.len());
Ok(AffectationGammes { code_fournisseur: code_fournisseur.to_string(), nom_fournisseur, resultats })
}
+488
View File
@@ -0,0 +1,488 @@
//! CollectFlow — Validation des paramètres de l'API `/api/v1`
//! (port de `src/lib/api-schemas.ts`, schémas zod).
//!
//! Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches
//! de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec
//! le détail des issues zod (`parsed.error.issues`), pour que l'appelant sache
//! quoi corriger — voir [`Issues::to_json`].
//!
//! Les paramètres d'URL arrivent comme `Object.fromEntries(searchParams)` : une
//! table nom → valeur (la dernière occurrence l'emporte), cf. [`query_map`].
use super::gammes::GAMMES;
use super::store::{GridSortKey, GRID_SORT_KEYS};
use super::zod::{self, child, Issues, Seg};
use serde_json::{Map, Value};
use std::collections::HashMap;
/// `Object.fromEntries(req.nextUrl.searchParams)`.
pub fn query_map(pairs: Vec<(String, String)>) -> HashMap<String, String> {
pairs.into_iter().collect()
}
fn p(k: &str) -> Vec<Seg> {
vec![Seg::from(k)]
}
/// `z.string().min(1[, message]).optional()` sur un paramètre d'URL.
fn opt_non_vide(q: &HashMap<String, String>, k: &str, message: Option<&str>, issues: &mut Issues) -> Option<String> {
let v = q.get(k)?;
zod::check_len(v, &p(k), Some((1, message)), None, issues).then(|| v.clone())
}
/// `z.string().min(1, message)` obligatoire sur un paramètre d'URL.
fn requis(q: &HashMap<String, String>, k: &str, message: &str, issues: &mut Issues) -> Option<String> {
match q.get(k) {
None => {
issues.invalid_type(&p(k), "string", None);
None
}
Some(v) => zod::check_len(v, &p(k), Some((1, Some(message))), None, issues).then(|| v.clone()),
}
}
/// `z.enum([...]).default(d)` / `.optional()` sur un paramètre d'URL.
fn enum_param(q: &HashMap<String, String>, k: &str, values: &[&str], defaut: Option<&str>, issues: &mut Issues) -> Option<String> {
match q.get(k) {
None => defaut.map(str::to_string),
Some(v) if values.contains(&v.as_str()) => Some(v.clone()),
Some(_) => {
issues.invalid_value(&p(k), values, None);
None
}
}
}
/// `z.coerce.number().int().min(1)` avec défaut éventuel.
fn int_param(q: &HashMap<String, String>, k: &str, defaut: Option<i64>, max: Option<f64>, issues: &mut Issues) -> Option<i64> {
match q.get(k) {
None => defaut,
Some(v) => zod::coerce_int(v, &p(k), 1.0, max, issues),
}
}
/// Message du tri inconnu (liste des clés acceptées).
pub fn message_tri_inconnu() -> String {
format!("Tri inconnu. Valeurs acceptées : {}", GRID_SORT_KEYS.join(", "))
}
/// Paramètres de tri et de projection communs (`sortShape`).
#[derive(Debug, Clone, PartialEq)]
pub struct SortParams {
pub sort: Option<String>,
/// `"asc"` | `"desc"` (défaut `"desc"`).
pub order: String,
/// Liste de champs de ProductRow à conserver, séparés par des virgules.
pub fields: Option<String>,
/// `"1"` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment
/// de l'appel. `"0"` : sert l'instantané brut, un peu plus rapide.
pub enrich: String,
}
fn sort_shape(q: &HashMap<String, String>, issues: &mut Issues) -> SortParams {
let sort = q.get("sort").and_then(|v| {
if GRID_SORT_KEYS.contains(&v.as_str()) {
Some(v.clone())
} else {
issues.custom(&p("sort"), &message_tri_inconnu());
None
}
});
let order = enum_param(q, "order", &["asc", "desc"], Some("desc"), issues).unwrap_or_default();
let fields = q.get("fields").cloned();
let enrich = enum_param(q, "enrich", &["0", "1"], Some("1"), issues).unwrap_or_default();
SortParams { sort, order, fields, enrich }
}
/// `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search).
#[derive(Debug, Clone, PartialEq)]
pub struct GridQueryParams {
pub fournisseur: String,
pub gamme: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
pub code3: Option<String>,
/// Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par
/// /api/v1/nomenclatures. À préférer à code1/code2.
pub nomenclature: Option<String>,
pub search: Option<String>,
/// `"1"` (défaut) : calcule l'instantané à la demande s'il n'existe pas encore ;
/// `"0"` : comportement strict, échoue vite en `not_ready`.
pub compute: String,
pub sort: SortParams,
pub page: i64,
/// **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid`
/// renvoie *toutes* les lignes du fournisseur.
pub limit: Option<i64>,
}
const MESSAGE_NOMENCLATURE: &str = "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)";
/// `gridQuerySchema.safeParse(...)`.
pub fn parse_grid_query(q: &HashMap<String, String>) -> Result<GridQueryParams, Issues> {
let mut issues = Issues::default();
let fournisseur = requis(q, "fournisseur", "Paramètre 'fournisseur' requis", &mut issues);
let gamme = opt_non_vide(q, "gamme", None, &mut issues);
let code1 = opt_non_vide(q, "code1", None, &mut issues);
let code2 = opt_non_vide(q, "code2", None, &mut issues);
let code3 = opt_non_vide(q, "code3", None, &mut issues);
let nomenclature = q.get("nomenclature").and_then(|v| {
let chemin = p("nomenclature");
let mut ok = zod::check_len(v, &chemin, Some((1, None)), Some((20, None)), &mut issues);
if v.is_empty() || !v.bytes().all(|b| b.is_ascii_digit()) {
issues.invalid_regex(&chemin, "/^[0-9]+$/", MESSAGE_NOMENCLATURE);
ok = false;
}
ok.then(|| v.clone())
});
let search = opt_non_vide(q, "search", None, &mut issues);
let compute = enum_param(q, "compute", &["0", "1"], Some("1"), &mut issues).unwrap_or_default();
let sort = sort_shape(q, &mut issues);
let page = int_param(q, "page", Some(1), None, &mut issues);
let limit = int_param(q, "limit", None, None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(GridQueryParams {
fournisseur: fournisseur.unwrap_or_default(),
gamme,
code1,
code2,
code3,
nomenclature,
search,
compute,
sort,
page: page.unwrap_or(1),
limit,
})
}
/// `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale.
#[derive(Debug, Clone, PartialEq)]
pub struct ProductSearchParams {
pub q: String,
pub fournisseur: Option<String>,
pub gamme: Option<String>,
pub sort: SortParams,
pub page: i64,
/// Défaut 100, sans plafond.
pub limit: i64,
}
/// `productSearchSchema.safeParse(...)`.
pub fn parse_product_search(q: &HashMap<String, String>) -> Result<ProductSearchParams, Issues> {
let mut issues = Issues::default();
let texte = match q.get("q") {
None => {
issues.invalid_type(&p("q"), "string", None);
None
}
Some(v) => zod::check_len(v, &p("q"), Some((1, Some("Paramètre 'q' requis"))), Some((120, None)), &mut issues).then(|| v.clone()),
};
let fournisseur = opt_non_vide(q, "fournisseur", None, &mut issues);
let gamme = opt_non_vide(q, "gamme", None, &mut issues);
let sort = sort_shape(q, &mut issues);
let page = int_param(q, "page", Some(1), None, &mut issues);
let limit = int_param(q, "limit", Some(100), None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductSearchParams { q: texte.unwrap_or_default(), fournisseur, gamme, sort, page: page.unwrap_or(1), limit: limit.unwrap_or(100) })
}
#[derive(Debug, Clone, PartialEq)]
pub struct FournisseursQueryParams {
pub search: Option<String>,
/// `"1"` = uniquement les fournisseurs déjà présents dans l'instantané de grille.
pub with_data: Option<String>,
}
/// `fournisseursQuerySchema.safeParse(...)`.
pub fn parse_fournisseurs_query(q: &HashMap<String, String>) -> Result<FournisseursQueryParams, Issues> {
let mut issues = Issues::default();
let search = opt_non_vide(q, "search", None, &mut issues);
let with_data = enum_param(q, "withData", &["0", "1"], None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(FournisseursQueryParams { search, with_data })
}
/// `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur.
#[derive(Debug, Clone, PartialEq)]
pub struct NomenclaturesQueryParams {
pub fournisseur: String,
/// 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF).
pub niveau: u8,
/// Restreint à un poste parent (préfixe).
pub parent: Option<String>,
}
/// `nomenclaturesQuerySchema.safeParse(...)`.
pub fn parse_nomenclatures_query(q: &HashMap<String, String>) -> Result<NomenclaturesQueryParams, Issues> {
let mut issues = Issues::default();
let fournisseur = requis(q, "fournisseur", "Paramètre 'fournisseur' requis", &mut issues);
let niveau = int_param(q, "niveau", Some(1), Some(3.0), &mut issues);
let parent = opt_non_vide(q, "parent", None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(NomenclaturesQueryParams { fournisseur: fournisseur.unwrap_or_default(), niveau: niveau.unwrap_or(1).clamp(1, 3) as u8, parent })
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProductDetailParams {
pub fournisseur: Option<String>,
/// `"1"` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas
/// encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur.
pub compute: String,
}
/// `productDetailSchema.safeParse(...)`.
pub fn parse_product_detail(q: &HashMap<String, String>) -> Result<ProductDetailParams, Issues> {
let mut issues = Issues::default();
let fournisseur = opt_non_vide(q, "fournisseur", None, &mut issues);
let compute = enum_param(q, "compute", &["0", "1"], Some("1"), &mut issues).unwrap_or_default();
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductDetailParams { fournisseur, compute })
}
/// Message des gammes inconnues (`gammeShape`).
pub fn message_gamme_inconnue() -> String {
let codes: Vec<&str> = GAMMES.iter().map(|g| g.code).collect();
format!("Gamme inconnue. Valeurs acceptées : {}", codes.join(", "))
}
fn gamme_shape(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option<String> {
let codes: Vec<&str> = GAMMES.iter().map(|g| g.code).collect();
zod::enum_value(v, &codes, chemin, Some(&message_gamme_inconnue()), issues)
}
/// `z.boolean().default(true)`.
fn bool_defaut_vrai(o: &Map<String, Value>, k: &str, issues: &mut Issues) -> Option<bool> {
match o.get(k) {
None => Some(true),
v => zod::boolean(v, &p(k), issues),
}
}
/// `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article.
#[derive(Debug, Clone, PartialEq)]
pub struct ProductGammeBody {
pub gamme: String,
/// Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs.
pub fournisseur: Option<String>,
/// `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore.
pub compute: bool,
}
/// `productGammeBodySchema.safeParse(corps)`.
pub fn parse_product_gamme_body(corps: &Value) -> Result<ProductGammeBody, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(corps), &[], &mut issues) else { return Err(issues) };
let gamme = gamme_shape(o.get("gamme"), &p("gamme"), &mut issues);
let fournisseur = match o.get("fournisseur") {
None => Some(None),
v => zod::string(v, &p("fournisseur"), &mut issues)
.and_then(|s| zod::check_len(&s, &p("fournisseur"), Some((1, None)), None, &mut issues).then_some(Some(s))),
};
let compute = bool_defaut_vrai(o, "compute", &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductGammeBody { gamme: gamme.unwrap_or_default(), fournisseur: fournisseur.flatten(), compute: compute.unwrap_or(true) })
}
/// Un changement de `POST /api/v1/gammes`.
#[derive(Debug, Clone, PartialEq)]
pub struct GammeChange {
pub codein: String,
pub gamme: String,
}
/// `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur.
#[derive(Debug, Clone, PartialEq)]
pub struct GammesBatchBody {
pub fournisseur: String,
pub changes: Vec<GammeChange>,
pub compute: bool,
}
/// `gammesBatchBodySchema.safeParse(corps)`.
pub fn parse_gammes_batch_body(corps: &Value) -> Result<GammesBatchBody, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(corps), &[], &mut issues) else { return Err(issues) };
let fournisseur = zod::string(o.get("fournisseur"), &p("fournisseur"), &mut issues)
.and_then(|s| zod::check_len(&s, &p("fournisseur"), Some((1, Some("Champ 'fournisseur' requis"))), None, &mut issues).then_some(s));
let chemin = p("changes");
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues)
.and_then(|s| zod::check_len(&s, &child(&ci, "codein"), Some((1, None)), None, &mut issues).then_some(s));
let gamme = gamme_shape(c.get("gamme"), &child(&ci, "gamme"), &mut issues);
if let (Some(codein), Some(gamme)) = (codein, gamme) {
changes.push(GammeChange { codein, gamme });
}
}
if a.is_empty() {
issues.too_small(&chemin, "array", 1.0, Some("Au moins un changement attendu"));
}
if a.len() > 10_000 {
issues.too_big(&chemin, "array", 10_000.0, Some("10 000 changements au maximum par appel"));
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
let compute = bool_defaut_vrai(o, "compute", &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(GammesBatchBody { fournisseur: fournisseur.unwrap_or_default(), changes, compute: compute.unwrap_or(true) })
}
/// Projection de champs : `?fields=codein,libelle1,totalCa`.
///
/// Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles
/// et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de
/// lignes. `codein` est toujours conservé pour que la ligne reste identifiable.
pub fn pick_fields(row: Value, fields: Option<&str>) -> Value {
let Some(fields) = fields.filter(|f| !f.is_empty()) else { return row };
let mut wanted: Vec<&str> = Vec::new();
for f in fields.split(',').map(super::js::js_trim).filter(|f| !f.is_empty()) {
if !wanted.contains(&f) {
wanted.push(f);
}
}
if wanted.is_empty() {
return row;
}
if !wanted.contains(&"codein") {
wanted.push("codein");
}
let Value::Object(source) = row else { return row };
let mut out = Map::new();
for key in wanted {
if let Some(v) = source.get(key) {
out.insert(key.to_string(), v.clone());
}
}
Value::Object(out)
}
/// Normalise le tri validé vers la clé attendue par grid-store.
pub fn to_sort_key(sort: Option<&str>) -> Option<GridSortKey> {
sort.and_then(GridSortKey::from_key)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn q(pairs: &[(&str, &str)]) -> HashMap<String, String> {
query_map(pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect())
}
fn issues_json(i: Issues) -> String {
serde_json::to_string(&i.to_json()).unwrap()
}
#[test]
fn grid_query_comme_zod() {
let err = parse_grid_query(&q(&[
("fournisseur", "x"),
("nomenclature", ""),
("sort", "bad"),
("order", "up"),
("enrich", "2"),
("compute", "x"),
("page", "0"),
("limit", "-1"),
]))
.unwrap_err();
assert_eq!(
issues_json(err),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["nomenclature"],"message":"Too small: expected string to have >=1 characters"},{"origin":"string","code":"invalid_format","format":"regex","pattern":"/^[0-9]+$/","path":["nomenclature"],"message":"La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"},{"code":"invalid_value","values":["0","1"],"path":["compute"],"message":"Invalid option: expected one of \"0\"|\"1\""},{"code":"custom","path":["sort"],"message":"Tri inconnu. Valeurs acceptées : libelle1, codein, codeFournisseur, codeGamme, totalCa, totalQuantite, totalMarge, tauxMarge, stockActuel, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, computedAt"},{"code":"invalid_value","values":["asc","desc"],"path":["order"],"message":"Invalid option: expected one of \"asc\"|\"desc\""},{"code":"invalid_value","values":["0","1"],"path":["enrich"],"message":"Invalid option: expected one of \"0\"|\"1\""},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["page"],"message":"Too small: expected number to be >=1"},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["limit"],"message":"Too small: expected number to be >=1"}]"#
);
let err = parse_grid_query(&q(&[("fournisseur", "x"), ("nomenclature", "123456789012345678901a")])).unwrap_err();
assert_eq!(
issues_json(err),
r#"[{"origin":"string","code":"too_big","maximum":20,"inclusive":true,"path":["nomenclature"],"message":"Too big: expected string to have <=20 characters"},{"origin":"string","code":"invalid_format","format":"regex","pattern":"/^[0-9]+$/","path":["nomenclature"],"message":"La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"}]"#
);
let ok = parse_grid_query(&q(&[("fournisseur", "x")])).unwrap();
assert_eq!((ok.compute.as_str(), ok.sort.order.as_str(), ok.sort.enrich.as_str(), ok.page, ok.limit), ("1", "desc", "1", 1, None));
let ok = parse_grid_query(&q(&[("fournisseur", "x"), ("page", "2"), ("limit", "50"), ("sort", "codein"), ("fields", "")])).unwrap();
assert_eq!((ok.page, ok.limit, ok.sort.sort.as_deref(), ok.sort.fields.as_deref()), (2, Some(50), Some("codein"), Some("")));
}
#[test]
fn autres_schemas_comme_zod() {
assert_eq!(
issues_json(parse_nomenclatures_query(&q(&[])).unwrap_err()),
r#"[{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received undefined"}]"#
);
assert_eq!(
issues_json(parse_nomenclatures_query(&q(&[("fournisseur", "x"), ("niveau", "4"), ("parent", "")])).unwrap_err()),
r#"[{"origin":"number","code":"too_big","maximum":3,"inclusive":true,"path":["niveau"],"message":"Too big: expected number to be <=3"},{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["parent"],"message":"Too small: expected string to have >=1 characters"}]"#
);
assert_eq!(parse_nomenclatures_query(&q(&[("fournisseur", "x"), ("niveau", "2.0")])).unwrap().niveau, 2);
assert_eq!(
issues_json(parse_fournisseurs_query(&q(&[("withData", "2"), ("search", "")])).unwrap_err()),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["search"],"message":"Too small: expected string to have >=1 characters"},{"code":"invalid_value","values":["0","1"],"path":["withData"],"message":"Invalid option: expected one of \"0\"|\"1\""}]"#
);
assert_eq!(
issues_json(parse_product_search(&q(&[("q", "x"), ("page", "1.5"), ("limit", "0")])).unwrap_err()),
r#"[{"expected":"int","format":"safeint","code":"invalid_type","path":["page"],"message":"Invalid input: expected int, received number"},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["limit"],"message":"Too small: expected number to be >=1"}]"#
);
assert_eq!(parse_product_search(&q(&[("q", "x")])).unwrap().limit, 100);
assert_eq!(
issues_json(parse_product_detail(&q(&[("compute", "2")])).unwrap_err()),
r#"[{"code":"invalid_value","values":["0","1"],"path":["compute"],"message":"Invalid option: expected one of \"0\"|\"1\""}]"#
);
}
#[test]
fn corps_comme_zod() {
assert_eq!(
issues_json(parse_product_gamme_body(&json!({ "gamme": "Q", "compute": "yes", "fournisseur": 3 })).unwrap_err()),
r#"[{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received number"},{"expected":"boolean","code":"invalid_type","path":["compute"],"message":"Invalid input: expected boolean, received string"}]"#
);
assert_eq!(
issues_json(parse_product_gamme_body(&json!([])).unwrap_err()),
r#"[{"expected":"object","code":"invalid_type","path":[],"message":"Invalid input: expected object, received array"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "fournisseur": "", "changes": [] })).unwrap_err()),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["fournisseur"],"message":"Champ 'fournisseur' requis"},{"origin":"array","code":"too_small","minimum":1,"inclusive":true,"path":["changes"],"message":"Au moins un changement attendu"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "changes": [{ "codein": "", "gamme": "Q" }, { "gamme": 1 }, 5] })).unwrap_err()),
r#"[{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received undefined"},{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["changes",0,"codein"],"message":"Too small: expected string to have >=1 characters"},{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["changes",0,"gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"string","code":"invalid_type","path":["changes",1,"codein"],"message":"Invalid input: expected string, received undefined"},{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["changes",1,"gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"object","code":"invalid_type","path":["changes",2],"message":"Invalid input: expected object, received number"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "fournisseur": "f", "changes": "x", "compute": null })).unwrap_err()),
r#"[{"expected":"array","code":"invalid_type","path":["changes"],"message":"Invalid input: expected array, received string"},{"expected":"boolean","code":"invalid_type","path":["compute"],"message":"Invalid input: expected boolean, received null"}]"#
);
let ok = parse_gammes_batch_body(&json!({ "fournisseur": "f", "changes": [{ "codein": "1", "gamme": "A" }] })).unwrap();
assert!(ok.compute);
assert_eq!(ok.changes, vec![GammeChange { codein: "1".into(), gamme: "A".into() }]);
}
#[test]
fn projection_de_champs() {
let row = json!({ "codein": "1", "libelle1": "L", "totalCa": 3, "gtin": "G" });
assert_eq!(pick_fields(row.clone(), None), row);
assert_eq!(pick_fields(row.clone(), Some(" , ")), row);
let p = pick_fields(row, Some("totalCa, libelle1,absent"));
assert_eq!(serde_json::to_string(&p).unwrap(), r#"{"totalCa":3,"libelle1":"L","codein":"1"}"#);
assert_eq!(to_sort_key(Some("caReseau")), Some(GridSortKey::CaReseau));
assert_eq!(to_sort_key(Some("x")), None);
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Routes `/api/v1` du module qui ne dépendent que de l'instantané `grid_rows`
//! (phase 1) : `GET /api/v1/nomenclatures`.
use super::api_schemas::{parse_nomenclatures_query, query_map};
use super::store::{get_grid_freshness, list_grid_nomenclatures};
use crate::api_v1_response::{fail, ok, ApiErrorCode};
use crate::auth::ApiAuth;
use axum::extract::Query;
use axum::response::Response;
use serde_json::{json, Map, Value};
/// `GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…`
///
/// Répartition des articles d'un fournisseur par poste de nomenclature, avec le
/// nombre d'articles et le CA de chacun.
///
/// C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent
/// 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni
/// pour une application. On liste d'abord les postes — quelques dizaines de
/// lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste.
///
/// Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
/// niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte
/// un préfixe pour descendre l'arborescence sans tout charger.
pub async fn get_nomenclatures(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let params = query_map(params);
let parsed = match parse_nomenclatures_query(&params) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let fournisseur = parsed.fournisseur;
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
let freshness = match get_grid_freshness(&fournisseur).await {
Ok(f) => f,
Err(e) => return fail(ApiErrorCode::InternalError, e.message, None),
};
let Some(freshness) = freshness else {
return fail(
ApiErrorCode::NotReady,
format!(
"Aucun instantané pour le fournisseur « {fournisseur} ». Appelez /api/v1/grid?fournisseur={fournisseur} une fois pour le calculer."
),
Some(json!({ "fournisseur": fournisseur })),
);
};
let postes = match list_grid_nomenclatures(&fournisseur, parsed.niveau, parsed.parent.as_deref()).await {
Ok(p) => p,
Err(e) => return fail(ApiErrorCode::InternalError, e.message, None),
};
let nb_articles: i64 = postes.iter().map(|p| p.nb_articles).sum();
let mut meta = Map::new();
meta.insert("fournisseur".into(), json!(fournisseur));
meta.insert("niveau".into(), json!(parsed.niveau));
meta.insert("parent".into(), parsed.parent.map(Value::String).unwrap_or(Value::Null));
meta.insert("nbPostes".into(), json!(postes.len()));
// Total couvert par les postes listés — à rapprocher de snapshotRowCount.
meta.insert("nbArticles".into(), json!(nb_articles));
meta.insert("snapshotRowCount".into(), json!(freshness.row_count));
meta.insert("snapshotComputedAt".into(), json!(freshness.computed_at));
// Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus.
// `nomenclature` filtre par préfixe et fonctionne aux trois niveaux.
meta.insert("filtreGrid".into(), json!("nomenclature"));
ok(postes, None, Some(meta))
}
+551
View File
@@ -0,0 +1,551 @@
//! Routes `/api/v1` adossées au calcul de la grille (phase 2) : `/grid`,
//! `/products/search`, `/products/{codein}`, `/products/{codein}/gamme`,
//! `/gammes`, `/fournisseurs`, `/network/{codeCentrale}`.
use super::api_gammes::{affecter_gammes, lire_corps_json};
use super::api_schemas::{
parse_fournisseurs_query, parse_gammes_batch_body, parse_grid_query, parse_product_detail, parse_product_gamme_body,
parse_product_search, pick_fields, query_map, to_sort_key, GridQueryParams,
};
use super::enrich::enrich_rows;
use super::product_rows::{attendre_persistance, get_product_rows, GetProductRowsInput};
use super::store::{
get_grid_freshness, get_grid_row_by_codein, list_grid_suppliers, list_grid_suppliers_for_codein, query_grid_rows, GridFreshness,
GridQuery, SortOrder,
};
use super::types::ProductRow;
use crate::api_v1_response::{build_pagination, fail, ok, ApiErrorCode};
use crate::auth::ApiAuth;
use crate::error::AppError;
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use futures::channel::mpsc;
use futures::SinkExt;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
fn interne(e: AppError) -> Response {
fail(ApiErrorCode::InternalError, e.message, None)
}
fn meta(entrees: Vec<(&str, Value)>) -> Option<Map<String, Value>> {
Some(entrees.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
/// Lignes prêtes à sérialiser : enrichies (`enrich=1`) puis projetées (`fields`).
async fn lignes_json(rows: Vec<ProductRow>, enrichir: bool, fields: Option<&str>) -> Vec<Value> {
let valeurs: Vec<Value> = if enrichir {
enrich_rows(rows).await.into_iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect()
} else {
rows.into_iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect()
};
valeurs.into_iter().map(|v| pick_fields(v, fields)).collect()
}
fn ordre(order: &str) -> Option<SortOrder> {
Some(if order == "asc" { SortOrder::Asc } else { SortOrder::Desc })
}
/// `GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=`
///
/// Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
/// l'instantané persisté (`grid_rows`).
///
/// Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
/// (`compute=1`, défaut) ; `compute=0` restaure le comportement strict : échec
/// immédiat en `202 not_ready`.
pub async fn get_grid(ApiAuth(auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let q = match parse_grid_query(&query_map(params)) {
Ok(q) => q,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
let mut freshness = match get_grid_freshness(&q.fournisseur).await {
Ok(f) => f,
Err(e) => return interne(e),
};
let mut computed_on_demand = false;
if freshness.is_none() {
if q.compute == "0" {
return fail(
ApiErrorCode::NotReady,
format!(
"Aucun instantané pour le fournisseur « {} ». Relancez sans « compute=0 » pour le calculer à la demande.",
q.fournisseur
),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
tracing::info!("[api/v1/grid] instantané absent pour {} — calcul à la demande (via {})", q.fournisseur, auth.via);
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
// et ProductRow embarque déjà les ventilations par magasin.
match get_product_rows(GetProductRowsInput::total(&q.fournisseur, false)).await {
// La persistance est lancée en arrière-plan par le calcul : on attend
// qu'elle soit écrite avant de relire l'instantané.
Ok(_) => attendre_persistance(&q.fournisseur).await,
Err(e) => {
tracing::error!("[api/v1/grid] calcul à la demande KO pour {}: {e:#}", q.fournisseur);
return fail(
ApiErrorCode::InternalError,
format!("Le calcul de la grille a échoué pour le fournisseur « {} ».", q.fournisseur),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
}
freshness = match get_grid_freshness(&q.fournisseur).await {
Ok(f) => f,
Err(e) => return interne(e),
};
if freshness.is_none() {
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
// il n'a aucun article. À distinguer d'une panne.
return fail(
ApiErrorCode::NotFound,
format!("Aucun article pour le fournisseur « {} ». Vérifiez le code auprès de /api/v1/fournisseurs.", q.fournisseur),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
computed_on_demand = true;
}
let freshness = freshness.unwrap_or(GridFreshness { row_count: 0, computed_at: None });
let filtres = GridQuery {
code_fournisseur: Some(q.fournisseur.clone()),
gamme: q.gamme.clone(),
code1: q.code1.clone(),
code2: q.code2.clone(),
code3: q.code3.clone(),
nomenclature: q.nomenclature.clone(),
search: q.search.clone(),
sort: to_sort_key(q.sort.sort.as_deref()),
order: ordre(&q.sort.order),
page: None,
limit: None,
};
// Sans `limit`, la réponse peut porter tout un fournisseur — et certains en
// comptent plus de 130 000. On diffuse par lots, à mémoire constante.
let Some(limit) = q.limit else {
return stream_all_rows(filtres, q, freshness, computed_on_demand);
};
let result = match query_grid_rows(&GridQuery { page: Some(q.page), limit: Some(limit), ..filtres }).await {
Ok(r) => r,
Err(e) => return interne(e),
};
let enrichi = q.sort.enrich == "1";
let nb = result.rows.len();
let rows = lignes_json(result.rows, enrichi, q.sort.fields.as_deref()).await;
let pagination = build_pagination(q.page, limit, result.total);
let has_more = pagination.has_more;
let mut m = meta(vec![
("fournisseur", json!(q.fournisseur)),
("computedAt", json!(result.computed_at)),
("snapshotComputedAt", json!(freshness.computed_at)),
("snapshotRowCount", json!(freshness.row_count)),
("enrichi", json!(enrichi)),
// true = l'instantané n'existait pas et vient d'être calculé par cet appel.
("computedOnDemand", json!(computed_on_demand)),
// true = cette réponse contient **toutes** les lignes du fournisseur.
("complet", json!(!has_more)),
])
.unwrap_or_default();
if has_more {
m.insert(
"avertissement".into(),
json!(format!(
"Réponse partielle : {nb} lignes sur {}, parce que « limit » a été précisé. Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page={}.",
result.total,
q.page + 1
)),
);
}
ok(rows, Some(pagination), Some(m))
}
/// Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue.
const STREAM_BATCH: i64 = 1000;
/// Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans
/// jamais la construire entièrement en mémoire.
///
/// La forme reste celle des autres réponses (`{ data, pagination, meta }`) ;
/// `pagination` et `meta` sont écrits en dernier, une fois le total connu. Une
/// erreur survenant après les premiers octets ne peut plus changer le code HTTP :
/// on ferme alors le document avec `meta.erreur`.
fn stream_all_rows(filtres: GridQuery, q: GridQueryParams, freshness: GridFreshness, computed_on_demand: bool) -> Response {
let (mut tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(4);
tokio::spawn(async move {
let mut envoyees: i64 = 0;
let mut total: i64 = 0;
let mut computed_at: Option<String> = None;
let mut erreur: Option<String> = None;
let enrichi = q.sort.enrich == "1";
if tx.send(Ok(Bytes::from_static(b"{\"data\":["))).await.is_err() {
return;
}
let mut page = 1;
loop {
let lot = match query_grid_rows(&GridQuery { page: Some(page), limit: Some(STREAM_BATCH), ..filtres.clone() }).await {
Ok(l) => l,
Err(e) => {
erreur = Some(e.message);
break;
}
};
if page == 1 {
total = lot.total;
computed_at = lot.computed_at.clone();
}
let n = lot.rows.len() as i64;
if n == 0 {
break;
}
let mut morceau = Vec::new();
for r in lignes_json(lot.rows, enrichi, q.sort.fields.as_deref()).await {
if envoyees > 0 {
morceau.push(b',');
}
morceau.extend(serde_json::to_vec(&r).unwrap_or_default());
envoyees += 1;
}
if tx.send(Ok(Bytes::from(morceau))).await.is_err() {
return;
}
if n < STREAM_BATCH {
break;
}
page += 1;
}
if let Some(e) = &erreur {
tracing::error!("[api/v1/grid] diffusion interrompue pour {} après {envoyees} lignes: {e}", q.fournisseur);
}
let complet = erreur.is_none() && envoyees == total;
let mut meta = json!({
"fournisseur": q.fournisseur,
"computedAt": computed_at,
"snapshotComputedAt": freshness.computed_at,
"snapshotRowCount": freshness.row_count,
"enrichi": enrichi,
"computedOnDemand": computed_on_demand,
"diffuse": true,
"complet": complet,
});
if let Some(e) = erreur {
meta["erreur"] = json!(format!("Diffusion interrompue après {envoyees} lignes sur {total} : {e}"));
}
let fin = json!({
"pagination": { "page": 1, "limit": envoyees, "total": total, "totalPages": 1, "hasMore": !complet },
"meta": meta,
});
let texte = serde_json::to_string(&fin).unwrap_or_default();
let _ = tx.send(Ok(Bytes::from(format!("],{}", &texte[1..])))).await;
});
(
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
// Empêche un proxy de tamponner la réponse entière avant de la relayer.
(header::HeaderName::from_static("x-accel-buffering"), "no"),
],
Body::from_stream(rx),
)
.into_response()
}
/// `GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=`
///
/// **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
/// tous fournisseurs confondus. Lit uniquement l'instantané `grid_rows`.
pub async fn get_products_search(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let q = match parse_product_search(&query_map(params)) {
Ok(q) => q,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let result = match query_grid_rows(&GridQuery {
code_fournisseur: q.fournisseur.clone(),
gamme: q.gamme.clone(),
search: Some(q.q.clone()),
sort: to_sort_key(q.sort.sort.as_deref()),
order: ordre(&q.sort.order),
page: Some(q.page),
limit: Some(q.limit),
..Default::default()
})
.await
{
Ok(r) => r,
Err(e) => return interne(e),
};
let enrichi = q.sort.enrich == "1";
let total = result.total;
let computed_at = result.computed_at.clone();
let rows = lignes_json(result.rows, enrichi, q.sort.fields.as_deref()).await;
ok(
rows,
Some(build_pagination(q.page, q.limit, total)),
meta(vec![
("query", json!(q.q)),
("computedAt", json!(computed_at)),
("scope", json!(q.fournisseur.as_ref().map(|f| format!("fournisseur:{f}")).unwrap_or_else(|| "tous fournisseurs".into()))),
("enrichi", json!(enrichi)),
]),
)
}
/// `GET /api/v1/products/{codein}?fournisseur=…`
///
/// Fiche complète d'un produit, enrichie des métriques réseau Qlik lues **en cache**.
/// `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
/// sans lui, l'instantané le plus récent est renvoyé.
pub async fn get_product(ApiAuth(_auth): ApiAuth, Path(codein): Path<String>, Query(params): Query<Vec<(String, String)>>) -> Response {
if codein.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codein' requis.", None);
}
let parsed = match parse_product_detail(&query_map(params)) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let mut found = match get_grid_row_by_codein(&codein, parsed.fournisseur.as_deref()).await {
Ok(f) => f,
Err(e) => return interne(e),
};
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
if found.is_none() && parsed.compute == "1" {
if let Some(f) = parsed.fournisseur.as_deref() {
tracing::info!("[api/v1/products] {codein} absent — calcul du lot {f} à la demande");
let calcul = async {
get_product_rows(GetProductRowsInput::total(f, false)).await?;
attendre_persistance(f).await;
get_grid_row_by_codein(&codein, Some(f)).await.map_err(|e| anyhow::anyhow!(e.message))
};
match calcul.await {
Ok(r) => found = r,
Err(e) => tracing::error!("[api/v1/products] calcul KO pour {f}: {e:#}"),
}
}
}
let Some(found) = found else {
return fail(
ApiErrorCode::NotFound,
match &parsed.fournisseur {
Some(f) => format!("Produit « {codein} » introuvable chez le fournisseur « {f} »."),
None => format!(
"Aucun instantané pour le produit « {codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande."
),
},
None,
);
};
// Métriques réseau + gamme serveur relues maintenant : le payload date du dernier
// calcul de la grille, alors que ces deux données évoluent indépendamment.
let Some(enriched) = enrich_rows(vec![found.row]).await.into_iter().next() else {
return fail(ApiErrorCode::InternalError, "Enrichissement impossible.", None);
};
let network_fetched_at = enriched.network.as_ref().and_then(|n| n.fetched_at.clone());
let has_network = enriched.network.is_some();
ok(
enriched,
None,
meta(vec![
("computedAt", json!(found.computed_at)),
("networkFetchedAt", json!(network_fetched_at)),
("hasNetwork", json!(has_network)),
]),
)
}
fn maintenant_iso() -> String {
crate::db::iso_utc(chrono::Utc::now())
}
/// `PUT /api/v1/products/{codein}/gamme` `{ "gamme": "A", "fournisseur"?: "…" }`
///
/// Propose une gamme pour un article. La proposition reste **à valider** dans la
/// Grille. `fournisseur` n'est requis que si l'article est référencé chez plusieurs
/// fournisseurs, ou s'il n'apparaît pas encore dans l'instantané.
pub async fn put_product_gamme(ApiAuth(auth): ApiAuth, Path(codein): Path<String>, body: Bytes) -> Response {
if codein.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codein' requis.", None);
}
let corps = match lire_corps_json(&body) {
Ok(c) => c,
Err(r) => return r,
};
let parsed = match parse_product_gamme_body(&corps) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Corps de requête invalide.", Some(issues.to_json())),
};
let fournisseur = match parsed.fournisseur {
Some(f) => f,
None => {
let fournisseurs = match list_grid_suppliers_for_codein(&codein).await {
Ok(f) => f,
Err(e) => return interne(e),
};
if fournisseurs.is_empty() {
return fail(
ApiErrorCode::NotFound,
format!(
"Aucun instantané pour le produit « {codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande."
),
None,
);
}
if fournisseurs.len() > 1 {
return fail(
ApiErrorCode::BadRequest,
format!("Le produit « {codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur »."),
Some(json!({ "fournisseurs": fournisseurs })),
);
}
fournisseurs[0].code_fournisseur.clone()
}
};
let res = match affecter_gammes(&fournisseur, &[(codein, parsed.gamme)], parsed.compute, &auth.subject).await {
Ok(r) => r,
Err(r) => return r,
};
let mut data = match res.resultats.first().map(serde_json::to_value) {
Some(Ok(Value::Object(m))) => m,
_ => Map::new(),
};
data.insert("codeFournisseur".into(), json!(res.code_fournisseur));
data.insert("nomFournisseur".into(), json!(res.nom_fournisseur));
ok(Value::Object(data), None, meta(vec![("enregistreLe", json!(maintenant_iso()))]))
}
/// `POST /api/v1/gammes` `{ "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] }`
///
/// Propose la gamme de plusieurs articles d'un fournisseur en un appel, à valider
/// dans la Grille. Tout ou rien : un article inconnu chez le fournisseur fait
/// échouer l'appel (404).
pub async fn post_gammes(ApiAuth(auth): ApiAuth, body: Bytes) -> Response {
let corps = match lire_corps_json(&body) {
Ok(c) => c,
Err(r) => return r,
};
let parsed = match parse_gammes_batch_body(&corps) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Corps de requête invalide.", Some(issues.to_json())),
};
let changes: Vec<(String, String)> = parsed.changes.into_iter().map(|c| (c.codein, c.gamme)).collect();
let res = match affecter_gammes(&parsed.fournisseur, &changes, parsed.compute, &auth.subject).await {
Ok(r) => r,
Err(r) => return r,
};
let demandes = res.resultats.len();
let modifies = res.resultats.iter().filter(|r| r.modifie).count();
let a_valider = res.resultats.iter().filter(|r| r.statut == "a_valider").count();
ok(
&res.resultats,
None,
meta(vec![
("codeFournisseur", json!(res.code_fournisseur)),
("nomFournisseur", json!(res.nom_fournisseur)),
("demandes", json!(demandes)),
("modifies", json!(modifies)),
("aValider", json!(a_valider)),
("enregistreLe", json!(maintenant_iso())),
]),
)
}
/// `GET /api/v1/fournisseurs?search=…&withData=1`
///
/// Liste des fournisseurs : tout le référentiel (`fouident`), enrichi de la
/// fraîcheur de l'instantané de grille quand il existe. `withData=1` restreint aux
/// seuls fournisseurs déjà présents dans l'instantané.
pub async fn get_fournisseurs(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let parsed = match parse_fournisseurs_query(&query_map(params)) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let snapshot = match list_grid_suppliers().await {
Ok(s) => s,
Err(e) => return interne(e),
};
if parsed.with_data.as_deref() == Some("1") {
let term = parsed.search.as_ref().map(|s| s.to_lowercase());
let data: Vec<Value> = snapshot
.iter()
.filter(|s| match &term {
None => true,
Some(t) => {
s.code_fournisseur.to_lowercase().contains(t.as_str())
|| s.nom_fournisseur.as_deref().unwrap_or("").to_lowercase().contains(t.as_str())
}
})
.map(|s| {
json!({ "code": s.code_fournisseur, "nom": s.nom_fournisseur, "hasSnapshot": true, "rowCount": s.row_count, "computedAt": s.computed_at })
})
.collect();
let total = data.len();
return ok(data, None, meta(vec![("total", json!(total)), ("scope", json!("instantané uniquement"))]));
}
let by_code: HashMap<&str, &super::store::GridSupplier> = snapshot.iter().map(|s| (s.code_fournisseur.as_str(), s)).collect();
let fournisseurs = crate::ff::pg::pg_get_fournisseurs(parsed.search.as_deref()).await;
let data: Vec<Value> = fournisseurs
.iter()
.map(|f| {
let snap = by_code.get(f.code.as_str());
json!({
"code": f.code,
"nom": f.nom,
"hasSnapshot": snap.is_some(),
"rowCount": snap.map(|s| s.row_count).unwrap_or(0),
"computedAt": snap.and_then(|s| s.computed_at.clone()),
})
})
.collect();
let total = data.len();
ok(data, None, meta(vec![("total", json!(total)), ("withSnapshot", json!(by_code.len()))]))
}
/// `GET /api/v1/network/{codeCentrale}`
///
/// Métriques réseau Qlik d'un produit et sa courbe de quantités mensuelles.
/// Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik.
pub async fn get_network(ApiAuth(_auth): ApiAuth, Path(code_centrale): Path<String>) -> Response {
if code_centrale.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codeCentrale' requis.", None);
}
let metrics = match crate::qlik::network_cache::get_network_metrics_by_code_centrale(std::slice::from_ref(&code_centrale)).await {
Ok(m) => m,
Err(e) => return fail(ApiErrorCode::InternalError, format!("{e:#}"), None),
};
let Some(found) = metrics.get(&code_centrale) else {
return fail(
ApiErrorCode::NotFound,
format!(
"Aucune donnée réseau en cache pour le code centrale « {code_centrale} ». Lancez une synchronisation Qlik ou une recherche réseau."
),
None,
);
};
let mut data = Map::new();
data.insert("codeCentrale".into(), json!(code_centrale));
if let Ok(Value::Object(m)) = serde_json::to_value(found) {
data.extend(m);
}
ok(Value::Object(data), None, meta(vec![("fetchedAt", json!(found.fetched_at)), ("source", json!("cache"))]))
}
+293
View File
@@ -0,0 +1,293 @@
//! Enregistrement des gammes (port de `src/features/grid/api/enregistrer-gammes.ts`,
//! `save-draft-changes.ts` et `gammes-a-valider-actions.ts`).
use super::gammes_a_valider::{lister_gammes_a_valider, supprimer_gammes_a_valider};
use super::session::{final_user_id, verifier_session};
use super::snapshots::date_fr;
use super::zod::{self, child, Issues, Seg};
use super::{rows_cache, store};
use crate::db;
use crate::error::AppResult;
use crate::rpc::Ctx;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sqlx::Row;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangementGamme {
pub codein: String,
/// Gamme avant la modification (état serveur), conservée pour l'historique.
pub code_gamme_before: Option<String>,
pub code_gamme: String,
}
#[derive(Debug, Clone, Default)]
pub struct EnregistrementGammes {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Vec<ChangementGamme>,
pub user_id: Option<i64>,
/// Libellé du snapshot, « Draft — date » par défaut.
pub label: Option<String>,
}
/// Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1.
///
/// Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur,
/// puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`.
/// Lève une erreur si l'écriture du snapshot échoue.
pub async fn enregistrer_gammes(input: &EnregistrementGammes) -> AppResult<usize> {
let EnregistrementGammes { code_fournisseur, nom_fournisseur, magasin, changes, user_id, label } = input;
if changes.is_empty() {
return Ok(0);
}
let pool = db::pool()?;
// Charger le dernier snapshot existant pour merger les changements
let existing = sqlx::query("select changes from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(code_fournisseur)
.fetch_optional(&pool)
.await?;
let prev_changes: Map<String, Value> = match existing.map(|r| r.try_get::<Value, _>("changes")) {
Some(Ok(Value::Object(m))) => m,
_ => Map::new(),
};
// Merger les nouveaux changements par-dessus l'existant
let mut merged_changes = prev_changes;
for c in changes {
merged_changes.insert(c.codein.clone(), json!({ "before": c.code_gamme_before, "after": c.code_gamme }));
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
sqlx::query(
"insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \
values ($1, $2, $3, $4, $5, $6, null, 'snapshot')",
)
.bind(user_id)
.bind(code_fournisseur)
.bind(nom_fournisseur)
.bind(magasin)
.bind(label.clone().unwrap_or_else(|| format!("Draft — {}", date_fr())))
.bind(Value::Object(merged_changes))
.execute(&pool)
.await?;
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
rows_cache::patch_grid_rows_cache(code_fournisseur, changes.iter().map(|c| (c.codein.as_str(), c.code_gamme.as_str())));
let code = code_fournisseur.clone();
let paires: Vec<(String, String)> = changes.iter().map(|c| (c.codein.clone(), c.code_gamme.clone())).collect();
tokio::spawn(async move {
if let Err(e) = store::update_grid_rows_gamme(&code, paires.iter().map(|(c, g)| (c.as_str(), g.as_str()))).await {
tracing::error!("[enregistrerGammes] mise à jour grid_rows KO: {}", crate::error::short(&e.message, 200));
}
});
Ok(changes.len())
}
// ---------------------------------------------------------------------------
// saveDraftChanges
// ---------------------------------------------------------------------------
/// Données validées de `SaveDraftsSchema`.
#[derive(Debug, Clone)]
pub struct SaveDraftsInput {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Vec<ChangementGamme>,
/// Propositions de l'API examinées dans la Grille : validées (présentes dans
/// `changes`) ou écartées. Elles quittent la file « à valider ».
pub gammes_a_valider_traitees: Vec<String>,
}
/// `z.array(z.string())`.
fn tableau_de_chaines(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option<Vec<String>> {
let Some(Value::Array(a)) = v else {
issues.invalid_type(chemin, "array", v);
return None;
};
let mut out = Vec::with_capacity(a.len());
let mut ok = true;
for (i, x) in a.iter().enumerate() {
match zod::string(Some(x), &child(chemin, i), issues) {
Some(s) => out.push(s),
None => ok = false,
}
}
ok.then_some(out)
}
/// `SaveDraftsSchema.safeParse(raw)`.
pub fn parse_save_drafts(raw: &Value) -> Result<SaveDraftsInput, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) };
let p = |k: &str| vec![Seg::from(k)];
let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues);
let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues);
let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues);
let chemin = p("changes");
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues);
let before = zod::nullable_string(c.get("codeGammeBefore"), &child(&ci, "codeGammeBefore"), &mut issues);
let gamme = zod::string(c.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues);
if let (Some(codein), Some(code_gamme_before), Some(code_gamme)) = (codein, before, gamme) {
changes.push(ChangementGamme { codein, code_gamme_before, code_gamme });
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
let traitees = match o.get("gammesAValiderTraitees") {
None => Some(Vec::new()),
v => tableau_de_chaines(v, &p("gammesAValiderTraitees"), &mut issues),
};
if !issues.is_empty() {
return Err(issues);
}
Ok(SaveDraftsInput {
code_fournisseur: code_fournisseur.unwrap_or_default(),
nom_fournisseur: nom_fournisseur.flatten(),
magasin: magasin.unwrap_or_default(),
changes,
gammes_a_valider_traitees: traitees.unwrap_or_default(),
})
}
/// `saveDraftChanges` une fois la session vérifiée : `{ success, saved, error? }`.
pub async fn enregistrer_brouillon(user_raw_id: &str, raw: &Value) -> Value {
let d = match parse_save_drafts(raw) {
Ok(d) => d,
Err(issues) => return json!({ "success": false, "saved": 0, "error": format!("Validation failed: {}", issues.message()) }),
};
let user_id = final_user_id(user_raw_id).map(|v| v as i64);
let res: AppResult<usize> = async {
let saved = enregistrer_gammes(&EnregistrementGammes {
code_fournisseur: d.code_fournisseur.clone(),
nom_fournisseur: d.nom_fournisseur.clone(),
magasin: d.magasin.clone(),
changes: d.changes.clone(),
user_id,
label: None,
})
.await?;
supprimer_gammes_a_valider(&d.code_fournisseur, &d.gammes_a_valider_traitees).await?;
Ok(saved)
}
.await;
match res {
Ok(saved) => json!({ "success": true, "saved": saved }),
Err(e) => json!({ "success": false, "saved": 0, "error": e.message }),
}
}
/// Action `saveDraftChanges(raw)`.
async fn save_draft_changes(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(json!({ "success": false, "saved": 0, "error": message })),
};
Ok(enregistrer_brouillon(&utilisateur.id, &ctx.raw(0)).await)
}
// ---------------------------------------------------------------------------
// Gammes à valider (actions)
// ---------------------------------------------------------------------------
/// Action `getGammesAValider(codeFournisseur)` : gammes proposées par l'API pour
/// un fournisseur, en attente de validation.
async fn get_gammes_a_valider(ctx: Ctx) -> AppResult<Value> {
if let Err(message) = verifier_session(&ctx).await? {
return Ok(json!({ "success": false, "propositions": [], "error": message }));
}
let code = super::js::js_string(ctx.args.first());
match lister_gammes_a_valider(&code).await {
Ok(propositions) => Ok(json!({ "success": true, "propositions": propositions })),
Err(e) => Ok(json!({ "success": false, "propositions": [], "error": e.message })),
}
}
/// `RejetSchema.safeParse(raw)` → `(codeFournisseur, codeins)`.
pub fn parse_rejet(raw: &Value) -> Result<(String, Vec<String>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) };
let chemin = vec![Seg::from("codeFournisseur")];
let code = zod::string(o.get("codeFournisseur"), &chemin, &mut issues);
if let Some(c) = &code {
zod::check_len(c, &chemin, Some((1, None)), None, &mut issues);
}
let codeins = tableau_de_chaines(o.get("codeins"), &[Seg::from("codeins")], &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok((code.unwrap_or_default(), codeins.unwrap_or_default()))
}
/// Action `rejeterGammesAValider(raw)` : rejette des propositions de l'API
/// (« Annuler les modifications » dans la Grille).
async fn rejeter_gammes_a_valider(ctx: Ctx) -> AppResult<Value> {
if let Err(message) = verifier_session(&ctx).await? {
return Ok(json!({ "success": false, "error": message }));
}
let (code, codeins) = match parse_rejet(&ctx.raw(0)) {
Ok(v) => v,
Err(issues) => return Ok(json!({ "success": false, "error": format!("Validation failed: {}", issues.message()) })),
};
match supprimer_gammes_a_valider(&code, &codeins).await {
Ok(()) => Ok(json!({ "success": true })),
Err(e) => Ok(json!({ "success": false, "error": e.message })),
}
}
pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> {
vec![
("saveDraftChanges", crate::handler!(save_draft_changes)),
("getGammesAValider", crate::handler!(get_gammes_a_valider)),
("rejeterGammesAValider", crate::handler!(rejeter_gammes_a_valider)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_brouillon_comme_zod() {
let raw = json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a" }], "gammesAValiderTraitees": "x" });
let issues = parse_save_drafts(&raw).unwrap_err();
assert_eq!(
serde_json::to_string(&issues.to_json()).unwrap(),
r#"[{"expected":"string","code":"invalid_type","path":["changes",0,"codeGammeBefore"],"message":"Invalid input: expected string, received undefined"},{"expected":"string","code":"invalid_type","path":["changes",0,"codeGamme"],"message":"Invalid input: expected string, received undefined"},{"expected":"array","code":"invalid_type","path":["gammesAValiderTraitees"],"message":"Invalid input: expected array, received string"}]"#
);
let ok = parse_save_drafts(
&json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a", "codeGammeBefore": null, "codeGamme": "B" }] }),
)
.unwrap();
assert_eq!(ok.changes[0].code_gamme, "B");
assert!(ok.gammes_a_valider_traitees.is_empty());
}
#[test]
fn validation_rejet_comme_zod() {
let issues = parse_rejet(&json!({ "codeFournisseur": "", "codeins": [1] })).unwrap_err();
assert_eq!(
issues.message(),
"[\n {\n \"origin\": \"string\",\n \"code\": \"too_small\",\n \"minimum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"codeFournisseur\"\n ],\n \"message\": \"Too small: expected string to have >=1 characters\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"codeins\",\n 0\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]"
);
assert_eq!(parse_rejet(&json!(5)).unwrap_err().0.len(), 1);
}
}
+148
View File
@@ -0,0 +1,148 @@
//! CollectFlow — Enrichissement des lignes servies par `/api/v1`
//! (port de `src/lib/api-enrich.ts`).
//!
//! L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations
//! doivent pourtant refléter l'état **courant** au moment de l'appel :
//!
//! 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par
//! les synchronisations et les recherches réseau, indépendamment du calcul de la
//! grille. On les relit donc à la lecture, et on les expose telles quelles quand
//! elles existent (`network: null` sinon).
//!
//! 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session.
//! L'API expose la gamme **non modifiée**, celle réellement présente en base,
//! relue à chaque appel.
//!
//! Ce n'est pas un recalcul : des lectures indexées bornées au nombre de lignes.
use super::gammes_a_valider::gammes_a_valider_par_article;
use super::months;
use super::network_trend::{compute_network_trend, trend_label, Direction};
use super::types::ProductRow;
use crate::ff::pg::pg_get_gammes_by_codeins;
use crate::qlik::network_cache::{get_network_metrics_by_code_centrale, NetworkMetricCached};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
/// Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductTrend {
pub direction: Direction,
/// Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base.
#[serde(serialize_with = "super::js::opt_num::serialize")]
pub pct: Option<f64>,
/// Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »…
pub label: &'static str,
/// `true` = tendance calculée sur la quantité **par magasin vendeur**.
pub sur_qte_par_magasin: bool,
/// Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers.
pub nouveau: bool,
/// Nombre de mois de la série (12 quand elle est complète).
pub months_used: usize,
}
/// Ligne enrichie : la ligne, puis la gamme serveur, la proposition en attente,
/// les métriques réseau et la tendance (ordre des clés du TS).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnrichedProductRow {
#[serde(flatten)]
pub row: ProductRow,
/// Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée.
pub code_gamme_serveur: Option<String>,
/// Gamme proposée par l'API et pas encore validée dans la Grille.
pub gamme_a_valider: Option<String>,
/// Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas.
pub network: Option<NetworkMetricCached>,
/// Tendance réseau sur les 12 derniers mois, `null` sans série mensuelle.
pub trend: Option<ProductTrend>,
}
/// Enrichit un lot de lignes. Les lectures sont indépendantes et lancées en
/// parallèle ; un échec de l'une n'empêche pas les autres (l'API reste servie, avec
/// la valeur de l'instantané en repli).
pub async fn enrich_rows(rows: Vec<ProductRow>) -> Vec<EnrichedProductRow> {
if rows.is_empty() {
return vec![];
}
let mut vus = BTreeSet::new();
let codes_centraux: Vec<String> =
rows.iter().filter_map(|r| r.code_centrale.clone()).filter(|c| !c.is_empty() && vus.insert(c.clone())).collect();
let mut vus = BTreeSet::new();
let codeins: Vec<String> = rows.iter().map(|r| r.codein.clone()).filter(|c| !c.is_empty() && vus.insert(c.clone())).collect();
let (network, gammes, a_valider) = tokio::join!(
async {
if codes_centraux.is_empty() {
return HashMap::new();
}
get_network_metrics_by_code_centrale(&codes_centraux).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] métriques réseau KO: {}", crate::error::short(&e, 160));
HashMap::new()
})
},
async {
if codeins.is_empty() {
return HashMap::new();
}
pg_get_gammes_by_codeins(&codeins).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] gammes serveur KO: {}", crate::error::short(&e, 160));
HashMap::new()
})
},
async {
gammes_a_valider_par_article(&codeins).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] gammes à valider KO: {}", crate::error::short(&e.message, 160));
HashMap::new()
})
},
);
let aujourd_hui = months::today();
rows.into_iter()
.map(|mut row| {
let metrics = row.code_centrale.as_deref().filter(|c| !c.is_empty()).and_then(|c| network.get(c)).cloned();
// Gamme absente de la base = pas de gamme, et non « inconnue ».
let gamme_serveur = gammes.get(&row.codein).cloned();
let gamme_a_valider = a_valider.get(&format!("{}|{}", row.code_fournisseur, row.codein)).cloned();
// `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille :
// on la garde alignée pour que les deux champs ne divergent jamais.
if !gammes.is_empty() {
row.code_gamme_init = gamme_serveur.clone();
}
// Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le
// cache quand il a quelque chose à dire.
if let Some(m) = &metrics {
row.ca_reseau = Some(m.ca_reseau);
row.qte_reseau = Some(m.qte_reseau);
row.nb_magasins_reseau = Some(m.nb_magasins_reseau);
row.ca_par_magasin_reseau = Some(m.ca_par_magasin_reseau);
row.marge_pct_reseau = Some(m.marge_pct_reseau);
row.qte_reseau_by_month = Some(m.qte_by_month.clone());
row.network_fetched_at = m.fetched_at.clone();
}
// Tendance : mêmes entrées que la colonne « Tendance » de la Grille — la
// série des quantités **et** celle du nombre de magasins vendeurs.
let serie = row.qte_reseau_by_month.clone().flatten();
let nb_mag_by_month: Option<BTreeMap<String, f64>> = metrics
.as_ref()
.and_then(|m| m.metrics_by_month.as_ref())
.map(|d| d.iter().map(|(mois, v)| (mois.clone(), v.nb_mag.filter(|n| !n.is_nan()).unwrap_or(0.0))).collect());
let t = compute_network_trend(serie.as_ref(), nb_mag_by_month.as_ref(), aujourd_hui);
let trend = t.has_data.then(|| ProductTrend {
direction: t.direction,
pct: t.pct,
label: trend_label(t.pct, t.nouveau),
sur_qte_par_magasin: t.sur_qte_par_magasin,
nouveau: t.nouveau,
months_used: t.values.len(),
});
EnrichedProductRow { row, code_gamme_serveur: gamme_serveur, gamme_a_valider, network: metrics, trend }
})
.collect()
}
+402
View File
@@ -0,0 +1,402 @@
//! Exports Excel de la Grille (port de `src/app/api/export/excel/route.ts` et
//! `src/app/api/export/modified-gammes/route.ts`, ExcelJS → rust_xlsxwriter).
//!
//! Mêmes feuilles, colonnes, largeurs, styles d'en-tête, formats et noms de
//! fichier. Le découpage du fichier d'import des gammes en lots de 5 000 lignes
//! (`LIGNES_MAX_PAR_FICHIER_GAMMES`) se fait côté interface, qui appelle
//! `/api/export/modified-gammes` une fois par lot : la route ne fait que mettre en
//! forme les lignes reçues.
use super::js;
use super::zod::{self, child, Issues, Seg};
use crate::auth::SessionUser;
use crate::error::{AppError, AppResult};
use axum::body::Bytes;
use axum::http::{header, HeaderValue};
use axum::response::{IntoResponse, Response};
use axum::Json;
use rust_xlsxwriter::{Color, Format, FormatAlign, FormatBorder, FormatPattern, Workbook, Worksheet, XlsxError};
use serde_json::{json, Value};
const CONTENT_TYPE_XLSX: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/// Largeur ExcelJS (`width: 14`) : valeur écrite telle quelle dans `<col width>`.
/// rust_xlsxwriter ajoute la marge de cellule à une largeur en caractères ; en
/// pixels (7 px par caractère), il écrit exactement `width`.
fn largeur(ws: &mut Worksheet, col: u16, width: u32) -> Result<(), XlsxError> {
ws.set_column_width_pixels(col, width * 7)?;
Ok(())
}
/// Style d'en-tête commun aux deux exports.
fn style_entete() -> Format {
Format::new()
.set_bold()
.set_font_color(Color::RGB(0xFFFFFF))
.set_pattern(FormatPattern::Solid)
.set_background_color(Color::RGB(0x0F172A))
.set_align(FormatAlign::Center)
.set_border_bottom(FormatBorder::Thin)
.set_border_bottom_color(Color::RGB(0x334155))
}
fn xlsx_err(e: XlsxError) -> AppError {
AppError::internal(e.to_string())
}
/// `await req.json()` : un corps illisible lève une exception (500 côté Next).
fn lire_json(body: &Bytes) -> AppResult<Value> {
serde_json::from_slice(body).map_err(|e| AppError::internal(format!("Corps JSON illisible : {e}")))
}
fn payload_invalide() -> Response {
(axum::http::StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid payload" }))).into_response()
}
/// `${prefixe}_${nomFournisseur.replace(/\s+/g, "_")}_${YYYY-MM-DD}.xlsx` (date UTC,
/// comme `toISOString().slice(0, 10)`).
pub fn nom_fichier(prefixe: &str, nom_fournisseur: &str) -> String {
format!("{prefixe}_{}_{}.xlsx", js::replace_whitespace_runs(nom_fournisseur, "_"), chrono::Utc::now().format("%Y-%m-%d"))
}
/// `attachment; filename="…"`. Comme Node, l'en-tête est écrit octet par octet en
/// Latin-1 : un caractère au-delà de U+00FF (ou un caractère de contrôle) est
/// refusé, ce qui faisait échouer la route Next en 500.
pub fn content_disposition(filename: &str) -> AppResult<HeaderValue> {
let mut octets = Vec::with_capacity(filename.len() + 32);
octets.extend_from_slice(b"attachment; filename=\"");
for c in filename.chars() {
let v = c as u32;
if v > 0xFF || (v < 0x20 && v != 0x09) || v == 0x7F {
return Err(AppError::internal(format!("Invalid character in header content [\"Content-Disposition\"] : {c:?}")));
}
octets.push(v as u8);
}
octets.push(b'"');
HeaderValue::from_bytes(&octets).map_err(|e| AppError::internal(e.to_string()))
}
fn reponse_xlsx(buffer: Vec<u8>, filename: &str) -> AppResult<Response> {
let disposition = content_disposition(filename)?;
Ok(([(header::CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_XLSX)), (header::CONTENT_DISPOSITION, disposition)], buffer)
.into_response())
}
// ---------------------------------------------------------------------------
// POST /api/export/excel
// ---------------------------------------------------------------------------
/// Ligne de `ExportSchema`.
#[derive(Debug, Clone, PartialEq)]
pub struct ExportRow {
pub codein: String,
pub libelle1: String,
pub code3: String,
pub libelle3: String,
pub code_gamme: Option<String>,
pub total_quantite: f64,
pub total_ca: f64,
pub taux_marge: f64,
/// Before gamme for delta
pub gamme_avant: Option<String>,
}
/// `ExportSchema.safeParse(body)` → `(nomFournisseur, rows)`.
pub fn parse_export(body: &Value) -> Result<(String, Vec<ExportRow>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) };
let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues);
let chemin = vec![Seg::from("rows")];
let mut rows = Vec::new();
match o.get("rows") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue };
let s = |k: &str, issues: &mut Issues| zod::string(r.get(k), &child(&ci, k), issues);
let codein = s("codein", &mut issues);
let libelle1 = s("libelle1", &mut issues);
let code3 = s("code3", &mut issues);
let libelle3 = s("libelle3", &mut issues);
let code_gamme = zod::nullable_string(r.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues);
let n = |k: &str, issues: &mut Issues| zod::number(r.get(k), &child(&ci, k), issues);
let total_quantite = n("totalQuantite", &mut issues);
let total_ca = n("totalCa", &mut issues);
let taux_marge = n("tauxMarge", &mut issues);
let gamme_avant = match r.get("gammeAvant") {
None => Some(None),
v => zod::nullable_string(v, &child(&ci, "gammeAvant"), &mut issues),
};
if let (
Some(codein),
Some(libelle1),
Some(code3),
Some(libelle3),
Some(code_gamme),
Some(total_quantite),
Some(total_ca),
Some(taux_marge),
Some(gamme_avant),
) = (codein, libelle1, code3, libelle3, code_gamme, total_quantite, total_ca, taux_marge, gamme_avant)
{
rows.push(ExportRow {
codein,
libelle1,
code3,
libelle3,
code_gamme,
total_quantite,
total_ca,
taux_marge,
gamme_avant,
});
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
if !issues.is_empty() {
return Err(issues);
}
Ok((nom.unwrap_or_default(), rows))
}
/// Gamme colours
fn couleur_gamme(code: &str) -> Option<u32> {
match code {
"A" => Some(0x064E3B),
"B" => Some(0x1E3A5F),
"C" => Some(0x78350F),
"Z" => Some(0x4C0519),
_ => None,
}
}
/// Classeur « Assortiment » (tous les produits, gamme avant/après, chiffres 12 mois).
pub fn classeur_assortiment(rows: &[ExportRow]) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow"));
let sheet = workbook.add_worksheet();
sheet.set_name("Assortiment")?;
sheet.set_freeze_panes(1, 0)?;
let entete = style_entete();
let colonnes: [(&str, u32); 9] = [
("Code", 14),
("Désignation", 40),
("Nomenclature", 25),
("Gamme Avant", 14),
("Gamme Après", 14),
("Delta", 10),
("Vol. 12m", 12),
("CA 12m (€)", 14),
("Marge (%)", 12),
];
for (i, (titre, w)) in colonnes.iter().enumerate() {
largeur(sheet, i as u16, *w)?;
sheet.write_string_with_format(0, i as u16, *titre, &entete)?;
}
sheet.set_row_height(0, 22)?;
let fmt_ca = Format::new().set_num_format("#,##0.00 \"€\"");
let fmt_marge = Format::new().set_num_format("0.00\"%\"");
let fmt_delta = Format::new().set_bold().set_font_color(Color::RGB(0xFBBF24));
for (i, row) in rows.iter().enumerate() {
let r = (i + 1) as u32;
let delta = if row.gamme_avant != row.code_gamme {
format!("{} → {}", row.gamme_avant.as_deref().unwrap_or("—"), row.code_gamme.as_deref().unwrap_or("—"))
} else {
String::new()
};
sheet.write_string(r, 0, &row.codein)?;
sheet.write_string(r, 1, &row.libelle1)?;
sheet.write_string(r, 2, &row.libelle3)?;
sheet.write_string(r, 3, row.gamme_avant.as_deref().unwrap_or("—"))?;
// Colour the "Gamme Après" cell
let gamme_apres = row.code_gamme.as_deref().unwrap_or("—");
match row.code_gamme.as_deref().and_then(couleur_gamme) {
Some(c) => {
let f = Format::new()
.set_pattern(FormatPattern::Solid)
.set_background_color(Color::RGB(c))
.set_bold()
.set_font_color(Color::RGB(0xFFFFFF));
sheet.write_string_with_format(r, 4, gamme_apres, &f)?;
}
None => {
sheet.write_string(r, 4, gamme_apres)?;
}
}
// Highlight changed rows
if delta.is_empty() {
sheet.write_string(r, 5, &delta)?;
} else {
sheet.write_string_with_format(r, 5, &delta, &fmt_delta)?;
}
sheet.write_number(r, 6, js::js_round(row.total_quantite))?;
// Format numbers
sheet.write_number_with_format(r, 7, js::js_to_fixed2(row.total_ca), &fmt_ca)?;
sheet.write_number_with_format(r, 8, js::js_to_fixed2(row.taux_marge), &fmt_marge)?;
}
workbook.save_to_buffer()
}
/// `POST /api/export/excel`.
pub async fn export_excel(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult<Response> {
let body = lire_json(&body)?;
let Ok((nom_fournisseur, rows)) = parse_export(&body) else { return Ok(payload_invalide()) };
let buffer = classeur_assortiment(&rows).map_err(xlsx_err)?;
reponse_xlsx(buffer, &nom_fichier("Assortiment", &nom_fournisseur))
}
// ---------------------------------------------------------------------------
// POST /api/export/modified-gammes
// ---------------------------------------------------------------------------
/// Ligne de `ExportModifiedGammesSchema`.
#[derive(Debug, Clone, PartialEq)]
pub struct GammeModifiee {
pub codein: String,
pub gtin: String,
pub code_fournisseur: Option<String>,
pub gamme: String,
}
/// `ExportModifiedGammesSchema.safeParse(body)` → `(nomFournisseur, changes)`.
pub fn parse_modified_gammes(body: &Value) -> Result<(String, Vec<GammeModifiee>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) };
let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues);
let chemin = vec![Seg::from("changes")];
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(r.get("codein"), &child(&ci, "codein"), &mut issues);
let gtin = zod::string(r.get("gtin"), &child(&ci, "gtin"), &mut issues);
let code_fournisseur = zod::opt_string(r.get("codeFournisseur"), &child(&ci, "codeFournisseur"), &mut issues);
let gamme = zod::string(r.get("gamme"), &child(&ci, "gamme"), &mut issues);
if let (Some(codein), Some(gtin), Some(code_fournisseur), Some(gamme)) = (codein, gtin, code_fournisseur, gamme) {
changes.push(GammeModifiee { codein, gtin, code_fournisseur, gamme });
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
if !issues.is_empty() {
return Err(issues);
}
Ok((nom.unwrap_or_default(), changes))
}
/// `x || "—"`.
fn ou_tiret(s: &str) -> &str {
if s.is_empty() {
"—"
} else {
s
}
}
/// Classeur « Gammes Modifiées » (CODE FOURNISSEUR, CODEIN, GAMME) : le fichier
/// d'import des gammes.
pub fn classeur_gammes_modifiees(changes: &[GammeModifiee]) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow"));
let sheet = workbook.add_worksheet();
sheet.set_name("Gammes Modifiées")?;
sheet.set_freeze_panes(1, 0)?;
// Format en texte pour eviter la notation scientifique excel ; centrer la
// colonne Gamme. ExcelJS applique ces styles de colonne à toutes les cellules
// déjà écrites, en-tête compris. « @ » = format intégré n° 49, comme ExcelJS.
let texte = Format::new().set_num_format_index(49);
let centre = Format::new().set_align(FormatAlign::Center);
let entete = style_entete();
let entete_texte = style_entete().set_num_format_index(49);
largeur(sheet, 0, 22)?;
largeur(sheet, 1, 18)?;
largeur(sheet, 2, 12)?;
sheet.set_column_format(0, &texte)?;
sheet.set_column_format(1, &texte)?;
sheet.set_column_format(2, &centre)?;
sheet.write_string_with_format(0, 0, "CODE FOURNISSEUR", &entete_texte)?;
sheet.write_string_with_format(0, 1, "CODEIN", &entete_texte)?;
sheet.write_string_with_format(0, 2, "GAMME", &entete)?;
sheet.set_row_height(0, 22)?;
for (i, row) in changes.iter().enumerate() {
let r = (i + 1) as u32;
sheet.write_string_with_format(r, 0, ou_tiret(row.code_fournisseur.as_deref().unwrap_or("")), &texte)?;
sheet.write_string_with_format(r, 1, ou_tiret(&row.codein), &texte)?;
sheet.write_string_with_format(r, 2, ou_tiret(&row.gamme), &centre)?;
}
workbook.save_to_buffer()
}
/// `POST /api/export/modified-gammes`.
pub async fn export_modified_gammes(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult<Response> {
let body = lire_json(&body)?;
let Ok((nom_fournisseur, changes)) = parse_modified_gammes(&body) else { return Ok(payload_invalide()) };
let buffer = classeur_gammes_modifiees(&changes).map_err(xlsx_err)?;
reponse_xlsx(buffer, &nom_fichier("Modifications_Gammes", &nom_fournisseur))
}
#[cfg(test)]
mod tests {
use super::*;
/// Écrit les deux classeurs d'exemple dans `GRID_EXPORT_DIR` (pour inspection).
#[test]
fn classeurs_d_exemple() {
let (_, rows) = parse_export(&json!({
"nomFournisseur": "Le Fournisseur",
"rows": [
{ "codein": "123456", "libelle1": "Bougie parfumée", "code3": "320211", "libelle3": "Bougies", "codeGamme": "A", "totalQuantite": 12.5, "totalCa": 1234.125, "tauxMarge": 41.2345, "gammeAvant": "B" },
{ "codein": "000789", "libelle1": "Vase", "code3": "320212", "libelle3": "Vases", "codeGamme": null, "totalQuantite": -2.5, "totalCa": 10, "tauxMarge": 0, "gammeAvant": null },
{ "codein": "42", "libelle1": "", "code3": "", "libelle3": "", "codeGamme": "Y", "totalQuantite": 3, "totalCa": 0.125, "tauxMarge": 1.005, "gammeAvant": "Y" }
]
}))
.unwrap();
let a = classeur_assortiment(&rows).unwrap();
let (_, changes) = parse_modified_gammes(&json!({
"nomFournisseur": "X",
"changes": [
{ "codein": "000123", "gtin": "3700", "codeFournisseur": "F001", "gamme": "A" },
{ "codein": "", "gtin": "", "gamme": "" }
]
}))
.unwrap();
let b = classeur_gammes_modifiees(&changes).unwrap();
assert!(a.starts_with(b"PK") && b.starts_with(b"PK"));
if let Ok(dir) = std::env::var("GRID_EXPORT_DIR") {
std::fs::write(format!("{dir}/rust_excel.xlsx"), a).unwrap();
std::fs::write(format!("{dir}/rust_modified.xlsx"), b).unwrap();
}
}
#[test]
fn validation_et_entetes() {
assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [{ "codein": "a" }] })).is_err());
assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [] })).is_ok());
assert!(parse_modified_gammes(
&json!({ "nomFournisseur": "x", "changes": [{ "codein": "a", "gtin": "g", "gamme": "A", "codeFournisseur": 3 }] })
)
.is_err());
let nom = nom_fichier("Assortiment", "Le Fournisseur\tÉté");
assert!(nom.starts_with("Assortiment_Le_Fournisseur_Été_") && nom.ends_with(".xlsx"));
let h = content_disposition("Assortiment_Été.xlsx").unwrap();
assert_eq!(h.as_bytes(), b"attachment; filename=\"Assortiment_\xc9t\xe9.xlsx\"");
assert!(content_disposition("Fournisseur_œ.xlsx").is_err());
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Les gammes d'assortiment : code, nom et couleurs — source unique
//! (port de `src/lib/gammes.ts`, utilisé par la validation `/api/v1` et le
//! document OpenAPI).
pub struct Gamme {
pub code: &'static str,
/// Nom court : « Cœur », « Sortie »…
pub nom: &'static str,
/// Phrase d'explication pour les infobulles.
pub description: &'static str,
/// Classes de pastille (fond, bordure, texte) lisibles en clair comme en sombre.
pub classes: &'static str,
}
pub const GAMMES: [Gamme; 5] = [
Gamme {
code: "A",
nom: "Cœur",
description: "Produit permanent, au cœur de l'assortiment.",
classes: "border-emerald-500/40 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
},
Gamme {
code: "B",
nom: "Complémentaire",
description: "Produit qui complète l'offre du rayon.",
classes: "border-blue-500/40 bg-blue-500/10 text-blue-800 dark:text-blue-300",
},
Gamme {
code: "C",
nom: "Saisonnier",
description: "Produit présent selon la saison.",
classes: "border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-300",
},
Gamme {
code: "Y",
nom: "En veille",
description: "Produit mis en attente : il n'est plus commandé pour l'instant.",
classes: "border-violet-500/40 bg-violet-500/10 text-violet-800 dark:text-violet-300",
},
Gamme {
code: "Z",
nom: "Sortie",
description: "Produit à retirer de l'assortiment.",
classes: "border-rose-500/40 bg-rose-500/10 text-rose-800 dark:text-rose-300",
},
];
/// Pastille d'un produit sans gamme.
pub const CLASSES_SANS_GAMME: &str = "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300";
/// Codes de gamme, dans l'ordre de [`GAMMES`].
pub fn codes_gamme() -> Vec<&'static str> {
GAMMES.iter().map(|g| g.code).collect()
}
pub fn trouver_gamme(code: Option<&str>) -> Option<&'static Gamme> {
let code = code.filter(|c| !c.is_empty())?;
let c = super::js::js_trim(code);
GAMMES.iter().find(|g| g.code == c)
}
#[cfg(test)]
mod tests {
#[test]
fn coherent_avec_le_socle() {
assert_eq!(super::codes_gamme(), crate::domain::CODES_GAMME.to_vec());
assert_eq!(super::trouver_gamme(Some(" Z ")).unwrap().nom, "Sortie");
assert!(super::trouver_gamme(None).is_none());
}
}
+129
View File
@@ -0,0 +1,129 @@
//! CollectFlow — Gammes proposées par l'API, en attente de validation
//! (port de `src/lib/gammes-a-valider.ts`).
//!
//! L'API `/api/v1` ne change plus une gamme directement : elle dépose une
//! proposition, que la Grille affiche comme une modification non enregistrée.
//! L'utilisateur voit ainsi quels produits changent de gamme, puis valide
//! (« Enregistrer ») ou rejette (« Annuler les modifications »).
use crate::db;
use crate::error::AppResult;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder, Row};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PropositionGamme {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de la proposition.
pub gamme_precedente: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GammeAValider {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de la proposition.
pub gamme_precedente: Option<String>,
pub auteur: Option<String>,
/// Date ISO de la proposition.
pub proposee_le: String,
}
/// Dépose des propositions (la dernière l'emporte pour un même article) et retire
/// celles des articles `annules` — redemander la gamme déjà enregistrée annule la
/// proposition en attente.
pub async fn proposer_gammes(code_fournisseur: &str, propositions: &[PropositionGamme], annules: &[String], auteur: &str) -> AppResult<()> {
let pool = db::pool()?;
let mut tx = pool.begin().await?;
if !annules.is_empty() {
sqlx::query("delete from gammes_a_valider where code_fournisseur = $1 and codein = any($2)")
.bind(code_fournisseur)
.bind(annules)
.execute(&mut *tx)
.await?;
}
if !propositions.is_empty() {
let maintenant = chrono::Utc::now().naive_utc();
let auteur: String = auteur.chars().take(100).collect();
let mut qb: QueryBuilder<Postgres> =
QueryBuilder::new("insert into gammes_a_valider (code_fournisseur, codein, gamme, gamme_precedente, auteur, created_at) ");
qb.push_values(propositions, |mut b, p| {
b.push_bind(code_fournisseur.to_string())
.push_bind(p.codein.clone())
.push_bind(p.gamme.clone())
.push_bind(p.gamme_precedente.clone())
.push_bind(auteur.clone())
.push_bind(maintenant);
});
qb.push(
" on conflict (code_fournisseur, codein) do update set gamme = excluded.gamme, \
gamme_precedente = excluded.gamme_precedente, auteur = excluded.auteur, created_at = excluded.created_at",
);
qb.build().execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(())
}
/// Propositions en attente pour un fournisseur.
pub async fn lister_gammes_a_valider(code_fournisseur: &str) -> AppResult<Vec<GammeAValider>> {
let pool = db::pool()?;
let rows = sqlx::query(
"select code_fournisseur, codein, gamme, gamme_precedente, auteur, created_at from gammes_a_valider \
where code_fournisseur = $1",
)
.bind(code_fournisseur)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| GammeAValider {
codein: r.try_get("codein").unwrap_or_default(),
gamme: r.try_get("gamme").unwrap_or_default(),
gamme_precedente: r.try_get("gamme_precedente").ok().flatten(),
auteur: r.try_get("auteur").ok().flatten(),
proposee_le: r.try_get::<NaiveDateTime, _>("created_at").map(db::iso).unwrap_or_default(),
})
.collect())
}
/// Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`.
pub async fn gammes_a_valider_par_article(codeins: &[String]) -> AppResult<HashMap<String, String>> {
if codeins.is_empty() {
return Ok(HashMap::new());
}
let pool = db::pool()?;
let rows = sqlx::query("select code_fournisseur, codein, gamme from gammes_a_valider where codein = any($1)")
.bind(codeins)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| {
let f: String = r.try_get("code_fournisseur").unwrap_or_default();
let c: String = r.try_get("codein").unwrap_or_default();
(format!("{f}|{c}"), r.try_get("gamme").unwrap_or_default())
})
.collect())
}
/// Retire des propositions, une fois validées ou rejetées dans la Grille.
pub async fn supprimer_gammes_a_valider(code_fournisseur: &str, codeins: &[String]) -> AppResult<()> {
if codeins.is_empty() {
return Ok(());
}
let pool = db::pool()?;
sqlx::query("delete from gammes_a_valider where code_fournisseur = $1 and codein = any($2)")
.bind(code_fournisseur)
.bind(codeins)
.execute(&pool)
.await?;
Ok(())
}
+135
View File
@@ -0,0 +1,135 @@
//! Routes internes de la Grille (ports de `src/app/api/grid/rows/route.ts` et
//! `src/app/api/grid/rows/store-patch/route.ts`).
use super::js::JsNum;
use super::product_rows::{get_product_rows, get_store_patch, GetProductRowsInput};
use super::types::ProductRow;
use crate::auth::SessionUser;
use axum::body::{Body, Bytes};
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use futures::channel::mpsc;
use futures::SinkExt;
use serde::Serialize;
use serde_json::json;
/// `searchParams.get(nom)` : première occurrence.
pub fn premier<'a>(params: &'a [(String, String)], nom: &str) -> Option<&'a str> {
params.iter().find(|(k, _)| k == nom).map(|(_, v)| v.as_str())
}
#[derive(Serialize)]
struct Lot<'a> {
#[serde(rename = "type")]
type_: &'static str,
rows: &'a [ProductRow],
loaded: JsNum,
total: usize,
}
/// Indice de `Array.prototype.slice` (troncature, bornage à la longueur).
fn indice_slice(x: f64, len: usize) -> usize {
if x.is_nan() {
0
} else {
x.trunc().clamp(0.0, len as f64) as usize
}
}
fn ndjson(v: &impl Serialize) -> Bytes {
let mut out = serde_json::to_vec(v).unwrap_or_default();
out.push(b'\n');
Bytes::from(out)
}
/// `GET /api/grid/rows?fournisseur=…&magasin=…&chunkSize=…&refresh=1`
///
/// Flux NDJSON : `{ type: "start" }`, puis des lots `{ type: "chunk", rows, loaded,
/// total }`, puis `{ type: "done" }` — ou `{ type: "error", error }`.
pub async fn get_grid_rows(SessionUser(_utilisateur): SessionUser, Query(params): Query<Vec<(String, String)>>) -> Response {
let Some(code_fournisseur) = premier(&params, "fournisseur").filter(|f| !f.is_empty()).map(str::to_string) else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "Missing fournisseur" }))).into_response();
};
let magasin = premier(&params, "magasin").unwrap_or("TOTAL").to_string();
let chunk_size = {
let brut = premier(&params, "chunkSize").map(super::js::js_number_from_str).unwrap_or(150.0);
// Math.max(50, Math.min(500, x)) : NaN se propage.
if brut.is_nan() {
f64::NAN
} else {
brut.clamp(50.0, 500.0)
}
};
// `code1`/`code2`/`code3` ne sont pas lus : la Grille filtre en local
// (getProductRows ne s'en sert pas).
let force_refresh = premier(&params, "refresh") == Some("1");
// Client parti (changement de fournisseur, page quittée) : on arrête de
// sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et
// mis en cache, la prochaine ouverture en profitera.
let (mut tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(4);
tokio::spawn(async move {
if tx.send(Ok(ndjson(&json!({ "type": "start", "loaded": 0, "total": null })))).await.is_err() {
return;
}
let input = GetProductRowsInput { code_fournisseur, magasin: Some(magasin), force_refresh };
match get_product_rows(input).await {
Ok(shared) => {
let total = shared.len();
let mut start = 0.0f64;
while start < total as f64 {
let ligne = {
let rows = shared.rows.read();
let len = rows.len();
let (a, b) = (indice_slice(start, len), indice_slice(start + chunk_size, len));
let lot = &rows[a..b.max(a)];
ndjson(&Lot { type_: "chunk", rows: lot, loaded: JsNum((start + lot.len() as f64).min(total as f64)), total })
};
if tx.send(Ok(ligne)).await.is_err() {
return;
}
start += chunk_size;
tokio::task::yield_now().await;
}
let _ = tx.send(Ok(ndjson(&json!({ "type": "done", "loaded": total, "total": total })))).await;
}
Err(e) => {
let _ = tx.send(Ok(ndjson(&json!({ "type": "error", "error": e.to_string() })))).await;
}
}
});
(
[
(header::CONTENT_TYPE, "application/x-ndjson; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
(header::HeaderName::from_static("x-accel-buffering"), "no"),
],
Body::from_stream(rx),
)
.into_response()
}
/// `GET /api/grid/rows/store-patch?fournisseur=…&magasin=…`
///
/// Complément de l'API FF pour un magasin (cf. `store_patch`). La Grille charge
/// toujours les lignes « tous magasins » et applique ce complément à son arrivée :
/// le changement de magasin n'attend plus rien.
pub async fn get_grid_store_patch(SessionUser(_utilisateur): SessionUser, Query(params): Query<Vec<(String, String)>>) -> Response {
let code_fournisseur = premier(&params, "fournisseur").unwrap_or("");
let magasin = premier(&params, "magasin").unwrap_or("");
// Un code inconnu ferait de chaque article un candidat : autant d'appels
// inutiles à l'API FF. « Tous magasins » n'a pas de complément.
if code_fournisseur.is_empty() || !crate::domain::MAGASINS.iter().any(|m| m.code == magasin) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "Paramètres fournisseur et magasin requis." }))).into_response();
}
match get_store_patch(code_fournisseur, magasin).await {
Ok(patch) => ([(header::CACHE_CONTROL, "no-store")], Json(&*patch)).into_response(),
Err(e) => {
tracing::error!("[store-patch] {code_fournisseur}/{magasin} KO: {}", crate::error::short(&e, 200));
(StatusCode::BAD_GATEWAY, Json(json!({ "error": "Complément indisponible." }))).into_response()
}
}
}
+472
View File
@@ -0,0 +1,472 @@
//! Équivalents des conversions JavaScript dont dépend le comportement du code TS
//! d'origine : `Number(…)`, `parseInt(…, 10)`, `String(nombre)`, `Math.round`,
//! `toFixed`, et la sérialisation JSON des nombres (`12` et non `12.0`).
//!
//! Les nombres de `ProductRow` sont des `f64` ; `JSON.stringify` écrit un entier
//! sans décimale et `NaN` en `null`. Les modules de sérialisation ci-dessous
//! reproduisent ces deux règles, et relisent un `null` en `NaN` pour qu'une ligne
//! persistée par la version Node fasse l'aller-retour à l'identique.
use serde::de::Deserializer;
use serde::ser::{SerializeMap, Serializer};
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::sync::LazyLock;
/// Espace blanc au sens de JS (`trim`, `\s`).
pub fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\u{9}'..='\u{d}'
| ' '
| '\u{a0}'
| '\u{1680}'
| '\u{2000}'..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
| '\u{feff}'
)
}
/// `s.trim()`.
pub fn js_trim(s: &str) -> &str {
s.trim_matches(is_js_whitespace)
}
/// `s.replace(/\s+/g, remplacement)`.
pub fn replace_whitespace_runs(s: &str, remplacement: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut dans_blanc = false;
for c in s.chars() {
if is_js_whitespace(c) {
if !dans_blanc {
out.push_str(remplacement);
dans_blanc = true;
}
} else {
dans_blanc = false;
out.push(c);
}
}
out
}
static DECIMAL: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$").unwrap());
/// `Number(s)` pour une chaîne (coercition de `z.coerce.number()`).
pub fn js_number_from_str(s: &str) -> f64 {
let t = js_trim(s);
if t.is_empty() {
return 0.0;
}
match t {
"Infinity" | "+Infinity" => return f64::INFINITY,
"-Infinity" => return f64::NEG_INFINITY,
_ => {}
}
let base = |chiffres: &str, radix: u32| -> f64 {
if chiffres.is_empty() {
return f64::NAN;
}
let mut v = 0f64;
for c in chiffres.chars() {
match c.to_digit(radix) {
Some(d) => v = v * radix as f64 + d as f64,
None => return f64::NAN,
}
}
v
};
let prefixe = |p: &[&str]| p.iter().find_map(|x| t.strip_prefix(x));
if let Some(r) = prefixe(&["0x", "0X"]) {
return base(r, 16);
}
if let Some(r) = prefixe(&["0o", "0O"]) {
return base(r, 8);
}
if let Some(r) = prefixe(&["0b", "0B"]) {
return base(r, 2);
}
if !DECIMAL.is_match(t) {
return f64::NAN;
}
t.parse::<f64>().unwrap_or(f64::NAN)
}
/// `parseInt(s, 10)` : préfixe numérique, `NaN` s'il n'y en a pas.
pub fn js_parse_int(s: &str) -> f64 {
let t = s.trim_start_matches(is_js_whitespace);
let (negatif, reste) = match t.as_bytes().first() {
Some(b'-') => (true, &t[1..]),
Some(b'+') => (false, &t[1..]),
_ => (false, t),
};
let fin = reste.bytes().take_while(|b| b.is_ascii_digit()).count();
if fin == 0 {
return f64::NAN;
}
let v: f64 = reste[..fin].parse().unwrap_or(f64::NAN);
if negatif {
-v
} else {
v
}
}
/// `String(x)` pour un nombre (`Number.prototype.toString()`).
pub fn js_number_to_string(x: f64) -> String {
if x.is_nan() {
return "NaN".into();
}
if x == 0.0 {
return "0".into();
}
if x.is_infinite() {
return if x > 0.0 { "Infinity".into() } else { "-Infinity".into() };
}
let signe = if x < 0.0 { "-" } else { "" };
// `{:e}` donne les chiffres les plus courts qui relisent la même valeur,
// exactement ceux que retient l'algorithme de JS.
let e = format!("{:e}", x.abs());
let (mantisse, exposant) = e.split_once('e').unwrap_or((&e, "0"));
let chiffres: String = mantisse.chars().filter(|c| *c != '.').collect();
let k = chiffres.len() as i64;
let n = exposant.parse::<i64>().unwrap_or(0) + 1;
let corps = if k <= n && n <= 21 {
format!("{chiffres}{}", "0".repeat((n - k) as usize))
} else if 0 < n && n <= 21 {
format!("{}.{}", &chiffres[..n as usize], &chiffres[n as usize..])
} else if -6 < n && n <= 0 {
format!("0.{}{chiffres}", "0".repeat((-n) as usize))
} else {
let reste = if k > 1 { format!(".{}", &chiffres[1..]) } else { String::new() };
let e = n - 1;
format!("{}{reste}e{}{}", &chiffres[..1], if e < 0 { "-" } else { "+" }, e.abs())
};
format!("{signe}{corps}")
}
/// `String(v)` pour une valeur JSON quelconque (argument d'action non typé).
pub fn js_string(v: Option<&Value>) -> String {
match v {
None => "undefined".into(),
Some(Value::Null) => "null".into(),
Some(Value::Bool(b)) => b.to_string(),
Some(Value::Number(n)) => js_number_to_string(n.as_f64().unwrap_or(f64::NAN)),
Some(Value::String(s)) => s.clone(),
Some(Value::Array(a)) => a
.iter()
.map(|x| match x {
Value::Null => String::new(),
autre => js_string(Some(autre)),
})
.collect::<Vec<_>>()
.join(","),
Some(Value::Object(_)) => "[object Object]".into(),
}
}
/// Valeur « vraie » au sens de JS.
pub fn js_truthy(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
Some(Value::String(s)) => !s.is_empty(),
Some(_) => true,
}
}
/// `Math.round(x)` : demi arrondi vers +∞ (`Math.round(-2.5) === -2`).
pub fn js_round(x: f64) -> f64 {
if !x.is_finite() {
return x;
}
let f = x.floor();
if x - f >= 0.5 {
f + 1.0
} else {
f
}
}
/// `parseFloat(x.toFixed(2))`.
///
/// `toFixed` arrondit la valeur binaire exacte ; en cas d'égalité parfaite
/// (0,125 → « 0.13 »), il retient le plus grand entier en valeur absolue, là où
/// le formatage de Rust arrondit au pair.
pub fn js_to_fixed2(x: f64) -> f64 {
if !x.is_finite() || x.abs() >= 1e21 {
return x;
}
let a = x.abs();
// Une égalité exacte au millième n'est possible que pour un multiple de 1/8.
let huitiemes = a * 8.0;
let r = if huitiemes.fract() == 0.0 && huitiemes < 9.0e15 {
let centiemes = a * 100.0; // exact : multiple de 12,5
let f = centiemes.floor();
let c = if centiemes - f >= 0.5 { f + 1.0 } else { f };
c / 100.0
} else {
format!("{a:.2}").parse::<f64>().unwrap_or(a)
};
if x < 0.0 {
-r
} else {
r
}
}
/// Clé d'objet qui est un indice de tableau pour JS (`"0"`, `"404"`, `"123456"`) :
/// ces clés sont énumérées avant les autres, par ordre croissant.
pub fn js_array_index(k: &str) -> Option<u64> {
if k.is_empty() || k.len() > 10 || (k.len() > 1 && k.starts_with('0')) || !k.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
k.parse::<u64>().ok().filter(|v| *v < u32::MAX as u64)
}
/// Réordonne un objet comme JS énumère ses clés : indices croissants, puis les
/// autres clés dans l'ordre d'insertion.
pub fn js_object_order(m: Map<String, Value>) -> Map<String, Value> {
if !m.keys().any(|k| js_array_index(k).is_some()) {
return m;
}
let mut indices: Vec<(u64, String, Value)> = Vec::new();
let mut autres: Vec<(String, Value)> = Vec::new();
for (k, v) in m {
match js_array_index(&k) {
Some(i) => indices.push((i, k, v)),
None => autres.push((k, v)),
}
}
indices.sort_by_key(|(i, _, _)| *i);
indices.into_iter().map(|(_, k, v)| (k, v)).chain(autres).collect()
}
/// Texte d'un nombre pour une colonne `numeric` (équivalent de `String(n)`).
pub fn numeric_text(x: f64) -> String {
js_number_to_string(x)
}
// ---------------------------------------------------------------------------
// Sérialisation des nombres façon `JSON.stringify`
// ---------------------------------------------------------------------------
/// Nombre sérialisé comme le ferait `JSON.stringify` : entier sans décimale,
/// `null` pour `NaN`/`Infinity`.
pub struct JsNum(pub f64);
impl serde::Serialize for JsNum {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let v = self.0;
if !v.is_finite() {
s.serialize_unit()
} else if v.fract() == 0.0 && v.abs() < 9.0e15 {
s.serialize_i64(v as i64)
} else {
s.serialize_f64(v)
}
}
}
/// `f64` (un `null` relu vaut `NaN`).
pub mod num {
use super::*;
pub fn serialize<S: Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
serde::Serialize::serialize(&JsNum(*v), s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
}
}
/// `Option<f64>` d'un champ facultatif (présent à `null` → `Some(NaN)`).
pub mod opt_num {
use super::*;
pub fn serialize<S: Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(x) => serde::Serialize::serialize(&JsNum(*x), s),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
Ok(Some(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN)))
}
}
fn ser_map<S: Serializer>(m: &BTreeMap<String, f64>, s: S) -> Result<S::Ok, S::Error> {
let mut out = s.serialize_map(Some(m.len()))?;
for (k, v) in m {
out.serialize_entry(k, &JsNum(*v))?;
}
out.end()
}
fn de_map<'de, D: Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f64>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?.unwrap_or_default();
Ok(brut.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect())
}
/// `Record<string, number>` obligatoire.
pub mod map {
use super::*;
pub fn serialize<S: Serializer>(m: &BTreeMap<String, f64>, s: S) -> Result<S::Ok, S::Error> {
ser_map(m, s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f64>, D::Error> {
de_map(d)
}
}
/// `Record<string, number>` facultatif.
pub mod opt_map {
use super::*;
pub fn serialize<S: Serializer>(m: &Option<BTreeMap<String, f64>>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(m) => ser_map(m, s),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<BTreeMap<String, f64>>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?;
Ok(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect()))
}
}
/// `Record<string, number> | null` facultatif : absent (`None`), `null`
/// (`Some(None)`) ou renseigné.
pub mod opt_null_map {
use super::*;
pub fn serialize<S: Serializer>(m: &Option<Option<BTreeMap<String, f64>>>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(Some(m)) => ser_map(m, s),
_ => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<BTreeMap<String, f64>>>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?;
Ok(Some(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect())))
}
}
/// `Record<string, Record<string, number>>` facultatif (ventilations par magasin).
pub mod opt_nested_map {
use super::*;
pub type Nested = BTreeMap<String, BTreeMap<String, f64>>;
struct Interne<'a>(&'a BTreeMap<String, f64>);
impl serde::Serialize for Interne<'_> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
ser_map(self.0, s)
}
}
pub fn serialize<S: Serializer>(m: &Option<Nested>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(m) => {
let mut out = s.serialize_map(Some(m.len()))?;
for (k, v) in m {
out.serialize_entry(k, &Interne(v))?;
}
out.end()
}
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Nested>, D::Error> {
let brut = Option::<BTreeMap<String, Option<BTreeMap<String, Option<f64>>>>>::deserialize(d)?;
Ok(brut.map(|m| {
m.into_iter().map(|(k, v)| (k, v.unwrap_or_default().into_iter().map(|(p, x)| (p, x.unwrap_or(f64::NAN))).collect())).collect()
}))
}
}
/// Chaîne obligatoire : un `null` relu devient `""`.
pub mod string {
use super::*;
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn number_from_str_comme_js() {
assert_eq!(js_number_from_str(""), 0.0);
assert_eq!(js_number_from_str(" "), 0.0);
assert_eq!(js_number_from_str(" 0x2 "), 2.0);
assert_eq!(js_number_from_str("0b11"), 3.0);
assert_eq!(js_number_from_str("0o2"), 2.0);
assert!(js_number_from_str("-0x2").is_nan());
assert!(js_number_from_str("1_000").is_nan());
assert!(js_number_from_str("abc").is_nan());
assert!(js_number_from_str("inf").is_nan());
assert_eq!(js_number_from_str("+3"), 3.0);
assert_eq!(js_number_from_str(".2e1"), 2.0);
assert_eq!(js_number_from_str("1."), 1.0);
assert_eq!(js_number_from_str("\t2\n"), 2.0);
assert_eq!(js_number_from_str("-Infinity"), f64::NEG_INFINITY);
assert_eq!(js_number_from_str("1e400"), f64::INFINITY);
}
#[test]
fn parse_int_comme_js() {
assert_eq!(js_parse_int("12abc"), 12.0);
assert_eq!(js_parse_int(" -5"), -5.0);
assert_eq!(js_parse_int("0"), 0.0);
assert!(js_parse_int("abc").is_nan());
assert!(js_parse_int("").is_nan());
assert_eq!(js_parse_int("0x10"), 0.0);
}
#[test]
fn number_to_string_comme_js() {
assert_eq!(js_number_to_string(123.0), "123");
assert_eq!(js_number_to_string(-1.5), "-1.5");
assert_eq!(js_number_to_string(0.1 + 0.2), "0.30000000000000004");
assert_eq!(js_number_to_string(1e21), "1e+21");
assert_eq!(js_number_to_string(1e20), "100000000000000000000");
assert_eq!(js_number_to_string(1e-7), "1e-7");
assert_eq!(js_number_to_string(0.000001), "0.000001");
assert_eq!(js_number_to_string(1.5e-10), "1.5e-10");
assert_eq!(js_number_to_string(f64::NAN), "NaN");
}
#[test]
fn arrondis_comme_js() {
assert_eq!(js_round(-2.5), -2.0);
assert_eq!(js_round(2.5), 3.0);
assert_eq!(js_round(0.49999999999999994), 0.0);
assert_eq!(js_to_fixed2(0.125), 0.13);
assert_eq!(js_to_fixed2(-0.125), -0.13);
assert_eq!(js_to_fixed2(1.005), 1.0);
assert_eq!(js_to_fixed2(1234.5678), 1234.57);
}
#[test]
fn nombres_json_comme_js() {
assert_eq!(serde_json::to_string(&JsNum(12.0)).unwrap(), "12");
assert_eq!(serde_json::to_string(&JsNum(-0.0)).unwrap(), "0");
assert_eq!(serde_json::to_string(&JsNum(12.5)).unwrap(), "12.5");
assert_eq!(serde_json::to_string(&JsNum(f64::NAN)).unwrap(), "null");
}
#[test]
fn string_et_truthy() {
assert_eq!(js_string(Some(&serde_json::json!(123))), "123");
assert_eq!(js_string(Some(&serde_json::json!(null))), "null");
assert_eq!(js_string(None), "undefined");
assert!(!js_truthy(Some(&serde_json::json!(""))));
assert!(js_truthy(Some(&serde_json::json!("export"))));
assert_eq!(replace_whitespace_runs("Le Fournisseur\tX", "_"), "Le_Fournisseur_X");
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Module `grid` — grille d'assortiment.
//!
//! - [`types`] : `ProductRow` et types associés (JSON identique au TS) ;
//! - [`product_rows`] : calcul des lignes (`getProductRows`), branché sur
//! [`rows_cache`] (cache mémoire 10 min) et [`store`] (instantané `grid_rows`) ;
//! - [`network_trend`], [`months`], [`store_patch`] : logique pure ;
//! - [`gammes_a_valider`], [`enregistrer`], [`snapshots`] : gammes et snapshots ;
//! - [`grid_rows`] : `/api/grid/rows` (NDJSON) et `/api/grid/rows/store-patch` ;
//! - [`exports`] : exports Excel ;
//! - API publique : [`api_schemas`], [`enrich`], [`api_gammes`], [`api_v1`],
//! [`api_v1_grid`], [`openapi`].
pub mod api_gammes;
pub mod api_schemas;
pub mod api_v1;
pub mod api_v1_grid;
pub mod enregistrer;
pub mod enrich;
pub mod exports;
pub mod gammes;
pub mod gammes_a_valider;
pub mod grid_rows;
pub mod js;
pub mod months;
pub mod network_trend;
pub mod openapi;
pub mod product_rows;
pub mod rows_cache;
pub mod session;
pub mod snapshots;
pub mod store;
pub mod store_patch;
pub mod types;
pub mod zod;
#[cfg(test)]
mod tests_db;
#[cfg(test)]
mod tests_ff;
use crate::rpc::Handler;
use axum::routing::{get, post, put};
use axum::Router;
/// Routes HTTP du module.
pub fn router() -> Router {
Router::new()
.route("/api/export/excel", post(exports::export_excel))
.route("/api/export/modified-gammes", post(exports::export_modified_gammes))
.route("/api/v1/nomenclatures", get(api_v1::get_nomenclatures))
.route("/api/v1/openapi.json", get(openapi::get_openapi))
.route("/api/grid/rows", get(grid_rows::get_grid_rows))
.route("/api/grid/rows/store-patch", get(grid_rows::get_grid_store_patch))
.route("/api/v1/grid", get(api_v1_grid::get_grid))
.route("/api/v1/products/search", get(api_v1_grid::get_products_search))
.route("/api/v1/products/{codein}", get(api_v1_grid::get_product))
.route("/api/v1/products/{codein}/gamme", put(api_v1_grid::put_product_gamme))
.route("/api/v1/gammes", post(api_v1_grid::post_gammes))
.route("/api/v1/fournisseurs", get(api_v1_grid::get_fournisseurs))
.route("/api/v1/network/{codeCentrale}", get(api_v1_grid::get_network))
}
/// Actions (`POST /api/actions/{nom}`) du module.
pub fn actions() -> Vec<(&'static str, Handler)> {
[snapshots::actions(), enregistrer::actions()].concat()
}
/// Données de pages (`GET /api/pages/{nom}`) du module.
pub fn pages() -> Vec<(&'static str, Handler)> {
vec![]
}
+97
View File
@@ -0,0 +1,97 @@
//! CollectFlow — Helpers de fenêtre mensuelle (port des fonctions de
//! `src/features/grid/lib/months.ts` utilisées côté serveur).
//!
//! ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
//! - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
//! - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
//!
//! Utiliser [`ff_month_to_qlik`] / [`qlik_month_to_ff`] pour passer de l'une à l'autre.
use super::types::ProductRow;
use chrono::{Datelike, NaiveDate};
/// Date du jour, heure locale du serveur (`new Date()`).
pub fn today() -> NaiveDate {
chrono::Local::now().date_naive()
}
/// Les 12 mois complets glissants, du plus ancien au plus récent, au format
/// `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
///
/// Même fenêtre que `last12Periods()` dans `get-product-rows.ts`.
pub fn get_last12_months(now: NaiveDate) -> Vec<String> {
(1..=12)
.rev()
.map(|i: i32| {
let total = now.year() * 12 + now.month0() as i32 - i;
format!("{}{:02}", total.div_euclid(12), total.rem_euclid(12) + 1)
})
.collect()
}
/// Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des
/// séries mensuelles des lignes reçues. `None` si aucune ligne ne porte de série
/// complète (l'appelant retombe alors sur [`get_last12_months`]).
pub fn get_months_from_rows(rows: &[ProductRow]) -> Option<Vec<String>> {
for r in rows {
let mut keys: Vec<&String> = r.sales12m.keys().filter(|k| k.len() == 6 && k.bytes().all(|b| b.is_ascii_digit())).collect();
if keys.len() < 12 {
continue;
}
// Toutes les lignes d'un même calcul partagent la même fenêtre : la
// première ligne complète suffit.
keys.sort();
return Some(keys[keys.len() - 12..].iter().map(|k| k.to_string()).collect());
}
None
}
/// `"202601"` → `"Jan 26"`.
pub fn format_month_label(key: &str) -> String {
const NOMS: [&str; 12] = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
let m: usize = key.get(4..6).and_then(|s| s.parse().ok()).unwrap_or(0);
let nom = m.checked_sub(1).and_then(|i| NOMS.get(i)).copied().unwrap_or("undefined");
format!("{nom} {}", key.get(2..4).unwrap_or(""))
}
/// Clé de mois FF `"202601"` → clé Qlik `"2026-01"`.
pub fn ff_month_to_qlik(key: &str) -> String {
format!("{}-{}", key.get(..4).unwrap_or(key), key.get(4..6).unwrap_or(""))
}
/// Clé de mois Qlik `"2026-01"` → clé FF `"202601"`.
pub fn qlik_month_to_ff(key: &str) -> String {
key.replacen('-', "", 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn douze_mois_hors_mois_en_cours() {
let m = get_last12_months(NaiveDate::from_ymd_opt(2026, 1, 31).unwrap());
assert_eq!(m.first().unwrap(), "202501");
assert_eq!(m.last().unwrap(), "202512");
assert_eq!(m.len(), 12);
}
#[test]
fn conversions_de_cles() {
assert_eq!(ff_month_to_qlik("202601"), "2026-01");
assert_eq!(qlik_month_to_ff("2026-01"), "202601");
assert_eq!(format_month_label("202602"), "Fév 26");
}
#[test]
fn fenetre_depuis_les_lignes() {
let mut r = ProductRow::default();
for k in get_last12_months(NaiveDate::from_ymd_opt(2026, 10, 1).unwrap()) {
r.sales12m.insert(k, 1.0);
}
r.sales12m.insert("202409".into(), 0.0);
let m = get_months_from_rows(&[ProductRow::default(), r]).unwrap();
assert_eq!(m.first().unwrap(), "202510");
assert_eq!(m.last().unwrap(), "202609");
}
}
+350
View File
@@ -0,0 +1,350 @@
//! CollectFlow — Tendance des ventes réseau Qlik sur 12 mois
//! (port de `src/features/grid/lib/network-trend.ts`).
//!
//! Partagé entre la Grille, la fiche produit et l'API `/api/v1` (champ `trend`).
//! Aucun changement de comportement.
use super::js;
use chrono::{Datelike, NaiveDate};
use serde::Serialize;
use std::collections::BTreeMap;
/// Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
/// de présence. Constante métier : source unique pour la Grille et la fiche produit.
pub const NB_MAGASINS_RESEAU: f64 = 270.0;
/// Nombre de mois comparés à chaque bout de la fenêtre.
///
/// Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute
/// la tendance de deux points, donc du hasard d'un réassort ou d'une opération.
pub const TREND_WINDOW: usize = 4;
/// Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI.
pub const TREND_STRONG: f64 = 0.25;
/// Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%).
pub const TREND_FLAT: f64 = 0.08;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
Up,
Down,
Flat,
}
impl Direction {
pub fn as_str(self) -> &'static str {
match self {
Direction::Up => "up",
Direction::Down => "down",
Direction::Flat => "flat",
}
}
}
/// Couleur de chaque direction (`TREND_COLOR`).
pub fn trend_color(d: Direction) -> &'static str {
match d {
Direction::Up => "#22c55e",
Direction::Down => "#ef4444",
Direction::Flat => "#94a3b8",
}
}
struct Nums<'a>(&'a [f64]);
impl Serialize for Nums<'_> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(self.0.iter().map(|v| js::JsNum(*v)))
}
}
fn ser_nums<S: serde::Serializer>(v: &[f64], s: S) -> Result<S::Ok, S::Error> {
Nums(v).serialize(s)
}
fn ser_opt_nums<S: serde::Serializer>(v: &Option<Vec<f64>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(v) => Nums(v).serialize(s),
None => s.serialize_none(),
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkTrend {
/// quantités mensuelles, ordre chronologique
#[serde(serialize_with = "ser_nums")]
pub values: Vec<f64>,
/// "YYYY-MM"
pub labels: Vec<String>,
/// Quantité moyenne par magasin vendeur, mois par mois. `None` si le nombre
/// de magasins n'est pas connu sur les 12 mois.
#[serde(serialize_with = "ser_opt_nums")]
pub per_store: Option<Vec<f64>>,
/// `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se
/// rabattre sur les quantités brutes faute de nombre de magasins.
pub sur_qte_par_magasin: bool,
pub direction: Direction,
/// Variation des 4 derniers mois vs les 4 premiers. `None` si aucune base.
#[serde(serialize_with = "js::opt_num::serialize")]
pub pct: Option<f64>,
/// Rien sur les 4 premiers mois, des ventes sur les 4 derniers.
pub nouveau: bool,
/// `true` si la fenêtre s'arrête un mois plus tôt que prévu : le cache Qlik
/// n'a pas encore été resynchronisé depuis le changement de mois.
pub en_retard: bool,
pub has_data: bool,
}
/// Premier jour du mois décalé de `delta` mois (`new Date(y, m + delta, 1)`).
fn mois_decale(now: NaiveDate, delta: i32) -> (i32, u32) {
let total = now.year() * 12 + now.month0() as i32 + delta;
(total.div_euclid(12), total.rem_euclid(12) as u32 + 1)
}
/// Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au
/// plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer
/// écraserait systématiquement la tendance vers le bas.
///
/// Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que
/// `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent
/// rester alignés.
pub fn build_rolling12_qlik_months(now: NaiveDate) -> Vec<String> {
(1..=12)
.rev()
.map(|i| {
let (y, m) = mois_decale(now, -i);
format!("{y}-{m:02}")
})
.collect()
}
/// Les 12 clés de la fenêtre sont-elles explicitement présentes et chiffrées ?
fn fenetre_complete(serie: &BTreeMap<String, f64>, labels: &[String]) -> bool {
labels.iter().all(|l| serie.get(l).map(|v| v.is_finite()).unwrap_or(false))
}
/// Les 12 mois réellement disponibles dans une série Qlik : la fenêtre du jour,
/// sinon celle qui s'arrête un mois plus tôt.
///
/// Le repli couvre les jours qui suivent un changement de mois : la fenêtre a
/// glissé, mais le cache Qlik n'est resynchronisé que fournisseur par
/// fournisseur, la nuit. Sans lui, toutes les tendances disparaissaient d'un
/// coup le 1er du mois. Ce sont toujours 12 mois complets et extraits — aucun
/// mois n'est inventé — simplement décalés d'un cran. `None` si aucune des deux
/// fenêtres n'est complète.
pub fn fenetre_disponible(serie: Option<&BTreeMap<String, f64>>, now: NaiveDate) -> Option<(Vec<String>, bool)> {
let serie = serie?;
let labels = build_rolling12_qlik_months(now);
if fenetre_complete(serie, &labels) {
return Some((labels, false));
}
let (y, m) = mois_decale(now, -1);
let precedente = build_rolling12_qlik_months(NaiveDate::from_ymd_opt(y, m, 1)?);
if fenetre_complete(serie, &precedente) {
return Some((precedente, true));
}
None
}
/// Tendance réseau : variation entre les **4 premiers** et les **4 derniers**
/// mois de la fenêtre de 12 mois glissants, mois en cours exclu.
///
/// Une série n'est retenue que si les **12 clés sont explicitement présentes**
/// (tout ou rien) ; la tendance porte sur la **quantité par magasin vendeur**
/// quand le nombre de magasins est connu sur les 12 mois, sinon sur les
/// quantités brutes (`surQteParMagasin = false`). Sans base de comparaison (rien
/// vendu sur les 4 premiers mois), aucun pourcentage n'est inventé : le produit
/// est signalé « nouveau ».
///
/// Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
pub fn compute_network_trend(
qte_by_month: Option<&BTreeMap<String, f64>>,
nb_mag_by_month: Option<&BTreeMap<String, f64>>,
now: NaiveDate,
) -> NetworkTrend {
let empty = NetworkTrend {
values: vec![],
labels: vec![],
per_store: None,
sur_qte_par_magasin: false,
direction: Direction::Flat,
pct: None,
nouveau: false,
en_retard: false,
has_data: false,
};
let (Some(qte), Some((labels, en_retard))) = (qte_by_month, fenetre_disponible(qte_by_month, now)) else {
return empty;
};
let values: Vec<f64> = labels.iter().map(|l| qte.get(l).copied().unwrap_or(f64::NAN)).collect();
let n = values.len();
// Quantité par magasin vendeur : l'indicateur qui isole la performance du
// produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le
// seul choix cohérent avec une quantité elle-même nulle.
let magasins = compute_stores_series(nb_mag_by_month, Some(&labels));
let per_store: Option<Vec<f64>> =
magasins.map(|(mags, _)| values.iter().zip(mags.iter()).map(|(v, m)| if *m > 0.0 { v / m } else { 0.0 }).collect());
let serie: &[f64] = per_store.as_deref().unwrap_or(&values);
let taille = TREND_WINDOW.min(n / 2);
let moyenne = |xs: &[f64]| if xs.is_empty() { 0.0 } else { xs.iter().sum::<f64>() / xs.len() as f64 };
let debut = moyenne(&serie[..taille]);
let fin = moyenne(&serie[n - taille..]);
let mut pct = None;
let mut nouveau = false;
let mut direction = Direction::Flat;
if debut > 0.0 {
// Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans
// retours massifs, et la division par une base positive garde le sens.
let p = (fin - debut) / debut;
direction = if p > TREND_FLAT {
Direction::Up
} else if p < -TREND_FLAT {
Direction::Down
} else {
Direction::Flat
};
pct = Some(p);
} else if fin > 0.0 {
// Rien au départ, des ventes à l'arrivée : la variation relative n'existe
// pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé.
nouveau = true;
direction = Direction::Up;
}
let sur_qte_par_magasin = per_store.is_some();
NetworkTrend { values, labels, per_store, sur_qte_par_magasin, direction, pct, nouveau, en_retard, has_data: true }
}
/// Série « nombre de magasins vendeurs » sur la MÊME fenêtre que
/// [`compute_network_trend`], pour la superposer à la courbe des quantités.
///
/// Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent
/// être explicitement présentes, sinon `None` — un mois non extrait ne doit pas
/// se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion.
/// Renvoie `(values, labels)`.
pub fn compute_stores_series(
nb_mag_by_month: Option<&BTreeMap<String, f64>>,
labels: Option<&[String]>,
) -> Option<(Vec<f64>, Vec<String>)> {
let serie = nb_mag_by_month?;
let labels = labels.filter(|l| !l.is_empty())?;
if !fenetre_complete(serie, labels) {
return None;
}
Some((labels.iter().map(|l| serie[l]).collect(), labels.to_vec()))
}
/// Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »…
pub fn trend_label(pct: Option<f64>, nouveau: bool) -> &'static str {
// Un produit sans base de comparaison n'est pas « stable » : il est nouveau.
if nouveau {
return "Nouveau";
}
let Some(p) = pct else { return "Stable" };
if p > TREND_STRONG {
"Forte hausse"
} else if p > TREND_FLAT {
"Hausse"
} else if p < -TREND_STRONG {
"Forte baisse"
} else if p < -TREND_FLAT {
"Baisse"
} else {
"Stable"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, j: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, j).unwrap()
}
fn serie(labels: &[String], f: impl Fn(usize) -> f64) -> BTreeMap<String, f64> {
labels.iter().enumerate().map(|(i, l)| (l.clone(), f(i))).collect()
}
#[test]
fn fenetre_glissante_hors_mois_en_cours() {
let m = build_rolling12_qlik_months(d(2026, 1, 15));
assert_eq!(m.len(), 12);
assert_eq!(m.first().unwrap(), "2025-01");
assert_eq!(m.last().unwrap(), "2025-12");
let m = build_rolling12_qlik_months(d(2026, 10, 1));
assert_eq!(m.first().unwrap(), "2025-10");
assert_eq!(m.last().unwrap(), "2026-09");
}
#[test]
fn tendance_hausse_par_magasin() {
let now = d(2026, 10, 12);
let labels = build_rolling12_qlik_months(now);
let qte = serie(&labels, |i| {
if i < 4 {
10.0
} else if i >= 8 {
20.0
} else {
15.0
}
});
let nb = serie(&labels, |_| 5.0);
let t = compute_network_trend(Some(&qte), Some(&nb), now);
assert!(t.has_data && t.sur_qte_par_magasin && !t.en_retard);
assert_eq!(t.direction, Direction::Up);
assert!((t.pct.unwrap() - 1.0).abs() < 1e-12);
assert_eq!(trend_label(t.pct, t.nouveau), "Forte hausse");
// Même quantité, mais diffusée dans deux fois plus de magasins : stable.
let nb2 = serie(&labels, |i| {
if i < 4 {
5.0
} else if i >= 8 {
10.0
} else {
7.5
}
});
let t2 = compute_network_trend(Some(&qte), Some(&nb2), now);
assert_eq!(t2.direction, Direction::Flat);
assert_eq!(trend_label(t2.pct, t2.nouveau), "Stable");
}
#[test]
fn tendance_nouveau_retard_et_incomplet() {
let now = d(2026, 10, 2);
// Cache pas encore resynchronisé : fenêtre d'un mois plus tôt.
let anciens = build_rolling12_qlik_months(d(2026, 9, 1));
let qte = serie(&anciens, |i| if i < 4 { 0.0 } else { 3.0 });
let t = compute_network_trend(Some(&qte), None, now);
assert!(t.has_data && t.en_retard && t.nouveau && !t.sur_qte_par_magasin);
assert_eq!(t.pct, None);
assert_eq!(t.direction, Direction::Up);
assert_eq!(trend_label(t.pct, t.nouveau), "Nouveau");
// Un mois manquant : aucune tendance.
let mut trou = qte.clone();
trou.remove(&anciens[5]);
let t = compute_network_trend(Some(&trou), None, now);
assert!(!t.has_data);
assert_eq!(
serde_json::to_string(&t).unwrap(),
r#"{"values":[],"labels":[],"perStore":null,"surQteParMagasin":false,"direction":"flat","pct":null,"nouveau":false,"enRetard":false,"hasData":false}"#
);
}
#[test]
fn libelles() {
assert_eq!(trend_label(None, false), "Stable");
assert_eq!(trend_label(Some(0.1), false), "Hausse");
assert_eq!(trend_label(Some(-0.1), false), "Baisse");
assert_eq!(trend_label(Some(-0.3), false), "Forte baisse");
assert_eq!(trend_label(Some(0.08), false), "Stable");
}
}
File diff suppressed because it is too large. Load diff
+879
View File
@@ -0,0 +1,879 @@
//! Calcul des lignes de la Grille (port de `src/features/grid/api/get-product-rows.ts`).
//!
//! Sept requêtes SQL en parallèle sur la base miroir FF, jointure avec le cache
//! réseau Qlik, report des gammes du dernier snapshot. Le résultat est gardé
//! 10 minutes en mémoire ([`super::rows_cache`]) et persisté dans `grid_rows`
//! (magasin « TOTAL » seulement), d'où il est resservi tant qu'il date du mois
//! en cours (`serveFromSnapshot`).
use super::months;
use super::network_trend::NB_MAGASINS_RESEAU;
use super::rows_cache::{self, GridRows, RowsFuture, SharedRows, StorePatchFuture};
use super::store::{read_grid_snapshot, upsert_grid_rows_shared};
use super::store_patch::{apply_store_patch_in_place, build_store_patch_entries, store_patch_candidates};
use super::types::{ProductRow, StorePatch, StorePatchEntry};
use crate::ff::api::{build_last12_months_range, get_mensuel_by_articles, FfArticle};
use crate::ff::pg::{self, PgStockRow};
use crate::qlik::network_cache::{get_network_metrics_by_code_centrale, NetworkMetricCached};
use futures::future::{BoxFuture, FutureExt};
use serde_json::{Map, Value};
use sqlx::Row;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use std::time::Duration;
/// Paramètres de `getProductRows`. (`filters` n'est pas repris : le TS ne s'en
/// servait pas, la Grille filtre en local.)
#[derive(Debug, Clone, Default)]
pub struct GetProductRowsInput {
pub code_fournisseur: String,
/// `None` = « TOTAL ».
pub magasin: Option<String>,
pub force_refresh: bool,
}
impl GetProductRowsInput {
pub fn total(code_fournisseur: &str, force_refresh: bool) -> Self {
Self { code_fournisseur: code_fournisseur.to_string(), magasin: Some("TOTAL".into()), force_refresh }
}
fn magasin(&self) -> &str {
self.magasin.as_deref().unwrap_or("TOTAL")
}
}
/// Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan.
const SNAPSHOT_REFRESH_AFTER: Duration = Duration::from_secs(20 * 60 * 60);
fn court(e: &dyn std::fmt::Display) -> String {
crate::error::short(e, 200)
}
/// `Number(x) || 0` sur une valeur déjà numérique.
fn or0(v: f64) -> f64 {
if v.is_nan() {
0.0
} else {
v
}
}
/// Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
/// sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
/// touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT)
/// est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le
/// marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif.
async fn refresh_gamme_init(rows: &GridRows, code_fournisseur: &str) {
match pg::pg_get_gammes_by_fournisseur(code_fournisseur).await {
Ok(gamme_map) => appliquer_gammes_init(rows, &gamme_map),
Err(e) => tracing::error!("[getProductRows] refreshGammeInit error: {}", court(&e)),
}
}
fn appliquer_gammes_init(rows: &GridRows, gamme_map: &HashMap<String, String>) {
if gamme_map.is_empty() {
return;
}
for row in rows.rows.write().iter_mut() {
if let Some(live) = gamme_map.get(&row.codein) {
row.code_gamme_init = Some(live.clone());
}
}
}
/// Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`).
///
/// Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations
/// par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes.
/// Toute erreur est avalée : la persistance ne doit jamais casser l'affichage.
async fn persist_grid_snapshot(input: &GetProductRowsInput, rows: &GridRows) {
if input.magasin() != "TOTAL" {
return;
}
match upsert_grid_rows_shared(&input.code_fournisseur, rows).await {
Ok(n) => tracing::info!("[getProductRows] instantané persisté: {n} lignes pour {}", input.code_fournisseur),
Err(e) => tracing::error!("[getProductRows] persistance grid_rows KO: {}", court(&e.message)),
}
}
/// Lignes de la Grille d'un fournisseur (cache 10 min, instantané, sinon calcul).
pub fn get_product_rows(input: GetProductRowsInput) -> BoxFuture<'static, anyhow::Result<SharedRows>> {
async move {
let cache_key = rows_cache::grid_rows_cache_key(&input.code_fournisseur, input.magasin.as_deref());
if !input.force_refresh {
if let Some(cached) = rows_cache::get_fresh(&cache_key) {
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
refresh_gamme_init(&cached, &input.code_fournisseur).await;
return Ok(cached);
}
}
// Un calcul déjà en cours est réutilisé même en rechargement forcé : il part
// de données fraîches, et en lancer un second en parallèle doublait la charge
// (double clic, onglet rouvert…) sans rien apporter.
if let Some(pending) = rows_cache::pending_get(&cache_key) {
return pending.await.map_err(|e| anyhow::anyhow!(e));
}
// Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi
// en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement
// forcé — c'est précisément ce que l'utilisateur demande d'éviter.
if !input.force_refresh {
if let Some(servi) = serve_from_snapshot(&input, &cache_key).await {
return Ok(servi);
}
}
let version_au_depart = rows_cache::gamme_save_version(&input.code_fournisseur);
// Le calcul est lancé à part, comme une promesse JS : il continue même si
// l'appelant s'en va (client déconnecté), et il est partagé entre appelants.
let (tx, rx) = tokio::sync::oneshot::channel::<Result<SharedRows, String>>();
let partage: RowsFuture =
async move { rx.await.unwrap_or_else(|_| Err("calcul de la grille interrompu".to_string())) }.boxed().shared();
rows_cache::pending_insert(&cache_key, partage.clone());
let entree = input.clone();
let cle = cache_key.clone();
tokio::spawn(async move {
let resultat = async {
let mut rows = build_product_rows(&entree).await?;
if rows_cache::gamme_save_version(&entree.code_fournisseur) != version_au_depart {
// Gammes enregistrées pendant le calcul : on reprend le dernier snapshot.
apply_snapshot_changes(&mut rows, load_latest_snapshot_changes(&entree.code_fournisseur).await.as_ref());
}
let shared = GridRows::new(rows);
rows_cache::cache_grid_rows(&cle, shared.clone());
rows_cache::pending_remove(&cle);
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
// l'affichage de la Grille ne doit pas attendre l'écriture.
let a_persister = shared.clone();
let code = entree.code_fournisseur.clone();
let ecriture: Persistance =
tokio::spawn(async move { persist_grid_snapshot(&entree, &a_persister).await }).map(|_| ()).boxed().shared();
PERSISTANCES.lock().insert(code.clone(), ecriture.clone());
tokio::spawn(async move {
ecriture.clone().await;
let mut p = PERSISTANCES.lock();
if p.get(&code).map(|e| e.ptr_eq(&ecriture)).unwrap_or(false) {
p.remove(&code);
}
});
anyhow::Ok(shared)
}
.await;
let resultat = resultat.map_err(|e| {
rows_cache::pending_remove(&cle);
format!("{e:#}")
});
let _ = tx.send(resultat);
});
partage.await.map_err(|e| anyhow::anyhow!(e))
}
.boxed()
}
/// Écriture de l'instantané en cours, par fournisseur.
type Persistance = futures::future::Shared<BoxFuture<'static, ()>>;
static PERSISTANCES: std::sync::LazyLock<parking_lot::Mutex<HashMap<String, Persistance>>> =
std::sync::LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
/// Attend la fin de l'écriture de l'instantané lancée par le dernier calcul du
/// fournisseur (s'il y en a une en cours).
///
/// Écart volontaire avec le TS : `/api/v1` relisait `grid_rows` juste après un
/// calcul à la demande alors que la persistance, non attendue, pouvait ne pas
/// être finie — d'où un faux « aucun article ». Les routes attendent ici l'écriture.
pub async fn attendre_persistance(code_fournisseur: &str) {
let en_cours = PERSISTANCES.lock().get(code_fournisseur).cloned();
if let Some(e) = en_cours {
e.await;
}
}
/// `getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true })` :
/// recalcul complet (synchro nocturne, préchauffage). Renvoie le nombre de lignes.
pub async fn get_product_rows_for_sync(code_fournisseur: &str) -> anyhow::Result<usize> {
let rows = get_product_rows(GetProductRowsInput::total(code_fournisseur, true)).await?;
Ok(rows.len())
}
/// Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le
/// calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et
/// les dernières gammes enregistrées reportées dessus. Hors « tous magasins »,
/// le rattrapage du magasin choisi est appliqué comme après un calcul.
///
/// `None` = pas d'instantané utilisable (absent, ancien format, autre mois) :
/// l'appelant recalcule.
async fn serve_from_snapshot(input: &GetProductRowsInput, cache_key: &str) -> Option<SharedRows> {
let code = input.code_fournisseur.as_str();
let magasin = input.magasin();
let snapshot = match read_grid_snapshot(code).await {
Ok(s) => s,
Err(e) => {
tracing::error!("[getProductRows] lecture grid_rows KO: {}", court(&e.message));
return None;
}
};
let snapshot = snapshot.filter(|s| !s.rows.is_empty())?;
let computed_at = snapshot.computed_at;
let mut rows = snapshot.rows;
// Les colonnes réseau de l'instantané datent de son calcul : on les relit dans
// le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite
// depuis (au changement de mois notamment) restait invisible jusqu'au
// prochain recalcul, et la tendance réseau disparaissait.
let codes: Vec<String> = rows.iter().map(|r| r.code_centrale.clone().unwrap_or_default()).collect();
let (changes, network, gammes) =
tokio::join!(load_latest_snapshot_changes(code), fetch_network_metrics(&codes), pg::pg_get_gammes_by_fournisseur(code));
match gammes {
Ok(g) if !g.is_empty() => {
for row in rows.iter_mut() {
if let Some(live) = g.get(&row.codein) {
row.code_gamme_init = Some(live.clone());
}
}
}
Ok(_) => {}
Err(e) => tracing::error!("[getProductRows] refreshGammeInit error: {}", court(&e)),
}
apply_snapshot_changes(&mut rows, changes.as_ref());
enrich_with_network_metrics(&mut rows, network.as_ref());
if magasin != "TOTAL" {
let range = build_last12_months_range();
reconcile_selected_store_from_mensuel_api(&mut rows, magasin, &range.date_debut, &range.date_fin, &last12_periods()).await;
}
let shared = GridRows::new(rows);
rows_cache::cache_grid_rows(cache_key, shared.clone());
// Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans
// faire attendre l'utilisateur (la prochaine ouverture en profitera).
// Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané.
let age = chrono::Utc::now().naive_utc() - computed_at;
if age.to_std().map(|d| d > SNAPSHOT_REFRESH_AFTER).unwrap_or(false)
&& !rows_cache::pending_contains(&rows_cache::grid_rows_cache_key(code, Some("TOTAL")))
{
let code = code.to_string();
tokio::spawn(async move {
if let Err(e) = get_product_rows(GetProductRowsInput::total(&code, true)).await {
tracing::error!("[getProductRows] rafraîchissement en arrière-plan KO: {}", court(&e));
}
});
}
Some(shared)
}
/// Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul).
fn last12_periods() -> Vec<String> {
months::get_last12_months(months::today())
}
#[derive(Default, Clone, Copy)]
struct PeriodData {
qty: f64,
ca: f64,
marge: f64,
stock: f64,
receptions: f64,
}
/// `Math.max(...valeurs)` : `NaN` si l'une l'est.
fn js_max(valeurs: impl Iterator<Item = f64>) -> f64 {
valeurs.fold(f64::NEG_INFINITY, |m, v| if m.is_nan() || v.is_nan() { f64::NAN } else { m.max(v) })
}
/// Le calcul complet (`buildProductRows`).
async fn build_product_rows(input: &GetProductRowsInput) -> anyhow::Result<Vec<ProductRow>> {
let code_fournisseur = input.code_fournisseur.as_str();
let magasin = input.magasin();
tracing::info!("\n>>> [getProductRows] supplier: {code_fournisseur}, magasin: {magasin}");
let range = build_last12_months_range();
let (date_debut, date_fin) = (range.date_debut.as_str(), range.date_fin.as_str());
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
// Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des
// articles (codes centraux) ou le seul code fournisseur.
let articles_et_reseau = async {
let articles = pg::pg_get_articles_by_fournisseur(code_fournisseur).await.unwrap_or_else(|e| {
tracing::error!("[getProductRows] pgGetArticlesByFournisseur ERROR: {e:#}");
vec![]
});
let codes: Vec<String> = articles
.iter()
.map(|a| a.code_centrale.as_deref().filter(|c| !c.is_empty()).map(|c| c.trim().to_string()).unwrap_or_default())
.collect();
let network = fetch_network_metrics(&codes).await;
(articles, network)
};
let ou_vide = |nom: &'static str| move |e: &anyhow::Error| tracing::error!("[getProductRows] {nom} ERROR: {e:#}");
let ((articles, network), mensuel_rows, gamme_map, nom_map, stock_map, commandes_map, prix_vente_map, snapshot_changes) = tokio::join!(
articles_et_reseau,
async {
pg::pg_get_mensuel_by_fournisseur(code_fournisseur, date_debut, date_fin)
.await
.inspect_err(ou_vide("pgGetMensuelByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_gammes_by_fournisseur(code_fournisseur).await.inspect_err(ou_vide("pgGetGammesByFournisseur")).unwrap_or_default()
},
async {
pg::pg_get_nomenclature_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetNomenclatureByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_stock_by_fournisseur(code_fournisseur).await.inspect_err(ou_vide("pgGetStockByFournisseur")).unwrap_or_default()
},
async {
pg::pg_get_commandes_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetCommandesByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_prix_vente_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetPrixVenteByFournisseur"))
.unwrap_or_default()
},
load_latest_snapshot_changes(code_fournisseur),
);
tracing::info!("[getProductRows] {} articles, {} mensuel rows, {} gammes", articles.len(), mensuel_rows.len(), gamme_map.len());
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
let sorted_periods = last12_periods();
// ─── Phase 3 : Seed productMap depuis articles ────────────────────────
// Ordre d'insertion conservé (Map JS) : index par codein.
let mut products: Vec<ProductRow> = Vec::with_capacity(articles.len());
let mut index: HashMap<String, usize> = HashMap::with_capacity(articles.len());
for art in &articles {
if art.codein.is_empty() || index.contains_key(&art.codein) {
continue;
}
index.insert(art.codein.clone(), products.len());
let non_nul = |v: Option<f64>| v.filter(|x| *x != 0.0 && !x.is_nan());
products.push(ProductRow {
codein: art.codein.clone(),
code_fournisseur: art.codefou.clone(),
nom_fournisseur: art.nomfou.clone().unwrap_or_default(),
fournisseur_principal_code: art.codefou_principal.clone(),
fournisseur_principal_nom: art.nomfou_principal.clone(),
libelle1: art.libelle1.clone().unwrap_or_default(),
gtin: art.gtin.clone().unwrap_or_default(),
reference: art.reference.clone().unwrap_or_default(),
code_centrale: art.code_centrale.as_deref().filter(|c| !c.is_empty()).map(|c| c.trim().to_string()),
code_gamme: None,
code_gamme_init: None,
code_gamme_draft: None,
total_quantite: 0.0,
total_ca: 0.0,
total_marge: 0.0,
taux_marge: 0.0,
working_stores: vec![],
noid: (art.no_id != 0).then_some(art.no_id as f64),
pcb: non_nul(art.pcb),
prix_vente: non_nul(art.pv_central),
..Default::default()
});
}
// ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ─────────────
// SQL a déjà fait l'agrégation par (codein, site, mois).
// On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side.
let allowed: BTreeSet<&str> = sorted_periods.iter().map(String::as_str).collect();
let mut mensuel_by_codein: Vec<(String, HashMap<String, PeriodData>)> = Vec::new();
let mut mensuel_index: HashMap<String, usize> = HashMap::new();
let mut mensuel_by_site: HashMap<String, BTreeMap<String, HashMap<String, PeriodData>>> = HashMap::new();
let mut store_months: HashMap<String, BTreeSet<String>> = HashMap::new();
for row in &mensuel_rows {
let periode = row.mois.replacen('-', "", 1); // "2026-02" → "202602"
if !allowed.contains(periode.as_str()) {
continue;
}
let stock = or0(row.stock_fin_mois.unwrap_or(f64::NAN));
// Accumulation TOTAL (toujours tous sites)
let i = *mensuel_index.entry(row.codein.clone()).or_insert_with(|| {
mensuel_by_codein.push((row.codein.clone(), HashMap::new()));
mensuel_by_codein.len() - 1
});
let p = mensuel_by_codein[i].1.entry(periode.clone()).or_default();
p.stock += stock;
p.qty += or0(row.qte_vendue);
p.ca += or0(row.ca_ht);
p.marge += or0(row.marge);
p.receptions += or0(row.qte_recue);
// Accumulation par site (pour switch client-side)
let sp = mensuel_by_site.entry(row.codein.clone()).or_default().entry(row.site.clone()).or_default().entry(periode).or_default();
sp.qty += or0(row.qte_vendue);
sp.ca += or0(row.ca_ht);
sp.marge += or0(row.marge);
sp.stock = stock; // SQL donne déjà le dernier mouvement
sp.receptions += or0(row.qte_recue);
// Suivi magasins actifs (pour workingStores) : 1 vente suffit
if row.qte_vendue > 0.0 {
store_months.entry(row.codein.clone()).or_default().insert(row.site.clone());
}
}
// ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ─────────────
for (codein, by_period) in &mensuel_by_codein {
let Some(&pi) = index.get(codein) else { continue };
let product = &mut products[pi];
let mut last_stock = 0.0;
for periode in &sorted_periods {
match by_period.get(periode) {
Some(p) => {
product.sales12m.insert(periode.clone(), p.qty);
product.stock12m.insert(periode.clone(), p.stock);
product.receptions12m.insert(periode.clone(), p.receptions);
product.total_quantite += p.qty;
product.total_ca += p.ca;
product.total_marge += p.marge;
last_stock = p.stock;
}
None => {
product.sales12m.insert(periode.clone(), 0.0);
product.stock12m.insert(periode.clone(), last_stock); // carry-forward
product.receptions12m.insert(periode.clone(), 0.0);
}
}
}
product.taux_marge = if product.total_ca > 0.0 { (product.total_marge / product.total_ca) * 100.0 } else { 0.0 };
// Breakdowns par site pour le switch client-side
if let Some(site_map) = mensuel_by_site.get(codein) {
let mut sales_by_store = BTreeMap::new();
let mut stock_by_store = BTreeMap::new();
let mut receptions_by_store = BTreeMap::new();
let mut ca_by_store = BTreeMap::new();
let mut quantite_by_store = BTreeMap::new();
let mut marge_by_store = BTreeMap::new();
for (site, site_by_period) in site_map {
let (mut sales, mut stock, mut receptions) = (BTreeMap::new(), BTreeMap::new(), BTreeMap::new());
let (mut s_qty, mut s_ca, mut s_marge) = (0.0, 0.0, 0.0);
let mut last_site_stock = 0.0;
for periode in &sorted_periods {
let sp = site_by_period.get(periode);
sales.insert(periode.clone(), sp.map(|p| p.qty).unwrap_or(0.0));
receptions.insert(periode.clone(), sp.map(|p| p.receptions).unwrap_or(0.0));
match sp {
Some(p) => {
stock.insert(periode.clone(), p.stock);
last_site_stock = p.stock;
}
None => {
stock.insert(periode.clone(), last_site_stock); // carry-forward
}
}
s_qty += sp.map(|p| p.qty).unwrap_or(0.0);
s_ca += sp.map(|p| p.ca).unwrap_or(0.0);
s_marge += sp.map(|p| p.marge).unwrap_or(0.0);
}
sales_by_store.insert(site.clone(), sales);
stock_by_store.insert(site.clone(), stock);
receptions_by_store.insert(site.clone(), receptions);
quantite_by_store.insert(site.clone(), s_qty);
ca_by_store.insert(site.clone(), s_ca);
marge_by_store.insert(site.clone(), s_marge);
}
// Le stock TOTAL se recalcule ici, à partir des séries par site.
//
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
// détient toujours sa marchandise. Les séries par site, elles, sont
// reportées d'un mois sur l'autre : leur somme est la bonne.
if !stock_by_store.is_empty() {
for periode in &sorted_periods {
let total: f64 = stock_by_store.values().map(|s: &BTreeMap<String, f64>| s.get(periode).copied().unwrap_or(0.0)).sum();
product.stock12m.insert(periode.clone(), total);
}
}
product.sales12m_by_store = Some(sales_by_store);
product.stock12m_by_store = Some(stock_by_store);
product.receptions12m_by_store = Some(receptions_by_store);
product.ca_by_store = Some(ca_by_store);
product.quantite_by_store = Some(quantite_by_store);
product.marge_by_store = Some(marge_by_store);
}
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
if let Some(sites) = store_months.get(codein) {
product.working_stores = sites.iter().cloned().collect();
}
// Commandes en cours
if let Some(&cmd) = commandes_map.get(codein) {
if cmd != 0.0 && !cmd.is_nan() {
product.commandes_en_cours = Some(cmd);
}
}
}
// ─── Phase 6 : Gammes (saison active) ────────────────────────────────
for (codein, gamme) in &gamme_map {
let Some(&pi) = index.get(codein) else { continue };
products[pi].code_gamme_init = Some(gamme.clone());
products[pi].code_gamme = Some(gamme.clone());
}
// ─── Phase 6b : Nomenclature (famille / sous-famille) ────────────────
for (codein, nom) in &nom_map {
let Some(&pi) = index.get(codein) else { continue };
let p = &mut products[pi];
p.code3 = nom.code3.clone();
p.libelle3 = nom.libelle3.clone();
p.code2 = nom.code2.clone().unwrap_or_default();
p.libelle_niveau2 = nom.libelle2.clone().unwrap_or_default();
p.code1 = nom.code1.clone().unwrap_or_default();
p.libelle_niveau1 = nom.libelle1nom.clone().unwrap_or_default();
}
// ─── Phase 7 : Stock temps réel (cube_stock) ─────────────────────────
for (codein, stocks) in &stock_map {
let Some(&pi) = index.get(codein) else { continue };
let product = &mut products[pi];
let somme = |f: fn(&PgStockRow) -> Option<f64>| stocks.iter().map(|r| or0(f(r).unwrap_or(f64::NAN))).sum::<f64>();
product.stock_actuel = Some(somme(|r| r.stockdispo));
product.stock_total = Some(somme(|r| r.qte));
product.stock_valeur = Some(somme(|r| r.valstock));
if let Some(prmp) = stocks.iter().filter_map(|r| r.prmp).find(|p| *p > 0.0) {
product.pa = Some(prmp);
product.prix_achat = Some(prmp);
}
let plus_recente = |dates: Vec<&String>| -> String {
dates.into_iter().fold(String::new(), |best, d| if !d.is_empty() && *d > best { d.clone() } else { best })
};
let dern_vente = plus_recente(stocks.iter().filter_map(|r| r.dernierevente.as_ref()).collect());
if !dern_vente.is_empty() {
product.derniere_vente = Some(dern_vente);
}
let dern_liv = plus_recente(stocks.iter().filter_map(|r| r.dernierereception.as_ref()).collect());
if !dern_liv.is_empty() {
product.derniere_livraison = Some(dern_liv);
}
// Map store-specific delivery dates
let mut par_site = BTreeMap::new();
for r in stocks {
if let Some(d) = r.dernierereception.as_ref().filter(|d| !d.is_empty() && !r.site.is_empty()) {
par_site.insert(r.site.clone(), d.clone());
}
}
product.derniere_livraison_by_store = Some(par_site);
}
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
//
// Le vrai prix vit dans `cube_pv`, par article ET par site. On garde le
// détail par magasin — deux magasins peuvent ne pas pratiquer le même prix,
// et l'afficher vaut mieux que le moyenner.
for product in products.iter_mut() {
match prix_vente_map.get(&product.codein).filter(|m| !m.is_empty()) {
Some(par_site) => product.prix_vente_by_store = Some(par_site.clone()),
None => {
// Filet de secours : le cube de stock porte le même PV, mais
// seulement pour les articles qui ont une ligne de stock.
let mut depuis_stock = BTreeMap::new();
for s in stock_map.get(&product.codein).map(|v| v.as_slice()).unwrap_or_default() {
let pv = s.pv.unwrap_or(f64::NAN);
let site = s.site.trim();
if !site.is_empty() && pv.is_finite() && pv > 0.0 {
depuis_stock.insert(site.to_string(), pv);
}
}
if !depuis_stock.is_empty() {
product.prix_vente_by_store = Some(depuis_stock);
}
}
}
// Le plus élevé quand les magasins divergent : une moyenne inventerait
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
if let Some(prix) = product.prix_vente_by_store.as_ref().filter(|m| !m.is_empty()) {
product.prix_vente = Some(js_max(prix.values().copied()));
}
}
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
enrich_with_network_metrics(&mut products, network.as_ref());
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
// rechargé en Phase 6 — jamais écrasée ici.
apply_snapshot_changes(&mut products, snapshot_changes.as_ref());
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
let tous = products.len();
let mut rows: Vec<ProductRow> =
products.into_iter().filter(|p| p.code_gamme.as_deref() != Some("Y") || p.total_quantite > 0.0).collect();
reconcile_selected_store_from_mensuel_api(&mut rows, magasin, date_debut, date_fin, &sorted_periods).await;
let exclus_y = tous - rows.len();
tracing::info!(
"[getProductRows] {} produits ({exclus_y} gamme Y sans ventes exclus), {} avec ventes",
rows.len(),
mensuel_by_codein.len()
);
Ok(rows)
}
/// Articles complétés par l'API FF, au plus, lors d'un changement de magasin
/// (`GRID_STORE_RECONCILE_MAX`, 300 par défaut).
fn plafond_complement() -> f64 {
match std::env::var("GRID_STORE_RECONCILE_MAX") {
Ok(v) => super::js::js_number_from_str(&v),
Err(_) => 300.0,
}
}
/// `candidates.slice(0, plafond)`.
fn js_slice_end(len: usize, fin: f64) -> usize {
if fin.is_nan() {
return 0;
}
let fin = fin.trunc();
if fin < 0.0 {
(len as f64 + fin).max(0.0) as usize
} else {
fin.min(len as f64) as usize
}
}
/// Article candidat au complément, copié hors verrou.
struct Candidat {
codein: String,
libelle1: String,
codefou: String,
noid: Option<f64>,
}
fn candidats(rows: &[ProductRow], magasin: &str) -> Vec<Candidat> {
store_patch_candidates(rows, magasin)
.into_iter()
.map(|r| Candidat { codein: r.codein.clone(), libelle1: r.libelle1.clone(), codefou: r.code_fournisseur.clone(), noid: r.noid })
.collect()
}
/// Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch).
async fn fetch_store_patch_entries(
candidates: Vec<Candidat>,
magasin: &str,
date_debut: &str,
date_fin: &str,
periods: &[String],
) -> Vec<StorePatchEntry> {
if candidates.is_empty() {
return vec![];
}
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
// les candidats se comptent par milliers et le changement de magasin se fige
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
// plutôt que de tronquer en silence.
let nb = candidates.len();
let retenus = &candidates[..js_slice_end(nb, plafond_complement())];
if nb > retenus.len() {
tracing::warn!(
"[getProductRows] rattrapage magasin {magasin} borné à {} articles sur {nb} : les autres gardent la valeur SQL.",
retenus.len()
);
}
let articles: Vec<FfArticle> = retenus
.iter()
.map(|c| FfArticle {
codein: c.codein.clone(),
libelle1: c.libelle1.clone(),
codefou: c.codefou.clone(),
noid: c.noid.filter(|n| n.is_finite()).map(|n| n as i64),
..Default::default()
})
.collect();
let mensuel_map = get_mensuel_by_articles(&articles, date_debut, date_fin, 25).await;
let codes: Vec<String> = retenus.iter().map(|c| c.codein.clone()).collect();
build_store_patch_entries(&codes, magasin, periods, &mensuel_map)
}
async fn reconcile_selected_store_from_mensuel_api(
rows: &mut [ProductRow],
magasin: &str,
date_debut: &str,
date_fin: &str,
sorted_periods: &[String],
) {
if magasin == "TOTAL" {
return;
}
let entries = fetch_store_patch_entries(candidats(rows, magasin), magasin, date_debut, date_fin, sorted_periods).await;
let fixed = apply_store_patch_in_place(rows, magasin, sorted_periods, &entries);
if fixed > 0 {
tracing::info!("[getProductRows] {fixed} produits corrigés via API mensuelle pour magasin {magasin}");
}
}
/// Complément de l'API FF pour un magasin, calculé sur les lignes « tous
/// magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
/// recharger les lignes. Les demandes simultanées partagent le même calcul, et
/// un échec n'est jamais gardé.
pub async fn get_store_patch(code_fournisseur: &str, magasin: &str) -> anyhow::Result<Arc<StorePatch>> {
let rows = match rows_cache::peek_fresh(&rows_cache::grid_rows_cache_key(code_fournisseur, Some("TOTAL"))) {
Some(r) => r,
None => get_product_rows(GetProductRowsInput::total(code_fournisseur, false)).await?,
};
let patch: StorePatchFuture = {
let mut table = rows.store_patches.lock();
match table.get(magasin) {
Some(p) => p.clone(),
None => {
let periods = last12_periods();
let range = build_last12_months_range();
let candidates = candidats(&rows.rows.read(), magasin);
let magasin_s = magasin.to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let entries = fetch_store_patch_entries(candidates, &magasin_s, &range.date_debut, &range.date_fin, &periods).await;
let _ = tx.send(Ok(Arc::new(StorePatch { magasin: magasin_s, periods, entries })));
});
let en_cours: StorePatchFuture =
async move { rx.await.unwrap_or_else(|_| Err("complément interrompu".to_string())) }.boxed().shared();
table.insert(magasin.to_string(), en_cours.clone());
en_cours
}
}
};
match patch.clone().await {
Ok(p) => Ok(p),
Err(e) => {
let mut table = rows.store_patches.lock();
if table.get(magasin).map(|p| p.ptr_eq(&patch)).unwrap_or(false) {
table.remove(magasin);
}
Err(anyhow::anyhow!(e))
}
}
}
/// Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
///
/// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de `nbMag`,
/// c'est qu'aucun magasin n'a vendu, donc zéro. Renvoie `None` plutôt qu'un objet
/// vide pour que l'UI distingue « pas de donnée » de « zéro magasin ».
fn nb_mag_par_mois(m: &NetworkMetricCached) -> Option<BTreeMap<String, f64>> {
let detail = m.metrics_by_month.as_ref()?;
let par_mois: BTreeMap<String, f64> =
detail.iter().map(|(mois, v)| (mois.clone(), v.nb_mag.filter(|n| n.is_finite()).unwrap_or(0.0))).collect();
(!par_mois.is_empty()).then_some(par_mois)
}
/// Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ;
/// codeGammeInit reste figé (état serveur).
pub fn apply_snapshot_changes(rows: &mut [ProductRow], changes: Option<&Map<String, Value>>) {
let Some(changes) = changes else { return };
for row in rows.iter_mut() {
let after = changes.get(&row.codein).and_then(|c| c.get("after"));
if super::js::js_truthy(after) {
row.code_gamme = Some(super::js::js_string(after));
}
}
}
/// Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `None`.
pub async fn load_latest_snapshot_changes(code_fournisseur: &str) -> Option<Map<String, Value>> {
let res = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("select changes from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(code_fournisseur)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row.map(|r| r.try_get::<Value, _>("changes")).transpose()?)
}
.await;
match res {
Ok(Some(Value::Object(m))) => Some(m),
Ok(_) => None,
Err(e) => {
tracing::error!("[getProductRows] Snapshot restore error: {e:#}");
None
}
}
}
/// Lit les métriques réseau Qlik (cache qlik_network_metrics) des codes centraux
/// donnés. `None` en cas d'erreur : la grille s'affiche alors sans données réseau.
async fn fetch_network_metrics(codes_centrale: &[String]) -> Option<HashMap<String, NetworkMetricCached>> {
let mut vus = BTreeSet::new();
let codes: Vec<String> = codes_centrale.iter().filter(|c| !c.is_empty() && vus.insert(c.as_str())).cloned().collect();
if codes.is_empty() {
return None;
}
match get_network_metrics_by_code_centrale(&codes).await {
Ok(m) => Some(m),
Err(e) => {
tracing::error!("[getProductRows] enrichWithNetworkMetrics error: {e:#}");
None
}
}
}
/// Enrichit les produits avec les métriques réseau Qlik, jointes par code centrale.
/// Dégradation propre si la sync Qlik n'a jamais tourné ou si le code centrale
/// n'est pas encore disponible.
pub fn enrich_with_network_metrics(products: &mut [ProductRow], metrics: Option<&HashMap<String, NetworkMetricCached>>) {
let Some(metrics) = metrics.filter(|m| !m.is_empty()) else { return };
for product in products.iter_mut() {
let Some(m) = product.code_centrale.as_deref().filter(|c| !c.is_empty()).and_then(|c| metrics.get(c)) else { continue };
product.ca_reseau = Some(m.ca_reseau);
product.qte_reseau = Some(m.qte_reseau);
product.nb_magasins_reseau = Some(m.nb_magasins_reseau);
product.ca_par_magasin_reseau = Some(m.ca_par_magasin_reseau);
product.marge_pct_reseau = Some(m.marge_pct_reseau);
product.taux_presence_reseau = Some(m.nb_magasins_reseau / NB_MAGASINS_RESEAU);
product.qte_reseau_by_month = Some(m.qte_by_month.clone());
product.nb_mag_reseau_by_month = Some(nb_mag_par_mois(m));
product.network_fetched_at = m.fetched_at.clone();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slice_et_max_comme_js() {
assert_eq!(js_slice_end(10, 300.0), 10);
assert_eq!(js_slice_end(10, 3.7), 3);
assert_eq!(js_slice_end(10, -2.0), 8);
assert_eq!(js_slice_end(10, f64::NAN), 0);
assert_eq!(js_max([1.0, 3.0, 2.0].into_iter()), 3.0);
assert!(js_max([1.0, f64::NAN].into_iter()).is_nan());
}
#[test]
fn report_des_gammes_du_snapshot() {
let mut rows = vec![
ProductRow { codein: "1".into(), code_gamme: Some("B".into()), ..Default::default() },
ProductRow { codein: "2".into(), code_gamme: Some("B".into()), ..Default::default() },
ProductRow { codein: "3".into(), code_gamme: Some("B".into()), ..Default::default() },
];
let changes = serde_json::json!({ "1": { "before": "B", "after": "Z" }, "2": { "before": "B", "after": "" } });
apply_snapshot_changes(&mut rows, changes.as_object());
let g: Vec<_> = rows.iter().map(|r| r.code_gamme.as_deref().unwrap()).collect();
assert_eq!(g, vec!["Z", "B", "B"]);
}
}
+273
View File
@@ -0,0 +1,273 @@
//! Cache mémoire des lignes de grille calculées (partie « cache » de
//! `src/features/grid/api/get-product-rows.ts`).
//!
//! Clé : `"{codeFournisseur}:{magasin ?? "TOTAL"}"`. Durée de vie : 10 minutes.
//! Le calcul lui-même (`getProductRows`, `buildProductRows`, `serveFromSnapshot`,
//! `getStorePatch`) viendra en phase 2 et s'appuie sur les briques ci-dessous :
//!
//! - [`get_fresh`] : lecture d'une entrée de moins de 10 min, remontée en tête de
//! l'ordre d'usage (éviction LRU) ; [`peek_fresh`] la lit sans la remonter
//! (`getStorePatch`) ;
//! - [`cache_grid_rows`] : insertion avec éviction (`cacheGridRows`) ;
//! - [`pending_get`] / [`pending_insert`] / [`pending_remove`] : calculs en cours,
//! partagés entre appels simultanés (`gridRowsPending`) ;
//! - [`gamme_save_version`] : compteur d'enregistrements de gammes par fournisseur,
//! qu'un calcul compare avant/après pour relire le dernier snapshot ;
//! - [`invalidate_grid_rows_cache`], [`patch_grid_rows_cache`].
//!
//! Les lignes d'une entrée sont partagées ([`SharedRows`]) et modifiées sur place,
//! comme le tableau JS : `patchGridRowsCache` et `refreshGammeInit` écrivent dans
//! les lignes déjà servies. Les compléments par magasin (`storePatches`, une
//! `WeakMap` indexée par le tableau en TS) sont rattachés au même objet : ils
//! vivent et meurent avec lui.
use super::types::{GammeCode, ProductRow, StorePatch};
use futures::future::{BoxFuture, Shared};
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
pub const GRID_ROWS_CACHE_TTL: Duration = Duration::from_secs(10 * 60);
/// Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par
/// `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait
/// par garder toutes les grilles en mémoire (plusieurs centaines de Mo). Ordre
/// d'insertion = ordre d'usage : on évince les entrées les moins récemment servies.
///
/// `Number(process.env.X ?? défaut)` : une valeur illisible donne `NaN`, et toute
/// comparaison avec `NaN` est fausse — comportement conservé.
fn borne(nom: &str, defaut: f64) -> f64 {
match std::env::var(nom) {
Ok(v) => super::js::js_number_from_str(&v),
Err(_) => defaut,
}
}
fn max_entries() -> f64 {
borne("GRID_ROWS_CACHE_MAX_ENTRIES", 30.0)
}
fn max_rows() -> f64 {
borne("GRID_ROWS_CACHE_MAX_ROWS", 150_000.0)
}
/// Complément par magasin en cours de calcul ou calculé (partagé entre appelants).
pub type StorePatchFuture = Shared<BoxFuture<'static, Result<Arc<StorePatch>, String>>>;
/// Lignes d'une entrée du cache, partagées et modifiables sur place.
#[derive(Default)]
pub struct GridRows {
pub rows: RwLock<Vec<ProductRow>>,
/// `storePatches` : magasin → complément, rattaché à ce tableau de lignes.
pub store_patches: Mutex<HashMap<String, StorePatchFuture>>,
}
impl GridRows {
pub fn new(rows: Vec<ProductRow>) -> SharedRows {
Arc::new(GridRows { rows: RwLock::new(rows), store_patches: Mutex::new(HashMap::new()) })
}
pub fn len(&self) -> usize {
self.rows.read().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Copie des lignes (pour les sérialiser hors verrou).
pub fn snapshot(&self) -> Vec<ProductRow> {
self.rows.read().clone()
}
}
pub type SharedRows = Arc<GridRows>;
/// Calcul en cours, partagé : l'erreur est un message (le calcul TS relance l'erreur
/// à chaque appelant).
pub type RowsFuture = Shared<BoxFuture<'static, Result<SharedRows, String>>>;
struct Entree {
rows: SharedRows,
created_at: Instant,
}
#[derive(Default)]
struct Etat {
/// Entrées dans l'ordre d'usage (la plus ancienne en tête).
cache: Vec<(String, Entree)>,
pending: HashMap<String, RowsFuture>,
/// Nombre d'enregistrements de gammes par fournisseur (cf. [`patch_grid_rows_cache`]).
gamme_save_version: HashMap<String, u64>,
}
static ETAT: LazyLock<Mutex<Etat>> = LazyLock::new(|| Mutex::new(Etat::default()));
/// `gridRowsCacheKey` : `"{codeFournisseur}:{magasin ?? "TOTAL"}"`.
pub fn grid_rows_cache_key(code_fournisseur: &str, magasin: Option<&str>) -> String {
format!("{code_fournisseur}:{}", magasin.unwrap_or("TOTAL"))
}
fn prefixe(code_fournisseur: &str) -> String {
format!("{code_fournisseur}:")
}
/// `cacheGridRows` : (ré)insère l'entrée en fin d'ordre d'usage, puis évince les
/// plus anciennes tant que les bornes sont dépassées (jamais la dernière).
pub fn cache_grid_rows(key: &str, rows: SharedRows) {
let (max_e, max_r) = (max_entries(), max_rows());
let mut etat = ETAT.lock();
etat.cache.retain(|(k, _)| k != key);
etat.cache.push((key.to_string(), Entree { rows, created_at: Instant::now() }));
let mut total_rows: f64 = etat.cache.iter().map(|(_, e)| e.rows.len() as f64).sum();
while etat.cache.len() > 1 {
let taille = etat.cache.len() as f64;
if taille <= max_e && total_rows <= max_r {
break;
}
let (_, e) = etat.cache.remove(0);
total_rows -= e.rows.len() as f64;
}
}
/// Entrée de moins de 10 min, remontée en tête de l'ordre d'usage (hit de
/// `getProductRows`). `None` si absente ou expirée.
pub fn get_fresh(key: &str) -> Option<SharedRows> {
let mut etat = ETAT.lock();
let pos = etat.cache.iter().position(|(k, e)| k == key && e.created_at.elapsed() < GRID_ROWS_CACHE_TTL)?;
// Remonte l'entrée en tête de l'ordre d'usage (éviction LRU), sans changer
// sa date de création.
let entree = etat.cache.remove(pos);
let rows = entree.1.rows.clone();
etat.cache.push(entree);
Some(rows)
}
/// Entrée de moins de 10 min, sans toucher à l'ordre d'usage (`getStorePatch`).
pub fn peek_fresh(key: &str) -> Option<SharedRows> {
let etat = ETAT.lock();
etat.cache.iter().find(|(k, e)| k == key && e.created_at.elapsed() < GRID_ROWS_CACHE_TTL).map(|(_, e)| e.rows.clone())
}
/// Calcul en cours pour cette clé.
pub fn pending_get(key: &str) -> Option<RowsFuture> {
ETAT.lock().pending.get(key).cloned()
}
pub fn pending_contains(key: &str) -> bool {
ETAT.lock().pending.contains_key(key)
}
pub fn pending_insert(key: &str, fut: RowsFuture) {
ETAT.lock().pending.insert(key.to_string(), fut);
}
pub fn pending_remove(key: &str) {
ETAT.lock().pending.remove(key);
}
/// Compteur d'enregistrements de gammes du fournisseur.
pub fn gamme_save_version(code_fournisseur: &str) -> u64 {
ETAT.lock().gamme_save_version.get(code_fournisseur).copied().unwrap_or(0)
}
/// `invalidateGridRowsCache` : un fournisseur (toutes ses vues magasin), ou tout.
pub fn invalidate_grid_rows_cache(code_fournisseur: Option<&str>) {
let mut etat = ETAT.lock();
match code_fournisseur.filter(|c| !c.is_empty()) {
Some(code) => {
let p = prefixe(code);
etat.cache.retain(|(k, _)| !k.starts_with(&p));
etat.pending.retain(|k, _| !k.starts_with(&p));
}
None => {
etat.cache.clear();
etat.pending.clear();
}
}
}
/// Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter
/// le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros
/// fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné
/// ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9),
/// la colonne INIT reste l'état serveur.
pub fn patch_grid_rows_cache<'a>(code_fournisseur: &str, changes: impl IntoIterator<Item = (&'a str, &'a str)>) {
let by_codein: HashMap<&str, GammeCode> = changes.into_iter().map(|(c, g)| (c, g.to_string())).collect();
if by_codein.is_empty() {
return;
}
let entrees: Vec<SharedRows> = {
let mut etat = ETAT.lock();
// Un calcul en cours pour ce fournisseur a lu les gammes avant cet
// enregistrement : le compteur lui signale de les relire avant d'être caché.
*etat.gamme_save_version.entry(code_fournisseur.to_string()).or_insert(0) += 1;
let p = prefixe(code_fournisseur);
etat.cache.iter().filter(|(k, _)| k.starts_with(&p)).map(|(_, e)| e.rows.clone()).collect()
};
for entree in entrees {
for row in entree.rows.write().iter_mut() {
if let Some(g) = by_codein.get(row.codein.as_str()) {
row.code_gamme = Some(g.clone());
}
}
}
}
/// Nombre d'entrées en cache (diagnostic).
pub fn cache_len() -> usize {
ETAT.lock().cache.len()
}
/// Sérialise les tests qui touchent à l'état global du cache.
#[cfg(test)]
pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
pub(crate) fn reset_for_tests() {
*ETAT.lock() = Etat::default();
}
#[cfg(test)]
mod tests {
use super::*;
fn lignes(n: usize) -> SharedRows {
GridRows::new((0..n).map(|i| ProductRow { codein: format!("C{i}"), ..Default::default() }).collect())
}
/// Un seul test : l'état est global au processus.
#[test]
fn cache_patch_invalidation_eviction() {
let _verrou = TEST_LOCK.lock();
reset_for_tests();
assert_eq!(grid_rows_cache_key("F1", None), "F1:TOTAL");
assert_eq!(grid_rows_cache_key("F1", Some("292")), "F1:292");
let total = lignes(3);
cache_grid_rows("F1:TOTAL", total.clone());
cache_grid_rows("F1:292", lignes(3));
cache_grid_rows("F10:TOTAL", lignes(3));
assert!(get_fresh("F1:TOTAL").is_some());
assert!(peek_fresh("absent").is_none());
// Patch : seules les entrées du fournisseur F1 (pas F10) reçoivent la gamme.
patch_grid_rows_cache("F1", [("C1", "Z")]);
assert_eq!(gamme_save_version("F1"), 1);
assert_eq!(gamme_save_version("F10"), 0);
assert_eq!(total.rows.read()[1].code_gamme.as_deref(), Some("Z"));
assert_eq!(get_fresh("F1:292").unwrap().rows.read()[1].code_gamme.as_deref(), Some("Z"));
assert_eq!(get_fresh("F10:TOTAL").unwrap().rows.read()[1].code_gamme, None);
// Invalidation d'un fournisseur : « F1: » ne touche pas « F10: ».
invalidate_grid_rows_cache(Some("F1"));
assert!(get_fresh("F1:TOTAL").is_none() && get_fresh("F1:292").is_none());
assert!(get_fresh("F10:TOTAL").is_some());
// Éviction LRU par nombre de lignes (borne 150 000 par défaut).
cache_grid_rows("GROS:TOTAL", lignes(150_000));
assert!(get_fresh("F10:TOTAL").is_none(), "l'entrée la moins récente est évincée");
assert!(get_fresh("GROS:TOTAL").is_some());
// Jamais la dernière, même au-delà des bornes.
cache_grid_rows("ENORME:TOTAL", lignes(150_001));
assert_eq!(cache_len(), 1);
invalidate_grid_rows_cache(None);
assert_eq!(cache_len(), 0);
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Accès et identifiant utilisateur, tels que les actions TS les manipulent.
use super::js;
use crate::auth::UtilisateurCourant;
use crate::error::AppResult;
use crate::rpc::Ctx;
use axum::http::StatusCode;
use serde_json::{Map, Value};
/// `verifierSession()` : `Ok(Ok(u))` si connecté, `Ok(Err(message))` pour un
/// refus d'accès (401/403) que l'action renvoie dans `{ success: false, error }`.
/// Toute autre erreur est propagée, comme le `throw e` du TS.
pub async fn verifier_session(ctx: &Ctx) -> AppResult<Result<UtilisateurCourant, String>> {
match ctx.session().await {
Ok(u) => Ok(Ok(u)),
Err(e) if e.status == StatusCode::UNAUTHORIZED || e.status == StatusCode::FORBIDDEN => Ok(Err(e.message)),
Err(e) => Err(e),
}
}
/// `rawUserId ? parseInt(rawUserId, 10) : null` — `None` pour `null`, sinon le
/// nombre (éventuellement `NaN`).
pub fn parse_user_id(raw: &str) -> Option<f64> {
if raw.is_empty() {
None
} else {
Some(js::js_parse_int(raw))
}
}
/// `userId && !isNaN(userId) ? userId : null` : le compte de secours « 0 » et les
/// identifiants non numériques valent `null` (snapshots « anonymes »).
pub fn final_user_id(raw: &str) -> Option<f64> {
parse_user_id(raw).filter(|v| *v != 0.0 && !v.is_nan())
}
/// Représentation de `${userId}` dans les journaux.
pub fn user_id_log(raw: &str) -> String {
match parse_user_id(raw) {
None => "null".into(),
Some(v) => js::js_number_to_string(v),
}
}
/// Valeur liée à une colonne `integer` : hors bornes, PostgreSQL refuse la
/// requête (« value out of range for type integer ») — même issue qu'en TS.
pub fn as_pg_integer(v: f64) -> Result<i32, String> {
if v.fract() == 0.0 && v >= i32::MIN as f64 && v <= i32::MAX as f64 {
Ok(v as i32)
} else {
Err(format!("value \"{}\" is out of range for type integer", js::js_number_to_string(v)))
}
}
/// Clés d'un objet dans l'ordre où JS les énumère : indices entiers croissants
/// d'abord (« 123456 » est un indice), puis les autres dans l'ordre d'insertion.
pub fn js_key_order(m: &Map<String, Value>) -> Vec<(&String, &Value)> {
let indice = js::js_array_index;
let mut entiers: Vec<(u64, &String, &Value)> = Vec::new();
let mut autres = Vec::new();
for (k, v) in m {
match indice(k) {
Some(i) => entiers.push((i, k, v)),
None => autres.push((k, v)),
}
}
entiers.sort_by_key(|(i, _, _)| *i);
entiers.into_iter().map(|(_, k, v)| (k, v)).chain(autres).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifiants_comme_en_ts() {
assert_eq!(final_user_id("12"), Some(12.0));
assert_eq!(final_user_id("12abc"), Some(12.0));
assert_eq!(final_user_id("0"), None);
assert_eq!(final_user_id("abc"), None);
assert_eq!(final_user_id(""), None);
assert_eq!(user_id_log("abc"), "NaN");
assert_eq!(user_id_log(""), "null");
assert_eq!(user_id_log("0"), "0");
assert!(as_pg_integer(99_999_999_999.0).is_err());
assert_eq!(as_pg_integer(-5.0), Ok(-5));
}
#[test]
fn ordre_des_cles_js() {
let v: Value = serde_json::from_str(r#"{"b":1,"20":2,"a":3,"3":4,"007":5}"#).unwrap();
let ordre: Vec<&str> = js_key_order(v.as_object().unwrap()).into_iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(ordre, vec!["3", "20", "b", "a", "007"]);
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Snapshots de la Grille (port de `src/features/snapshots/api/*.ts`) :
//! actions `getSnapshots`, `deleteSnapshot`, `saveSnapshot`.
//!
//! `session_snapshots.user_id` est un entier : l'identifiant de session est lu
//! comme le faisait le TS (`parseInt`), et le compte de secours « 0 » ou un
//! identifiant non numérique travaillent sur les snapshots « anonymes »
//! (`user_id IS NULL`). Les `revalidatePath` de Next.js n'ont plus d'objet.
use super::session::{as_pg_integer, final_user_id, js_key_order, user_id_log, verifier_session};
use super::zod::{self, child, Issues, Seg};
use crate::db;
use crate::error::{AppError, AppResult};
use crate::rpc::{json, Ctx};
use chrono::NaiveDateTime;
use serde::Serialize;
use serde_json::{json as j, Map, Value};
use sqlx::{PgPool, Row};
/// Ligne de `session_snapshots`, telle que `db.select().from(sessionSnapshots)`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSnapshot {
pub id: i32,
pub user_id: Option<i32>,
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Value,
pub summary_json: Option<Value>,
pub label: Option<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub created_at: Option<String>,
}
fn ligne_snapshot(r: &sqlx::postgres::PgRow) -> SessionSnapshot {
SessionSnapshot {
id: r.try_get("id").unwrap_or_default(),
user_id: r.try_get("user_id").ok().flatten(),
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
magasin: r.try_get("magasin").unwrap_or_default(),
changes: r.try_get("changes").unwrap_or(Value::Null),
summary_json: r.try_get("summary_json").ok().flatten(),
label: r.try_get("label").ok().flatten(),
type_: r.try_get("type").ok().flatten(),
created_at: r.try_get::<Option<NaiveDateTime>, _>("created_at").ok().flatten().map(db::iso),
}
}
// ---------------------------------------------------------------------------
// getSnapshots
// ---------------------------------------------------------------------------
/// Snapshots de l'utilisateur connecté (ou anonymes), du plus récent au plus ancien.
pub async fn lister_snapshots(user_raw_id: &str, type_: Option<&str>) -> Result<Vec<SessionSnapshot>, String> {
let pool = db::pool().map_err(|e| e.message)?;
// Si on a un userId, on filtre par celui-ci.
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
let mut sql = String::from(
"select id, user_id, code_fournisseur, nom_fournisseur, magasin, changes, summary_json, label, type, created_at \
from session_snapshots where ",
);
let user_id = match final_user_id(user_raw_id) {
Some(v) => Some(as_pg_integer(v)?),
None => None,
};
sql.push_str(if user_id.is_some() { "user_id = $1" } else { "user_id is null" });
if type_.is_some() {
sql.push_str(if user_id.is_some() { " and type = $2" } else { " and type = $1" });
}
sql.push_str(" order by created_at desc");
let mut q = sqlx::query(&sql);
if let Some(id) = user_id {
q = q.bind(id);
}
if let Some(t) = type_ {
q = q.bind(t);
}
let rows = q.fetch_all(&pool).await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(ligne_snapshot).collect())
}
/// Action `getSnapshots(type?)`.
async fn get_snapshots(ctx: Ctx) -> AppResult<Value> {
let utilisateur = ctx.session().await?;
let arg = ctx.args.first();
// `if (type)` : seule une valeur « vraie » filtre.
let type_ = super::js::js_truthy(arg).then(|| super::js::js_string(arg));
match lister_snapshots(&utilisateur.id, type_.as_deref()).await {
Ok(rows) => json(rows),
Err(msg) => {
let premiere_ligne = msg.lines().next().unwrap_or_default().to_string();
tracing::error!("[getSnapshots] ERROR (type={}): {premiere_ligne}", type_.as_deref().unwrap_or("undefined"));
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
// au lieu de signaler que le chargement a échoué.
Err(AppError::internal("Chargement de l'historique impossible."))
}
}
}
// ---------------------------------------------------------------------------
// deleteSnapshot
// ---------------------------------------------------------------------------
/// Supprime un snapshot de l'utilisateur (ou anonyme). Résultat `{ success, error? }`.
pub async fn supprimer_snapshot(user_raw_id: &str, id: &Value) -> Value {
tracing::info!("[deleteSnapshot] Request for ID: {}, Session UserId: {}", super::js::js_string(Some(id)), user_id_log(user_raw_id));
let res: Result<u64, String> = async {
let pool = db::pool().map_err(|e| e.message)?;
// Condition de sécurité : match ID ET match le propriétaire (ou anonyme)
let user_id = match final_user_id(user_raw_id) {
Some(v) => Some(as_pg_integer(v)?),
None => None,
};
let id_sql: Option<i32> = match id {
Value::Null => None,
Value::Number(n) => Some(as_pg_integer(n.as_f64().unwrap_or(f64::NAN))?),
Value::String(s) => Some(s.trim().parse::<i32>().map_err(|_| format!("invalid input syntax for type integer: \"{s}\""))?),
autre => return Err(format!("invalid input syntax for type integer: {autre}")),
};
let sql = if user_id.is_some() {
"delete from session_snapshots where (id = $1 and user_id = $2) returning id"
} else {
"delete from session_snapshots where (id = $1 and user_id is null) returning id"
};
let mut q = sqlx::query(sql).bind(id_sql);
if let Some(u) = user_id {
q = q.bind(u);
}
let rows = q.fetch_all(&pool).await.map_err(|e| e.to_string())?;
Ok(rows.len() as u64)
}
.await;
match res {
Ok(0) => {
tracing::warn!(
"[deleteSnapshot] No rows deleted for ID {}. Snapshot may not exist or belongs to another user.",
super::js::js_string(Some(id))
);
j!({ "success": false, "error": "Snapshot non trouvé ou non autorisé" })
}
Ok(_) => {
tracing::info!("[deleteSnapshot] Successfully deleted ID: {}", super::js::js_string(Some(id)));
j!({ "success": true })
}
Err(e) => {
// Le détail technique reste dans les journaux du serveur.
tracing::error!("[deleteSnapshot] DB Error: {e}");
j!({ "success": false, "error": "erreur technique sur le serveur" })
}
}
}
/// Action `deleteSnapshot(id)`.
async fn delete_snapshot(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(j!({ "success": false, "error": message })),
};
Ok(supprimer_snapshot(&utilisateur.id, &ctx.raw(0)).await)
}
// ---------------------------------------------------------------------------
// saveSnapshot
// ---------------------------------------------------------------------------
/// Données validées de `SaveSnapshotSchema` (clés inconnues retirées, comme zod).
#[derive(Debug, Clone)]
pub struct SaveSnapshotInput {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub label: Option<String>,
/// codein → `{ before, after }`.
pub changes: Map<String, Value>,
pub type_: Option<String>,
/// `{ totalRows, totalCa, totalMarge, tauxMargeGlobal }`.
pub summary: Option<Value>,
}
/// `SaveSnapshotSchema.safeParse(raw)`.
pub fn parse_save_snapshot(raw: &Value) -> Result<SaveSnapshotInput, Issues> {
let mut issues = Issues::default();
let racine: Vec<Seg> = vec![];
let Some(o) = zod::object(Some(raw), &racine, &mut issues) else { return Err(issues) };
let p = |k: &str| vec![Seg::from(k)];
let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues);
let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues);
let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues);
let label = zod::opt_string(o.get("label"), &p("label"), &mut issues);
let mut changes = Map::new();
let chemin_changes = p("changes");
match o.get("changes") {
Some(Value::Object(m)) => {
for (k, v) in js_key_order(m) {
let chemin = child(&chemin_changes, k.as_str());
let Some(entree) = zod::object(Some(v), &chemin, &mut issues) else { continue };
let before = zod::nullable_string(entree.get("before"), &child(&chemin, "before"), &mut issues);
let after = zod::string(entree.get("after"), &child(&chemin, "after"), &mut issues);
if let (Some(before), Some(after)) = (before, after) {
changes.insert(k.clone(), j!({ "before": before, "after": after }));
}
}
}
autre => issues.invalid_type(&chemin_changes, "record", autre),
}
let type_ = match o.get("type") {
None => Some(None),
v => zod::enum_value(v, &["snapshot", "export"], &p("type"), None, &mut issues).map(Some),
};
let summary = match o.get("summary") {
None => Some(None),
v => {
let chemin = p("summary");
match zod::object(v, &chemin, &mut issues) {
Some(s) => {
let mut out = Map::new();
let mut complet = true;
for k in ["totalRows", "totalCa", "totalMarge", "tauxMargeGlobal"] {
match zod::number(s.get(k), &child(&chemin, k), &mut issues) {
Some(_) => {
out.insert(k.to_string(), s[k].clone());
}
None => complet = false,
}
}
complet.then_some(Some(Value::Object(out)))
}
None => None,
}
}
};
if !issues.is_empty() {
return Err(issues);
}
Ok(SaveSnapshotInput {
code_fournisseur: code_fournisseur.unwrap_or_default(),
nom_fournisseur: nom_fournisseur.flatten(),
magasin: magasin.unwrap_or_default(),
label: label.flatten(),
changes,
type_: type_.flatten(),
summary: summary.flatten(),
})
}
/// Date du jour façon `toLocaleDateString("fr-FR")`.
pub fn date_fr() -> String {
chrono::Local::now().format("%d/%m/%Y").to_string()
}
async fn inserer_snapshot(pool: &PgPool, user_id: Option<f64>, d: &SaveSnapshotInput) -> Result<i32, sqlx::Error> {
let label = d
.label
.clone()
.unwrap_or_else(|| format!("{} — {}", if d.type_.as_deref() == Some("export") { "Export" } else { "Snapshot" }, date_fr()));
// L'identifiant est lié en entier 64 bits : hors bornes de la colonne
// `integer`, PostgreSQL refuse l'insertion, comme pour la version Node.
let row = sqlx::query(
"insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \
values ($1, $2, $3, $4, $5, $6, $7, $8) returning id",
)
.bind(user_id.map(|v| v as i64))
.bind(&d.code_fournisseur)
.bind(&d.nom_fournisseur)
.bind(&d.magasin)
.bind(label)
.bind(Value::Object(d.changes.clone()))
.bind(d.summary.clone())
.bind(d.type_.clone().unwrap_or_else(|| "snapshot".into()))
.fetch_one(pool)
.await?;
row.try_get("id")
}
/// Enregistre un snapshot pour l'utilisateur. Résultat `{ success, snapshotId?, error? }`.
pub async fn enregistrer_snapshot(user_raw_id: &str, raw: &Value) -> Value {
let d = match parse_save_snapshot(raw) {
Ok(d) => d,
Err(issues) => {
tracing::error!("Snapshot validation failed: {}", serde_json::to_string(&issues.to_json()).unwrap_or_default());
return j!({ "success": false, "error": format!("Validation failed: {}", issues.messages_joined()) });
}
};
tracing::info!(
"[saveSnapshot] User: {} (raw: {user_raw_id}), Supplier: {}, Type: {}",
user_id_log(user_raw_id),
d.code_fournisseur,
d.type_.as_deref().unwrap_or("undefined")
);
let final_id = final_user_id(user_raw_id);
let pool = match db::pool() {
Ok(p) => p,
Err(e) => {
tracing::error!("Snapshot auto-repair/retry failed: {}", e.message);
return j!({ "success": false, "error": "erreur technique sur le serveur" });
}
};
match inserer_snapshot(&pool, final_id, &d).await {
Ok(id) => j!({ "success": true, "snapshotId": id }),
Err(err) => {
tracing::error!("Initial snapshot save failed, attempting auto-repair... {err}");
let reparation = async {
// Tentative systématique de création de table (no-op si déjà là)
sqlx::raw_sql(
"CREATE TABLE IF NOT EXISTS session_snapshots (
id SERIAL PRIMARY KEY,
user_id INTEGER,
code_fournisseur VARCHAR(20) NOT NULL,
nom_fournisseur VARCHAR(255),
magasin VARCHAR(20) NOT NULL,
changes JSONB NOT NULL,
summary_json JSONB,
label TEXT,
type VARCHAR(20) DEFAULT 'snapshot',
created_at TIMESTAMP DEFAULT NOW()
);",
)
.execute(&pool)
.await?;
// Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute
let alter = async {
sqlx::raw_sql("ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'")
.execute(&pool)
.await?;
sqlx::raw_sql("ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER").execute(&pool).await
};
let _ = alter.await; // Ignore
// Deuxième tentative d'insertion
inserer_snapshot(&pool, final_id, &d).await
};
match reparation.await {
Ok(id) => j!({ "success": true, "snapshotId": id }),
Err(e) => {
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
tracing::error!("Snapshot auto-repair/retry failed: {e}");
j!({ "success": false, "error": "erreur technique sur le serveur" })
}
}
}
}
}
/// Action `saveSnapshot(raw)`.
async fn save_snapshot(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(j!({ "success": false, "error": message })),
};
Ok(enregistrer_snapshot(&utilisateur.id, &ctx.raw(0)).await)
}
pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> {
vec![
("getSnapshots", crate::handler!(get_snapshots)),
("deleteSnapshot", crate::handler!(delete_snapshot)),
("saveSnapshot", crate::handler!(save_snapshot)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_comme_zod() {
let raw: Value = serde_json::from_str(
r#"{"codeFournisseur":"1","magasin":"T","changes":{"a":{"before":1},"b":3},"type":"x","summary":{"totalRows":"1"}}"#,
)
.unwrap();
let issues = parse_save_snapshot(&raw).unwrap_err();
assert_eq!(
serde_json::to_string(&issues.to_json()).unwrap(),
r#"[{"expected":"string","code":"invalid_type","path":["changes","a","before"],"message":"Invalid input: expected string, received number"},{"expected":"string","code":"invalid_type","path":["changes","a","after"],"message":"Invalid input: expected string, received undefined"},{"expected":"object","code":"invalid_type","path":["changes","b"],"message":"Invalid input: expected object, received number"},{"code":"invalid_value","values":["snapshot","export"],"path":["type"],"message":"Invalid option: expected one of \"snapshot\"|\"export\""},{"expected":"number","code":"invalid_type","path":["summary","totalRows"],"message":"Invalid input: expected number, received string"},{"expected":"number","code":"invalid_type","path":["summary","totalCa"],"message":"Invalid input: expected number, received undefined"},{"expected":"number","code":"invalid_type","path":["summary","totalMarge"],"message":"Invalid input: expected number, received undefined"},{"expected":"number","code":"invalid_type","path":["summary","tauxMargeGlobal"],"message":"Invalid input: expected number, received undefined"}]"#
);
let vide = parse_save_snapshot(&j!({})).unwrap_err();
assert_eq!(
vide.messages_joined(),
"Invalid input: expected string, received undefined, Invalid input: expected string, received undefined, Invalid input: expected record, received undefined"
);
assert!(parse_save_snapshot(&j!({ "codeFournisseur": "1", "magasin": "T", "changes": [] })).is_err());
}
#[test]
fn donnees_nettoyees() {
let raw = j!({
"codeFournisseur": "F1", "magasin": "TOTAL", "inconnu": true,
"changes": { "200": { "before": null, "after": "A", "x": 1 }, "100": { "before": "B", "after": "Z" } },
"summary": { "totalRows": 2, "totalCa": 10.5, "totalMarge": 3, "tauxMargeGlobal": 28.5, "y": 0 }
});
let d = parse_save_snapshot(&raw).unwrap();
assert_eq!(serde_json::to_string(&d.changes).unwrap(), r#"{"100":{"before":"B","after":"Z"},"200":{"before":null,"after":"A"}}"#);
assert_eq!(d.summary.unwrap(), j!({ "totalRows": 2, "totalCa": 10.5, "totalMarge": 3, "tauxMargeGlobal": 28.5 }));
assert_eq!(d.type_, None);
assert_eq!(d.label, None);
}
}
+723
View File
@@ -0,0 +1,723 @@
//! CollectFlow — Instantané persisté de la grille (table `grid_rows`),
//! port de `src/lib/grid-store.ts`.
//!
//! `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base
//! miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1`
//! ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de
//! bord quand quelqu'un ouvre la Grille.
//!
//! On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat
//! qui existe déjà, et il survit désormais aux redémarrages.
use super::js;
use super::types::ProductRow;
use crate::db;
use crate::error::{AppError, AppResult};
use chrono::{Datelike, NaiveDateTime, TimeZone};
use serde::Serialize;
use serde_json::Value;
use sqlx::{Postgres, QueryBuilder, Row};
use std::collections::HashMap;
/// Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GridSortKey {
Libelle1,
Codein,
CodeFournisseur,
CodeGamme,
TotalCa,
TotalQuantite,
TotalMarge,
TauxMarge,
StockActuel,
CaReseau,
QteReseau,
NbMagasinsReseau,
CaParMagasinReseau,
ComputedAt,
}
/// `SORTABLE` : clé exposée → colonne SQL.
const SORTABLE: [(GridSortKey, &str, &str); 14] = [
(GridSortKey::Libelle1, "libelle1", "libelle1"),
(GridSortKey::Codein, "codein", "codein"),
(GridSortKey::CodeFournisseur, "codeFournisseur", "code_fournisseur"),
(GridSortKey::CodeGamme, "codeGamme", "code_gamme"),
(GridSortKey::TotalCa, "totalCa", "total_ca"),
(GridSortKey::TotalQuantite, "totalQuantite", "total_quantite"),
(GridSortKey::TotalMarge, "totalMarge", "total_marge"),
(GridSortKey::TauxMarge, "tauxMarge", "taux_marge"),
(GridSortKey::StockActuel, "stockActuel", "stock_actuel"),
(GridSortKey::CaReseau, "caReseau", "ca_reseau"),
(GridSortKey::QteReseau, "qteReseau", "qte_reseau"),
(GridSortKey::NbMagasinsReseau, "nbMagasinsReseau", "nb_magasins_reseau"),
(GridSortKey::CaParMagasinReseau, "caParMagasinReseau", "ca_par_magasin_reseau"),
(GridSortKey::ComputedAt, "computedAt", "computed_at"),
];
/// `GRID_SORT_KEYS`, dans l'ordre de `SORTABLE`.
pub const GRID_SORT_KEYS: [&str; 14] = [
"libelle1",
"codein",
"codeFournisseur",
"codeGamme",
"totalCa",
"totalQuantite",
"totalMarge",
"tauxMarge",
"stockActuel",
"caReseau",
"qteReseau",
"nbMagasinsReseau",
"caParMagasinReseau",
"computedAt",
];
impl GridSortKey {
/// Clé de tri depuis son nom exposé ; `None` hors liste blanche.
pub fn from_key(key: &str) -> Option<Self> {
SORTABLE.iter().find(|(_, k, _)| *k == key).map(|(s, _, _)| *s)
}
pub fn as_str(self) -> &'static str {
SORTABLE.iter().find(|(s, _, _)| *s == self).map(|(_, k, _)| *k).unwrap_or("totalCa")
}
/// Colonne SQL (constante du code, jamais l'entrée utilisateur).
fn column(self) -> &'static str {
SORTABLE.iter().find(|(s, _, _)| *s == self).map(|(_, _, c)| *c).unwrap_or("total_ca")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
Asc,
#[default]
Desc,
}
#[derive(Debug, Clone, Default)]
pub struct GridQuery {
pub code_fournisseur: Option<String>,
pub gamme: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
pub code3: Option<String>,
/// Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3.
pub nomenclature: Option<String>,
/// Recherche libre : libellé, codein, GTIN, référence, code centrale.
pub search: Option<String>,
pub sort: Option<GridSortKey>,
pub order: Option<SortOrder>,
pub page: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridQueryResult {
pub rows: Vec<ProductRow>,
pub total: i64,
/// Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée.
pub computed_at: Option<String>,
}
/// Version du format des lignes persistées. À incrémenter dès qu'un champ de
/// `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne
/// seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1.
pub const GRID_PAYLOAD_VERSION: f64 = 1.0;
/// Lots d'écriture : payload jsonb volumineux, lots plus petits que pour des scalaires.
const CHUNK_UPSERT: usize = 200;
/// Bien en deçà des 65 535 paramètres liés de PostgreSQL.
const CHUNK_CODEINS: usize = 5000;
/// Chaîne non vide (`if (q.x)` côté TS).
fn renseigne(s: &Option<String>) -> Option<&str> {
s.as_deref().filter(|v| !v.is_empty())
}
/// Horodatage façon `new Date()` stocké par drizzle (UTC, à la milliseconde).
fn maintenant_utc_ms() -> NaiveDateTime {
use chrono::Timelike;
let now = chrono::Utc::now().naive_utc();
now.with_nanosecond(now.nanosecond() / 1_000_000 * 1_000_000).unwrap_or(now)
}
/// Colonne `numeric` : le TS passe `String(n)` ; on fait de même (texte casté).
fn push_numeric(b: &mut sqlx::query_builder::Separated<'_, '_, Postgres, &'static str>, v: Option<f64>) {
b.push("CAST(");
b.push_bind_unseparated(v.map(js::numeric_text));
b.push_unseparated(" AS numeric)");
}
/// Persiste les lignes calculées pour un fournisseur.
///
/// Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime
/// celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles
/// disparus du catalogue sans avoir à passer des centaines de codein en paramètres.
pub async fn upsert_grid_rows(code_fournisseur: &str, rows: &[ProductRow]) -> AppResult<usize> {
if code_fournisseur.is_empty() || rows.is_empty() {
return Ok(0);
}
let values: Vec<&ProductRow> = rows.iter().filter(|r| !r.codein.is_empty()).collect();
upsert_lots(code_fournisseur, values.len(), |plage| values[plage].iter().map(|r| (*r).clone()).collect()).await
}
/// Même écriture depuis les lignes partagées du cache mémoire : chaque lot est
/// copié sous verrou, puis écrit sans le tenir (pas de copie intégrale d'un
/// fournisseur de 130 000 articles).
pub async fn upsert_grid_rows_shared(code_fournisseur: &str, rows: &super::rows_cache::GridRows) -> AppResult<usize> {
let indices: Vec<usize> = {
let lignes = rows.rows.read();
if code_fournisseur.is_empty() || lignes.is_empty() {
return Ok(0);
}
lignes.iter().enumerate().filter(|(_, r)| !r.codein.is_empty()).map(|(i, _)| i).collect()
};
upsert_lots(code_fournisseur, indices.len(), |plage| {
let lignes = rows.rows.read();
indices[plage].iter().filter_map(|i| lignes.get(*i).cloned()).collect()
})
.await
}
/// Écrit `nb` lignes par lots de [`CHUNK_UPSERT`] (`lot(plage)` fournit les lignes
/// d'une plage), puis purge les lignes du fournisseur restées sur un calcul antérieur.
async fn upsert_lots(code_fournisseur: &str, nb: usize, lot: impl Fn(std::ops::Range<usize>) -> Vec<ProductRow>) -> AppResult<usize> {
if nb == 0 {
return Ok(0);
}
let pool = db::pool()?;
let computed_at = maintenant_utc_ms();
let mut written = 0usize;
for debut in (0..nb).step_by(CHUNK_UPSERT) {
let batch = lot(debut..(debut + CHUNK_UPSERT).min(nb));
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
"insert into grid_rows (code_fournisseur, codein, nom_fournisseur, libelle1, gtin, reference, \
code_centrale, code1, code2, code3, code_gamme, code_gamme_init, total_ca, total_quantite, \
total_marge, taux_marge, stock_actuel, ca_reseau, qte_reseau, nb_magasins_reseau, \
ca_par_magasin_reseau, marge_pct_reseau, payload, computed_at) ",
);
let mut erreur_payload = None;
qb.push_values(batch.iter(), |mut b, r| {
let mut payload = r.clone();
payload.payload_version = Some(GRID_PAYLOAD_VERSION);
let payload = serde_json::to_value(&payload).unwrap_or_else(|e| {
erreur_payload = Some(e.to_string());
Value::Null
});
b.push_bind(code_fournisseur.to_string())
.push_bind(r.codein.clone())
.push_bind(r.nom_fournisseur.clone())
.push_bind(r.libelle1.clone())
.push_bind(r.gtin.clone())
.push_bind(r.reference.clone())
.push_bind(r.code_centrale.clone())
.push_bind(r.code1.clone())
.push_bind(r.code2.clone())
.push_bind(r.code3.clone())
.push_bind(r.code_gamme.clone())
.push_bind(r.code_gamme_init.clone());
push_numeric(&mut b, Some(r.total_ca));
push_numeric(&mut b, Some(r.total_quantite));
push_numeric(&mut b, Some(r.total_marge));
push_numeric(&mut b, Some(r.taux_marge));
push_numeric(&mut b, r.stock_actuel);
push_numeric(&mut b, r.ca_reseau);
push_numeric(&mut b, r.qte_reseau);
// Colonne integer : le TS passe le nombre tel quel.
b.push("CAST(");
b.push_bind_unseparated(r.nb_magasins_reseau.map(js::numeric_text));
b.push_unseparated(" AS integer)");
push_numeric(&mut b, r.ca_par_magasin_reseau);
push_numeric(&mut b, r.marge_pct_reseau);
b.push_bind(payload).push_bind(computed_at);
});
if let Some(e) = erreur_payload {
return Err(AppError::internal(e));
}
qb.push(
" on conflict (code_fournisseur, codein) do update set \
nom_fournisseur = excluded.nom_fournisseur, libelle1 = excluded.libelle1, gtin = excluded.gtin, \
reference = excluded.reference, code_centrale = excluded.code_centrale, code1 = excluded.code1, \
code2 = excluded.code2, code3 = excluded.code3, code_gamme = excluded.code_gamme, \
code_gamme_init = excluded.code_gamme_init, total_ca = excluded.total_ca, \
total_quantite = excluded.total_quantite, total_marge = excluded.total_marge, \
taux_marge = excluded.taux_marge, stock_actuel = excluded.stock_actuel, ca_reseau = excluded.ca_reseau, \
qte_reseau = excluded.qte_reseau, nb_magasins_reseau = excluded.nb_magasins_reseau, \
ca_par_magasin_reseau = excluded.ca_par_magasin_reseau, marge_pct_reseau = excluded.marge_pct_reseau, \
payload = excluded.payload, computed_at = excluded.computed_at",
);
qb.build().execute(&pool).await?;
written += batch.len();
}
// Purge des articles qui ne font plus partie du catalogue du fournisseur.
sqlx::query("delete from grid_rows where code_fournisseur = $1 and computed_at < $2")
.bind(code_fournisseur)
.bind(computed_at)
.execute(&pool)
.await?;
Ok(written)
}
/// Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
pub struct GridSnapshot {
pub rows: Vec<ProductRow>,
/// Calcul le plus ancien des lignes (UTC).
pub computed_at: NaiveDateTime,
}
/// Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
///
/// Renvoie `None` (→ recalcul) si rien n'est persisté, si une ligne est d'une
/// version de format antérieure, ou si le calcul date d'un autre mois que le mois
/// en cours : la fenêtre des 12 mois glissants aurait changé entre-temps.
/// Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article).
pub async fn read_grid_snapshot(code_fournisseur: &str) -> AppResult<Option<GridSnapshot>> {
let pool = db::pool()?;
let found = sqlx::query("select payload, computed_at from grid_rows where code_fournisseur = $1")
.bind(code_fournisseur)
.fetch_all(&pool)
.await?;
if found.is_empty() {
return Ok(None);
}
let mut oldest: Option<NaiveDateTime> = None;
let mut rows = Vec::with_capacity(found.len());
for r in &found {
let payload: Value = r.try_get("payload")?;
if payload.get("payloadVersion").and_then(Value::as_f64) != Some(GRID_PAYLOAD_VERSION) {
return Ok(None);
}
let row: ProductRow = match serde_json::from_value(payload) {
Ok(row) => row,
Err(e) => {
tracing::warn!("[grid-store] payload illisible pour {code_fournisseur} : {}", crate::error::short(&e, 200));
return Ok(None);
}
};
let computed_at: NaiveDateTime = r.try_get("computed_at")?;
if oldest.map(|o| computed_at < o).unwrap_or(true) {
oldest = Some(computed_at);
}
rows.push(row);
}
let Some(oldest) = oldest else { return Ok(None) };
// Comparaison en heure locale, comme `getFullYear()` / `getMonth()`.
let local = chrono::Local.from_utc_datetime(&oldest);
let now = chrono::Local::now();
if local.year() != now.year() || local.month() != now.month() {
return Ok(None);
}
rows.sort_by(|a, b| {
let (x, y) = (a.noid.unwrap_or(0.0), b.noid.unwrap_or(0.0));
(x - y).partial_cmp(&0.0).unwrap_or(std::cmp::Ordering::Equal)
});
Ok(Some(GridSnapshot { rows, computed_at: oldest }))
}
/// Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload),
/// pour que /api/v1 les voie sans attendre un recalcul complet de la grille.
pub async fn update_grid_rows_gamme<'a>(code_fournisseur: &str, changes: impl IntoIterator<Item = (&'a str, &'a str)>) -> AppResult<()> {
// Map TS : ordre de première apparition de chaque gamme.
let mut par_gamme: Vec<(String, Vec<String>)> = Vec::new();
for (codein, gamme) in changes {
match par_gamme.iter_mut().find(|(g, _)| g == gamme) {
Some((_, codeins)) => codeins.push(codein.to_string()),
None => par_gamme.push((gamme.to_string(), vec![codein.to_string()])),
}
}
if par_gamme.is_empty() {
return Ok(());
}
let pool = db::pool()?;
for (gamme, codeins) in &par_gamme {
for lot in codeins.chunks(CHUNK_CODEINS) {
sqlx::query(
"update grid_rows set code_gamme = $1, payload = jsonb_set(payload, '{codeGamme}', to_jsonb($2::text)) \
where code_fournisseur = $3 and codein = any($4)",
)
.bind(gamme)
.bind(gamme)
.bind(code_fournisseur)
.bind(lot)
.execute(&pool)
.await?;
}
}
Ok(())
}
/// Construit la clause WHERE commune à la grille et à la recherche transversale.
fn push_where(qb: &mut QueryBuilder<'_, Postgres>, q: &GridQuery) {
let mut premiere = true;
let mut et = |qb: &mut QueryBuilder<'_, Postgres>| {
qb.push(if premiere { " where (" } else { " and " });
premiere = false;
};
if let Some(v) = renseigne(&q.code_fournisseur) {
et(qb);
qb.push("code_fournisseur = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.gamme) {
et(qb);
qb.push("code_gamme = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code1) {
et(qb);
qb.push("code1 = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code2) {
et(qb);
qb.push("code2 = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code3) {
et(qb);
qb.push("code3 = ").push_bind(v.to_string());
}
// Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ».
// C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de
// code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée.
if let Some(v) = renseigne(&q.nomenclature) {
et(qb);
qb.push("code3 like ").push_bind(format!("{v}%"));
}
if let Some(search) = q.search.as_deref().map(js::js_trim).filter(|s| !s.is_empty()) {
let pattern = format!("%{search}%");
et(qb);
qb.push("(libelle1 ilike ")
.push_bind(pattern.clone())
.push(" or codein ilike ")
.push_bind(pattern.clone())
.push(" or gtin ilike ")
.push_bind(pattern.clone())
.push(" or reference ilike ")
.push_bind(pattern.clone())
.push(" or code_centrale ilike ")
.push_bind(pattern)
.push(")");
}
if !premiere {
qb.push(")");
}
}
/// Ligne exposée par l'API : la version de format est un détail interne.
fn vers_ligne_api(payload: Value) -> AppResult<ProductRow> {
let mut ligne: ProductRow =
serde_json::from_value(payload).map_err(|e| AppError::internal(format!("payload grid_rows illisible : {e}")))?;
ligne.payload_version = None;
Ok(ligne)
}
/// Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL.
/// Ne déclenche aucun recalcul et ne contacte jamais Qlik.
pub async fn query_grid_rows(q: &GridQuery) -> AppResult<GridQueryResult> {
let pool = db::pool()?;
let page = q.page.unwrap_or(1).max(1);
// `limit` absent = aucune limite : on renvoie toutes les lignes du filtre.
let limit = q.limit.map(|l| l.max(1));
let sort_col = q.sort.unwrap_or(GridSortKey::TotalCa).column();
let sens = if q.order == Some(SortOrder::Asc) { "asc" } else { "desc" };
let mut selection: QueryBuilder<Postgres> = QueryBuilder::new("select payload, computed_at from grid_rows");
push_where(&mut selection, q);
// Mêmes ORDER BY que drizzle (`desc(col)`, `asc(codein)`).
selection.push(format!(" order by {sort_col} {sens}, codein asc"));
if let Some(l) = limit {
selection.push(" limit ").push_bind(l).push(" offset ").push_bind((page - 1) * l);
}
let mut comptage: QueryBuilder<Postgres> = QueryBuilder::new("select count(*)::int as total from grid_rows");
push_where(&mut comptage, q);
// Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente.
let (count_row, found) = tokio::try_join!(comptage.build().fetch_one(&pool), selection.build().fetch_all(&pool))?;
let mut oldest: Option<NaiveDateTime> = None;
let mut rows = Vec::with_capacity(found.len());
for r in &found {
if let Ok(Some(c)) = r.try_get::<Option<NaiveDateTime>, _>("computed_at") {
if oldest.map(|o| c < o).unwrap_or(true) {
oldest = Some(c);
}
}
rows.push(vers_ligne_api(r.try_get("payload")?)?);
}
Ok(GridQueryResult { rows, total: count_row.try_get::<i32, _>("total").map(i64::from).unwrap_or(0), computed_at: oldest.map(db::iso) })
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridRowFound {
pub row: ProductRow,
pub computed_at: Option<String>,
}
/// Fiche complète d'un produit. `code_fournisseur` lève l'ambiguïté d'un article multi-fournisseurs.
pub async fn get_grid_row_by_codein(codein: &str, code_fournisseur: Option<&str>) -> AppResult<Option<GridRowFound>> {
let pool = db::pool()?;
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select payload, computed_at from grid_rows where (codein = ");
qb.push_bind(codein.to_string());
if let Some(f) = code_fournisseur.filter(|f| !f.is_empty()) {
qb.push(" and code_fournisseur = ").push_bind(f.to_string());
}
qb.push(") order by computed_at desc limit 1");
let Some(found) = qb.build().fetch_optional(&pool).await? else {
return Ok(None);
};
let computed_at: Option<NaiveDateTime> = found.try_get("computed_at").ok().flatten();
Ok(Some(GridRowFound { row: vers_ligne_api(found.try_get("payload")?)?, computed_at: computed_at.map(db::iso) }))
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridSupplierRef {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
}
/// Fournisseurs chez lesquels un article figure dans l'instantané.
pub async fn list_grid_suppliers_for_codein(codein: &str) -> AppResult<Vec<GridSupplierRef>> {
let pool = db::pool()?;
let rows = sqlx::query("select code_fournisseur, nom_fournisseur from grid_rows where codein = $1 order by code_fournisseur asc")
.bind(codein)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| GridSupplierRef {
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
})
.collect())
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GammeInstantane {
/// Gamme courante, modifications enregistrées comprises.
pub code_gamme: Option<String>,
/// Gamme serveur au moment du calcul de la grille.
pub code_gamme_init: Option<String>,
pub nom_fournisseur: Option<String>,
}
/// Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la
/// carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé).
pub async fn get_grid_rows_gammes(code_fournisseur: &str, codeins: &[String]) -> AppResult<HashMap<String, GammeInstantane>> {
let mut out = HashMap::new();
if codeins.is_empty() {
return Ok(out);
}
let pool = db::pool()?;
for lot in codeins.chunks(CHUNK_CODEINS) {
let found = sqlx::query(
"select codein, code_gamme, code_gamme_init, nom_fournisseur from grid_rows \
where code_fournisseur = $1 and codein = any($2)",
)
.bind(code_fournisseur)
.bind(lot)
.fetch_all(&pool)
.await?;
for r in &found {
out.insert(
r.try_get::<String, _>("codein").unwrap_or_default(),
GammeInstantane {
code_gamme: r.try_get("code_gamme").ok().flatten(),
code_gamme_init: r.try_get("code_gamme_init").ok().flatten(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
},
);
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridFreshness {
pub row_count: i64,
pub computed_at: Option<String>,
}
/// Indique si un fournisseur a déjà été calculé au moins une fois.
/// Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
pub async fn get_grid_freshness(code_fournisseur: &str) -> AppResult<Option<GridFreshness>> {
let pool = db::pool()?;
let found =
sqlx::query("select count(*)::int as row_count, max(computed_at) as computed_at from grid_rows where code_fournisseur = $1")
.bind(code_fournisseur)
.fetch_one(&pool)
.await?;
let row_count = found.try_get::<i32, _>("row_count").map(i64::from).unwrap_or(0);
if row_count == 0 {
return Ok(None);
}
let computed_at: Option<NaiveDateTime> = found.try_get("computed_at").ok().flatten();
Ok(Some(GridFreshness { row_count, computed_at: computed_at.map(db::iso) }))
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridSupplier {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub row_count: i64,
pub computed_at: Option<String>,
}
/// Fournisseurs présents dans l'instantané, avec leur fraîcheur.
pub async fn list_grid_suppliers() -> AppResult<Vec<GridSupplier>> {
let pool = db::pool()?;
let found = sqlx::query(
"select code_fournisseur, max(nom_fournisseur) as nom_fournisseur, count(*)::int as row_count, \
max(computed_at) as computed_at from grid_rows group by code_fournisseur order by code_fournisseur asc",
)
.fetch_all(&pool)
.await?;
Ok(found
.iter()
.map(|r| GridSupplier {
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
row_count: r.try_get::<i32, _>("row_count").map(i64::from).unwrap_or(0),
computed_at: r.try_get::<Option<NaiveDateTime>, _>("computed_at").ok().flatten().map(db::iso),
})
.collect())
}
// ---------------------------------------------------------------------------
// Inventaire des nomenclatures d'un fournisseur
// ---------------------------------------------------------------------------
/// Un poste de nomenclature, avec son poids — de quoi décider par où découper.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridNomenclature {
pub code: String,
/// Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée).
pub libelle: Option<String>,
pub nb_articles: i64,
#[serde(serialize_with = "js::num::serialize")]
pub total_ca: f64,
#[serde(serialize_with = "js::num::serialize")]
pub total_quantite: f64,
}
/// Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ».
pub const NOMENCLATURE_LONGUEURS: [(u8, i32); 3] = [(1, 2), (2, 4), (3, 6)];
/// Longueur du code d'un niveau (1, 2 ou 3).
pub fn nomenclature_longueur(niveau: u8) -> i32 {
NOMENCLATURE_LONGUEURS.iter().find(|(n, _)| *n == niveau).map(|(_, l)| *l).unwrap_or(2)
}
/// Répartition des articles d'un fournisseur par niveau de nomenclature.
///
/// Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature
/// est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles**
/// valeurs existent — il devrait tout télécharger pour les découvrir, ce qui
/// annule le bénéfice du découpage.
///
/// Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6
/// chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci
/// ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la
/// hiérarchie ; quand elle échoue, seul `code3` est rempli. Le préfixe, lui, est
/// toujours disponible.
///
/// Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
/// un fournisseur de 130 000 articles.
///
/// Écart volontaire avec le TS : drizzle répétait `left(code3, $n)` dans le
/// GROUP BY avec un **autre** paramètre, ce que PostgreSQL refuse (« must appear
/// in the GROUP BY clause ») — la requête échouait systématiquement. On groupe
/// ici par position (`group by 1`), même résultat attendu.
pub async fn list_grid_nomenclatures(code_fournisseur: &str, niveau: u8, parent: Option<&str>) -> AppResult<Vec<GridNomenclature>> {
let pool = db::pool()?;
let longueur = nomenclature_longueur(niveau);
// Les libellés n'ont pas de colonne dédiée : on les lit dans le payload.
// Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ;
// le code et les compteurs, eux, restent justes.
let libelle_key = match niveau {
1 => "libelleNiveau1",
2 => "libelleNiveau2",
_ => "libelle3",
};
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select left(code3, ");
qb.push_bind(longueur)
.push(") as code, max(payload ->> ")
.push_bind(libelle_key)
.push(") as libelle, count(*)::int as nb_articles, coalesce(sum(total_ca), 0)::float as total_ca, ")
.push("coalesce(sum(total_quantite), 0)::float as total_quantite from grid_rows where (code_fournisseur = ")
.push_bind(code_fournisseur.to_string())
.push(" and code3 is not null and length(code3) >= ")
.push_bind(longueur);
// `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2.
if let Some(p) = parent.filter(|p| !p.is_empty()) {
qb.push(" and code3 like ").push_bind(format!("{p}%"));
}
qb.push(") group by 1 order by coalesce(sum(total_ca), 0) desc");
let rows = qb.build().fetch_all(&pool).await?;
Ok(rows
.iter()
.map(|r| GridNomenclature {
code: db::string_or_empty(r, "code"),
libelle: r.try_get("libelle").ok().flatten(),
nb_articles: db::f64_or0(r, "nb_articles") as i64,
total_ca: db::f64_or0(r, "total_ca"),
total_quantite: db::f64_or0(r, "total_quantite"),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn liste_blanche_du_tri() {
assert_eq!(GRID_SORT_KEYS.len(), SORTABLE.len());
for (i, (_, k, _)) in SORTABLE.iter().enumerate() {
assert_eq!(GRID_SORT_KEYS[i], *k);
assert_eq!(GridSortKey::from_key(k).unwrap().as_str(), *k);
}
assert_eq!(GridSortKey::from_key("total_ca; drop table x"), None);
assert_eq!(GridSortKey::TotalCa.column(), "total_ca");
}
#[test]
fn clause_where() {
let q = GridQuery {
code_fournisseur: Some("F1".into()),
gamme: Some(String::new()),
nomenclature: Some("32".into()),
search: Some(" bougie ".into()),
..Default::default()
};
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select 1 from grid_rows");
push_where(&mut qb, &q);
assert_eq!(
qb.sql(),
"select 1 from grid_rows where (code_fournisseur = $1 and code3 like $2 and (libelle1 ilike $3 or codein ilike $4 or gtin ilike $5 or reference ilike $6 or code_centrale ilike $7))"
);
let mut vide: QueryBuilder<Postgres> = QueryBuilder::new("select 1 from grid_rows");
push_where(&mut vide, &GridQuery::default());
assert_eq!(vide.sql(), "select 1 from grid_rows");
}
#[test]
fn longueurs_de_nomenclature() {
assert_eq!(nomenclature_longueur(1), 2);
assert_eq!(nomenclature_longueur(2), 4);
assert_eq!(nomenclature_longueur(3), 6);
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Complément des chiffres d'un magasin par l'API FF (ventes mensuelles) — partie
//! pure de `src/features/grid/lib/store-patch.ts`.
//!
//! Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
//! articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
//! pour ces articles seulement, les séries et totaux du magasin.
use super::types::{ProductRow, StorePatchEntry};
use crate::ff::api::FfMensuelEntry;
use crate::ff::js::js_number_or0;
use std::collections::HashMap;
/// Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes.
///
/// `retenus` : codes article des candidats retenus (cf. [`store_patch_candidates`]).
pub fn build_store_patch_entries(
retenus: &[String],
magasin: &str,
periods: &[String],
mensuel: &HashMap<String, Vec<FfMensuelEntry>>,
) -> Vec<StorePatchEntry> {
struct Valeurs {
qty: f64,
ca: f64,
marge: f64,
stock: f64,
receptions: f64,
}
let mut resultat = Vec::new();
for codein in retenus {
let entries: Vec<&FfMensuelEntry> =
mensuel.get(codein).map(|v| v.iter().filter(|e| e.site == magasin).collect()).unwrap_or_default();
if entries.is_empty() {
continue;
}
let mut by_period: HashMap<String, Valeurs> = HashMap::new();
for entry in entries {
let period = entry.mois.replacen('-', "", 1);
if !periods.contains(&period) {
continue;
}
let ventes = entry.ventes.as_ref();
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
// les nie au lieu de Math.abs pour que les retours restent déduits.
let qty = -ventes.map(|v| js_number_or0(&v.qte_vendue)).unwrap_or(0.0);
let ca = -ventes.map(|v| js_number_or0(&v.ca_ht)).unwrap_or(0.0);
let marge = ventes.map(|v| js_number_or0(&v.marge)).unwrap_or(0.0);
let stock = js_number_or0(&entry.stock_fin_mois);
let receptions = entry.receptions.as_ref().map(|r| js_number_or0(&r.qte_recue)).unwrap_or(0.0);
by_period.insert(period, Valeurs { qty, ca, marge, stock, receptions });
}
let api_qty: f64 = by_period.values().map(|v| v.qty).sum();
if api_qty == 0.0 {
continue;
}
let mut e = StorePatchEntry { codein: codein.clone(), ..Default::default() };
let mut last_stock = 0.0;
for period in periods {
let value = by_period.get(period);
e.sales.insert(period.clone(), value.map(|v| v.qty).unwrap_or(0.0));
e.receptions.insert(period.clone(), value.map(|v| v.receptions).unwrap_or(0.0));
if let Some(v) = value {
last_stock = v.stock;
}
e.stock.insert(period.clone(), last_stock);
e.quantite += value.map(|v| v.qty).unwrap_or(0.0);
e.ca += value.map(|v| v.ca).unwrap_or(0.0);
e.marge += value.map(|v| v.marge).unwrap_or(0.0);
}
resultat.push(e);
}
resultat
}
/// Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin.
pub fn store_patch_candidates<'a>(rows: &'a [ProductRow], magasin: &str) -> Vec<&'a ProductRow> {
rows.iter()
.filter(|row| {
row.noid.map(|n| n != 0.0 && !n.is_nan()).unwrap_or(false)
&& row.quantite_by_store.as_ref().and_then(|m| m.get(magasin)).copied().unwrap_or(0.0) == 0.0
})
.collect()
}
/// Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le
/// magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne.
/// Renvoie le nombre de lignes corrigées.
pub fn apply_store_patch_in_place(rows: &mut [ProductRow], magasin: &str, periods: &[String], entries: &[StorePatchEntry]) -> usize {
let par_code: HashMap<&str, &StorePatchEntry> = entries.iter().map(|e| (e.codein.as_str(), e)).collect();
let mut corrigees = 0;
for row in rows.iter_mut() {
let Some(e) = par_code.get(row.codein.as_str()) else { continue };
let sales = row.sales12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
let mut deltas_ventes = Vec::with_capacity(periods.len());
for period in periods {
let current = sales.get(period).copied().unwrap_or(0.0);
let next = e.sales.get(period).copied().unwrap_or(0.0);
sales.insert(period.clone(), next);
deltas_ventes.push((period, next - current));
}
let receptions = row.receptions12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
for period in periods {
receptions.insert(period.clone(), e.receptions.get(period).copied().unwrap_or(0.0));
}
let stock = row.stock12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
for period in periods {
stock.insert(period.clone(), e.stock.get(period).copied().unwrap_or(0.0));
}
for (period, delta) in deltas_ventes {
let v = row.sales12m.get(period).copied().unwrap_or(0.0);
row.sales12m.insert(period.clone(), v + delta);
}
let ca_by_store = row.ca_by_store.get_or_insert_with(Default::default);
let quantite_by_store = row.quantite_by_store.get_or_insert_with(Default::default);
let marge_by_store = row.marge_by_store.get_or_insert_with(Default::default);
let delta_qty = e.quantite - quantite_by_store.get(magasin).copied().unwrap_or(0.0);
let delta_ca = e.ca - ca_by_store.get(magasin).copied().unwrap_or(0.0);
let delta_marge = e.marge - marge_by_store.get(magasin).copied().unwrap_or(0.0);
quantite_by_store.insert(magasin.to_string(), e.quantite);
ca_by_store.insert(magasin.to_string(), e.ca);
marge_by_store.insert(magasin.to_string(), e.marge);
row.total_quantite += delta_qty;
row.total_ca += delta_ca;
row.total_marge += delta_marge;
row.taux_marge = if row.total_ca > 0.0 { (row.total_marge / row.total_ca) * 100.0 } else { 0.0 };
if !row.working_stores.iter().any(|s| s == magasin) {
row.working_stores.push(magasin.to_string());
}
corrigees += 1;
}
corrigees
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn complement_depuis_l_api() {
let periods = vec!["202601".to_string(), "202602".to_string(), "202603".to_string()];
let entree = |mois: &str, site: &str, qte: &str| -> FfMensuelEntry {
serde_json::from_value(serde_json::json!({
"mois": mois, "site": site, "stock_fin_mois": "5.000",
"ventes": { "qte_vendue": qte, "ca_ht": "-26.66", "marge": "12.76" }, "receptions": null
}))
.unwrap()
};
let mut mensuel = HashMap::new();
mensuel.insert(
"A1".to_string(),
vec![entree("2026-01", "292", "-2.000"), entree("2026-02", "579", "-9"), entree("2025-01", "292", "-4")],
);
mensuel.insert("A2".to_string(), vec![entree("2026-01", "292", "0")]);
let e = build_store_patch_entries(&["A1".into(), "A2".into(), "A3".into()], "292", &periods, &mensuel);
assert_eq!(e.len(), 1, "A2 sans vente, A3 sans donnée");
assert_eq!(e[0].quantite, 2.0);
assert!((e[0].ca - 26.66).abs() < 1e-9);
assert_eq!(e[0].sales["202602"], 0.0);
assert_eq!(e[0].stock["202603"], 5.0, "stock reporté");
assert_eq!(serde_json::to_value(&e[0]).unwrap()["receptions"], serde_json::json!({ "202601": 0, "202602": 0, "202603": 0 }));
}
#[test]
fn complement_reporte_sur_les_totaux() {
let periods = vec!["202601".to_string(), "202602".to_string()];
let mut row = ProductRow { codein: "A1".into(), noid: Some(7.0), ..Default::default() };
row.sales12m.insert("202601".into(), 4.0);
row.total_quantite = 4.0;
row.total_ca = 40.0;
row.total_marge = 10.0;
row.working_stores = vec!["292".into()];
let mut q = std::collections::BTreeMap::new();
q.insert("292".to_string(), 4.0);
row.quantite_by_store = Some(q);
assert_eq!(store_patch_candidates(std::slice::from_ref(&row), "579").len(), 1);
assert_eq!(store_patch_candidates(std::slice::from_ref(&row), "292").len(), 0);
let mut e = StorePatchEntry { codein: "A1".into(), quantite: 3.0, ca: 30.0, marge: 6.0, ..Default::default() };
e.sales.insert("202602".into(), 3.0);
e.stock.insert("202601".into(), 5.0);
let mut rows = vec![row];
assert_eq!(apply_store_patch_in_place(&mut rows, "579", &periods, &[e]), 1);
let r = &rows[0];
assert_eq!(r.total_quantite, 7.0);
assert_eq!(r.total_ca, 70.0);
assert!((r.taux_marge - 16.0 / 70.0 * 100.0).abs() < 1e-9);
assert_eq!(r.sales12m["202602"], 3.0);
assert_eq!(r.sales12m_by_store.as_ref().unwrap()["579"]["202601"], 0.0);
assert_eq!(r.stock12m_by_store.as_ref().unwrap()["579"]["202601"], 5.0);
assert_eq!(r.working_stores, vec!["292".to_string(), "579".to_string()]);
}
}
+300
View File
@@ -0,0 +1,300 @@
//! Test d'intégration contre une vraie base PostgreSQL : `grid_rows`,
//! `session_snapshots`, `gammes_a_valider`.
//!
//! Désactivé par défaut. Pour le lancer :
//!
//! ```sh
//! GRID_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/collectflow cargo test grid::tests_db
//! ```
//!
//! Les données de test portent des codes fournisseur `TSTGRID…`, supprimés avant
//! et après le passage.
use super::api_schemas::query_map;
use super::enregistrer::{enregistrer_brouillon, enregistrer_gammes, ChangementGamme, EnregistrementGammes};
use super::gammes_a_valider::*;
use super::rows_cache;
use super::snapshots::{enregistrer_snapshot, lister_snapshots, supprimer_snapshot};
use super::store::*;
use super::types::ProductRow;
use serde_json::{json, Value};
use std::time::Duration;
const F1: &str = "TSTGRID1";
const F2: &str = "TSTGRID2";
fn ligne(codein: &str, noid: f64, libelle: &str, code3: &str, ca: f64) -> ProductRow {
let mut r: ProductRow = serde_json::from_value(json!({
"codein": codein, "codeFournisseur": F1, "nomFournisseur": "FOURNISSEUR TEST",
"libelle1": libelle, "gtin": format!("370{codein}"), "reference": format!("REF-{codein}"),
"codeCentrale": format!("10000{codein}"),
"code1": &code3[..2], "libelleNiveau1": "Univers", "code2": &code3[..4], "libelleNiveau2": "Famille",
"code3": code3, "libelle3": format!("Sous-famille {code3}"),
"codeGamme": "B", "codeGammeInit": "B", "codeGammeDraft": null,
"sales12m": { "202509": 1, "202510": 2 }, "stock12m": { "202509": 5, "202510": 4 },
"receptions12m": { "202509": 0, "202510": 0 },
"totalQuantite": 3, "totalCa": ca, "totalMarge": ca / 4.0, "tauxMarge": 25,
"workingStores": ["292"], "noid": noid, "stockActuel": 4.5, "nbMagasinsReseau": 120,
"qteReseauByMonth": null, "champInconnu": { "garde": true }
}))
.unwrap();
r.libelle1 = libelle.to_string();
r
}
async fn nettoyer(pool: &sqlx::PgPool) {
for t in ["grid_rows", "session_snapshots", "gammes_a_valider"] {
sqlx::query(&format!("delete from {t} where code_fournisseur like 'TSTGRID%'")).execute(pool).await.unwrap();
}
}
#[tokio::test]
async fn grid_rows_snapshots_gammes_a_valider() {
let Ok(url) = std::env::var("GRID_TEST_DATABASE_URL") else {
eprintln!("GRID_TEST_DATABASE_URL absent : test d'intégration ignoré.");
return;
};
let _verrou = rows_cache::TEST_LOCK.lock();
// La configuration lue par `db::pool()` : pas de fichier de réglages, l'URL de test.
let dir = std::env::temp_dir().join(format!("grid-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("DATA_DIR", &dir);
std::env::set_var("DATABASE_URL", &url);
// Une autre base a pu servir à un test précédent du même processus.
crate::db::refresh();
let pool = crate::db::pool().unwrap();
sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap();
nettoyer(&pool).await;
// ── upsert / lecture de l'instantané ────────────────────────────────────
let rows = vec![
ligne("900002", 20.0, "Vase en verre", "320212", 50.0),
ligne("900001", 10.0, "Bougie parfumée", "320211", 150.0),
ProductRow { codein: String::new(), ..Default::default() },
ligne("900003", 5.0, "Cadre photo", "330101", 0.125),
];
assert_eq!(upsert_grid_rows(F1, &rows).await.unwrap(), 3);
assert_eq!(upsert_grid_rows("", &rows).await.unwrap(), 0);
let snap = read_grid_snapshot(F1).await.unwrap().expect("instantané");
let codes: Vec<&str> = snap.rows.iter().map(|r| r.codein.as_str()).collect();
assert_eq!(codes, vec!["900003", "900001", "900002"], "tri par noid");
assert_eq!(snap.rows[1].payload_version, Some(GRID_PAYLOAD_VERSION));
let mut relu = serde_json::to_value(&snap.rows[1]).unwrap();
relu.as_object_mut().unwrap().remove("payloadVersion");
assert_eq!(relu, serde_json::to_value(&rows[1]).unwrap(), "aller-retour du payload");
assert!(read_grid_snapshot("TSTGRID-ABSENT").await.unwrap().is_none());
// Colonnes scalaires (numeric arrondi par PostgreSQL).
let (ca, nb): (f64, Option<i32>) =
sqlx::query_as("select total_ca::float8, nb_magasins_reseau from grid_rows where code_fournisseur = $1 and codein = '900003'")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!((ca, nb), (0.13, Some(120)));
// Une ligne d'un ancien format n'est pas servie à la Grille.
sqlx::query("update grid_rows set payload = payload - 'payloadVersion' where code_fournisseur = $1 and codein = '900002'")
.bind(F1)
.execute(&pool)
.await
.unwrap();
assert!(read_grid_snapshot(F1).await.unwrap().is_none());
assert_eq!(upsert_grid_rows(F1, &rows).await.unwrap(), 3);
// ── requêtes filtrées / triées / paginées ──────────────────────────────
let tout = query_grid_rows(&GridQuery { code_fournisseur: Some(F1.into()), ..Default::default() }).await.unwrap();
assert_eq!(tout.total, 3);
assert_eq!(tout.rows.iter().map(|r| r.codein.as_str()).collect::<Vec<_>>(), vec!["900001", "900002", "900003"]);
assert!(tout.rows.iter().all(|r| r.payload_version.is_none()));
assert_eq!(tout.rows[0].extra.get("champInconnu"), Some(&json!({ "garde": true })));
assert!(tout.computed_at.as_deref().unwrap().ends_with('Z'));
let page2 = query_grid_rows(&GridQuery {
code_fournisseur: Some(F1.into()),
sort: Some(GridSortKey::Codein),
order: Some(SortOrder::Asc),
page: Some(2),
limit: Some(1),
..Default::default()
})
.await
.unwrap();
assert_eq!((page2.total, page2.rows.len(), page2.rows[0].codein.as_str()), (3, 1, "900002"));
let recherche =
query_grid_rows(&GridQuery { search: Some(" BOUGIE ".into()), code_fournisseur: Some(F1.into()), ..Default::default() })
.await
.unwrap();
assert_eq!(recherche.total, 1);
let par_centrale = query_grid_rows(&GridQuery { search: Some("10000900003".into()), ..Default::default() }).await.unwrap();
assert_eq!(par_centrale.rows[0].codein, "900003");
let nomenclature =
query_grid_rows(&GridQuery { code_fournisseur: Some(F1.into()), nomenclature: Some("3202".into()), ..Default::default() })
.await
.unwrap();
assert_eq!(nomenclature.total, 2);
// Une valeur hostile reste une valeur liée.
let hostile = query_grid_rows(&GridQuery { code_fournisseur: Some("x' or '1'='1".into()), ..Default::default() }).await.unwrap();
assert_eq!(hostile.total, 0);
// ── gammes, fiches, fournisseurs, fraîcheur, nomenclatures ─────────────
update_grid_rows_gamme(F1, [("900001", "A"), ("900002", "Z"), ("900003", "A")]).await.unwrap();
let gammes = get_grid_rows_gammes(F1, &["900001".into(), "900002".into(), "inconnu".into()]).await.unwrap();
assert_eq!(gammes.len(), 2);
assert_eq!(gammes["900001"].code_gamme.as_deref(), Some("A"));
assert_eq!(gammes["900001"].code_gamme_init.as_deref(), Some("B"));
let fiche = get_grid_row_by_codein("900002", Some(F1)).await.unwrap().unwrap();
assert_eq!(fiche.row.code_gamme.as_deref(), Some("Z"), "payload mis à jour");
assert!(get_grid_row_by_codein("900002", Some("TSTGRID-ABSENT")).await.unwrap().is_none());
let mut chez_f2 = ligne("900001", 1.0, "Bougie parfumée", "320211", 1.0);
chez_f2.code_fournisseur = F2.into();
upsert_grid_rows(F2, &[chez_f2]).await.unwrap();
let fournisseurs: Vec<String> =
list_grid_suppliers_for_codein("900001").await.unwrap().into_iter().map(|f| f.code_fournisseur).collect();
assert!(fournisseurs.contains(&F1.to_string()) && fournisseurs.contains(&F2.to_string()));
let tous = list_grid_suppliers().await.unwrap();
let f1 = tous.iter().find(|s| s.code_fournisseur == F1).unwrap();
assert_eq!((f1.row_count, f1.nom_fournisseur.as_deref()), (3, Some("FOURNISSEUR TEST")));
let fraicheur = get_grid_freshness(F1).await.unwrap().unwrap();
assert_eq!(fraicheur.row_count, 3);
assert!(get_grid_freshness("TSTGRID-ABSENT").await.unwrap().is_none());
let n1 = list_grid_nomenclatures(F1, 1, None).await.unwrap();
assert_eq!(n1.iter().map(|n| (n.code.as_str(), n.nb_articles)).collect::<Vec<_>>(), vec![("32", 2), ("33", 1)]);
assert_eq!(n1[0].libelle.as_deref(), Some("Univers"));
assert_eq!(n1[0].total_ca, 200.0);
let n3 = list_grid_nomenclatures(F1, 3, Some("3202")).await.unwrap();
assert_eq!(n3.iter().map(|n| n.code.as_str()).collect::<Vec<_>>(), vec!["320211", "320212"]);
assert_eq!(n3[0].libelle.as_deref(), Some("Sous-famille 320211"));
// Purge : un nouveau calcul sans l'article 900003 le retire.
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(upsert_grid_rows(F1, &rows[..2]).await.unwrap(), 2);
assert_eq!(get_grid_freshness(F1).await.unwrap().unwrap().row_count, 2);
// ── snapshots (actions) ────────────────────────────────────────────────
let brut = json!({ "codeFournisseur": F1, "magasin": "TOTAL", "changes": { "900001": { "before": "B", "after": "A" } } });
let anonyme = enregistrer_snapshot("0", &brut).await;
assert_eq!(anonyme["success"], true, "{anonyme}");
let id_anonyme = anonyme["snapshotId"].as_i64().unwrap();
let a_moi = enregistrer_snapshot("987654", &json!({ "codeFournisseur": F1, "magasin": "292", "type": "export", "label": "Mon export", "changes": {}, "summary": { "totalRows": 1, "totalCa": 2.5, "totalMarge": 1, "tauxMargeGlobal": 40 } })).await;
assert_eq!(a_moi["success"], true, "{a_moi}");
let id_a_moi = a_moi["snapshotId"].as_i64().unwrap();
let ids = |v: Vec<super::snapshots::SessionSnapshot>| -> Vec<i64> {
v.into_iter().filter(|s| s.code_fournisseur == F1).map(|s| s.id as i64).collect()
};
assert_eq!(ids(lister_snapshots("0", None).await.unwrap()), vec![id_anonyme]);
assert_eq!(ids(lister_snapshots("abc", None).await.unwrap()), vec![id_anonyme], "identifiant non numérique = anonyme");
assert_eq!(ids(lister_snapshots("987654", Some("export")).await.unwrap()), vec![id_a_moi]);
assert!(ids(lister_snapshots("987654", Some("snapshot")).await.unwrap()).is_empty());
let detail = lister_snapshots("987654", None).await.unwrap().into_iter().find(|s| s.id as i64 == id_a_moi).unwrap();
let detail = serde_json::to_value(detail).unwrap();
assert_eq!(detail["label"], "Mon export");
assert_eq!(detail["userId"], 987654);
assert_eq!(detail["summaryJson"], json!({ "totalRows": 1, "totalCa": 2.5, "totalMarge": 1, "tauxMargeGlobal": 40 }));
assert!(detail["createdAt"].as_str().unwrap().ends_with('Z'));
assert!(lister_snapshots("99999999999", None).await.is_err(), "hors bornes integer : erreur SQL comme en TS");
let label_defaut: (String,) =
sqlx::query_as("select label from session_snapshots where id = $1").bind(id_anonyme as i32).fetch_one(&pool).await.unwrap();
assert!(label_defaut.0.starts_with("Snapshot — "));
let invalide = enregistrer_snapshot("0", &json!({ "codeFournisseur": F1 })).await;
assert_eq!(
invalide,
json!({ "success": false, "error": "Validation failed: Invalid input: expected string, received undefined, Invalid input: expected record, received undefined" })
);
let trop_grand = enregistrer_snapshot("99999999999", &brut).await;
assert_eq!(trop_grand, json!({ "success": false, "error": "erreur technique sur le serveur" }));
assert_eq!(
supprimer_snapshot("0", &json!(id_a_moi)).await,
json!({ "success": false, "error": "Snapshot non trouvé ou non autorisé" })
);
assert_eq!(supprimer_snapshot("987654", &json!(id_a_moi)).await, json!({ "success": true }));
assert_eq!(supprimer_snapshot("0", &json!("abc")).await, json!({ "success": false, "error": "erreur technique sur le serveur" }));
// ── enregistrerGammes / saveDraftChanges ───────────────────────────────
rows_cache::invalidate_grid_rows_cache(None);
let en_cache = rows_cache::GridRows::new(rows[..2].to_vec());
rows_cache::cache_grid_rows(&rows_cache::grid_rows_cache_key(F1, None), en_cache.clone());
let n = enregistrer_gammes(&EnregistrementGammes {
code_fournisseur: F1.into(),
nom_fournisseur: Some("FOURNISSEUR TEST".into()),
magasin: "TOTAL".into(),
changes: vec![ChangementGamme { codein: "900002".into(), code_gamme_before: Some("B".into()), code_gamme: "C".into() }],
user_id: None,
label: None,
})
.await
.unwrap();
assert_eq!(n, 1);
let dernier: (Value, Option<String>) =
sqlx::query_as("select changes, label from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
// Fusion par-dessus le dernier snapshot du fournisseur (celui de 987654 a été supprimé).
assert_eq!(dernier.0, json!({ "900001": { "before": "B", "after": "A" }, "900002": { "before": "B", "after": "C" } }));
assert!(dernier.1.unwrap().starts_with("Draft — "));
assert_eq!(en_cache.rows.read()[0].code_gamme.as_deref(), Some("C"), "cache patché (900002)");
// Le report dans grid_rows est asynchrone.
let mut vu = false;
for _ in 0..50 {
let g = get_grid_rows_gammes(F1, &["900002".into()]).await.unwrap();
if g["900002"].code_gamme.as_deref() == Some("C") {
vu = true;
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(vu, "grid_rows mis à jour en arrière-plan");
// ── gammes à valider ───────────────────────────────────────────────────
let p = |c: &str, g: &str| PropositionGamme { codein: c.into(), gamme: g.into(), gamme_precedente: Some("B".into()) };
proposer_gammes(F1, &[p("900001", "Z"), p("900002", "A")], &[], &"x".repeat(150)).await.unwrap();
proposer_gammes(F1, &[p("900002", "Y")], &["900001".into()], "api-key").await.unwrap();
let liste = lister_gammes_a_valider(F1).await.unwrap();
assert_eq!(liste.len(), 1);
assert_eq!((liste[0].codein.as_str(), liste[0].gamme.as_str(), liste[0].auteur.as_deref()), ("900002", "Y", Some("api-key")));
assert!(liste[0].proposee_le.ends_with('Z'));
let par_article = gammes_a_valider_par_article(&["900002".into(), "900009".into()]).await.unwrap();
assert_eq!(par_article.get(&format!("{F1}|900002")).map(String::as_str), Some("Y"));
proposer_gammes(F1, &[p("900001", "Z")], &[], &"x".repeat(150)).await.unwrap();
let auteur: (String,) = sqlx::query_as("select auteur from gammes_a_valider where code_fournisseur = $1 and codein = '900001'")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(auteur.0.len(), 100);
// saveDraftChanges : les propositions traitées quittent la file.
let res = enregistrer_brouillon(
"987654",
&json!({ "codeFournisseur": F1, "magasin": "TOTAL", "changes": [{ "codein": "900002", "codeGammeBefore": "C", "codeGamme": "Y" }], "gammesAValiderTraitees": ["900002"] }),
)
.await;
assert_eq!(res, json!({ "success": true, "saved": 1 }));
let restantes: Vec<String> = lister_gammes_a_valider(F1).await.unwrap().into_iter().map(|g| g.codein).collect();
assert_eq!(restantes, vec!["900001".to_string()]);
supprimer_gammes_a_valider(F1, &["900001".into()]).await.unwrap();
assert!(lister_gammes_a_valider(F1).await.unwrap().is_empty());
let invalide = enregistrer_brouillon("0", &json!({ "codeFournisseur": F1 })).await;
assert_eq!(invalide["success"], false);
assert!(invalide["error"].as_str().unwrap().starts_with("Validation failed: [\n {\n \"expected\": \"string\""));
// ── route /api/v1/nomenclatures (sans HTTP : schéma + requêtes) ────────
let params = query_map(vec![("fournisseur".into(), F1.into()), ("niveau".into(), "2".into())]);
let parsed = super::api_schemas::parse_nomenclatures_query(&params).unwrap();
let postes = list_grid_nomenclatures(&parsed.fournisseur, parsed.niveau, None).await.unwrap();
assert_eq!(postes.iter().map(|p| p.code.as_str()).collect::<Vec<_>>(), vec!["3202"]);
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
}
+302
View File
@@ -0,0 +1,302 @@
//! Test d'intégration du calcul de la grille (phase 2) sur une base de test
//! contenant les tables FF (`ff_fixture`), avec une fausse API FF Nancy.
//!
//! ```sh
//! GRID_FF_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/ff_fixture cargo test grid::tests_ff -- --nocapture
//! ```
//!
//! Écrit dans `grid_rows`, `session_snapshots`, `gammes_a_valider`, `users` et
//! `api_keys` de la base de test (nettoyés à la fin).
use super::enrich::enrich_rows;
use super::months;
use super::product_rows::*;
use super::rows_cache;
use super::store::*;
use crate::auth::Claims;
use axum::body::Body;
use axum::extract::Path;
use axum::http::Request;
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use tower::ServiceExt;
const CLE_API: &str = "cf_cle-de-test-grille-phase-2";
/// Fausse API FF : seul l'historique mensuel sert au complément par magasin.
async fn fausse_api(mois: String) -> String {
let app = Router::new().route(
"/api/articles/{id}/mensuel",
get(move |Path(id): Path<String>| {
let mois = mois.clone();
async move {
Json(json!({ "data": [
{ "mois": mois, "site": "579", "stock_fin_mois": "7.000",
"ventes": { "qte_vendue": "-3.000", "ca_ht": "-30.00", "marge": "9.5" },
"receptions": { "qte_recue": "2" }, "id": id },
{ "mois": mois, "site": "292", "stock_fin_mois": "1",
"ventes": { "qte_vendue": "-100", "ca_ht": "-1", "marge": "0" } }
] }))
}
}),
);
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}")
}
async fn appel(req: Request<Body>) -> (u16, Vec<u8>) {
let res = super::router().oneshot(req).await.unwrap();
let status = res.status().as_u16();
(status, axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec())
}
fn get_cle(uri: &str) -> Request<Body> {
Request::builder().uri(uri).header("x-api-key", CLE_API).body(Body::empty()).unwrap()
}
fn json_de(b: &[u8]) -> Value {
serde_json::from_slice(b).unwrap_or_else(|e| panic!("JSON illisible ({e}) : {}", String::from_utf8_lossy(b)))
}
async fn nettoyer(pool: &sqlx::PgPool) {
for sql in [
"delete from grid_rows where code_fournisseur in ('F001', 'F002', 'F404')",
"delete from session_snapshots where code_fournisseur in ('F001', 'F002')",
"delete from gammes_a_valider where code_fournisseur in ('F001', 'F002')",
"delete from api_keys where name = 'test-grille'",
"delete from users where username = 'test-grille'",
] {
sqlx::query(sql).execute(pool).await.unwrap();
}
}
#[tokio::test]
async fn calcul_de_la_grille_et_routes() {
let Ok(url) = std::env::var("GRID_FF_TEST_DATABASE_URL") else {
eprintln!("GRID_FF_TEST_DATABASE_URL absent : test d'intégration du calcul ignoré.");
return;
};
let _verrou = rows_cache::TEST_LOCK.lock();
let dir = std::env::temp_dir().join(format!("grid-ff-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("DATA_DIR", &dir);
std::env::set_var("DATABASE_URL", &url);
std::env::set_var("AUTH_SECRET", "secret-de-test");
crate::db::refresh();
let periods = months::get_last12_months(months::today());
let dernier_mois = months::ff_month_to_qlik(periods.last().unwrap());
std::env::set_var("FF_API_BASE_URL", fausse_api(dernier_mois).await);
crate::ff::api::reset_ff_api_base_cache();
let pool = crate::db::pool().unwrap();
sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap();
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
// ── calcul complet ─────────────────────────────────────────────────────
let rows = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
let lignes = rows.snapshot();
println!("── lignes calculées\n{}", serde_json::to_string_pretty(&lignes).unwrap());
assert!(!lignes.is_empty());
// `articles.codein` est un char(10) dans la base de test : codes complétés d'espaces.
let tapis = lignes.iter().find(|r| r.codein.trim() == "100001").expect("article 100001");
let code_tapis = tapis.codein.clone();
let tapis_url = urlencoding::encode(&code_tapis).into_owned();
assert_eq!(tapis.sales12m.len(), 12);
assert_eq!(tapis.code_centrale.as_deref(), Some("10000111111"));
assert_eq!(tapis.ca_reseau, Some(50000.0), "métriques réseau du cache Qlik");
assert_eq!(tapis.taux_presence_reseau, Some(1.0));
assert!(tapis.sales12m_by_store.as_ref().unwrap().contains_key("292"));
assert!(tapis.working_stores.contains(&"292".to_string()));
assert_eq!(tapis.stock_actuel, Some(4.0), "5 + -1 sur les deux sites");
assert_eq!(tapis.prix_vente, Some(12.9), "le PV le plus élevé des deux magasins");
assert!(tapis.total_quantite > 0.0 && tapis.total_ca > 0.0);
// Persistance (lancée en arrière-plan) puis instantané.
attendre_persistance("F001").await;
let fraicheur = get_grid_freshness("F001").await.unwrap().expect("instantané écrit");
assert_eq!(fraicheur.row_count as usize, lignes.len());
// Cache : même tableau partagé.
let encore = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
assert!(std::sync::Arc::ptr_eq(&rows, &encore));
// Instantané resservi après invalidation du cache, gammes du snapshot reportées.
sqlx::query("insert into session_snapshots (code_fournisseur, magasin, changes, type) values ('F001', 'TOTAL', $1, 'snapshot')")
.bind(json!({ code_tapis.clone(): { "before": null, "after": "Z" } }))
.execute(&pool)
.await
.unwrap();
rows_cache::invalidate_grid_rows_cache(Some("F001"));
let servies = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
assert!(!std::sync::Arc::ptr_eq(&rows, &servies));
assert_eq!(servies.len(), lignes.len());
let s = servies.snapshot();
assert_eq!(s.iter().find(|r| r.codein == code_tapis).unwrap().code_gamme.as_deref(), Some("Z"));
assert!(s.iter().all(|r| r.payload_version == Some(GRID_PAYLOAD_VERSION)), "lignes issues de grid_rows");
// Synchro : recalcul forcé, nombre de lignes.
assert_eq!(get_product_rows_for_sync("F001").await.unwrap(), lignes.len());
// ── complément par magasin (fausse API FF) ─────────────────────────────
let patch = get_store_patch("F001", "579").await.unwrap();
println!("── complément 579\n{}", serde_json::to_string_pretty(&*patch).unwrap());
assert_eq!(patch.magasin, "579");
assert_eq!(patch.periods, periods);
assert!(!patch.entries.is_empty());
assert!(patch.entries.iter().all(|e| e.quantite == 3.0 && e.ca == 30.0));
let meme = get_store_patch("F001", "579").await.unwrap();
assert!(std::sync::Arc::ptr_eq(&patch, &meme), "complément partagé");
// Vue d'un magasin : rattrapage appliqué aux lignes.
let vue_579 =
get_product_rows(GetProductRowsInput { code_fournisseur: "F001".into(), magasin: Some("579".into()), force_refresh: false })
.await
.unwrap();
assert!(vue_579.snapshot().iter().any(|r| r.quantite_by_store.as_ref().and_then(|q| q.get("579")).copied() == Some(3.0)));
// ── enrichissement /api/v1 ─────────────────────────────────────────────
let enrichies =
enrich_rows(query_grid_rows(&GridQuery { code_fournisseur: Some("F001".into()), ..Default::default() }).await.unwrap().rows).await;
let e = enrichies.iter().find(|r| r.row.codein == code_tapis).unwrap();
assert_eq!(e.network.as_ref().map(|n| n.ca_reseau), Some(50000.0));
let v = serde_json::to_value(e).unwrap();
assert!(v.get("codeGammeServeur").is_some() && v.get("gammeAValider").is_some() && v.get("trend").is_some());
assert_eq!(v["codein"], json!(code_tapis));
// ── routes ─────────────────────────────────────────────────────────────
let (id_user,): (i32,) =
sqlx::query_as("insert into users (username, password_hash, role) values ('test-grille', 'x', 'admin') returning id")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query("insert into api_keys (name, key_prefix, key_hash, role) values ('test-grille', 'cf_cle', $1, 'user')")
.bind(crate::auth::api_key::hash_api_key(CLE_API))
.execute(&pool)
.await
.unwrap();
let claims =
Claims { id: id_user.to_string(), name: "test-grille".into(), role: "admin".into(), exp: chrono::Utc::now().timestamp() + 3600 };
let cookie = format!("{}={}", crate::auth::session::COOKIE_NAME, crate::auth::session::encode(&claims));
let get_session = |uri: &str| Request::builder().uri(uri).header("cookie", cookie.clone()).body(Body::empty()).unwrap();
// /api/grid/rows (NDJSON)
let (st, corps) = appel(get_session("/api/grid/rows?fournisseur=F001&magasin=TOTAL&chunkSize=1")).await;
assert_eq!(st, 200);
let messages: Vec<Value> = String::from_utf8(corps).unwrap().lines().map(|l| json_de(l.as_bytes())).collect();
assert_eq!(messages[0], json!({ "type": "start", "loaded": 0, "total": null }));
let chunks: Vec<&Value> = messages.iter().filter(|m| m["type"] == "chunk").collect();
// chunkSize=1 est relevé au minimum de 50 : un seul lot ici.
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0]["rows"].as_array().unwrap().len(), lignes.len());
assert_eq!(chunks[0]["loaded"], lignes.len());
assert_eq!(messages.last().unwrap(), &json!({ "type": "done", "loaded": lignes.len(), "total": lignes.len() }));
let (st, _) = appel(get_session("/api/grid/rows")).await;
assert_eq!(st, 400);
let (st, _) = appel(Request::builder().uri("/api/grid/rows?fournisseur=F001").body(Body::empty()).unwrap()).await;
assert_eq!(st, 401);
// /api/grid/rows/store-patch
let (st, corps) = appel(get_session("/api/grid/rows/store-patch?fournisseur=F001&magasin=579")).await;
assert_eq!(st, 200);
assert_eq!(json_de(&corps)["magasin"], "579");
let (st, corps) = appel(get_session("/api/grid/rows/store-patch?fournisseur=F001&magasin=TOTAL")).await;
assert_eq!((st, json_de(&corps)), (400, json!({ "error": "Paramètres fournisseur et magasin requis." })));
// /api/v1/grid : diffusion complète, puis page
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F001")).await;
assert_eq!(st, 200);
let tout = json_de(&corps);
assert_eq!(tout["data"].as_array().unwrap().len(), lignes.len());
assert_eq!(tout["meta"]["diffuse"], true);
assert_eq!(tout["meta"]["complet"], true);
assert_eq!(tout["pagination"]["total"], lignes.len());
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F001&limit=1&fields=totalCa&sort=codein&order=asc")).await;
assert_eq!(st, 200);
let page = json_de(&corps);
assert_eq!(page["data"][0].as_object().unwrap().keys().collect::<Vec<_>>(), vec!["totalCa", "codein"]);
assert_eq!(page["meta"]["complet"], json!(lignes.len() <= 1));
if lignes.len() > 1 {
assert!(page["meta"]["avertissement"].as_str().unwrap().contains("page=2"));
}
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F002&compute=0")).await;
assert_eq!((st, json_de(&corps)["error"]["code"].clone()), (202, json!("not_ready")));
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F002")).await;
let f002 = json_de(&corps);
assert_eq!(st, 200, "{f002}");
assert_eq!(f002["meta"]["computedOnDemand"], true);
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F404")).await;
assert_eq!((st, json_de(&corps)["error"]["code"].clone()), (404, json!("not_found")));
// /api/v1/products/*
let (st, corps) = appel(get_cle("/api/v1/products/search?q=tapis&fields=libelle1")).await;
assert_eq!(st, 200);
let rech = json_de(&corps);
assert_eq!(rech["data"][0]["codein"], json!(code_tapis));
assert_eq!(rech["meta"]["scope"], "tous fournisseurs");
let (st, corps) = appel(get_cle(&format!("/api/v1/products/{tapis_url}?fournisseur=F001"))).await;
assert_eq!(st, 200);
let fiche = json_de(&corps);
assert_eq!(fiche["meta"]["hasNetwork"], true);
assert_eq!(fiche["data"]["network"]["caReseau"], 50000);
let (st, _) = appel(get_cle("/api/v1/products/999999")).await;
assert_eq!(st, 404);
// Gammes : proposition à valider, puis lot avec un inconnu (tout ou rien).
let put = Request::builder()
.method("PUT")
.uri(format!("/api/v1/products/{tapis_url}/gamme"))
.header("x-api-key", CLE_API)
.header("content-type", "application/json")
.body(Body::from(r#"{"gamme":"A","fournisseur":"F001"}"#))
.unwrap();
let (st, corps) = appel(put).await;
let rep = json_de(&corps);
assert_eq!(st, 200, "{rep}");
assert_eq!(rep["data"]["statut"], "a_valider");
assert_eq!(rep["data"]["codeFournisseur"], "F001");
let a_valider = super::gammes_a_valider::lister_gammes_a_valider("F001").await.unwrap();
assert_eq!((a_valider[0].codein.as_str(), a_valider[0].auteur.as_deref()), (code_tapis.as_str(), Some("test-grille")));
let post = |corps: &str| {
Request::builder().method("POST").uri("/api/v1/gammes").header("x-api-key", CLE_API).body(Body::from(corps.to_string())).unwrap()
};
let (st, corps) = appel(post(
&json!({ "fournisseur": "F001", "changes": [{ "codein": code_tapis, "gamme": "B" }, { "codein": "XXX", "gamme": "A" }] })
.to_string(),
))
.await;
let rep = json_de(&corps);
assert_eq!(st, 404);
assert_eq!(rep["error"]["details"]["inconnus"], json!(["XXX"]));
let (st, corps) = appel(post("pas du json")).await;
assert_eq!(
(st, json_de(&corps)["error"]["message"].clone()),
(400, json!("Corps de requête JSON attendu (Content-Type: application/json)."))
);
let (st, corps) = appel(post(&json!({ "fournisseur": "F001", "changes": [{ "codein": code_tapis, "gamme": "Z" }] }).to_string())).await;
let rep = json_de(&corps);
assert_eq!(st, 200, "{rep}");
assert_eq!(rep["meta"]["demandes"], 1);
// /api/v1/fournisseurs, /api/v1/network
let (st, corps) = appel(get_cle("/api/v1/fournisseurs")).await;
assert_eq!(st, 200);
let fous = json_de(&corps);
assert!(fous["data"].as_array().unwrap().iter().any(|f| f["code"] == "F001" && f["hasSnapshot"] == true));
let (st, corps) = appel(get_cle("/api/v1/fournisseurs?withData=1&search=f00")).await;
assert_eq!(st, 200);
assert_eq!(json_de(&corps)["meta"]["scope"], "instantané uniquement");
let (st, corps) = appel(get_cle("/api/v1/network/10000111111")).await;
assert_eq!(st, 200);
let net = json_de(&corps);
assert_eq!(net["data"].as_object().unwrap().keys().next().unwrap(), "codeCentrale");
assert_eq!(net["meta"]["source"], "cache");
let (st, _) = appel(get_cle("/api/v1/network/inconnu")).await;
assert_eq!(st, 404);
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
}
+354
View File
@@ -0,0 +1,354 @@
//! CollectFlow - Core Grid Types (port de `src/types/grid.ts`).
//!
//! `ProductRow` circule tel quel en JSON : vers la Grille, dans `grid_rows.payload`
//! (jsonb) et dans l'API `/api/v1`. Les attributs serde reproduisent
//! `JSON.stringify` : noms camelCase, champs facultatifs omis quand ils sont
//! `undefined`, `null` conservé pour les champs `| null`, entiers sans décimale.
//!
//! Lecture tolérante : un payload écrit par la version Node se relit sans perte —
//! champ manquant → valeur par défaut, `null` numérique → `NaN` (réécrit `null`),
//! champ inconnu conservé dans `extra` et réécrit à l'identique.
use super::js;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
/// A/B/C/Z status codes for product categories, but can be any string from DB (like 'Aucune')
pub type GammeCode = String;
/// Série mensuelle `{ "YYYYMM": valeur }` (ou `"YYYY-MM"` pour le réseau Qlik).
pub type Serie = BTreeMap<String, f64>;
/// Ventilation par magasin puis par mois.
pub type SerieParMagasin = BTreeMap<String, Serie>;
/// A product row with its 12-month sales history and computed KPIs.
///
/// Ordre des champs = ordre de construction dans `getProductRows` (donc celui du
/// JSON produit par le calcul en direct).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ProductRow {
#[serde(deserialize_with = "js::string::deserialize")]
pub codein: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code_fournisseur: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub nom_fournisseur: String,
/// Dernier fournisseur enregistré pour cet article (artfou1.no_id DESC). Peut différer du fournisseur consulté.
#[serde(skip_serializing_if = "Option::is_none")]
pub fournisseur_principal_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fournisseur_principal_nom: Option<String>,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub gtin: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub reference: String,
/// Code centrale (clé jointure Qlik, format 10000XXXXXX)
#[serde(skip_serializing_if = "Option::is_none")]
pub code_centrale: Option<String>,
#[serde(deserialize_with = "js::string::deserialize")]
pub code1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle_niveau1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code2: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle_niveau2: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code3: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle3: String,
pub code_gamme: Option<GammeCode>,
pub code_gamme_init: Option<GammeCode>,
/// Draft gamme value before saving
pub code_gamme_draft: Option<GammeCode>,
/// 12-month sales quantities, indexed by YYYYMM key
#[serde(with = "js::map")]
pub sales12m: Serie,
/// End-of-month stock levels, indexed by YYYYMM key
#[serde(with = "js::map")]
pub stock12m: Serie,
/// Monthly reception quantities, indexed by YYYYMM key
#[serde(with = "js::map")]
pub receptions12m: Serie,
#[serde(with = "js::num")]
pub total_quantite: f64,
#[serde(with = "js::num")]
pub total_ca: f64,
#[serde(with = "js::num")]
pub total_marge: f64,
#[serde(with = "js::num")]
pub taux_marge: f64,
pub working_stores: Vec<String>,
/// SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub noid: Option<f64>,
/// Conditionnement (pack size)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub pcb: Option<f64>,
/// Prix de vente pratiqué, en euros (le plus élevé quand les magasins divergent :
/// `prixVenteByStore` porte le détail).
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub prix_vente: Option<f64>,
/// Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub sales12m_by_store: Option<SerieParMagasin>,
/// Per-store stock breakdown (site → YYYYMM → stock fin de mois)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub stock12m_by_store: Option<SerieParMagasin>,
/// Per-store reception breakdown (site → YYYYMM → qté reçue)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub receptions12m_by_store: Option<SerieParMagasin>,
/// Per-store CA totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub ca_by_store: Option<Serie>,
/// Per-store quantity totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub quantite_by_store: Option<Serie>,
/// Per-store margin totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub marge_by_store: Option<Serie>,
/// Qté en commande fournisseur
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub commandes_en_cours: Option<f64>,
/// Stock dispo agrégé tous sites
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_actuel: Option<f64>,
/// Qte en stock agrégé
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_total: Option<f64>,
/// Valeur stock agrégée
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_valeur: Option<f64>,
/// PRMP (alias prixAchat)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub pa: Option<f64>,
/// PA / PRMP
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub prix_achat: Option<f64>,
/// ISO date de dernière vente
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_vente: Option<String>,
/// ISO date de dernière réception
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_livraison: Option<String>,
/// Per-store last delivery/reception date (site → ISO date string)
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_livraison_by_store: Option<BTreeMap<String, String>>,
/// Prix de vente par magasin (site → PV) — `cube_pv`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub prix_vente_by_store: Option<Serie>,
/// Données réseau Qlik (~270 magasins)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_reseau: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub qte_reseau: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub nb_magasins_reseau: Option<f64>,
/// CA moyen par magasin réseau (productivité normalisée présence)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_par_magasin_reseau: Option<f64>,
/// Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub marge_pct_reseau: Option<f64>,
/// Taux de présence réseau = nbMagasinsReseau / 270
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub taux_presence_reseau: Option<f64>,
/// Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal).
/// `None` = absent, `Some(None)` = `null`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_null_map")]
pub qte_reseau_by_month: Option<Option<Serie>>,
/// Nombre de magasins vendeurs par mois : { "YYYY-MM": nb } (deuxième courbe de
/// la carte tendance). `None` = absent, `Some(None)` = `null`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_null_map")]
pub nb_mag_reseau_by_month: Option<Option<Serie>>,
/// Fraîcheur des données réseau (ISO)
#[serde(skip_serializing_if = "Option::is_none")]
pub network_fetched_at: Option<String>,
/// Rotation normalisée : unités vendues / magasin / mois
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub units_per_store_per_month: Option<f64>,
/// CA normalisé : € / magasin / an
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_per_store_per_year: Option<f64>,
/// Produit récent (≤ 3 mois de données)
#[serde(skip_serializing_if = "Option::is_none")]
pub is_recent: Option<bool>,
/// Seul produit du fournisseur dans le lot
#[serde(skip_serializing_if = "Option::is_none")]
pub is_last_product: Option<bool>,
/// Top 30% CA du lot fournisseur
#[serde(skip_serializing_if = "Option::is_none")]
pub is_top30_supplier: Option<bool>,
/// Moyennes de comparaison pour l'IA
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub avg_qty_fournisseur: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub avg_qty_rayon: Option<f64>,
/// Poids relatifs (%) du produit chez le fournisseur
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_ca: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_marge: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_qty: Option<f64>,
/// Référentiels globaux
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub total_fournisseur_ca: Option<f64>,
/// Version du format de la ligne, posée à l'écriture dans `grid_rows`. Une
/// ligne d'une version antérieure n'est pas servie telle quelle à la Grille
/// (des champs récents lui manqueraient) : elle est recalculée.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub payload_version: Option<f64>,
/// Champs inconnus de ce type (payload écrit par une autre version) : conservés
/// et réécrits tels quels.
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// Summary bar totals for the currently visible/filtered rows
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct GridSummary {
#[serde(with = "js::num")]
pub total_products: f64,
#[serde(with = "js::num")]
pub total_rows: f64,
#[serde(with = "js::num")]
pub total_quantite: f64,
#[serde(with = "js::num")]
pub total_ca: f64,
#[serde(with = "js::num")]
pub total_marge: f64,
#[serde(with = "js::num")]
pub taux_marge_global: f64,
}
/// Filters applied to the grid
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct GridFilters {
pub magasin: Option<String>,
pub code_fournisseur: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
/// Nomenclatures retenues (codes `code3`).
/// `None` = aucun filtre (tout s'affiche) ; `Some(vec![])` = rien de coché.
pub code3: Option<Vec<String>>,
pub code_gamme: Option<GammeCode>,
pub search: String,
}
/// One row in the data grid, representing a product at a store for one period.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct VenteProduit {
#[serde(with = "js::num")]
pub id: f64,
pub codein: String,
pub code_fournisseur: Option<String>,
pub nom_fournisseur: Option<String>,
pub libelle1: Option<String>,
pub gtin: Option<String>,
pub reference: Option<String>,
pub code_gamme: Option<GammeCode>,
pub code_gamme_init: Option<GammeCode>,
pub code3: Option<String>,
pub libelle3: Option<String>,
pub magasin: String,
pub code_magasin: Option<String>,
pub annee: Option<f64>,
pub mois: Option<f64>,
pub periode: String,
pub quantite: Option<f64>,
pub montant_mvt: Option<f64>,
pub marge_mvt: Option<f64>,
}
// ---------------------------------------------------------------------------
// Complément par magasin (`src/features/grid/lib/store-patch.ts`)
// ---------------------------------------------------------------------------
/// Chiffres d'un article pour le magasin complété.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StorePatchEntry {
pub codein: String,
/// Ventes par mois (« YYYYMM »).
#[serde(with = "js::map")]
pub sales: Serie,
/// Stock de fin de mois, reporté sur les mois sans donnée.
#[serde(with = "js::map")]
pub stock: Serie,
#[serde(with = "js::map")]
pub receptions: Serie,
#[serde(with = "js::num")]
pub quantite: f64,
#[serde(with = "js::num")]
pub ca: f64,
#[serde(with = "js::num")]
pub marge: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StorePatch {
pub magasin: String,
/// Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré.
pub periods: Vec<String>,
pub entries: Vec<StorePatchEntry>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Ligne telle que l'écrit la version Node (ordre jsonb, null, champ inconnu).
fn payload_node() -> Value {
json!({
"pa": 2.5, "pcb": 6, "gtin": "3700000000001", "noid": 4242,
"code1": "32", "code2": "3202", "code3": "320211",
"codein": "123456", "libelle1": "Bougie parfumée", "libelle3": "Bougies",
"sales12m": { "202510": 3, "202511": 0 }, "stock12m": { "202510": 10, "202511": 10 },
"totalCa": 37.5, "caByStore": { "292": 37.5 }, "codeGamme": "A",
"reference": "REF-1", "tauxMarge": 41.2, "prixAchat": 2.5, "totalMarge": 15.45,
"codeCentrale": "10000123456", "codeGammeInit": "B", "codeGammeDraft": null,
"nomFournisseur": "FOURNISSEUR TEST", "receptions12m": { "202510": 0, "202511": 0 },
"totalQuantite": 3, "workingStores": ["292"], "libelleNiveau1": "Déco", "libelleNiveau2": "Senteurs",
"codeFournisseur": "F001", "payloadVersion": 1, "sales12mByStore": { "292": { "202510": 3 }, "579": {} },
"qteReseauByMonth": null, "margePctReseau": null, "champFutur": { "x": [1, 2] },
"derniereLivraisonByStore": { "292": "2025-10-03" }
})
}
#[test]
fn aller_retour_payload_node() {
let brut = payload_node();
let row: ProductRow = serde_json::from_value(brut.clone()).unwrap();
assert_eq!(row.codein, "123456");
assert_eq!(row.noid, Some(4242.0));
assert_eq!(row.code_gamme_draft, None);
assert_eq!(row.qte_reseau_by_month, Some(None));
assert!(row.marge_pct_reseau.unwrap().is_nan());
assert!(row.nb_mag_reseau_by_month.is_none());
assert_eq!(row.extra.get("champFutur"), Some(&json!({ "x": [1, 2] })));
// Même contenu JSON après relecture et réécriture.
let reecrit = serde_json::to_value(&row).unwrap();
assert_eq!(reecrit, brut);
}
#[test]
fn champs_facultatifs_omis_et_entiers_sans_decimale() {
let row = ProductRow { codein: "1".into(), total_quantite: 12.0, total_ca: 10.5, ..Default::default() };
let s = serde_json::to_string(&row).unwrap();
assert!(s.starts_with(r#"{"codein":"1","codeFournisseur":"","nomFournisseur":"","libelle1":"""#));
assert!(s.contains(r#""codeGamme":null,"codeGammeInit":null,"codeGammeDraft":null"#));
assert!(s.contains(r#""totalQuantite":12,"totalCa":10.5"#));
assert!(!s.contains("noid"));
assert!(!s.contains("payloadVersion"));
}
}
+362
View File
@@ -0,0 +1,362 @@
//! Validation façon zod (v4) : mêmes contrôles, mêmes « issues ».
//!
//! Les routes `/api/v1` renvoient `parsed.error.issues` dans `error.details`, et
//! certaines actions recopient `parsed.error.message` (le JSON indenté des issues)
//! dans leur message d'erreur. Pour que l'appelant reçoive exactement le même
//! détail, on reproduit la forme des issues de zod 4 : clés dans le même ordre,
//! mêmes messages par défaut, même ordre de parcours (celui du schéma).
use super::js;
use serde_json::{json, Map, Value};
/// Plus grand entier « sûr » de JS (`Number.MAX_SAFE_INTEGER`).
pub const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
/// Segment de chemin d'une issue : clé d'objet ou indice de tableau.
#[derive(Debug, Clone)]
pub enum Seg {
K(String),
I(usize),
}
impl From<&str> for Seg {
fn from(s: &str) -> Self {
Seg::K(s.to_string())
}
}
impl From<usize> for Seg {
fn from(i: usize) -> Self {
Seg::I(i)
}
}
pub type Path = Vec<Seg>;
pub fn path_json(path: &[Seg]) -> Value {
Value::Array(
path.iter()
.map(|s| match s {
Seg::K(k) => Value::String(k.clone()),
Seg::I(i) => Value::from(*i),
})
.collect(),
)
}
pub fn child(path: &[Seg], seg: impl Into<Seg>) -> Path {
let mut p = path.to_vec();
p.push(seg.into());
p
}
/// Type reçu, tel que zod le nomme dans ses messages.
pub fn received(v: Option<&Value>) -> &'static str {
match v {
None => "undefined",
Some(Value::Null) => "null",
Some(Value::Bool(_)) => "boolean",
Some(Value::Number(_)) => "number",
Some(Value::String(_)) => "string",
Some(Value::Array(_)) => "array",
Some(Value::Object(_)) => "object",
}
}
/// Nombre affiché dans un message (`String(n)`).
fn n(x: f64) -> String {
js::js_number_to_string(x)
}
fn num_json(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() <= MAX_SAFE_INTEGER {
Value::from(x as i64)
} else {
serde_json::Number::from_f64(x).map(Value::Number).unwrap_or(Value::Null)
}
}
/// Liste d'issues, dans l'ordre où zod les produit.
#[derive(Debug, Default, Clone)]
pub struct Issues(pub Vec<Value>);
impl Issues {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
fn push(&mut self, mut issue: Map<String, Value>, path: &[Seg], message: String) {
issue.insert("path".into(), path_json(path));
issue.insert("message".into(), Value::String(message));
self.0.push(Value::Object(issue));
}
fn obj(v: Value) -> Map<String, Value> {
match v {
Value::Object(m) => m,
_ => Map::new(),
}
}
/// `invalid_type` : type attendu absent ou différent.
pub fn invalid_type(&mut self, path: &[Seg], expected: &str, got: Option<&Value>) {
let issue = Self::obj(json!({ "expected": expected, "code": "invalid_type" }));
self.push(issue, path, format!("Invalid input: expected {expected}, received {}", received(got)));
}
/// `invalid_type` d'un nombre non fini (`NaN`, `Infinity`) après coercition.
pub fn invalid_number(&mut self, path: &[Seg], x: f64) {
let recu = if x.is_nan() { "NaN" } else { "Infinity" };
let issue = Self::obj(json!({ "expected": "number", "code": "invalid_type", "received": recu }));
let msg = if x.is_nan() { "received NaN" } else { "received number" };
self.push(issue, path, format!("Invalid input: expected number, {msg}"));
}
/// `.int()` sur un nombre à décimales.
pub fn not_int(&mut self, path: &[Seg]) {
let issue = Self::obj(json!({ "expected": "int", "format": "safeint", "code": "invalid_type" }));
self.push(issue, path, "Invalid input: expected int, received number".into());
}
/// `.int()` hors de l'intervalle des entiers sûrs.
pub fn unsafe_int(&mut self, path: &[Seg], trop_grand: bool) {
let (code, cle, borne, sens, op) = if trop_grand {
("too_big", "maximum", MAX_SAFE_INTEGER, "Too big", "<=")
} else {
("too_small", "minimum", -MAX_SAFE_INTEGER, "Too small", ">=")
};
let mut issue = Map::new();
issue.insert("code".into(), json!(code));
issue.insert(cle.into(), num_json(borne));
issue.insert("note".into(), json!("Integers must be within the safe integer range."));
issue.insert("origin".into(), json!("int"));
issue.insert("inclusive".into(), json!(true));
self.push(issue, path, format!("{sens}: expected int to be {op}{}", n(borne)));
}
/// `too_small` (`origin` : `string`, `number` ou `array`).
pub fn too_small(&mut self, path: &[Seg], origin: &str, minimum: f64, message: Option<&str>) {
let mut issue = Map::new();
issue.insert("origin".into(), json!(origin));
issue.insert("code".into(), json!("too_small"));
issue.insert("minimum".into(), num_json(minimum));
issue.insert("inclusive".into(), json!(true));
let defaut = match origin {
"string" => format!("Too small: expected string to have >={} characters", n(minimum)),
"array" => format!("Too small: expected array to have >={} items", n(minimum)),
_ => format!("Too small: expected {origin} to be >={}", n(minimum)),
};
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `too_big` (`origin` : `string`, `number` ou `array`).
pub fn too_big(&mut self, path: &[Seg], origin: &str, maximum: f64, message: Option<&str>) {
let mut issue = Map::new();
issue.insert("origin".into(), json!(origin));
issue.insert("code".into(), json!("too_big"));
issue.insert("maximum".into(), num_json(maximum));
issue.insert("inclusive".into(), json!(true));
let defaut = match origin {
"string" => format!("Too big: expected string to have <={} characters", n(maximum)),
"array" => format!("Too big: expected array to have <={} items", n(maximum)),
_ => format!("Too big: expected {origin} to be <={}", n(maximum)),
};
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `invalid_value` d'un `z.enum([...])`.
pub fn invalid_value(&mut self, path: &[Seg], values: &[&str], message: Option<&str>) {
let issue = Self::obj(json!({ "code": "invalid_value", "values": values }));
let defaut = format!("Invalid option: expected one of {}", values.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join("|"));
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `invalid_format` d'un `.regex(...)`.
pub fn invalid_regex(&mut self, path: &[Seg], pattern: &str, message: &str) {
let issue = Self::obj(json!({ "origin": "string", "code": "invalid_format", "format": "regex", "pattern": pattern }));
self.push(issue, path, message.to_string());
}
/// `custom` d'un `.refine(...)`.
pub fn custom(&mut self, path: &[Seg], message: &str) {
let issue = Self::obj(json!({ "code": "custom" }));
self.push(issue, path, message.to_string());
}
/// Les issues, telles que `parsed.error.issues`.
pub fn to_json(&self) -> Value {
Value::Array(self.0.clone())
}
/// `parsed.error.message` : `JSON.stringify(issues, null, 2)`.
pub fn message(&self) -> String {
serde_json::to_string_pretty(&self.0).unwrap_or_default()
}
/// `issues.map((i) => i.message).join(", ")`.
pub fn messages_joined(&self) -> String {
self.0.iter().map(|i| i.get("message").and_then(Value::as_str).unwrap_or_default().to_string()).collect::<Vec<_>>().join(", ")
}
}
// ---------------------------------------------------------------------------
// Briques de schéma
// ---------------------------------------------------------------------------
/// `z.object(...)` : la racine doit être un objet. `None` (issue posée) sinon.
pub fn object<'a>(v: Option<&'a Value>, path: &[Seg], issues: &mut Issues) -> Option<&'a Map<String, Value>> {
match v {
Some(Value::Object(m)) => Some(m),
autre => {
issues.invalid_type(path, "object", autre);
None
}
}
}
/// `z.string()` obligatoire.
pub fn string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<String> {
match v {
Some(Value::String(s)) => Some(s.clone()),
autre => {
issues.invalid_type(path, "string", autre);
None
}
}
}
/// `z.string().optional()` : `Ok(None)` si absent.
pub fn opt_string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<Option<String>> {
match v {
None => Some(None),
autre => string(autre, path, issues).map(Some),
}
}
/// `z.string().nullable()`.
pub fn nullable_string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<Option<String>> {
match v {
Some(Value::Null) => Some(None),
autre => string(autre, path, issues).map(Some),
}
}
/// Bornes de longueur d'une chaîne (`.min(n)`, `.max(n)`), en unités UTF-16 comme JS.
pub fn check_len(
s: &str,
path: &[Seg],
min: Option<(usize, Option<&str>)>,
max: Option<(usize, Option<&str>)>,
issues: &mut Issues,
) -> bool {
let len = s.encode_utf16().count();
let mut ok = true;
if let Some((m, msg)) = min {
if len < m {
issues.too_small(path, "string", m as f64, msg);
ok = false;
}
}
if let Some((m, msg)) = max {
if len > m {
issues.too_big(path, "string", m as f64, msg);
ok = false;
}
}
ok
}
/// `z.number()` (pas de coercition) : nombre fini.
pub fn number(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<f64> {
match v {
Some(Value::Number(x)) => x.as_f64(),
autre => {
issues.invalid_type(path, "number", autre);
None
}
}
}
/// `z.boolean()`.
pub fn boolean(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<bool> {
match v {
Some(Value::Bool(b)) => Some(*b),
autre => {
issues.invalid_type(path, "boolean", autre);
None
}
}
}
/// `z.enum([...])` sur une valeur quelconque.
pub fn enum_value(v: Option<&Value>, values: &[&str], path: &[Seg], message: Option<&str>, issues: &mut Issues) -> Option<String> {
match v.and_then(Value::as_str) {
Some(s) if values.contains(&s) => Some(s.to_string()),
_ => {
issues.invalid_value(path, values, message);
None
}
}
}
/// `z.coerce.number().int().min(min)[.max(max)]` sur un paramètre d'URL.
pub fn coerce_int(s: &str, path: &[Seg], min: f64, max: Option<f64>, issues: &mut Issues) -> Option<i64> {
let x = js::js_number_from_str(s);
if !x.is_finite() {
issues.invalid_number(path, x);
return None;
}
if x.fract() != 0.0 {
issues.not_int(path);
return None;
}
let mut ok = true;
if x > MAX_SAFE_INTEGER {
issues.unsafe_int(path, true);
ok = false;
} else if x < -MAX_SAFE_INTEGER {
issues.unsafe_int(path, false);
ok = false;
}
if x < min {
issues.too_small(path, "number", min, None);
ok = false;
}
if let Some(m) = max {
if x > m {
issues.too_big(path, "number", m, None);
ok = false;
}
}
// `-0` reste un entier valide pour zod ; il ne peut de toute façon pas
// franchir un minimum de 1.
ok.then_some(x as i64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forme_des_issues() {
let mut i = Issues::default();
let p = vec![Seg::from("niveau")];
assert!(coerce_int("abc", &p, 1.0, Some(3.0), &mut i).is_none());
assert!(coerce_int("1e20", &p, 1.0, Some(3.0), &mut i).is_none());
assert!(coerce_int("1.5", &p, 1.0, Some(3.0), &mut i).is_none());
assert_eq!(coerce_int(" 0x2 ", &p, 1.0, Some(3.0), &mut i), Some(2));
assert_eq!(
serde_json::to_string(&i.to_json()).unwrap(),
r#"[{"expected":"number","code":"invalid_type","received":"NaN","path":["niveau"],"message":"Invalid input: expected number, received NaN"},{"code":"too_big","maximum":9007199254740991,"note":"Integers must be within the safe integer range.","origin":"int","inclusive":true,"path":["niveau"],"message":"Too big: expected int to be <=9007199254740991"},{"origin":"number","code":"too_big","maximum":3,"inclusive":true,"path":["niveau"],"message":"Too big: expected number to be <=3"},{"expected":"int","format":"safeint","code":"invalid_type","path":["niveau"],"message":"Invalid input: expected int, received number"}]"#
);
}
#[test]
fn message_indente_comme_json_stringify() {
let mut i = Issues::default();
i.invalid_type(&[], "object", Some(&Value::Null));
assert_eq!(
i.message(),
"[\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [],\n \"message\": \"Invalid input: expected object, received null\"\n }\n]"
);
}
}
+225
View File
@@ -0,0 +1,225 @@
//! Capture des journaux serveur d'un job, pour téléchargement.
//!
//! Les extractions Qlik produisent des milliers de lignes ; la sortie standard
//! du conteneur les tronque. Tant qu'au moins une capture est ouverte, chaque
//! ligne journalisée (par n'importe quel code du processus) y est recopiée, puis
//! écrite sur disque au fil de l'eau (toutes les 2 s au plus).
//!
//! ⚠️ Deux jobs simultanés partagent les journaux du processus : chaque capture
//! ouverte reçoit TOUTES les lignes de la période.
use chrono::Utc;
use parking_lot::Mutex;
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::PathBuf;
use std::sync::LazyLock;
use std::time::{Instant, SystemTime};
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::Context;
use tracing_subscriber::Layer;
const MAX_LIGNES: usize = 200_000;
const MAX_LONGUEUR_LIGNE: usize = 8_000;
const MAX_FICHIERS: usize = 20;
const FLUSH_MS: u128 = 2_000;
struct Capture {
lignes: Vec<String>,
tronque: bool,
debut: Instant,
dernier_flush: Option<Instant>,
ecrites: usize,
}
static CAPTURES: LazyLock<Mutex<HashMap<String, Capture>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
fn log_dir() -> PathBuf {
std::env::temp_dir().join("collectflow-logs")
}
fn nom_sur(id: &str) -> String {
id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect()
}
fn fichier_pour(id: &str) -> PathBuf {
log_dir().join(format!("{}.log", nom_sur(id)))
}
fn horodatage() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
/// Écrit sur disque les lignes pas encore écrites (synchrone, appelé sous verrou).
fn flush(id: &str, c: &mut Capture) {
let _ = std::fs::create_dir_all(log_dir());
let total = c.lignes.len();
if c.ecrites == 0 || total > c.ecrites {
let nouvelles = &c.lignes[c.ecrites..total];
let mut texte = nouvelles.join("\n");
texte.push('\n');
let res = if c.ecrites == 0 {
std::fs::write(fichier_pour(id), texte)
} else {
std::fs::OpenOptions::new()
.append(true)
.open(fichier_pour(id))
.and_then(|mut f| f.write_all(texte.as_bytes()))
};
if res.is_ok() {
c.ecrites = total;
}
}
c.dernier_flush = Some(Instant::now());
}
/// Ajoute une ligne à toutes les captures ouvertes.
pub fn ajouter(ligne: &str) {
let mut captures = CAPTURES.lock();
if captures.is_empty() {
return;
}
let texte = if ligne.len() > MAX_LONGUEUR_LIGNE {
let mut cut = MAX_LONGUEUR_LIGNE;
while !ligne.is_char_boundary(cut) {
cut -= 1;
}
format!("{} …[ligne tronquée]", &ligne[..cut])
} else {
ligne.to_string()
};
let prefixe = format!("[{}] ", horodatage());
for (id, c) in captures.iter_mut() {
if c.lignes.len() >= MAX_LIGNES {
if !c.tronque {
c.tronque = true;
c.lignes.push(format!("{prefixe}[log-capture] limite de {MAX_LIGNES} lignes atteinte — suite non enregistrée"));
}
continue;
}
c.lignes.push(format!("{prefixe}{texte}"));
if c.dernier_flush.map(|t| t.elapsed().as_millis() >= FLUSH_MS).unwrap_or(true) {
flush(id, c);
}
}
}
fn purger() {
let Ok(rd) = std::fs::read_dir(log_dir()) else { return };
let mut fichiers: Vec<(PathBuf, SystemTime)> = rd
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".log"))
.map(|e| (e.path(), e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH)))
.collect();
if fichiers.len() <= MAX_FICHIERS {
return;
}
fichiers.sort_by(|a, b| b.1.cmp(&a.1));
for (p, _) in fichiers.into_iter().skip(MAX_FICHIERS) {
let _ = std::fs::remove_file(p);
}
}
/// Ouvre une capture : toutes les lignes journalisées à partir de maintenant y
/// sont recopiées jusqu'à [`stop_capture`].
pub fn start_capture(id: &str, entete: Option<&str>) {
let mut c = Capture { lignes: Vec::new(), tronque: false, debut: Instant::now(), dernier_flush: None, ecrites: 0 };
c.lignes.push(format!("[{}] [log-capture] début de capture {id}", horodatage()));
if let Some(e) = entete {
c.lignes.push(format!("[{}] [log-capture] {e}", horodatage()));
}
flush(id, &mut c);
CAPTURES.lock().insert(id.to_string(), c);
purger();
}
/// Ferme la capture et écrit son état final sur disque.
pub fn stop_capture(id: &str, pied: Option<&str>) {
let Some(mut c) = CAPTURES.lock().remove(id) else { return };
let duree = c.debut.elapsed().as_secs();
let n = c.lignes.len();
c.lignes.push(format!("[{}] [log-capture] fin de capture {id} — {n} lignes, {duree} s", horodatage()));
if let Some(p) = pied {
c.lignes.push(format!("[{}] [log-capture] {p}", horodatage()));
}
flush(id, &mut c);
}
/// Contenu d'un journal : la capture en cours, sinon le fichier sur disque.
pub fn read_capture(id: &str) -> Option<String> {
if let Some(c) = CAPTURES.lock().get(id) {
return Some(c.lignes.join("\n") + "\n");
}
std::fs::read_to_string(fichier_pour(id)).ok()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptureInfo {
pub id: String,
pub octets: u64,
pub modifie_le: String,
pub en_cours: bool,
}
/// Journaux disponibles, du plus récent au plus ancien.
pub fn list_captures() -> Vec<CaptureInfo> {
let en_cours: Vec<String> = CAPTURES.lock().keys().cloned().collect();
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(log_dir()) {
for e in rd.filter_map(|e| e.ok()) {
let nom = e.file_name().to_string_lossy().to_string();
let Some(id) = nom.strip_suffix(".log") else { continue };
let Ok(meta) = e.metadata() else { continue };
let modifie: chrono::DateTime<Utc> = meta.modified().map(Into::into).unwrap_or_else(|_| Utc::now());
out.push(CaptureInfo {
id: id.to_string(),
octets: meta.len(),
modifie_le: modifie.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
en_cours: en_cours.iter().any(|x| nom_sur(x) == id),
});
}
}
out.sort_by(|a, b| b.modifie_le.cmp(&a.modifie_le));
out
}
// ---------------------------------------------------------------------------
// Couche `tracing` qui alimente les captures
// ---------------------------------------------------------------------------
pub struct CaptureLayer;
struct MessageVisitor(String);
impl Visit for MessageVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
let _ = write!(self.0, "{value:?}");
} else {
let _ = write!(self.0, " {}={value:?}", field.name());
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.0.push_str(value);
} else {
let _ = write!(self.0, " {}={value}", field.name());
}
}
}
impl<S: Subscriber> Layer<S> for CaptureLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
// Aucune capture ouverte (le cas courant) : on ne formate rien.
if CAPTURES.lock().is_empty() {
return;
}
let mut v = MessageVisitor(String::new());
event.record(&mut v);
ajouter(&v.0);
}
}
Loaded 100 of 304 files, more files were not shown because too many files have changed in this diff. Show more