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,9 +326,15 @@ 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, customerKey: row.customer_key,
@@ -308,7 +346,7 @@ const getRfmAnalytics = async (range = {}) => {
lastPurchaseDate: row.last_purchase_date, lastPurchaseDate: row.last_purchase_date,
recencyDays: toNumber(row.recency_days) recencyDays: toNumber(row.recency_days)
}))); })));
const tagsByCustomerKey = new Map(historyClients.map(client => [client.customerKey, client])); 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);
@@ -349,6 +387,8 @@ const getRfmAnalytics = async (range = {}) => {
return b.monetary - a.monetary; return b.monetary - a.monetary;
}); });
await client.query('COMMIT');
return { return {
range: { range: {
start: normalizeDateParam(range.start), start: normalizeDateParam(range.start),
@@ -361,6 +401,12 @@ const getRfmAnalytics = async (range = {}) => {
valueScores: [1, 2, 3] valueScores: [1, 2, 3]
} }
}; };
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
throw error;
} finally {
client.release();
}
}; };
module.exports = { module.exports = {

View File

@@ -192,14 +192,19 @@ 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 = {
pool.query = async (sql, params) => { query: async (sql, params = []) => {
calls.push({ sql, params }); calls.push({ sql, params });
const isHistoryQuery = sql.includes('recency_days'); const isHistoryQuery = sql.includes('recency_days');
const isPeriodQuery = sql.includes('FROM orders');
const referenceDate = params[0]; const referenceDate = params[0];
if (!isPeriodQuery) {
return { rows: [] };
}
if (isHistoryQuery) { if (isHistoryQuery) {
return { return {
rows: [ rows: [
@@ -278,20 +283,27 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
} }
] ]
}; };
},
release: () => {}
}; };
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);
} }
}; };