feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker

This commit is contained in:
VPS 4 Builder
2026-05-15 10:39:35 +02:00
commit 3beac0e0f2
288 changed files with 78761 additions and 0 deletions
+674
View File
@@ -0,0 +1,674 @@
'use strict'
const Cotacao = require('../models/Cotacao')
const Pedido = require('../models/Pedido')
const Produto = require('../models/Produto')
const Promotion = require('../models/Promotion')
const CotacaoFlashPromo = require('../models/CotacaoFlashPromo')
const Notification = require('../models/Notification')
const { queryOne: dbQueryOne, query: dbQuery } = require('../database/postgres')
// Notifica o WA-Inbox que um agente foi atribuído a um pedido (best-effort)
async function notifyNwAssignAgent(pedidoId, accountId) {
if (!accountId) return
try {
const cfgRows = await dbQuery(`SELECT key, value FROM plugin_configs WHERE plugin_id='newwhats'`)
const cfg = {}
for (const r of cfgRows) cfg[r.key] = r.value
if (!cfg.newwhats_url || !cfg.integration_key) return
// Busca chat_id vinculado ao pedido e dados do account
const pedido = await dbQueryOne('SELECT chat_id FROM pedidos WHERE id=$1', [pedidoId])
const account = await dbQueryOne('SELECT id, name FROM accounts WHERE id=$1', [accountId])
if (!pedido?.chat_id || !account) return
await fetch(`${cfg.newwhats_url}/api/ext/v1/protocol/assign-agent`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integration_key },
body: JSON.stringify({ chatId: pedido.chat_id, agentId: String(account.id), agentName: account.name }),
})
} catch { /* best-effort: nunca bloqueia o fluxo principal */ }
}
// Aplica preço promocional se existir (PROMO-03)
async function resolverPreco(produtoId, tenantId) {
const prod = await Produto.findById(produtoId)
if (!prod) return null
return {
preco_unitario: Number(prod.preco),
preco_promocional: prod.preco_promocional ? Number(prod.preco_promocional) : null,
nome_produto: prod.nome,
sku: prod.sku,
}
}
// Desconto por volume PJ (PROMO-05): >10 itens = 3%, >30 = 5%, >50 = 8%
function descontoPorVolume(tipo, quantidade) {
if (tipo !== 'PJ') return 0
if (quantidade >= 50) return 8
if (quantidade >= 30) return 5
if (quantidade >= 10) return 3
return 0
}
const cotacaoController = {
// ── Área do cliente ───────────────────────────────────────
async minhas(req, res) {
try {
const rows = await Cotacao.listByUser(req.user.id, req.tenant.id)
res.json(rows)
} catch (err) {
res.status(500).json({ error: 'Erro ao listar cotações' })
}
},
async getCotacao(req, res) {
try {
const cotacao = await Cotacao.findById(req.params.id)
if (!cotacao) return res.status(404).json({ error: 'Cotação não encontrada' })
const itens = await Cotacao.listItens(req.params.id)
res.json({ ...cotacao, itens })
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar cotação' })
}
},
async criarOuObterRascunho(req, res) {
try {
const tenantId = req.tenant.id
const userId = req.user.id
// Busca rascunho aberto do usuário
const [rascunho] = await Cotacao.listByUser(userId, tenantId)
.then(rows => rows.filter(r => r.status === 'rascunho'))
if (rascunho) {
const itens = await Cotacao.listItens(rascunho.id)
return res.json({ ...rascunho, itens })
}
const nova = await Cotacao.create({ tenant_id: tenantId, user_id: userId })
res.status(201).json({ ...nova, itens: [] })
} catch (err) {
console.error('[cotacao.rascunho]', err.message)
res.status(500).json({ error: 'Erro ao criar carrinho' })
}
},
async addItem(req, res) {
try {
const { cotacao_id, produto_id, quantidade } = req.body
if (!cotacao_id || !produto_id || !quantidade) {
return res.status(400).json({ error: 'cotacao_id, produto_id e quantidade são obrigatórios' })
}
const cotacao = await Cotacao.findById(cotacao_id)
if (!cotacao) return res.status(404).json({ error: 'Cotação não encontrada' })
const info = await resolverPreco(produto_id, req.tenant.id)
if (!info) return res.status(404).json({ error: 'Produto não encontrado' })
const desconto_pct = descontoPorVolume(cotacao.cliente_tipo, quantidade)
const item = await Cotacao.addItem({
cotacao_id,
produto_id,
...info,
quantidade,
desconto_pct,
})
await Cotacao.recalcTotal(cotacao_id)
res.status(201).json(item)
} catch (err) {
console.error('[cotacao.addItem]', err.message)
res.status(500).json({ error: 'Erro ao adicionar item' })
}
},
async removeItem(req, res) {
try {
await Cotacao.removeItem(req.params.itemId)
await Cotacao.recalcTotal(req.params.cotacaoId)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao remover item' })
}
},
async updateItemObs(req, res) {
try {
const { itemId } = req.params
const { observacao } = req.body
await Cotacao.updateItemObs(itemId, observacao)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao salvar observação' })
}
},
async salvar(req, res) {
try {
const { id } = req.params
await Cotacao.update(id, { ...(await Cotacao.findById(id)), status: 'salva' })
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao salvar cotação' })
}
},
async finalizar(req, res) {
try {
const { id } = req.params
const { forma_pagto, endereco_id, tipo_entrega, observacao, endereco } = req.body
if (!forma_pagto) return res.status(400).json({ error: 'forma_pagto é obrigatória' })
const cotacao = await Cotacao.findById(id)
if (!cotacao) return res.status(404).json({ error: 'Cotação não encontrada' })
if (cotacao.status === 'finalizada') return res.status(409).json({ error: 'Cotação já finalizada' })
await Cotacao.update(id, { ...cotacao, forma_pagto })
const pedido = await Cotacao.toOrder(id, { endereco_id, tipo_entrega, observacao })
// Endereço inline (sem endereco_id salvo)
if (endereco && tipo_entrega === 'entrega') {
await Pedido.updateAddressInline(pedido.id, endereco)
}
// Pagamento na entrega → aguarda aprovação financeira
if (forma_pagto === 'na_entrega') {
const { execute } = require('../database/postgres')
await execute(
`UPDATE pedidos SET financeiro_status='pendente', updated_at=NOW() WHERE id=$1`,
[pedido.id]
)
}
res.status(201).json(pedido)
} catch (err) {
console.error('[cotacao.finalizar]', err.message)
res.status(500).json({ error: 'Erro ao finalizar cotação' })
}
},
// ── Admin — financeiro ────────────────────────────────────
async listFinanceiro(req, res) {
try {
const { limit, offset } = req.query
const rows = await Pedido.listFinanceiro(req.tenant.id, { limit, offset })
res.json(rows)
} catch (err) {
res.status(500).json({ error: 'Erro ao listar pendências financeiras' })
}
},
async financeiroDecisao(req, res) {
try {
const { id } = req.params
const { acao, obs } = req.body
if (!['aprovar', 'rejeitar'].includes(acao)) {
return res.status(400).json({ error: 'acao deve ser aprovar ou rejeitar' })
}
const pedido = await Pedido.findById(id)
if (!pedido) return res.status(404).json({ error: 'Pedido não encontrado' })
if (pedido.financeiro_status !== 'pendente') {
return res.status(409).json({ error: 'Pedido já foi avaliado pelo financeiro' })
}
await Pedido.financeiroDecisao(id, { acao, obs })
res.json({ ok: true, acao })
} catch (err) {
console.error('[cotacao.financeiroDecisao]', err.message)
res.status(500).json({ error: 'Erro ao processar decisão financeira' })
}
},
// ── Admin — pedidos ───────────────────────────────────────
async listPedidos(req, res) {
try {
const { status, limit, offset } = req.query
const rows = await Pedido.listAdmin(req.tenant.id, { status, limit, offset })
res.json(rows)
} catch (err) {
res.status(500).json({ error: 'Erro ao listar pedidos' })
}
},
async getPedido(req, res) {
try {
const pedido = await Pedido.findById(req.params.id)
if (!pedido) return res.status(404).json({ error: 'Pedido não encontrado' })
const [itens, historico_responsaveis] = await Promise.all([
Pedido.listItens(req.params.id),
Pedido.findHistorico(req.params.id),
])
res.json({ ...pedido, itens, historico_responsaveis })
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar pedido' })
}
},
async updateStatusPedido(req, res) {
try {
const { status, responsavel_entrega } = req.body
const statusValidos = ['aguardando','separacao','embalagem','auditoria','fiscal','expedicao','entregue','cancelado']
if (!statusValidos.includes(status)) return res.status(400).json({ error: 'Status inválido' })
const { expedidor_id } = req.body
const accountId = expedidor_id
? parseInt(expedidor_id)
: (req.admin?.id || req.teamAccount?.accountId || null)
if (status === 'entregue') {
await Pedido.marcarEntregue(req.params.id, responsavel_entrega || null, accountId)
} else {
await Pedido.updateStatus(req.params.id, status, accountId)
}
// Notificação interna para o usuário (user_id) que fez o pedido
const STATUS_NOTIF = {
separacao: { title: '📦 Pedido em separação', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} está sendo preparado.` },
embalagem: { title: '📦 Pedido em embalagem', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} está sendo embalado.` },
expedicao: { title: '🚚 Pedido saiu para entrega', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} saiu para entrega. Fique de olho!` },
entregue: { title: '✅ Pedido marcado como entregue', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} foi marcado como entregue. Se não recebeu, entre em contato.` },
cancelado: { title: '❌ Pedido cancelado', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} foi cancelado. Fale conosco se precisar de ajuda.` },
auditoria: { title: '🔍 Pedido em auditoria', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} está em verificação final.` },
fiscal: { title: '🧾 Pedido em processamento fiscal', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} está na etapa fiscal.` },
}
if (STATUS_NOTIF[status]) {
setImmediate(async () => {
try {
const pedido = await Pedido.findById(req.params.id)
if (!pedido?.user_id) return
const userRow = await dbQueryOne('SELECT cpf FROM users WHERE id = $1', [pedido.user_id])
if (!userRow?.cpf) return
const notif = STATUS_NOTIF[status]
await Notification.create({
user_cpf: userRow.cpf.replace(/\D/g, ''),
title: notif.title,
message: notif.message(pedido),
type: status === 'cancelado' ? 'error' : status === 'entregue' ? 'success' : 'info',
})
} catch { /* silent */ }
})
}
// WPP-08 — notifica cliente por WhatsApp em mudanças de status relevantes
const STATUS_MSG = {
separacao: (p) => `📦 *Pedido #${p.id}${p.protocolo ? ` (${p.protocolo})` : ''} em separação!*\nEstamos preparando seu pedido. Em breve está pronto! 😊`,
expedicao: (p) => `🚚 *Pedido #${p.id}${p.protocolo ? ` (${p.protocolo})` : ''} saiu para entrega!*\nEm breve chegará até você. Qualquer dúvida, fale conosco! 🛵`,
entregue: (p) => `✅ *Pedido #${p.id} entregue com sucesso!*\nObrigado pela confiança! Ficamos felizes em servir você. ⭐`,
cancelado: (p) => `❌ *Pedido #${p.id} cancelado.*\nPrecisa de ajuda? Fale conosco aqui pelo WhatsApp.`,
}
if (STATUS_MSG[status]) {
setImmediate(async () => {
try {
const pedido = await Pedido.findById(req.params.id)
if (!pedido?.cliente_id) return
const { query: dbQuery } = require('../database/postgres')
const tels = await dbQuery(
`SELECT numero FROM cliente_telefones WHERE cliente_id = $1 AND aceita_whatsapp = true ORDER BY principal DESC LIMIT 1`,
[pedido.cliente_id]
)
if (!tels.length) return
const numero = tels[0].numero.replace(/\D/g, '')
const msg = STATUS_MSG[status](pedido)
const apiUrl = process.env.NEWWHATS_API_URL
if (!apiUrl) return
await fetch(`${apiUrl}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.NEWWHATS_API_KEY || ''}` },
body: JSON.stringify({ phone: `55${numero}`, message: msg }),
}).catch(() => {})
} catch { /* silent — notificação não deve travar o fluxo */ }
})
}
// Notifica WA-Inbox (best-effort)
if (accountId) setImmediate(() => notifyNwAssignAgent(req.params.id, accountId))
res.json({ ok: true })
} catch (err) {
console.error('[pedido.updateStatus]', err.message)
res.status(500).json({ error: 'Erro ao atualizar status' })
}
},
// EMB-02 — registra embalador; aceita embalador_id (account FK) ou texto legado
async marcarEmbalado(req, res) {
try {
const { embalador, embalador_id } = req.body
if (!embalador && !embalador_id) return res.status(400).json({ error: 'embalador ou embalador_id é obrigatório' })
// accountId: preferência para embalador_id explícito, depois token do colaborador logado
const accountId = embalador_id
? parseInt(embalador_id)
: (req.admin?.id || req.teamAccount?.accountId || null)
await Pedido.marcarEmbalado(req.params.id, embalador || null, accountId)
if (accountId) setImmediate(() => notifyNwAssignAgent(req.params.id, accountId))
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao registrar embalagem' })
}
},
// Lista colaboradores disponíveis para uma função operacional. A regra é
// direta: o colaborador aparece se está vinculado (team_members) a um setor
// cujo nome bate com a função. Cada função aceita variações de nome para
// tolerar grafias comuns ("Separação"/"Separacao"/"Separar"). Setor é a fonte
// da verdade — gerenciado em /admin-v2/equipe; nada para configurar em Settings.
async colaboradoresDisponiveis(req, res) {
try {
const SETOR_POR_FUNCAO = {
separador: ['separação','separacao','separar','separador'],
embalador: ['embalagem','embalador','embalar'],
auditor: ['auditoria','auditor','auditar'],
fiscal: ['fiscal','fiscalização','fiscalizacao'],
expedidor: ['expedição','expedicao','entrega','expedidor','motoboy','entregador'],
}
const funcao = String(req.query.funcao || '').toLowerCase()
const aliases = SETOR_POR_FUNCAO[funcao]
if (!aliases) {
return res.status(400).json({ error: 'Função inválida', funcoes_validas: Object.keys(SETOR_POR_FUNCAO) })
}
const tenantId = req.tenant.id
// Coluna de FK no pedido para contar pedidos ativos por colaborador
const FK = {
separador: 'separador_id', embalador: 'embalador_id',
auditor: 'auditor_id', fiscal: 'fiscal_id',
expedidor: 'expedidor_id',
}[funcao]
// Match case-insensitive contra os aliases do setor.
// DISTINCT ON evita duplicar colaborador que esteja em mais de um setor compatível.
const colaboradores = await dbQuery(`
SELECT DISTINCT ON (a.id)
a.id, a.name, a.email, a.role, a.availability, a.avatar_url, a.phone_connected,
COALESCE(BOOL_OR(tm.on_duty) OVER (PARTITION BY a.id), FALSE) AS on_duty,
s.name AS sector_match,
(SELECT COUNT(*)::int
FROM pedidos p
WHERE p.tenant_id = $1
AND p.${FK} = a.id
AND p.status NOT IN ('entregue','cancelado')) AS pedidos_ativos,
(SELECT MAX(created_at)
FROM pedidos p
WHERE p.tenant_id = $1
AND p.${FK} = a.id) AS ultimo_pedido_em
FROM accounts a
JOIN team_members tm ON tm.account_id = a.id
JOIN sectors s ON s.id = tm.sector_id
WHERE a.tenant_id = $1
AND a.active = TRUE
AND s.active = TRUE
AND LOWER(s.name) = ANY($2::text[])
ORDER BY a.id, a.name
`, [tenantId, aliases])
// Reordena no Node (simples e legível) — disponível primeiro, depois ocupado, offline por último.
colaboradores.sort((x, y) => {
const r = (a) => a.availability === 'online' ? 0 : a.availability === 'busy' ? 1 : 2
const d = r(x) - r(y)
return d !== 0 ? d : x.name.localeCompare(y.name)
})
res.json({
configurado: true,
setores_aceitos: aliases,
colaboradores,
})
} catch (err) {
console.error('[colaboradoresDisponiveis]', err.message)
res.status(500).json({ error: 'Erro ao listar colaboradores' })
}
},
// Endpoint genérico para registrar separador/auditor/fiscal num pedido.
// Embalagem (mudança de status) usa marcarEmbalado; expedição usa marcarEntregue.
async setResponsavelEtapa(req, res) {
try {
const FUNCOES_VALIDAS = ['separador','auditor','fiscal']
const funcao = String(req.params.funcao || '').toLowerCase()
if (!FUNCOES_VALIDAS.includes(funcao)) {
return res.status(400).json({ error: 'Função inválida' })
}
const accountId = req.body.account_id ? parseInt(req.body.account_id) : null
if (!accountId) return res.status(400).json({ error: 'account_id é obrigatório' })
// Valida que o colaborador pertence ao tenant antes de gravar
const acc = await dbQueryOne(
`SELECT id FROM accounts WHERE id=$1 AND tenant_id=$2 AND active=TRUE`,
[accountId, req.tenant.id]
)
if (!acc) return res.status(404).json({ error: 'Colaborador não encontrado' })
await Pedido.setResponsavel(req.params.id, funcao, accountId)
setImmediate(() => notifyNwAssignAgent(req.params.id, accountId))
res.json({ ok: true })
} catch (err) {
console.error('[setResponsavelEtapa]', err.message)
res.status(500).json({ error: 'Erro ao atribuir responsável' })
}
},
// Registra cupom fiscal emitido externamente (PDV/ECF).
// Aceita em qualquer fase pós-pagamento — validação leve para não bloquear
// workflow real do operador. Pode ser sobrescrito (re-emissão por erro).
async registrarCupomFiscal(req, res) {
try {
const id = req.params.id
const { numero, chave } = req.body || {}
const num = String(numero || '').trim()
const chave44 = String(chave || '').replace(/\D/g, '')
if (!num && !chave44) {
return res.status(400).json({ error: 'Informe o número do cupom ou a chave de acesso (44 dígitos).' })
}
if (chave44 && chave44.length !== 44) {
return res.status(400).json({ error: 'Chave de acesso NFC-e deve ter 44 dígitos.' })
}
const pedido = await Pedido.findById(id)
if (!pedido) return res.status(404).json({ error: 'Pedido não encontrado' })
if (pedido.tenant_id !== req.tenant.id) return res.status(403).json({ error: 'Acesso negado' })
if (['cancelado'].includes(pedido.status)) {
return res.status(409).json({ error: 'Pedido cancelado — não é possível emitir cupom.' })
}
const accountId = req.admin?.id || req.teamAccount?.accountId || null
await Pedido.registrarCupom(id, { numero: num || null, chave: chave44 || null, accountId })
const atualizado = await Pedido.findById(id)
res.json({
ok: true,
cupom_numero: atualizado.cupom_numero,
cupom_chave: atualizado.cupom_chave,
cupom_emitido_em: atualizado.cupom_emitido_em,
cupom_emitido_por: atualizado.cupom_emitido_por,
cupom_emitido_por_nome: atualizado.cupom_emitido_por_nome,
})
} catch (err) {
console.error('[registrarCupomFiscal]', err.message)
res.status(500).json({ error: 'Erro ao registrar cupom fiscal' })
}
},
async atribuirEquipe(req, res) {
try {
await Pedido.atribuirEquipe(req.params.id, req.body.equipe_id)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao atribuir equipe' })
}
},
async marcarItemSeparado(req, res) {
try {
await Pedido.marcarItemSeparado(req.params.itemId, req.body.separado !== false)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao marcar item' })
}
},
async setAlertaItem(req, res) {
try {
const { tipo, obs, foto } = req.body
const TIPOS = ['falta', 'marca_diferente', 'substituicao']
if (!TIPOS.includes(tipo)) return res.status(400).json({ error: 'tipo inválido: falta | marca_diferente | substituicao' })
await Pedido.setAlertaItem(req.params.itemId, { tipo, obs, foto })
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao salvar alerta' })
}
},
async limparAlertaItem(req, res) {
try {
await Pedido.limparAlertaItem(req.params.itemId)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao limpar alerta' })
}
},
async confirmarAlertaItem(req, res) {
try {
await Pedido.confirmarAlertaItem(req.params.itemId)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao confirmar alerta' })
}
},
// DASH-04 — notificações internas de novos pedidos
async notificacoes(req, res) {
try {
const minutes = Math.min(Number(req.query.minutes) || 1440, 10080) // max 7 dias
const rows = await Pedido.notificacoes(req.tenant.id, minutes)
res.json(rows)
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar notificações' })
}
},
async contadores(req, res) {
try {
const dados = await Pedido.contadores(req.tenant.id)
// Promoções ativas (PROMO-09)
const promos = await Promotion.findAllActive(req.tenant.id)
res.json({ ...dados, promocoes_ativas: promos.length })
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar contadores' })
}
},
// PROMO-10 — Flash promo ativa para o checkout da cotação
async getFlashPromo(req, res) {
try {
const promo = await CotacaoFlashPromo.getActive(req.tenant?.id ?? 1)
res.json(promo ?? null)
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar flash promo' })
}
},
async createFlashPromo(req, res) {
try {
const { produto_id, discount_pct, label, duration_minutes } = req.body
if (!discount_pct || !duration_minutes) {
return res.status(400).json({ error: 'discount_pct e duration_minutes são obrigatórios' })
}
const result = await CotacaoFlashPromo.create({
tenantId: req.tenant?.id ?? 1,
produtoId: produto_id || null,
discount_pct,
label,
duration_minutes,
})
res.status(201).json(result)
} catch (err) {
res.status(500).json({ error: 'Erro ao criar flash promo' })
}
},
async cancelFlashPromo(req, res) {
try {
await CotacaoFlashPromo.cancel(req.params.id)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ error: 'Erro ao cancelar flash promo' })
}
},
// FUT-04/05 — Ranking de equipes e relatório de performance
async rankingEquipes(req, res) {
try {
const { query } = require('../database/postgres')
const { from, to } = req.query
const params = [req.tenant.id]
let where = 'WHERE p.tenant_id = $1'
if (from) { params.push(from); where += ` AND DATE(p.created_at) >= $${params.length}` }
if (to) { params.push(to); where += ` AND DATE(p.created_at) <= $${params.length}` }
const ranking = await query(
`SELECT
p.equipe_id,
COUNT(*) FILTER (WHERE p.status = 'entregue') AS pedidos_entregues,
COUNT(*) FILTER (WHERE p.status NOT IN ('cancelado','aguardando')) AS pedidos_processados,
ROUND(AVG(EXTRACT(EPOCH FROM (p.entregue_em - p.created_at))/3600)::NUMERIC, 1) AS media_horas_entrega,
COUNT(*) FILTER (WHERE p.embalador IS NOT NULL) AS pedidos_embalados,
MIN(p.created_at)::DATE AS primeiro_pedido,
MAX(p.created_at)::DATE AS ultimo_pedido
FROM pedidos p
${where}
AND p.equipe_id IS NOT NULL
GROUP BY p.equipe_id
ORDER BY pedidos_entregues DESC`,
params
)
res.json(ranking)
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar ranking' })
}
},
async relatorioSetor(req, res) {
try {
const { query } = require('../database/postgres')
const { from, to } = req.query
const params = [req.tenant.id]
let where = 'WHERE tenant_id = $1'
if (from) { params.push(from); where += ` AND DATE(created_at) >= $${params.length}` }
if (to) { params.push(to); where += ` AND DATE(created_at) <= $${params.length}` }
const [volumeStatus, volumeDia, top_total] = await Promise.all([
query(
`SELECT status, COUNT(*) AS total, SUM(total) AS receita
FROM pedidos ${where} GROUP BY status ORDER BY total DESC`,
params
),
query(
`SELECT DATE(created_at) AS dia, COUNT(*) AS pedidos, SUM(total) AS receita
FROM pedidos ${where} GROUP BY DATE(created_at) ORDER BY dia DESC LIMIT 30`,
params
),
query(
`SELECT cl.nome AS cliente, COUNT(*) AS pedidos, SUM(p.total) AS gasto
FROM pedidos p
LEFT JOIN clientes cl ON cl.id = p.cliente_id
${where} AND p.status = 'entregue'
GROUP BY cl.nome ORDER BY gasto DESC LIMIT 10`,
params
),
])
res.json({ volume_por_status: volumeStatus, volume_por_dia: volumeDia, top_clientes: top_total })
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar relatório' })
}
},
}
module.exports = cotacaoController