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:
Claude committed 2026-10-03 20:28:14 +00:00
1 parent 2c84cfda53
commit 7da4c6d5fd
35 files changed
+1010 -240

No files matched your search

+111
View File
@@ -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,
});
}