Calibrate RFV frequency scoring
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m14s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m14s
This commit is contained in:
@@ -4,6 +4,7 @@ const RFM_QUERY_TIMEOUT_MS = 15000;
|
|||||||
const RECENT_MAX_DAYS = 60;
|
const RECENT_MAX_DAYS = 60;
|
||||||
const COOLING_MAX_DAYS = 180;
|
const COOLING_MAX_DAYS = 180;
|
||||||
const LOST_MIN_DAYS = 366;
|
const LOST_MIN_DAYS = 366;
|
||||||
|
const FREQUENCY_MEDIUM_MAX_ORDERS = 4;
|
||||||
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 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 = `
|
const PRODUCT_NAME_SQL = `
|
||||||
CASE
|
CASE
|
||||||
@@ -133,6 +134,13 @@ const getRecencyScore = (recencyDays) => {
|
|||||||
return 1;
|
return 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFrequencyScore = (frequency) => {
|
||||||
|
const orders = Math.max(0, toNumber(frequency));
|
||||||
|
if (orders <= 1) return 1;
|
||||||
|
if (orders <= FREQUENCY_MEDIUM_MAX_ORDERS) return 2;
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
|
||||||
const getLifecycleSegment = ({
|
const getLifecycleSegment = ({
|
||||||
recencyDays,
|
recencyDays,
|
||||||
historicalFrequency,
|
historicalFrequency,
|
||||||
@@ -157,8 +165,11 @@ const getLifecycleSegment = ({
|
|||||||
return { segment: RFM_SEGMENTS_BY_KEY.get('new_customers'), valueScore: 1 };
|
return { segment: RFM_SEGMENTS_BY_KEY.get('new_customers'), valueScore: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (days <= RECENT_MAX_DAYS && valueScore === 1) {
|
if (days <= RECENT_MAX_DAYS) {
|
||||||
return { segment: RFM_SEGMENTS_BY_KEY.get('potential_loyalists'), valueScore: 2 };
|
const isChampion = frequencyScore === 3 && monetaryScore === 3;
|
||||||
|
return isChampion
|
||||||
|
? { segment: RFM_SEGMENTS_BY_KEY.get('champions'), valueScore: 3 }
|
||||||
|
: { segment: RFM_SEGMENTS_BY_KEY.get('potential_loyalists'), valueScore: 2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -200,7 +211,6 @@ const buildRfmSegments = (clients) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildRfmClients = (baseClients) => {
|
const buildRfmClients = (baseClients) => {
|
||||||
const frequencyScoreFor = buildTertileScorer(baseClients.map(client => client.rfmFrequency ?? client.frequency), true);
|
|
||||||
const monetaryScoreFor = buildTertileScorer(baseClients.map(client => client.rfmMonetary ?? client.monetary), true);
|
const monetaryScoreFor = buildTertileScorer(baseClients.map(client => client.rfmMonetary ?? client.monetary), true);
|
||||||
|
|
||||||
return baseClients.map(client => {
|
return baseClients.map(client => {
|
||||||
@@ -208,7 +218,7 @@ const buildRfmClients = (baseClients) => {
|
|||||||
const historicalFrequency = toNumber(client.rfmFrequency ?? client.frequency);
|
const historicalFrequency = toNumber(client.rfmFrequency ?? client.frequency);
|
||||||
const historicalMonetary = toNumber(client.rfmMonetary ?? client.monetary);
|
const historicalMonetary = toNumber(client.rfmMonetary ?? client.monetary);
|
||||||
const recencyScore = getRecencyScore(recencyDays);
|
const recencyScore = getRecencyScore(recencyDays);
|
||||||
const frequencyScore = client.rfmFrequencyScore ?? frequencyScoreFor(historicalFrequency);
|
const frequencyScore = client.rfmFrequencyScore ?? getFrequencyScore(historicalFrequency);
|
||||||
const monetaryScore = client.rfmMonetaryScore ?? monetaryScoreFor(historicalMonetary);
|
const monetaryScore = client.rfmMonetaryScore ?? monetaryScoreFor(historicalMonetary);
|
||||||
const baseValueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
|
const baseValueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
|
||||||
const classification = getLifecycleSegment({
|
const classification = getLifecycleSegment({
|
||||||
@@ -462,9 +472,9 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
SELECT
|
SELECT
|
||||||
customer_history.*,
|
customer_history.*,
|
||||||
CASE
|
CASE
|
||||||
WHEN COUNT(*) OVER () = 1 THEN 3
|
WHEN frequency <= 1 THEN 1
|
||||||
WHEN MIN(frequency) OVER () = MAX(frequency) OVER () THEN 2
|
WHEN frequency <= ${FREQUENCY_MEDIUM_MAX_ORDERS} THEN 2
|
||||||
ELSE LEAST(3, GREATEST(1, FLOOR(PERCENT_RANK() OVER (ORDER BY frequency) * 3)::int + 1))
|
ELSE 3
|
||||||
END as frequency_score,
|
END as frequency_score,
|
||||||
CASE
|
CASE
|
||||||
WHEN COUNT(*) OVER () = 1 THEN 3
|
WHEN COUNT(*) OVER () = 1 THEN 3
|
||||||
@@ -552,6 +562,7 @@ module.exports = {
|
|||||||
buildDateFilter,
|
buildDateFilter,
|
||||||
buildRfmClients,
|
buildRfmClients,
|
||||||
buildRfmSegments,
|
buildRfmSegments,
|
||||||
|
getFrequencyScore,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
getRecencyScore,
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const {
|
|||||||
buildRfmClients,
|
buildRfmClients,
|
||||||
buildRfmSegments,
|
buildRfmSegments,
|
||||||
buildDateFilter,
|
buildDateFilter,
|
||||||
|
getFrequencyScore,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
getRecencyScore,
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
@@ -120,6 +121,15 @@ test('getRecencyScore uses fixed lifecycle boundaries', () => {
|
|||||||
assert.equal(getRecencyScore(366), 1);
|
assert.equal(getRecencyScore(366), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getFrequencyScore uses fixed historical order thresholds', () => {
|
||||||
|
assert.equal(getFrequencyScore(0), 1);
|
||||||
|
assert.equal(getFrequencyScore(1), 1);
|
||||||
|
assert.equal(getFrequencyScore(2), 2);
|
||||||
|
assert.equal(getFrequencyScore(4), 2);
|
||||||
|
assert.equal(getFrequencyScore(5), 3);
|
||||||
|
assert.equal(getFrequencyScore(100), 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('getRfmSegment maps the 3x3 RFM matrix', () => {
|
test('getRfmSegment maps the 3x3 RFM matrix', () => {
|
||||||
assert.deepEqual(getRfmSegment(3, 3), { key: 'champions', label: 'Champions' });
|
assert.deepEqual(getRfmSegment(3, 3), { key: 'champions', label: 'Champions' });
|
||||||
assert.deepEqual(getRfmSegment(2, 2), { key: 'need_attention', label: 'Precisam de Atenção' });
|
assert.deepEqual(getRfmSegment(2, 2), { key: 'need_attention', label: 'Precisam de Atenção' });
|
||||||
@@ -143,7 +153,7 @@ test('buildRfmSegments summarizes segment count and revenue', () => {
|
|||||||
assert.equal(lost.totalRevenue, 25);
|
assert.equal(lost.totalRevenue, 25);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildRfmClients keeps a yesterday buyer in the same high-recency segment when it is the only qualifying client', () => {
|
test('buildRfmClients keeps a repeat recent buyer as potential loyal across period filters', () => {
|
||||||
const sevenDayClients = buildRfmClients([
|
const sevenDayClients = buildRfmClients([
|
||||||
{ name: 'Cliente Ontem', phone: '1', monetary: 500, frequency: 2, quantityPurchased: 2, lastPurchaseDate: '2026-06-14', recencyDays: 1 }
|
{ name: 'Cliente Ontem', phone: '1', monetary: 500, frequency: 2, quantityPurchased: 2, lastPurchaseDate: '2026-06-14', recencyDays: 1 }
|
||||||
]);
|
]);
|
||||||
@@ -151,11 +161,42 @@ test('buildRfmClients keeps a yesterday buyer in the same high-recency segment w
|
|||||||
{ name: 'Cliente Ontem', phone: '1', monetary: 500, frequency: 2, quantityPurchased: 2, lastPurchaseDate: '2026-06-14', recencyDays: 0 }
|
{ name: 'Cliente Ontem', phone: '1', monetary: 500, frequency: 2, quantityPurchased: 2, lastPurchaseDate: '2026-06-14', recencyDays: 0 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(sevenDayClients[0].segmentKey, 'champions');
|
assert.equal(sevenDayClients[0].segmentKey, 'potential_loyalists');
|
||||||
assert.equal(yesterdayClients[0].segmentKey, 'champions');
|
assert.equal(yesterdayClients[0].segmentKey, 'potential_loyalists');
|
||||||
assert.equal(sevenDayClients[0].phone, yesterdayClients[0].phone);
|
assert.equal(sevenDayClients[0].phone, yesterdayClients[0].phone);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildRfmClients requires five orders and top value for a recent Champion', () => {
|
||||||
|
const clients = buildRfmClients([
|
||||||
|
{
|
||||||
|
customerKey: 'potential',
|
||||||
|
name: 'Potencial',
|
||||||
|
monetary: 5000,
|
||||||
|
frequency: 4,
|
||||||
|
recencyDays: 10,
|
||||||
|
rfmFrequency: 4,
|
||||||
|
rfmMonetary: 5000,
|
||||||
|
rfmMonetaryScore: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customerKey: 'champion',
|
||||||
|
name: 'Champion',
|
||||||
|
monetary: 5000,
|
||||||
|
frequency: 5,
|
||||||
|
recencyDays: 10,
|
||||||
|
rfmFrequency: 5,
|
||||||
|
rfmMonetary: 5000,
|
||||||
|
rfmMonetaryScore: 3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const byKey = new Map(clients.map(client => [client.customerKey, client]));
|
||||||
|
|
||||||
|
assert.equal(byKey.get('potential').frequencyScore, 2);
|
||||||
|
assert.equal(byKey.get('potential').segmentKey, 'potential_loyalists');
|
||||||
|
assert.equal(byKey.get('champion').frequencyScore, 3);
|
||||||
|
assert.equal(byKey.get('champion').segmentKey, 'champions');
|
||||||
|
});
|
||||||
|
|
||||||
test('buildRfmClients scores segments from RFM history when period totals are smaller', () => {
|
test('buildRfmClients scores segments from RFM history when period totals are smaller', () => {
|
||||||
const clients = buildRfmClients([
|
const clients = buildRfmClients([
|
||||||
{
|
{
|
||||||
@@ -376,7 +417,9 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
|
|||||||
assert.doesNotMatch(selectCalls[0].sql, /cliente_fone IS NOT NULL/);
|
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, /\(\$1::date - MAX\(data_pedido_date\)\)::int/);
|
||||||
assert.match(selectCalls[1].sql, /data_pedido_date <= \$1::date/);
|
assert.match(selectCalls[1].sql, /data_pedido_date <= \$1::date/);
|
||||||
assert.match(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY frequency\)/);
|
assert.match(selectCalls[1].sql, /WHEN frequency <= 1 THEN 1/);
|
||||||
|
assert.match(selectCalls[1].sql, /WHEN frequency <= 4 THEN 2/);
|
||||||
|
assert.doesNotMatch(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, /PERCENT_RANK\(\) OVER \(ORDER BY monetary\)/);
|
||||||
assert.match(selectCalls[1].sql, /customer_key = ANY\(\$2::text\[\]\)/);
|
assert.match(selectCalls[1].sql, /customer_key = ANY\(\$2::text\[\]\)/);
|
||||||
assert.doesNotMatch(selectCalls[1].sql, /cliente_fone IS NOT NULL/);
|
assert.doesNotMatch(selectCalls[1].sql, /cliente_fone IS NOT NULL/);
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ const rfmSegmentDefinitions: Array<Pick<RfmSegment, 'key' | 'label'>> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const segmentDescriptions: Record<string, string> = {
|
const segmentDescriptions: Record<string, string> = {
|
||||||
champions: 'Recentes, frequentes e valiosos',
|
champions: 'Recentes, 5+ pedidos e valor alto',
|
||||||
potential_loyalists: 'Recentes e em evolução',
|
potential_loyalists: 'Recentes com 2+ pedidos em evolução',
|
||||||
new_customers: 'Primeiro pedido nos últimos 60 dias',
|
new_customers: 'Primeiro pedido nos últimos 60 dias',
|
||||||
loyal_customers: 'Bom histórico, compra entre 61 e 180 dias',
|
loyal_customers: 'Bom histórico, compra entre 61 e 180 dias',
|
||||||
need_attention: 'Perfil médio, compra entre 61 e 180 dias',
|
need_attention: 'Perfil médio, compra entre 61 e 180 dias',
|
||||||
@@ -312,7 +312,7 @@ const Rfm = () => {
|
|||||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-dark-text">Matriz RFV</h2>
|
<h2 className="text-lg font-bold text-dark-text">Matriz RFV</h2>
|
||||||
<p className="text-sm font-medium text-dark-muted">Compradores do período agrupados por recência fixa e perfil histórico de frequência e valor.</p>
|
<p className="text-sm font-medium text-dark-muted">Recência fixa, frequência por número de pedidos e valor relativo ao histórico.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
|
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
|
||||||
<span>Menor prioridade</span>
|
<span>Menor prioridade</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user