chore: import gti-maquina-comercial history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: gti-maquina-comercial git-subtree-mainline:93ca82979egit-subtree-split:b77484946f
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,38 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Credenciais da API externa do Z-PRO (Admin > API no painel do Z-PRO), usadas
|
||||||
|
# pela automacao de envio WhatsApp. Normalmente configuradas direto na tela
|
||||||
|
# (Configuracoes > Credenciais Z-PRO) — essas variaveis aqui sao so um
|
||||||
|
# fallback (util em deploy via Docker/Proxmox) e so valem se os campos da
|
||||||
|
# tela estiverem vazios. Sem credenciais em nenhum dos dois lugares a
|
||||||
|
# automacao fica sem efeito mesmo com outreach.ativo=true.
|
||||||
|
#ZPRO_BASE_URL=seu-dominio-zpro.com.br
|
||||||
|
#ZPRO_API_ID=
|
||||||
|
#ZPRO_BEARER_TOKEN=
|
||||||
|
#ZPRO_WHATSAPP_ID=
|
||||||
|
|
||||||
|
# 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=15000
|
||||||
|
#OVERPASS_TEXT_SEARCH=false
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
data/
|
||||||
|
prospeccao/cnpj/rfb-raw/
|
||||||
|
prospeccao/cnpj/*.csv
|
||||||
|
prospeccao/cnpj/*.ndjson
|
||||||
|
*.log
|
||||||
|
GTI-Comercial.exe
|
||||||
|
launcher/publish/
|
||||||
|
.worktrees/
|
||||||
@@ -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,192 @@
|
|||||||
|
# GTI Maquina Comercial
|
||||||
|
|
||||||
|
Aplicativo local do Grupo GTI para capturar, qualificar, tratar e acionar leads B2B via WhatsApp. O sistema roda no computador do usuario, usa SQLite como banco local e integra com Z-PRO/GChat para envio manual ou automatico.
|
||||||
|
|
||||||
|
URL local padrao: `http://127.0.0.1:3900`
|
||||||
|
|
||||||
|
Login inicial: `admin` / `admin`
|
||||||
|
|
||||||
|
## Uso Diario
|
||||||
|
|
||||||
|
1. Abra `GTI-Comercial.exe` ou `INICIAR-GTI.bat`.
|
||||||
|
2. Acesse `http://127.0.0.1:3900` e faca login.
|
||||||
|
3. Use a aba `Capturar Leads` para escolher UF, cidades, nichos e fontes.
|
||||||
|
4. Use a aba `Tratar Leads` para filtrar, priorizar, abrir detalhes e exportar leads.
|
||||||
|
5. Use o botao de WhatsApp na primeira coluna da tabela para iniciar uma mensagem para um lead especifico.
|
||||||
|
6. Use `Configuracoes` para ajustar Z-PRO, templates WABA, limites de envio, usuarios e exportacoes.
|
||||||
|
|
||||||
|
## Principais Recursos
|
||||||
|
|
||||||
|
- Captura de leads B2B por UF, cidade e nicho.
|
||||||
|
- Base CNPJ da Receita Federal como fonte principal.
|
||||||
|
- Fontes complementares: OpenStreetMap/Overpass, Nominatim, Wikidata, BrasilAPI e Google Places.
|
||||||
|
- Deduplicacao por CNPJ, telefone/WhatsApp ou empresa+cidade.
|
||||||
|
- Scores de qualidade e potencial comercial.
|
||||||
|
- Dashboard com totais, rankings, funil e maior oportunidade.
|
||||||
|
- Tabela de tratamento com filtros, paginação e detalhe completo do lead.
|
||||||
|
- Acao rapida de WhatsApp por lead, fixa na primeira coluna da tabela.
|
||||||
|
- Envio manual por Z-PRO para numero especifico.
|
||||||
|
- Envio oficial por template WABA aprovado.
|
||||||
|
- Sincronizacao de templates WABA do GChat.
|
||||||
|
- Pre-visualizacao de templates com variaveis resolvidas por lead.
|
||||||
|
- Automacao de envio por produto recomendado, com limite diario.
|
||||||
|
- Exportacoes para Excel/CSV, oportunidades Z-PRO e disparo em massa.
|
||||||
|
- Login local com usuarios `admin` e `comercial`.
|
||||||
|
|
||||||
|
## Fluxo Comercial
|
||||||
|
|
||||||
|
1. Capture ou importe a base de leads.
|
||||||
|
2. Filtre por cidade, nicho, qualidade, WhatsApp, prioridade ou status de exportacao.
|
||||||
|
3. Revise o lead na tabela ou no modal de detalhes.
|
||||||
|
4. Clique no botao de WhatsApp do lead para abrir o envio manual com o numero preenchido.
|
||||||
|
5. Escolha texto livre ou template oficial.
|
||||||
|
6. Se usar template, selecione o modelo salvo e confira a pre-visualizacao.
|
||||||
|
7. Envie pelo Z-PRO e acompanhe o log de envios.
|
||||||
|
|
||||||
|
Ao abrir o envio manual a partir de um lead, o app busca os dados completos desse cliente. Variaveis como `{{cnpj}}`, `{{empresa}}`, `{{businessName}}`, `{{produto}}`, `{{cidade}}`, `{{uf}}`, `{{segmento}}`, `{{whatsapp}}` e `{{email}}` sao preenchidas com os dados reais do lead na pre-visualizacao e no envio.
|
||||||
|
|
||||||
|
## WhatsApp, Z-PRO e GChat
|
||||||
|
|
||||||
|
O app suporta dois tipos de envio:
|
||||||
|
|
||||||
|
| Canal | Uso | Observacao |
|
||||||
|
|---|---|---|
|
||||||
|
| Nao oficial/texto livre | Mensagem manual simples | Usa endpoint externo do Z-PRO para enviar texto |
|
||||||
|
| Oficial/template WABA | Mensagem com template aprovado | Usa template Meta/WABA via Z-PRO |
|
||||||
|
|
||||||
|
Configuracoes importantes ficam em `Configuracoes`:
|
||||||
|
|
||||||
|
- Base URL Z-PRO.
|
||||||
|
- API ID Z-PRO.
|
||||||
|
- Token/Bearer token.
|
||||||
|
- Rota do template (`template`, `templateBody` ou equivalente configurado).
|
||||||
|
- URL de templates do GChat.
|
||||||
|
- Token GChat opcional para sincronizar modelos.
|
||||||
|
- Limite diario de envios.
|
||||||
|
- Templates automaticos por produto.
|
||||||
|
|
||||||
|
O seletor de templates WABA permite carregar modelos sincronizados do GChat. O sistema identifica variaveis posicionais como `{{1}}`, `{{2}}`, `{{3}}` no corpo do template e mostra os campos necessarios.
|
||||||
|
|
||||||
|
## Variaveis Suportadas
|
||||||
|
|
||||||
|
As mensagens e templates podem usar variaveis internas da Maquina Comercial/GChat:
|
||||||
|
|
||||||
|
| Variavel | Valor esperado |
|
||||||
|
|---|---|
|
||||||
|
| `{{name}}` | Nome do contato quando existir; fallback para empresa |
|
||||||
|
| `{{empresa}}` | Nome da empresa |
|
||||||
|
| `{{businessName}}` | Nome da empresa no padrao GChat |
|
||||||
|
| `{{firstName}}` | Primeiro nome |
|
||||||
|
| `{{lastName}}` | Sobrenome, quando existir |
|
||||||
|
| `{{produto}}` | Produto recomendado/interesse |
|
||||||
|
| `{{protocol}}` | Assunto/protocolo; fallback para produto |
|
||||||
|
| `{{cidade}}` | Cidade |
|
||||||
|
| `{{uf}}` | UF |
|
||||||
|
| `{{segmento}}` | Nicho/segmento |
|
||||||
|
| `{{cnpj}}` | CNPJ |
|
||||||
|
| `{{email}}` | E-mail |
|
||||||
|
| `{{phoneNumber}}` | WhatsApp/telefone |
|
||||||
|
| `{{whatsapp}}` | WhatsApp/telefone |
|
||||||
|
| `{{kanban}}` | Produto/interesse |
|
||||||
|
| `{{user}}` | Remetente |
|
||||||
|
| `{{userEmail}}` | E-mail do remetente |
|
||||||
|
| `{{greeting}}` | Saudacao |
|
||||||
|
|
||||||
|
Tambem existem placeholders legados: `{empresa}`, `{cidade}`, `{produto}` e `{segmento}`.
|
||||||
|
|
||||||
|
## Exportacoes
|
||||||
|
|
||||||
|
| Tipo | Saida | Para que serve |
|
||||||
|
|---|---|---|
|
||||||
|
| Planilha | CSV + XLS | Analise comercial e tratamento externo |
|
||||||
|
| Z-PRO Oportunidades | JSONL | Payloads para criacao de oportunidades |
|
||||||
|
| Z-PRO Disparo em massa | CSV | `dataInput` para disparo em massa |
|
||||||
|
|
||||||
|
Leads exportados ficam marcados. Use o filtro de nao exportados para evitar retrabalho.
|
||||||
|
|
||||||
|
## Score e Priorizacao
|
||||||
|
|
||||||
|
O app calcula scores para ajudar o time comercial a decidir quem abordar primeiro.
|
||||||
|
|
||||||
|
Regras principais:
|
||||||
|
|
||||||
|
- WhatsApp/telefone valido aumenta a prioridade.
|
||||||
|
- CNPJ valido aumenta a confiabilidade.
|
||||||
|
- Empresa valida, cidade, nicho, CNAE, website e e-mail melhoram o score.
|
||||||
|
- Nichos com maior aderencia comercial recebem mais peso.
|
||||||
|
- Leads sem WhatsApp podem continuar cadastrados, mas aparecem com prioridade menor.
|
||||||
|
- Duplicidade segue a ordem CNPJ, telefone, empresa+cidade.
|
||||||
|
|
||||||
|
## Fontes de Captura
|
||||||
|
|
||||||
|
- Base CNPJ da Receita Federal: fonte principal para empresas ativas por UF.
|
||||||
|
- OpenStreetMap/Overpass: fonte aberta complementar.
|
||||||
|
- Nominatim: geocodificacao e apoio geografico.
|
||||||
|
- Wikidata: fonte aberta de baixo volume.
|
||||||
|
- BrasilAPI: enriquecimento de dados cadastrais.
|
||||||
|
- Google Places: fonte opcional paga, exige `GOOGLE_MAPS_API_KEY` no `.env`.
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
npm test
|
||||||
|
npm run capture
|
||||||
|
npm run extract
|
||||||
|
npm run recalc-scores
|
||||||
|
npm run outreach
|
||||||
|
npm run build:launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemplos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/servidor.mjs
|
||||||
|
node scripts/capturar-leads.mjs --sources=cnpj-base --uf=BA --cities="Ilheus"
|
||||||
|
node scripts/extrair-base-cnpj.mjs --uf=SP
|
||||||
|
node scripts/enviar-outreach.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
```text
|
||||||
|
GTI-Comercial.exe Launcher local do app
|
||||||
|
INICIAR-GTI.bat Inicializacao alternativa no Windows
|
||||||
|
scripts/ Entradas de linha de comando
|
||||||
|
src/ Codigo do servidor e regras de negocio
|
||||||
|
src/server/ HTTP, rotas, sessoes e autenticacao
|
||||||
|
src/capture/ Pipeline de captura
|
||||||
|
src/sources/ Fontes de dados
|
||||||
|
src/treatment/ Consultas e dashboard de tratamento
|
||||||
|
src/export/ Exportacoes Excel/CSV/Z-PRO
|
||||||
|
src/outreach/ Z-PRO, templates WABA e automacao WhatsApp
|
||||||
|
public/ Interface HTML/CSS/JS
|
||||||
|
tests/ Testes automatizados
|
||||||
|
data/gti.db Banco SQLite local, nao versionado
|
||||||
|
data/exports/ Arquivos exportados, nao versionado
|
||||||
|
prospeccao/cnpj/ Base CNPJ e dados brutos da Receita
|
||||||
|
docs/ Documentacao complementar
|
||||||
|
.env Segredos locais, nao versionado
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Windows.
|
||||||
|
- Node.js 24 ou superior.
|
||||||
|
- Para extrair novas UFs, `unzip`, Git Bash ou `tar` nativo do Windows.
|
||||||
|
|
||||||
|
## Seguranca e Dados
|
||||||
|
|
||||||
|
- O banco principal fica em `data/gti.db`.
|
||||||
|
- Segredos locais devem ficar no `.env` ou nas configuracoes locais do app.
|
||||||
|
- Tokens Z-PRO/GChat nao devem ser commitados.
|
||||||
|
- A zona de risco em Configuracoes apaga leads, capturas e exportacoes; a acao e irreversivel.
|
||||||
|
- Nao ha backup automatico do banco local.
|
||||||
|
|
||||||
|
## Problemas Comuns
|
||||||
|
|
||||||
|
- Se a tela nao abrir, reinicie por `GTI-Comercial.exe` ou `INICIAR-GTI.bat`.
|
||||||
|
- Se preferir depurar, rode `node scripts/servidor.mjs` no terminal.
|
||||||
|
- Log do servidor: `data/server.log`.
|
||||||
|
- Se a sincronizacao de templates GChat falhar com erro de token, gere/salve um token GChat valido novamente.
|
||||||
|
- Se um template WABA falhar no Z-PRO, teste outro template aprovado e confira rota, idioma e variaveis obrigatorias.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 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 serviços externos obrigatórios: tudo roda no computador da GTI hoje
|
||||||
|
(e vai rodar num container Docker no Proxmox da empresa no futuro, sem mudança de
|
||||||
|
código). O primeiro contato via WhatsApp pode ser automático (ver
|
||||||
|
[08-automacao-envio.md](08-automacao-envio.md)); campanhas maiores e o
|
||||||
|
acompanhamento seguem por fora, no 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.
|
||||||
|
- Automação de primeiro contato via WhatsApp (opcional, desligada por padrão):
|
||||||
|
manda uma mensagem com o template do produto recomendado pra cada lead ainda
|
||||||
|
não exportado, registra o que foi mandado e aplica tags no contato do Z-PRO —
|
||||||
|
ver [08-automacao-envio.md](08-automacao-envio.md).
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Automação de envio WhatsApp (Z-PRO)
|
||||||
|
|
||||||
|
Manda automaticamente a primeira mensagem de WhatsApp pro produto/serviço
|
||||||
|
recomendado de cada lead, usando um template por produto, e registra tudo
|
||||||
|
(quem recebeu, qual produto, quando) pra nunca mandar a mesma coisa duas
|
||||||
|
vezes pro mesmo lead.
|
||||||
|
|
||||||
|
## Como funciona
|
||||||
|
|
||||||
|
- Roda **sozinha, dentro do próprio servidor**, a cada 20 minutos, enquanto
|
||||||
|
o app estiver ligado — não depende de Agendador de Tarefas do Windows nem
|
||||||
|
de cron. Isso é de propósito: o projeto hoje roda local, mas vai ser
|
||||||
|
migrado pra um container Docker no Proxmox da empresa, e um agendador do
|
||||||
|
sistema operacional teria que ser reconfigurado nesse dia. O timer interno
|
||||||
|
funciona idêntico nos dois ambientes.
|
||||||
|
- Só considera leads **ainda não exportados** (`exported_at` vazio). Assim
|
||||||
|
que um lead vira oportunidade manual no Z-PRO (exportação normal da tela
|
||||||
|
de Tratamento), a automação para de mandar mensagem pra ele — quem assumiu
|
||||||
|
o lead foi um vendedor, não o robô.
|
||||||
|
- Cada lead tem uma lista de produtos recomendados por nicho (ex.: ERP 5⭐,
|
||||||
|
G-Delivery 3⭐). Hoje a automação só tenta o **produto principal** (o de
|
||||||
|
maior estrela), uma única vez por lead. Existe uma segunda etapa pronta
|
||||||
|
(mas desligada) pra tentar o próximo produto da lista se não houver
|
||||||
|
resposta depois de alguns dias — ver `outreach.tentarProximoProduto`
|
||||||
|
abaixo.
|
||||||
|
- Manda uma mensagem por número, com controle nosso de quem já recebeu o
|
||||||
|
quê — não pelo `bulkSendMessageWithVariable` usado na exportação manual
|
||||||
|
em massa, que depende de um CSV com substituição posicional de variável.
|
||||||
|
Cada produto tem um **canal** próprio (ver seção abaixo).
|
||||||
|
- Depois de mandar, aplica duas tags no contato do Z-PRO:
|
||||||
|
`GTI P1`/`GTI P2`/`GTI P3`/`GTI P4` (prioridade do lead) e
|
||||||
|
`GTI Oferta: <produto>` (o que foi ofertado) — pra quem for atender depois
|
||||||
|
já ver esse contexto direto na conversa.
|
||||||
|
|
||||||
|
## Canal oficial x não-oficial
|
||||||
|
|
||||||
|
Cada produto/template tem um seletor de canal em Configurações:
|
||||||
|
|
||||||
|
- **Não-oficial (texto livre)** — usa `bulkIndividual`, funciona numa
|
||||||
|
conexão QR Code/Baileys do Z-PRO. Manda o texto do template com
|
||||||
|
`{empresa}`/`{cidade}`/`{segmento}` substituídos. É o padrão, e o risco
|
||||||
|
aqui é o de sempre com WhatsApp não-oficial: possibilidade de bloqueio do
|
||||||
|
número por comportamento automatizado (por isso o limite diário e o
|
||||||
|
intervalo aleatório entre envios).
|
||||||
|
- **Oficial (Meta, template aprovado)** — usa a API oficial do WhatsApp
|
||||||
|
(Cloud API/WABA) via Z-PRO. Aqui **não dá pra mandar texto livre**: o
|
||||||
|
WhatsApp só permite iniciar uma conversa com um **template pré-aprovado**
|
||||||
|
pelo Meta Business Manager (fora da janela de 24h de uma conversa já
|
||||||
|
iniciada pelo cliente). Por isso o campo vira "Nome do template aprovado"
|
||||||
|
(tem que ser **exatamente** o nome cadastrado no Meta) + "Idioma" (ex.:
|
||||||
|
`pt_BR`).
|
||||||
|
|
||||||
|
**A aprovação do template em si é feita fora deste app** — no Meta
|
||||||
|
Business Manager (ou no painel do Z-PRO, se ele tiver essa tela) — o
|
||||||
|
Z-PRO só expõe o *envio* usando um template que já existe, não a criação.
|
||||||
|
Cadastre e espere aprovar lá antes de configurar o nome aqui.
|
||||||
|
|
||||||
|
Limitação atual: o envio oficial aqui é só nome+idioma, sem variáveis
|
||||||
|
dentro do corpo do template aprovado (a API do Z-PRO tem endpoints
|
||||||
|
`templateBody`/`templateMarketingBody` que parecem aceitar variáveis, mas
|
||||||
|
o formato exato não está documentado na collection — se for necessário,
|
||||||
|
precisa testar contra a instância real antes de automatizar).
|
||||||
|
|
||||||
|
## Configurar pela primeira vez
|
||||||
|
|
||||||
|
1. **Credenciais do Z-PRO**: no painel do Z-PRO, vá em Admin > API e gere um
|
||||||
|
token (Base URL, API ID, Token e WhatsApp ID). Na tela, abra
|
||||||
|
**⚙️ Configurações > Credenciais Z-PRO (WhatsApp)**, preencha os 4 campos
|
||||||
|
e clique em **Testar conexão** (salva e faz uma chamada real e barata pra
|
||||||
|
confirmar que as credenciais estão certas, sem mandar nenhuma mensagem).
|
||||||
|
Sem essas 4 credenciais a automação não manda nada, mesmo ligada.
|
||||||
|
|
||||||
|
Alternativa pra quem prefere não guardar o token no banco local (ex.: em
|
||||||
|
produção no Docker/Proxmox, gerenciando segredo por variável de
|
||||||
|
ambiente): deixe os 4 campos da tela em branco e defina
|
||||||
|
`ZPRO_BASE_URL`/`ZPRO_API_ID`/`ZPRO_BEARER_TOKEN`/`ZPRO_WHATSAPP_ID` no
|
||||||
|
`.env` — a tela vazia cai automaticamente pro `.env`. Se a tela tiver
|
||||||
|
algum valor preenchido, ele tem prioridade sobre o `.env`.
|
||||||
|
2. Na mesma tela, em **Automação de envio (WhatsApp)**: revise o texto de
|
||||||
|
cada template (um por produto — `{empresa}`, `{cidade}` e `{segmento}`
|
||||||
|
são substituídos automaticamente), ajuste o **limite por dia** se
|
||||||
|
quiser, e marque **Ativada**. Salve.
|
||||||
|
|
||||||
|
Antes de ativar, vale mandar um teste manual (próxima seção) pro seu
|
||||||
|
próprio WhatsApp.
|
||||||
|
|
||||||
|
## Configurações (`settings`, chave → o que faz)
|
||||||
|
|
||||||
|
| Chave | Padrão | O que faz |
|
||||||
|
|---|---|---|
|
||||||
|
| `outreach.ativo` | `false` | Interruptor geral. Começa desligado de propósito. |
|
||||||
|
| `outreach.mensagensPorDia` | `10` | Máximo de envios automáticos por dia (soma de todos os produtos). |
|
||||||
|
| `outreach.diasEntreProdutos` | `5` | Dias de espera antes de tentar o próximo produto (só usado se a cascata estiver ligada). |
|
||||||
|
| `outreach.tentarProximoProduto` | `false` | Liga a cascata: quando `false`, só tenta o produto principal uma vez. |
|
||||||
|
| `outreach.horaInicio` / `outreach.horaFim` | `08:00` / `18:00` | Só manda mensagem dentro desse horário. |
|
||||||
|
|
||||||
|
Tudo isso é editável pela tela de Configurações (os dois primeiros) ou
|
||||||
|
diretamente via `PUT /api/settings` (todos).
|
||||||
|
|
||||||
|
## Disparo manual (fora do ciclo automático)
|
||||||
|
|
||||||
|
Pra testar ou forçar um lote sem esperar o próximo ciclo de 20 min:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run outreach
|
||||||
|
```
|
||||||
|
|
||||||
|
Respeita as mesmas regras do ciclo automático (`outreach.ativo`, horário
|
||||||
|
comercial, limite diário) — não manda nada que a tela não mostraria.
|
||||||
|
|
||||||
|
## Enviar pra um número específico
|
||||||
|
|
||||||
|
Em **Configurações > Enviar mensagem manual**: digite o número (com DDI+DDD,
|
||||||
|
ex.: `5511999999999`), escolha o canal (texto livre ou template oficial já
|
||||||
|
aprovado) e clique em Enviar. Não depende de lead cadastrado nem de produto.
|
||||||
|
|
||||||
|
No canal **Não-oficial (texto livre)**, o app usa `createTicket` para criar uma
|
||||||
|
conversa visível no painel do Z-PRO. O ticket usa o canal informado em
|
||||||
|
`WhatsApp ID` e o usuário de `Responsável` nas configurações.
|
||||||
|
|
||||||
|
No canal **Oficial**, o app usa o endpoint de template aprovado (`template`) e
|
||||||
|
também espera um `ticketId` na resposta.
|
||||||
|
|
||||||
|
Toda tentativa fica registrada no mesmo histórico dos envios automáticos
|
||||||
|
(marcado como origem "manual") e **conta pro limite diário**, já que o risco de
|
||||||
|
bloqueio é do número de WhatsApp usado, não de quem disparou a mensagem.
|
||||||
|
|
||||||
|
## Onde fica o histórico
|
||||||
|
|
||||||
|
Toda tentativa de envio (sucesso ou erro) vira uma linha na tabela
|
||||||
|
`outreach_sends` (lead, produto, mensagem exata mandada, status, quando).
|
||||||
|
Um resumo do dia (enviados hoje, por produto, últimos 20 envios) fica
|
||||||
|
disponível em `GET /api/outreach/status`.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
|||||||
|
# Campanhas de Outreach — GTI Comercial
|
||||||
|
|
||||||
|
Data: 2026-08-01
|
||||||
|
Status: aprovado pelo usuário, pendente de plano de implementação
|
||||||
|
|
||||||
|
## Contexto e motivação
|
||||||
|
|
||||||
|
A Máquina Comercial Pessoal tem um subsistema de campanhas de outreach (fila com
|
||||||
|
preview/aprovação, variantes A/B/C, escalonamento semanal de limite diário, pausa
|
||||||
|
automática por saúde, classificação de estágio comercial, agendamento de
|
||||||
|
demonstração) construído sobre o envio direto via Baileys. A GTI Comercial já tem
|
||||||
|
sua própria automação de outreach (`src/outreach/run.mjs` + `scheduler.mjs`),
|
||||||
|
que dispara um lote a cada 20 minutos, respeitando `produto_principal` de cada
|
||||||
|
lead, canal oficial (WABA/template aprovado) ou não-oficial (texto livre via
|
||||||
|
Z-PRO), e grava tudo em `outreach_sends`.
|
||||||
|
|
||||||
|
O pedido: portar a mesma metodologia de campanhas da Pessoal para a Comercial,
|
||||||
|
mas adaptada a duas diferenças reais entre os projetos:
|
||||||
|
|
||||||
|
1. **Meio de conexão**: a Comercial não fala com o WhatsApp diretamente (Baileys);
|
||||||
|
ela passa pelo Z-PRO (`src/outreach/zpro-client.mjs`), que oferece dois canais
|
||||||
|
distintos — não-oficial (`sendText`/`bulkIndividual`, texto livre) e oficial
|
||||||
|
(`sendTemplateWaba`, só templates pré-aprovados no Meta Business Manager, sem
|
||||||
|
texto livre nem variáveis além das que o próprio template já define).
|
||||||
|
2. **Catálogo de negócio**: a Pessoal tem 2-3 campanhas fixas, hardcoded em
|
||||||
|
`campaign-catalog.mjs`, cada uma amarrada a um segmento específico. A
|
||||||
|
Comercial tem 57 segmentos e 4 produtos (G-Chat, Sistema de Ponto, ERP,
|
||||||
|
G-Delivery) — catálogo fixo não cabe nessa escala. Aqui, campanha precisa ser
|
||||||
|
um registro que o operador cria pela tela (produto + segmentos + textos),
|
||||||
|
não código.
|
||||||
|
|
||||||
|
Decisões já validadas com o usuário:
|
||||||
|
- Campanhas rodam **ao lado** do `runOutreachBatch` existente, sem substituí-lo.
|
||||||
|
- Variantes A/B/C só funcionam no canal **não-oficial** (o oficial não aceita
|
||||||
|
texto livre, então não há o que variar).
|
||||||
|
- Pacote completo: escalonamento semanal, fila/preview/aprovação, variantes com
|
||||||
|
eleição de vencedora, pausa automática por saúde, classificação de estágio
|
||||||
|
comercial, agendamento de demonstração.
|
||||||
|
- Classificação de estágio e registro de resposta são **manuais** por enquanto —
|
||||||
|
não existe endpoint de webhook de resposta do Z-PRO implementado nesta base.
|
||||||
|
|
||||||
|
## Arquitetura
|
||||||
|
|
||||||
|
Novo diretório `src/outreach/campaign-*.mjs`, paralelo a `run.mjs`/`scheduler.mjs`
|
||||||
|
— nenhum dos dois é modificado. Único ponto de integração: `outreach_sends`
|
||||||
|
ganha um novo valor de `origem` (`'campanha'`, além dos já existentes
|
||||||
|
`'automatico'`/`'manual'`) e a seleção de candidatos de campanha exclui leads
|
||||||
|
recentemente contatados para o mesmo produto **por qualquer origem**, evitando
|
||||||
|
contato duplicado entre a automação existente e as campanhas novas.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/outreach/
|
||||||
|
run.mjs (existente, intocado)
|
||||||
|
scheduler.mjs (existente, intocado)
|
||||||
|
zpro-client.mjs (existente, intocado — reusado)
|
||||||
|
templates.mjs (existente, intocado — renderTemplate reusado)
|
||||||
|
campaign-catalog.mjs (novo — CRUD de outreach_campaigns/variants)
|
||||||
|
campaign-queue.mjs (novo — seleção de candidatos, preview, aprovação)
|
||||||
|
campaign-worker.mjs (novo — envio das linhas aprovadas)
|
||||||
|
campaign-metrics.mjs (novo — saúde, auto-pausa, comparação de variantes)
|
||||||
|
campaign-scheduler.mjs (novo — timer próprio, chama campaign-worker)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modelo de dados
|
||||||
|
|
||||||
|
Migração nova em `src/db.mjs` (mesmo padrão das migrações v5-v7 já existentes:
|
||||||
|
`ALTER TABLE`/`CREATE TABLE IF NOT EXISTS` guardado por versão do schema).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE outreach_campaigns (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
nome TEXT NOT NULL,
|
||||||
|
produto TEXT NOT NULL,
|
||||||
|
segmentos_json TEXT NOT NULL, -- ["Oficina", "Restaurante", ...]
|
||||||
|
status TEXT NOT NULL DEFAULT 'rascunho', -- rascunho|rodando|pausado|concluido
|
||||||
|
pause_reason TEXT,
|
||||||
|
limite_diario_atual INTEGER NOT NULL DEFAULT 10,
|
||||||
|
semana_iniciada_em TEXT, -- controla quando escalar o limite
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_campaign_variants (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
|
||||||
|
code TEXT NOT NULL, -- A|B|C
|
||||||
|
mensagem TEXT NOT NULL,
|
||||||
|
ativo INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_campaign_queue (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||||
|
variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id),
|
||||||
|
segmento TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'preview', -- preview|approved|cancelled|reserved|sent|unknown|error
|
||||||
|
outreach_send_id INTEGER REFERENCES outreach_sends(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_lead_states (
|
||||||
|
lead_id INTEGER PRIMARY KEY REFERENCES leads(id),
|
||||||
|
stage TEXT NOT NULL, -- interessado|sem_interesse|opt_out|venda_fechada|...
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_stage_events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||||
|
campaign_id INTEGER REFERENCES outreach_campaigns(id),
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_appointments (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||||
|
kind TEXT NOT NULL, -- demonstracao|teste_g_delivery|...
|
||||||
|
scheduled_at TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_opt_out (
|
||||||
|
numero TEXT PRIMARY KEY,
|
||||||
|
motivo TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE outreach_sends ADD COLUMN campaign_queue_id INTEGER REFERENCES outreach_campaign_queue(id);
|
||||||
|
```
|
||||||
|
|
||||||
|
`outreach_opt_out` não existe hoje na Comercial (a Pessoal tem um equivalente
|
||||||
|
mais complexo por causa da equivalência de números 8/9 dígitos). Aqui basta uma
|
||||||
|
tabela simples por `numero` já normalizado (`onlyDigits`), sem a complexidade de
|
||||||
|
variantes de número da Pessoal — a Comercial já normaliza números na validação
|
||||||
|
(`validatePhone`/`onlyDigits`). Está incluída porque uma fila que recontata o
|
||||||
|
mesmo lead ao longo de semanas sem checar opt-out é um risco real de spam/banimento,
|
||||||
|
não porque foi pedida explicitamente — sinalizando aqui para confirmação.
|
||||||
|
|
||||||
|
## Fluxo
|
||||||
|
|
||||||
|
1. **Criar campanha**: operador escolhe produto, segmentos (lista vindo de
|
||||||
|
`SELECT DISTINCT segmento, COUNT(*) FROM leads WHERE produto_principal = ?
|
||||||
|
GROUP BY segmento` — mesmo padrão de facet já usado em outros lugares do
|
||||||
|
projeto), escreve 1-3 variantes de mensagem (texto livre, mesmas variáveis
|
||||||
|
de `templates.mjs`: `{empresa}`, `{produto}`, `{cidade}`, `{segmento}`),
|
||||||
|
define limite diário inicial.
|
||||||
|
2. **Gerar fila**: candidatos = leads do produto/segmentos escolhidos, com
|
||||||
|
`telefone_valido=1`, sem envio recente (`outreach_sends`, qualquer origem)
|
||||||
|
para o mesmo produto, sem opt-out. Distribuição round-robin entre variantes
|
||||||
|
ativas. Linhas entram como `preview` — nada é enviado.
|
||||||
|
3. **Revisar e aprovar**: tela lista a fila em preview (empresa, telefone,
|
||||||
|
variante, mensagem já renderizada); operador aprova em lote ou individualmente
|
||||||
|
→ status `approved`. Só linhas aprovadas entram no worker.
|
||||||
|
4. **Envio**: `campaign-worker.mjs`, rodando no `campaign-scheduler.mjs` (timer
|
||||||
|
próprio, mesmo padrão do `scheduler.mjs` existente — `setInterval` com
|
||||||
|
`.unref()`), pega linhas `approved` respeitando `limite_diario_atual` da
|
||||||
|
campanha, revalida o lead na hora (ainda não opt-out, ainda no segmento),
|
||||||
|
envia via `zproClient.sendText`, grava em `outreach_sends`
|
||||||
|
(`origem='campanha'`, `campaign_queue_id` preenchido), atualiza a linha da
|
||||||
|
fila para `sent`/`error`.
|
||||||
|
5. **Escalonamento semanal**: a cada 7 dias corridos sem pausa por saúde desde
|
||||||
|
`semana_iniciada_em`, `limite_diario_atual` dobra (10→20→40...), até um teto
|
||||||
|
configurável.
|
||||||
|
6. **Saúde/auto-pausa** (`campaign-metrics.mjs`, mesmos 3 critérios da Pessoal,
|
||||||
|
adaptados às tabelas daqui):
|
||||||
|
- taxa de erro de envio > 10% com pelo menos 10 tentativas no dia → pausa
|
||||||
|
`high_failure_rate`
|
||||||
|
- taxa de estágio negativo/opt-out > 10% com pelo menos 20 contatos → pausa
|
||||||
|
`high_negative_rate`
|
||||||
|
- taxa de resposta < 5% com pelo menos 50 contatos → pausa `low_reply_rate`
|
||||||
|
(mesmos limiares numéricos já usados em `campaign-metrics.mjs` da Pessoal)
|
||||||
|
Pausa muda `status` para `pausado`; o worker para de pegar novas linhas
|
||||||
|
dessa campanha até o operador retomar manualmente.
|
||||||
|
7. **Variantes e eleição de vencedora**: após um mínimo de 40 envios por
|
||||||
|
variante, a tela mostra taxa de resposta por variante e sinaliza a de melhor
|
||||||
|
desempenho — indicativo, não desliga variantes sozinho.
|
||||||
|
8. **Classificação de estágio e agendamento**: 100% manual por enquanto —
|
||||||
|
operador marca o estágio do lead pela tela (gera `outreach_stage_events` +
|
||||||
|
atualiza `outreach_lead_states`) e cria agendamentos (`outreach_appointments`)
|
||||||
|
manualmente. Sem webhook de resposta do Z-PRO nesta fase.
|
||||||
|
|
||||||
|
## Prevenção de contato duplicado com a automação existente
|
||||||
|
|
||||||
|
`runOutreachBatch` (`run.mjs`) já verifica `lastEnviado` antes de mandar. A
|
||||||
|
seleção de candidatos de campanha faz a checagem simétrica: exclui qualquer
|
||||||
|
lead com `outreach_sends` para o mesmo produto nos últimos N dias, **indepen-
|
||||||
|
dente de `origem`**. Isso garante que uma campanha nunca contata quem o lote
|
||||||
|
automático já contatou recentemente para aquele produto, e vice-versa — os
|
||||||
|
dois sistemas competem pelo mesmo "direito de contato" por produto, não
|
||||||
|
disparam em paralelo para o mesmo lead.
|
||||||
|
|
||||||
|
## Fora de escopo (explicitamente adiado)
|
||||||
|
|
||||||
|
- Webhook de resposta do Z-PRO / classificação automática de estágio.
|
||||||
|
- Envio de variantes pelo canal oficial (impossível hoje — WABA não aceita
|
||||||
|
texto livre).
|
||||||
|
- Equivalência de números 8/9 dígitos ou detecção de número compartilhado
|
||||||
|
(não existe na Comercial hoje e não foi pedido).
|
||||||
|
|
||||||
|
## Testes
|
||||||
|
|
||||||
|
Mesma disciplina da Pessoal: um arquivo de teste por módulo novo
|
||||||
|
(`campaign-catalog`, `campaign-queue`, `campaign-worker`, `campaign-metrics`),
|
||||||
|
cobrindo pelo menos: geração de fila respeita segmentos/produto/exclusões,
|
||||||
|
aprovação só libera linhas aprovadas para o worker, worker respeita limite
|
||||||
|
diário e revalida antes de enviar, auto-pausa dispara nos 3 critérios e
|
||||||
|
bloqueia novas linhas, escalonamento semanal avança e não avança durante
|
||||||
|
pausa, prevenção de contato duplicado entre `origem='automatico'` e
|
||||||
|
`origem='campanha'`.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Generated
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "gti-maquina-comercial",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "gti-maquina-comercial",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"outreach": "node scripts/enviar-outreach.mjs",
|
||||||
|
"build:launcher": "powershell -File scripts/build-launcher.ps1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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 extrair a base de uma UF a partir dos zips brutos da Receita
|
||||||
|
(gera o CSV `estabelecimentos-<UF>.csv` usado pela captura):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run extract -- --uf=BA
|
||||||
|
```
|
||||||
|
|
||||||
|
Comando para capturar leads usando a base já extraída:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run capture -- --sources=cnpj-base --uf=BA --cities="Ilheus"
|
||||||
|
```
|
||||||
|
|
||||||
|
Com enriquecimento pela BrasilAPI, inclua a fonte `brasilapi`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run capture -- --sources=cnpj-base,brasilapi --uf=BA --cities="Ilheus"
|
||||||
|
```
|
||||||
|
|
||||||
|
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,710 @@
|
|||||||
|
/* GTI Maquina Comercial - interface principal */
|
||||||
|
:root {
|
||||||
|
--page: #f5f7fb;
|
||||||
|
--page-2: #eef2f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f9fafc;
|
||||||
|
--ink: #162033;
|
||||||
|
--ink-2: #4c5870;
|
||||||
|
--muted: #7b8495;
|
||||||
|
--grid: #dce2ec;
|
||||||
|
--border: rgba(22, 32, 51, 0.11);
|
||||||
|
--accent: #f06a25;
|
||||||
|
--accent-2: #1f6feb;
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
--bar: #f06a25;
|
||||||
|
--good: #10a66a;
|
||||||
|
--good-text: #057046;
|
||||||
|
--warning: #d99a1f;
|
||||||
|
--critical: #d63d4a;
|
||||||
|
--critical-bg: #fff1f2;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-lg: 24px;
|
||||||
|
--shadow: 0 14px 40px rgba(22, 32, 51, 0.08);
|
||||||
|
--shadow-soft: 0 8px 24px rgba(22, 32, 51, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--page: #0d1118;
|
||||||
|
--page-2: #111827;
|
||||||
|
--surface: #171c27;
|
||||||
|
--surface-2: #1d2430;
|
||||||
|
--ink: #f5f7fb;
|
||||||
|
--ink-2: #c8d0dd;
|
||||||
|
--muted: #8e98aa;
|
||||||
|
--grid: #2c3443;
|
||||||
|
--border: rgba(255, 255, 255, 0.11);
|
||||||
|
--accent: #ff7a33;
|
||||||
|
--accent-2: #60a5fa;
|
||||||
|
--bar: #ff7a33;
|
||||||
|
--good-text: #55d99c;
|
||||||
|
--critical-bg: rgba(214, 61, 74, 0.12);
|
||||||
|
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
|
||||||
|
--shadow-soft: 0 10px 28px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; min-height: 100%; }
|
||||||
|
body {
|
||||||
|
color: var(--ink);
|
||||||
|
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 34rem),
|
||||||
|
radial-gradient(circle at 86% -6%, color-mix(in srgb, var(--accent-2) 12%, transparent), transparent 30rem),
|
||||||
|
linear-gradient(180deg, var(--page), var(--page-2));
|
||||||
|
}
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
button { color: inherit; }
|
||||||
|
|
||||||
|
/* topo */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(220px, 1fr) auto auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 22px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 30px rgba(22, 32, 51, 0.04);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; min-width: max-content; }
|
||||||
|
.brand-mark { height: 32px; width: auto; display: block; border-radius: 8px; }
|
||||||
|
.brand-mark-lg { height: 62px; }
|
||||||
|
.brand-name { font-weight: 800; letter-spacing: -0.02em; color: var(--ink); }
|
||||||
|
.tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 750;
|
||||||
|
padding: 8px 14px;
|
||||||
|
transition: 0.18s ease;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--ink); background: color-mix(in srgb, var(--accent) 9%, transparent); }
|
||||||
|
.tab.active { background: var(--ink); color: var(--page); box-shadow: var(--shadow-soft); }
|
||||||
|
|
||||||
|
main { max-width: 1320px; margin: 0 auto; padding: 24px 22px 70px; }
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* layout */
|
||||||
|
.captura-wrap { max-width: 820px; margin: 0 auto; }
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 14px; font-size: 18px; letter-spacing: -0.02em; }
|
||||||
|
.grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
|
||||||
|
/* formularios */
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field > label:first-child,
|
||||||
|
.field > summary,
|
||||||
|
label.settings-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.row.space-between { justify-content: space-between; }
|
||||||
|
.row.wrap { flex-wrap: wrap; }
|
||||||
|
label { color: var(--ink-2); }
|
||||||
|
label > input,
|
||||||
|
label > select,
|
||||||
|
label > textarea { display: block; margin-top: 5px; }
|
||||||
|
.radio {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 4px 0;
|
||||||
|
color: var(--ink-2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
input[type="radio"], input[type="checkbox"] { accent-color: var(--accent); }
|
||||||
|
select, input, textarea {
|
||||||
|
width: auto;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--grid);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||||
|
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
textarea { width: 100%; resize: vertical; min-height: 84px; }
|
||||||
|
select:hover, input:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--grid)); }
|
||||||
|
select:focus, input:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
input[type="number"] { width: 96px; }
|
||||||
|
details summary { cursor: pointer; }
|
||||||
|
|
||||||
|
/* botoes */
|
||||||
|
.btn, .btn-ghost, .btn-danger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 9px 15px;
|
||||||
|
border: 1px solid var(--grid);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: 0.18s ease;
|
||||||
|
}
|
||||||
|
.btn:hover, .btn-ghost:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--shadow-soft); }
|
||||||
|
.btn-primary { background: linear-gradient(135deg, var(--accent), #ff8b47); border-color: transparent; color: var(--accent-ink); }
|
||||||
|
.btn-primary:hover { filter: saturate(1.08); }
|
||||||
|
.btn-lg { width: 100%; min-height: 48px; font-size: 15px; }
|
||||||
|
.btn-sm { min-height: 32px; padding: 6px 10px; font-size: 12.5px; }
|
||||||
|
.btn-ghost { border-color: transparent; background: transparent; color: var(--ink-2); }
|
||||||
|
.btn-danger { color: var(--critical); border-color: color-mix(in srgb, var(--critical) 45%, var(--grid)); background: color-mix(in srgb, var(--critical) 6%, transparent); }
|
||||||
|
.btn-danger:hover { background: var(--critical); color: #fff; border-color: var(--critical); }
|
||||||
|
button:disabled { opacity: 0.55; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
|
||||||
|
|
||||||
|
/* configuracoes */
|
||||||
|
#modal-settings { max-width: 900px; }
|
||||||
|
#modal-settings .modal-body { max-height: min(86vh, 900px); overflow-y: auto; }
|
||||||
|
.settings-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.settings-header h2 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.03em; }
|
||||||
|
.settings-header p { margin: 0; }
|
||||||
|
#outreach-status { cursor: default; white-space: nowrap; }
|
||||||
|
.settings-card,
|
||||||
|
.settings-section {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.34) inset;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.settings-card { padding: 16px; }
|
||||||
|
.settings-principal {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||||
|
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--surface)), var(--surface-2));
|
||||||
|
}
|
||||||
|
.settings-controls { display: flex; gap: 12px; align-items: end; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--grid);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 13px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.settings-section { padding: 0 16px 16px; }
|
||||||
|
.settings-section > summary {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
padding: 15px 30px 15px 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.settings-section > summary::-webkit-details-marker { display: none; }
|
||||||
|
.settings-section > summary::after {
|
||||||
|
content: "+";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 13px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--grid);
|
||||||
|
}
|
||||||
|
.settings-section[open] > summary::after { content: "-"; }
|
||||||
|
.settings-section > .muted { margin-top: -4px; }
|
||||||
|
.settings-section .lista { margin-top: 10px; }
|
||||||
|
.settings-section input[type="text"],
|
||||||
|
.settings-section input[type="password"],
|
||||||
|
.settings-section textarea,
|
||||||
|
.settings-section select { min-width: 220px; }
|
||||||
|
|
||||||
|
.manual-send-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.manual-send-card .full { grid-column: 1 / -1; }
|
||||||
|
.manual-channel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.manual-channel .radio {
|
||||||
|
border: 1px solid var(--grid);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
#manual-oficial-campos {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
#manual-oficial-campos.hidden { display: none !important; }
|
||||||
|
#manual-template-nome { min-width: 0; width: 100%; }
|
||||||
|
.template-vars {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--grid);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.template-vars > label { color: var(--ink); font-weight: 800; }
|
||||||
|
.template-vars p { margin: 2px 0 10px; }
|
||||||
|
.variable-helper {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.variable-helper select, .variable-picker { min-width: min(100%, 320px); }
|
||||||
|
.template-vars-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.template-vars-grid label:last-child { grid-column: 1 / -1; }
|
||||||
|
.template-preview {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-2);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.manual-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
#manual-status:not(:empty) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
#manual-status:not(:empty)::first-letter { font-weight: 900; }
|
||||||
|
#manual-status:not(:empty) { color: var(--ink-2); }
|
||||||
|
#manual-status.status-ok { color: var(--good-text); background: color-mix(in srgb, var(--good) 10%, var(--surface)); border-color: color-mix(in srgb, var(--good) 28%, var(--border)); }
|
||||||
|
#manual-status.status-error { color: var(--critical); background: var(--critical-bg); border-color: color-mix(in srgb, var(--critical) 30%, var(--border)); }
|
||||||
|
#btn-toggle-outreach-log { justify-self: start; margin-top: 2px; }
|
||||||
|
|
||||||
|
.outreach-template { flex-direction: column; align-items: stretch; gap: 8px; }
|
||||||
|
.outreach-template .row.space-between { align-items: center; }
|
||||||
|
.outreach-template textarea { width: 100%; margin-top: 2px; }
|
||||||
|
.outreach-template-oficial { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.outreach-template-vars { margin-top: 2px; }
|
||||||
|
.outreach-template-vars .template-vars-grid { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
|
||||||
|
.outreach-template-vars .template-vars-grid label:last-child { grid-column: auto; }
|
||||||
|
.outreach-template-link { color: var(--accent-2); font-size: 12.5px; font-weight: 750; text-decoration: none; }
|
||||||
|
.outreach-template-link:hover { text-decoration: underline; }
|
||||||
|
.outreach-template-aviso { color: var(--critical); font-size: 12.5px; margin: 0; }
|
||||||
|
#outreach-limite { width: 82px; }
|
||||||
|
|
||||||
|
.zona-risco { border-color: color-mix(in srgb, var(--critical) 45%, var(--border)); background: color-mix(in srgb, var(--critical) 5%, var(--surface)); }
|
||||||
|
.zona-risco summary { color: var(--critical); }
|
||||||
|
.template-sync-box {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed var(--grid);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.45fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.template-sync-box .row { grid-column: 1 / -1; }
|
||||||
|
.template-sync-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* login */
|
||||||
|
.login-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 12%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 28rem),
|
||||||
|
radial-gradient(circle at 82% 4%, color-mix(in srgb, var(--accent-2) 18%, transparent), transparent 26rem),
|
||||||
|
linear-gradient(135deg, var(--page), var(--page-2));
|
||||||
|
}
|
||||||
|
.login-wrap { margin: auto; width: 100%; max-width: 410px; padding: 22px; }
|
||||||
|
.login-card { display: flex; flex-direction: column; gap: 14px; padding: 28px; }
|
||||||
|
.login-brand { justify-content: center; margin-bottom: 6px; }
|
||||||
|
.login-erro { color: var(--critical); min-height: 1em; margin: 0; }
|
||||||
|
.erro { color: var(--critical); margin: 4px 0 0; font-weight: 750; }
|
||||||
|
.login-card .field input { width: 100%; }
|
||||||
|
|
||||||
|
/* chips, avisos e listas */
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.chip.ok { background: color-mix(in srgb, var(--good) 12%, var(--surface)); color: var(--good-text); border-color: color-mix(in srgb, var(--good) 26%, var(--border)); }
|
||||||
|
.chip.warn { background: color-mix(in srgb, var(--warning) 14%, var(--surface)); color: var(--ink); border-color: color-mix(in srgb, var(--warning) 32%, var(--border)); }
|
||||||
|
.notice {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--warning) 42%, var(--border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--warning) 10%, var(--surface));
|
||||||
|
}
|
||||||
|
.notice p { margin: 0 0 10px; }
|
||||||
|
.lista { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.lista .item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.lista .item .sub { color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
|
/* pickers */
|
||||||
|
.picker { margin-top: 10px; }
|
||||||
|
.picker input[type="search"], .cnae-avulso input[type="search"] { width: 100%; margin-bottom: 8px; }
|
||||||
|
.sugestoes { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto; }
|
||||||
|
.sugestoes button {
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.sugestoes button:hover { background: var(--surface-2); }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 8px 0 12px; }
|
||||||
|
.chips .chip { cursor: pointer; }
|
||||||
|
.chips .chip::after { content: " x"; color: var(--muted); }
|
||||||
|
.chips .chip:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); }
|
||||||
|
.segmento-lista { display: flex; flex-wrap: wrap; gap: 7px; margin: 8px 0 12px; }
|
||||||
|
.segmento-opcao {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nichos-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: 4px 12px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.nichos-grid label { display: flex; gap: 7px; align-items: center; padding: 6px 7px; border-radius: 10px; cursor: pointer; }
|
||||||
|
.nichos-grid label:hover { background: var(--surface-2); }
|
||||||
|
.cnae-avulso { margin-top: 14px; border-top: 1px dashed var(--grid); padding-top: 12px; }
|
||||||
|
.cnae-avulso summary { color: var(--ink-2); font-size: 13px; font-weight: 800; }
|
||||||
|
.cnae-avulso-hint { margin: 7px 0 9px; font-size: 12.5px; }
|
||||||
|
.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: 8px; }
|
||||||
|
.fonte {
|
||||||
|
display: flex;
|
||||||
|
gap: 11px;
|
||||||
|
align-items: flex-start;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fonte:hover { border-color: var(--accent); box-shadow: var(--shadow-soft); }
|
||||||
|
.fonte input { margin-top: 3px; }
|
||||||
|
.fonte .desc { color: var(--ink-2); font-size: 12.5px; }
|
||||||
|
.fonte.indisponivel { opacity: 0.58; }
|
||||||
|
|
||||||
|
/* progresso */
|
||||||
|
.progress-bar { height: 10px; border-radius: 999px; background: var(--grid); overflow: hidden; margin: 12px 0 9px; }
|
||||||
|
.progress-fill { height: 100%; width: 3%; background: linear-gradient(90deg, var(--accent), #ffb36d); border-radius: 999px; transition: width 0.4s; }
|
||||||
|
.progress-msg { color: var(--ink-2); margin: 4px 0 11px; min-height: 1.4em; }
|
||||||
|
.stat-row { display: flex; gap: 18px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.stat-row .mini { display: flex; flex-direction: column; min-width: 96px; }
|
||||||
|
.stat-row .mini b { font-size: 23px; letter-spacing: -0.03em; }
|
||||||
|
.stat-row .mini span { color: var(--muted); font-size: 12px; }
|
||||||
|
.log {
|
||||||
|
max-height: 170px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #0f172a;
|
||||||
|
color: #dbeafe;
|
||||||
|
font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tratamento */
|
||||||
|
.filtros-card { padding: 14px; }
|
||||||
|
.filtros { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 9px; align-items: center; }
|
||||||
|
.filtros select, .filtros input { width: 100%; min-width: 0; }
|
||||||
|
#f-busca { grid-column: span 2; }
|
||||||
|
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 18px; }
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
.tile::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: auto -20px -28px auto;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.tile .valor { font-size: 29px; font-weight: 900; letter-spacing: -0.04em; }
|
||||||
|
.tile .rotulo { color: var(--ink-2); font-size: 12.5px; font-weight: 750; margin-top: 2px; }
|
||||||
|
.tile .detalhe { color: var(--muted); font-size: 12px; }
|
||||||
|
.tile.t-good .valor { color: var(--good-text); }
|
||||||
|
.tile.tile-wide { grid-column: span 2; }
|
||||||
|
.meta-input { width: 78px; text-align: right; padding: 5px 7px; }
|
||||||
|
|
||||||
|
.ranking, .funil { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
|
||||||
|
.rank-row, .funil-etapa {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px 1fr 70px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.funil-etapa { grid-template-columns: 190px 1fr 70px; }
|
||||||
|
.rank-row { cursor: pointer; }
|
||||||
|
.rank-row:hover, .oportunidade-clicavel:hover { background: var(--surface-2); }
|
||||||
|
.rank-row.ativo, .oportunidade-clicavel.ativo { background: color-mix(in srgb, var(--accent) 13%, var(--surface)); font-weight: 800; }
|
||||||
|
.rank-row.nao-clicavel { cursor: default; }
|
||||||
|
.rank-row.nao-clicavel:hover { background: transparent; }
|
||||||
|
.funil-etapa.pendente { opacity: 0.43; }
|
||||||
|
.rank-label, .funil-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
||||||
|
.rank-track, .funil-barra { height: 12px; overflow: hidden; border-radius: 999px; background: var(--grid); }
|
||||||
|
.rank-fill, .funil-fill { height: 100%; min-width: 3px; border-radius: 999px; background: linear-gradient(90deg, var(--bar), #ffb36d); }
|
||||||
|
.rank-value, .funil-valor { color: var(--ink-2); text-align: right; font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
||||||
|
.oportunidade-clicavel { cursor: pointer; border-radius: 12px; padding: 8px 10px; }
|
||||||
|
|
||||||
|
/* tabela */
|
||||||
|
.table-wrap { overflow-x: auto; margin: 0 -6px; }
|
||||||
|
table { width: 100%; border-collapse: separate; border-spacing: 0; }
|
||||||
|
th, td { text-align: left; white-space: nowrap; padding: 10px 12px; border-bottom: 1px solid var(--grid); }
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.acao-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
width: 58px;
|
||||||
|
min-width: 58px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 10px 0 18px -18px rgba(22, 32, 51, 0.8);
|
||||||
|
}
|
||||||
|
td.acao-col { padding-left: 8px; padding-right: 8px; }
|
||||||
|
tbody tr { cursor: pointer; }
|
||||||
|
tbody tr:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||||
|
tbody tr:hover .acao-col { background: color-mix(in srgb, var(--accent) 6%, var(--surface)); }
|
||||||
|
.badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 850; white-space: nowrap; }
|
||||||
|
.badge.excelente { background: color-mix(in srgb, var(--good) 18%, 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) 20%, transparent); color: var(--ink); }
|
||||||
|
.badge.baixo { background: color-mix(in srgb, var(--warning) 16%, var(--critical) 10%); color: var(--critical); }
|
||||||
|
.badge.muito_baixo { background: color-mix(in srgb, var(--critical) 14%, transparent); color: var(--critical); }
|
||||||
|
.badge.prioridade-1 { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good-text); }
|
||||||
|
.badge.prioridade-2 { background: color-mix(in srgb, var(--warning) 20%, 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; }
|
||||||
|
.empresa-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.whatsapp-action {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--good) 35%, var(--grid));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--good) 12%, var(--surface));
|
||||||
|
color: var(--good-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.whatsapp-action:hover { background: var(--good); color: #fff; }
|
||||||
|
.pontos-fortes, .score-breakdown { list-style: none; margin: 7px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
||||||
|
.resumo-comercial, .score-breakdown-wrap { border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 14px; margin-top: 12px; background: var(--surface-2); }
|
||||||
|
.resumo-comercial h3 { margin: 0 0 8px; font-size: 14px; }
|
||||||
|
.score-breakdown li.nok { color: var(--muted); }
|
||||||
|
|
||||||
|
/* usuarios */
|
||||||
|
#lista-usuarios { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
|
||||||
|
#lista-usuarios > div { padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-2); }
|
||||||
|
|
||||||
|
/* modais e toast */
|
||||||
|
dialog {
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
dialog::backdrop { background: rgba(8, 13, 22, 0.58); backdrop-filter: blur(4px); }
|
||||||
|
.modal-body { padding: 24px; }
|
||||||
|
.modal-body h2 { margin-top: 0; letter-spacing: -0.03em; }
|
||||||
|
#modal-historico { max-width: 680px; }
|
||||||
|
#modal-historico .lista { max-height: 60vh; overflow-y: auto; margin-top: 10px; }
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 100;
|
||||||
|
max-width: min(86vw, 680px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--page);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.muted { color: var(--muted); font-weight: 400; }
|
||||||
|
.detalhe-grid { display: grid; grid-template-columns: 150px 1fr; gap: 6px 14px; font-size: 13.5px; }
|
||||||
|
.detalhe-grid dt { color: var(--muted); }
|
||||||
|
.detalhe-grid dd { margin: 0; word-break: break-word; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.grid-3 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.topbar { grid-template-columns: 1fr auto auto auto; }
|
||||||
|
.tabs { grid-column: 1 / -1; order: 2; width: 100%; justify-content: center; }
|
||||||
|
#usuario-logado { justify-self: end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
main { padding: 16px 12px 50px; }
|
||||||
|
.topbar { padding: 10px 12px; grid-template-columns: 1fr auto auto; gap: 8px; }
|
||||||
|
.brand-name { display: none; }
|
||||||
|
#usuario-logado { display: none; }
|
||||||
|
.tabs { overflow-x: auto; justify-content: flex-start; }
|
||||||
|
.tab { white-space: nowrap; }
|
||||||
|
.grid-3 { grid-template-columns: 1fr; }
|
||||||
|
.card { border-radius: var(--radius); padding: 16px; }
|
||||||
|
.settings-header, .settings-principal { grid-template-columns: 1fr; }
|
||||||
|
.settings-controls { justify-content: flex-start; }
|
||||||
|
.manual-send-card, #manual-oficial-campos { grid-template-columns: 1fr; }
|
||||||
|
.settings-section input[type="text"],
|
||||||
|
.settings-section input[type="password"],
|
||||||
|
.settings-section textarea,
|
||||||
|
.settings-section select,
|
||||||
|
.manual-send-card input,
|
||||||
|
.manual-send-card select { width: 100%; min-width: 0; }
|
||||||
|
.manual-actions { align-items: stretch; flex-direction: column; }
|
||||||
|
.manual-actions .btn { width: 100%; }
|
||||||
|
#f-busca, .tile.tile-wide { grid-column: span 1; }
|
||||||
|
.rank-row, .funil-etapa { grid-template-columns: 1fr 90px; }
|
||||||
|
.rank-track, .funil-barra { grid-column: 1 / -1; order: 3; }
|
||||||
|
th { top: 107px; }
|
||||||
|
.modal-body { padding: 18px; }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,566 @@
|
|||||||
|
<!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="/img/logo-gti.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="brand-mark" src="/img/logo-gti.png" alt="GTI">
|
||||||
|
<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>
|
||||||
|
<button class="tab" data-tab="campanhas" role="tab" aria-selected="false">🎯 Campanhas</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 class="acao-col">Ação</th><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>
|
||||||
|
|
||||||
|
<!-- ==================== CAMPANHAS ==================== -->
|
||||||
|
<section id="tab-campanhas" class="tab-panel">
|
||||||
|
<div class="row space-between">
|
||||||
|
<h2>Campanhas de outreach</h2>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-nova-campanha" type="button">+ Nova campanha</button>
|
||||||
|
</div>
|
||||||
|
<div id="campanhas-lista" class="lista"></div>
|
||||||
|
|
||||||
|
<div id="campanha-detalhe" class="card hidden">
|
||||||
|
<div class="row space-between">
|
||||||
|
<h2 id="detalhe-nome"></h2>
|
||||||
|
<div class="row">
|
||||||
|
<span class="chip" id="detalhe-status"></span>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-fechar-detalhe" type="button">Fechar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" id="detalhe-acoes">
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-iniciar-campanha" type="button">Iniciar</button>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-pausar-campanha" type="button">Pausar</button>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-retomar-campanha" type="button">Retomar</button>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-gerar-fila" type="button">Gerar fila</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted" id="detalhe-saude"></p>
|
||||||
|
<div id="detalhe-variantes" class="row"></div>
|
||||||
|
|
||||||
|
<div class="row space-between">
|
||||||
|
<h3>Fila</h3>
|
||||||
|
<select id="fila-filtro-status">
|
||||||
|
<option value="preview">Preview</option>
|
||||||
|
<option value="approved">Aprovadas</option>
|
||||||
|
<option value="sent">Enviadas</option>
|
||||||
|
<option value="error">Erro</option>
|
||||||
|
<option value="cancelled">Canceladas</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-aprovar-selecionados" type="button">Aprovar selecionados</button>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-cancelar-selecionados" type="button">Cancelar selecionados</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th></th><th>Lead ID</th><th>Numero</th><th>Mensagem</th><th>Status</th><th>Estagio</th></tr></thead>
|
||||||
|
<tbody id="fila-tabela"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="modal-nova-campanha">
|
||||||
|
<form id="form-nova-campanha" method="dialog">
|
||||||
|
<h2>Nova campanha</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label for="campanha-nome">Nome</label>
|
||||||
|
<input type="text" id="campanha-nome" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="campanha-produto">Produto</label>
|
||||||
|
<select id="campanha-produto" required></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Segmentos</label>
|
||||||
|
<div id="campanha-segmentos" class="segmento-lista"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Variantes de mensagem (A/B/C)</label>
|
||||||
|
<div id="campanha-variantes"></div>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-add-variante" type="button">+ Variante</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Limite diário inicial <input type="number" id="campanha-limite-inicial" value="10" min="1"></label>
|
||||||
|
<label>Teto do limite <input type="number" id="campanha-limite-teto" value="80" min="1"></label>
|
||||||
|
</div>
|
||||||
|
<p class="muted" id="campanha-erro"></p>
|
||||||
|
<div class="row space-between">
|
||||||
|
<button class="btn-ghost" id="btn-cancelar-campanha" type="button">Cancelar</button>
|
||||||
|
<button class="btn btn-primary" id="btn-salvar-campanha" type="button">Criar campanha</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</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">
|
||||||
|
<div class="settings-header">
|
||||||
|
<div>
|
||||||
|
<h2>Configurações</h2>
|
||||||
|
<p class="muted">Ajustes de envio, credenciais Z-PRO e preferências comerciais.</p>
|
||||||
|
</div>
|
||||||
|
<span class="chip" id="outreach-status"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="settings-card settings-principal">
|
||||||
|
<div>
|
||||||
|
<label class="settings-title">Envio WhatsApp</label>
|
||||||
|
<p class="muted">O limite vale para envios manuais e automáticos feitos hoje.</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-controls">
|
||||||
|
<label class="toggle"><input type="checkbox" id="outreach-ativo"> Automação ativa</label>
|
||||||
|
<label>Limite por dia <input type="number" id="outreach-limite" min="1"></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="settings-section" open>
|
||||||
|
<summary>Enviar mensagem manual</summary>
|
||||||
|
<p class="muted">Cria ou reutiliza um ticket no painel Z-PRO e respeita o limite diário.</p>
|
||||||
|
<div class="manual-send-card">
|
||||||
|
<label class="full">Número do WhatsApp
|
||||||
|
<input type="text" id="manual-numero" placeholder="5511999999999" inputmode="numeric">
|
||||||
|
</label>
|
||||||
|
<div class="manual-channel full" aria-label="Canal de envio manual">
|
||||||
|
<label class="radio"><input type="radio" name="manual-canal" id="manual-canal-naooficial" value="nao_oficial" checked> Não-oficial (texto livre)</label>
|
||||||
|
<label class="radio"><input type="radio" name="manual-canal" id="manual-canal-oficial" value="oficial"> Oficial (Meta, template aprovado)</label>
|
||||||
|
</div>
|
||||||
|
<label class="full" id="manual-mensagem-wrap">Mensagem
|
||||||
|
<textarea id="manual-mensagem" rows="2" placeholder="Digite a mensagem de texto livre..."></textarea>
|
||||||
|
</label>
|
||||||
|
<div id="manual-oficial-campos" class="hidden full">
|
||||||
|
<label class="full">Escolher modelo salvo
|
||||||
|
<select id="manual-template-biblioteca">
|
||||||
|
<option value="">Selecione um template...</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="row full">
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-testar-template-biblioteca" type="button">Testar modelo selecionado</button>
|
||||||
|
<span class="muted">Usa o número informado acima e variáveis de exemplo.</span>
|
||||||
|
</div>
|
||||||
|
<label>Template aprovado
|
||||||
|
<input type="text" id="manual-template-nome" placeholder="ex.: confirma_suporte">
|
||||||
|
</label>
|
||||||
|
<label>Idioma
|
||||||
|
<input type="text" id="manual-template-idioma" value="pt_BR">
|
||||||
|
</label>
|
||||||
|
<label>Rota Z-PRO
|
||||||
|
<select id="manual-template-rota">
|
||||||
|
<option value="template">Template padrão</option>
|
||||||
|
<option value="templateBody">Utility/Body</option>
|
||||||
|
<option value="templateMarketingBody">Marketing/Body</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="template-vars full">
|
||||||
|
<label>Variáveis do template</label>
|
||||||
|
<p class="muted">Preencha na mesma ordem do template: {{1}}, {{2}}, {{3}}...</p>
|
||||||
|
<div class="variable-helper">
|
||||||
|
<select id="manual-variable-picker">
|
||||||
|
<option value="">Inserir variável da Máquina Comercial...</option>
|
||||||
|
</select>
|
||||||
|
<span class="muted">Clique em um campo abaixo e escolha uma variável.</span>
|
||||||
|
</div>
|
||||||
|
<div class="template-vars-grid">
|
||||||
|
<label class="template-var-field">Variável {{1}} <input type="text" class="manual-template-var" id="manual-template-var1" data-var-index="1" placeholder="Ex.: Gustavo"></label>
|
||||||
|
<label class="template-var-field">Variável {{2}} <input type="text" class="manual-template-var" data-var-index="2" placeholder="Ex.: Suporte GLPI #123"></label>
|
||||||
|
<label class="template-var-field">Variável {{3}} <input type="text" class="manual-template-var" data-var-index="3"></label>
|
||||||
|
<label class="template-var-field">Variável {{4}} <input type="text" class="manual-template-var" data-var-index="4"></label>
|
||||||
|
<label class="template-var-field">Variável {{5}} <input type="text" class="manual-template-var" data-var-index="5"></label>
|
||||||
|
</div>
|
||||||
|
<div class="template-preview" id="manual-template-preview">Selecione um template para ver a pré-visualização.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="manual-actions full">
|
||||||
|
<span class="muted" id="manual-status"></span>
|
||||||
|
<button class="btn btn-primary" id="btn-enviar-manual" type="button">Enviar mensagem</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-toggle-outreach-log" type="button">Ver log de envios</button>
|
||||||
|
<div id="outreach-ultimos" class="lista hidden full"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="settings-section">
|
||||||
|
<summary>Templates automáticos</summary>
|
||||||
|
<p class="muted">Mensagens usadas quando a automação estiver ativada. Uma por produto recomendado.</p>
|
||||||
|
<div class="template-sync-inline">
|
||||||
|
<span class="muted" id="waba-template-library-status">Carregando biblioteca de templates...</span>
|
||||||
|
<button class="btn-ghost btn-sm" id="btn-sync-gchat-templates-inline" type="button">Atualizar lista do GChat</button>
|
||||||
|
</div>
|
||||||
|
<div id="outreach-templates" class="lista"></div>
|
||||||
|
<div class="row space-between">
|
||||||
|
<span></span>
|
||||||
|
<button class="btn-ghost" id="btn-salvar-outreach" type="button">Salvar templates</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="settings-section">
|
||||||
|
<summary>Credenciais Z-PRO</summary>
|
||||||
|
<p class="muted">Use os dados de Admin > API do Z-PRO. Pode colar a Base URL com ou sem <code>https://</code>.</p>
|
||||||
|
<div class="row">
|
||||||
|
<label>Base URL <input type="text" id="cfg-zpro-baseurl" placeholder="seu-dominio-zpro.com.br"></label>
|
||||||
|
<label>API ID <input type="text" id="cfg-zpro-apiid"></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Token <input type="password" id="cfg-zpro-token" autocomplete="off"></label>
|
||||||
|
<label>WhatsApp ID <input type="text" id="cfg-zpro-whatsappid"></label>
|
||||||
|
</div>
|
||||||
|
<div class="row space-between">
|
||||||
|
<span class="muted" id="zpro-credenciais-status"></span>
|
||||||
|
<button class="btn-ghost" id="btn-testar-zpro" type="button">Testar conexão</button>
|
||||||
|
</div>
|
||||||
|
<div class="template-sync-box">
|
||||||
|
<label>URL de templates do GChat <input type="text" id="cfg-gchat-templates-url" placeholder="https://apigchat.gtigrupo.com.br/...templates..."></label>
|
||||||
|
<label>Token GChat opcional <input type="password" id="cfg-gchat-token" autocomplete="off"></label>
|
||||||
|
<div class="row space-between">
|
||||||
|
<span class="muted" id="gchat-template-sync-status"></span>
|
||||||
|
<button class="btn-ghost" id="btn-sync-gchat-templates" type="button">Sincronizar templates</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="settings-section">
|
||||||
|
<summary>Padrões de exportação e ticket</summary>
|
||||||
|
<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>
|
||||||
|
<label>Fila ticket manual <input type="number" id="cfg-ticket-queue" min="1"></label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="settings-section zona-risco">
|
||||||
|
<summary>Zona de risco</summary>
|
||||||
|
<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>
|
||||||
|
</details>
|
||||||
|
<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>
|
||||||
|
<p class="muted erro hidden" id="reset-base-erro"></p>
|
||||||
|
<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/campanhas.js"></script>
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* 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;
|
||||||
|
// Aviso: um <dialog> aberto via showModal() fica acima de qualquer div normal
|
||||||
|
// (top layer do navegador, z-index nao ajuda — e testamos que nem a Popover
|
||||||
|
// API resolve, o dialog continua por cima). Por isso o toast NAO deve ser a
|
||||||
|
// unica forma de mostrar erro de uma acao disparada de dentro de um modal
|
||||||
|
// aberto: use tambem uma mensagem inline no proprio modal nesses casos.
|
||||||
|
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,685 @@
|
|||||||
|
/* 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();
|
||||||
|
if (tab.dataset.tab === 'campanhas') Campanhas.ativar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// configurações
|
||||||
|
let WABA_TEMPLATE_LIBRARY = [];
|
||||||
|
let COMMERCIAL_VARIABLES = [];
|
||||||
|
let focusedVariableInput = null;
|
||||||
|
let manualLeadContext = null;
|
||||||
|
|
||||||
|
async function carregarVariaveisComerciais() {
|
||||||
|
const { variables } = await API.get('/api/outreach/variables');
|
||||||
|
COMMERCIAL_VARIABLES = variables || [];
|
||||||
|
const picker = el('manual-variable-picker');
|
||||||
|
if (picker) {
|
||||||
|
picker.replaceChildren(
|
||||||
|
h('option', { value: '' }, 'Inserir variável da Máquina Comercial...'),
|
||||||
|
...COMMERCIAL_VARIABLES.map((variable) => h('option', { value: `{{${variable.key}}}` }, `{{${variable.key}}} - ${variable.label}`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarBibliotecaWaba() {
|
||||||
|
const { templates, lastSync } = await API.get('/api/outreach/waba-templates');
|
||||||
|
WABA_TEMPLATE_LIBRARY = templates || [];
|
||||||
|
const select = el('manual-template-biblioteca');
|
||||||
|
if (select) select.replaceChildren(...templateOptions(select.value));
|
||||||
|
const statusEl = el('waba-template-library-status');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = lastSync
|
||||||
|
? `${WABA_TEMPLATE_LIBRARY.length} templates carregados. Última atualização: ${dataBr(lastSync)}`
|
||||||
|
: `${WABA_TEMPLATE_LIBRARY.length} templates locais carregados. Para trazer todos do GChat, preencha o Token GChat e clique em Atualizar.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateLabel(template) {
|
||||||
|
return `${template.name} (${template.language})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateOptions(selected = '') {
|
||||||
|
return [h('option', { value: '' }, 'Selecione um template...')].concat(
|
||||||
|
WABA_TEMPLATE_LIBRARY.map((template) => h('option', {
|
||||||
|
value: template.name,
|
||||||
|
selected: template.name === selected ? '' : null
|
||||||
|
}, templateLabel(template)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preencherVariaveis(inputs, variables, fallback = []) {
|
||||||
|
inputs.forEach((input, index) => {
|
||||||
|
input.value = variables[index] || fallback[index] || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateVariableCount(template) {
|
||||||
|
if (!template) return 0;
|
||||||
|
const fromVariables = Array.isArray(template.variables) ? template.variables.length : 0;
|
||||||
|
const matches = String(template.text || '').match(/{{\s*\d+\s*}}/g) || [];
|
||||||
|
const fromText = Math.max(0, ...matches.map((match) => Number(match.replace(/\D/g, ''))));
|
||||||
|
return Math.min(5, Math.max(fromVariables, fromText));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVariableFields(container, template, inputs, fallback = []) {
|
||||||
|
const count = templateVariableCount(template);
|
||||||
|
inputs.forEach((input, index) => {
|
||||||
|
const field = input.closest('.template-var-field') || input.closest('label');
|
||||||
|
const visible = index < count;
|
||||||
|
if (field) field.classList.toggle('hidden', !visible);
|
||||||
|
if (!visible) input.value = '';
|
||||||
|
else if (!input.value) input.value = template?.variables?.[index] || fallback[index] || '';
|
||||||
|
});
|
||||||
|
const empty = container?.querySelector?.('.template-vars-empty');
|
||||||
|
if (empty) empty.classList.toggle('hidden', count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewTemplateText(template, inputs) {
|
||||||
|
const text = String(template?.text || '').trim();
|
||||||
|
if (!template) return 'Selecione um template para ver a pré-visualização.';
|
||||||
|
if (!text) return 'Pré-visualização indisponível para este template local.';
|
||||||
|
return text.replace(/{{\s*(\d+)\s*}}/g, (_, index) => {
|
||||||
|
const input = inputs[Number(index) - 1];
|
||||||
|
const value = resolverVariavelManual(input?.value?.trim());
|
||||||
|
return value ? `[${value}]` : `[Variável ${index}]`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function atualizarPreview(previewEl, template, inputs) {
|
||||||
|
if (previewEl) previewEl.textContent = previewTemplateText(template, inputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVisibleVariables(inputs) {
|
||||||
|
return inputs
|
||||||
|
.filter((input) => !(input.closest('.template-var-field') || input.closest('label'))?.classList.contains('hidden'))
|
||||||
|
.map((input) => input.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVisibleVariablesResolvidas(inputs) {
|
||||||
|
return collectVisibleVariables(inputs).map(resolverVariavelManual);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valoresVariaveisManuais(lead = manualLeadContext) {
|
||||||
|
const empresa = lead?.empresa || lead?.legal_name || lead?.businessName || lead?.name || 'Empresa Teste';
|
||||||
|
const produto = lead?.produto || lead?.produto_principal || lead?.interesse || 'Atendimento comercial';
|
||||||
|
const segmento = lead?.segmento_label || lead?.segmento || 'Suporte';
|
||||||
|
const name = lead?.name || empresa;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
empresa,
|
||||||
|
businessName: empresa,
|
||||||
|
firstName: String(name).split(/\s+/).filter(Boolean)[0] || empresa,
|
||||||
|
lastName: lead?.lastName || '',
|
||||||
|
produto,
|
||||||
|
protocol: lead?.protocol || produto,
|
||||||
|
cidade: lead?.cidade || 'Salvador',
|
||||||
|
uf: lead?.uf || 'BA',
|
||||||
|
segmento,
|
||||||
|
cnpj: lead?.cnpj || '00.000.000/0001-00',
|
||||||
|
email: lead?.email || 'cliente@exemplo.com.br',
|
||||||
|
phoneNumber: lead?.whatsapp || '5575999091780',
|
||||||
|
whatsapp: lead?.whatsapp || '5575999091780',
|
||||||
|
kanban: lead?.kanban || produto,
|
||||||
|
user: lead?.user || 'Grupo GTI',
|
||||||
|
userEmail: lead?.userEmail || 'comercial@gtigrupo.com.br',
|
||||||
|
greeting: 'Olá'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolverVariavelManual(value) {
|
||||||
|
const values = valoresVariaveisManuais();
|
||||||
|
let output = String(value || 'Informação')
|
||||||
|
.replaceAll('{empresa}', values.empresa)
|
||||||
|
.replaceAll('{produto}', values.produto)
|
||||||
|
.replaceAll('{cidade}', values.cidade)
|
||||||
|
.replaceAll('{segmento}', values.segmento);
|
||||||
|
for (const [key, variableValue] of Object.entries(values)) {
|
||||||
|
output = output.replaceAll(`{{${key}}}`, variableValue);
|
||||||
|
}
|
||||||
|
for (const variable of COMMERCIAL_VARIABLES) {
|
||||||
|
if (!output.includes(`{{${variable.key}}}`)) continue;
|
||||||
|
output = output.replaceAll(`{{${variable.key}}}`, values[variable.key] || variable.label);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variavelExemplo = resolverVariavelManual;
|
||||||
|
|
||||||
|
function missingVisibleVariables(inputs) {
|
||||||
|
return inputs
|
||||||
|
.filter((input) => !(input.closest('.template-var-field') || input.closest('label'))?.classList.contains('hidden'))
|
||||||
|
.filter((input) => !input.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function aplicarTemplateBiblioteca(template, { nomeEl, idiomaEl, routeEl, variableInputs }) {
|
||||||
|
if (!template) return;
|
||||||
|
nomeEl.value = template.name;
|
||||||
|
idiomaEl.value = template.language;
|
||||||
|
if (routeEl) routeEl.value = template.route;
|
||||||
|
preencherVariaveis(variableInputs, template.variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
function variablePickerEl() {
|
||||||
|
return h('select', { class: 'variable-picker' },
|
||||||
|
h('option', { value: '' }, 'Inserir variável da Máquina Comercial...'),
|
||||||
|
...COMMERCIAL_VARIABLES.map((variable) => h('option', { value: `{{${variable.key}}}` }, `{{${variable.key}}} - ${variable.label}`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarOutreach() {
|
||||||
|
const { settings } = await API.get('/api/settings');
|
||||||
|
el('outreach-ativo').checked = settings['outreach.ativo'] === 'true';
|
||||||
|
el('outreach-limite').value = settings['outreach.mensagensPorDia'] || 10;
|
||||||
|
|
||||||
|
const [{ templates }, status] = await Promise.all([
|
||||||
|
API.get('/api/outreach/templates'),
|
||||||
|
API.get('/api/outreach/status')
|
||||||
|
]);
|
||||||
|
el('outreach-status').textContent = `Enviado hoje: ${status.enviadosHoje}/${status.mensagensPorDia}`;
|
||||||
|
|
||||||
|
el('outreach-templates').replaceChildren(...templates.map((t) => {
|
||||||
|
const oficial = t.canal === 'oficial';
|
||||||
|
const mensagemEl = h('textarea', {
|
||||||
|
class: 'outreach-template-mensagem', rows: 2,
|
||||||
|
style: oficial ? 'display:none' : ''
|
||||||
|
}, t.mensagem);
|
||||||
|
const nomeEl = h('input', { type: 'text', class: 'outreach-template-waba-nome', value: t.template_waba_nome || '' });
|
||||||
|
const idiomaEl = h('input', { type: 'text', class: 'outreach-template-waba-idioma', value: t.template_waba_idioma || 'pt_BR' });
|
||||||
|
let variaveisSalvas = [];
|
||||||
|
try {
|
||||||
|
variaveisSalvas = JSON.parse(t.template_waba_variaveis_json || '[]');
|
||||||
|
} catch {
|
||||||
|
variaveisSalvas = [];
|
||||||
|
}
|
||||||
|
let templateSelecionado = WABA_TEMPLATE_LIBRARY.find((item) => item.name === t.template_waba_nome) || null;
|
||||||
|
const variaveisEls = Array.from({ length: 5 }, (_, index) => {
|
||||||
|
const input = h('input', {
|
||||||
|
type: 'text',
|
||||||
|
class: 'outreach-template-waba-var',
|
||||||
|
value: variaveisSalvas[index] || (index === 0 ? '{empresa}' : index === 1 ? '{produto}' : ''),
|
||||||
|
placeholder: index === 0 ? '{empresa}' : index === 1 ? '{produto}' : ''
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', () => { focusedVariableInput = input; });
|
||||||
|
return h('label', { class: 'template-var-field' }, `Variável {{${index + 1}}}`, input);
|
||||||
|
});
|
||||||
|
const bibliotecaEl = h('select', { class: 'outreach-template-biblioteca' }, ...templateOptions(t.template_waba_nome || ''));
|
||||||
|
const variablePicker = variablePickerEl();
|
||||||
|
const previewEl = h('div', { class: 'template-preview' }, 'Selecione um template para ver a pré-visualização.');
|
||||||
|
variablePicker.addEventListener('change', () => {
|
||||||
|
if (!focusedVariableInput || !oficialEl.contains(focusedVariableInput) || !variablePicker.value) return;
|
||||||
|
focusedVariableInput.value = variablePicker.value;
|
||||||
|
focusedVariableInput.dispatchEvent(new Event('input'));
|
||||||
|
variablePicker.value = '';
|
||||||
|
});
|
||||||
|
bibliotecaEl.addEventListener('change', () => {
|
||||||
|
templateSelecionado = WABA_TEMPLATE_LIBRARY.find((item) => item.name === bibliotecaEl.value);
|
||||||
|
const inputs = Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var'));
|
||||||
|
aplicarTemplateBiblioteca(templateSelecionado, {
|
||||||
|
nomeEl,
|
||||||
|
idiomaEl,
|
||||||
|
variableInputs: inputs
|
||||||
|
});
|
||||||
|
setVariableFields(oficialEl, templateSelecionado, inputs, ['{empresa}', '{produto}']);
|
||||||
|
atualizarPreview(previewEl, templateSelecionado, inputs);
|
||||||
|
atualizarAviso();
|
||||||
|
});
|
||||||
|
const avisoEl = h('p', { class: 'muted outreach-template-aviso hidden' },
|
||||||
|
'⚠️ Preencha o nome do template pra essa automação funcionar.');
|
||||||
|
const oficialEl = h('div', {
|
||||||
|
class: 'outreach-template-oficial',
|
||||||
|
style: oficial ? '' : 'display:none'
|
||||||
|
},
|
||||||
|
h('label', {}, 'Escolher modelo salvo', bibliotecaEl),
|
||||||
|
h('div', { class: 'row' },
|
||||||
|
h('label', {}, 'Nome do template aprovado', nomeEl),
|
||||||
|
h('label', {}, 'Idioma', idiomaEl)),
|
||||||
|
h('div', { class: 'template-vars outreach-template-vars' },
|
||||||
|
h('label', {}, 'Variáveis automáticas'),
|
||||||
|
h('p', { class: 'muted' }, 'Use placeholders como {empresa}, {produto}, {cidade}, {segmento}.'),
|
||||||
|
h('div', { class: 'variable-helper' },
|
||||||
|
variablePicker,
|
||||||
|
h('span', { class: 'muted' }, 'Clique em um campo abaixo e escolha uma variável.')),
|
||||||
|
h('p', { class: 'muted template-vars-empty hidden' }, 'Este template não exige variáveis.'),
|
||||||
|
h('div', { class: 'template-vars-grid' }, ...variaveisEls),
|
||||||
|
previewEl),
|
||||||
|
h('a', {
|
||||||
|
href: 'https://business.facebook.com/wa/manage/message-templates/',
|
||||||
|
target: '_blank', rel: 'noopener', class: 'outreach-template-link'
|
||||||
|
}, '↗ Ver/criar templates no Meta Business Manager'),
|
||||||
|
avisoEl);
|
||||||
|
|
||||||
|
const atualizarAviso = () => {
|
||||||
|
avisoEl.classList.toggle('hidden', radioOficial.checked ? Boolean(nomeEl.value.trim()) : true);
|
||||||
|
};
|
||||||
|
nomeEl.addEventListener('input', atualizarAviso);
|
||||||
|
for (const input of variaveisEls.map((label) => label.querySelector('input'))) {
|
||||||
|
input.addEventListener('input', () => atualizarPreview(previewEl, templateSelecionado, Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var'))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const radioNaoOficial = h('input', {
|
||||||
|
type: 'radio', name: `canal-${t.produto}`, class: 'outreach-template-canal', value: 'nao_oficial',
|
||||||
|
checked: oficial ? null : ''
|
||||||
|
});
|
||||||
|
const radioOficial = h('input', {
|
||||||
|
type: 'radio', name: `canal-${t.produto}`, class: 'outreach-template-canal', value: 'oficial',
|
||||||
|
checked: oficial ? '' : null
|
||||||
|
});
|
||||||
|
const alternarCanal = () => {
|
||||||
|
const usaOficial = radioOficial.checked;
|
||||||
|
mensagemEl.style.display = usaOficial ? 'none' : '';
|
||||||
|
oficialEl.style.display = usaOficial ? '' : 'none';
|
||||||
|
atualizarAviso();
|
||||||
|
};
|
||||||
|
radioNaoOficial.addEventListener('change', alternarCanal);
|
||||||
|
radioOficial.addEventListener('change', alternarCanal);
|
||||||
|
atualizarAviso();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const inputs = Array.from(oficialEl.querySelectorAll('.outreach-template-waba-var'));
|
||||||
|
setVariableFields(oficialEl, templateSelecionado, inputs, ['{empresa}', '{produto}']);
|
||||||
|
atualizarPreview(previewEl, templateSelecionado, inputs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'item outreach-template', 'data-produto': t.produto },
|
||||||
|
h('div', { class: 'row space-between' },
|
||||||
|
h('strong', {}, t.produto),
|
||||||
|
h('label', { class: 'radio' },
|
||||||
|
h('input', { type: 'checkbox', class: 'outreach-template-ativo', checked: t.ativo ? '' : null }),
|
||||||
|
' Ativo')),
|
||||||
|
h('div', { class: 'row' },
|
||||||
|
h('label', { class: 'radio' }, radioNaoOficial, ' Não-oficial (texto livre)'),
|
||||||
|
h('label', { class: 'radio' }, radioOficial, ' Oficial (Meta, template aprovado)')),
|
||||||
|
mensagemEl,
|
||||||
|
oficialEl);
|
||||||
|
}));
|
||||||
|
|
||||||
|
el('outreach-ultimos').replaceChildren(...status.ultimos.map((s) => {
|
||||||
|
const quem = s.origem === 'manual' ? (s.numero || 'número avulso') : `${s.empresa || '?'} (${s.cidade || '?'})`;
|
||||||
|
const rotulo = s.origem === 'manual' ? `Manual → ${quem}` : `${s.produto} → ${quem}`;
|
||||||
|
const statusIcone = s.status === 'enviado' ? '✅' : '❌';
|
||||||
|
return h('div', { class: 'item' },
|
||||||
|
h('div', {}, `${statusIcone} ${rotulo}`),
|
||||||
|
h('div', { class: 'sub' }, `${dataBr(s.sent_at)} · canal ${s.canal === 'oficial' ? 'oficial' : 'não-oficial'}${s.erro ? ` · ${s.erro}` : ''}`));
|
||||||
|
}));
|
||||||
|
if (!status.ultimos.length) {
|
||||||
|
el('outreach-ultimos').replaceChildren(h('p', { class: 'muted' }, 'Nenhum envio ainda.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-ticket-queue').value = settings['zpro.ticketQueueId'] || 12;
|
||||||
|
el('cfg-forecast').value = settings['zpro.closingForecastDays'] || 30;
|
||||||
|
el('cfg-zpro-baseurl').value = settings['zpro.baseUrl'] || '';
|
||||||
|
el('cfg-zpro-apiid').value = settings['zpro.apiId'] || '';
|
||||||
|
el('cfg-zpro-token').value = settings['zpro.bearerToken'] || '';
|
||||||
|
el('cfg-zpro-whatsappid').value = settings['zpro.whatsappId'] || '';
|
||||||
|
el('cfg-gchat-templates-url').value = settings['gchat.templatesUrl'] || '';
|
||||||
|
el('cfg-gchat-token').value = settings['gchat.bearerToken'] || '';
|
||||||
|
el('zpro-credenciais-status').textContent = '';
|
||||||
|
el('modal-settings').showModal();
|
||||||
|
try {
|
||||||
|
await carregarBibliotecaWaba();
|
||||||
|
await carregarVariaveisComerciais();
|
||||||
|
await carregarOutreach();
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Erro ao carregar automação: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-testar-zpro').addEventListener('click', async () => {
|
||||||
|
const statusEl = el('zpro-credenciais-status');
|
||||||
|
statusEl.textContent = 'Salvando e testando...';
|
||||||
|
try {
|
||||||
|
await API.put('/api/settings', {
|
||||||
|
'zpro.baseUrl': el('cfg-zpro-baseurl').value.trim(),
|
||||||
|
'zpro.apiId': el('cfg-zpro-apiid').value.trim(),
|
||||||
|
'zpro.bearerToken': el('cfg-zpro-token').value.trim(),
|
||||||
|
'zpro.whatsappId': el('cfg-zpro-whatsappid').value.trim(),
|
||||||
|
'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(),
|
||||||
|
'gchat.bearerToken': el('cfg-gchat-token').value.trim(),
|
||||||
|
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
|
||||||
|
'outreach.mensagensPorDia': el('outreach-limite').value
|
||||||
|
});
|
||||||
|
const resultado = await API.post('/api/outreach/testar-conexao');
|
||||||
|
const diag = resultado.diagnostico
|
||||||
|
? `\nBase: ${resultado.diagnostico.baseUrl || '-'} | API ID: ${resultado.diagnostico.apiId || '-'} | Token: ${resultado.diagnostico.token || '-'} | WhatsApp ID: ${resultado.diagnostico.whatsappId || '-'}`
|
||||||
|
: '';
|
||||||
|
statusEl.textContent = resultado.ok ? `✅ Conexão OK${diag}` : `❌ ${resultado.erro}${diag}`;
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = `❌ ${error.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-sync-gchat-templates').addEventListener('click', async () => {
|
||||||
|
await sincronizarTemplatesGchat();
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-sync-gchat-templates-inline').addEventListener('click', async () => {
|
||||||
|
await sincronizarTemplatesGchat();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sincronizarTemplatesGchat() {
|
||||||
|
const statusEl = el('gchat-template-sync-status');
|
||||||
|
const inlineStatusEl = el('waba-template-library-status');
|
||||||
|
statusEl.textContent = 'Sincronizando...';
|
||||||
|
if (inlineStatusEl) inlineStatusEl.textContent = 'Sincronizando com GChat...';
|
||||||
|
try {
|
||||||
|
await API.put('/api/settings', {
|
||||||
|
'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(),
|
||||||
|
'gchat.bearerToken': el('cfg-gchat-token').value.trim()
|
||||||
|
});
|
||||||
|
const result = await API.post('/api/outreach/waba-templates/sync');
|
||||||
|
statusEl.textContent = result.ok ? `✅ ${result.total} templates sincronizados` : `❌ ${result.erro}`;
|
||||||
|
if (inlineStatusEl) inlineStatusEl.textContent = result.ok
|
||||||
|
? `✅ ${result.total} templates sincronizados do GChat`
|
||||||
|
: `❌ ${result.erro}. Preencha o Token GChat opcional em Credenciais Z-PRO.`;
|
||||||
|
await carregarBibliotecaWaba();
|
||||||
|
await carregarOutreach();
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = `❌ ${error.message}`;
|
||||||
|
if (inlineStatusEl) inlineStatusEl.textContent = `❌ ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el('btn-salvar-outreach').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await API.put('/api/settings', {
|
||||||
|
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
|
||||||
|
'outreach.mensagensPorDia': el('outreach-limite').value
|
||||||
|
});
|
||||||
|
for (const item of el('outreach-templates').children) {
|
||||||
|
const canalEl = item.querySelector('.outreach-template-canal:checked');
|
||||||
|
await API.put(`/api/outreach/templates/${encodeURIComponent(item.dataset.produto)}`, {
|
||||||
|
mensagem: item.querySelector('.outreach-template-mensagem').value,
|
||||||
|
ativo: item.querySelector('.outreach-template-ativo').checked,
|
||||||
|
canal: canalEl ? canalEl.value : 'nao_oficial',
|
||||||
|
templateWabaNome: item.querySelector('.outreach-template-waba-nome').value,
|
||||||
|
templateWabaIdioma: item.querySelector('.outreach-template-waba-idioma').value,
|
||||||
|
templateWabaVariaveis: collectVisibleVariables(Array.from(item.querySelectorAll('.outreach-template-waba-var')))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await carregarOutreach();
|
||||||
|
toast('Automação salva');
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Erro: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function alternarCanalManual() {
|
||||||
|
const oficial = el('manual-canal-oficial').checked;
|
||||||
|
el('manual-mensagem-wrap').classList.toggle('hidden', oficial);
|
||||||
|
el('manual-oficial-campos').classList.toggle('hidden', !oficial);
|
||||||
|
}
|
||||||
|
el('manual-canal-naooficial').addEventListener('change', alternarCanalManual);
|
||||||
|
el('manual-canal-oficial').addEventListener('change', alternarCanalManual);
|
||||||
|
|
||||||
|
el('manual-template-biblioteca').replaceChildren(...templateOptions());
|
||||||
|
el('manual-template-biblioteca').addEventListener('change', () => {
|
||||||
|
const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value);
|
||||||
|
const inputs = Array.from(document.querySelectorAll('.manual-template-var'));
|
||||||
|
aplicarTemplateBiblioteca(template, {
|
||||||
|
nomeEl: el('manual-template-nome'),
|
||||||
|
idiomaEl: el('manual-template-idioma'),
|
||||||
|
routeEl: el('manual-template-rota'),
|
||||||
|
variableInputs: inputs
|
||||||
|
});
|
||||||
|
setVariableFields(el('manual-oficial-campos'), template, inputs);
|
||||||
|
atualizarPreview(el('manual-template-preview'), template, inputs);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const input of document.querySelectorAll('.manual-template-var')) {
|
||||||
|
input.addEventListener('focus', () => { focusedVariableInput = input; });
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value);
|
||||||
|
atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
el('manual-variable-picker').addEventListener('change', () => {
|
||||||
|
if (!focusedVariableInput || !el('manual-variable-picker').value) return;
|
||||||
|
focusedVariableInput.value = el('manual-variable-picker').value;
|
||||||
|
focusedVariableInput.dispatchEvent(new Event('input'));
|
||||||
|
el('manual-variable-picker').value = '';
|
||||||
|
});
|
||||||
|
setVariableFields(el('manual-oficial-campos'), null, Array.from(document.querySelectorAll('.manual-template-var')));
|
||||||
|
|
||||||
|
el('btn-testar-template-biblioteca').addEventListener('click', async () => {
|
||||||
|
const statusEl = el('manual-status');
|
||||||
|
const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value);
|
||||||
|
const numero = el('manual-numero').value.trim();
|
||||||
|
statusEl.classList.remove('status-ok', 'status-error');
|
||||||
|
if (!template) {
|
||||||
|
statusEl.textContent = '❌ Selecione um modelo salvo primeiro.';
|
||||||
|
statusEl.classList.add('status-error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!numero) {
|
||||||
|
statusEl.textContent = '❌ Informe um número de teste acima.';
|
||||||
|
statusEl.classList.add('status-error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aplicarTemplateBiblioteca(template, {
|
||||||
|
nomeEl: el('manual-template-nome'),
|
||||||
|
idiomaEl: el('manual-template-idioma'),
|
||||||
|
routeEl: el('manual-template-rota'),
|
||||||
|
variableInputs: Array.from(document.querySelectorAll('.manual-template-var'))
|
||||||
|
});
|
||||||
|
const variaveis = template.variables.map(resolverVariavelManual);
|
||||||
|
preencherVariaveis(Array.from(document.querySelectorAll('.manual-template-var')), variaveis);
|
||||||
|
setVariableFields(el('manual-oficial-campos'), template, Array.from(document.querySelectorAll('.manual-template-var')));
|
||||||
|
atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var')));
|
||||||
|
statusEl.textContent = `Testando ${template.name}...`;
|
||||||
|
try {
|
||||||
|
const resultado = await API.post('/api/outreach/enviar-manual', {
|
||||||
|
numero,
|
||||||
|
canal: 'oficial',
|
||||||
|
mensagem: '',
|
||||||
|
templateWabaNome: template.name,
|
||||||
|
templateWabaIdioma: template.language,
|
||||||
|
templateRota: template.route,
|
||||||
|
templateVariaveis: variaveis
|
||||||
|
});
|
||||||
|
statusEl.textContent = resultado.ok
|
||||||
|
? `✅ ${template.name} funcionou${resultado.ticketId ? ` — ticket ${resultado.ticketId}` : ''}`
|
||||||
|
: `❌ ${template.name} falhou: ${resultado.erro}`;
|
||||||
|
statusEl.classList.toggle('status-ok', Boolean(resultado.ok));
|
||||||
|
statusEl.classList.toggle('status-error', !resultado.ok);
|
||||||
|
await carregarOutreach();
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = `❌ ${template.name} falhou: ${error.message}`;
|
||||||
|
statusEl.classList.add('status-error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-toggle-outreach-log').addEventListener('click', () => {
|
||||||
|
const log = el('outreach-ultimos');
|
||||||
|
const hidden = log.classList.toggle('hidden');
|
||||||
|
el('btn-toggle-outreach-log').textContent = hidden ? 'Ver log de envios' : 'Ocultar log de envios';
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-enviar-manual').addEventListener('click', async () => {
|
||||||
|
const statusEl = el('manual-status');
|
||||||
|
const oficial = el('manual-canal-oficial').checked;
|
||||||
|
statusEl.classList.remove('status-ok', 'status-error');
|
||||||
|
if (oficial) {
|
||||||
|
const faltando = missingVisibleVariables(Array.from(document.querySelectorAll('.manual-template-var')));
|
||||||
|
if (faltando.length) {
|
||||||
|
statusEl.textContent = `❌ Preencha todas as variáveis do template (${faltando.length} pendente${faltando.length > 1 ? 's' : ''}).`;
|
||||||
|
statusEl.classList.add('status-error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusEl.textContent = 'Enviando...';
|
||||||
|
try {
|
||||||
|
const resultado = await API.post('/api/outreach/enviar-manual', {
|
||||||
|
numero: el('manual-numero').value.trim(),
|
||||||
|
canal: oficial ? 'oficial' : 'nao_oficial',
|
||||||
|
mensagem: el('manual-mensagem').value,
|
||||||
|
templateWabaNome: el('manual-template-nome').value.trim(),
|
||||||
|
templateWabaIdioma: el('manual-template-idioma').value.trim(),
|
||||||
|
templateVariaveis: collectVisibleVariablesResolvidas(Array.from(document.querySelectorAll('.manual-template-var'))),
|
||||||
|
templateRota: el('manual-template-rota').value,
|
||||||
|
lead: manualLeadContext
|
||||||
|
});
|
||||||
|
statusEl.textContent = resultado.ok
|
||||||
|
? `✅ Enviado${resultado.ticketId ? ` — ticket ${resultado.ticketId}` : ''}`
|
||||||
|
: `❌ ${resultado.erro}`;
|
||||||
|
statusEl.classList.toggle('status-ok', Boolean(resultado.ok));
|
||||||
|
statusEl.classList.toggle('status-error', !resultado.ok);
|
||||||
|
await carregarOutreach();
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = `❌ ${error.message}`;
|
||||||
|
statusEl.classList.add('status-error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.GTISelecionarLeadManual = (lead) => {
|
||||||
|
manualLeadContext = lead || null;
|
||||||
|
const template = WABA_TEMPLATE_LIBRARY.find((item) => item.name === el('manual-template-biblioteca').value);
|
||||||
|
atualizarPreview(el('manual-template-preview'), template, Array.from(document.querySelectorAll('.manual-template-var')));
|
||||||
|
};
|
||||||
|
|
||||||
|
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.ticketQueueId': el('cfg-ticket-queue').value,
|
||||||
|
'zpro.closingForecastDays': el('cfg-forecast').value,
|
||||||
|
'zpro.baseUrl': el('cfg-zpro-baseurl').value.trim(),
|
||||||
|
'zpro.apiId': el('cfg-zpro-apiid').value.trim(),
|
||||||
|
'zpro.bearerToken': el('cfg-zpro-token').value.trim(),
|
||||||
|
'zpro.whatsappId': el('cfg-zpro-whatsappid').value.trim(),
|
||||||
|
'gchat.templatesUrl': el('cfg-gchat-templates-url').value.trim(),
|
||||||
|
'gchat.bearerToken': el('cfg-gchat-token').value.trim(),
|
||||||
|
'outreach.ativo': el('outreach-ativo').checked ? 'true' : 'false',
|
||||||
|
'outreach.mensagensPorDia': el('outreach-limite').value
|
||||||
|
});
|
||||||
|
await carregarOutreach();
|
||||||
|
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('reset-base-erro').classList.add('hidden');
|
||||||
|
el('modal-settings').close();
|
||||||
|
el('modal-reset-base').showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-cancelar-reset-base').addEventListener('click', () => {
|
||||||
|
el('modal-reset-base').close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Erro aqui precisa aparecer DENTRO do modal (nao so no toast): um <dialog>
|
||||||
|
// aberto via showModal() fica na "top layer" do navegador, acima de
|
||||||
|
// qualquer div normal — inclusive do toast, que ficava invisivel atras do
|
||||||
|
// modal quando a senha estava errada, dando a impressao de que nada
|
||||||
|
// acontecia (bug reportado: "limpeza nao foi feita mesmo pondo a senha").
|
||||||
|
el('btn-confirmar-reset-base').addEventListener('click', async () => {
|
||||||
|
const erroEl = el('reset-base-erro');
|
||||||
|
const senha = el('reset-base-senha').value;
|
||||||
|
if (!senha) {
|
||||||
|
erroEl.textContent = 'Informe a senha de segurança';
|
||||||
|
erroEl.classList.remove('hidden');
|
||||||
|
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) {
|
||||||
|
erroEl.textContent = `Erro: ${error.message}`;
|
||||||
|
erroEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,253 @@
|
|||||||
|
/* Aba Campanhas: criacao, fila de aprovacao, saude/variantes, estagio/agendamento. */
|
||||||
|
const Campanhas = (() => {
|
||||||
|
let carregado = false;
|
||||||
|
let variantesCount = 0;
|
||||||
|
|
||||||
|
const STATUS_LABELS = { rascunho: 'Rascunho', rodando: 'Rodando', pausado: 'Pausado', concluido: 'Concluido' };
|
||||||
|
|
||||||
|
const MAX_VARIANTES = 3; // A/B/C — mesmo limite usado na convencao do restante da UI/backend.
|
||||||
|
|
||||||
|
function addVarianteRow(codigoSugerido) {
|
||||||
|
if (variantesCount >= MAX_VARIANTES) return;
|
||||||
|
const codigo = codigoSugerido || String.fromCharCode(65 + variantesCount);
|
||||||
|
variantesCount += 1;
|
||||||
|
el('campanha-variantes').append(
|
||||||
|
h('div', { class: 'row', 'data-variante-code': codigo },
|
||||||
|
h('span', { class: 'chip' }, codigo),
|
||||||
|
h('textarea', { class: 'campanha-variante-mensagem', rows: 2, placeholder: 'Ola {empresa}, ...' }))
|
||||||
|
);
|
||||||
|
atualizarBotaoAddVariante();
|
||||||
|
}
|
||||||
|
|
||||||
|
function atualizarBotaoAddVariante() {
|
||||||
|
el('btn-add-variante').disabled = variantesCount >= MAX_VARIANTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarProdutos() {
|
||||||
|
const { produtos } = await API.get('/api/campaigns/produtos');
|
||||||
|
el('campanha-produto').replaceChildren(
|
||||||
|
...produtos.map((p) => h('option', { value: p.value }, `${p.value} (${fmt.format(p.total)} leads)`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarSegmentos(produto) {
|
||||||
|
const container = el('campanha-segmentos');
|
||||||
|
if (!produto) { container.replaceChildren(); return; }
|
||||||
|
const { segmentos } = await API.get(`/api/campaigns/segmentos?produto=${encodeURIComponent(produto)}`);
|
||||||
|
container.replaceChildren(...segmentos.map((s) =>
|
||||||
|
h('label', { class: 'segmento-opcao' },
|
||||||
|
h('input', { type: 'checkbox', value: s.value, class: 'campanha-segmento-check' }),
|
||||||
|
` ${s.value} (${fmt.format(s.total)})`)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentosSelecionados() {
|
||||||
|
return Array.from(document.querySelectorAll('.campanha-segmento-check:checked')).map((i) => i.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantesPreenchidas() {
|
||||||
|
return Array.from(el('campanha-variantes').children).map((row) => ({
|
||||||
|
code: row.dataset.varianteCode,
|
||||||
|
mensagem: row.querySelector('.campanha-variante-mensagem').value.trim()
|
||||||
|
})).filter((v) => v.mensagem);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abrirModalNovaCampanha() {
|
||||||
|
el('campanha-nome').value = '';
|
||||||
|
el('campanha-limite-inicial').value = 10;
|
||||||
|
el('campanha-limite-teto').value = 80;
|
||||||
|
el('campanha-erro').textContent = '';
|
||||||
|
el('campanha-variantes').replaceChildren();
|
||||||
|
variantesCount = 0;
|
||||||
|
addVarianteRow('A');
|
||||||
|
await carregarProdutos();
|
||||||
|
await carregarSegmentos(el('campanha-produto').value);
|
||||||
|
el('modal-nova-campanha').showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCampanhaCard(campanha) {
|
||||||
|
return h('div', { class: 'item', onclick: () => carregarDetalhe(campanha.id).catch((e) => toast(`Erro: ${e.message}`)) },
|
||||||
|
h('div', {},
|
||||||
|
h('strong', {}, campanha.nome),
|
||||||
|
h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)),
|
||||||
|
h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` },
|
||||||
|
STATUS_LABELS[campanha.status] || campanha.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarLista() {
|
||||||
|
const { campanhas } = await API.get('/api/campaigns');
|
||||||
|
el('campanhas-lista').replaceChildren(
|
||||||
|
...(campanhas.length ? campanhas.map(renderCampanhaCard) : [h('p', { class: 'muted' }, 'Nenhuma campanha criada ainda.')])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ativar() {
|
||||||
|
if (carregado) return;
|
||||||
|
carregado = true;
|
||||||
|
|
||||||
|
el('btn-nova-campanha').addEventListener('click', () => {
|
||||||
|
abrirModalNovaCampanha().catch((error) => toast(`Erro: ${error.message}`));
|
||||||
|
});
|
||||||
|
el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close());
|
||||||
|
el('btn-add-variante').addEventListener('click', () => addVarianteRow());
|
||||||
|
el('campanha-produto').addEventListener('change', (event) => {
|
||||||
|
// O modal de nova campanha esta aberto (<dialog> via showModal()) quando
|
||||||
|
// isto dispara — toast sozinho fica escondido atras do dialog (ver
|
||||||
|
// comentario em api.js), entao o erro tambem precisa aparecer no
|
||||||
|
// paragrafo inline #campanha-erro que ja existe pro erro de submit.
|
||||||
|
carregarSegmentos(event.target.value).catch((error) => {
|
||||||
|
toast(`Erro: ${error.message}`);
|
||||||
|
el('campanha-erro').textContent = `Erro: ${error.message}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-salvar-campanha').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await API.post('/api/campaigns', {
|
||||||
|
nome: el('campanha-nome').value.trim(),
|
||||||
|
produto: el('campanha-produto').value,
|
||||||
|
segmentos: segmentosSelecionados(),
|
||||||
|
variantes: variantesPreenchidas(),
|
||||||
|
limiteDiarioInicial: Number(el('campanha-limite-inicial').value) || 10,
|
||||||
|
limiteDiarioTeto: Number(el('campanha-limite-teto').value) || 80
|
||||||
|
});
|
||||||
|
el('modal-nova-campanha').close();
|
||||||
|
toast('Campanha criada');
|
||||||
|
await carregarLista();
|
||||||
|
} catch (error) {
|
||||||
|
el('campanha-erro').textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ligarDetalheCampanha();
|
||||||
|
|
||||||
|
carregarLista().catch((error) => toast(`Erro: ${error.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
let campanhaAtualId = null;
|
||||||
|
|
||||||
|
async function carregarDetalhe(campanhaId) {
|
||||||
|
campanhaAtualId = campanhaId;
|
||||||
|
const campanha = await API.get(`/api/campaigns/${campanhaId}`);
|
||||||
|
el('detalhe-nome').textContent = campanha.nome;
|
||||||
|
el('detalhe-status').textContent = STATUS_LABELS[campanha.status] || campanha.status;
|
||||||
|
el('campanha-detalhe').classList.remove('hidden');
|
||||||
|
await Promise.all([carregarSaude(campanhaId), carregarVariantesStats(campanhaId), carregarFila(campanhaId)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarSaude(campanhaId) {
|
||||||
|
const saude = await API.get(`/api/campaigns/${campanhaId}/saude`);
|
||||||
|
el('detalhe-saude').textContent = saude.pause
|
||||||
|
? `Pausada automaticamente: ${saude.reason}`
|
||||||
|
: 'Saude: sem alertas.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarVariantesStats(campanhaId) {
|
||||||
|
const { variantes } = await API.get(`/api/campaigns/${campanhaId}/variantes-stats`);
|
||||||
|
el('detalhe-variantes').replaceChildren(...variantes.map((v) =>
|
||||||
|
h('span', { class: 'chip' },
|
||||||
|
`${v.code}: ${v.enviados} enviados, ${(v.taxaResposta * 100).toFixed(1)}% resposta${v.elegivelParaComparacao ? ' (elegivel)' : ''}`)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_OPTIONS = [
|
||||||
|
'respondeu', 'interessado', 'demonstracao_agendada', 'sem_interesse', 'opt_out', 'venda_fechada'
|
||||||
|
];
|
||||||
|
|
||||||
|
async function carregarFila(campanhaId) {
|
||||||
|
const status = el('fila-filtro-status').value;
|
||||||
|
const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`);
|
||||||
|
el('fila-tabela').replaceChildren(...fila.map((linha) => {
|
||||||
|
const podeMarcarEstagio = linha.status === 'sent' || linha.status === 'error';
|
||||||
|
const stageSelect = h('select', { class: 'fila-estagio-select', disabled: podeMarcarEstagio ? null : 'disabled' },
|
||||||
|
h('option', { value: '' }, '—'),
|
||||||
|
...STAGE_OPTIONS.map((s) => h('option', { value: s }, s)));
|
||||||
|
const marcarBtn = h('button', {
|
||||||
|
class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
if (!stageSelect.value) return;
|
||||||
|
await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId });
|
||||||
|
toast('Estagio atualizado');
|
||||||
|
if (stageSelect.value === 'demonstracao_agendada') {
|
||||||
|
const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):');
|
||||||
|
if (dataAgendamento) {
|
||||||
|
await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento });
|
||||||
|
toast('Agendamento registrado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast(`Erro: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 'Marcar');
|
||||||
|
return h('tr', {},
|
||||||
|
h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })),
|
||||||
|
h('td', {}, String(linha.lead_id)),
|
||||||
|
h('td', {}, linha.numero),
|
||||||
|
h('td', {}, linha.rendered_message),
|
||||||
|
h('td', {}, linha.status),
|
||||||
|
h('td', { class: 'row' }, stageSelect, marcarBtn));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filaSelecionada() {
|
||||||
|
return Array.from(document.querySelectorAll('.fila-check:checked')).map((i) => Number(i.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ligarDetalheCampanha() {
|
||||||
|
el('btn-fechar-detalhe').addEventListener('click', () => el('campanha-detalhe').classList.add('hidden'));
|
||||||
|
el('fila-filtro-status').addEventListener('change', () => carregarFila(campanhaAtualId).catch((e) => toast(`Erro: ${e.message}`)));
|
||||||
|
|
||||||
|
el('btn-gerar-fila').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/gerar-fila`, {});
|
||||||
|
toast(`${resultado.adicionadosNaFila} leads adicionados a fila`);
|
||||||
|
await carregarFila(campanhaAtualId);
|
||||||
|
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-iniciar-campanha').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await API.post(`/api/campaigns/${campanhaAtualId}/iniciar`, {});
|
||||||
|
await carregarDetalhe(campanhaAtualId);
|
||||||
|
await carregarLista();
|
||||||
|
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||||
|
});
|
||||||
|
el('btn-pausar-campanha').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await API.post(`/api/campaigns/${campanhaAtualId}/pausar`, { motivo: 'manual' });
|
||||||
|
await carregarDetalhe(campanhaAtualId);
|
||||||
|
await carregarLista();
|
||||||
|
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||||
|
});
|
||||||
|
el('btn-retomar-campanha').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await API.post(`/api/campaigns/${campanhaAtualId}/retomar`, {});
|
||||||
|
await carregarDetalhe(campanhaAtualId);
|
||||||
|
await carregarLista();
|
||||||
|
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||||
|
});
|
||||||
|
|
||||||
|
el('btn-aprovar-selecionados').addEventListener('click', async () => {
|
||||||
|
const queueIds = filaSelecionada();
|
||||||
|
if (!queueIds.length) return;
|
||||||
|
try {
|
||||||
|
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/aprovar`, { queueIds });
|
||||||
|
toast(`${resultado.aprovados} aprovados`);
|
||||||
|
await carregarFila(campanhaAtualId);
|
||||||
|
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||||
|
});
|
||||||
|
el('btn-cancelar-selecionados').addEventListener('click', async () => {
|
||||||
|
const queueIds = filaSelecionada();
|
||||||
|
if (!queueIds.length) return;
|
||||||
|
try {
|
||||||
|
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/cancelar`, { queueIds, motivo: 'operador' });
|
||||||
|
toast(`${resultado.cancelados} cancelados`);
|
||||||
|
await carregarFila(campanhaAtualId);
|
||||||
|
} catch (error) { toast(`Erro: ${error.message}`); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ativar };
|
||||||
|
})();
|
||||||
@@ -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,658 @@
|
|||||||
|
/* 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: '10', class: 'muted' }, 'Nenhum lead com este filtro.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abrirWhatsAppLead = async (event, lead) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!lead.whatsapp) {
|
||||||
|
toast('Este lead não tem WhatsApp cadastrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let leadCompleto = lead;
|
||||||
|
try {
|
||||||
|
leadCompleto = await API.get(`/api/leads/${lead.id}`);
|
||||||
|
} catch {
|
||||||
|
leadCompleto = lead;
|
||||||
|
}
|
||||||
|
const settingsButton = el('btn-settings');
|
||||||
|
settingsButton.click();
|
||||||
|
setTimeout(() => {
|
||||||
|
window.GTISelecionarLeadManual?.(leadCompleto);
|
||||||
|
el('manual-numero').value = leadCompleto.whatsapp || lead.whatsapp;
|
||||||
|
el('manual-canal-naooficial').checked = true;
|
||||||
|
el('manual-canal-naooficial').dispatchEvent(new Event('change'));
|
||||||
|
el('manual-mensagem').value = `Olá ${leadCompleto.empresa || lead.empresa}! Somos do Grupo GTI. Podemos conversar?`;
|
||||||
|
el('manual-status').textContent = '';
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
tbody.replaceChildren(...data.rows.map((lead) => h('tr', { onclick: () => abrirLead(lead.id) },
|
||||||
|
h('td', { class: 'acao-col' }, h('button', {
|
||||||
|
class: 'whatsapp-action',
|
||||||
|
title: 'Enviar WhatsApp para este lead',
|
||||||
|
onclick: (event) => abrirWhatsAppLead(event, lead)
|
||||||
|
}, '☎')),
|
||||||
|
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,35 @@
|
|||||||
|
<!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="/img/logo-gti.png">
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
|
||||||
|
<main class="login-wrap">
|
||||||
|
<form class="card login-card" id="form-login">
|
||||||
|
<div class="brand login-brand">
|
||||||
|
<img class="brand-mark brand-mark-lg" src="/img/logo-gti.png" alt="GTI">
|
||||||
|
<span class="brand-name">Máquina Comercial</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-username">Usuário</label>
|
||||||
|
<input type="text" id="login-username" autocomplete="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-password">Senha</label>
|
||||||
|
<input type="password" id="login-password" autocomplete="current-password" required>
|
||||||
|
</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,24 @@
|
|||||||
|
import { loadDotEnv } from '../src/env.mjs';
|
||||||
|
import { openDb, ensureSeeds } from '../src/db.mjs';
|
||||||
|
import { runOutreachBatch } from '../src/outreach/run.mjs';
|
||||||
|
|
||||||
|
// Dispara um lote manual da automacao de envio WhatsApp (Z-PRO), fora do
|
||||||
|
// ciclo automatico de 20 min do servidor. Util pra testar ou forcar um lote
|
||||||
|
// extra sem reiniciar o servidor:
|
||||||
|
// node scripts/enviar-outreach.mjs (ou npm run outreach)
|
||||||
|
//
|
||||||
|
// Respeita as mesmas regras do ciclo automatico (outreach.ativo, horario
|
||||||
|
// comercial, limite diario) — nao manda nada "escondido" do que a tela
|
||||||
|
// de Configuracoes mostra.
|
||||||
|
|
||||||
|
await loadDotEnv();
|
||||||
|
|
||||||
|
const log = (message) => console.log(`[outreach] ${message}`);
|
||||||
|
|
||||||
|
const db = openDb();
|
||||||
|
await ensureSeeds(db, { log });
|
||||||
|
|
||||||
|
const resumo = await runOutreachBatch({ db, log });
|
||||||
|
console.log(JSON.stringify(resumo, null, 2));
|
||||||
|
|
||||||
|
db.close();
|
||||||
@@ -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,41 @@
|
|||||||
|
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';
|
||||||
|
import { startOutreachScheduler } from '../src/outreach/scheduler.mjs';
|
||||||
|
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.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 });
|
||||||
|
startOutreachScheduler({ db, log });
|
||||||
|
startCampaignScheduler({ db, 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', 15000)
|
||||||
|
},
|
||||||
|
overpassTextSearch: envString('OVERPASS_TEXT_SEARCH', 'false') === 'true',
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
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'))
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
// v4 — automacao de envio WhatsApp (Z-PRO): um template de mensagem por
|
||||||
|
// produto/servico, e o historico de disparos (o que foi mandado, pra quem,
|
||||||
|
// quando) usado tanto pra nao repetir envio quanto pra decidir quando tentar
|
||||||
|
// o proximo produto recomendado.
|
||||||
|
`
|
||||||
|
CREATE TABLE message_templates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
produto TEXT NOT NULL UNIQUE,
|
||||||
|
mensagem TEXT NOT NULL,
|
||||||
|
ativo INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_sends (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||||
|
produto TEXT NOT NULL,
|
||||||
|
mensagem_enviada TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'enviado',
|
||||||
|
erro TEXT,
|
||||||
|
zpro_ticket_id TEXT,
|
||||||
|
sent_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto);
|
||||||
|
CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at);
|
||||||
|
`,
|
||||||
|
// v5 — canal de envio por template: 'nao_oficial' (texto livre, bulkIndividual,
|
||||||
|
// conexao QR/Baileys) ou 'oficial' (API oficial Meta/WABA, exige template
|
||||||
|
// ja aprovado no Meta Business Manager — nao da pra criar/aprovar por aqui).
|
||||||
|
`
|
||||||
|
ALTER TABLE message_templates ADD COLUMN canal TEXT NOT NULL DEFAULT 'nao_oficial';
|
||||||
|
ALTER TABLE message_templates ADD COLUMN template_waba_nome TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE message_templates ADD COLUMN template_waba_idioma TEXT NOT NULL DEFAULT 'pt_BR';
|
||||||
|
|
||||||
|
ALTER TABLE outreach_sends ADD COLUMN canal TEXT NOT NULL DEFAULT 'nao_oficial';
|
||||||
|
`,
|
||||||
|
// v6 — envio manual avulso (mandar mensagem pra um numero especifico, sem
|
||||||
|
// estar ligado a um lead da base). lead_id precisa virar opcional (SQLite
|
||||||
|
// nao remove NOT NULL com ALTER TABLE direto, por isso o rebuild da tabela)
|
||||||
|
// e numero/origem guardam pra quem foi e se foi automatico ou manual.
|
||||||
|
`
|
||||||
|
CREATE TABLE outreach_sends_v6 (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
lead_id INTEGER REFERENCES leads(id),
|
||||||
|
produto TEXT NOT NULL DEFAULT '',
|
||||||
|
numero TEXT,
|
||||||
|
mensagem_enviada TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'enviado',
|
||||||
|
erro TEXT,
|
||||||
|
zpro_ticket_id TEXT,
|
||||||
|
canal TEXT NOT NULL DEFAULT 'nao_oficial',
|
||||||
|
origem TEXT NOT NULL DEFAULT 'automatico',
|
||||||
|
sent_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO outreach_sends_v6
|
||||||
|
(id, lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal, sent_at)
|
||||||
|
SELECT id, lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal, sent_at
|
||||||
|
FROM outreach_sends;
|
||||||
|
|
||||||
|
DROP TABLE outreach_sends;
|
||||||
|
ALTER TABLE outreach_sends_v6 RENAME TO outreach_sends;
|
||||||
|
|
||||||
|
CREATE INDEX idx_outreach_lead_produto ON outreach_sends(lead_id, produto);
|
||||||
|
CREATE INDEX idx_outreach_sent_at ON outreach_sends(sent_at);
|
||||||
|
`,
|
||||||
|
// v7 — variaveis dos templates WABA automaticos por produto. Cada item pode
|
||||||
|
// usar placeholders resolvidos por lead: {empresa}, {produto}, {cidade}, etc.
|
||||||
|
`
|
||||||
|
ALTER TABLE message_templates ADD COLUMN template_waba_variaveis_json TEXT NOT NULL DEFAULT '[]';
|
||||||
|
`,
|
||||||
|
// v8 — campanhas de outreach com catalogo dinamico (a Comercial tem 57
|
||||||
|
// segmentos e 4 produtos, catalogo fixo em codigo nao cabe nessa escala:
|
||||||
|
// o operador cria a campanha pela tela). Roda ao lado do runOutreachBatch
|
||||||
|
// existente (run.mjs) sem substitui-lo. outreach_opt_out e novo aqui (nao
|
||||||
|
// existia antes): uma fila que recontata o mesmo lead ao longo de semanas
|
||||||
|
// precisa checar quem ja pediu pra parar.
|
||||||
|
`
|
||||||
|
CREATE TABLE outreach_campaigns (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
nome TEXT NOT NULL,
|
||||||
|
produto TEXT NOT NULL,
|
||||||
|
segmentos_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
status TEXT NOT NULL DEFAULT 'rascunho',
|
||||||
|
pause_reason TEXT,
|
||||||
|
limite_diario_atual INTEGER NOT NULL DEFAULT 10,
|
||||||
|
limite_diario_teto INTEGER NOT NULL DEFAULT 80,
|
||||||
|
semana_iniciada_em TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_campaign_variants (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
mensagem TEXT NOT NULL,
|
||||||
|
ativo INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_campaign_variants_campaign ON outreach_campaign_variants(campaign_id);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_campaign_queue (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||||
|
variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id),
|
||||||
|
segmento TEXT NOT NULL DEFAULT '',
|
||||||
|
numero TEXT NOT NULL,
|
||||||
|
rendered_message TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'preview',
|
||||||
|
cancel_reason TEXT,
|
||||||
|
outreach_send_id INTEGER REFERENCES outreach_sends(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_campaign_queue_campaign_status ON outreach_campaign_queue(campaign_id, status);
|
||||||
|
CREATE INDEX idx_campaign_queue_lead ON outreach_campaign_queue(lead_id);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_lead_states (
|
||||||
|
lead_id INTEGER PRIMARY KEY REFERENCES leads(id),
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_stage_events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||||
|
campaign_id INTEGER REFERENCES outreach_campaigns(id),
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_stage_events_lead ON outreach_stage_events(lead_id);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_appointments (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
scheduled_at TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_appointments_lead ON outreach_appointments(lead_id);
|
||||||
|
|
||||||
|
CREATE TABLE outreach_opt_out (
|
||||||
|
numero TEXT PRIMARY KEY,
|
||||||
|
motivo TEXT,
|
||||||
|
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.ticketQueueId': '12',
|
||||||
|
'zpro.status': 'open',
|
||||||
|
'zpro.closingForecastDays': '30',
|
||||||
|
'zpro.validateNumber': 'true',
|
||||||
|
// Credenciais da API externa do Z-PRO usadas pela automacao de envio
|
||||||
|
// WhatsApp (src/outreach/zpro-client.mjs). Editaveis em Configuracoes >
|
||||||
|
// Credenciais Z-PRO; vazio aqui cai pro .env (ZPRO_BASE_URL/etc) se existir.
|
||||||
|
'zpro.baseUrl': '',
|
||||||
|
'zpro.apiId': '',
|
||||||
|
'zpro.bearerToken': '',
|
||||||
|
'zpro.whatsappId': '',
|
||||||
|
'meta_diaria': '100',
|
||||||
|
// Automacao de envio WhatsApp (Z-PRO) — comeca desligada de proposito:
|
||||||
|
// so liga depois que o .env tiver as credenciais e os templates forem revisados.
|
||||||
|
'outreach.ativo': 'false',
|
||||||
|
'outreach.mensagensPorDia': '10',
|
||||||
|
'outreach.diasEntreProdutos': '5',
|
||||||
|
'outreach.tentarProximoProduto': 'false',
|
||||||
|
'outreach.horaInicio': '08:00',
|
||||||
|
'outreach.horaFim': '18:00',
|
||||||
|
// Endpoint interno do GChat que lista templates WABA do canal GRUPO GTI COEX.
|
||||||
|
// O token de sessao do GChat deve ser preenchido localmente na tela; nao fica
|
||||||
|
// embutido no codigo.
|
||||||
|
'gchat.templatesUrl': 'https://apigchat.gtigrupo.com.br/wabametaTemplate/1126322523899986',
|
||||||
|
'gchat.bearerToken': ''
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENSAGEM_PADRAO =
|
||||||
|
'Ola! Somos o Grupo GTI. Notamos que a {empresa} pode se beneficiar do nosso ' +
|
||||||
|
'{produto} — ajudamos empresas em {cidade} com isso. Podemos conversar?';
|
||||||
|
|
||||||
|
// Um template por produto/servico (INSERT OR IGNORE: nao sobrescreve texto ja
|
||||||
|
// editado pelo usuario na tela de Configuracoes ao reaplicar em boots seguintes).
|
||||||
|
export function seedMessageTemplates(db, produtos) {
|
||||||
|
transaction(db, () => {
|
||||||
|
const stmt = db.prepare('INSERT OR IGNORE INTO message_templates (produto, mensagem) VALUES (?, ?)');
|
||||||
|
for (const produto of produtos) {
|
||||||
|
if (!produto) continue;
|
||||||
|
stmt.run(produto, MENSAGEM_PADRAO.replace('{produto}', produto));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMessageTemplates(db) {
|
||||||
|
return db.prepare('SELECT * FROM message_templates ORDER BY produto').all();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMessageTemplate(db, produto, { mensagem, ativo, canal, templateWabaNome, templateWabaIdioma, templateWabaVariaveis }) {
|
||||||
|
const row = db.prepare('SELECT * FROM message_templates WHERE produto = ?').get(produto);
|
||||||
|
if (!row) return null;
|
||||||
|
const novaMensagem = mensagem !== undefined ? mensagem : row.mensagem;
|
||||||
|
const novoAtivo = ativo !== undefined ? (ativo ? 1 : 0) : row.ativo;
|
||||||
|
const novoCanal = canal === 'oficial' ? 'oficial' : canal === 'nao_oficial' ? 'nao_oficial' : row.canal;
|
||||||
|
const novoNome = templateWabaNome !== undefined ? templateWabaNome : row.template_waba_nome;
|
||||||
|
const novoIdioma = templateWabaIdioma !== undefined ? templateWabaIdioma : row.template_waba_idioma;
|
||||||
|
const novasVariaveis = templateWabaVariaveis !== undefined
|
||||||
|
? JSON.stringify((Array.isArray(templateWabaVariaveis) ? templateWabaVariaveis : [])
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter(Boolean))
|
||||||
|
: row.template_waba_variaveis_json;
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE message_templates
|
||||||
|
SET mensagem = ?, ativo = ?, canal = ?, template_waba_nome = ?, template_waba_idioma = ?, template_waba_variaveis_json = ?, updated_at = datetime('now')
|
||||||
|
WHERE produto = ?`
|
||||||
|
).run(novaMensagem, novoAtivo, novoCanal, novoNome, novoIdioma, novasVariaveis, produto);
|
||||||
|
return db.prepare('SELECT * FROM message_templates WHERE produto = ?').get(produto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 || []);
|
||||||
|
const produtos = [...new Set(
|
||||||
|
(config.segments || []).flatMap((s) => (s.products || []).map((p) => p.nome)).filter(Boolean)
|
||||||
|
)];
|
||||||
|
seedMessageTemplates(db, produtos);
|
||||||
|
log(`seeds: ${countRows(db, 'segments')} segmentos, ${countRows(db, 'geo_cache')} bboxes, ${countRows(db, 'message_templates')} templates`);
|
||||||
|
} 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)');
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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 let GTI_PORT = envNumber('GTI_PORT', 3900);
|
||||||
|
|
||||||
|
function refreshEnvDerivedValues() {
|
||||||
|
paths.db = process.env.GTI_DB_PATH || path.join(projectRoot, 'data', 'gti.db');
|
||||||
|
paths.cnpjDir = process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj');
|
||||||
|
paths.rfbRaw = path.join(paths.cnpjDir, 'rfb-raw');
|
||||||
|
GTI_PORT = envNumber('GTI_PORT', 3900);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshEnvDerivedValues();
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { renderTemplate } from './templates.mjs';
|
||||||
|
|
||||||
|
// Distribuicao deterministica por lead_id % variantes.length: o mesmo lead
|
||||||
|
// sempre cai na mesma variante mesmo que a fila seja regenerada depois, sem
|
||||||
|
// precisar guardar nenhum estado extra so pra isso.
|
||||||
|
export function variantForLead(variants, leadId) {
|
||||||
|
if (!variants.length) throw new Error('campanha sem variantes ativas');
|
||||||
|
const index = Number(leadId) % variants.length;
|
||||||
|
return variants[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCampaignMessage(variant, lead) {
|
||||||
|
return renderTemplate(variant.mensagem, lead);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
function dateKey(date) {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPause(db, campaignId, reason) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE outreach_campaigns SET status = 'pausado', pause_reason = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND status = 'rodando'`
|
||||||
|
).run(reason, campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mesmos 3 criterios e limiares numericos da Maquina Pessoal (10 tentativas/
|
||||||
|
// 10% erro, 20 contatos/10% negativo+opt-out, 50 contatos/5% resposta),
|
||||||
|
// adaptados as tabelas da Comercial via JOIN por outreach_campaign_queue
|
||||||
|
// (nao por um valor de origem dedicado).
|
||||||
|
//
|
||||||
|
// PURA/read-only: so calcula e retorna {pause, reason}, nunca escreve no
|
||||||
|
// banco. Quem precisa da mutacao (pausar de verdade quando pause=true) deve
|
||||||
|
// chamar applyCampaignHealth, nao esta funcao — assim uma rota GET (como
|
||||||
|
// /api/campaigns/:id/saude, chamada so de abrir o painel na UI) nunca pausa
|
||||||
|
// uma campanha como efeito colateral de uma leitura.
|
||||||
|
export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
|
||||||
|
const campaign = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
||||||
|
if (!campaign || campaign.status !== 'rodando') return { pause: false, reason: null };
|
||||||
|
|
||||||
|
const todayKey = dateKey(now);
|
||||||
|
const attemptsToday = Number(db.prepare(
|
||||||
|
`SELECT COUNT(*) AS total
|
||||||
|
FROM outreach_campaign_queue q
|
||||||
|
JOIN outreach_sends s ON s.id = q.outreach_send_id
|
||||||
|
WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = ?`
|
||||||
|
).get(campaignId, todayKey).total);
|
||||||
|
|
||||||
|
if (attemptsToday >= 10) {
|
||||||
|
const failureCount = Number(db.prepare(
|
||||||
|
`SELECT COUNT(*) AS total
|
||||||
|
FROM outreach_campaign_queue q
|
||||||
|
JOIN outreach_sends s ON s.id = q.outreach_send_id
|
||||||
|
WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?`
|
||||||
|
).get(campaignId, todayKey).total);
|
||||||
|
if (failureCount / attemptsToday > 0.10) {
|
||||||
|
return { pause: true, reason: 'high_failure_rate' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = Number(db.prepare(
|
||||||
|
`SELECT COUNT(*) AS total FROM outreach_campaign_queue WHERE campaign_id = ? AND status IN ('sent','error')`
|
||||||
|
).get(campaignId).total);
|
||||||
|
|
||||||
|
if (contacts >= 20) {
|
||||||
|
const negativeCount = Number(db.prepare(
|
||||||
|
`SELECT COUNT(DISTINCT q.lead_id) AS total
|
||||||
|
FROM outreach_campaign_queue q
|
||||||
|
JOIN outreach_lead_states ls ON ls.lead_id = q.lead_id
|
||||||
|
WHERE q.campaign_id = ? AND q.status IN ('sent','error')
|
||||||
|
AND ls.stage IN ('sem_interesse', 'opt_out')`
|
||||||
|
).get(campaignId).total);
|
||||||
|
if (negativeCount / contacts > 0.10) {
|
||||||
|
return { pause: true, reason: 'high_negative_rate' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contacts >= 50) {
|
||||||
|
const respondedCount = Number(db.prepare(
|
||||||
|
`SELECT COUNT(DISTINCT q.lead_id) AS total
|
||||||
|
FROM outreach_campaign_queue q
|
||||||
|
JOIN outreach_stage_events se ON se.lead_id = q.lead_id AND se.campaign_id = q.campaign_id
|
||||||
|
WHERE q.campaign_id = ? AND q.status IN ('sent','error')`
|
||||||
|
).get(campaignId).total);
|
||||||
|
if (respondedCount / contacts < 0.05) {
|
||||||
|
return { pause: true, reason: 'low_reply_rate' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pause: false, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// MUTANTE: chama a avaliacao pura acima e, se ela mandar pausar, persiste a
|
||||||
|
// pausa de verdade via applyPause. E isto que o worker deve chamar apos cada
|
||||||
|
// envio real — e so o worker, nunca uma rota de leitura.
|
||||||
|
export function applyCampaignHealth(db, campaignId, now = new Date()) {
|
||||||
|
const resultado = evaluateCampaignHealth(db, campaignId, now);
|
||||||
|
if (resultado.pause) {
|
||||||
|
applyPause(db, campaignId, resultado.reason);
|
||||||
|
}
|
||||||
|
return resultado;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numero de leads distintos com resposta por variante, via subquery
|
||||||
|
// correlacionada (nao um JOIN direto) para nao contar duplicado quando um
|
||||||
|
// lead tem mais de um outreach_stage_events.
|
||||||
|
export function variantStats(db, campaignId) {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT v.id AS variant_id, v.code,
|
||||||
|
COUNT(*) AS enviados,
|
||||||
|
(SELECT COUNT(DISTINCT q2.lead_id)
|
||||||
|
FROM outreach_campaign_queue q2
|
||||||
|
JOIN outreach_stage_events se ON se.lead_id = q2.lead_id AND se.campaign_id = q2.campaign_id
|
||||||
|
WHERE q2.campaign_id = q.campaign_id AND q2.variant_id = v.id AND q2.status IN ('sent','error')
|
||||||
|
) AS respostas
|
||||||
|
FROM outreach_campaign_queue q
|
||||||
|
JOIN outreach_campaign_variants v ON v.id = q.variant_id
|
||||||
|
WHERE q.campaign_id = ? AND q.status IN ('sent','error')
|
||||||
|
GROUP BY v.id, v.code`
|
||||||
|
).all(campaignId);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
enviados: row.enviados,
|
||||||
|
respostas: row.respostas,
|
||||||
|
taxaResposta: row.enviados > 0 ? row.respostas / row.enviados : 0,
|
||||||
|
elegivelParaComparacao: row.enviados >= 40
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { getCampaign } from './campaign-store.mjs';
|
||||||
|
import { variantForLead, renderCampaignMessage } from './campaign-messages.mjs';
|
||||||
|
|
||||||
|
const ACTIVE_QUEUE_STATUSES = "'preview','approved','sent'";
|
||||||
|
|
||||||
|
function candidateQuery() {
|
||||||
|
return `
|
||||||
|
SELECT leads.id, leads.empresa, leads.whatsapp, leads.segmento, leads.cidade, leads.uf,
|
||||||
|
leads.segmento_label, leads.prioridade, leads.score
|
||||||
|
FROM leads
|
||||||
|
WHERE leads.produto_principal = ?
|
||||||
|
AND leads.segmento = ?
|
||||||
|
AND leads.telefone_valido = 1
|
||||||
|
AND leads.whatsapp != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM outreach_sends
|
||||||
|
WHERE outreach_sends.produto = ? AND outreach_sends.status = 'enviado'
|
||||||
|
AND outreach_sends.lead_id = leads.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM outreach_sends
|
||||||
|
WHERE outreach_sends.status = 'enviado'
|
||||||
|
AND outreach_sends.numero = leads.whatsapp
|
||||||
|
AND outreach_sends.origem = 'manual'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM outreach_campaign_queue q
|
||||||
|
JOIN outreach_campaigns c ON c.id = q.campaign_id
|
||||||
|
WHERE q.lead_id = leads.id AND c.produto = ? AND q.status IN (${ACTIVE_QUEUE_STATUSES})
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM outreach_opt_out WHERE numero = leads.whatsapp)
|
||||||
|
ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gera ate `quantidade` linhas novas de fila (status 'preview') para a
|
||||||
|
// campanha, buscando candidatos em todos os segmentos configurados. Nao e
|
||||||
|
// limitado ao limite diario de envio (isso so controla a VELOCIDADE de envio
|
||||||
|
// do worker) — o operador pode gerar e revisar um lote maior do que vai
|
||||||
|
// enviar hoje.
|
||||||
|
export function generateCampaignQueue(db, campaignId, { quantidade = 50 } = {}) {
|
||||||
|
const campaign = getCampaign(db, campaignId);
|
||||||
|
if (!campaign) throw new Error('campanha nao encontrada');
|
||||||
|
if (!campaign.variants.length) throw new Error('campanha sem variantes ativas');
|
||||||
|
if (!campaign.segmentos.length) throw new Error('campanha sem segmentos configurados');
|
||||||
|
|
||||||
|
const statement = db.prepare(candidateQuery());
|
||||||
|
const todosCandidatos = campaign.segmentos
|
||||||
|
.flatMap((segmento) => statement.all(campaign.produto, segmento, campaign.produto, campaign.produto))
|
||||||
|
.sort((a, b) => a.prioridade - b.prioridade || b.score - a.score || a.id - b.id);
|
||||||
|
const candidatos = todosCandidatos.slice(0, quantidade);
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO outreach_campaign_queue
|
||||||
|
(campaign_id, lead_id, variant_id, segmento, numero, rendered_message, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'preview')`
|
||||||
|
);
|
||||||
|
let adicionados = 0;
|
||||||
|
for (const lead of candidatos) {
|
||||||
|
const variant = variantForLead(campaign.variants, lead.id);
|
||||||
|
const renderedMessage = renderCampaignMessage(variant, lead);
|
||||||
|
insert.run(campaignId, lead.id, variant.id, lead.segmento, lead.whatsapp, renderedMessage);
|
||||||
|
adicionados += 1;
|
||||||
|
}
|
||||||
|
return { candidatosEncontrados: todosCandidatos.length, adicionadosNaFila: adicionados };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listQueue(db, campaignId, status = null) {
|
||||||
|
if (status) {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? AND status = ? ORDER BY id'
|
||||||
|
).all(campaignId, status);
|
||||||
|
}
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id'
|
||||||
|
).all(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveQueueRows(db, campaignId, queueIds) {
|
||||||
|
if (!queueIds.length) return 0;
|
||||||
|
const placeholders = queueIds.map(() => '?').join(', ');
|
||||||
|
return db.prepare(
|
||||||
|
`UPDATE outreach_campaign_queue SET status = 'approved', updated_at = datetime('now')
|
||||||
|
WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})`
|
||||||
|
).run(campaignId, ...queueIds).changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelQueueRows(db, campaignId, queueIds, reason = 'operador') {
|
||||||
|
if (!queueIds.length) return 0;
|
||||||
|
const placeholders = queueIds.map(() => '?').join(', ');
|
||||||
|
return db.prepare(
|
||||||
|
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = ?, updated_at = datetime('now')
|
||||||
|
WHERE campaign_id = ? AND status IN ('preview','approved') AND id IN (${placeholders})`
|
||||||
|
).run(reason, campaignId, ...queueIds).changes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { runCampaignQueue } from './campaign-worker.mjs';
|
||||||
|
import { escalateWeeklyLimits } from './campaign-store.mjs';
|
||||||
|
|
||||||
|
const CICLO_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
|
// Timer interno separado do scheduler.mjs existente (run.mjs/runOutreachBatch)
|
||||||
|
// — mesmo padrao (setInterval com .unref(), primeiro ciclo antecipado pouco
|
||||||
|
// depois do boot), rodando ao lado sem interferir um no outro.
|
||||||
|
export function startCampaignScheduler({ db, log = () => {} }) {
|
||||||
|
const ciclo = () => {
|
||||||
|
escalateWeeklyLimits(db);
|
||||||
|
runCampaignQueue({ db, log }).catch((error) => {
|
||||||
|
log(`campanhas: erro inesperado no ciclo automatico: ${error.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const primeiro = setTimeout(ciclo, 45 * 1000);
|
||||||
|
primeiro.unref();
|
||||||
|
const timer = setInterval(ciclo, CICLO_MS);
|
||||||
|
timer.unref();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(primeiro);
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { transaction } from '../db.mjs';
|
||||||
|
|
||||||
|
export function createCampaign(db, {
|
||||||
|
nome, produto, segmentos, variantes, limiteDiarioInicial = 10, limiteDiarioTeto = 80
|
||||||
|
}) {
|
||||||
|
return transaction(db, () => {
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT INTO outreach_campaigns
|
||||||
|
(nome, produto, segmentos_json, limite_diario_atual, limite_diario_teto)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).run(nome, produto, JSON.stringify(segmentos || []), limiteDiarioInicial, limiteDiarioTeto);
|
||||||
|
const campaignId = Number(result.lastInsertRowid);
|
||||||
|
const insertVariant = db.prepare(
|
||||||
|
`INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const variante of variantes) {
|
||||||
|
insertVariant.run(campaignId, variante.code, variante.mensagem);
|
||||||
|
}
|
||||||
|
return getCampaign(db, campaignId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCampaign(db, id) {
|
||||||
|
const row = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id);
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
segmentos: JSON.parse(row.segmentos_json || '[]'),
|
||||||
|
variants: listVariants(db, id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCampaigns(db) {
|
||||||
|
return db.prepare('SELECT * FROM outreach_campaigns ORDER BY id DESC').all()
|
||||||
|
.map((row) => ({ ...row, segmentos: JSON.parse(row.segmentos_json || '[]') }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listVariants(db, campaignId) {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT * FROM outreach_campaign_variants WHERE campaign_id = ? AND ativo = 1 ORDER BY code'
|
||||||
|
).all(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// status: 'rascunho' -> 'rodando' -> 'pausado' <-> 'rodando' -> 'concluido'.
|
||||||
|
// pause_reason so fica setado quando o novo status e 'pausado'.
|
||||||
|
export function setCampaignStatus(db, campaignId, status, pauseReason = null) {
|
||||||
|
if (status === 'rodando') {
|
||||||
|
// When transitioning to 'rodando', stamp semana_iniciada_em only on first run (if currently NULL)
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE outreach_campaigns
|
||||||
|
SET status = ?, pause_reason = ?, semana_iniciada_em = COALESCE(semana_iniciada_em, datetime('now')), updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(status, status === 'pausado' ? pauseReason : null, campaignId);
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE outreach_campaigns SET status = ?, pause_reason = ?, updated_at = datetime('now') WHERE id = ?`
|
||||||
|
).run(status, status === 'pausado' ? pauseReason : null, campaignId);
|
||||||
|
}
|
||||||
|
return getCampaign(db, campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// So avanca campanha que estiver de fato pausada — retomar uma campanha em
|
||||||
|
// rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando').
|
||||||
|
export function resumeCampaign(db, campaignId) {
|
||||||
|
// Reseta semana_iniciada_em pra agora: uma campanha que ficou pausada (por
|
||||||
|
// saude ruim ou motivo manual) por varios dias nao pode, ao ser retomada,
|
||||||
|
// já satisfazer o "rodando ha 7+ dias" do escalateWeeklyLimits e dobrar o
|
||||||
|
// limite diario logo de cara — isso inverteria a logica de seguranca.
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE outreach_campaigns
|
||||||
|
SET status = 'rodando', pause_reason = NULL, semana_iniciada_em = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND status = 'pausado'`
|
||||||
|
).run(campaignId);
|
||||||
|
return getCampaign(db, campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalonamento semanal: dobra o limite diario a cada 7 dias corridos rodando
|
||||||
|
// sem pausa desde semana_iniciada_em, ate o teto da campanha. So campanhas com
|
||||||
|
// status='rodando' avancam — uma pausada fica congelada ate o operador retomar.
|
||||||
|
export function escalateWeeklyLimits(db, now = new Date()) {
|
||||||
|
const campaigns = db.prepare(
|
||||||
|
`SELECT id, limite_diario_atual, limite_diario_teto, semana_iniciada_em
|
||||||
|
FROM outreach_campaigns WHERE status = 'rodando'`
|
||||||
|
).all();
|
||||||
|
let escaladas = 0;
|
||||||
|
for (const campaign of campaigns) {
|
||||||
|
if (!campaign.semana_iniciada_em) continue;
|
||||||
|
const dias = (now.getTime() - new Date(campaign.semana_iniciada_em.replace(' ', 'T') + 'Z').getTime()) / 86400000;
|
||||||
|
if (dias < 7) continue;
|
||||||
|
const novoLimite = Math.min(campaign.limite_diario_teto, campaign.limite_diario_atual * 2);
|
||||||
|
if (novoLimite === campaign.limite_diario_atual) continue;
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE outreach_campaigns
|
||||||
|
SET limite_diario_atual = ?, semana_iniciada_em = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(novoLimite, campaign.id);
|
||||||
|
escaladas += 1;
|
||||||
|
}
|
||||||
|
return escaladas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOptedOut(db, numero) {
|
||||||
|
return Boolean(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(numero));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addOptOut(db, numero, motivo = null) {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO outreach_opt_out (numero, motivo) VALUES (?, ?)
|
||||||
|
ON CONFLICT(numero) DO UPDATE SET motivo = excluded.motivo`
|
||||||
|
).run(numero, motivo);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOptOut(db, numero) {
|
||||||
|
db.prepare('DELETE FROM outreach_opt_out WHERE numero = ?').run(numero);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { createZproClient } from './zpro-client.mjs';
|
||||||
|
import { isOptedOut } from './campaign-store.mjs';
|
||||||
|
import { applyCampaignHealth } from './campaign-metrics.mjs';
|
||||||
|
import { getSetting } from '../db.mjs';
|
||||||
|
|
||||||
|
// Mesmo intervalo aleatorio entre envios do run.mjs (INTERVALO_MIN_MS/
|
||||||
|
// INTERVALO_MAX_MS), duplicado aqui de proposito — os dois workers devem se
|
||||||
|
// comportar de forma consistente contra o mesmo numero de WhatsApp, mas sao
|
||||||
|
// modulos independentes (nao importar um do outro).
|
||||||
|
const SLEEP_MIN_MS = 3000;
|
||||||
|
const SLEEP_MAX_MS = 8000;
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomSleepMs() {
|
||||||
|
return SLEEP_MIN_MS + Math.random() * (SLEEP_MAX_MS - SLEEP_MIN_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mesma logica de comparacao de string HH:MM do run.mjs.
|
||||||
|
function withinBusinessHours(horaInicio, horaFim, now = new Date()) {
|
||||||
|
const hhmm = now.toTimeString().slice(0, 5);
|
||||||
|
return hhmm >= horaInicio && hhmm <= horaFim;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayAttempts(db, campaignId) {
|
||||||
|
return Number(db.prepare(
|
||||||
|
`SELECT COUNT(*) AS total
|
||||||
|
FROM outreach_campaign_queue q
|
||||||
|
JOIN outreach_sends s ON s.id = q.outreach_send_id
|
||||||
|
WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = date('now', 'localtime')`
|
||||||
|
).get(campaignId).total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diferente da Maquina Pessoal (que reserva o envio antes de mandar, porque a
|
||||||
|
// entrega via Baileys e assincrona/incerta), aqui o envio via Z-PRO
|
||||||
|
// (zproClient.sendText) e sincrono: ou resolve (aceito pelo Z-PRO) ou lanca
|
||||||
|
// erro. Por isso o resultado ja e gravado direto como 'enviado'/'erro', sem
|
||||||
|
// um estado intermediario "reservado" nem logica de recuperacao pos-crash.
|
||||||
|
export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {}, sleepMs = null }) {
|
||||||
|
const client = zproClient || createZproClient(db);
|
||||||
|
const summary = { enviados: 0, erros: 0, pulados: 0 };
|
||||||
|
|
||||||
|
// Mesmas duas travas de seguranca do disparo automatico (run.mjs):
|
||||||
|
// interruptor geral e janela de horario comercial. Sem elas, o worker de
|
||||||
|
// campanha mandaria mensagem 24h/dia mesmo com o operador tendo desligado
|
||||||
|
// o outreach nas Configuracoes.
|
||||||
|
if (getSetting(db, 'outreach.ativo', 'false') !== 'true') {
|
||||||
|
log('campanhas: desativado (outreach.ativo=false)');
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const horaInicio = getSetting(db, 'outreach.horaInicio', '08:00');
|
||||||
|
const horaFim = getSetting(db, 'outreach.horaFim', '18:00');
|
||||||
|
if (!withinBusinessHours(horaInicio, horaFim, now)) {
|
||||||
|
log(`campanhas: fora do horario comercial (${horaInicio}-${horaFim})`);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaigns = db.prepare(`SELECT id FROM outreach_campaigns WHERE status = 'rodando'`).all();
|
||||||
|
for (const { id: campaignId } of campaigns) {
|
||||||
|
const campaign = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
||||||
|
let attemptsToday = todayAttempts(db, campaignId);
|
||||||
|
|
||||||
|
while (attemptsToday < campaign.limite_diario_atual) {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT * FROM outreach_campaign_queue
|
||||||
|
WHERE campaign_id = ? AND status = 'approved'
|
||||||
|
ORDER BY id ASC LIMIT 1`
|
||||||
|
).get(campaignId);
|
||||||
|
if (!row) break;
|
||||||
|
|
||||||
|
if (isOptedOut(db, row.numero)) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'opt_out', updated_at = datetime('now') WHERE id = ?`
|
||||||
|
).run(row.id);
|
||||||
|
summary.pulados += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jaEnviado = db.prepare(
|
||||||
|
`SELECT 1 FROM outreach_sends WHERE produto = ? AND status = 'enviado'
|
||||||
|
AND (lead_id = ? OR numero = ?) LIMIT 1`
|
||||||
|
).get(campaign.produto, row.lead_id, row.numero);
|
||||||
|
if (jaEnviado) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'ja_contatado', updated_at = datetime('now') WHERE id = ?`
|
||||||
|
).run(row.id);
|
||||||
|
summary.pulados += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalKey = `campanha-${campaignId}-${row.id}`;
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'nao_oficial', 'automatico')`
|
||||||
|
);
|
||||||
|
let sendId;
|
||||||
|
let queueStatus;
|
||||||
|
try {
|
||||||
|
const resposta = await client.sendText({ number: row.numero, message: row.rendered_message, externalKey });
|
||||||
|
const ticketId = resposta?.data?.ticketId ? String(resposta.data.ticketId) : null;
|
||||||
|
sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'enviado', null, ticketId).lastInsertRowid);
|
||||||
|
queueStatus = 'sent';
|
||||||
|
summary.enviados += 1;
|
||||||
|
} catch (error) {
|
||||||
|
sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'erro', String(error.message || error), null).lastInsertRowid);
|
||||||
|
queueStatus = 'error';
|
||||||
|
summary.erros += 1;
|
||||||
|
log(`campanha #${campaignId}: envio falhou (fila #${row.id}): ${error.message}`);
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE outreach_campaign_queue SET status = ?, outreach_send_id = ?, updated_at = datetime('now') WHERE id = ?`
|
||||||
|
).run(queueStatus, sendId, row.id);
|
||||||
|
|
||||||
|
attemptsToday += 1;
|
||||||
|
// Jitter entre tentativas reais de envio (nao entre pulos/cancelamentos,
|
||||||
|
// que nao batem no Z-PRO) — mesmo padrao de run.mjs, pra nao parecer
|
||||||
|
// disparo automatizado agressivo pro mesmo numero de WhatsApp.
|
||||||
|
await sleep(sleepMs != null ? sleepMs : randomSleepMs());
|
||||||
|
|
||||||
|
const health = applyCampaignHealth(db, campaignId, now);
|
||||||
|
if (health.pause) {
|
||||||
|
log(`campanha #${campaignId}: pausada automaticamente (${health.reason})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { getSetting, listMessageTemplates } from '../db.mjs';
|
||||||
|
import { renderTemplate } from './templates.mjs';
|
||||||
|
import { createZproClient } from './zpro-client.mjs';
|
||||||
|
|
||||||
|
// Intervalo aleatorio entre envios (mesmo padrao dos endpoints de bulk do
|
||||||
|
// proprio Z-PRO) para nao parecer disparo automatizado agressivo.
|
||||||
|
const INTERVALO_MIN_MS = 3000;
|
||||||
|
const INTERVALO_MAX_MS = 8000;
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function withinBusinessHours(horaInicio, horaFim, now = new Date()) {
|
||||||
|
const hhmm = now.toTimeString().slice(0, 5);
|
||||||
|
return hhmm >= horaInicio && hhmm <= horaFim;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastEnviado(db, leadId, produto) {
|
||||||
|
return db.prepare(
|
||||||
|
"SELECT * FROM outreach_sends WHERE lead_id = ? AND produto = ? AND status = 'enviado' ORDER BY sent_at DESC LIMIT 1"
|
||||||
|
).get(leadId, produto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide qual produto tentar pra um lead, olhando o historico de envios:
|
||||||
|
// - cascata desligada (padrao): so tenta o produto principal, uma vez.
|
||||||
|
// - cascata ligada: percorre os produtos em ordem de estrelas; usa o primeiro
|
||||||
|
// nunca enviado, ou pula pro proximo se o ultimo envio ja passou do prazo
|
||||||
|
// de espera. Se o produto atual ainda esta "no prazo", o lead e pulado
|
||||||
|
// nessa rodada (nao aumenta o consumo do limite diario).
|
||||||
|
function pickProduto(db, lead, { tentarProximoProduto, diasEntreProdutos }) {
|
||||||
|
const produtos = JSON.parse(lead.produtos_json || '[]')
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (b.estrelas || 0) - (a.estrelas || 0))
|
||||||
|
.map((p) => p.nome)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!produtos.length) return null;
|
||||||
|
|
||||||
|
if (tentarProximoProduto !== 'true') {
|
||||||
|
const produto = produtos[0];
|
||||||
|
return lastEnviado(db, lead.id, produto) ? null : produto;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const produto of produtos) {
|
||||||
|
const ultimo = lastEnviado(db, lead.id, produto);
|
||||||
|
if (!ultimo) return produto;
|
||||||
|
const diasPassados = (Date.now() - new Date(`${ultimo.sent_at}Z`).getTime()) / 86400000;
|
||||||
|
if (diasPassados < Number(diasEntreProdutos)) return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityTagName(prioridade) {
|
||||||
|
return `GTI P${prioridade || 4}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function offerTagName(produto) {
|
||||||
|
return `GTI Oferta: ${produto}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateVariablesForLead(template, lead, produto) {
|
||||||
|
let configured = [];
|
||||||
|
try {
|
||||||
|
configured = JSON.parse(template.template_waba_variaveis_json || '[]');
|
||||||
|
} catch {
|
||||||
|
configured = [];
|
||||||
|
}
|
||||||
|
const variables = Array.isArray(configured) && configured.length
|
||||||
|
? configured
|
||||||
|
: ['{empresa}', '{produto}'];
|
||||||
|
const data = { ...lead, produto };
|
||||||
|
return variables
|
||||||
|
.map((value) => renderTemplate(String(value || ''), data).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache em memoria (por lote) de nome de tag -> id: evita chamar listTags a
|
||||||
|
// cada lead e evita criar a mesma tag duas vezes.
|
||||||
|
async function ensureTagIds(zproClient, nomes, cache) {
|
||||||
|
if (!cache.loaded) {
|
||||||
|
const resposta = await zproClient.listTags({ isActive: true });
|
||||||
|
const lista = Array.isArray(resposta) ? resposta : resposta?.data || resposta?.tags || [];
|
||||||
|
for (const tag of lista) {
|
||||||
|
if (tag?.tag) cache.map.set(tag.tag, tag.id);
|
||||||
|
}
|
||||||
|
cache.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [];
|
||||||
|
for (const nome of nomes) {
|
||||||
|
let id = cache.map.get(nome);
|
||||||
|
if (!id) {
|
||||||
|
const criada = await zproClient.createTag({ tag: nome });
|
||||||
|
id = criada?.id || criada?.data?.id;
|
||||||
|
if (id) cache.map.set(nome, id);
|
||||||
|
}
|
||||||
|
if (id) ids.push(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Um lote de disparo automatico: verifica se esta ligado/dentro do horario/
|
||||||
|
// dentro do limite diario, escolhe candidatos elegiveis e manda uma mensagem
|
||||||
|
// por lead via bulkIndividual, registrando tudo em outreach_sends e aplicando
|
||||||
|
// tags de prioridade + oferta no contato do Z-PRO.
|
||||||
|
export async function runOutreachBatch({ db, zproClient = createZproClient(db), log = () => {} }) {
|
||||||
|
const resumo = { tentados: 0, enviados: 0, erros: 0, pulados: 0 };
|
||||||
|
|
||||||
|
if (getSetting(db, 'outreach.ativo', 'false') !== 'true') {
|
||||||
|
log('outreach: desativado (outreach.ativo=false)');
|
||||||
|
return resumo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const horaInicio = getSetting(db, 'outreach.horaInicio', '08:00');
|
||||||
|
const horaFim = getSetting(db, 'outreach.horaFim', '18:00');
|
||||||
|
if (!withinBusinessHours(horaInicio, horaFim)) {
|
||||||
|
log(`outreach: fora do horario comercial (${horaInicio}-${horaFim})`);
|
||||||
|
return resumo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limiteDiario = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
|
||||||
|
const jaEnviadosHoje = db.prepare(
|
||||||
|
"SELECT COUNT(*) AS t FROM outreach_sends WHERE status = 'enviado' AND date(sent_at) = date('now')"
|
||||||
|
).get().t;
|
||||||
|
let restante = limiteDiario - jaEnviadosHoje;
|
||||||
|
if (restante <= 0) {
|
||||||
|
log(`outreach: limite diario atingido (${jaEnviadosHoje}/${limiteDiario})`);
|
||||||
|
return resumo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tentarProximoProduto = getSetting(db, 'outreach.tentarProximoProduto', 'false');
|
||||||
|
const diasEntreProdutos = getSetting(db, 'outreach.diasEntreProdutos', '5');
|
||||||
|
const templates = new Map(listMessageTemplates(db).map((t) => [t.produto, t]));
|
||||||
|
|
||||||
|
const candidatos = db.prepare(
|
||||||
|
`SELECT id, empresa, cidade, segmento, segmento_label, whatsapp, prioridade, produtos_json
|
||||||
|
FROM leads
|
||||||
|
WHERE exported_at IS NULL AND telefone_valido = 1 AND whatsapp != '' AND produto_principal != ''
|
||||||
|
ORDER BY prioridade ASC, score DESC`
|
||||||
|
).all();
|
||||||
|
|
||||||
|
const insertSend = db.prepare(
|
||||||
|
`INSERT INTO outreach_sends (lead_id, produto, mensagem_enviada, status, erro, zpro_ticket_id, canal)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
const tagCache = { loaded: false, map: new Map() };
|
||||||
|
|
||||||
|
for (let i = 0; i < candidatos.length; i += 1) {
|
||||||
|
const lead = candidatos[i];
|
||||||
|
if (restante <= 0) break;
|
||||||
|
|
||||||
|
const produto = pickProduto(db, lead, { tentarProximoProduto, diasEntreProdutos });
|
||||||
|
if (!produto) {
|
||||||
|
resumo.pulados += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = templates.get(produto);
|
||||||
|
if (!template || !template.ativo) {
|
||||||
|
resumo.pulados += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canal = template.canal === 'oficial' ? 'oficial' : 'nao_oficial';
|
||||||
|
if (canal === 'oficial' && !template.template_waba_nome) {
|
||||||
|
log(`outreach: produto "${produto}" no canal oficial sem template aprovado configurado — lead #${lead.id} pulado`);
|
||||||
|
resumo.pulados += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resumo.tentados += 1;
|
||||||
|
const externalKey = `gti-lead-${lead.id}-${produto}-${Date.now()}`;
|
||||||
|
const mensagem = canal === 'oficial'
|
||||||
|
? `template:${template.template_waba_nome}/${template.template_waba_idioma}`
|
||||||
|
: renderTemplate(template.mensagem, lead);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resposta = canal === 'oficial'
|
||||||
|
? await zproClient.sendTemplateWaba({
|
||||||
|
number: lead.whatsapp,
|
||||||
|
templateName: template.template_waba_nome,
|
||||||
|
languageCode: template.template_waba_idioma,
|
||||||
|
variables: templateVariablesForLead(template, lead, produto)
|
||||||
|
})
|
||||||
|
: await zproClient.sendText({ number: lead.whatsapp, message: mensagem, externalKey });
|
||||||
|
const ticketId = resposta?.ticketId || resposta?.data?.ticketId ? String(resposta.ticketId || resposta.data.ticketId) : null;
|
||||||
|
insertSend.run(lead.id, produto, mensagem, 'enviado', null, ticketId, canal);
|
||||||
|
resumo.enviados += 1;
|
||||||
|
restante -= 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tagIds = await ensureTagIds(
|
||||||
|
zproClient,
|
||||||
|
[priorityTagName(lead.prioridade), offerTagName(produto)],
|
||||||
|
tagCache
|
||||||
|
);
|
||||||
|
if (tagIds.length) await zproClient.addTagContact({ number: lead.whatsapp, tagIds });
|
||||||
|
} catch (tagError) {
|
||||||
|
log(`outreach: falha ao aplicar tags do lead #${lead.id}: ${tagError.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
insertSend.run(lead.id, produto, mensagem, 'erro', String(error.message || error), null, canal);
|
||||||
|
resumo.erros += 1;
|
||||||
|
log(`outreach: erro ao enviar lead #${lead.id} (${produto}): ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restante > 0 && i < candidatos.length - 1) {
|
||||||
|
await sleep(INTERVALO_MIN_MS + Math.random() * (INTERVALO_MAX_MS - INTERVALO_MIN_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`outreach: ${resumo.enviados} enviados, ${resumo.erros} erros, ${resumo.pulados} pulados (tentados ${resumo.tentados})`);
|
||||||
|
return resumo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { runOutreachBatch } from './run.mjs';
|
||||||
|
|
||||||
|
const CICLO_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
|
// Timer interno no processo do servidor: nao depende de agendador do sistema
|
||||||
|
// operacional (Task Scheduler/cron), entao funciona identico local (Windows)
|
||||||
|
// ou dentro de um container Docker apos a migracao — so precisa do servidor
|
||||||
|
// estar de pe, o que ja e verdade hoje. runOutreachBatch e idempotente e sai
|
||||||
|
// cedo quando desligado/fora de horario/limite batido, entao rodar a cada
|
||||||
|
// 20 min e seguro sem lock ou fila.
|
||||||
|
export function startOutreachScheduler({ db, log = () => {} }) {
|
||||||
|
const ciclo = () => {
|
||||||
|
runOutreachBatch({ db, log }).catch((error) => {
|
||||||
|
log(`outreach: erro inesperado no ciclo automatico: ${error.message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// roda um primeiro ciclo pouco depois do boot (idempotente/seguro), sem
|
||||||
|
// esperar os 20 min inteiros para o primeiro lote do dia.
|
||||||
|
const primeiro = setTimeout(ciclo, 30 * 1000);
|
||||||
|
primeiro.unref();
|
||||||
|
|
||||||
|
const timer = setInterval(ciclo, CICLO_MS);
|
||||||
|
timer.unref();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(primeiro);
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export const COMMERCIAL_VARIABLES = [
|
||||||
|
{ key: 'name', label: 'Nome do WhatsApp/contato' },
|
||||||
|
{ key: 'empresa', label: 'Nome da empresa' },
|
||||||
|
{ key: 'businessName', label: 'Nome da empresa (GChat)' },
|
||||||
|
{ key: 'firstName', label: 'Primeiro nome' },
|
||||||
|
{ key: 'produto', label: 'Produto recomendado' },
|
||||||
|
{ key: 'protocol', label: 'Assunto/protocolo' },
|
||||||
|
{ key: 'cidade', label: 'Cidade' },
|
||||||
|
{ key: 'uf', label: 'UF' },
|
||||||
|
{ key: 'segmento', label: 'Nicho/segmento' },
|
||||||
|
{ key: 'cnpj', label: 'CNPJ' },
|
||||||
|
{ key: 'email', label: 'E-mail' },
|
||||||
|
{ key: 'phoneNumber', label: 'WhatsApp/telefone' },
|
||||||
|
{ key: 'whatsapp', label: 'WhatsApp/telefone' },
|
||||||
|
{ key: 'kanban', label: 'Produto/interesse' },
|
||||||
|
{ key: 'user', label: 'Usuário/remetente' },
|
||||||
|
{ key: 'userEmail', label: 'E-mail do remetente' },
|
||||||
|
{ key: 'greeting', label: 'Saudação' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function commercialVariableValues(lead = {}) {
|
||||||
|
const empresa = lead.empresa || lead.businessName || lead.name || 'sua empresa';
|
||||||
|
const produto = lead.produto || lead.produto_principal || lead.interesse || 'Atendimento comercial';
|
||||||
|
const cidade = lead.cidade || 'sua regiao';
|
||||||
|
const segmento = lead.segmento_label || lead.segmento || '';
|
||||||
|
const phoneNumber = lead.whatsapp || lead.phoneNumber || '';
|
||||||
|
const firstName = String(lead.name || empresa).split(/\s+/).filter(Boolean)[0] || empresa;
|
||||||
|
return {
|
||||||
|
name: lead.name || empresa,
|
||||||
|
empresa,
|
||||||
|
businessName: empresa,
|
||||||
|
firstName,
|
||||||
|
lastName: lead.lastName || '',
|
||||||
|
produto,
|
||||||
|
protocol: lead.protocol || produto,
|
||||||
|
cidade,
|
||||||
|
uf: lead.uf || '',
|
||||||
|
segmento,
|
||||||
|
cnpj: lead.cnpj || '',
|
||||||
|
email: lead.email || '',
|
||||||
|
phoneNumber,
|
||||||
|
whatsapp: phoneNumber,
|
||||||
|
kanban: lead.kanban || produto,
|
||||||
|
user: lead.user || 'Grupo GTI',
|
||||||
|
userEmail: lead.userEmail || 'comercial@gtigrupo.com.br',
|
||||||
|
greeting: 'Ola'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substituicao simples de variaveis no texto do template. Cada mensagem ja e
|
||||||
|
// resolvida por completo antes de chamar o Z-PRO (bulkIndividual manda texto
|
||||||
|
// pronto), entao nao dependemos da substituicao posicional por CSV que o
|
||||||
|
// bulkSendMessageWithVariable do Z-PRO usa.
|
||||||
|
export function renderTemplate(mensagem, lead) {
|
||||||
|
const values = commercialVariableValues(lead);
|
||||||
|
let output = String(mensagem || '')
|
||||||
|
.replaceAll('{empresa}', values.empresa)
|
||||||
|
.replaceAll('{cidade}', values.cidade)
|
||||||
|
.replaceAll('{produto}', values.produto)
|
||||||
|
.replaceAll('{segmento}', values.segmento);
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
output = output.replaceAll(`{{${key}}}`, value);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { getSetting, setSetting } from '../db.mjs';
|
||||||
|
|
||||||
|
export const DEFAULT_WABA_TEMPLATES = [
|
||||||
|
{ name: 'gti_inicio_atendimento_solicitado', label: 'Inicio de atendimento solicitado', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', 'Atendimento sobre {produto}'] },
|
||||||
|
{ name: 'gti_agendamento_suporte_remoto', label: 'Agendamento de suporte remoto', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}'] },
|
||||||
|
{ name: 'gti_alerta_instabilidade_cliente', label: 'Alerta de instabilidade', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] },
|
||||||
|
{ name: 'gti_aviso_pendente_usuario', label: 'Aviso pendente usuario', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] },
|
||||||
|
{ name: 'gti_chamado_em_andamento', label: 'Chamado em andamento', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}', '{produto}'] },
|
||||||
|
{ name: 'confirma_suporte', label: 'Confirma suporte', language: 'pt_BR', route: 'templateBody', variables: ['{empresa}'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
function defaultVariable(index) {
|
||||||
|
if (index === 0) return '{empresa}';
|
||||||
|
if (index === 1) return '{produto}';
|
||||||
|
return 'Informacao';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplate(raw) {
|
||||||
|
const name = raw?.name || raw?.templateName || raw?.elementName;
|
||||||
|
if (!name || typeof name !== 'string') return null;
|
||||||
|
const language = raw?.language || raw?.languageCode || raw?.lang || raw?.locale || 'pt_BR';
|
||||||
|
const category = raw?.category || raw?.templateCategory || '';
|
||||||
|
const bodyComponent = Array.isArray(raw?.components)
|
||||||
|
? raw.components.find((component) => String(component?.type || '').toUpperCase() === 'BODY')
|
||||||
|
: null;
|
||||||
|
const text = raw?.text || raw?.body || raw?.content || raw?.templateText || bodyComponent?.text || '';
|
||||||
|
const matches = String(text).match(/{{\s*\d+\s*}}/g) || [];
|
||||||
|
const count = Math.max(...matches.map((match) => Number(match.replace(/\D/g, ''))), 0);
|
||||||
|
const variables = Array.from({ length: count }, (_, index) => defaultVariable(index));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
label: raw?.label || raw?.title || name,
|
||||||
|
language,
|
||||||
|
category,
|
||||||
|
status: raw?.status || raw?.templateStatus || '',
|
||||||
|
route: String(category).toUpperCase() === 'MARKETING' ? 'templateMarketingBody' : 'templateBody',
|
||||||
|
variables,
|
||||||
|
text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTemplates(value, out = []) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) collectTemplates(item, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object') return out;
|
||||||
|
const normalized = normalizeTemplate(value);
|
||||||
|
if (normalized) out.push(normalized);
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
if (child && typeof child === 'object') collectTemplates(child, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueByName(templates) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const template of templates) {
|
||||||
|
if (!template?.name) continue;
|
||||||
|
const count = Math.max(
|
||||||
|
Array.isArray(template.variables) ? template.variables.length : 0,
|
||||||
|
...((String(template.text || '').match(/{{\s*\d+\s*}}/g) || []).map((match) => Number(match.replace(/\D/g, '')))),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
template.variables = Array.from({ length: count }, (_, index) => String(template.variables?.[index] || '').trim() || defaultVariable(index));
|
||||||
|
map.set(`${template.name}:${template.language || 'pt_BR'}`, template);
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWabaTemplateLibrary(db) {
|
||||||
|
let synced = [];
|
||||||
|
try {
|
||||||
|
synced = JSON.parse(getSetting(db, 'gchat.wabaTemplatesJson', '[]'));
|
||||||
|
} catch {
|
||||||
|
synced = [];
|
||||||
|
}
|
||||||
|
return uniqueByName([...DEFAULT_WABA_TEMPLATES, ...synced]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncWabaTemplateLibrary(db) {
|
||||||
|
const url = getSetting(db, 'gchat.templatesUrl', '').trim();
|
||||||
|
const token = getSetting(db, 'gchat.bearerToken', '').trim().replace(/^Bearer\s+/i, '');
|
||||||
|
if (!url) {
|
||||||
|
return { ok: false, erro: 'configure gchat.templatesUrl com a URL de listagem de templates do GChat' };
|
||||||
|
}
|
||||||
|
const headers = { accept: 'application/json' };
|
||||||
|
if (token) headers.authorization = `Bearer ${token}`;
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return { ok: false, erro: `GChat retornou resposta nao-JSON (${response.status})` };
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, erro: `GChat templates falhou (${response.status}): ${JSON.stringify(data).slice(0, 300)}` };
|
||||||
|
}
|
||||||
|
const templates = uniqueByName(collectTemplates(data));
|
||||||
|
setSetting(db, 'gchat.wabaTemplatesJson', JSON.stringify(templates));
|
||||||
|
setSetting(db, 'gchat.templatesLastSync', new Date().toISOString());
|
||||||
|
return { ok: true, total: templates.length, templates };
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { envString } from '../env.mjs';
|
||||||
|
import { getSetting } from '../db.mjs';
|
||||||
|
|
||||||
|
// Cliente fino para a API externa do Z-PRO (docs/referencia/Z-PRO.postman_collection.json).
|
||||||
|
// Todas as rotas: POST/GET https://{BaseUrl}/v2/api/external/{ApiID}/<acao>, auth Bearer.
|
||||||
|
// Credenciais vem da tela de Configuracoes > Credenciais Z-PRO (tabela settings,
|
||||||
|
// editavel sem reiniciar o servidor) — se estiverem vazias la, cai pro .env
|
||||||
|
// (ZPRO_BASE_URL/ZPRO_API_ID/ZPRO_BEARER_TOKEN/ZPRO_WHATSAPP_ID), util pra
|
||||||
|
// deploy via variavel de ambiente (Docker/Proxmox) sem depender da tela.
|
||||||
|
|
||||||
|
// Aceita tanto "seu-dominio.com.br" quanto "https://seu-dominio.com.br/" (o
|
||||||
|
// que o usuario colar) — sem isso, colar a URL com protocolo virava
|
||||||
|
// "https://https://..." na hora de montar a chamada e todo teste falhava.
|
||||||
|
function normalizeBaseUrl(value) {
|
||||||
|
return value.trim().replace(/^https?:\/\//i, '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBearerToken(value) {
|
||||||
|
return String(value || '').trim().replace(/^Bearer\s+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTicketId(data) {
|
||||||
|
if (data?.data?.ticketId) return String(data.data.ticketId);
|
||||||
|
if (data?.data?.id) return String(data.data.id);
|
||||||
|
|
||||||
|
const message = data?.data?.message;
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
const jsonStart = message.indexOf('{');
|
||||||
|
if (jsonStart !== -1) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message.slice(jsonStart));
|
||||||
|
if (parsed?.id) return String(parsed.id);
|
||||||
|
if (parsed?.ticketId) return String(parsed.ticketId);
|
||||||
|
} catch {
|
||||||
|
// Algumas respostas do Z-PRO embutem JSON em texto livre; se falhar,
|
||||||
|
// seguimos sem ticketId em vez de rejeitar uma chamada bem-sucedida.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateRoute(route) {
|
||||||
|
const allowed = new Set(['template', 'templateBody', 'templateMarketingBody']);
|
||||||
|
return allowed.has(route) ? route : 'template';
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(db) {
|
||||||
|
const fromDbOrEnv = (settingKey, envKey) => {
|
||||||
|
const fromDb = db ? getSetting(db, settingKey, '') : '';
|
||||||
|
return fromDb || envString(envKey, '');
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
baseUrl: normalizeBaseUrl(fromDbOrEnv('zpro.baseUrl', 'ZPRO_BASE_URL')),
|
||||||
|
apiId: fromDbOrEnv('zpro.apiId', 'ZPRO_API_ID'),
|
||||||
|
bearerToken: normalizeBearerToken(fromDbOrEnv('zpro.bearerToken', 'ZPRO_BEARER_TOKEN')),
|
||||||
|
whatsappId: fromDbOrEnv('zpro.whatsappId', 'ZPRO_WHATSAPP_ID')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zproDiagnostics(db) {
|
||||||
|
const { baseUrl, apiId, bearerToken, whatsappId } = config(db);
|
||||||
|
return {
|
||||||
|
baseUrl: baseUrl || '',
|
||||||
|
apiId: apiId ? `${apiId.slice(0, 8)}...` : '',
|
||||||
|
token: bearerToken ? `${bearerToken.slice(0, 4)}...${bearerToken.slice(-4)} (${bearerToken.length} chars)` : '',
|
||||||
|
whatsappId: whatsappId || '',
|
||||||
|
configured: Boolean(baseUrl && apiId && bearerToken),
|
||||||
|
hasWhatsappId: Boolean(whatsappId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zproConfigured(db) {
|
||||||
|
const { baseUrl, apiId, bearerToken } = config(db);
|
||||||
|
return Boolean(baseUrl && apiId && bearerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(db, action, { method = 'POST', body, query } = {}) {
|
||||||
|
const { baseUrl, apiId, bearerToken } = config(db);
|
||||||
|
if (!baseUrl || !apiId || !bearerToken) {
|
||||||
|
throw new Error('Z-PRO nao configurado: preencha as credenciais em Configuracoes > Credenciais Z-PRO (ou ZPRO_BASE_URL/ZPRO_API_ID/ZPRO_BEARER_TOKEN no .env)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = action ? `${apiId}/${action}` : apiId;
|
||||||
|
const url = new URL(`https://${baseUrl}/v2/api/external/${path}`);
|
||||||
|
if (query) {
|
||||||
|
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${bearerToken}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: method === 'GET' ? undefined : JSON.stringify(body ?? {})
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const apiError = data?.error || data?.message || data?.erro || '';
|
||||||
|
if (response.status === 401 || response.status === 403 || apiError === 'ERR_SESSION_NOT_AUTH_TOKEN') {
|
||||||
|
throw new Error([
|
||||||
|
`Z-PRO recusou a autenticacao (${response.status}${apiError ? ` / ${apiError}` : ''}).`,
|
||||||
|
'Confira se o Token e o API ID sao os de Admin > API do Z-PRO, nao o token de sessao/login.',
|
||||||
|
'Depois clique em "Testar conexao" antes de enviar mensagem.'
|
||||||
|
].join(' '));
|
||||||
|
}
|
||||||
|
if (apiError === 'ERR_SEND_TEMPLATE') {
|
||||||
|
throw new Error('Z-PRO recusou o template (ERR_SEND_TEMPLATE). Confirme se o API ID pertence ao mesmo canal WABA onde o template foi aprovado, se o idioma esta correto e se todas as variaveis do template foram preenchidas.');
|
||||||
|
}
|
||||||
|
throw new Error(`Z-PRO ${action} falhou (${response.status}): ${JSON.stringify(data).slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
if (data?.success === false || data?.error) {
|
||||||
|
const apiError = data?.error || data?.message || data?.erro || '';
|
||||||
|
if (apiError === 'ERR_SEND_TEMPLATE') {
|
||||||
|
throw new Error('Z-PRO recusou o template (ERR_SEND_TEMPLATE). Confirme se o API ID pertence ao mesmo canal WABA onde o template foi aprovado, se o idioma esta correto e se todas as variaveis do template foram preenchidas.');
|
||||||
|
}
|
||||||
|
throw new Error(`Z-PRO ${action} falhou: ${JSON.stringify(data).slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cliente com as credenciais (settings + fallback .env) resolvidas uma vez e
|
||||||
|
// presas via closure — assim cada chamada do lote de outreach ja usa as
|
||||||
|
// credenciais atuais da tela de Configuracoes, sem precisar reiniciar o servidor.
|
||||||
|
export function createZproClient(db) {
|
||||||
|
return {
|
||||||
|
// Manda uma unica mensagem de texto pronta para um numero (docs: bulkIndividual).
|
||||||
|
// externalKey permite correlacionar a resposta/webhook do Z-PRO com o envio.
|
||||||
|
bulkIndividual({ number, message, externalKey }) {
|
||||||
|
const { whatsappId } = config(db);
|
||||||
|
return call(db, 'bulkIndividual', { body: { whatsappId, number, message, externalKey } });
|
||||||
|
},
|
||||||
|
|
||||||
|
sendText({ number, message, externalKey, isClosed = false }) {
|
||||||
|
return call(db, '', { body: { body: message, number, externalKey, isClosed, validateNumber: true } });
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTicket({ number, message, externalKey, userId, channelId, queueId, status = 'pending' }) {
|
||||||
|
const body = {
|
||||||
|
body: message,
|
||||||
|
number,
|
||||||
|
externalKey,
|
||||||
|
userId: Number(userId),
|
||||||
|
status,
|
||||||
|
validateNumber: true
|
||||||
|
};
|
||||||
|
if (channelId) body.channelId = Number(channelId);
|
||||||
|
if (queueId) body.queueId = Number(queueId);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await call(db, 'createTicket', { body });
|
||||||
|
} catch (error) {
|
||||||
|
if (!channelId || !String(error.message || '').includes('createTicket falhou')) throw error;
|
||||||
|
const fallbackBody = { ...body };
|
||||||
|
delete fallbackBody.channelId;
|
||||||
|
response = await call(db, 'createTicket', { body: fallbackBody });
|
||||||
|
}
|
||||||
|
return { ...response, ticketId: parseTicketId(response) };
|
||||||
|
},
|
||||||
|
|
||||||
|
listTags({ isActive = true } = {}) {
|
||||||
|
return call(db, 'listTags', { method: 'GET', query: { isActive: String(isActive) } });
|
||||||
|
},
|
||||||
|
|
||||||
|
createTag({ tag, color = '#2A78D6', isActive = true }) {
|
||||||
|
return call(db, 'createTag', { body: { tag, color, isActive } });
|
||||||
|
},
|
||||||
|
|
||||||
|
addTagContact({ number, tagIds }) {
|
||||||
|
return call(db, 'addTagContact', { body: { number, tagIds, validateNumber: true } });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manda um template ja aprovado no Meta Business Manager (canal oficial/WABA).
|
||||||
|
// Diferente de bulkIndividual: nao aceita texto livre, so nome+idioma de um
|
||||||
|
// template que ja existe e ja foi aprovado (a aprovacao e feita fora do app).
|
||||||
|
sendTemplateWaba({ number, templateName, languageCode = 'pt_BR', route = 'template', variables = [] }) {
|
||||||
|
const template = { name: templateName, language: { code: languageCode } };
|
||||||
|
const bodyParameters = variables
|
||||||
|
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
|
||||||
|
.map((value) => ({ type: 'text', text: String(value) }));
|
||||||
|
|
||||||
|
if (bodyParameters.length) {
|
||||||
|
template.components = [{ type: 'body', parameters: bodyParameters }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return call(db, normalizeTemplateRoute(route), {
|
||||||
|
body: {
|
||||||
|
number,
|
||||||
|
isClosed: false,
|
||||||
|
validateNumber: true,
|
||||||
|
templateData: {
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
to: number,
|
||||||
|
type: 'template',
|
||||||
|
template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,130 @@
|
|||||||
|
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 { registerOutreachRoutes } from './routes/outreach.mjs';
|
||||||
|
import { registerCampaignRoutes } from './routes/campaigns.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);
|
||||||
|
registerOutreachRoutes(router, deps);
|
||||||
|
registerCampaignRoutes(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,162 @@
|
|||||||
|
import {
|
||||||
|
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
|
||||||
|
addOptOut, removeOptOut
|
||||||
|
} from '../../outreach/campaign-store.mjs';
|
||||||
|
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../../outreach/campaign-queue.mjs';
|
||||||
|
import { evaluateCampaignHealth, variantStats } from '../../outreach/campaign-metrics.mjs';
|
||||||
|
import { onlyDigits } from '../../scoring.mjs';
|
||||||
|
|
||||||
|
export function registerCampaignRoutes(router, { db }) {
|
||||||
|
router.add('POST', '/api/campaigns', async (req, res, { json, body }) => {
|
||||||
|
const nome = String(body?.nome || '').trim();
|
||||||
|
const produto = String(body?.produto || '').trim();
|
||||||
|
const segmentos = Array.isArray(body?.segmentos) ? body.segmentos.filter(Boolean) : [];
|
||||||
|
const variantesValidas = Array.isArray(body?.variantes)
|
||||||
|
? body.variantes.filter((v) => v && String(v.mensagem || '').trim())
|
||||||
|
: [];
|
||||||
|
if (!nome || !produto) { json(400, { erro: 'informe nome e produto' }); return; }
|
||||||
|
if (!segmentos.length) { json(400, { erro: 'escolha ao menos um segmento' }); return; }
|
||||||
|
if (!variantesValidas.length) { json(400, { erro: 'escreva ao menos uma variante de mensagem' }); return; }
|
||||||
|
|
||||||
|
const campanha = createCampaign(db, {
|
||||||
|
nome, produto, segmentos,
|
||||||
|
variantes: variantesValidas.map((v, i) => ({ code: v.code || String.fromCharCode(65 + i), mensagem: v.mensagem })),
|
||||||
|
limiteDiarioInicial: Number(body.limiteDiarioInicial) || 10,
|
||||||
|
limiteDiarioTeto: Number(body.limiteDiarioTeto) || 80
|
||||||
|
});
|
||||||
|
json(200, campanha);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/campaigns', async (req, res, { json }) => {
|
||||||
|
json(200, { campanhas: listCampaigns(db) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Precisa vir ANTES de GET /api/campaigns/:id (mesmo numero de segmentos no
|
||||||
|
// path — o router retorna a primeira rota que casar).
|
||||||
|
router.add('GET', '/api/campaigns/segmentos', async (req, res, { json, query }) => {
|
||||||
|
const produto = String(query?.get('produto') || '').trim();
|
||||||
|
if (!produto) { json(400, { erro: 'informe o produto' }); return; }
|
||||||
|
const segmentos = db.prepare(
|
||||||
|
`SELECT segmento AS value, COUNT(*) AS total FROM leads
|
||||||
|
WHERE produto_principal = ? AND segmento != '' GROUP BY segmento ORDER BY total DESC`
|
||||||
|
).all(produto);
|
||||||
|
json(200, { segmentos });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/campaigns/produtos', async (req, res, { json }) => {
|
||||||
|
const produtos = db.prepare(
|
||||||
|
`SELECT produto_principal AS value, COUNT(*) AS total FROM leads
|
||||||
|
WHERE produto_principal != '' GROUP BY produto_principal ORDER BY total DESC`
|
||||||
|
).all();
|
||||||
|
json(200, { produtos });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/campaigns/:id', async (req, res, { json, params }) => {
|
||||||
|
const campanha = getCampaign(db, Number(params.id));
|
||||||
|
if (!campanha) { json(404, { erro: 'campanha nao encontrada' }); return; }
|
||||||
|
json(200, campanha);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/:id/gerar-fila', async (req, res, { json, params, body }) => {
|
||||||
|
try {
|
||||||
|
const resultado = generateCampaignQueue(db, Number(params.id), { quantidade: Number(body?.quantidade) || 50 });
|
||||||
|
json(200, resultado);
|
||||||
|
} catch (error) {
|
||||||
|
json(400, { erro: String(error.message || error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/campaigns/:id/fila', async (req, res, { json, params, query }) => {
|
||||||
|
json(200, { fila: listQueue(db, Number(params.id), query?.get('status') || null) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/:id/fila/aprovar', async (req, res, { json, params, body }) => {
|
||||||
|
const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : [];
|
||||||
|
json(200, { aprovados: approveQueueRows(db, Number(params.id), queueIds) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/:id/fila/cancelar', async (req, res, { json, params, body }) => {
|
||||||
|
const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : [];
|
||||||
|
json(200, { cancelados: cancelQueueRows(db, Number(params.id), queueIds, body?.motivo || 'operador') });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/:id/iniciar', async (req, res, { json, params }) => {
|
||||||
|
json(200, setCampaignStatus(db, Number(params.id), 'rodando'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/:id/pausar', async (req, res, { json, params, body }) => {
|
||||||
|
json(200, setCampaignStatus(db, Number(params.id), 'pausado', body?.motivo || 'manual'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/:id/retomar', async (req, res, { json, params }) => {
|
||||||
|
json(200, resumeCampaign(db, Number(params.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// So le o status atual — nao aplica pausa. A aplicacao de pausa (mutacao)
|
||||||
|
// acontece exclusivamente no worker (applyCampaignHealth), nunca como efeito
|
||||||
|
// colateral de abrir o painel de detalhe da campanha.
|
||||||
|
router.add('GET', '/api/campaigns/:id/saude', async (req, res, { json, params }) => {
|
||||||
|
json(200, evaluateCampaignHealth(db, Number(params.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/campaigns/:id/variantes-stats', async (req, res, { json, params }) => {
|
||||||
|
json(200, { variantes: variantStats(db, Number(params.id)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params, body }) => {
|
||||||
|
const stage = String(body?.stage || '').trim();
|
||||||
|
if (!stage) { json(400, { erro: 'informe o estagio' }); return; }
|
||||||
|
const leadId = Number(params.leadId);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO outreach_lead_states (lead_id, stage, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(lead_id) DO UPDATE SET stage = excluded.stage, updated_at = excluded.updated_at`
|
||||||
|
).run(leadId, stage);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note) VALUES (?, ?, ?, ?)`
|
||||||
|
).run(leadId, body?.campaignId ? Number(body.campaignId) : null, stage, body?.note || null);
|
||||||
|
// Marcar 'opt_out' via estagio precisa REALMENTE tirar o lead de circulacao
|
||||||
|
// (senao ele continua elegivel na candidateQuery/worker) — nao basta
|
||||||
|
// registrar o estagio, tem que gravar em outreach_opt_out tambem.
|
||||||
|
if (stage === 'opt_out') {
|
||||||
|
const lead = db.prepare('SELECT whatsapp FROM leads WHERE id = ?').get(leadId);
|
||||||
|
const numero = onlyDigits(lead?.whatsapp || '');
|
||||||
|
if (numero) addOptOut(db, numero, 'marcado via estagio');
|
||||||
|
}
|
||||||
|
json(200, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params }) => {
|
||||||
|
const leadId = Number(params.leadId);
|
||||||
|
json(200, {
|
||||||
|
atual: db.prepare('SELECT * FROM outreach_lead_states WHERE lead_id = ?').get(leadId) || null,
|
||||||
|
historico: db.prepare('SELECT * FROM outreach_stage_events WHERE lead_id = ? ORDER BY id DESC').all(leadId)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params, body }) => {
|
||||||
|
const kind = String(body?.kind || '').trim();
|
||||||
|
if (!kind) { json(400, { erro: 'informe o tipo de agendamento' }); return; }
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO outreach_appointments (lead_id, kind, scheduled_at, note) VALUES (?, ?, ?, ?)`
|
||||||
|
).run(Number(params.leadId), kind, body?.scheduledAt || null, body?.note || null);
|
||||||
|
json(200, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params }) => {
|
||||||
|
json(200, {
|
||||||
|
agendamentos: db.prepare('SELECT * FROM outreach_appointments WHERE lead_id = ? ORDER BY id DESC').all(Number(params.leadId))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/campaigns/opt-out', async (req, res, { json, body }) => {
|
||||||
|
const numero = onlyDigits(body?.numero || '');
|
||||||
|
if (!numero) { json(400, { erro: 'informe o numero' }); return; }
|
||||||
|
addOptOut(db, numero, body?.motivo || null);
|
||||||
|
json(200, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('DELETE', '/api/campaigns/opt-out/:numero', async (req, res, { json, params }) => {
|
||||||
|
removeOptOut(db, onlyDigits(params.numero));
|
||||||
|
json(200, { 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,155 @@
|
|||||||
|
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).
|
||||||
|
// Funcao (nao const) de proposito: os imports deste arquivo rodam antes do
|
||||||
|
// "await loadDotEnv()" em scripts/servidor.mjs (imports sao hoisted acima de
|
||||||
|
// qualquer top-level await), entao ler process.env.GTI_RESET_PASSWORD direto
|
||||||
|
// num const no topo do modulo sempre capturava undefined — nenhuma senha
|
||||||
|
// nunca batia, mesmo a correta. Lendo dentro da rota (call-time) o .env ja
|
||||||
|
// foi carregado quando a chamada realmente acontece.
|
||||||
|
function resetBasePassword() {
|
||||||
|
return 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.
|
||||||
|
// 403 (nao 401): o usuario ja esta autenticado, so a senha de confirmacao
|
||||||
|
// da acao esta errada. 401 aqui disparava o redirect global de "sessao
|
||||||
|
// expirada" do api.js antes do erro aparecer na tela — o usuario via a
|
||||||
|
// tela de login e achava que so tinha caido a sessao, sem saber que a
|
||||||
|
// limpeza nunca rodou.
|
||||||
|
router.add('POST', '/api/admin/reset', async (req, res, { json, body }) => {
|
||||||
|
if (!body || body.senha !== resetBasePassword()) {
|
||||||
|
json(403, { 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,205 @@
|
|||||||
|
import { getSetting, listMessageTemplates, updateMessageTemplate } from '../../db.mjs';
|
||||||
|
import { createZproClient, zproConfigured, zproDiagnostics } from '../../outreach/zpro-client.mjs';
|
||||||
|
import { listWabaTemplateLibrary, syncWabaTemplateLibrary } from '../../outreach/waba-template-library.mjs';
|
||||||
|
import { COMMERCIAL_VARIABLES, renderTemplate } from '../../outreach/templates.mjs';
|
||||||
|
import { validatePhone, onlyDigits } from '../../scoring.mjs';
|
||||||
|
|
||||||
|
function dailyLimitStatus(db) {
|
||||||
|
const limite = Number(getSetting(db, 'outreach.mensagensPorDia', '10')) || 0;
|
||||||
|
const enviadosHoje = db.prepare(
|
||||||
|
"SELECT COUNT(*) AS t FROM outreach_sends WHERE status = 'enviado' AND date(sent_at) = date('now')"
|
||||||
|
).get().t;
|
||||||
|
|
||||||
|
return { limite, enviadosHoje, restante: Math.max(0, limite - enviadosHoje) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerOutreachRoutes(router, { db }) {
|
||||||
|
router.add('GET', '/api/outreach/status', async (req, res, { json }) => {
|
||||||
|
const ativo = getSetting(db, 'outreach.ativo', 'false') === 'true';
|
||||||
|
const { limite, enviadosHoje, restante } = dailyLimitStatus(db);
|
||||||
|
|
||||||
|
const porProduto = db.prepare(
|
||||||
|
`SELECT produto, COUNT(*) AS total
|
||||||
|
FROM outreach_sends
|
||||||
|
WHERE status = 'enviado' AND date(sent_at) = date('now')
|
||||||
|
GROUP BY produto ORDER BY total DESC`
|
||||||
|
).all();
|
||||||
|
|
||||||
|
// LEFT JOIN (nao JOIN): envio manual nao tem lead_id, senao sumiria da lista.
|
||||||
|
const ultimos = db.prepare(
|
||||||
|
`SELECT outreach_sends.id, outreach_sends.produto, outreach_sends.status,
|
||||||
|
outreach_sends.sent_at, outreach_sends.canal, outreach_sends.origem,
|
||||||
|
outreach_sends.numero, outreach_sends.erro, leads.empresa, leads.cidade
|
||||||
|
FROM outreach_sends LEFT JOIN leads ON leads.id = outreach_sends.lead_id
|
||||||
|
ORDER BY outreach_sends.id DESC LIMIT 20`
|
||||||
|
).all();
|
||||||
|
|
||||||
|
json(200, {
|
||||||
|
ativo,
|
||||||
|
configurado: zproConfigured(db),
|
||||||
|
mensagensPorDia: limite,
|
||||||
|
enviadosHoje,
|
||||||
|
restante,
|
||||||
|
porProduto,
|
||||||
|
ultimos
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Testa as credenciais Z-PRO atuais (settings ou .env) com uma chamada real
|
||||||
|
// e barata (listTags), sem mandar nenhuma mensagem — pra validar a tela de
|
||||||
|
// Credenciais Z-PRO antes de ativar a automacao de verdade.
|
||||||
|
router.add('POST', '/api/outreach/testar-conexao', async (req, res, { json }) => {
|
||||||
|
if (!zproConfigured(db)) {
|
||||||
|
json(400, { ok: false, erro: 'preencha Base URL, API ID e Token antes de testar', diagnostico: zproDiagnostics(db) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createZproClient(db).listTags({ isActive: true });
|
||||||
|
json(200, { ok: true, diagnostico: zproDiagnostics(db) });
|
||||||
|
} catch (error) {
|
||||||
|
json(200, { ok: false, erro: String(error.message || error), diagnostico: zproDiagnostics(db) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envio avulso pra um numero especifico, fora do lote automatico e sem
|
||||||
|
// depender de um lead cadastrado — util pra mandar uma mensagem pontual
|
||||||
|
// ou testar um template antes de ligar a automacao pra valer. Fica
|
||||||
|
// registrado em outreach_sends com origem='manual' (lead_id/produto
|
||||||
|
// ficam vazios), aparece nos "ultimos" igual aos envios automaticos.
|
||||||
|
router.add('POST', '/api/outreach/enviar-manual', async (req, res, { json, body }) => {
|
||||||
|
const numeroDigitado = String(body?.numero || '').trim();
|
||||||
|
if (!numeroDigitado) {
|
||||||
|
json(400, { erro: 'informe o numero (com DDI+DDD, ex.: 5511999999999)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// O Z-PRO aceita (200 OK) numero mal formatado e so falha depois, de forma
|
||||||
|
// assincrona, sem avisar o app — por isso valida aqui ANTES de mandar,
|
||||||
|
// em vez de confiar na resposta da API pra saber se o numero e valido.
|
||||||
|
const { valid, motivo } = validatePhone(numeroDigitado);
|
||||||
|
if (!valid) {
|
||||||
|
json(400, { erro: `numero invalido (${motivo}): use DDI+DDD+numero, so digitos, ex. 5511999999999` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numero = onlyDigits(numeroDigitado);
|
||||||
|
const canal = body?.canal === 'oficial' ? 'oficial' : 'nao_oficial';
|
||||||
|
const mensagem = String(body?.mensagem || '').trim();
|
||||||
|
const templateWabaNome = String(body?.templateWabaNome || '').trim();
|
||||||
|
const templateWabaIdioma = String(body?.templateWabaIdioma || 'pt_BR').trim() || 'pt_BR';
|
||||||
|
const templateRoute = String(body?.templateRota || 'template').trim() || 'template';
|
||||||
|
const rawTemplateVariables = Array.isArray(body?.templateVariaveis)
|
||||||
|
? body.templateVariaveis.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
: [String(body?.templateVariavel1 || '').trim()].filter(Boolean);
|
||||||
|
const leadContext = body?.lead && typeof body.lead === 'object' ? body.lead : {};
|
||||||
|
const templateVariables = rawTemplateVariables.map((value) => renderTemplate(value, {
|
||||||
|
empresa: 'Gustavo',
|
||||||
|
produto: 'Atendimento comercial',
|
||||||
|
cidade: 'Salvador',
|
||||||
|
segmento_label: 'Suporte',
|
||||||
|
email: 'cliente@exemplo.com.br',
|
||||||
|
...leadContext,
|
||||||
|
whatsapp: leadContext.whatsapp || onlyDigits(numeroDigitado)
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (canal === 'nao_oficial' && !mensagem) {
|
||||||
|
json(400, { erro: 'escreva a mensagem pro canal nao-oficial' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canal === 'oficial' && !templateWabaNome) {
|
||||||
|
json(400, { erro: 'informe o nome do template aprovado pro canal oficial' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limite, enviadosHoje, restante } = dailyLimitStatus(db);
|
||||||
|
if (restante <= 0) {
|
||||||
|
json(429, {
|
||||||
|
ok: false,
|
||||||
|
erro: `limite diario atingido (${enviadosHoje}/${limite}). Aumente o limite por dia ou aguarde virar o dia.`,
|
||||||
|
limite,
|
||||||
|
enviadosHoje,
|
||||||
|
restante
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem)
|
||||||
|
VALUES (NULL, '', ?, ?, ?, ?, ?, ?, 'manual')`
|
||||||
|
);
|
||||||
|
const mensagemEnviada = canal === 'oficial' ? `template:${templateWabaNome}/${templateWabaIdioma}` : mensagem;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zproClient = createZproClient(db);
|
||||||
|
const externalKey = `gti-manual-${Date.now()}`;
|
||||||
|
const resposta = canal === 'oficial'
|
||||||
|
? await zproClient.sendTemplateWaba({
|
||||||
|
number: numero,
|
||||||
|
templateName: templateWabaNome,
|
||||||
|
languageCode: templateWabaIdioma,
|
||||||
|
route: templateRoute,
|
||||||
|
variables: templateVariables
|
||||||
|
})
|
||||||
|
: await zproClient.sendText({ number: numero, message: mensagem, externalKey });
|
||||||
|
const ticketId = resposta?.ticketId || resposta?.data?.ticketId ? String(resposta.ticketId || resposta.data.ticketId) : null;
|
||||||
|
insert.run(numero, mensagemEnviada, 'enviado', null, ticketId, canal);
|
||||||
|
json(200, { ok: true, ticketId });
|
||||||
|
} catch (error) {
|
||||||
|
insert.run(numero, mensagemEnviada, 'erro', String(error.message || error), null, canal);
|
||||||
|
json(200, { ok: false, erro: String(error.message || error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/outreach/templates', async (req, res, { json }) => {
|
||||||
|
json(200, { templates: listMessageTemplates(db) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/outreach/waba-templates', async (req, res, { json }) => {
|
||||||
|
const syncUrl = getSetting(db, 'gchat.templatesUrl', '').trim();
|
||||||
|
const lastSync = getSetting(db, 'gchat.templatesLastSync', '');
|
||||||
|
const lastSyncAgeMs = lastSync ? Date.now() - new Date(lastSync).getTime() : Infinity;
|
||||||
|
if (syncUrl && lastSyncAgeMs > 10 * 60 * 1000) {
|
||||||
|
try {
|
||||||
|
await syncWabaTemplateLibrary(db);
|
||||||
|
} catch {
|
||||||
|
// A biblioteca local continua funcionando mesmo se o GChat estiver fora.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json(200, {
|
||||||
|
templates: listWabaTemplateLibrary(db),
|
||||||
|
lastSync: getSetting(db, 'gchat.templatesLastSync', '')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('GET', '/api/outreach/variables', async (req, res, { json }) => {
|
||||||
|
json(200, { variables: COMMERCIAL_VARIABLES });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('POST', '/api/outreach/waba-templates/sync', async (req, res, { json }) => {
|
||||||
|
try {
|
||||||
|
json(200, await syncWabaTemplateLibrary(db));
|
||||||
|
} catch (error) {
|
||||||
|
json(200, { ok: false, erro: String(error.message || error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.add('PUT', '/api/outreach/templates/:produto', async (req, res, { json, params, body }) => {
|
||||||
|
const produto = params.produto;
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
json(400, { erro: 'corpo JSON esperado: {"mensagem": "...", "ativo": true}' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atualizado = updateMessageTemplate(db, produto, {
|
||||||
|
mensagem: body.mensagem,
|
||||||
|
ativo: body.ativo,
|
||||||
|
canal: body.canal,
|
||||||
|
templateWabaNome: body.templateWabaNome,
|
||||||
|
templateWabaIdioma: body.templateWabaIdioma,
|
||||||
|
templateWabaVariaveis: body.templateWabaVariaveis
|
||||||
|
});
|
||||||
|
if (!atualizado) {
|
||||||
|
json(404, { erro: `template do produto "${produto}" nao encontrado` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json(200, atualizado);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,27 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { variantForLead, renderCampaignMessage } from '../src/outreach/campaign-messages.mjs';
|
||||||
|
|
||||||
|
const variants = [{ id: 1, code: 'A', mensagem: 'Ola A' }, { id: 2, code: 'B', mensagem: 'Ola B' }, { id: 3, code: 'C', mensagem: 'Ola C' }];
|
||||||
|
|
||||||
|
test('variantForLead e deterministico (mesmo lead sempre cai na mesma variante)', () => {
|
||||||
|
const primeira = variantForLead(variants, 42);
|
||||||
|
const segunda = variantForLead(variants, 42);
|
||||||
|
assert.equal(primeira.code, segunda.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('variantForLead distribui entre variantes diferentes conforme o id', () => {
|
||||||
|
assert.equal(variantForLead(variants, 0).code, 'A');
|
||||||
|
assert.equal(variantForLead(variants, 1).code, 'B');
|
||||||
|
assert.equal(variantForLead(variants, 2).code, 'C');
|
||||||
|
assert.equal(variantForLead(variants, 3).code, 'A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('variantForLead lanca erro sem variantes ativas', () => {
|
||||||
|
assert.throws(() => variantForLead([], 1), /sem variantes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderCampaignMessage substitui variaveis do lead', () => {
|
||||||
|
const texto = renderCampaignMessage({ mensagem: 'Ola {empresa}, sobre {produto}' }, { empresa: 'Oficina do Ze', produto: 'ERP' });
|
||||||
|
assert.equal(texto, 'Ola Oficina do Ze, sobre ERP');
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { openDb, createLeadInserter } from '../src/db.mjs';
|
||||||
|
import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs';
|
||||||
|
import { evaluateCampaignHealth, applyCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
|
||||||
|
|
||||||
|
function inserirLead(db, dedup) {
|
||||||
|
const inserir = createLeadInserter(db);
|
||||||
|
inserir({
|
||||||
|
dedup_key: dedup, empresa: 'E', legal_name: '', whatsapp: `55119999${dedup.padStart(5, '0')}`,
|
||||||
|
ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '',
|
||||||
|
cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1,
|
||||||
|
cnpj_valido: 0, empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '',
|
||||||
|
score_qualidade: 50, score_comercial: 50, prioridade: 1, produto_principal: 'ERP',
|
||||||
|
produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null
|
||||||
|
});
|
||||||
|
return db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function campanhaRodando(db) {
|
||||||
|
const campanha = createCampaign(db, {
|
||||||
|
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
|
||||||
|
});
|
||||||
|
setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
return campanha;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simularEnvio(db, campanhaId, variantId, leadId, numero, status) {
|
||||||
|
const sendId = db.prepare(
|
||||||
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', ?)`
|
||||||
|
).run(leadId, numero, status).lastInsertRowid;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
|
||||||
|
VALUES (?, ?, ?, ?, 'oi', ?, ?)`
|
||||||
|
).run(campanhaId, leadId, variantId, numero, status === 'erro' ? 'error' : 'sent', sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('evaluateCampaignHealth nao pausa campanha saudavel', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaRodando(db);
|
||||||
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
const lead = inserirLead(db, `l${i}`);
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||||
|
}
|
||||||
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||||
|
assert.equal(resultado.pause, false);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 10+ tentativas', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaRodando(db);
|
||||||
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const lead = inserirLead(db, `ok${i}`);
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
const lead = inserirLead(db, `err${i}`);
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
|
||||||
|
}
|
||||||
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||||
|
assert.equal(resultado.pause, true);
|
||||||
|
assert.equal(resultado.reason, 'high_failure_rate');
|
||||||
|
// evaluateCampaignHealth e read-only: so retorna o veredito, nao mexe no banco.
|
||||||
|
const semMutacao = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||||
|
assert.equal(semMutacao.status, 'rodando');
|
||||||
|
|
||||||
|
// A mutacao (pausar de verdade) so acontece via applyCampaignHealth.
|
||||||
|
const aplicado = applyCampaignHealth(db, campanha.id);
|
||||||
|
assert.equal(aplicado.pause, true);
|
||||||
|
assert.equal(aplicado.reason, 'high_failure_rate');
|
||||||
|
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||||
|
assert.equal(atualizada.status, 'pausado');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluateCampaignHealth nunca muda outreach_campaigns.status, mesmo quando retornaria pause:true (GET /saude nao deve pausar como efeito colateral de uma leitura)', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaRodando(db);
|
||||||
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const lead = inserirLead(db, `puro-ok${i}`);
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
const lead = inserirLead(db, `puro-err${i}`);
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||||
|
assert.equal(resultado.pause, true, 'deveria continuar sinalizando pause:true a cada chamada');
|
||||||
|
}
|
||||||
|
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||||
|
assert.equal(aindaRodando.status, 'rodando', 'chamar evaluateCampaignHealth repetidamente nao deveria ter pausado a campanha');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaRodando(db);
|
||||||
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||||
|
const lead = inserirLead(db, 'so1');
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
|
||||||
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||||
|
assert.equal(resultado.pause, false);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluateCampaignHealth pausa por high_negative_rate quando >10% dos 20+ contatados estao em sem_interesse/opt_out', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaRodando(db);
|
||||||
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||||
|
for (let i = 0; i < 20; i += 1) {
|
||||||
|
const lead = inserirLead(db, `neg${i}`);
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||||
|
if (i < 3) {
|
||||||
|
db.prepare(`INSERT INTO outreach_lead_states (lead_id, stage) VALUES (?, 'sem_interesse')`).run(lead.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||||
|
assert.equal(resultado.pause, true);
|
||||||
|
assert.equal(resultado.reason, 'high_negative_rate');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluateCampaignHealth pausa por low_reply_rate quando <5% dos 50+ contatados responderam', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaRodando(db);
|
||||||
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||||
|
for (let i = 0; i < 50; i += 1) {
|
||||||
|
const lead = inserirLead(db, `rep${i}`);
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||||
|
}
|
||||||
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||||
|
assert.equal(resultado.pause, true);
|
||||||
|
assert.equal(resultado.reason, 'low_reply_rate');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluateCampaignHealth em campanha ja pausada nao faz nada', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaRodando(db);
|
||||||
|
setCampaignStatus(db, campanha.id, 'pausado', 'manual');
|
||||||
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
||||||
|
assert.equal(resultado.pause, false);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('variantStats calcula taxa de resposta e elegibilidade (limiar 40 envios)', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaRodando(db);
|
||||||
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
const lead = inserirLead(db, `v${i}`);
|
||||||
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
||||||
|
if (i < 1) {
|
||||||
|
db.prepare(`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, 'interessado')`).run(lead.id, campanha.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stats = variantStats(db, campanha.id);
|
||||||
|
assert.equal(stats.length, 1);
|
||||||
|
assert.equal(stats[0].enviados, 5);
|
||||||
|
assert.equal(stats[0].respostas, 1);
|
||||||
|
assert.equal(stats[0].taxaResposta, 0.2);
|
||||||
|
assert.equal(stats[0].elegivelParaComparacao, false);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { openDb, createLeadInserter } from '../src/db.mjs';
|
||||||
|
import { createCampaign } from '../src/outreach/campaign-store.mjs';
|
||||||
|
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../src/outreach/campaign-queue.mjs';
|
||||||
|
|
||||||
|
function inserirLead(db, overrides = {}) {
|
||||||
|
const inserir = createLeadInserter(db);
|
||||||
|
const dedup = overrides.dedup_key || `lead-${Math.random()}`;
|
||||||
|
inserir({
|
||||||
|
dedup_key: dedup, empresa: 'Oficina Teste', legal_name: '', whatsapp: '5511999990001',
|
||||||
|
ddd: '11', email: '', cidade: 'SP', uf: 'SP', segmento: 'oficinas_mecanicas', segmento_label: '',
|
||||||
|
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'teste',
|
||||||
|
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 80, classificacao: 'bom',
|
||||||
|
motivo_classificacao: '', score_qualidade: 80, score_comercial: 80, prioridade: 1,
|
||||||
|
produto_principal: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
return db.prepare('SELECT * FROM leads WHERE dedup_key = ?').get(dedup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function campanhaComVariantes(db, overrides = {}) {
|
||||||
|
return createCampaign(db, {
|
||||||
|
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas'],
|
||||||
|
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generateCampaignQueue cria linhas preview so para leads do produto/segmento certos', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, { dedup_key: 'l1', whatsapp: '5511999990001' });
|
||||||
|
inserirLead(db, { dedup_key: 'l2', segmento: 'outro_segmento', whatsapp: '5511999990002' });
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
|
||||||
|
const resultado = generateCampaignQueue(db, campanha.id);
|
||||||
|
assert.equal(resultado.candidatosEncontrados, 1);
|
||||||
|
assert.equal(resultado.adicionadosNaFila, 1);
|
||||||
|
|
||||||
|
const fila = listQueue(db, campanha.id);
|
||||||
|
assert.equal(fila.length, 1);
|
||||||
|
assert.equal(fila[0].status, 'preview');
|
||||||
|
assert.equal(fila[0].rendered_message, 'Ola Oficina Teste');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateCampaignQueue exclui lead com envio ja enviado para o mesmo produto (por lead_id)', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const lead = inserirLead(db);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'enviado')`
|
||||||
|
).run(lead.id, lead.whatsapp);
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
|
||||||
|
const resultado = generateCampaignQueue(db, campanha.id);
|
||||||
|
assert.equal(resultado.candidatosEncontrados, 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateCampaignQueue exclui numero com envio manual ja enviado (sem lead_id, produto vazio, como o envio manual real grava)', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, { whatsapp: '5511999990009' });
|
||||||
|
// Reproduz exatamente como src/server/routes/outreach.mjs grava um envio
|
||||||
|
// manual: lead_id NULL, produto '' (nunca o produto da campanha) e
|
||||||
|
// origem='manual'. A exclusao precisa valer mesmo assim, por numero.
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
|
||||||
|
VALUES (NULL, '', '5511999990009', 'oi', 'enviado', 'manual')`
|
||||||
|
).run();
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
|
||||||
|
const resultado = generateCampaignQueue(db, campanha.id);
|
||||||
|
assert.equal(resultado.candidatosEncontrados, 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateCampaignQueue exclui numero em opt-out', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, { whatsapp: '5511999990001' });
|
||||||
|
db.prepare(`INSERT INTO outreach_opt_out (numero) VALUES ('5511999990001')`).run();
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
|
||||||
|
const resultado = generateCampaignQueue(db, campanha.id);
|
||||||
|
assert.equal(resultado.candidatosEncontrados, 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateCampaignQueue nao duplica lead ja na fila (preview/approved/sent) de outra campanha do mesmo produto', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, { dedup_key: 'unico', whatsapp: '5511999990001' });
|
||||||
|
const campanha1 = campanhaComVariantes(db, { nome: 'Campanha 1' });
|
||||||
|
generateCampaignQueue(db, campanha1.id);
|
||||||
|
|
||||||
|
const campanha2 = campanhaComVariantes(db, { nome: 'Campanha 2' });
|
||||||
|
const resultado = generateCampaignQueue(db, campanha2.id);
|
||||||
|
assert.equal(resultado.candidatosEncontrados, 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateCampaignQueue respeita o parametro quantidade', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
inserirLead(db, { dedup_key: `lead-${i}`, whatsapp: `551199999000${i}` });
|
||||||
|
}
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 3 });
|
||||||
|
assert.equal(resultado.adicionadosNaFila, 3);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateCampaignQueue reporta candidatosEncontrados como o pool elegivel completo, mesmo com quantidade menor', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
inserirLead(db, { dedup_key: `pool-${i}`, whatsapp: `551199998000${i}` });
|
||||||
|
}
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 2 });
|
||||||
|
assert.equal(resultado.candidatosEncontrados, 5);
|
||||||
|
assert.equal(resultado.adicionadosNaFila, 2);
|
||||||
|
assert.equal(listQueue(db, campanha.id).length, 2);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('approveQueueRows so aprova linhas em preview da campanha certa', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, { whatsapp: '5511999990001' });
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
generateCampaignQueue(db, campanha.id);
|
||||||
|
const [linha] = listQueue(db, campanha.id);
|
||||||
|
|
||||||
|
const total = approveQueueRows(db, campanha.id, [linha.id]);
|
||||||
|
assert.equal(total, 1);
|
||||||
|
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelQueueRows cancela e registra motivo', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, { whatsapp: '5511999990001' });
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
generateCampaignQueue(db, campanha.id);
|
||||||
|
const [linha] = listQueue(db, campanha.id);
|
||||||
|
|
||||||
|
const total = cancelQueueRows(db, campanha.id, [linha.id], 'nao interessa');
|
||||||
|
assert.equal(total, 1);
|
||||||
|
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
|
||||||
|
assert.equal(cancelada.cancel_reason, 'nao interessa');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('approveQueueRows/cancelQueueRows com lista vazia nao lanca erro', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = campanhaComVariantes(db);
|
||||||
|
assert.equal(approveQueueRows(db, campanha.id, []), 0);
|
||||||
|
assert.equal(cancelQueueRows(db, campanha.id, []), 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { openDb, createLeadInserter } from '../src/db.mjs';
|
||||||
|
import { createRouter } from '../src/server/router.mjs';
|
||||||
|
import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs';
|
||||||
|
|
||||||
|
function setupRouter(db) {
|
||||||
|
const router = createRouter();
|
||||||
|
registerCampaignRoutes(router, { db });
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeJson() {
|
||||||
|
const calls = [];
|
||||||
|
return { json: (status, data) => calls.push({ status, data }), calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inserirLead(db, dedup, produto = 'ERP', segmento = 'oficinas') {
|
||||||
|
const inserir = createLeadInserter(db);
|
||||||
|
inserir({
|
||||||
|
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp: `551199990${dedup}`, ddd: '11',
|
||||||
|
email: '', cidade: '', uf: '', segmento, segmento_label: '', interesse: '', cnpj: '', cnae: '',
|
||||||
|
endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0,
|
||||||
|
empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50,
|
||||||
|
score_comercial: 50, prioridade: 1, produto_principal: produto, produtos_json: '[]',
|
||||||
|
capture_run_id: null, captured_at: null, exported_at: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('POST /api/campaigns cria campanha com segmentos e variantes', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const router = setupRouter(db);
|
||||||
|
const { handler } = router.match('POST', '/api/campaigns');
|
||||||
|
const { json, calls } = fakeJson();
|
||||||
|
|
||||||
|
await handler(null, null, { json, body: {
|
||||||
|
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas'],
|
||||||
|
variantes: [{ mensagem: 'Ola {empresa}' }]
|
||||||
|
} });
|
||||||
|
|
||||||
|
assert.equal(calls[0].status, 200);
|
||||||
|
assert.equal(calls[0].data.produto, 'ERP');
|
||||||
|
assert.equal(calls[0].data.variants.length, 1);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/campaigns rejeita sem segmentos', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const router = setupRouter(db);
|
||||||
|
const { handler } = router.match('POST', '/api/campaigns');
|
||||||
|
const { json, calls } = fakeJson();
|
||||||
|
|
||||||
|
await handler(null, null, { json, body: { nome: 'X', produto: 'ERP', segmentos: [], variantes: [{ mensagem: 'oi' }] } });
|
||||||
|
assert.equal(calls[0].status, 400);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/campaigns/segmentos lista segmentos distintos do produto com contagem', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, '1', 'ERP', 'oficinas');
|
||||||
|
inserirLead(db, '2', 'ERP', 'oficinas');
|
||||||
|
inserirLead(db, '3', 'ERP', 'padarias');
|
||||||
|
inserirLead(db, '4', 'G-Chat', 'padarias');
|
||||||
|
const router = setupRouter(db);
|
||||||
|
const { handler } = router.match('GET', '/api/campaigns/segmentos');
|
||||||
|
const { json, calls } = fakeJson();
|
||||||
|
|
||||||
|
await handler(null, null, { json, query: new URLSearchParams({ produto: 'ERP' }) });
|
||||||
|
assert.equal(calls[0].status, 200);
|
||||||
|
const segmentos = calls[0].data.segmentos;
|
||||||
|
assert.equal(segmentos.find((s) => s.value === 'oficinas').total, 2);
|
||||||
|
assert.equal(segmentos.find((s) => s.value === 'padarias').total, 1);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fluxo completo: criar campanha, gerar fila, aprovar e checar saude', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, '1', 'ERP', 'oficinas');
|
||||||
|
const router = setupRouter(db);
|
||||||
|
|
||||||
|
const criar = router.match('POST', '/api/campaigns');
|
||||||
|
const respCriar = fakeJson();
|
||||||
|
await criar.handler(null, null, { json: respCriar.json, body: {
|
||||||
|
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
|
||||||
|
} });
|
||||||
|
const campaignId = respCriar.calls[0].data.id;
|
||||||
|
|
||||||
|
const gerarFila = router.match('POST', `/api/campaigns/${campaignId}/gerar-fila`);
|
||||||
|
const respFila = fakeJson();
|
||||||
|
await gerarFila.handler(null, null, { json: respFila.json, params: { id: String(campaignId) }, body: {} });
|
||||||
|
assert.equal(respFila.calls[0].data.adicionadosNaFila, 1);
|
||||||
|
|
||||||
|
const listarFila = router.match('GET', `/api/campaigns/${campaignId}/fila`);
|
||||||
|
const respLista = fakeJson();
|
||||||
|
await listarFila.handler(null, null, { json: respLista.json, params: { id: String(campaignId) }, query: new URLSearchParams() });
|
||||||
|
const queueId = respLista.calls[0].data.fila[0].id;
|
||||||
|
|
||||||
|
const aprovar = router.match('POST', `/api/campaigns/${campaignId}/fila/aprovar`);
|
||||||
|
const respAprovar = fakeJson();
|
||||||
|
await aprovar.handler(null, null, { json: respAprovar.json, params: { id: String(campaignId) }, body: { queueIds: [queueId] } });
|
||||||
|
assert.equal(respAprovar.calls[0].data.aprovados, 1);
|
||||||
|
|
||||||
|
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
|
||||||
|
const respSaude = fakeJson();
|
||||||
|
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
|
||||||
|
assert.equal(respSaude.calls[0].data.pause, false);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/campaigns/leads/:leadId/estagio grava estagio atual e historico', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, '1');
|
||||||
|
const lead = db.prepare('SELECT id FROM leads LIMIT 1').get();
|
||||||
|
const router = setupRouter(db);
|
||||||
|
|
||||||
|
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
|
||||||
|
const respMarcar = fakeJson();
|
||||||
|
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'interessado', note: 'ligar amanha' } });
|
||||||
|
assert.equal(respMarcar.calls[0].status, 200);
|
||||||
|
|
||||||
|
const consultar = router.match('GET', '/api/campaigns/leads/:leadId/estagio');
|
||||||
|
const respConsultar = fakeJson();
|
||||||
|
await consultar.handler(null, null, { json: respConsultar.json, params: { leadId: String(lead.id) } });
|
||||||
|
assert.equal(respConsultar.calls[0].data.atual.stage, 'interessado');
|
||||||
|
assert.equal(respConsultar.calls[0].data.historico.length, 1);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/campaigns/opt-out bloqueia e DELETE libera', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const router = setupRouter(db);
|
||||||
|
|
||||||
|
const adicionar = router.match('POST', '/api/campaigns/opt-out');
|
||||||
|
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '5511999990001', motivo: 'pediu' } });
|
||||||
|
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
|
||||||
|
|
||||||
|
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
|
||||||
|
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '5511999990001' } });
|
||||||
|
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST/DELETE /api/campaigns/opt-out normalizam o numero pra digitos, igual ao resto do app', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const router = setupRouter(db);
|
||||||
|
|
||||||
|
const adicionar = router.match('POST', '/api/campaigns/opt-out');
|
||||||
|
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '+55 (11) 99999-0001', motivo: 'pediu' } });
|
||||||
|
// Tem que ter gravado so digitos, senao isOptedOut/candidateQuery (que comparam
|
||||||
|
// contra leads.whatsapp, ja digit-only) nunca vao casar com este registro.
|
||||||
|
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
|
||||||
|
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('+55 (11) 99999-0001'), undefined);
|
||||||
|
|
||||||
|
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
|
||||||
|
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '+55 (11) 99999-0001' } });
|
||||||
|
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/campaigns/leads/:leadId/estagio com stage=opt_out tambem grava em outreach_opt_out (numero digit-only)', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, '1');
|
||||||
|
const lead = db.prepare('SELECT id, whatsapp FROM leads LIMIT 1').get();
|
||||||
|
const router = setupRouter(db);
|
||||||
|
|
||||||
|
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
|
||||||
|
const respMarcar = fakeJson();
|
||||||
|
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'opt_out' } });
|
||||||
|
assert.equal(respMarcar.calls[0].status, 200);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(lead.whatsapp),
|
||||||
|
'marcar estagio opt_out deveria ter chamado addOptOut com o numero do lead'
|
||||||
|
);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /:id/saude nao muta o status da campanha (leitura pura); o worker e quem aplica a pausa', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
inserirLead(db, '1', 'ERP', 'oficinas');
|
||||||
|
const router = setupRouter(db);
|
||||||
|
|
||||||
|
const criar = router.match('POST', '/api/campaigns');
|
||||||
|
const respCriar = fakeJson();
|
||||||
|
await criar.handler(null, null, { json: respCriar.json, body: {
|
||||||
|
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
|
||||||
|
} });
|
||||||
|
const campaignId = respCriar.calls[0].data.id;
|
||||||
|
|
||||||
|
const iniciar = router.match('POST', `/api/campaigns/${campaignId}/iniciar`);
|
||||||
|
await iniciar.handler(null, null, { json: fakeJson().json, params: { id: String(campaignId) } });
|
||||||
|
|
||||||
|
// Simula 10 tentativas hoje, todas com erro (100% > 10%) -> evaluateCampaignHealth
|
||||||
|
// deveria retornar pause:true, mas a rota GET nao pode persistir essa pausa.
|
||||||
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campaignId);
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
const dedup = `saude-${i}`;
|
||||||
|
inserirLead(db, dedup, 'ERP', 'oficinas');
|
||||||
|
const l = db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
|
||||||
|
const sendId = db.prepare(
|
||||||
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'erro')`
|
||||||
|
).run(l.id, l.whatsapp).lastInsertRowid;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
|
||||||
|
VALUES (?, ?, ?, ?, 'oi', 'error', ?)`
|
||||||
|
).run(campaignId, l.id, variant.id, l.whatsapp, sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
|
||||||
|
const respSaude = fakeJson();
|
||||||
|
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
|
||||||
|
assert.equal(respSaude.calls[0].data.pause, true, 'a avaliacao deveria sinalizar pause:true dado 100% de erro');
|
||||||
|
|
||||||
|
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
||||||
|
assert.equal(aindaRodando.status, 'rodando', 'abrir o painel de saude (GET) nao deveria ter pausado a campanha');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { openDb } from '../src/db.mjs';
|
||||||
|
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';
|
||||||
|
|
||||||
|
test('startCampaignScheduler retorna funcao de parada que limpa os timers sem lancar erro', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const stop = startCampaignScheduler({ db, log: () => {} });
|
||||||
|
assert.equal(typeof stop, 'function');
|
||||||
|
assert.doesNotThrow(() => stop());
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { openDb } from '../src/db.mjs';
|
||||||
|
import {
|
||||||
|
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
|
||||||
|
escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut
|
||||||
|
} from '../src/outreach/campaign-store.mjs';
|
||||||
|
|
||||||
|
function campanhaBase(overrides = {}) {
|
||||||
|
return {
|
||||||
|
nome: 'ERP oficinas',
|
||||||
|
produto: 'ERP',
|
||||||
|
segmentos: ['oficinas_mecanicas', 'autopecas'],
|
||||||
|
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }],
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('createCampaign grava campanha, segmentos e variantes', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = createCampaign(db, campanhaBase());
|
||||||
|
assert.equal(campanha.status, 'rascunho');
|
||||||
|
assert.equal(campanha.produto, 'ERP');
|
||||||
|
assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']);
|
||||||
|
assert.equal(campanha.variants.length, 2);
|
||||||
|
assert.equal(campanha.variants[0].code, 'A');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listCampaigns retorna mais recente primeiro', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
createCampaign(db, campanhaBase({ nome: 'Primeira' }));
|
||||||
|
createCampaign(db, campanhaBase({ nome: 'Segunda' }));
|
||||||
|
const campanhas = listCampaigns(db);
|
||||||
|
assert.equal(campanhas.length, 2);
|
||||||
|
assert.equal(campanhas[0].nome, 'Segunda');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = createCampaign(db, campanhaBase());
|
||||||
|
setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
|
||||||
|
assert.equal(pausada.status, 'pausado');
|
||||||
|
assert.equal(pausada.pause_reason, 'high_failure_rate');
|
||||||
|
const retomada = resumeCampaign(db, campanha.id);
|
||||||
|
assert.equal(retomada.status, 'rodando');
|
||||||
|
assert.equal(retomada.pause_reason, null);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setCampaignStatus rodando stamps semana_iniciada_em on first transition', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = createCampaign(db, campanhaBase());
|
||||||
|
assert.equal(campanha.semana_iniciada_em, null);
|
||||||
|
|
||||||
|
const rodando = setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
assert.notEqual(rodando.semana_iniciada_em, null, 'semana_iniciada_em should be set on first rodando transition');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setCampaignStatus rodando does not reset already-set semana_iniciada_em', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = createCampaign(db, campanhaBase());
|
||||||
|
|
||||||
|
// Set to rodando first time, which stamps semana_iniciada_em
|
||||||
|
setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
|
||||||
|
// Manually set to a known past value via raw SQL
|
||||||
|
const knownTime = '2026-07-15 10:00:00';
|
||||||
|
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = ? WHERE id = ?`).run(knownTime, campanha.id);
|
||||||
|
|
||||||
|
// Call setCampaignStatus again while already 'rodando'
|
||||||
|
const unchanged = setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
|
||||||
|
// Verify semana_iniciada_em was not changed
|
||||||
|
assert.equal(unchanged.semana_iniciada_em, knownTime, 'semana_iniciada_em should not be reset on subsequent rodando calls');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resumeCampaign reseta semana_iniciada_em, evitando escalonamento imediato apos retomar de uma pausa longa', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 80 }));
|
||||||
|
setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
|
||||||
|
// Simula que a campanha ficou pausada por muito tempo: semana_iniciada_em
|
||||||
|
// backdated 10 dias, o que satisfaria os "7 dias corridos" do escalonamento.
|
||||||
|
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(campanha.id);
|
||||||
|
|
||||||
|
resumeCampaign(db, campanha.id);
|
||||||
|
const escaladas = escalateWeeklyLimits(db);
|
||||||
|
assert.equal(escaladas, 0, 'retomar deveria ter resetado o relogio semanal, entao nao deveria escalonar imediatamente');
|
||||||
|
const atualizada = getCampaign(db, campanha.id);
|
||||||
|
assert.equal(atualizada.limite_diario_atual, 10);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = createCampaign(db, campanhaBase());
|
||||||
|
const resultado = resumeCampaign(db, campanha.id);
|
||||||
|
assert.equal(resultado.status, 'rascunho');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 }));
|
||||||
|
setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id);
|
||||||
|
|
||||||
|
const escaladas = escalateWeeklyLimits(db);
|
||||||
|
assert.equal(escaladas, 1);
|
||||||
|
const atualizada = getCampaign(db, campanha.id);
|
||||||
|
assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' }));
|
||||||
|
setCampaignStatus(db, rodando.id, 'rodando');
|
||||||
|
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id);
|
||||||
|
|
||||||
|
const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' }));
|
||||||
|
setCampaignStatus(db, pausada.id, 'rodando');
|
||||||
|
setCampaignStatus(db, pausada.id, 'pausado', 'manual');
|
||||||
|
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id);
|
||||||
|
|
||||||
|
const escaladas = escalateWeeklyLimits(db);
|
||||||
|
assert.equal(escaladas, 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opt-out: addOptOut bloqueia, removeOptOut libera', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
assert.equal(isOptedOut(db, '5511999990001'), false);
|
||||||
|
addOptOut(db, '5511999990001', 'pediu pra parar');
|
||||||
|
assert.equal(isOptedOut(db, '5511999990001'), true);
|
||||||
|
removeOptOut(db, '5511999990001');
|
||||||
|
assert.equal(isOptedOut(db, '5511999990001'), false);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
|
||||||
|
import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs';
|
||||||
|
import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs';
|
||||||
|
import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs';
|
||||||
|
|
||||||
|
// Testes de banco em memoria nao seedam settings.outreach.* (fica no fallback
|
||||||
|
// 'false'), entao por padrao o worker nao faria nada — precisa ligar
|
||||||
|
// explicitamente e abrir a janela de horario pro dia todo pra nao depender do
|
||||||
|
// horario real em que o teste roda.
|
||||||
|
function ativarWorker(db) {
|
||||||
|
setSetting(db, 'outreach.ativo', 'true');
|
||||||
|
setSetting(db, 'outreach.horaInicio', '00:00');
|
||||||
|
setSetting(db, 'outreach.horaFim', '23:59');
|
||||||
|
}
|
||||||
|
|
||||||
|
function inserirLead(db, dedup, whatsapp) {
|
||||||
|
const inserir = createLeadInserter(db);
|
||||||
|
inserir({
|
||||||
|
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp, ddd: '11', email: '',
|
||||||
|
cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '', cnpj: '', cnae: '',
|
||||||
|
endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0,
|
||||||
|
empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50,
|
||||||
|
score_comercial: 50, prioridade: 1, produto_principal: 'ERP', produtos_json: '[]',
|
||||||
|
capture_run_id: null, captured_at: null, exported_at: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function campanhaAprovada(db, quantidadeLeads = 1) {
|
||||||
|
for (let i = 0; i < quantidadeLeads; i += 1) {
|
||||||
|
inserirLead(db, `lead-${i}`, `551199999000${i}`);
|
||||||
|
}
|
||||||
|
const campanha = createCampaign(db, {
|
||||||
|
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
|
||||||
|
limiteDiarioInicial: 10
|
||||||
|
});
|
||||||
|
setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
generateCampaignQueue(db, campanha.id);
|
||||||
|
const fila = listQueue(db, campanha.id, 'preview');
|
||||||
|
approveQueueRows(db, campanha.id, fila.map((r) => r.id));
|
||||||
|
return campanha;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeZproClient(overrides = {}) {
|
||||||
|
const enviados = [];
|
||||||
|
return {
|
||||||
|
enviados,
|
||||||
|
sendText: async (args) => { enviados.push(args); return { data: { ticketId: 999 } }; },
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
ativarWorker(db);
|
||||||
|
const campanha = campanhaAprovada(db);
|
||||||
|
const zproClient = fakeZproClient();
|
||||||
|
|
||||||
|
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||||
|
assert.equal(resumo.enviados, 1);
|
||||||
|
assert.equal(zproClient.enviados.length, 1);
|
||||||
|
assert.equal(zproClient.enviados[0].message, 'Ola Empresa');
|
||||||
|
|
||||||
|
const fila = listQueue(db, campanha.id, 'sent');
|
||||||
|
assert.equal(fila.length, 1);
|
||||||
|
assert.ok(fila[0].outreach_send_id);
|
||||||
|
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(fila[0].outreach_send_id);
|
||||||
|
assert.equal(send.status, 'enviado');
|
||||||
|
assert.equal(send.origem, 'automatico');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
ativarWorker(db);
|
||||||
|
const campanha = campanhaAprovada(db, 2);
|
||||||
|
let chamada = 0;
|
||||||
|
const zproClient = fakeZproClient({
|
||||||
|
sendText: async (args) => {
|
||||||
|
chamada += 1;
|
||||||
|
if (chamada === 1) throw new Error('Z-PRO falhou');
|
||||||
|
return { data: { ticketId: 1 } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||||
|
assert.equal(resumo.enviados, 1);
|
||||||
|
assert.equal(resumo.erros, 1);
|
||||||
|
const erro = listQueue(db, campanha.id, 'error')[0];
|
||||||
|
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(erro.outreach_send_id);
|
||||||
|
assert.equal(send.status, 'erro');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runCampaignQueue respeita o limite diario da campanha', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
ativarWorker(db);
|
||||||
|
for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`);
|
||||||
|
const campanha = createCampaign(db, {
|
||||||
|
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }],
|
||||||
|
limiteDiarioInicial: 1
|
||||||
|
});
|
||||||
|
setCampaignStatus(db, campanha.id, 'rodando');
|
||||||
|
generateCampaignQueue(db, campanha.id);
|
||||||
|
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
|
||||||
|
|
||||||
|
const zproClient = fakeZproClient();
|
||||||
|
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||||
|
assert.equal(resumo.enviados, 1);
|
||||||
|
assert.equal(zproClient.enviados.length, 1);
|
||||||
|
assert.equal(listQueue(db, campanha.id, 'approved').length, 2);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
ativarWorker(db);
|
||||||
|
const campanha = campanhaAprovada(db);
|
||||||
|
addOptOut(db, '5511999990000', 'pediu pra parar');
|
||||||
|
db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id);
|
||||||
|
|
||||||
|
const zproClient = fakeZproClient();
|
||||||
|
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||||
|
assert.equal(resumo.pulados, 1);
|
||||||
|
assert.equal(zproClient.enviados.length, 0);
|
||||||
|
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
|
||||||
|
assert.equal(cancelada.cancel_reason, 'opt_out');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runCampaignQueue so processa campanhas com status rodando', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
ativarWorker(db);
|
||||||
|
inserirLead(db, 'l1', '5511999990001');
|
||||||
|
const campanha = createCampaign(db, {
|
||||||
|
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
|
||||||
|
});
|
||||||
|
generateCampaignQueue(db, campanha.id);
|
||||||
|
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
|
||||||
|
// campanha continua em 'rascunho' — nunca foi iniciada
|
||||||
|
|
||||||
|
const zproClient = fakeZproClient();
|
||||||
|
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||||
|
assert.equal(resumo.enviados, 0);
|
||||||
|
assert.equal(zproClient.enviados.length, 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
ativarWorker(db);
|
||||||
|
const campanha = campanhaAprovada(db, 10);
|
||||||
|
db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id);
|
||||||
|
let chamada = 0;
|
||||||
|
const zproClient = fakeZproClient({
|
||||||
|
sendText: async () => {
|
||||||
|
chamada += 1;
|
||||||
|
if (chamada <= 8) return { data: { ticketId: chamada } };
|
||||||
|
throw new Error('falha simulada');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||||
|
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||||
|
// 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila
|
||||||
|
assert.equal(atualizada.status, 'pausado');
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runCampaignQueue nao envia nada quando outreach.ativo esta desligado', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
// Nao chama ativarWorker: settings.outreach.ativo fica no fallback 'false'.
|
||||||
|
const campanha = campanhaAprovada(db);
|
||||||
|
const zproClient = fakeZproClient();
|
||||||
|
|
||||||
|
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||||
|
assert.equal(resumo.enviados, 0);
|
||||||
|
assert.equal(zproClient.enviados.length, 0);
|
||||||
|
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runCampaignQueue nao envia nada fora do horario comercial configurado', async () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
setSetting(db, 'outreach.ativo', 'true');
|
||||||
|
setSetting(db, 'outreach.horaInicio', '08:00');
|
||||||
|
setSetting(db, 'outreach.horaFim', '18:00');
|
||||||
|
const campanha = campanhaAprovada(db);
|
||||||
|
const zproClient = fakeZproClient();
|
||||||
|
|
||||||
|
// 03:00 (horario local), bem fora da janela 08:00-18:00 configurada acima.
|
||||||
|
const foraDoHorario = new Date();
|
||||||
|
foraDoHorario.setHours(3, 0, 0, 0);
|
||||||
|
|
||||||
|
const resumo = await runCampaignQueue({ db, zproClient, now: foraDoHorario, sleepMs: 0 });
|
||||||
|
assert.equal(resumo.enviados, 0);
|
||||||
|
assert.equal(zproClient.enviados.length, 0);
|
||||||
|
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
|
||||||
|
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();
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user