exportExcel's CSV/XLS write streams had no 'error' listener, so a write failure (disk full, export directory becomes inaccessible mid-run — both realistic for a large CNPJ export) surfaced as an uncaught exception that crashed the whole export-job.mjs child process, bypassing every await/try-catch in the caller. The exports row was then never marked 'error' and stayed stuck, and a half-written file could sit at the final path (no temp+rename, unlike the same pattern already used in rfb-extractor.mjs/rfb-downloader.mjs). Streams now track their error via a listener instead of throwing, writes and stream-end are rejected through the normal promise chain so export-job.mjs's existing catch/finally handles it, and both files are written to .tmp paths and only renamed to their final names once both streams close successfully. Found via a full-project audit.
153 lines
5.5 KiB
JavaScript
153 lines
5.5 KiB
JavaScript
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';
|
|
|
|
// Exportacao "planilha": CSV + .xls gemeo, ambos a prova de Excel — os campos
|
|
// numericos longos (whatsapp, cnpj, cnae) sao gravados como TEXTO para nunca
|
|
// virarem notacao cientifica. Mesmo formato dos arquivos TRATADOS legados:
|
|
// CSV -> formula ="valor"
|
|
// XLS -> tabela HTML com mso-number-format:"\@"
|
|
|
|
const HEADERS = [
|
|
'empresa', 'whatsapp', 'ddd', 'cidade', 'uf', 'segmento', 'segmento_legivel', 'interesse',
|
|
'cnpj', 'cnae', 'telefone_valido', 'motivo_telefone', 'cnpj_valido', 'empresa_valida',
|
|
'motivo_empresa', 'score', 'classificacao', 'motivo_classificacao', 'chave_duplicidade', 'linha_origem',
|
|
'score_qualidade', 'score_comercial', 'prioridade', 'produto_recomendado'
|
|
];
|
|
|
|
const TEXT_COLUMNS = new Set(['whatsapp', 'ddd', 'cnpj', 'cnae']);
|
|
|
|
function leadToExportRow(lead) {
|
|
return {
|
|
empresa: lead.empresa,
|
|
whatsapp: lead.whatsapp,
|
|
ddd: lead.ddd,
|
|
cidade: lead.cidade,
|
|
uf: lead.uf,
|
|
segmento: lead.segmento,
|
|
segmento_legivel: lead.segmento_label,
|
|
interesse: lead.interesse,
|
|
cnpj: lead.cnpj,
|
|
cnae: lead.cnae,
|
|
telefone_valido: lead.telefone_valido ? 'sim' : 'nao',
|
|
motivo_telefone: lead.telefone_valido ? 'telefone valido' : 'telefone invalido ou vazio',
|
|
cnpj_valido: lead.cnpj_valido ? 'sim' : 'nao',
|
|
empresa_valida: lead.empresa_valida ? 'sim' : 'nao',
|
|
motivo_empresa: lead.empresa_valida ? 'empresa valida' : (lead.motivo_classificacao || 'empresa invalida'),
|
|
score: lead.score,
|
|
classificacao: lead.classificacao,
|
|
motivo_classificacao: lead.motivo_classificacao,
|
|
chave_duplicidade: lead.dedup_key,
|
|
linha_origem: lead.id,
|
|
score_qualidade: lead.score_qualidade,
|
|
score_comercial: lead.score_comercial,
|
|
prioridade: lead.prioridade,
|
|
produto_recomendado: lead.produto_principal
|
|
};
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
// 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, 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(csvTmpPath, { encoding: 'utf8' });
|
|
const xls = fs.createWriteStream(xlsTmpPath, { encoding: 'utf8' });
|
|
const csvError = trackStreamError(csv);
|
|
const xlsError = trackStreamError(xls);
|
|
|
|
let total = 0;
|
|
|
|
try {
|
|
await writeStream(csv, BOM + HEADERS.join(';') + '\n', csvError);
|
|
await writeStream(xls, `${BOM}<html><head><meta charset="utf-8"><style>td.text{mso-number-format:"\\@";}</style></head><body><table border="1"><tr>${
|
|
HEADERS.map((h) => `<th>${escapeHtml(h)}</th>`).join('')
|
|
}</tr>\n`, xlsError);
|
|
|
|
for (const lead of iterateLeads(db, filtro)) {
|
|
const row = leadToExportRow(lead);
|
|
|
|
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);
|
|
|
|
const xlsCells = HEADERS.map((h) =>
|
|
`<td${TEXT_COLUMNS.has(h) ? ' class="text"' : ''}>${escapeHtml(TEXT_COLUMNS.has(h) ? row[h] : neutralizeSpreadsheetFormula(row[h]))}</td>`
|
|
);
|
|
await writeStream(xls, `<tr>${xlsCells.join('')}</tr>\n`, xlsError);
|
|
|
|
if (collectIds) collectIds.push(lead.id);
|
|
total += 1;
|
|
if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
|
|
}
|
|
|
|
await writeStream(xls, '</table></body></html>\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 Promise.all([
|
|
fsp.rename(csvTmpPath, csvPath),
|
|
fsp.rename(xlsTmpPath, xlsPath)
|
|
]);
|
|
|
|
return {
|
|
total,
|
|
files: [path.basename(csvPath), path.basename(xlsPath)]
|
|
};
|
|
}
|