diff --git a/src/db.mjs b/src/db.mjs index 9c363b1..620f745 100644 --- a/src/db.mjs +++ b/src/db.mjs @@ -5,7 +5,12 @@ import { paths } from './env.mjs'; import { segmentLabel } from './scoring.mjs'; import { cleanText } from './lead-engine.mjs'; import { hashPassword } from './auth.mjs'; -import { equivalentPhoneNumbers, normalizePhone } from './phone.mjs'; +import { + canonicalPhoneKey, + canonicalPhoneVariants, + equivalentPhoneNumbers, + normalizePhone +} from './phone.mjs'; const MIGRATIONS = [ // v1 — esquema inicial @@ -956,8 +961,12 @@ export function removeOptOut(db, numero) { }); } +// Deteccao de numero compartilhado usa canonicalPhoneVariants (e nao +// equivalentPhoneNumbers): a relacao precisa ser simetrica para que os dois +// lados de um par equivalente sejam bloqueados e aparecam no mesmo grupo de +// revisao. export function leadsSharingNumber(db, numero) { - const numbers = equivalentPhoneNumbers(numero); + const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return []; const placeholders = numbers.map(() => '?').join(', '); return db.prepare(`SELECT id, empresa, cnpj FROM leads WHERE whatsapp IN (${placeholders})`).all(...numbers); @@ -968,7 +977,7 @@ export function isNumberSharedAcrossLeads(db, numero) { } export function getSharedNumberOverride(db, numero) { - const numbers = equivalentPhoneNumbers(numero); + const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return null; const placeholders = numbers.map(() => '?').join(', '); return db.prepare( @@ -981,7 +990,7 @@ export function isSharedNumberOverridden(db, numero) { } export function addSharedNumberOverride(db, numero, motivo = '') { - const numbers = equivalentPhoneNumbers(numero); + const numbers = canonicalPhoneVariants(numero); if (!numbers.length) throw new Error('numero obrigatorio'); return transaction(db, () => { const placeholders = numbers.map(() => '?').join(', '); @@ -995,7 +1004,7 @@ export function addSharedNumberOverride(db, numero, motivo = '') { } export function removeSharedNumberOverride(db, numero) { - const numbers = equivalentPhoneNumbers(numero); + const numbers = canonicalPhoneVariants(numero); if (!numbers.length) return false; return transaction(db, () => { const placeholders = numbers.map(() => '?').join(', '); @@ -1007,10 +1016,12 @@ export function listSharedNumberGroups(db) { const rows = db.prepare("SELECT id, empresa, cnpj, whatsapp FROM leads WHERE whatsapp <> ''").all(); const groups = new Map(); for (const row of rows) { - const equivalents = equivalentPhoneNumbers(row.whatsapp); - if (!equivalents.length) continue; - const key = equivalents.slice().sort().join(':'); - if (!groups.has(key)) groups.set(key, { numero: equivalents[0], leads: [] }); + // Agrupar pela chave canonica (e nao pelo conjunto de equivalentes) mantem + // as duas formas do mesmo numero no mesmo balde mesmo quando a expansao de + // equivalentPhoneNumbers e assimetrica. + const key = canonicalPhoneKey(row.whatsapp); + if (!key) continue; + if (!groups.has(key)) groups.set(key, { numero: normalizePhone(row.whatsapp), leads: [] }); groups.get(key).leads.push({ id: row.id, empresa: row.empresa, cnpj: row.cnpj }); } return [...groups.values()] diff --git a/src/outreach/campaign-queue.mjs b/src/outreach/campaign-queue.mjs index 8673dd5..df7eddc 100644 --- a/src/outreach/campaign-queue.mjs +++ b/src/outreach/campaign-queue.mjs @@ -2,28 +2,20 @@ import { isOptedOut, saveWhatsAppVerification } from '../db.mjs'; -import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs'; +import { + canonicalKeySql, + canonicalVariantSql, + equivalentPhoneNumbers, + equivalentSql, + normalizePhone, + normalizedSql +} from '../phone.mjs'; import { getCampaign } from './campaign-store.mjs'; import { renderCampaignMessage, variantForLead } from './campaign-messages.mjs'; const ACTIVE_QUEUE_STATUSES = "'preview','approved','scheduled','reserved','sent','unknown'"; const PENDING_QUEUE_STATUSES = "'preview','approved','scheduled','reserved'"; -function normalizedSql(column) { - return `replace(replace(replace(replace(replace(${column}, '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')`; -} - -function equivalentSql(column) { - const number = normalizedSql(column); - return `CASE - WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9' - THEN substr(${number}, 1, 4) || substr(${number}, 6) - WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 12 AND substr(${number}, 5, 1) BETWEEN '6' AND '9' - THEN substr(${number}, 1, 4) || '9' || substr(${number}, 5) - ELSE ${number} - END`; -} - function candidateQuery(confirmed) { // lead_number/lead_equivalent are named columns computed in the derived // `candidates` subquery below, purely as a readability device so the rest of @@ -41,8 +33,15 @@ function candidateQuery(confirmed) { // See task-3-report.md for the full investigation and evidence. const leadNumberExpr = normalizedSql('raw.whatsapp'); const leadEquivalentExpr = equivalentSql('raw.whatsapp'); + // Chave canonica + a outra forma dela: par simetrico usado so na deteccao de + // numero compartilhado (opt-out/verificacao/inbound seguem a expansao + // unidirecional de equivalentPhoneNumbers). + const leadCanonicalExpr = canonicalKeySql('raw.whatsapp'); + const leadCanonicalVariantExpr = canonicalVariantSql('raw.whatsapp'); const leadNumber = 'candidates.lead_number'; const leadEquivalent = 'candidates.lead_equivalent'; + const leadCanonical = 'candidates.lead_canonical'; + const leadCanonicalVariant = 'candidates.lead_canonical_variant'; const verificationFilter = confirmed ? "verification.status = 'confirmado'" : "(verification.status IS NULL OR verification.status = 'erro_temporario')"; @@ -55,7 +54,9 @@ function candidateQuery(confirmed) { FROM ( SELECT raw.*, ${leadNumberExpr} AS lead_number, - ${leadEquivalentExpr} AS lead_equivalent + ${leadEquivalentExpr} AS lead_equivalent, + ${leadCanonicalExpr} AS lead_canonical, + ${leadCanonicalVariantExpr} AS lead_canonical_variant FROM leads raw WHERE raw.segmento = ? ) AS candidates @@ -87,12 +88,12 @@ function candidateQuery(confirmed) { AND ( NOT EXISTS ( SELECT 1 FROM leads other - WHERE other.whatsapp IN (${leadNumber}, ${leadEquivalent}) + WHERE other.whatsapp IN (${leadCanonical}, ${leadCanonicalVariant}) AND other.id <> candidates.id ) OR EXISTS ( SELECT 1 FROM outreach_shared_number_overrides ov - WHERE ov.numero IN (${leadNumber}, ${leadEquivalent}) + WHERE ov.numero IN (${leadCanonical}, ${leadCanonicalVariant}) ) ) AND NOT EXISTS ( diff --git a/src/outreach/campaign-worker.mjs b/src/outreach/campaign-worker.mjs index 5595e52..20d12a4 100644 --- a/src/outreach/campaign-worker.mjs +++ b/src/outreach/campaign-worker.mjs @@ -2,7 +2,11 @@ import { equivalentPhoneNumbers, normalizePhone } from '../phone.mjs'; -import { setSetting } from '../db.mjs'; +import { + isNumberSharedAcrossLeads, + isSharedNumberOverridden, + setSetting +} from '../db.mjs'; import { evaluateCampaignHealth } from './campaign-metrics.mjs'; const ACTIVE_SEND_STATUSES = "'pendente','aceito','enviado','entregue','lido','desconhecido'"; @@ -103,6 +107,16 @@ export function reserveCampaignQueueSend(db, { queueId, now = new Date() }) { ).get(row.lead_id, row.produto)) { throw permanentPolicyConflict('envio ativo para o mesmo lead e produto', 'duplicate_product_send'); } + // Ultima milha: a selecao de candidatos avaliou o compartilhamento la + // atras. Entre o agendamento e o envio o operador pode ter rebloqueado o + // numero em Configuracoes, ou uma nova captura pode ter criado um segundo + // lead com o mesmo telefone. + if (isNumberSharedAcrossLeads(db, row.numero) && !isSharedNumberOverridden(db, row.numero)) { + throw permanentPolicyConflict( + 'numero compartilhado por multiplos leads (revise em Configuracoes antes de enviar)', + 'shared_number' + ); + } } catch (error) { if (!error.permanentCampaignPolicy) throw error; const cancelled = db.prepare( diff --git a/src/phone.mjs b/src/phone.mjs index bf509bc..ba31f3c 100644 --- a/src/phone.mjs +++ b/src/phone.mjs @@ -12,3 +12,80 @@ export function equivalentPhoneNumbers(value = '') { if (local.length === 8 && /^[6-9]/.test(local)) numbers.push(`${prefix}9${local}`); return [...new Set(numbers)]; } + +// equivalentPhoneNumbers NAO e simetrica: ela so gera a variante de 8 digitos a +// partir de um celular de 9 digitos quando o local comeca com 6-9. Um numero +// como 557533334444 (local '33334444') nao volta para 5575933334444, embora o +// caminho inverso exista — o que fazia um lado do par ser bloqueado e o outro +// nao, e escondia o grupo da tela de revisao. +// +// canonicalPhoneKey resolve isso reduzindo SEMPRE o celular brasileiro de 13 +// digitos com nono digito para a forma de 12 digitos (mesma regra de remocao ja +// usada por equivalentPhoneNumbers). Dois numeros pertencem a mesma linha +// quando compartilham a chave canonica — uma relacao simetrica por construcao. +export function canonicalPhoneKey(value = '') { + const digits = normalizePhone(value); + if (!digits.startsWith('55')) return digits; + const prefix = digits.slice(0, 4); + const local = digits.slice(4); + if (local.length === 9 && local.startsWith('9')) return `${prefix}${local.slice(1)}`; + return digits; +} + +// Todas as formas que um numero pode ter armazenado e que reduzem a mesma chave +// canonica. O numero informado vem sempre primeiro (o override guarda esse +// valor). Usado para casar contra a coluna crua `leads.whatsapp`. +export function canonicalPhoneVariants(value = '') { + const digits = normalizePhone(value); + if (!digits) return []; + const key = canonicalPhoneKey(digits); + const variants = new Set([digits, key]); + if (key.startsWith('55') && key.length === 12) { + variants.add(`${key.slice(0, 4)}9${key.slice(4)}`); + } + return [...variants]; +} + +// Fragmentos SQL equivalentes as funcoes JS acima. Ficam neste modulo para que +// exista uma unica origem da regra 8/9 digitos — tests/phone.test.mjs prova que +// as duas implementacoes concordam. +export function normalizedSql(column) { + return `replace(replace(replace(replace(replace(${column}, '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')`; +} + +// Espelha equivalentPhoneNumbers: alterna entre as duas variantes brasileiras. +export function equivalentSql(column) { + const number = normalizedSql(column); + return `CASE + WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9' + THEN substr(${number}, 1, 4) || substr(${number}, 6) + WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 12 AND substr(${number}, 5, 1) BETWEEN '6' AND '9' + THEN substr(${number}, 1, 4) || '9' || substr(${number}, 5) + ELSE ${number} + END`; +} + +// Espelha canonicalPhoneKey. +export function canonicalKeySql(column) { + const number = normalizedSql(column); + return `CASE + WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9' + THEN substr(${number}, 1, 4) || substr(${number}, 6) + ELSE ${number} + END`; +} + +// A outra forma da mesma chave canonica (a de 13 digitos). Junto com +// canonicalKeySql cobre as duas formas possiveis de armazenamento, permitindo +// `outra.whatsapp IN (chave, variante)` — comparacao contra a coluna crua, que +// mantem o uso de idx_leads_whatsapp. +export function canonicalVariantSql(column) { + const number = normalizedSql(column); + return `CASE + WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 13 AND substr(${number}, 5, 1) = '9' + THEN ${number} + WHEN substr(${number}, 1, 2) = '55' AND length(${number}) = 12 + THEN substr(${number}, 1, 4) || '9' || substr(${number}, 5) + ELSE ${number} + END`; +} diff --git a/src/treatment/queries.mjs b/src/treatment/queries.mjs index c2d8ccb..3671cc1 100644 --- a/src/treatment/queries.mjs +++ b/src/treatment/queries.mjs @@ -2,7 +2,27 @@ // exportacoes: o MESMO objeto de filtro que a tela usa para navegar e o que // define quais leads entram num relatorio/exportacao. -import { getWhatsAppVerification, listSharedNumberGroups } from '../db.mjs'; +import { getWhatsAppVerification } from '../db.mjs'; +import { canonicalKeySql, canonicalVariantSql, normalizedSql } from '../phone.mjs'; + +// Estado de numero compartilhado calculado por linha, em SQL, com subconsultas +// correlacionadas: o custo acompanha o tamanho da pagina, nao o da tabela +// (a versao anterior varria `leads` inteira a cada listagem). A comparacao e +// feita contra a coluna crua `leads.whatsapp` para que idx_leads_whatsapp seja +// usado, mesma forma ja provada em campaign-queue.mjs. +function sharedNumberColumnsSql(column) { + const numero = normalizedSql(column); + const canonical = canonicalKeySql(column); + const variant = canonicalVariantSql(column); + return `CASE WHEN ${numero} = '' THEN 0 ELSE ( + SELECT COUNT(*) FROM leads sharing + WHERE sharing.whatsapp IN (${canonical}, ${variant}) + ) END AS numero_compartilhado_total_raw, + CASE WHEN ${numero} = '' THEN 0 ELSE ( + SELECT COUNT(*) FROM outreach_shared_number_overrides ov + WHERE ov.numero IN (${canonical}, ${variant}) + ) END AS numero_compartilhado_override_raw`; +} const SORTS = { 'prioridade:asc': 'prioridade ASC, score DESC, cidade ASC, empresa ASC', @@ -75,25 +95,28 @@ export function listLeads(db, filtro = {}) { const orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc']; const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t; - const sharedIndex = sharedNumberIndex(db); 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 + exported_at, captured_at, segments.affinity AS afinidade, + ${sharedNumberColumnsSql('leads.whatsapp')} FROM leads LEFT JOIN segments ON segments.name = leads.segmento ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?` ).all(...params, pageSize, (page - 1) * pageSize) - .map((lead) => withSharedNumberState(withWhatsAppState(db, lead), sharedIndex)); + .map((lead) => withSharedNumberState(withWhatsAppState(db, lead))); return { total, page, pageSize, rows }; } export function getLead(db, id) { - const storedLead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id); - const lead = storedLead ? withSharedNumberState(withWhatsAppState(db, storedLead), sharedNumberIndex(db)) : null; + const storedLead = db.prepare( + `SELECT leads.*, ${sharedNumberColumnsSql('leads.whatsapp')} + FROM leads WHERE leads.id = ?` + ).get(id); + const lead = storedLead ? withSharedNumberState(withWhatsAppState(db, storedLead)) : null; if (!lead) return null; const exportHistory = db.prepare( @@ -144,23 +167,20 @@ function withWhatsAppState(db, lead) { }; } -function sharedNumberIndex(db) { - const index = new Map(); - for (const group of listSharedNumberGroups(db)) { - for (const lead of group.leads) { - index.set(lead.id, { total: group.leads.length, liberado: group.liberado }); - } - } - return index; -} - -function withSharedNumberState(lead, index) { - const shared = index.get(lead.id); +function withSharedNumberState(lead) { + const { + numero_compartilhado_total_raw: totalRaw, + numero_compartilhado_override_raw: overrideRaw, + ...rest + } = lead; + // Compartilhado = 2+ leads na mesma linha (o proprio lead conta), mesma + // semantica de isNumberSharedAcrossLeads. + const shared = Number(totalRaw) >= 2; return { - ...lead, - numero_compartilhado: Boolean(shared), - numero_compartilhado_total: shared?.total || 0, - numero_compartilhado_liberado: shared?.liberado || false + ...rest, + numero_compartilhado: shared, + numero_compartilhado_total: shared ? Number(totalRaw) : 0, + numero_compartilhado_liberado: shared && Number(overrideRaw) > 0 }; } diff --git a/tests/campaign-queue.test.mjs b/tests/campaign-queue.test.mjs index b7ceef9..87e6308 100644 --- a/tests/campaign-queue.test.mjs +++ b/tests/campaign-queue.test.mjs @@ -576,6 +576,37 @@ test('numero compartilhado por mais de um lead fica fora da fila ate liberacao m db.close(); }); +// 557533334444 vs 5575933334444: equivalentPhoneNumbers so relaciona o segundo +// com o primeiro. Os dois precisam ficar fora da fila. +test('par 12/13 digitos assimetrico fica fora da fila nos dois sentidos', async () => { + const { db, campaign } = setupCampaign(); + const doze = insertLead(db, { key: 'assim-12', whatsapp: '557533334444', score: 100 }); + const treze = insertLead(db, { key: 'assim-13', whatsapp: '5575933334444', score: 99 }); + const solo = insertLead(db, { key: 'assim-solo', whatsapp: '5575992220000', score: 80 }); + for (const numero of ['557533334444', '5575933334444', '5575992220000']) confirmed(db, numero); + + await prepareDailyQueue({ + db, + whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }), + campaignId: campaign.id, + now: new Date('2026-07-29T14:00:00-03:00') + }); + assert.deepEqual(listCampaignQueue(db, campaign.id).map(({ lead_id }) => lead_id), [solo]); + + addSharedNumberOverride(db, '557533334444', 'contabilidade confirmada'); + await prepareDailyQueue({ + db, + whatsapp: noSendWhatsapp(async () => { throw new Error('todos os numeros possuem cache'); }), + campaignId: campaign.id, + now: new Date('2026-07-29T14:00:00-03:00') + }); + assert.deepEqual( + listCampaignQueue(db, campaign.id).map(({ lead_id }) => lead_id).sort((a, b) => a - b), + [doze, treze, solo].sort((a, b) => a - b) + ); + db.close(); +}); + test('variantes 8 e 9 digitos do mesmo numero contam como compartilhado na fila', async () => { const { db, campaign } = setupCampaign(); const nineDigits = insertLead(db, { key: 'nine-digits', whatsapp: '5575999091780', score: 100 }); diff --git a/tests/campaign-worker.test.mjs b/tests/campaign-worker.test.mjs index 35efd12..f38f2fc 100644 --- a/tests/campaign-worker.test.mjs +++ b/tests/campaign-worker.test.mjs @@ -6,8 +6,10 @@ import path from 'node:path'; import { Worker } from 'node:worker_threads'; import { addOptOut, + addSharedNumberOverride, openDb, recoverPendingOutreach, + removeSharedNumberOverride, reserveOutreachSend, saveWhatsAppVerification, setSetting @@ -448,7 +450,11 @@ test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedu ['envio ativo do mesmo produto', 'duplicate_product_send', ({ db, campaign, item }) => db.prepare( `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem) VALUES (?, ?, ?, 'duplicado', 'desconhecido', 'automatico')` - ).run(item.leadId, campaign.produto, item.numero)] + ).run(item.leadId, campaign.produto, item.numero)], + ['numero virou compartilhado', 'shared_number', ({ db, item }) => db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido) + VALUES ('worker-compartilhado', 'Outro lead com o mesmo telefone', ?, 'restaurantes', 1)` + ).run(item.numero)] ]; for (const [name, cancelReason, mutate] of cases) { @@ -495,6 +501,93 @@ test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedu db.close(); }); +// Cenario real do operador: numero compartilhado liberado em Configuracoes, +// item entra na fila e e agendado, operador reconsidera e clica "Bloquear de +// novo". Sem a revalidacao de ultima milha o envio ja agendado sairia mesmo +// assim, porque a checagem de compartilhamento so acontecia na selecao. +test('libera, agenda e rebloqueia numero compartilhado cancela o envio ja agendado', async () => { + const { runDueCampaignQueue } = await workerModule(); + for (const [name, reblock, expected] of [ + ['override mantido', false, { tentados: 1, enviados: 1, erros: 0, pulados: 0 }], + ['override removido antes do envio', true, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }] + ]) { + const { db, campaign } = setup(); + const item = addQueue(db, campaign, { suffix: 600 }); + db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido) + VALUES ('worker-600-b', 'Cliente da mesma contabilidade', ?, 'restaurantes', 1)` + ).run(item.numero); + addSharedNumberOverride(db, item.numero, 'contabilidade confirmada'); + if (reblock) assert.equal(removeSharedNumberOverride(db, item.numero), true, name); + + const whatsapp = whatsappFake(); + const summary = await runDueCampaignQueue({ + db, + whatsapp, + now: new Date('2026-07-29T12:00:00.000Z'), + log: () => {} + }); + + assert.deepEqual(summary, expected, name); + assert.equal(whatsapp.providerCalls, reblock ? 0 : 1, name); + assert.deepEqual( + { ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(item.queueId) }, + reblock ? { status: 'cancelled', cancel_reason: 'shared_number' } : { status: 'sent', cancel_reason: null }, + name + ); + assert.equal(db.prepare('SELECT COUNT(*) total FROM outreach_sends').get().total, reblock ? 0 : 1, name); + db.close(); + } +}); + +// Par assimetrico: 557533334444 (local '33334444', fora da faixa 6-9) e +// 5575933334444. equivalentPhoneNumbers so relaciona o segundo com o primeiro, +// entao antes da chave canonica o lead de 12 digitos passava batido aqui. +test('ultima milha bloqueia os dois lados de um par 12/13 digitos assimetrico', async () => { + const { runDueCampaignQueue } = await workerModule(); + for (const [queued, outro] of [ + ['557533334444', '5575933334444'], + ['5575933334444', '557533334444'] + ]) { + const { db, campaign } = setup(); + const leadId = Number(db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido) + VALUES ('assimetrico-fila', 'Lead na fila', ?, 'restaurantes', 1)` + ).run(queued).lastInsertRowid); + db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, segmento, telefone_valido) + VALUES ('assimetrico-outro', 'Lead com o mesmo telefone', ?, 'restaurantes', 1)` + ).run(outro); + saveWhatsAppVerification(db, { numero: queued, status: 'confirmado' }); + const variantId = db.prepare( + 'SELECT id FROM outreach_campaign_variants WHERE campaign_id = ? ORDER BY id LIMIT 1' + ).get(campaign.id).id; + const queueId = Number(db.prepare( + `INSERT INTO outreach_campaign_queue + (campaign_id, lead_id, variant_id, produto, numero, segmento, + rendered_message, selection_reason, status, scheduled_at) + VALUES (?, ?, ?, ?, ?, 'restaurantes', 'Mensagem', 'teste', 'scheduled', '2026-07-29T12:00:00.000Z')` + ).run(campaign.id, leadId, variantId, campaign.produto, queued).lastInsertRowid); + + const whatsapp = whatsappFake(); + const summary = await runDueCampaignQueue({ + db, + whatsapp, + now: new Date('2026-07-29T12:00:00.000Z'), + log: () => {} + }); + + assert.deepEqual(summary, { tentados: 1, enviados: 0, erros: 0, pulados: 1 }, queued); + assert.equal(whatsapp.providerCalls, 0, queued); + assert.deepEqual( + { ...db.prepare('SELECT status, cancel_reason FROM outreach_campaign_queue WHERE id = ?').get(queueId) }, + { status: 'cancelled', cancel_reason: 'shared_number' }, + queued + ); + db.close(); + } +}); + test('afterSend finaliza fila e historico uma unica vez por resultado', async () => { const { runDueCampaignQueue } = await workerModule(); for (const [outcome, queueStatus, sendStatus] of [ diff --git a/tests/db.test.mjs b/tests/db.test.mjs index 890d337..0d45c25 100644 --- a/tests/db.test.mjs +++ b/tests/db.test.mjs @@ -131,6 +131,48 @@ test('listSharedNumberGroups agrupa por equivalencia e reflete override', () => db.close(); }); +// equivalentPhoneNumbers nao e simetrica: 5575933334444 expande para +// 557533334444 (remove o nono digito sem condicao), mas 557533334444 nao volta +// para 5575933334444 (o local '33334444' esta fora da faixa 6-9). O par +// precisa ser detectado, agrupado e bloqueado nos dois sentidos. +test('par 12/13 digitos assimetrico compartilha numero nos dois sentidos', () => { + const db = openDb(':memory:'); + const insert = createLeadInserter(db); + insert(sampleLead({ dedup_key: 'assim-a', empresa: 'Empresa A', whatsapp: '557533334444' })); + insert(sampleLead({ dedup_key: 'assim-b', empresa: 'Empresa B', whatsapp: '5575933334444' })); + insert(sampleLead({ dedup_key: 'assim-solo', empresa: 'Empresa Solo', whatsapp: '5511999990001' })); + + for (const numero of ['557533334444', '5575933334444']) { + assert.equal(database.isNumberSharedAcrossLeads(db, numero), true, numero); + assert.deepEqual( + database.leadsSharingNumber(db, numero).map((lead) => lead.empresa).sort(), + ['Empresa A', 'Empresa B'], + numero + ); + } + assert.equal(database.isNumberSharedAcrossLeads(db, '5511999990001'), false); + + const groups = database.listSharedNumberGroups(db); + assert.equal(groups.length, 1); + assert.deepEqual(groups[0].leads.map((lead) => lead.empresa).sort(), ['Empresa A', 'Empresa B']); + + // Envio manual bloqueado pelas duas formas, e liberado pelas duas assim que o + // operador revisa o grupo unico da tela de Configuracoes. + for (const numero of ['557533334444', '5575933334444']) { + assert.throws( + () => reserveOutreachSend(db, { numero, mensagem: 'oi' }), + (error) => error.statusCode === 409, + numero + ); + } + database.addSharedNumberOverride(db, groups[0].numero, 'contabilidade confirmada'); + for (const numero of ['557533334444', '5575933334444']) { + assert.equal(database.isSharedNumberOverridden(db, numero), true, numero); + assert.ok(reserveOutreachSend(db, { numero, mensagem: 'oi' }) > 0, numero); + } + db.close(); +}); + test('v15 preserva linhas legadas ao migrar uma base populada em v14', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-v15-')); const dbPath = path.join(tempDir, 'legacy.sqlite'); diff --git a/tests/personal-backend.test.mjs b/tests/personal-backend.test.mjs index 938aa50..3fc4bf9 100644 --- a/tests/personal-backend.test.mjs +++ b/tests/personal-backend.test.mjs @@ -175,3 +175,115 @@ test('listagem e detalhe marcam numero compartilhado por mais de um lead', async assert.equal(refreshed.numero_compartilhado_liberado, true); db.close(); }); + +// Par assimetrico de equivalentPhoneNumbers (557533334444 / 5575933334444): +// os dois lados precisam aparecer marcados na tela de tratamento. +test('listagem marca os dois lados de um par 12/13 digitos assimetrico', async () => { + const { openDb } = await import('../src/db.mjs'); + const { getLead, listLeads } = await import('../src/treatment/queries.mjs'); + const db = openDb(':memory:'); + const doze = Number(db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) + VALUES ('assim-12', 'Empresa 12 digitos', '557533334444', 1)` + ).run().lastInsertRowid); + const treze = Number(db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido) + VALUES ('assim-13', 'Empresa 13 digitos', '5575933334444', 1)` + ).run().lastInsertRowid); + + const rows = new Map(listLeads(db).rows.map((row) => [row.id, row])); + for (const id of [doze, treze]) { + assert.equal(rows.get(id).numero_compartilhado, true, String(id)); + assert.equal(rows.get(id).numero_compartilhado_total, 2, String(id)); + assert.equal(getLead(db, id).numero_compartilhado, true, String(id)); + } + db.close(); +}); + +// A versao anterior carregava TODOS os leads com telefone em JS a cada +// listagem. Agora o estado sai de subconsultas correlacionadas por linha: o +// plano precisa usar idx_leads_whatsapp e a listagem paginada nao pode custar +// proporcional ao tamanho da tabela. +test('estado de numero compartilhado sai por linha, sem varrer a tabela inteira', async () => { + const { openDb } = await import('../src/db.mjs'); + const { listLeads } = await import('../src/treatment/queries.mjs'); + const db = openDb(':memory:'); + db.exec(` + WITH RECURSIVE sequence(value) AS ( + VALUES(1) UNION ALL SELECT value + 1 FROM sequence WHERE value < 60000 + ) + INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score) + SELECT 'ruido-' || value, 'Ruido ' || value, '5511' || printf('%08d', value), 1, 4, 0 + FROM sequence; + `); + db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score) + VALUES ('pagina-compartilhado-a', 'Compartilhado A', '5575999091780', 1, 1, 100)` + ).run(); + db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score) + VALUES ('pagina-compartilhado-b', 'Compartilhado B', '557599091780', 1, 1, 99)` + ).run(); + db.prepare( + `INSERT INTO leads (dedup_key, empresa, whatsapp, telefone_valido, prioridade, score) + VALUES ('pagina-unico', 'Unico', '5575992220000', 1, 1, 98)` + ).run(); + + // Conta TODAS as linhas que chegam ao JS durante a listagem: a implementacao + // antiga trazia os 60 mil leads com telefone para montar o indice em memoria. + const trace = { sql: null, args: null, rowsRead: 0 }; + const tracedDb = new Proxy(db, { + get(target, property) { + if (property !== 'prepare') { + const value = target[property]; + return typeof value === 'function' ? value.bind(target) : value; + } + return (sql) => { + const statement = target.prepare(sql); + if (sql.includes('numero_compartilhado_total_raw')) trace.sql = sql; + return new Proxy(statement, { + get(stmt, name) { + if (name === 'all') { + return (...args) => { + const rows = stmt.all(...args); + trace.rowsRead += rows.length; + if (sql.includes('numero_compartilhado_total_raw')) trace.args = args; + return rows; + }; + } + const value = stmt[name]; + return typeof value === 'function' ? value.bind(stmt) : value; + } + }); + }; + } + }); + + const startedAt = performance.now(); + const rows = listLeads(tracedDb, { pageSize: 3 }).rows; + const elapsedMs = performance.now() - startedAt; + + assert.deepEqual( + rows.map((row) => [row.empresa, row.numero_compartilhado, row.numero_compartilhado_total]), + [ + ['Compartilhado A', true, 2], + ['Compartilhado B', true, 2], + ['Unico', false, 0] + ] + ); + assert.ok( + trace.rowsRead <= 10, + `listagem trouxe ${trace.rowsRead} linhas para o JS com pageSize 3 (base de 60003 leads)` + ); + assert.ok(elapsedMs < 1_000, `listagem paginada levou ${elapsedMs.toFixed(1)}ms`); + + // Prova estrutural na consulta real: a subconsulta correlacionada procura os + // irmaos pelo indice do telefone, e a pagina e limitada no SQL. + assert.ok(trace.sql, 'consulta real de listagem nao foi observada'); + assert.match(trace.sql, /LIMIT \? OFFSET \?/); + const plan = db.prepare(`EXPLAIN QUERY PLAN ${trace.sql}`).all(...trace.args) + .map((row) => row.detail).join('\n'); + assert.match(plan, /sharing USING [A-Z ]*INDEX idx_leads_whatsapp/i, plan); + assert.doesNotMatch(plan, /SCAN sharing/i, plan); + db.close(); +}); diff --git a/tests/phone.test.mjs b/tests/phone.test.mjs new file mode 100644 index 0000000..37d6adb --- /dev/null +++ b/tests/phone.test.mjs @@ -0,0 +1,120 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { + canonicalKeySql, + canonicalPhoneKey, + canonicalPhoneVariants, + canonicalVariantSql, + equivalentPhoneNumbers, + equivalentSql, + normalizePhone, + normalizedSql +} from '../src/phone.mjs'; + +// A regra 8/9 digitos existe duas vezes: em JS (equivalentPhoneNumbers / +// canonicalPhoneKey, usadas pelo envio manual e pela revalidacao de ultima +// milha) e em SQL (equivalentSql / canonicalKeySql, usadas pela selecao de +// candidatos da campanha e pela listagem de tratamento). Se alguem editar uma +// sem a outra, o bloqueio automatico e o manual passam a discordar em silencio. +// Estes testes rodam o mesmo conjunto de numeros pelos dois caminhos. +const NUMBERS = [ + '5575999091780', // celular BR de 13 digitos com nono digito + '557599091780', // mesma linha em 8 digitos (local comeca com 9) + '5571993335451', // outro celular de 13 digitos + '557193335451', // fixo/8 digitos que comeca com 9 -> tem par + '557133334444', // fixo de 12 digitos, local comeca com 3 (sem par direto) + '5575933334444', // par assimetrico do numero abaixo + '557533334444', // 12 digitos, local '33334444' fora da faixa 6-9 + '551140028922', // 12 digitos, local comeca com 4 + '557561234567', // 12 digitos, local comeca com 6 (limite inferior da faixa) + '557551234567', // 12 digitos, local comeca com 5 (logo abaixo da faixa) + '557591234567', // 12 digitos, local comeca com 9 (limite superior da faixa) + '5511987654321', // 13 digitos comum + '11987654321', // sem DDI 55 + '5575', // curto demais + '' // vazio +]; + +function sqlDb() { + const db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE amostra (bruto TEXT)'); + return db; +} + +function sqlValues(db, numero) { + db.prepare('DELETE FROM amostra').run(); + db.prepare('INSERT INTO amostra (bruto) VALUES (?)').run(numero); + return db.prepare( + `SELECT ${normalizedSql('bruto')} AS normalizado, + ${equivalentSql('bruto')} AS equivalente, + ${canonicalKeySql('bruto')} AS canonico, + ${canonicalVariantSql('bruto')} AS variante + FROM amostra` + ).get(); +} + +test('normalizedSql concorda com normalizePhone', () => { + const db = sqlDb(); + for (const numero of [...NUMBERS, '+55 (75) 99909-1780', '55 71 9333-5451']) { + assert.equal(sqlValues(db, numero).normalizado, normalizePhone(numero), numero); + } + db.close(); +}); + +test('equivalentSql concorda com equivalentPhoneNumbers em todas as amostras', () => { + const db = sqlDb(); + for (const numero of NUMBERS) { + const equivalentes = equivalentPhoneNumbers(numero); + // equivalentPhoneNumbers devolve [proprio, alternativo?]; equivalentSql + // devolve apenas o "outro lado" (ou o proprio quando nao ha alternativa). + const esperado = equivalentes.length > 1 ? equivalentes[1] : normalizePhone(numero); + assert.equal(sqlValues(db, numero).equivalente, esperado, numero); + } + db.close(); +}); + +test('canonicalKeySql concorda com canonicalPhoneKey em todas as amostras', () => { + const db = sqlDb(); + for (const numero of NUMBERS) { + assert.equal(sqlValues(db, numero).canonico, canonicalPhoneKey(numero), numero); + } + db.close(); +}); + +test('canonicalKeySql + canonicalVariantSql cobrem o mesmo conjunto de canonicalPhoneVariants', () => { + const db = sqlDb(); + for (const numero of NUMBERS) { + const { canonico, variante } = sqlValues(db, numero); + const sqlSet = [...new Set([canonico, variante].filter(Boolean))].sort(); + const jsSet = [...new Set(canonicalPhoneVariants(numero))].sort(); + assert.deepEqual(sqlSet, jsSet, numero); + } + db.close(); +}); + +test('a chave canonica e simetrica onde equivalentPhoneNumbers nao e', () => { + // Caso concreto: A tem 12 digitos com local fora da faixa 6-9, B tem 13. + assert.deepEqual(equivalentPhoneNumbers('557533334444'), ['557533334444']); + assert.deepEqual(equivalentPhoneNumbers('5575933334444'), ['5575933334444', '557533334444']); + assert.equal(canonicalPhoneKey('557533334444'), '557533334444'); + assert.equal(canonicalPhoneKey('5575933334444'), '557533334444'); + + // E os pares 8/9 digitos ja cobertos pela suite continuam batendo entre si. + assert.equal(canonicalPhoneKey('5575999091780'), canonicalPhoneKey('557599091780')); + assert.equal(canonicalPhoneKey('5571993335451'), canonicalPhoneKey('557193335451')); + + // Simetria total do conjunto de casamento nas amostras brasileiras. + for (const numero of NUMBERS) { + for (const outro of canonicalPhoneVariants(numero)) { + assert.ok( + canonicalPhoneVariants(outro).includes(normalizePhone(numero)), + `${numero} <-> ${outro}` + ); + } + } + + // Numeros de linhas diferentes nao podem colidir. + assert.notEqual(canonicalPhoneKey('557533334444'), canonicalPhoneKey('557133334444')); + assert.notEqual(canonicalPhoneKey('5511987654321'), canonicalPhoneKey('551140028922')); +});