From ca90a6e94bf5f7537c1d079101af6d951e5ab13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 17 Jun 2026 16:12:36 -0300 Subject: [PATCH] Fix RFV all-period scaling --- backend/services/analyticsService.js | 81 +++++++++++++++++++++++++-- backend/test/analyticsService.test.js | 74 ++++++++++++------------ 2 files changed, 110 insertions(+), 45 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 15a66b6..6eb8ece 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -94,10 +94,50 @@ const scoreTertile = (value, values, higherIsBetter = true) => { return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)); }; +const buildTertileScorer = (values, higherIsBetter = true) => { + const numericValues = values.map(toNumber).filter(Number.isFinite); + if (!numericValues.length) return () => 1; + if (numericValues.length === 1) return () => 3; + + const min = Math.min(...numericValues); + const max = Math.max(...numericValues); + if (min === max) return () => higherIsBetter ? 2 : 3; + + const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a); + const scoresByValue = new Map(); + + sorted.forEach((candidate, index) => { + if (scoresByValue.has(candidate)) return; + + const percentile = index / (sorted.length - 1); + scoresByValue.set(candidate, Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1))); + }); + + return (value) => scoresByValue.get(toNumber(value)) || 1; +}; + const getRfmSegment = (recencyScore, valueScore) => { return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1']; }; +const getDateDiffDays = (endDate, startDate) => { + const normalizedEnd = normalizeDateParam(endDate); + const normalizedStart = normalizeDateParam(startDate); + + if (!normalizedEnd || !normalizedStart) return 0; + + const end = new Date(`${normalizedEnd}T00:00:00.000Z`); + const start = new Date(`${normalizedStart}T00:00:00.000Z`); + return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86400000)); +}; + +const getDateOnly = (value) => { + if (!value) return null; + if (value instanceof Date) return value.toISOString().slice(0, 10); + const match = String(value).match(/^(\d{4}-\d{2}-\d{2})/); + return match ? match[1] : null; +}; + const buildRfmSegments = (clients) => { return Object.values(RFM_SEGMENTS).map(segment => { const segmentClients = clients.filter(client => client.segmentKey === segment.key); @@ -113,16 +153,16 @@ const buildRfmSegments = (clients) => { }; const buildRfmClients = (baseClients) => { - const recencyValues = baseClients.map(client => client.recencyDays); - const frequencyValues = baseClients.map(client => client.rfmFrequency ?? client.frequency); - const monetaryValues = baseClients.map(client => client.rfmMonetary ?? client.monetary); + const recencyScoreFor = buildTertileScorer(baseClients.map(client => client.recencyDays), false); + const frequencyScoreFor = buildTertileScorer(baseClients.map(client => client.rfmFrequency ?? client.frequency), true); + const monetaryScoreFor = buildTertileScorer(baseClients.map(client => client.rfmMonetary ?? client.monetary), true); return baseClients.map(client => { const frequencyForScore = client.rfmFrequency ?? client.frequency; const monetaryForScore = client.rfmMonetary ?? client.monetary; - const recencyScore = scoreTertile(client.recencyDays, recencyValues, false); - const frequencyScore = scoreTertile(frequencyForScore, frequencyValues, true); - const monetaryScore = scoreTertile(monetaryForScore, monetaryValues, true); + const recencyScore = recencyScoreFor(client.recencyDays); + const frequencyScore = frequencyScoreFor(frequencyForScore); + const monetaryScore = monetaryScoreFor(monetaryForScore); const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))); const segment = getRfmSegment(recencyScore, valueScore); @@ -267,6 +307,35 @@ const getRfmAnalytics = async (range = {}) => { const normalizedEnd = normalizeDateParam(range.end); const periodRecencyReferenceDate = normalizedEnd ? `$${params.length}::date` : 'CURRENT_DATE'; const usePeriodAsHistory = !normalizedStart || normalizedStart <= '2000-01-01'; + + if (usePeriodAsHistory) { + const clientRows = await getClientAnalytics({ end: normalizedEnd }); + const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10); + const clients = buildRfmClients(clientRows.map(row => ({ + customerKey: row.customerKey, + name: row.name, + phone: row.phone || '', + monetary: row.totalSpent, + frequency: row.orderCount, + quantityPurchased: row.quantityPurchased, + lastPurchaseDate: row.lastPurchaseDate, + recencyDays: getDateDiffDays(recencyEnd, getDateOnly(row.lastPurchaseDate)) + }))); + + return { + range: { + start: normalizeDateParam(range.start), + end: normalizeDateParam(range.end) + }, + clients, + segments: buildRfmSegments(clients), + matrix: { + recencyScores: [3, 2, 1], + valueScores: [1, 2, 3] + } + }; + } + const client = await pool.connect(); try { diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 8d993d1..73cf810 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -325,58 +325,54 @@ test('getRfmAnalytics classifies period buyers by history through the selected r } }); -test('getRfmAnalytics reuses period rows as RFV history for all-period ranges', async () => { +test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period ranges', async () => { + const originalQuery = pool.query; const originalConnect = pool.connect; const calls = []; - const mockClient = { - query: async (sql, params = []) => { - calls.push({ sql, params }); - if (!sql.includes('FROM orders')) { - return { rows: [] }; - } + pool.query = async (sql, params = []) => { + calls.push({ sql, params }); - 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: () => {} + return { + rows: [ + { + customer_key: '1', + name: 'Cliente Frequente', + phone: '1', + total_spent: 5000, + order_count: 20, + quantity_purchased: 20, + last_purchase_date: '2026-06-14' + }, + { + customer_key: 'name:Cliente Sem Fone', + name: 'Cliente Sem Fone', + phone: null, + total_spent: 50, + order_count: 1, + quantity_purchased: 1, + last_purchase_date: '2026-01-01' + } + ] + }; + }; + pool.connect = async () => { + throw new Error('all-period RFV should not use a checked-out client'); }; - 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(calls.length, 1); + assert.match(calls[0].sql, /GROUP BY customer_key/); + assert.doesNotMatch(calls[0].sql, /recency_days/); + assert.doesNotMatch(calls[0].sql, /data_pedido_date >=/); + assert.deepEqual(calls[0].params, ['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.query = originalQuery; pool.connect = originalConnect; } });