diff --git a/backend/db.js b/backend/db.js index 3d7d62b..7f07290 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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 != ''; diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index a5b83ef..6627420 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -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 }; diff --git a/backend/sql/orderDateSql.js b/backend/sql/orderDateSql.js new file mode 100644 index 0000000..a48122b --- /dev/null +++ b/backend/sql/orderDateSql.js @@ -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 +}; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index ce63580..ddec12a 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -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); diff --git a/src/dataService.ts b/src/dataService.ts index ff5d093..1903508 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -109,46 +109,6 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise = { - 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 = { - '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 => { const params = new URLSearchParams({ start: formatDateParam(dateRange.start), @@ -159,78 +119,6 @@ const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise => { - 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 => { try { const params = new URLSearchParams({ @@ -253,11 +141,11 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise {

Clientes no Período

{clients.length}

-

Agrupados pela tag RFV anterior

+

Segmento RFV calculado até o fim do período

Receita no Período

@@ -312,7 +312,7 @@ const Rfm = () => {

Matriz RFV

-

Compradores do período agrupados pela tag RFV anterior ao período.

+

Compradores do período agrupados pelo RFV histórico até a data final.

Menor prioridade