Files
COMERCIAL-GTI/src/export/excel.mjs
T
Gustavo 3d552b5bc0 Fix export excel writer crashing the process on a stream write error
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.
2026-07-31 22:13:49 -03:00

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, '&lt;')
.replace(/>/g, '&gt;');
}
// 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)]
};
}