diff --git a/src/export/excel.mjs b/src/export/excel.mjs
index 8911213..b3df36b 100644
--- a/src/export/excel.mjs
+++ b/src/export/excel.mjs
@@ -1,4 +1,5 @@
import fs from 'node:fs';
+import fsp from 'node:fs/promises';
import path from 'node:path';
import { csvEscape, excelTextCell, neutralizeSpreadsheetFormula, BOM } from '../util/csv.mjs';
import { iterateLeads } from '../treatment/queries.mjs';
@@ -54,50 +55,94 @@ function escapeHtml(value) {
.replace(/>/g, '>');
}
-async function writeStream(stream, text) {
+// Rastreia o evento 'error' do stream sem lancar (evita a excecao nao
+// capturada que derrubaria o processo inteiro): writeStream/endStream
+// verificam esse estado e transformam a falha numa promise rejeitada, que o
+// try/catch normal deste modulo (e do export-job.mjs) consegue tratar.
+function trackStreamError(stream) {
+ let error = null;
+ stream.on('error', (streamError) => { error = streamError; });
+ return () => error;
+}
+
+async function writeStream(stream, text, getError) {
+ if (getError()) throw getError();
if (!stream.write(text)) {
- await new Promise((resolve) => stream.once('drain', resolve));
+ await new Promise((resolve, reject) => {
+ stream.once('drain', resolve);
+ stream.once('error', reject);
+ });
}
}
+async function endStream(stream, getError) {
+ if (getError()) throw getError();
+ await new Promise((resolve, reject) => {
+ stream.once('error', reject);
+ stream.end((error) => (error ? reject(error) : resolve()));
+ });
+}
+
export async function exportExcel({ db, filtro, dir, onProgress = () => {}, collectIds }) {
fs.mkdirSync(dir, { recursive: true });
const baseName = 'leads-excel';
const csvPath = path.join(dir, `${baseName}.csv`);
const xlsPath = path.join(dir, `${baseName}.xls`);
+ // Grava em .tmp e so promove (rename) pro nome final se as duas escritas
+ // terminarem com sucesso — nunca deixa um arquivo pela metade no lugar do
+ // arquivo final, mesmo caminho ja usado em rfb-extractor.mjs/rfb-downloader.mjs.
+ const csvTmpPath = `${csvPath}.tmp`;
+ const xlsTmpPath = `${xlsPath}.tmp`;
- const csv = fs.createWriteStream(csvPath, { encoding: 'utf8' });
- const xls = fs.createWriteStream(xlsPath, { encoding: 'utf8' });
-
- await writeStream(csv, BOM + HEADERS.join(';') + '\n');
- await writeStream(xls, `${BOM}
${
- HEADERS.map((h) => `| ${escapeHtml(h)} | `).join('')
- }
\n`);
+ const csv = fs.createWriteStream(csvTmpPath, { encoding: 'utf8' });
+ const xls = fs.createWriteStream(xlsTmpPath, { encoding: 'utf8' });
+ const csvError = trackStreamError(csv);
+ const xlsError = trackStreamError(xls);
let total = 0;
- for (const lead of iterateLeads(db, filtro)) {
- const row = leadToExportRow(lead);
+ try {
+ await writeStream(csv, BOM + HEADERS.join(';') + '\n', csvError);
+ await writeStream(xls, `${BOM}${
+ HEADERS.map((h) => `| ${escapeHtml(h)} | `).join('')
+ }
\n`, xlsError);
- const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== ''
- ? excelTextCell(row[h])
- : csvEscape(neutralizeSpreadsheetFormula(row[h]))));
- await writeStream(csv, csvCells.join(';') + '\n');
+ for (const lead of iterateLeads(db, filtro)) {
+ const row = leadToExportRow(lead);
- const xlsCells = HEADERS.map((h) =>
- `${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(row[h]))} | `
- );
- await writeStream(xls, `${xlsCells.join('')}
\n`);
+ const csvCells = HEADERS.map((h) => (TEXT_COLUMNS.has(h) && row[h] !== ''
+ ? excelTextCell(row[h])
+ : csvEscape(neutralizeSpreadsheetFormula(row[h]))));
+ await writeStream(csv, csvCells.join(';') + '\n', csvError);
- if (collectIds) collectIds.push(lead.id);
- total += 1;
- if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
+ const xlsCells = HEADERS.map((h) =>
+ `${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(row[h]))} | `
+ );
+ await writeStream(xls, `${xlsCells.join('')}
\n`, xlsError);
+
+ if (collectIds) collectIds.push(lead.id);
+ total += 1;
+ if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
+ }
+
+ await writeStream(xls, '
\n', xlsError);
+ await Promise.all([
+ endStream(csv, csvError),
+ endStream(xls, xlsError)
+ ]);
+ } catch (error) {
+ csv.destroy();
+ xls.destroy();
+ await Promise.all([
+ fsp.rm(csvTmpPath, { force: true }),
+ fsp.rm(xlsTmpPath, { force: true })
+ ]);
+ throw error;
}
- await writeStream(xls, '
\n');
await Promise.all([
- new Promise((resolve, reject) => csv.end((e) => (e ? reject(e) : resolve()))),
- new Promise((resolve, reject) => xls.end((e) => (e ? reject(e) : resolve())))
+ fsp.rename(csvTmpPath, csvPath),
+ fsp.rename(xlsTmpPath, xlsPath)
]);
return {
diff --git a/tests/export.test.mjs b/tests/export.test.mjs
index 1842261..18f49f3 100644
--- a/tests/export.test.mjs
+++ b/tests/export.test.mjs
@@ -45,6 +45,27 @@ test('exportExcel usa nome interno e neutraliza formulas no CSV/XLS', async () =
}
});
+// Antes, um erro no stream de escrita (disco cheio, pasta inacessivel) virava
+// excecao nao capturada e derrubava o processo do job inteiro, sem nunca
+// chegar ao catch de export-job.mjs; e o arquivo final podia ficar pela
+// metade no lugar do arquivo definitivo. Forca EISDIR criando um diretorio
+// no caminho .tmp esperado do CSV, no lugar de um disco cheio de verdade.
+test('exportExcel rejeita (nao derruba o processo) quando o stream falha, e nao deixa arquivo pela metade', async () => {
+ const db = openDb(':memory:');
+ seedLead(db);
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pessoal-export-falha-'));
+ try {
+ await fs.mkdir(path.join(dir, 'leads-excel.csv.tmp'));
+ await assert.rejects(() => exportExcel({ db, filtro: {}, dir }));
+ assert.equal(await fs.stat(path.join(dir, 'leads-excel.csv')).then(() => true, () => false), false);
+ assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls')).then(() => true, () => false), false);
+ assert.equal(await fs.stat(path.join(dir, 'leads-excel.xls.tmp')).then(() => true, () => false), false);
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ db.close();
+ }
+});
+
test('rota de exportacao rejeita tipos corporativos removidos', async () => {
const db = openDb(':memory:');
seedLead(db);