Fix analytics date fallback and RFV scoring
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s

This commit is contained in:
Cauê Faleiros
2026-06-17 12:04:58 -03:00
parent f88ae2d491
commit aedfedca76
6 changed files with 131 additions and 150 deletions

View File

@@ -1,5 +1,6 @@
const { Pool } = require('pg');
const { DATABASE_URL } = require('./config');
const { ORDER_DATE_SQL } = require('./sql/orderDateSql');
const pool = new Pool({
connectionString: DATABASE_URL
@@ -36,11 +37,7 @@ const initDB = async () => {
await pool.query(`
UPDATE orders
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
SET data_pedido_date = ${ORDER_DATE_SQL}
WHERE data_pedido_date IS NULL
AND data_pedido IS NOT NULL
AND data_pedido != '';

View File

@@ -1,4 +1,5 @@
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 PRODUCT_NAME_SQL = `
@@ -34,18 +35,18 @@ const normalizeDateParam = (value) => {
const buildDateFilter = ({ start, end } = {}) => {
const params = [];
const filters = ['data_pedido_date IS NOT NULL'];
const filters = [`${ORDER_DATE_SQL} IS NOT NULL`];
const normalizedStart = normalizeDateParam(start);
const normalizedEnd = normalizeDateParam(end);
if (normalizedStart) {
params.push(normalizedStart);
filters.push(`data_pedido_date >= $${params.length}::date`);
filters.push(`${ORDER_DATE_SQL} >= $${params.length}::date`);
}
if (normalizedEnd) {
params.push(normalizedEnd);
filters.push(`data_pedido_date <= $${params.length}::date`);
filters.push(`${ORDER_DATE_SQL} <= $${params.length}::date`);
}
return {
@@ -212,8 +213,8 @@ const getProductAnalytics = async (range = {}) => {
COALESCE(SUM(quantidade), 0) as quantity_sold,
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
COUNT(*)::int as order_line_count,
MIN(data_pedido_date) as first_sale_date,
MAX(data_pedido_date) as last_sale_date
MIN(${ORDER_DATE_SQL}) as first_sale_date,
MAX(${ORDER_DATE_SQL}) as last_sale_date
FROM orders
${whereClause}
GROUP BY name
@@ -242,7 +243,7 @@ const getClientAnalytics = async (range = {}) => {
COALESCE(SUM(quantidade), 0) as quantity_purchased,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count,
MAX(data_pedido_date) as last_purchase_date
MAX(${ORDER_DATE_SQL}) as last_purchase_date
FROM orders
${whereClause}
GROUP BY customer_key
@@ -277,8 +278,8 @@ const getRfmAnalytics = async (range = {}) => {
COALESCE(SUM(quantidade * valor_unitario), 0) as monetary,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency,
COALESCE(SUM(quantidade), 0) as quantity_purchased,
MAX(data_pedido_date) as last_purchase_date,
GREATEST((${periodRecencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days
MAX(${ORDER_DATE_SQL}) as last_purchase_date,
GREATEST((${periodRecencyReferenceDate} - MAX(${ORDER_DATE_SQL}))::int, 0) as recency_days
FROM orders
${whereClause}
GROUP BY customer_key
@@ -293,11 +294,11 @@ const getRfmAnalytics = async (range = {}) => {
COALESCE(SUM(quantidade * valor_unitario), 0) as monetary,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency,
COALESCE(SUM(quantidade), 0) as quantity_purchased,
MAX(data_pedido_date) as last_purchase_date,
GREATEST((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days
MAX(${ORDER_DATE_SQL}) as last_purchase_date,
GREATEST((${recencyReferenceDate} - MAX(${ORDER_DATE_SQL}))::int, 0) as recency_days
FROM orders
WHERE data_pedido_date IS NOT NULL
AND data_pedido_date <= ${recencyReferenceDate}
WHERE ${ORDER_DATE_SQL} IS NOT NULL
AND ${ORDER_DATE_SQL} <= ${recencyReferenceDate}
GROUP BY customer_key;
`, historyParams);
@@ -383,5 +384,6 @@ module.exports = {
getDashboardAnalytics,
getProductAnalytics,
normalizeDateParam,
ORDER_DATE_SQL,
scoreTertile
};

View File

@@ -0,0 +1,44 @@
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,10 +9,26 @@ const {
getRfmAnalytics,
getRfmSegment,
normalizeDateParam,
ORDER_DATE_SQL,
scoreTertile
} = require('../services/analyticsService');
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', () => {
assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28');
});
@@ -28,8 +44,8 @@ test('buildDateFilter builds bounded date predicates', () => {
assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
compactSql(filter.whereClause),
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' }))
);
});
@@ -38,8 +54,8 @@ test('buildDateFilter builds Hoje as an inclusive single-day date predicate', ()
assert.deepEqual(filter.params, ['2026-06-15', '2026-06-15']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
compactSql(filter.whereClause),
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' }))
);
});
@@ -48,8 +64,8 @@ test('buildDateFilter builds Ontem as an inclusive single-day date predicate', (
assert.deepEqual(filter.params, ['2026-06-14', '2026-06-14']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
compactSql(filter.whereClause),
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' }))
);
});
@@ -58,8 +74,8 @@ test('buildDateFilter builds Ultimos 7 dias as an inclusive calendar range', ()
assert.deepEqual(filter.params, ['2026-06-09', '2026-06-15']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
compactSql(filter.whereClause),
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' }))
);
});
@@ -68,8 +84,8 @@ test('buildDateFilter builds custom single-day ranges inclusively', () => {
assert.deepEqual(filter.params, ['2026-06-10', '2026-06-10']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
compactSql(filter.whereClause),
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' }))
);
});
@@ -78,11 +94,20 @@ test('buildDateFilter ignores invalid bounds', () => {
assert.deepEqual(filter.params, ['2026-05-28']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
compactSql(filter.whereClause),
compactSql(expectedDateFilter({ endPlaceholder: '$1' }))
);
});
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', () => {
assert.equal(getPreviousDate('2026-06-15'), '2026-06-14');
assert.equal(getPreviousDate('2026-03-01'), '2026-02-28');
@@ -197,7 +222,7 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
pool.query = async (sql, params) => {
calls.push({ sql, params });
const isHistoryQuery = sql.includes('AND data_pedido_date <= $1::date');
const isHistoryQuery = params.length === 1;
const referenceDate = params[0];
if (isHistoryQuery) {
@@ -232,6 +257,16 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
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
}
]
};
@@ -256,6 +291,15 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
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'
}
]
};
@@ -266,8 +310,8 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' });
assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15']);
assert.match(calls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/);
assert.match(calls[1].sql, /data_pedido_date <= \$1::date/);
assert.match(compactSql(calls[1].sql), /\(\$1::date - MAX\( COALESCE\( data_pedido_date,/);
assert.match(compactSql(calls[1].sql), /COALESCE\( data_pedido_date,.* <= \$1::date/);
assert.deepEqual(calls[1].params, ['2026-06-15']);
assert.deepEqual(calls[2].params, ['2026-06-14', '2026-06-14']);
assert.deepEqual(calls[3].params, ['2026-06-14']);
@@ -275,9 +319,15 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
assert.equal(yesterday.clients[0].segmentKey, 'champions');
assert.equal(yesterday.clients[0].frequency, 1);
assert.equal(yesterday.clients[0].monetary, 100);
assert.equal(yesterday.clients.length, 2);
assert.equal(yesterday.clients.length, 3);
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.customerKey === 'name:Cliente Sem Fone' &&
client.phone === '' &&
client.segmentKey === 'champions' &&
client.monetary === 30
)));
} finally {
pool.query = originalQuery;
}
@@ -319,7 +369,7 @@ test('getRfmAnalytics reuses period rows as RFV history for all-period ranges',
const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' });
assert.equal(calls.length, 1);
assert.match(calls[0].sql, /\(\$2::date - MAX\(data_pedido_date\)\)::int/);
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);