feat: revenue analytics (P&L real) no BI admin
Seção Revenue Analytics com cálculo real de receita por produto: - Query completa com fees, pfee, bonus, taxa_cr por provider - Granulação dinâmica (dia/mês/ano) com filtro de período - KPIs: receita total, BR→US, US→BR, receita/operação - Stacked bar por produto + donut composição + tabela detalhada - Produtos: Checkout, CambioTransfer, balance, swift, etc. 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 path = require('path');
|
||||
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
|
||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData } = require('./src/queries');
|
||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics } = require('./src/queries');
|
||||
const { buildHTML } = require('./src/dashboard');
|
||||
const { buildAdminHTML } = require('./src/admin-panel');
|
||||
const { buildAdminHomeHTML } = require('./src/admin-home');
|
||||
@@ -355,6 +355,18 @@ app.get('/admin/api/bi', requireRole('admin'), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/bi/revenue', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const { start, end, granularity } = req.query;
|
||||
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
||||
const data = await fetchRevenueAnalytics(start, end, granularity || 'dia');
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error('Revenue API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create user (admin only)
|
||||
app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
|
||||
const { nome, email, agente_id, senha, role } = req.body;
|
||||
|
||||
248
src/admin-bi.js
248
src/admin-bi.js
@@ -244,6 +244,29 @@ function buildAdminBIHTML(user) {
|
||||
.netting-value.green { color: var(--green); }
|
||||
.netting-value.red { color: var(--red); }
|
||||
|
||||
/* Revenue Controls */
|
||||
.revenue-controls {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px; flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.gran-selector { display: flex; align-items: center; gap: 8px; }
|
||||
.gran-label {
|
||||
font-size: 13px; font-weight: 600; color: var(--text-secondary);
|
||||
}
|
||||
.gran-btn {
|
||||
padding: 8px 18px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--bg); font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
color: var(--text-secondary); transition: all 0.15s; font-family: inherit;
|
||||
}
|
||||
.gran-btn:hover { border-color: #F9A825; color: #F9A825; }
|
||||
.gran-btn.active { background: #F9A825; color: white; border-color: #F9A825; }
|
||||
.revenue-status {
|
||||
font-size: 12px; color: var(--text-muted); font-weight: 500;
|
||||
background: var(--bg); padding: 6px 12px; border-radius: 6px;
|
||||
}
|
||||
.hero-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.hero-card[style*="--top-color"]::before { background: var(--top-color); }
|
||||
|
||||
/* Loading */
|
||||
.loading-overlay {
|
||||
position: absolute; inset: 0; background: rgba(255,255,255,0.8);
|
||||
@@ -261,8 +284,10 @@ function buildAdminBIHTML(user) {
|
||||
.live-rate-btn .rate-value { font-size: 18px; }
|
||||
.live-rate-time { width: 100%; text-align: center; }
|
||||
.hero-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.hero-grid-4 { grid-template-columns: repeat(2, 1fr); }
|
||||
.hero-card:last-child { grid-column: span 2; }
|
||||
.hero-value { font-size: 22px; }
|
||||
.gran-selector { flex-wrap: wrap; }
|
||||
.charts-row, .charts-row.equal, .charts-row.triple { grid-template-columns: 1fr; }
|
||||
.filter-bar { padding: 14px 16px; gap: 10px; }
|
||||
.filter-presets { flex-wrap: wrap; }
|
||||
@@ -279,7 +304,7 @@ function buildAdminBIHTML(user) {
|
||||
.live-rate-btn .rate-type { font-size: 8px; }
|
||||
.rate-flags { font-size: 9px; }
|
||||
.live-rate-time { font-size: 9px; }
|
||||
.hero-grid { grid-template-columns: 1fr; }
|
||||
.hero-grid, .hero-grid-4 { grid-template-columns: 1fr; }
|
||||
.hero-card:last-child { grid-column: span 1; }
|
||||
.hero-value { font-size: 20px; }
|
||||
.chart-card { padding: 16px; }
|
||||
@@ -460,6 +485,66 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Revenue Analytics -->
|
||||
<div class="section-title" style="margin-top:12px;">
|
||||
<span class="icon" style="background:#FFF8E1;color:#F9A825;">💰</span>
|
||||
Revenue Analytics (P&L Real)
|
||||
</div>
|
||||
|
||||
<div class="revenue-controls">
|
||||
<div class="gran-selector">
|
||||
<span class="gran-label">Granulacao:</span>
|
||||
<button class="gran-btn active" data-gran="dia">Dia</button>
|
||||
<button class="gran-btn" data-gran="mes">Mes</button>
|
||||
<button class="gran-btn" data-gran="ano">Ano</button>
|
||||
</div>
|
||||
<span class="revenue-status" id="revenueStatus">Carregando...</span>
|
||||
</div>
|
||||
|
||||
<div class="hero-grid hero-grid-4" id="revenueKpis">
|
||||
<div class="hero-card" style="--top-color:linear-gradient(90deg,#F9A825,#FFD54F);">
|
||||
<div class="hero-label">Receita Total (P&L)</div>
|
||||
<div class="hero-value" id="revTotal">--</div>
|
||||
<div class="hero-sub" id="revTotalQtd">-- operacoes</div>
|
||||
</div>
|
||||
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--blue),#42A5F5);">
|
||||
<div class="hero-label">BR → US</div>
|
||||
<div class="hero-value" id="revBrUs">--</div>
|
||||
<div class="hero-sub">Checkout + CambioTransfer</div>
|
||||
</div>
|
||||
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--green),#4CAF50);">
|
||||
<div class="hero-label">US → BR</div>
|
||||
<div class="hero-value" id="revUsBr">--</div>
|
||||
<div class="hero-sub">Spread + Fees</div>
|
||||
</div>
|
||||
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--purple),#AB47BC);">
|
||||
<div class="hero-label">Receita / Operacao</div>
|
||||
<div class="hero-value" id="revTicket">--</div>
|
||||
<div class="hero-sub">ticket medio de receita</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-row equal">
|
||||
<div class="chart-card">
|
||||
<h3>Receita por Produto ao Longo do Tempo</h3>
|
||||
<div class="chart-wrap"><canvas id="chartRevTimeline"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>Composicao de Receita por Produto</h3>
|
||||
<div class="chart-wrap short"><canvas id="chartRevDonut"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card" style="margin-bottom:28px;">
|
||||
<h3>Detalhamento por Periodo <span class="badge" id="revGranBadge">dia</span></h3>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="revTable">
|
||||
<thead><tr><th>Periodo</th><th>Produto</th><th>Receita</th><th>Operacoes</th><th>Receita/Op</th></tr></thead>
|
||||
<tbody id="revTableBody"><tr><td colspan="5" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
${buildFooter()}
|
||||
@@ -500,21 +585,23 @@ function setPreset(preset) {
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('[data-preset="'+preset+'"]').classList.add('active');
|
||||
currentStart = start; currentEnd = today;
|
||||
loadBI();
|
||||
loadAll();
|
||||
}
|
||||
|
||||
function loadAll() { loadBI(); if (typeof loadRevenue === 'function') loadRevenue(); }
|
||||
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => setPreset(btn.dataset.preset));
|
||||
});
|
||||
document.getElementById('dateStart').addEventListener('change', () => {
|
||||
currentStart = document.getElementById('dateStart').value;
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
loadBI();
|
||||
loadAll();
|
||||
});
|
||||
document.getElementById('dateEnd').addEventListener('change', () => {
|
||||
currentEnd = document.getElementById('dateEnd').value;
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
loadBI();
|
||||
loadAll();
|
||||
});
|
||||
|
||||
// === Chart instances ===
|
||||
@@ -766,8 +853,159 @@ async function fetchLiveRate() {
|
||||
} catch (e) { /* retry next cycle */ }
|
||||
}
|
||||
|
||||
// === Revenue Analytics ===
|
||||
let currentGran = 'dia';
|
||||
let chartRevTimeline, chartRevDonut;
|
||||
|
||||
function destroyRevCharts() {
|
||||
[chartRevTimeline, chartRevDonut].forEach(c => { if (c) c.destroy(); });
|
||||
}
|
||||
|
||||
// Color palette for products
|
||||
const productColors = {
|
||||
'BR\u2192US: Checkout': '#1A73E8',
|
||||
'BR\u2192US: CambioTransfer': '#42A5F5',
|
||||
'US\u2192BR: balance': '#1E8E3E',
|
||||
'US\u2192BR: desconhecido': '#66BB6A',
|
||||
'US\u2192BR: swift': '#00897B',
|
||||
'US\u2192BR: wire': '#26A69A',
|
||||
'US\u2192BR: pix': '#4CAF50',
|
||||
};
|
||||
function getProductColor(produto, i) {
|
||||
return productColors[produto] || ['#7B1FA2','#AB47BC','#F9A825','#FF7043','#78909C','#EC407A','#5C6BC0','#8D6E63'][i % 8];
|
||||
}
|
||||
|
||||
async function loadRevenue() {
|
||||
document.getElementById('revenueStatus').textContent = 'Carregando...';
|
||||
try {
|
||||
const resp = await fetch('/admin/api/bi/revenue?start=' + currentStart + '&end=' + currentEnd + '&granularity=' + currentGran);
|
||||
const d = await resp.json();
|
||||
|
||||
if (d.error) { document.getElementById('revenueStatus').textContent = 'Erro: ' + d.error; return; }
|
||||
|
||||
// KPI cards
|
||||
document.getElementById('revTotal').textContent = fmtUSD(d.summary.total_receita);
|
||||
document.getElementById('revTotalQtd').textContent = fmtNum(d.summary.total_qtd) + ' operacoes';
|
||||
document.getElementById('revBrUs').textContent = fmtUSD(d.summary.receita_br_us);
|
||||
document.getElementById('revUsBr').textContent = fmtUSD(d.summary.receita_us_br);
|
||||
document.getElementById('revTicket').textContent = fmtUSD(d.summary.ticket_medio_receita);
|
||||
document.getElementById('revGranBadge').textContent = currentGran;
|
||||
|
||||
// Get unique periods and products
|
||||
const periods = [...new Set(d.timeline.map(r => r.periodo_label))].sort();
|
||||
const products = [...new Set(d.timeline.map(r => r.produto))].sort();
|
||||
|
||||
// Build datasets for stacked bar
|
||||
const datasets = products.map((prod, i) => ({
|
||||
label: prod,
|
||||
data: periods.map(p => {
|
||||
const match = d.timeline.find(r => r.periodo_label === p && r.produto === prod);
|
||||
return match ? match.receita : 0;
|
||||
}),
|
||||
backgroundColor: getProductColor(prod, i),
|
||||
borderRadius: 3
|
||||
}));
|
||||
|
||||
// Stacked bar chart
|
||||
destroyRevCharts();
|
||||
const labels = periods.map(p => {
|
||||
if (currentGran === 'dia') { const parts = p.split('-'); return parts[2] + '/' + parts[1]; }
|
||||
if (currentGran === 'mes') { const parts = p.split('-'); return parts[1] + '/' + parts[0]; }
|
||||
return p;
|
||||
});
|
||||
|
||||
chartRevTimeline = new Chart(document.getElementById('chartRevTimeline'), {
|
||||
type: 'bar',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { font: { size: 11, weight: 600 }, padding: 10, boxWidth: 12 } },
|
||||
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + fmtUSD(ctx.raw) } }
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45 } },
|
||||
y: { stacked: true, grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { callback: v => fmtUSD(v), font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Donut chart by product
|
||||
const donutData = d.totals.map(t => t.receita);
|
||||
const donutLabels = d.totals.map(t => t.produto);
|
||||
const donutColors = d.totals.map((t, i) => getProductColor(t.direcao + ': ' + t.tipo, i));
|
||||
|
||||
chartRevDonut = new Chart(document.getElementById('chartRevDonut'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: donutLabels,
|
||||
datasets: [{ data: donutData, backgroundColor: donutColors, borderWidth: 0, hoverOffset: 8 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { padding: 12, font: { size: 11, weight: 600 }, boxWidth: 12 } },
|
||||
tooltip: { callbacks: { label: ctx => ctx.label + ': ' + fmtUSD(ctx.raw) + ' (' + d.totals[ctx.dataIndex].qtd + ' ops)' } }
|
||||
},
|
||||
cutout: '60%'
|
||||
}
|
||||
});
|
||||
|
||||
// Detailed table
|
||||
const tbody = document.getElementById('revTableBody');
|
||||
if (d.timeline.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);">Sem dados no periodo</td></tr>';
|
||||
} else {
|
||||
// Group by period for subtotals
|
||||
const byPeriod = {};
|
||||
d.timeline.forEach(r => {
|
||||
if (!byPeriod[r.periodo_label]) byPeriod[r.periodo_label] = [];
|
||||
byPeriod[r.periodo_label].push(r);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
for (const [periodo, rows] of Object.entries(byPeriod)) {
|
||||
const periodTotal = rows.reduce((s, r) => s + r.receita, 0);
|
||||
const periodQtd = rows.reduce((s, r) => s + r.qtd, 0);
|
||||
rows.forEach(r => {
|
||||
const ticketOp = r.qtd > 0 ? r.receita / r.qtd : 0;
|
||||
html += '<tr>' +
|
||||
'<td style="font-weight:600;">' + periodo + '</td>' +
|
||||
'<td>' + r.produto + '</td>' +
|
||||
'<td style="color:' + (r.receita >= 0 ? 'var(--green)' : 'var(--red)') + ';font-weight:600;">' + fmtUSD(r.receita) + '</td>' +
|
||||
'<td>' + fmtNum(r.qtd) + '</td>' +
|
||||
'<td>' + fmtUSD(ticketOp) + '</td></tr>';
|
||||
});
|
||||
if (rows.length > 1) {
|
||||
html += '<tr style="background:var(--bg);font-weight:700;">' +
|
||||
'<td>' + periodo + '</td><td>TOTAL</td>' +
|
||||
'<td style="color:' + (periodTotal >= 0 ? 'var(--green)' : 'var(--red)') + ';">' + fmtUSD(periodTotal) + '</td>' +
|
||||
'<td>' + fmtNum(periodQtd) + '</td>' +
|
||||
'<td>' + fmtUSD(periodQtd > 0 ? periodTotal / periodQtd : 0) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
document.getElementById('revenueStatus').textContent = d.timeline.length + ' registros | ' + currentGran;
|
||||
} catch (err) {
|
||||
console.error('Revenue load error:', err);
|
||||
document.getElementById('revenueStatus').textContent = 'Erro ao carregar';
|
||||
}
|
||||
}
|
||||
|
||||
// Granularity buttons
|
||||
document.querySelectorAll('.gran-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.gran-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentGran = btn.dataset.gran;
|
||||
loadRevenue();
|
||||
});
|
||||
});
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => { loadBI(); fetchLiveRate(); });
|
||||
document.addEventListener('DOMContentLoaded', () => { loadBI(); loadRevenue(); fetchLiveRate(); });
|
||||
setInterval(fetchLiveRate, 3000);
|
||||
<\/script>
|
||||
</body>
|
||||
|
||||
194
src/queries.js
194
src/queries.js
@@ -761,6 +761,197 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// Revenue Analytics - Real P&L by product with dynamic granularity
|
||||
async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Validate granularity
|
||||
const validGran = ['dia', 'mes', 'ano'].includes(granularity) ? granularity : 'dia';
|
||||
|
||||
// Dynamic grouping expressions
|
||||
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 (for KPI cards)
|
||||
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]);
|
||||
|
||||
// Format timeline data
|
||||
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)
|
||||
}));
|
||||
|
||||
// Format totals
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTransacoes,
|
||||
fetchAllTransacoes,
|
||||
@@ -771,5 +962,6 @@ module.exports = {
|
||||
fetchTopAgentes,
|
||||
fetchTrendByPeriod,
|
||||
fetchKPIsByPeriod,
|
||||
fetchBIData
|
||||
fetchBIData,
|
||||
fetchRevenueAnalytics
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user