Add design doc for campaign outreach subsystem port
Ports the Máquina Comercial Pessoal's campaign methodology (queue/preview/ approval, A/B/C variants, weekly limit escalation, health-based auto-pause, stage classification, appointment scheduling), adapted for the Z-PRO/WABA send layer and a dynamic operator-defined catalog instead of a hardcoded one.
This commit is contained in:
@@ -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'`.
|
||||
Reference in New Issue
Block a user