Commit inicial: GTI Maquina Comercial
App local (Node + SQLite) para captura, tratamento e exportacao de leads comerciais, com dashboard de gestao, login/usuarios e integracao Z-PRO. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "gti-servidor",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["scripts/servidor.mjs"],
|
||||
"port": 3900
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# GTI Maquina Comercial - configuracao local (copie para .env se precisar mudar algo)
|
||||
|
||||
# Porta do aplicativo local (padrao 3900)
|
||||
#GTI_PORT=3900
|
||||
|
||||
# Caminho do banco SQLite (padrao data/gti.db)
|
||||
#GTI_DB_PATH=data/gti.db
|
||||
|
||||
# Pasta com os CSVs da base CNPJ extraida (padrao prospeccao/cnpj)
|
||||
#CNPJ_BASE_DIR=prospeccao/cnpj
|
||||
|
||||
# Necessaria APENAS para a fonte Google Places (paga)
|
||||
#GOOGLE_MAPS_API_KEY=
|
||||
|
||||
# Senha pedida para confirmar a limpeza total da base (Configuracoes > Zona de risco)
|
||||
#GTI_RESET_PASSWORD=troque-esta-senha
|
||||
|
||||
# Endpoints das fontes gratuitas (padroes ja funcionam; mude so se precisar)
|
||||
#OVERPASS_API_URL=https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter
|
||||
#NOMINATIM_API_URL=https://nominatim.openstreetmap.org/search
|
||||
#WIKIDATA_API_URL=https://query.wikidata.org/sparql
|
||||
#BRASILAPI_URL=https://brasilapi.com.br/api
|
||||
|
||||
# Ritmo das consultas ao OpenStreetMap (ms)
|
||||
#OVERPASS_REQUEST_DELAY_MS=5000
|
||||
#OVERPASS_TIMEOUT_MS=45000
|
||||
#OVERPASS_TEXT_SEARCH=true
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
data/
|
||||
prospeccao/cnpj/rfb-raw/
|
||||
prospeccao/cnpj/*.csv
|
||||
prospeccao/cnpj/*.ndjson
|
||||
*.log
|
||||
GTI-Comercial.exe
|
||||
launcher/publish/
|
||||
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
if exist "GTI-Comercial.exe" (
|
||||
start "" "GTI-Comercial.exe"
|
||||
) else (
|
||||
echo GTI-Comercial.exe nao encontrado. Gerando agora...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "scripts\build-launcher.ps1"
|
||||
if exist "GTI-Comercial.exe" start "" "GTI-Comercial.exe"
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
# GTI Máquina Comercial
|
||||
|
||||
Aplicativo local do Grupo GTI para **capturar** e **tratar** leads B2B em qualquer
|
||||
estado/cidade do Brasil, com foco na base oficial de CNPJs da Receita Federal.
|
||||
|
||||
## Como usar (dia a dia)
|
||||
|
||||
1. Dê dois cliques em **`GTI-Comercial.exe`** (ou em `INICIAR-GTI.bat`).
|
||||
2. O navegador abre em `http://127.0.0.1:3900` com duas abas:
|
||||
- **📡 Capturar Leads** — escolha estado, cidades, nichos e fontes e rode a captura
|
||||
com progresso ao vivo. Se a base CNPJ do estado ainda não existe, o botão
|
||||
"Extrair base agora" prepara tudo (~40–60 min, uma única vez por estado).
|
||||
- **📊 Tratar Leads** — dashboard com totais e rankings por cidade/nicho, navegador
|
||||
de leads com filtros (qualidade, WhatsApp, exportado...) e o botão
|
||||
**Gerar relatório / exportação** que produz os arquivos para envio.
|
||||
|
||||
Tudo roda somente neste computador (porta local 127.0.0.1). Nenhum dado sai da máquina,
|
||||
exceto as consultas às fontes públicas durante a captura.
|
||||
|
||||
## Exportações disponíveis
|
||||
|
||||
| Tipo | Para quê |
|
||||
|---|---|
|
||||
| 📊 Planilha (CSV + Excel) | Lista completa com score — números como texto, sem notação científica |
|
||||
| 🗂️ Z-PRO Oportunidades (JSONL) | Um payload `createOpportunity` por linha, pronto para importar |
|
||||
| 📨 Z-PRO Disparo em massa (CSV) | `dataInput` para `BulkSendMessageWithVariable` + instruções |
|
||||
|
||||
Leads exportados ficam marcados — o filtro "ainda não exportados" evita reenvio.
|
||||
Os IDs padrão do Z-PRO (pipeline/etapa/responsável) ficam no ⚙️ Configurações.
|
||||
|
||||
> O `.xls` gerado é uma tabela HTML (o Excel avisa sobre o formato ao abrir — é normal,
|
||||
> basta confirmar). Use o `.csv` se preferir.
|
||||
|
||||
## Qualidade dos leads (score)
|
||||
|
||||
Score: +35 telefone válido, +25 CNPJ válido, +25 empresa válida, +5 cidade,
|
||||
+5 nicho, +5 CNAE. **Bom ≥ 85**, médio 60–84, ruim < 60. Empresa vazia, com lixo
|
||||
ou começando com número é sempre **ruim** (regra crítica). Duplicidade: CNPJ →
|
||||
WhatsApp → empresa+cidade.
|
||||
|
||||
## Fontes de captura
|
||||
|
||||
- **Base CNPJ (Receita Federal)** — principal: todas as empresas ativas da UF, ~97% com telefone.
|
||||
- **OpenStreetMap** (Overpass e Nominatim) — grátis, complementar.
|
||||
- **Wikidata** — grátis, volume baixo.
|
||||
- **BrasilAPI** — enriquecimento opcional da base CNPJ.
|
||||
- **Google Places** — paga; preencha `GOOGLE_MAPS_API_KEY` no `.env`.
|
||||
|
||||
## Novo estado (ex.: expandir para Sergipe)
|
||||
|
||||
1. Na aba Captura, escolha **SE — Sergipe**.
|
||||
2. Clique em **Extrair base agora** e aguarde (usa os zips já baixados em
|
||||
`prospeccao/cnpj/rfb-raw/`).
|
||||
3. Pronto: capture qualquer cidade de SE normalmente. Nada muda na interface.
|
||||
|
||||
Os zips brutos da Receita (7 GB, Brasil inteiro) já estão no projeto. Para atualizá-los
|
||||
no futuro (a Receita publica mensalmente): `bash scripts/baixar-base-rfb.sh`.
|
||||
|
||||
## Uso avançado (linha de comando)
|
||||
|
||||
```bash
|
||||
npm start # sobe o servidor
|
||||
npm test # roda os testes
|
||||
node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus"
|
||||
node scripts/extrair-base-cnpj.mjs --uf=SP # extrai base de outra UF
|
||||
```
|
||||
|
||||
## Estrutura do projeto
|
||||
|
||||
```
|
||||
GTI-Comercial.exe inicia tudo (servidor + navegador)
|
||||
scripts/ entradas de linha de comando
|
||||
src/ código do servidor, fontes de captura, exportações
|
||||
public/ interface (HTML/CSS/JS)
|
||||
data/gti.db banco local (SQLite) com todos os leads
|
||||
data/exports/ arquivos de exportação gerados
|
||||
prospeccao/cnpj/ base CNPJ extraída por UF + zips brutos da Receita
|
||||
docs/ playbook comercial, LGPD e referência da API Z-PRO
|
||||
```
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Windows com **Node.js 24+** (https://nodejs.org)
|
||||
- Para extrair novas UFs: `unzip` (vem com o Git para Windows) ou o `tar` nativo do Windows
|
||||
|
||||
## Log e problemas
|
||||
|
||||
- Log do servidor: `data/server.log`
|
||||
- Se a tela não abrir: rode `node scripts/servidor.mjs` num terminal e veja a mensagem.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Visão Geral
|
||||
|
||||
O Grupo GTI opera uma **máquina comercial local**: um aplicativo (GTI-Comercial.exe)
|
||||
que captura leads B2B das fontes públicas — principalmente a base oficial de CNPJs
|
||||
da Receita Federal — e ajuda a tratá-los e exportá-los para as campanhas de
|
||||
WhatsApp no Z-PRO.
|
||||
|
||||
## Soluções vendidas
|
||||
|
||||
- **ERP e sistemas de gestão** para comércio, serviços, restaurantes, mercados e distribuidoras.
|
||||
- **Gestão de TI** para empresas com suporte, infraestrutura, servidores, redes, backup e segurança.
|
||||
- **Autoatendimento para WhatsApp** com triagem, atendimento automático, vendas e agendamento.
|
||||
|
||||
Cada nicho do catálogo (57 nichos) já vem associado ao interesse provável e às dores
|
||||
típicas, que alimentam a personalização das mensagens.
|
||||
|
||||
## Arquitetura (simples de propósito)
|
||||
|
||||
```
|
||||
GTI-Comercial.exe → servidor Node local (porta 3900) → navegador
|
||||
│
|
||||
├── Captura: base CNPJ por UF + OpenStreetMap + Wikidata (+ Google opcional)
|
||||
├── Banco SQLite local (data/gti.db): leads, score, dedup, histórico
|
||||
└── Tratamento: dashboard, filtros, relatórios e exportações Z-PRO
|
||||
```
|
||||
|
||||
Sem n8n, sem Docker, sem serviços externos obrigatórios: tudo roda no computador
|
||||
da GTI. O envio das mensagens é feito por fora (Z-PRO), a partir dos arquivos
|
||||
gerados pelo módulo de tratamento.
|
||||
|
||||
## Premissas
|
||||
|
||||
- Foco inicial na Bahia (Santo Antônio de Jesus e região + capitais), mas o app
|
||||
funciona para **qualquer UF/cidade do Brasil** sem mudança de interface.
|
||||
- A equipe não tem braço para operação manual intensa: o app faz a triagem
|
||||
(score de qualidade dos dados + score comercial por nicho, combinados numa
|
||||
prioridade P1-P4) e evita retrabalho (leads exportados ficam marcados).
|
||||
- O WhatsApp é o canal central de conversão — leads com telefone válido têm prioridade.
|
||||
|
||||
## Resultado esperado
|
||||
|
||||
- Base grande e organizada de leads com WhatsApp (97%+ de cobertura via base CNPJ).
|
||||
- Relatórios por nicho/cidade prontos para campanhas segmentadas.
|
||||
- Histórico de tudo que já foi exportado/enviado, sem duplicar contato.
|
||||
- Dashboard de gestão comercial na tela de Tratamento: percentual da base
|
||||
pronta para abordagem imediata (Potencial Comercial), cobertura da base
|
||||
já trabalhada, distribuição visual por prioridade, produtos recomendados
|
||||
por cidade e meta diária de exportações da equipe.
|
||||
- Interface explica o "porquê" de cada score (composição detalhada no modal
|
||||
do lead), destaca a cidade+produto de maior oportunidade, mostra a
|
||||
evolução diária de exportações e oferece atalhos de um clique para gerar
|
||||
listas de prospecção (só P1, só um produto, só não exportados etc.).
|
||||
- Acesso protegido por login (usuário/senha): o primeiro acesso vem com
|
||||
`admin`/`admin`, que pode cadastrar novos usuários (comercial ou admin)
|
||||
pela tela de Usuários.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Playbook Comercial
|
||||
|
||||
## Oferta De Entrada
|
||||
|
||||
Nome: Diagnostico GTI de Gestao, TI e Atendimento.
|
||||
|
||||
Promessa: em 15 minutos, identificar gargalos em sistema, atendimento, infraestrutura ou processos manuais da empresa.
|
||||
|
||||
Essa oferta reduz resistencia porque nao comeca vendendo ERP, contrato de TI ou automacao diretamente.
|
||||
|
||||
## ICP Principal
|
||||
|
||||
Priorizar empresas de Santo Antonio de Jesus e regiao com qualquer uma destas caracteristicas:
|
||||
|
||||
- Controle por planilha ou papel.
|
||||
- Sistema atual ruim ou insatisfatorio.
|
||||
- Alto volume de WhatsApp.
|
||||
- Muitos computadores ou usuarios.
|
||||
- Problemas frequentes com suporte, rede, backup ou servidor.
|
||||
- Dificuldade com estoque, financeiro, fiscal ou vendas.
|
||||
|
||||
## Segmentos Prioritarios
|
||||
|
||||
- Mercados e supermercados.
|
||||
- Farmacias e drogarias.
|
||||
- Restaurantes e delivery.
|
||||
- Distribuidoras.
|
||||
- Autopecas.
|
||||
- Material de construcao.
|
||||
- Clinicas.
|
||||
- Oficinas.
|
||||
- Escolas.
|
||||
- Lojas de varejo.
|
||||
- Empresas de servicos.
|
||||
|
||||
## Metodo De Atendimento
|
||||
|
||||
Usar SPIN de forma simples:
|
||||
|
||||
- Situacao: entender como a empresa funciona hoje.
|
||||
- Problema: descobrir gargalos.
|
||||
- Implicacao: mostrar o impacto de continuar assim.
|
||||
- Necessidade: conectar a dor a uma solucao GTI.
|
||||
|
||||
## Perguntas Do Diagnostico
|
||||
|
||||
- Como voces controlam vendas, estoque e financeiro hoje?
|
||||
- Qual processo mais toma tempo ou gera retrabalho?
|
||||
- Hoje voces usam sistema, planilha ou controle manual?
|
||||
- Quando da problema de TI, quem resolve?
|
||||
- Existe rotina de backup?
|
||||
- Quantas mensagens chegam por dia no WhatsApp?
|
||||
- Qual o impacto se esse problema continuar por mais 6 meses?
|
||||
- Se a GTI resolver isso, qual resultado seria mais importante?
|
||||
|
||||
## Roteiro Para Lead Quente
|
||||
|
||||
```text
|
||||
Ola, [nome]. Vi aqui seu contato sobre [servico].
|
||||
Pelo que voce informou, parece que existe uma oportunidade clara de melhorar [dor].
|
||||
Quero fazer um diagnostico rapido de 15 minutos para entender sua operacao e te dizer o melhor caminho.
|
||||
Pode ser hoje pela tarde ou amanha pela manha?
|
||||
```
|
||||
|
||||
## Follow-up Manual Em Proposta
|
||||
|
||||
Dia 1:
|
||||
|
||||
```text
|
||||
Oi, [nome]. Conseguiu avaliar a proposta da GTI? Posso te ajudar com alguma duvida tecnica ou comercial?
|
||||
```
|
||||
|
||||
Dia 3:
|
||||
|
||||
```text
|
||||
[nome], queria confirmar se esse projeto ainda faz sentido para agora ou se prefere que eu reprograme o contato para outro momento.
|
||||
```
|
||||
|
||||
Dia 7:
|
||||
|
||||
```text
|
||||
Vou deixar seu atendimento em aberto para retomarmos quando fizer sentido. Se quiser avancar, me responda por aqui.
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# LGPD E Regras De WhatsApp
|
||||
|
||||
Este projeto deve ser operado com cuidado para evitar bloqueio de WhatsApp e problemas de privacidade.
|
||||
|
||||
## Regras Basicas
|
||||
|
||||
- Guardar origem do lead.
|
||||
- Registrar consentimento quando existir.
|
||||
- Evitar disparo massivo sem opt-in.
|
||||
- Permitir descadastro ou parada de contato.
|
||||
- Nao comprar listas frias para disparo automatico.
|
||||
- Usar abordagem consultiva, nao agressiva.
|
||||
- Manter historico de data, origem e mensagem enviada.
|
||||
|
||||
## Mensagem De Descadastro
|
||||
|
||||
Toda sequencia pode aceitar palavras como:
|
||||
|
||||
```text
|
||||
PARAR
|
||||
CANCELAR
|
||||
SAIR
|
||||
NAO QUERO
|
||||
```
|
||||
|
||||
Quando uma dessas respostas chegar, o lead deve ser marcado como `Opt-out` no CRM e sair das automacoes.
|
||||
|
||||
## Prospecao Ativa Segura
|
||||
|
||||
Para listas frias locais, a abordagem deve ser manual ou semi-automatizada, com baixo volume e personalizacao.
|
||||
|
||||
Exemplo:
|
||||
|
||||
```text
|
||||
Ola, tudo bem? Aqui e do Grupo GTI, de Santo Antonio de Jesus.
|
||||
Vi que sua empresa atua em [segmento] e queria entender se hoje voces ja usam algum sistema para vendas, estoque ou atendimento.
|
||||
Se nao fizer sentido, pode me avisar que nao envio novas mensagens.
|
||||
```
|
||||
|
||||
## Dados Minimos No CRM
|
||||
|
||||
- Nome.
|
||||
- Empresa.
|
||||
- WhatsApp.
|
||||
- Origem.
|
||||
- Data do primeiro contato.
|
||||
- Consentimento.
|
||||
- Status de opt-out.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Captura de Leads
|
||||
|
||||
A captura procura empresas por **UF + cidades + nichos** nas fontes selecionadas,
|
||||
pontua cada lead (score/classificação) e grava tudo no banco local com
|
||||
deduplicação automática — um lead capturado uma vez nunca é duplicado, em nenhuma
|
||||
execução futura.
|
||||
|
||||
O jeito normal de usar é pela **aba "Capturar Leads" do aplicativo**
|
||||
(GTI-Comercial.exe). Este documento cobre os detalhes e o uso por linha de comando.
|
||||
|
||||
## Fontes
|
||||
|
||||
| Fonte | Custo | Observações |
|
||||
|---|---|---|
|
||||
| `cnpj-base` | grátis | **Principal.** Base oficial da Receita Federal extraída por UF (~97% com telefone). |
|
||||
| `brasilapi` | grátis | Mesma base CNPJ, enriquecida online via BrasilAPI (mais lenta). |
|
||||
| `overpass` | grátis | OpenStreetMap por área da cidade (bounding box automática via geocode). |
|
||||
| `nominatim` | grátis | Busca textual no OpenStreetMap. |
|
||||
| `wikidata` | grátis | Empresas registradas no Wikidata (volume baixo). |
|
||||
| `google-places` | paga | Exige `GOOGLE_MAPS_API_KEY` no `.env`. |
|
||||
| `mock` | — | 3 leads fictícios para testar o fluxo. |
|
||||
|
||||
## Base CNPJ por estado
|
||||
|
||||
Cada UF precisa de uma extração única (~40–60 min) que gera
|
||||
`prospeccao/cnpj/estabelecimentos-<UF>.csv` a partir dos zips brutos da Receita
|
||||
(`prospeccao/cnpj/rfb-raw/`, Brasil inteiro, já baixados). A interface oferece o
|
||||
botão **"Extrair base agora"** quando a UF escolhida ainda não tem base; na linha
|
||||
de comando:
|
||||
|
||||
```bash
|
||||
node scripts/extrair-base-cnpj.mjs --uf=SP
|
||||
```
|
||||
|
||||
A extração cobre **todos os municípios da UF** — depois dela, qualquer cidade do
|
||||
estado pode ser capturada na hora, sem re-extração. Um checkpoint em disco permite
|
||||
retomar de onde parou se o processo for interrompido.
|
||||
|
||||
## Deduplicação e pontuação
|
||||
|
||||
- Chave de duplicidade: **CNPJ válido → WhatsApp válido → empresa+cidade**.
|
||||
- **Score de Qualidade** (0-100, "os dados são confiáveis?"): +30 WhatsApp válido,
|
||||
+20 CNPJ válido, +20 empresa válida, +5 cidade, +10 nicho, +10 CNAE, +5 website.
|
||||
- **Score Comercial** (0-100, "qual a chance de comprar?"): aderência do nicho
|
||||
(alta 40 / média 30 / baixa 20, configurada por nicho em `prospeccao/config.json`)
|
||||
+ website 15 + e-mail 10 + WhatsApp válido 15 + CNPJ válido 20.
|
||||
- **Score Geral** = 60% comercial + 40% qualidade — é o valor usado para ordenar
|
||||
a tela de Tratamento.
|
||||
- **Classificação**: 90-100 Excelente, 75-89 Bom, 55-74 Médio, 35-54 Baixo,
|
||||
0-34 Muito Baixo. A **Prioridade comercial** (P1-P4, mostrada na tela) deriva
|
||||
direto dela: Excelente/Bom ⇒ P1 (atacar já), Médio ⇒ P2 (na semana), Baixo ⇒ P3,
|
||||
Muito Baixo ⇒ P4 (só em campanhas específicas).
|
||||
- Cada nicho tem produtos GTI recomendados automaticamente (ERP, G-Delivery,
|
||||
G-Chat, Sistema de Ponto) que aparecem no card do lead. Se o catálogo de
|
||||
aderência/produtos do `config.json` mudar, rode `npm run recalc-scores` para
|
||||
recalcular todos os leads já capturados.
|
||||
|
||||
## Linha de comando (uso avançado)
|
||||
|
||||
```bash
|
||||
node scripts/capturar-leads.mjs --sources=mock
|
||||
node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus,Itabuna"
|
||||
node scripts/capturar-leads.mjs --sources=cnpj-base,overpass --uf=BA --cities=all --segments=all
|
||||
node scripts/capturar-leads.mjs --help
|
||||
```
|
||||
|
||||
Parâmetros úteis: `--segments=a,b,c` (nichos pelo nome), `--segment-limit=N`,
|
||||
`--city-limit=N`, `--max=N` (resultados por consulta nas fontes de API),
|
||||
`--query-limit=N` (teto total de consultas).
|
||||
|
||||
## Ritmo e boas práticas (fontes OpenStreetMap)
|
||||
|
||||
Os servidores públicos do Overpass/Nominatim têm limites de uso. O app já aplica
|
||||
espera entre consultas (`OVERPASS_REQUEST_DELAY_MS`), failover entre endpoints e
|
||||
cooldown após falhas — falha de uma cidade não derruba a captura, só pula.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>GTI-Comercial</AssemblyName>
|
||||
<RootNamespace>GtiComercialLauncher</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
|
||||
// Launcher do GTI Maquina Comercial:
|
||||
// 1. localiza a pasta do projeto (procura package.json + scripts/servidor.mjs);
|
||||
// 2. se o servidor ja responde em http://127.0.0.1:3900, so abre o navegador;
|
||||
// 3. senao, inicia "node scripts/servidor.mjs" escondido (log em data/server.log),
|
||||
// espera o /api/health responder (ate 30s) e abre o navegador.
|
||||
//
|
||||
// Compilavel tanto pelo dotnet SDK (net8.0) quanto pelo csc.exe do
|
||||
// .NET Framework 4 (C# 5): sem interpolacao de string, sem APIs novas.
|
||||
|
||||
namespace GtiComercialLauncher
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
private static string BaseUrl()
|
||||
{
|
||||
string port = Environment.GetEnvironmentVariable("GTI_PORT");
|
||||
if (string.IsNullOrEmpty(port)) port = "3900";
|
||||
return "http://127.0.0.1:" + port;
|
||||
}
|
||||
|
||||
private static int Main()
|
||||
{
|
||||
Console.Title = "GTI Maquina Comercial";
|
||||
Console.WriteLine("=== GTI Maquina Comercial ===");
|
||||
|
||||
string root = LocateProjectDir();
|
||||
if (root == null)
|
||||
{
|
||||
return Fail("Pasta do projeto nao encontrada (procurei package.json + scripts/servidor.mjs).\n" +
|
||||
"Deixe o GTI-Comercial.exe dentro da pasta do projeto.");
|
||||
}
|
||||
|
||||
string url = BaseUrl();
|
||||
|
||||
if (IsHealthy(url))
|
||||
{
|
||||
Console.WriteLine("Servidor ja esta rodando. Abrindo o navegador...");
|
||||
OpenBrowser(url);
|
||||
return 0;
|
||||
}
|
||||
|
||||
string node = FindNode();
|
||||
if (node == null)
|
||||
{
|
||||
return Fail("Node.js nao encontrado no PATH. Instale em https://nodejs.org (versao 24 ou superior).");
|
||||
}
|
||||
|
||||
Console.WriteLine("Iniciando o servidor (aguarde alguns segundos)...");
|
||||
if (!StartServer(node, root))
|
||||
{
|
||||
return Fail("Nao foi possivel iniciar o servidor. Veja o log em data\\server.log");
|
||||
}
|
||||
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
if (IsHealthy(url))
|
||||
{
|
||||
Console.WriteLine("Pronto! Abrindo " + url + " no navegador...");
|
||||
OpenBrowser(url);
|
||||
Thread.Sleep(1500);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return Fail("O servidor nao respondeu em 30 segundos.\n" +
|
||||
"Veja o log em data\\server.log ou rode manualmente: node scripts\\servidor.mjs");
|
||||
}
|
||||
|
||||
private static string LocateProjectDir()
|
||||
{
|
||||
string[] candidates = new string[]
|
||||
{
|
||||
AppDomain.CurrentDomain.BaseDirectory,
|
||||
Directory.GetCurrentDirectory()
|
||||
};
|
||||
|
||||
foreach (string start in candidates)
|
||||
{
|
||||
DirectoryInfo dir = new DirectoryInfo(start);
|
||||
for (int depth = 0; dir != null && depth < 5; depth++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "package.json")) &&
|
||||
File.Exists(Path.Combine(Path.Combine(dir.FullName, "scripts"), "servidor.mjs")))
|
||||
{
|
||||
return dir.FullName;
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FindNode()
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessStartInfo psi = new ProcessStartInfo("where.exe", "node");
|
||||
psi.UseShellExecute = false;
|
||||
psi.RedirectStandardOutput = true;
|
||||
psi.CreateNoWindow = true;
|
||||
using (Process p = Process.Start(psi))
|
||||
{
|
||||
string output = p.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit(5000);
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
string first = output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)[0].Trim();
|
||||
if (File.Exists(first)) return first;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// continua para o retorno nulo
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool StartServer(string node, string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
string dataDir = Path.Combine(root, "data");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
string logPath = Path.Combine(dataDir, "server.log");
|
||||
|
||||
File.AppendAllText(logPath,
|
||||
"---- " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " launcher iniciou o servidor ----\r\n");
|
||||
|
||||
// O redirecionamento para o log e feito pelo proprio cmd.exe: assim o
|
||||
// launcher pode encerrar sem deixar o servidor escrevendo num pipe
|
||||
// sem leitor (o que derrubaria o node com EPIPE mais tarde).
|
||||
string comando = "/c \"\"" + node + "\" scripts\\servidor.mjs >> \"" + logPath + "\" 2>&1\"";
|
||||
ProcessStartInfo psi = new ProcessStartInfo("cmd.exe", comando);
|
||||
psi.WorkingDirectory = root;
|
||||
psi.UseShellExecute = false;
|
||||
psi.CreateNoWindow = true;
|
||||
|
||||
Process.Start(psi);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Falha ao iniciar: " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHealthy(string baseUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(baseUrl + "/api/health");
|
||||
request.Timeout = 1500;
|
||||
request.Method = "GET";
|
||||
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
|
||||
{
|
||||
return (int)response.StatusCode == 200;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenBrowser(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessStartInfo psi = new ProcessStartInfo(url);
|
||||
psi.UseShellExecute = true;
|
||||
Process.Start(psi);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Console.WriteLine("Abra manualmente no navegador: " + url);
|
||||
}
|
||||
}
|
||||
|
||||
private static int Fail(string message)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("ERRO: " + message);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Pressione qualquer tecla para fechar...");
|
||||
try { Console.ReadKey(true); } catch (Exception) { Thread.Sleep(8000); }
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "gti-maquina-comercial",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "GTI Maquina Comercial: aplicativo local de captura e tratamento de leads B2B (base CNPJ da Receita Federal + fontes abertas).",
|
||||
"scripts": {
|
||||
"start": "node scripts/servidor.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"capture": "node scripts/capturar-leads.mjs",
|
||||
"extract": "node scripts/extrair-base-cnpj.mjs",
|
||||
"recalc-scores": "node scripts/recalcular-scores.mjs",
|
||||
"build:launcher": "powershell -File scripts/build-launcher.ps1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
Coloque aqui arquivos CSV extraidos da base publica de CNPJ da Receita Federal ou uma planilha ja tratada.
|
||||
|
||||
O captador aceita CSV com cabecalho e tenta reconhecer colunas comuns como:
|
||||
|
||||
- `cnpj`
|
||||
- `razao_social` ou `razao social`
|
||||
- `nome_fantasia` ou `nome fantasia`
|
||||
- `telefone`, `telefone 1`, `ddd`, `ddd 1`
|
||||
- `municipio` ou `cidade`
|
||||
- `uf`
|
||||
- `cnae`, `cnae_fiscal` ou `cnae principal`
|
||||
- `logradouro`, `numero`, `bairro`
|
||||
|
||||
Comando para usar a base local:
|
||||
|
||||
```bash
|
||||
npm run prospect -- --source=cnpj-base --send
|
||||
```
|
||||
|
||||
Com enriquecimento pela BrasilAPI, use:
|
||||
|
||||
```bash
|
||||
npm run prospect -- --source=brasilapi --send
|
||||
```
|
||||
|
||||
O modo `brasilapi` usa os CNPJs encontrados nos CSVs locais para consultar dados atualizados por CNPJ.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
/* GTI Máquina Comercial — tokens de design (paleta validada, light/dark) */
|
||||
:root {
|
||||
--page: #f9f9f7;
|
||||
--surface: #fcfcfb;
|
||||
--ink: #0b0b0b;
|
||||
--ink-2: #52514e;
|
||||
--muted: #898781;
|
||||
--grid: #e1e0d9;
|
||||
--border: rgba(11, 11, 11, 0.10);
|
||||
--accent: #2a78d6; /* series-1 azul */
|
||||
--accent-ink: #ffffff;
|
||||
--bar: #5598e7; /* sequential 350 — barras de ranking */
|
||||
--good: #0ca30c;
|
||||
--good-text: #006300;
|
||||
--warning: #fab219;
|
||||
--critical: #d03b3b;
|
||||
--shadow: 0 1px 3px rgba(11, 11, 11, 0.08);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--page: #0d0d0d;
|
||||
--surface: #1a1a19;
|
||||
--ink: #ffffff;
|
||||
--ink-2: #c3c2b7;
|
||||
--muted: #898781;
|
||||
--grid: #2c2c2a;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--accent: #3987e5;
|
||||
--bar: #3987e5;
|
||||
--good-text: #0ca30c;
|
||||
--shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--page);
|
||||
color: var(--ink);
|
||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
/* ---------- topo ---------- */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--grid);
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
}
|
||||
.brand { display: flex; align-items: baseline; gap: 8px; }
|
||||
.brand-mark {
|
||||
background: var(--accent); color: var(--accent-ink);
|
||||
font-weight: 800; padding: 3px 9px; border-radius: 6px; letter-spacing: 0.5px;
|
||||
}
|
||||
.brand-name { font-weight: 600; color: var(--ink-2); }
|
||||
.tabs { display: flex; gap: 4px; flex: 1; }
|
||||
.tab {
|
||||
border: none; background: none; color: var(--ink-2);
|
||||
font: inherit; font-weight: 600; padding: 8px 16px;
|
||||
border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
.tab:hover { background: var(--grid); }
|
||||
.tab.active { background: var(--accent); color: var(--accent-ink); }
|
||||
|
||||
main { max-width: 1280px; margin: 0 auto; padding: 16px 20px 60px; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* ---------- cartões e grades ---------- */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.captura-wrap { max-width: 720px; margin: 0 auto; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
@media (max-width: 1100px) {
|
||||
.grid-3 { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.grid-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---------- formulários ---------- */
|
||||
.field { margin-bottom: 18px; }
|
||||
.field > label:first-child, .field > summary { font-weight: 600; display: block; margin-bottom: 6px; }
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.row.space-between { justify-content: space-between; }
|
||||
.row.wrap { flex-wrap: wrap; }
|
||||
.radio { display: flex; align-items: center; gap: 6px; font-weight: 400; cursor: pointer; margin: 4px 0; }
|
||||
select, input, textarea {
|
||||
font: inherit; color: var(--ink);
|
||||
background: var(--page);
|
||||
border: 1px solid var(--grid);
|
||||
border-radius: 8px; padding: 7px 10px;
|
||||
}
|
||||
select:focus, input:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
input[type="number"] { width: 90px; }
|
||||
label > input[type="number"] { display: block; margin-top: 4px; }
|
||||
details summary { cursor: pointer; }
|
||||
|
||||
.btn, .btn-ghost, .btn-danger {
|
||||
font: inherit; font-weight: 600; cursor: pointer;
|
||||
border-radius: 8px; padding: 8px 14px;
|
||||
border: 1px solid var(--grid);
|
||||
background: var(--surface); color: var(--ink);
|
||||
}
|
||||
.btn:hover, .btn-ghost:hover { border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
||||
.btn-primary:hover { filter: brightness(1.1); }
|
||||
.btn-lg { width: 100%; padding: 12px; font-size: 15px; }
|
||||
.btn-ghost { border-color: transparent; color: var(--ink-2); }
|
||||
.btn-danger { color: var(--critical); border-color: var(--critical); background: none; }
|
||||
.btn-danger:hover { background: var(--critical); color: #fff; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.zona-risco { border: 1px solid var(--critical); border-radius: 10px; padding: 10px 12px; margin-top: 14px; }
|
||||
.zona-risco label { color: var(--critical); font-weight: 700; }
|
||||
|
||||
/* ---------- login ---------- */
|
||||
.login-body { display: flex; min-height: 100vh; }
|
||||
.login-wrap { margin: auto; width: 100%; max-width: 360px; padding: 20px; }
|
||||
.login-card { display: flex; flex-direction: column; gap: 14px; }
|
||||
.login-brand { justify-content: center; margin-bottom: 4px; }
|
||||
.login-erro { color: var(--critical); min-height: 1em; margin: 0; }
|
||||
|
||||
/* ---------- usuarios ---------- */
|
||||
#lista-usuarios { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
#lista-usuarios > div { padding: 6px 0; border-bottom: 1px solid var(--grid); }
|
||||
#form-novo-usuario select { border: 1px solid var(--grid); border-radius: 8px; padding: 7px 10px; background: var(--surface); color: var(--ink); }
|
||||
|
||||
.chip {
|
||||
font-size: 12px; font-weight: 600;
|
||||
padding: 3px 10px; border-radius: 999px;
|
||||
background: var(--grid); color: var(--ink-2);
|
||||
}
|
||||
.chip.ok { background: color-mix(in srgb, var(--good) 15%, transparent); color: var(--good-text); }
|
||||
.chip.warn { background: color-mix(in srgb, var(--warning) 20%, transparent); color: var(--ink); }
|
||||
|
||||
.notice {
|
||||
margin-top: 10px; padding: 12px;
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--warning) 8%, transparent);
|
||||
}
|
||||
.notice p { margin: 0 0 10px; }
|
||||
|
||||
/* pickers de cidade/nicho */
|
||||
.picker { margin-top: 8px; }
|
||||
.picker input[type="search"] { width: 100%; margin-bottom: 6px; }
|
||||
.sugestoes { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; }
|
||||
.sugestoes button {
|
||||
text-align: left; font: inherit; border: none; background: none;
|
||||
color: var(--ink); padding: 6px 8px; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.sugestoes button:hover { background: var(--grid); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.chips .chip { cursor: pointer; }
|
||||
.chips .chip::after { content: " ✕"; color: var(--muted); }
|
||||
.nichos-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 2px 12px; max-height: 300px; overflow-y: auto; margin-top: 8px;
|
||||
}
|
||||
.nichos-grid label { display: flex; gap: 6px; align-items: center; padding: 3px 4px; border-radius: 6px; cursor: pointer; }
|
||||
.nichos-grid label:hover { background: var(--grid); }
|
||||
|
||||
.cnae-avulso { margin-top: 10px; border-top: 1px dashed var(--grid); padding-top: 10px; }
|
||||
.cnae-avulso summary { cursor: pointer; font-size: 13px; color: var(--ink-2); font-weight: 600; }
|
||||
.cnae-avulso-hint { margin: 6px 0 8px; font-size: 12.5px; }
|
||||
.cnae-avulso input[type="search"] { width: 100%; margin-bottom: 6px; }
|
||||
.cnae-avulso .sugestoes button { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.cnae-avulso .sugestoes .cod { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||
|
||||
.fontes { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fonte {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
border: 1px solid var(--grid); border-radius: 8px; padding: 8px 10px; cursor: pointer;
|
||||
}
|
||||
.fonte input { margin-top: 3px; }
|
||||
.fonte .desc { font-size: 12.5px; color: var(--ink-2); }
|
||||
.fonte.indisponivel { opacity: 0.55; }
|
||||
|
||||
/* ---------- progresso ---------- */
|
||||
.progress-bar { height: 8px; border-radius: 999px; background: var(--grid); overflow: hidden; margin: 10px 0 8px; }
|
||||
.progress-fill { height: 100%; width: 3%; background: var(--accent); border-radius: 999px; transition: width 0.4s; }
|
||||
.progress-msg { color: var(--ink-2); margin: 4px 0 10px; min-height: 1.4em; }
|
||||
.stat-row { display: flex; gap: 18px; margin-bottom: 10px; }
|
||||
.stat-row .mini { display: flex; flex-direction: column; }
|
||||
.stat-row .mini b { font-size: 20px; }
|
||||
.stat-row .mini span { font-size: 12px; color: var(--muted); }
|
||||
.log {
|
||||
font: 12px/1.5 ui-monospace, Consolas, monospace;
|
||||
background: var(--page); border: 1px solid var(--grid); border-radius: 8px;
|
||||
padding: 8px 10px; max-height: 160px; overflow-y: auto; white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lista { display: flex; flex-direction: column; gap: 8px; }
|
||||
.lista .item {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
||||
border-bottom: 1px solid var(--grid); padding: 8px 2px; flex-wrap: wrap;
|
||||
}
|
||||
.lista .item:last-child { border-bottom: none; }
|
||||
.lista .item .sub { font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* ---------- tratamento: tiles e rankings ---------- */
|
||||
.filtros-card { padding: 12px 16px; }
|
||||
.filtros { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.filtros select, .filtros input { min-width: 130px; }
|
||||
#f-busca { flex: 1; min-width: 200px; }
|
||||
|
||||
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.tile {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 14px 16px; box-shadow: var(--shadow);
|
||||
}
|
||||
.tile .valor { font-size: 26px; font-weight: 700; }
|
||||
.tile .rotulo { font-size: 12.5px; color: var(--ink-2); margin-top: 2px; }
|
||||
.tile .detalhe { font-size: 12px; color: var(--muted); }
|
||||
.tile.t-good .valor { color: var(--good-text); }
|
||||
.tile.t-warn .valor { color: var(--ink); }
|
||||
.tile.tile-wide { grid-column: span 2; }
|
||||
@media (max-width: 640px) { .tile.tile-wide { grid-column: span 1; } }
|
||||
.meta-input { width: 70px; text-align: right; padding: 3px 6px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--ink); }
|
||||
|
||||
/* ---------- funil comercial ---------- */
|
||||
.funil { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||
.funil-etapa { display: grid; grid-template-columns: 190px 1fr 70px; align-items: center; gap: 8px; padding: 1px 4px; }
|
||||
.funil-etapa.pendente { opacity: 0.4; }
|
||||
.funil-label { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.funil-barra { height: 14px; border-radius: 4px; overflow: hidden; background: none; }
|
||||
.funil-fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; min-width: 2px; }
|
||||
.funil-valor { font-size: 12.5px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* barras de ranking: magnitude = um matiz (azul), rótulo direto, sem legenda */
|
||||
.ranking { display: flex; flex-direction: column; gap: 7px; }
|
||||
.rank-row { display: grid; grid-template-columns: 150px 1fr 70px; gap: 8px; align-items: center; cursor: pointer; border-radius: 6px; padding: 1px 4px; }
|
||||
.rank-row:hover { background: var(--grid); }
|
||||
.rank-row.ativo { background: color-mix(in srgb, var(--accent) 16%, transparent); font-weight: 600; }
|
||||
.rank-row.nao-clicavel { cursor: default; }
|
||||
.rank-row.nao-clicavel:hover { background: none; }
|
||||
.oportunidade-clicavel { cursor: pointer; border-radius: 8px; padding: 6px 8px; }
|
||||
.oportunidade-clicavel:hover { background: var(--grid); }
|
||||
.oportunidade-clicavel.ativo { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||
.rank-label { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rank-track { height: 14px; border-radius: 4px; overflow: hidden; background: none; }
|
||||
.rank-fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; min-width: 2px; }
|
||||
.rank-value { font-size: 12.5px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---------- tabela ---------- */
|
||||
.table-wrap { overflow-x: auto; margin: 0 -4px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--grid); white-space: nowrap; }
|
||||
th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); }
|
||||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
.badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
||||
/* Escala sequencial ordinal (excelente -> muito baixo), verde -> ambar -> vermelho,
|
||||
construida so com os tokens ja validados (--good/--warning/--critical). */
|
||||
.badge.excelente { background: color-mix(in srgb, var(--good) 20%, transparent); color: var(--good-text); }
|
||||
.badge.bom { background: color-mix(in srgb, var(--good) 12%, var(--warning) 8%); color: var(--good-text); }
|
||||
.badge.medio { background: color-mix(in srgb, var(--warning) 22%, transparent); color: var(--ink); }
|
||||
.badge.baixo { background: color-mix(in srgb, var(--warning) 18%, var(--critical) 12%); color: var(--critical); }
|
||||
.badge.muito_baixo { background: color-mix(in srgb, var(--critical) 16%, transparent); color: var(--critical); }
|
||||
|
||||
.badge.prioridade-1 { background: color-mix(in srgb, var(--good) 20%, transparent); color: var(--good-text); }
|
||||
.badge.prioridade-2 { background: color-mix(in srgb, var(--warning) 22%, transparent); color: var(--ink); }
|
||||
.badge.prioridade-3 { background: var(--grid); color: var(--ink-2); }
|
||||
.badge.prioridade-4 { background: var(--grid); color: var(--muted); }
|
||||
|
||||
.produto-cell { font-size: 12.5px; }
|
||||
.estrelas { color: var(--warning); letter-spacing: 1px; white-space: nowrap; }
|
||||
.pontos-fortes { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 13px; }
|
||||
.resumo-comercial { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-top: 12px; }
|
||||
.resumo-comercial h3 { margin: 0 0 8px; font-size: 14px; }
|
||||
.empresa-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ---------- explicacao de score (v2.1) ---------- */
|
||||
.score-breakdown-wrap { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-top: 12px; }
|
||||
.score-breakdown { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font-size: 13px; }
|
||||
.score-breakdown li.ok { color: var(--ink); }
|
||||
.score-breakdown li.nok { color: var(--muted); }
|
||||
|
||||
/* ---------- atalhos de exportacao (v2.1) ---------- */
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 12px; }
|
||||
.chip {
|
||||
border: 1px solid var(--border); background: var(--surface); color: var(--ink);
|
||||
border-radius: 999px; padding: 4px 12px; font-size: 12.5px; cursor: pointer;
|
||||
}
|
||||
.chip:hover { background: var(--grid); border-color: var(--accent); }
|
||||
|
||||
/* ---------- modais e toast ---------- */
|
||||
dialog {
|
||||
border: 1px solid var(--border); border-radius: 14px;
|
||||
background: var(--surface); color: var(--ink);
|
||||
max-width: 560px; width: calc(100vw - 40px);
|
||||
padding: 0;
|
||||
}
|
||||
dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
||||
.modal-body { padding: 20px 22px; }
|
||||
.modal-body h2 { margin-top: 0; }
|
||||
#modal-historico { max-width: 620px; }
|
||||
#modal-historico .lista { max-height: 60vh; overflow-y: auto; margin-top: 10px; }
|
||||
textarea { width: 100%; resize: vertical; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--ink); color: var(--page);
|
||||
padding: 10px 18px; border-radius: 10px; font-weight: 600;
|
||||
z-index: 100; max-width: 80vw;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.muted { color: var(--muted); font-weight: 400; }
|
||||
|
||||
/* detalhe do lead */
|
||||
.detalhe-grid { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; font-size: 13.5px; }
|
||||
.detalhe-grid dt { color: var(--muted); }
|
||||
.detalhe-grid dd { margin: 0; word-break: break-word; }
|
||||
@@ -0,0 +1,360 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GTI Máquina Comercial</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎯</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">GTI</span>
|
||||
<span class="brand-name">Máquina Comercial</span>
|
||||
</div>
|
||||
<nav class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="captura" role="tab" aria-selected="true">📡 Capturar Leads</button>
|
||||
<button class="tab" data-tab="tratamento" role="tab" aria-selected="false">📊 Tratar Leads</button>
|
||||
</nav>
|
||||
<span class="muted" id="usuario-logado"></span>
|
||||
<button class="btn-ghost hidden" id="btn-usuarios" title="Usuários">👤 Usuários</button>
|
||||
<button class="btn-ghost" id="btn-settings" title="Configurações">⚙️</button>
|
||||
<button class="btn-ghost" id="btn-sair" title="Sair">🚪 Sair</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ==================== CAPTURA ==================== -->
|
||||
<section id="tab-captura" class="tab-panel active">
|
||||
<div class="captura-wrap">
|
||||
<div class="card" id="captura-form">
|
||||
<div class="row space-between">
|
||||
<h2>Nova captura</h2>
|
||||
<button class="btn-ghost" id="btn-ver-historico" type="button">🕓 Histórico de capturas</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="sel-uf">1. Estado (UF)</label>
|
||||
<div class="row">
|
||||
<select id="sel-uf"></select>
|
||||
<span id="uf-status" class="chip"></span>
|
||||
</div>
|
||||
<div id="extracao-aviso" class="notice hidden">
|
||||
<p>A base CNPJ de <strong id="extracao-uf"></strong> ainda não foi extraída neste computador.
|
||||
A extração lê os arquivos da Receita Federal e demora <strong>~40 a 60 minutos</strong> (uma única vez por estado).</p>
|
||||
<button class="btn" id="btn-extrair">⛏️ Extrair base agora</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>2. Cidades</label>
|
||||
<div class="row">
|
||||
<label class="radio"><input type="radio" name="cidades-modo" value="todas" checked> Todas as cidades da UF</label>
|
||||
<label class="radio"><input type="radio" name="cidades-modo" value="escolher"> Escolher cidades</label>
|
||||
</div>
|
||||
<div id="cidades-picker" class="picker hidden">
|
||||
<input type="search" id="busca-cidade" placeholder="Digite para buscar cidade...">
|
||||
<div id="cidades-sugestoes" class="sugestoes"></div>
|
||||
<div id="cidades-chips" class="chips"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>3. Nichos <span class="muted" id="nichos-contagem"></span></label>
|
||||
<div class="row">
|
||||
<label class="radio"><input type="radio" name="nichos-modo" value="todos" checked> Todos os nichos</label>
|
||||
<label class="radio"><input type="radio" name="nichos-modo" value="escolher"> Escolher nichos</label>
|
||||
</div>
|
||||
<div id="nichos-picker" class="picker hidden">
|
||||
<div class="row">
|
||||
<input type="search" id="busca-nicho" placeholder="Filtrar nichos...">
|
||||
<button class="btn-ghost" id="btn-nichos-todos" type="button">Marcar visíveis</button>
|
||||
<button class="btn-ghost" id="btn-nichos-nenhum" type="button">Desmarcar todos</button>
|
||||
</div>
|
||||
<div id="nichos-grid" class="nichos-grid"></div>
|
||||
</div>
|
||||
|
||||
<details class="cnae-avulso">
|
||||
<summary>Não achou o nicho? Buscar qualquer atividade da Receita Federal (CNAE)</summary>
|
||||
<p class="muted cnae-avulso-hint">Os 57 nichos acima já vêm com mensagem e interesse configurados. Um CNAE avulso é adicionado só para a busca — ele participa da captura normalmente.</p>
|
||||
<input type="search" id="busca-cnae" placeholder="Ex.: salão de beleza, transportadora, escola...">
|
||||
<div id="cnae-sugestoes" class="sugestoes"></div>
|
||||
<div id="cnae-chips" class="chips"></div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>4. Fontes de busca</label>
|
||||
<div id="fontes-lista" class="fontes"></div>
|
||||
</div>
|
||||
|
||||
<details class="field">
|
||||
<summary>Opções avançadas</summary>
|
||||
<div class="row">
|
||||
<label>Máx. por consulta (fontes de API)
|
||||
<input type="number" id="inp-max" value="10" min="1" max="60">
|
||||
</label>
|
||||
<label>Limite total de consultas (0 = sem limite)
|
||||
<input type="number" id="inp-query-limit" value="0" min="0">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button class="btn btn-primary btn-lg" id="btn-capturar">🚀 Iniciar captura</button>
|
||||
</div>
|
||||
|
||||
<div class="card hidden" id="progresso-card">
|
||||
<div class="row space-between">
|
||||
<h2 id="progresso-titulo">Captura em andamento</h2>
|
||||
<button class="btn-danger" id="btn-cancelar">Cancelar</button>
|
||||
</div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="progresso-fill"></div></div>
|
||||
<p class="progress-msg" id="progresso-msg">iniciando...</p>
|
||||
<div class="stat-row" id="progresso-stats"></div>
|
||||
<div class="log" id="progresso-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== TRATAMENTO ==================== -->
|
||||
<section id="tab-tratamento" class="tab-panel">
|
||||
<div class="card filtros-card">
|
||||
<div class="filtros" id="filtros">
|
||||
<select id="f-uf" data-filtro="uf"><option value="">UF: todas</option></select>
|
||||
<input list="dl-cidades" id="f-cidade" data-filtro="cidade" placeholder="Cidade: todas">
|
||||
<datalist id="dl-cidades"></datalist>
|
||||
<select id="f-segmento" data-filtro="segmento"><option value="">Nicho: todos</option></select>
|
||||
<select id="f-classificacao" data-filtro="classificacao">
|
||||
<option value="">Classificação: todas</option>
|
||||
<option value="excelente">🟢 Excelente</option>
|
||||
<option value="bom">🟢 Bom</option>
|
||||
<option value="medio">🟡 Médio</option>
|
||||
<option value="baixo">🟠 Baixo</option>
|
||||
<option value="muito_baixo">🔴 Muito Baixo</option>
|
||||
</select>
|
||||
<select id="f-prioridade" data-filtro="prioridade">
|
||||
<option value="">Prioridade: todas</option>
|
||||
<option value="1">P1 — atacar agora</option>
|
||||
<option value="2">P2 — esta semana</option>
|
||||
<option value="3">P3 — quando sobrar</option>
|
||||
<option value="4">P4 — campanhas específicas</option>
|
||||
</select>
|
||||
<select id="f-produto" data-filtro="produto"><option value="">Produto: todos</option></select>
|
||||
<select id="f-interesse" data-filtro="interesse"><option value="">Interesse: todos</option></select>
|
||||
<select id="f-telefone" data-filtro="telefone_valido">
|
||||
<option value="">WhatsApp: todos</option>
|
||||
<option value="sim">Com WhatsApp</option>
|
||||
<option value="nao">Sem WhatsApp</option>
|
||||
</select>
|
||||
<select id="f-website" data-filtro="website">
|
||||
<option value="">Website: todos</option>
|
||||
<option value="sim">Com website</option>
|
||||
<option value="nao">Sem website</option>
|
||||
</select>
|
||||
<select id="f-email" data-filtro="email">
|
||||
<option value="">E-mail: todos</option>
|
||||
<option value="sim">Com e-mail</option>
|
||||
<option value="nao">Sem e-mail</option>
|
||||
</select>
|
||||
<select id="f-exportado" data-filtro="exportado">
|
||||
<option value="">Exportação: todos</option>
|
||||
<option value="nao">Ainda não exportados</option>
|
||||
<option value="sim">Já exportados</option>
|
||||
</select>
|
||||
<input type="search" id="f-busca" data-filtro="q" placeholder="🔎 empresa, CNPJ ou WhatsApp">
|
||||
<button class="btn-ghost" id="btn-limpar-filtros">Limpar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-tiles" id="stat-tiles"></div>
|
||||
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<div class="row space-between">
|
||||
<h2>Top cidades</h2>
|
||||
</div>
|
||||
<div id="rank-cidades" class="ranking"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row space-between">
|
||||
<h2>Top nichos</h2>
|
||||
</div>
|
||||
<div id="rank-segmentos" class="ranking"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row space-between">
|
||||
<h2 id="rank-produtos-titulo">Top produto recomendado</h2>
|
||||
</div>
|
||||
<div id="rank-produtos" class="ranking"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-3">
|
||||
<div class="card" id="card-oportunidades-dia"></div>
|
||||
<div class="card" id="card-maior-oportunidade"></div>
|
||||
<div class="card" id="card-evolucao-diaria"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="card-funil"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row space-between wrap">
|
||||
<h2>Leads <span class="muted" id="leads-total"></span></h2>
|
||||
<button class="btn btn-primary" id="btn-exportar">📤 Gerar relatório / exportação com este filtro</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="tabela-leads">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Empresa</th><th>WhatsApp</th><th>Cidade</th><th>Nicho</th>
|
||||
<th>Prioridade</th><th class="num">Geral</th>
|
||||
<th>Classificação</th><th>Produto</th><th>Exportado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="row space-between">
|
||||
<button class="btn-ghost" id="pg-prev">← Anterior</button>
|
||||
<span class="muted" id="pg-info"></span>
|
||||
<button class="btn-ghost" id="pg-next">Próxima →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Exportações geradas</h2>
|
||||
<div id="historico-exports" class="lista"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Modal de exportação -->
|
||||
<dialog id="modal-export">
|
||||
<form method="dialog" class="modal-body" id="form-export">
|
||||
<h2>Gerar relatório / exportação</h2>
|
||||
<p class="muted" id="export-recap"></p>
|
||||
<div id="export-atalhos" class="chips"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Tipo de arquivo</label>
|
||||
<label class="radio"><input type="radio" name="export-tipo" value="excel" checked>
|
||||
📊 <strong>Planilha (CSV + Excel)</strong> — lista completa com score, à prova de notação científica</label>
|
||||
<label class="radio"><input type="radio" name="export-tipo" value="zpro-oportunidades">
|
||||
🗂️ <strong>Z-PRO: Oportunidades (JSONL)</strong> — payloads prontos para createOpportunity</label>
|
||||
<label class="radio"><input type="radio" name="export-tipo" value="zpro-bulk">
|
||||
📨 <strong>Z-PRO: Disparo em massa (CSV)</strong> — dataInput para BulkSendMessageWithVariable</label>
|
||||
</div>
|
||||
|
||||
<div class="field" id="export-zpro-params">
|
||||
<label>Parâmetros Z-PRO</label>
|
||||
<div class="row">
|
||||
<label>Pipeline <input type="number" id="zp-pipeline" min="1"></label>
|
||||
<label>Etapa <input type="number" id="zp-stage" min="1"></label>
|
||||
<label>Responsável <input type="number" id="zp-responsible" min="1"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field hidden" id="export-mensagem-wrap">
|
||||
<label>Mensagem sugerida (use {empresa}, {cidade}, {segmento})</label>
|
||||
<textarea id="export-mensagem" rows="3">Olá {empresa}! Somos do Grupo GTI e ajudamos empresas de {cidade} com sistema de gestão, TI e atendimento automático no WhatsApp. Podemos conversar?</textarea>
|
||||
</div>
|
||||
|
||||
<label class="radio"><input type="checkbox" id="export-marcar" checked>
|
||||
Marcar estes leads como exportados (não aparecem de novo no filtro "ainda não exportados")</label>
|
||||
|
||||
<div class="row space-between">
|
||||
<button class="btn-ghost" value="cancel">Cancelar</button>
|
||||
<button class="btn btn-primary" id="btn-confirmar-export" value="default">Gerar arquivos</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal de histórico de capturas -->
|
||||
<dialog id="modal-historico">
|
||||
<div class="modal-body">
|
||||
<div class="row space-between">
|
||||
<h2>Últimas capturas</h2>
|
||||
<button class="btn-ghost" id="btn-fechar-historico" type="button">Fechar</button>
|
||||
</div>
|
||||
<div id="historico-capturas" class="lista"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal de configurações -->
|
||||
<dialog id="modal-settings">
|
||||
<form method="dialog" class="modal-body">
|
||||
<h2>Configurações</h2>
|
||||
<div class="field">
|
||||
<label>Padrões Z-PRO (usados nas exportações)</label>
|
||||
<div class="row">
|
||||
<label>Pipeline <input type="number" id="cfg-pipeline" min="1"></label>
|
||||
<label>Etapa <input type="number" id="cfg-stage" min="1"></label>
|
||||
<label>Responsável <input type="number" id="cfg-responsible" min="1"></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Previsão de fechamento (dias) <input type="number" id="cfg-forecast" min="1"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field zona-risco">
|
||||
<label>Zona de risco</label>
|
||||
<p class="muted">Apaga permanentemente todos os leads, exportações, capturas e extrações. Configurações e nichos não são afetados.</p>
|
||||
<button class="btn-danger" id="btn-limpar-base" type="button">🗑️ Limpar toda a base de leads</button>
|
||||
</div>
|
||||
<div class="row space-between">
|
||||
<button class="btn-ghost" value="cancel">Fechar</button>
|
||||
<button class="btn btn-primary" id="btn-salvar-settings" value="default">Salvar</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal de confirmacao da limpeza total da base -->
|
||||
<dialog id="modal-reset-base">
|
||||
<div class="modal-body">
|
||||
<h2>Limpar toda a base de leads</h2>
|
||||
<p class="muted">Esta ação é <strong>irreversível</strong>: todos os leads, exportações, capturas e extrações serão apagados. Digite a senha de segurança para confirmar.</p>
|
||||
<div class="field">
|
||||
<label>Senha de segurança <input type="password" id="reset-base-senha" autocomplete="off"></label>
|
||||
</div>
|
||||
<div class="row space-between">
|
||||
<button class="btn-ghost" id="btn-cancelar-reset-base" type="button">Cancelar</button>
|
||||
<button class="btn-danger" id="btn-confirmar-reset-base" type="button">Confirmar limpeza</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal de usuarios (somente admin) -->
|
||||
<dialog id="modal-usuarios">
|
||||
<div class="modal-body">
|
||||
<div class="row space-between">
|
||||
<h2>Usuários</h2>
|
||||
<button class="btn-ghost" id="btn-fechar-usuarios" type="button">Fechar</button>
|
||||
</div>
|
||||
<div id="lista-usuarios" class="lista"></div>
|
||||
<form class="field" id="form-novo-usuario">
|
||||
<label>Novo usuário</label>
|
||||
<div class="row">
|
||||
<input type="text" id="novo-usuario-username" placeholder="usuário" minlength="3" required>
|
||||
<input type="password" id="novo-usuario-password" placeholder="senha" minlength="4" required>
|
||||
<select id="novo-usuario-role">
|
||||
<option value="comercial">Comercial</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">Criar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Modal detalhe do lead -->
|
||||
<dialog id="modal-lead">
|
||||
<div class="modal-body" id="lead-detalhe"></div>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/captura.js"></script>
|
||||
<script src="/js/tratamento.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Cliente da API local + utilitários compartilhados. */
|
||||
// 401 fora da tela de login = sessao expirada/inexistente: manda pro login.
|
||||
function redirecionaSeSessaoExpirou(path, status) {
|
||||
if (status === 401 && path !== '/api/auth/login' && !location.pathname.endsWith('/login.html')) {
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
|
||||
const API = {
|
||||
async get(path) {
|
||||
const res = await fetch(path);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
redirecionaSeSessaoExpirou(path, res.status);
|
||||
throw new Error(data.erro || `HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
async send(method, path, body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
redirecionaSeSessaoExpirou(path, res.status);
|
||||
const error = new Error(data.erro || `HTTP ${res.status}`);
|
||||
error.data = data;
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
post(path, body) { return this.send('POST', path, body); },
|
||||
put(path, body) { return this.send('PUT', path, body); },
|
||||
|
||||
// Acompanha um job via SSE; handlers: {progress, log, done, error}
|
||||
watchJob(jobId, handlers) {
|
||||
const source = new EventSource(`/api/jobs/${jobId}/events`);
|
||||
const close = () => source.close();
|
||||
|
||||
source.addEventListener('progress', (e) => handlers.progress?.(JSON.parse(e.data)));
|
||||
source.addEventListener('log', (e) => handlers.log?.(JSON.parse(e.data)));
|
||||
source.addEventListener('done', (e) => {
|
||||
handlers.done?.(JSON.parse(e.data));
|
||||
close();
|
||||
});
|
||||
source.addEventListener('error', (e) => {
|
||||
if (e.data) {
|
||||
handlers.error?.(JSON.parse(e.data));
|
||||
close();
|
||||
}
|
||||
// sem e.data = queda de conexão; EventSource tenta reconectar sozinho
|
||||
});
|
||||
|
||||
return close;
|
||||
}
|
||||
};
|
||||
|
||||
const fmt = new Intl.NumberFormat('pt-BR');
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function h(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else if (value !== null && value !== undefined) node.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child === null || child === undefined) continue;
|
||||
node.append(child.nodeType ? child : document.createTextNode(child));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(message, ms = 3500) {
|
||||
const box = el('toast');
|
||||
box.textContent = message;
|
||||
box.classList.remove('hidden');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => box.classList.add('hidden'), ms);
|
||||
}
|
||||
|
||||
function dataBr(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso.includes('T') || iso.includes(' ') ? iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z') : iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/* Shell do aplicativo: login + abas + configurações + usuários. */
|
||||
(async function init() {
|
||||
// sessao (o servidor ja bloqueia "/" sem cookie, mas precisamos dos dados do usuario)
|
||||
let usuarioAtual;
|
||||
try {
|
||||
({ user: usuarioAtual } = await API.get('/api/auth/me'));
|
||||
} catch {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
el('usuario-logado').textContent = `${usuarioAtual.username} (${usuarioAtual.role === 'admin' ? 'admin' : 'comercial'})`;
|
||||
el('btn-usuarios').classList.toggle('hidden', usuarioAtual.role !== 'admin');
|
||||
|
||||
el('btn-sair').addEventListener('click', async () => {
|
||||
await API.post('/api/auth/logout');
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
|
||||
// usuarios (somente admin)
|
||||
async function carregarListaUsuarios() {
|
||||
const { users } = await API.get('/api/users');
|
||||
el('lista-usuarios').replaceChildren(...users.map((u) =>
|
||||
h('div', { class: 'row space-between' },
|
||||
h('span', {}, u.username),
|
||||
h('span', { class: 'muted' }, u.role === 'admin' ? 'Administrador' : 'Comercial'))));
|
||||
}
|
||||
|
||||
el('btn-usuarios').addEventListener('click', async () => {
|
||||
el('modal-usuarios').showModal();
|
||||
try {
|
||||
await carregarListaUsuarios();
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
el('btn-fechar-usuarios').addEventListener('click', () => el('modal-usuarios').close());
|
||||
|
||||
el('form-novo-usuario').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await API.post('/api/users', {
|
||||
username: el('novo-usuario-username').value,
|
||||
password: el('novo-usuario-password').value,
|
||||
role: el('novo-usuario-role').value
|
||||
});
|
||||
el('novo-usuario-username').value = '';
|
||||
el('novo-usuario-password').value = '';
|
||||
await carregarListaUsuarios();
|
||||
toast('Usuário criado');
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// abas
|
||||
for (const tab of document.querySelectorAll('.tab')) {
|
||||
tab.addEventListener('click', () => {
|
||||
for (const t of document.querySelectorAll('.tab')) {
|
||||
t.classList.toggle('active', t === tab);
|
||||
t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
|
||||
}
|
||||
for (const panel of document.querySelectorAll('.tab-panel')) {
|
||||
panel.classList.toggle('active', panel.id === `tab-${tab.dataset.tab}`);
|
||||
}
|
||||
if (tab.dataset.tab === 'tratamento') Tratamento.ativar();
|
||||
});
|
||||
}
|
||||
|
||||
// configurações
|
||||
el('btn-settings').addEventListener('click', async () => {
|
||||
const { settings } = await API.get('/api/settings');
|
||||
el('cfg-pipeline').value = settings['zpro.pipelineId'] || 1;
|
||||
el('cfg-stage').value = settings['zpro.stageId'] || 1;
|
||||
el('cfg-responsible').value = settings['zpro.responsibleId'] || 15;
|
||||
el('cfg-forecast').value = settings['zpro.closingForecastDays'] || 30;
|
||||
el('modal-settings').showModal();
|
||||
});
|
||||
|
||||
el('btn-salvar-settings').addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await API.put('/api/settings', {
|
||||
'zpro.pipelineId': el('cfg-pipeline').value,
|
||||
'zpro.stageId': el('cfg-stage').value,
|
||||
'zpro.responsibleId': el('cfg-responsible').value,
|
||||
'zpro.closingForecastDays': el('cfg-forecast').value
|
||||
});
|
||||
el('modal-settings').close();
|
||||
toast('Configurações salvas');
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// limpar toda a base (zona de risco)
|
||||
el('btn-limpar-base').addEventListener('click', () => {
|
||||
el('reset-base-senha').value = '';
|
||||
el('modal-settings').close();
|
||||
el('modal-reset-base').showModal();
|
||||
});
|
||||
|
||||
el('btn-cancelar-reset-base').addEventListener('click', () => {
|
||||
el('modal-reset-base').close();
|
||||
});
|
||||
|
||||
el('btn-confirmar-reset-base').addEventListener('click', async () => {
|
||||
const senha = el('reset-base-senha').value;
|
||||
if (!senha) {
|
||||
toast('Informe a senha de segurança');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await API.post('/api/admin/reset', { senha });
|
||||
el('modal-reset-base').close();
|
||||
toast('Base de leads limpa com sucesso — recarregando...');
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// fecha modais clicando fora
|
||||
for (const dialog of document.querySelectorAll('dialog')) {
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
}
|
||||
|
||||
Tratamento.bindEvents();
|
||||
|
||||
try {
|
||||
await Captura.init();
|
||||
} catch (error) {
|
||||
toast(`Erro ao carregar: ${error.message}`, 8000);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,405 @@
|
||||
/* Aba Captura: wizard UF → cidades → nichos → fontes → rodar, com progresso SSE. */
|
||||
const Captura = (() => {
|
||||
let ufs = [];
|
||||
let municipios = []; // da UF selecionada
|
||||
let segments = [];
|
||||
let sources = [];
|
||||
const cidadesEscolhidas = new Set();
|
||||
const nichosEscolhidos = new Set();
|
||||
const cnaesAvulsos = new Map(); // codigo -> descricao
|
||||
let stopWatcher = null;
|
||||
|
||||
// ---------- carregamento ----------
|
||||
async function init() {
|
||||
const [ufsData, segData, srcData] = await Promise.all([
|
||||
API.get('/api/meta/ufs'),
|
||||
API.get('/api/meta/segments'),
|
||||
API.get('/api/meta/sources')
|
||||
]);
|
||||
ufs = ufsData.ufs;
|
||||
segments = segData.segments;
|
||||
sources = srcData.sources;
|
||||
|
||||
renderUfs();
|
||||
renderNichos();
|
||||
renderFontes();
|
||||
bindEvents();
|
||||
await onUfChange();
|
||||
await carregarHistorico();
|
||||
await retomarJobAtivo();
|
||||
}
|
||||
|
||||
function renderUfs() {
|
||||
const sel = el('sel-uf');
|
||||
sel.replaceChildren(...ufs.map((u) =>
|
||||
h('option', { value: u.uf, selected: u.uf === 'BA' ? '' : null },
|
||||
`${u.uf} — ${u.nome}${u.extraida ? ' ✓' : ''}`)
|
||||
));
|
||||
}
|
||||
|
||||
function renderNichos(filtro = '') {
|
||||
const grid = el('nichos-grid');
|
||||
const termo = filtro.toLowerCase();
|
||||
grid.replaceChildren(...segments
|
||||
.filter((s) => !termo || s.label.toLowerCase().includes(termo))
|
||||
.map((s) => h('label', {},
|
||||
h('input', {
|
||||
type: 'checkbox', value: s.name,
|
||||
checked: nichosEscolhidos.has(s.name) ? '' : null,
|
||||
onchange: (e) => {
|
||||
if (e.target.checked) nichosEscolhidos.add(s.name);
|
||||
else nichosEscolhidos.delete(s.name);
|
||||
atualizarContagem();
|
||||
}
|
||||
}),
|
||||
s.label
|
||||
)));
|
||||
el('nichos-contagem').textContent = `(${segments.length} disponíveis)`;
|
||||
}
|
||||
|
||||
function atualizarContagem() {
|
||||
const modo = document.querySelector('input[name="nichos-modo"]:checked').value;
|
||||
el('nichos-contagem').textContent = modo === 'todos'
|
||||
? `(${segments.length} disponíveis)`
|
||||
: `(${nichosEscolhidos.size} de ${segments.length} selecionados)`;
|
||||
}
|
||||
|
||||
function renderFontes() {
|
||||
const box = el('fontes-lista');
|
||||
box.replaceChildren(...sources.map((s) => {
|
||||
const input = h('input', {
|
||||
type: 'checkbox', value: s.id,
|
||||
checked: s.id === 'cnpj-base' ? '' : null,
|
||||
disabled: s.disponivel ? null : ''
|
||||
});
|
||||
return h('label', { class: `fonte${s.disponivel ? '' : ' indisponivel'}` },
|
||||
input,
|
||||
h('div', {},
|
||||
h('div', {}, h('strong', {}, s.label), s.recomendada ? ' ⭐' : '', s.gratuita ? '' : ' 💰'),
|
||||
h('div', { class: 'desc' }, s.descricao + (s.disponivel ? '' : ` — ${s.motivo}`))
|
||||
)
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------- UF / extração ----------
|
||||
function ufAtual() {
|
||||
return ufs.find((u) => u.uf === el('sel-uf').value);
|
||||
}
|
||||
|
||||
async function onUfChange() {
|
||||
const u = ufAtual();
|
||||
const status = el('uf-status');
|
||||
|
||||
if (u.extraindo) {
|
||||
status.textContent = '⏳ extraindo base...';
|
||||
status.className = 'chip warn';
|
||||
} else if (u.extraida) {
|
||||
status.textContent = '✓ base CNPJ pronta';
|
||||
status.className = 'chip ok';
|
||||
} else {
|
||||
status.textContent = 'base CNPJ não extraída';
|
||||
status.className = 'chip warn';
|
||||
}
|
||||
el('extracao-aviso').classList.toggle('hidden', u.extraida || u.extraindo);
|
||||
el('extracao-uf').textContent = `${u.uf} — ${u.nome}`;
|
||||
|
||||
cidadesEscolhidas.clear();
|
||||
renderChips();
|
||||
const data = await API.get(`/api/meta/municipios?uf=${u.uf}`);
|
||||
municipios = data.municipios;
|
||||
}
|
||||
|
||||
async function extrairBase() {
|
||||
const u = ufAtual();
|
||||
try {
|
||||
const { jobId } = await API.post('/api/extractions', { uf: u.uf });
|
||||
toast(`Extração de ${u.uf} iniciada — pode levar até 1 hora`);
|
||||
acompanharJob(jobId, `Extraindo base CNPJ de ${u.uf}`);
|
||||
u.extraindo = true;
|
||||
await onUfChange();
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- cidades ----------
|
||||
function renderChips() {
|
||||
el('cidades-chips').replaceChildren(...[...cidadesEscolhidas].map((nome) =>
|
||||
h('span', {
|
||||
class: 'chip', title: 'remover',
|
||||
onclick: () => { cidadesEscolhidas.delete(nome); renderChips(); }
|
||||
}, nome)
|
||||
));
|
||||
}
|
||||
|
||||
function buscarCidade(termo) {
|
||||
const box = el('cidades-sugestoes');
|
||||
if (!termo || termo.length < 2) {
|
||||
box.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const t = termo.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
const achados = municipios
|
||||
.filter((m) => m.nome.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').includes(t))
|
||||
.slice(0, 12);
|
||||
box.replaceChildren(...achados.map((m) =>
|
||||
h('button', {
|
||||
type: 'button',
|
||||
onclick: () => {
|
||||
cidadesEscolhidas.add(m.nome);
|
||||
renderChips();
|
||||
el('busca-cidade').value = '';
|
||||
box.replaceChildren();
|
||||
}
|
||||
}, m.nome)
|
||||
));
|
||||
}
|
||||
|
||||
// ---------- CNAE avulso (qualquer atividade da Receita, alem dos 57 nichos) ----------
|
||||
function renderCnaeChips() {
|
||||
el('cnae-chips').replaceChildren(...[...cnaesAvulsos].map(([codigo, descricao]) =>
|
||||
h('span', {
|
||||
class: 'chip', title: 'remover',
|
||||
onclick: () => { cnaesAvulsos.delete(codigo); renderCnaeChips(); }
|
||||
}, descricao)
|
||||
));
|
||||
}
|
||||
|
||||
let buscaCnaeTimer = null;
|
||||
function buscarCnae(termo) {
|
||||
clearTimeout(buscaCnaeTimer);
|
||||
const box = el('cnae-sugestoes');
|
||||
if (!termo || termo.trim().length < 2) {
|
||||
box.replaceChildren();
|
||||
return;
|
||||
}
|
||||
buscaCnaeTimer = setTimeout(async () => {
|
||||
const { cnaes } = await API.get(`/api/meta/cnaes?q=${encodeURIComponent(termo)}`);
|
||||
box.replaceChildren(...cnaes.map((c) =>
|
||||
h('button', {
|
||||
type: 'button',
|
||||
onclick: () => {
|
||||
cnaesAvulsos.set(c.codigo, c.descricao);
|
||||
renderCnaeChips();
|
||||
el('busca-cnae').value = '';
|
||||
box.replaceChildren();
|
||||
}
|
||||
}, h('span', {}, c.descricao), h('span', { class: 'cod' }, c.codigo))
|
||||
));
|
||||
if (!cnaes.length) {
|
||||
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma atividade encontrada'));
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
// ---------- captura ----------
|
||||
function montarPayload() {
|
||||
const fontes = [...document.querySelectorAll('#fontes-lista input:checked')].map((i) => i.value);
|
||||
const cidadesModo = document.querySelector('input[name="cidades-modo"]:checked').value;
|
||||
const nichosModo = document.querySelector('input[name="nichos-modo"]:checked').value;
|
||||
const cnaeIds = [...cnaesAvulsos.keys()].map((codigo) => `cnae:${codigo}`);
|
||||
|
||||
return {
|
||||
sources: fontes,
|
||||
uf: el('sel-uf').value,
|
||||
cities: cidadesModo === 'todas' ? ['all'] : [...cidadesEscolhidas],
|
||||
segments: [...(nichosModo === 'todos' ? ['all'] : [...nichosEscolhidos]), ...cnaeIds],
|
||||
limits: {
|
||||
maxPerQuery: Number(el('inp-max').value) || 10,
|
||||
queryLimit: Number(el('inp-query-limit').value) || 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function iniciarCaptura() {
|
||||
const payload = montarPayload();
|
||||
|
||||
if (!payload.sources.length) {
|
||||
toast('Selecione pelo menos uma fonte de busca');
|
||||
return;
|
||||
}
|
||||
if (payload.cities.length === 0) {
|
||||
toast('Escolha pelo menos uma cidade (ou marque "todas")');
|
||||
return;
|
||||
}
|
||||
if (payload.segments.length === 0) {
|
||||
toast('Escolha pelo menos um nicho (ou marque "todos")');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { jobId } = await API.post('/api/capture/runs', payload);
|
||||
acompanharJob(jobId, 'Captura em andamento');
|
||||
} catch (error) {
|
||||
if (error.data?.precisaExtracao) {
|
||||
el('extracao-aviso').classList.remove('hidden');
|
||||
toast(error.message);
|
||||
} else {
|
||||
toast(`Erro: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- progresso ----------
|
||||
function acompanharJob(jobId, titulo) {
|
||||
const card = el('progresso-card');
|
||||
card.classList.remove('hidden');
|
||||
card.dataset.jobId = jobId;
|
||||
el('progresso-titulo').textContent = titulo;
|
||||
el('progresso-msg').textContent = 'iniciando...';
|
||||
el('progresso-log').textContent = '';
|
||||
el('progresso-fill').style.width = '3%';
|
||||
el('progresso-stats').replaceChildren();
|
||||
el('btn-cancelar').disabled = false;
|
||||
el('btn-capturar').disabled = true;
|
||||
|
||||
if (stopWatcher) stopWatcher();
|
||||
|
||||
stopWatcher = API.watchJob(jobId, {
|
||||
progress(p) {
|
||||
if (p.message) el('progresso-msg').textContent = p.message;
|
||||
if (typeof p.pct === 'number') el('progresso-fill').style.width = `${Math.max(3, p.pct)}%`;
|
||||
if (p.counts) {
|
||||
el('progresso-fill').style.width = '50%';
|
||||
el('progresso-stats').replaceChildren(
|
||||
mini(p.counts.raw, 'encontrados'),
|
||||
mini(p.counts.new, 'novos'),
|
||||
mini(p.counts.dup, 'já capturados')
|
||||
);
|
||||
}
|
||||
},
|
||||
log(l) {
|
||||
const box = el('progresso-log');
|
||||
box.textContent += `${l.message}\n`;
|
||||
box.scrollTop = box.scrollHeight;
|
||||
},
|
||||
done(d) {
|
||||
el('progresso-fill').style.width = '100%';
|
||||
const s = d.summary || {};
|
||||
el('progresso-msg').textContent = s.cancelled
|
||||
? 'Cancelado.'
|
||||
: s.totalRows !== undefined
|
||||
? `✅ Base extraída: ${fmt.format(s.totalRows)} empresas (${fmt.format(s.withPhone)} com telefone)`
|
||||
: `✅ Concluído: ${fmt.format(s.new ?? 0)} leads novos (${fmt.format(s.dup ?? 0)} já capturados antes)`;
|
||||
finalizar();
|
||||
},
|
||||
error(e) {
|
||||
el('progresso-msg').textContent = `❌ Erro: ${e.message}`;
|
||||
finalizar();
|
||||
}
|
||||
});
|
||||
|
||||
function mini(valor, rotulo) {
|
||||
return h('div', { class: 'mini' }, h('b', {}, fmt.format(valor ?? 0)), h('span', {}, rotulo));
|
||||
}
|
||||
|
||||
async function finalizar() {
|
||||
el('btn-cancelar').disabled = true;
|
||||
el('btn-capturar').disabled = false;
|
||||
await Promise.all([carregarHistorico(), recarregarUfs()]);
|
||||
window.Tratamento?.invalidar();
|
||||
}
|
||||
}
|
||||
|
||||
async function recarregarUfs() {
|
||||
ufs = (await API.get('/api/meta/ufs')).ufs;
|
||||
const atual = el('sel-uf').value;
|
||||
renderUfs();
|
||||
el('sel-uf').value = atual;
|
||||
await onUfChange();
|
||||
}
|
||||
|
||||
async function cancelarJob() {
|
||||
const jobId = el('progresso-card').dataset.jobId;
|
||||
const ativos = (await API.get('/api/jobs/active')).jobs;
|
||||
const job = ativos.find((j) => j.jobId === jobId);
|
||||
if (!job) {
|
||||
toast('Nenhum job ativo para cancelar');
|
||||
return;
|
||||
}
|
||||
const rota = job.type === 'extracao'
|
||||
? `/api/extractions/${job.meta.extractionId}/cancel`
|
||||
: `/api/capture/runs/${job.meta.runId}/cancel`;
|
||||
try {
|
||||
await API.post(rota, {});
|
||||
toast('Cancelamento solicitado — aguardando o processo parar...');
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function retomarJobAtivo() {
|
||||
const ativos = (await API.get('/api/jobs/active')).jobs;
|
||||
if (!ativos.length) return;
|
||||
const job = ativos[0];
|
||||
acompanharJob(job.jobId, job.type === 'extracao'
|
||||
? `Extraindo base CNPJ de ${job.meta.uf}`
|
||||
: 'Captura em andamento');
|
||||
}
|
||||
|
||||
// ---------- histórico ----------
|
||||
async function carregarHistorico() {
|
||||
const { runs } = await API.get('/api/capture/runs?limit=8');
|
||||
el('btn-ver-historico').textContent = runs.length ? `🕓 Histórico de capturas (${runs.length})` : '🕓 Histórico de capturas';
|
||||
const box = el('historico-capturas');
|
||||
if (!runs.length) {
|
||||
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma captura ainda. Configure ao lado e clique em Iniciar.'));
|
||||
return;
|
||||
}
|
||||
const statusLabel = {
|
||||
done: '✅ concluída', running: '⏳ rodando', pending: '⏳ na fila',
|
||||
cancelled: '⚪ cancelada', error: '❌ erro'
|
||||
};
|
||||
box.replaceChildren(...runs.map((r) => h('div', { class: 'item' },
|
||||
h('div', {},
|
||||
h('div', {}, h('strong', {}, `#${r.id} · ${(r.params.sources || []).join(', ')} · ${r.params.uf || ''}`)),
|
||||
h('div', { class: 'sub' },
|
||||
`${dataBr(r.created_at)} · ${statusLabel[r.status] || r.status}`,
|
||||
r.error ? ` · ${r.error.slice(0, 80)}` : '')
|
||||
),
|
||||
h('div', { class: 'sub' },
|
||||
r.status === 'done' ? `${fmt.format(r.new_count)} novos / ${fmt.format(r.dup_count)} repetidos` : '')
|
||||
)));
|
||||
}
|
||||
|
||||
// ---------- eventos ----------
|
||||
function bindEvents() {
|
||||
el('sel-uf').addEventListener('change', onUfChange);
|
||||
el('btn-extrair').addEventListener('click', extrairBase);
|
||||
el('btn-capturar').addEventListener('click', iniciarCaptura);
|
||||
el('btn-cancelar').addEventListener('click', cancelarJob);
|
||||
el('btn-ver-historico').addEventListener('click', () => el('modal-historico').showModal());
|
||||
el('btn-fechar-historico').addEventListener('click', () => el('modal-historico').close());
|
||||
|
||||
for (const radio of document.querySelectorAll('input[name="cidades-modo"]')) {
|
||||
radio.addEventListener('change', () => {
|
||||
el('cidades-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked);
|
||||
});
|
||||
}
|
||||
for (const radio of document.querySelectorAll('input[name="nichos-modo"]')) {
|
||||
radio.addEventListener('change', () => {
|
||||
el('nichos-picker').classList.toggle('hidden', radio.value !== 'escolher' || !radio.checked);
|
||||
atualizarContagem();
|
||||
});
|
||||
}
|
||||
|
||||
el('busca-cidade').addEventListener('input', (e) => buscarCidade(e.target.value));
|
||||
el('busca-nicho').addEventListener('input', (e) => renderNichos(e.target.value));
|
||||
el('busca-cnae').addEventListener('input', (e) => buscarCnae(e.target.value));
|
||||
el('btn-nichos-todos').addEventListener('click', () => {
|
||||
for (const input of document.querySelectorAll('#nichos-grid input')) {
|
||||
input.checked = true;
|
||||
nichosEscolhidos.add(input.value);
|
||||
}
|
||||
atualizarContagem();
|
||||
});
|
||||
el('btn-nichos-nenhum').addEventListener('click', () => {
|
||||
nichosEscolhidos.clear();
|
||||
renderNichos(el('busca-nicho').value);
|
||||
atualizarContagem();
|
||||
});
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Tela de login: autentica e redireciona para o app. */
|
||||
el('form-login').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const erroBox = el('login-erro');
|
||||
erroBox.textContent = '';
|
||||
|
||||
try {
|
||||
await API.post('/api/auth/login', {
|
||||
username: el('login-username').value,
|
||||
password: el('login-password').value
|
||||
});
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
erroBox.textContent = error.message;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
/* Aba Tratamento: dashboard, navegador de leads com filtros e exportações. */
|
||||
const Tratamento = (() => {
|
||||
let page = 1;
|
||||
let totalPages = 1;
|
||||
let carregado = false;
|
||||
let refreshTimer = null;
|
||||
|
||||
const CLASSIFICACAO_LABELS = {
|
||||
excelente: 'Excelente', bom: 'Bom', medio: 'Médio', baixo: 'Baixo', muito_baixo: 'Muito Baixo'
|
||||
};
|
||||
const PRIORIDADE_LABELS = {
|
||||
1: 'P1 — atacar imediatamente', 2: 'P2 — trabalhar durante a semana',
|
||||
3: 'P3 — quando não houver prioritários', 4: 'P4 — só em campanhas específicas'
|
||||
};
|
||||
|
||||
function estrelas(n) {
|
||||
const cheias = Math.max(0, Math.min(5, Number(n) || 0));
|
||||
return '★'.repeat(cheias) + '☆'.repeat(5 - cheias);
|
||||
}
|
||||
|
||||
// ---------- filtros ----------
|
||||
function filtroAtual() {
|
||||
const filtro = {};
|
||||
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
|
||||
const value = input.value.trim();
|
||||
if (value) filtro[input.dataset.filtro] = value;
|
||||
}
|
||||
return filtro;
|
||||
}
|
||||
|
||||
function filtroQuery(extra = {}) {
|
||||
return new URLSearchParams({ ...filtroAtual(), ...extra }).toString();
|
||||
}
|
||||
|
||||
// Clique nos cards/barras do dashboard: aplica o filtro somando aos ja
|
||||
// ativos; clicar de novo no mesmo valor remove so aquele filtro.
|
||||
function toggleFiltro(inputId, valor) {
|
||||
const input = el(inputId);
|
||||
const novo = String(valor ?? '');
|
||||
input.value = input.value === novo ? '' : novo;
|
||||
atualizar();
|
||||
}
|
||||
|
||||
function descreverFiltro() {
|
||||
const f = filtroAtual();
|
||||
const partes = [];
|
||||
if (f.uf) partes.push(`UF ${f.uf}`);
|
||||
if (f.cidade) partes.push(f.cidade);
|
||||
if (f.segmento) partes.push(el('f-segmento').selectedOptions[0]?.textContent || f.segmento);
|
||||
if (f.classificacao) partes.push(CLASSIFICACAO_LABELS[f.classificacao] || f.classificacao);
|
||||
if (f.prioridade) partes.push(`P${f.prioridade}`);
|
||||
if (f.produto) partes.push(`produto: ${f.produto}`);
|
||||
if (f.interesse) partes.push(f.interesse);
|
||||
if (f.telefone_valido) partes.push(f.telefone_valido === 'sim' ? 'com WhatsApp' : 'sem WhatsApp');
|
||||
if (f.website) partes.push(f.website === 'sim' ? 'com website' : 'sem website');
|
||||
if (f.email) partes.push(f.email === 'sim' ? 'com e-mail' : 'sem e-mail');
|
||||
if (f.exportado) partes.push(f.exportado === 'nao' ? 'ainda não exportados' : 'já exportados');
|
||||
if (f.q) partes.push(`busca "${f.q}"`);
|
||||
return partes.length ? partes.join(' · ') : 'todos os leads';
|
||||
}
|
||||
|
||||
// ---------- carregamento ----------
|
||||
async function atualizar() {
|
||||
page = 1;
|
||||
const [summary, evolutionRes, oportunidadeRes] = await Promise.all([
|
||||
API.get(`/api/dashboard/summary?${filtroQuery()}`),
|
||||
API.get(`/api/dashboard/evolution?dias=14&${filtroQuery()}`),
|
||||
API.get(`/api/dashboard/oportunidade?${filtroQuery()}`)
|
||||
]);
|
||||
await Promise.all([carregarTiles(summary), carregarRankings(summary), carregarFunil(summary), carregarTabela()]);
|
||||
carregarEvolucaoDiaria(evolutionRes.evolution);
|
||||
carregarMaiorOportunidade(oportunidadeRes.oportunidade);
|
||||
}
|
||||
|
||||
function invalidar() {
|
||||
carregado = false;
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
if (document.querySelector('#tab-tratamento.active')) ativar();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
async function ativar() {
|
||||
if (carregado) return;
|
||||
carregado = true;
|
||||
await Promise.all([carregarFacets(), atualizar(), carregarExports()]);
|
||||
}
|
||||
|
||||
async function carregarFacets() {
|
||||
const facets = await API.get('/api/leads/facets');
|
||||
|
||||
const fill = (select, valores, prefixo, formatar = (v) => v) => {
|
||||
const atual = select.value;
|
||||
const primeira = select.options[0];
|
||||
select.replaceChildren(primeira, ...valores.map((item) =>
|
||||
h('option', { value: item.value }, `${formatar(item.value)} (${fmt.format(item.total)})`)
|
||||
));
|
||||
select.value = atual;
|
||||
};
|
||||
|
||||
fill(el('f-uf'), facets.ufs);
|
||||
fill(el('f-interesse'), facets.interesses);
|
||||
fill(el('f-produto'), facets.produtos);
|
||||
|
||||
const segSelect = el('f-segmento');
|
||||
const atual = segSelect.value;
|
||||
segSelect.replaceChildren(segSelect.options[0], ...facets.segmentos.map((s) =>
|
||||
h('option', { value: s.value }, `${s.value.replace(/_/g, ' ')} (${fmt.format(s.total)})`)
|
||||
));
|
||||
segSelect.value = atual;
|
||||
|
||||
el('dl-cidades').replaceChildren(...facets.cidades.map((c) => h('option', { value: c.value })));
|
||||
}
|
||||
|
||||
async function salvarMetaDiaria(valor) {
|
||||
const meta = Math.max(1, Number(valor) || 100);
|
||||
await API.put('/api/settings', { meta_diaria: meta });
|
||||
const summary = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
|
||||
carregarTiles(summary);
|
||||
}
|
||||
|
||||
async function carregarTiles(s) {
|
||||
const pct = (n) => s.total ? ` (${((n / s.total) * 100).toFixed(1)}%)` : '';
|
||||
|
||||
el('stat-tiles').replaceChildren(
|
||||
tile(s.total, 'Leads no filtro', ''),
|
||||
tile(s.com_whatsapp, 'Com WhatsApp', pct(s.com_whatsapp), 't-good'),
|
||||
tile(s.p1, '🎯 Potencial Comercial Imediato', `${s.potencial_pct}% da base — atacar agora`, 't-good'),
|
||||
tile(s.p4, '📦 P4 — campanhas específicas', pct(s.p4))
|
||||
);
|
||||
|
||||
el('stat-tiles').append(
|
||||
cardCobertura(s),
|
||||
cardPrioridades(s),
|
||||
cardMeta(s)
|
||||
);
|
||||
|
||||
function tile(valor, rotulo, detalhe, cls = '') {
|
||||
return h('div', { class: `tile ${cls}` },
|
||||
h('div', { class: 'valor' }, fmt.format(valor)),
|
||||
h('div', { class: 'rotulo' }, rotulo),
|
||||
h('div', { class: 'detalhe' }, detalhe)
|
||||
);
|
||||
}
|
||||
|
||||
function cardCobertura(s) {
|
||||
const cidadeAtiva = filtroAtual().cidade;
|
||||
const titulo = cidadeAtiva ? `Cobertura em ${cidadeAtiva}` : 'Base Comercial — cobertura';
|
||||
return h('div', { class: 'tile tile-wide' },
|
||||
h('div', { class: 'rotulo' }, titulo),
|
||||
h('div', { class: 'progress-bar' },
|
||||
h('div', { class: 'progress-fill', style: `width:${s.cobertura_pct}%` })),
|
||||
h('div', { class: 'detalhe' },
|
||||
`${s.cobertura_pct}% trabalhada · ${fmt.format(s.exportados)} exportadas · ${fmt.format(s.disponiveis)} disponíveis`)
|
||||
);
|
||||
}
|
||||
|
||||
function cardPrioridades(s) {
|
||||
const ativa = filtroAtual().prioridade;
|
||||
const linhas = [
|
||||
['P1', s.p1, s.p1_pct], ['P2', s.p2, s.p2_pct], ['P3', s.p3, s.p3_pct], ['P4', s.p4, s.p4_pct]
|
||||
];
|
||||
return h('div', { class: 'tile tile-wide' },
|
||||
h('div', { class: 'rotulo' }, 'Distribuição por prioridade'),
|
||||
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...linhas.map(([label, total, p], i) =>
|
||||
h('div', {
|
||||
class: `rank-row${ativa === String(i + 1) ? ' ativo' : ''}`,
|
||||
title: `${label}: ${fmt.format(total)} leads — clique para filtrar`,
|
||||
onclick: () => toggleFiltro('f-prioridade', i + 1)
|
||||
},
|
||||
h('div', { class: 'rank-label' }, label),
|
||||
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
|
||||
h('div', { class: 'rank-value' }, `${p}%`)
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
function cardMeta(s) {
|
||||
return h('div', { class: 'tile tile-wide' },
|
||||
h('div', { class: 'row space-between' },
|
||||
h('div', { class: 'rotulo' }, '🎯 Meta diária'),
|
||||
h('input', {
|
||||
type: 'number', min: '1', value: String(s.meta_diaria), class: 'meta-input',
|
||||
onchange: (e) => salvarMetaDiaria(e.target.value)
|
||||
})
|
||||
),
|
||||
h('div', { class: 'progress-bar' },
|
||||
h('div', { class: 'progress-fill', style: `width:${Math.min(100, s.meta_pct)}%` })),
|
||||
h('div', { class: 'detalhe' },
|
||||
`${fmt.format(s.hoje_exportados)} de ${fmt.format(s.meta_diaria)} hoje (${s.meta_pct}%) · ~${fmt.format(s.dias_estimados)} dias úteis restantes no ritmo atual`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function carregarRankings(summary) {
|
||||
const cidadeAtiva = filtroAtual().cidade;
|
||||
el('rank-produtos-titulo').textContent = cidadeAtiva ? `Produtos recomendados em ${cidadeAtiva}` : 'Top produto recomendado';
|
||||
|
||||
const [cidades, segmentos, produtos] = await Promise.all([
|
||||
API.get(`/api/dashboard/rankings?by=cidade&limit=10&${filtroQuery()}`),
|
||||
API.get(`/api/dashboard/rankings?by=segmento&limit=10&${filtroQuery()}`),
|
||||
API.get(`/api/dashboard/rankings?by=produto&limit=10&${filtroQuery()}`)
|
||||
]);
|
||||
|
||||
const f = filtroAtual();
|
||||
const segmentoValuePorLabel = (label) => {
|
||||
const opts = [...el('f-segmento').options];
|
||||
const porValor = opts.find((o) => o.value && o.value.replace(/_/g, ' ').toLowerCase() === label.toLowerCase());
|
||||
const porTexto = opts.find((o) => o.textContent.toLowerCase().startsWith(label.toLowerCase()));
|
||||
return (porValor || porTexto)?.value || '';
|
||||
};
|
||||
|
||||
renderRanking(el('rank-cidades'), cidades.ranking,
|
||||
(label) => toggleFiltro('f-cidade', label),
|
||||
null,
|
||||
(label) => f.cidade === label);
|
||||
renderRanking(el('rank-segmentos'), segmentos.ranking,
|
||||
(label) => toggleFiltro('f-segmento', segmentoValuePorLabel(label)),
|
||||
null,
|
||||
(label) => !!f.segmento && f.segmento === segmentoValuePorLabel(label));
|
||||
renderRanking(el('rank-produtos'), produtos.ranking,
|
||||
(label) => toggleFiltro('f-produto', label),
|
||||
summary.total,
|
||||
(label) => f.produto === label);
|
||||
|
||||
carregarOportunidadesDoDia(produtos.ranking, summary.total);
|
||||
}
|
||||
|
||||
function carregarOportunidadesDoDia(produtosRanking, totalGeral) {
|
||||
const top3 = produtosRanking.slice(0, 3);
|
||||
if (!top3.length) {
|
||||
el('card-oportunidades-dia').replaceChildren(
|
||||
h('h2', {}, '🔥 Oportunidades do Dia'),
|
||||
h('div', { class: 'muted' }, 'Sem dados para este filtro.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
const produtoAtivo = filtroAtual().produto;
|
||||
el('card-oportunidades-dia').replaceChildren(
|
||||
h('h2', {}, '🔥 Oportunidades do Dia'),
|
||||
h('div', { class: 'ranking', style: 'margin-top:6px' }, ...top3.map((r) => {
|
||||
const p = totalGeral ? Math.round((r.total / totalGeral) * 100) : 0;
|
||||
return h('div', {
|
||||
class: `rank-row${produtoAtivo === r.label ? ' ativo' : ''}`,
|
||||
title: `${r.label}: ${fmt.format(r.total)} leads — clique para filtrar`,
|
||||
onclick: () => toggleFiltro('f-produto', r.label)
|
||||
},
|
||||
h('div', { class: 'rank-label' }, r.label),
|
||||
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, p)}%` })),
|
||||
h('div', { class: 'rank-value' }, `${p}%`)
|
||||
);
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function dataBrCurta(diaIso) {
|
||||
const [, mes, dia] = diaIso.split('-');
|
||||
return `${dia}/${mes}`;
|
||||
}
|
||||
|
||||
function carregarEvolucaoDiaria(evolution) {
|
||||
if (!evolution.length) {
|
||||
el('card-evolucao-diaria').replaceChildren(
|
||||
h('h2', {}, '📈 Evolução Diária'),
|
||||
h('div', { class: 'muted' }, 'Nenhuma exportação registrada ainda.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
const max = Math.max(...evolution.map((d) => d.total));
|
||||
el('card-evolucao-diaria').replaceChildren(
|
||||
h('h2', {}, '📈 Evolução Diária'),
|
||||
h('div', { class: 'muted', style: 'margin:2px 0 8px' }, 'Exportações por dia — única atividade com data registrada hoje.'),
|
||||
h('div', { class: 'ranking' }, ...evolution.map((d) => h('div', { class: 'rank-row nao-clicavel' },
|
||||
h('div', { class: 'rank-label' }, dataBrCurta(d.dia)),
|
||||
h('div', { class: 'rank-track' }, h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (d.total / max) * 100)}%` })),
|
||||
h('div', { class: 'rank-value' }, fmt.format(d.total))
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
function carregarMaiorOportunidade(oportunidade) {
|
||||
if (!oportunidade) {
|
||||
el('card-maior-oportunidade').replaceChildren(
|
||||
h('h2', {}, '🏆 Maior Oportunidade'),
|
||||
h('div', { class: 'muted' }, 'Sem dados suficientes para este filtro.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
const f = filtroAtual();
|
||||
const jaAplicado = f.cidade === oportunidade.cidade && f.produto === oportunidade.produto;
|
||||
el('card-maior-oportunidade').replaceChildren(
|
||||
h('h2', {}, '🏆 Maior Oportunidade'),
|
||||
h('div', {
|
||||
class: `row space-between oportunidade-clicavel${jaAplicado ? ' ativo' : ''}`,
|
||||
style: 'margin-top:6px',
|
||||
title: jaAplicado ? 'Clique para remover o filtro cidade + produto' : 'Clique para filtrar por esta cidade + produto',
|
||||
onclick: () => {
|
||||
el('f-cidade').value = jaAplicado ? '' : oportunidade.cidade;
|
||||
el('f-produto').value = jaAplicado ? '' : oportunidade.produto;
|
||||
atualizar();
|
||||
}
|
||||
},
|
||||
h('div', {}, h('div', { class: 'rotulo' }, 'Cidade'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.cidade)),
|
||||
h('div', {}, h('div', { class: 'rotulo' }, 'Produto'), h('div', { class: 'valor', style: 'font-size:20px' }, oportunidade.produto)),
|
||||
h('div', {}, h('div', { class: 'rotulo' }, 'Empresas'), h('div', { class: 'valor', style: 'font-size:20px' }, fmt.format(oportunidade.total)))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderRanking(box, ranking, onClick, totalGeral, isAtivo = () => false) {
|
||||
if (!ranking.length) {
|
||||
box.replaceChildren(h('div', { class: 'muted' }, 'Sem dados para este filtro.'));
|
||||
return;
|
||||
}
|
||||
const max = ranking[0].total;
|
||||
box.replaceChildren(...ranking.map((r) => {
|
||||
const pctGeral = totalGeral ? ` · ${Math.round((r.total / totalGeral) * 100)}%` : '';
|
||||
const ativo = isAtivo(r.label);
|
||||
return h('div', {
|
||||
class: `rank-row${ativo ? ' ativo' : ''}`,
|
||||
title: `${r.label}: ${fmt.format(r.total)} leads (${fmt.format(r.p1)} prioridade 1) — ${ativo ? 'clique para remover o filtro' : 'clique para filtrar'}`,
|
||||
onclick: () => onClick(r.label)
|
||||
},
|
||||
h('div', { class: 'rank-label' }, r.label),
|
||||
h('div', { class: 'rank-track' },
|
||||
h('div', { class: 'rank-fill', style: `width:${Math.max(1.5, (r.total / max) * 100)}%` })),
|
||||
h('div', { class: 'rank-value' }, `${fmt.format(r.total)}${pctGeral}`)
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
const FUNIL_ETAPAS = [
|
||||
{ key: 'total', label: 'Leads capturados' },
|
||||
{ key: 'disponiveis', label: 'Leads disponíveis' },
|
||||
{ key: 'exportados', label: 'Leads exportados' },
|
||||
{ key: null, label: 'Empresas contatadas' },
|
||||
{ key: null, label: 'Empresas interessadas' },
|
||||
{ key: null, label: 'Propostas enviadas' },
|
||||
{ key: null, label: 'Clientes convertidos' }
|
||||
];
|
||||
|
||||
function carregarFunil(s) {
|
||||
const base = s.total || 1;
|
||||
el('card-funil').replaceChildren(
|
||||
h('h2', {}, 'Evolução Comercial'),
|
||||
h('div', { class: 'funil' }, ...FUNIL_ETAPAS.map((etapa) => {
|
||||
const pendente = etapa.key === null;
|
||||
const valor = pendente ? null : s[etapa.key];
|
||||
const largura = pendente ? 0 : Math.max(1.5, (valor / base) * 100);
|
||||
return h('div', { class: `funil-etapa${pendente ? ' pendente' : ''}`,
|
||||
title: pendente ? 'Aguardando integração com CRM' : '' },
|
||||
h('div', { class: 'funil-label' }, etapa.label),
|
||||
h('div', { class: 'funil-barra' }, h('div', { class: 'funil-fill', style: `width:${largura}%` })),
|
||||
h('div', { class: 'funil-valor' }, pendente ? '—' : fmt.format(valor))
|
||||
);
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function carregarTabela() {
|
||||
const data = await API.get(`/api/leads?${filtroQuery({ page, pageSize: 50 })}`);
|
||||
totalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
|
||||
el('leads-total').textContent = `— ${fmt.format(data.total)} encontrados`;
|
||||
el('pg-info').textContent = `página ${data.page} de ${fmt.format(totalPages)}`;
|
||||
el('pg-prev').disabled = page <= 1;
|
||||
el('pg-next').disabled = page >= totalPages;
|
||||
|
||||
const tbody = document.querySelector('#tabela-leads tbody');
|
||||
if (!data.rows.length) {
|
||||
tbody.replaceChildren(h('tr', {}, h('td', { colspan: '9', class: 'muted' }, 'Nenhum lead com este filtro.')));
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) },
|
||||
h('td', { class: 'empresa-cell', title: lead.legal_name || lead.empresa }, lead.empresa),
|
||||
h('td', {}, lead.whatsapp || '—'),
|
||||
h('td', {}, lead.cidade),
|
||||
h('td', {}, lead.segmento_label || lead.segmento),
|
||||
h('td', {}, h('span', { class: `badge prioridade-${lead.prioridade}` }, `P${lead.prioridade}`)),
|
||||
h('td', { class: 'num' }, String(lead.score)),
|
||||
h('td', {}, h('span', { class: `badge ${lead.classificacao}` }, CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao)),
|
||||
h('td', { class: 'produto-cell' }, produtoCell(lead.produto_principal, lead.afinidade)),
|
||||
h('td', {}, lead.exported_at ? '📤 sim' : '—')
|
||||
)));
|
||||
}
|
||||
|
||||
const AFINIDADE_TABELA_LABELS = { alta: 'Alta aderência', media: 'Média aderência', baixa: 'Baixa aderência' };
|
||||
|
||||
function produtoCell(produto, afinidade) {
|
||||
if (!produto) return '—';
|
||||
const label = AFINIDADE_TABELA_LABELS[afinidade];
|
||||
return label ? `${produto} · ${label}` : produto;
|
||||
}
|
||||
|
||||
const OBSERVACAO_POR_PRIORIDADE = {
|
||||
1: 'Empresa apta para abordagem imediata.',
|
||||
2: 'Aguardar a janela de trabalho desta semana.',
|
||||
3: 'Abordar quando não houver leads de prioridade mais alta.',
|
||||
4: 'Reservar para campanhas específicas — baixa prioridade comercial.'
|
||||
};
|
||||
const AFINIDADE_LABELS = { alta: 'nicho de alta aderência', media: 'nicho de média aderência', baixa: 'nicho de baixa aderência' };
|
||||
|
||||
function montarResumoComercial(lead) {
|
||||
const potencial = Math.max(1, Math.min(5, Math.round((lead.score_comercial || 0) / 20)));
|
||||
const pontosFortes = [];
|
||||
if (lead.telefone_valido) pontosFortes.push('WhatsApp válido');
|
||||
if (lead.cnpj_valido) pontosFortes.push('CNPJ válido');
|
||||
if (lead.website) pontosFortes.push('Website');
|
||||
if (lead.email) pontosFortes.push('E-mail cadastrado');
|
||||
if (lead.afinidade) pontosFortes.push(AFINIDADE_LABELS[lead.afinidade] || 'nicho identificado');
|
||||
|
||||
return h('div', { class: 'resumo-comercial' },
|
||||
h('h3', {}, 'Resumo comercial'),
|
||||
h('div', { class: 'row space-between' },
|
||||
h('span', { class: 'estrelas', title: `Potencial comercial: ${potencial}/5` }, estrelas(potencial)),
|
||||
h('span', { class: `badge prioridade-${lead.prioridade}` }, PRIORIDADE_LABELS[lead.prioridade] || `P${lead.prioridade}`)
|
||||
),
|
||||
lead.produtos && lead.produtos.length
|
||||
? h('div', {}, h('div', { class: 'muted', style: 'margin:8px 0 4px' }, 'Produtos recomendados'),
|
||||
...lead.produtos.map((p) => h('div', {}, h('span', { class: 'estrelas' }, estrelas(p.estrelas)), ` ${p.nome}`)))
|
||||
: h('div', { class: 'muted', style: 'margin-top:8px' }, 'Nenhum produto sugerido para este nicho.'),
|
||||
pontosFortes.length
|
||||
? h('ul', { class: 'pontos-fortes', style: 'margin-top:10px' }, ...pontosFortes.map((p) => h('li', {}, `✓ ${p}`)))
|
||||
: null,
|
||||
h('p', { class: 'muted', style: 'margin:10px 0 0' }, OBSERVACAO_POR_PRIORIDADE[lead.prioridade] || '')
|
||||
);
|
||||
}
|
||||
|
||||
function montarScoreBreakdown(lead) {
|
||||
const nichoPontos = lead.afinidade === 'alta' ? '+40' : lead.afinidade === 'baixa' ? '+20' : '+30';
|
||||
const qualidade = [
|
||||
[lead.telefone_valido, 'WhatsApp válido', '+30'],
|
||||
[lead.cnpj_valido, 'CNPJ válido', '+20'],
|
||||
[lead.empresa_valida, 'Empresa válida', '+20'],
|
||||
[!!lead.cidade, 'Cidade identificada', '+5'],
|
||||
[!!lead.segmento, 'Nicho identificado', '+10'],
|
||||
[!!lead.cnae, 'CNAE identificado', '+10'],
|
||||
[!!lead.website, 'Website informado', '+5']
|
||||
];
|
||||
const comercial = [
|
||||
[true, `Nicho ${AFINIDADE_TABELA_LABELS[lead.afinidade] || 'de aderência não identificada'}`, nichoPontos],
|
||||
[!!lead.website, 'Website informado', '+15'],
|
||||
[!!lead.email, 'E-mail informado', '+10'],
|
||||
[lead.telefone_valido, 'WhatsApp válido', '+15'],
|
||||
[lead.cnpj_valido, 'CNPJ válido', '+20']
|
||||
];
|
||||
|
||||
const bloco = (itens, titulo, valorTotal) => h('div', {},
|
||||
h('div', { class: 'row space-between', style: 'margin-top:10px' },
|
||||
h('strong', {}, titulo), h('span', {}, String(valorTotal))),
|
||||
h('ul', { class: 'score-breakdown' }, ...itens.map(([ok, label, pontos]) =>
|
||||
h('li', { class: ok ? 'ok' : 'nok' }, `${ok ? '✓' : '✗'} ${label} (${pontos})`)))
|
||||
);
|
||||
|
||||
return h('div', { class: 'score-breakdown-wrap' },
|
||||
bloco(qualidade, 'Score Qualidade', lead.score_qualidade),
|
||||
bloco(comercial, 'Score Comercial', lead.score_comercial)
|
||||
);
|
||||
}
|
||||
|
||||
async function abrirLead(id) {
|
||||
const lead = await API.get(`/api/leads/${id}`);
|
||||
const linha = (dt, dd) => [h('dt', {}, dt), h('dd', {}, dd || '—')];
|
||||
el('lead-detalhe').replaceChildren(
|
||||
h('h2', {}, lead.empresa),
|
||||
montarResumoComercial(lead),
|
||||
montarScoreBreakdown(lead),
|
||||
h('dl', { class: 'detalhe-grid', style: 'margin-top:14px' },
|
||||
linha('Razão social', lead.legal_name),
|
||||
linha('WhatsApp', lead.whatsapp),
|
||||
linha('Cidade/UF', `${lead.cidade}/${lead.uf}`),
|
||||
linha('Endereço', lead.endereco),
|
||||
linha('Nicho', lead.segmento_label || lead.segmento),
|
||||
linha('Interesse', lead.interesse),
|
||||
linha('CNPJ', lead.cnpj),
|
||||
linha('CNAE', lead.cnae),
|
||||
linha('Score geral', `${lead.score} (${CLASSIFICACAO_LABELS[lead.classificacao] || lead.classificacao})`),
|
||||
linha('Dores', (lead.pains || []).join(', ')),
|
||||
linha('Origem', lead.origem),
|
||||
linha('Capturado em', dataBr(lead.captured_at || lead.created_at)),
|
||||
linha('Exportado em', lead.exported_at ? dataBr(lead.exported_at) : 'ainda não'),
|
||||
linha('Exportações', lead.exports.length
|
||||
? lead.exports.map((e) => `#${e.id} ${e.tipo}`).join(', ')
|
||||
: 'nenhuma')
|
||||
),
|
||||
h('div', { class: 'row space-between', style: 'margin-top:14px' },
|
||||
h('button', { class: 'btn-ghost', onclick: () => el('modal-lead').close() }, 'Fechar')
|
||||
)
|
||||
);
|
||||
el('modal-lead').showModal();
|
||||
}
|
||||
|
||||
// ---------- exportação ----------
|
||||
const EXPORT_ATALHOS = [
|
||||
{ label: 'Só P1', set: () => { el('f-prioridade').value = '1'; } },
|
||||
{ label: 'Só ERP', set: () => { el('f-produto').value = 'ERP'; } },
|
||||
{ label: 'Só G-Chat', set: () => { el('f-produto').value = 'G-Chat'; } },
|
||||
{ label: 'Só Sistema de Ponto', set: () => { el('f-produto').value = 'Sistema de Ponto'; } },
|
||||
{ label: 'Não exportados', set: () => { el('f-exportado').value = 'nao'; } }
|
||||
];
|
||||
|
||||
async function atualizarRecapExport() {
|
||||
const { total } = await API.get(`/api/dashboard/summary?${filtroQuery()}`);
|
||||
el('export-recap').textContent = `${fmt.format(total)} leads · ${descreverFiltro()}`;
|
||||
return total;
|
||||
}
|
||||
|
||||
function renderAtalhosExport() {
|
||||
el('export-atalhos').replaceChildren(...EXPORT_ATALHOS.map((atalho) =>
|
||||
h('button', {
|
||||
type: 'button', class: 'chip', onclick: async () => {
|
||||
atalho.set();
|
||||
await atualizar();
|
||||
await atualizarRecapExport();
|
||||
}
|
||||
}, atalho.label)));
|
||||
}
|
||||
|
||||
async function abrirModalExport() {
|
||||
const total = await atualizarRecapExport();
|
||||
if (!total) {
|
||||
toast('Nenhum lead com este filtro — ajuste os filtros antes de exportar');
|
||||
return;
|
||||
}
|
||||
renderAtalhosExport();
|
||||
|
||||
const { settings } = await API.get('/api/settings');
|
||||
el('zp-pipeline').value = settings['zpro.pipelineId'] || 1;
|
||||
el('zp-stage').value = settings['zpro.stageId'] || 1;
|
||||
el('zp-responsible').value = settings['zpro.responsibleId'] || 15;
|
||||
|
||||
el('modal-export').showModal();
|
||||
}
|
||||
|
||||
async function confirmarExport(event) {
|
||||
event.preventDefault();
|
||||
const tipo = document.querySelector('input[name="export-tipo"]:checked').value;
|
||||
|
||||
const body = {
|
||||
tipo,
|
||||
filtro: filtroAtual(),
|
||||
params: {
|
||||
marcarExportado: el('export-marcar').checked,
|
||||
pipelineId: Number(el('zp-pipeline').value) || undefined,
|
||||
stageId: Number(el('zp-stage').value) || undefined,
|
||||
responsibleId: Number(el('zp-responsible').value) || undefined,
|
||||
mensagem: tipo === 'zpro-bulk' ? el('export-mensagem').value : undefined
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const { jobId, totalEstimado } = await API.post('/api/exports', body);
|
||||
el('modal-export').close();
|
||||
toast(`Gerando arquivos para ${fmt.format(totalEstimado)} leads...`);
|
||||
|
||||
API.watchJob(jobId, {
|
||||
done() {
|
||||
toast('✅ Exportação pronta! Baixe os arquivos na lista abaixo.');
|
||||
carregarExports();
|
||||
atualizar();
|
||||
},
|
||||
error(e) {
|
||||
toast(`❌ Erro na exportação: ${e.message}`);
|
||||
carregarExports();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
toast(`Erro: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function carregarExports() {
|
||||
const { exports } = await API.get('/api/exports?limit=12');
|
||||
const box = el('historico-exports');
|
||||
if (!exports.length) {
|
||||
box.replaceChildren(h('div', { class: 'muted' }, 'Nenhuma exportação gerada ainda.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const tipoLabel = {
|
||||
excel: '📊 Planilha', 'zpro-oportunidades': '🗂️ Z-PRO Oportunidades',
|
||||
'zpro-bulk': '📨 Z-PRO Disparo em massa', legado: '📦 Importação antiga (Z-PRO)'
|
||||
};
|
||||
|
||||
box.replaceChildren(...exports.map((e) => h('div', { class: 'item' },
|
||||
h('div', {},
|
||||
h('div', {}, h('strong', {}, `#${e.id} · ${tipoLabel[e.tipo] || e.tipo}`),
|
||||
` · ${fmt.format(e.total_leads)} leads`),
|
||||
h('div', { class: 'sub' }, `${dataBr(e.created_at)} · ${e.status}${e.error ? ` · ${e.error.slice(0, 60)}` : ''}`)
|
||||
),
|
||||
h('div', { class: 'row' }, ...(e.status === 'done' && e.tipo !== 'legado'
|
||||
? e.arquivos.map((nome) =>
|
||||
h('a', { class: 'btn-ghost', href: `/api/exports/${e.id}/download?arquivo=${encodeURIComponent(nome)}` },
|
||||
`⬇ ${nome.split('.').pop().toUpperCase()}`))
|
||||
: []))
|
||||
)));
|
||||
}
|
||||
|
||||
// ---------- eventos ----------
|
||||
function bindEvents() {
|
||||
let debounce = null;
|
||||
for (const input of document.querySelectorAll('#filtros [data-filtro]')) {
|
||||
input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(atualizar, input.tagName === 'SELECT' ? 0 : 350);
|
||||
});
|
||||
}
|
||||
el('btn-limpar-filtros').addEventListener('click', () => {
|
||||
for (const input of document.querySelectorAll('#filtros [data-filtro]')) input.value = '';
|
||||
atualizar();
|
||||
});
|
||||
el('pg-prev').addEventListener('click', () => { page -= 1; carregarTabela(); });
|
||||
el('pg-next').addEventListener('click', () => { page += 1; carregarTabela(); });
|
||||
el('btn-exportar').addEventListener('click', abrirModalExport);
|
||||
el('form-export').addEventListener('submit', confirmarExport);
|
||||
|
||||
for (const radio of document.querySelectorAll('input[name="export-tipo"]')) {
|
||||
radio.addEventListener('change', () => {
|
||||
el('export-mensagem-wrap').classList.toggle('hidden', radio.value !== 'zpro-bulk');
|
||||
el('export-zpro-params').classList.toggle('hidden', radio.value === 'excel');
|
||||
});
|
||||
}
|
||||
el('export-zpro-params').classList.add('hidden');
|
||||
}
|
||||
|
||||
return { ativar, invalidar, bindEvents };
|
||||
})();
|
||||
|
||||
window.Tratamento = Tratamento;
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GTI Máquina Comercial — Login</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎯</text></svg>">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
|
||||
<main class="login-wrap">
|
||||
<form class="card login-card" id="form-login">
|
||||
<div class="brand login-brand">
|
||||
<span class="brand-mark">GTI</span>
|
||||
<span class="brand-name">Máquina Comercial</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Usuário <input type="text" id="login-username" autocomplete="username" required></label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Senha <input type="password" id="login-password" autocomplete="current-password" required></label>
|
||||
</div>
|
||||
<p class="muted login-erro" id="login-erro"></p>
|
||||
<button class="btn btn-primary btn-lg" type="submit">Entrar</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Baixa a base completa de CNPJ da Receita Federal (extrato mais recente) a partir do
|
||||
# espelho publico da Casa dos Dados (mesma base oficial, servida via Cloudflare).
|
||||
# Uso: bash scripts/baixar-base-rfb.sh
|
||||
set -e
|
||||
|
||||
BASE_URL="https://dados-abertos-rf-cnpj.casadosdados.com.br/arquivos/2026-06-14"
|
||||
DEST_DIR="prospeccao/cnpj/rfb-raw"
|
||||
mkdir -p "$DEST_DIR"
|
||||
|
||||
FILES=(
|
||||
Cnaes.zip Motivos.zip Municipios.zip Naturezas.zip Paises.zip Qualificacoes.zip Simples.zip
|
||||
Empresas0.zip Empresas1.zip Empresas2.zip Empresas3.zip Empresas4.zip
|
||||
Empresas5.zip Empresas6.zip Empresas7.zip Empresas8.zip Empresas9.zip
|
||||
Estabelecimentos0.zip Estabelecimentos1.zip Estabelecimentos2.zip Estabelecimentos3.zip Estabelecimentos4.zip
|
||||
Estabelecimentos5.zip Estabelecimentos6.zip Estabelecimentos7.zip Estabelecimentos8.zip Estabelecimentos9.zip
|
||||
Socios0.zip Socios1.zip Socios2.zip Socios3.zip Socios4.zip
|
||||
Socios5.zip Socios6.zip Socios7.zip Socios8.zip Socios9.zip
|
||||
)
|
||||
|
||||
TOTAL=${#FILES[@]}
|
||||
COUNT=0
|
||||
|
||||
for FILE in "${FILES[@]}"; do
|
||||
COUNT=$((COUNT+1))
|
||||
DEST="$DEST_DIR/$FILE"
|
||||
|
||||
if [ -f "$DEST" ]; then
|
||||
EXPECTED=$(curl -sI --max-time 20 "$BASE_URL/$FILE" | grep -i content-length | tr -d '\r' | awk '{print $2}')
|
||||
ACTUAL=$(stat -c%s "$DEST" 2>/dev/null || stat -f%z "$DEST" 2>/dev/null)
|
||||
if [ -n "$EXPECTED" ] && [ "$EXPECTED" == "$ACTUAL" ]; then
|
||||
echo "[$COUNT/$TOTAL] $FILE ja existe e esta completo ($ACTUAL bytes). Pulando."
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$COUNT/$TOTAL] baixando $FILE ..."
|
||||
curl -sS --retry 3 --retry-delay 5 --max-time 1800 -o "$DEST" "$BASE_URL/$FILE"
|
||||
SIZE=$(stat -c%s "$DEST" 2>/dev/null || stat -f%z "$DEST" 2>/dev/null)
|
||||
echo "[$COUNT/$TOTAL] $FILE concluido ($SIZE bytes)"
|
||||
done
|
||||
|
||||
echo "download completo."
|
||||
du -sh "$DEST_DIR"
|
||||
@@ -0,0 +1,22 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$target = Join-Path $root "GTI-Comercial.exe"
|
||||
$source = Join-Path $root "launcher\Program.cs"
|
||||
$sdk = & dotnet --list-sdks 2>$null
|
||||
|
||||
if ($sdk) {
|
||||
$project = Join-Path $root "launcher\GTI-Comercial.csproj"
|
||||
$publish = Join-Path $root "launcher\publish"
|
||||
dotnet publish $project -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o $publish
|
||||
$exe = Join-Path $publish "GTI-Comercial.exe"
|
||||
Copy-Item -LiteralPath $exe -Destination $target -Force
|
||||
} else {
|
||||
$csc = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
|
||||
if (-not (Test-Path -LiteralPath $csc)) {
|
||||
throw "Compilador C# nao encontrado: $csc"
|
||||
}
|
||||
& $csc /nologo /target:exe /out:$target $source
|
||||
}
|
||||
|
||||
Write-Host "Executavel gerado em: $target"
|
||||
@@ -0,0 +1,113 @@
|
||||
import { loadDotEnv } from '../src/env.mjs';
|
||||
import { openDb, ensureSeeds } from '../src/db.mjs';
|
||||
import { runCapture, createCaptureRun } from '../src/capture/runner.mjs';
|
||||
import { describeSources } from '../src/sources/index.mjs';
|
||||
|
||||
// CLI de captura (uso avancado / testes). A interface principal e o app:
|
||||
// node scripts/servidor.mjs (ou o GTI-Comercial.exe)
|
||||
//
|
||||
// Exemplos:
|
||||
// node scripts/capturar-leads.mjs --sources=mock
|
||||
// node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Santo Antonio de Jesus"
|
||||
// node scripts/capturar-leads.mjs --sources=cnpj-base,overpass --uf=BA --cities=all --segments=all
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const args = new Map(
|
||||
process.argv.slice(2).map((arg) => {
|
||||
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
|
||||
function usage() {
|
||||
const fontes = describeSources().map((s) => ` ${s.id} — ${s.label}${s.disponivel ? '' : ` (indisponivel: ${s.motivo})`}`);
|
||||
return [
|
||||
'Uso: node scripts/capturar-leads.mjs --sources=<ids> [opcoes]',
|
||||
'',
|
||||
'Opcoes:',
|
||||
' --sources=cnpj-base,overpass fontes separadas por virgula (ou "mock")',
|
||||
' --uf=BA estado alvo',
|
||||
' --cities=all | "A,B,C" cidades (all = todas as cidades da UF na base)',
|
||||
' --segments=all | "a,b,c" nichos pelo nome (all = todos os 57)',
|
||||
' --segment-limit=N usa so os N primeiros nichos',
|
||||
' --city-limit=N usa so as N primeiras cidades',
|
||||
' --max=10 max de resultados por consulta (fontes de API)',
|
||||
' --query-limit=0 max total de consultas (0 = sem limite)',
|
||||
'',
|
||||
'Fontes:',
|
||||
...fontes
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (args.has('help')) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const db = openDb();
|
||||
await ensureSeeds(db, { log: (m) => console.log(`[captura] ${m}`) });
|
||||
|
||||
const uf = (args.get('uf') || 'BA').toUpperCase();
|
||||
const sources = (args.get('sources') || 'mock').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
let segments = db.prepare('SELECT * FROM segments WHERE active = 1 ORDER BY id').all().map((row) => ({
|
||||
name: row.name,
|
||||
label: row.label,
|
||||
interest: row.interest,
|
||||
queries: JSON.parse(row.queries_json),
|
||||
cnaeCodes: JSON.parse(row.cnae_codes_json),
|
||||
overpassTags: JSON.parse(row.overpass_tags_json),
|
||||
pains: JSON.parse(row.pains_json)
|
||||
}));
|
||||
|
||||
const segmentsArg = args.get('segments');
|
||||
if (segmentsArg && segmentsArg !== 'all') {
|
||||
const wanted = new Set(segmentsArg.split(',').map((s) => s.trim()));
|
||||
segments = segments.filter((s) => wanted.has(s.name));
|
||||
}
|
||||
const segmentLimit = Number(args.get('segment-limit') || 0);
|
||||
if (segmentLimit > 0) segments = segments.slice(0, segmentLimit);
|
||||
|
||||
let cities;
|
||||
const citiesArg = args.get('cities') || 'all';
|
||||
if (citiesArg === 'all') {
|
||||
cities = db.prepare('SELECT nome FROM municipios WHERE uf = ? ORDER BY nome').all(uf).map((r) => r.nome);
|
||||
} else {
|
||||
cities = citiesArg.split(',').map((c) => c.trim()).filter(Boolean);
|
||||
}
|
||||
const cityLimit = Number(args.get('city-limit') || 0);
|
||||
if (cityLimit > 0) cities = cities.slice(0, cityLimit);
|
||||
|
||||
const params = {
|
||||
sources,
|
||||
uf,
|
||||
cities,
|
||||
segments,
|
||||
limits: {
|
||||
maxPerQuery: Number(args.get('max') || 10),
|
||||
queryLimit: Number(args.get('query-limit') || 0),
|
||||
googlePages: Number(args.get('google-pages') || 3)
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`[captura] fontes: ${sources.join(', ')} | UF: ${uf} | cidades: ${cities.length} | nichos: ${segments.length}`);
|
||||
|
||||
const runId = createCaptureRun(db, { ...params, segments: segments.map((s) => s.name) });
|
||||
const summary = await runCapture({
|
||||
db,
|
||||
runId,
|
||||
params,
|
||||
emit: (p) => console.log(`[captura] ${p.phase}: ${p.message || ''}`),
|
||||
log: (m) => console.log(`[captura] ${m}`)
|
||||
});
|
||||
|
||||
console.log(`[captura] resultado: ${summary.raw} brutos, ${summary.new} novos, ${summary.dup} duplicados (${summary.status})`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[captura] erro: ${error.stack || error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import path from 'node:path';
|
||||
import { loadDotEnv, paths } from '../src/env.mjs';
|
||||
import { openDb, ensureSeeds } from '../src/db.mjs';
|
||||
import { extractUf } from '../src/extract/rfb-extractor.mjs';
|
||||
|
||||
// Extrai a base CNPJ de uma UF inteira a partir dos zips da Receita Federal:
|
||||
// node scripts/extrair-base-cnpj.mjs --uf=BA
|
||||
// Pre-requisito: zips baixados em prospeccao/cnpj/rfb-raw (scripts/baixar-base-rfb.sh).
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const args = new Map(
|
||||
process.argv.slice(2).map((arg) => {
|
||||
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
|
||||
const uf = (args.get('uf') || '').toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(uf)) {
|
||||
console.error('Uso: node scripts/extrair-base-cnpj.mjs --uf=BA');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rawDir = args.get('raw-dir') || paths.rfbRaw;
|
||||
const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`);
|
||||
const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`);
|
||||
|
||||
async function main() {
|
||||
const db = openDb();
|
||||
await ensureSeeds(db, { log: (m) => console.log(`[extrair] ${m}`) });
|
||||
|
||||
const inserted = db.prepare(
|
||||
"INSERT INTO extractions (uf, status, started_at) VALUES (?, 'running', datetime('now'))"
|
||||
).run(uf);
|
||||
const extractionId = Number(inserted.lastInsertRowid);
|
||||
db.prepare('UPDATE extractions SET pid = ? WHERE id = ?').run(process.pid, extractionId);
|
||||
|
||||
try {
|
||||
const result = await extractUf({
|
||||
uf,
|
||||
rawDir,
|
||||
outCsv,
|
||||
checkpointPath,
|
||||
db,
|
||||
log: (m) => console.log(`[extrair] ${m}`),
|
||||
onProgress: (p) => {
|
||||
console.log(`[extrair] ${p.pct}% ${p.phase}: ${p.message}`);
|
||||
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify(p), extractionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.cancelled) {
|
||||
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?")
|
||||
.run(extractionId);
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE extractions SET status = 'done', csv_path = ?, total_rows = ?, with_phone = ?,
|
||||
municipios_count = ?, finished_at = datetime('now') WHERE id = ?`
|
||||
).run(result.csvPath, result.totalRows, result.withPhone, result.municipiosCount, extractionId);
|
||||
}
|
||||
} catch (error) {
|
||||
db.prepare(
|
||||
"UPDATE extractions SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?"
|
||||
).run(String(error.message || error), extractionId);
|
||||
throw error;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[extrair] erro: ${error.stack || error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { loadDotEnv } from '../src/env.mjs';
|
||||
import { openDb, transaction } from '../src/db.mjs';
|
||||
import { scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral } from '../src/scoring.mjs';
|
||||
|
||||
// Recalcula score_qualidade/score_comercial/score/classificacao/prioridade/
|
||||
// produto_principal de TODOS os leads a partir dos dados ja gravados + da
|
||||
// afinidade/produtos configurados por nicho (tabela segments). Nao e um
|
||||
// script de uso unico: roda de novo (`npm run recalc-scores`) sempre que o
|
||||
// catalogo de afinidade/produtos em prospeccao/config.json mudar.
|
||||
//
|
||||
// Roda com o servidor parado (evita concorrencia de escrita no WAL).
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const BATCH_SIZE = 2000;
|
||||
|
||||
function produtoPrincipalDe(produtos) {
|
||||
return produtos.length ? [...produtos].sort((a, b) => b.estrelas - a.estrelas)[0].nome : '';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const db = openDb();
|
||||
|
||||
const segmentos = new Map(
|
||||
db.prepare('SELECT name, affinity, products_json FROM segments').all()
|
||||
.map((row) => [row.name, { affinity: row.affinity, products: JSON.parse(row.products_json) }])
|
||||
);
|
||||
|
||||
const update = db.prepare(`
|
||||
UPDATE leads SET
|
||||
score = ?, classificacao = ?, motivo_classificacao = ?,
|
||||
score_qualidade = ?, score_comercial = ?, prioridade = ?,
|
||||
produto_principal = ?, produtos_json = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t;
|
||||
const porPrioridade = { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||
let processed = 0;
|
||||
let lastId = 0;
|
||||
|
||||
const page = db.prepare('SELECT * FROM leads WHERE id > ? ORDER BY id LIMIT ?');
|
||||
|
||||
while (true) {
|
||||
const rows = page.all(lastId, BATCH_SIZE);
|
||||
if (!rows.length) break;
|
||||
|
||||
transaction(db, () => {
|
||||
for (const lead of rows) {
|
||||
const info = segmentos.get(lead.segmento) || { affinity: 'media', products: [] };
|
||||
const qualidade = scoreQualidade({
|
||||
whatsapp: lead.whatsapp, cnpj: lead.cnpj, empresa: lead.empresa,
|
||||
cidade: lead.cidade, segmento: lead.segmento, cnae: lead.cnae, website: lead.website
|
||||
});
|
||||
const comercial = scoreComercial({
|
||||
whatsapp: lead.whatsapp, cnpj: lead.cnpj, website: lead.website,
|
||||
email: lead.email, affinity: info.affinity
|
||||
});
|
||||
const geral = scoreGeral(qualidade.score, comercial);
|
||||
const tier = classificarScoreGeral(geral);
|
||||
const produtoPrincipal = produtoPrincipalDe(info.products);
|
||||
|
||||
update.run(
|
||||
geral, tier.id, `score geral ${geral} (qualidade ${qualidade.score}, comercial ${comercial})`,
|
||||
qualidade.score, comercial, tier.prioridade,
|
||||
produtoPrincipal, JSON.stringify(info.products),
|
||||
lead.id
|
||||
);
|
||||
porPrioridade[tier.prioridade] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
processed += rows.length;
|
||||
lastId = rows[rows.length - 1].id;
|
||||
console.log(`${processed}/${total} leads recalculados...`);
|
||||
}
|
||||
|
||||
console.log('Concluido. Distribuicao por prioridade:', porPrioridade);
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Erro no recalculo de scores:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { loadDotEnv, GTI_PORT } from '../src/env.mjs';
|
||||
import { openDb, ensureSeeds } from '../src/db.mjs';
|
||||
import { createJobManager, recoverOrphans } from '../src/jobs/manager.mjs';
|
||||
import { createAppServer } from '../src/server/index.mjs';
|
||||
|
||||
// Servidor local do GTI Maquina Comercial. Inicie com:
|
||||
// npm start (ou node scripts/servidor.mjs, ou o GTI-Comercial.exe)
|
||||
// e abra http://127.0.0.1:3900 no navegador.
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const log = (message) => console.log(`[servidor] ${message}`);
|
||||
|
||||
const db = openDb();
|
||||
await ensureSeeds(db, { log });
|
||||
recoverOrphans(db, log);
|
||||
|
||||
const jobs = createJobManager();
|
||||
const server = createAppServer({ db, jobs, log });
|
||||
|
||||
const port = GTI_PORT;
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
log(`GTI Maquina Comercial rodando em http://127.0.0.1:${port}`);
|
||||
log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.');
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
log('encerrando...');
|
||||
server.close(() => {
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
setTimeout(() => process.exit(0), 3000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
@@ -0,0 +1,19 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const KEY_LEN = 64;
|
||||
|
||||
// Hash com salt aleatorio (scrypt) — formato armazenado: "salt:hash", ambos hex.
|
||||
export function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync(password, salt, KEY_LEN).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password, stored) {
|
||||
const [salt, hash] = String(stored || '').split(':');
|
||||
if (!salt || !hash) return false;
|
||||
|
||||
const expected = Buffer.from(hash, 'hex');
|
||||
const candidate = crypto.scryptSync(password, salt, expected.length);
|
||||
return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { loadDotEnv } from '../env.mjs';
|
||||
import { openDb } from '../db.mjs';
|
||||
import { runCapture } from './runner.mjs';
|
||||
import { createJobIO } from '../jobs/protocol.mjs';
|
||||
|
||||
// Processo filho de captura: le os parametros da linha em capture_runs,
|
||||
// resolve nichos/cidades e roda o runner, reportando progresso via stdout.
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const args = new Map(
|
||||
process.argv.slice(2).map((arg) => {
|
||||
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
|
||||
const io = createJobIO();
|
||||
|
||||
async function main() {
|
||||
const runId = Number(args.get('run-id'));
|
||||
if (!runId) throw new Error('capture-job: --run-id obrigatorio');
|
||||
|
||||
const db = openDb();
|
||||
const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId);
|
||||
if (!run) throw new Error(`capture_run #${runId} nao encontrado`);
|
||||
|
||||
const saved = JSON.parse(run.params_json);
|
||||
|
||||
let segments = db.prepare('SELECT * FROM segments WHERE active = 1 ORDER BY id').all().map((row) => ({
|
||||
name: row.name,
|
||||
label: row.label,
|
||||
interest: row.interest,
|
||||
queries: JSON.parse(row.queries_json),
|
||||
cnaeCodes: JSON.parse(row.cnae_codes_json),
|
||||
overpassTags: JSON.parse(row.overpass_tags_json),
|
||||
pains: JSON.parse(row.pains_json),
|
||||
affinity: row.affinity,
|
||||
products: JSON.parse(row.products_json)
|
||||
}));
|
||||
|
||||
{
|
||||
// Cada item selecionado pode ser o nome de um nicho pre-configurado, o
|
||||
// sentinela "all" (todos os pre-configurados), ou um CNAE avulso
|
||||
// escolhido na busca livre por atividade da Receita (prefixo "cnae:<codigo>").
|
||||
const wanted = Array.isArray(saved.segments) && saved.segments.length ? saved.segments : ['all'];
|
||||
const includeAllCurated = wanted.includes('all');
|
||||
const wantedNames = new Set(wanted.filter((id) => typeof id === 'string' && id !== 'all' && !id.startsWith('cnae:')));
|
||||
const wantedCnaes = wanted.filter((id) => typeof id === 'string' && id.startsWith('cnae:')).map((id) => id.slice('cnae:'.length));
|
||||
|
||||
if (!includeAllCurated) {
|
||||
segments = segments.filter((s) => wantedNames.has(s.name));
|
||||
}
|
||||
|
||||
if (wantedCnaes.length) {
|
||||
const placeholders = wantedCnaes.map(() => '?').join(',');
|
||||
const cnaeRows = db.prepare(`SELECT codigo, descricao FROM cnaes WHERE codigo IN (${placeholders})`).all(...wantedCnaes);
|
||||
for (const row of cnaeRows) {
|
||||
segments.push({
|
||||
name: `cnae_${row.codigo}`,
|
||||
label: row.descricao,
|
||||
interest: 'gestao de TI',
|
||||
queries: [row.descricao],
|
||||
cnaeCodes: [row.codigo],
|
||||
overpassTags: [],
|
||||
pains: ['atendimento', 'vendas'],
|
||||
affinity: 'media',
|
||||
products: []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cities = saved.cities;
|
||||
if (!Array.isArray(cities) || !cities.length || cities[0] === 'all') {
|
||||
cities = db.prepare('SELECT nome FROM municipios WHERE uf = ? ORDER BY nome')
|
||||
.all(saved.uf).map((r) => r.nome);
|
||||
}
|
||||
|
||||
const params = {
|
||||
sources: saved.sources,
|
||||
uf: saved.uf,
|
||||
cities,
|
||||
segments,
|
||||
limits: saved.limits || { maxPerQuery: 10, queryLimit: 0 }
|
||||
};
|
||||
|
||||
const summary = await runCapture({
|
||||
db,
|
||||
runId,
|
||||
params,
|
||||
emit: (p) => io.emit(p),
|
||||
log: (m) => io.log(m),
|
||||
isCancelled: io.isCancelled
|
||||
});
|
||||
|
||||
io.done({
|
||||
cancelled: summary.status === 'cancelled',
|
||||
raw: summary.raw,
|
||||
new: summary.new,
|
||||
dup: summary.dup,
|
||||
status: summary.status
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
io.fail(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cleanText } from '../lead-engine.mjs';
|
||||
import { UA_HEADERS, UF_NAMES } from '../sources/common.mjs';
|
||||
|
||||
// Resolve a bounding box de uma cidade para as consultas Overpass.
|
||||
// Substitui o antigo cityAreas fixo do config.json: consulta o Nominatim
|
||||
// uma unica vez por cidade e guarda para sempre em geo_cache (os bboxes
|
||||
// legados das 36 cidades da Bahia ja foram semeados como 'config-legacy').
|
||||
export function createGeocoder(db, { nominatimUrl }) {
|
||||
const select = db.prepare('SELECT south, west, north, east FROM geo_cache WHERE key = ?');
|
||||
const insert = db.prepare(
|
||||
`INSERT OR REPLACE INTO geo_cache (key, south, west, north, east, provider)
|
||||
VALUES (?, ?, ?, ?, ?, 'nominatim')`
|
||||
);
|
||||
|
||||
return async function resolveBbox(uf, cidade) {
|
||||
const key = `bbox:${uf}:${cleanText(cidade)}`;
|
||||
const cached = select.get(key);
|
||||
if (cached) return [cached.south, cached.west, cached.north, cached.east];
|
||||
|
||||
const url = new URL(nominatimUrl);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('q', `${cidade}, ${UF_NAMES[uf] || uf}, Brasil`);
|
||||
url.searchParams.set('countrycodes', 'br');
|
||||
url.searchParams.set('limit', '1');
|
||||
|
||||
const response = await fetch(url, { headers: UA_HEADERS });
|
||||
if (!response.ok) throw new Error(`Nominatim geocode HTTP ${response.status} para ${cidade}/${uf}`);
|
||||
|
||||
const results = await response.json();
|
||||
const box = results[0]?.boundingbox;
|
||||
if (!box || box.length !== 4) return null;
|
||||
|
||||
// Nominatim retorna [sul, norte, oeste, leste]; geo_cache guarda sul/oeste/norte/leste.
|
||||
const south = Number(box[0]);
|
||||
const north = Number(box[1]);
|
||||
const west = Number(box[2]);
|
||||
const east = Number(box[3]);
|
||||
if (![south, north, west, east].every(Number.isFinite)) return null;
|
||||
|
||||
insert.run(key, south, west, north, east);
|
||||
return [south, west, north, east];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral,
|
||||
dedupKey, extractDdd, segmentLabel, onlyDigits
|
||||
} from '../scoring.mjs';
|
||||
|
||||
// Converte um item processado ({payload, lead}) — formato produzido por
|
||||
// processLead e tambem o formato dos JSONs legados — na linha canonica da
|
||||
// tabela leads, com os scores/classificacao/dedup_key calculados.
|
||||
export function leadRowFromProcessed(item, fallbackOrigin = '') {
|
||||
const lead = item.lead || {};
|
||||
const prospecting = item.payload?.prospecting || lead.raw?.prospecting || {};
|
||||
const empresa = String(lead.companyName ?? '').trim();
|
||||
const whatsapp = onlyDigits(lead.phone);
|
||||
const cnpj = onlyDigits(prospecting.cnpj);
|
||||
const cidade = String(lead.city ?? '').trim();
|
||||
const segmento = String(lead.segment ?? '').trim();
|
||||
const cnae = String(prospecting.cnae ?? '').trim();
|
||||
const website = String(prospecting.website ?? '').trim();
|
||||
const email = String(lead.email ?? '').trim();
|
||||
const affinity = prospecting.affinity || 'media';
|
||||
const produtos = Array.isArray(prospecting.products) ? prospecting.products : [];
|
||||
|
||||
const qualidade = scoreQualidade({ whatsapp, cnpj, empresa, cidade, segmento, cnae, website });
|
||||
const comercial = scoreComercial({ whatsapp, cnpj, website, email, affinity });
|
||||
const geral = scoreGeral(qualidade.score, comercial);
|
||||
const tier = classificarScoreGeral(geral);
|
||||
const produtoPrincipal = produtos.length
|
||||
? [...produtos].sort((a, b) => b.estrelas - a.estrelas)[0].nome
|
||||
: '';
|
||||
|
||||
return {
|
||||
lead_uid: lead.id || null,
|
||||
dedup_key: dedupKey({ cnpj, whatsapp, empresa, cidade }),
|
||||
empresa,
|
||||
legal_name: String(prospecting.legal_name ?? ''),
|
||||
whatsapp,
|
||||
ddd: extractDdd(whatsapp),
|
||||
email,
|
||||
cidade,
|
||||
uf: String(lead.state ?? '').trim().toUpperCase(),
|
||||
segmento,
|
||||
segmento_label: segmentLabel(segmento),
|
||||
interesse: String(lead.serviceLabel ?? ''),
|
||||
cnpj,
|
||||
cnae,
|
||||
endereco: String(prospecting.address ?? ''),
|
||||
website,
|
||||
pains_json: JSON.stringify(lead.pains || []),
|
||||
origem: String(lead.source || fallbackOrigin || 'indefinida'),
|
||||
telefone_valido: qualidade.telefoneValido ? 1 : 0,
|
||||
cnpj_valido: qualidade.cnpjValido ? 1 : 0,
|
||||
empresa_valida: qualidade.empresaValida ? 1 : 0,
|
||||
score: geral,
|
||||
classificacao: tier.id,
|
||||
motivo_classificacao: `score geral ${geral} (qualidade ${qualidade.score}, comercial ${comercial})`,
|
||||
score_qualidade: qualidade.score,
|
||||
score_comercial: comercial,
|
||||
prioridade: tier.prioridade,
|
||||
produto_principal: produtoPrincipal,
|
||||
produtos_json: JSON.stringify(produtos),
|
||||
capture_run_id: null,
|
||||
captured_at: lead.createdAt || null,
|
||||
exported_at: null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { processLead } from '../lead-engine.mjs';
|
||||
import { createLeadInserter, transaction } from '../db.mjs';
|
||||
import { createGeocoder } from './geocode.mjs';
|
||||
import { leadRowFromProcessed } from './lead-row.mjs';
|
||||
import { getSource } from '../sources/index.mjs';
|
||||
import { envString, envNumber, paths } from '../env.mjs';
|
||||
|
||||
export function defaultEndpoints() {
|
||||
return {
|
||||
overpassUrls: envString(
|
||||
'OVERPASS_API_URL',
|
||||
'https://overpass-api.de/api/interpreter,https://overpass.kumi.systems/api/interpreter'
|
||||
).split(',').map((url) => url.trim()).filter(Boolean),
|
||||
nominatimUrl: envString('NOMINATIM_API_URL', 'https://nominatim.openstreetmap.org/search'),
|
||||
wikidataUrl: envString('WIKIDATA_API_URL', 'https://query.wikidata.org/sparql'),
|
||||
brasilApiUrl: envString('BRASILAPI_URL', 'https://brasilapi.com.br/api').replace(/\/$/, '')
|
||||
};
|
||||
}
|
||||
|
||||
// Executa uma captura: percorre as fontes selecionadas, processa cada lead
|
||||
// (normalizacao + score) e insere no banco em lotes. A dedup acontece no
|
||||
// proprio INSERT (ON CONFLICT dedup_key DO NOTHING) — leads ja capturados
|
||||
// em qualquer execucao anterior contam como duplicados.
|
||||
export async function runCapture({ db, runId, params, emit = () => {}, log = () => {}, isCancelled = () => false }) {
|
||||
const insert = createLeadInserter(db);
|
||||
const endpoints = defaultEndpoints();
|
||||
|
||||
const ctx = {
|
||||
db,
|
||||
emit,
|
||||
log,
|
||||
isCancelled,
|
||||
cnpjDir: params.cnpjDir || paths.cnpjDir,
|
||||
endpoints,
|
||||
delays: {
|
||||
overpassDelayMs: envNumber('OVERPASS_REQUEST_DELAY_MS', 5000),
|
||||
overpassTimeoutMs: envNumber('OVERPASS_TIMEOUT_MS', 30000)
|
||||
},
|
||||
overpassTextSearch: envString('OVERPASS_TEXT_SEARCH', 'true') !== 'false',
|
||||
geocode: createGeocoder(db, { nominatimUrl: endpoints.nominatimUrl })
|
||||
};
|
||||
|
||||
const counts = { raw: 0, new: 0, dup: 0 };
|
||||
const updateRun = runId
|
||||
? db.prepare(
|
||||
`UPDATE capture_runs SET raw_count = ?, new_count = ?, dup_count = ?, progress_json = ? WHERE id = ?`
|
||||
)
|
||||
: null;
|
||||
|
||||
const persistCounts = (progress) => {
|
||||
if (updateRun) {
|
||||
updateRun.run(counts.raw, counts.new, counts.dup, JSON.stringify(progress || {}), runId);
|
||||
}
|
||||
};
|
||||
|
||||
if (runId) {
|
||||
db.prepare(
|
||||
`UPDATE capture_runs SET status = 'running', pid = ?, started_at = datetime('now') WHERE id = ?`
|
||||
).run(process.pid, runId);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
try {
|
||||
for (const sourceId of params.sources) {
|
||||
if (isCancelled()) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const source = getSource(sourceId);
|
||||
if (!source) {
|
||||
log(`fonte desconhecida: ${sourceId} — pulada`);
|
||||
continue;
|
||||
}
|
||||
|
||||
log(`fonte ${sourceId}: iniciando`);
|
||||
emit({ phase: sourceId, message: 'iniciando', counts: { ...counts } });
|
||||
|
||||
for await (const batch of source.collect(params, ctx)) {
|
||||
counts.raw += batch.leads.length;
|
||||
|
||||
transaction(db, () => {
|
||||
for (const payload of batch.leads) {
|
||||
const result = processLead(payload);
|
||||
const row = leadRowFromProcessed({ payload, lead: result.lead }, sourceId);
|
||||
row.capture_run_id = runId || null;
|
||||
if (insert(row) === 'inserted') counts.new += 1;
|
||||
else counts.dup += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const progress = {
|
||||
phase: sourceId,
|
||||
message: `${batch.label}: ${counts.new} novos, ${counts.dup} duplicados (${counts.raw} brutos)`,
|
||||
counts: { ...counts }
|
||||
};
|
||||
emit(progress);
|
||||
persistCounts(progress);
|
||||
|
||||
if (isCancelled()) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) break;
|
||||
log(`fonte ${sourceId}: concluida`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (runId) {
|
||||
db.prepare(
|
||||
`UPDATE capture_runs SET status = 'error', error = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?`
|
||||
).run(String(error.message || error), counts.raw, counts.new, counts.dup, runId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const finalStatus = cancelled ? 'cancelled' : 'done';
|
||||
if (runId) {
|
||||
db.prepare(
|
||||
`UPDATE capture_runs SET status = ?, raw_count = ?, new_count = ?, dup_count = ?, finished_at = datetime('now') WHERE id = ?`
|
||||
).run(finalStatus, counts.raw, counts.new, counts.dup, runId);
|
||||
}
|
||||
|
||||
return { ...counts, status: finalStatus };
|
||||
}
|
||||
|
||||
export function createCaptureRun(db, params) {
|
||||
const result = db.prepare('INSERT INTO capture_runs (params_json, status) VALUES (?, ?)')
|
||||
.run(JSON.stringify(params), 'pending');
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { paths } from './env.mjs';
|
||||
import { segmentLabel } from './scoring.mjs';
|
||||
import { cleanText } from './lead-engine.mjs';
|
||||
import { hashPassword } from './auth.mjs';
|
||||
|
||||
const MIGRATIONS = [
|
||||
// v1 — esquema inicial
|
||||
`
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE municipios (
|
||||
codigo_ibge TEXT PRIMARY KEY,
|
||||
codigo_tom TEXT,
|
||||
nome TEXT NOT NULL,
|
||||
nome_clean TEXT NOT NULL,
|
||||
uf TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_municipios_uf ON municipios(uf, nome_clean);
|
||||
CREATE INDEX idx_municipios_tom ON municipios(codigo_tom);
|
||||
|
||||
CREATE TABLE cnaes (
|
||||
codigo TEXT PRIMARY KEY,
|
||||
descricao TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE segments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
interest TEXT,
|
||||
queries_json TEXT NOT NULL DEFAULT '[]',
|
||||
cnae_codes_json TEXT NOT NULL DEFAULT '[]',
|
||||
overpass_tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
pains_json TEXT NOT NULL DEFAULT '[]',
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE geo_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
south REAL, west REAL, north REAL, east REAL,
|
||||
provider TEXT NOT NULL,
|
||||
resolved_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE capture_runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
job_id TEXT UNIQUE,
|
||||
params_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
progress_json TEXT,
|
||||
raw_count INTEGER NOT NULL DEFAULT 0,
|
||||
new_count INTEGER NOT NULL DEFAULT 0,
|
||||
dup_count INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
pid INTEGER,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE extractions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
job_id TEXT UNIQUE,
|
||||
uf TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
csv_path TEXT,
|
||||
total_rows INTEGER,
|
||||
with_phone INTEGER,
|
||||
municipios_count INTEGER,
|
||||
progress_json TEXT,
|
||||
error TEXT,
|
||||
pid INTEGER,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE leads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
lead_uid TEXT,
|
||||
dedup_key TEXT NOT NULL UNIQUE,
|
||||
empresa TEXT NOT NULL,
|
||||
legal_name TEXT DEFAULT '',
|
||||
whatsapp TEXT DEFAULT '',
|
||||
ddd TEXT DEFAULT '',
|
||||
email TEXT DEFAULT '',
|
||||
cidade TEXT DEFAULT '',
|
||||
uf TEXT DEFAULT '',
|
||||
segmento TEXT DEFAULT '',
|
||||
segmento_label TEXT DEFAULT '',
|
||||
interesse TEXT DEFAULT '',
|
||||
cnpj TEXT DEFAULT '',
|
||||
cnae TEXT DEFAULT '',
|
||||
endereco TEXT DEFAULT '',
|
||||
website TEXT DEFAULT '',
|
||||
pains_json TEXT NOT NULL DEFAULT '[]',
|
||||
origem TEXT NOT NULL DEFAULT '',
|
||||
telefone_valido INTEGER NOT NULL DEFAULT 0,
|
||||
cnpj_valido INTEGER NOT NULL DEFAULT 0,
|
||||
empresa_valida INTEGER NOT NULL DEFAULT 0,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
classificacao TEXT NOT NULL DEFAULT 'ruim',
|
||||
motivo_classificacao TEXT DEFAULT '',
|
||||
capture_run_id INTEGER REFERENCES capture_runs(id),
|
||||
captured_at TEXT,
|
||||
exported_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_leads_uf_cidade ON leads(uf, cidade);
|
||||
CREATE INDEX idx_leads_class_score ON leads(classificacao, score DESC);
|
||||
CREATE INDEX idx_leads_segmento ON leads(segmento);
|
||||
CREATE INDEX idx_leads_whatsapp ON leads(whatsapp);
|
||||
CREATE INDEX idx_leads_cnpj ON leads(cnpj);
|
||||
CREATE INDEX idx_leads_exported ON leads(exported_at);
|
||||
CREATE INDEX idx_leads_origem ON leads(origem);
|
||||
|
||||
CREATE TABLE exports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
job_id TEXT UNIQUE,
|
||||
tipo TEXT NOT NULL,
|
||||
filtro_json TEXT NOT NULL DEFAULT '{}',
|
||||
params_json TEXT NOT NULL DEFAULT '{}',
|
||||
arquivos_json TEXT NOT NULL DEFAULT '[]',
|
||||
total_leads INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE export_leads (
|
||||
export_id INTEGER NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
|
||||
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||
PRIMARY KEY (export_id, lead_id)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX idx_export_leads_lead ON export_leads(lead_id);
|
||||
`,
|
||||
// v2 — score de qualidade dos dados + score comercial (aderencia de nicho,
|
||||
// website, e-mail) + score geral (60% comercial + 40% qualidade), prioridade
|
||||
// comercial (1-4, derivada da classificacao) e produto recomendado por nicho.
|
||||
`
|
||||
ALTER TABLE segments ADD COLUMN affinity TEXT NOT NULL DEFAULT 'media';
|
||||
ALTER TABLE segments ADD COLUMN products_json TEXT NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE leads ADD COLUMN score_qualidade INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE leads ADD COLUMN score_comercial INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE leads ADD COLUMN prioridade INTEGER NOT NULL DEFAULT 4;
|
||||
ALTER TABLE leads ADD COLUMN produto_principal TEXT DEFAULT '';
|
||||
ALTER TABLE leads ADD COLUMN produtos_json TEXT NOT NULL DEFAULT '[]';
|
||||
|
||||
CREATE INDEX idx_leads_prioridade ON leads(prioridade, score DESC);
|
||||
CREATE INDEX idx_leads_produto ON leads(produto_principal);
|
||||
`,
|
||||
// v3 — login: usuarios do app (senha com hash, papel admin/comercial).
|
||||
`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'comercial',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`
|
||||
];
|
||||
|
||||
export function openDb(dbPath = paths.db) {
|
||||
if (dbPath !== ':memory:') {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec('PRAGMA journal_mode = WAL;');
|
||||
db.exec('PRAGMA busy_timeout = 5000;');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function migrate(db) {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);`);
|
||||
|
||||
const row = db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get();
|
||||
const current = row?.version || 0;
|
||||
|
||||
for (let version = current + 1; version <= MIGRATIONS.length; version += 1) {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.exec(MIGRATIONS[version - 1]);
|
||||
db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function transaction(db, fn) {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const result = fn();
|
||||
db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const LEAD_COLUMNS = [
|
||||
'lead_uid', 'dedup_key', 'empresa', 'legal_name', 'whatsapp', 'ddd', 'email',
|
||||
'cidade', 'uf', 'segmento', 'segmento_label', 'interesse', 'cnpj', 'cnae',
|
||||
'endereco', 'website', 'pains_json', 'origem', 'telefone_valido', 'cnpj_valido',
|
||||
'empresa_valida', 'score', 'classificacao', 'motivo_classificacao',
|
||||
'score_qualidade', 'score_comercial', 'prioridade', 'produto_principal', 'produtos_json',
|
||||
'capture_run_id', 'captured_at', 'exported_at'
|
||||
];
|
||||
|
||||
// INSERT com dedup embutida: conflitos na dedup_key sao ignorados
|
||||
// (lead ja existe de captura/importacao anterior).
|
||||
export function createLeadInserter(db) {
|
||||
const placeholders = LEAD_COLUMNS.map((c) => `:${c}`).join(', ');
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO leads (${LEAD_COLUMNS.join(', ')}) VALUES (${placeholders})
|
||||
ON CONFLICT(dedup_key) DO NOTHING`
|
||||
);
|
||||
|
||||
return (lead) => {
|
||||
const params = {};
|
||||
for (const column of LEAD_COLUMNS) {
|
||||
const value = lead[column];
|
||||
params[column] = value === undefined || value === null ? null : value;
|
||||
}
|
||||
if (params.empresa === null) params.empresa = '';
|
||||
if (params.score_qualidade === null) params.score_qualidade = 0;
|
||||
if (params.score_comercial === null) params.score_comercial = 0;
|
||||
if (params.prioridade === null) params.prioridade = 4;
|
||||
if (params.produto_principal === null) params.produto_principal = '';
|
||||
if (params.produtos_json === null) params.produtos_json = '[]';
|
||||
if (params.dedup_key === null) throw new Error('lead sem dedup_key');
|
||||
const result = stmt.run(params);
|
||||
return result.changes > 0 ? 'inserted' : 'duplicate';
|
||||
};
|
||||
}
|
||||
|
||||
export function getSetting(db, key, fallback = null) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : fallback;
|
||||
}
|
||||
|
||||
export function setSetting(db, key, value) {
|
||||
db.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run(key, String(value));
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
'zpro.pipelineId': '1',
|
||||
'zpro.stageId': '1',
|
||||
'zpro.responsibleId': '15',
|
||||
'zpro.status': 'open',
|
||||
'zpro.closingForecastDays': '30',
|
||||
'zpro.validateNumber': 'true',
|
||||
'meta_diaria': '100'
|
||||
};
|
||||
|
||||
export function seedSettings(db, extra = {}) {
|
||||
const entries = { ...DEFAULT_SETTINGS, ...extra };
|
||||
transaction(db, () => {
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
stmt.run(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Upsert (nao so INSERT OR IGNORE): reaplicar o config.json com afinidade/produtos
|
||||
// ajustados deve atualizar os 57 nichos ja existentes, nao so inserir os novos.
|
||||
export function seedSegments(db, segments) {
|
||||
transaction(db, () => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO segments (name, label, interest, queries_json, cnae_codes_json, overpass_tags_json, pains_json, affinity, products_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
interest = excluded.interest,
|
||||
queries_json = excluded.queries_json,
|
||||
cnae_codes_json = excluded.cnae_codes_json,
|
||||
overpass_tags_json = excluded.overpass_tags_json,
|
||||
pains_json = excluded.pains_json,
|
||||
affinity = excluded.affinity,
|
||||
products_json = excluded.products_json`
|
||||
);
|
||||
for (const segment of segments) {
|
||||
stmt.run(
|
||||
segment.name,
|
||||
segmentLabel(segment.name),
|
||||
segment.interest || '',
|
||||
JSON.stringify(segment.queries || []),
|
||||
JSON.stringify(segment.cnaeCodes || []),
|
||||
JSON.stringify(segment.overpassTags || []),
|
||||
JSON.stringify(segment.pains || []),
|
||||
segment.affinity || 'media',
|
||||
JSON.stringify(segment.products || [])
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function seedMunicipios(db, rows) {
|
||||
transaction(db, () => {
|
||||
const stmt = db.prepare(
|
||||
'INSERT OR IGNORE INTO municipios (codigo_ibge, nome, nome_clean, uf) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
for (const row of rows) {
|
||||
stmt.run(String(row.codigo_ibge), row.nome, cleanText(row.nome), row.uf);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function seedCnaes(db, rows) {
|
||||
transaction(db, () => {
|
||||
const stmt = db.prepare('INSERT OR IGNORE INTO cnaes (codigo, descricao) VALUES (?, ?)');
|
||||
for (const row of rows) {
|
||||
stmt.run(String(row.codigo), row.descricao);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function seedGeoCache(db, cityAreas) {
|
||||
transaction(db, () => {
|
||||
const stmt = db.prepare(
|
||||
`INSERT OR IGNORE INTO geo_cache (key, south, west, north, east, provider)
|
||||
VALUES (?, ?, ?, ?, ?, 'config-legacy')`
|
||||
);
|
||||
for (const area of cityAreas || []) {
|
||||
const [south, west, north, east] = area.bbox;
|
||||
stmt.run(`bbox:${area.state}:${cleanText(area.name)}`, south, west, north, east);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Zera a base comercial (leads, exportacoes, capturas e extracoes) mantendo
|
||||
// tabelas de referencia (segments, settings, municipios, cnaes) intactas.
|
||||
// Usado pelo botao "Limpar toda a base" da tela de Configuracoes.
|
||||
export function resetLeadsBase(db) {
|
||||
transaction(db, () => {
|
||||
db.exec('DELETE FROM export_leads');
|
||||
db.exec('DELETE FROM exports');
|
||||
db.exec('DELETE FROM leads');
|
||||
db.exec('DELETE FROM capture_runs');
|
||||
db.exec('DELETE FROM extractions');
|
||||
db.exec('DELETE FROM geo_cache');
|
||||
});
|
||||
db.exec('VACUUM');
|
||||
}
|
||||
|
||||
export function createUser(db, { username, passwordHash, role = 'comercial' }) {
|
||||
db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(username, passwordHash, role);
|
||||
}
|
||||
|
||||
export function findUserByUsername(db, username) {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
}
|
||||
|
||||
export function listUsers(db) {
|
||||
return db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id').all();
|
||||
}
|
||||
|
||||
// Primeiro acesso: sem usuarios cadastrados, cria admin/admin — deve ser
|
||||
// trocado ou complementado com outros usuarios pela tela de Usuarios.
|
||||
export function ensureDefaultAdmin(db) {
|
||||
if (countRows(db, 'users') > 0) return;
|
||||
createUser(db, { username: 'admin', passwordHash: hashPassword('admin'), role: 'admin' });
|
||||
}
|
||||
|
||||
function countRows(db, table) {
|
||||
return db.prepare(`SELECT COUNT(*) AS total FROM ${table}`).get().total;
|
||||
}
|
||||
|
||||
// Popula tabelas de referencia na primeira execucao. Tolerante a arquivos de
|
||||
// seed ausentes (loga e segue) para o servidor nunca falhar no boot por isso.
|
||||
export async function ensureSeeds(db, { log = () => {} } = {}) {
|
||||
const fsp = await import('node:fs/promises');
|
||||
|
||||
// seedSegments faz upsert (nao so insere): sempre reaplicado no boot para que
|
||||
// ajustes de afinidade/produtos no config.json cheguem ao banco so reiniciando.
|
||||
try {
|
||||
const config = JSON.parse(await fsp.readFile(paths.config, 'utf8'));
|
||||
seedSegments(db, config.segments || []);
|
||||
if (countRows(db, 'geo_cache') === 0) seedGeoCache(db, config.cityAreas || []);
|
||||
log(`seeds: ${countRows(db, 'segments')} segmentos, ${countRows(db, 'geo_cache')} bboxes`);
|
||||
} catch (error) {
|
||||
log(`seeds: config.json indisponivel (${error.message})`);
|
||||
}
|
||||
|
||||
if (countRows(db, 'municipios') === 0) {
|
||||
try {
|
||||
const seedPath = path.join(paths.seeds, 'municipios-ibge.json');
|
||||
const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8'));
|
||||
seedMunicipios(db, rows);
|
||||
log(`seeds: ${countRows(db, 'municipios')} municipios (IBGE)`);
|
||||
} catch (error) {
|
||||
log(`seeds: municipios-ibge.json indisponivel (${error.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (countRows(db, 'cnaes') === 0) {
|
||||
try {
|
||||
const seedPath = path.join(paths.seeds, 'cnaes.json');
|
||||
const rows = JSON.parse(await fsp.readFile(seedPath, 'utf8'));
|
||||
seedCnaes(db, rows);
|
||||
log(`seeds: ${countRows(db, 'cnaes')} CNAEs`);
|
||||
} catch (error) {
|
||||
log(`seeds: cnaes.json indisponivel (${error.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
let templates = {};
|
||||
try {
|
||||
const templatesPath = path.join(paths.root, 'whatsapp', 'templates-mensagens.json');
|
||||
templates = { 'templates.mensagens': await fsp.readFile(templatesPath, 'utf8') };
|
||||
} catch {
|
||||
// pasta whatsapp/ ja removida: template ja deve estar no banco
|
||||
}
|
||||
seedSettings(db, templates);
|
||||
|
||||
const criouAdmin = countRows(db, 'users') === 0;
|
||||
ensureDefaultAdmin(db);
|
||||
if (criouAdmin) log('seeds: usuario admin/admin criado (primeiro acesso)');
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Raiz do projeto resolvida a partir deste arquivo (src/env.mjs -> pasta acima),
|
||||
// para funcionar igual quando o servidor e iniciado pelo launcher (cwd diferente).
|
||||
export const projectRoot = path.dirname(fileURLToPath(new URL('.', import.meta.url)));
|
||||
|
||||
export const paths = {
|
||||
root: projectRoot,
|
||||
data: path.join(projectRoot, 'data'),
|
||||
db: process.env.GTI_DB_PATH || path.join(projectRoot, 'data', 'gti.db'),
|
||||
exports: path.join(projectRoot, 'data', 'exports'),
|
||||
seeds: path.join(projectRoot, 'data', 'seeds'),
|
||||
cnpjDir: process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj'),
|
||||
rfbRaw: path.join(projectRoot, 'prospeccao', 'cnpj', 'rfb-raw'),
|
||||
config: path.join(projectRoot, 'prospeccao', 'config.json'),
|
||||
public: path.join(projectRoot, 'public')
|
||||
};
|
||||
|
||||
export async function loadDotEnv(rootDir = projectRoot) {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(rootDir, '.env'), 'utf8');
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const separator = trimmed.indexOf('=');
|
||||
if (separator === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
const value = trimmed.slice(separator + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function envNumber(name, fallback) {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function envString(name, fallback) {
|
||||
return process.env[name] || fallback;
|
||||
}
|
||||
|
||||
export const GTI_PORT = envNumber('GTI_PORT', 3900);
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { csvEscape, excelTextCell, 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, '>');
|
||||
}
|
||||
|
||||
async function writeStream(stream, text) {
|
||||
if (!stream.write(text)) {
|
||||
await new Promise((resolve) => stream.once('drain', resolve));
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportExcel({ db, filtro, dir, baseName, onProgress = () => {}, collectIds }) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
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(row[h])));
|
||||
await writeStream(csv, csvCells.join(';') + '\n');
|
||||
|
||||
const xlsCells = HEADERS.map((h) =>
|
||||
`<td${TEXT_COLUMNS.has(h) ? ' class="text"' : ''}>${escapeHtml(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)]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import path from 'node:path';
|
||||
import { loadDotEnv, paths } from '../env.mjs';
|
||||
import { openDb, transaction } from '../db.mjs';
|
||||
import { createJobIO } from '../jobs/protocol.mjs';
|
||||
import { exportExcel } from './excel.mjs';
|
||||
import { exportOportunidades, exportBulkDataInput } from './zpro.mjs';
|
||||
|
||||
// Processo filho de exportacao: gera os arquivos do tipo pedido e, ao final,
|
||||
// numa unica transacao, registra os leads exportados (export_leads) e marca
|
||||
// exported_at (para o filtro "ainda nao exportado" excluir esses leads).
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const args = new Map(
|
||||
process.argv.slice(2).map((arg) => {
|
||||
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
|
||||
const io = createJobIO();
|
||||
|
||||
const GENERATORS = {
|
||||
excel: exportExcel,
|
||||
'zpro-oportunidades': exportOportunidades,
|
||||
'zpro-bulk': exportBulkDataInput
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const exportId = Number(args.get('export-id'));
|
||||
if (!exportId) throw new Error('export-job: --export-id obrigatorio');
|
||||
|
||||
const db = openDb();
|
||||
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(exportId);
|
||||
if (!row) throw new Error(`export #${exportId} nao encontrado`);
|
||||
|
||||
const generator = GENERATORS[row.tipo];
|
||||
if (!generator) throw new Error(`tipo de exportacao desconhecido: ${row.tipo}`);
|
||||
|
||||
const filtro = JSON.parse(row.filtro_json || '{}');
|
||||
const params = JSON.parse(row.params_json || '{}');
|
||||
|
||||
db.prepare("UPDATE exports SET status = 'running' WHERE id = ?").run(exportId);
|
||||
io.emit({ phase: row.tipo, message: 'gerando arquivos...' });
|
||||
|
||||
const dir = path.join(paths.exports, `export-${exportId}`);
|
||||
const baseName = params.baseName || `leads-${row.tipo}`;
|
||||
const leadIds = [];
|
||||
|
||||
try {
|
||||
const result = await generator({
|
||||
db,
|
||||
filtro,
|
||||
params,
|
||||
dir,
|
||||
baseName,
|
||||
collectIds: leadIds,
|
||||
onProgress: (p) => io.emit({ phase: row.tipo, ...p })
|
||||
});
|
||||
|
||||
const marcar = params.marcarExportado !== false;
|
||||
io.emit({ phase: row.tipo, message: `registrando ${leadIds.length} leads exportados...` });
|
||||
|
||||
transaction(db, () => {
|
||||
const insertLink = db.prepare('INSERT OR IGNORE INTO export_leads (export_id, lead_id) VALUES (?, ?)');
|
||||
const mark = marcar
|
||||
? db.prepare("UPDATE leads SET exported_at = COALESCE(exported_at, datetime('now')), updated_at = datetime('now') WHERE id = ?")
|
||||
: null;
|
||||
|
||||
for (const leadId of leadIds) {
|
||||
insertLink.run(exportId, leadId);
|
||||
if (mark) mark.run(leadId);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
"UPDATE exports SET status = 'done', total_leads = ?, arquivos_json = ?, finished_at = datetime('now') WHERE id = ?"
|
||||
).run(result.total, JSON.stringify(result.files), exportId);
|
||||
});
|
||||
|
||||
io.done({ total: result.total, arquivos: result.files });
|
||||
} catch (error) {
|
||||
db.prepare("UPDATE exports SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?")
|
||||
.run(String(error.message || error), exportId);
|
||||
throw error;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
io.fail(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { iterateLeads } from '../treatment/queries.mjs';
|
||||
import { getSetting } from '../db.mjs';
|
||||
|
||||
// Exportacoes para o Z-PRO (sem envio direto — gera os arquivos prontos):
|
||||
// - zpro-oportunidades: JSONL com um payload createOpportunity por linha,
|
||||
// no formato exato testado na importacao manual de 2026-07-09.
|
||||
// - zpro-bulk: CSV dataInput para BulkSendMessageWithVariable
|
||||
// (colunas: numero,empresa,cidade,segmento) + txt com instrucoes.
|
||||
|
||||
function zproDefaults(db, params = {}) {
|
||||
const setting = (key, fallback) => params[key] ?? getSetting(db, `zpro.${key}`, fallback);
|
||||
return {
|
||||
pipelineId: Number(setting('pipelineId', 1)),
|
||||
stageId: Number(setting('stageId', 1)),
|
||||
responsibleId: Number(setting('responsibleId', 15)),
|
||||
status: String(setting('status', 'open')),
|
||||
closingForecastDays: Number(setting('closingForecastDays', 30)),
|
||||
validateNumber: String(setting('validateNumber', 'true')) !== 'false'
|
||||
};
|
||||
}
|
||||
|
||||
function closingForecast(days) {
|
||||
const date = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function writeStream(stream, text) {
|
||||
if (!stream.write(text)) {
|
||||
await new Promise((resolve) => stream.once('drain', resolve));
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportOportunidades({ db, filtro, params, dir, baseName, onProgress = () => {}, collectIds }) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const zpro = zproDefaults(db, params);
|
||||
const forecast = closingForecast(zpro.closingForecastDays);
|
||||
|
||||
const jsonlPath = path.join(dir, `${baseName}.jsonl`);
|
||||
const out = fs.createWriteStream(jsonlPath, { encoding: 'utf8' });
|
||||
let total = 0;
|
||||
|
||||
for (const lead of iterateLeads(db, filtro)) {
|
||||
const payload = {
|
||||
number: lead.whatsapp,
|
||||
contactName: lead.empresa,
|
||||
email: lead.email || '',
|
||||
name: `${lead.empresa} - ${lead.interesse || 'Contato comercial'}`,
|
||||
value: 0,
|
||||
status: zpro.status,
|
||||
pipelineId: zpro.pipelineId,
|
||||
stageId: zpro.stageId,
|
||||
responsibleId: zpro.responsibleId,
|
||||
closingForecast: forecast,
|
||||
description: [
|
||||
'Exportado pelo GTI Maquina Comercial.',
|
||||
`Empresa: ${lead.empresa}`,
|
||||
lead.legal_name ? `Razao social: ${lead.legal_name}` : '',
|
||||
`CNPJ: ${lead.cnpj || 'nao informado'}`,
|
||||
`WhatsApp: ${lead.whatsapp || 'nao informado'}`,
|
||||
`Cidade/UF: ${lead.cidade}/${lead.uf}`,
|
||||
`Segmento: ${lead.segmento_label || lead.segmento}`,
|
||||
`Interesse: ${lead.interesse || 'a definir'}`,
|
||||
`Score: ${lead.score}`,
|
||||
`Classificacao: ${lead.classificacao}`,
|
||||
`Lead: ${lead.id}`
|
||||
].filter(Boolean).join('\n'),
|
||||
validateNumber: zpro.validateNumber
|
||||
};
|
||||
|
||||
await writeStream(out, JSON.stringify(payload) + '\n');
|
||||
if (collectIds) collectIds.push(lead.id);
|
||||
total += 1;
|
||||
if (total % 10000 === 0) onProgress({ message: `${total} oportunidades geradas...` });
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())));
|
||||
return { total, files: [path.basename(jsonlPath)] };
|
||||
}
|
||||
|
||||
export async function exportBulkDataInput({ db, filtro, params, dir, baseName, onProgress = () => {}, collectIds }) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const dataPath = path.join(dir, `${baseName}-datainput.csv`);
|
||||
const infoPath = path.join(dir, `${baseName}-instrucoes.txt`);
|
||||
const out = fs.createWriteStream(dataPath, { encoding: 'utf8' });
|
||||
let total = 0;
|
||||
|
||||
for (const lead of iterateLeads(db, filtro)) {
|
||||
if (!lead.whatsapp) continue;
|
||||
const cells = [lead.whatsapp, lead.empresa, lead.cidade, lead.segmento_label || lead.segmento]
|
||||
.map((v) => String(v ?? '').replace(/[,\r\n]+/g, ' ').trim());
|
||||
await writeStream(out, cells.join(',') + '\n');
|
||||
if (collectIds) collectIds.push(lead.id);
|
||||
total += 1;
|
||||
if (total % 10000 === 0) onProgress({ message: `${total} numeros gravados...` });
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())));
|
||||
|
||||
const mensagem = params?.mensagem || 'Ola {empresa}! Somos o Grupo GTI...';
|
||||
fs.writeFileSync(infoPath, [
|
||||
'DISPARO EM MASSA NO Z-PRO (BulkSendMessageWithVariable)',
|
||||
'',
|
||||
`Arquivo de dados: ${path.basename(dataPath)} (${total} numeros)`,
|
||||
'Colunas (nesta ordem): numero, empresa, cidade, segmento',
|
||||
'',
|
||||
'Como usar no Z-PRO:',
|
||||
'1. Endpoint: POST {BaseUrl}/v2/api/external/{ApiID}/bulkSendMessageWithVariable',
|
||||
'2. Campo "dataInput": cole o conteudo do CSV (ou envie por upload, conforme sua versao).',
|
||||
'3. Campo "message": escreva a mensagem usando as variaveis pela posicao das colunas,',
|
||||
' ex.: "Ola {empresa}! Vimos que voces sao de {cidade}..."',
|
||||
'4. Campos "min" e "max": intervalo aleatorio (em segundos) entre envios — use valores',
|
||||
' altos (ex.: 30 a 90) para reduzir risco de bloqueio do numero.',
|
||||
'',
|
||||
'Mensagem sugerida:',
|
||||
mensagem
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
return { total, files: [path.basename(dataPath), path.basename(infoPath)] };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import path from 'node:path';
|
||||
import { loadDotEnv, paths } from '../env.mjs';
|
||||
import { openDb } from '../db.mjs';
|
||||
import { extractUf } from './rfb-extractor.mjs';
|
||||
import { createJobIO } from '../jobs/protocol.mjs';
|
||||
|
||||
// Processo filho de extracao RFB: le a UF da linha em extractions e roda a
|
||||
// extracao completa, reportando progresso via stdout.
|
||||
|
||||
await loadDotEnv();
|
||||
|
||||
const args = new Map(
|
||||
process.argv.slice(2).map((arg) => {
|
||||
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
|
||||
const io = createJobIO();
|
||||
|
||||
async function main() {
|
||||
const extractionId = Number(args.get('extraction-id'));
|
||||
if (!extractionId) throw new Error('extract-job: --extraction-id obrigatorio');
|
||||
|
||||
const db = openDb();
|
||||
const extraction = db.prepare('SELECT * FROM extractions WHERE id = ?').get(extractionId);
|
||||
if (!extraction) throw new Error(`extraction #${extractionId} nao encontrada`);
|
||||
|
||||
const uf = extraction.uf;
|
||||
db.prepare("UPDATE extractions SET status = 'running', pid = ?, started_at = datetime('now') WHERE id = ?")
|
||||
.run(process.pid, extractionId);
|
||||
|
||||
const outCsv = path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`);
|
||||
const checkpointPath = path.join(paths.cnpjDir, `checkpoint-estabelecimentos-${uf}.ndjson`);
|
||||
|
||||
try {
|
||||
const result = await extractUf({
|
||||
uf,
|
||||
rawDir: paths.rfbRaw,
|
||||
outCsv,
|
||||
checkpointPath,
|
||||
db,
|
||||
log: (m) => io.log(m),
|
||||
isCancelled: io.isCancelled,
|
||||
onProgress: (p) => {
|
||||
io.emit(p);
|
||||
db.prepare('UPDATE extractions SET progress_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify(p), extractionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.cancelled) {
|
||||
db.prepare("UPDATE extractions SET status = 'cancelled', finished_at = datetime('now') WHERE id = ?")
|
||||
.run(extractionId);
|
||||
io.done({ cancelled: true });
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE extractions SET status = 'done', csv_path = ?, total_rows = ?, with_phone = ?,
|
||||
municipios_count = ?, finished_at = datetime('now') WHERE id = ?`
|
||||
).run(result.csvPath, result.totalRows, result.withPhone, result.municipiosCount, extractionId);
|
||||
io.done({ totalRows: result.totalRows, withPhone: result.withPhone, municipios: result.municipiosCount });
|
||||
}
|
||||
} catch (error) {
|
||||
db.prepare("UPDATE extractions SET status = 'error', error = ?, finished_at = datetime('now') WHERE id = ?")
|
||||
.run(String(error.message || error), extractionId);
|
||||
throw error;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
io.fail(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import readline from 'node:readline';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { cleanText, stripAccents } from '../lead-engine.mjs';
|
||||
import { copyStr } from '../util/collections.mjs';
|
||||
import { csvEscape, BOM } from '../util/csv.mjs';
|
||||
|
||||
// Extrai a base de estabelecimentos ativos de UMA UF inteira (todos os
|
||||
// municipios) a partir dos zips brutos da Receita Federal, gerando
|
||||
// prospeccao/cnpj/estabelecimentos-<UF>.csv.
|
||||
//
|
||||
// Cuidados de memoria (aprendidos nas execucoes da Bahia):
|
||||
// - checkpoint em NDJSON streamado para disco: nunca guarda o array de
|
||||
// matches em memoria (uma UF inteira passa de 1 milhao de linhas);
|
||||
// - copyStr (Buffer.from) quebra o vinculo SlicedString do V8 entre campos
|
||||
// retidos e a linha CSV original;
|
||||
// - firstField evita o split completo das ~50 milhoes de linhas descartadas
|
||||
// dos Empresas*.zip.
|
||||
|
||||
const CANDIDATE_READERS = [
|
||||
{ name: 'unzip', probeArgs: ['-v'], argsFor: (zipPath) => ['-p', zipPath] },
|
||||
{ name: 'C:\\Program Files\\Git\\usr\\bin\\unzip.exe', probeArgs: ['-v'], argsFor: (zipPath) => ['-p', zipPath] },
|
||||
{ name: 'tar', probeArgs: ['--version'], argsFor: (zipPath) => ['-xOf', zipPath] },
|
||||
{ name: 'C:\\Windows\\System32\\tar.exe', probeArgs: ['--version'], argsFor: (zipPath) => ['-xOf', zipPath] }
|
||||
];
|
||||
|
||||
export function resolveZipReader() {
|
||||
for (const candidate of CANDIDATE_READERS) {
|
||||
try {
|
||||
const probe = spawnSync(candidate.name, candidate.probeArgs, { stdio: 'ignore' });
|
||||
if (!probe.error && probe.status === 0) return candidate;
|
||||
} catch {
|
||||
// tenta o proximo
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
'Nenhum leitor de ZIP encontrado (unzip ou tar). Instale o Git para Windows ou verifique o PATH.'
|
||||
);
|
||||
}
|
||||
|
||||
// Abre um zip da RFB e devolve as linhas ja decodificadas de ISO-8859-1.
|
||||
function readZipLines(reader, zipPath) {
|
||||
const child = spawn(reader.name, reader.argsFor(zipPath), { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stderr.on('data', () => {});
|
||||
child.stdout.setEncoding('latin1');
|
||||
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||
return {
|
||||
rl,
|
||||
close() {
|
||||
rl.close();
|
||||
child.kill();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// So o primeiro campo (antes do primeiro '";"'), sem split completo.
|
||||
function firstField(line) {
|
||||
const end = line.indexOf('";"', 1);
|
||||
if (end === -1) return '';
|
||||
return line.slice(1, end);
|
||||
}
|
||||
|
||||
function parseQuotedRow(line) {
|
||||
if (line.startsWith('"') && line.endsWith('"')) {
|
||||
return line.slice(1, -1).split('";"');
|
||||
}
|
||||
return line.split(';').map((value) => value.replace(/^"|"$/g, ''));
|
||||
}
|
||||
|
||||
function titleCase(name) {
|
||||
return String(name).toLowerCase().replace(/(^|\s)([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase());
|
||||
}
|
||||
|
||||
async function buildTomMap(reader, rawDir) {
|
||||
const zipPath = path.join(rawDir, 'Municipios.zip');
|
||||
if (!fs.existsSync(zipPath)) {
|
||||
throw new Error(`Municipios.zip nao encontrado em ${rawDir}`);
|
||||
}
|
||||
|
||||
const tomToNome = new Map();
|
||||
const zip = readZipLines(reader, zipPath);
|
||||
for await (const line of zip.rl) {
|
||||
if (!line.trim()) continue;
|
||||
const [codigo, nome] = parseQuotedRow(line);
|
||||
if (codigo) tomToNome.set(copyStr(codigo), copyStr(nome));
|
||||
}
|
||||
zip.close();
|
||||
return tomToNome;
|
||||
}
|
||||
|
||||
async function* estabelecimentoRows(reader, rawDir, uf, { onFileProgress, isCancelled }) {
|
||||
for (let i = 0; i <= 9; i += 1) {
|
||||
const zipPath = path.join(rawDir, `Estabelecimentos${i}.zip`);
|
||||
if (!fs.existsSync(zipPath)) continue;
|
||||
|
||||
const zip = readZipLines(reader, zipPath);
|
||||
let lineCount = 0;
|
||||
let matchCount = 0;
|
||||
|
||||
for await (const line of zip.rl) {
|
||||
lineCount += 1;
|
||||
if (lineCount % 500000 === 0 && isCancelled()) {
|
||||
zip.close();
|
||||
return;
|
||||
}
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// Filtro barato por UF antes do parse completo: a UF aparece como
|
||||
// campo '";UF";"' — o indexOf evita o split das ~29M linhas de outras UFs.
|
||||
if (!line.includes(`"${uf}"`)) continue;
|
||||
|
||||
const cols = parseQuotedRow(line);
|
||||
if (cols[19] !== uf) continue;
|
||||
if (cols[5] !== '02') continue;
|
||||
|
||||
matchCount += 1;
|
||||
yield {
|
||||
b: copyStr(cols[0]), // cnpj basico
|
||||
o: copyStr(cols[1]), // ordem
|
||||
d: copyStr(cols[2]), // dv
|
||||
f: copyStr(cols[4]), // nome fantasia
|
||||
c: copyStr(cols[11]), // cnae
|
||||
l: copyStr([cols[13], cols[14]].filter(Boolean).join(' ')), // logradouro
|
||||
n: copyStr(cols[15]), // numero
|
||||
ba: copyStr(cols[17]), // bairro
|
||||
m: copyStr(cols[20]), // codigo municipio (TOM)
|
||||
d1: copyStr(cols[21]),
|
||||
t1: copyStr(cols[22]),
|
||||
d2: copyStr(cols[23]),
|
||||
t2: copyStr(cols[24])
|
||||
};
|
||||
}
|
||||
|
||||
zip.close();
|
||||
onFileProgress(`Estabelecimentos${i}.zip`, lineCount, matchCount, i);
|
||||
if (global.gc) global.gc();
|
||||
if (isCancelled()) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildRazaoMap(reader, rawDir, cnpjBasicoSet, { onFileProgress, isCancelled }) {
|
||||
const razaoByCnpj = new Map();
|
||||
|
||||
for (let i = 0; i <= 9; i += 1) {
|
||||
const zipPath = path.join(rawDir, `Empresas${i}.zip`);
|
||||
if (!fs.existsSync(zipPath)) continue;
|
||||
|
||||
const zip = readZipLines(reader, zipPath);
|
||||
let lineCount = 0;
|
||||
let matchCount = 0;
|
||||
|
||||
for await (const line of zip.rl) {
|
||||
lineCount += 1;
|
||||
if (lineCount % 500000 === 0 && isCancelled()) {
|
||||
zip.close();
|
||||
return razaoByCnpj;
|
||||
}
|
||||
if (!line.trim()) continue;
|
||||
const cnpjBasico = firstField(line);
|
||||
if (!cnpjBasico || !cnpjBasicoSet.has(cnpjBasico)) continue;
|
||||
const cols = parseQuotedRow(line);
|
||||
razaoByCnpj.set(copyStr(cnpjBasico), copyStr(cols[1]));
|
||||
matchCount += 1;
|
||||
}
|
||||
|
||||
zip.close();
|
||||
onFileProgress(`Empresas${i}.zip`, lineCount, matchCount, i);
|
||||
if (global.gc) global.gc();
|
||||
if (isCancelled()) break;
|
||||
}
|
||||
|
||||
return razaoByCnpj;
|
||||
}
|
||||
|
||||
export async function extractUf({
|
||||
uf,
|
||||
rawDir,
|
||||
outCsv,
|
||||
checkpointPath,
|
||||
db = null,
|
||||
onProgress = () => {},
|
||||
isCancelled = () => false,
|
||||
log = () => {}
|
||||
}) {
|
||||
const ufUpper = String(uf).toUpperCase();
|
||||
const reader = resolveZipReader();
|
||||
log(`leitor de zip: ${reader.name}`);
|
||||
|
||||
const tomToNome = await buildTomMap(reader, rawDir);
|
||||
log(`${tomToNome.size} municipios no indice da RFB`);
|
||||
|
||||
// Nome "bonito" (IBGE, sem acento, capitalizado) por codigo TOM, para o CSV
|
||||
// ficar consistente com os leads ja migrados ('Santo Antonio de Jesus').
|
||||
const prettyByClean = new Map();
|
||||
if (db) {
|
||||
for (const row of db.prepare('SELECT nome, nome_clean FROM municipios WHERE uf = ?').all(ufUpper)) {
|
||||
prettyByClean.set(row.nome_clean, stripAccents(row.nome));
|
||||
}
|
||||
}
|
||||
const prettyName = (tomCode) => {
|
||||
const tomNome = tomToNome.get(tomCode) || '';
|
||||
return prettyByClean.get(cleanText(tomNome)) || titleCase(tomNome);
|
||||
};
|
||||
|
||||
// ---- Fase 1: Estabelecimentos -> checkpoint NDJSON em disco ----
|
||||
const cnpjBasicoSet = new Set();
|
||||
const tomSeen = new Set();
|
||||
let totalRows = 0;
|
||||
let withPhone = 0;
|
||||
|
||||
const checkpointDone = fs.existsSync(checkpointPath);
|
||||
if (checkpointDone) {
|
||||
log('checkpoint encontrado: pulando varredura de Estabelecimentos');
|
||||
await new Promise((resolve, reject) => {
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(checkpointPath, { encoding: 'utf8' }),
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
rl.on('line', (line) => {
|
||||
if (!line.trim()) return;
|
||||
const item = JSON.parse(line);
|
||||
cnpjBasicoSet.add(item.b);
|
||||
tomSeen.add(item.m);
|
||||
totalRows += 1;
|
||||
if (item.t1 || item.t2) withPhone += 1;
|
||||
});
|
||||
rl.on('close', resolve);
|
||||
rl.on('error', reject);
|
||||
});
|
||||
} else {
|
||||
const tmpPath = `${checkpointPath}.tmp`;
|
||||
const out = fs.createWriteStream(tmpPath, { encoding: 'utf8' });
|
||||
|
||||
onProgress({ phase: 'estabelecimentos', pct: 0, message: 'varrendo Estabelecimentos0-9.zip' });
|
||||
|
||||
for await (const item of estabelecimentoRows(reader, rawDir, ufUpper, {
|
||||
isCancelled,
|
||||
onFileProgress: (file, lines, matches, index) => {
|
||||
log(`${file}: ${lines} linhas, ${matches} ativas em ${ufUpper}`);
|
||||
onProgress({
|
||||
phase: 'estabelecimentos',
|
||||
pct: Math.round(((index + 1) / 10) * 55),
|
||||
message: `${file}: ${totalRows} estabelecimentos ate agora`
|
||||
});
|
||||
}
|
||||
})) {
|
||||
cnpjBasicoSet.add(item.b);
|
||||
tomSeen.add(item.m);
|
||||
totalRows += 1;
|
||||
if (item.t1 || item.t2) withPhone += 1;
|
||||
if (!out.write(JSON.stringify(item) + '\n')) {
|
||||
await new Promise((resolve) => out.once('drain', resolve));
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => out.end((e) => (e ? reject(e) : resolve())));
|
||||
|
||||
if (isCancelled()) {
|
||||
await fsp.rm(tmpPath, { force: true });
|
||||
return { cancelled: true };
|
||||
}
|
||||
await fsp.rename(tmpPath, checkpointPath);
|
||||
log(`checkpoint salvo: ${checkpointPath}`);
|
||||
}
|
||||
|
||||
log(`total de estabelecimentos ativos em ${ufUpper}: ${totalRows}`);
|
||||
if (isCancelled()) return { cancelled: true };
|
||||
|
||||
// ---- Fase 2: Empresas -> razao social ----
|
||||
onProgress({ phase: 'empresas', pct: 55, message: 'varrendo Empresas0-9.zip (razao social)' });
|
||||
let razoesRecuperadas = 0;
|
||||
const razaoByCnpj = await buildRazaoMap(reader, rawDir, cnpjBasicoSet, {
|
||||
isCancelled,
|
||||
onFileProgress: (file, lines, matches, index) => {
|
||||
razoesRecuperadas += matches;
|
||||
const heapMb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(0);
|
||||
log(`${file}: ${lines} linhas, ${matches} razoes sociais, heap=${heapMb}MB`);
|
||||
onProgress({
|
||||
phase: 'empresas',
|
||||
pct: 55 + Math.round(((index + 1) / 10) * 35),
|
||||
message: `${file}: ${razoesRecuperadas} razoes sociais`
|
||||
});
|
||||
}
|
||||
});
|
||||
if (isCancelled()) return { cancelled: true };
|
||||
|
||||
// ---- Fase 3: CSV final (streaming do checkpoint) ----
|
||||
onProgress({ phase: 'csv', pct: 90, message: 'gravando CSV final' });
|
||||
const headers = [
|
||||
'cnpj', 'razao_social', 'nome_fantasia', 'ddd_1', 'telefone_1', 'ddd_2', 'telefone_2',
|
||||
'municipio', 'municipio_codigo', 'uf', 'cnae', 'logradouro', 'numero', 'bairro'
|
||||
];
|
||||
const csvOut = fs.createWriteStream(outCsv, { encoding: 'utf8' });
|
||||
csvOut.write(BOM + headers.join(';') + '\n');
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(checkpointPath, { encoding: 'utf8' }),
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
// readline ainda entrega linhas ja em buffer depois do pause(); o flag
|
||||
// evita empilhar um listener de drain por linha excedente.
|
||||
let waitingDrain = false;
|
||||
rl.on('line', (line) => {
|
||||
if (!line.trim()) return;
|
||||
const item = JSON.parse(line);
|
||||
const row = [
|
||||
`${item.b}${item.o}${item.d}`,
|
||||
razaoByCnpj.get(item.b) || '',
|
||||
item.f,
|
||||
item.d1, item.t1, item.d2, item.t2,
|
||||
prettyName(item.m),
|
||||
item.m,
|
||||
ufUpper,
|
||||
item.c,
|
||||
item.l, item.n, item.ba
|
||||
].map(csvEscape).join(';');
|
||||
if (!csvOut.write(row + '\n') && !waitingDrain) {
|
||||
waitingDrain = true;
|
||||
rl.pause();
|
||||
csvOut.once('drain', () => {
|
||||
waitingDrain = false;
|
||||
rl.resume();
|
||||
});
|
||||
}
|
||||
});
|
||||
rl.on('close', resolve);
|
||||
rl.on('error', reject);
|
||||
});
|
||||
await new Promise((resolve, reject) => csvOut.end((e) => (e ? reject(e) : resolve())));
|
||||
|
||||
// ---- Fase 4: aprende codigo TOM dos municipios no banco ----
|
||||
let municipiosCount = tomSeen.size;
|
||||
if (db) {
|
||||
const update = db.prepare('UPDATE municipios SET codigo_tom = ? WHERE uf = ? AND nome_clean = ?');
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const tom of tomSeen) {
|
||||
const nome = tomToNome.get(tom);
|
||||
if (nome) update.run(tom, ufUpper, cleanText(nome));
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// O checkpoint so serve para retomar uma extracao interrompida. Mantido apos
|
||||
// concluir, faria uma re-extracao futura (base nova da Receita) reutilizar
|
||||
// dados velhos da fase 1 — entao e apagado no sucesso.
|
||||
await fsp.rm(checkpointPath, { force: true });
|
||||
|
||||
onProgress({ phase: 'done', pct: 100, message: `${totalRows} estabelecimentos, ${withPhone} com telefone` });
|
||||
log(`CSV gravado: ${outCsv}`);
|
||||
log(`com telefone: ${withPhone} (${totalRows ? ((withPhone / totalRows) * 100).toFixed(1) : 0}%)`);
|
||||
|
||||
return { cancelled: false, totalRows, withPhone, municipiosCount, csvPath: outCsv };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import readline from 'node:readline';
|
||||
import path from 'node:path';
|
||||
import { paths } from '../env.mjs';
|
||||
import { parseJobLine } from './protocol.mjs';
|
||||
|
||||
const KILL_GRACE_MS = 10000;
|
||||
|
||||
// Gerencia os processos filhos de trabalho pesado (captura, extracao RFB,
|
||||
// exportacao). O filho e dono das escritas de leads no banco (conexao
|
||||
// propria, WAL); o servidor so acompanha o progresso via stdout e repassa
|
||||
// para os assinantes SSE.
|
||||
export function createJobManager() {
|
||||
const jobs = new Map();
|
||||
|
||||
function fanout(job, event) {
|
||||
job.lastEvent = event;
|
||||
for (const send of job.subscribers) {
|
||||
try {
|
||||
send(event);
|
||||
} catch {
|
||||
// assinante ja desconectou
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startJob({ type, script, args = [], meta = {} }) {
|
||||
const jobId = randomUUID();
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[path.join(paths.root, script), `--job-id=${jobId}`, ...args],
|
||||
{ cwd: paths.root, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
|
||||
);
|
||||
|
||||
const job = {
|
||||
id: jobId,
|
||||
type,
|
||||
meta,
|
||||
child,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
lastEvent: { t: 'progress', phase: type, message: 'iniciando...' },
|
||||
stderrTail: '',
|
||||
subscribers: new Set(),
|
||||
killTimer: null
|
||||
};
|
||||
jobs.set(jobId, job);
|
||||
|
||||
const rl = readline.createInterface({ input: child.stdout });
|
||||
rl.on('line', (line) => {
|
||||
const event = parseJobLine(line);
|
||||
if (!event) return;
|
||||
if (event.t === 'done') {
|
||||
job.status = event.summary?.cancelled ? 'cancelled' : 'done';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
} else if (event.t === 'error') {
|
||||
job.status = 'error';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
}
|
||||
fanout(job, event);
|
||||
});
|
||||
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk) => {
|
||||
job.stderrTail = (job.stderrTail + chunk).slice(-4096);
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (job.killTimer) clearTimeout(job.killTimer);
|
||||
if (job.status === 'running') {
|
||||
job.status = 'error';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
fanout(job, {
|
||||
t: 'error',
|
||||
message: `processo encerrou inesperadamente (codigo ${code}). ${job.stderrTail.slice(-500)}`
|
||||
});
|
||||
}
|
||||
// guarda o job finalizado por 10 minutos para consultas tardias
|
||||
setTimeout(() => jobs.delete(jobId), 10 * 60 * 1000).unref();
|
||||
});
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
function cancelJob(jobId) {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job || job.status !== 'running') return false;
|
||||
|
||||
try {
|
||||
job.child.stdin.write('cancel\n');
|
||||
} catch {
|
||||
// stdin ja fechado
|
||||
}
|
||||
job.killTimer = setTimeout(() => {
|
||||
if (job.status === 'running') job.child.kill();
|
||||
}, KILL_GRACE_MS);
|
||||
job.killTimer.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
function subscribe(jobId, send) {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job) return null;
|
||||
|
||||
// replay do ultimo evento para quem conecta no meio (refresh da pagina)
|
||||
send(job.lastEvent);
|
||||
if (job.status !== 'running') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
job.subscribers.add(send);
|
||||
return () => job.subscribers.delete(send);
|
||||
}
|
||||
|
||||
function getJob(jobId) {
|
||||
return jobs.get(jobId) || null;
|
||||
}
|
||||
|
||||
function activeJobs() {
|
||||
return [...jobs.values()]
|
||||
.filter((job) => job.status === 'running')
|
||||
.map((job) => ({
|
||||
jobId: job.id,
|
||||
type: job.type,
|
||||
meta: job.meta,
|
||||
startedAt: job.startedAt,
|
||||
lastEvent: job.lastEvent
|
||||
}));
|
||||
}
|
||||
|
||||
return { startJob, cancelJob, subscribe, getJob, activeJobs };
|
||||
}
|
||||
|
||||
// No boot do servidor: execucoes marcadas como running cujo processo morreu
|
||||
// (queda de energia, kill) viram erro para nao travar a interface.
|
||||
export function recoverOrphans(db, log = () => {}) {
|
||||
const isAlive = (pid) => {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const table of ['capture_runs', 'extractions']) {
|
||||
const running = db.prepare(`SELECT id, pid FROM ${table} WHERE status IN ('running', 'pending')`).all();
|
||||
for (const row of running) {
|
||||
if (!isAlive(row.pid)) {
|
||||
db.prepare(
|
||||
`UPDATE ${table} SET status = 'error', error = 'interrompido (processo nao esta mais rodando)', finished_at = datetime('now') WHERE id = ?`
|
||||
).run(row.id);
|
||||
log(`${table} #${row.id} marcado como interrompido`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import readline from 'node:readline';
|
||||
|
||||
// Protocolo entre o servidor e os processos filhos de job:
|
||||
// - filho imprime UMA mensagem JSON por linha no stdout:
|
||||
// {"t":"progress", ...} | {"t":"log","message":...} |
|
||||
// {"t":"done","summary":{...}} | {"t":"error","message":...}
|
||||
// - servidor escreve "cancel\n" no stdin do filho para pedir cancelamento
|
||||
// cooperativo (o filho checa isCancelled() entre lotes).
|
||||
|
||||
export function createJobIO() {
|
||||
let cancelled = false;
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on('line', (line) => {
|
||||
if (line.trim() === 'cancel') cancelled = true;
|
||||
});
|
||||
// stdin nao deve segurar o processo vivo apos o trabalho terminar
|
||||
process.stdin.unref?.();
|
||||
|
||||
const write = (obj) => {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
};
|
||||
|
||||
return {
|
||||
isCancelled: () => cancelled,
|
||||
emit: (progress) => write({ t: 'progress', ...progress }),
|
||||
log: (message) => write({ t: 'log', message: String(message) }),
|
||||
done: (summary = {}) => write({ t: 'done', summary }),
|
||||
fail: (error) => write({ t: 'error', message: String(error?.message || error) })
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJobLine(line) {
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
return obj && typeof obj === 'object' && obj.t ? obj : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
const TARGET_CITY_ALIASES = new Set([
|
||||
'santo antonio de jesus',
|
||||
'santo antonio de jesus ba',
|
||||
'saj'
|
||||
]);
|
||||
|
||||
const TARGET_SEGMENTS = new Set([
|
||||
'mercado',
|
||||
'supermercado',
|
||||
'farmacia',
|
||||
'drogaria',
|
||||
'restaurante',
|
||||
'delivery',
|
||||
'distribuidora',
|
||||
'autopecas',
|
||||
'material de construcao',
|
||||
'clinica',
|
||||
'loja',
|
||||
'varejo',
|
||||
'atacado',
|
||||
'oficina',
|
||||
'escola',
|
||||
'salao de beleza',
|
||||
'barbearia',
|
||||
'estetica',
|
||||
'celular',
|
||||
'transportadora',
|
||||
'logistica'
|
||||
]);
|
||||
|
||||
const SEGMENT_STOPWORDS = new Set(['de', 'da', 'do', 'e']);
|
||||
|
||||
function segmentTokens(value = '') {
|
||||
return cleanText(value)
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((token) => token.length > 2 && !SEGMENT_STOPWORDS.has(token));
|
||||
}
|
||||
|
||||
const TARGET_SEGMENT_TOKENS = new Set(
|
||||
[...TARGET_SEGMENTS].flatMap((term) => segmentTokens(term))
|
||||
);
|
||||
|
||||
function tokenMatches(token, targetToken) {
|
||||
return token === targetToken
|
||||
|| (token.length > 4 && targetToken.length > 4 && (token.startsWith(targetToken) || targetToken.startsWith(token)));
|
||||
}
|
||||
|
||||
function isTargetSegmentMatch(segmentKey) {
|
||||
if (!segmentKey) return false;
|
||||
if (TARGET_SEGMENTS.has(segmentKey)) return true;
|
||||
return segmentTokens(segmentKey).some((token) =>
|
||||
[...TARGET_SEGMENT_TOKENS].some((targetToken) => tokenMatches(token, targetToken))
|
||||
);
|
||||
}
|
||||
|
||||
const SERVICE_LABELS = {
|
||||
erp: 'ERP / sistema de gestao',
|
||||
ti: 'Gestao de TI',
|
||||
whatsapp: 'Autoatendimento para WhatsApp',
|
||||
indefinido: 'A definir'
|
||||
};
|
||||
|
||||
const URGENCY_LABELS = {
|
||||
alta: 'Alta',
|
||||
media: 'Media',
|
||||
baixa: 'Baixa',
|
||||
indefinida: 'Indefinida'
|
||||
};
|
||||
|
||||
export function stripAccents(value = '') {
|
||||
return String(value)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
export function cleanText(value = '') {
|
||||
return stripAccents(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s@._+-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function firstDefined(...values) {
|
||||
return values.find((value) => value !== undefined && value !== null && value !== '') ?? '';
|
||||
}
|
||||
|
||||
function numberFrom(value) {
|
||||
if (value === undefined || value === null || value === '') return 0;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
const match = String(value).match(/\d+/);
|
||||
return match ? Number(match[0]) : 0;
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
if (Array.isArray(value)) return value.filter(Boolean).map(String);
|
||||
if (!value) return [];
|
||||
return String(value)
|
||||
.split(/[,;|]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizePhone(value = '') {
|
||||
let digits = String(value).replace(/\D/g, '');
|
||||
|
||||
while (digits.startsWith('0')) digits = digits.slice(1);
|
||||
|
||||
if (digits.length === 10 || digits.length === 11) {
|
||||
digits = `55${digits}`;
|
||||
}
|
||||
|
||||
return digits;
|
||||
}
|
||||
|
||||
export function detectService(value = '') {
|
||||
const text = cleanText(value);
|
||||
|
||||
if (!text) return 'indefinido';
|
||||
|
||||
if (/\b(1|erp|sistema|gestao empresarial|estoque|financeiro|fiscal|nfce|nfe|pdv)\b/.test(text)) {
|
||||
return 'erp';
|
||||
}
|
||||
|
||||
if (/\b(2|ti|suporte|servidor|rede|backup|seguranca|computador|infraestrutura)\b/.test(text)) {
|
||||
return 'ti';
|
||||
}
|
||||
|
||||
if (/\b(3|whatsapp|autoatendimento|chatbot|bot|atendimento automatico|mensagem)\b/.test(text)) {
|
||||
return 'whatsapp';
|
||||
}
|
||||
|
||||
return 'indefinido';
|
||||
}
|
||||
|
||||
export function normalizeUrgency(value = '') {
|
||||
const text = cleanText(value);
|
||||
|
||||
if (!text) return 'indefinida';
|
||||
if (/\b(alta|urgente|imediato|hoje|agora|essa semana|parado|problema serio|1)\b/.test(text)) return 'alta';
|
||||
if (/\b(media|medio|proximo mes|em breve|avaliando|2)\b/.test(text)) return 'media';
|
||||
if (/\b(baixa|sem pressa|futuro|pesquisa|so pesquisando|3)\b/.test(text)) return 'baixa';
|
||||
|
||||
return 'indefinida';
|
||||
}
|
||||
|
||||
export function normalizeCurrentSystem(value = '') {
|
||||
const text = cleanText(value);
|
||||
|
||||
if (!text) return 'nao_informado';
|
||||
if (/\b(trocar|insatisfeito|migrar|outro sistema|uso outro|4)\b/.test(text)) return 'quer_trocar';
|
||||
if (/\b(planilha|excel|manual|papel|3)\b/.test(text)) return 'planilhas';
|
||||
if (/\b(sim|tenho|uso sistema|ja uso|1)\b/.test(text)) return 'usa_sistema';
|
||||
if (/\b(nao|nenhum|nao tenho|sem sistema|2)\b/.test(text)) return 'sem_sistema';
|
||||
|
||||
return 'nao_informado';
|
||||
}
|
||||
|
||||
export function normalizeLead(input = {}) {
|
||||
const body = input.body && typeof input.body === 'object' ? input.body : input;
|
||||
const contact = body.contact ?? body.contato ?? {};
|
||||
const company = body.company ?? body.empresa_dados ?? {};
|
||||
const message = body.message ?? body.mensagem ?? body.text ?? body.texto ?? body.description ?? '';
|
||||
const serviceRaw = firstDefined(
|
||||
body.service,
|
||||
body.servico,
|
||||
body.interest,
|
||||
body.interesse,
|
||||
body.solution,
|
||||
body.solucao,
|
||||
message
|
||||
);
|
||||
|
||||
const city = firstDefined(company.city, company.cidade, body.city, body.cidade);
|
||||
const state = firstDefined(company.state, company.uf, body.state, body.uf, body.estado);
|
||||
const segment = firstDefined(company.segment, company.segmento, body.segment, body.segmento);
|
||||
const phone = normalizePhone(firstDefined(contact.phone, contact.whatsapp, body.phone, body.telefone, body.whatsapp, body.from));
|
||||
const name = firstDefined(contact.name, contact.nome, body.name, body.nome, body.contact_name, 'Lead sem nome');
|
||||
const email = firstDefined(contact.email, body.email);
|
||||
const companyName = firstDefined(company.name, company.nome, body.company_name, body.empresa, body.nome_empresa, 'Empresa nao informada');
|
||||
const pains = toArray(firstDefined(body.pains, body.dores, body.pain, body.dor, body.maior_dificuldade));
|
||||
const source = firstDefined(body.source, body.origem, body.canal, 'indefinida');
|
||||
const service = detectService(serviceRaw);
|
||||
const urgency = normalizeUrgency(firstDefined(body.urgency, body.urgencia, body.prazo, message));
|
||||
const currentSystem = normalizeCurrentSystem(firstDefined(body.current_system, body.sistema_atual, body.usa_sistema, body.sistema, message));
|
||||
const employees = numberFrom(firstDefined(company.employees, company.funcionarios, body.employees, body.funcionarios));
|
||||
const computers = numberFrom(firstDefined(company.computers, company.computadores, body.computers, body.computadores, body.usuarios));
|
||||
const whatsappVolume = numberFrom(firstDefined(body.whatsapp_volume, body.volume_whatsapp, body.mensagens_dia));
|
||||
const consent = Boolean(firstDefined(body.consent, body.consentimento, body.opt_in, body.lgpd, false));
|
||||
const cityKey = cleanText(city);
|
||||
const segmentKey = cleanText(segment);
|
||||
|
||||
return {
|
||||
id: body.id ?? body.lead_id ?? `GTI-${Date.now()}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`,
|
||||
createdAt: body.created_at ?? body.createdAt ?? new Date().toISOString(),
|
||||
source: String(source || 'indefinida'),
|
||||
name: String(name),
|
||||
phone,
|
||||
email: String(email || ''),
|
||||
companyName: String(companyName),
|
||||
city: String(city || ''),
|
||||
state: String(state || ''),
|
||||
segment: String(segment || ''),
|
||||
service,
|
||||
serviceLabel: SERVICE_LABELS[service],
|
||||
urgency,
|
||||
urgencyLabel: URGENCY_LABELS[urgency],
|
||||
currentSystem,
|
||||
pains,
|
||||
message: String(message || ''),
|
||||
employees,
|
||||
computers,
|
||||
whatsappVolume,
|
||||
consent,
|
||||
isLocal: TARGET_CITY_ALIASES.has(cityKey) || (cityKey.includes('santo antonio') && cityKey.includes('jesus')),
|
||||
isTargetSegment: isTargetSegmentMatch(segmentKey),
|
||||
raw: body
|
||||
};
|
||||
}
|
||||
|
||||
export function prioritizeLead(lead) {
|
||||
const hasWhatsapp = Boolean(lead.phone);
|
||||
|
||||
return {
|
||||
priority: hasWhatsapp ? 'com_whatsapp' : 'sem_whatsapp',
|
||||
hasWhatsapp,
|
||||
reasons: hasWhatsapp ? ['Possui WhatsApp/telefone para contato'] : []
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNextQuestion(lead) {
|
||||
if (lead.service === 'indefinido') {
|
||||
return [
|
||||
'Para te direcionar melhor, escolha uma opcao:',
|
||||
'1 - Sistema ERP / gestao empresarial',
|
||||
'2 - Suporte e gestao de TI',
|
||||
'3 - Autoatendimento para WhatsApp',
|
||||
'4 - Ainda nao sei, preciso de orientacao'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (lead.service === 'erp') {
|
||||
if (!lead.segment) {
|
||||
return [
|
||||
'Qual o segmento da sua empresa?',
|
||||
'1 - Comercio / loja',
|
||||
'2 - Restaurante / delivery',
|
||||
'3 - Mercado / supermercado',
|
||||
'4 - Distribuidora',
|
||||
'5 - Servicos',
|
||||
'6 - Outro'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (lead.currentSystem === 'nao_informado') {
|
||||
return [
|
||||
'Hoje voce ja usa algum sistema?',
|
||||
'1 - Sim',
|
||||
'2 - Nao',
|
||||
'3 - Uso planilhas',
|
||||
'4 - Uso outro sistema, mas quero trocar'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (!lead.pains.length) {
|
||||
return [
|
||||
'Qual sua maior dificuldade hoje?',
|
||||
'1 - Controle financeiro',
|
||||
'2 - Estoque',
|
||||
'3 - Vendas',
|
||||
'4 - Emissao fiscal',
|
||||
'5 - Relatorios',
|
||||
'6 - Tudo muito manual'
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (lead.service === 'ti') {
|
||||
if (!lead.computers) {
|
||||
return [
|
||||
'Sua empresa possui quantos computadores ou usuarios?',
|
||||
'1 - Ate 5',
|
||||
'2 - 6 a 15',
|
||||
'3 - 16 a 30',
|
||||
'4 - Mais de 30'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (!lead.pains.length) {
|
||||
return [
|
||||
'Qual sua maior necessidade de TI?',
|
||||
'1 - Suporte tecnico',
|
||||
'2 - Servidores',
|
||||
'3 - Rede / internet',
|
||||
'4 - Backup',
|
||||
'5 - Seguranca',
|
||||
'6 - Terceirizar o TI'
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (lead.service === 'whatsapp') {
|
||||
if (!lead.pains.length) {
|
||||
return [
|
||||
'Voce quer automatizar qual tipo de atendimento?',
|
||||
'1 - Vendas',
|
||||
'2 - Suporte',
|
||||
'3 - Agendamento',
|
||||
'4 - Cardapio / pedidos',
|
||||
'5 - Triagem de mensagens',
|
||||
'6 - Financeiro / segunda via'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (!lead.whatsappVolume) {
|
||||
return [
|
||||
'Em media, quantas mensagens chegam no WhatsApp da empresa por dia?',
|
||||
'1 - Ate 20',
|
||||
'2 - 21 a 50',
|
||||
'3 - 51 a 100',
|
||||
'4 - Mais de 100'
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (lead.urgency === 'indefinida') {
|
||||
return [
|
||||
'Qual a urgencia para resolver isso?',
|
||||
'1 - Alta, preciso resolver agora',
|
||||
'2 - Media, quero avaliar para breve',
|
||||
'3 - Baixa, estou pesquisando'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
'Pelo que voce informou, faz sentido um diagnostico rapido com a GTI.',
|
||||
'Escolha o melhor horario:',
|
||||
'1 - Hoje pela manha',
|
||||
'2 - Hoje a tarde',
|
||||
'3 - Amanha pela manha',
|
||||
'4 - Amanha a tarde'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildInternalWhatsAppMessage(lead, priorityInfo) {
|
||||
const pains = lead.pains.length ? lead.pains.join(', ') : 'Nao informado';
|
||||
const phone = lead.phone || 'Nao informado';
|
||||
const priority = priorityInfo.priority === 'com_whatsapp' ? 'COM WHATSAPP' : 'SEM WHATSAPP';
|
||||
|
||||
return [
|
||||
`Novo lead captado - ${priority}`,
|
||||
'',
|
||||
`Empresa: ${lead.companyName}`,
|
||||
`Contato: ${lead.name}`,
|
||||
`WhatsApp: ${phone}`,
|
||||
`Cidade/UF: ${lead.city || 'Nao informada'}${lead.state ? `/${lead.state}` : ''}`,
|
||||
`Segmento: ${lead.segment || 'Nao informado'}`,
|
||||
`Origem: ${lead.source}`,
|
||||
`Dores: ${pains}`,
|
||||
'',
|
||||
'Acao recomendada: tratar manualmente pela equipe GTI.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCrmRecord(lead, priorityInfo) {
|
||||
return {
|
||||
lead_id: lead.id,
|
||||
data_criacao: lead.createdAt,
|
||||
status: 'Captado',
|
||||
prioridade: priorityInfo.priority,
|
||||
tem_whatsapp: Boolean(lead.phone),
|
||||
origem: lead.source,
|
||||
servico: lead.service,
|
||||
servico_label: lead.serviceLabel,
|
||||
empresa: lead.companyName,
|
||||
contato: lead.name,
|
||||
whatsapp: lead.phone,
|
||||
email: lead.email,
|
||||
cidade: lead.city,
|
||||
uf: lead.state,
|
||||
segmento: lead.segment,
|
||||
urgencia: lead.urgency,
|
||||
sistema_atual: lead.currentSystem,
|
||||
dores: lead.pains.join('; '),
|
||||
funcionarios: lead.employees,
|
||||
computadores: lead.computers,
|
||||
volume_whatsapp: lead.whatsappVolume,
|
||||
consentimento: lead.consent,
|
||||
proxima_acao: 'Tratamento manual',
|
||||
observacao: lead.message
|
||||
};
|
||||
}
|
||||
|
||||
export function processLead(input = {}, options = {}) {
|
||||
const lead = normalizeLead(input);
|
||||
const priority = prioritizeLead(lead);
|
||||
const commercialWhatsApp = options.commercialWhatsApp || process.env.GTI_COMMERCIAL_WHATSAPP || '{{GTI_COMMERCIAL_WHATSAPP}}';
|
||||
|
||||
return {
|
||||
lead,
|
||||
priority,
|
||||
crmRecord: buildCrmRecord(lead, priority),
|
||||
whatsapp: {
|
||||
internal: {
|
||||
to: commercialWhatsApp,
|
||||
message: buildInternalWhatsAppMessage(lead, priority)
|
||||
}
|
||||
},
|
||||
nextAction: 'tratamento_manual'
|
||||
};
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { cleanText } from './lead-engine.mjs';
|
||||
|
||||
// Regras de validacao, score e deduplicacao do tratamento de leads.
|
||||
//
|
||||
// Dois indicadores separados:
|
||||
// Score Qualidade (0-100, "os dados sao confiaveis?"): +30 WhatsApp valido,
|
||||
// +20 CNPJ valido, +20 empresa valida, +5 cidade, +10 segmento, +10 CNAE,
|
||||
// +5 website.
|
||||
// Score Comercial (0-100, "qual a chance de comprar?"): aderencia do nicho
|
||||
// (alta 40 / media 30 / baixa 20) + 15 website + 10 e-mail + 15 WhatsApp
|
||||
// valido + 20 CNPJ valido.
|
||||
// Score Geral = round(Comercial*0.6 + Qualidade*0.4) — usado pra ordenar a tela.
|
||||
// Classificacao (por Score Geral): 90-100 excelente, 75-89 bom, 55-74 medio,
|
||||
// 35-54 baixo, 0-34 muito_baixo. Prioridade comercial deriva 1:1 dela.
|
||||
// Duplicidade: CNPJ valido > WhatsApp valido > empresa + cidade.
|
||||
|
||||
export function onlyDigits(value = '') {
|
||||
return String(value).replace(/\D/g, '');
|
||||
}
|
||||
|
||||
export function validatePhone(value = '') {
|
||||
const digits = onlyDigits(value);
|
||||
if (!digits) return { valid: false, motivo: 'telefone vazio' };
|
||||
if (!digits.startsWith('55') || digits.length < 12 || digits.length > 13) {
|
||||
return { valid: false, motivo: 'telefone invalido' };
|
||||
}
|
||||
|
||||
const ddd = Number(digits.slice(2, 4));
|
||||
if (ddd < 11 || ddd > 99) return { valid: false, motivo: 'ddd invalido' };
|
||||
|
||||
const local = digits.slice(4);
|
||||
if (/^(\d)\1+$/.test(local)) return { valid: false, motivo: 'telefone invalido' };
|
||||
|
||||
return { valid: true, motivo: 'telefone valido' };
|
||||
}
|
||||
|
||||
export function validateCnpj(value = '') {
|
||||
const cnpj = onlyDigits(value);
|
||||
if (cnpj.length !== 14 || /^(\d)\1+$/.test(cnpj)) return false;
|
||||
|
||||
const digit = (base) => {
|
||||
let weight = base.length - 7;
|
||||
let sum = 0;
|
||||
for (const char of base) {
|
||||
sum += Number(char) * weight;
|
||||
weight -= 1;
|
||||
if (weight < 2) weight = 9;
|
||||
}
|
||||
const rest = sum % 11;
|
||||
return rest < 2 ? 0 : 11 - rest;
|
||||
};
|
||||
|
||||
return digit(cnpj.slice(0, 12)) === Number(cnpj[12])
|
||||
&& digit(cnpj.slice(0, 13)) === Number(cnpj[13]);
|
||||
}
|
||||
|
||||
export function validateEmpresa(nome = '') {
|
||||
const texto = String(nome ?? '').trim();
|
||||
const limpo = cleanText(texto);
|
||||
|
||||
if (!limpo || limpo.length < 2) {
|
||||
return { valid: false, motivo: 'empresa vazia ou com lixo' };
|
||||
}
|
||||
if (/^\d/.test(texto)) {
|
||||
return { valid: false, motivo: 'empresa comeca com numero' };
|
||||
}
|
||||
if (!/[a-z]/.test(limpo)) {
|
||||
return { valid: false, motivo: 'empresa vazia ou com lixo' };
|
||||
}
|
||||
|
||||
return { valid: true, motivo: 'empresa valida' };
|
||||
}
|
||||
|
||||
// "Os dados deste lead sao confiaveis?" — nao mede potencial de compra.
|
||||
export function scoreQualidade({ whatsapp, cnpj, empresa, cidade, segmento, cnae, website }) {
|
||||
const telefone = validatePhone(whatsapp);
|
||||
const cnpjValido = validateCnpj(cnpj);
|
||||
const empresaCheck = validateEmpresa(empresa);
|
||||
|
||||
let score = 0;
|
||||
if (telefone.valid) score += 30;
|
||||
if (cnpjValido) score += 20;
|
||||
if (empresaCheck.valid) score += 20;
|
||||
if (String(cidade ?? '').trim()) score += 5;
|
||||
if (String(segmento ?? '').trim()) score += 10;
|
||||
if (onlyDigits(cnae)) score += 10;
|
||||
if (String(website ?? '').trim()) score += 5;
|
||||
|
||||
return {
|
||||
score,
|
||||
telefoneValido: telefone.valid,
|
||||
motivoTelefone: telefone.motivo,
|
||||
cnpjValido,
|
||||
empresaValida: empresaCheck.valid,
|
||||
motivoEmpresa: empresaCheck.motivo
|
||||
};
|
||||
}
|
||||
|
||||
const AFFINITY_POINTS = { alta: 40, media: 30, baixa: 20 };
|
||||
|
||||
// "Qual a chance desta empresa contratar uma solucao da GTI?"
|
||||
export function scoreComercial({ whatsapp, cnpj, website, email, affinity }) {
|
||||
let score = AFFINITY_POINTS[affinity] ?? AFFINITY_POINTS.media;
|
||||
if (String(website ?? '').trim()) score += 15;
|
||||
if (String(email ?? '').trim()) score += 10;
|
||||
if (validatePhone(whatsapp).valid) score += 15;
|
||||
if (validateCnpj(cnpj)) score += 20;
|
||||
return score;
|
||||
}
|
||||
|
||||
export function scoreGeral(scoreQualidadeValor, scoreComercialValor) {
|
||||
return Math.round(scoreComercialValor * 0.6 + scoreQualidadeValor * 0.4);
|
||||
}
|
||||
|
||||
// Prioridade comercial: 1 = atacar imediatamente, 4 = so em campanhas especificas.
|
||||
export const CLASSIFICACAO_TIERS = [
|
||||
{ min: 90, id: 'excelente', label: 'Excelente', prioridade: 1 },
|
||||
{ min: 75, id: 'bom', label: 'Bom', prioridade: 1 },
|
||||
{ min: 55, id: 'medio', label: 'Médio', prioridade: 2 },
|
||||
{ min: 35, id: 'baixo', label: 'Baixo', prioridade: 3 },
|
||||
{ min: 0, id: 'muito_baixo', label: 'Muito Baixo', prioridade: 4 }
|
||||
];
|
||||
|
||||
export function classificarScoreGeral(score) {
|
||||
return CLASSIFICACAO_TIERS.find((tier) => score >= tier.min);
|
||||
}
|
||||
|
||||
// Chave byte-compativel com a chave_duplicidade do TRATADOS (cnpj:...) e com o
|
||||
// registro legado de envios (phone:... / name:<empresa>:<cidade>).
|
||||
export function dedupKey({ cnpj, whatsapp, empresa, cidade }) {
|
||||
const cnpjDigits = onlyDigits(cnpj);
|
||||
if (validateCnpj(cnpjDigits)) return `cnpj:${cnpjDigits}`;
|
||||
|
||||
const phoneDigits = onlyDigits(whatsapp);
|
||||
if (validatePhone(phoneDigits).valid) return `phone:${phoneDigits}`;
|
||||
|
||||
return `name:${cleanText(empresa)}:${cleanText(cidade)}`;
|
||||
}
|
||||
|
||||
export function extractDdd(whatsapp = '') {
|
||||
const digits = onlyDigits(whatsapp);
|
||||
return digits.startsWith('55') && digits.length >= 12 ? digits.slice(2, 4) : '';
|
||||
}
|
||||
|
||||
// 'lojas_de_calcados' -> 'Lojas De Calcados' (mesmo formato do segmento_legivel legado)
|
||||
export function segmentLabel(name = '') {
|
||||
return String(name)
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import http from 'node:http';
|
||||
import { paths } from '../env.mjs';
|
||||
import { createRouter } from './router.mjs';
|
||||
import { serveStatic } from './static.mjs';
|
||||
import { openSse } from './sse.mjs';
|
||||
import { registerMetaRoutes } from './routes/meta.mjs';
|
||||
import { registerCaptureRoutes } from './routes/capture.mjs';
|
||||
import { registerExtractionRoutes } from './routes/extractions.mjs';
|
||||
import { registerLeadRoutes } from './routes/leads.mjs';
|
||||
import { registerExportRoutes } from './routes/exports.mjs';
|
||||
import { registerAuthRoutes } from './routes/auth.mjs';
|
||||
import { getCookie, getSession, SESSION_COOKIE } from './session.mjs';
|
||||
|
||||
const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/health']);
|
||||
|
||||
const BODY_LIMIT = 1024 * 1024; // 1MB
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > BODY_LIMIT) {
|
||||
reject(new Error('corpo da requisicao muito grande'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!chunks.length) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch {
|
||||
reject(new Error('JSON invalido no corpo da requisicao'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function createAppServer({ db, jobs, log = () => {} }) {
|
||||
const router = createRouter();
|
||||
const deps = { db, jobs };
|
||||
|
||||
registerAuthRoutes(router, deps);
|
||||
registerMetaRoutes(router, deps);
|
||||
registerCaptureRoutes(router, deps);
|
||||
registerExtractionRoutes(router, deps);
|
||||
registerLeadRoutes(router, deps);
|
||||
registerExportRoutes(router, deps);
|
||||
|
||||
// SSE de progresso dos jobs (rota especial: gerencia a resposta manualmente)
|
||||
router.add('GET', '/api/jobs/:jobId/events', async (req, res, { params }) => {
|
||||
const sse = openSse(res);
|
||||
const unsubscribe = jobs.subscribe(params.jobId, (event) => {
|
||||
const name = event.t === 'done' ? 'done' : event.t === 'error' ? 'error' : event.t;
|
||||
sse.send(name, event);
|
||||
});
|
||||
|
||||
if (unsubscribe === null) {
|
||||
sse.send('error', { t: 'error', message: 'job nao encontrado (o servidor pode ter sido reiniciado)' });
|
||||
sse.close();
|
||||
return;
|
||||
}
|
||||
|
||||
req.on('close', () => {
|
||||
unsubscribe();
|
||||
sse.close();
|
||||
});
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
const pathname = url.pathname;
|
||||
|
||||
const json = (status, data) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
const session = getSession(getCookie(req, SESSION_COOKIE));
|
||||
const user = session?.user || null;
|
||||
|
||||
try {
|
||||
if (pathname.startsWith('/api/') && !PUBLIC_API_PATHS.has(pathname) && !user) {
|
||||
json(401, { erro: 'não autenticado' });
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = router.match(req.method, pathname);
|
||||
if (matched) {
|
||||
let body = null;
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
body = await readJsonBody(req);
|
||||
}
|
||||
await matched.handler(req, res, { params: matched.params, query: url.searchParams, body, json, user });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && !pathname.startsWith('/api/')) {
|
||||
if ((pathname === '/' || pathname === '/index.html') && !user) {
|
||||
res.writeHead(302, { location: '/login.html' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (serveStatic(paths.public, pathname, res)) return;
|
||||
}
|
||||
|
||||
json(404, { erro: `rota nao encontrada: ${req.method} ${pathname}` });
|
||||
} catch (error) {
|
||||
log(`erro em ${req.method} ${pathname}: ${error.stack || error.message}`);
|
||||
if (!res.headersSent) {
|
||||
json(500, { erro: String(error.message || error) });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Roteador minimo: metodo + padrao com :parametros. Sem dependencias.
|
||||
export function createRouter() {
|
||||
const routes = [];
|
||||
|
||||
function add(method, pattern, handler) {
|
||||
const parts = pattern.split('/').filter(Boolean);
|
||||
routes.push({ method, parts, handler, pattern });
|
||||
}
|
||||
|
||||
function match(method, pathname) {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.method !== method || route.parts.length !== parts.length) continue;
|
||||
|
||||
const params = {};
|
||||
let ok = true;
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const expected = route.parts[i];
|
||||
if (expected.startsWith(':')) {
|
||||
params[expected.slice(1)] = decodeURIComponent(parts[i]);
|
||||
} else if (expected !== parts[i]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return { handler: route.handler, params };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return { add, match };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { findUserByUsername, createUser, listUsers } from '../../db.mjs';
|
||||
import { hashPassword, verifyPassword } from '../../auth.mjs';
|
||||
import { createSession, destroySession, getCookie, sessionCookieHeader, clearSessionCookieHeader, SESSION_COOKIE } from '../session.mjs';
|
||||
|
||||
function publicUser(user) {
|
||||
return { id: user.id, username: user.username, role: user.role };
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(router, { db }) {
|
||||
router.add('POST', '/api/auth/login', async (req, res, { json, body }) => {
|
||||
const username = String(body?.username || '').trim();
|
||||
const password = String(body?.password || '');
|
||||
const user = username ? findUserByUsername(db, username) : null;
|
||||
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
json(401, { erro: 'usuário ou senha inválidos' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = createSession(publicUser(user));
|
||||
res.setHeader('Set-Cookie', sessionCookieHeader(token));
|
||||
json(200, { user: publicUser(user) });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/auth/logout', async (req, res, { json }) => {
|
||||
const token = getCookie(req, SESSION_COOKIE);
|
||||
if (token) destroySession(token);
|
||||
res.setHeader('Set-Cookie', clearSessionCookieHeader());
|
||||
json(200, { ok: true });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/auth/me', async (req, res, { json, user }) => {
|
||||
if (!user) {
|
||||
json(401, { erro: 'não autenticado' });
|
||||
return;
|
||||
}
|
||||
json(200, { user });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/users', async (req, res, { json, user }) => {
|
||||
if (user?.role !== 'admin') {
|
||||
json(403, { erro: 'apenas administradores podem ver os usuários' });
|
||||
return;
|
||||
}
|
||||
json(200, { users: listUsers(db) });
|
||||
});
|
||||
|
||||
router.add('POST', '/api/users', async (req, res, { json, user, body }) => {
|
||||
if (user?.role !== 'admin') {
|
||||
json(403, { erro: 'apenas administradores podem criar usuários' });
|
||||
return;
|
||||
}
|
||||
|
||||
const username = String(body?.username || '').trim();
|
||||
const password = String(body?.password || '');
|
||||
const role = body?.role === 'admin' ? 'admin' : 'comercial';
|
||||
|
||||
if (username.length < 3) {
|
||||
json(400, { erro: 'usuário deve ter ao menos 3 caracteres' });
|
||||
return;
|
||||
}
|
||||
if (password.length < 4) {
|
||||
json(400, { erro: 'senha deve ter ao menos 4 caracteres' });
|
||||
return;
|
||||
}
|
||||
if (findUserByUsername(db, username)) {
|
||||
json(409, { erro: 'já existe um usuário com esse nome' });
|
||||
return;
|
||||
}
|
||||
|
||||
createUser(db, { username, passwordHash: hashPassword(password), role });
|
||||
json(201, { ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { paths } from '../../env.mjs';
|
||||
import { UF_NAMES } from '../../sources/common.mjs';
|
||||
import { getSource } from '../../sources/index.mjs';
|
||||
|
||||
function hasCnpjBaseForUf(uf) {
|
||||
return fs.existsSync(path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`))
|
||||
|| (uf === 'BA' && fs.existsSync(path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')));
|
||||
}
|
||||
|
||||
export function registerCaptureRoutes(router, { db, jobs }) {
|
||||
router.add('POST', '/api/capture/runs', async (req, res, { json, body }) => {
|
||||
const sources = Array.isArray(body?.sources) ? body.sources.filter(Boolean) : [];
|
||||
const uf = String(body?.uf || '').toUpperCase();
|
||||
const cities = Array.isArray(body?.cities) && body.cities.length ? body.cities : ['all'];
|
||||
const segments = Array.isArray(body?.segments) && body.segments.length ? body.segments : ['all'];
|
||||
const limits = {
|
||||
maxPerQuery: Math.max(1, Number(body?.limits?.maxPerQuery) || 10),
|
||||
queryLimit: Math.max(0, Number(body?.limits?.queryLimit) || 0),
|
||||
googlePages: Math.min(3, Math.max(1, Number(body?.limits?.googlePages) || 3))
|
||||
};
|
||||
|
||||
if (!sources.length) {
|
||||
json(400, { erro: 'selecione pelo menos uma fonte' });
|
||||
return;
|
||||
}
|
||||
for (const id of sources) {
|
||||
if (!getSource(id)) {
|
||||
json(400, { erro: `fonte desconhecida: ${id}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!UF_NAMES[uf]) {
|
||||
json(400, { erro: 'informe uma UF valida' });
|
||||
return;
|
||||
}
|
||||
if ((sources.includes('cnpj-base') || sources.includes('brasilapi')) && !hasCnpjBaseForUf(uf)) {
|
||||
json(409, {
|
||||
erro: `a base CNPJ de ${uf} ainda nao foi extraida`,
|
||||
precisaExtracao: true,
|
||||
uf
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const running = db.prepare("SELECT id FROM capture_runs WHERE status IN ('pending','running')").get();
|
||||
if (running) {
|
||||
json(409, { erro: `ja existe uma captura em andamento (#${running.id}). Aguarde ou cancele.` });
|
||||
return;
|
||||
}
|
||||
|
||||
const params = { sources, uf, cities, segments, limits };
|
||||
const inserted = db.prepare("INSERT INTO capture_runs (params_json, status) VALUES (?, 'pending')")
|
||||
.run(JSON.stringify(params));
|
||||
const runId = Number(inserted.lastInsertRowid);
|
||||
|
||||
const jobId = jobs.startJob({
|
||||
type: 'captura',
|
||||
script: 'src/capture/capture-job.mjs',
|
||||
args: [`--run-id=${runId}`],
|
||||
meta: { runId, uf, sources }
|
||||
});
|
||||
db.prepare('UPDATE capture_runs SET job_id = ? WHERE id = ?').run(jobId, runId);
|
||||
|
||||
json(202, { runId, jobId });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/capture/runs', async (req, res, { json, query }) => {
|
||||
const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20));
|
||||
const rows = db.prepare(
|
||||
`SELECT id, job_id, params_json, status, progress_json, raw_count, new_count, dup_count,
|
||||
error, started_at, finished_at, created_at
|
||||
FROM capture_runs ORDER BY id DESC LIMIT ?`
|
||||
).all(limit);
|
||||
|
||||
json(200, {
|
||||
runs: rows.map((row) => ({
|
||||
...row,
|
||||
params: JSON.parse(row.params_json || '{}'),
|
||||
progress: JSON.parse(row.progress_json || 'null'),
|
||||
params_json: undefined,
|
||||
progress_json: undefined
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
router.add('GET', '/api/capture/runs/:id', async (req, res, { json, params }) => {
|
||||
const row = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(Number(params.id));
|
||||
if (!row) {
|
||||
json(404, { erro: 'execucao nao encontrada' });
|
||||
return;
|
||||
}
|
||||
json(200, {
|
||||
...row,
|
||||
params: JSON.parse(row.params_json || '{}'),
|
||||
progress: JSON.parse(row.progress_json || 'null'),
|
||||
params_json: undefined,
|
||||
progress_json: undefined
|
||||
});
|
||||
});
|
||||
|
||||
router.add('POST', '/api/capture/runs/:id/cancel', async (req, res, { json, params }) => {
|
||||
const row = db.prepare('SELECT job_id, status FROM capture_runs WHERE id = ?').get(Number(params.id));
|
||||
if (!row) {
|
||||
json(404, { erro: 'execucao nao encontrada' });
|
||||
return;
|
||||
}
|
||||
const ok = row.job_id ? jobs.cancelJob(row.job_id) : false;
|
||||
json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { paths } from '../../env.mjs';
|
||||
import { countLeads } from '../../treatment/queries.mjs';
|
||||
|
||||
const TIPOS = new Set(['excel', 'zpro-oportunidades', 'zpro-bulk']);
|
||||
|
||||
function parseExportRow(row) {
|
||||
return {
|
||||
...row,
|
||||
filtro: JSON.parse(row.filtro_json || '{}'),
|
||||
params: JSON.parse(row.params_json || '{}'),
|
||||
arquivos: JSON.parse(row.arquivos_json || '[]'),
|
||||
filtro_json: undefined,
|
||||
params_json: undefined,
|
||||
arquivos_json: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function registerExportRoutes(router, { db, jobs }) {
|
||||
router.add('POST', '/api/exports', async (req, res, { json, body }) => {
|
||||
const tipo = String(body?.tipo || '');
|
||||
if (!TIPOS.has(tipo)) {
|
||||
json(400, { erro: `tipo invalido. Use: ${[...TIPOS].join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const filtro = body?.filtro && typeof body.filtro === 'object' ? body.filtro : {};
|
||||
const params = body?.params && typeof body.params === 'object' ? body.params : {};
|
||||
|
||||
const total = countLeads(db, filtro);
|
||||
if (!total) {
|
||||
json(400, { erro: 'nenhum lead corresponde ao filtro selecionado' });
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = db.prepare(
|
||||
"INSERT INTO exports (tipo, filtro_json, params_json, status) VALUES (?, ?, ?, 'pending')"
|
||||
).run(tipo, JSON.stringify(filtro), JSON.stringify(params));
|
||||
const exportId = Number(inserted.lastInsertRowid);
|
||||
|
||||
const jobId = jobs.startJob({
|
||||
type: 'exportacao',
|
||||
script: 'src/export/export-job.mjs',
|
||||
args: [`--export-id=${exportId}`],
|
||||
meta: { exportId, tipo, total }
|
||||
});
|
||||
db.prepare('UPDATE exports SET job_id = ? WHERE id = ?').run(jobId, exportId);
|
||||
|
||||
json(202, { exportId, jobId, totalEstimado: total });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/exports', async (req, res, { json, query }) => {
|
||||
const limit = Math.min(100, Math.max(1, Number(query.get('limit')) || 20));
|
||||
const rows = db.prepare('SELECT * FROM exports ORDER BY id DESC LIMIT ?').all(limit);
|
||||
json(200, { exports: rows.map(parseExportRow) });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/exports/:id', async (req, res, { json, params }) => {
|
||||
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(Number(params.id));
|
||||
if (!row) {
|
||||
json(404, { erro: 'exportacao nao encontrada' });
|
||||
return;
|
||||
}
|
||||
json(200, parseExportRow(row));
|
||||
});
|
||||
|
||||
router.add('GET', '/api/exports/:id/download', async (req, res, { json, params, query }) => {
|
||||
const row = db.prepare('SELECT * FROM exports WHERE id = ?').get(Number(params.id));
|
||||
if (!row) {
|
||||
json(404, { erro: 'exportacao nao encontrada' });
|
||||
return;
|
||||
}
|
||||
|
||||
const arquivos = JSON.parse(row.arquivos_json || '[]');
|
||||
const nome = String(query.get('arquivo') || arquivos[0] || '');
|
||||
if (!arquivos.includes(nome)) {
|
||||
json(400, { erro: 'arquivo nao pertence a esta exportacao' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = path.resolve(paths.exports, `export-${row.id}`);
|
||||
const resolved = path.resolve(dir, nome);
|
||||
if (!resolved.startsWith(dir + path.sep) || !fs.existsSync(resolved)) {
|
||||
json(404, { erro: 'arquivo nao encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-disposition': `attachment; filename="${encodeURIComponent(nome)}"`,
|
||||
'content-length': fs.statSync(resolved).size
|
||||
});
|
||||
fs.createReadStream(resolved).pipe(res);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs';
|
||||
import { paths } from '../../env.mjs';
|
||||
import { UF_NAMES } from '../../sources/common.mjs';
|
||||
import { resolveZipReader } from '../../extract/rfb-extractor.mjs';
|
||||
|
||||
export function registerExtractionRoutes(router, { db, jobs }) {
|
||||
router.add('POST', '/api/extractions', async (req, res, { json, body }) => {
|
||||
const uf = String(body?.uf || '').toUpperCase();
|
||||
if (!UF_NAMES[uf]) {
|
||||
json(400, { erro: 'informe uma UF valida' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(paths.rfbRaw)) {
|
||||
json(409, { erro: `zips da Receita Federal nao encontrados em ${paths.rfbRaw}. Rode scripts/baixar-base-rfb.sh primeiro.` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolveZipReader();
|
||||
} catch (error) {
|
||||
json(409, { erro: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const running = db.prepare(
|
||||
"SELECT id, uf FROM extractions WHERE status IN ('pending','running')"
|
||||
).get();
|
||||
if (running) {
|
||||
json(409, { erro: `ja existe uma extracao em andamento (${running.uf}). Aguarde terminar.` });
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = db.prepare("INSERT INTO extractions (uf, status) VALUES (?, 'pending')").run(uf);
|
||||
const extractionId = Number(inserted.lastInsertRowid);
|
||||
|
||||
const jobId = jobs.startJob({
|
||||
type: 'extracao',
|
||||
script: 'src/extract/extract-job.mjs',
|
||||
args: [`--extraction-id=${extractionId}`],
|
||||
meta: { extractionId, uf }
|
||||
});
|
||||
db.prepare('UPDATE extractions SET job_id = ? WHERE id = ?').run(jobId, extractionId);
|
||||
|
||||
json(202, { extractionId, jobId });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/extractions', async (req, res, { json }) => {
|
||||
const rows = db.prepare(
|
||||
`SELECT id, job_id, uf, status, csv_path, total_rows, with_phone, municipios_count,
|
||||
progress_json, error, started_at, finished_at, created_at
|
||||
FROM extractions ORDER BY id DESC LIMIT 20`
|
||||
).all();
|
||||
json(200, {
|
||||
extractions: rows.map((row) => ({
|
||||
...row,
|
||||
progress: JSON.parse(row.progress_json || 'null'),
|
||||
progress_json: undefined
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
router.add('POST', '/api/extractions/:id/cancel', async (req, res, { json, params }) => {
|
||||
const row = db.prepare('SELECT job_id, status FROM extractions WHERE id = ?').get(Number(params.id));
|
||||
if (!row) {
|
||||
json(404, { erro: 'extracao nao encontrada' });
|
||||
return;
|
||||
}
|
||||
const ok = row.job_id ? jobs.cancelJob(row.job_id) : false;
|
||||
json(ok ? 202 : 409, ok ? { cancelando: true } : { erro: `nao ha job ativo para cancelar (status: ${row.status})` });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { listLeads, getLead, leadFacets } from '../../treatment/queries.mjs';
|
||||
import {
|
||||
dashboardSummary, dashboardRanking, dashboardDailyEvolution, dashboardMaiorOportunidade
|
||||
} from '../../treatment/dashboard.mjs';
|
||||
|
||||
function filterFromQuery(query) {
|
||||
const filtro = {};
|
||||
for (const key of ['uf', 'cidade', 'segmento', 'classificacao', 'interesse', 'origem',
|
||||
'telefone_valido', 'exportado', 'q', 'score_min', 'page', 'pageSize', 'sort',
|
||||
'prioridade', 'produto', 'website', 'email']) {
|
||||
const value = query.get(key);
|
||||
if (value !== null && value !== '') filtro[key] = value;
|
||||
}
|
||||
return filtro;
|
||||
}
|
||||
|
||||
export function registerLeadRoutes(router, { db }) {
|
||||
router.add('GET', '/api/leads', async (req, res, { json, query }) => {
|
||||
json(200, listLeads(db, filterFromQuery(query)));
|
||||
});
|
||||
|
||||
router.add('GET', '/api/leads/facets', async (req, res, { json, query }) => {
|
||||
json(200, leadFacets(db, filterFromQuery(query)));
|
||||
});
|
||||
|
||||
router.add('GET', '/api/leads/:id', async (req, res, { json, params }) => {
|
||||
const lead = getLead(db, Number(params.id));
|
||||
if (!lead) {
|
||||
json(404, { erro: 'lead nao encontrado' });
|
||||
return;
|
||||
}
|
||||
json(200, lead);
|
||||
});
|
||||
|
||||
router.add('GET', '/api/dashboard/summary', async (req, res, { json, query }) => {
|
||||
json(200, dashboardSummary(db, filterFromQuery(query)));
|
||||
});
|
||||
|
||||
router.add('GET', '/api/dashboard/rankings', async (req, res, { json, query }) => {
|
||||
const by = query.get('by') || 'cidade';
|
||||
const limit = query.get('limit') || 15;
|
||||
json(200, { by, ranking: dashboardRanking(db, by, filterFromQuery(query), limit) });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/dashboard/evolution', async (req, res, { json, query }) => {
|
||||
const dias = query.get('dias') || 14;
|
||||
json(200, { evolution: dashboardDailyEvolution(db, filterFromQuery(query), dias) });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/dashboard/oportunidade', async (req, res, { json, query }) => {
|
||||
json(200, { oportunidade: dashboardMaiorOportunidade(db, filterFromQuery(query)) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { paths } from '../../env.mjs';
|
||||
import { UF_NAMES } from '../../sources/common.mjs';
|
||||
import { describeSources } from '../../sources/index.mjs';
|
||||
import { getSetting, setSetting, resetLeadsBase } from '../../db.mjs';
|
||||
import { cleanText } from '../../lead-engine.mjs';
|
||||
|
||||
// Senha de confirmacao para a limpeza total da base (acao destrutiva e
|
||||
// irreversivel) — pedida no modal de Configuracoes antes de zerar leads,
|
||||
// exportacoes, capturas e extracoes. Vem do .env (nunca versionado).
|
||||
const RESET_BASE_PASSWORD = process.env.GTI_RESET_PASSWORD;
|
||||
|
||||
function extractedCsvForUf(uf) {
|
||||
const candidates = [
|
||||
path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`),
|
||||
// nome antigo da primeira extracao (Bahia)
|
||||
...(uf === 'BA' ? [path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')] : [])
|
||||
];
|
||||
return candidates.find((file) => fs.existsSync(file)) || null;
|
||||
}
|
||||
|
||||
export function registerMetaRoutes(router, { db, jobs }) {
|
||||
router.add('GET', '/api/health', async (req, res, { json }) => {
|
||||
json(200, { ok: true, app: 'gti-maquina-comercial', pid: process.pid });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/meta/ufs', async (req, res, { json }) => {
|
||||
const doneByUf = new Map(
|
||||
db.prepare(
|
||||
`SELECT uf, MAX(finished_at) AS finished_at, total_rows, municipios_count
|
||||
FROM extractions WHERE status = 'done' GROUP BY uf`
|
||||
).all().map((row) => [row.uf, row])
|
||||
);
|
||||
const municipiosByUf = new Map(
|
||||
db.prepare('SELECT uf, COUNT(*) AS total FROM municipios GROUP BY uf').all()
|
||||
.map((row) => [row.uf, row.total])
|
||||
);
|
||||
const runningUfs = new Set(
|
||||
db.prepare("SELECT uf FROM extractions WHERE status IN ('pending','running')").all().map((r) => r.uf)
|
||||
);
|
||||
|
||||
const ufs = Object.entries(UF_NAMES).map(([uf, nome]) => {
|
||||
const csv = extractedCsvForUf(uf);
|
||||
const done = doneByUf.get(uf);
|
||||
return {
|
||||
uf,
|
||||
nome,
|
||||
municipios: municipiosByUf.get(uf) || 0,
|
||||
extraida: Boolean(csv),
|
||||
extraindo: runningUfs.has(uf),
|
||||
extracao: done ? { finished_at: done.finished_at, total_rows: done.total_rows } : null
|
||||
};
|
||||
});
|
||||
|
||||
json(200, { ufs });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/meta/municipios', async (req, res, { json, query }) => {
|
||||
const uf = String(query.get('uf') || '').toUpperCase();
|
||||
if (!UF_NAMES[uf]) {
|
||||
json(400, { erro: 'informe uf valida, ex.: ?uf=BA' });
|
||||
return;
|
||||
}
|
||||
|
||||
const q = String(query.get('q') || '').toLowerCase();
|
||||
let rows = db.prepare('SELECT codigo_ibge, nome, nome_clean, uf FROM municipios WHERE uf = ? ORDER BY nome').all(uf);
|
||||
if (q) rows = rows.filter((row) => row.nome_clean.includes(q));
|
||||
|
||||
json(200, { municipios: rows.map((r) => ({ codigo: r.codigo_ibge, nome: r.nome })) });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/meta/segments', async (req, res, { json }) => {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, name, label, interest, cnae_codes_json FROM segments WHERE active = 1 ORDER BY label'
|
||||
).all();
|
||||
json(200, {
|
||||
segments: rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
label: row.label,
|
||||
interesse: row.interest,
|
||||
cnaes: JSON.parse(row.cnae_codes_json).length
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
// Busca livre em todas as ~1359 atividades (CNAE) da Receita Federal — usada
|
||||
// para adicionar nichos avulsos alem dos 57 pre-configurados em segments.
|
||||
router.add('GET', '/api/meta/cnaes', async (req, res, { json, query }) => {
|
||||
const q = cleanText(String(query.get('q') || ''));
|
||||
if (q.length < 2) {
|
||||
json(200, { cnaes: [] });
|
||||
return;
|
||||
}
|
||||
const rows = db.prepare('SELECT codigo, descricao FROM cnaes').all();
|
||||
const achados = rows
|
||||
.filter((r) => cleanText(r.descricao).includes(q) || r.codigo.startsWith(q))
|
||||
.slice(0, 30);
|
||||
json(200, { cnaes: achados });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/meta/sources', async (req, res, { json }) => {
|
||||
json(200, { sources: describeSources() });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/settings', async (req, res, { json }) => {
|
||||
const rows = db.prepare('SELECT key, value FROM settings ORDER BY key').all();
|
||||
json(200, { settings: Object.fromEntries(rows.map((r) => [r.key, r.value])) });
|
||||
});
|
||||
|
||||
router.add('PUT', '/api/settings', async (req, res, { json, body }) => {
|
||||
if (!body || typeof body !== 'object') {
|
||||
json(400, { erro: 'corpo JSON esperado: {"chave": "valor"}' });
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
setSetting(db, key, value);
|
||||
}
|
||||
json(200, { ok: true, atualizadas: Object.keys(body).length });
|
||||
});
|
||||
|
||||
router.add('GET', '/api/jobs/active', async (req, res, { json }) => {
|
||||
json(200, { jobs: jobs.activeJobs() });
|
||||
});
|
||||
|
||||
// Zera toda a base de leads (irreversivel): exige a senha de confirmacao.
|
||||
router.add('POST', '/api/admin/reset', async (req, res, { json, body }) => {
|
||||
if (!body || body.senha !== RESET_BASE_PASSWORD) {
|
||||
json(401, { erro: 'senha incorreta' });
|
||||
return;
|
||||
}
|
||||
|
||||
resetLeadsBase(db);
|
||||
|
||||
for (const entry of fs.readdirSync(paths.exports, { withFileTypes: true })) {
|
||||
fs.rmSync(path.join(paths.exports, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
json(200, { ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Sessoes em memoria: simples e suficiente para um app local de usuario unico
|
||||
// por vez. Reiniciar o servidor derruba as sessoes (usuarios logam de novo).
|
||||
export const SESSION_COOKIE = 'gti_session';
|
||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000; // 12h, renovada a cada requisicao
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
export function createSession(user) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
sessions.set(token, { user, expiresAt: Date.now() + SESSION_TTL_MS });
|
||||
return token;
|
||||
}
|
||||
|
||||
export function getSession(token) {
|
||||
if (!token) return null;
|
||||
const session = sessions.get(token);
|
||||
if (!session) return null;
|
||||
if (session.expiresAt < Date.now()) {
|
||||
sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
||||
return session;
|
||||
}
|
||||
|
||||
export function destroySession(token) {
|
||||
sessions.delete(token);
|
||||
}
|
||||
|
||||
export function getCookie(req, name) {
|
||||
const header = req.headers.cookie;
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const separator = part.indexOf('=');
|
||||
if (separator === -1) continue;
|
||||
if (part.slice(0, separator).trim() === name) {
|
||||
return decodeURIComponent(part.slice(separator + 1).trim());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sessionCookieHeader(token) {
|
||||
return `${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
|
||||
}
|
||||
|
||||
export function clearSessionCookieHeader() {
|
||||
return `${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0`;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Server-Sent Events: canal de progresso ao vivo dos jobs para a tela.
|
||||
export function openSse(res) {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no'
|
||||
});
|
||||
res.write(':ok\n\n');
|
||||
|
||||
const keepAlive = setInterval(() => {
|
||||
try {
|
||||
res.write(':ping\n\n');
|
||||
} catch {
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}, 15000);
|
||||
keepAlive.unref();
|
||||
|
||||
return {
|
||||
send(event, data) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
},
|
||||
close() {
|
||||
clearInterval(keepAlive);
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
// ja fechado
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
// Servidor de arquivos do frontend (pasta public/), com protecao contra
|
||||
// path traversal: qualquer caminho resolvido fora da pasta e rejeitado.
|
||||
export function serveStatic(publicDir, urlPath, res) {
|
||||
const relative = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
|
||||
const resolved = path.resolve(publicDir, relative);
|
||||
|
||||
if (!resolved.startsWith(path.resolve(publicDir) + path.sep) && resolved !== path.resolve(publicDir, 'index.html')) {
|
||||
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('Acesso negado');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
res.writeHead(200, {
|
||||
'content-type': MIME[ext] || 'application/octet-stream',
|
||||
'cache-control': 'no-cache'
|
||||
});
|
||||
fs.createReadStream(resolved).pipe(res);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { cleanText } from '../lead-engine.mjs';
|
||||
import { onlyDigits } from '../scoring.mjs';
|
||||
import { streamCsvRows } from '../util/csv.mjs';
|
||||
import { buildLeadFromPlace, UA_HEADERS } from './common.mjs';
|
||||
|
||||
async function brasilApiCnpj(baseUrl, cnpj) {
|
||||
if (!cnpj || cnpj.length !== 14) return null;
|
||||
const response = await fetch(`${baseUrl}/cnpj/v1/${cnpj}`, { headers: UA_HEADERS });
|
||||
if (!response.ok) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function mergeBrasilApi(place, data) {
|
||||
if (!data) return place;
|
||||
return {
|
||||
...place,
|
||||
name: data.nome_fantasia || data.razao_social || place.name,
|
||||
legal_name: data.razao_social || place.legal_name,
|
||||
phone: [data.ddd_telefone_1, data.ddd_telefone_2].filter(Boolean)[0] || place.phone,
|
||||
city: data.municipio || place.city,
|
||||
state: data.uf || place.state,
|
||||
cnae: data.cnae_fiscal ? String(data.cnae_fiscal) : place.cnae,
|
||||
address: [data.logradouro, data.numero, data.bairro].filter(Boolean).join(', ') || place.address
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHeader(header) {
|
||||
return cleanText(header).replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
function getAny(row, keys) {
|
||||
for (const key of keys) {
|
||||
const normalized = normalizeHeader(key);
|
||||
if (row[normalized]) return row[normalized];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function cnpjMatchesSegment(row, segment, textCache) {
|
||||
const cnae = onlyDigits(getAny(row, ['cnae', 'cnae_fiscal', 'cnae principal', 'cnae_principal']));
|
||||
const cnaeCodes = segment._cnaeCodes;
|
||||
|
||||
for (const code of cnaeCodes) {
|
||||
if (cnae.startsWith(code)) return true;
|
||||
}
|
||||
|
||||
if (textCache.value === null) {
|
||||
textCache.value = cleanText([
|
||||
getAny(row, ['nome fantasia', 'nome_fantasia', 'fantasia']),
|
||||
getAny(row, ['razao social', 'razao_social', 'nome empresarial']),
|
||||
getAny(row, ['atividade principal', 'atividade_principal', 'descricao cnae', 'descricao_cnae'])
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
const text = textCache.value;
|
||||
if (!text) return false;
|
||||
return segment._queryTerms.some((term) => text.includes(term));
|
||||
}
|
||||
|
||||
function cnpjRowToPlace(row, segment, city, state) {
|
||||
const cnpj = onlyDigits(getAny(row, ['cnpj', 'cnpj_basico', 'cnpj completo', 'cnpj_completo']));
|
||||
const phone = [
|
||||
getAny(row, ['ddd 1', 'ddd1', 'ddd']),
|
||||
getAny(row, ['telefone 1', 'telefone1', 'telefone', 'whatsapp'])
|
||||
].filter(Boolean).join('');
|
||||
const name = getAny(row, ['nome fantasia', 'nome_fantasia', 'fantasia'])
|
||||
|| getAny(row, ['razao social', 'razao_social', 'nome empresarial']);
|
||||
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
name,
|
||||
legal_name: getAny(row, ['razao social', 'razao_social', 'nome empresarial']),
|
||||
phone,
|
||||
city: getAny(row, ['municipio', 'cidade']) || city,
|
||||
state: getAny(row, ['uf', 'estado']) || state,
|
||||
segment: segment.name,
|
||||
website: '',
|
||||
cnpj,
|
||||
cnae: getAny(row, ['cnae', 'cnae_fiscal', 'cnae principal', 'cnae_principal']),
|
||||
address: [
|
||||
getAny(row, ['logradouro']),
|
||||
getAny(row, ['numero']),
|
||||
getAny(row, ['bairro'])
|
||||
].filter(Boolean).join(', '),
|
||||
query: `cnpj-base ${segment.name} em ${city} ${state}`
|
||||
};
|
||||
}
|
||||
|
||||
// Fonte cnpj-base: le os CSVs locais extraidos da Receita Federal
|
||||
// (prospeccao/cnpj/estabelecimentos-*.csv) em streaming, filtrando por
|
||||
// UF + cidades e casando cada linha com o primeiro nicho compativel.
|
||||
export async function* collect(params, ctx) {
|
||||
const { uf, cities, segments } = params;
|
||||
const allCities = !cities || !cities.length || cities.includes('*');
|
||||
const citySet = allCities ? null : new Set(cities.map((c) => cleanText(c)));
|
||||
const ufClean = cleanText(uf);
|
||||
|
||||
// Pre-processa termos por segmento uma unica vez (o loop interno roda
|
||||
// milhoes de vezes na base de uma UF inteira).
|
||||
const preparedSegments = segments.map((segment) => ({
|
||||
...segment,
|
||||
_cnaeCodes: (segment.cnaeCodes || []).map(onlyDigits).filter(Boolean),
|
||||
_queryTerms: (segment.queries || []).map((term) => cleanText(term)).filter(Boolean)
|
||||
}));
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
files = (await fs.readdir(ctx.cnpjDir))
|
||||
.filter((file) => /\.csv$/i.test(file))
|
||||
.map((file) => path.join(ctx.cnpjDir, file));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
ctx.log(`base CNPJ nao encontrada em ${ctx.cnpjDir} — fonte cnpj-base pulada`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let batch = [];
|
||||
let scanned = 0;
|
||||
|
||||
for (const file of files) {
|
||||
ctx.log(`cnpj-base: lendo ${path.basename(file)}`);
|
||||
|
||||
for await (const row of streamCsvRows(file, { normalizeHeader })) {
|
||||
if (ctx.isCancelled()) return;
|
||||
scanned += 1;
|
||||
|
||||
if (scanned % 100000 === 0) {
|
||||
ctx.emit({ phase: 'cnpj-base', message: `${scanned} linhas lidas`, detail: path.basename(file) });
|
||||
}
|
||||
|
||||
const rowState = cleanText(getAny(row, ['uf', 'estado']));
|
||||
if (rowState && ufClean && rowState !== ufClean) continue;
|
||||
|
||||
const rowCity = cleanText(getAny(row, ['municipio', 'cidade']));
|
||||
if (citySet && !citySet.has(rowCity)) continue;
|
||||
|
||||
const textCache = { value: null };
|
||||
for (const segment of preparedSegments) {
|
||||
if (!cnpjMatchesSegment(row, segment, textCache)) continue;
|
||||
|
||||
let place = cnpjRowToPlace(row, segment, rowCity, uf);
|
||||
if (place) {
|
||||
if (params.enrichBrasilApi && place.cnpj) {
|
||||
try {
|
||||
place = mergeBrasilApi(place, await brasilApiCnpj(ctx.endpoints.brasilApiUrl, place.cnpj));
|
||||
} catch {
|
||||
// enriquecimento e opcional: falha de rede nao derruba a captura
|
||||
}
|
||||
}
|
||||
const origin = params.enrichBrasilApi ? 'cnpj-base+brasilapi' : 'cnpj-base';
|
||||
batch.push(buildLeadFromPlace(place, segment, place.city, uf, origin));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (batch.length >= 2000) {
|
||||
yield { origin: 'cnpj-base', label: path.basename(file), leads: batch };
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length) {
|
||||
yield { origin: 'cnpj-base', label: 'cnpj-base', leads: batch };
|
||||
}
|
||||
ctx.log(`cnpj-base: ${scanned} linhas da base local processadas`);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { normalizePhone } from '../lead-engine.mjs';
|
||||
|
||||
export const UA_HEADERS = {
|
||||
accept: 'application/json,text/plain,*/*',
|
||||
'user-agent': 'Grupo-GTI-Comercial/1.0 contato@grupogti.local'
|
||||
};
|
||||
|
||||
export const UF_NAMES = {
|
||||
AC: 'Acre', AL: 'Alagoas', AP: 'Amapa', AM: 'Amazonas', BA: 'Bahia',
|
||||
CE: 'Ceara', DF: 'Distrito Federal', ES: 'Espirito Santo', GO: 'Goias',
|
||||
MA: 'Maranhao', MT: 'Mato Grosso', MS: 'Mato Grosso do Sul', MG: 'Minas Gerais',
|
||||
PA: 'Para', PB: 'Paraiba', PR: 'Parana', PE: 'Pernambuco', PI: 'Piaui',
|
||||
RJ: 'Rio de Janeiro', RN: 'Rio Grande do Norte', RS: 'Rio Grande do Sul',
|
||||
RO: 'Rondonia', RR: 'Roraima', SC: 'Santa Catarina', SP: 'Sao Paulo',
|
||||
SE: 'Sergipe', TO: 'Tocantins'
|
||||
};
|
||||
|
||||
export const UFS = Object.keys(UF_NAMES);
|
||||
|
||||
export function splitCityState(cityText = '') {
|
||||
const parts = String(cityText).trim().split(/\s+/);
|
||||
const last = parts.at(-1);
|
||||
if (last && last.length === 2 && UF_NAMES[last.toUpperCase()]) {
|
||||
return { city: parts.slice(0, -1).join(' '), state: last.toUpperCase() };
|
||||
}
|
||||
return { city: String(cityText).trim(), state: '' };
|
||||
}
|
||||
|
||||
// Monta o payload padrao de lead (mesmo formato consumido por processLead)
|
||||
// a partir de um estabelecimento/lugar encontrado em qualquer fonte.
|
||||
export function buildLeadFromPlace(place, segment, city, state, origin) {
|
||||
const phone = normalizePhone(place.phone || place.formatted_phone_number || place.international_phone_number || '');
|
||||
const segmentName = place.segment || segment.name;
|
||||
|
||||
return {
|
||||
source: origin,
|
||||
contact: {
|
||||
name: 'Responsavel comercial',
|
||||
phone
|
||||
},
|
||||
company: {
|
||||
name: place.name,
|
||||
city: place.city || city,
|
||||
state: place.state || state,
|
||||
segment: segmentName
|
||||
},
|
||||
interest: segment.interest,
|
||||
pains: segment.pains,
|
||||
urgency: 'media',
|
||||
message: `Lead prospectado automaticamente em ${origin}. Segmento: ${segmentName}. Website: ${place.website || 'nao informado'}.`,
|
||||
consent: false,
|
||||
send_external_whatsapp: false,
|
||||
prospecting: {
|
||||
source: origin,
|
||||
query: place.query,
|
||||
place_id: place.place_id || '',
|
||||
osm_id: place.osm_id || '',
|
||||
osm_type: place.osm_type || '',
|
||||
website: place.website || '',
|
||||
cnpj: place.cnpj || '',
|
||||
cnae: place.cnae || '',
|
||||
legal_name: place.legal_name || '',
|
||||
address: place.formatted_address || place.address || '',
|
||||
rating: place.rating || '',
|
||||
user_ratings_total: place.user_ratings_total || '',
|
||||
affinity: segment.affinity || 'media',
|
||||
products: segment.products || []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function applyLimits(items, limit) {
|
||||
return limit > 0 ? items.slice(0, limit) : items;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { sleep } from '../util/collections.mjs';
|
||||
import { buildLeadFromPlace } from './common.mjs';
|
||||
|
||||
async function searchPage(apiKey, params) {
|
||||
const url = new URL('https://maps.googleapis.com/maps/api/place/textsearch/json');
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
}
|
||||
url.searchParams.set('key', apiKey);
|
||||
url.searchParams.set('language', 'pt-BR');
|
||||
url.searchParams.set('region', 'br');
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Google Places HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status && !['OK', 'ZERO_RESULTS'].includes(data.status)) {
|
||||
throw new Error(`Google Places ${data.status}: ${data.error_message || 'sem detalhes'}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function search(query, apiKey, { maxPerQuery, pages }) {
|
||||
const places = [];
|
||||
let page = 0;
|
||||
let pageToken = '';
|
||||
|
||||
while (page < pages && places.length < maxPerQuery) {
|
||||
if (pageToken) await sleep(2500);
|
||||
const data = await searchPage(apiKey, pageToken ? { pagetoken: pageToken } : { query });
|
||||
// Resultados por pagina sao no maximo 20 — spread aqui e seguro.
|
||||
places.push(...(data.results || []));
|
||||
page += 1;
|
||||
pageToken = data.next_page_token || '';
|
||||
if (!pageToken) break;
|
||||
}
|
||||
|
||||
return places.slice(0, maxPerQuery);
|
||||
}
|
||||
|
||||
async function placeDetails(placeId, apiKey) {
|
||||
const url = new URL('https://maps.googleapis.com/maps/api/place/details/json');
|
||||
url.searchParams.set('place_id', placeId);
|
||||
url.searchParams.set('key', apiKey);
|
||||
url.searchParams.set('language', 'pt-BR');
|
||||
url.searchParams.set('fields', 'place_id,name,formatted_address,formatted_phone_number,international_phone_number,website,rating,user_ratings_total');
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Google Details HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.result || {};
|
||||
}
|
||||
|
||||
// Fonte google-places: exige GOOGLE_MAPS_API_KEY (paga). Traz telefone e
|
||||
// website com boa cobertura.
|
||||
export async function* collect(params, ctx) {
|
||||
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
if (!apiKey || apiKey === 'preencher_google_maps_api_key') {
|
||||
throw new Error('GOOGLE_MAPS_API_KEY ausente: configure a chave para usar a fonte Google Places');
|
||||
}
|
||||
|
||||
const { uf, cities, segments, limits } = params;
|
||||
const pages = Math.min(3, Math.max(1, limits.googlePages || 3));
|
||||
let executedQueries = 0;
|
||||
|
||||
for (const city of cities || []) {
|
||||
const label = `${city} ${uf}`;
|
||||
const cityLeads = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
for (const queryTerm of segment.queries || []) {
|
||||
if (ctx.isCancelled()) return;
|
||||
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) {
|
||||
if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads };
|
||||
return;
|
||||
}
|
||||
|
||||
const query = `${queryTerm} em ${label}`;
|
||||
ctx.emit({ phase: 'google-places', message: query });
|
||||
executedQueries += 1;
|
||||
|
||||
let places = [];
|
||||
try {
|
||||
places = await search(query, apiKey, { maxPerQuery: limits.maxPerQuery || 10, pages });
|
||||
} catch (error) {
|
||||
ctx.log(`google-places falhou em ${query}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
try {
|
||||
const details = place.place_id ? await placeDetails(place.place_id, apiKey) : {};
|
||||
cityLeads.push(buildLeadFromPlace({ ...place, ...details, query }, segment, city, uf, 'google-places'));
|
||||
} catch (error) {
|
||||
ctx.log(`google-places falhou ao detalhar "${place.name || place.place_id}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cityLeads.length) yield { origin: 'google-places', label, leads: cityLeads };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as cnpjBase from './cnpj-base.mjs';
|
||||
import * as overpass from './overpass.mjs';
|
||||
import * as nominatim from './nominatim.mjs';
|
||||
import * as wikidata from './wikidata.mjs';
|
||||
import * as googlePlaces from './google-places.mjs';
|
||||
import * as mock from './mock.mjs';
|
||||
|
||||
// Registro central de fontes de captura. `describe()` alimenta a tela de
|
||||
// captura (o front nunca conhece detalhes das fontes, so este catalogo).
|
||||
export const SOURCES = [
|
||||
{
|
||||
id: 'cnpj-base',
|
||||
label: 'Base CNPJ (Receita Federal)',
|
||||
descricao: 'Base oficial local: todas as empresas ativas da UF com telefone. Fonte principal.',
|
||||
gratuita: true,
|
||||
recomendada: true,
|
||||
collect: cnpjBase.collect
|
||||
},
|
||||
{
|
||||
id: 'brasilapi',
|
||||
label: 'Base CNPJ + BrasilAPI',
|
||||
descricao: 'Mesma base CNPJ, enriquecida online pela BrasilAPI (mais lenta).',
|
||||
gratuita: true,
|
||||
recomendada: false,
|
||||
collect: (params, ctx) => cnpjBase.collect({ ...params, enrichBrasilApi: true }, ctx)
|
||||
},
|
||||
{
|
||||
id: 'overpass',
|
||||
label: 'OpenStreetMap (Overpass)',
|
||||
descricao: 'Estabelecimentos mapeados no OpenStreetMap, com telefone/site quando cadastrados.',
|
||||
gratuita: true,
|
||||
recomendada: true,
|
||||
collect: overpass.collect
|
||||
},
|
||||
{
|
||||
id: 'nominatim',
|
||||
label: 'OpenStreetMap (Nominatim)',
|
||||
descricao: 'Busca textual no OpenStreetMap por termo de nicho.',
|
||||
gratuita: true,
|
||||
recomendada: false,
|
||||
collect: nominatim.collect
|
||||
},
|
||||
{
|
||||
id: 'wikidata',
|
||||
label: 'Wikidata',
|
||||
descricao: 'Empresas registradas no Wikidata (volume baixo, util para sites).',
|
||||
gratuita: true,
|
||||
recomendada: false,
|
||||
collect: wikidata.collect
|
||||
},
|
||||
{
|
||||
id: 'google-places',
|
||||
label: 'Google Places',
|
||||
descricao: 'Google Maps (paga, exige GOOGLE_MAPS_API_KEY no .env).',
|
||||
gratuita: false,
|
||||
recomendada: false,
|
||||
needsKey: 'GOOGLE_MAPS_API_KEY',
|
||||
collect: googlePlaces.collect
|
||||
},
|
||||
{
|
||||
id: 'mock',
|
||||
label: 'Teste (mock)',
|
||||
descricao: '3 leads ficticios para testar o fluxo sem tocar em APIs.',
|
||||
gratuita: true,
|
||||
recomendada: false,
|
||||
oculta: true,
|
||||
collect: mock.collect
|
||||
}
|
||||
];
|
||||
|
||||
export function getSource(id) {
|
||||
return SOURCES.find((source) => source.id === id) || null;
|
||||
}
|
||||
|
||||
export function describeSources() {
|
||||
return SOURCES.filter((s) => !s.oculta).map((source) => {
|
||||
const keyMissing = source.needsKey
|
||||
&& (!process.env[source.needsKey] || process.env[source.needsKey].startsWith('preencher'));
|
||||
return {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
descricao: source.descricao,
|
||||
gratuita: source.gratuita,
|
||||
recomendada: source.recomendada,
|
||||
disponivel: !keyMissing,
|
||||
motivo: keyMissing ? `configure ${source.needsKey} no arquivo .env` : ''
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { buildLeadFromPlace } from './common.mjs';
|
||||
|
||||
// Fonte mock: 3 leads ficticios para testar o fluxo completo (captura ->
|
||||
// banco -> tratamento -> exportacao) sem tocar em nenhuma API externa.
|
||||
const MOCK_PLACES = [
|
||||
{
|
||||
name: 'Mercado Central SAJ',
|
||||
phone: '(75) 99999-0001',
|
||||
city: 'Santo Antonio de Jesus',
|
||||
state: 'BA',
|
||||
segment: 'mercados_de_bairro',
|
||||
website: 'https://mercadocentral.example'
|
||||
},
|
||||
{
|
||||
name: 'Clinica Vida Plena',
|
||||
phone: '(75) 99999-0002',
|
||||
city: 'Santo Antonio de Jesus',
|
||||
state: 'BA',
|
||||
segment: 'clinicas_medicas',
|
||||
website: ''
|
||||
},
|
||||
{
|
||||
name: 'Restaurante Sabor da Terra',
|
||||
phone: '(75) 99999-0003',
|
||||
city: 'Santo Antonio de Jesus',
|
||||
state: 'BA',
|
||||
segment: 'restaurantes',
|
||||
website: ''
|
||||
}
|
||||
];
|
||||
|
||||
export async function* collect(params, ctx) {
|
||||
const segments = params.segments || [];
|
||||
const leads = MOCK_PLACES.map((place) => {
|
||||
const segment = segments.find((item) => item.name === place.segment)
|
||||
|| segments[0]
|
||||
|| { name: place.segment, interest: 'ERP / sistema de gestao', pains: [] };
|
||||
return buildLeadFromPlace(
|
||||
{ ...place, query: `mock ${place.segment}` },
|
||||
segment,
|
||||
place.city,
|
||||
place.state,
|
||||
'mock'
|
||||
);
|
||||
});
|
||||
|
||||
ctx.emit({ phase: 'mock', message: `${leads.length} leads de teste gerados` });
|
||||
yield { origin: 'mock', label: 'mock', leads };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { cleanText } from '../lead-engine.mjs';
|
||||
import { sleep } from '../util/collections.mjs';
|
||||
import { UA_HEADERS, UF_NAMES, buildLeadFromPlace } from './common.mjs';
|
||||
|
||||
async function nominatimSearch(baseUrl, query, limit) {
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set('format', 'jsonv2');
|
||||
url.searchParams.set('q', query);
|
||||
url.searchParams.set('countrycodes', 'br');
|
||||
url.searchParams.set('addressdetails', '1');
|
||||
url.searchParams.set('limit', String(Math.max(1, limit)));
|
||||
|
||||
const response = await fetch(url, { headers: UA_HEADERS });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Nominatim HTTP ${response.status}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function isResultInTarget(result, city, uf) {
|
||||
const address = result.address || {};
|
||||
const resultCity = address.city || address.town || address.village || address.municipality || '';
|
||||
const resultState = cleanText(address.state || '');
|
||||
const expectedState = cleanText(UF_NAMES[uf] || uf);
|
||||
const currentCity = cleanText(resultCity);
|
||||
const expectedCity = cleanText(city);
|
||||
|
||||
return resultState === expectedState
|
||||
&& (currentCity === expectedCity || currentCity.includes(expectedCity) || expectedCity.includes(currentCity));
|
||||
}
|
||||
|
||||
function resultToPlace(result, segment, city, uf, query) {
|
||||
const name = result.name || result.display_name?.split(',')[0];
|
||||
if (!name) return null;
|
||||
|
||||
const address = result.address || {};
|
||||
|
||||
return {
|
||||
name,
|
||||
phone: '',
|
||||
city: address.city || address.town || address.village || city,
|
||||
state: uf,
|
||||
segment: segment.name,
|
||||
website: '',
|
||||
address: result.display_name || '',
|
||||
formatted_address: result.display_name || '',
|
||||
query,
|
||||
osm_id: String(result.osm_id || ''),
|
||||
osm_type: result.osm_type || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Fonte nominatim (OpenStreetMap): busca textual termo a termo por cidade,
|
||||
// validando que o resultado pertence mesmo a cidade/UF pedida.
|
||||
export async function* collect(params, ctx) {
|
||||
const { uf, cities, segments, limits } = params;
|
||||
if (!cities || !cities.length) {
|
||||
ctx.log('nominatim: nenhuma cidade selecionada — fonte pulada');
|
||||
return;
|
||||
}
|
||||
|
||||
let executedQueries = 0;
|
||||
|
||||
for (const city of cities) {
|
||||
const label = `${city} ${uf}`;
|
||||
const cityLeads = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
for (const queryTerm of segment.queries || []) {
|
||||
if (ctx.isCancelled()) return;
|
||||
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) {
|
||||
if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads };
|
||||
return;
|
||||
}
|
||||
|
||||
const query = `${queryTerm} ${city} ${uf}`;
|
||||
executedQueries += 1;
|
||||
ctx.emit({ phase: 'nominatim', message: query });
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
results = await nominatimSearch(ctx.endpoints.nominatimUrl, query, limits.maxPerQuery || 10);
|
||||
} catch (error) {
|
||||
ctx.log(`nominatim falhou em ${query}: ${error.message}`);
|
||||
await sleep(Math.max(ctx.delays.overpassDelayMs, 5000));
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
if (!isResultInTarget(result, city, uf)) continue;
|
||||
const place = resultToPlace(result, segment, city, uf, query);
|
||||
if (!place) continue;
|
||||
cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'nominatim'));
|
||||
}
|
||||
|
||||
await sleep(Math.max(ctx.delays.overpassDelayMs, 1200));
|
||||
}
|
||||
}
|
||||
|
||||
if (cityLeads.length) yield { origin: 'nominatim', label, leads: cityLeads };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { cleanText } from '../lead-engine.mjs';
|
||||
import { sleep } from '../util/collections.mjs';
|
||||
import { UA_HEADERS, buildLeadFromPlace } from './common.mjs';
|
||||
|
||||
const OVERPASS_COOLDOWN_MS = 60000;
|
||||
|
||||
function overpassSelector(tag, bbox) {
|
||||
const [south, west, north, east] = bbox;
|
||||
const key = String(tag.key).replace(/"/g, '\\"');
|
||||
const value = String(tag.value).replace(/"/g, '\\"');
|
||||
const selector = `["${key}"="${value}"](${south},${west},${north},${east});`;
|
||||
return [`node${selector}`, `way${selector}`, `relation${selector}`].join('\n');
|
||||
}
|
||||
|
||||
export function escapeOverpassRegex(value) {
|
||||
return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function overpassTextSelector(key, regex, bbox) {
|
||||
const [south, west, north, east] = bbox;
|
||||
const selector = `["${key}"~"${regex}",i](${south},${west},${north},${east});`;
|
||||
return [`node${selector}`, `way${selector}`].join('\n');
|
||||
}
|
||||
|
||||
function segmentTextRegex(segment) {
|
||||
return (segment.queries || [])
|
||||
.filter((term) => String(term).trim().length >= 6)
|
||||
.slice(0, 8)
|
||||
.map(escapeOverpassRegex)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function buildCombinedQuery(segments, bbox, includeText) {
|
||||
const seen = new Set();
|
||||
const selectors = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
for (const tag of segment.overpassTags || []) {
|
||||
const key = `tag:${tag.key}=${tag.value}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
selectors.push(overpassSelector(tag, bbox));
|
||||
}
|
||||
}
|
||||
|
||||
if (includeText) {
|
||||
for (const segment of segments) {
|
||||
const textRegex = segmentTextRegex(segment);
|
||||
const key = `text:${segment.name}`;
|
||||
if (!textRegex || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
selectors.push(overpassTextSelector('name', textRegex, bbox));
|
||||
selectors.push(overpassTextSelector('brand', textRegex, bbox));
|
||||
selectors.push(overpassTextSelector('operator', textRegex, bbox));
|
||||
}
|
||||
}
|
||||
|
||||
return ['[out:json][timeout:40];', '(', selectors.join('\n'), ');', 'out body center;'].join('\n');
|
||||
}
|
||||
|
||||
function matchSegmentForElement(tags, segments, includeText) {
|
||||
for (const segment of segments) {
|
||||
for (const tag of segment.overpassTags || []) {
|
||||
if (tags[tag.key] === tag.value) return segment;
|
||||
}
|
||||
}
|
||||
|
||||
if (includeText) {
|
||||
const haystack = cleanText([tags.name, tags.brand, tags.operator].filter(Boolean).join(' '));
|
||||
if (!haystack) return null;
|
||||
|
||||
for (const segment of segments) {
|
||||
const terms = (segment.queries || []).filter((term) => String(term).trim().length >= 6);
|
||||
if (terms.some((term) => haystack.includes(cleanText(term)))) return segment;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function valueFromTags(tags = {}, keys = []) {
|
||||
for (const key of keys) {
|
||||
if (tags[key]) return tags[key];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function elementToPlace(element, segment, city, uf, query) {
|
||||
const tags = element.tags || {};
|
||||
const name = tags.name || tags.brand || tags.operator;
|
||||
if (!name) return null;
|
||||
|
||||
const street = valueFromTags(tags, ['addr:street']);
|
||||
const housenumber = valueFromTags(tags, ['addr:housenumber']);
|
||||
const address = [street, housenumber].filter(Boolean).join(', ');
|
||||
|
||||
return {
|
||||
name,
|
||||
phone: valueFromTags(tags, ['contact:phone', 'phone', 'mobile', 'contact:mobile']),
|
||||
city: valueFromTags(tags, ['addr:city']) || city,
|
||||
state: uf,
|
||||
segment: segment.name,
|
||||
website: valueFromTags(tags, ['contact:website', 'website', 'url']),
|
||||
address,
|
||||
formatted_address: address,
|
||||
query,
|
||||
osm_id: String(element.id || ''),
|
||||
osm_type: element.type || ''
|
||||
};
|
||||
}
|
||||
|
||||
function createOverpassClient({ urls, timeoutMs }) {
|
||||
const failures = new Map();
|
||||
|
||||
return async function request(query) {
|
||||
let lastError;
|
||||
|
||||
for (const apiUrl of urls) {
|
||||
const state = failures.get(apiUrl);
|
||||
if (state?.until && Date.now() < state.until) continue;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const recordFailure = () => {
|
||||
const current = failures.get(apiUrl) || { count: 0, until: 0 };
|
||||
const count = current.count + 1;
|
||||
failures.set(apiUrl, { count, until: count >= 3 ? Date.now() + OVERPASS_COOLDOWN_MS : 0 });
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...UA_HEADERS,
|
||||
'content-type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
},
|
||||
body: new URLSearchParams({ data: query }),
|
||||
signal: controller.signal
|
||||
}).catch((error) => {
|
||||
lastError = error.name === 'AbortError'
|
||||
? new Error(`Overpass timeout apos ${timeoutMs}ms em ${apiUrl}`)
|
||||
: error;
|
||||
recordFailure();
|
||||
return null;
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
if (!response) continue;
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
lastError = new Error(`Overpass HTTP ${response.status} em ${apiUrl}: ${text.slice(0, 300)}`);
|
||||
recordFailure();
|
||||
if (response.status === 429) await sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
failures.set(apiUrl, { count: 0, until: 0 });
|
||||
return data.elements || [];
|
||||
}
|
||||
|
||||
throw lastError || new Error('Overpass indisponivel: todos os endpoints em cooldown');
|
||||
};
|
||||
}
|
||||
|
||||
// Fonte overpass (OpenStreetMap): uma consulta combinada por cidade cobrindo
|
||||
// todos os nichos selecionados, com failover entre endpoints e cooldown.
|
||||
export async function* collect(params, ctx) {
|
||||
const { uf, cities, segments, limits } = params;
|
||||
if (!cities || !cities.length) {
|
||||
ctx.log('overpass: nenhuma cidade selecionada — fonte pulada');
|
||||
return;
|
||||
}
|
||||
|
||||
const request = createOverpassClient({
|
||||
urls: ctx.endpoints.overpassUrls,
|
||||
timeoutMs: ctx.delays.overpassTimeoutMs
|
||||
});
|
||||
|
||||
for (const city of cities) {
|
||||
if (ctx.isCancelled()) return;
|
||||
const label = `${city} ${uf}`;
|
||||
|
||||
let bbox = null;
|
||||
try {
|
||||
bbox = await ctx.geocode(uf, city);
|
||||
} catch (error) {
|
||||
ctx.log(`overpass: geocode falhou em ${label}: ${error.message}`);
|
||||
}
|
||||
if (!bbox) {
|
||||
ctx.log(`overpass: sem bounding box para ${label} — cidade pulada`);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.emit({ phase: 'overpass', message: `consultando ${label}` });
|
||||
|
||||
let elements = [];
|
||||
try {
|
||||
elements = await request(buildCombinedQuery(segments, bbox, ctx.overpassTextSearch));
|
||||
} catch (error) {
|
||||
ctx.log(`overpass falhou em ${label}: ${error.message}`);
|
||||
await sleep(Math.max(ctx.delays.overpassDelayMs, 15000));
|
||||
continue;
|
||||
}
|
||||
|
||||
const perSegmentCount = new Map();
|
||||
const cityLeads = [];
|
||||
|
||||
for (const element of elements) {
|
||||
const segment = matchSegmentForElement(element.tags || {}, segments, ctx.overpassTextSearch);
|
||||
if (!segment) continue;
|
||||
|
||||
if (limits.maxPerQuery > 0) {
|
||||
const count = perSegmentCount.get(segment.name) || 0;
|
||||
if (count >= limits.maxPerQuery) continue;
|
||||
perSegmentCount.set(segment.name, count + 1);
|
||||
}
|
||||
|
||||
const place = elementToPlace(element, segment, city, uf, `${segment.name} em ${label}`);
|
||||
if (!place) continue;
|
||||
cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'overpass'));
|
||||
}
|
||||
|
||||
ctx.log(`overpass: ${elements.length} elementos brutos, ${cityLeads.length} leads em ${label}`);
|
||||
if (cityLeads.length) yield { origin: 'overpass', label, leads: cityLeads };
|
||||
await sleep(ctx.delays.overpassDelayMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanText } from '../lead-engine.mjs';
|
||||
import { UA_HEADERS, buildLeadFromPlace } from './common.mjs';
|
||||
import { escapeOverpassRegex } from './overpass.mjs';
|
||||
|
||||
async function findWikidataCityId(city) {
|
||||
// Busca por rotulo exato em SPARQL falha para nomes sem acento (base usa
|
||||
// "Santo Antonio", Wikidata registra "Santo Antônio"). wbsearchentities
|
||||
// e tolerante a acentos.
|
||||
try {
|
||||
const url = new URL('https://www.wikidata.org/w/api.php');
|
||||
url.searchParams.set('action', 'wbsearchentities');
|
||||
url.searchParams.set('search', city);
|
||||
url.searchParams.set('language', 'pt');
|
||||
url.searchParams.set('type', 'item');
|
||||
url.searchParams.set('format', 'json');
|
||||
|
||||
const response = await fetch(url, { headers: UA_HEADERS });
|
||||
if (!response.ok) return '';
|
||||
const data = await response.json();
|
||||
|
||||
const match = (data.search || []).find((item) => {
|
||||
const description = cleanText(item.description || '');
|
||||
return description.includes('municipality') || description.includes('municipio') || description.includes('munic');
|
||||
});
|
||||
|
||||
return match?.id || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Fonte wikidata: empresas registradas no Wikidata por municipio (SPARQL).
|
||||
// Volume baixo, mas gratuito e util para achar empresas com website.
|
||||
export async function* collect(params, ctx) {
|
||||
const { uf, cities, segments, limits } = params;
|
||||
let executedQueries = 0;
|
||||
|
||||
for (const city of cities || []) {
|
||||
if (ctx.isCancelled()) return;
|
||||
|
||||
const cityId = await findWikidataCityId(city);
|
||||
if (!cityId) {
|
||||
ctx.log(`wikidata: municipio nao encontrado para ${city} — pulando`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = `${city} ${uf}`;
|
||||
const cityLeads = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (ctx.isCancelled()) return;
|
||||
if (limits.queryLimit > 0 && executedQueries >= limits.queryLimit) break;
|
||||
|
||||
const regex = (segment.queries || [])
|
||||
.filter((term) => term.length >= 4)
|
||||
.slice(0, 10)
|
||||
.map(escapeOverpassRegex)
|
||||
.join('|');
|
||||
if (!regex) continue;
|
||||
|
||||
const sparql = `
|
||||
SELECT ?item ?itemLabel ?website WHERE {
|
||||
?item wdt:P31/wdt:P279* wd:Q4830453.
|
||||
{ ?item wdt:P131 wd:${cityId}. } UNION { ?item wdt:P159/wdt:P131* wd:${cityId}. }
|
||||
OPTIONAL { ?item wdt:P856 ?website. }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "pt,en". }
|
||||
FILTER(REGEX(?itemLabel, "${regex}", "i"))
|
||||
}
|
||||
LIMIT ${limits.maxPerQuery || 10}
|
||||
`;
|
||||
|
||||
ctx.emit({ phase: 'wikidata', message: `${segment.name} em ${label}` });
|
||||
executedQueries += 1;
|
||||
|
||||
try {
|
||||
const url = new URL(ctx.endpoints.wikidataUrl);
|
||||
url.searchParams.set('query', sparql);
|
||||
url.searchParams.set('format', 'json');
|
||||
const response = await fetch(url, {
|
||||
headers: { ...UA_HEADERS, accept: 'application/sparql-results+json' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`Wikidata HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
for (const result of data.results?.bindings || []) {
|
||||
const place = {
|
||||
name: result.itemLabel?.value || '',
|
||||
city,
|
||||
state: uf,
|
||||
segment: segment.name,
|
||||
website: result.website?.value || '',
|
||||
query: `wikidata ${segment.name} em ${label}`,
|
||||
place_id: result.item?.value || ''
|
||||
};
|
||||
if (place.name) cityLeads.push(buildLeadFromPlace(place, segment, city, uf, 'wikidata'));
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.log(`wikidata falhou em ${segment.name} ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (cityLeads.length) yield { origin: 'wikidata', label, leads: cityLeads };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { buildLeadFilter } from './queries.mjs';
|
||||
import { getSetting } from '../db.mjs';
|
||||
|
||||
function pct(parte, total) {
|
||||
return total > 0 ? Math.round((parte / total) * 100) : 0;
|
||||
}
|
||||
|
||||
export function dashboardSummary(db, filtro = {}) {
|
||||
const { whereSql, params } = buildLeadFilter(filtro);
|
||||
|
||||
const row = db.prepare(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN prioridade = 1 THEN 1 ELSE 0 END) AS p1,
|
||||
SUM(CASE WHEN prioridade = 2 THEN 1 ELSE 0 END) AS p2,
|
||||
SUM(CASE WHEN prioridade = 3 THEN 1 ELSE 0 END) AS p3,
|
||||
SUM(CASE WHEN prioridade = 4 THEN 1 ELSE 0 END) AS p4,
|
||||
SUM(telefone_valido) AS com_whatsapp,
|
||||
SUM(CASE WHEN exported_at IS NOT NULL THEN 1 ELSE 0 END) AS exportados,
|
||||
SUM(CASE WHEN exported_at IS NOT NULL AND date(exported_at) = date('now') THEN 1 ELSE 0 END) AS hoje_exportados
|
||||
FROM leads ${whereSql}`
|
||||
).get(...params);
|
||||
|
||||
const total = row.total || 0;
|
||||
const exportados = row.exportados || 0;
|
||||
const hojeExportados = row.hoje_exportados || 0;
|
||||
const metaDiaria = Number(getSetting(db, 'meta_diaria', '100')) || 100;
|
||||
const disponiveis = total - exportados;
|
||||
|
||||
return {
|
||||
total,
|
||||
p1: row.p1 || 0,
|
||||
p2: row.p2 || 0,
|
||||
p3: row.p3 || 0,
|
||||
p4: row.p4 || 0,
|
||||
p1_pct: pct(row.p1 || 0, total),
|
||||
p2_pct: pct(row.p2 || 0, total),
|
||||
p3_pct: pct(row.p3 || 0, total),
|
||||
p4_pct: pct(row.p4 || 0, total),
|
||||
potencial_pct: pct(row.p1 || 0, total),
|
||||
com_whatsapp: row.com_whatsapp || 0,
|
||||
sem_whatsapp: total - (row.com_whatsapp || 0),
|
||||
exportados,
|
||||
disponiveis,
|
||||
cobertura_pct: pct(exportados, total),
|
||||
hoje_exportados: hojeExportados,
|
||||
meta_diaria: metaDiaria,
|
||||
meta_pct: pct(hojeExportados, metaDiaria),
|
||||
dias_estimados: metaDiaria > 0 ? Math.ceil(disponiveis / metaDiaria) : 0
|
||||
};
|
||||
}
|
||||
|
||||
const RANKING_COLUMNS = {
|
||||
cidade: 'cidade',
|
||||
segmento: 'segmento_label',
|
||||
uf: 'uf',
|
||||
interesse: 'interesse',
|
||||
origem: 'origem',
|
||||
produto: 'produto_principal'
|
||||
};
|
||||
|
||||
export function dashboardRanking(db, by, filtro = {}, limit = 15) {
|
||||
const column = RANKING_COLUMNS[by] || 'cidade';
|
||||
const { whereSql, params } = buildLeadFilter(filtro);
|
||||
const capped = Math.min(50, Math.max(1, Number(limit) || 15));
|
||||
|
||||
return db.prepare(
|
||||
`SELECT ${column} AS label,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN prioridade = 1 THEN 1 ELSE 0 END) AS p1
|
||||
FROM leads ${whereSql}
|
||||
${whereSql ? 'AND' : 'WHERE'} ${column} != ''
|
||||
GROUP BY ${column}
|
||||
ORDER BY total DESC
|
||||
LIMIT ?`
|
||||
).all(...params, capped);
|
||||
}
|
||||
|
||||
// Historico de exportacoes por dia — unico evento comercial com timestamp
|
||||
// que o app registra (nao ha rastreio de contato/interesse/proposta).
|
||||
export function dashboardDailyEvolution(db, filtro = {}, dias = 14) {
|
||||
const { whereSql, params } = buildLeadFilter(filtro);
|
||||
const capped = Math.min(90, Math.max(1, Number(dias) || 14));
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT date(exported_at) AS dia, COUNT(*) AS total
|
||||
FROM leads ${whereSql}
|
||||
${whereSql ? 'AND' : 'WHERE'} exported_at IS NOT NULL
|
||||
GROUP BY dia
|
||||
ORDER BY dia DESC
|
||||
LIMIT ?`
|
||||
).all(...params, capped);
|
||||
|
||||
return rows.reverse();
|
||||
}
|
||||
|
||||
// Combinacao cidade+produto com mais empresas dentro do filtro atual.
|
||||
export function dashboardMaiorOportunidade(db, filtro = {}) {
|
||||
const { whereSql, params } = buildLeadFilter(filtro);
|
||||
|
||||
const row = db.prepare(
|
||||
`SELECT cidade, produto_principal AS produto, COUNT(*) AS total
|
||||
FROM leads ${whereSql}
|
||||
${whereSql ? 'AND' : 'WHERE'} cidade != '' AND produto_principal != ''
|
||||
GROUP BY cidade, produto_principal
|
||||
ORDER BY total DESC
|
||||
LIMIT 1`
|
||||
).get(...params);
|
||||
|
||||
return row || null;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Filtros de leads compartilhados entre a listagem, o dashboard e as
|
||||
// exportacoes: o MESMO objeto de filtro que a tela usa para navegar e o que
|
||||
// define quais leads entram num relatorio/exportacao.
|
||||
|
||||
const SORTS = {
|
||||
'prioridade:asc': 'prioridade ASC, score DESC, cidade ASC, empresa ASC',
|
||||
'score:desc': 'score DESC, empresa ASC',
|
||||
'score:asc': 'score ASC, empresa ASC',
|
||||
'empresa:asc': 'empresa ASC',
|
||||
'empresa:desc': 'empresa DESC',
|
||||
'cidade:asc': 'cidade ASC, empresa ASC',
|
||||
'captured_at:desc': "COALESCE(captured_at, created_at) DESC",
|
||||
'captured_at:asc': "COALESCE(captured_at, created_at) ASC"
|
||||
};
|
||||
|
||||
export function buildLeadFilter(filtro = {}) {
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
const eq = (column, value) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
where.push(`${column} = ?`);
|
||||
params.push(value);
|
||||
};
|
||||
|
||||
eq('uf', filtro.uf ? String(filtro.uf).toUpperCase() : '');
|
||||
eq('cidade', filtro.cidade);
|
||||
eq('segmento', filtro.segmento);
|
||||
eq('classificacao', filtro.classificacao);
|
||||
eq('interesse', filtro.interesse);
|
||||
eq('origem', filtro.origem);
|
||||
|
||||
if (filtro.telefone_valido === 'sim') where.push('telefone_valido = 1');
|
||||
if (filtro.telefone_valido === 'nao') where.push('telefone_valido = 0');
|
||||
|
||||
if (filtro.exportado === 'sim') where.push('exported_at IS NOT NULL');
|
||||
if (filtro.exportado === 'nao') where.push('exported_at IS NULL');
|
||||
|
||||
if (filtro.website === 'sim') where.push("website != ''");
|
||||
if (filtro.website === 'nao') where.push("website = ''");
|
||||
|
||||
if (filtro.email === 'sim') where.push("email != ''");
|
||||
if (filtro.email === 'nao') where.push("email = ''");
|
||||
|
||||
eq('produto_principal', filtro.produto);
|
||||
|
||||
if (filtro.prioridade !== undefined && filtro.prioridade !== '') {
|
||||
where.push('prioridade = ?');
|
||||
params.push(Number(filtro.prioridade) || 4);
|
||||
}
|
||||
|
||||
if (filtro.score_min !== undefined && filtro.score_min !== '') {
|
||||
where.push('score >= ?');
|
||||
params.push(Number(filtro.score_min) || 0);
|
||||
}
|
||||
|
||||
if (filtro.q) {
|
||||
const like = `%${String(filtro.q).trim()}%`;
|
||||
where.push('(empresa LIKE ? OR legal_name LIKE ? OR whatsapp LIKE ? OR cnpj LIKE ?)');
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
|
||||
return {
|
||||
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
export function listLeads(db, filtro = {}) {
|
||||
const { whereSql, params } = buildLeadFilter(filtro);
|
||||
const pageSize = Math.min(200, Math.max(1, Number(filtro.pageSize) || 50));
|
||||
const page = Math.max(1, Number(filtro.page) || 1);
|
||||
const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc'];
|
||||
|
||||
const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
|
||||
const rows = db.prepare(
|
||||
`SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label,
|
||||
interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao,
|
||||
prioridade, produto_principal, telefone_valido, origem,
|
||||
exported_at, captured_at, segments.affinity AS afinidade
|
||||
FROM leads LEFT JOIN segments ON segments.name = leads.segmento
|
||||
${whereSql}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, (page - 1) * pageSize);
|
||||
|
||||
return { total, page, pageSize, rows };
|
||||
}
|
||||
|
||||
export function getLead(db, id) {
|
||||
const lead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id);
|
||||
if (!lead) return null;
|
||||
|
||||
const exportHistory = db.prepare(
|
||||
`SELECT e.id, e.tipo, e.created_at, e.total_leads
|
||||
FROM export_leads el JOIN exports e ON e.id = el.export_id
|
||||
WHERE el.lead_id = ?
|
||||
ORDER BY e.created_at DESC`
|
||||
).all(id);
|
||||
|
||||
const segmentInfo = lead.segmento
|
||||
? db.prepare('SELECT affinity FROM segments WHERE name = ?').get(lead.segmento)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...lead,
|
||||
pains: JSON.parse(lead.pains_json || '[]'),
|
||||
produtos: JSON.parse(lead.produtos_json || '[]'),
|
||||
afinidade: segmentInfo?.affinity || null,
|
||||
exports: exportHistory
|
||||
};
|
||||
}
|
||||
|
||||
export function countLeads(db, filtro = {}) {
|
||||
const { whereSql, params } = buildLeadFilter(filtro);
|
||||
return db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
|
||||
}
|
||||
|
||||
// Percorre todos os leads do filtro em blocos paginados por id — usado pelas
|
||||
// exportacoes para nunca carregar dezenas de milhares de linhas de uma vez.
|
||||
export function* iterateLeads(db, filtro = {}, chunkSize = 5000) {
|
||||
const { whereSql, params } = buildLeadFilter(filtro);
|
||||
const sql = `SELECT * FROM leads ${whereSql ? `${whereSql} AND` : 'WHERE'} id > ?
|
||||
ORDER BY id LIMIT ?`;
|
||||
const stmt = db.prepare(sql);
|
||||
let lastId = 0;
|
||||
|
||||
while (true) {
|
||||
const rows = stmt.all(...params, lastId, chunkSize);
|
||||
if (!rows.length) return;
|
||||
for (const row of rows) yield row;
|
||||
lastId = rows[rows.length - 1].id;
|
||||
}
|
||||
}
|
||||
|
||||
// Valores distintos para os seletores de filtro da tela.
|
||||
export function leadFacets(db, filtro = {}) {
|
||||
const base = buildLeadFilter({ uf: filtro.uf });
|
||||
|
||||
const facet = (column) => db.prepare(
|
||||
`SELECT ${column} AS value, COUNT(*) AS total FROM leads ${base.whereSql}
|
||||
${base.whereSql ? 'AND' : 'WHERE'} ${column} != ''
|
||||
GROUP BY ${column} ORDER BY total DESC LIMIT 300`
|
||||
).all(...base.params);
|
||||
|
||||
return {
|
||||
ufs: db.prepare("SELECT uf AS value, COUNT(*) AS total FROM leads WHERE uf != '' GROUP BY uf ORDER BY total DESC").all(),
|
||||
cidades: facet('cidade'),
|
||||
segmentos: facet('segmento'),
|
||||
interesses: facet('interesse'),
|
||||
origens: facet('origem'),
|
||||
produtos: facet('produto_principal'),
|
||||
classificacoes: db.prepare(
|
||||
`SELECT classificacao AS value, COUNT(*) AS total FROM leads ${base.whereSql} GROUP BY classificacao`
|
||||
).all(...base.params)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Evita `target.push(...source)`: com arrays de dezenas/centenas de milhares de itens
|
||||
// (bases CNPJ completas, varias cidades) o spread operator em argumentos de funcao
|
||||
// estoura a pilha de chamadas do V8 (RangeError: Maximum call stack size exceeded).
|
||||
export function pushAll(target, source) {
|
||||
for (const item of source) target.push(item);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Quebra o vinculo do V8 entre uma substring (fatia) e o buffer da string original.
|
||||
// Sem isso, qualquer campo guardado a longo prazo mantem a linha CSV/JSON inteira
|
||||
// viva na memoria (SlicedString), o que com centenas de milhares de linhas retidas
|
||||
// simultaneamente estoura o heap.
|
||||
export function copyStr(value) {
|
||||
return Buffer.from(String(value ?? ''), 'utf8').toString('utf8');
|
||||
}
|
||||
|
||||
export function chunk(array, size) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import fs from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
|
||||
export const BOM = '';
|
||||
|
||||
export function splitCsvLine(line, separator = ';') {
|
||||
const values = [];
|
||||
let current = '';
|
||||
let quoted = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
|
||||
if (char === '"' && quoted && next === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
} else if (char === separator && !quoted) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
values.push(current.trim());
|
||||
return values;
|
||||
}
|
||||
|
||||
export function csvEscape(value) {
|
||||
const text = String(value ?? '');
|
||||
if (/[",\n;]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
||||
return text;
|
||||
}
|
||||
|
||||
// Formula de texto do Excel: impede que numeros longos (whatsapp, cnpj) virem
|
||||
// notacao cientifica ao abrir o CSV. Mesmo truque usado nos arquivos TRATADOS.
|
||||
export function excelTextCell(value) {
|
||||
const text = String(value ?? '').replace(/"/g, '""');
|
||||
return `"=""${text}"""`;
|
||||
}
|
||||
|
||||
export function stripBom(text) {
|
||||
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
}
|
||||
|
||||
function detectSeparator(headerLine) {
|
||||
return headerLine.includes(';') ? ';' : ',';
|
||||
}
|
||||
|
||||
// Le um CSV linha a linha sem carregar o arquivo inteiro em memoria
|
||||
// (a base CNPJ de uma UF inteira passa de 100MB). Emite um objeto por linha,
|
||||
// chaveado pelos headers (opcionalmente normalizados). Campos entre aspas
|
||||
// podem conter quebras de linha (ex.: descricoes de exportacao) — a paridade
|
||||
// de aspas detecta o registro incompleto e acumula ate fechar.
|
||||
export async function* streamCsvRows(filePath, { separator, normalizeHeader } = {}) {
|
||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
let headers = null;
|
||||
let sep = separator;
|
||||
let pending = '';
|
||||
let pendingQuotes = 0;
|
||||
|
||||
try {
|
||||
for await (const rawLine of rl) {
|
||||
const piece = headers === null && !pending ? stripBom(rawLine) : rawLine;
|
||||
pending = pending ? `${pending}\n${piece}` : piece;
|
||||
pendingQuotes += (piece.match(/"/g) || []).length;
|
||||
|
||||
if (pendingQuotes % 2 === 1) continue;
|
||||
|
||||
const line = pending;
|
||||
pending = '';
|
||||
pendingQuotes = 0;
|
||||
if (!line.trim()) continue;
|
||||
|
||||
if (headers === null) {
|
||||
sep = sep || detectSeparator(line);
|
||||
headers = splitCsvLine(line, sep).map((header) =>
|
||||
normalizeHeader ? normalizeHeader(header) : header
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const values = splitCsvLine(line, sep);
|
||||
const row = {};
|
||||
for (let i = 0; i < headers.length; i += 1) {
|
||||
row[headers[i]] = values[i] || '';
|
||||
}
|
||||
yield row;
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Writer de CSV em stream: grava BOM + header e depois linha a linha,
|
||||
// sem montar o arquivo inteiro em memoria.
|
||||
export function createCsvWriter(filePath, headers, { separator = ';' } = {}) {
|
||||
const stream = fs.createWriteStream(filePath, { encoding: 'utf8' });
|
||||
stream.write(BOM + headers.join(separator) + '\n');
|
||||
|
||||
return {
|
||||
writeRow(values) {
|
||||
return stream.write(values.join(separator) + '\n');
|
||||
},
|
||||
async end() {
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.end((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
// Le um arquivo JSON que contem um array de objetos SEM carregar o arquivo todo
|
||||
// em memoria (o export legado de leads tem 472MB — JSON.parse direto estoura o heap).
|
||||
// Scanner de profundidade: acha cada objeto de nivel superior do array e faz
|
||||
// JSON.parse elemento a elemento (~3KB cada), mantendo a memoria constante.
|
||||
// Suporta apenas arrays cujos elementos sao objetos (caso deste projeto).
|
||||
export async function* streamJsonArray(filePath, { highWaterMark = 64 * 1024 } = {}) {
|
||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8', highWaterMark });
|
||||
|
||||
let arrayStarted = false;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escapeNext = false;
|
||||
let parts = [];
|
||||
let first = true;
|
||||
|
||||
try {
|
||||
for await (let chunkText of stream) {
|
||||
if (first) {
|
||||
first = false;
|
||||
if (chunkText.charCodeAt(0) === 0xfeff) chunkText = chunkText.slice(1);
|
||||
}
|
||||
|
||||
let elementStart = -1;
|
||||
|
||||
for (let i = 0; i < chunkText.length; i += 1) {
|
||||
const char = chunkText[i];
|
||||
|
||||
if (inString) {
|
||||
if (escapeNext) {
|
||||
escapeNext = false;
|
||||
} else if (char === '\\') {
|
||||
escapeNext = true;
|
||||
} else if (char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
if (depth > 0) inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!arrayStarted) {
|
||||
if (char === '[') arrayStarted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
if (depth === 0) elementStart = i;
|
||||
depth += 1;
|
||||
} else if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
const startIndex = elementStart >= 0 ? elementStart : 0;
|
||||
parts.push(chunkText.slice(startIndex, i + 1));
|
||||
const text = parts.length === 1 ? parts[0] : parts.join('');
|
||||
parts = [];
|
||||
elementStart = -1;
|
||||
yield JSON.parse(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > 0) {
|
||||
parts.push(elementStart >= 0 ? chunkText.slice(elementStart) : chunkText);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
|
||||
if (depth !== 0) {
|
||||
throw new Error(`JSON truncado em ${filePath}: elemento incompleto no fim do arquivo`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { hashPassword, verifyPassword } from '../src/auth.mjs';
|
||||
import { openDb, createUser, findUserByUsername, listUsers, ensureDefaultAdmin } from '../src/db.mjs';
|
||||
|
||||
test('hashPassword/verifyPassword aceita a senha certa e rejeita a errada', () => {
|
||||
const stored = hashPassword('minhaSenha123');
|
||||
assert.ok(verifyPassword('minhaSenha123', stored));
|
||||
assert.ok(!verifyPassword('senhaErrada', stored));
|
||||
});
|
||||
|
||||
test('hashPassword gera salt diferente a cada chamada (hashes distintos p/ mesma senha)', () => {
|
||||
assert.notEqual(hashPassword('abc'), hashPassword('abc'));
|
||||
});
|
||||
|
||||
test('ensureDefaultAdmin cria admin/admin somente no primeiro acesso', () => {
|
||||
const db = openDb(':memory:');
|
||||
ensureDefaultAdmin(db);
|
||||
|
||||
const admin = findUserByUsername(db, 'admin');
|
||||
assert.ok(admin);
|
||||
assert.equal(admin.role, 'admin');
|
||||
assert.ok(verifyPassword('admin', admin.password_hash));
|
||||
|
||||
createUser(db, { username: 'vendedor1', passwordHash: hashPassword('123456'), role: 'comercial' });
|
||||
ensureDefaultAdmin(db); // nao deve recriar nem duplicar
|
||||
|
||||
assert.equal(listUsers(db).length, 2);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('createUser rejeita username duplicado (constraint UNIQUE)', () => {
|
||||
const db = openDb(':memory:');
|
||||
createUser(db, { username: 'joao', passwordHash: hashPassword('123456'), role: 'comercial' });
|
||||
|
||||
assert.throws(() => createUser(db, { username: 'joao', passwordHash: hashPassword('outra'), role: 'admin' }));
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { splitCsvLine, csvEscape, excelTextCell, streamCsvRows, createCsvWriter, stripBom, BOM } from '../src/util/csv.mjs';
|
||||
|
||||
test('splitCsvLine separa campos com separador dentro de aspas', () => {
|
||||
assert.deepEqual(splitCsvLine('a;"b;c";d'), ['a', 'b;c', 'd']);
|
||||
});
|
||||
|
||||
test('splitCsvLine trata aspas duplas escapadas', () => {
|
||||
assert.deepEqual(splitCsvLine('"x""y";z'), ['x"y', 'z']);
|
||||
});
|
||||
|
||||
test('splitCsvLine aceita separador virgula', () => {
|
||||
assert.deepEqual(splitCsvLine('a,b,"c,d"', ','), ['a', 'b', 'c,d']);
|
||||
});
|
||||
|
||||
test('csvEscape protege valores com separador, aspas e quebra de linha', () => {
|
||||
assert.equal(csvEscape('sem risco'), 'sem risco');
|
||||
assert.equal(csvEscape('a;b'), '"a;b"');
|
||||
assert.equal(csvEscape('diz "oi"'), '"diz ""oi"""');
|
||||
assert.equal(csvEscape(null), '');
|
||||
});
|
||||
|
||||
test('excelTextCell gera formula de texto para numeros longos', () => {
|
||||
assert.equal(excelTextCell('557193335451'), '"=""557193335451"""');
|
||||
});
|
||||
|
||||
test('stripBom remove BOM apenas quando presente', () => {
|
||||
assert.equal(stripBom('abc'), 'abc');
|
||||
assert.equal(stripBom('abc'), 'abc');
|
||||
});
|
||||
|
||||
test('streamCsvRows le arquivo com BOM, auto-detecta separador e normaliza headers', async () => {
|
||||
const file = path.join(os.tmpdir(), `gti-test-csv-${Date.now()}.csv`);
|
||||
await fs.writeFile(file, `${BOM}Nome Empresa;Telefone 1\n"ACME; Ltda";7599999\nBeta;`, 'utf8');
|
||||
|
||||
try {
|
||||
const rows = [];
|
||||
for await (const row of streamCsvRows(file, {
|
||||
normalizeHeader: (h) => h.toLowerCase().replace(/\s+/g, '_')
|
||||
})) {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].nome_empresa, 'ACME; Ltda');
|
||||
assert.equal(rows[0].telefone_1, '7599999');
|
||||
assert.equal(rows[1].telefone_1, '');
|
||||
} finally {
|
||||
await fs.rm(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('streamCsvRows suporta campo com quebra de linha dentro de aspas', async () => {
|
||||
const file = path.join(os.tmpdir(), `gti-test-csvml-${Date.now()}.csv`);
|
||||
await fs.writeFile(file, 'a;b\n1;"linha um\nlinha dois"\n2;simples', 'utf8');
|
||||
|
||||
try {
|
||||
const rows = [];
|
||||
for await (const row of streamCsvRows(file)) rows.push(row);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].b, 'linha um\nlinha dois');
|
||||
assert.equal(rows[1].b, 'simples');
|
||||
} finally {
|
||||
await fs.rm(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('createCsvWriter grava BOM, header e linhas', async () => {
|
||||
const file = path.join(os.tmpdir(), `gti-test-csvw-${Date.now()}.csv`);
|
||||
const writer = createCsvWriter(file, ['a', 'b']);
|
||||
writer.writeRow(['1', csvEscape('x;y')]);
|
||||
writer.writeRow(['2', excelTextCell('123')]);
|
||||
await writer.end();
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(file, 'utf8');
|
||||
assert.ok(content.startsWith(BOM));
|
||||
const lines = stripBom(content).trim().split('\n');
|
||||
assert.equal(lines[0], 'a;b');
|
||||
assert.equal(lines[1], '1;"x;y"');
|
||||
assert.equal(lines[2], '2;"=""123"""');
|
||||
} finally {
|
||||
await fs.rm(file, { force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
|
||||
import { dashboardSummary, dashboardDailyEvolution, dashboardMaiorOportunidade } from '../src/treatment/dashboard.mjs';
|
||||
|
||||
function seedLeads(db) {
|
||||
const insert = createLeadInserter(db);
|
||||
const base = {
|
||||
legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '',
|
||||
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
|
||||
origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1,
|
||||
score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null,
|
||||
captured_at: null
|
||||
};
|
||||
const hoje = new Date().toISOString();
|
||||
|
||||
insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: hoje });
|
||||
insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', prioridade: 1, exported_at: null });
|
||||
insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Ilheus', uf: 'BA', prioridade: 2, exported_at: null });
|
||||
insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', prioridade: 4, exported_at: null });
|
||||
}
|
||||
|
||||
test('dashboardSummary calcula totais, percentuais e meta diaria sobre a base toda', () => {
|
||||
const db = openDb(':memory:');
|
||||
seedLeads(db);
|
||||
setSetting(db, 'meta_diaria', '2');
|
||||
|
||||
const s = dashboardSummary(db, {});
|
||||
|
||||
assert.equal(s.total, 4);
|
||||
assert.equal(s.p1, 2);
|
||||
assert.equal(s.p2, 1);
|
||||
assert.equal(s.p3, 0);
|
||||
assert.equal(s.p4, 1);
|
||||
assert.equal(s.potencial_pct, 50);
|
||||
assert.equal(s.p1_pct, 50);
|
||||
assert.equal(s.p4_pct, 25);
|
||||
assert.equal(s.exportados, 1);
|
||||
assert.equal(s.disponiveis, 3);
|
||||
assert.equal(s.cobertura_pct, 25);
|
||||
assert.equal(s.hoje_exportados, 1);
|
||||
assert.equal(s.meta_diaria, 2);
|
||||
assert.equal(s.meta_pct, 50);
|
||||
assert.equal(s.dias_estimados, 2);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('dashboardSummary respeita o filtro (cidade) ao calcular percentuais', () => {
|
||||
const db = openDb(':memory:');
|
||||
seedLeads(db);
|
||||
|
||||
const s = dashboardSummary(db, { cidade: 'Ilheus' });
|
||||
|
||||
assert.equal(s.total, 2);
|
||||
assert.equal(s.p1, 0);
|
||||
assert.equal(s.p2, 1);
|
||||
assert.equal(s.p4, 1);
|
||||
assert.equal(s.p2_pct, 50);
|
||||
assert.equal(s.p4_pct, 50);
|
||||
assert.equal(s.exportados, 0);
|
||||
assert.equal(s.cobertura_pct, 0);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('dashboardSummary com base vazia nao divide por zero', () => {
|
||||
const db = openDb(':memory:');
|
||||
|
||||
const s = dashboardSummary(db, {});
|
||||
|
||||
assert.equal(s.total, 0);
|
||||
assert.equal(s.potencial_pct, 0);
|
||||
assert.equal(s.cobertura_pct, 0);
|
||||
assert.equal(s.meta_pct, 0);
|
||||
assert.equal(s.dias_estimados, 0);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
function seedLeadsComProdutos(db) {
|
||||
const insert = createLeadInserter(db);
|
||||
const base = {
|
||||
legal_name: '', whatsapp: '', ddd: '', email: '', segmento: '', segmento_label: '',
|
||||
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]',
|
||||
origem: 'cnpj-base', telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1,
|
||||
score: 50, classificacao: 'medio', motivo_classificacao: '', capture_run_id: null,
|
||||
captured_at: null, prioridade: 1, produtos_json: '[]'
|
||||
};
|
||||
|
||||
insert({ ...base, lead_uid: 'A', dedup_key: 'k1', empresa: 'A', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T10:00:00.000Z' });
|
||||
insert({ ...base, lead_uid: 'B', dedup_key: 'k2', empresa: 'B', cidade: 'Salvador', uf: 'BA', produto_principal: 'ERP', exported_at: '2026-07-10T11:00:00.000Z' });
|
||||
insert({ ...base, lead_uid: 'C', dedup_key: 'k3', empresa: 'C', cidade: 'Salvador', uf: 'BA', produto_principal: 'G-Chat', exported_at: '2026-07-11T09:00:00.000Z' });
|
||||
insert({ ...base, lead_uid: 'D', dedup_key: 'k4', empresa: 'D', cidade: 'Ilheus', uf: 'BA', produto_principal: 'ERP', exported_at: null });
|
||||
}
|
||||
|
||||
test('dashboardDailyEvolution agrupa exportacoes por dia em ordem cronologica', () => {
|
||||
const db = openDb(':memory:');
|
||||
seedLeadsComProdutos(db);
|
||||
|
||||
const evolution = dashboardDailyEvolution(db, {}, 14).map((r) => ({ ...r }));
|
||||
|
||||
assert.deepEqual(evolution, [
|
||||
{ dia: '2026-07-10', total: 2 },
|
||||
{ dia: '2026-07-11', total: 1 }
|
||||
]);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('dashboardDailyEvolution respeita o filtro', () => {
|
||||
const db = openDb(':memory:');
|
||||
seedLeadsComProdutos(db);
|
||||
|
||||
const evolution = dashboardDailyEvolution(db, { cidade: 'Ilheus' }, 14);
|
||||
|
||||
assert.deepEqual(evolution, []);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('dashboardMaiorOportunidade retorna a combinacao cidade+produto com mais empresas', () => {
|
||||
const db = openDb(':memory:');
|
||||
seedLeadsComProdutos(db);
|
||||
|
||||
const oportunidade = dashboardMaiorOportunidade(db, {});
|
||||
|
||||
assert.deepEqual({ ...oportunidade }, { cidade: 'Salvador', produto: 'ERP', total: 2 });
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('dashboardMaiorOportunidade retorna null quando nao ha dados', () => {
|
||||
const db = openDb(':memory:');
|
||||
|
||||
assert.equal(dashboardMaiorOportunidade(db, {}), null);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter, transaction, getSetting, setSetting, seedSegments, seedMunicipios, seedGeoCache, seedSettings } from '../src/db.mjs';
|
||||
|
||||
function sampleLead(overrides = {}) {
|
||||
return {
|
||||
lead_uid: 'GTI-TEST-1',
|
||||
dedup_key: 'cnpj:57557263000113',
|
||||
empresa: 'PREMIUM EXPRESS',
|
||||
legal_name: 'PREMIUM EXPRESS LTDA',
|
||||
whatsapp: '557193335451',
|
||||
ddd: '71',
|
||||
email: '',
|
||||
cidade: 'Salvador',
|
||||
uf: 'BA',
|
||||
segmento: 'restaurantes',
|
||||
segmento_label: 'Restaurantes',
|
||||
interesse: 'Autoatendimento para WhatsApp',
|
||||
cnpj: '57557263000113',
|
||||
cnae: '5611203',
|
||||
endereco: 'RUA X, 1',
|
||||
website: '',
|
||||
pains_json: '["atendimento"]',
|
||||
origem: 'cnpj-base',
|
||||
telefone_valido: 1,
|
||||
cnpj_valido: 1,
|
||||
empresa_valida: 1,
|
||||
score: 100,
|
||||
classificacao: 'bom',
|
||||
motivo_classificacao: 'ok',
|
||||
capture_run_id: null,
|
||||
captured_at: '2026-07-09T14:26:18.560Z',
|
||||
exported_at: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('openDb cria esquema e aplica migrations uma unica vez', () => {
|
||||
const db = openDb(':memory:');
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get().v;
|
||||
assert.ok(version >= 1);
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
||||
for (const table of ['leads', 'capture_runs', 'extractions', 'exports', 'export_leads', 'segments', 'municipios', 'geo_cache', 'settings']) {
|
||||
assert.ok(tables.includes(table), `tabela ${table} ausente`);
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('createLeadInserter insere e ignora duplicata pela dedup_key', () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
|
||||
assert.equal(insert(sampleLead()), 'inserted');
|
||||
assert.equal(insert(sampleLead({ empresa: 'OUTRO NOME' })), 'duplicate');
|
||||
|
||||
const total = db.prepare('SELECT COUNT(*) AS t FROM leads').get().t;
|
||||
assert.equal(total, 1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('transaction faz rollback em erro', () => {
|
||||
const db = openDb(':memory:');
|
||||
const insert = createLeadInserter(db);
|
||||
|
||||
assert.throws(() =>
|
||||
transaction(db, () => {
|
||||
insert(sampleLead());
|
||||
throw new Error('boom');
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('settings: get/set e seed com defaults Z-PRO', () => {
|
||||
const db = openDb(':memory:');
|
||||
seedSettings(db);
|
||||
|
||||
assert.equal(getSetting(db, 'zpro.pipelineId'), '1');
|
||||
assert.equal(getSetting(db, 'zpro.stageId'), '1');
|
||||
assert.equal(getSetting(db, 'zpro.responsibleId'), '15');
|
||||
|
||||
setSetting(db, 'zpro.pipelineId', '9');
|
||||
assert.equal(getSetting(db, 'zpro.pipelineId'), '9');
|
||||
|
||||
// seed nao sobrescreve valor ja definido pelo usuario
|
||||
seedSettings(db);
|
||||
assert.equal(getSetting(db, 'zpro.pipelineId'), '9');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('seeds de segments, municipios e geo_cache', () => {
|
||||
const db = openDb(':memory:');
|
||||
|
||||
seedSegments(db, [
|
||||
{ name: 'clinicas_medicas', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [{ key: 'amenity', value: 'clinic' }], interest: 'gestao de TI', pains: ['agenda'] }
|
||||
]);
|
||||
const segment = db.prepare('SELECT * FROM segments WHERE name = ?').get('clinicas_medicas');
|
||||
assert.equal(segment.label, 'Clinicas Medicas');
|
||||
assert.deepEqual(JSON.parse(segment.cnae_codes_json), ['8630501']);
|
||||
|
||||
seedMunicipios(db, [{ codigo_ibge: '2928703', nome: 'Santo Antônio de Jesus', uf: 'BA' }]);
|
||||
const municipio = db.prepare('SELECT * FROM municipios WHERE uf = ?').get('BA');
|
||||
assert.equal(municipio.nome_clean, 'santo antonio de jesus');
|
||||
|
||||
seedGeoCache(db, [{ name: 'Santo Antonio de Jesus', state: 'BA', bbox: [-12.995, -39.325, -12.925, -39.225] }]);
|
||||
const bbox = db.prepare('SELECT * FROM geo_cache WHERE key = ?').get('bbox:BA:santo antonio de jesus');
|
||||
assert.equal(bbox.south, -12.995);
|
||||
assert.equal(bbox.provider, 'config-legacy');
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { openDb, createLeadInserter, seedSettings } from '../src/db.mjs';
|
||||
import { exportExcel } from '../src/export/excel.mjs';
|
||||
import { exportOportunidades, exportBulkDataInput } from '../src/export/zpro.mjs';
|
||||
import { stripBom } from '../src/util/csv.mjs';
|
||||
|
||||
function seedLeads(db) {
|
||||
const insert = createLeadInserter(db);
|
||||
insert({
|
||||
lead_uid: 'GTI-1', dedup_key: 'cnpj:57557263000113', empresa: 'PREMIUM EXPRESS',
|
||||
legal_name: 'PREMIUM EXPRESS LTDA', whatsapp: '557193335451', ddd: '71', email: '',
|
||||
cidade: 'Salvador', uf: 'BA', segmento: 'restaurantes', segmento_label: 'Restaurantes',
|
||||
interesse: 'Autoatendimento para WhatsApp', cnpj: '57557263000113', cnae: '5611203',
|
||||
endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base',
|
||||
telefone_valido: 1, cnpj_valido: 1, empresa_valida: 1, score: 100,
|
||||
classificacao: 'bom', motivo_classificacao: 'ok', capture_run_id: null,
|
||||
captured_at: null, exported_at: null
|
||||
});
|
||||
insert({
|
||||
lead_uid: 'GTI-2', dedup_key: 'name:sem telefone:salvador', empresa: 'SEM TELEFONE',
|
||||
legal_name: '', whatsapp: '', ddd: '', email: '', cidade: 'Salvador', uf: 'BA',
|
||||
segmento: 'restaurantes', segmento_label: 'Restaurantes', interesse: '', cnpj: '',
|
||||
cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'cnpj-base',
|
||||
telefone_valido: 0, cnpj_valido: 0, empresa_valida: 1, score: 35,
|
||||
classificacao: 'ruim', motivo_classificacao: 'score baixo', capture_run_id: null,
|
||||
captured_at: null, exported_at: null
|
||||
});
|
||||
}
|
||||
|
||||
async function withTempDir(fn) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gti-export-'));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('exportExcel gera CSV com formulas de texto e xls HTML', async () => {
|
||||
const db = openDb(':memory:');
|
||||
seedLeads(db);
|
||||
|
||||
await withTempDir(async (dir) => {
|
||||
const ids = [];
|
||||
const result = await exportExcel({ db, filtro: { uf: 'BA' }, dir, baseName: 'teste', collectIds: ids });
|
||||
|
||||
assert.equal(result.total, 2);
|
||||
assert.deepEqual(result.files, ['teste.csv', 'teste.xls']);
|
||||
assert.equal(ids.length, 2);
|
||||
|
||||
const csv = stripBom(await fs.readFile(path.join(dir, 'teste.csv'), 'utf8'));
|
||||
const lines = csv.trim().split('\n');
|
||||
assert.ok(lines[0].startsWith('empresa;whatsapp;ddd;cidade;uf;segmento;segmento_legivel'));
|
||||
assert.ok(lines[1].includes('"=""557193335451"""'), 'whatsapp deve ser formula de texto');
|
||||
assert.ok(lines[1].includes('"=""57557263000113"""'), 'cnpj deve ser formula de texto');
|
||||
assert.ok(lines[1].includes('cnpj:57557263000113'), 'chave_duplicidade presente');
|
||||
|
||||
const xls = await fs.readFile(path.join(dir, 'teste.xls'), 'utf8');
|
||||
assert.ok(xls.includes('mso-number-format'));
|
||||
assert.ok(xls.includes('<td class="text">557193335451</td>'));
|
||||
});
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('exportOportunidades gera JSONL no formato Z-PRO testado', async () => {
|
||||
const db = openDb(':memory:');
|
||||
seedSettings(db);
|
||||
seedLeads(db);
|
||||
|
||||
await withTempDir(async (dir) => {
|
||||
const result = await exportOportunidades({
|
||||
db, filtro: { classificacao: 'bom' }, params: {}, dir, baseName: 'op'
|
||||
});
|
||||
|
||||
assert.equal(result.total, 1);
|
||||
const lines = (await fs.readFile(path.join(dir, 'op.jsonl'), 'utf8')).trim().split('\n');
|
||||
const payload = JSON.parse(lines[0]);
|
||||
|
||||
assert.equal(payload.number, '557193335451');
|
||||
assert.equal(payload.contactName, 'PREMIUM EXPRESS');
|
||||
assert.equal(payload.name, 'PREMIUM EXPRESS - Autoatendimento para WhatsApp');
|
||||
assert.equal(payload.pipelineId, 1);
|
||||
assert.equal(payload.stageId, 1);
|
||||
assert.equal(payload.responsibleId, 15);
|
||||
assert.equal(payload.status, 'open');
|
||||
assert.equal(payload.validateNumber, true);
|
||||
assert.match(payload.closingForecast, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.ok(payload.description.includes('CNPJ: 57557263000113'));
|
||||
});
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('exportBulkDataInput inclui apenas leads com whatsapp', async () => {
|
||||
const db = openDb(':memory:');
|
||||
seedSettings(db);
|
||||
seedLeads(db);
|
||||
|
||||
await withTempDir(async (dir) => {
|
||||
const result = await exportBulkDataInput({
|
||||
db, filtro: {}, params: { mensagem: 'Ola {empresa}!' }, dir, baseName: 'bulk'
|
||||
});
|
||||
|
||||
assert.equal(result.total, 1, 'lead sem whatsapp deve ficar de fora');
|
||||
const csv = (await fs.readFile(path.join(dir, 'bulk-datainput.csv'), 'utf8')).trim();
|
||||
assert.equal(csv, '557193335451,PREMIUM EXPRESS,Salvador,Restaurantes');
|
||||
|
||||
const info = await fs.readFile(path.join(dir, 'bulk-instrucoes.txt'), 'utf8');
|
||||
assert.ok(info.includes('Ola {empresa}!'));
|
||||
assert.ok(info.includes('bulkSendMessageWithVariable'));
|
||||
});
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
buildNextQuestion,
|
||||
detectService,
|
||||
normalizePhone,
|
||||
processLead
|
||||
} from '../src/lead-engine.mjs';
|
||||
|
||||
describe('lead engine', () => {
|
||||
it('normaliza telefone brasileiro para formato internacional', () => {
|
||||
assert.equal(normalizePhone('(75) 99999-1111'), '5575999991111');
|
||||
assert.equal(normalizePhone('+55 75 99999-1111'), '5575999991111');
|
||||
});
|
||||
|
||||
it('detecta servico por texto livre', () => {
|
||||
assert.equal(detectService('quero trocar meu sistema de estoque'), 'erp');
|
||||
assert.equal(detectService('preciso de suporte em servidor e backup'), 'ti');
|
||||
assert.equal(detectService('automatizar atendimento no WhatsApp'), 'whatsapp');
|
||||
});
|
||||
|
||||
it('prioriza lead com WhatsApp para tratamento manual', () => {
|
||||
const result = processLead({
|
||||
source: 'site',
|
||||
contact: { name: 'Carlos Souza', phone: '(75) 99999-1111', email: 'carlos@mercado.com' },
|
||||
company: {
|
||||
name: 'Mercado Sao Joao',
|
||||
city: 'Santo Antonio de Jesus',
|
||||
state: 'BA',
|
||||
segment: 'mercado',
|
||||
employees: 18
|
||||
},
|
||||
interest: 'ERP',
|
||||
current_system: 'uso outro sistema, mas quero trocar',
|
||||
pains: ['estoque', 'financeiro', 'emissao fiscal'],
|
||||
urgency: 'alta',
|
||||
consent: true
|
||||
});
|
||||
|
||||
assert.equal(result.lead.service, 'erp');
|
||||
assert.equal(result.priority.priority, 'com_whatsapp');
|
||||
assert.equal(result.priority.hasWhatsapp, true);
|
||||
assert.ok(result.whatsapp.internal.message.includes('Novo lead captado - COM WHATSAPP'));
|
||||
assert.equal(result.nextAction, 'tratamento_manual');
|
||||
});
|
||||
|
||||
it('mantem lead de TI com dados parciais como captado', () => {
|
||||
const result = processLead({
|
||||
origem: 'indicacao',
|
||||
nome: 'Ana Lima',
|
||||
telefone: '75988887777',
|
||||
empresa: 'Clinica Vida',
|
||||
cidade: 'Santo Antonio de Jesus',
|
||||
uf: 'BA',
|
||||
segmento: 'clinica',
|
||||
servico: 'gestao de TI',
|
||||
computadores: 8,
|
||||
dor: 'backup; suporte tecnico',
|
||||
urgencia: 'media'
|
||||
});
|
||||
|
||||
assert.equal(result.lead.service, 'ti');
|
||||
assert.equal(result.priority.priority, 'com_whatsapp');
|
||||
assert.ok(result.crmRecord.whatsapp.startsWith('55'));
|
||||
});
|
||||
|
||||
it('mantem lead sem WhatsApp abaixo na prioridade', () => {
|
||||
const result = processLead({
|
||||
name: 'Lead Sem Servico',
|
||||
message: 'quero mais informacoes'
|
||||
});
|
||||
|
||||
assert.equal(result.lead.service, 'indefinido');
|
||||
assert.equal(result.priority.priority, 'sem_whatsapp');
|
||||
assert.equal(result.priority.hasWhatsapp, false);
|
||||
assert.ok(buildNextQuestion(result.lead).includes('1 - Sistema ERP'));
|
||||
});
|
||||
|
||||
it('reconhece segmentos compostos da prospeccao automatica como segmento-alvo', () => {
|
||||
const compostos = processLead({
|
||||
name: 'Lead Prospeccao',
|
||||
whatsapp: '75999993333',
|
||||
company_name: 'Restaurante Sabor da Terra',
|
||||
city: 'Amargosa',
|
||||
state: 'BA',
|
||||
segment: 'restaurante_pizzaria'
|
||||
});
|
||||
assert.equal(compostos.lead.isTargetSegment, true);
|
||||
|
||||
const materialConstrucao = processLead({
|
||||
name: 'Lead Prospeccao',
|
||||
whatsapp: '75999994444',
|
||||
company_name: 'Deposito Central',
|
||||
segment: 'material_construcao_cimento_blocos'
|
||||
});
|
||||
assert.equal(materialConstrucao.lead.isTargetSegment, true);
|
||||
|
||||
const clinica = processLead({
|
||||
name: 'Lead Prospeccao',
|
||||
whatsapp: '75999995555',
|
||||
company_name: 'Clinica Odonto Vida',
|
||||
segment: 'clinica_laboratorio_odonto_otica'
|
||||
});
|
||||
assert.equal(clinica.lead.isTargetSegment, true);
|
||||
});
|
||||
|
||||
it('nao marca como segmento-alvo um segmento fora da lista', () => {
|
||||
const result = processLead({
|
||||
name: 'Lead Prospeccao',
|
||||
whatsapp: '75999996666',
|
||||
company_name: 'Posto Boa Vista',
|
||||
segment: 'posto_combustivel'
|
||||
});
|
||||
assert.equal(result.lead.isTargetSegment, false);
|
||||
});
|
||||
|
||||
it('reconhece os novos nichos (beleza, celulares, transportadora) como segmento-alvo', () => {
|
||||
const beleza = processLead({
|
||||
name: 'Lead Prospeccao',
|
||||
whatsapp: '75999997777',
|
||||
company_name: 'Studio Beleza Bahia',
|
||||
segment: 'beleza_estetica_barbearia'
|
||||
});
|
||||
assert.equal(beleza.lead.isTargetSegment, true);
|
||||
|
||||
const celulares = processLead({
|
||||
name: 'Lead Prospeccao',
|
||||
whatsapp: '75999998888',
|
||||
company_name: 'Cell Center',
|
||||
segment: 'celulares_eletronicos_assistencia'
|
||||
});
|
||||
assert.equal(celulares.lead.isTargetSegment, true);
|
||||
|
||||
const transportadora = processLead({
|
||||
name: 'Lead Prospeccao',
|
||||
whatsapp: '75999999999',
|
||||
company_name: 'Transportadora Recôncavo',
|
||||
segment: 'transportadora_logistica'
|
||||
});
|
||||
assert.equal(transportadora.lead.isTargetSegment, true);
|
||||
});
|
||||
|
||||
it('direciona lead de WhatsApp para pergunta de volume quando a dor ja existe', () => {
|
||||
const result = processLead({
|
||||
name: 'Mariana',
|
||||
whatsapp: '75999992222',
|
||||
company_name: 'Restaurante Sabor',
|
||||
city: 'Santo Antonio de Jesus',
|
||||
state: 'BA',
|
||||
interest: 'autoatendimento WhatsApp',
|
||||
pains: ['pedidos', 'triagem']
|
||||
});
|
||||
|
||||
assert.equal(result.lead.service, 'whatsapp');
|
||||
assert.ok(buildNextQuestion(result.lead).includes('quantas mensagens'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb } from '../src/db.mjs';
|
||||
import { runCapture, createCaptureRun } from '../src/capture/runner.mjs';
|
||||
|
||||
const SEGMENTS = [
|
||||
{ name: 'mercados_de_bairro', label: 'Mercados De Bairro', interest: 'ERP / sistema de gestao', queries: ['mercado'], cnaeCodes: ['4711302'], overpassTags: [], pains: ['estoque'] },
|
||||
{ name: 'clinicas_medicas', label: 'Clinicas Medicas', interest: 'gestao de TI', queries: ['clinica'], cnaeCodes: ['8630501'], overpassTags: [], pains: ['agenda'] },
|
||||
{ name: 'restaurantes', label: 'Restaurantes', interest: 'autoatendimento WhatsApp', queries: ['restaurante'], cnaeCodes: ['5611201'], overpassTags: [], pains: ['pedidos'] }
|
||||
];
|
||||
|
||||
function captureParams() {
|
||||
return {
|
||||
sources: ['mock'],
|
||||
uf: 'BA',
|
||||
cities: ['Santo Antonio de Jesus'],
|
||||
segments: SEGMENTS,
|
||||
limits: { maxPerQuery: 10, queryLimit: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
test('runCapture insere leads do mock e deduplica no rerun', async () => {
|
||||
const db = openDb(':memory:');
|
||||
|
||||
const runId = createCaptureRun(db, {});
|
||||
const first = await runCapture({ db, runId, params: captureParams() });
|
||||
assert.equal(first.raw, 3);
|
||||
assert.equal(first.new, 3);
|
||||
assert.equal(first.dup, 0);
|
||||
assert.equal(first.status, 'done');
|
||||
|
||||
const run = db.prepare('SELECT * FROM capture_runs WHERE id = ?').get(runId);
|
||||
assert.equal(run.status, 'done');
|
||||
assert.equal(run.new_count, 3);
|
||||
assert.ok(run.finished_at);
|
||||
|
||||
const lead = db.prepare("SELECT * FROM leads WHERE segmento = 'restaurantes'").get();
|
||||
assert.equal(lead.uf, 'BA');
|
||||
assert.equal(lead.cidade, 'Santo Antonio de Jesus');
|
||||
assert.equal(lead.origem, 'mock');
|
||||
assert.equal(lead.telefone_valido, 1);
|
||||
assert.ok(lead.dedup_key.startsWith('phone:'));
|
||||
assert.equal(lead.capture_run_id, runId);
|
||||
|
||||
// rerun: tudo duplicado
|
||||
const runId2 = createCaptureRun(db, {});
|
||||
const second = await runCapture({ db, runId: runId2, params: captureParams() });
|
||||
assert.equal(second.new, 0);
|
||||
assert.equal(second.dup, 3);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS t FROM leads').get().t, 3);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runCapture respeita cancelamento', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const runId = createCaptureRun(db, {});
|
||||
|
||||
const result = await runCapture({
|
||||
db,
|
||||
runId,
|
||||
params: captureParams(),
|
||||
isCancelled: () => true
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'cancelled');
|
||||
assert.equal(db.prepare('SELECT status FROM capture_runs WHERE id = ?').get(runId).status, 'cancelled');
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
validatePhone, validateCnpj, validateEmpresa, dedupKey, extractDdd, segmentLabel,
|
||||
scoreQualidade, scoreComercial, scoreGeral, classificarScoreGeral
|
||||
} from '../src/scoring.mjs';
|
||||
|
||||
test('validatePhone aceita celular e fixo com 55', () => {
|
||||
assert.equal(validatePhone('557193335451').valid, true);
|
||||
assert.equal(validatePhone('5571993335451').valid, true);
|
||||
});
|
||||
|
||||
test('validatePhone rejeita vazio, curto e ddd invalido', () => {
|
||||
assert.deepEqual(validatePhone(''), { valid: false, motivo: 'telefone vazio' });
|
||||
assert.equal(validatePhone('7199999').valid, false);
|
||||
assert.equal(validatePhone('550599999999').valid, false);
|
||||
assert.equal(validatePhone('5571000000000').valid, false);
|
||||
});
|
||||
|
||||
test('validateCnpj valida digitos verificadores', () => {
|
||||
// CNPJ real da base legada (PREMIUM EXPRESS)
|
||||
assert.equal(validateCnpj('57557263000113'), true);
|
||||
assert.equal(validateCnpj('57557263000114'), false);
|
||||
assert.equal(validateCnpj('11111111111111'), false);
|
||||
assert.equal(validateCnpj('123'), false);
|
||||
});
|
||||
|
||||
test('validateEmpresa aplica regra critica do relatorio legado', () => {
|
||||
assert.equal(validateEmpresa('PREMIUM EXPRESS').valid, true);
|
||||
assert.deepEqual(validateEmpresa('57.563.316 JHONATAS SOUZA'), { valid: false, motivo: 'empresa comeca com numero' });
|
||||
assert.deepEqual(validateEmpresa('-'), { valid: false, motivo: 'empresa vazia ou com lixo' });
|
||||
assert.deepEqual(validateEmpresa(''), { valid: false, motivo: 'empresa vazia ou com lixo' });
|
||||
assert.equal(validateEmpresa('...***...').valid, false);
|
||||
});
|
||||
|
||||
test('scoreQualidade: lead completo com website bate 100', () => {
|
||||
const result = scoreQualidade({
|
||||
whatsapp: '557193335451',
|
||||
cnpj: '57557263000113',
|
||||
empresa: 'PREMIUM EXPRESS',
|
||||
cidade: 'Salvador',
|
||||
segmento: 'restaurantes',
|
||||
cnae: '5611203',
|
||||
website: 'https://premiumexpress.com.br'
|
||||
});
|
||||
|
||||
assert.equal(result.score, 100);
|
||||
assert.equal(result.telefoneValido, true);
|
||||
assert.equal(result.cnpjValido, true);
|
||||
assert.equal(result.empresaValida, true);
|
||||
});
|
||||
|
||||
test('scoreQualidade: sem telefone/website perde 30+5 pontos', () => {
|
||||
const result = scoreQualidade({
|
||||
whatsapp: '',
|
||||
cnpj: '57557263000113',
|
||||
empresa: 'PREMIUM EXPRESS',
|
||||
cidade: 'Salvador',
|
||||
segmento: 'restaurantes',
|
||||
cnae: '5611203',
|
||||
website: ''
|
||||
});
|
||||
|
||||
assert.equal(result.score, 65);
|
||||
assert.equal(result.telefoneValido, false);
|
||||
});
|
||||
|
||||
test('scoreComercial: nicho alta aderencia + tudo valido bate 100', () => {
|
||||
const score = scoreComercial({
|
||||
whatsapp: '557193335451',
|
||||
cnpj: '57557263000113',
|
||||
website: 'https://premiumexpress.com.br',
|
||||
email: 'contato@premiumexpress.com.br',
|
||||
affinity: 'alta'
|
||||
});
|
||||
assert.equal(score, 100);
|
||||
});
|
||||
|
||||
test('scoreComercial: nicho baixa aderencia sem website/email/whatsapp/cnpj fica so com os pontos do nicho', () => {
|
||||
const score = scoreComercial({ whatsapp: '', cnpj: '', website: '', email: '', affinity: 'baixa' });
|
||||
assert.equal(score, 20);
|
||||
});
|
||||
|
||||
test('scoreGeral combina 60% comercial + 40% qualidade', () => {
|
||||
assert.equal(scoreGeral(100, 100), 100);
|
||||
assert.equal(scoreGeral(50, 50), 50);
|
||||
assert.equal(scoreGeral(0, 100), 60);
|
||||
assert.equal(scoreGeral(100, 0), 40);
|
||||
});
|
||||
|
||||
test('classificarScoreGeral aplica as 5 faixas', () => {
|
||||
assert.equal(classificarScoreGeral(95).id, 'excelente');
|
||||
assert.equal(classificarScoreGeral(80).id, 'bom');
|
||||
assert.equal(classificarScoreGeral(60).id, 'medio');
|
||||
assert.equal(classificarScoreGeral(40).id, 'baixo');
|
||||
assert.equal(classificarScoreGeral(10).id, 'muito_baixo');
|
||||
});
|
||||
|
||||
test('dedupKey segue precedencia cnpj > phone > empresa+cidade', () => {
|
||||
assert.equal(
|
||||
dedupKey({ cnpj: '57557263000113', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }),
|
||||
'cnpj:57557263000113'
|
||||
);
|
||||
assert.equal(
|
||||
dedupKey({ cnpj: '123', whatsapp: '557193335451', empresa: 'X', cidade: 'Y' }),
|
||||
'phone:557193335451'
|
||||
);
|
||||
assert.equal(
|
||||
dedupKey({ cnpj: '', whatsapp: '', empresa: 'Premium Express', cidade: 'Salvador' }),
|
||||
'name:premium express:salvador'
|
||||
);
|
||||
});
|
||||
|
||||
test('extractDdd retira o ddd do numero normalizado', () => {
|
||||
assert.equal(extractDdd('557193335451'), '71');
|
||||
assert.equal(extractDdd('999'), '');
|
||||
});
|
||||
|
||||
test('segmentLabel formata igual ao segmento_legivel legado', () => {
|
||||
assert.equal(segmentLabel('lojas_de_calcados'), 'Lojas De Calcados');
|
||||
assert.equal(segmentLabel('saloes_de_beleza'), 'Saloes De Beleza');
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { streamJsonArray } from '../src/util/stream-json.mjs';
|
||||
|
||||
async function collect(filePath, options) {
|
||||
const items = [];
|
||||
for await (const item of streamJsonArray(filePath, options)) {
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function withTempFile(content, fn) {
|
||||
const file = path.join(os.tmpdir(), `gti-test-json-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
||||
await fs.writeFile(file, content, 'utf8');
|
||||
try {
|
||||
return await fn(file);
|
||||
} finally {
|
||||
await fs.rm(file, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('streamJsonArray le array pretty-printed com chaves e aspas dentro de strings', async () => {
|
||||
const data = [
|
||||
{ nome: 'Empresa {teste} "aspas"', valor: 10, aninhado: { lista: [1, 2, { x: '}' }] } },
|
||||
{ nome: 'Segunda \\ barra', vazio: null }
|
||||
];
|
||||
|
||||
await withTempFile(JSON.stringify(data, null, 2), async (file) => {
|
||||
const items = await collect(file);
|
||||
assert.deepEqual(items, data);
|
||||
});
|
||||
});
|
||||
|
||||
test('streamJsonArray funciona com JSON compacto e BOM', async () => {
|
||||
const data = [{ a: 1 }, { b: '[{teste}]' }, { c: [{ d: 2 }] }];
|
||||
|
||||
await withTempFile('' + JSON.stringify(data), async (file) => {
|
||||
const items = await collect(file);
|
||||
assert.deepEqual(items, data);
|
||||
});
|
||||
});
|
||||
|
||||
test('streamJsonArray processa elementos maiores que o chunk', async () => {
|
||||
const data = [
|
||||
{ texto: 'x'.repeat(500), fim: 'ok' },
|
||||
{ texto: 'y'.repeat(300) }
|
||||
];
|
||||
|
||||
await withTempFile(JSON.stringify(data, null, 2), async (file) => {
|
||||
const items = await collect(file, { highWaterMark: 64 });
|
||||
assert.deepEqual(items, data);
|
||||
});
|
||||
});
|
||||
|
||||
test('streamJsonArray retorna vazio para array vazio', async () => {
|
||||
await withTempFile('[]', async (file) => {
|
||||
const items = await collect(file);
|
||||
assert.deepEqual(items, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('streamJsonArray falha com erro claro em arquivo truncado', async () => {
|
||||
await withTempFile('[\n { "a": 1 },\n { "b": 2', async (file) => {
|
||||
await assert.rejects(() => collect(file), /JSON truncado/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user