348 lines
12 KiB
JavaScript
348 lines
12 KiB
JavaScript
const { pool } = require('../db');
|
|
|
|
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 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 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 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 2;
|
|
|
|
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 getRfmSegment = (recencyScore, valueScore) => {
|
|
return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1'];
|
|
};
|
|
|
|
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 recencyValues = baseClients.map(client => client.recencyDays);
|
|
const frequencyValues = baseClients.map(client => client.rfmFrequency ?? client.frequency);
|
|
const monetaryValues = baseClients.map(client => client.rfmMonetary ?? client.monetary);
|
|
|
|
return baseClients.map(client => {
|
|
const frequencyForScore = client.rfmFrequency ?? client.frequency;
|
|
const monetaryForScore = client.rfmMonetary ?? client.monetary;
|
|
const recencyScore = scoreTertile(client.recencyDays, recencyValues, false);
|
|
const frequencyScore = scoreTertile(frequencyForScore, frequencyValues, true);
|
|
const monetaryScore = scoreTertile(monetaryForScore, monetaryValues, true);
|
|
const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
|
|
const segment = getRfmSegment(recencyScore, valueScore);
|
|
|
|
return {
|
|
...client,
|
|
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(`
|
|
SELECT
|
|
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
|
|
MAX(produto_id) as id,
|
|
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
|
|
FROM orders
|
|
${whereClause}
|
|
GROUP BY name
|
|
ORDER BY revenue DESC, quantity_sold DESC
|
|
LIMIT 500;
|
|
`, 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),
|
|
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
|
|
MAX(cliente_nome) as name,
|
|
cliente_fone as phone,
|
|
COALESCE(SUM(quantidade), 0) as quantity_purchased,
|
|
COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent,
|
|
COUNT(*)::int as order_line_count,
|
|
MAX(data_pedido_date) as last_purchase_date
|
|
FROM orders
|
|
${whereClause}
|
|
AND cliente_fone IS NOT NULL
|
|
AND cliente_fone != ''
|
|
GROUP BY cliente_fone
|
|
ORDER BY total_spent DESC
|
|
LIMIT 500;
|
|
`, params);
|
|
|
|
return result.rows.map(row => ({
|
|
name: row.name,
|
|
phone: row.phone,
|
|
quantityPurchased: toNumber(row.quantity_purchased),
|
|
totalSpent: toNumber(row.total_spent),
|
|
orderLineCount: toNumber(row.order_line_count),
|
|
lastPurchaseDate: row.last_purchase_date
|
|
}));
|
|
};
|
|
|
|
const getRfmAnalytics = async (range = {}) => {
|
|
const { params, whereClause } = buildDateFilter(range);
|
|
const queryParams = [...params];
|
|
const normalizedEnd = normalizeDateParam(range.end);
|
|
const recencyReferenceDate = normalizedEnd ? `$${queryParams.length + 1}::date` : 'CURRENT_DATE';
|
|
|
|
if (normalizedEnd) {
|
|
queryParams.push(normalizedEnd);
|
|
}
|
|
|
|
const result = await pool.query(`
|
|
WITH period_clients AS (
|
|
SELECT
|
|
MAX(cliente_nome) as name,
|
|
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
|
|
FROM orders
|
|
${whereClause}
|
|
AND cliente_fone IS NOT NULL
|
|
AND cliente_fone != ''
|
|
GROUP BY cliente_fone
|
|
),
|
|
rfm_clients AS (
|
|
SELECT
|
|
cliente_fone as phone,
|
|
COALESCE(SUM(quantidade * valor_unitario), 0) as rfm_monetary,
|
|
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as rfm_frequency,
|
|
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}
|
|
AND cliente_fone IS NOT NULL
|
|
AND cliente_fone != ''
|
|
GROUP BY cliente_fone
|
|
)
|
|
SELECT
|
|
period_clients.name,
|
|
period_clients.phone,
|
|
period_clients.monetary,
|
|
period_clients.frequency,
|
|
period_clients.quantity_purchased,
|
|
period_clients.last_purchase_date,
|
|
rfm_clients.rfm_monetary,
|
|
rfm_clients.rfm_frequency,
|
|
rfm_clients.recency_days
|
|
FROM period_clients
|
|
INNER JOIN rfm_clients ON rfm_clients.phone = period_clients.phone
|
|
ORDER BY monetary DESC
|
|
LIMIT 1000;
|
|
`, queryParams);
|
|
|
|
const baseClients = result.rows.map(row => ({
|
|
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),
|
|
rfmFrequency: toNumber(row.rfm_frequency),
|
|
rfmMonetary: toNumber(row.rfm_monetary)
|
|
}));
|
|
|
|
const clients = buildRfmClients(baseClients);
|
|
|
|
return {
|
|
range: {
|
|
start: normalizeDateParam(range.start),
|
|
end: normalizeDateParam(range.end)
|
|
},
|
|
clients,
|
|
segments: buildRfmSegments(clients),
|
|
matrix: {
|
|
recencyScores: [3, 2, 1],
|
|
valueScores: [1, 2, 3]
|
|
}
|
|
};
|
|
};
|
|
|
|
module.exports = {
|
|
buildDateFilter,
|
|
buildRfmClients,
|
|
buildRfmSegments,
|
|
getRfmAnalytics,
|
|
getRfmSegment,
|
|
getClientAnalytics,
|
|
getDashboardAnalytics,
|
|
getProductAnalytics,
|
|
normalizeDateParam,
|
|
scoreTertile
|
|
};
|