Rollback analytics to ed1f129b07
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s

This commit is contained in:
Cauê Faleiros
2026-06-17 12:13:48 -03:00
parent aedfedca76
commit 10c69457ba
8 changed files with 78 additions and 239 deletions

View File

@@ -1,6 +1,5 @@
const { Pool } = require('pg'); const { Pool } = require('pg');
const { DATABASE_URL } = require('./config'); const { DATABASE_URL } = require('./config');
const { ORDER_DATE_SQL } = require('./sql/orderDateSql');
const pool = new Pool({ const pool = new Pool({
connectionString: DATABASE_URL connectionString: DATABASE_URL
@@ -37,7 +36,11 @@ const initDB = async () => {
await pool.query(` await pool.query(`
UPDATE orders UPDATE orders
SET data_pedido_date = ${ORDER_DATE_SQL} SET data_pedido_date = CASE
WHEN data_pedido ~ '^\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}' THEN to_date(replace(left(data_pedido, 10), '/', '-'), 'YYYY-MM-DD')
WHEN data_pedido ~ '^\\d{1,2}[-/]\\d{1,2}[-/]\\d{4}' THEN to_date(replace(left(data_pedido, 10), '/', '-'), 'DD-MM-YYYY')
ELSE NULL
END
WHERE data_pedido_date IS NULL WHERE data_pedido_date IS NULL
AND data_pedido IS NOT NULL AND data_pedido IS NOT NULL
AND data_pedido != ''; AND data_pedido != '';

View File

@@ -1,5 +1,4 @@
const { pool } = require('../db'); const { pool } = require('../db');
const { ORDER_DATE_SQL } = require('../sql/orderDateSql');
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 = `
@@ -8,7 +7,6 @@ const PRODUCT_NAME_SQL = `
ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '') ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '')
END END
`; `;
const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))";
const normalizeDateParam = (value) => { const normalizeDateParam = (value) => {
if (!value) return null; if (!value) return null;
@@ -35,18 +33,18 @@ const normalizeDateParam = (value) => {
const buildDateFilter = ({ start, end } = {}) => { const buildDateFilter = ({ start, end } = {}) => {
const params = []; const params = [];
const filters = [`${ORDER_DATE_SQL} IS NOT NULL`]; const filters = ['data_pedido_date IS NOT NULL'];
const normalizedStart = normalizeDateParam(start); const normalizedStart = normalizeDateParam(start);
const normalizedEnd = normalizeDateParam(end); const normalizedEnd = normalizeDateParam(end);
if (normalizedStart) { if (normalizedStart) {
params.push(normalizedStart); params.push(normalizedStart);
filters.push(`${ORDER_DATE_SQL} >= $${params.length}::date`); filters.push(`data_pedido_date >= $${params.length}::date`);
} }
if (normalizedEnd) { if (normalizedEnd) {
params.push(normalizedEnd); params.push(normalizedEnd);
filters.push(`${ORDER_DATE_SQL} <= $${params.length}::date`); filters.push(`data_pedido_date <= $${params.length}::date`);
} }
return { return {
@@ -213,8 +211,8 @@ const getProductAnalytics = async (range = {}) => {
COALESCE(SUM(quantidade), 0) as quantity_sold, COALESCE(SUM(quantidade), 0) as quantity_sold,
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
COUNT(*)::int as order_line_count, COUNT(*)::int as order_line_count,
MIN(${ORDER_DATE_SQL}) as first_sale_date, MIN(data_pedido_date) as first_sale_date,
MAX(${ORDER_DATE_SQL}) as last_sale_date MAX(data_pedido_date) as last_sale_date
FROM orders FROM orders
${whereClause} ${whereClause}
GROUP BY name GROUP BY name
@@ -237,21 +235,19 @@ const getClientAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range); const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(` const result = await pool.query(`
SELECT SELECT
${CUSTOMER_KEY_SQL} as customer_key, COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') as name,
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
MAX(NULLIF(cliente_fone, '')) as phone, MAX(NULLIF(cliente_fone, '')) as phone,
COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade), 0) as quantity_purchased,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent, COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count,
MAX(${ORDER_DATE_SQL}) as last_purchase_date MAX(data_pedido_date) as last_purchase_date
FROM orders FROM orders
${whereClause} ${whereClause}
GROUP BY customer_key GROUP BY COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')
ORDER BY total_spent DESC; ORDER BY total_spent DESC;
`, params); `, params);
return result.rows.map(row => ({ return result.rows.map(row => ({
customerKey: row.customer_key,
name: row.name, name: row.name,
phone: row.phone || '', phone: row.phone || '',
quantityPurchased: toNumber(row.quantity_purchased), quantityPurchased: toNumber(row.quantity_purchased),
@@ -265,69 +261,62 @@ const getRfmAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range); const { params, whereClause } = buildDateFilter(range);
const normalizedStart = normalizeDateParam(range.start); const normalizedStart = normalizeDateParam(range.start);
const normalizedEnd = normalizeDateParam(range.end); const normalizedEnd = normalizeDateParam(range.end);
const periodRecencyReferenceDate = normalizedEnd ? `$${params.length}::date` : 'CURRENT_DATE'; const tagReference = getPreviousDate(normalizedStart) || normalizedEnd;
const recencyReferenceDate = normalizedEnd ? '$1::date' : 'CURRENT_DATE'; const recencyReferenceDate = tagReference ? '$1::date' : 'CURRENT_DATE';
const historyParams = normalizedEnd ? [normalizedEnd] : []; const historyParams = tagReference ? [tagReference] : [];
const usePeriodAsHistory = !normalizedStart || normalizedStart <= '2000-01-01';
const periodQuery = pool.query(` const [periodResult, historyResult] = await Promise.all([
pool.query(`
SELECT SELECT
${CUSTOMER_KEY_SQL} as customer_key,
MAX(cliente_nome) as name, MAX(cliente_nome) as name,
MAX(NULLIF(cliente_fone, '')) as phone, cliente_fone as phone,
COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COALESCE(SUM(quantidade * valor_unitario), 0) as monetary,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency,
COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade), 0) as quantity_purchased,
MAX(${ORDER_DATE_SQL}) as last_purchase_date, MAX(data_pedido_date) as last_purchase_date
GREATEST((${periodRecencyReferenceDate} - MAX(${ORDER_DATE_SQL}))::int, 0) as recency_days
FROM orders FROM orders
${whereClause} ${whereClause}
GROUP BY customer_key AND cliente_fone IS NOT NULL
AND cliente_fone != ''
GROUP BY cliente_fone
ORDER BY monetary DESC; ORDER BY monetary DESC;
`, params); `, params),
pool.query(`
const historyQuery = usePeriodAsHistory ? null : pool.query(`
SELECT SELECT
${CUSTOMER_KEY_SQL} as customer_key,
MAX(cliente_nome) as name, MAX(cliente_nome) as name,
MAX(NULLIF(cliente_fone, '')) as phone, cliente_fone as phone,
COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COALESCE(SUM(quantidade * valor_unitario), 0) as monetary,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency,
COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade), 0) as quantity_purchased,
MAX(${ORDER_DATE_SQL}) as last_purchase_date, MAX(data_pedido_date) as last_purchase_date,
GREATEST((${recencyReferenceDate} - MAX(${ORDER_DATE_SQL}))::int, 0) as recency_days GREATEST((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days
FROM orders FROM orders
WHERE ${ORDER_DATE_SQL} IS NOT NULL WHERE data_pedido_date IS NOT NULL
AND ${ORDER_DATE_SQL} <= ${recencyReferenceDate} AND data_pedido_date <= ${recencyReferenceDate}
GROUP BY customer_key; AND cliente_fone IS NOT NULL
`, historyParams); AND cliente_fone != ''
GROUP BY cliente_fone;
`, historyParams)
]);
const [periodResult, historyResult] = historyQuery const historyClients = buildRfmClients(historyResult.rows.map(row => ({
? await Promise.all([periodQuery, historyQuery])
: [await periodQuery, null];
const historyRows = historyResult ? historyResult.rows : periodResult.rows;
const historyClients = buildRfmClients(historyRows.map(row => ({
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) recencyDays: toNumber(row.recency_days)
}))); })));
const tagsByCustomerKey = new Map(historyClients.map(client => [client.customerKey, client])); const tagsByPhone = new Map(historyClients.map(client => [client.phone, client]));
const clients = periodResult.rows.map(row => { const clients = periodResult.rows.map(row => {
const taggedClient = tagsByCustomerKey.get(row.customer_key); const taggedClient = tagsByPhone.get(row.phone);
if (!taggedClient) { if (!taggedClient) {
const newCustomerSegment = getRfmSegment(3, 1); const newCustomerSegment = getRfmSegment(3, 1);
return { return {
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),
@@ -345,9 +334,8 @@ const getRfmAnalytics = async (range = {}) => {
return { return {
...taggedClient, ...taggedClient,
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),
@@ -384,6 +372,5 @@ module.exports = {
getDashboardAnalytics, getDashboardAnalytics,
getProductAnalytics, getProductAnalytics,
normalizeDateParam, normalizeDateParam,
ORDER_DATE_SQL,
scoreTertile scoreTertile
}; };

View File

@@ -1,44 +0,0 @@
const ISO_ORDER_YEAR_SQL = "substring(data_pedido from '^(\\d{4})[-/]')::int";
const ISO_ORDER_MONTH_SQL = "substring(data_pedido from '^\\d{4}[-/](\\d{1,2})[-/]')::int";
const ISO_ORDER_DAY_SQL = "substring(data_pedido from '^\\d{4}[-/]\\d{1,2}[-/](\\d{1,2})')::int";
const BR_ORDER_DAY_SQL = "substring(data_pedido from '^(\\d{1,2})[-/]')::int";
const BR_ORDER_MONTH_SQL = "substring(data_pedido from '^\\d{1,2}[-/](\\d{1,2})[-/]')::int";
const BR_ORDER_YEAR_SQL = "substring(data_pedido from '^\\d{1,2}[-/]\\d{1,2}[-/](\\d{4})')::int";
const buildSafeDateSql = ({ yearSql, monthSql, daySql }) => {
const candidateDateSql = `(make_date(${yearSql}, ${monthSql}, 1) + ((${daySql} - 1) * INTERVAL '1 day'))::date`;
return `
CASE
WHEN ${yearSql} BETWEEN 1 AND 9999
AND ${monthSql} BETWEEN 1 AND 12
AND ${daySql} BETWEEN 1 AND 31
AND EXTRACT(MONTH FROM ${candidateDateSql})::int = ${monthSql}
THEN ${candidateDateSql}
ELSE NULL
END
`;
};
const ORDER_DATE_SQL = `
COALESCE(
data_pedido_date,
CASE
WHEN data_pedido ~ '^\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}' THEN ${buildSafeDateSql({
yearSql: ISO_ORDER_YEAR_SQL,
monthSql: ISO_ORDER_MONTH_SQL,
daySql: ISO_ORDER_DAY_SQL
})}
WHEN data_pedido ~ '^\\d{1,2}[-/]\\d{1,2}[-/]\\d{4}' THEN ${buildSafeDateSql({
yearSql: BR_ORDER_YEAR_SQL,
monthSql: BR_ORDER_MONTH_SQL,
daySql: BR_ORDER_DAY_SQL
})}
ELSE NULL
END
)
`;
module.exports = {
ORDER_DATE_SQL
};

View File

@@ -9,26 +9,10 @@ const {
getRfmAnalytics, getRfmAnalytics,
getRfmSegment, getRfmSegment,
normalizeDateParam, normalizeDateParam,
ORDER_DATE_SQL,
scoreTertile scoreTertile
} = require('../services/analyticsService'); } = require('../services/analyticsService');
const { pool } = require('../db'); const { pool } = require('../db');
const compactSql = (sql) => sql.replace(/\s+/g, ' ').trim();
const expectedDateFilter = ({ startPlaceholder, endPlaceholder } = {}) => {
const filters = [`${ORDER_DATE_SQL} IS NOT NULL`];
if (startPlaceholder) {
filters.push(`${ORDER_DATE_SQL} >= ${startPlaceholder}::date`);
}
if (endPlaceholder) {
filters.push(`${ORDER_DATE_SQL} <= ${endPlaceholder}::date`);
}
return `WHERE ${filters.join(' AND ')}`;
};
test('normalizeDateParam accepts strict ISO dates', () => { test('normalizeDateParam accepts strict ISO dates', () => {
assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28'); assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28');
}); });
@@ -44,8 +28,8 @@ test('buildDateFilter builds bounded date predicates', () => {
assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28']); assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28']);
assert.equal( assert.equal(
compactSql(filter.whereClause), filter.whereClause,
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' })) 'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
); );
}); });
@@ -54,8 +38,8 @@ test('buildDateFilter builds Hoje as an inclusive single-day date predicate', ()
assert.deepEqual(filter.params, ['2026-06-15', '2026-06-15']); assert.deepEqual(filter.params, ['2026-06-15', '2026-06-15']);
assert.equal( assert.equal(
compactSql(filter.whereClause), filter.whereClause,
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' })) 'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
); );
}); });
@@ -64,8 +48,8 @@ test('buildDateFilter builds Ontem as an inclusive single-day date predicate', (
assert.deepEqual(filter.params, ['2026-06-14', '2026-06-14']); assert.deepEqual(filter.params, ['2026-06-14', '2026-06-14']);
assert.equal( assert.equal(
compactSql(filter.whereClause), filter.whereClause,
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' })) 'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
); );
}); });
@@ -74,8 +58,8 @@ test('buildDateFilter builds Ultimos 7 dias as an inclusive calendar range', ()
assert.deepEqual(filter.params, ['2026-06-09', '2026-06-15']); assert.deepEqual(filter.params, ['2026-06-09', '2026-06-15']);
assert.equal( assert.equal(
compactSql(filter.whereClause), filter.whereClause,
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' })) 'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
); );
}); });
@@ -84,8 +68,8 @@ test('buildDateFilter builds custom single-day ranges inclusively', () => {
assert.deepEqual(filter.params, ['2026-06-10', '2026-06-10']); assert.deepEqual(filter.params, ['2026-06-10', '2026-06-10']);
assert.equal( assert.equal(
compactSql(filter.whereClause), filter.whereClause,
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' })) 'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
); );
}); });
@@ -94,20 +78,11 @@ test('buildDateFilter ignores invalid bounds', () => {
assert.deepEqual(filter.params, ['2026-05-28']); assert.deepEqual(filter.params, ['2026-05-28']);
assert.equal( assert.equal(
compactSql(filter.whereClause), filter.whereClause,
compactSql(expectedDateFilter({ endPlaceholder: '$1' })) 'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
); );
}); });
test('buildDateFilter falls back to parsing the stored display date when normalized dates are missing', () => {
const filter = buildDateFilter({ start: '2026-05-01', end: '2026-05-28' });
const whereClause = compactSql(filter.whereClause);
assert.match(whereClause, /COALESCE\( data_pedido_date,/);
assert.match(whereClause, /data_pedido ~ '\^\\d\{4\}/);
assert.match(whereClause, /data_pedido ~ '\^\\d\{1,2\}/);
});
test('getPreviousDate returns the calendar day before an ISO date', () => { test('getPreviousDate returns the calendar day before an ISO date', () => {
assert.equal(getPreviousDate('2026-06-15'), '2026-06-14'); assert.equal(getPreviousDate('2026-06-15'), '2026-06-14');
assert.equal(getPreviousDate('2026-03-01'), '2026-02-28'); assert.equal(getPreviousDate('2026-03-01'), '2026-02-28');
@@ -216,30 +191,28 @@ test('buildRfmClients scores segments from RFM history when period totals are sm
assert.equal(yesterdayBuyer.monetaryScore, 3); assert.equal(yesterdayBuyer.monetaryScore, 3);
}); });
test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => { test('getRfmAnalytics groups period buyers by their RFM tag before the selected period', async () => {
const originalQuery = pool.query; const originalQuery = pool.query;
const calls = []; const calls = [];
pool.query = async (sql, params) => { pool.query = async (sql, params) => {
calls.push({ sql, params }); calls.push({ sql, params });
const isHistoryQuery = params.length === 1; const isHistoryQuery = sql.includes('recency_days');
const referenceDate = params[0]; const endDate = params[0];
if (isHistoryQuery) { if (isHistoryQuery) {
return { return {
rows: [ rows: [
{ {
customer_key: '1',
name: 'Cliente Ontem', name: 'Cliente Ontem',
phone: '1', phone: '1',
monetary: 5000, monetary: 5000,
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: endDate === '2026-06-13' ? 0 : 1
}, },
{ {
customer_key: '2',
name: 'Cliente Antigo', name: 'Cliente Antigo',
phone: '2', phone: '2',
monetary: 50, monetary: 50,
@@ -249,7 +222,6 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
recency_days: 164 recency_days: 164
}, },
{ {
customer_key: '3',
name: 'Cliente Medio', name: 'Cliente Medio',
phone: '3', phone: '3',
monetary: 100, monetary: 100,
@@ -257,16 +229,6 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
quantity_purchased: 2, quantity_purchased: 2,
last_purchase_date: '2026-03-01', last_purchase_date: '2026-03-01',
recency_days: 105 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
} }
] ]
}; };
@@ -275,7 +237,6 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
return { return {
rows: [ rows: [
{ {
customer_key: '1',
name: 'Cliente Ontem', name: 'Cliente Ontem',
phone: '1', phone: '1',
monetary: 100, monetary: 100,
@@ -284,22 +245,12 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
last_purchase_date: '2026-06-14' last_purchase_date: '2026-06-14'
}, },
{ {
customer_key: '4',
name: 'Cliente Novo no Periodo', name: 'Cliente Novo no Periodo',
phone: '4', phone: '4',
monetary: 25, monetary: 25,
frequency: 1, frequency: 1,
quantity_purchased: 1, quantity_purchased: 1,
last_purchase_date: '2026-06-14' 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'
} }
] ]
}; };
@@ -310,69 +261,18 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' }); const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' });
assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15']); assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15']);
assert.match(compactSql(calls[1].sql), /\(\$1::date - MAX\( COALESCE\( data_pedido_date,/); assert.match(calls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/);
assert.match(compactSql(calls[1].sql), /COALESCE\( data_pedido_date,.* <= \$1::date/); assert.match(calls[1].sql, /data_pedido_date <= \$1::date/);
assert.deepEqual(calls[1].params, ['2026-06-15']); assert.deepEqual(calls[1].params, ['2026-06-08']);
assert.deepEqual(calls[2].params, ['2026-06-14', '2026-06-14']); assert.deepEqual(calls[2].params, ['2026-06-14', '2026-06-14']);
assert.deepEqual(calls[3].params, ['2026-06-14']); assert.deepEqual(calls[3].params, ['2026-06-13']);
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);
assert.equal(yesterday.clients[0].monetary, 100); assert.equal(yesterday.clients[0].monetary, 100);
assert.equal(yesterday.clients.length, 3); assert.equal(yesterday.clients.length, 2);
assert.ok(!yesterday.clients.some(client => client.phone === '2')); assert.ok(!yesterday.clients.some(client => client.phone === '2'));
assert.ok(yesterday.clients.some(client => client.phone === '4' && client.segmentKey === 'new_customers')); assert.ok(yesterday.clients.some(client => client.phone === '4' && client.segmentKey === 'new_customers'));
assert.ok(yesterday.clients.some(client => (
client.customerKey === 'name:Cliente Sem Fone' &&
client.phone === '' &&
client.segmentKey === 'champions' &&
client.monetary === 30
)));
} finally {
pool.query = originalQuery;
}
});
test('getRfmAnalytics reuses period rows as RFV history for all-period ranges', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params) => {
calls.push({ sql, params });
return {
rows: [
{
customer_key: '1',
name: 'Cliente Frequente',
phone: '1',
monetary: 5000,
frequency: 20,
quantity_purchased: 20,
last_purchase_date: '2026-06-14',
recency_days: 1
},
{
customer_key: '2',
name: 'Cliente Antigo',
phone: '2',
monetary: 50,
frequency: 1,
quantity_purchased: 1,
last_purchase_date: '2026-01-01',
recency_days: 165
}
]
};
};
try {
const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' });
assert.equal(calls.length, 1);
assert.match(compactSql(calls[0].sql), /\(\$2::date - MAX\( COALESCE\( data_pedido_date,/);
assert.deepEqual(calls[0].params, ['2000-01-01', '2026-06-15']);
assert.equal(result.clients.length, 2);
assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2);
} finally { } finally {
pool.query = originalQuery; pool.query = originalQuery;
} }

View File

@@ -109,16 +109,6 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
return response; return response;
}; };
const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/clients?${params.toString()}`);
if (!response.ok) return [];
return await response.json();
};
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => { export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -151,7 +141,13 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalyt
export const fetchClientAnalytics = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => { export const fetchClientAnalytics = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
try { try {
return await fetchClientAnalyticsForRange(dateRange); const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/clients?${params.toString()}`);
if (!response.ok) return [];
return await response.json();
} catch (error) { } catch (error) {
console.error('Fetch client analytics failed', error); console.error('Fetch client analytics failed', error);
return []; return [];

View File

@@ -7,7 +7,7 @@ import DateRangePicker from '../components/DateRangePicker';
import type { ClientSortOption, ClientSummary } from '../analytics/clients'; import type { ClientSortOption, ClientSummary } from '../analytics/clients';
const clientTypeStyles: Record<string, string> = { const clientTypeStyles: Record<string, string> = {
'Sem análise': 'border-zinc-500/30 bg-zinc-500/15 text-zinc-300', 'Sem análise': 'border-zinc-600/30 bg-zinc-600/15 text-zinc-300',
'Campeão': 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', 'Campeão': 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300',
'Potencial Leal': 'border-sky-500/30 bg-sky-500/15 text-sky-300', 'Potencial Leal': 'border-sky-500/30 bg-sky-500/15 text-sky-300',
'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300', 'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300',
@@ -83,13 +83,12 @@ const Clients = () => {
let isMounted = true; let isMounted = true;
const loadClients = async () => { const loadClients = async () => {
const clientsData = await fetchClientAnalytics(dateRange); const [clientsData, rfmData] = await Promise.all([
fetchClientAnalytics(dateRange),
fetchRfmAnalytics(dateRange)
]);
if (isMounted) { if (isMounted) {
setClientAnalytics(clientsData); setClientAnalytics(clientsData);
}
const rfmData = await fetchRfmAnalytics(dateRange);
if (isMounted) {
setRfmAnalytics(rfmData); setRfmAnalytics(rfmData);
} }
}; };
@@ -103,9 +102,9 @@ const Clients = () => {
const allClientsData = useMemo(() => { const allClientsData = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase(); const normalizedSearch = searchTerm.trim().toLowerCase();
const rfmByCustomerKey = new Map((rfmAnalytics?.clients || []).map(client => [client.customerKey, client])); const rfmByPhone = new Map((rfmAnalytics?.clients || []).map(client => [client.phone, client]));
const clients = clientAnalytics.map((client): ClientSummary => { const clients = clientAnalytics.map((client): ClientSummary => {
const rfmClient = rfmByCustomerKey.get(client.customerKey); const rfmClient = client.phone ? rfmByPhone.get(client.phone) : undefined;
return { return {
name: client.name, name: client.name,
phone: client.phone, phone: client.phone,

View File

@@ -292,7 +292,7 @@ const Rfm = () => {
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm"> <div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<p className="text-dark-muted text-sm font-medium mb-1">Clientes no Período</p> <p className="text-dark-muted text-sm font-medium mb-1">Clientes no Período</p>
<h3 className="text-3xl font-bold text-dark-text">{clients.length}</h3> <h3 className="text-3xl font-bold text-dark-text">{clients.length}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">Segmento RFV calculado até o fim do período</p> <p className="mt-1 text-xs font-semibold text-dark-muted">Agrupados pela tag RFV anterior</p>
</div> </div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm"> <div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<p className="text-dark-muted text-sm font-medium mb-1">Receita no Período</p> <p className="text-dark-muted text-sm font-medium mb-1">Receita no Período</p>
@@ -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 a a data final.</p> <p className="text-sm font-medium text-dark-muted">Compradores do período agrupados pela tag RFV anterior ao período.</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>

View File

@@ -64,7 +64,6 @@ export interface DashboardAnalytics {
} }
export interface ClientAnalyticsItem { export interface ClientAnalyticsItem {
customerKey: string;
name: string; name: string;
phone: string; phone: string;
quantityPurchased: number; quantityPurchased: number;
@@ -74,7 +73,6 @@ export interface ClientAnalyticsItem {
} }
export interface RfmClient { export interface RfmClient {
customerKey: string;
name: string; name: string;
phone: string; phone: string;
monetary: number; monetary: number;