feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user