Fix final-review findings: last-mile shared-number recheck, symmetric canonical key, per-row treatment query, JS/SQL agreement test
The last-mile validator in campaign-worker.mjs revalidated opt-out/inbound/ dedupe right before sending but never rechecked shared-number state, so re-blocking a released number after scheduling didn't stop the send. equivalentPhoneNumbers isn't symmetric for all 8/9-digit pairs, which made isNumberSharedAcrossLeads and listSharedNumberGroups disagree for a 12-digit local outside the 6-9 range vs its 13-digit counterpart: one side of the pair could be enforcement-blocked while invisible and unreleasable in the review screen. A new canonicalPhoneKey/canonicalPhoneVariants pair in phone.mjs (plus SQL mirrors) makes the matching relation symmetric by construction. listSharedNumberGroups did a full leads-table scan on every listLeads/getLead call, the app's primary paginated screen. Replaced with per-row correlated subqueries against idx_leads_whatsapp so cost tracks the page, not the table. normalizedSql/equivalentSql (SQL) duplicated equivalentPhoneNumbers (JS) with no test proving they agreed; moved into phone.mjs alongside their canonical counterparts and added tests/phone.test.mjs asserting agreement across a shared number set, including the asymmetric case.
This commit is contained in:
+20
-9
@@ -5,7 +5,12 @@ import { paths } from './env.mjs';
|
|||||||
import { segmentLabel } from './scoring.mjs';
|
import { segmentLabel } from './scoring.mjs';
|
||||||
import { cleanText } from './lead-engine.mjs';
|
import { cleanText } from './lead-engine.mjs';
|
||||||
import { hashPassword } from './auth.mjs';
|
import { hashPassword } from './auth.mjs';
|
||||||
import { equivalentPhoneNumbers, normalizePhone } from './phone.mjs';
|
import {
|
||||||
|
canonicalPhoneKey,
|
||||||
|
canonicalPhoneVariants,
|
||||||
|
equivalentPhoneNumbers,
|
||||||
|
normalizePhone
|
||||||
|
} from './phone.mjs';
|
||||||
|
|
||||||
const MIGRATIONS = [
|
const MIGRATIONS = [
|
||||||
// v1 — esquema inicial
|
// 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) {
|
export function leadsSharingNumber(db, numero) {
|
||||||
const numbers = equivalentPhoneNumbers(numero);
|
const numbers = canonicalPhoneVariants(numero);
|
||||||
if (!numbers.length) return [];
|
if (!numbers.length) return [];
|
||||||
const placeholders = numbers.map(() => '?').join(', ');
|
const placeholders = numbers.map(() => '?').join(', ');
|
||||||
return db.prepare(`SELECT id, empresa, cnpj FROM leads WHERE whatsapp IN (${placeholders})`).all(...numbers);
|
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) {
|
export function getSharedNumberOverride(db, numero) {
|
||||||
const numbers = equivalentPhoneNumbers(numero);
|
const numbers = canonicalPhoneVariants(numero);
|
||||||
if (!numbers.length) return null;
|
if (!numbers.length) return null;
|
||||||
const placeholders = numbers.map(() => '?').join(', ');
|
const placeholders = numbers.map(() => '?').join(', ');
|
||||||
return db.prepare(
|
return db.prepare(
|
||||||
@@ -981,7 +990,7 @@ export function isSharedNumberOverridden(db, numero) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function addSharedNumberOverride(db, numero, motivo = '') {
|
export function addSharedNumberOverride(db, numero, motivo = '') {
|
||||||
const numbers = equivalentPhoneNumbers(numero);
|
const numbers = canonicalPhoneVariants(numero);
|
||||||
if (!numbers.length) throw new Error('numero obrigatorio');
|
if (!numbers.length) throw new Error('numero obrigatorio');
|
||||||
return transaction(db, () => {
|
return transaction(db, () => {
|
||||||
const placeholders = numbers.map(() => '?').join(', ');
|
const placeholders = numbers.map(() => '?').join(', ');
|
||||||
@@ -995,7 +1004,7 @@ export function addSharedNumberOverride(db, numero, motivo = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function removeSharedNumberOverride(db, numero) {
|
export function removeSharedNumberOverride(db, numero) {
|
||||||
const numbers = equivalentPhoneNumbers(numero);
|
const numbers = canonicalPhoneVariants(numero);
|
||||||
if (!numbers.length) return false;
|
if (!numbers.length) return false;
|
||||||
return transaction(db, () => {
|
return transaction(db, () => {
|
||||||
const placeholders = numbers.map(() => '?').join(', ');
|
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 rows = db.prepare("SELECT id, empresa, cnpj, whatsapp FROM leads WHERE whatsapp <> ''").all();
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const equivalents = equivalentPhoneNumbers(row.whatsapp);
|
// Agrupar pela chave canonica (e nao pelo conjunto de equivalentes) mantem
|
||||||
if (!equivalents.length) continue;
|
// as duas formas do mesmo numero no mesmo balde mesmo quando a expansao de
|
||||||
const key = equivalents.slice().sort().join(':');
|
// equivalentPhoneNumbers e assimetrica.
|
||||||
if (!groups.has(key)) groups.set(key, { numero: equivalents[0], leads: [] });
|
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 });
|
groups.get(key).leads.push({ id: row.id, empresa: row.empresa, cnpj: row.cnpj });
|
||||||
}
|
}
|
||||||
return [...groups.values()]
|
return [...groups.values()]
|
||||||
|
|||||||
@@ -2,28 +2,20 @@ import {
|
|||||||
isOptedOut,
|
isOptedOut,
|
||||||
saveWhatsAppVerification
|
saveWhatsAppVerification
|
||||||
} from '../db.mjs';
|
} 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 { getCampaign } from './campaign-store.mjs';
|
||||||
import { renderCampaignMessage, variantForLead } from './campaign-messages.mjs';
|
import { renderCampaignMessage, variantForLead } from './campaign-messages.mjs';
|
||||||
|
|
||||||
const ACTIVE_QUEUE_STATUSES = "'preview','approved','scheduled','reserved','sent','unknown'";
|
const ACTIVE_QUEUE_STATUSES = "'preview','approved','scheduled','reserved','sent','unknown'";
|
||||||
const PENDING_QUEUE_STATUSES = "'preview','approved','scheduled','reserved'";
|
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) {
|
function candidateQuery(confirmed) {
|
||||||
// lead_number/lead_equivalent are named columns computed in the derived
|
// lead_number/lead_equivalent are named columns computed in the derived
|
||||||
// `candidates` subquery below, purely as a readability device so the rest of
|
// `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.
|
// See task-3-report.md for the full investigation and evidence.
|
||||||
const leadNumberExpr = normalizedSql('raw.whatsapp');
|
const leadNumberExpr = normalizedSql('raw.whatsapp');
|
||||||
const leadEquivalentExpr = equivalentSql('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 leadNumber = 'candidates.lead_number';
|
||||||
const leadEquivalent = 'candidates.lead_equivalent';
|
const leadEquivalent = 'candidates.lead_equivalent';
|
||||||
|
const leadCanonical = 'candidates.lead_canonical';
|
||||||
|
const leadCanonicalVariant = 'candidates.lead_canonical_variant';
|
||||||
const verificationFilter = confirmed
|
const verificationFilter = confirmed
|
||||||
? "verification.status = 'confirmado'"
|
? "verification.status = 'confirmado'"
|
||||||
: "(verification.status IS NULL OR verification.status = 'erro_temporario')";
|
: "(verification.status IS NULL OR verification.status = 'erro_temporario')";
|
||||||
@@ -55,7 +54,9 @@ function candidateQuery(confirmed) {
|
|||||||
FROM (
|
FROM (
|
||||||
SELECT raw.*,
|
SELECT raw.*,
|
||||||
${leadNumberExpr} AS lead_number,
|
${leadNumberExpr} AS lead_number,
|
||||||
${leadEquivalentExpr} AS lead_equivalent
|
${leadEquivalentExpr} AS lead_equivalent,
|
||||||
|
${leadCanonicalExpr} AS lead_canonical,
|
||||||
|
${leadCanonicalVariantExpr} AS lead_canonical_variant
|
||||||
FROM leads raw
|
FROM leads raw
|
||||||
WHERE raw.segmento = ?
|
WHERE raw.segmento = ?
|
||||||
) AS candidates
|
) AS candidates
|
||||||
@@ -87,12 +88,12 @@ function candidateQuery(confirmed) {
|
|||||||
AND (
|
AND (
|
||||||
NOT EXISTS (
|
NOT EXISTS (
|
||||||
SELECT 1 FROM leads other
|
SELECT 1 FROM leads other
|
||||||
WHERE other.whatsapp IN (${leadNumber}, ${leadEquivalent})
|
WHERE other.whatsapp IN (${leadCanonical}, ${leadCanonicalVariant})
|
||||||
AND other.id <> candidates.id
|
AND other.id <> candidates.id
|
||||||
)
|
)
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM outreach_shared_number_overrides ov
|
SELECT 1 FROM outreach_shared_number_overrides ov
|
||||||
WHERE ov.numero IN (${leadNumber}, ${leadEquivalent})
|
WHERE ov.numero IN (${leadCanonical}, ${leadCanonicalVariant})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import {
|
|||||||
equivalentPhoneNumbers,
|
equivalentPhoneNumbers,
|
||||||
normalizePhone
|
normalizePhone
|
||||||
} from '../phone.mjs';
|
} from '../phone.mjs';
|
||||||
import { setSetting } from '../db.mjs';
|
import {
|
||||||
|
isNumberSharedAcrossLeads,
|
||||||
|
isSharedNumberOverridden,
|
||||||
|
setSetting
|
||||||
|
} from '../db.mjs';
|
||||||
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
|
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
|
||||||
|
|
||||||
const ACTIVE_SEND_STATUSES = "'pendente','aceito','enviado','entregue','lido','desconhecido'";
|
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)) {
|
).get(row.lead_id, row.produto)) {
|
||||||
throw permanentPolicyConflict('envio ativo para o mesmo lead e produto', 'duplicate_product_send');
|
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) {
|
} catch (error) {
|
||||||
if (!error.permanentCampaignPolicy) throw error;
|
if (!error.permanentCampaignPolicy) throw error;
|
||||||
const cancelled = db.prepare(
|
const cancelled = db.prepare(
|
||||||
|
|||||||
@@ -12,3 +12,80 @@ export function equivalentPhoneNumbers(value = '') {
|
|||||||
if (local.length === 8 && /^[6-9]/.test(local)) numbers.push(`${prefix}9${local}`);
|
if (local.length === 8 && /^[6-9]/.test(local)) numbers.push(`${prefix}9${local}`);
|
||||||
return [...new Set(numbers)];
|
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`;
|
||||||
|
}
|
||||||
|
|||||||
+42
-22
@@ -2,7 +2,27 @@
|
|||||||
// exportacoes: o MESMO objeto de filtro que a tela usa para navegar e o que
|
// exportacoes: o MESMO objeto de filtro que a tela usa para navegar e o que
|
||||||
// define quais leads entram num relatorio/exportacao.
|
// 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 = {
|
const SORTS = {
|
||||||
'prioridade:asc': 'prioridade ASC, score DESC, cidade ASC, empresa ASC',
|
'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 orderBy = SORTS[filtro.sort] || SORTS['prioridade:asc'];
|
||||||
|
|
||||||
const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
|
const total = db.prepare(`SELECT COUNT(*) AS t FROM leads ${whereSql}`).get(...params).t;
|
||||||
const sharedIndex = sharedNumberIndex(db);
|
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label,
|
`SELECT leads.id AS id, empresa, legal_name, whatsapp, ddd, cidade, uf, segmento, segmento_label,
|
||||||
interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao,
|
interesse, cnpj, cnae, score, score_qualidade, score_comercial, classificacao,
|
||||||
prioridade, produto_principal, telefone_valido, origem,
|
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
|
FROM leads LEFT JOIN segments ON segments.name = leads.segmento
|
||||||
${whereSql}
|
${whereSql}
|
||||||
ORDER BY ${orderBy}
|
ORDER BY ${orderBy}
|
||||||
LIMIT ? OFFSET ?`
|
LIMIT ? OFFSET ?`
|
||||||
).all(...params, pageSize, (page - 1) * pageSize)
|
).all(...params, pageSize, (page - 1) * pageSize)
|
||||||
.map((lead) => withSharedNumberState(withWhatsAppState(db, lead), sharedIndex));
|
.map((lead) => withSharedNumberState(withWhatsAppState(db, lead)));
|
||||||
|
|
||||||
return { total, page, pageSize, rows };
|
return { total, page, pageSize, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLead(db, id) {
|
export function getLead(db, id) {
|
||||||
const storedLead = db.prepare('SELECT * FROM leads WHERE id = ?').get(id);
|
const storedLead = db.prepare(
|
||||||
const lead = storedLead ? withSharedNumberState(withWhatsAppState(db, storedLead), sharedNumberIndex(db)) : null;
|
`SELECT leads.*, ${sharedNumberColumnsSql('leads.whatsapp')}
|
||||||
|
FROM leads WHERE leads.id = ?`
|
||||||
|
).get(id);
|
||||||
|
const lead = storedLead ? withSharedNumberState(withWhatsAppState(db, storedLead)) : null;
|
||||||
if (!lead) return null;
|
if (!lead) return null;
|
||||||
|
|
||||||
const exportHistory = db.prepare(
|
const exportHistory = db.prepare(
|
||||||
@@ -144,23 +167,20 @@ function withWhatsAppState(db, lead) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sharedNumberIndex(db) {
|
function withSharedNumberState(lead) {
|
||||||
const index = new Map();
|
const {
|
||||||
for (const group of listSharedNumberGroups(db)) {
|
numero_compartilhado_total_raw: totalRaw,
|
||||||
for (const lead of group.leads) {
|
numero_compartilhado_override_raw: overrideRaw,
|
||||||
index.set(lead.id, { total: group.leads.length, liberado: group.liberado });
|
...rest
|
||||||
}
|
} = lead;
|
||||||
}
|
// Compartilhado = 2+ leads na mesma linha (o proprio lead conta), mesma
|
||||||
return index;
|
// semantica de isNumberSharedAcrossLeads.
|
||||||
}
|
const shared = Number(totalRaw) >= 2;
|
||||||
|
|
||||||
function withSharedNumberState(lead, index) {
|
|
||||||
const shared = index.get(lead.id);
|
|
||||||
return {
|
return {
|
||||||
...lead,
|
...rest,
|
||||||
numero_compartilhado: Boolean(shared),
|
numero_compartilhado: shared,
|
||||||
numero_compartilhado_total: shared?.total || 0,
|
numero_compartilhado_total: shared ? Number(totalRaw) : 0,
|
||||||
numero_compartilhado_liberado: shared?.liberado || false
|
numero_compartilhado_liberado: shared && Number(overrideRaw) > 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -576,6 +576,37 @@ test('numero compartilhado por mais de um lead fica fora da fila ate liberacao m
|
|||||||
db.close();
|
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 () => {
|
test('variantes 8 e 9 digitos do mesmo numero contam como compartilhado na fila', async () => {
|
||||||
const { db, campaign } = setupCampaign();
|
const { db, campaign } = setupCampaign();
|
||||||
const nineDigits = insertLead(db, { key: 'nine-digits', whatsapp: '5575999091780', score: 100 });
|
const nineDigits = insertLead(db, { key: 'nine-digits', whatsapp: '5575999091780', score: 100 });
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import path from 'node:path';
|
|||||||
import { Worker } from 'node:worker_threads';
|
import { Worker } from 'node:worker_threads';
|
||||||
import {
|
import {
|
||||||
addOptOut,
|
addOptOut,
|
||||||
|
addSharedNumberOverride,
|
||||||
openDb,
|
openDb,
|
||||||
recoverPendingOutreach,
|
recoverPendingOutreach,
|
||||||
|
removeSharedNumberOverride,
|
||||||
reserveOutreachSend,
|
reserveOutreachSend,
|
||||||
saveWhatsAppVerification,
|
saveWhatsAppVerification,
|
||||||
setSetting
|
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(
|
['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)
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
|
||||||
VALUES (?, ?, ?, 'duplicado', 'desconhecido', 'automatico')`
|
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) {
|
for (const [name, cancelReason, mutate] of cases) {
|
||||||
@@ -495,6 +501,93 @@ test('beforeSend revalida campanha, READY, confirmacao, opt-out, resposta e dedu
|
|||||||
db.close();
|
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 () => {
|
test('afterSend finaliza fila e historico uma unica vez por resultado', async () => {
|
||||||
const { runDueCampaignQueue } = await workerModule();
|
const { runDueCampaignQueue } = await workerModule();
|
||||||
for (const [outcome, queueStatus, sendStatus] of [
|
for (const [outcome, queueStatus, sendStatus] of [
|
||||||
|
|||||||
@@ -131,6 +131,48 @@ test('listSharedNumberGroups agrupa por equivalencia e reflete override', () =>
|
|||||||
db.close();
|
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', () => {
|
test('v15 preserva linhas legadas ao migrar uma base populada em v14', () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-v15-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'campaign-v15-'));
|
||||||
const dbPath = path.join(tempDir, 'legacy.sqlite');
|
const dbPath = path.join(tempDir, 'legacy.sqlite');
|
||||||
|
|||||||
@@ -175,3 +175,115 @@ test('listagem e detalhe marcam numero compartilhado por mais de um lead', async
|
|||||||
assert.equal(refreshed.numero_compartilhado_liberado, true);
|
assert.equal(refreshed.numero_compartilhado_liberado, true);
|
||||||
db.close();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user