diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index f4c6dbd..8b25cba 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -74,7 +74,7 @@ const scoreTertile = (value, values, higherIsBetter = true) => { const min = Math.min(...numericValues); const max = Math.max(...numericValues); - if (min === max) return 2; + if (min === max) return higherIsBetter ? 2 : 3; const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a); const index = sorted.findIndex(candidate => candidate === toNumber(value)); @@ -253,16 +253,12 @@ const getClientAnalytics = async (range = {}) => { const getRfmAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); - const queryParams = [...params]; const normalizedEnd = normalizeDateParam(range.end); - const recencyReferenceDate = normalizedEnd ? `$${queryParams.length + 1}::date` : 'CURRENT_DATE'; + const recencyReferenceDate = normalizedEnd ? '$1::date' : 'CURRENT_DATE'; + const historyParams = normalizedEnd ? [normalizedEnd] : []; - if (normalizedEnd) { - queryParams.push(normalizedEnd); - } - - const result = await pool.query(` - WITH period_clients AS ( + const [periodResult, historyResult] = await Promise.all([ + pool.query(` SELECT MAX(cliente_nome) as name, cliente_fone as phone, @@ -275,49 +271,55 @@ const getRfmAnalytics = async (range = {}) => { AND cliente_fone IS NOT NULL AND cliente_fone != '' GROUP BY cliente_fone - ), - rfm_clients AS ( + ORDER BY monetary DESC + LIMIT 1000; + `, params), + pool.query(` SELECT + MAX(cliente_nome) as name, cliente_fone as phone, - COALESCE(SUM(quantidade * valor_unitario), 0) as rfm_monetary, - COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as rfm_frequency, + 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 cliente_fone IS NOT NULL AND cliente_fone != '' - GROUP BY cliente_fone - ) - SELECT - period_clients.name, - period_clients.phone, - period_clients.monetary, - period_clients.frequency, - period_clients.quantity_purchased, - period_clients.last_purchase_date, - rfm_clients.rfm_monetary, - rfm_clients.rfm_frequency, - rfm_clients.recency_days - FROM period_clients - INNER JOIN rfm_clients ON rfm_clients.phone = period_clients.phone - ORDER BY monetary DESC - LIMIT 1000; - `, queryParams); + GROUP BY cliente_fone; + `, historyParams) + ]); - const baseClients = result.rows.map(row => ({ + const historyClients = buildRfmClients(historyResult.rows.map(row => ({ 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), - rfmFrequency: toNumber(row.rfm_frequency), - rfmMonetary: toNumber(row.rfm_monetary) - })); + recencyDays: toNumber(row.recency_days) + }))); + const rfmByPhone = new Map(historyClients.map(client => [client.phone, client])); - const clients = buildRfmClients(baseClients); + const clients = periodResult.rows.map(row => { + const rfmClient = rfmByPhone.get(row.phone); + + return { + ...rfmClient, + name: row.name, + phone: row.phone, + monetary: toNumber(row.monetary), + frequency: toNumber(row.frequency), + quantityPurchased: toNumber(row.quantity_purchased), + lastPurchaseDate: row.last_purchase_date + }; + }).filter(client => client.segmentKey).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; + }); return { range: { diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 721bc7f..77cba25 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -98,6 +98,10 @@ test('scoreTertile can score lower values higher for recency', () => { assert.equal(scoreTertile(80, recencyDays, false), 1); }); +test('scoreTertile treats equal recency as high recency', () => { + assert.equal(scoreTertile(0, [0, 0, 0], false), 3); +}); + test('getRfmSegment maps the 3x3 RFM matrix', () => { assert.deepEqual(getRfmSegment(3, 3), { key: 'champions', label: 'Champions' }); assert.deepEqual(getRfmSegment(2, 2), { key: 'need_attention', label: 'Precisam de Atenção' }); @@ -186,16 +190,52 @@ test('getRfmAnalytics calculates recency against selected range end date', async pool.query = async (sql, params) => { calls.push({ sql, params }); + const isHistoryQuery = sql.includes('recency_days'); + const endDate = params[0]; + + if (isHistoryQuery) { + return { + rows: [ + { + name: 'Cliente Ontem', + phone: '1', + monetary: 5000, + frequency: 20, + quantity_purchased: 20, + last_purchase_date: '2026-06-14', + recency_days: endDate === '2026-06-14' ? 0 : 1 + }, + { + name: 'Cliente Antigo', + phone: '2', + monetary: 50, + frequency: 1, + quantity_purchased: 1, + last_purchase_date: '2026-01-01', + recency_days: 164 + }, + { + name: 'Cliente Medio', + phone: '3', + monetary: 100, + frequency: 2, + quantity_purchased: 2, + last_purchase_date: '2026-03-01', + recency_days: 105 + } + ] + }; + } + return { rows: [ { name: 'Cliente Ontem', phone: '1', - monetary: 500, - frequency: 2, - quantity_purchased: 2, - last_purchase_date: '2026-06-14', - recency_days: params[2] === '2026-06-14' ? 0 : 1 + monetary: 100, + frequency: 1, + quantity_purchased: 1, + last_purchase_date: '2026-06-14' } ] }; @@ -205,13 +245,17 @@ test('getRfmAnalytics calculates recency against selected range end date', async const sevenDays = await getRfmAnalytics({ start: '2026-06-09', end: '2026-06-15' }); const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' }); - assert.match(calls[0].sql, /\(\$3::date - MAX\(data_pedido_date\)\)::int/); - assert.match(calls[0].sql, /data_pedido_date <= \$3::date/); - assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15', '2026-06-15']); - assert.deepEqual(calls[1].params, ['2026-06-14', '2026-06-14', '2026-06-14']); + assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15']); + assert.match(calls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/); + assert.match(calls[1].sql, /data_pedido_date <= \$1::date/); + 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.equal(sevenDays.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].recencyDays, 0); + assert.equal(yesterday.clients[0].frequency, 1); + assert.equal(yesterday.clients[0].monetary, 100); } finally { pool.query = originalQuery; }