Fix analytics date fallback and RFV scoring
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -36,11 +37,7 @@ const initDB = async () => {
|
|||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
UPDATE orders
|
UPDATE orders
|
||||||
SET data_pedido_date = CASE
|
SET data_pedido_date = ${ORDER_DATE_SQL}
|
||||||
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 != '';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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 = `
|
||||||
@@ -34,18 +35,18 @@ const normalizeDateParam = (value) => {
|
|||||||
|
|
||||||
const buildDateFilter = ({ start, end } = {}) => {
|
const buildDateFilter = ({ start, end } = {}) => {
|
||||||
const params = [];
|
const params = [];
|
||||||
const filters = ['data_pedido_date IS NOT NULL'];
|
const filters = [`${ORDER_DATE_SQL} 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(`data_pedido_date >= $${params.length}::date`);
|
filters.push(`${ORDER_DATE_SQL} >= $${params.length}::date`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedEnd) {
|
if (normalizedEnd) {
|
||||||
params.push(normalizedEnd);
|
params.push(normalizedEnd);
|
||||||
filters.push(`data_pedido_date <= $${params.length}::date`);
|
filters.push(`${ORDER_DATE_SQL} <= $${params.length}::date`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -212,8 +213,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(data_pedido_date) as first_sale_date,
|
MIN(${ORDER_DATE_SQL}) as first_sale_date,
|
||||||
MAX(data_pedido_date) as last_sale_date
|
MAX(${ORDER_DATE_SQL}) as last_sale_date
|
||||||
FROM orders
|
FROM orders
|
||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY name
|
GROUP BY name
|
||||||
@@ -242,7 +243,7 @@ const getClientAnalytics = async (range = {}) => {
|
|||||||
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(data_pedido_date) as last_purchase_date
|
MAX(${ORDER_DATE_SQL}) as last_purchase_date
|
||||||
FROM orders
|
FROM orders
|
||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY customer_key
|
GROUP BY customer_key
|
||||||
@@ -277,8 +278,8 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
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(data_pedido_date) as last_purchase_date,
|
MAX(${ORDER_DATE_SQL}) as last_purchase_date,
|
||||||
GREATEST((${periodRecencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days
|
GREATEST((${periodRecencyReferenceDate} - MAX(${ORDER_DATE_SQL}))::int, 0) as recency_days
|
||||||
FROM orders
|
FROM orders
|
||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY customer_key
|
GROUP BY customer_key
|
||||||
@@ -293,11 +294,11 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
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(data_pedido_date) as last_purchase_date,
|
MAX(${ORDER_DATE_SQL}) as last_purchase_date,
|
||||||
GREATEST((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days
|
GREATEST((${recencyReferenceDate} - MAX(${ORDER_DATE_SQL}))::int, 0) as recency_days
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE data_pedido_date IS NOT NULL
|
WHERE ${ORDER_DATE_SQL} IS NOT NULL
|
||||||
AND data_pedido_date <= ${recencyReferenceDate}
|
AND ${ORDER_DATE_SQL} <= ${recencyReferenceDate}
|
||||||
GROUP BY customer_key;
|
GROUP BY customer_key;
|
||||||
`, historyParams);
|
`, historyParams);
|
||||||
|
|
||||||
@@ -383,5 +384,6 @@ module.exports = {
|
|||||||
getDashboardAnalytics,
|
getDashboardAnalytics,
|
||||||
getProductAnalytics,
|
getProductAnalytics,
|
||||||
normalizeDateParam,
|
normalizeDateParam,
|
||||||
|
ORDER_DATE_SQL,
|
||||||
scoreTertile
|
scoreTertile
|
||||||
};
|
};
|
||||||
|
|||||||
44
backend/sql/orderDateSql.js
Normal file
44
backend/sql/orderDateSql.js
Normal 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
|
||||||
|
};
|
||||||
@@ -9,10 +9,26 @@ 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');
|
||||||
});
|
});
|
||||||
@@ -28,8 +44,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(
|
||||||
filter.whereClause,
|
compactSql(filter.whereClause),
|
||||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
|
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.deepEqual(filter.params, ['2026-06-15', '2026-06-15']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
filter.whereClause,
|
compactSql(filter.whereClause),
|
||||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
|
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.deepEqual(filter.params, ['2026-06-14', '2026-06-14']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
filter.whereClause,
|
compactSql(filter.whereClause),
|
||||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
|
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.deepEqual(filter.params, ['2026-06-09', '2026-06-15']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
filter.whereClause,
|
compactSql(filter.whereClause),
|
||||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
|
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.deepEqual(filter.params, ['2026-06-10', '2026-06-10']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
filter.whereClause,
|
compactSql(filter.whereClause),
|
||||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
|
compactSql(expectedDateFilter({ startPlaceholder: '$1', endPlaceholder: '$2' }))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,11 +94,20 @@ test('buildDateFilter ignores invalid bounds', () => {
|
|||||||
|
|
||||||
assert.deepEqual(filter.params, ['2026-05-28']);
|
assert.deepEqual(filter.params, ['2026-05-28']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
filter.whereClause,
|
compactSql(filter.whereClause),
|
||||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
|
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', () => {
|
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');
|
||||||
@@ -197,7 +222,7 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
|
|||||||
|
|
||||||
pool.query = async (sql, params) => {
|
pool.query = async (sql, params) => {
|
||||||
calls.push({ 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];
|
const referenceDate = params[0];
|
||||||
|
|
||||||
if (isHistoryQuery) {
|
if (isHistoryQuery) {
|
||||||
@@ -232,6 +257,16 @@ 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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -256,6 +291,15 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
|
|||||||
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'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -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' });
|
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(calls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/);
|
assert.match(compactSql(calls[1].sql), /\(\$1::date - MAX\( COALESCE\( data_pedido_date,/);
|
||||||
assert.match(calls[1].sql, /data_pedido_date <= \$1::date/);
|
assert.match(compactSql(calls[1].sql), /COALESCE\( data_pedido_date,.* <= \$1::date/);
|
||||||
assert.deepEqual(calls[1].params, ['2026-06-15']);
|
assert.deepEqual(calls[1].params, ['2026-06-15']);
|
||||||
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-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].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, 2);
|
assert.equal(yesterday.clients.length, 3);
|
||||||
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 {
|
} finally {
|
||||||
pool.query = originalQuery;
|
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' });
|
const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' });
|
||||||
|
|
||||||
assert.equal(calls.length, 1);
|
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.deepEqual(calls[0].params, ['2000-01-01', '2026-06-15']);
|
||||||
assert.equal(result.clients.length, 2);
|
assert.equal(result.clients.length, 2);
|
||||||
assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2);
|
assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2);
|
||||||
|
|||||||
@@ -109,46 +109,6 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rfmSegmentLabels: Record<string, string> = {
|
|
||||||
champions: 'Champions',
|
|
||||||
potential_loyalists: 'Potenciais Leais',
|
|
||||||
new_customers: 'Novos Clientes',
|
|
||||||
loyal_customers: 'Clientes Leais',
|
|
||||||
need_attention: 'Precisam de Atenção',
|
|
||||||
about_to_sleep: 'Quase Dormindo',
|
|
||||||
at_risk: 'Em Risco',
|
|
||||||
hibernating: 'Hibernando',
|
|
||||||
lost: 'Perdidos'
|
|
||||||
};
|
|
||||||
|
|
||||||
const rfmSegmentByScore: Record<string, string> = {
|
|
||||||
'3-3': 'champions',
|
|
||||||
'3-2': 'potential_loyalists',
|
|
||||||
'3-1': 'new_customers',
|
|
||||||
'2-3': 'loyal_customers',
|
|
||||||
'2-2': 'need_attention',
|
|
||||||
'2-1': 'about_to_sleep',
|
|
||||||
'1-3': 'at_risk',
|
|
||||||
'1-2': 'hibernating',
|
|
||||||
'1-1': 'lost'
|
|
||||||
};
|
|
||||||
|
|
||||||
const scoreTertile = (value: number, values: number[], higherIsBetter = true): 1 | 2 | 3 => {
|
|
||||||
const numericValues = values.filter(Number.isFinite);
|
|
||||||
if (!numericValues.length) return 1;
|
|
||||||
if (numericValues.length === 1) return 3;
|
|
||||||
|
|
||||||
const min = Math.min(...numericValues);
|
|
||||||
const max = Math.max(...numericValues);
|
|
||||||
if (min === max) return higherIsBetter ? 2 : 3;
|
|
||||||
|
|
||||||
const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a);
|
|
||||||
const index = sorted.findIndex(candidate => candidate === value);
|
|
||||||
const percentile = index / (sorted.length - 1);
|
|
||||||
|
|
||||||
return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)) as 1 | 2 | 3;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
|
const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
start: formatDateParam(dateRange.start),
|
start: formatDateParam(dateRange.start),
|
||||||
@@ -159,78 +119,6 @@ const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise<Clien
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildFallbackRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
|
||||||
let clientRows: ClientAnalyticsItem[];
|
|
||||||
try {
|
|
||||||
clientRows = await fetchClientAnalyticsForRange(dateRange);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fetch fallback RFM analytics failed', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clientRows.length) return null;
|
|
||||||
|
|
||||||
const rangeEndTime = dateRange.end.getTime();
|
|
||||||
const recencyValues = clientRows.map(client => {
|
|
||||||
const lastPurchaseTime = client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : rangeEndTime;
|
|
||||||
return Math.max(0, Math.floor((rangeEndTime - lastPurchaseTime) / 86400000));
|
|
||||||
});
|
|
||||||
const frequencyValues = clientRows.map(client => client.orderCount);
|
|
||||||
const monetaryValues = clientRows.map(client => client.totalSpent);
|
|
||||||
|
|
||||||
const clients = clientRows.map((client, index) => {
|
|
||||||
const recencyScore = scoreTertile(recencyValues[index], recencyValues, false);
|
|
||||||
const frequencyScore = scoreTertile(client.orderCount, frequencyValues);
|
|
||||||
const monetaryScore = scoreTertile(client.totalSpent, monetaryValues);
|
|
||||||
const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))) as 1 | 2 | 3;
|
|
||||||
const segmentKey = rfmSegmentByScore[`${recencyScore}-${valueScore}`] || 'lost';
|
|
||||||
|
|
||||||
return {
|
|
||||||
customerKey: client.customerKey,
|
|
||||||
name: client.name,
|
|
||||||
phone: client.phone,
|
|
||||||
monetary: client.totalSpent,
|
|
||||||
frequency: client.orderCount,
|
|
||||||
quantityPurchased: client.quantityPurchased,
|
|
||||||
lastPurchaseDate: client.lastPurchaseDate,
|
|
||||||
recencyDays: recencyValues[index],
|
|
||||||
recencyScore,
|
|
||||||
frequencyScore,
|
|
||||||
monetaryScore,
|
|
||||||
valueScore,
|
|
||||||
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
|
||||||
segmentKey,
|
|
||||||
segmentLabel: rfmSegmentLabels[segmentKey] || 'Perdidos'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const segments = Object.entries(rfmSegmentLabels).map(([key, label]) => {
|
|
||||||
const segmentClients = clients.filter(client => client.segmentKey === key);
|
|
||||||
const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
label,
|
|
||||||
count: segmentClients.length,
|
|
||||||
totalRevenue,
|
|
||||||
averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
range: {
|
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
},
|
|
||||||
clients,
|
|
||||||
segments,
|
|
||||||
matrix: {
|
|
||||||
recencyScores: [3, 2, 1],
|
|
||||||
valueScores: [1, 2, 3]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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({
|
||||||
@@ -253,11 +141,11 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalyt
|
|||||||
end: formatDateParam(dateRange.end)
|
end: formatDateParam(dateRange.end)
|
||||||
});
|
});
|
||||||
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
|
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
|
||||||
if (!response.ok) return await buildFallbackRfmAnalytics(dateRange);
|
if (!response.ok) return null;
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch RFM analytics failed', error);
|
console.error('Fetch RFM analytics failed', error);
|
||||||
return await buildFallbackRfmAnalytics(dateRange);
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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">Agrupados pela tag RFV anterior</p>
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Segmento RFV calculado até o fim do período</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 pela tag RFV anterior ao período.</p>
|
<p className="text-sm font-medium text-dark-muted">Compradores do período agrupados pelo RFV histórico até a data final.</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user