From 496f9d432dc193efecf266c5fd49f3d73827c48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 29 Jul 2026 09:54:22 -0300 Subject: [PATCH] Deduplicate top campaign clients by name --- backend/services/campaignService.js | 56 ++++++++++++++++++++++++---- backend/test/campaignService.test.js | 23 ++++++++++-- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/backend/services/campaignService.js b/backend/services/campaignService.js index 4096bc9..f0d941f 100644 --- a/backend/services/campaignService.js +++ b/backend/services/campaignService.js @@ -17,6 +17,32 @@ const MAX_CAMPAIGN_ATTEMPTS = 3; const CAMPAIGN_DELTA_THRESHOLD = 100; const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo'; const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')"; +const NORMALIZED_CUSTOMER_PHONE_SQL = "NULLIF(regexp_replace(COALESCE(cliente_fone, ''), '\\D', '', 'g'), '')"; +const WHATSAPP_CUSTOMER_PHONE_SQL = ` + CASE + WHEN ${NORMALIZED_CUSTOMER_PHONE_SQL} LIKE '55%' THEN ${NORMALIZED_CUSTOMER_PHONE_SQL} + WHEN length(${NORMALIZED_CUSTOMER_PHONE_SQL}) IN (10, 11) THEN '55' || ${NORMALIZED_CUSTOMER_PHONE_SQL} + ELSE ${NORMALIZED_CUSTOMER_PHONE_SQL} + END +`; +const CANONICAL_CAMPAIGN_CUSTOMER_NAME_SQL = ` + NULLIF(TRIM(regexp_replace( + regexp_replace( + regexp_replace( + regexp_replace(LOWER(COALESCE(cliente_nome, '')), '[^[:alnum:][:space:]]+', ' ', 'g'), + '(^|[[:space:]])[0-9]{2,14}([[:space:]]|$)', + ' ', + 'g' + ), + '(^|[[:space:]])(ltda|me|eireli|epp)([[:space:]]|$)', + ' ', + 'g' + ), + '[[:space:]]+', + ' ', + 'g' + )), '') +`; const CUSTOMER_IDENTITY_CTE = ` WITH customer_phone_by_name AS ( SELECT @@ -148,19 +174,32 @@ const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => { const range = getTopClientsDateRange({ days, start, end }); const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT); const result = await pool.query(` - ${CUSTOMER_IDENTITY_CTE} + WITH campaign_orders AS ( + SELECT + orders.*, + ${CANONICAL_CAMPAIGN_CUSTOMER_NAME_SQL} as canonical_customer_name, + ${WHATSAPP_CUSTOMER_PHONE_SQL} as whatsapp_phone + FROM orders + ) SELECT - MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as nome, - customer_key as fone, + ( + ARRAY_AGG(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') + ORDER BY data_pedido_date DESC NULLS LAST, id DESC) + )[1] as nome, + ( + ARRAY_AGG(whatsapp_phone ORDER BY data_pedido_date DESC NULLS LAST, id DESC) + FILTER (WHERE whatsapp_phone IS NOT NULL) + )[1] as fone, COALESCE(SUM(quantidade * valor_unitario), 0) as total_gasto, COALESCE(SUM(quantidade), 0) as total_comprado, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos, - MAX(data_pedido_date) as ultima_compra - FROM identity_orders + MAX(data_pedido_date) as ultima_compra, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT whatsapp_phone), NULL) as telefones + FROM campaign_orders WHERE data_pedido_date >= $1::date AND data_pedido_date <= $2::date - AND customer_key NOT LIKE 'name:%' - GROUP BY customer_key + AND whatsapp_phone IS NOT NULL + GROUP BY COALESCE(canonical_customer_name, whatsapp_phone) ORDER BY total_gasto DESC LIMIT $3; `, [range.start, range.end, normalizedLimit]); @@ -171,7 +210,8 @@ const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => { total_gasto: Number(row.total_gasto || 0), total_comprado: Number(row.total_comprado || 0), total_pedidos: Number(row.total_pedidos || 0), - ultima_compra: row.ultima_compra + ultima_compra: row.ultima_compra, + telefones: Array.isArray(row.telefones) ? row.telefones : [] })); return { diff --git a/backend/test/campaignService.test.js b/backend/test/campaignService.test.js index 6b412ff..e27d08c 100644 --- a/backend/test/campaignService.test.js +++ b/backend/test/campaignService.test.js @@ -48,7 +48,8 @@ test('getTopClientsForCampaign returns top clients for an explicit date range', total_gasto: '1234.50', total_comprado: '18', total_pedidos: 4, - ultima_compra: '2026-07-27' + ultima_compra: '2026-07-27', + telefones: ['5516999999901', '5516999999902'] } ] }), async ({ getTopClientsForCampaign }, queries) => { @@ -68,12 +69,14 @@ test('getTopClientsForCampaign returns top clients for an explicit date range', total_gasto: 1234.5, total_comprado: 18, total_pedidos: 4, - ultima_compra: '2026-07-27' + ultima_compra: '2026-07-27', + telefones: ['5516999999901', '5516999999902'] }); assert.equal(queries.length, 1); assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]); - assert.match(queries[0].sql, /customer_key NOT LIKE 'name:%'/); + assert.match(queries[0].sql, /GROUP BY COALESCE\(canonical_customer_name, whatsapp_phone\)/); + assert.match(queries[0].sql, /ARRAY_AGG\(DISTINCT whatsapp_phone\)/); assert.match(queries[0].sql, /ORDER BY total_gasto DESC/); }); }); @@ -91,3 +94,17 @@ test('getTopClientsForCampaign derives an inclusive 30 day range from the end da assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]); }); }); + +test('getTopClientsForCampaign normalizes phones and ranks one row per canonical client name', async () => { + await withCampaignService(async () => ({ rows: [] }), async ({ getTopClientsForCampaign }, queries) => { + await getTopClientsForCampaign({ + days: '30', + end: '2026-07-27' + }); + + assert.match(queries[0].sql, /regexp_replace\(COALESCE\(cliente_fone, ''\), '\\D', '', 'g'\)/); + assert.match(queries[0].sql, /WHEN length\(/); + assert.match(queries[0].sql, /'55' \|\|/); + assert.match(queries[0].sql, /canonical_customer_name/); + }); +});