diff --git a/ecole/src/controllers/historiqueParcoursController.js b/ecole/src/controllers/historiqueParcoursController.js index 65def7dc..204e0f05 100644 --- a/ecole/src/controllers/historiqueParcoursController.js +++ b/ecole/src/controllers/historiqueParcoursController.js @@ -275,88 +275,91 @@ router.post("/export/csv", async (req, res) => { res.write(headers.join(";") + "\n"); /** - * Export par pages de 1000 parcours pour éviter la surcharge mémoire + * OPTIMISATION : getFullList pour récupérer tous les parcours en une requête + * + batch client pour récupérer tous les clients manquants en une requête */ - const perPage = 1000; - let page = 1; + + // Construction du filtre régions (identique à getParcoursByRegionsPage) + let regFilter = ""; + if (Array.isArray(effective.regions) && effective.regions.length > 0) { + const ors = effective.regions.map(r => `dernierUtilisateur.region.nom = "${r}"`); + regFilter = `(${ors.join(" || ")})`; + } + const finalFilter = [regFilter, filter].filter(Boolean).join(" && "); + + // Format avec espaces comme dans le code original qui fonctionnait + const expandFields = "contrat, contrat.client, contrat.intermediaire, dernierUtilisateur.region"; + + // Récupération de tous les parcours en une seule requête + let allParcours; + try { + allParcours = await parcoursService.getParcoursFullList({ + filter: finalFilter, + sort: sort || "-created", + expand: expandFields, + batch: 500, + }); + } catch (err) { + logger.log("error", "Erreur récupération parcours pour export CSV:", err); + if (!res.headersSent) { + return res.status(500).send("Erreur lors de la récupération des données"); + } + try { res.end(); } catch {} + return; + } - while (true) { + // Collecte des IDs clients manquants (l'expand contrat.client ne fonctionne pas en SDK 0.7.x) + const missingClientIds = []; + for (const parcours of allParcours) { + const contrat = parcours["@expand"]?.contrat; + if (contrat && contrat.client && !contrat["@expand"]?.client) { + missingClientIds.push(contrat.client); + } + } + + // Récupération batch de tous les clients manquants en une seule requête + const clientsMap = await clientService.getClientsBatch(missingClientIds); + + // Traitement des parcours + for (const parcours of allParcours) { if (aborted) break; - let result; + const contrat = parcours["@expand"]?.contrat || null; + const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null; + + // Client : d'abord depuis l'expand, sinon depuis le batch + let client = contrat ? (contrat["@expand"]?.client || null) : null; + if (!client && contrat && contrat.client) { + client = clientsMap.get(contrat.client) || null; + } + + const lastUser = parcours["@expand"]?.dernierUtilisateur; + const region = lastUser?.["@expand"]?.region; + + const row = [ + parcours.numParcours, + fmtDateFR(parcours.created), + lastUser?.matricule || "NC", + lastUser ? `${lastUser.prenom || ""} ${lastUser.nom || ""}`.trim() || "NC" : "NC", + region ? (region.nom || "NC") : "NC", + contrat ? (contrat.numSaisine || "NC") : "NC", + contrat ? (contrat.numContrat || "NC") : "NC", + contrat ? (contrat.produit || "NC") : "NC", + contrat ? (contrat.type || "NC") : "NC", + intermediaire ? (intermediaire.numPortefeuille || "NC") : "NC", + intermediaire ? (intermediaire.nom || "NC") : "NC", + client ? (client.numClient || "NC") : "NC", + client ? (client.nom || "NC") : "NC", + ]; + + const safe = row.map(v => String(v).replaceAll(";", ",").replace(/\r?\n/g, " ")); try { - result = await parcoursService.getParcoursByRegionsPage( - effective.regions, - page, - perPage, - { filter, sort } - ); - } catch (err) { - logger.log("error", err); + res.write(safe.join(";") + "\n"); + } catch (werr) { + logger.log("error", werr); + aborted = true; break; } - - /** - * Traitement séquentiel de chaque parcours avec récupération des clients - */ - for (const parcours of result.items) { - if (aborted) break; - - const contrat = parcours["@expand"]?.contrat || null; - const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null; - - /** - * Récupération du client avec fallback - * L'expand PocketBase ne fonctionne pas toujours, donc récupération directe si nécessaire - */ - let client = contrat ? (contrat["@expand"]?.client || null) : null; - if (!client && contrat && contrat.client) { - try { - const clientData = await clientService.getClient(contrat.client); - if (clientData) { - client = clientData; - } - } catch (err) { - // Erreur silencieuse : client non trouvé ou erreur de récupération - client = null; - } - } - const lastUser = parcours["@expand"]?.dernierUtilisateur; - const region = lastUser?.["@expand"]?.region; - - const row = [ - parcours.numParcours, - fmtDateFR(parcours.created), - lastUser?.matricule || "NC", - lastUser ? `${lastUser.prenom || ""} ${lastUser.nom || ""}`.trim() || "NC" : "NC", - region ? (region.nom || "NC") : "NC", - contrat ? (contrat.numSaisine || "NC") : "NC", - contrat ? (contrat.numContrat || "NC") : "NC", - contrat ? (contrat.produit || "NC") : "NC", - contrat ? (contrat.type || "NC") : "NC", - intermediaire ? (intermediaire.numPortefeuille || "NC") : "NC", - intermediaire ? (intermediaire.nom || "NC") : "NC", - client ? (client.numClient || "NC") : "NC", - client ? (client.nom || "NC") : "NC", - ]; - - /** - * Échappement des caractères spéciaux pour le format CSV - * Remplacement des ";" par "," et suppression des retours à la ligne - */ - const safe = row.map(v => String(v).replaceAll(";", ",").replace(/\r?\n/g, " ")); - try { - res.write(safe.join(";") + "\n"); - } catch (werr) { - logger.log("error", werr); - aborted = true; - break; - } - } - - // Passage à la page suivante si nécessaire - if (aborted || page >= result.totalPages) break; - page++; } if (!aborted) { @@ -411,14 +414,50 @@ router.post("/export/xls", async (req, res) => { ]; /** - * Export par pages de 1000 parcours pour éviter la surcharge mémoire + * OPTIMISATION : getFullList pour récupérer tous les parcours en une requête + * + batch client pour récupérer tous les clients manquants en une requête */ - const perPage = 1000; - let page = 1; + + // Construction du filtre régions (identique à getParcoursByRegionsPage) + let regFilter = ""; + if (Array.isArray(effective.regions) && effective.regions.length > 0) { + const ors = effective.regions.map(r => `dernierUtilisateur.region.nom = "${r}"`); + regFilter = `(${ors.join(" || ")})`; + } + const finalFilter = [regFilter, filter].filter(Boolean).join(" && "); + + // Format avec espaces comme dans le code original qui fonctionnait + const expandFields = "contrat, contrat.client, contrat.intermediaire, dernierUtilisateur.region"; + + // Récupération de tous les parcours en une seule requête + let allParcours; + try { + allParcours = await parcoursService.getParcoursFullList({ + filter: finalFilter, + sort: sort || "-created", + expand: expandFields, + batch: 500, + }); + } catch (err) { + logger.log("error", "Erreur récupération parcours pour export XLS:", err); + if (!res.headersSent) { + return res.status(500).send("Erreur lors de la récupération des données"); + } + try { res.end(); } catch {} + return; + } - const first = await parcoursService.getParcoursByRegionsPage( - effective.regions, page, perPage, { filter, sort } - ); + // Collecte des IDs clients manquants (l'expand contrat.client ne fonctionne pas en SDK 0.7.x) + const missingClientIds = []; + for (const parcours of allParcours) { + const contrat = parcours["@expand"]?.contrat; + if (contrat && contrat.client && !contrat["@expand"]?.client) { + missingClientIds.push(contrat.client); + } + } + + // Récupération batch de tous les clients manquants en une seule requête + const clientsMap = await clientService.getClientsBatch(missingClientIds); const fileName = (mode === "full") ? "historique_parcours_complet.xls" @@ -427,10 +466,7 @@ router.post("/export/xls", async (req, res) => { res.setHeader("Content-Type", "application/vnd.ms-excel; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`); - /** - * En-tête SpreadsheetML 2003 - * Nécessaire pour que le fichier soit reconnu comme XLS par Excel - */ + // En-tête SpreadsheetML 2003 res.write( ` @@ -464,74 +500,40 @@ router.post("/export/xls", async (req, res) => { `\n` ); - /** - * Écrit une page de résultats dans le fichier XLS - * Récupère les clients avec fallback si l'expand ne fonctionne pas - */ - const writePage = async (result) => { - for (const parcours of result.items) { - const contrat = parcours["@expand"]?.contrat || null; - const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null; - - /** - * Récupération du client avec fallback - * L'expand PocketBase ne fonctionne pas toujours, donc récupération directe si nécessaire - */ - let client = contrat ? (contrat["@expand"]?.client || null) : null; - if (!client && contrat && contrat.client) { - try { - client = await clientService.getClient(contrat.client); - } catch (err) { - // Erreur silencieuse : client non trouvé ou erreur de récupération - client = null; - } - } - const lastUser = parcours["@expand"]?.dernierUtilisateur; - const region = lastUser?.["@expand"]?.region; - - const row = [ - parcours.numParcours, - fmtDateFR(parcours.created), - lastUser?.matricule || "NC", - lastUser ? `${lastUser.prenom || ""} ${lastUser.nom || ""}`.trim() || "NC" : "NC", - region ? (region.nom || "NC") : "NC", - contrat ? (contrat.numSaisine || "NC") : "NC", - contrat ? (contrat.numContrat || "NC") : "NC", - contrat ? (contrat.produit || "NC") : "NC", - contrat ? (contrat.type || "NC") : "NC", - intermediaire ? (intermediaire.numPortefeuille || "NC") : "NC", - intermediaire ? (intermediaire.nom || "NC") : "NC", - client ? (client.numClient || "NC") : "NC", - client ? (client.nom || "NC") : "NC", - ].map(v => String(v).replace(/\r?\n/g, " ")); - - res.write(rowXml(row) + "\n"); + // Traitement des parcours + for (const parcours of allParcours) { + const contrat = parcours["@expand"]?.contrat || null; + const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null; + + // Client : d'abord depuis l'expand, sinon depuis le batch + let client = contrat ? (contrat["@expand"]?.client || null) : null; + if (!client && contrat && contrat.client) { + client = clientsMap.get(contrat.client) || null; } - }; + + const lastUser = parcours["@expand"]?.dernierUtilisateur; + const region = lastUser?.["@expand"]?.region; - await writePage(first); + const row = [ + parcours.numParcours, + fmtDateFR(parcours.created), + lastUser?.matricule || "NC", + lastUser ? `${lastUser.prenom || ""} ${lastUser.nom || ""}`.trim() || "NC" : "NC", + region ? (region.nom || "NC") : "NC", + contrat ? (contrat.numSaisine || "NC") : "NC", + contrat ? (contrat.numContrat || "NC") : "NC", + contrat ? (contrat.produit || "NC") : "NC", + contrat ? (contrat.type || "NC") : "NC", + intermediaire ? (intermediaire.numPortefeuille || "NC") : "NC", + intermediaire ? (intermediaire.nom || "NC") : "NC", + client ? (client.numClient || "NC") : "NC", + client ? (client.nom || "NC") : "NC", + ].map(v => String(v).replace(/\r?\n/g, " ")); - /** - * Traitement des pages suivantes - * Export par pages de 1000 parcours pour éviter la surcharge mémoire - */ - const totalPages = first.totalPages || 1; - while (page < totalPages) { - page++; - try { - const next = await parcoursService.getParcoursByRegionsPage( - effective.regions, page, perPage, { filter, sort } - ); - await writePage(next); - } catch (e) { - logger.log("error", e); - break; - } + res.write(rowXml(row) + "\n"); } - /** - * Fermeture du fichier XML SpreadsheetML - */ + // Fermeture du fichier XML SpreadsheetML res.write( ` diff --git a/ecole/src/services/clientService.js b/ecole/src/services/clientService.js index d21355f3..0b94dcd0 100644 --- a/ecole/src/services/clientService.js +++ b/ecole/src/services/clientService.js @@ -14,7 +14,55 @@ async function getClient(id) { return globalService.fetchInfoByCriteria("client", criteria); } +/** + * Récupère plusieurs clients en plusieurs requêtes batch (optimisation pour exports) + * Découpe en chunks de 50 IDs pour éviter les filtres trop longs + * SDK 0.7.x : getFullList(collection, batchSize, options) + * @param {string[]} clientIds - Tableau d'IDs de clients + * @returns {Map} - Map des clients par ID + */ +async function getClientsBatch(clientIds) { + const clientMap = new Map(); + if (!clientIds || clientIds.length === 0) return clientMap; + + // Filtrer les IDs valides et uniques + const uniqueIds = [...new Set(clientIds.filter(id => id && typeof id === 'string'))]; + if (uniqueIds.length === 0) return clientMap; + + // Découper en chunks de 50 pour éviter les filtres trop longs + const CHUNK_SIZE = 50; + const chunks = []; + for (let i = 0; i < uniqueIds.length; i += CHUNK_SIZE) { + chunks.push(uniqueIds.slice(i, i + CHUNK_SIZE)); + } + + // Traiter chaque chunk + for (const chunk of chunks) { + try { + // Construire le filtre OR pour ce chunk + const filter = chunk.map(id => `id = "${id}"`).join(" || "); + + // SDK 0.7.x : getFullList(collection, batchSize, options) + const clients = await db.records.getFullList("client", 500, { + filter: filter, + }); + + // Ajouter à la map + clients.forEach(client => { + if (client && client.id) { + clientMap.set(client.id, client); + } + }); + } catch (err) { + logger.log("warn", `Erreur récupération clients chunk (${chunk.length} IDs):`, err?.message || String(err)); + } + } + + return clientMap; +} + module.exports = { createClient, getClient, + getClientsBatch, }; \ No newline at end of file diff --git a/ecole/src/services/parcoursService.js b/ecole/src/services/parcoursService.js index b3315779..8a18b309 100644 --- a/ecole/src/services/parcoursService.js +++ b/ecole/src/services/parcoursService.js @@ -21,16 +21,30 @@ async function getParcoursByNumParcours(numParcours) { /** * Full list (batch côté PocketBase). | Fetch l'ensemble de la BD via chunk "batch" + * SDK 0.7.x : getFullList(collection, batchSize, options) */ -async function getParcoursFullList({ filter, sort, expand, fields, batch = 500, skipTotal = true }) { - return db.records.getFullList("parcours", { - filter: filter || "", +async function getParcoursFullList({ filter, sort, expand, fields, batch = 500 }) { + const options = { sort: sort || "-created", - expand, - fields, - batch, // taille interne des sous-requêtes - skipTotal, // évite le COUNT total (évite calcul inutile + gain de temps) - }); + }; + + // Ajouter expand si défini + if (expand) { + options.expand = expand; + } + + // Ajouter fields si défini + if (fields) { + options.fields = fields; + } + + // Ajouter filter SEULEMENT s'il n'est pas vide (SDK 0.7.x rejette les filtres vides) + if (filter && filter.trim() !== "") { + options.filter = filter; + } + + // SDK 0.7.x : getFullList(collection, batchSize, options) + return db.records.getFullList("parcours", batch, options); } /** * Pagination multi-régions + filtres/tri optionnels (server-side DataTables)