From 1af624aa77416ccd5524469396851bcd99dd8948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 18 Jun 2026 15:51:35 -0300 Subject: [PATCH] Calibrate RFV customer lifecycle segments --- backend/services/analyticsService.js | 154 ++++++++++++++++++-------- backend/test/analyticsService.test.js | 120 ++++++++++++++++---- src/pages/Rfm.tsx | 26 ++--- 3 files changed, 219 insertions(+), 81 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 6eb8ece..f9592aa 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -1,6 +1,9 @@ const { pool } = require('../db'); const RFM_QUERY_TIMEOUT_MS = 15000; +const RECENT_MAX_DAYS = 60; +const COOLING_MAX_DAYS = 180; +const LOST_MIN_DAYS = 366; 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 @@ -77,6 +80,9 @@ const RFM_SEGMENTS = { '1-2': { key: 'hibernating', label: 'Hibernando' }, '1-1': { key: 'lost', label: 'Perdidos' } }; +const RFM_SEGMENTS_BY_KEY = new Map( + Object.values(RFM_SEGMENTS).map(segment => [segment.key, segment]) +); const scoreTertile = (value, values, higherIsBetter = true) => { const numericValues = values.map(toNumber).filter(Number.isFinite); @@ -120,6 +126,47 @@ const getRfmSegment = (recencyScore, valueScore) => { return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1']; }; +const getRecencyScore = (recencyDays) => { + const days = Math.max(0, toNumber(recencyDays)); + if (days <= RECENT_MAX_DAYS) return 3; + if (days <= COOLING_MAX_DAYS) return 2; + return 1; +}; + +const getLifecycleSegment = ({ + recencyDays, + historicalFrequency, + frequencyScore, + monetaryScore, + valueScore +}) => { + const days = Math.max(0, toNumber(recencyDays)); + + if (days >= LOST_MIN_DAYS) { + return { segment: RFM_SEGMENTS_BY_KEY.get('lost'), valueScore: 1 }; + } + + if (days > COOLING_MAX_DAYS) { + const hasStrongHistory = frequencyScore === 3 || monetaryScore === 3; + return hasStrongHistory + ? { segment: RFM_SEGMENTS_BY_KEY.get('at_risk'), valueScore: 3 } + : { segment: RFM_SEGMENTS_BY_KEY.get('hibernating'), valueScore: 2 }; + } + + if (days <= RECENT_MAX_DAYS && historicalFrequency === 1) { + return { segment: RFM_SEGMENTS_BY_KEY.get('new_customers'), valueScore: 1 }; + } + + if (days <= RECENT_MAX_DAYS && valueScore === 1) { + return { segment: RFM_SEGMENTS_BY_KEY.get('potential_loyalists'), valueScore: 2 }; + } + + return { + segment: getRfmSegment(getRecencyScore(days), valueScore), + valueScore + }; +}; + const getDateDiffDays = (endDate, startDate) => { const normalizedEnd = normalizeDateParam(endDate); const normalizedStart = normalizeDateParam(startDate); @@ -153,21 +200,30 @@ const buildRfmSegments = (clients) => { }; const buildRfmClients = (baseClients) => { - 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 = 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); + const recencyDays = Math.max(0, toNumber(client.recencyDays)); + const historicalFrequency = toNumber(client.rfmFrequency ?? client.frequency); + const historicalMonetary = toNumber(client.rfmMonetary ?? client.monetary); + const recencyScore = getRecencyScore(recencyDays); + const frequencyScore = client.rfmFrequencyScore ?? frequencyScoreFor(historicalFrequency); + const monetaryScore = client.rfmMonetaryScore ?? monetaryScoreFor(historicalMonetary); + const baseValueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))); + const classification = getLifecycleSegment({ + recencyDays, + historicalFrequency, + frequencyScore, + monetaryScore, + valueScore: baseValueScore + }); + const valueScore = classification.valueScore; + const segment = classification.segment; return { ...client, + recencyDays, recencyScore, frequencyScore, monetaryScore, @@ -377,39 +433,49 @@ const getRfmAnalytics = async (range = {}) => { let historyRows = periodResult.rows; 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)) + const periodCustomerKeys = [...new Set(periodResult.rows + .map(row => row.customer_key) .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 customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`; 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; + WITH customer_history AS ( + 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} + GROUP BY customer_key + ), + scored_history AS MATERIALIZED ( + SELECT + customer_history.*, + CASE + WHEN COUNT(*) OVER () = 1 THEN 3 + WHEN MIN(frequency) OVER () = MAX(frequency) OVER () THEN 2 + ELSE LEAST(3, GREATEST(1, FLOOR(PERCENT_RANK() OVER (ORDER BY frequency) * 3)::int + 1)) + END as frequency_score, + CASE + WHEN COUNT(*) OVER () = 1 THEN 3 + WHEN MIN(monetary) OVER () = MAX(monetary) OVER () THEN 2 + ELSE LEAST(3, GREATEST(1, FLOOR(PERCENT_RANK() OVER (ORDER BY monetary) * 3)::int + 1)) + END as monetary_score + FROM customer_history + ) + SELECT * + FROM scored_history + WHERE customer_key = ANY(${customerKeysParam}); `, historyParams); historyRows = historyResult.rows; } @@ -422,15 +488,16 @@ const getRfmAnalytics = async (range = {}) => { frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), lastPurchaseDate: row.last_purchase_date, - recencyDays: toNumber(row.recency_days) + recencyDays: toNumber(row.recency_days), + rfmFrequencyScore: row.frequency_score === undefined ? undefined : toNumber(row.frequency_score), + rfmMonetaryScore: row.monetary_score === undefined ? undefined : toNumber(row.monetary_score) }))); const tagsByCustomerKey = new Map(historyClients.map(historyClient => [historyClient.customerKey, historyClient])); const clients = periodResult.rows.map(row => { const taggedClient = tagsByCustomerKey.get(row.customer_key); if (!taggedClient) { - const newCustomerSegment = getRfmSegment(3, 1); - return { + const [fallbackClient] = buildRfmClients([{ customerKey: row.customer_key, name: row.name, phone: row.phone || '', @@ -438,15 +505,9 @@ const getRfmAnalytics = async (range = {}) => { 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 - }; + recencyDays: toNumber(row.recency_days) + }]); + return fallbackClient; } return { @@ -492,6 +553,7 @@ module.exports = { buildRfmClients, buildRfmSegments, getPreviousDate, + getRecencyScore, getRfmAnalytics, getRfmSegment, getClientAnalytics, diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 73cf810..ab550d5 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -6,6 +6,7 @@ const { buildRfmSegments, buildDateFilter, getPreviousDate, + getRecencyScore, getRfmAnalytics, getRfmSegment, normalizeDateParam, @@ -109,6 +110,16 @@ test('scoreTertile treats equal recency as high recency', () => { assert.equal(scoreTertile(0, [0, 0, 0], false), 3); }); +test('getRecencyScore uses fixed lifecycle boundaries', () => { + assert.equal(getRecencyScore(0), 3); + assert.equal(getRecencyScore(60), 3); + assert.equal(getRecencyScore(61), 2); + assert.equal(getRecencyScore(180), 2); + assert.equal(getRecencyScore(181), 1); + assert.equal(getRecencyScore(365), 1); + assert.equal(getRecencyScore(366), 1); +}); + 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' }); @@ -191,6 +202,74 @@ test('buildRfmClients scores segments from RFM history when period totals are sm assert.equal(yesterdayBuyer.monetaryScore, 3); }); +test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk, and lost clients', () => { + const clients = buildRfmClients([ + { + customerKey: 'new', + name: 'Novo', + monetary: 50, + frequency: 1, + recencyDays: 60, + rfmFrequency: 1, + rfmMonetary: 50, + rfmFrequencyScore: 1, + rfmMonetaryScore: 1 + }, + { + customerKey: 'not-new', + name: 'Não é mais novo', + monetary: 50, + frequency: 1, + recencyDays: 61, + rfmFrequency: 1, + rfmMonetary: 50, + rfmFrequencyScore: 1, + rfmMonetaryScore: 1 + }, + { + customerKey: 'hibernating', + name: 'Hibernando', + monetary: 50, + frequency: 1, + recencyDays: 250, + rfmFrequency: 1, + rfmMonetary: 50, + rfmFrequencyScore: 1, + rfmMonetaryScore: 1 + }, + { + customerKey: 'at-risk', + name: 'Em Risco', + monetary: 100, + frequency: 1, + recencyDays: 250, + rfmFrequency: 10, + rfmMonetary: 1000, + rfmFrequencyScore: 3, + rfmMonetaryScore: 2 + }, + { + customerKey: 'lost', + name: 'Perdido', + monetary: 2000, + frequency: 1, + recencyDays: 366, + rfmFrequency: 1, + rfmMonetary: 2000, + rfmFrequencyScore: 1, + rfmMonetaryScore: 3 + } + ]); + const byKey = new Map(clients.map(client => [client.customerKey, client])); + + assert.equal(byKey.get('new').segmentKey, 'new_customers'); + assert.equal(byKey.get('not-new').segmentKey, 'about_to_sleep'); + assert.equal(byKey.get('hibernating').segmentKey, 'hibernating'); + assert.equal(byKey.get('at-risk').segmentKey, 'at_risk'); + assert.equal(byKey.get('lost').segmentKey, 'lost'); + assert.equal(byKey.get('lost').rfmScore, '113'); +}); + test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => { const originalConnect = pool.connect; const calls = []; @@ -216,27 +295,21 @@ test('getRfmAnalytics classifies period buyers by history through the selected r frequency: 20, quantity_purchased: 20, last_purchase_date: '2026-06-14', - recency_days: referenceDate === '2026-06-14' ? 0 : 1 + recency_days: referenceDate === '2026-06-14' ? 0 : 1, + frequency_score: 3, + monetary_score: 3 }, { - customer_key: '2', - name: 'Cliente Antigo', - phone: '2', - monetary: 50, + customer_key: '4', + name: 'Cliente Novo no Periodo', + phone: '4', + monetary: 25, 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 + last_purchase_date: '2026-06-14', + recency_days: referenceDate === '2026-06-14' ? 0 : 1, + frequency_score: 1, + monetary_score: 1 }, { customer_key: 'name:Cliente Sem Fone', @@ -246,7 +319,9 @@ test('getRfmAnalytics classifies period buyers by history through the selected r frequency: 10, quantity_purchased: 10, last_purchase_date: '2026-06-14', - recency_days: 1 + recency_days: referenceDate === '2026-06-14' ? 0 : 1, + frequency_score: 3, + monetary_score: 3 } ] }; @@ -301,12 +376,13 @@ test('getRfmAnalytics classifies period buyers by history through the selected r 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.match(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.deepEqual(selectCalls[1].params, ['2026-06-15', ['1', '4'], ['Cliente Sem Fone']]); + 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'], ['Cliente Sem Fone']]); + assert.deepEqual(selectCalls[3].params, ['2026-06-14', ['1', '4', 'name:Cliente Sem Fone']]); assert.equal(sevenDays.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].frequency, 1); diff --git a/src/pages/Rfm.tsx b/src/pages/Rfm.tsx index 64fc89c..53848ae 100644 --- a/src/pages/Rfm.tsx +++ b/src/pages/Rfm.tsx @@ -34,14 +34,14 @@ const rfmSegmentDefinitions: Array> = [ const segmentDescriptions: Record = { champions: 'Recentes, frequentes e valiosos', - potential_loyalists: 'Recentes com bom potencial', - new_customers: 'Primeira compra recente', - loyal_customers: 'Valiosos, mas menos recentes', - need_attention: 'Base intermediária para nutrir', - about_to_sleep: 'Baixo valor começando a esfriar', - at_risk: 'Valiosos sem compra recente', - hibernating: 'Sem compra recente e valor médio', - lost: 'Baixa atividade e distante' + potential_loyalists: 'Recentes e em evolução', + new_customers: 'Primeiro pedido nos últimos 60 dias', + loyal_customers: 'Bom histórico, compra entre 61 e 180 dias', + need_attention: 'Perfil médio, compra entre 61 e 180 dias', + about_to_sleep: 'Perfil baixo, compra entre 61 e 180 dias', + at_risk: 'Histórico forte, sem compra entre 181 e 365 dias', + hibernating: 'Sem compra entre 181 e 365 dias', + lost: 'Sem compra há 366 dias ou mais' }; const segmentActions: Record = { @@ -312,7 +312,7 @@ const Rfm = () => {

Matriz RFV

-

Compradores do período agrupados pelo RFV histórico até a data final.

+

Compradores do período agrupados por recência fixa e perfil histórico de frequência e valor.

Menor prioridade @@ -326,12 +326,12 @@ const Rfm = () => {

Eixos

-

Recência / Valor

+

Recência / Perfil

{[1, 2, 3].map(valueScore => (
-

Valor {valueScore}

+

Perfil {valueScore}

{scoreLabel(valueScore)}

))} @@ -343,7 +343,7 @@ const Rfm = () => {

Rec. {recencyScore}

{scoreLabel(recencyScore)}

- {recencyScore === 3 ? 'Compra recente' : recencyScore === 2 ? 'Intermediário' : 'Distante'} + {recencyScore === 3 ? '0 a 60 dias' : recencyScore === 2 ? '61 a 180 dias' : '181+ dias'}

@@ -371,7 +371,7 @@ const Rfm = () => {
- R{recencyScore} V{valueScore} + R{recencyScore} P{valueScore}

{segment?.label}