feat: BI-CCC evolution — 6-phase platform upgrade (45→85 maturity)
Phase 1: Refactor queries.js (1787 lines) into domain modules with facade pattern
- src/queries/{helpers,payin,payout,corporate,bi,client,provider,compliance}.queries.js
- New provider performance + compliance data layer queries
- Health check endpoint (GET /health)
Phase 2: Provider Performance Dashboard (src/admin-providers.js)
- Hero cards, sortable tables, Chart.js charts, date range filter
- API routes: /admin/api/providers, /admin/api/providers/failed, /admin/api/providers/trend
Phase 3: Excel Export (exceljs)
- CambioReal-branded exports for BI, clients, providers, transactions
- Export buttons added to BI and Client 360 dashboards
Phase 4: Alert System (node-cron + nodemailer)
- 5 alert rules: volume spike, spread anomaly, large tx, failed tx spike, provider inactivity
- SQLite alerts table, bell icon UI with acknowledge workflow
- Email notifications via SMTP
Phase 5: Enhanced Analytics
- Churn prediction: weighted RFM model (src/services/churn-predictor.js)
- Volume forecasting: exponential smoothing with confidence bands (src/services/forecast.js)
- Forecast chart in BI dashboard, churn risk in Client 360
Phase 6: SQLite Analytics Store (ETL)
- src/db-analytics.js: daily_metrics, client_health_daily, monthly_revenue tables
- src/etl/daily-sync.js: MySQL RDS → SQLite daily sync at 1 AM + 90-day backfill
- src/etl/data-quality.js: post-sync validation (row counts, reconciliation)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
659
src/queries/bi.queries.js
Normal file
659
src/queries/bi.queries.js
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* BI Executive Dashboard Queries
|
||||
* Comprehensive analytics: KPIs, revenue P&L, strategic cohort analysis
|
||||
*/
|
||||
const { pool, fmtDate, fmtTrendRows, calcPrevPeriod } = require('./helpers');
|
||||
|
||||
// BI Analytics - Comprehensive data for admin BI dashboard
|
||||
async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const { prevStartStr, prevEndStr } = calcPrevPeriod(dataInicio, dataFim);
|
||||
|
||||
// 1. BRL→USD KPIs
|
||||
const [kpiBrlUsd] = await conn.execute(`
|
||||
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((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,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 2. USD→BRL KPIs
|
||||
const [kpiUsdBrl] = await conn.execute(`
|
||||
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((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,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
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')
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 3. USD→USD KPIs
|
||||
const [kpiUsdUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 4. Unique active clients across all flows
|
||||
const [uniqueClients] = await conn.execute(`
|
||||
SELECT COUNT(DISTINCT id_conta) as total FROM (
|
||||
SELECT id_conta FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
UNION
|
||||
SELECT id_conta FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
) all_clients
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 5. Previous period totals for comparison
|
||||
const [prevBrlUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||
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) <= ?
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
const [prevUsdBrl] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue
|
||||
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')
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
const [prevUsdUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
|
||||
// 6. BRL→USD daily trend with spread
|
||||
const [trendBrlUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(amount_usd), 2) as vol_usd,
|
||||
ROUND(AVG((exchange_rate - ptax) / exchange_rate * 100), 2) as avg_spread
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 7. USD→BRL daily trend with spread
|
||||
const [trendUsdBrl] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd,
|
||||
ROUND(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 2) as avg_spread
|
||||
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 DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 8. USD→USD daily trend
|
||||
const [trendUsdUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 9. Top 10 clients by volume
|
||||
const [topClients] = await conn.execute(`
|
||||
SELECT nome, SUM(vol) as total_usd, SUM(qtd) as total_qtd FROM (
|
||||
SELECT c.nome, SUM(t.amount_usd) as vol, COUNT(*) as qtd
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY c.nome
|
||||
UNION ALL
|
||||
SELECT c.nome, SUM(p.valor) as vol, COUNT(*) as qtd
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
GROUP BY c.nome
|
||||
) combined
|
||||
GROUP BY nome ORDER BY total_usd DESC LIMIT 10
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 10. Client retention
|
||||
const [retention] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(DISTINCT prev.id_conta) as prev_clients,
|
||||
COUNT(DISTINCT CASE WHEN curr.id_conta IS NOT NULL THEN prev.id_conta END) as retained
|
||||
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) <= ?
|
||||
) prev
|
||||
LEFT JOIN (
|
||||
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) <= ?
|
||||
) curr ON prev.id_conta = curr.id_conta
|
||||
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr, dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 11. Clients at risk
|
||||
const [clientsAtRisk] = await conn.execute(`
|
||||
SELECT nome, MAX(last_op) as last_op, SUM(vol) as total_usd, SUM(qtd) as total_qtd,
|
||||
DATEDIFF(CURDATE(), MAX(last_op)) as days_inactive
|
||||
FROM (
|
||||
SELECT c.nome, MAX(t.created_at) as last_op, SUM(t.amount_usd) as vol, COUNT(*) as qtd
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
GROUP BY c.nome
|
||||
UNION ALL
|
||||
SELECT c.nome, MAX(p.created_at) as last_op, SUM(p.valor) as vol, COUNT(*) as qtd
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
GROUP BY c.nome
|
||||
) combined
|
||||
GROUP BY nome
|
||||
HAVING MAX(last_op) < CURDATE()
|
||||
ORDER BY total_usd DESC LIMIT 20
|
||||
`);
|
||||
|
||||
// 12. Agent ranking with spread revenue
|
||||
const [agentRanking] = await conn.execute(`
|
||||
SELECT agente_id, SUM(vol) as total_usd, SUM(qtd) as total_qtd,
|
||||
ROUND(SUM(spread_rev), 2) as total_spread, COUNT(DISTINCT client_id) as clientes
|
||||
FROM (
|
||||
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.exchange_rate * t.amount_usd) as spread_rev
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY ac.agente_id, t.id_conta
|
||||
UNION ALL
|
||||
SELECT ac.agente_id, p.id_conta as client_id, SUM(p.valor) as vol, COUNT(*) as qtd,
|
||||
SUM((p.ptax - p.cotacao) / p.ptax * p.valor) as spread_rev
|
||||
FROM pagamento_br p
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0
|
||||
AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
GROUP BY ac.agente_id, p.id_conta
|
||||
) combined
|
||||
GROUP BY agente_id ORDER BY total_usd DESC LIMIT 10
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// Resolve agent names
|
||||
const agents = agentRanking.map((r, i) => {
|
||||
const nome = getAgenteName ? (getAgenteName(r.agente_id) || `Agente ${r.agente_id}`) : `Agente ${r.agente_id}`;
|
||||
return { rank: i + 1, agente_id: r.agente_id, nome,
|
||||
vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
|
||||
spread_revenue: Number(r.total_spread), clientes: Number(r.clientes)
|
||||
};
|
||||
});
|
||||
|
||||
// Format results
|
||||
const fmtKpi = (r) => ({
|
||||
qtd: Number(r?.qtd) || 0, vol_usd: Number(r?.vol_usd) || 0,
|
||||
vol_brl: Number(r?.vol_brl) || 0, spread_revenue: Number(r?.spread_revenue) || 0,
|
||||
avg_spread_pct: Number(r?.avg_spread_pct) || 0, clientes: Number(r?.clientes) || 0
|
||||
});
|
||||
|
||||
const brl = fmtKpi(kpiBrlUsd[0]);
|
||||
const usd = fmtKpi(kpiUsdBrl[0]);
|
||||
const uu = { qtd: Number(kpiUsdUsd[0]?.qtd) || 0, vol_usd: Number(kpiUsdUsd[0]?.vol_usd) || 0, clientes: Number(kpiUsdUsd[0]?.clientes) || 0 };
|
||||
const totalQtd = brl.qtd + usd.qtd + uu.qtd;
|
||||
const totalVolUsd = brl.vol_usd + usd.vol_usd + uu.vol_usd;
|
||||
|
||||
const pBrl = Number(prevBrlUsd[0]?.qtd) || 0;
|
||||
const pUsd = Number(prevUsdBrl[0]?.qtd) || 0;
|
||||
const pUu = Number(prevUsdUsd[0]?.qtd) || 0;
|
||||
const prevQtd = pBrl + pUsd + pUu;
|
||||
const prevVolUsd = (Number(prevBrlUsd[0]?.vol_usd) || 0) + (Number(prevUsdBrl[0]?.vol_usd) || 0) + (Number(prevUsdUsd[0]?.vol_usd) || 0);
|
||||
const prevSpread = (Number(prevBrlUsd[0]?.spread_revenue) || 0) + (Number(prevUsdBrl[0]?.spread_revenue) || 0);
|
||||
|
||||
const retPrev = Number(retention[0]?.prev_clients) || 0;
|
||||
const retCurr = Number(retention[0]?.retained) || 0;
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
brlUsd: brl, usdBrl: usd, usdUsd: uu,
|
||||
total: {
|
||||
qtd: totalQtd, vol_usd: totalVolUsd,
|
||||
spread_revenue: brl.spread_revenue + usd.spread_revenue,
|
||||
clientes: Number(uniqueClients[0]?.total) || 0,
|
||||
ticket_medio: totalQtd > 0 ? Math.round(totalVolUsd / totalQtd) : 0
|
||||
}
|
||||
},
|
||||
comparison: { prev_qtd: prevQtd, prev_vol_usd: prevVolUsd, prev_spread: prevSpread },
|
||||
trend: { brlUsd: fmtTrendRows(trendBrlUsd), usdBrl: fmtTrendRows(trendUsdBrl), usdUsd: fmtTrendRows(trendUsdUsd) },
|
||||
topClients: topClients.map(r => ({ nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd) })),
|
||||
retention: { prev_clients: retPrev, retained: retCurr, rate: retPrev > 0 ? Math.round(retCurr / retPrev * 100) : 0 },
|
||||
clientsAtRisk: clientsAtRisk.map(r => ({
|
||||
nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
|
||||
last_op: r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 16),
|
||||
days_inactive: Number(r.days_inactive) || 0
|
||||
})),
|
||||
agentRanking: agents,
|
||||
netting: {
|
||||
saida_usd: brl.vol_usd, entrada_usd: usd.vol_usd,
|
||||
posicao_liquida: usd.vol_usd - brl.vol_usd,
|
||||
eficiencia: brl.vol_usd > 0 ? Math.min(100, Math.round(usd.vol_usd / brl.vol_usd * 100)) : 0
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Revenue Analytics - Real P&L by product with dynamic granularity
|
||||
async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const validGran = ['dia', 'mes', 'ano'].includes(granularity) ? granularity : 'dia';
|
||||
|
||||
let periodoInicio, periodoLabel;
|
||||
switch (validGran) {
|
||||
case 'ano':
|
||||
periodoInicio = "MAKEDATE(YEAR(dia), 1)";
|
||||
periodoLabel = "DATE_FORMAT(dia, '%Y')";
|
||||
break;
|
||||
case 'mes':
|
||||
periodoInicio = "CAST(DATE_FORMAT(dia, '%Y-%m-01') AS DATE)";
|
||||
periodoLabel = "DATE_FORMAT(dia, '%Y-%m')";
|
||||
break;
|
||||
default:
|
||||
periodoInicio = "DATE(dia)";
|
||||
periodoLabel = "DATE_FORMAT(dia, '%Y-%m-%d')";
|
||||
}
|
||||
|
||||
const [rows] = await conn.execute(`
|
||||
WITH limites AS (
|
||||
SELECT
|
||||
CAST(? AS DATE) AS inicio,
|
||||
DATE_ADD(CAST(? AS DATE), INTERVAL 1 DAY) AS fim_exclusivo
|
||||
),
|
||||
q1 AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN pb.tipo_envio = 'balance' THEN DATE(pb.data_cp)
|
||||
ELSE DATE(pb.created_at)
|
||||
END AS dia,
|
||||
CONCAT('US→BR: ', COALESCE(pb.tipo_envio, 'desconhecido')) AS produto,
|
||||
CASE
|
||||
WHEN pb.tipo_envio = 'balance' THEN COALESCE(pb.fee, 0)
|
||||
ELSE COALESCE(
|
||||
CASE
|
||||
WHEN pb.ptax IS NOT NULL AND pb.ptax > 0
|
||||
THEN ((pb.ptax - pb.cotacao) * pb.valor) / pb.ptax
|
||||
ELSE 0
|
||||
END, 0
|
||||
) + COALESCE(pb.fee, 0)
|
||||
END AS receita
|
||||
FROM pagamento_br pb
|
||||
JOIN limites l ON (
|
||||
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
|
||||
) >= l.inicio
|
||||
AND (
|
||||
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
|
||||
) < l.fim_exclusivo
|
||||
WHERE pb.valor > 0
|
||||
AND pb.data_cp IS NOT NULL
|
||||
AND pb.data_cp <> '0000-00-00'
|
||||
),
|
||||
q2 AS (
|
||||
SELECT
|
||||
DATE(t.created_at) AS dia,
|
||||
CASE
|
||||
WHEN t.cobranca_id IS NOT NULL THEN 'BR→US: Checkout'
|
||||
ELSE 'BR→US: CambioTransfer'
|
||||
END AS produto,
|
||||
(
|
||||
(
|
||||
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
|
||||
- COALESCE(t.pfee, 0)
|
||||
) - (
|
||||
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
|
||||
)
|
||||
) AS receita
|
||||
FROM br_transaction_to_usa t
|
||||
JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
JOIN limites l ON t.created_at >= l.inicio AND t.created_at < l.fim_exclusivo
|
||||
WHERE pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
|
||||
AND t.ptax IS NOT NULL AND t.ptax > 0
|
||||
AND (
|
||||
t.status IN ('boleto_pago','finalizado')
|
||||
OR t.date_sent_usa <> '0000-00-00 00:00:00'
|
||||
)
|
||||
),
|
||||
unioned AS (
|
||||
SELECT dia, produto, receita FROM q1
|
||||
UNION ALL
|
||||
SELECT dia, produto, receita FROM q2
|
||||
)
|
||||
SELECT
|
||||
${periodoInicio} AS periodo_inicio,
|
||||
${periodoLabel} AS periodo_label,
|
||||
produto,
|
||||
ROUND(SUM(receita), 2) AS receita,
|
||||
COUNT(*) AS qtd
|
||||
FROM unioned
|
||||
GROUP BY periodo_inicio, periodo_label, produto
|
||||
ORDER BY periodo_inicio, produto
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// Also get totals by product
|
||||
const [totals] = await conn.execute(`
|
||||
WITH limites AS (
|
||||
SELECT
|
||||
CAST(? AS DATE) AS inicio,
|
||||
DATE_ADD(CAST(? AS DATE), INTERVAL 1 DAY) AS fim_exclusivo
|
||||
),
|
||||
q1 AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN pb.tipo_envio = 'balance' THEN COALESCE(pb.fee, 0)
|
||||
ELSE COALESCE(
|
||||
CASE WHEN pb.ptax IS NOT NULL AND pb.ptax > 0
|
||||
THEN ((pb.ptax - pb.cotacao) * pb.valor) / pb.ptax ELSE 0
|
||||
END, 0
|
||||
) + COALESCE(pb.fee, 0)
|
||||
END AS receita,
|
||||
'US→BR' AS direcao,
|
||||
COALESCE(pb.tipo_envio, 'desconhecido') AS tipo
|
||||
FROM pagamento_br pb
|
||||
JOIN limites l ON (
|
||||
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
|
||||
) >= l.inicio
|
||||
AND (
|
||||
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
|
||||
) < l.fim_exclusivo
|
||||
WHERE pb.valor > 0 AND pb.data_cp IS NOT NULL AND pb.data_cp <> '0000-00-00'
|
||||
),
|
||||
q2 AS (
|
||||
SELECT
|
||||
(
|
||||
(ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2) - COALESCE(t.pfee, 0))
|
||||
- (t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0))
|
||||
) AS receita,
|
||||
'BR→US' AS direcao,
|
||||
CASE WHEN t.cobranca_id IS NOT NULL THEN 'Checkout' ELSE 'CambioTransfer' END AS tipo
|
||||
FROM br_transaction_to_usa t
|
||||
JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
JOIN limites l ON t.created_at >= l.inicio AND t.created_at < l.fim_exclusivo
|
||||
WHERE pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
|
||||
AND t.ptax IS NOT NULL AND t.ptax > 0
|
||||
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
|
||||
)
|
||||
SELECT
|
||||
direcao,
|
||||
tipo,
|
||||
ROUND(SUM(receita), 2) AS total_receita,
|
||||
COUNT(*) AS total_qtd
|
||||
FROM (SELECT receita, direcao, tipo FROM q1 UNION ALL SELECT receita, direcao, tipo FROM q2) all_data
|
||||
GROUP BY direcao, tipo
|
||||
ORDER BY direcao, tipo
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
const timeline = rows.map(r => ({
|
||||
periodo_inicio: r.periodo_inicio instanceof Date ? r.periodo_inicio.toISOString().slice(0, 10) : String(r.periodo_inicio).slice(0, 10),
|
||||
periodo_label: r.periodo_label,
|
||||
produto: r.produto,
|
||||
receita: Number(r.receita),
|
||||
qtd: Number(r.qtd)
|
||||
}));
|
||||
|
||||
const totalsByProduct = totals.map(r => ({
|
||||
direcao: r.direcao,
|
||||
tipo: r.tipo,
|
||||
produto: r.direcao + ': ' + r.tipo,
|
||||
receita: Number(r.total_receita),
|
||||
qtd: Number(r.total_qtd)
|
||||
}));
|
||||
|
||||
const grandTotal = totalsByProduct.reduce((s, r) => s + r.receita, 0);
|
||||
const grandQtd = totalsByProduct.reduce((s, r) => s + r.qtd, 0);
|
||||
const receitaBrUs = totalsByProduct.filter(r => r.direcao === 'BR→US').reduce((s, r) => s + r.receita, 0);
|
||||
const receitaUsBr = totalsByProduct.filter(r => r.direcao === 'US→BR').reduce((s, r) => s + r.receita, 0);
|
||||
|
||||
return {
|
||||
timeline,
|
||||
totals: totalsByProduct,
|
||||
summary: {
|
||||
total_receita: Math.round(grandTotal * 100) / 100,
|
||||
total_qtd: grandQtd,
|
||||
receita_br_us: Math.round(receitaBrUs * 100) / 100,
|
||||
receita_us_br: Math.round(receitaUsBr * 100) / 100,
|
||||
ticket_medio_receita: grandQtd > 0 ? Math.round(grandTotal / grandQtd * 100) / 100 : 0
|
||||
},
|
||||
granularity: validGran
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBIStrategic(dataInicio, dataFim) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const { prevStartStr, prevEndStr } = calcPrevPeriod(dataInicio, dataFim);
|
||||
|
||||
// === 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 = {
|
||||
fetchBIData,
|
||||
fetchRevenueAnalytics,
|
||||
fetchBIStrategic
|
||||
};
|
||||
Reference in New Issue
Block a user