Add campaign health auto-pause and variant comparison stats
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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).
|
||||
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) {
|
||||
applyPause(db, campaignId, 'high_failure_rate');
|
||||
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) {
|
||||
applyPause(db, campaignId, 'high_negative_rate');
|
||||
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) {
|
||||
applyPause(db, campaignId, 'low_reply_rate');
|
||||
return { pause: true, reason: 'low_reply_rate' };
|
||||
}
|
||||
}
|
||||
|
||||
return { pause: false, reason: null };
|
||||
}
|
||||
|
||||
// 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,140 @@
|
||||
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';
|
||||
|
||||
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');
|
||||
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
||||
assert.equal(atualizada.status, 'pausado');
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user