feat: BI estrategico — cohort retention, revenue expansion, cross-sell, maturity + receita em USD
- Cohort retention heatmap com matriz de retencao por mes de aquisicao - Revenue expansion/contraction waterfall (new/expansion/stable/contraction/churned) - Cross-sell analysis (CambioPay only vs CambioCheckout only vs Both) - Client maturity segmentation (new/growing/mature/declining) - Nova query fetchBIStrategic + endpoint /admin/api/bi/strategic - Todas receitas de spread convertidas de BRL para USD em todas as queries - Labels e formatacao atualizados para USD em admin-bi e admin-cliente Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
14
server.js
14
server.js
@@ -11,7 +11,7 @@ const express = require('express');
|
|||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
|
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
|
||||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchClientList, fetchClientProfile, fetchClientData } = require('./src/queries');
|
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchClientList, fetchClientProfile, fetchClientData } = require('./src/queries');
|
||||||
const { buildHTML } = require('./src/dashboard');
|
const { buildHTML } = require('./src/dashboard');
|
||||||
const { buildAdminHTML } = require('./src/admin-panel');
|
const { buildAdminHTML } = require('./src/admin-panel');
|
||||||
const { buildAdminHomeHTML } = require('./src/admin-home');
|
const { buildAdminHomeHTML } = require('./src/admin-home');
|
||||||
@@ -370,6 +370,18 @@ app.get('/admin/api/bi/revenue', requireRole('admin'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/bi/strategic', requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
||||||
|
const data = await fetchBIStrategic(start, end);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Strategic BI API error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Admin Cliente Dashboard (admin only) ---
|
// --- Admin Cliente Dashboard (admin only) ---
|
||||||
app.get('/admin/cliente', requireRole('admin'), (req, res) => {
|
app.get('/admin/cliente', requireRole('admin'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
1458
src/admin-bi.js
1458
src/admin-bi.js
File diff suppressed because it is too large
Load Diff
1371
src/admin-cliente.js
1371
src/admin-cliente.js
File diff suppressed because it is too large
Load Diff
292
src/queries.js
292
src/queries.js
@@ -524,7 +524,7 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
|||||||
COUNT(*) as qtd,
|
COUNT(*) as qtd,
|
||||||
ROUND(COALESCE(SUM(amount_usd), 0), 2) as vol_usd,
|
ROUND(COALESCE(SUM(amount_usd), 0), 2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM(amount_brl), 0), 2) as vol_brl,
|
ROUND(COALESCE(SUM(amount_brl), 0), 2) as vol_brl,
|
||||||
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd), 0), 2) as spread_revenue,
|
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd), 0), 2) as spread_revenue,
|
||||||
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100), 0), 2) as avg_spread_pct,
|
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100), 0), 2) as avg_spread_pct,
|
||||||
COUNT(DISTINCT id_conta) as clientes
|
COUNT(DISTINCT id_conta) as clientes
|
||||||
FROM br_transaction_to_usa
|
FROM br_transaction_to_usa
|
||||||
@@ -537,7 +537,7 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
|||||||
COUNT(*) as qtd,
|
COUNT(*) as qtd,
|
||||||
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
|
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM(valor_sol), 0), 2) as vol_brl,
|
ROUND(COALESCE(SUM(valor_sol), 0), 2) as vol_brl,
|
||||||
ROUND(COALESCE(SUM((ptax - cotacao) * valor), 0), 2) as spread_revenue,
|
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor), 0), 2) as spread_revenue,
|
||||||
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 0), 2) as avg_spread_pct,
|
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 0), 2) as avg_spread_pct,
|
||||||
COUNT(DISTINCT id_conta) as clientes
|
COUNT(DISTINCT id_conta) as clientes
|
||||||
FROM pagamento_br
|
FROM pagamento_br
|
||||||
@@ -571,12 +571,12 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
|||||||
// 5. Previous period totals for comparison
|
// 5. Previous period totals for comparison
|
||||||
const [prevBrlUsd] = await conn.execute(`
|
const [prevBrlUsd] = await conn.execute(`
|
||||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue
|
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue
|
||||||
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
`, [prevStartStr, prevEndStr]);
|
`, [prevStartStr, prevEndStr]);
|
||||||
const [prevUsdBrl] = await conn.execute(`
|
const [prevUsdBrl] = await conn.execute(`
|
||||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue
|
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue
|
||||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
`, [prevStartStr, prevEndStr]);
|
`, [prevStartStr, prevEndStr]);
|
||||||
@@ -679,14 +679,14 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
|||||||
ROUND(SUM(spread_rev), 2) as total_spread, COUNT(DISTINCT client_id) as clientes
|
ROUND(SUM(spread_rev), 2) as total_spread, COUNT(DISTINCT client_id) as clientes
|
||||||
FROM (
|
FROM (
|
||||||
SELECT ac.agente_id, t.id_conta as client_id, SUM(t.amount_usd) as vol, COUNT(*) as qtd,
|
SELECT ac.agente_id, t.id_conta as client_id, SUM(t.amount_usd) as vol, COUNT(*) as qtd,
|
||||||
SUM((t.exchange_rate - t.ptax) * t.amount_usd) as spread_rev
|
SUM((t.exchange_rate - t.ptax) / t.exchange_rate * t.amount_usd) as spread_rev
|
||||||
FROM br_transaction_to_usa t
|
FROM br_transaction_to_usa t
|
||||||
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
|
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
|
||||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||||
GROUP BY ac.agente_id, t.id_conta
|
GROUP BY ac.agente_id, t.id_conta
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT ac.agente_id, p.id_conta as client_id, SUM(p.valor) as vol, COUNT(*) as qtd,
|
SELECT ac.agente_id, p.id_conta as client_id, SUM(p.valor) as vol, COUNT(*) as qtd,
|
||||||
SUM((p.ptax - p.cotacao) * p.valor) as spread_rev
|
SUM((p.ptax - p.cotacao) / p.ptax * p.valor) as spread_rev
|
||||||
FROM pagamento_br p
|
FROM pagamento_br p
|
||||||
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
|
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
|
||||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||||
@@ -984,7 +984,7 @@ async function fetchClientProfile(clienteId) {
|
|||||||
const [brl] = await conn.execute(`
|
const [brl] = await conn.execute(`
|
||||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
|
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
|
||||||
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue,
|
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue,
|
||||||
MIN(created_at) as first_op, MAX(created_at) as last_op
|
MIN(created_at) as first_op, MAX(created_at) as last_op
|
||||||
FROM br_transaction_to_usa WHERE id_conta = ?
|
FROM br_transaction_to_usa WHERE id_conta = ?
|
||||||
`, [clienteId]);
|
`, [clienteId]);
|
||||||
@@ -993,7 +993,7 @@ async function fetchClientProfile(clienteId) {
|
|||||||
const [usd] = await conn.execute(`
|
const [usd] = await conn.execute(`
|
||||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
|
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
|
||||||
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue,
|
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue,
|
||||||
MIN(created_at) as first_op, MAX(created_at) as last_op
|
MIN(created_at) as first_op, MAX(created_at) as last_op
|
||||||
FROM pagamento_br WHERE id_conta = ?
|
FROM pagamento_br WHERE id_conta = ?
|
||||||
AND cotacao IS NOT NULL AND cotacao > 0
|
AND cotacao IS NOT NULL AND cotacao > 0
|
||||||
@@ -1011,6 +1011,20 @@ async function fetchClientProfile(clienteId) {
|
|||||||
|
|
||||||
const brlQtd = Number(brlData.qtd) || 0;
|
const brlQtd = Number(brlData.qtd) || 0;
|
||||||
const usdQtd = Number(usdData.qtd) || 0;
|
const usdQtd = Number(usdData.qtd) || 0;
|
||||||
|
const totalOps = brlQtd + usdQtd;
|
||||||
|
const totalVolUsd = (Number(brlData.vol_usd) || 0) + (Number(usdData.vol_usd) || 0);
|
||||||
|
const totalSpreadRev = (Number(brlData.spread_revenue) || 0) + (Number(usdData.spread_revenue) || 0);
|
||||||
|
|
||||||
|
// Months active (distinct months with transactions)
|
||||||
|
const [monthsRows] = await conn.execute(`
|
||||||
|
SELECT COUNT(DISTINCT mes) as months_active FROM (
|
||||||
|
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes FROM br_transaction_to_usa WHERE id_conta = ?
|
||||||
|
UNION
|
||||||
|
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes FROM pagamento_br WHERE id_conta = ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
) m
|
||||||
|
`, [clienteId, clienteId]);
|
||||||
|
const monthsActive = Number(monthsRows[0]?.months_active) || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: clienteId,
|
id: clienteId,
|
||||||
@@ -1018,10 +1032,15 @@ async function fetchClientProfile(clienteId) {
|
|||||||
first_op: firstOp ? firstOp.toISOString().slice(0, 10) : null,
|
first_op: firstOp ? firstOp.toISOString().slice(0, 10) : null,
|
||||||
last_op: lastOp ? lastOp.toISOString().slice(0, 10) : null,
|
last_op: lastOp ? lastOp.toISOString().slice(0, 10) : null,
|
||||||
days_inactive: daysInactive,
|
days_inactive: daysInactive,
|
||||||
total_ops: brlQtd + usdQtd,
|
total_ops: totalOps,
|
||||||
total_vol_usd: (Number(brlData.vol_usd) || 0) + (Number(usdData.vol_usd) || 0),
|
total_vol_usd: totalVolUsd,
|
||||||
total_vol_brl: (Number(brlData.vol_brl) || 0) + (Number(usdData.vol_brl) || 0),
|
total_vol_brl: (Number(brlData.vol_brl) || 0) + (Number(usdData.vol_brl) || 0),
|
||||||
total_spread_revenue: (Number(brlData.spread_revenue) || 0) + (Number(usdData.spread_revenue) || 0),
|
total_spread_revenue: totalSpreadRev,
|
||||||
|
months_active: monthsActive,
|
||||||
|
avg_monthly_vol: monthsActive > 0 ? Math.round(totalVolUsd / monthsActive) : 0,
|
||||||
|
avg_monthly_ops: monthsActive > 0 ? Math.round(totalOps / monthsActive * 10) / 10 : 0,
|
||||||
|
avg_monthly_revenue: monthsActive > 0 ? Math.round(totalSpreadRev / monthsActive * 100) / 100 : 0,
|
||||||
|
ltv: totalSpreadRev,
|
||||||
brlUsd: { qtd: brlQtd, vol_usd: Number(brlData.vol_usd) || 0 },
|
brlUsd: { qtd: brlQtd, vol_usd: Number(brlData.vol_usd) || 0 },
|
||||||
usdBrl: { qtd: usdQtd, vol_usd: Number(usdData.vol_usd) || 0 }
|
usdBrl: { qtd: usdQtd, vol_usd: Number(usdData.vol_usd) || 0 }
|
||||||
};
|
};
|
||||||
@@ -1047,7 +1066,7 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
|
|||||||
const [kpiBrl] = await conn.execute(`
|
const [kpiBrl] = await conn.execute(`
|
||||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
|
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
|
||||||
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue,
|
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue,
|
||||||
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100),0),2) as avg_spread_pct
|
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100),0),2) as avg_spread_pct
|
||||||
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
`, [clienteId, dataInicio, dataFim]);
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
@@ -1056,7 +1075,7 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
|
|||||||
const [kpiUsd] = await conn.execute(`
|
const [kpiUsd] = await conn.execute(`
|
||||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
|
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
|
||||||
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue,
|
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue,
|
||||||
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END),0),2) as avg_spread_pct
|
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END),0),2) as avg_spread_pct
|
||||||
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
@@ -1065,12 +1084,12 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
|
|||||||
// Previous period
|
// Previous period
|
||||||
const [prevBrl] = await conn.execute(`
|
const [prevBrl] = await conn.execute(`
|
||||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue
|
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue
|
||||||
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
`, [clienteId, prevStartStr, prevEndStr]);
|
`, [clienteId, prevStartStr, prevEndStr]);
|
||||||
const [prevUsd] = await conn.execute(`
|
const [prevUsd] = await conn.execute(`
|
||||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||||
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue
|
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue
|
||||||
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
`, [clienteId, prevStartStr, prevEndStr]);
|
`, [clienteId, prevStartStr, prevEndStr]);
|
||||||
@@ -1151,14 +1170,18 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
|
|||||||
GROUP BY p.tipo_envio
|
GROUP BY p.tipo_envio
|
||||||
`, [clienteId, dataInicio, dataFim]);
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
|
|
||||||
// Monthly average
|
// Monthly breakdown (volume + revenue + qty)
|
||||||
const [monthlyBrl] = await conn.execute(`
|
const [monthlyBrl] = await conn.execute(`
|
||||||
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, ROUND(AVG(amount_usd),2) as avg_usd, COUNT(*) as qtd
|
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, COUNT(*) as qtd,
|
||||||
|
ROUND(SUM(amount_usd),2) as vol_usd,
|
||||||
|
ROUND(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),2) as spread_revenue
|
||||||
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
|
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
|
||||||
`, [clienteId, dataInicio, dataFim]);
|
`, [clienteId, dataInicio, dataFim]);
|
||||||
const [monthlyUsd] = await conn.execute(`
|
const [monthlyUsd] = await conn.execute(`
|
||||||
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, ROUND(AVG(valor),2) as avg_usd, COUNT(*) as qtd
|
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, COUNT(*) as qtd,
|
||||||
|
ROUND(SUM(valor),2) as vol_usd,
|
||||||
|
ROUND(SUM((ptax - cotacao) / ptax * valor),2) as spread_revenue
|
||||||
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
|
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
|
||||||
@@ -1193,12 +1216,13 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
|
|||||||
provMap[n].vol_usd += Number(r.vol_usd);
|
provMap[n].vol_usd += Number(r.vol_usd);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge monthly avg
|
// Merge monthly data
|
||||||
const monthMap = {};
|
const monthMap = {};
|
||||||
[...monthlyBrl, ...monthlyUsd].forEach(r => {
|
[...monthlyBrl, ...monthlyUsd].forEach(r => {
|
||||||
if (!monthMap[r.mes]) monthMap[r.mes] = { mes: r.mes, total_usd: 0, total_qtd: 0 };
|
if (!monthMap[r.mes]) monthMap[r.mes] = { mes: r.mes, qtd: 0, vol_usd: 0, spread_revenue: 0 };
|
||||||
monthMap[r.mes].total_usd += Number(r.avg_usd) * Number(r.qtd);
|
monthMap[r.mes].qtd += Number(r.qtd);
|
||||||
monthMap[r.mes].total_qtd += Number(r.qtd);
|
monthMap[r.mes].vol_usd += Number(r.vol_usd);
|
||||||
|
monthMap[r.mes].spread_revenue += Number(r.spread_revenue);
|
||||||
});
|
});
|
||||||
|
|
||||||
const transactions = [
|
const transactions = [
|
||||||
@@ -1234,13 +1258,234 @@ async function fetchClientData(clienteId, dataInicio, dataFim) {
|
|||||||
transactions,
|
transactions,
|
||||||
dayOfWeek: dowMap,
|
dayOfWeek: dowMap,
|
||||||
providers: Object.values(provMap).sort((a, b) => b.vol_usd - a.vol_usd),
|
providers: Object.values(provMap).sort((a, b) => b.vol_usd - a.vol_usd),
|
||||||
monthlyAvg: Object.values(monthMap).map(m => ({ mes: m.mes, avg_usd: m.total_qtd > 0 ? Math.round(m.total_usd / m.total_qtd) : 0, qtd: m.total_qtd })).sort((a, b) => a.mes.localeCompare(b.mes))
|
monthly: Object.values(monthMap).map(m => ({ mes: m.mes, qtd: m.qtd, vol_usd: Math.round(m.vol_usd * 100) / 100, spread_revenue: Math.round(m.spread_revenue * 100) / 100, avg_usd: m.qtd > 0 ? Math.round(m.vol_usd / m.qtd) : 0 })).sort((a, b) => a.mes.localeCompare(b.mes))
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
conn.release();
|
conn.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchBIStrategic(dataInicio, dataFim) {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
const start = new Date(dataInicio);
|
||||||
|
const end = new Date(dataFim);
|
||||||
|
const periodDays = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
const prevEnd = new Date(start);
|
||||||
|
prevEnd.setDate(prevEnd.getDate() - 1);
|
||||||
|
const prevStart = new Date(prevEnd);
|
||||||
|
prevStart.setDate(prevStart.getDate() - periodDays + 1);
|
||||||
|
const prevStartStr = prevStart.toISOString().slice(0, 10);
|
||||||
|
const prevEndStr = prevEnd.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// === 1. COHORT RETENTION ===
|
||||||
|
const [cohortClients] = await conn.execute(`
|
||||||
|
SELECT id_conta, DATE_FORMAT(MIN(first_op), '%Y-%m') as cohort_month FROM (
|
||||||
|
SELECT id_conta, MIN(created_at) as first_op FROM br_transaction_to_usa GROUP BY id_conta
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_conta, MIN(created_at) as first_op FROM pagamento_br
|
||||||
|
WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY id_conta
|
||||||
|
) f GROUP BY id_conta
|
||||||
|
`);
|
||||||
|
const [activeMonths] = await conn.execute(`
|
||||||
|
SELECT id_conta, active_month FROM (
|
||||||
|
SELECT id_conta, DATE_FORMAT(created_at, '%Y-%m') as active_month
|
||||||
|
FROM br_transaction_to_usa GROUP BY id_conta, DATE_FORMAT(created_at, '%Y-%m')
|
||||||
|
UNION
|
||||||
|
SELECT id_conta, DATE_FORMAT(created_at, '%Y-%m') as active_month
|
||||||
|
FROM pagamento_br WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY id_conta, DATE_FORMAT(created_at, '%Y-%m')
|
||||||
|
) m
|
||||||
|
`);
|
||||||
|
|
||||||
|
const clientCohort = {};
|
||||||
|
cohortClients.forEach(r => { clientCohort[r.id_conta] = r.cohort_month; });
|
||||||
|
const clientMonths = {};
|
||||||
|
activeMonths.forEach(r => {
|
||||||
|
if (!clientMonths[r.id_conta]) clientMonths[r.id_conta] = new Set();
|
||||||
|
clientMonths[r.id_conta].add(r.active_month);
|
||||||
|
});
|
||||||
|
const allMonths = [...new Set([...cohortClients.map(r => r.cohort_month), ...activeMonths.map(r => r.active_month)])].sort();
|
||||||
|
|
||||||
|
const cohortMap = {};
|
||||||
|
cohortClients.forEach(r => {
|
||||||
|
const cm = r.cohort_month;
|
||||||
|
if (!cohortMap[cm]) cohortMap[cm] = { size: 0, months: {} };
|
||||||
|
cohortMap[cm].size++;
|
||||||
|
});
|
||||||
|
Object.keys(clientCohort).forEach(clientId => {
|
||||||
|
const cm = clientCohort[clientId];
|
||||||
|
const months = clientMonths[clientId] || new Set();
|
||||||
|
const cmIdx = allMonths.indexOf(cm);
|
||||||
|
months.forEach(am => {
|
||||||
|
const amIdx = allMonths.indexOf(am);
|
||||||
|
const offset = amIdx - cmIdx;
|
||||||
|
if (offset >= 0) {
|
||||||
|
if (!cohortMap[cm].months[offset]) cohortMap[cm].months[offset] = 0;
|
||||||
|
cohortMap[cm].months[offset]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cohortKeys = Object.keys(cohortMap).sort().slice(-12);
|
||||||
|
const cohorts = cohortKeys.map(cm => {
|
||||||
|
const c = cohortMap[cm];
|
||||||
|
const maxOff = allMonths.length - allMonths.indexOf(cm);
|
||||||
|
const retention = [];
|
||||||
|
for (let i = 0; i < Math.min(maxOff, 13); i++) {
|
||||||
|
retention.push(c.size > 0 ? Math.round((c.months[i] || 0) / c.size * 100) : 0);
|
||||||
|
}
|
||||||
|
return { month: cm, size: c.size, retention };
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 2. REVENUE EXPANSION / CONTRACTION ===
|
||||||
|
const [currRevenue] = await conn.execute(`
|
||||||
|
SELECT id_conta, ROUND(SUM(revenue), 2) as revenue, ROUND(SUM(vol_usd), 2) as vol_usd FROM (
|
||||||
|
SELECT id_conta, SUM((exchange_rate - ptax) / exchange_rate * amount_usd) as revenue, SUM(amount_usd) as vol_usd
|
||||||
|
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
GROUP BY id_conta
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_conta, SUM((ptax - cotacao) / ptax * valor) as revenue, SUM(valor) as vol_usd
|
||||||
|
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY id_conta
|
||||||
|
) c GROUP BY id_conta
|
||||||
|
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||||
|
const [prevRevenue] = await conn.execute(`
|
||||||
|
SELECT id_conta, ROUND(SUM(revenue), 2) as revenue, ROUND(SUM(vol_usd), 2) as vol_usd FROM (
|
||||||
|
SELECT id_conta, SUM((exchange_rate - ptax) / exchange_rate * amount_usd) as revenue, SUM(amount_usd) as vol_usd
|
||||||
|
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
GROUP BY id_conta
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_conta, SUM((ptax - cotacao) / ptax * valor) as revenue, SUM(valor) as vol_usd
|
||||||
|
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY id_conta
|
||||||
|
) p GROUP BY id_conta
|
||||||
|
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr]);
|
||||||
|
|
||||||
|
const currMap = {};
|
||||||
|
currRevenue.forEach(r => { currMap[r.id_conta] = { revenue: Number(r.revenue), vol_usd: Number(r.vol_usd) }; });
|
||||||
|
const prevMap = {};
|
||||||
|
prevRevenue.forEach(r => { prevMap[r.id_conta] = { revenue: Number(r.revenue), vol_usd: Number(r.vol_usd) }; });
|
||||||
|
|
||||||
|
const allClientIds = new Set([...Object.keys(currMap), ...Object.keys(prevMap)]);
|
||||||
|
const expansion = {
|
||||||
|
new_clients: { count: 0, revenue: 0 },
|
||||||
|
expansion: { count: 0, revenue: 0 },
|
||||||
|
stable: { count: 0, revenue: 0 },
|
||||||
|
contraction: { count: 0, revenue: 0 },
|
||||||
|
churned: { count: 0, revenue: 0 }
|
||||||
|
};
|
||||||
|
allClientIds.forEach(id => {
|
||||||
|
const curr = currMap[id];
|
||||||
|
const prev = prevMap[id];
|
||||||
|
if (curr && !prev) {
|
||||||
|
expansion.new_clients.count++; expansion.new_clients.revenue += curr.revenue;
|
||||||
|
} else if (!curr && prev) {
|
||||||
|
expansion.churned.count++; expansion.churned.revenue -= Math.abs(prev.revenue);
|
||||||
|
} else if (curr && prev) {
|
||||||
|
const absP = Math.abs(prev.revenue);
|
||||||
|
const change = absP > 0 ? (curr.revenue - prev.revenue) / absP : 0;
|
||||||
|
if (change > 0.1) {
|
||||||
|
expansion.expansion.count++; expansion.expansion.revenue += (curr.revenue - prev.revenue);
|
||||||
|
} else if (change < -0.1) {
|
||||||
|
expansion.contraction.count++; expansion.contraction.revenue += (curr.revenue - prev.revenue);
|
||||||
|
} else {
|
||||||
|
expansion.stable.count++; expansion.stable.revenue += curr.revenue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 3. CROSS-SELL ===
|
||||||
|
const [crossSellData] = await conn.execute(`
|
||||||
|
SELECT c.id_conta, t.vol_usd as pay_vol, p.vol_usd as checkout_vol
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
) c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT id_conta, ROUND(SUM(amount_usd), 2) as vol_usd
|
||||||
|
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
GROUP BY id_conta
|
||||||
|
) t ON t.id_conta = c.id_conta
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT id_conta, ROUND(SUM(valor), 2) as vol_usd
|
||||||
|
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY id_conta
|
||||||
|
) p ON p.id_conta = c.id_conta
|
||||||
|
`, [dataInicio, dataFim, dataInicio, dataFim, dataInicio, dataFim, dataInicio, dataFim]);
|
||||||
|
|
||||||
|
const crossSell = { pay_only: { count: 0, vol: 0 }, checkout_only: { count: 0, vol: 0 }, both: { count: 0, vol: 0 } };
|
||||||
|
crossSellData.forEach(r => {
|
||||||
|
const pv = Number(r.pay_vol) || 0;
|
||||||
|
const cv = Number(r.checkout_vol) || 0;
|
||||||
|
if (pv > 0 && cv > 0) { crossSell.both.count++; crossSell.both.vol += pv + cv; }
|
||||||
|
else if (pv > 0) { crossSell.pay_only.count++; crossSell.pay_only.vol += pv; }
|
||||||
|
else if (cv > 0) { crossSell.checkout_only.count++; crossSell.checkout_only.vol += cv; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 4. CLIENT MATURITY SEGMENTS ===
|
||||||
|
const [maturityData] = await conn.execute(`
|
||||||
|
SELECT id_conta,
|
||||||
|
MIN(first_op) as first_op,
|
||||||
|
FLOOR(DATEDIFF(CURDATE(), MIN(first_op)) / 30.44) as months_active,
|
||||||
|
SUM(vol_usd) as lifetime_vol,
|
||||||
|
SUM(CASE WHEN period = 'curr' THEN vol_usd ELSE 0 END) as curr_vol,
|
||||||
|
SUM(CASE WHEN period = 'prev' THEN vol_usd ELSE 0 END) as prev_vol
|
||||||
|
FROM (
|
||||||
|
SELECT id_conta, MIN(created_at) as first_op, SUM(amount_usd) as vol_usd, 'all' as period
|
||||||
|
FROM br_transaction_to_usa GROUP BY id_conta
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_conta, MIN(created_at) as first_op, SUM(valor) as vol_usd, 'all' as period
|
||||||
|
FROM pagamento_br WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY id_conta
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_conta, NULL, SUM(amount_usd) as vol_usd, 'curr' as period
|
||||||
|
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ? GROUP BY id_conta
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_conta, NULL, SUM(valor) as vol_usd, 'curr' as period
|
||||||
|
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY id_conta
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_conta, NULL, SUM(amount_usd) as vol_usd, 'prev' as period
|
||||||
|
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ? GROUP BY id_conta
|
||||||
|
UNION ALL
|
||||||
|
SELECT id_conta, NULL, SUM(valor) as vol_usd, 'prev' as period
|
||||||
|
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||||
|
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||||
|
GROUP BY id_conta
|
||||||
|
) combined GROUP BY id_conta
|
||||||
|
`, [dataInicio, dataFim, dataInicio, dataFim, prevStartStr, prevEndStr, prevStartStr, prevEndStr]);
|
||||||
|
|
||||||
|
const maturity = { new_client: { count: 0, vol: 0 }, growing: { count: 0, vol: 0 }, mature: { count: 0, vol: 0 }, declining: { count: 0, vol: 0 } };
|
||||||
|
maturityData.forEach(r => {
|
||||||
|
const months = Number(r.months_active) || 0;
|
||||||
|
const cv = Number(r.curr_vol) || 0;
|
||||||
|
const pv = Number(r.prev_vol) || 0;
|
||||||
|
const lv = Number(r.lifetime_vol) || 0;
|
||||||
|
if (months < 3) {
|
||||||
|
maturity.new_client.count++; maturity.new_client.vol += lv;
|
||||||
|
} else if (pv > 0 && cv < pv * 0.85) {
|
||||||
|
maturity.declining.count++; maturity.declining.vol += lv;
|
||||||
|
} else if (months >= 12) {
|
||||||
|
maturity.mature.count++; maturity.mature.vol += lv;
|
||||||
|
} else {
|
||||||
|
maturity.growing.count++; maturity.growing.vol += lv;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cohorts, expansion, crossSell, maturity };
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchTransacoes,
|
fetchTransacoes,
|
||||||
fetchAllTransacoes,
|
fetchAllTransacoes,
|
||||||
@@ -1253,6 +1498,7 @@ module.exports = {
|
|||||||
fetchKPIsByPeriod,
|
fetchKPIsByPeriod,
|
||||||
fetchBIData,
|
fetchBIData,
|
||||||
fetchRevenueAnalytics,
|
fetchRevenueAnalytics,
|
||||||
|
fetchBIStrategic,
|
||||||
fetchClientList,
|
fetchClientList,
|
||||||
fetchClientProfile,
|
fetchClientProfile,
|
||||||
fetchClientData
|
fetchClientData
|
||||||
|
|||||||
Reference in New Issue
Block a user