Files
graphs/backend/services/analyticsService.js
Cauê Faleiros 3cd4bfc426
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Optimize products list analytics loading
2026-06-22 15:30:54 -03:00

844 lines
31 KiB
JavaScript

const crypto = require('node:crypto');
const { pool } = require('../db');
const RFM_QUERY_TIMEOUT_MS = 15000;
const RECENT_MAX_DAYS = 60;
const COOLING_MAX_DAYS = 180;
const LOST_MIN_DAYS = 366;
const FREQUENCY_MEDIUM_MAX_ORDERS = 4;
const CLIENT_TOKEN_VERSION = 'v1';
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 = `
CASE
WHEN COALESCE(produto_descricao, 'Unknown') ILIKE 'ETIQUETA%' THEN COALESCE(produto_descricao, 'Unknown')
ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '')
END
`;
const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))";
const getClientTokenSecret = () => (
process.env.CLIENT_TOKEN_SECRET ||
process.env.JWT_SECRET ||
process.env.API_KEY ||
'nexstar-client-token-development-secret'
);
let clientTokenSecretCache = null;
const getClientTokenHashSecret = () => {
const secret = getClientTokenSecret();
if (clientTokenSecretCache?.secret === secret) return clientTokenSecretCache.hashSecret;
clientTokenSecretCache = {
secret,
hashSecret: crypto.createHash('sha256').update(`${secret}:client-token`).digest('base64url')
};
return clientTokenSecretCache.hashSecret;
};
const createClientToken = (customerKey) => {
if (!customerKey) return '';
const normalizedCustomerKey = String(customerKey);
const digest = crypto
.createHash('sha256')
.update(getClientTokenHashSecret())
.update(':')
.update(normalizedCustomerKey)
.digest('base64url');
return `${CLIENT_TOKEN_VERSION}.${digest}`;
};
const isClientToken = (clientToken) => {
const parts = String(clientToken || '').split('.');
return parts.length === 2 && parts[0] === CLIENT_TOKEN_VERSION && /^[A-Za-z0-9_-]+$/.test(parts[1] || '');
};
const persistClientTokenMappings = async (clients, queryable = pool) => {
const mappingsByCustomerKey = new Map();
clients.forEach(client => {
const customerKey = client.customerKey || client.customer_key;
const clientToken = client.clientToken || createClientToken(customerKey);
if (customerKey && clientToken) {
mappingsByCustomerKey.set(customerKey, clientToken);
}
});
const mappings = [...mappingsByCustomerKey.entries()];
const chunkSize = 5000;
for (let index = 0; index < mappings.length; index += chunkSize) {
const chunk = mappings.slice(index, index + chunkSize);
const params = [];
const values = chunk.map(([customerKey, clientToken], chunkIndex) => {
params.push(customerKey, clientToken);
const offset = chunkIndex * 2;
return `($${offset + 1}, $${offset + 2})`;
});
await queryable.query(`
INSERT INTO client_identity_tokens (customer_key, token)
VALUES ${values.join(', ')}
ON CONFLICT (customer_key)
DO UPDATE SET
token = EXCLUDED.token,
updated_at = NOW()
WHERE client_identity_tokens.token IS DISTINCT FROM EXCLUDED.token;
`, params);
}
};
const resolveClientToken = async (clientToken) => {
if (!isClientToken(clientToken)) return null;
const result = await pool.query(`
SELECT customer_key
FROM client_identity_tokens
WHERE token = $1
LIMIT 1;
`, [clientToken]);
return result.rows[0]?.customer_key || null;
};
const normalizeDateParam = (value) => {
if (!value) return null;
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const [, yearValue, monthValue, dayValue] = match;
const year = Number(yearValue);
const month = Number(monthValue);
const day = Number(dayValue);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return `${yearValue}-${monthValue}-${dayValue}`;
};
const buildDateFilter = ({ start, end } = {}) => {
const params = [];
const filters = ['data_pedido_date IS NOT NULL'];
const normalizedStart = normalizeDateParam(start);
const normalizedEnd = normalizeDateParam(end);
if (normalizedStart) {
params.push(normalizedStart);
filters.push(`data_pedido_date >= $${params.length}::date`);
}
if (normalizedEnd) {
params.push(normalizedEnd);
filters.push(`data_pedido_date <= $${params.length}::date`);
}
return {
params,
whereClause: `WHERE ${filters.join(' AND ')}`
};
};
const getPreviousDate = (value) => {
const normalizedDate = normalizeDateParam(value);
if (!normalizedDate) return null;
const date = new Date(`${normalizedDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() - 1);
return date.toISOString().slice(0, 10);
};
const toNumber = (value) => Number(value || 0);
const RFM_SEGMENTS = {
'3-3': { key: 'champions', label: 'Champions' },
'3-2': { key: 'potential_loyalists', label: 'Potenciais Leais' },
'3-1': { key: 'new_customers', label: 'Novos Clientes' },
'2-3': { key: 'loyal_customers', label: 'Clientes Leais' },
'2-2': { key: 'need_attention', label: 'Precisam de Atenção' },
'2-1': { key: 'about_to_sleep', label: 'Quase Dormindo' },
'1-3': { key: 'at_risk', label: 'Em Risco' },
'1-2': { key: 'hibernating', label: 'Hibernando' },
'1-1': { key: 'lost', label: 'Perdidos' }
};
const RFM_SEGMENTS_BY_KEY = new Map(
Object.values(RFM_SEGMENTS).map(segment => [segment.key, segment])
);
const scoreTertile = (value, values, higherIsBetter = true) => {
const numericValues = values.map(toNumber).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 === toNumber(value));
const percentile = index / (sorted.length - 1);
return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1));
};
const buildTertileScorer = (values, higherIsBetter = true) => {
const numericValues = values.map(toNumber).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 scoresByValue = new Map();
sorted.forEach((candidate, index) => {
if (scoresByValue.has(candidate)) return;
const percentile = index / (sorted.length - 1);
scoresByValue.set(candidate, Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)));
});
return (value) => scoresByValue.get(toNumber(value)) || 1;
};
const getRfmSegment = (recencyScore, valueScore) => {
return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1'];
};
const getRecencyScore = (recencyDays) => {
const days = Math.max(0, toNumber(recencyDays));
if (days <= RECENT_MAX_DAYS) return 3;
if (days <= COOLING_MAX_DAYS) return 2;
return 1;
};
const getFrequencyScore = (frequency) => {
const orders = Math.max(0, toNumber(frequency));
if (orders <= 1) return 1;
if (orders <= FREQUENCY_MEDIUM_MAX_ORDERS) return 2;
return 3;
};
const getLifecycleSegment = ({
recencyDays,
historicalFrequency,
frequencyScore,
monetaryScore,
valueScore
}) => {
const days = Math.max(0, toNumber(recencyDays));
if (days >= LOST_MIN_DAYS) {
return { segment: RFM_SEGMENTS_BY_KEY.get('lost'), valueScore: 1 };
}
if (days > COOLING_MAX_DAYS) {
const hasStrongHistory = frequencyScore === 3 || monetaryScore === 3;
return hasStrongHistory
? { segment: RFM_SEGMENTS_BY_KEY.get('at_risk'), valueScore: 3 }
: { segment: RFM_SEGMENTS_BY_KEY.get('hibernating'), valueScore: 2 };
}
if (days <= RECENT_MAX_DAYS && historicalFrequency === 1) {
return { segment: RFM_SEGMENTS_BY_KEY.get('new_customers'), valueScore: 1 };
}
if (days <= RECENT_MAX_DAYS) {
const isChampion = frequencyScore === 3 && monetaryScore === 3;
return isChampion
? { segment: RFM_SEGMENTS_BY_KEY.get('champions'), valueScore: 3 }
: { segment: RFM_SEGMENTS_BY_KEY.get('potential_loyalists'), valueScore: 2 };
}
return {
segment: getRfmSegment(getRecencyScore(days), valueScore),
valueScore
};
};
const getDateDiffDays = (endDate, startDate) => {
const normalizedEnd = normalizeDateParam(endDate);
const normalizedStart = normalizeDateParam(startDate);
if (!normalizedEnd || !normalizedStart) return 0;
const end = new Date(`${normalizedEnd}T00:00:00.000Z`);
const start = new Date(`${normalizedStart}T00:00:00.000Z`);
return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86400000));
};
const getDateOnly = (value) => {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
const match = String(value).match(/^(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : null;
};
const buildRfmSegments = (clients) => {
return Object.values(RFM_SEGMENTS).map(segment => {
const segmentClients = clients.filter(client => client.segmentKey === segment.key);
const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0);
return {
...segment,
count: segmentClients.length,
totalRevenue,
averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0
};
});
};
const buildRfmClients = (baseClients) => {
const monetaryScoreFor = buildTertileScorer(baseClients.map(client => client.rfmMonetary ?? client.monetary), true);
return baseClients.map(client => {
const recencyDays = Math.max(0, toNumber(client.recencyDays));
const historicalFrequency = toNumber(client.rfmFrequency ?? client.frequency);
const historicalMonetary = toNumber(client.rfmMonetary ?? client.monetary);
const recencyScore = getRecencyScore(recencyDays);
const frequencyScore = client.rfmFrequencyScore ?? getFrequencyScore(historicalFrequency);
const monetaryScore = client.rfmMonetaryScore ?? monetaryScoreFor(historicalMonetary);
const baseValueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
const classification = getLifecycleSegment({
recencyDays,
historicalFrequency,
frequencyScore,
monetaryScore,
valueScore: baseValueScore
});
const valueScore = classification.valueScore;
const segment = classification.segment;
return {
...client,
recencyDays,
recencyScore,
frequencyScore,
monetaryScore,
valueScore,
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
segmentKey: segment.key,
segmentLabel: segment.label
};
}).sort((a, b) => {
if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore;
if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore;
return b.monetary - a.monetary;
});
};
const getDashboardAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const [totalsResult, salesResult, revenueResult] = await Promise.all([
pool.query(`
SELECT
COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue,
COALESCE(SUM(quantidade), 0) as total_items,
COUNT(*)::int as order_line_count
FROM orders
${whereClause};
`, params),
pool.query(`
SELECT
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
MAX(produto_id) as id,
COALESCE(SUM(quantidade), 0) as value
FROM orders
${whereClause}
GROUP BY name
ORDER BY value DESC
LIMIT 10;
`, params),
pool.query(`
SELECT
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
MAX(produto_id) as id,
COALESCE(SUM(quantidade * valor_unitario), 0) as value
FROM orders
${whereClause}
GROUP BY name
ORDER BY value DESC
LIMIT 10;
`, params)
]);
const totals = totalsResult.rows[0] || {};
const orderLineCount = toNumber(totals.order_line_count);
const totalRevenue = toNumber(totals.total_revenue);
return {
range: {
start: normalizeDateParam(range.start),
end: normalizeDateParam(range.end)
},
totalRevenue,
totalOrders: toNumber(totals.total_items),
orderLineCount,
averageOrderValue: orderLineCount ? totalRevenue / orderLineCount : 0,
salesByProduct: salesResult.rows.map(row => ({
name: row.name,
id: row.id,
value: toNumber(row.value)
})),
revenueByProduct: revenueResult.rows.map(row => ({
name: row.name,
id: row.id,
value: toNumber(row.value)
}))
};
};
const getProductAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(`
WITH period_sales AS (
SELECT
produto_id as id,
MAX(COALESCE(NULLIF(produto_descricao, ''), 'Unknown')) as order_name,
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,
(ARRAY_AGG(valor_unitario ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as last_price
FROM orders
${whereClause}
GROUP BY produto_id
),
stock_rows AS (
SELECT
produto_id as id,
MAX(NULLIF(nome, '')) as stock_name,
COALESCE(MAX(saldo), 0) as stock
FROM stock
GROUP BY produto_id
)
SELECT
COALESCE(period_sales.id, stock_rows.id) as id,
COALESCE(stock_rows.stock_name, period_sales.order_name, 'Unknown') as name,
COALESCE(period_sales.quantity_sold, 0) as quantity_sold,
COALESCE(period_sales.revenue, 0) as revenue,
COALESCE(period_sales.order_line_count, 0)::int as order_line_count,
period_sales.first_sale_date,
period_sales.last_sale_date,
COALESCE(period_sales.last_price, 0) as last_price,
COALESCE(stock_rows.stock, 0) as stock
FROM period_sales
FULL OUTER JOIN stock_rows ON stock_rows.id = period_sales.id
WHERE COALESCE(period_sales.id, stock_rows.id) IS NOT NULL
ORDER BY quantity_sold DESC, revenue DESC, name ASC;
`, params);
return result.rows.map(row => ({
name: row.name,
id: row.id,
quantitySold: toNumber(row.quantity_sold),
revenue: toNumber(row.revenue),
orderLineCount: toNumber(row.order_line_count),
lastPrice: toNumber(row.last_price),
stock: toNumber(row.stock),
firstSaleDate: row.first_sale_date,
lastSaleDate: row.last_sale_date
}));
};
const getClientAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(`
SELECT
${CUSTOMER_KEY_SQL} as customer_key,
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
MAX(NULLIF(cliente_fone, '')) as phone,
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
FROM orders
${whereClause}
GROUP BY customer_key
ORDER BY total_spent DESC;
`, params);
const clients = result.rows.map(row => ({
customerKey: row.customer_key,
clientToken: createClientToken(row.customer_key),
name: row.name,
phone: row.phone || '',
quantityPurchased: toNumber(row.quantity_purchased),
totalSpent: toNumber(row.total_spent),
orderCount: toNumber(row.order_count),
lastPurchaseDate: row.last_purchase_date
}));
await persistClientTokenMappings(clients);
return clients;
};
const getOrderGroupKey = (row) => (
row.pedido_id ||
`${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}`
);
const getClientDetailsAnalytics = async (clientToken, range = {}) => {
const customerKey = await resolveClientToken(clientToken);
if (!customerKey) return null;
const normalizedStart = normalizeDateParam(range.start);
const normalizedEnd = normalizeDateParam(range.end);
const periodParams = [customerKey];
const periodFilters = [
`${CUSTOMER_KEY_SQL} = $1`,
'data_pedido_date IS NOT NULL'
];
if (normalizedStart) {
periodParams.push(normalizedStart);
periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`);
}
if (normalizedEnd) {
periodParams.push(normalizedEnd);
periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`);
}
const [summaryResult, periodResult] = await Promise.all([
pool.query(`
SELECT
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
MAX(NULLIF(cliente_fone, '')) as phone,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as all_time_order_count
FROM orders
WHERE ${CUSTOMER_KEY_SQL} = $1
AND data_pedido_date IS NOT NULL;
`, [customerKey]),
pool.query(`
SELECT
cliente_nome,
cliente_fone,
data_pedido,
data_pedido_date,
valor_pedido,
produto_id,
produto_descricao,
quantidade,
valor_unitario,
pedido_id,
cliente_nome_fantasia,
id_vendedor,
nome_vendedor,
marketplace,
canal_venda,
numero_ecommerce
FROM orders
WHERE ${periodFilters.join(' AND ')}
ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC;
`, periodParams)
]);
const summary = summaryResult.rows[0] || {};
const allTimeOrderCount = toNumber(summary.all_time_order_count);
if (!allTimeOrderCount) return null;
const groupedOrdersByKey = new Map();
const spentByDate = new Map();
let periodSpent = 0;
let periodItems = 0;
periodResult.rows.forEach(row => {
const itemRevenue = toNumber(row.quantidade) * toNumber(row.valor_unitario);
const dateKey = getDateOnly(row.data_pedido_date) || getDateOnly(row.data_pedido) || '';
const dateLabel = row.data_pedido || dateKey;
const groupKey = getOrderGroupKey(row);
periodSpent += itemRevenue;
periodItems += toNumber(row.quantidade);
if (dateLabel) {
const currentDateSpend = spentByDate.get(dateLabel) || { date: dateLabel, sortDate: dateKey, value: 0 };
currentDateSpend.value += itemRevenue;
spentByDate.set(dateLabel, currentDateSpend);
}
if (!groupedOrdersByKey.has(groupKey)) {
groupedOrdersByKey.set(groupKey, {
date: dateLabel,
sortDate: dateKey,
orderId: row.pedido_id || groupKey,
orderTotal: 0,
items: []
});
}
const group = groupedOrdersByKey.get(groupKey);
group.orderTotal += itemRevenue;
group.items.push({
Nome_Cliente: row.cliente_nome || summary.name || 'Cliente Desconhecido',
Data_Pedido: dateLabel,
Valor_Pedido: toNumber(row.valor_pedido),
ID_Produto: row.produto_id || '',
Descricao_Produto: row.produto_descricao || 'Unknown',
Quantidade: toNumber(row.quantidade),
Valor_Unitario: toNumber(row.valor_unitario),
ID_Pedido: row.pedido_id || '',
Fone_Cliente: row.cliente_fone || '',
cliente_nome_fantasia: row.cliente_nome_fantasia || '',
id_vendedor: row.id_vendedor || '',
nome_vendedor: row.nome_vendedor || '',
marketplace: row.marketplace || '',
canal_venda: row.canal_venda || '',
numero_ecommerce: row.numero_ecommerce || ''
});
});
const groupedOrders = [...groupedOrdersByKey.values()]
.sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate)))
.map(({ sortDate, ...group }) => group);
const chartData = [...spentByDate.values()]
.sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate)))
.map(({ sortDate, ...entry }) => entry);
const periodOrderCount = groupedOrders.length;
return {
range: {
start: normalizedStart,
end: normalizedEnd
},
clientToken,
clientName: summary.name || 'Cliente Desconhecido',
clientPhone: summary.phone || '',
hasClient: true,
allTimeOrderCount,
periodSpent,
periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0,
periodOrderCount,
periodItems,
chartData,
groupedOrders
};
};
const getRfmAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const normalizedStart = normalizeDateParam(range.start);
const normalizedEnd = normalizeDateParam(range.end);
const periodRecencyReferenceDate = normalizedEnd ? `$${params.length}::date` : 'CURRENT_DATE';
const usePeriodAsHistory = !normalizedStart || normalizedStart <= '2000-01-01';
if (usePeriodAsHistory) {
const clientRows = await getClientAnalytics({ end: normalizedEnd });
const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10);
const clients = buildRfmClients(clientRows.map(row => ({
customerKey: row.customerKey,
clientToken: row.clientToken || createClientToken(row.customerKey),
name: row.name,
phone: row.phone || '',
monetary: row.totalSpent,
frequency: row.orderCount,
quantityPurchased: row.quantityPurchased,
lastPurchaseDate: row.lastPurchaseDate,
recencyDays: getDateDiffDays(recencyEnd, getDateOnly(row.lastPurchaseDate))
})));
return {
range: {
start: normalizeDateParam(range.start),
end: normalizeDateParam(range.end)
},
clients,
segments: buildRfmSegments(clients),
matrix: {
recencyScores: [3, 2, 1],
valueScores: [1, 2, 3]
}
};
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`SET LOCAL statement_timeout = '${RFM_QUERY_TIMEOUT_MS}ms'`);
const periodResult = await client.query(`
SELECT
${CUSTOMER_KEY_SQL} as customer_key,
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
MAX(NULLIF(cliente_fone, '')) as phone,
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
FROM orders
${whereClause}
GROUP BY customer_key
ORDER BY monetary DESC;
`, params);
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]
}
};
}
let historyRows = periodResult.rows;
if (!usePeriodAsHistory) {
const periodCustomerKeys = [...new Set(periodResult.rows
.map(row => row.customer_key)
.filter(Boolean))];
const historyParams = [];
const recencyReferenceDate = normalizedEnd
? `$${historyParams.push(normalizedEnd)}::date`
: 'CURRENT_DATE';
const customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`;
const historyResult = await client.query(`
WITH customer_history AS (
SELECT
${CUSTOMER_KEY_SQL} as customer_key,
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
MAX(NULLIF(cliente_fone, '')) as phone,
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
FROM orders
WHERE data_pedido_date IS NOT NULL
AND data_pedido_date <= ${recencyReferenceDate}
GROUP BY customer_key
),
scored_history AS MATERIALIZED (
SELECT
customer_history.*,
CASE
WHEN frequency <= 1 THEN 1
WHEN frequency <= ${FREQUENCY_MEDIUM_MAX_ORDERS} THEN 2
ELSE 3
END as frequency_score,
CASE
WHEN COUNT(*) OVER () = 1 THEN 3
WHEN MIN(monetary) OVER () = MAX(monetary) OVER () THEN 2
ELSE LEAST(3, GREATEST(1, FLOOR(PERCENT_RANK() OVER (ORDER BY monetary) * 3)::int + 1))
END as monetary_score
FROM customer_history
)
SELECT *
FROM scored_history
WHERE customer_key = ANY(${customerKeysParam});
`, historyParams);
historyRows = historyResult.rows;
}
const historyClients = buildRfmClients(historyRows.map(row => ({
customerKey: row.customer_key,
clientToken: createClientToken(row.customer_key),
name: row.name,
phone: row.phone || '',
monetary: toNumber(row.monetary),
frequency: toNumber(row.frequency),
quantityPurchased: toNumber(row.quantity_purchased),
lastPurchaseDate: row.last_purchase_date,
recencyDays: toNumber(row.recency_days),
rfmFrequencyScore: row.frequency_score === undefined ? undefined : toNumber(row.frequency_score),
rfmMonetaryScore: row.monetary_score === undefined ? undefined : toNumber(row.monetary_score)
})));
const tagsByCustomerKey = new Map(historyClients.map(historyClient => [historyClient.customerKey, historyClient]));
const clients = periodResult.rows.map(row => {
const taggedClient = tagsByCustomerKey.get(row.customer_key);
if (!taggedClient) {
const [fallbackClient] = buildRfmClients([{
customerKey: row.customer_key,
clientToken: createClientToken(row.customer_key),
name: row.name,
phone: row.phone || '',
monetary: toNumber(row.monetary),
frequency: toNumber(row.frequency),
quantityPurchased: toNumber(row.quantity_purchased),
lastPurchaseDate: row.last_purchase_date,
recencyDays: toNumber(row.recency_days)
}]);
return fallbackClient;
}
return {
...taggedClient,
customerKey: row.customer_key,
clientToken: taggedClient.clientToken || createClientToken(row.customer_key),
name: row.name,
phone: row.phone || '',
monetary: toNumber(row.monetary),
frequency: toNumber(row.frequency),
quantityPurchased: toNumber(row.quantity_purchased),
lastPurchaseDate: row.last_purchase_date
};
}).sort((a, b) => {
if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore;
if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore;
return b.monetary - a.monetary;
});
await persistClientTokenMappings(clients, client);
await client.query('COMMIT');
return {
range: {
start: normalizeDateParam(range.start),
end: normalizeDateParam(range.end)
},
clients,
segments: buildRfmSegments(clients),
matrix: {
recencyScores: [3, 2, 1],
valueScores: [1, 2, 3]
}
};
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
throw error;
} finally {
client.release();
}
};
module.exports = {
buildDateFilter,
buildRfmClients,
buildRfmSegments,
createClientToken,
isClientToken,
getFrequencyScore,
getClientDetailsAnalytics,
getPreviousDate,
getRecencyScore,
getRfmAnalytics,
getRfmSegment,
getClientAnalytics,
getDashboardAnalytics,
getProductAnalytics,
normalizeDateParam,
scoreTertile
};