mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 14:08:08 +02:00
- Grille : réglages écrits dans le localStorage au plus toutes les 500 ms (immédiatement à la fermeture), tendance réseau et texte de recherche calculés une fois par ligne, courbe de tendance mémorisée, barre du bas abonnée au seul nombre de modifications. - Commandes : appels « franco » 6 par 6 ; dernière réception limitée aux fournisseurs du cadencier (résultats identiques sur la base de test). - Dépendances inutilisées retirées (react-query, @google/genai, ai, react-markdown, remark-gfm, xlsx, @radix-ui/react-dropdown-menu) ; pnpm-lock.yaml supprimé (npm est utilisé par Docker et le README). - Code mort et journaux de build retirés. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
115 lines
4.9 KiB
TypeScript
115 lines
4.9 KiB
TypeScript
/**
|
|
* 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, pour les fournisseurs du
|
|
* cadencier seulement : de loin la lecture la plus lourde de la page Commandes.
|
|
* Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée.
|
|
* Vide = erreur avalée (ou aucune réception), non gardé.
|
|
*/
|
|
export function getDerniereReceptionCached(codes: string[]): Promise<Map<string, string>> {
|
|
const liste = [...new Set(codes.map((c) => c.trim()))].sort();
|
|
if (liste.length === 0) return Promise.resolve(new Map());
|
|
return cachedFF(`derniere-reception:${liste.join(",")}`, () => pgGetDerniereReceptionParFournisseur(liste), {
|
|
cacheIf: (map) => map.size > 0,
|
|
});
|
|
}
|