From 2445272f3a505ebcfc12bc4e82657ad32d2f061f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 17 Jun 2026 15:50:27 -0300 Subject: [PATCH] Optimize RFV all-period scoring --- backend/services/analyticsService.js | 79 +++++++++++++++------------ backend/test/analyticsService.test.js | 67 +++++++++++++++++++++-- 2 files changed, 107 insertions(+), 39 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 2f4dd80..15a66b6 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -263,7 +263,10 @@ const getClientAnalytics = async (range = {}) => { const getRfmAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); + const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); + const periodRecencyReferenceDate = normalizedEnd ? `$${params.length}::date` : 'CURRENT_DATE'; + const usePeriodAsHistory = !normalizedStart || normalizedStart <= '2000-01-01'; const client = await pool.connect(); try { @@ -278,7 +281,8 @@ const getRfmAnalytics = async (range = {}) => { COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COALESCE(SUM(quantidade), 0) as quantity_purchased, - MAX(data_pedido_date) as last_purchase_date + MAX(data_pedido_date) as last_purchase_date, + GREATEST((${periodRecencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days FROM orders ${whereClause} GROUP BY customer_key @@ -301,42 +305,47 @@ const getRfmAnalytics = async (range = {}) => { }; } - const periodPhones = [...new Set(periodResult.rows.map(row => row.phone).filter(Boolean))]; - const periodNamesWithoutPhone = [...new Set(periodResult.rows - .filter(row => !row.phone && String(row.customer_key || '').startsWith('name:')) - .map(row => String(row.customer_key).slice(5)) - .filter(Boolean))]; - const historyParams = []; - const recencyReferenceDate = normalizedEnd - ? `$${historyParams.push(normalizedEnd)}::date` - : 'CURRENT_DATE'; - const phoneListParam = `$${historyParams.push(periodPhones)}::text[]`; - const nameListParam = `$${historyParams.push(periodNamesWithoutPhone)}::text[]`; + let historyRows = periodResult.rows; - const historyResult = await client.query(` - SELECT - ${CUSTOMER_KEY_SQL} as customer_key, - MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, - MAX(NULLIF(cliente_fone, '')) as phone, - COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, - COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, - 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 - WHERE data_pedido_date IS NOT NULL - AND data_pedido_date <= ${recencyReferenceDate} - AND ( - NULLIF(cliente_fone, '') = ANY(${phoneListParam}) - OR ( - NULLIF(cliente_fone, '') IS NULL - AND COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') = ANY(${nameListParam}) - ) - ) - GROUP BY customer_key; - `, historyParams); + if (!usePeriodAsHistory) { + const periodPhones = [...new Set(periodResult.rows.map(row => row.phone).filter(Boolean))]; + const periodNamesWithoutPhone = [...new Set(periodResult.rows + .filter(row => !row.phone && String(row.customer_key || '').startsWith('name:')) + .map(row => String(row.customer_key).slice(5)) + .filter(Boolean))]; + const historyParams = []; + const recencyReferenceDate = normalizedEnd + ? `$${historyParams.push(normalizedEnd)}::date` + : 'CURRENT_DATE'; + const phoneListParam = `$${historyParams.push(periodPhones)}::text[]`; + const nameListParam = `$${historyParams.push(periodNamesWithoutPhone)}::text[]`; - const historyClients = buildRfmClients(historyResult.rows.map(row => ({ + const historyResult = await client.query(` + SELECT + ${CUSTOMER_KEY_SQL} as customer_key, + MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, + MAX(NULLIF(cliente_fone, '')) as phone, + COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, + 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 + WHERE data_pedido_date IS NOT NULL + AND data_pedido_date <= ${recencyReferenceDate} + AND ( + NULLIF(cliente_fone, '') = ANY(${phoneListParam}) + OR ( + NULLIF(cliente_fone, '') IS NULL + AND COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') = ANY(${nameListParam}) + ) + ) + GROUP BY customer_key; + `, historyParams); + historyRows = historyResult.rows; + } + + const historyClients = buildRfmClients(historyRows.map(row => ({ customerKey: row.customer_key, name: row.name, phone: row.phone || '', diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 84d434c..8d993d1 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -197,7 +197,7 @@ test('getRfmAnalytics classifies period buyers by history through the selected r const mockClient = { query: async (sql, params = []) => { calls.push({ sql, params }); - const isHistoryQuery = sql.includes('recency_days'); + const isHistoryQuery = sql.includes('= ANY'); const isPeriodQuery = sql.includes('FROM orders'); const referenceDate = params[0]; @@ -261,7 +261,8 @@ test('getRfmAnalytics classifies period buyers by history through the selected r monetary: 100, frequency: 1, quantity_purchased: 1, - last_purchase_date: '2026-06-14' + last_purchase_date: '2026-06-14', + recency_days: 1 }, { customer_key: '4', @@ -270,7 +271,8 @@ test('getRfmAnalytics classifies period buyers by history through the selected r monetary: 25, frequency: 1, quantity_purchased: 1, - last_purchase_date: '2026-06-14' + last_purchase_date: '2026-06-14', + recency_days: 1 }, { customer_key: 'name:Cliente Sem Fone', @@ -279,7 +281,8 @@ test('getRfmAnalytics classifies period buyers by history through the selected r monetary: 30, frequency: 1, quantity_purchased: 1, - last_purchase_date: '2026-06-14' + last_purchase_date: '2026-06-14', + recency_days: 1 } ] }; @@ -321,3 +324,59 @@ test('getRfmAnalytics classifies period buyers by history through the selected r pool.connect = originalConnect; } }); + +test('getRfmAnalytics reuses period rows as RFV history for all-period ranges', async () => { + const originalConnect = pool.connect; + const calls = []; + const mockClient = { + query: async (sql, params = []) => { + calls.push({ sql, params }); + + if (!sql.includes('FROM orders')) { + return { rows: [] }; + } + + return { + rows: [ + { + customer_key: '1', + name: 'Cliente Frequente', + phone: '1', + monetary: 5000, + frequency: 20, + quantity_purchased: 20, + last_purchase_date: '2026-06-14', + recency_days: 1 + }, + { + customer_key: 'name:Cliente Sem Fone', + name: 'Cliente Sem Fone', + phone: null, + monetary: 50, + frequency: 1, + quantity_purchased: 1, + last_purchase_date: '2026-01-01', + recency_days: 165 + } + ] + }; + }, + release: () => {} + }; + pool.connect = async () => mockClient; + + try { + const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' }); + const selectCalls = calls.filter(call => call.sql.includes('FROM orders')); + + assert.equal(selectCalls.length, 1); + assert.match(selectCalls[0].sql, /\(\$2::date - MAX\(data_pedido_date\)\)::int/); + assert.doesNotMatch(selectCalls[0].sql, /= ANY/); + assert.deepEqual(selectCalls[0].params, ['2000-01-01', '2026-06-15']); + assert.equal(result.clients.length, 2); + assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2); + assert.ok(result.clients.some(client => client.customerKey === 'name:Cliente Sem Fone' && client.phone === '')); + } finally { + pool.connect = originalConnect; + } +});