mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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
This commit is contained in:
35 files changed
+1010
-240
No files matched your search
@@ -0,0 +1,272 @@
|
||||
# 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.
|
||||
@@ -61,6 +61,15 @@ async function main() {
|
||||
"created_at" timestamp DEFAULT now()
|
||||
);
|
||||
`);
|
||||
// Index : la Grille relit le dernier snapshot du fournisseur à chaque calcul
|
||||
// et à chaque enregistrement, et l'historique liste ceux de l'utilisateur.
|
||||
// Sans eux, chaque lecture parcourait toute la table (qui ne fait que grossir).
|
||||
await tempPool.query(`
|
||||
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);
|
||||
`);
|
||||
console.log("[DB Init] Table session_snapshots is verified/created.");
|
||||
|
||||
await tempPool.query(`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
|
||||
CheckCircle, Database, Network, Search,
|
||||
@@ -114,6 +114,10 @@ export function SynchronisationClient() {
|
||||
return () => clearInterval(id);
|
||||
}, [state?.enCours, rafraichirEtat]);
|
||||
|
||||
// Saisie numérique pas encore envoyée (cf. majSettingsDifferee).
|
||||
const patchEnAttente = useRef<Partial<SyncSettings>>({});
|
||||
const minuteurPatch = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const majSettings = async (patch: Partial<SyncSettings>) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
@@ -122,10 +126,26 @@ export function SynchronisationClient() {
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
||||
setSettings(d.settings);
|
||||
// Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse.
|
||||
setSettings({ ...d.settings, ...patchEnAttente.current });
|
||||
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
|
||||
};
|
||||
|
||||
// Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur
|
||||
// est regroupé 600 ms après la dernière frappe. Avant, chaque chiffre tapé
|
||||
// partait en PATCH, et une réponse tardive pouvait écraser la saisie en cours.
|
||||
const majSettingsDifferee = (patch: Partial<SyncSettings>) => {
|
||||
setSettings((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
patchEnAttente.current = { ...patchEnAttente.current, ...patch };
|
||||
if (minuteurPatch.current) clearTimeout(minuteurPatch.current);
|
||||
minuteurPatch.current = setTimeout(() => {
|
||||
const aEnvoyer = patchEnAttente.current;
|
||||
patchEnAttente.current = {};
|
||||
minuteurPatch.current = null;
|
||||
void majSettings(aEnvoyer);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
@@ -207,7 +227,7 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureDebut}
|
||||
onChange={(e) => majSettings({ heureDebut: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
|
||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
@@ -215,7 +235,7 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureFin}
|
||||
onChange={(e) => majSettings({ heureFin: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
|
||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
@@ -225,14 +245,14 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
|
||||
<input type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||
onChange={(e) => majSettings({ qlikParNuit: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
|
||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
|
||||
<input type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||
onChange={(e) => majSettings({ qlikMinJours: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
|
||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client";
|
||||
import { AnalyticsClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
// Calcul du mois N-1 (même mois année précédente)
|
||||
function getMoisN1(mois: string): string {
|
||||
@@ -84,7 +85,7 @@ export default async function AnalyticsPage(props: {
|
||||
[];
|
||||
|
||||
if (mode === "fournisseur") {
|
||||
const result = await pgGetCaByFournisseur(mois, moisN1);
|
||||
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: r.nom,
|
||||
@@ -93,7 +94,7 @@ export default async function AnalyticsPage(props: {
|
||||
ca_ttc: r.ca_ttc,
|
||||
}));
|
||||
} else {
|
||||
const result = await pgGetCaByNomenclature(mois, moisN1);
|
||||
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: `${r.code} — ${r.libelle}`,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
@@ -162,17 +161,17 @@ function SiteSection({
|
||||
rows: CadenceView[];
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const sorted = useMemo(() => trier(rows), [rows]);
|
||||
const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
|
||||
const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length;
|
||||
|
||||
// Pas de router.refresh() : les actions appellent déjà revalidatePath, dont
|
||||
// la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois.
|
||||
function run(fn: () => Promise<unknown>) {
|
||||
startTransition(async () => {
|
||||
await fn();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
|
||||
import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions";
|
||||
import { CommandesAutoTabs } from "./tabs";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export type { PgCommandeAutoRow };
|
||||
|
||||
@@ -9,7 +10,13 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CommandesAutoPage() {
|
||||
const [rows, cadences, fournisseurs] = await Promise.all([
|
||||
pgGetCommandesAuto(),
|
||||
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
|
||||
// gardées 2 minutes, pour que chaque modification du cadencier — qui
|
||||
// re-rend la page — n'attende pas de nouveau toute l'API.
|
||||
cachedFF("commandes-auto", () => pgGetCommandesAuto(), {
|
||||
ttlMs: 2 * 60 * 1000,
|
||||
cacheIf: (rows) => rows.length > 0,
|
||||
}),
|
||||
listCadences(),
|
||||
getFournisseursPourCadence(),
|
||||
]);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
import type { HitParadePivotRow } from "./page";
|
||||
|
||||
type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "stockTotal" | "stock292" | "stock579";
|
||||
@@ -136,7 +135,9 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
||||
), [sorted]);
|
||||
|
||||
function exportToExcel() {
|
||||
async function exportToExcel() {
|
||||
// Chargée au clic seulement : la bibliothèque pèse plusieurs centaines de Ko.
|
||||
const XLSX = await import("xlsx");
|
||||
const headers = [
|
||||
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
||||
"Qté Nancy", "CA TTC Nancy", "% Marge Nancy", "Stock Nancy",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client";
|
||||
import { HitParadeClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export interface HitParadePivotRow {
|
||||
codein: string;
|
||||
@@ -80,7 +81,7 @@ export default async function HitParadePage(props: {
|
||||
const dateDebut = (searchParams.debut as string) || defaults.debut;
|
||||
const dateFin = (searchParams.fin as string) || defaults.fin;
|
||||
|
||||
const rows = await pgGetHitParade(dateDebut, dateFin);
|
||||
const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin));
|
||||
const pivotted = pivotHitParade(rows);
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,8 @@ function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; s
|
||||
const STATUT_LABELS: Record<string, { label: string; cls: string }> = {
|
||||
en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" },
|
||||
passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
||||
// Le filtre envoie `passees` à l'API : la même valeur peut revenir sur les lignes.
|
||||
passees: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
||||
a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PublicitesClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export interface Publicite {
|
||||
tcr_code: string;
|
||||
@@ -40,17 +41,31 @@ export interface PubliciteHistorique {
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
const PAGE_SIZE = 50;
|
||||
/** Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page. */
|
||||
const FF_API_TIMEOUT_MS = 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_MS = 10 * 60 * 1000;
|
||||
|
||||
async function fetchPublicites(statut: string, page: number): Promise<{
|
||||
data: Publicite[];
|
||||
total: number;
|
||||
pages: number;
|
||||
error?: string;
|
||||
}> {
|
||||
type PublicitesResult = { data: Publicite[]; total: number; pages: number; error?: string };
|
||||
|
||||
function fetchPublicites(statut: string, page: number): Promise<PublicitesResult> {
|
||||
return cachedFF(`publicites:${statut}:${page}`, () => fetchPublicitesApi(statut, page), {
|
||||
ttlMs: PUB_CACHE_TTL_MS,
|
||||
cacheIf: (r) => !r.error,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPublicitesApi(statut: string, page: number): Promise<PublicitesResult> {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) });
|
||||
if (statut && statut !== "toutes") params.set("statut", statut);
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(FF_API_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { data: [], total: 0, pages: 0, error: `Erreur API: ${res.status} ${res.statusText}` };
|
||||
const json = await res.json();
|
||||
return {
|
||||
@@ -63,9 +78,21 @@ async function fetchPublicites(statut: string, page: number): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistorique(): Promise<{ data: PubliciteHistorique[]; error?: string }> {
|
||||
type HistoriqueResult = { data: PubliciteHistorique[]; error?: string };
|
||||
|
||||
function fetchHistorique(): Promise<HistoriqueResult> {
|
||||
return cachedFF("publicites-historique", fetchHistoriqueApi, {
|
||||
ttlMs: PUB_CACHE_TTL_MS,
|
||||
cacheIf: (r) => !r.error,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchHistoriqueApi(): Promise<HistoriqueResult> {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(FF_API_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { data: [], error: `Erreur historique: ${res.status}` };
|
||||
const json = await res.json();
|
||||
return { data: (json.publicites ?? []) as PubliciteHistorique[] };
|
||||
|
||||
@@ -13,6 +13,22 @@ import { ApiConnectionInfo } from "@/features/admin/components/api-connection-in
|
||||
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||
|
||||
/**
|
||||
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
|
||||
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
|
||||
* serveur s'exécutent les unes après les autres.
|
||||
*/
|
||||
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
|
||||
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
|
||||
if (force || !savedConfigPromise) {
|
||||
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
|
||||
savedConfigPromise = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return savedConfigPromise;
|
||||
}
|
||||
|
||||
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="apple-card">
|
||||
@@ -51,7 +67,7 @@ function FfApiStatusSection() {
|
||||
|
||||
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig()
|
||||
loadSavedConfig()
|
||||
.then((cfg) => {
|
||||
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
@@ -177,7 +193,7 @@ function QlikSettingsSection() {
|
||||
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig().then((c) => {
|
||||
loadSavedConfig().then((c) => {
|
||||
if (!c) return;
|
||||
setHost(c.qlikHost ?? "");
|
||||
setUser(c.qlikUser ?? "");
|
||||
@@ -265,8 +281,8 @@ export default function SettingsPage() {
|
||||
getDatabaseUrl
|
||||
} = useDbSettingsStore();
|
||||
|
||||
const reloadFromServer = useCallback(async () => {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const reloadFromServer = useCallback(async (force = false) => {
|
||||
const config = await loadSavedConfig(force);
|
||||
if (config) {
|
||||
if (config.url) {
|
||||
// Parser l'URL pour remettre dans le store
|
||||
@@ -290,7 +306,9 @@ export default function SettingsPage() {
|
||||
// au montage, ce que la règle déconseille en général mais qui est ici le but.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsMounted(true);
|
||||
reloadFromServer();
|
||||
// Lecture fraîche à chaque ouverture de la page ; les sections, montées
|
||||
// juste après (cf. `isMounted`), réutilisent cette même requête.
|
||||
reloadFromServer(true);
|
||||
}, [reloadFromServer]);
|
||||
|
||||
const testDb = async () => {
|
||||
@@ -410,7 +428,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={reloadFromServer}
|
||||
onClick={() => reloadFromServer(true)}
|
||||
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client";
|
||||
import { GestionStockClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow };
|
||||
|
||||
@@ -15,10 +16,12 @@ export default async function GestionStockPage(props: {
|
||||
const magasin = (searchParams.magasin as string) || "";
|
||||
const tab = (searchParams.tab as string) || "negatif";
|
||||
|
||||
// Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts).
|
||||
const site = magasin || undefined;
|
||||
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
|
||||
pgGetStockNegatif(magasin || undefined),
|
||||
pgGetStockSansVente(magasin || undefined),
|
||||
pgGetSansVente6Mois(magasin || undefined),
|
||||
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
|
||||
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
|
||||
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { GridFilters } from "@/types/grid";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, value: unknown) {
|
||||
controller.enqueue(new TextEncoder().encode(`${JSON.stringify(value)}\n`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -26,14 +28,25 @@ export async function GET(request: NextRequest) {
|
||||
};
|
||||
const forceRefresh = searchParams.get("refresh") === "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 cancelled = false;
|
||||
request.signal.addEventListener("abort", () => { cancelled = true; });
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
async start(controller) {
|
||||
try {
|
||||
writeNdjson(controller, { type: "start", loaded: 0, total: null });
|
||||
const rows = await getProductRows({ codeFournisseur, magasin, filters, forceRefresh });
|
||||
if (cancelled) return;
|
||||
const total = rows.length;
|
||||
|
||||
for (let start = 0; start < total; start += chunkSize) {
|
||||
if (cancelled) return;
|
||||
const chunk = rows.slice(start, start + chunkSize);
|
||||
writeNdjson(controller, {
|
||||
type: "chunk",
|
||||
@@ -46,12 +59,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
writeNdjson(controller, { type: "done", loaded: total, total });
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
writeNdjson(controller, {
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
controller.close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* flux déjà fermé côté client */
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+36
-11
@@ -4,12 +4,19 @@ import * as schema from "./schema";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
let pool: Pool | null = null;
|
||||
let currentDb: NodePgDatabase<typeof schema> | null = null;
|
||||
/**
|
||||
* Pool unique par processus, rangé dans `globalThis` : l'instrumentation (synchro
|
||||
* nocturne) et les routes peuvent être compilées dans des lots distincts, chacun
|
||||
* avec sa propre copie de ce module — et donc son propre pool de 20 connexions.
|
||||
* Même chose à chaque rechargement à chaud en développement.
|
||||
*/
|
||||
type DbState = { pool: Pool | null; db: NodePgDatabase<typeof schema> | null };
|
||||
const globalForDb = globalThis as typeof globalThis & { __collectflowDb?: DbState };
|
||||
const state: DbState = (globalForDb.__collectflowDb ??= { pool: null, db: null });
|
||||
|
||||
export function getPool(): Pool {
|
||||
getDb(); // ensure pool is initialized
|
||||
return pool!;
|
||||
return state.pool!;
|
||||
}
|
||||
|
||||
function maskUrl(url: string | undefined) {
|
||||
@@ -21,7 +28,7 @@ function maskUrl(url: string | undefined) {
|
||||
* Initializes or returns the current database instance.
|
||||
*/
|
||||
export function getDb() {
|
||||
if (currentDb) return currentDb;
|
||||
if (state.db) return state.db;
|
||||
|
||||
let connectionString = "";
|
||||
|
||||
@@ -54,19 +61,37 @@ export function getDb() {
|
||||
const connectionHostname = connectionString.match(/@([^:/]+)/)?.[1] || "unknown";
|
||||
console.log(`[DB] Target Hostname: ${connectionHostname}`);
|
||||
|
||||
pool = new Pool({
|
||||
// Délai maximal par requête : opt-in (les calculs nocturnes des plus gros
|
||||
// fournisseurs peuvent être longs), pour qu'une requête emballée ne monopolise
|
||||
// pas indéfiniment une des 20 connexions.
|
||||
const statementTimeout = Number(process.env.PG_STATEMENT_TIMEOUT_MS) || undefined;
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
application_name: "collectflow",
|
||||
...(statementTimeout ? { statement_timeout: statementTimeout } : {}),
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected error on idle client', err);
|
||||
});
|
||||
|
||||
currentDb = drizzle(pool, { schema });
|
||||
return currentDb;
|
||||
// Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop petit
|
||||
// dans le conteneur, et font échouer les grosses agrégations. Réglé une fois
|
||||
// par connexion (la requête est mise en file avant toute autre sur ce client)
|
||||
// au lieu d'une transaction BEGIN / SET LOCAL / COMMIT autour de chaque requête.
|
||||
pool.on('connect', (client) => {
|
||||
client.query("SET max_parallel_workers_per_gather = 0").catch((err) => {
|
||||
console.error('[DB] SET max_parallel_workers_per_gather failed:', err?.message);
|
||||
});
|
||||
});
|
||||
|
||||
state.pool = pool;
|
||||
state.db = drizzle(pool, { schema });
|
||||
return state.db;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,11 +100,11 @@ export function getDb() {
|
||||
*/
|
||||
export function refreshDb() {
|
||||
console.log("[DB] Refreshing database connection...");
|
||||
if (pool) {
|
||||
pool.end().catch(err => console.error("[DB] Error closing pool:", err));
|
||||
pool = null;
|
||||
if (state.pool) {
|
||||
state.pool.end().catch(err => console.error("[DB] Error closing pool:", err));
|
||||
state.pool = null;
|
||||
}
|
||||
currentDb = null;
|
||||
state.db = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-1
@@ -75,7 +75,11 @@ export const sessionSnapshots = pgTable("session_snapshots", {
|
||||
/** snapshot vs export */
|
||||
type: varchar("type", { length: 20 }).default("snapshot"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
}, (table) => [
|
||||
// Créés par scripts/db-init.js (la migration réellement jouée au démarrage).
|
||||
index("idx_snapshots_fournisseur_date").on(table.codeFournisseur, table.createdAt.desc()),
|
||||
index("idx_snapshots_user_type_date").on(table.userId, table.type, table.createdAt.desc()),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cadencier / alertes de commande par fournisseur ET par magasin.
|
||||
|
||||
@@ -33,6 +33,7 @@ import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings";
|
||||
import { invalidateFFCache } from "@/lib/ff-cache";
|
||||
|
||||
export type { SyncSettings };
|
||||
|
||||
@@ -338,6 +339,9 @@ export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre
|
||||
etat.derniereErreur = e instanceof Error ? e.message : String(e);
|
||||
console.error("[sync-nuit] round interrompu :", etat.derniereErreur);
|
||||
} finally {
|
||||
// Les pages (stock, hit-parade, CA…) relisent la base fraîche au lieu
|
||||
// d'attendre la fin de vie de leur cache.
|
||||
invalidateFFCache();
|
||||
etat.enCours = false;
|
||||
etat.fournisseurCourant = null;
|
||||
etat.etape = null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { commandeCadences } from "@/db/schema";
|
||||
import { pgGetFournisseurs, pgGetDerniereReceptionParFournisseur } from "@/lib/pg-ff-client";
|
||||
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
|
||||
|
||||
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
|
||||
|
||||
@@ -37,7 +37,7 @@ function calcStatut(joursRestants: number | null): CadenceStatut {
|
||||
|
||||
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
|
||||
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
|
||||
return pgGetFournisseurs();
|
||||
return getFournisseursCached();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +50,7 @@ export async function listCadences(): Promise<CadenceView[]> {
|
||||
try {
|
||||
[rows, receptions] = await Promise.all([
|
||||
db.select().from(commandeCadences),
|
||||
pgGetDerniereReceptionParFournisseur(),
|
||||
getDerniereReceptionCached(),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("[cadence] listCadences error:", (e as Error).message);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getSitesFromApi } from "@/lib/api-ff-client";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { getProductRows } from "./api/get-product-rows";
|
||||
import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
|
||||
@@ -9,7 +9,9 @@ import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
* Get the list of all suppliers from PostgreSQL (fouadr1).
|
||||
*/
|
||||
export async function getFournisseurs() {
|
||||
return pgGetFournisseurs();
|
||||
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
|
||||
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
|
||||
return getFournisseursCached();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
|
||||
import { upsertGridRows } from "@/lib/grid-store";
|
||||
@@ -28,8 +28,32 @@ interface GetProductRowsInput {
|
||||
}
|
||||
|
||||
const GRID_ROWS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* 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), ce qui
|
||||
* ralentissait toutes les requêtes (ramasse-miettes). Ordre d'insertion de la Map
|
||||
* = ordre d'usage : on évince les entrées les moins récemment servies.
|
||||
*/
|
||||
const GRID_ROWS_CACHE_MAX_ENTRIES = Number(process.env.GRID_ROWS_CACHE_MAX_ENTRIES ?? 30);
|
||||
const GRID_ROWS_CACHE_MAX_ROWS = Number(process.env.GRID_ROWS_CACHE_MAX_ROWS ?? 150_000);
|
||||
const gridRowsCache = new Map<string, { rows: ProductRow[]; createdAt: number }>();
|
||||
const gridRowsPending = new Map<string, Promise<ProductRow[]>>();
|
||||
/** Nombre d'enregistrements de gammes par fournisseur (cf. patchGridRowsCache). */
|
||||
const gammeSaveVersion = new Map<string, number>();
|
||||
|
||||
function cacheGridRows(key: string, rows: ProductRow[]): void {
|
||||
gridRowsCache.delete(key);
|
||||
gridRowsCache.set(key, { rows, createdAt: Date.now() });
|
||||
let totalRows = 0;
|
||||
for (const entry of gridRowsCache.values()) totalRows += entry.rows.length;
|
||||
for (const [oldKey, entry] of gridRowsCache) {
|
||||
if (gridRowsCache.size <= 1) break;
|
||||
if (gridRowsCache.size <= GRID_ROWS_CACHE_MAX_ENTRIES && totalRows <= GRID_ROWS_CACHE_MAX_ROWS) break;
|
||||
gridRowsCache.delete(oldKey);
|
||||
totalRows -= entry.rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
function gridRowsCacheKey(input: GetProductRowsInput): string {
|
||||
return `${input.codeFournisseur}:${input.magasin ?? "TOTAL"}`;
|
||||
@@ -49,6 +73,31 @@ export function invalidateGridRowsCache(codeFournisseur?: string) {
|
||||
gridRowsPending.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.
|
||||
*/
|
||||
export function patchGridRowsCache(
|
||||
codeFournisseur: string,
|
||||
changes: ReadonlyArray<{ codein: string; codeGamme: string }>,
|
||||
): void {
|
||||
if (changes.length === 0) return;
|
||||
// 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é.
|
||||
gammeSaveVersion.set(codeFournisseur, (gammeSaveVersion.get(codeFournisseur) ?? 0) + 1);
|
||||
const byCodein = new Map(changes.map((c) => [c.codein, c.codeGamme as GammeCode]));
|
||||
for (const [key, entry] of gridRowsCache) {
|
||||
if (!key.startsWith(`${codeFournisseur}:`)) continue;
|
||||
for (const row of entry.rows) {
|
||||
const gamme = byCodein.get(row.codein);
|
||||
if (gamme !== undefined) row.codeGamme = gamme;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
|
||||
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
|
||||
@@ -90,19 +139,30 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
const cacheKey = gridRowsCacheKey(input);
|
||||
const cached = gridRowsCache.get(cacheKey);
|
||||
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
|
||||
// Remonte l'entrée en tête de l'ordre d'usage (éviction LRU).
|
||||
gridRowsCache.delete(cacheKey);
|
||||
gridRowsCache.set(cacheKey, cached);
|
||||
// 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).
|
||||
await refreshGammeInit(cached.rows, input.codeFournisseur);
|
||||
return cached.rows;
|
||||
}
|
||||
|
||||
// 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.
|
||||
const pending = gridRowsPending.get(cacheKey);
|
||||
if (!input.forceRefresh && pending) {
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = buildProductRows(input).then((rows) => {
|
||||
gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() });
|
||||
const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0;
|
||||
const promise = buildProductRows(input).then(async (rows) => {
|
||||
if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) {
|
||||
// Gammes enregistrées pendant le calcul : on reprend le dernier snapshot.
|
||||
applySnapshotChanges(rows, await loadLatestSnapshotChanges(input.codeFournisseur));
|
||||
}
|
||||
cacheGridRows(cacheKey, rows);
|
||||
gridRowsPending.delete(cacheKey);
|
||||
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
|
||||
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
|
||||
@@ -127,6 +187,15 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
|
||||
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
|
||||
// Remplace des centaines/milliers d'appels HTTP per-article.
|
||||
const articlesPromise = pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; });
|
||||
// Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des
|
||||
// articles (codes centraux) ou le seul code fournisseur. Exécutées après la
|
||||
// Phase 1, elles ajoutaient deux allers-retours en série à chaque calcul.
|
||||
const networkPromise = articlesPromise.then((arts) =>
|
||||
fetchNetworkMetrics(arts.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))),
|
||||
);
|
||||
const snapshotPromise = loadLatestSnapshotChanges(codeFournisseur);
|
||||
|
||||
const [
|
||||
articles,
|
||||
mensuelRows,
|
||||
@@ -136,7 +205,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
commandesMap,
|
||||
prixVenteMap,
|
||||
] = await Promise.all([
|
||||
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
|
||||
articlesPromise,
|
||||
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
|
||||
pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map<string, string>(); }),
|
||||
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
|
||||
@@ -407,34 +476,13 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
|
||||
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||
await enrichWithNetworkMetrics(productMap);
|
||||
enrichWithNetworkMetrics(productMap, await networkPromise);
|
||||
|
||||
// ─── 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.
|
||||
try {
|
||||
const snaps = await db
|
||||
.select()
|
||||
.from(sessionSnapshots)
|
||||
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
|
||||
.orderBy(desc(sessionSnapshots.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (snaps.length > 0) {
|
||||
const changes = snaps[0].changes as Record<string, { before: string | null; after: string }>;
|
||||
for (const [codein, change] of Object.entries(changes)) {
|
||||
const product = productMap.get(codein);
|
||||
if (product && change.after) {
|
||||
// codeGammeInit reste figé (état serveur) — seul codeGamme est overridé
|
||||
product.codeGamme = change.after as GammeCode;
|
||||
}
|
||||
}
|
||||
console.log(`[getProductRows] Gammes restaurées depuis snapshot`);
|
||||
}
|
||||
} catch (snapErr) {
|
||||
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
||||
}
|
||||
applySnapshotChanges(productMap.values(), await snapshotPromise);
|
||||
|
||||
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
|
||||
const allRows = Array.from(productMap.values());
|
||||
@@ -601,41 +649,74 @@ function nbMagParMois(
|
||||
return Object.keys(parMois).length > 0 ? parMois : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
|
||||
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
|
||||
* ou si le code centrale n'est pas encore disponible.
|
||||
*/
|
||||
async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Promise<void> {
|
||||
try {
|
||||
const byCodeCentrale = new Map<string, ProductRow[]>();
|
||||
for (const product of productMap.values()) {
|
||||
const cc = product.codeCentrale;
|
||||
if (!cc) continue;
|
||||
if (!byCodeCentrale.has(cc)) byCodeCentrale.set(cc, []);
|
||||
byCodeCentrale.get(cc)!.push(product);
|
||||
}
|
||||
if (byCodeCentrale.size === 0) return;
|
||||
type SnapshotChanges = Record<string, { before: string | null; after: string }>;
|
||||
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
|
||||
const sampleKey = [...byCodeCentrale.keys()].find((cc) => (metrics.get(cc)?.caReseau ?? 0) > 0) ?? [...byCodeCentrale.keys()].find((cc) => metrics.has(cc));
|
||||
console.log(`[getProductRows] réseau: ${byCodeCentrale.size} codes centraux → ${metrics.size} matchés en cache. Sample:`, sampleKey ? JSON.stringify(metrics.get(sampleKey)) : "aucun match");
|
||||
for (const [cc, products] of byCodeCentrale.entries()) {
|
||||
const m = metrics.get(cc);
|
||||
if (!m) continue;
|
||||
for (const product of products) {
|
||||
product.caReseau = m.caReseau;
|
||||
product.qteReseau = m.qteReseau;
|
||||
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||
product.margePctReseau = m.margePctReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ;
|
||||
* codeGammeInit reste figé (état serveur).
|
||||
*/
|
||||
function applySnapshotChanges(rows: Iterable<ProductRow>, changes: SnapshotChanges | null): void {
|
||||
if (!changes) return;
|
||||
for (const row of rows) {
|
||||
const change = changes[row.codein];
|
||||
if (change?.after) row.codeGamme = change.after as GammeCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `null`. */
|
||||
async function loadLatestSnapshotChanges(codeFournisseur: string): Promise<SnapshotChanges | null> {
|
||||
try {
|
||||
const snaps = await db
|
||||
.select({ changes: sessionSnapshots.changes })
|
||||
.from(sessionSnapshots)
|
||||
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
|
||||
.orderBy(desc(sessionSnapshots.createdAt))
|
||||
.limit(1);
|
||||
return snaps.length > 0 ? (snaps[0].changes as SnapshotChanges) : null;
|
||||
} catch (snapErr) {
|
||||
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les metriques reseau Qlik (cache qlik_network_metrics) des codes centraux
|
||||
* donnes. `null` en cas d'erreur : la grille s'affiche alors sans donnees reseau.
|
||||
*/
|
||||
async function fetchNetworkMetrics(
|
||||
codesCentrale: string[],
|
||||
): Promise<Map<string, NetworkMetricCached> | null> {
|
||||
try {
|
||||
const codes = [...new Set(codesCentrale.filter(Boolean))];
|
||||
if (codes.length === 0) return null;
|
||||
return await getNetworkMetricsByCodeCentrale(codes);
|
||||
} catch (error) {
|
||||
console.error("[getProductRows] enrichWithNetworkMetrics error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit les produits avec les metriques reseau Qlik, jointes par code centrale.
|
||||
* Degradation propre si la sync Qlik n'a jamais tourne ou si le code centrale
|
||||
* n'est pas encore disponible.
|
||||
*/
|
||||
function enrichWithNetworkMetrics(
|
||||
productMap: Map<string, ProductRow>,
|
||||
metrics: Map<string, NetworkMetricCached> | null,
|
||||
): void {
|
||||
if (!metrics || metrics.size === 0) return;
|
||||
for (const product of productMap.values()) {
|
||||
const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined;
|
||||
if (!m) continue;
|
||||
product.caReseau = m.caReseau;
|
||||
product.qteReseau = m.qteReseau;
|
||||
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||
product.margePctReseau = m.margePctReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { invalidateGridRowsCache } from "./get-product-rows";
|
||||
import { patchGridRowsCache } from "./get-product-rows";
|
||||
import { updateGridRowsGamme } from "@/lib/grid-store";
|
||||
|
||||
const SaveDraftsSchema = z.object({
|
||||
codeFournisseur: z.string(),
|
||||
@@ -70,7 +71,13 @@ export async function saveDraftChanges(
|
||||
type: "snapshot",
|
||||
});
|
||||
|
||||
invalidateGridRowsCache(codeFournisseur);
|
||||
// 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.
|
||||
patchGridRowsCache(codeFournisseur, changes);
|
||||
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
|
||||
console.error("[saveDraftChanges] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
|
||||
);
|
||||
|
||||
return { success: true, saved: changes.length };
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown, Table2 } from "lucide-react";
|
||||
import * as ExcelJS from "exceljs";
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
|
||||
// exceljs, jspdf et jspdf-autotable (~1 Mo) ne sont chargés qu'au clic : importés
|
||||
// en tête de fichier, ils alourdissaient la page Grille pour tous les utilisateurs.
|
||||
// Les lignes sont lues dans le store au moment de l'export, sans abonnement : le
|
||||
// menu n'a pas à se redessiner à chaque modification de la Grille.
|
||||
|
||||
export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { rows, draftChanges } = useGridStore();
|
||||
|
||||
const handleExportAllProducts = async () => {
|
||||
setIsOpen(false);
|
||||
const { rows, draftChanges } = useGridStore.getState();
|
||||
if (rows.length === 0) {
|
||||
alert("Aucune donnée à exporter.");
|
||||
return;
|
||||
@@ -59,7 +61,9 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setIsOpen(false);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const { rows, draftChanges } = useGridStore.getState();
|
||||
const { Workbook } = await import("exceljs");
|
||||
const workbook = new Workbook();
|
||||
const worksheet = workbook.addWorksheet("Gamme A");
|
||||
|
||||
worksheet.columns = [
|
||||
@@ -100,8 +104,13 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handlePrintPDF = () => {
|
||||
const handlePrintPDF = async () => {
|
||||
setIsOpen(false);
|
||||
const { rows, draftChanges } = useGridStore.getState();
|
||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import("jspdf"),
|
||||
import("jspdf-autotable"),
|
||||
]);
|
||||
const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" });
|
||||
|
||||
doc.setFontSize(16);
|
||||
|
||||
@@ -36,7 +36,10 @@ export function FloatingSummaryBar() {
|
||||
const summary = useGridStore((s) => s.summary);
|
||||
const resetDrafts = useGridStore((s) => s.resetDrafts);
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const filterFournisseur = useGridStore((s) => s.filters.codeFournisseur);
|
||||
// Magasin affiché (bascule de la Grille). `filters.magasin` n'est jamais
|
||||
// renseigné : les sessions et validations étaient toutes notées « TOTAL ».
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const draftChanges = useGridStore((s) => s.draftChanges);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||
@@ -54,9 +57,9 @@ export function FloatingSummaryBar() {
|
||||
});
|
||||
|
||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(filters.magasin || "TOTAL", visibleCodeins);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
|
||||
|
||||
const supplierCode = filters.codeFournisseur || rows[0]?.codeFournisseur;
|
||||
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
|
||||
const lastQlikUpdate = useMemo(() => {
|
||||
let max: string | null = null;
|
||||
for (const r of rows) {
|
||||
@@ -99,9 +102,9 @@ export function FloatingSummaryBar() {
|
||||
);
|
||||
|
||||
const res = await saveSnapshot({
|
||||
codeFournisseur: filters.codeFournisseur || rows[0].codeFournisseur,
|
||||
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
|
||||
nomFournisseur: rows[0].nomFournisseur,
|
||||
magasin: filters.magasin || "TOTAL",
|
||||
magasin: activeMagasin || "TOTAL",
|
||||
label: labelOverride || `${type === 'export' ? 'Export' : 'Session'} ${rows[0].nomFournisseur} — ${new Date().toLocaleTimeString()}`,
|
||||
changes,
|
||||
type,
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ProductRow } from "@/types/grid";
|
||||
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
|
||||
interface GridClientProps {
|
||||
@@ -51,7 +51,7 @@ function MetricPill({ label, value, accent = false }: { label: string; value: st
|
||||
);
|
||||
}
|
||||
|
||||
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
|
||||
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
||||
const setRows = useGridStore((s) => s.setRows);
|
||||
@@ -61,8 +61,6 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -101,7 +99,13 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
}
|
||||
}, [codeFournisseur, setFilter, setCode3Filter, isMounted]);
|
||||
|
||||
const refreshToken = searchParams.get("_refresh");
|
||||
// Rechargement forcé (bouton « Rafraîchir », fin de synchro Qlik) : demandé via
|
||||
// le store, pas via l'URL. On retient la dernière demande déjà servie pour ne
|
||||
// pas re-forcer le recalcul serveur à chaque remontage ou changement de filtre.
|
||||
const refreshRequest = useGridStore((s) => s.refreshRequest);
|
||||
const requestRefresh = useGridStore((s) => s.requestRefresh);
|
||||
const servedRefreshRef = useRef(refreshRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
@@ -109,14 +113,14 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const params = new URLSearchParams();
|
||||
params.set("fournisseur", codeFournisseur);
|
||||
params.set("magasin", magasin || "TOTAL");
|
||||
// `code3` n'est volontairement pas transmis : le serveur ignore ce filtre
|
||||
// (getProductRows ne s'en sert pas) et la Grille l'applique en local. Le
|
||||
// faire voyager relançait un chargement complet à chaque case cochée.
|
||||
if (filters.code1) params.set("code1", filters.code1);
|
||||
if (filters.code2) params.set("code2", filters.code2);
|
||||
if (refreshToken) params.set("refresh", "1");
|
||||
// Les filtres de nomenclature (code1/code2/code3) ne sont pas transmis : le
|
||||
// serveur ne s'en sert pas et la Grille les applique en local. Les faire
|
||||
// voyager relançait un chargement complet à chaque changement.
|
||||
const forceRefresh = refreshRequest !== servedRefreshRef.current;
|
||||
if (forceRefresh) params.set("refresh", "1");
|
||||
|
||||
let accumulatedRows: ProductRow[] = [];
|
||||
const accumulatedRows: ProductRow[] = [];
|
||||
let lastFlush = 0;
|
||||
setRows([]);
|
||||
setRowsLoaded(0);
|
||||
setTotalRows(null);
|
||||
@@ -124,6 +128,20 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
setIsLoadingRows(true);
|
||||
setShowStartOverlay(true);
|
||||
|
||||
// Chaque mise à jour du store reconstruit tout le tableau (index, tri,
|
||||
// filtres, compteurs) : la faire à chaque paquet de 150 lignes rendait le
|
||||
// chargement quadratique. On regroupe donc les paquets et on ne pousse les
|
||||
// lignes que toutes les FLUSH_MS (et une dernière fois à la fin).
|
||||
const FLUSH_MS = 300;
|
||||
// Les lignes déjà poussées dans le store ne doivent plus bouger : on lui
|
||||
// passe une copie, et on continue d'accumuler dans le tableau privé.
|
||||
const flush = (loaded: number, total: number | null, final = false) => {
|
||||
setRows(final ? accumulatedRows : accumulatedRows.slice());
|
||||
setRowsLoaded(loaded);
|
||||
setTotalRows(total);
|
||||
lastFlush = Date.now();
|
||||
};
|
||||
|
||||
async function loadRows() {
|
||||
try {
|
||||
const response = await fetch(`/api/grid/rows?${params.toString()}`, {
|
||||
@@ -137,6 +155,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let loaded = 0;
|
||||
let total: number | null = null;
|
||||
let pending = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -150,18 +171,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
if (!line.trim()) continue;
|
||||
const message = JSON.parse(line) as GridRowsStreamMessage;
|
||||
if (message.type === "chunk") {
|
||||
accumulatedRows = accumulatedRows.concat(message.rows);
|
||||
setRows(accumulatedRows);
|
||||
setRowsLoaded(message.loaded);
|
||||
setTotalRows(message.total);
|
||||
for (const row of message.rows) accumulatedRows.push(row);
|
||||
loaded = message.loaded;
|
||||
total = message.total;
|
||||
pending = true;
|
||||
} else if (message.type === "done") {
|
||||
setRowsLoaded(message.loaded);
|
||||
setTotalRows(message.total);
|
||||
loaded = message.loaded;
|
||||
total = message.total;
|
||||
} else if (message.type === "error") {
|
||||
throw new Error(message.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (pending && Date.now() - lastFlush >= FLUSH_MS) {
|
||||
flush(loaded, total);
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
flush(loaded, total, true);
|
||||
if (forceRefresh) servedRefreshRef.current = refreshRequest;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoadError(error instanceof Error ? error.message : "Erreur de chargement");
|
||||
@@ -176,7 +205,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
loadRows();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [codeFournisseur, magasin, filters.code1, filters.code2, refreshToken, setRows, isMounted]);
|
||||
}, [codeFournisseur, magasin, refreshRequest, setRows, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
useEffect(() => {
|
||||
@@ -193,13 +222,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
};
|
||||
|
||||
// Force le rechargement des données depuis le serveur en ignorant le cache
|
||||
// 10 min : on change le paramètre URL `_refresh`, ce qui relance le fetch
|
||||
// avec `refresh=1` (→ forceRefresh) et remonte l'état serveur à jour (INIT).
|
||||
// 10 min (`refresh=1` → forceRefresh) et remonte l'état serveur à jour (INIT).
|
||||
const handleForceRefresh = () => {
|
||||
if (isLoadingRows) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
requestRefresh();
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
|
||||
@@ -104,11 +104,10 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
router.replace(`/grid?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const requestRefresh = useGridStore((s) => s.requestRefresh);
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", Date.now().toString());
|
||||
router.replace(`/grid?${params.toString()}`, { scroll: false });
|
||||
requestRefresh();
|
||||
// Visual feedback delay
|
||||
setTimeout(() => setIsRefreshing(false), 800);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
NB_MAGASINS_RESEAU,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
@@ -94,8 +95,7 @@ function prixMoyenReseau(row: ProductRow): number | null {
|
||||
}
|
||||
|
||||
/** Montant en euros au centime — l'usage sur des prix unitaires. */
|
||||
const fmtEuro2 = (v: number) =>
|
||||
v.toLocaleString("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const fmtEuro2 = fmtEur2;
|
||||
|
||||
/**
|
||||
* Prix de vente à montrer pour le magasin consulté.
|
||||
@@ -315,7 +315,9 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
|
||||
data-index={virtualRow.index}
|
||||
onClick={() => row.toggleSelected()}
|
||||
className={cn(
|
||||
"absolute w-full flex items-center cursor-pointer transition-all duration-200 group/row",
|
||||
// Pas de `transition-all` : la ligne est positionnée par `transform`
|
||||
// et chaque défilement l'aurait animée pendant 200 ms.
|
||||
"absolute w-full flex items-center cursor-pointer transition-[opacity,filter] duration-200 group/row",
|
||||
effectiveGamme === "Z" && "opacity-40 grayscale-[0.5] hover:grayscale-0 hover:opacity-100"
|
||||
)}
|
||||
style={{
|
||||
@@ -625,7 +627,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
// totaux sont figés sur la fenêtre du serveur, les colonnes doivent l'être
|
||||
// aussi, sinon un mois manque aux cases mais pas au total (cf. months.ts).
|
||||
// Le calcul local ne sert que tant qu'aucune ligne n'est chargée.
|
||||
const MONTHS_12 = useMemo(() => getMonthsFromRows(rows) ?? getLast12Months(), [rows]);
|
||||
// Mémorisé sur sa clé texte : un nouveau tableau à chaque arrivée de lignes
|
||||
// reconstruisait toutes les définitions de colonnes (donc tout le tableau).
|
||||
const monthsKey = useMemo(() => (getMonthsFromRows(rows) ?? getLast12Months()).join(","), [rows]);
|
||||
const MONTHS_12 = useMemo(() => monthsKey.split(","), [monthsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
@@ -647,28 +652,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
setRowSelection({});
|
||||
}, [rows]);
|
||||
|
||||
const prevSelectionRef = useRef<string[]>([]);
|
||||
|
||||
// Propagate selection changes via useEffect to avoid "update during render" error
|
||||
useEffect(() => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
const selectedIdxs = Object.keys(rowSelection).filter((k) => rowSelection[k]);
|
||||
const selectedCodeins = selectedIdxs
|
||||
.map((idx) => filteredData[parseInt(idx)]?.codein ?? "")
|
||||
.filter(Boolean);
|
||||
|
||||
// Only update if selection actually changed to avoid re-render loops & console warnings
|
||||
const currentString = JSON.stringify(selectedCodeins);
|
||||
const prevString = JSON.stringify(prevSelectionRef.current);
|
||||
|
||||
if (currentString !== prevString) {
|
||||
prevSelectionRef.current = selectedCodeins;
|
||||
// Delay update to next tick to ensure we're out of any render cycles
|
||||
setTimeout(() => onSelectionChange(selectedCodeins), 0);
|
||||
}
|
||||
}, [rowSelection, onSelectionChange, filteredData]);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
|
||||
{
|
||||
id: "select",
|
||||
@@ -807,7 +790,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const val = row.original.caReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
|
||||
{val != null ? fmtEur0(val) : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -822,7 +805,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const val = row.original.qteReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? Math.round(val).toLocaleString("fr-FR") : "-"}
|
||||
{val != null ? fmtEntier(val) : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -936,7 +919,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const val = row.original.caParMagasinReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
|
||||
{val != null ? fmtEur0(val) : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -954,7 +937,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const color = pct >= 30 ? "text-emerald-500" : pct >= 15 ? "text-amber-500" : "text-rose-500";
|
||||
return (
|
||||
<div className={cn("text-center font-bold text-[12px] tabular-nums", color)}>
|
||||
{pct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
|
||||
{fmtDecimal1(pct)}%
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -1017,7 +1000,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
borderColor: "var(--border-strong)"
|
||||
}}
|
||||
>
|
||||
{Math.round(qty).toLocaleString("fr-FR")}
|
||||
{fmtEntier(qty)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
@@ -1033,7 +1016,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
: (row.original.caByStore?.[activeMagasin] ?? 0);
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{Math.round(ca).toLocaleString("fr-FR")} €
|
||||
{fmtEntier(ca)} €
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -1054,7 +1037,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{Math.round(marge).toLocaleString("fr-FR")} €
|
||||
{fmtEntier(marge)} €
|
||||
</span>
|
||||
<span className="tabular-nums text-[10px] font-bold opacity-70" style={{
|
||||
color: taux >= 40 ? "var(--accent-success)" : taux >= 25 ? "var(--accent-warning)" : "var(--accent-error)"
|
||||
@@ -1095,10 +1078,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
columnResizeMode: "onChange",
|
||||
// Identifiant stable : avec l'index par défaut, la sélection désignait
|
||||
// d'autres produits dès qu'un filtre changeait l'ordre des lignes.
|
||||
getRowId: (row) => row.codein,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
enableRowSelection: true,
|
||||
// La recherche ne dépend pas de la colonne (elle lit libellé, code, référence
|
||||
// et GTIN de la ligne) : un seul passage par ligne suffit, au lieu d'un par
|
||||
// colonne filtrable (une quinzaine) à chaque frappe.
|
||||
getColumnCanGlobalFilter: (column) => column.id === "codein",
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const search = String(filterValue).toLowerCase();
|
||||
const libelle = String(row.original.libelle1 || "").toLowerCase();
|
||||
@@ -1111,6 +1101,31 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
|
||||
const { rows: tableRows } = table.getRowModel();
|
||||
|
||||
const prevSelectionRef = useRef<string[]>([]);
|
||||
|
||||
// Propagate selection changes via useEffect to avoid "update during render" error
|
||||
useEffect(() => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
// La sélection est indexée par code article (`getRowId`), et seules les
|
||||
// lignes encore affichées comptent : une action groupée ne doit jamais
|
||||
// toucher un produit masqué par un filtre ou par la recherche.
|
||||
const selectedCodeins = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((r) => r.original.codein);
|
||||
|
||||
// Only update if selection actually changed to avoid re-render loops & console warnings
|
||||
const currentString = JSON.stringify(selectedCodeins);
|
||||
const prevString = JSON.stringify(prevSelectionRef.current);
|
||||
|
||||
if (currentString !== prevString) {
|
||||
prevSelectionRef.current = selectedCodeins;
|
||||
// Delay update to next tick to ensure we're out of any render cycles
|
||||
setTimeout(() => onSelectionChange(selectedCodeins), 0);
|
||||
}
|
||||
}, [table, rowSelection, onSelectionChange, filteredData, filters.search]);
|
||||
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tableRows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
@@ -1291,8 +1306,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
<thead className="sticky top-0 z-10 block" style={{
|
||||
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
|
||||
borderBottom: "1px solid var(--border-strong)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)"
|
||||
}}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="flex w-full">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
|
||||
@@ -28,21 +27,12 @@ function formatLastUpdate(iso?: string | null): string {
|
||||
* dans `useQlikSyncJob`, partagée avec la fiche produit.
|
||||
*/
|
||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
|
||||
const refreshGrid = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
router.refresh();
|
||||
}, [pathname, router, searchParams]);
|
||||
/** Recharge la grille en ignorant le cache serveur (la barre de résumé suit). */
|
||||
const requestRefresh = useGridStore((s) => s.requestRefresh);
|
||||
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "fournisseur", fournisseur: codeFournisseur },
|
||||
onSuccess: refreshGrid,
|
||||
onSuccess: requestRefresh,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,14 @@ interface GridState {
|
||||
displayDensity: "compact" | "normal" | "comfortable";
|
||||
/** The active URL search parameters (e.g. ?fournisseur=123&magasin=TOTAL) */
|
||||
activeGridQuery: string;
|
||||
/**
|
||||
* Compteur de demandes de rechargement forcé (ignore le cache serveur).
|
||||
* Volontairement hors URL et non persisté : un paramètre `_refresh` dans
|
||||
* l'URL restait mémorisé dans `activeGridQuery`, et chaque retour sur la
|
||||
* Grille par le menu relançait un recalcul complet.
|
||||
*/
|
||||
refreshRequest: number;
|
||||
requestRefresh: () => void;
|
||||
/** Persisted column visibility state */
|
||||
columnVisibility: Record<string, boolean>;
|
||||
/** Persisted column sizing state */
|
||||
@@ -59,6 +67,15 @@ interface GridState {
|
||||
|
||||
let summaryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Retire l'ancien paramètre `_refresh` (cache-buster) d'une query string mémorisée. */
|
||||
function sansParamRefresh(query: string): string {
|
||||
if (!query.includes("_refresh")) return query;
|
||||
const params = new URLSearchParams(query.startsWith("?") ? query.slice(1) : query);
|
||||
params.delete("_refresh");
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
function indexRows(rows: ProductRow[]): Record<string, ProductRow> {
|
||||
const indexed: Record<string, ProductRow> = {};
|
||||
for (const row of rows) indexed[row.codein] = row;
|
||||
@@ -117,6 +134,7 @@ export const useGridStore = create<GridState>()(
|
||||
},
|
||||
displayDensity: "normal",
|
||||
activeGridQuery: "",
|
||||
refreshRequest: 0,
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
showMonthlySales: true,
|
||||
@@ -202,7 +220,8 @@ export const useGridStore = create<GridState>()(
|
||||
set((state) => ({ ...state, filters: { ...state.filters, code3: codes } }));
|
||||
},
|
||||
setDisplayDensity: (density) => set({ displayDensity: density }),
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
|
||||
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
|
||||
restoreSnapshot: (changes) => {
|
||||
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
|
||||
},
|
||||
@@ -249,8 +268,11 @@ export const useGridStore = create<GridState>()(
|
||||
* dans le navigateur restait une chaîne : `new Set("320211")` produit un
|
||||
* ensemble de caractères, plus aucune ligne ne correspond, et la Grille
|
||||
* apparaît vide. Le numéro de version force la reprise de l'existant.
|
||||
*
|
||||
* v2 — `activeGridQuery` pouvait contenir `_refresh=…` : le lien « Grille »
|
||||
* du menu forçait alors un recalcul serveur complet à chaque retour.
|
||||
*/
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: (persisted: unknown, version: number): GridPersisted => {
|
||||
const etat = (persisted ?? {}) as GridPersisted & { filters?: Record<string, unknown> };
|
||||
if (version < 1 && etat.filters) {
|
||||
@@ -262,6 +284,9 @@ export const useGridStore = create<GridState>()(
|
||||
// à la fusion (le bloc disparaîtrait sans raison).
|
||||
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
|
||||
if (typeof etat.showPrices !== "boolean") etat.showPrices = true;
|
||||
if (typeof etat.activeGridQuery === "string") {
|
||||
etat.activeGridQuery = sansParamRefresh(etat.activeGridQuery);
|
||||
}
|
||||
return etat;
|
||||
},
|
||||
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
|
||||
|
||||
@@ -27,6 +27,8 @@ export async function getSnapshots(type?: "snapshot" | "export") {
|
||||
} catch (err: any) {
|
||||
const msg = (err?.message || String(err)).split("\n")[0];
|
||||
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
|
||||
return [];
|
||||
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
|
||||
// au lieu de signaler que le chargement a échoué.
|
||||
throw new Error("Chargement de l'historique impossible.");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
History,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { SuccessModal } from "@/components/shared/success-modal";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
@@ -78,6 +79,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
|
||||
const fetchSnapshots = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getSnapshots(type);
|
||||
setSnapshots(data);
|
||||
@@ -146,6 +148,19 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error && snapshots.length === 0) {
|
||||
return (
|
||||
<div className="border border-[var(--accent-error)] rounded-2xl p-10 text-center bg-[var(--accent-error-bg)]">
|
||||
<AlertCircle className="w-8 h-8 mx-auto mb-3 text-[var(--accent-error)]" />
|
||||
<h3 className="text-[var(--text-primary)] font-bold mb-1">L'historique n'a pas pu être chargé</h3>
|
||||
<p className="text-[var(--text-secondary)] text-sm mb-4">{error}</p>
|
||||
<button onClick={fetchSnapshots} className="btn-action btn-action-secondary">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<div className="border border-dashed border-[var(--border-strong)] rounded-2xl p-16 text-center bg-[var(--bg-elevated)]/30">
|
||||
|
||||
@@ -16,6 +16,15 @@ const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr";
|
||||
const FF_API_BASE_TTL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Options de tout appel à l'API FF : jamais de cache HTTP, et un délai maximal.
|
||||
* Sans délai, une API muette figeait indéfiniment la page qui l'attendait.
|
||||
*/
|
||||
const FF_FETCH_TIMEOUT_MS = Number(process.env.FF_API_TIMEOUT_MS) || 30_000;
|
||||
export function ffFetchInit(): RequestInit {
|
||||
return { cache: "no-store", signal: AbortSignal.timeout(FF_FETCH_TIMEOUT_MS) };
|
||||
}
|
||||
|
||||
let ffApiBaseCache: { value: string; at: number } | null = null;
|
||||
|
||||
/**
|
||||
@@ -201,7 +210,7 @@ async function fetchAllPages<T>(
|
||||
// Page 1 — récupère les items et tente d'obtenir le total pour paralléliser
|
||||
const url1 = buildUrl(1);
|
||||
console.log(`[api-ff] GET ${url1}`);
|
||||
const res1 = await fetch(url1, { cache: "no-store" });
|
||||
const res1 = await fetch(url1, ffFetchInit());
|
||||
if (!res1.ok) {
|
||||
console.error(`[api-ff] HTTP ${res1.status} — ${url1}`);
|
||||
return [];
|
||||
@@ -227,7 +236,7 @@ async function fetchAllPages<T>(
|
||||
const results = await Promise.all(chunk.map(async (p) => {
|
||||
const url = buildUrl(p);
|
||||
console.log(`[api-ff] GET ${url}`);
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) return [] as T[];
|
||||
const data = await res.json();
|
||||
return extractList(data) as T[];
|
||||
@@ -245,7 +254,7 @@ async function fetchAllPages<T>(
|
||||
const results = await Promise.all(chunk.map(async (p) => {
|
||||
const url = buildUrl(p);
|
||||
console.log(`[api-ff] GET ${url}`);
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) return { p, items: [] as T[] };
|
||||
const data = await res.json();
|
||||
return { p, items: extractList(data) as T[] };
|
||||
@@ -311,7 +320,7 @@ export async function getFournisseursFromApi(
|
||||
: `${FF_API_BASE}/api/fournisseurs?limit=500`;
|
||||
|
||||
console.log(`[api-ff] GET ${url}`);
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
|
||||
const data = await res.json();
|
||||
|
||||
@@ -468,7 +477,7 @@ export async function getMensuelByArticles(
|
||||
if (!noId) return;
|
||||
try {
|
||||
const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const entries: FfMensuelEntry[] = Array.isArray(data.data) ? data.data : [];
|
||||
@@ -555,7 +564,7 @@ export async function getReferentielByArticles(
|
||||
if (!noId) return;
|
||||
try {
|
||||
const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/referentiel`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) return;
|
||||
const data: FfReferentiel = await res.json();
|
||||
if (data?.article) result.set(art.codein, data);
|
||||
@@ -581,7 +590,7 @@ export async function getCommandesByFournisseur(
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`,
|
||||
{ cache: "no-store" }
|
||||
ffFetchInit()
|
||||
);
|
||||
if (!res.ok) return result;
|
||||
const data = await res.json();
|
||||
@@ -602,7 +611,7 @@ export async function getCommandesByFournisseur(
|
||||
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
|
||||
try {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/sync/status`, ffFetchInit());
|
||||
if (!res.ok) return null;
|
||||
return normalizeSyncStatus(await res.json());
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* CollectFlow — Cache mémoire des lectures « FF » (base miroir et API FF Nancy).
|
||||
*
|
||||
* Les tables FF (`mvtart`, `cube_stock`, `articles`…) ne sont recopiées qu'une
|
||||
* fois par nuit depuis SQL Server : recalculer à chaque visite les mêmes
|
||||
* agrégations lourdes (stock, hit-parade, CA mensuel…) coûtait plusieurs
|
||||
* secondes par page pour un résultat identique.
|
||||
*
|
||||
* Pourquoi pas `unstable_cache` : il sérialise en JSON (une `Map` devient `{}`,
|
||||
* une `Date` une chaîne) et ne peut pas être invalidé hors d'une requête — or
|
||||
* c'est la synchro nocturne, lancée par un minuteur, qui sait quand les données
|
||||
* changent. Ici :
|
||||
* - une 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 ;
|
||||
* - `invalidateFFCache()` vide tout (appelé en fin de synchro nocturne).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { pgGetDerniereReceptionParFournisseur, pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
|
||||
const TTL_MS = (Number(process.env.FF_CACHE_TTL_S) || 30 * 60) * 1000;
|
||||
/** Plafond d'entrées : les clés incluent des paramètres (dates, mois, magasin). */
|
||||
const MAX_ENTRIES = 100;
|
||||
|
||||
interface Entry {
|
||||
value?: unknown;
|
||||
expiresAt: number;
|
||||
pending?: Promise<unknown>;
|
||||
}
|
||||
|
||||
// Rangé dans globalThis : un seul cache par processus, même si ce module est
|
||||
// présent dans plusieurs lots compilés (routes, instrumentation).
|
||||
const globalForFFCache = globalThis as typeof globalThis & {
|
||||
__collectflowFFCache?: { store: Map<string, Entry>; generation: number };
|
||||
};
|
||||
const cache = (globalForFFCache.__collectflowFFCache ??= { store: new Map(), generation: 0 });
|
||||
const store = cache.store;
|
||||
|
||||
interface CachedOptions<T> {
|
||||
/** Durée de vie propre à cette clé, en millisecondes. */
|
||||
ttlMs?: number;
|
||||
/**
|
||||
* Ne garder le résultat que s'il passe ce test. Sert aux lectures qui
|
||||
* avalent leurs erreurs et renvoient une liste vide : sans ce garde-fou,
|
||||
* une panne passagère resterait affichée pendant toute la durée de vie.
|
||||
*/
|
||||
cacheIf?: (value: T) => boolean;
|
||||
}
|
||||
|
||||
export async function cachedFF<T>(key: string, load: () => Promise<T>, options: CachedOptions<T> = {}): Promise<T> {
|
||||
const now = Date.now();
|
||||
const entry = store.get(key);
|
||||
if (entry && "value" in entry && entry.expiresAt > now) return entry.value as T;
|
||||
if (entry?.pending) return entry.pending as Promise<T>;
|
||||
|
||||
// Une invalidation pendant le chargement rend son résultat suspect : il est
|
||||
// alors renvoyé à l'appelant, mais pas conservé.
|
||||
const generation = cache.generation;
|
||||
const pending = load().then(
|
||||
(value) => {
|
||||
if (generation !== cache.generation) return value;
|
||||
if (options.cacheIf && !options.cacheIf(value)) {
|
||||
store.delete(key);
|
||||
} else {
|
||||
store.delete(key); // réinsertion en fin : ordre d'éviction = ancienneté
|
||||
store.set(key, { value, expiresAt: Date.now() + (options.ttlMs ?? TTL_MS) });
|
||||
while (store.size > MAX_ENTRIES) {
|
||||
const oldest = store.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
store.delete(oldest);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
if (generation === cache.generation) store.delete(key);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
// Une éventuelle valeur expirée est remplacée : seule la requête en cours compte.
|
||||
store.set(key, { expiresAt: 0, pending });
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Vide tout le cache FF (fin de synchro nocturne, données fraîches). */
|
||||
export function invalidateFFCache(): void {
|
||||
cache.generation++;
|
||||
store.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lectures partagées par plusieurs pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé. */
|
||||
export function getFournisseursCached(): Promise<{ code: string; nom: string }[]> {
|
||||
return cachedFF("fournisseurs", () => pgGetFournisseurs(), { cacheIf: (rows) => rows.length > 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Dernière réception par fournisseur et magasin (cadencier). Agrège tout
|
||||
* l'historique de `mvtart` : de loin la lecture la plus lourde de la page
|
||||
* Commandes. Vide = erreur avalée, non gardé.
|
||||
*/
|
||||
export function getDerniereReceptionCached(): Promise<Map<string, string>> {
|
||||
return cachedFF("derniere-reception", () => pgGetDerniereReceptionParFournisseur(), {
|
||||
cacheIf: (map) => map.size > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Formateurs de nombres partagés (fr-FR).
|
||||
*
|
||||
* `Number.prototype.toLocaleString` appelé avec des options construit un nouvel
|
||||
* `Intl.NumberFormat` à chaque appel : dans la Grille, plusieurs milliers de
|
||||
* cellules le payaient à chaque défilement et à chaque tri. Ces instances sont
|
||||
* créées une seule fois et donnent exactement le même texte.
|
||||
*/
|
||||
|
||||
const nfEntier = new Intl.NumberFormat("fr-FR");
|
||||
const nfDecimal1 = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 1 });
|
||||
const nfEur0 = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
||||
const nfEur2 = new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/** Entier arrondi avec séparateur de milliers : `12 345`. */
|
||||
export const fmtEntier = (v: number): string => nfEntier.format(Math.round(v));
|
||||
|
||||
/** Nombre avec au plus une décimale : `12,5`. */
|
||||
export const fmtDecimal1 = (v: number): string => nfDecimal1.format(v);
|
||||
|
||||
/** Montant en euros sans centimes : `12 345 €`. */
|
||||
export const fmtEur0 = (v: number): string => nfEur0.format(v);
|
||||
|
||||
/** Montant en euros au centime (prix unitaires) : `3,49 €`. */
|
||||
export const fmtEur2 = (v: number): string => nfEur2.format(v);
|
||||
+32
-1
@@ -14,7 +14,7 @@ import "server-only";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { gridRows } from "@/db/schema";
|
||||
import { and, asc, desc, eq, ilike, or, sql, lt, type SQL } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, ilike, inArray, or, sql, lt, type SQL } from "drizzle-orm";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */
|
||||
@@ -147,6 +147,37 @@ export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function updateGridRowsGamme(
|
||||
codeFournisseur: string,
|
||||
changes: ReadonlyArray<{ codein: string; codeGamme: string }>,
|
||||
): Promise<void> {
|
||||
const parGamme = new Map<string, string[]>();
|
||||
for (const c of changes) {
|
||||
const codeins = parGamme.get(c.codeGamme) ?? [];
|
||||
codeins.push(c.codein);
|
||||
parGamme.set(c.codeGamme, codeins);
|
||||
}
|
||||
const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL
|
||||
for (const [gamme, codeins] of parGamme) {
|
||||
for (let i = 0; i < codeins.length; i += CHUNK) {
|
||||
await db
|
||||
.update(gridRows)
|
||||
.set({
|
||||
codeGamme: gamme,
|
||||
payload: sql`jsonb_set(${gridRows.payload}, '{codeGamme}', to_jsonb(${gamme}::text))`,
|
||||
})
|
||||
.where(and(
|
||||
eq(gridRows.codeFournisseur, codeFournisseur),
|
||||
inArray(gridRows.codein, codeins.slice(i, i + CHUNK)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Construit la clause WHERE commune à la grille et à la recherche transversale. */
|
||||
function buildWhere(q: GridQuery): SQL | undefined {
|
||||
const clauses: SQL[] = [];
|
||||
|
||||
+18
-4
@@ -22,7 +22,7 @@
|
||||
* un job qui fait tomber le process laisse quand même son journal.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { appendFile, mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -35,7 +35,7 @@ const MAX_LIGNES = 200_000;
|
||||
const MAX_LONGUEUR_LIGNE = 8_000;
|
||||
/** Nombre de journaux conservés sur disque. */
|
||||
const MAX_FICHIERS = 20;
|
||||
/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */
|
||||
/** Écriture disque au plus toutes les N ms (seules les nouvelles lignes sont ajoutées). */
|
||||
const FLUSH_MS = 2_000;
|
||||
|
||||
interface Capture {
|
||||
@@ -46,6 +46,8 @@ interface Capture {
|
||||
flushEnCours: boolean;
|
||||
flushDemande: boolean;
|
||||
dernierFlush: number;
|
||||
/** Nombre de lignes déjà écrites sur disque. */
|
||||
ecrites: number;
|
||||
}
|
||||
|
||||
const captures = new Map<string, Capture>();
|
||||
@@ -82,7 +84,16 @@ async function flush(capture: Capture): Promise<void> {
|
||||
capture.flushEnCours = true;
|
||||
try {
|
||||
await mkdir(LOG_DIR, { recursive: true });
|
||||
await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8");
|
||||
// Ajout des seules nouvelles lignes : réécrire tout le journal (jusqu'à
|
||||
// 200 000 lignes) toutes les 2 s bloquait le serveur pendant les synchros.
|
||||
const total = capture.lignes.length;
|
||||
const nouvelles = capture.lignes.slice(capture.ecrites, total);
|
||||
if (capture.ecrites === 0) {
|
||||
await writeFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8");
|
||||
} else if (nouvelles.length > 0) {
|
||||
await appendFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8");
|
||||
}
|
||||
capture.ecrites = total;
|
||||
capture.dernierFlush = Date.now();
|
||||
} catch {
|
||||
// Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe.
|
||||
@@ -128,7 +139,9 @@ function instrumenterConsole(): void {
|
||||
const origine = console[methode].bind(console);
|
||||
console[methode] = (...args: unknown[]) => {
|
||||
try {
|
||||
ajouter(formater(args));
|
||||
// Aucune capture ouverte (le cas courant) : on ne formate rien.
|
||||
// Sérialiser chaque argument de chaque log coûtait à toutes les requêtes.
|
||||
if (captures.size > 0) ajouter(formater(args));
|
||||
} catch {
|
||||
// idem : jamais au détriment du log réel
|
||||
}
|
||||
@@ -173,6 +186,7 @@ export function startCapture(id: string, entete?: string): void {
|
||||
flushEnCours: false,
|
||||
flushDemande: false,
|
||||
dernierFlush: 0,
|
||||
ecrites: 0,
|
||||
};
|
||||
captures.set(id, capture);
|
||||
capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`);
|
||||
|
||||
+14
-22
@@ -9,6 +9,7 @@
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import { ffFetchInit } from "@/lib/api-ff-client";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
@@ -20,16 +21,13 @@ let _nomenclatureParentCol: string | null | undefined = undefined; // undefined
|
||||
// Utilitaire : exécuter une requête sans workers parallèles PostgreSQL.
|
||||
// Les workers parallèles consomment /dev/shm (mémoire partagée Docker) et font
|
||||
// échouer les requêtes si le conteneur a un shm_size insuffisant.
|
||||
// SET LOCAL s'applique uniquement à la transaction courante.
|
||||
// Le réglage est désormais posé une fois par connexion du pool (src/db/index.ts) :
|
||||
// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait
|
||||
// trois allers-retours de plus à chacune.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
|
||||
let rows: unknown[] = [];
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`);
|
||||
const r = await tx.execute(query);
|
||||
rows = r.rows;
|
||||
});
|
||||
return { rows };
|
||||
const r = await db.execute(query);
|
||||
return { rows: r.rows };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -96,7 +94,6 @@ export async function pgGetFournisseurs(search?: string): Promise<{ code: string
|
||||
${search ? sql`AND (fi.nom ILIKE ${'%' + search + '%'} OR fi.code ILIKE ${'%' + search + '%'})` : sql``}
|
||||
ORDER BY fi.nom
|
||||
`);
|
||||
console.log(`[pg-ff] pgGetFournisseurs: ${result.rows.length} fournisseurs`);
|
||||
return (result.rows as unknown as { code: string; nom: string }[]).filter(r => r.code && r.nom);
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetFournisseurs error:", (e as Error).message?.slice(0, 200));
|
||||
@@ -297,10 +294,6 @@ export async function pgGetGammesByFournisseur(codefou: string): Promise<Map<str
|
||||
for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) {
|
||||
if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim());
|
||||
}
|
||||
if (result.rows.length > 0) {
|
||||
console.log("[pg-ff] Gammes sample row:", JSON.stringify(result.rows[0]));
|
||||
}
|
||||
console.log(`[pg-ff] Gammes: ${map.size} articles avec gamme`);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -429,7 +422,6 @@ export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<M
|
||||
}
|
||||
|
||||
console.log(`[pg-ff] Nomenclature: ${result.rows.length} articles, parentCol="${parentCol ?? "none"}"`);
|
||||
if (result.rows.length > 0) console.log("[pg-ff] Nomenclature sample:", JSON.stringify(result.rows[0]));
|
||||
return buildNomMap(result.rows as unknown as PgNomRow[]);
|
||||
}
|
||||
|
||||
@@ -635,9 +627,9 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
|
||||
|
||||
// Deux appels parallèles : données hier + données même jour N-1
|
||||
const [apiResult, apiN1Result] = await Promise.all([
|
||||
fetch(`${apiBase}/dashboard?date=${dateHier}`, { cache: "no-store" })
|
||||
fetch(`${apiBase}/dashboard?date=${dateHier}`, ffFetchInit())
|
||||
.then(r => { if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); return r.json() as Promise<ApiPerfDashboard>; }),
|
||||
fetch(`${apiBase}/dashboard?date=${dateN1Param}`, { cache: "no-store" })
|
||||
fetch(`${apiBase}/dashboard?date=${dateN1Param}`, ffFetchInit())
|
||||
.then(r => r.ok ? r.json() as Promise<ApiPerfDashboard> : null)
|
||||
.catch(() => null),
|
||||
]);
|
||||
@@ -686,7 +678,7 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
|
||||
const batch = allCodeins.slice(i, i + 5);
|
||||
await Promise.all(batch.map(async (codein) => {
|
||||
try {
|
||||
const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`);
|
||||
const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`, ffFetchInit());
|
||||
if (!artRes.ok) return;
|
||||
const artData = await artRes.json();
|
||||
if (!artData.articles || artData.articles.length === 0) return;
|
||||
@@ -695,8 +687,8 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
|
||||
if (!noId) return;
|
||||
|
||||
const [refRes, dernierRes] = await Promise.all([
|
||||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`),
|
||||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`),
|
||||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`, ffFetchInit()),
|
||||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`, ffFetchInit()),
|
||||
]);
|
||||
|
||||
// Stock (toujours depuis referentiel)
|
||||
@@ -1373,7 +1365,7 @@ function mapCommandeAutoRow(r: any): PgCommandeAutoRow {
|
||||
export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, ffFetchInit());
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
// L'API retourne { count: N, propositions: [...] } ou un tableau direct
|
||||
@@ -1414,7 +1406,7 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
uniqueCodeFous.map(async codefou => {
|
||||
// 1. Essai sans filtre site
|
||||
try {
|
||||
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, { cache: "no-store" });
|
||||
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
|
||||
if (res2.ok) {
|
||||
const detail = await res2.json();
|
||||
const f = extractFranco(detail);
|
||||
@@ -1423,7 +1415,7 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
} catch { /* ignore */ }
|
||||
// 2. Fallback site=000
|
||||
try {
|
||||
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, { cache: "no-store" });
|
||||
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
|
||||
if (res3.ok) {
|
||||
const detail = await res3.json();
|
||||
const f = extractFranco(detail);
|
||||
|
||||
Reference in new issue
Block a user