fix : exports (csv, xls) fixed, clients expand, fixed

This commit is contained in:
Alexis Burnaz 2025-12-22 11:46:51 +01:00
parent 4f82b29f18
commit 5de492b308
3 changed files with 220 additions and 156 deletions

View File

@ -275,52 +275,64 @@ router.post("/export/csv", async (req, res) => {
res.write(headers.join(";") + "\n"); 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;
while (true) { // Construction du filtre régions (identique à getParcoursByRegionsPage)
if (aborted) break; 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(" && ");
let result; // 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 { try {
result = await parcoursService.getParcoursByRegionsPage( allParcours = await parcoursService.getParcoursFullList({
effective.regions, filter: finalFilter,
page, sort: sort || "-created",
perPage, expand: expandFields,
{ filter, sort } batch: 500,
); });
} catch (err) { } catch (err) {
logger.log("error", err); logger.log("error", "Erreur récupération parcours pour export CSV:", err);
break; if (!res.headersSent) {
return res.status(500).send("Erreur lors de la récupération des données");
}
try { res.end(); } catch {}
return;
} }
/** // Collecte des IDs clients manquants (l'expand contrat.client ne fonctionne pas en SDK 0.7.x)
* Traitement séquentiel de chaque parcours avec récupération des clients const missingClientIds = [];
*/ for (const parcours of allParcours) {
for (const parcours of result.items) { 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; if (aborted) break;
const contrat = parcours["@expand"]?.contrat || null; const contrat = parcours["@expand"]?.contrat || null;
const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null; const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null;
/** // Client : d'abord depuis l'expand, sinon depuis le batch
* 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; let client = contrat ? (contrat["@expand"]?.client || null) : null;
if (!client && contrat && contrat.client) { if (!client && contrat && contrat.client) {
try { client = clientsMap.get(contrat.client) || null;
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 lastUser = parcours["@expand"]?.dernierUtilisateur;
const region = lastUser?.["@expand"]?.region; const region = lastUser?.["@expand"]?.region;
@ -340,10 +352,6 @@ router.post("/export/csv", async (req, res) => {
client ? (client.nom || "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, " ")); const safe = row.map(v => String(v).replaceAll(";", ",").replace(/\r?\n/g, " "));
try { try {
res.write(safe.join(";") + "\n"); res.write(safe.join(";") + "\n");
@ -354,11 +362,6 @@ router.post("/export/csv", async (req, res) => {
} }
} }
// Passage à la page suivante si nécessaire
if (aborted || page >= result.totalPages) break;
page++;
}
if (!aborted) { if (!aborted) {
res.end(); res.end();
} }
@ -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;
const first = await parcoursService.getParcoursByRegionsPage( // Construction du filtre régions (identique à getParcoursByRegionsPage)
effective.regions, page, perPage, { filter, sort } 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;
}
// 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") const fileName = (mode === "full")
? "historique_parcours_complet.xls" ? "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-Type", "application/vnd.ms-excel; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`); res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
/** // En-tête SpreadsheetML 2003
* En-tête SpreadsheetML 2003
* Nécessaire pour que le fichier soit reconnu comme XLS par Excel
*/
res.write( res.write(
`<?xml version="1.0"?> `<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?> <?mso-application progid="Excel.Sheet"?>
@ -464,28 +500,17 @@ router.post("/export/xls", async (req, res) => {
`</Row>\n` `</Row>\n`
); );
/** // Traitement des parcours
* Écrit une page de résultats dans le fichier XLS for (const parcours of allParcours) {
* 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 contrat = parcours["@expand"]?.contrat || null;
const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null; const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null;
/** // Client : d'abord depuis l'expand, sinon depuis le batch
* 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; let client = contrat ? (contrat["@expand"]?.client || null) : null;
if (!client && contrat && contrat.client) { if (!client && contrat && contrat.client) {
try { client = clientsMap.get(contrat.client) || null;
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 lastUser = parcours["@expand"]?.dernierUtilisateur;
const region = lastUser?.["@expand"]?.region; const region = lastUser?.["@expand"]?.region;
@ -507,31 +532,8 @@ router.post("/export/xls", async (req, res) => {
res.write(rowXml(row) + "\n"); res.write(rowXml(row) + "\n");
} }
};
await writePage(first); // Fermeture du fichier XML SpreadsheetML
/**
* 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;
}
}
/**
* Fermeture du fichier XML SpreadsheetML
*/
res.write( res.write(
` </Table> ` </Table>
</Worksheet> </Worksheet>

View File

@ -14,7 +14,55 @@ async function getClient(id) {
return globalService.fetchInfoByCriteria("client", criteria); 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<string, Object>} - 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 = { module.exports = {
createClient, createClient,
getClient, getClient,
getClientsBatch,
}; };

View File

@ -21,16 +21,30 @@ async function getParcoursByNumParcours(numParcours) {
/** /**
* Full list (batch côté PocketBase). | Fetch l'ensemble de la BD via chunk "batch" * 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 }) { async function getParcoursFullList({ filter, sort, expand, fields, batch = 500 }) {
return db.records.getFullList("parcours", { const options = {
filter: filter || "",
sort: sort || "-created", sort: sort || "-created",
expand, };
fields,
batch, // taille interne des sous-requêtes // Ajouter expand si défini
skipTotal, // évite le COUNT total (évite calcul inutile + gain de temps) 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) * Pagination multi-régions + filtres/tri optionnels (server-side DataTables)