From 7764933fd93efb1740b46ebd2e5188687bb5112a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 17 Jun 2026 15:26:24 -0300 Subject: [PATCH] Limit RFV history query to period buyers --- backend/services/analyticsService.js | 174 ++++++++++++++++---------- backend/test/analyticsService.test.js | 160 ++++++++++++----------- src/pages/Clients.tsx | 10 +- 3 files changed, 202 insertions(+), 142 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index a6e2d22..2f4dd80 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -1,5 +1,6 @@ const { pool } = require('../db'); +const RFM_QUERY_TIMEOUT_MS = 15000; const SIZE_SUFFIX_SQL_PATTERN = '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$'; const PRODUCT_NAME_SQL = ` CASE @@ -263,11 +264,13 @@ const getClientAnalytics = async (range = {}) => { const getRfmAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const normalizedEnd = normalizeDateParam(range.end); - const recencyReferenceDate = normalizedEnd ? '$1::date' : 'CURRENT_DATE'; - const historyParams = normalizedEnd ? [normalizedEnd] : []; + const client = await pool.connect(); - const [periodResult, historyResult] = await Promise.all([ - pool.query(` + try { + await client.query('BEGIN'); + await client.query(`SET LOCAL statement_timeout = '${RFM_QUERY_TIMEOUT_MS}ms'`); + + const periodResult = await client.query(` SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, @@ -280,8 +283,37 @@ const getRfmAnalytics = async (range = {}) => { ${whereClause} GROUP BY customer_key ORDER BY monetary DESC; - `, params), - pool.query(` + `, params); + + if (!periodResult.rows.length) { + await client.query('COMMIT'); + return { + range: { + start: normalizeDateParam(range.start), + end: normalizeDateParam(range.end) + }, + clients: [], + segments: buildRfmSegments([]), + matrix: { + recencyScores: [3, 2, 1], + valueScores: [1, 2, 3] + } + }; + } + + 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 historyResult = await client.query(` SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, @@ -294,73 +326,87 @@ const getRfmAnalytics = async (range = {}) => { 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) - ]); + `, historyParams); - const historyClients = buildRfmClients(historyResult.rows.map(row => ({ - customerKey: row.customer_key, - name: row.name, - phone: row.phone || '', - monetary: toNumber(row.monetary), - frequency: toNumber(row.frequency), - quantityPurchased: toNumber(row.quantity_purchased), - lastPurchaseDate: row.last_purchase_date, - recencyDays: toNumber(row.recency_days) - }))); - const tagsByCustomerKey = new Map(historyClients.map(client => [client.customerKey, client])); - - const clients = periodResult.rows.map(row => { - const taggedClient = tagsByCustomerKey.get(row.customer_key); - if (!taggedClient) { - const newCustomerSegment = getRfmSegment(3, 1); - return { - customerKey: row.customer_key, - name: row.name, - phone: row.phone || '', - monetary: toNumber(row.monetary), - frequency: toNumber(row.frequency), - quantityPurchased: toNumber(row.quantity_purchased), - lastPurchaseDate: row.last_purchase_date, - recencyDays: 0, - recencyScore: 3, - frequencyScore: 1, - monetaryScore: 1, - valueScore: 1, - rfmScore: '311', - segmentKey: newCustomerSegment.key, - segmentLabel: newCustomerSegment.label - }; - } - - return { - ...taggedClient, + const historyClients = buildRfmClients(historyResult.rows.map(row => ({ customerKey: row.customer_key, name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), - lastPurchaseDate: row.last_purchase_date - }; - }).sort((a, b) => { - if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore; - if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore; - return b.monetary - a.monetary; - }); + lastPurchaseDate: row.last_purchase_date, + recencyDays: toNumber(row.recency_days) + }))); + const tagsByCustomerKey = new Map(historyClients.map(historyClient => [historyClient.customerKey, historyClient])); - return { - range: { - start: normalizeDateParam(range.start), - end: normalizeDateParam(range.end) - }, - clients, - segments: buildRfmSegments(clients), - matrix: { - recencyScores: [3, 2, 1], - valueScores: [1, 2, 3] - } - }; + const clients = periodResult.rows.map(row => { + const taggedClient = tagsByCustomerKey.get(row.customer_key); + if (!taggedClient) { + const newCustomerSegment = getRfmSegment(3, 1); + return { + customerKey: row.customer_key, + name: row.name, + phone: row.phone || '', + monetary: toNumber(row.monetary), + frequency: toNumber(row.frequency), + quantityPurchased: toNumber(row.quantity_purchased), + lastPurchaseDate: row.last_purchase_date, + recencyDays: 0, + recencyScore: 3, + frequencyScore: 1, + monetaryScore: 1, + valueScore: 1, + rfmScore: '311', + segmentKey: newCustomerSegment.key, + segmentLabel: newCustomerSegment.label + }; + } + + return { + ...taggedClient, + customerKey: row.customer_key, + name: row.name, + phone: row.phone || '', + monetary: toNumber(row.monetary), + frequency: toNumber(row.frequency), + quantityPurchased: toNumber(row.quantity_purchased), + lastPurchaseDate: row.last_purchase_date + }; + }).sort((a, b) => { + if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore; + if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore; + return b.monetary - a.monetary; + }); + + await client.query('COMMIT'); + + return { + range: { + start: normalizeDateParam(range.start), + end: normalizeDateParam(range.end) + }, + clients, + segments: buildRfmSegments(clients), + matrix: { + recencyScores: [3, 2, 1], + valueScores: [1, 2, 3] + } + }; + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + throw error; + } finally { + client.release(); + } }; module.exports = { diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 7842619..84d434c 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -192,106 +192,118 @@ test('buildRfmClients scores segments from RFM history when period totals are sm }); test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => { - const originalQuery = pool.query; + const originalConnect = pool.connect; const calls = []; + const mockClient = { + query: async (sql, params = []) => { + calls.push({ sql, params }); + const isHistoryQuery = sql.includes('recency_days'); + const isPeriodQuery = sql.includes('FROM orders'); + const referenceDate = params[0]; - pool.query = async (sql, params) => { - calls.push({ sql, params }); - const isHistoryQuery = sql.includes('recency_days'); - const referenceDate = params[0]; + if (!isPeriodQuery) { + return { rows: [] }; + } + + if (isHistoryQuery) { + return { + rows: [ + { + customer_key: '1', + name: 'Cliente Ontem', + phone: '1', + monetary: 5000, + frequency: 20, + quantity_purchased: 20, + last_purchase_date: '2026-06-14', + recency_days: referenceDate === '2026-06-14' ? 0 : 1 + }, + { + customer_key: '2', + name: 'Cliente Antigo', + phone: '2', + monetary: 50, + frequency: 1, + quantity_purchased: 1, + last_purchase_date: '2026-01-01', + recency_days: 164 + }, + { + customer_key: '3', + name: 'Cliente Medio', + phone: '3', + monetary: 100, + frequency: 2, + quantity_purchased: 2, + last_purchase_date: '2026-03-01', + recency_days: 105 + }, + { + customer_key: 'name:Cliente Sem Fone', + name: 'Cliente Sem Fone', + phone: null, + monetary: 1000, + frequency: 10, + quantity_purchased: 10, + last_purchase_date: '2026-06-14', + recency_days: 1 + } + ] + }; + } - if (isHistoryQuery) { return { rows: [ { customer_key: '1', name: 'Cliente Ontem', phone: '1', - monetary: 5000, - frequency: 20, - quantity_purchased: 20, - last_purchase_date: '2026-06-14', - recency_days: referenceDate === '2026-06-14' ? 0 : 1 - }, - { - customer_key: '2', - name: 'Cliente Antigo', - phone: '2', - monetary: 50, + monetary: 100, frequency: 1, quantity_purchased: 1, - last_purchase_date: '2026-01-01', - recency_days: 164 + last_purchase_date: '2026-06-14' }, { - customer_key: '3', - name: 'Cliente Medio', - phone: '3', - monetary: 100, - frequency: 2, - quantity_purchased: 2, - last_purchase_date: '2026-03-01', - recency_days: 105 + customer_key: '4', + name: 'Cliente Novo no Periodo', + phone: '4', + monetary: 25, + frequency: 1, + quantity_purchased: 1, + last_purchase_date: '2026-06-14' }, { customer_key: 'name:Cliente Sem Fone', name: 'Cliente Sem Fone', phone: null, - monetary: 1000, - frequency: 10, - quantity_purchased: 10, - last_purchase_date: '2026-06-14', - recency_days: 1 + monetary: 30, + frequency: 1, + quantity_purchased: 1, + last_purchase_date: '2026-06-14' } ] }; - } - - return { - rows: [ - { - customer_key: '1', - name: 'Cliente Ontem', - phone: '1', - monetary: 100, - frequency: 1, - quantity_purchased: 1, - last_purchase_date: '2026-06-14' - }, - { - customer_key: '4', - name: 'Cliente Novo no Periodo', - phone: '4', - monetary: 25, - frequency: 1, - quantity_purchased: 1, - last_purchase_date: '2026-06-14' - }, - { - customer_key: 'name:Cliente Sem Fone', - name: 'Cliente Sem Fone', - phone: null, - monetary: 30, - frequency: 1, - quantity_purchased: 1, - last_purchase_date: '2026-06-14' - } - ] - }; + }, + release: () => {} }; + pool.connect = async () => mockClient; try { const sevenDays = await getRfmAnalytics({ start: '2026-06-09', end: '2026-06-15' }); const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' }); + const selectCalls = calls.filter(call => call.sql.includes('FROM orders')); - assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15']); - assert.doesNotMatch(calls[0].sql, /cliente_fone IS NOT NULL/); - assert.match(calls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/); - assert.match(calls[1].sql, /data_pedido_date <= \$1::date/); - assert.doesNotMatch(calls[1].sql, /cliente_fone IS NOT NULL/); - assert.deepEqual(calls[1].params, ['2026-06-15']); - assert.deepEqual(calls[2].params, ['2026-06-14', '2026-06-14']); - assert.deepEqual(calls[3].params, ['2026-06-14']); + 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.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, /NULLIF\(cliente_fone, ''\) = ANY\(\$2::text\[\]\)/); + assert.match(selectCalls[1].sql, /COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\) = ANY\(\$3::text\[\]\)/); + assert.doesNotMatch(selectCalls[1].sql, /cliente_fone IS NOT NULL/); + assert.deepEqual(selectCalls[1].params, ['2026-06-15', ['1', '4'], ['Cliente Sem Fone']]); + assert.deepEqual(selectCalls[2].params, ['2026-06-14', '2026-06-14']); + assert.deepEqual(selectCalls[3].params, ['2026-06-14', ['1', '4'], ['Cliente Sem Fone']]); assert.equal(sevenDays.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].frequency, 1); @@ -306,6 +318,6 @@ test('getRfmAnalytics classifies period buyers by history through the selected r client.monetary === 30 ))); } finally { - pool.query = originalQuery; + pool.connect = originalConnect; } }); diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index dc487d8..b88adad 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -83,12 +83,14 @@ const Clients = () => { let isMounted = true; const loadClients = async () => { - const [clientsData, rfmData] = await Promise.all([ - fetchClientAnalytics(dateRange), - fetchRfmAnalytics(dateRange) - ]); + const clientsData = await fetchClientAnalytics(dateRange); if (isMounted) { setClientAnalytics(clientsData); + setRfmAnalytics(null); + } + + const rfmData = await fetchRfmAnalytics(dateRange); + if (isMounted) { setRfmAnalytics(rfmData); } };