Baseline commit of the existing Maquina Comercial Pessoal project

Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
This commit is contained in:
Gustavo
2026-07-31 10:54:07 -03:00
parent 6cd336a41e
commit d330bb5ff0
132 changed files with 29969 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import fs from 'node:fs';
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;');
}
async function writeStream(stream, text) {
if (!stream.write(text)) {
await new Promise((resolve) => stream.once('drain', 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`);
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}<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`);
let total = 0;
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');
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`);
if (collectIds) collectIds.push(lead.id);
total += 1;
if (total % 10000 === 0) onProgress({ message: `${total} leads gravados...` });
}
await writeStream(xls, '</table></body></html>\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())))
]);
return {
total,
files: [path.basename(csvPath), path.basename(xlsPath)]
};
}