Calibrate RFV customer lifecycle segments
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
const { pool } = require('../db');
|
const { pool } = require('../db');
|
||||||
|
|
||||||
const RFM_QUERY_TIMEOUT_MS = 15000;
|
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 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
|
||||||
@@ -77,6 +80,9 @@ const RFM_SEGMENTS = {
|
|||||||
'1-2': { key: 'hibernating', label: 'Hibernando' },
|
'1-2': { key: 'hibernating', label: 'Hibernando' },
|
||||||
'1-1': { key: 'lost', label: 'Perdidos' }
|
'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 scoreTertile = (value, values, higherIsBetter = true) => {
|
||||||
const numericValues = values.map(toNumber).filter(Number.isFinite);
|
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'];
|
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 getDateDiffDays = (endDate, startDate) => {
|
||||||
const normalizedEnd = normalizeDateParam(endDate);
|
const normalizedEnd = normalizeDateParam(endDate);
|
||||||
const normalizedStart = normalizeDateParam(startDate);
|
const normalizedStart = normalizeDateParam(startDate);
|
||||||
@@ -153,21 +200,30 @@ const buildRfmSegments = (clients) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildRfmClients = (baseClients) => {
|
const buildRfmClients = (baseClients) => {
|
||||||
const recencyScoreFor = buildTertileScorer(baseClients.map(client => client.recencyDays), false);
|
|
||||||
const frequencyScoreFor = buildTertileScorer(baseClients.map(client => client.rfmFrequency ?? client.frequency), true);
|
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 => {
|
||||||
const frequencyForScore = client.rfmFrequency ?? client.frequency;
|
const recencyDays = Math.max(0, toNumber(client.recencyDays));
|
||||||
const monetaryForScore = client.rfmMonetary ?? client.monetary;
|
const historicalFrequency = toNumber(client.rfmFrequency ?? client.frequency);
|
||||||
const recencyScore = recencyScoreFor(client.recencyDays);
|
const historicalMonetary = toNumber(client.rfmMonetary ?? client.monetary);
|
||||||
const frequencyScore = frequencyScoreFor(frequencyForScore);
|
const recencyScore = getRecencyScore(recencyDays);
|
||||||
const monetaryScore = monetaryScoreFor(monetaryForScore);
|
const frequencyScore = client.rfmFrequencyScore ?? frequencyScoreFor(historicalFrequency);
|
||||||
const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
|
const monetaryScore = client.rfmMonetaryScore ?? monetaryScoreFor(historicalMonetary);
|
||||||
const segment = getRfmSegment(recencyScore, valueScore);
|
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 {
|
return {
|
||||||
...client,
|
...client,
|
||||||
|
recencyDays,
|
||||||
recencyScore,
|
recencyScore,
|
||||||
frequencyScore,
|
frequencyScore,
|
||||||
monetaryScore,
|
monetaryScore,
|
||||||
@@ -377,19 +433,17 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
let historyRows = periodResult.rows;
|
let historyRows = periodResult.rows;
|
||||||
|
|
||||||
if (!usePeriodAsHistory) {
|
if (!usePeriodAsHistory) {
|
||||||
const periodPhones = [...new Set(periodResult.rows.map(row => row.phone).filter(Boolean))];
|
const periodCustomerKeys = [...new Set(periodResult.rows
|
||||||
const periodNamesWithoutPhone = [...new Set(periodResult.rows
|
.map(row => row.customer_key)
|
||||||
.filter(row => !row.phone && String(row.customer_key || '').startsWith('name:'))
|
|
||||||
.map(row => String(row.customer_key).slice(5))
|
|
||||||
.filter(Boolean))];
|
.filter(Boolean))];
|
||||||
const historyParams = [];
|
const historyParams = [];
|
||||||
const recencyReferenceDate = normalizedEnd
|
const recencyReferenceDate = normalizedEnd
|
||||||
? `$${historyParams.push(normalizedEnd)}::date`
|
? `$${historyParams.push(normalizedEnd)}::date`
|
||||||
: 'CURRENT_DATE';
|
: 'CURRENT_DATE';
|
||||||
const phoneListParam = `$${historyParams.push(periodPhones)}::text[]`;
|
const customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`;
|
||||||
const nameListParam = `$${historyParams.push(periodNamesWithoutPhone)}::text[]`;
|
|
||||||
|
|
||||||
const historyResult = await client.query(`
|
const historyResult = await client.query(`
|
||||||
|
WITH customer_history AS (
|
||||||
SELECT
|
SELECT
|
||||||
${CUSTOMER_KEY_SQL} as customer_key,
|
${CUSTOMER_KEY_SQL} as customer_key,
|
||||||
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
|
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
|
||||||
@@ -402,14 +456,26 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
FROM orders
|
FROM orders
|
||||||
WHERE data_pedido_date IS NOT NULL
|
WHERE data_pedido_date IS NOT NULL
|
||||||
AND data_pedido_date <= ${recencyReferenceDate}
|
AND data_pedido_date <= ${recencyReferenceDate}
|
||||||
AND (
|
GROUP BY customer_key
|
||||||
NULLIF(cliente_fone, '') = ANY(${phoneListParam})
|
),
|
||||||
OR (
|
scored_history AS MATERIALIZED (
|
||||||
NULLIF(cliente_fone, '') IS NULL
|
SELECT
|
||||||
AND COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') = ANY(${nameListParam})
|
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 *
|
||||||
GROUP BY customer_key;
|
FROM scored_history
|
||||||
|
WHERE customer_key = ANY(${customerKeysParam});
|
||||||
`, historyParams);
|
`, historyParams);
|
||||||
historyRows = historyResult.rows;
|
historyRows = historyResult.rows;
|
||||||
}
|
}
|
||||||
@@ -422,15 +488,16 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
frequency: toNumber(row.frequency),
|
frequency: toNumber(row.frequency),
|
||||||
quantityPurchased: toNumber(row.quantity_purchased),
|
quantityPurchased: toNumber(row.quantity_purchased),
|
||||||
lastPurchaseDate: row.last_purchase_date,
|
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 tagsByCustomerKey = new Map(historyClients.map(historyClient => [historyClient.customerKey, historyClient]));
|
||||||
|
|
||||||
const clients = periodResult.rows.map(row => {
|
const clients = periodResult.rows.map(row => {
|
||||||
const taggedClient = tagsByCustomerKey.get(row.customer_key);
|
const taggedClient = tagsByCustomerKey.get(row.customer_key);
|
||||||
if (!taggedClient) {
|
if (!taggedClient) {
|
||||||
const newCustomerSegment = getRfmSegment(3, 1);
|
const [fallbackClient] = buildRfmClients([{
|
||||||
return {
|
|
||||||
customerKey: row.customer_key,
|
customerKey: row.customer_key,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
@@ -438,15 +505,9 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
frequency: toNumber(row.frequency),
|
frequency: toNumber(row.frequency),
|
||||||
quantityPurchased: toNumber(row.quantity_purchased),
|
quantityPurchased: toNumber(row.quantity_purchased),
|
||||||
lastPurchaseDate: row.last_purchase_date,
|
lastPurchaseDate: row.last_purchase_date,
|
||||||
recencyDays: 0,
|
recencyDays: toNumber(row.recency_days)
|
||||||
recencyScore: 3,
|
}]);
|
||||||
frequencyScore: 1,
|
return fallbackClient;
|
||||||
monetaryScore: 1,
|
|
||||||
valueScore: 1,
|
|
||||||
rfmScore: '311',
|
|
||||||
segmentKey: newCustomerSegment.key,
|
|
||||||
segmentLabel: newCustomerSegment.label
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -492,6 +553,7 @@ module.exports = {
|
|||||||
buildRfmClients,
|
buildRfmClients,
|
||||||
buildRfmSegments,
|
buildRfmSegments,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
getRfmSegment,
|
getRfmSegment,
|
||||||
getClientAnalytics,
|
getClientAnalytics,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
buildRfmSegments,
|
buildRfmSegments,
|
||||||
buildDateFilter,
|
buildDateFilter,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
getRfmSegment,
|
getRfmSegment,
|
||||||
normalizeDateParam,
|
normalizeDateParam,
|
||||||
@@ -109,6 +110,16 @@ test('scoreTertile treats equal recency as high recency', () => {
|
|||||||
assert.equal(scoreTertile(0, [0, 0, 0], false), 3);
|
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', () => {
|
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' });
|
||||||
@@ -191,6 +202,74 @@ test('buildRfmClients scores segments from RFM history when period totals are sm
|
|||||||
assert.equal(yesterdayBuyer.monetaryScore, 3);
|
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 () => {
|
test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => {
|
||||||
const originalConnect = pool.connect;
|
const originalConnect = pool.connect;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -216,27 +295,21 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
|
|||||||
frequency: 20,
|
frequency: 20,
|
||||||
quantity_purchased: 20,
|
quantity_purchased: 20,
|
||||||
last_purchase_date: '2026-06-14',
|
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',
|
customer_key: '4',
|
||||||
name: 'Cliente Antigo',
|
name: 'Cliente Novo no Periodo',
|
||||||
phone: '2',
|
phone: '4',
|
||||||
monetary: 50,
|
monetary: 25,
|
||||||
frequency: 1,
|
frequency: 1,
|
||||||
quantity_purchased: 1,
|
quantity_purchased: 1,
|
||||||
last_purchase_date: '2026-01-01',
|
last_purchase_date: '2026-06-14',
|
||||||
recency_days: 164
|
recency_days: referenceDate === '2026-06-14' ? 0 : 1,
|
||||||
},
|
frequency_score: 1,
|
||||||
{
|
monetary_score: 1
|
||||||
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',
|
customer_key: 'name:Cliente Sem Fone',
|
||||||
@@ -246,7 +319,9 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
|
|||||||
frequency: 10,
|
frequency: 10,
|
||||||
quantity_purchased: 10,
|
quantity_purchased: 10,
|
||||||
last_purchase_date: '2026-06-14',
|
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.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, /NULLIF\(cliente_fone, ''\) = ANY\(\$2::text\[\]\)/);
|
assert.match(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY frequency\)/);
|
||||||
assert.match(selectCalls[1].sql, /COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\) = ANY\(\$3::text\[\]\)/);
|
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.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[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(sevenDays.clients[0].segmentKey, 'champions');
|
||||||
assert.equal(yesterday.clients[0].segmentKey, 'champions');
|
assert.equal(yesterday.clients[0].segmentKey, 'champions');
|
||||||
assert.equal(yesterday.clients[0].frequency, 1);
|
assert.equal(yesterday.clients[0].frequency, 1);
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ const rfmSegmentDefinitions: Array<Pick<RfmSegment, 'key' | 'label'>> = [
|
|||||||
|
|
||||||
const segmentDescriptions: Record<string, string> = {
|
const segmentDescriptions: Record<string, string> = {
|
||||||
champions: 'Recentes, frequentes e valiosos',
|
champions: 'Recentes, frequentes e valiosos',
|
||||||
potential_loyalists: 'Recentes com bom potencial',
|
potential_loyalists: 'Recentes e em evolução',
|
||||||
new_customers: 'Primeira compra recente',
|
new_customers: 'Primeiro pedido nos últimos 60 dias',
|
||||||
loyal_customers: 'Valiosos, mas menos recentes',
|
loyal_customers: 'Bom histórico, compra entre 61 e 180 dias',
|
||||||
need_attention: 'Base intermediária para nutrir',
|
need_attention: 'Perfil médio, compra entre 61 e 180 dias',
|
||||||
about_to_sleep: 'Baixo valor começando a esfriar',
|
about_to_sleep: 'Perfil baixo, compra entre 61 e 180 dias',
|
||||||
at_risk: 'Valiosos sem compra recente',
|
at_risk: 'Histórico forte, sem compra entre 181 e 365 dias',
|
||||||
hibernating: 'Sem compra recente e valor médio',
|
hibernating: 'Sem compra entre 181 e 365 dias',
|
||||||
lost: 'Baixa atividade e distante'
|
lost: 'Sem compra há 366 dias ou mais'
|
||||||
};
|
};
|
||||||
|
|
||||||
const segmentActions: Record<string, string> = {
|
const segmentActions: Record<string, string> = {
|
||||||
@@ -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 pelo RFV histórico até a data final.</p>
|
<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>
|
||||||
</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>
|
||||||
@@ -326,12 +326,12 @@ const Rfm = () => {
|
|||||||
<div className="flex items-center justify-center rounded-xl border border-dark-border bg-dark-input/60 px-3 py-2.5 text-center">
|
<div className="flex items-center justify-center rounded-xl border border-dark-border bg-dark-input/60 px-3 py-2.5 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Eixos</p>
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Eixos</p>
|
||||||
<p className="text-xs font-semibold text-dark-text">Recência / Valor</p>
|
<p className="text-xs font-semibold text-dark-text">Recência / Perfil</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{[1, 2, 3].map(valueScore => (
|
{[1, 2, 3].map(valueScore => (
|
||||||
<div key={valueScore} className="rounded-xl border border-dark-border bg-dark-input/70 px-3 py-2.5 text-center">
|
<div key={valueScore} className="rounded-xl border border-dark-border bg-dark-input/70 px-3 py-2.5 text-center">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Valor {valueScore}</p>
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Perfil {valueScore}</p>
|
||||||
<p className="text-xs font-bold text-dark-text">{scoreLabel(valueScore)}</p>
|
<p className="text-xs font-bold text-dark-text">{scoreLabel(valueScore)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -343,7 +343,7 @@ const Rfm = () => {
|
|||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Rec. {recencyScore}</p>
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Rec. {recencyScore}</p>
|
||||||
<p className="text-xs font-bold text-dark-text">{scoreLabel(recencyScore)}</p>
|
<p className="text-xs font-bold text-dark-text">{scoreLabel(recencyScore)}</p>
|
||||||
<p className="mt-1 text-[10px] font-semibold text-dark-muted">
|
<p className="mt-1 text-[10px] font-semibold text-dark-muted">
|
||||||
{recencyScore === 3 ? 'Compra recente' : recencyScore === 2 ? 'Intermediário' : 'Distante'}
|
{recencyScore === 3 ? '0 a 60 dias' : recencyScore === 2 ? '61 a 180 dias' : '181+ dias'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +371,7 @@ const Rfm = () => {
|
|||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
<span className={`h-2 w-8 rounded-full ${style.accent}`} />
|
<span className={`h-2 w-8 rounded-full ${style.accent}`} />
|
||||||
<span className="rounded-md border border-dark-border bg-black/10 px-1.5 py-0.5 text-[10px] font-bold text-dark-muted">
|
<span className="rounded-md border border-dark-border bg-black/10 px-1.5 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||||
R{recencyScore} V{valueScore}
|
R{recencyScore} P{valueScore}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold text-dark-text">{segment?.label}</p>
|
<p className="text-sm font-bold text-dark-text">{segment?.label}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user