Limit RFV history query to period buyers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s

This commit is contained in:
Cauê Faleiros
2026-06-17 15:26:24 -03:00
parent 3a907b0743
commit 7764933fd9
3 changed files with 202 additions and 142 deletions

View File

@@ -1,5 +1,6 @@
const { pool } = require('../db'); const { pool } = require('../db');
const RFM_QUERY_TIMEOUT_MS = 15000;
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
@@ -263,11 +264,13 @@ const getClientAnalytics = async (range = {}) => {
const getRfmAnalytics = async (range = {}) => { const getRfmAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range); const { params, whereClause } = buildDateFilter(range);
const normalizedEnd = normalizeDateParam(range.end); const normalizedEnd = normalizeDateParam(range.end);
const recencyReferenceDate = normalizedEnd ? '$1::date' : 'CURRENT_DATE'; const client = await pool.connect();
const historyParams = normalizedEnd ? [normalizedEnd] : [];
const [periodResult, historyResult] = await Promise.all([ try {
pool.query(` await client.query('BEGIN');
await client.query(`SET LOCAL statement_timeout = '${RFM_QUERY_TIMEOUT_MS}ms'`);
const periodResult = await client.query(`
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,
@@ -280,8 +283,37 @@ const getRfmAnalytics = async (range = {}) => {
${whereClause} ${whereClause}
GROUP BY customer_key GROUP BY customer_key
ORDER BY monetary DESC; ORDER BY monetary DESC;
`, params), `, params);
pool.query(`
if (!periodResult.rows.length) {
await client.query('COMMIT');
return {
range: {
start: normalizeDateParam(range.start),
end: normalizeDateParam(range.end)
},
clients: [],
segments: buildRfmSegments([]),
matrix: {
recencyScores: [3, 2, 1],
valueScores: [1, 2, 3]
}
};
}
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))
.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 historyResult = await client.query(`
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,
@@ -294,73 +326,87 @@ 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 (
NULLIF(cliente_fone, '') = ANY(${phoneListParam})
OR (
NULLIF(cliente_fone, '') IS NULL
AND COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') = ANY(${nameListParam})
)
)
GROUP BY customer_key; GROUP BY customer_key;
`, historyParams) `, historyParams);
]);
const historyClients = buildRfmClients(historyResult.rows.map(row => ({ const historyClients = buildRfmClients(historyResult.rows.map(row => ({
customerKey: row.customer_key,
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)
})));
const tagsByCustomerKey = new Map(historyClients.map(client => [client.customerKey, client]));
const clients = periodResult.rows.map(row => {
const taggedClient = tagsByCustomerKey.get(row.customer_key);
if (!taggedClient) {
const newCustomerSegment = getRfmSegment(3, 1);
return {
customerKey: row.customer_key,
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: 0,
recencyScore: 3,
frequencyScore: 1,
monetaryScore: 1,
valueScore: 1,
rfmScore: '311',
segmentKey: newCustomerSegment.key,
segmentLabel: newCustomerSegment.label
};
}
return {
...taggedClient,
customerKey: row.customer_key, customerKey: row.customer_key,
name: row.name, name: row.name,
phone: row.phone || '', phone: row.phone || '',
monetary: toNumber(row.monetary), monetary: toNumber(row.monetary),
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)
}).sort((a, b) => { })));
if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore; const tagsByCustomerKey = new Map(historyClients.map(historyClient => [historyClient.customerKey, historyClient]));
if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore;
return b.monetary - a.monetary;
});
return { const clients = periodResult.rows.map(row => {
range: { const taggedClient = tagsByCustomerKey.get(row.customer_key);
start: normalizeDateParam(range.start), if (!taggedClient) {
end: normalizeDateParam(range.end) const newCustomerSegment = getRfmSegment(3, 1);
}, return {
clients, customerKey: row.customer_key,
segments: buildRfmSegments(clients), name: row.name,
matrix: { phone: row.phone || '',
recencyScores: [3, 2, 1], monetary: toNumber(row.monetary),
valueScores: [1, 2, 3] 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
};
}
return {
...taggedClient,
customerKey: row.customer_key,
name: row.name,
phone: row.phone || '',
monetary: toNumber(row.monetary),
frequency: toNumber(row.frequency),
quantityPurchased: toNumber(row.quantity_purchased),
lastPurchaseDate: row.last_purchase_date
};
}).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;
});
await client.query('COMMIT');
return {
range: {
start: normalizeDateParam(range.start),
end: normalizeDateParam(range.end)
},
clients,
segments: buildRfmSegments(clients),
matrix: {
recencyScores: [3, 2, 1],
valueScores: [1, 2, 3]
}
};
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
throw error;
} finally {
client.release();
}
}; };
module.exports = { module.exports = {

View File

@@ -192,106 +192,118 @@ test('buildRfmClients scores segments from RFM history when period totals are sm
}); });
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 originalQuery = pool.query; const originalConnect = pool.connect;
const calls = []; const calls = [];
const mockClient = {
query: async (sql, params = []) => {
calls.push({ sql, params });
const isHistoryQuery = sql.includes('recency_days');
const isPeriodQuery = sql.includes('FROM orders');
const referenceDate = params[0];
pool.query = async (sql, params) => { if (!isPeriodQuery) {
calls.push({ sql, params }); return { rows: [] };
const isHistoryQuery = sql.includes('recency_days'); }
const referenceDate = params[0];
if (isHistoryQuery) {
return {
rows: [
{
customer_key: '1',
name: 'Cliente Ontem',
phone: '1',
monetary: 5000,
frequency: 20,
quantity_purchased: 20,
last_purchase_date: '2026-06-14',
recency_days: referenceDate === '2026-06-14' ? 0 : 1
},
{
customer_key: '2',
name: 'Cliente Antigo',
phone: '2',
monetary: 50,
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
},
{
customer_key: 'name:Cliente Sem Fone',
name: 'Cliente Sem Fone',
phone: null,
monetary: 1000,
frequency: 10,
quantity_purchased: 10,
last_purchase_date: '2026-06-14',
recency_days: 1
}
]
};
}
if (isHistoryQuery) {
return { return {
rows: [ rows: [
{ {
customer_key: '1', customer_key: '1',
name: 'Cliente Ontem', name: 'Cliente Ontem',
phone: '1', phone: '1',
monetary: 5000, monetary: 100,
frequency: 20,
quantity_purchased: 20,
last_purchase_date: '2026-06-14',
recency_days: referenceDate === '2026-06-14' ? 0 : 1
},
{
customer_key: '2',
name: 'Cliente Antigo',
phone: '2',
monetary: 50,
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
}, },
{ {
customer_key: '3', customer_key: '4',
name: 'Cliente Medio', name: 'Cliente Novo no Periodo',
phone: '3', phone: '4',
monetary: 100, monetary: 25,
frequency: 2, frequency: 1,
quantity_purchased: 2, quantity_purchased: 1,
last_purchase_date: '2026-03-01', last_purchase_date: '2026-06-14'
recency_days: 105
}, },
{ {
customer_key: 'name:Cliente Sem Fone', customer_key: 'name:Cliente Sem Fone',
name: 'Cliente Sem Fone', name: 'Cliente Sem Fone',
phone: null, phone: null,
monetary: 1000, monetary: 30,
frequency: 10, frequency: 1,
quantity_purchased: 10, quantity_purchased: 1,
last_purchase_date: '2026-06-14', last_purchase_date: '2026-06-14'
recency_days: 1
} }
] ]
}; };
} },
release: () => {}
return {
rows: [
{
customer_key: '1',
name: 'Cliente Ontem',
phone: '1',
monetary: 100,
frequency: 1,
quantity_purchased: 1,
last_purchase_date: '2026-06-14'
},
{
customer_key: '4',
name: 'Cliente Novo no Periodo',
phone: '4',
monetary: 25,
frequency: 1,
quantity_purchased: 1,
last_purchase_date: '2026-06-14'
},
{
customer_key: 'name:Cliente Sem Fone',
name: 'Cliente Sem Fone',
phone: null,
monetary: 30,
frequency: 1,
quantity_purchased: 1,
last_purchase_date: '2026-06-14'
}
]
};
}; };
pool.connect = async () => mockClient;
try { try {
const sevenDays = await getRfmAnalytics({ start: '2026-06-09', end: '2026-06-15' }); const sevenDays = await getRfmAnalytics({ start: '2026-06-09', end: '2026-06-15' });
const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' }); const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' });
const selectCalls = calls.filter(call => call.sql.includes('FROM orders'));
assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15']); assert.match(calls[1].sql, /SET LOCAL statement_timeout/);
assert.doesNotMatch(calls[0].sql, /cliente_fone IS NOT NULL/); assert.deepEqual(selectCalls[0].params, ['2026-06-09', '2026-06-15']);
assert.match(calls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/); assert.doesNotMatch(selectCalls[0].sql, /cliente_fone IS NOT NULL/);
assert.match(calls[1].sql, /data_pedido_date <= \$1::date/); assert.match(selectCalls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/);
assert.doesNotMatch(calls[1].sql, /cliente_fone IS NOT NULL/); assert.match(selectCalls[1].sql, /data_pedido_date <= \$1::date/);
assert.deepEqual(calls[1].params, ['2026-06-15']); assert.match(selectCalls[1].sql, /NULLIF\(cliente_fone, ''\) = ANY\(\$2::text\[\]\)/);
assert.deepEqual(calls[2].params, ['2026-06-14', '2026-06-14']); assert.match(selectCalls[1].sql, /COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\) = ANY\(\$3::text\[\]\)/);
assert.deepEqual(calls[3].params, ['2026-06-14']); 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[2].params, ['2026-06-14', '2026-06-14']);
assert.deepEqual(selectCalls[3].params, ['2026-06-14', ['1', '4'], ['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);
@@ -306,6 +318,6 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
client.monetary === 30 client.monetary === 30
))); )));
} finally { } finally {
pool.query = originalQuery; pool.connect = originalConnect;
} }
}); });

View File

@@ -83,12 +83,14 @@ const Clients = () => {
let isMounted = true; let isMounted = true;
const loadClients = async () => { const loadClients = async () => {
const [clientsData, rfmData] = await Promise.all([ const clientsData = await fetchClientAnalytics(dateRange);
fetchClientAnalytics(dateRange),
fetchRfmAnalytics(dateRange)
]);
if (isMounted) { if (isMounted) {
setClientAnalytics(clientsData); setClientAnalytics(clientsData);
setRfmAnalytics(null);
}
const rfmData = await fetchRfmAnalytics(dateRange);
if (isMounted) {
setRfmAnalytics(rfmData); setRfmAnalytics(rfmData);
} }
}; };