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
+842
View File
@@ -0,0 +1,842 @@
'use strict'
/**
* context-builder.js
* Constrói o contexto local do projeto Alemão Conveniências para enriquecer
* cada chamada à Secretária IA no motor NewWhats.
*
* O objeto retornado é injetado no system prompt do ProtocolEngine como
* "=== CONTEXTO DO CLIENTE (dados reais do projeto) ===".
*
* Se nada for encontrado, retorna null (sem overhead no motor).
*/
const { queryOne, query } = require('../../database/postgres')
/**
* Normaliza um número de telefone para 1011 dígitos (sem código de país).
* Entrada: JID whatsapp (556799138794@s.whatsapp.net) ou número bruto.
*/
function normalizePhone(raw) {
if (!raw) return ''
// Remove tudo que não for dígito e código de país BR
const digits = raw.replace(/\D/g, '')
if (digits.startsWith('55') && digits.length >= 12) return digits.slice(2)
return digits
}
/**
* Tenta localizar um usuário pelo telefone normalizado.
* Retorna o registro ou null se não encontrado.
*/
const escapeLike = s => s.replace(/[%_\\]/g, c => '\\' + c)
async function findUserByPhone(phone) {
if (!phone || phone.length < 8) return null
try {
// Tenta match exato e com variações de comprimento (com/sem 9 extra)
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3)) // remove 9 extra
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2)) // adiciona 9 extra
for (const v of variants) {
const user = await queryOne(
`SELECT id, nome, cpf, email, telefone, created_at
FROM users
WHERE REGEXP_REPLACE(telefone, '[^0-9]', '', 'g') LIKE $1
LIMIT 1`,
[`%${escapeLike(v)}`],
)
if (user) return user
}
} catch (e) {
// Tabela pode ter nome diferente — tenta customers
try {
const user = await queryOne(
`SELECT id, nome, cpf, email, telefone, created_at FROM customers WHERE telefone LIKE $1 LIMIT 1`,
[`%${escapeLike(phone)}`],
)
if (user) return user
} catch { /* sem tabela customers */ }
}
return null
}
/**
* Busca o clube e plano ativo do usuário.
*/
async function findActivePlan(userCpf) {
if (!userCpf) return null
try {
return await queryOne(
`SELECT cm.status, cm.started_at AS member_since,
bc.name AS club_name,
cp.name AS plan_name, cp.price_monthly AS price
FROM club_members cm
JOIN benefit_clubs bc ON bc.id = cm.club_id
JOIN club_plans cp ON cp.id = cm.plan_id
WHERE cm.user_cpf = $1 AND cm.status IN ('active','pending_payment')
ORDER BY cm.started_at DESC
LIMIT 1`,
[userCpf.replace(/\D/g, '')],
)
} catch { return null }
}
/**
* Busca os últimos pedidos do usuário (tabela pedidos, por user_id).
*/
async function findRecentOrders(userId, limit = 3) {
if (!userId) return []
try {
return await query(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega, total, financeiro_status, created_at
FROM pedidos
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[userId, limit],
)
} catch { return [] }
}
/**
* Busca promoções ativas do dia (schema correto do projeto).
*/
async function findActivePromotions(tenantId = 1) {
try {
return await query(
`SELECT nome, descricao, preco_original, preco_promocional
FROM promotions
WHERE ativo = TRUE AND tenant_id = $1
ORDER BY data_criacao DESC
LIMIT 5`,
[tenantId],
)
} catch { return [] }
}
/**
* Localiza um funcionário/colaborador pelo telefone na tabela accounts.
* Retorna o registro ou null. Usado para detectar quando o remetente é
* um agente interno — evita que a IA trate o funcionário como cliente externo.
*/
async function findAccountByPhone(phone) {
if (!phone || phone.length < 8) return null
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3))
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2))
try {
for (const v of variants) {
const row = await queryOne(
`SELECT id, name, role, availability
FROM accounts
WHERE REGEXP_REPLACE(phone, '[^0-9]', '', 'g') LIKE $1
AND active = true
LIMIT 1`,
[`%${escapeLike(v)}`],
)
if (row) return row
}
} catch { /* tabela accounts pode não existir em todos os projetos */ }
return null
}
/**
* WPP-02 — Localiza cliente PF/PJ na tabela clientes pelo telefone.
*/
async function findClienteByPhone(phone, tenantId = 1) {
if (!phone || phone.length < 8) return null
try {
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3))
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2))
for (const v of variants) {
const row = await queryOne(
`SELECT c.id, c.nome, c.tipo, c.cpf_cnpj, c.email, c.status
FROM clientes c
JOIN cliente_telefones ct ON ct.cliente_id = c.id
WHERE c.tenant_id = $1
AND REGEXP_REPLACE(ct.numero, '[^0-9]', '', 'g') LIKE $2
LIMIT 1`,
[tenantId, `%${escapeLike(v)}`],
)
if (row) return row
}
} catch { /* tabela pode não existir ainda */ }
return null
}
// Mapeamento de status para descrição amigável
const STATUS_COTACAO = {
rascunho: 'rascunho (carrinho ainda não confirmado)',
salva: 'salva (aguardando confirmação do cliente)',
enviada: 'enviada (aguardando análise)',
processando: 'em processamento',
concluida: 'concluída',
finalizada: 'finalizada (pedido gerado)',
cancelada: 'cancelada',
}
const STATUS_PEDIDO = {
aguardando: 'aguardando (recebido, aguardando separação)',
novo: 'novo (recebido, aguardando separação)',
separacao: 'em separação (itens sendo separados no estoque)',
embalagem: 'em embalagem (sendo embalado)',
expedicao: 'saiu para entrega / expedição',
entregue: 'entregue',
cancelado: 'cancelado',
auditoria: 'em auditoria interna',
fiscal: 'em processamento fiscal',
}
const FINANCEIRO_STATUS = {
pendente: 'pagamento pendente',
aprovado: 'pagamento aprovado',
recusado: 'pagamento recusado',
reembolsado: 'reembolsado',
}
function descStatus(val, map) {
if (!val) return null
return map[val] ?? val
}
/**
* Extrai menções de cotação ou pedido ID/protocolo da mensagem do usuário.
* Ex: "cotação #1", "cotação 1", "pedido #3", "PED-20260426-3"
*/
function extractMentions(msg) {
if (!msg) return []
const results = []
// Protocolo completo: PED-XXXXXXXX-N
const protos = msg.match(/PED-\d{8}-\d+/gi) ?? []
protos.forEach(p => results.push({ type: 'protocolo', value: p.toUpperCase() }))
// cotação #N ou cotacao N
const cots = msg.match(/cota[çc][aã]o\s*[#nº]?\s*(\d+)/gi) ?? []
cots.forEach(m => {
const id = m.match(/\d+/)?.[0]
if (id) results.push({ type: 'cotacao_id', value: id })
})
// pedido #N
const peds = msg.match(/pedido\s*[#nº]?\s*(\d+)/gi) ?? []
peds.forEach(m => {
const id = m.match(/\d+/)?.[0]
if (id) results.push({ type: 'pedido_id', value: id })
})
return results
}
/**
* Busca cotação + pedido vinculado diretamente por ID (qualquer usuário).
*/
async function findCotacaoById(id, tenantId = 1) {
try {
const c = await queryOne(
`SELECT c.id, c.status AS cotacao_status, c.total,
p.id AS pedido_id, p.protocolo, p.status AS pedido_status,
p.forma_pagto, p.tipo_entrega, p.financeiro_status,
p.created_at AS pedido_criado_em
FROM cotacoes c
LEFT JOIN pedidos p ON p.cotacao_id = c.id AND p.tenant_id = c.tenant_id
WHERE c.id = $1 AND c.tenant_id = $2
ORDER BY p.id DESC
LIMIT 1`,
[id, tenantId],
)
return c
} catch { return null }
}
/**
* Busca pedido por protocolo (qualquer usuário).
*/
async function findPedidoByProtocolo(protocolo, tenantId = 1) {
try {
return await queryOne(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega,
financeiro_status, total, created_at
FROM pedidos
WHERE protocolo ILIKE $1 AND tenant_id = $2
LIMIT 1`,
[protocolo, tenantId],
)
} catch { return null }
}
/**
* WPP-04 — Busca cotação aberta do cliente.
*/
async function findCotacaoAberta(clienteId) {
if (!clienteId) return null
try {
return await queryOne(
`SELECT c.id, c.status, c.total,
COUNT(ci.id) AS total_itens
FROM cotacoes c
LEFT JOIN cotacao_itens ci ON ci.cotacao_id = c.id
WHERE c.cliente_id = $1 AND c.status IN ('rascunho','salva')
GROUP BY c.id
ORDER BY c.updated_at DESC
LIMIT 1`,
[clienteId],
)
} catch { return null }
}
/**
* WPP-04 — Busca últimos pedidos do cliente no módulo de cotação.
*/
async function findPedidosCliente(clienteId, limit = 3) {
if (!clienteId) return []
try {
return await query(
`SELECT id, status, total, tipo_entrega, created_at
FROM pedidos
WHERE cliente_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[clienteId, limit],
)
} catch { return [] }
}
/**
* WPP-03 — WPP-04 — Produtos em destaque por categoria para sugestão rápida.
*/
async function findProdutosDestaque(tenantId = 1) {
try {
return await query(
`SELECT nome, sku, preco, preco_promocional AS preco_promo, unidade, estoque, categoria
FROM produtos p
WHERE tenant_id = $1 AND ativo = TRUE AND estoque > 0
ORDER BY categoria, nome
LIMIT 12`,
[tenantId],
)
} catch { return [] }
}
// ── Saudação contextual por horário (America/Sao_Paulo) ─────────────────────
function saudacaoPorHorario(date = new Date()) {
// Ajusta para fuso de SP (-03:00) sem dependência externa
const utc = date.getUTCHours() * 60 + date.getUTCMinutes()
const minSP = (utc - 180 + 1440) % 1440
const h = Math.floor(minSP / 60)
const dow = (date.getUTCDay() + (utc - 180 < 0 ? -1 : 0) + 7) % 7
const periodo = h < 5 ? 'madrugada' : h < 12 ? 'manhã' : h < 18 ? 'tarde' : 'noite'
const saudacoes = {
madrugada: 'Boa madrugada',
'manhã': 'Bom dia',
tarde: 'Boa tarde',
noite: 'Boa noite',
}
// Verifica se a loja está aberta conforme brain (Seg-Qui 8-23, Sex-Sab 8-2, Dom 10-22)
let aberto
if (dow === 0) aberto = h >= 10 && h < 22 // domingo
else if (dow >= 1 && dow <= 4) aberto = h >= 8 && h < 23 // seg-qui
else if (dow === 5) aberto = h >= 8 || h < 2 // sexta (atravessa madrugada)
else aberto = h >= 8 || h < 2 // sábado
return {
saudacao: saudacoes[periodo],
periodo,
hora_local:`${String(h).padStart(2, '0')}h`,
loja_aberta: aberto,
dia_semana: ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'][dow],
}
}
// ── Detecção de intenções na mensagem ───────────────────────────────────────
const STOPWORDS = new Set([
'o','a','os','as','um','uma','de','da','do','das','dos','em','no','na','nos','nas',
'e','ou','que','para','pra','por','com','sem','meu','minha','seu','sua','tem','ter',
'tinha','vou','quero','queria','gostaria','poderia','pode','podem','é','são','foi',
'voce','você','vc','ai','aí','la','lá','aqui','tudo','bem','oi','ola','olá','bom',
'boa','dia','tarde','noite','obrigado','obrigada','valeu','flw','ok','sim','não','nao',
])
function tokenize(msg) {
return (msg || '')
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(t => t.length >= 3 && !STOPWORDS.has(t))
}
function detectIntents(msg) {
const m = (msg || '').toLowerCase()
return {
pergunta_horario: /\b(abert[oa]s?|fechad[oa]s?|hor[áa]rio|funcion|que\s+horas)\b/i.test(m),
pergunta_endereco: /\b(endere[çc]o|onde\s+(fica|esta|est[áa])|localiza|como\s+chego)\b/i.test(m),
pergunta_entrega: /\b(entrega|entregar|delivery|tele|leva\s+at[ée]|taxa\s+de\s+entrega|frete|prazo)\b/i.test(m),
pergunta_pagamento: /\b(pagamento|pagar|pix|cart[ãa]o|d[ée]bito|cr[ée]dito|dinheiro|boleto|forma\s+de\s+pag)\b/i.test(m),
listar_meus_pedidos: /\b(meus\s+pedidos|minhas\s+compras|hist[óo]rico|o\s+que\s+(j[áa]\s+)?comprei)\b/i.test(m),
pergunta_sorteio: /\b(sorteio|rifa|ticket|sorteios|premia[çc][aã]o|ganhei)\b/i.test(m),
pergunta_clube: /\b(clube|fideli|assinatura|plano|membro|benef[íi]cio)\b/i.test(m),
urgencia: /\b(urgente|urg[ée]ncia|r[áa]pido|imediato|j[áa]\s+(j[áa]|ja)|tem\s+pressa|t[ôo]\s+com\s+pressa|estou\s+com\s+pressa|emerg[êe]ncia)\b/i.test(m),
frustracao: /\b(absurdo|p[ée]ssimo|horr[ií]vel|nunca\s+mais|cad[êe]\s+meu|reclama[çc][aã]o|cancela|inaceit[áa]vel|demor[oa]u\s+demais|n[ãa]o\s+aguento|t[ôo]\s+puto)\b/i.test(m),
despedida: /^(obrigad[oa]|valeu|flw|tchau|at[ée]|brigad[oa]|t[áa]\s+bom|ok\s+obrigad|nada\s+mais)[\s!.\,]*$/i.test(m.trim()),
saudacao_simples: /^(oi|ol[áa]|opa|eai|e\s+a[íi]|bom\s+dia|boa\s+(tarde|noite|madrugada)|alguem\s+a[íi])[\s!.\,?]*$/i.test(m.trim()),
msg_curta_ambigua: m.trim().length > 0 && m.trim().length < 4, // "?", "ok", "..."
}
}
// ── Busca de produtos por palavras-chave da mensagem ────────────────────────
async function findProdutosByKeywords(tokens, tenantId = 1, limit = 8) {
if (!tokens?.length) return []
try {
// Cria pattern ILIKE para cada token e busca em nome/categoria/descrição
const conditions = tokens.map((_, i) => `(
p.nome ILIKE $${i + 2}
OR p.categoria ILIKE $${i + 2}
OR COALESCE(p.descricao, '') ILIKE $${i + 2}
)`).join(' OR ')
const params = [tenantId, ...tokens.map(t => `%${escapeLike(t)}%`)]
return await query(
`SELECT nome, sku, preco, preco_promocional AS preco_promo, unidade, estoque, categoria
FROM produtos p
WHERE tenant_id = $1 AND ativo = TRUE AND (${conditions})
ORDER BY (estoque > 0) DESC, preco ASC
LIMIT ${Number(limit)}`,
params,
)
} catch { return [] }
}
// ── Mídias recentes processadas (PDF / imagem) ───────────────────────────────
/**
* Busca as últimas mídias processadas neste chat (PDFs e imagens) nas últimas
* `lookbackMinutes` minutos. O texto extraído fica em nw_auto_replies.user_msg
* com prefixo "[documento PDF]" ou "[imagem]".
*
* Útil para que o LLM saiba que o cliente enviou um comprovante/cupom mesmo
* que a mensagem atual seja apenas texto ou um ping de follow-up.
*/
async function findRecentMedia(chatId, lookbackMinutes = 120) {
if (!chatId) return []
try {
const rows = await query(
`SELECT user_msg, created_at
FROM nw_auto_replies
WHERE chat_id = $1
AND created_at > NOW() - ($2 || ' minutes')::interval
AND status IN ('sent','sent_local')
AND (user_msg ILIKE '[documento PDF]%' OR user_msg ILIKE '[imagem]%')
ORDER BY created_at DESC
LIMIT 3`,
[chatId, String(lookbackMinutes)],
)
return rows.map(r => {
const ageMs = Date.now() - new Date(r.created_at).getTime()
const ageMin = Math.round(ageMs / 60_000)
const age = ageMin < 60 ? `${ageMin}min` : `${Math.round(ageMin / 60)}h`
const tipo = r.user_msg.startsWith('[documento PDF]') ? 'PDF' : 'imagem'
return { tipo, conteudo: r.user_msg, enviado: age }
})
} catch { return [] }
}
// ── Sorteios ativos ─────────────────────────────────────────────────────────
async function findSorteiosAtivos(tenantId = 1) {
try {
return await query(
`SELECT id, titulo, descricao, data_sorteio, premio
FROM sorteios
WHERE tenant_id = $1 AND status = 'ativo' AND data_sorteio > NOW()
ORDER BY data_sorteio ASC
LIMIT 5`,
[tenantId],
)
} catch { return [] }
}
/**
* Ponto de entrada principal.
* @param {string} rawPhone — número do remetente (com ou sem código de país)
* @param {number} tenantId
* @param {string} userMsg — mensagem do usuário (para detectar menções de cotação/pedido)
* @param {string|null} chatId — chat_id do WhatsApp (ex: 5567…@s.whatsapp.net); habilita histórico de mídias
* @returns {object|null} contexto para injeção no motor, ou null se nada encontrado
*/
async function buildContext(rawPhone, tenantId = 1, userMsg = '', chatId = null) {
const phone = normalizePhone(rawPhone)
const result = {}
let hasData = false
// ── Saudação contextual e estado da loja ─────────────────────────────────
result.contexto_temporal = saudacaoPorHorario()
hasData = true // sempre injeta, custo zero
// ── Detecção de intenções da mensagem ──────────────────────────────────
const intents = detectIntents(userMsg)
const intentsAtivas = Object.entries(intents).filter(([, v]) => v).map(([k]) => k)
if (intentsAtivas.length > 0) result.intencoes_detectadas = intentsAtivas
// ── Detecção de funcionário/colaborador interno ───────────────────────────
// Se o remetente for um agente cadastrado em accounts, injeta aviso para a IA
// não tratar como cliente externo — evita paradoxos como "como falo com [eu mesmo]?"
const account = await findAccountByPhone(phone)
if (account) {
hasData = true
result.eh_funcionario = true
result.funcionario = {
nome: account.name,
cargo: account.role,
disponivel: account.availability === 'online',
}
}
// ── WPP-02: Cliente do módulo Cotação (PF/PJ) ─────────────────────────────
const cliente = await findClienteByPhone(phone, tenantId)
if (cliente) {
hasData = true
result.cliente_cadastro = {
nome: cliente.nome,
tipo: cliente.tipo, // PF ou PJ
cpf_cnpj: cliente.cpf_cnpj,
email: cliente.email ?? null,
status: cliente.status,
}
// WPP-04: cotação aberta do cliente
const cotacao = await findCotacaoAberta(cliente.id)
if (cotacao) {
result.cotacao_aberta = {
id: cotacao.id,
status: cotacao.status,
total: cotacao.total ? `R$ ${parseFloat(cotacao.total).toFixed(2)}` : 'R$ 0,00',
total_itens: cotacao.total_itens,
}
}
// WPP-04: pedidos recentes do cliente (módulo cotação)
const pedidos = await findPedidosCliente(cliente.id)
if (pedidos.length > 0) {
result.pedidos_cotacao = pedidos.map(p => ({
id: p.id,
status: descStatus(p.status, STATUS_PEDIDO),
valor: p.total ? `R$ ${parseFloat(p.total).toFixed(2)}` : null,
tipo_entrega: p.tipo_entrega,
data: p.created_at ? new Date(p.created_at).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Cliente legacy (tabela users / hotspot) ───────────────────────────────
const user = await findUserByPhone(phone)
if (user) {
hasData = true
if (!result.cliente_cadastro) {
result.cliente = {
nome: user.nome ?? 'Não identificado',
email: user.email ?? null,
telefone: user.telefone ?? phone,
cliente_desde: user.created_at ? new Date(user.created_at).toLocaleDateString('pt-BR') : null,
}
}
// Plano/Clube
const plan = await findActivePlan(user.cpf)
if (plan) {
result.plano = {
clube: plan.club_name,
plano: plan.plan_name ?? 'Sem plano específico',
status: plan.status,
membro_desde: plan.member_since ? new Date(plan.member_since).toLocaleDateString('pt-BR') : null,
preco: plan.price ? `R$ ${parseFloat(plan.price).toFixed(2)}` : null,
}
}
// Pedidos do cliente (app de cotação, via user_id)
const orders = await findRecentOrders(user.id)
if (orders.length > 0) {
result.pedidos_recentes = orders.map(o => ({
protocolo: o.protocolo || `#${o.id}`,
status: o.status,
forma_pagto: o.forma_pagto || null,
tipo_entrega: o.tipo_entrega || null,
valor: o.total ? `R$ ${parseFloat(o.total).toFixed(2)}` : null,
financeiro: o.financeiro_status || null,
data: o.created_at ? new Date(o.created_at).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Promoções/produtos: gate por intenção (economia de tokens) ──────────
// Carrega promoções e destaques só se o cliente está em modo "explorar":
// - saudação simples (mostrar o que tem de novo)
// - intent de promoção/clube/produto
// - mensagem com tokens significativos (busca de produto)
const querUmCatalogo = intents.saudacao_simples ||
intents.pergunta_clube ||
/\b(promo[çc][aã]o|oferta|desconto|tem\s+(o\s+que|algo)|cat[áa]logo|destaque)\b/i.test(userMsg)
if (querUmCatalogo) {
const promos = await findActivePromotions(tenantId)
if (promos.length > 0) {
hasData = true
result.promocoes_ativas = promos.slice(0, 3).map(p => ({
produto: p.nome,
preco_promocional: `R$ ${parseFloat(p.preco_promocional).toFixed(2)}`,
desconto_pct: p.preco_original
? `${Math.round((1 - p.preco_promocional / p.preco_original) * 100)}%`
: null,
}))
}
}
// ── Busca de produtos por palavras-chave da mensagem ────────────────────
// Só dispara se a mensagem tiver tokens significativos (não só saudação)
if (!intents.saudacao_simples && !intents.despedida && !intents.msg_curta_ambigua) {
const tokens = tokenize(userMsg)
if (tokens.length > 0) {
const buscados = await findProdutosByKeywords(tokens, tenantId, 8)
if (buscados.length > 0) {
hasData = true
result.produtos_buscados = buscados.map(p => ({
nome: p.nome,
sku: p.sku,
preco: p.preco_promo
? `R$ ${parseFloat(p.preco_promo).toFixed(2)} (promo, era R$ ${parseFloat(p.preco).toFixed(2)})`
: `R$ ${parseFloat(p.preco).toFixed(2)}`,
unidade: p.unidade,
estoque: Number(p.estoque) > 0 ? 'disponível' : 'indisponível',
categoria: p.categoria,
}))
}
}
}
// ── Sorteios ativos (somente se houver intenção/menção) ────────────────
if (intents.pergunta_sorteio) {
const sorteios = await findSorteiosAtivos(tenantId)
if (sorteios.length > 0) {
hasData = true
result.sorteios_ativos = sorteios.map(s => ({
id: s.id,
titulo: s.titulo,
premio: s.premio,
data_sorteio: s.data_sorteio ? new Date(s.data_sorteio).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Mídias recentes (PDF / imagem processados neste chat) ─────────────────
// Carrega sempre que houver chatId e a mensagem atual não for uma saudação
// simples sem contexto — evita ruído em "oi" de primeiro contato.
if (chatId && !intents.saudacao_simples) {
const midias = await findRecentMedia(chatId)
if (midias.length > 0) {
result.midias_recentes = midias
hasData = true
}
}
// ── Carrinho abandonado (cotação salva sem finalizar há > 4h) ──────────
// Só checa se temos cliente ou user identificado
if (cliente || user) {
try {
const ownerCol = cliente ? 'cliente_id' : 'user_id'
const ownerVal = cliente ? cliente.id : user.id
const carrinho = await queryOne(
`SELECT id, total, updated_at,
(SELECT COUNT(*) FROM cotacao_itens ci WHERE ci.cotacao_id = c.id) AS total_itens
FROM cotacoes c
WHERE ${ownerCol} = $1 AND tenant_id = $2
AND status IN ('rascunho','salva')
AND updated_at < NOW() - INTERVAL '4 hours'
AND updated_at > NOW() - INTERVAL '7 days'
ORDER BY updated_at DESC LIMIT 1`,
[ownerVal, tenantId],
)
if (carrinho && Number(carrinho.total_itens) > 0) {
result.carrinho_abandonado = {
id: carrinho.id,
total_itens: Number(carrinho.total_itens),
valor: carrinho.total ? `R$ ${parseFloat(carrinho.total).toFixed(2)}` : null,
desde: carrinho.updated_at ? new Date(carrinho.updated_at).toLocaleDateString('pt-BR') : null,
}
}
} catch { /* tabelas podem variar */ }
}
// ── Lookup direto por menção na mensagem (cotação #N, pedido #N, PED-…) ──
const mentions = extractMentions(userMsg)
// Busca itens de um pedido para incluir no contexto da IA
async function itensResumidosPedido(pedidoId) {
try {
const rows = await query(
`SELECT nome_produto, quantidade, observacao FROM pedido_itens WHERE pedido_id=$1 ORDER BY id LIMIT 20`,
[pedidoId]
)
return rows.map(i => {
const obs = i.observacao ? ` (obs: ${i.observacao})` : ''
return `${Number(i.quantidade)}x ${i.nome_produto}${obs}`
})
} catch { return [] }
}
async function itensResumidosCotacao(cotacaoId) {
try {
const rows = await query(
`SELECT nome_produto, quantidade, observacao FROM cotacao_itens WHERE cotacao_id=$1 ORDER BY id LIMIT 20`,
[cotacaoId]
)
return rows.map(i => {
const obs = i.observacao ? ` (obs: ${i.observacao})` : ''
return `${Number(i.quantidade)}x ${i.nome_produto}${obs}`
})
} catch { return [] }
}
if (mentions.length > 0) {
const consultados = []
for (const m of mentions) {
if (m.type === 'cotacao_id') {
const row = await findCotacaoById(m.value, tenantId)
if (row) {
const jaEPedido = row.cotacao_status === 'finalizada' && row.pedido_id
if (jaEPedido) {
// Cotação virou pedido — a IA deve falar do pedido, não da cotação
const itens = await itensResumidosPedido(row.pedido_id)
consultados.push({
nota_ia: `O cliente se referiu a "cotação #${m.value}" mas esta já foi confirmada e gerou um PEDIDO. Responda sempre sobre o PEDIDO abaixo, nunca diga "cotação finalizada".`,
pedido_protocolo: row.protocolo ?? `#${row.pedido_id}`,
pedido_status: descStatus(row.pedido_status, STATUS_PEDIDO),
forma_pagto: row.forma_pagto ?? null,
tipo_entrega: row.tipo_entrega ?? null,
financeiro: descStatus(row.financeiro_status, FINANCEIRO_STATUS),
valor: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
criado_em: row.pedido_criado_em ? new Date(row.pedido_criado_em).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
} else {
// Ainda é cotação (rascunho ou salva, sem pedido gerado)
const itens = await itensResumidosCotacao(row.id)
consultados.push({
cotacao_id: row.id,
cotacao_status: descStatus(row.cotacao_status, STATUS_COTACAO),
total: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
itens: itens.length > 0 ? itens : undefined,
})
}
hasData = true
}
} else if (m.type === 'protocolo') {
const row = await findPedidoByProtocolo(m.value, tenantId)
if (row) {
const itens = await itensResumidosPedido(row.id)
consultados.push({
pedido_protocolo: row.protocolo,
pedido_status: descStatus(row.status, STATUS_PEDIDO),
forma_pagto: row.forma_pagto ?? null,
tipo_entrega: row.tipo_entrega ?? null,
financeiro: descStatus(row.financeiro_status, FINANCEIRO_STATUS),
valor: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
criado_em: row.created_at ? new Date(row.created_at).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
hasData = true
}
} else if (m.type === 'pedido_id') {
// 1) tenta protocolo terminando com o número (ex: PED-…-1)
let pedRow = await findPedidoByProtocolo(`%${m.value}`, tenantId).catch(() => null)
// 2) tenta pedido por id numérico direto
if (!pedRow) {
pedRow = await queryOne(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega, financeiro_status, total, created_at
FROM pedidos WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
[m.value, tenantId]
).catch(() => null)
}
if (pedRow) {
const itens = await itensResumidosPedido(pedRow.id)
consultados.push({
pedido_protocolo: pedRow.protocolo ?? `#${pedRow.id}`,
pedido_status: descStatus(pedRow.status, STATUS_PEDIDO),
forma_pagto: pedRow.forma_pagto ?? null,
tipo_entrega: pedRow.tipo_entrega ?? null,
financeiro: descStatus(pedRow.financeiro_status, FINANCEIRO_STATUS),
valor: pedRow.total ? `R$ ${parseFloat(pedRow.total).toFixed(2)}` : null,
criado_em: pedRow.created_at ? new Date(pedRow.created_at).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
hasData = true
} else {
// 3) último fallback: o cliente pode estar referindo-se ao número da cotação
// (ex: "pedido #1" → cotação #1 → PED-20260426-3)
const cot = await findCotacaoById(m.value, tenantId)
if (cot) {
const jaEPedido = cot.cotacao_status === 'finalizada' && cot.pedido_id
if (jaEPedido) {
const itens = await itensResumidosPedido(cot.pedido_id)
consultados.push({
nota_ia: `O cliente disse "pedido #${m.value}" mas este número é o da cotação original. O pedido real é ${cot.protocolo ?? '#' + cot.pedido_id}. Informe o protocolo correto ao cliente.`,
pedido_protocolo: cot.protocolo ?? `#${cot.pedido_id}`,
pedido_status: descStatus(cot.pedido_status, STATUS_PEDIDO),
forma_pagto: cot.forma_pagto ?? null,
tipo_entrega: cot.tipo_entrega ?? null,
financeiro: descStatus(cot.financeiro_status, FINANCEIRO_STATUS),
valor: cot.total ? `R$ ${parseFloat(cot.total).toFixed(2)}` : null,
criado_em: cot.pedido_criado_em ? new Date(cot.pedido_criado_em).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
} else {
const itens = await itensResumidosCotacao(cot.id)
consultados.push({
cotacao_id: cot.id,
cotacao_status: descStatus(cot.cotacao_status, STATUS_COTACAO),
total: cot.total ? `R$ ${parseFloat(cot.total).toFixed(2)}` : null,
itens: itens.length > 0 ? itens : undefined,
})
}
hasData = true
}
}
}
}
if (consultados.length > 0) result.consulta_mencionada = consultados
}
if (!hasData) return null
// ── SystemExtra: header de grounding + sinais em tempo real ──────────────
// O brain (sector_brain_nodes) já carrega persona, regras, conhecimento.
// Aqui adicionamos só o que muda por mensagem: rotular DADOS_REAIS + flags.
const sinais = []
// Grounding: nomeia o JSON do contexto como DADOS_REAIS (alinhado com brain)
sinais.push(
'[DADOS_REAIS DESTA CONVERSA]\n' +
'O JSON de "context" abaixo é a única fonte da verdade para fatos do cliente, pedidos e produtos. ' +
'NUNCA afirme valores, status ou prazos que não estejam nele. ' +
'Se o dado não está em DADOS_REAIS: pergunte ao cliente OU chame escalar_humano.'
)
// Sinais em tempo real (não estão em CONHECIMENTO estático)
const flags = []
if (!result.contexto_temporal.loja_aberta) {
flags.push(`LOJA_FECHADA (${result.contexto_temporal.dia_semana} ${result.contexto_temporal.hora_local})`)
}
if (result.consulta_mencionada?.length > 0) flags.push('PERGUNTA_SOBRE_PEDIDO_ESPECIFICO')
if (result.carrinho_abandonado) flags.push(`CARRINHO_ABANDONADO_${result.carrinho_abandonado.total_itens}_itens_desde_${result.carrinho_abandonado.desde}`)
if (intents.urgencia) flags.push('URGENCIA')
if (intents.frustracao) flags.push('FRUSTRACAO')
if (intents.despedida) flags.push('DESPEDIDA')
if (intents.msg_curta_ambigua) flags.push('MSG_AMBIGUA')
if (!user && !cliente) flags.push('CLIENTE_NAO_IDENTIFICADO')
if (cliente?.tipo === 'PJ') flags.push('CLIENTE_PJ')
if (result.midias_recentes?.length > 0) {
const tipos = result.midias_recentes.map(m => `${m.tipo}(${m.enviado})`).join(',')
flags.push(`MIDIA_RECENTE[${tipos}]`)
}
if (flags.length > 0) sinais.push('[SINAIS]\n' + flags.join(' | '))
result._systemExtra = sinais.join('\n\n')
return result
}
module.exports = { buildContext, extractMentions, findCotacaoById, findClienteByPhone, normalizePhone }