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, '>'); } // 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}${ HEADERS.map((h) => ``).join('') }\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) => `${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, '
${escapeHtml(h)}
\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)] }; }