675 lines
27 KiB
JavaScript
675 lines
27 KiB
JavaScript
'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
|