Compare commits
36
Commits
aa46c3da07
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f31dd2b87 | ||
|
|
417da56bd6 | ||
|
|
f5af038e19 | ||
|
|
93ca82979e | ||
|
|
a9ef4d776d | ||
|
|
b77484946f | ||
|
|
eaaf7af659 | ||
|
|
60d28c7465 | ||
|
|
6f6ed97206 | ||
|
|
3a91ca4732 | ||
|
|
8ce2fcdfea | ||
|
|
3e55b843e2 | ||
|
|
d6fdfc6e2d | ||
|
|
61a00e9fc2 | ||
|
|
b34d9294c6 | ||
|
|
3be769408c | ||
|
|
3d552b5bc0 | ||
|
|
0bae79e1aa | ||
|
|
2b83995446 | ||
|
|
0c36f27cb3 | ||
|
|
29f9f3a905 | ||
|
|
240c70b715 | ||
|
|
afa350de5e | ||
|
|
cb03597435 | ||
|
|
18d427e2bd | ||
|
|
a6dce5c936 | ||
|
|
1370b39cee | ||
|
|
16b29a83d0 | ||
|
|
e11658b4e0 | ||
|
|
e9f585458f | ||
|
|
d85fb12991 | ||
|
|
3d2144eefc | ||
|
|
40bd8d096e | ||
|
|
fe70436dbb | ||
|
|
d330bb5ff0 | ||
|
|
6cd336a41e |
+4
-10
@@ -1,11 +1,5 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.env
|
||||
.env.local
|
||||
data/
|
||||
prospeccao/cnpj/rfb-raw/
|
||||
prospeccao/cnpj/*.csv
|
||||
prospeccao/cnpj/*.ndjson
|
||||
*.log
|
||||
GTI-Comercial.exe
|
||||
launcher/publish/
|
||||
.worktrees/
|
||||
.env.*
|
||||
!*.example
|
||||
|
||||
@@ -1,192 +1,10 @@
|
||||
# GTI Maquina Comercial
|
||||
# COMERCIAL-GTI
|
||||
|
||||
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.
|
||||
Monorepo corporativo do Grupo GTI para as aplicacoes de captura, qualificacao e tratamento de leads B2B via WhatsApp.
|
||||
|
||||
URL local padrao: `http://127.0.0.1:3900`
|
||||
## Componentes
|
||||
|
||||
Login inicial: `admin` / `admin`
|
||||
- `gti-maquina-comercial/` — versao corporativa/multiusuario (Z-PRO/GChat, login com multiplos usuarios).
|
||||
- `maquina-comercial-pessoal/` — versao single-user (WhatsApp por sessao de dispositivo vinculado, Baileys).
|
||||
|
||||
## 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.
|
||||
Cada componente manteve seu historico Git anterior preservado dentro deste monorepo (importado via `git subtree`, sem squash).
|
||||
|
||||
@@ -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,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.
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -5,7 +5,10 @@ const Campanhas = (() => {
|
||||
|
||||
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(
|
||||
@@ -13,6 +16,11 @@ const Campanhas = (() => {
|
||||
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() {
|
||||
@@ -83,7 +91,14 @@ const Campanhas = (() => {
|
||||
el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close());
|
||||
el('btn-add-variante').addEventListener('click', () => addVarianteRow());
|
||||
el('campanha-produto').addEventListener('change', (event) => {
|
||||
carregarSegmentos(event.target.value).catch((error) => toast(`Erro: ${error.message}`));
|
||||
// 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 () => {
|
||||
@@ -150,15 +165,19 @@ const Campanhas = (() => {
|
||||
const marcarBtn = h('button', {
|
||||
class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled',
|
||||
onclick: async () => {
|
||||
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');
|
||||
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');
|
||||
+17
-3
@@ -16,6 +16,12 @@ function applyPause(db, campaignId, reason) {
|
||||
// 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 };
|
||||
@@ -36,7 +42,6 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
|
||||
WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?`
|
||||
).get(campaignId, todayKey).total);
|
||||
if (failureCount / attemptsToday > 0.10) {
|
||||
applyPause(db, campaignId, 'high_failure_rate');
|
||||
return { pause: true, reason: 'high_failure_rate' };
|
||||
}
|
||||
}
|
||||
@@ -54,7 +59,6 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
|
||||
AND ls.stage IN ('sem_interesse', 'opt_out')`
|
||||
).get(campaignId).total);
|
||||
if (negativeCount / contacts > 0.10) {
|
||||
applyPause(db, campaignId, 'high_negative_rate');
|
||||
return { pause: true, reason: 'high_negative_rate' };
|
||||
}
|
||||
}
|
||||
@@ -67,7 +71,6 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
|
||||
WHERE q.campaign_id = ? AND q.status IN ('sent','error')`
|
||||
).get(campaignId).total);
|
||||
if (respondedCount / contacts < 0.05) {
|
||||
applyPause(db, campaignId, 'low_reply_rate');
|
||||
return { pause: true, reason: 'low_reply_rate' };
|
||||
}
|
||||
}
|
||||
@@ -75,6 +78,17 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
|
||||
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.
|
||||
+7
-2
@@ -62,9 +62,14 @@ export function setCampaignStatus(db, campaignId, status, pauseReason = null) {
|
||||
// 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, updated_at = datetime('now')
|
||||
WHERE id = ? AND status = 'pausado'`
|
||||
`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);
|
||||
}
|
||||
+46
-3
@@ -1,6 +1,28 @@
|
||||
import { createZproClient } from './zpro-client.mjs';
|
||||
import { isOptedOut } from './campaign-store.mjs';
|
||||
import { evaluateCampaignHealth } from './campaign-metrics.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(
|
||||
@@ -16,10 +38,26 @@ function todayAttempts(db, campaignId) {
|
||||
// (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 = () => {} }) {
|
||||
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);
|
||||
@@ -77,7 +115,12 @@ export async function runCampaignQueue({ db, zproClient, now = new Date(), log =
|
||||
).run(queueStatus, sendId, row.id);
|
||||
|
||||
attemptsToday += 1;
|
||||
const health = evaluateCampaignHealth(db, campaignId, now);
|
||||
// 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;
|
||||
+31
-1
@@ -2,7 +2,7 @@ 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, variantStats } from '../src/outreach/campaign-metrics.mjs';
|
||||
import { evaluateCampaignHealth, applyCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
|
||||
|
||||
function inserirLead(db, dedup) {
|
||||
const inserir = createLeadInserter(db);
|
||||
@@ -63,11 +63,41 @@ test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 1
|
||||
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);
|
||||
@@ -79,6 +79,23 @@ test('setCampaignStatus rodando does not reset already-set semana_iniciada_em',
|
||||
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());
|
||||
+55
-7
@@ -1,10 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb, createLeadInserter } from '../src/db.mjs';
|
||||
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({
|
||||
@@ -43,10 +53,11 @@ function fakeZproClient(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 });
|
||||
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');
|
||||
@@ -62,6 +73,7 @@ test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async
|
||||
|
||||
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({
|
||||
@@ -72,7 +84,7 @@ test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante
|
||||
}
|
||||
});
|
||||
|
||||
const resumo = await runCampaignQueue({ db, zproClient });
|
||||
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];
|
||||
@@ -83,6 +95,7 @@ test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante
|
||||
|
||||
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' }],
|
||||
@@ -93,7 +106,7 @@ test('runCampaignQueue respeita o limite diario da campanha', async () => {
|
||||
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runCampaignQueue({ db, zproClient });
|
||||
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);
|
||||
@@ -102,12 +115,13 @@ test('runCampaignQueue respeita o limite diario da campanha', async () => {
|
||||
|
||||
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 });
|
||||
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];
|
||||
@@ -117,6 +131,7 @@ test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depo
|
||||
|
||||
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' }]
|
||||
@@ -126,7 +141,7 @@ test('runCampaignQueue so processa campanhas com status rodando', async () => {
|
||||
// campanha continua em 'rascunho' — nunca foi iniciada
|
||||
|
||||
const zproClient = fakeZproClient();
|
||||
const resumo = await runCampaignQueue({ db, zproClient });
|
||||
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
|
||||
assert.equal(resumo.enviados, 0);
|
||||
assert.equal(zproClient.enviados.length, 0);
|
||||
db.close();
|
||||
@@ -134,6 +149,7 @@ test('runCampaignQueue so processa campanhas com status rodando', async () => {
|
||||
|
||||
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;
|
||||
@@ -145,9 +161,41 @@ test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa',
|
||||
}
|
||||
});
|
||||
|
||||
await runCampaignQueue({ db, zproClient });
|
||||
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();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user