From 335d2319033cca5db55f7f6fe5088b69b70cfbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 23 Jun 2026 14:23:28 -0300 Subject: [PATCH] Fix client identity when phone is added --- backend/services/analyticsService.js | 76 +++++++++++++++++++++++--- backend/test/analyticsService.test.js | 79 ++++++++++++++++++++++----- 2 files changed, 133 insertions(+), 22 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index b6e94c9..fac19f1 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -14,7 +14,37 @@ const PRODUCT_NAME_SQL = ` ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '') END `; -const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))"; +const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')"; +// Phone-less historical rows must follow the later known phone for the same client name. +const CUSTOMER_IDENTITY_CTE = ` + WITH order_identity AS ( + SELECT + orders.*, + ${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name + FROM orders + ), + customer_phone_by_name AS ( + SELECT + normalized_customer_name, + (ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC) + FILTER (WHERE NULLIF(cliente_fone, '') IS NOT NULL))[1] as canonical_phone + FROM order_identity + WHERE normalized_customer_name IS NOT NULL + GROUP BY normalized_customer_name + ), + identity_orders AS ( + SELECT + order_identity.*, + COALESCE( + NULLIF(order_identity.cliente_fone, ''), + customer_phone_by_name.canonical_phone, + 'name:' || COALESCE(NULLIF(order_identity.cliente_nome, ''), 'Cliente Desconhecido') + ) as customer_key + FROM order_identity + LEFT JOIN customer_phone_by_name USING (normalized_customer_name) + ) +`; +const CUSTOMER_KEY_SQL = 'customer_key'; const getClientTokenSecret = () => ( process.env.CLIENT_TOKEN_SECRET || @@ -102,6 +132,28 @@ const resolveClientToken = async (clientToken) => { return result.rows[0]?.customer_key || null; }; +const resolveCanonicalCustomerKey = async (customerKey) => { + const normalizedCustomerKey = String(customerKey || ''); + if (!normalizedCustomerKey.startsWith('name:')) return customerKey; + + const legacyName = normalizedCustomerKey.slice(5).trim(); + if (!legacyName) return customerKey; + + const result = await pool.query(` + ${CUSTOMER_IDENTITY_CTE} + SELECT customer_key + FROM identity_orders + WHERE normalized_customer_name = NULLIF(LOWER(TRIM(regexp_replace($1, '\\s+', ' ', 'g'))), '') + ORDER BY + NULLIF(cliente_fone, '') IS NOT NULL DESC, + data_pedido_date DESC NULLS LAST, + id DESC + LIMIT 1; + `, [legacyName]); + + return result.rows[0]?.customer_key || customerKey; +}; + const normalizeDateParam = (value) => { if (!value) return null; @@ -605,6 +657,7 @@ const getProductDetailsAnalytics = async (productId, range = {}) => { const getClientAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const result = await pool.query(` + ${CUSTOMER_IDENTITY_CTE} SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, @@ -613,7 +666,7 @@ const getClientAnalytics = async (range = {}) => { COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count, MAX(data_pedido_date) as last_purchase_date - FROM orders + FROM identity_orders ${whereClause} GROUP BY customer_key ORDER BY total_spent DESC; @@ -699,10 +752,11 @@ const getOrderGroupKey = (row) => ( const getClientDetailsAnalytics = async (clientToken, range = {}) => { const customerKey = await resolveClientToken(clientToken); if (!customerKey) return null; + const resolvedCustomerKey = await resolveCanonicalCustomerKey(customerKey); const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); - const periodParams = [customerKey]; + const periodParams = [resolvedCustomerKey]; const periodFilters = [ `${CUSTOMER_KEY_SQL} = $1`, 'data_pedido_date IS NOT NULL' @@ -720,15 +774,17 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { const [summaryResult, periodResult] = await Promise.all([ pool.query(` + ${CUSTOMER_IDENTITY_CTE} SELECT MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, MAX(NULLIF(cliente_fone, '')) as phone, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as all_time_order_count - FROM orders + FROM identity_orders WHERE ${CUSTOMER_KEY_SQL} = $1 AND data_pedido_date IS NOT NULL; - `, [customerKey]), + `, [resolvedCustomerKey]), pool.query(` + ${CUSTOMER_IDENTITY_CTE} SELECT cliente_nome, cliente_fone, @@ -746,7 +802,7 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { marketplace, canal_venda, numero_ecommerce - FROM orders + FROM identity_orders WHERE ${periodFilters.join(' AND ')} ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; `, periodParams) @@ -877,6 +933,7 @@ const getRfmAnalytics = async (range = {}) => { await client.query(`SET LOCAL statement_timeout = '${RFM_QUERY_TIMEOUT_MS}ms'`); const periodResult = await client.query(` + ${CUSTOMER_IDENTITY_CTE} SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, @@ -886,7 +943,7 @@ const getRfmAnalytics = async (range = {}) => { COALESCE(SUM(quantidade), 0) as quantity_purchased, MAX(data_pedido_date) as last_purchase_date, GREATEST((${periodRecencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days - FROM orders + FROM identity_orders ${whereClause} GROUP BY customer_key ORDER BY monetary DESC; @@ -926,7 +983,8 @@ const getRfmAnalytics = async (range = {}) => { const customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`; const historyResult = await client.query(` - WITH customer_history AS ( + ${CUSTOMER_IDENTITY_CTE}, + customer_history AS ( SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, @@ -936,7 +994,7 @@ const getRfmAnalytics = async (range = {}) => { COALESCE(SUM(quantidade), 0) as quantity_purchased, MAX(data_pedido_date) as last_purchase_date, GREATEST((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days - FROM orders + FROM identity_orders WHERE ${historyFilters.join(' AND ')} GROUP BY customer_key ), diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index d03dbdd..0482d74 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -568,6 +568,50 @@ test('getClientAnalytics returns opaque client tokens', async () => { } }); +test('getClientAnalytics groups phone-less history through the customer identity CTE', async () => { + const originalQuery = pool.query; + const calls = []; + + pool.query = async (sql, params = []) => { + calls.push({ sql, params }); + + if (sql.includes('INSERT INTO client_identity_tokens')) { + return { rows: [] }; + } + + return { + rows: [ + { + customer_key: '(16) 99999-9999', + name: 'Cliente Com Historico', + phone: '(16) 99999-9999', + quantity_purchased: 1002, + total_spent: 50000, + order_count: 250, + last_purchase_date: '2026-06-15' + } + ] + }; + }; + + try { + const clients = await getClientAnalytics({ start: '2026-05-01', end: '2026-06-15' }); + + assert.equal(calls.length, 2); + assert.equal(clients.length, 1); + assert.equal(clients[0].customerKey, '(16) 99999-9999'); + assert.equal(clients[0].phone, '(16) 99999-9999'); + assert.match(calls[0].sql, /WITH order_identity AS/); + assert.match(calls[0].sql, /customer_phone_by_name AS/); + assert.match(calls[0].sql, /ARRAY_AGG\(NULLIF\(cliente_fone, ''\) ORDER BY data_pedido_date DESC NULLS LAST, id DESC\)/); + assert.match(calls[0].sql, /NULLIF\(order_identity\.cliente_fone, ''\),\s+customer_phone_by_name\.canonical_phone/s); + assert.match(calls[0].sql, /FROM identity_orders/); + assert.match(calls[0].sql, /GROUP BY customer_key/); + } finally { + pool.query = originalQuery; + } +}); + test('getClientFilterOptions returns distinct date-scoped order metadata options', async () => { const originalQuery = pool.query; const calls = []; @@ -612,7 +656,7 @@ test('getClientFilterOptions returns distinct date-scoped order metadata options } }); -test('getClientDetailsAnalytics fetches only the tokenized client and period rows', async () => { +test('getClientDetailsAnalytics resolves legacy name tokens to the canonical phone client', async () => { const originalQuery = pool.query; const calls = []; const clientToken = createClientToken('name:Cliente Sem Fone'); @@ -624,11 +668,15 @@ test('getClientDetailsAnalytics fetches only the tokenized client and period row return { rows: [{ customer_key: 'name:Cliente Sem Fone' }] }; } + if (sql.includes('SELECT customer_key') && sql.includes('FROM identity_orders')) { + return { rows: [{ customer_key: '(16) 99999-9999' }] }; + } + if (sql.includes('all_time_order_count')) { return { rows: [{ name: 'Cliente Sem Fone', - phone: null, + phone: '(16) 99999-9999', all_time_order_count: 3 }] }; @@ -638,7 +686,7 @@ test('getClientDetailsAnalytics fetches only the tokenized client and period row rows: [ { cliente_nome: 'Cliente Sem Fone', - cliente_fone: null, + cliente_fone: '', data_pedido: '10-06-2026', data_pedido_date: '2026-06-10', valor_pedido: 25, @@ -650,7 +698,7 @@ test('getClientDetailsAnalytics fetches only the tokenized client and period row }, { cliente_nome: 'Cliente Sem Fone', - cliente_fone: null, + cliente_fone: '(16) 99999-9999', data_pedido: '10-06-2026', data_pedido_date: '2026-06-10', valor_pedido: 25, @@ -667,16 +715,19 @@ test('getClientDetailsAnalytics fetches only the tokenized client and period row try { const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' }); - assert.equal(calls.length, 3); + assert.equal(calls.length, 4); assert.match(calls[0].sql, /FROM client_identity_tokens/); assert.deepEqual(calls[0].params, [clientToken]); - assert.match(calls[1].sql, /WHERE COALESCE\(NULLIF\(cliente_fone, ''\), 'name:' \|\| COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\)\) = \$1/); - assert.deepEqual(calls[1].params, ['name:Cliente Sem Fone']); - assert.match(calls[2].sql, /data_pedido_date >= \$2::date/); - assert.match(calls[2].sql, /data_pedido_date <= \$3::date/); - assert.deepEqual(calls[2].params, ['name:Cliente Sem Fone', '2026-06-01', '2026-06-15']); + assert.match(calls[1].sql, /SELECT customer_key/); + assert.deepEqual(calls[1].params, ['Cliente Sem Fone']); + assert.match(calls[2].sql, /FROM identity_orders/); + assert.match(calls[2].sql, /WHERE customer_key = \$1/); + assert.deepEqual(calls[2].params, ['(16) 99999-9999']); + assert.match(calls[3].sql, /data_pedido_date >= \$2::date/); + assert.match(calls[3].sql, /data_pedido_date <= \$3::date/); + assert.deepEqual(calls[3].params, ['(16) 99999-9999', '2026-06-01', '2026-06-15']); assert.equal(details.clientName, 'Cliente Sem Fone'); - assert.equal(details.clientPhone, ''); + assert.equal(details.clientPhone, '(16) 99999-9999'); assert.equal(details.allTimeOrderCount, 3); assert.equal(details.periodSpent, 25); assert.equal(details.periodItems, 3); @@ -807,7 +858,8 @@ test('getRfmAnalytics classifies period buyers by history through the selected r assert.match(calls[1].sql, /SET LOCAL statement_timeout/); assert.deepEqual(selectCalls[0].params, ['2026-06-09', '2026-06-15']); - assert.doesNotMatch(selectCalls[0].sql, /cliente_fone IS NOT NULL/); + assert.doesNotMatch(selectCalls[0].sql, /WHERE\s+cliente_fone IS NOT NULL/); + assert.match(selectCalls[0].sql, /FROM identity_orders/); assert.match(selectCalls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/); assert.match(selectCalls[1].sql, /data_pedido_date <= \$1::date/); assert.match(selectCalls[1].sql, /WHEN frequency <= 1 THEN 1/); @@ -815,7 +867,8 @@ test('getRfmAnalytics classifies period buyers by history through the selected r assert.doesNotMatch(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY frequency\)/); assert.match(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY monetary\)/); assert.match(selectCalls[1].sql, /customer_key = ANY\(\$2::text\[\]\)/); - assert.doesNotMatch(selectCalls[1].sql, /cliente_fone IS NOT NULL/); + assert.doesNotMatch(selectCalls[1].sql, /WHERE\s+cliente_fone IS NOT NULL/); + assert.match(selectCalls[1].sql, /FROM identity_orders/); assert.deepEqual(selectCalls[1].params, ['2026-06-15', ['1', '4', 'name:Cliente Sem Fone']]); assert.deepEqual(selectCalls[2].params, ['2026-06-14', '2026-06-14']); assert.deepEqual(selectCalls[3].params, ['2026-06-14', ['1', '4', 'name:Cliente Sem Fone']]);