diff --git a/src/outreach/campaign-metrics.mjs b/src/outreach/campaign-metrics.mjs new file mode 100644 index 0000000..aa7d0c8 --- /dev/null +++ b/src/outreach/campaign-metrics.mjs @@ -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 + })); +} diff --git a/tests/campaign-metrics.test.mjs b/tests/campaign-metrics.test.mjs new file mode 100644 index 0000000..4882d7a --- /dev/null +++ b/tests/campaign-metrics.test.mjs @@ -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(); +});