feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker
This commit is contained in:
@@ -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 10–11 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 ? `há ${ageMin}min` : `há ${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 }
|
||||
Reference in New Issue
Block a user