perf(serveur): réponses API allégées, compression, cache des assets et index

- Fichiers statiques servis avant la session (plus de requêtes SQL par
  asset), /assets en cache immuable 1 an, index.html en no-cache, et
  compression gzip/brotli des réponses (dépendance compression, externe
  dans le bundle esbuild du Dockerfile).
- req.user (déjà chargé par deserializeUser) réutilisé dans les handlers
  au lieu de relire l'utilisateur et ses magasins à chaque appel ;
  GET /api/user ne refait plus de requête.
- Listes : le magasin joint est réduit aux champs lus par l'interface
  (plus de logo base64 ni de configuration SMTP/NocoDB dans chaque ligne),
  plus aucune empreinte de mot de passe dans les créateurs/auteurs ni dans
  /api/users.
- N+1 supprimés (/api/users, annonces, historique SAV, caches de
  vérification des factures), requêtes indépendantes en parallèle (stats,
  analytics, météo, getDelivery, getUserWithGroups), jointure
  multiplicative des statistiques par magasin corrigée.
- Échéancier limité au magasin demandé ; filtre status sur
  GET /api/deliveries.
- Index de performance créés en arrière-plan au démarrage
  (CREATE INDEX CONCURRENTLY, reliquats invalides purgés sans verrou
  exclusif).
- La connexion n'attend plus la sauvegarde quotidienne ; purge du cache
  des factures active en production ; logs volumineux retirés des
  chemins chauds ; NODE_ENV fixé dans l'image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
This commit is contained in:
Claude committed 2026-10-03 04:29:51 +00:00
1 parent 3f2660c506
commit a5785f0244
13 files changed
+1113 -1116

No files matched your search

+50 -101
View File
@@ -1,5 +1,10 @@
import { sql } from "drizzle-orm";
import { db } from "./db.js";
import { storage } from "./storage.js";
// Blancs ASCII retirés par String.prototype.trim(), pour reproduire en SQL les clés de cache
const ASCII_WHITESPACE = ' \t\n\v\f\r';
/**
* Service de vérification des factures avec NocoDB
* Gère la vérification automatique des références de factures
@@ -19,26 +24,10 @@ export class InvoiceVerificationService {
async checkCache(invoiceReference: string, groupId: number): Promise<any | null> {
try {
const cacheKey = this.generateCacheKey(invoiceReference, groupId);
console.log('🔍 [CACHE] Recherche cache pour:', { invoiceReference, groupId, cacheKey });
const cached = await storage.getInvoiceVerificationCache(cacheKey);
console.log('🔍 [CACHE] Résultat cache:', {
found: !!cached,
isReconciled: cached?.isReconciled,
expired: cached ? new Date() >= new Date(cached.expiresAt) : 'N/A',
expiresAt: cached?.expiresAt,
currentTime: new Date().toISOString()
});
// ✅ CACHE PERMANENT : Facture validée avec coche verte = JAMAIS re-vérifier
if (cached && cached.isReconciled && cached.exists) {
console.log('🛡️ [CACHE] PERMANENT - Facture validée avec coche verte, AUCUNE vérification:', {
invoiceReference,
groupId,
exists: cached.exists,
invoiceAmount: cached.invoiceAmount,
dueDate: cached.dueDate
});
return {
exists: cached.exists,
matchType: cached.matchType,
@@ -55,7 +44,6 @@ export class InvoiceVerificationService {
// Cache temporaire non expiré
if (cached && new Date() < new Date(cached.expiresAt)) {
console.log('✅ [CACHE] Cache temporaire hit pour:', { invoiceReference, groupId, exists: cached.exists });
return {
exists: cached.exists,
matchType: cached.matchType,
@@ -69,13 +57,7 @@ export class InvoiceVerificationService {
permanent: false
};
}
if (cached && new Date() >= new Date(cached.expiresAt)) {
console.log('⏰ [CACHE] Cache temporaire expiré pour:', { invoiceReference, groupId });
} else {
console.log('❌ [CACHE] Cache miss pour:', { invoiceReference, groupId });
}
return null;
} catch (error) {
console.error('❌ [CACHE] Erreur lecture cache:', error);
@@ -92,32 +74,18 @@ export class InvoiceVerificationService {
// Durée de cache adaptative selon les cas
const expiresAt = new Date();
let cacheDescription = '';
if (isReconciled) {
// Cache PERMANENT pour factures validées - expire dans 50 ans
expiresAt.setFullYear(expiresAt.getFullYear() + 50);
cacheDescription = 'PERMANENT (validé)';
} else if (result.exists) {
// Facture trouvée mais non validée - cache 6h pour permettre corrections
expiresAt.setHours(expiresAt.getHours() + 6);
cacheDescription = 'temporaire 6h (trouvé)';
} else {
// Facture non trouvée - cache 12h pour éviter spam
expiresAt.setHours(expiresAt.getHours() + 12);
cacheDescription = 'temporaire 12h (pas trouvé)';
}
console.log('💾 [CACHE] Tentative sauvegarde:', {
invoiceReference,
groupId,
cacheKey,
exists: result.exists,
isReconciled,
cacheType: cacheDescription,
expiresAt: expiresAt.toISOString()
});
const cacheData = {
cacheKey,
groupId,
@@ -135,17 +103,7 @@ export class InvoiceVerificationService {
expiresAt
};
const savedCache = await storage.saveInvoiceVerificationCache(cacheData);
console.log('✅ [CACHE] Résultat sauvé en cache:', {
id: savedCache.id,
invoiceReference,
groupId,
exists: result.exists,
isReconciled,
cacheType: cacheDescription,
cacheKey
});
await storage.saveInvoiceVerificationCache(cacheData);
} catch (error) {
// Gérer spécifiquement les erreurs de contrainte unique (duplicate key)
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
@@ -211,7 +169,16 @@ export class InvoiceVerificationService {
async updateExistingReconciledCaches(): Promise<void> {
try {
console.log('🔄 [CACHE] Mise à jour en lot des caches pour livraisons validées...');
// Base PostgreSQL : une seule requête ensembliste au lieu de charger toutes
// les livraisons puis 2 requêtes (lecture + upsert) par référence
if (db) {
const updatedCount = await this.markReconciledCachesInDatabase();
console.log(`✅ [CACHE] Mise à jour terminée: ${updatedCount} caches mis à jour comme permanents`);
return;
}
// Stockage mémoire (développement sans base) : parcours livraison par livraison
// Récupérer toutes les livraisons validées (reconciled = true)
const deliveries = await storage.getDeliveries();
const reconciledDeliveries = deliveries.filter(d => d.reconciled);
@@ -243,6 +210,36 @@ export class InvoiceVerificationService {
}
}
/**
* Équivalent SQL de updateCacheAsReconciled appliqué à toutes les livraisons validées.
* Mêmes clés que generateCacheKey (référence facture et numéro de BL non vides après
* trim), mêmes conditions (entrée présente, non expirée, pas encore réconciliée) et
* mêmes colonnes modifiées que l'upsert de DatabaseStorage : is_reconciled et
* updated_at (expires_at n'y est pas mis à jour). Écart accepté : seuls les blancs
* ASCII sont retirés, et lower() suit la locale de la base.
* Retourne le nombre d'entrées de cache mises à jour.
*/
private async markReconciledCachesInDatabase(): Promise<number> {
// Heure UTC, comme drizzle écrit et relit les colonnes timestamp
const now = new Date().toISOString();
const result = await db.execute(sql`
UPDATE invoice_verification_cache
SET is_reconciled = true, updated_at = ${now}::timestamp
WHERE is_reconciled IS NOT TRUE
AND expires_at > ${now}::timestamp
AND cache_key IN (
SELECT group_id::text || '_' || lower(btrim(invoice_reference, ${ASCII_WHITESPACE}))
FROM deliveries
WHERE reconciled = true AND btrim(invoice_reference, ${ASCII_WHITESPACE}) <> ''
UNION
SELECT group_id::text || '_' || lower(btrim(bl_number, ${ASCII_WHITESPACE}))
FROM deliveries
WHERE reconciled = true AND btrim(bl_number, ${ASCII_WHITESPACE}) <> ''
)
`);
return result?.rowCount ?? 0;
}
/**
* Vérifie une référence de facture pour un groupe donné
*/
@@ -258,8 +255,6 @@ export class InvoiceVerificationService {
fromCache?: boolean;
}> {
try {
console.log('🔍 [INVOICE] Début vérification facture:', { invoiceReference, groupId, forceRefresh });
if (!invoiceReference || !invoiceReference.trim()) {
return {
exists: false,
@@ -270,15 +265,10 @@ export class InvoiceVerificationService {
// Vérifier le cache d'abord (sauf si refresh forcé)
if (!forceRefresh) {
console.log('🔍 [INVOICE] Vérification cache...');
const cachedResult = await this.checkCache(invoiceReference, groupId);
if (cachedResult) {
console.log('✅ [INVOICE] Résultat depuis cache:', cachedResult);
return cachedResult;
}
console.log('🔍 [INVOICE] Pas de cache, requête API...');
} else {
console.log('🔄 [INVOICE] Refresh forcé, ignorant le cache');
}
// Récupérer la configuration du groupe
@@ -291,16 +281,8 @@ export class InvoiceVerificationService {
};
}
console.log('🔧 Configuration groupe:', {
groupName: group.name,
hasNocodbConfig: !!group.nocodbConfigId,
hasTableName: !!group.nocodbTableName,
hasWebhook: !!group.webhookUrl
});
// Si pas de configuration NocoDB, retourner un résultat par défaut
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
console.log('⚠️ Pas de configuration NocoDB pour ce groupe');
return {
exists: false,
matchType: 'none',
@@ -353,8 +335,6 @@ export class InvoiceVerificationService {
}
// En production, faire l'appel réel à NocoDB
console.log('🔍 Vérification NocoDB en production...');
try {
// Récupérer la configuration NocoDB active
const nocodbConfig = await storage.getActiveNocodbConfig();
@@ -367,20 +347,8 @@ export class InvoiceVerificationService {
};
}
console.log('🔧 Configuration NocoDB trouvée:', {
configName: nocodbConfig.name,
baseUrl: nocodbConfig.baseUrl,
projectId: nocodbConfig.projectId,
hasToken: !!nocodbConfig.apiToken
});
// Utiliser l'ID de table configuré dans le groupe
const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b'; // Fallback par défaut
console.log('🔧 Utilisation table ID:', {
groupTable: group.nocodbTableName,
configuredId: group.nocodbTableId,
resolvedId: tableId
});
// Vérifier d'abord par référence de facture
let matchResult = await this.searchInNocoDB(
@@ -621,8 +589,6 @@ export class InvoiceVerificationService {
fromCache?: boolean;
}> {
try {
console.log('🔍 Début vérification facture par BL:', { blNumber, supplierName, groupId, forceRefresh });
if (!blNumber || !blNumber.trim()) {
return {
exists: false,
@@ -639,7 +605,6 @@ export class InvoiceVerificationService {
try {
const cached = await storage.getInvoiceVerificationCache(cacheKey);
if (cached && new Date() < new Date(cached.expiresAt)) {
console.log('💾 Cache hit pour BL:', { blNumber, groupId });
return {
exists: cached.exists,
matchType: cached.matchType as 'invoice_reference' | 'bl_number' | 'none',
@@ -664,17 +629,8 @@ export class InvoiceVerificationService {
};
}
console.log('🔧 Configuration groupe pour BL:', {
groupName: group.name,
hasNocodbConfig: !!group.nocodbConfigId,
hasTableName: !!group.nocodbTableName,
hasWebhook: !!group.webhookUrl,
blColumnName: group.nocodbBlColumnName
});
// Si pas de configuration NocoDB, retourner un résultat par défaut
if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) {
console.log('⚠️ Pas de configuration NocoDB pour ce groupe');
return {
exists: false,
matchType: 'none',
@@ -727,8 +683,6 @@ export class InvoiceVerificationService {
}
// En production, faire l'appel réel à NocoDB
console.log('🔍 Vérification BL NocoDB en production...');
try {
// Récupérer la configuration NocoDB active
const nocodbConfig = await storage.getActiveNocodbConfig();
@@ -743,11 +697,6 @@ export class InvoiceVerificationService {
// Utiliser l'ID de table configuré dans le groupe
const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b';
console.log('🔧 Utilisation table ID pour BL:', {
groupTable: group.nocodbTableName,
configuredId: group.nocodbTableId,
resolvedId: tableId
});
// Rechercher par numéro de BL
const blColumnName = group.nocodbBlColumnName || 'Numero_BL';