import { config } from "../config/index.js"; import { chatCompletion } from "./ollamaClient.js"; import { buildConversaTexto, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js"; import { Atendimento } from "../models/Atendimento.model.js"; import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js"; import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js"; import { NotFoundError } from "../shared/errors/index.js"; import { nomeSetor } from "../config/setores.js"; const AVALIACAO_SYSTEM_PROMPT = [ "Você é um auditor de qualidade de atendimento ao cliente de um provedor de internet (Star).", "Avalie a conversa abaixo entre cliente e atendente(s), extraída do sistema de atendimento (ifbot).", "Mensagens rotuladas como 'Sistema' são automáticas e não contam como atuação do atendente.", 'Mensagens do cliente marcadas com "[FORA DO EXPEDIENTE]" foram enviadas fora do horário de', "atendimento do setor responsável; não penalize o atendente pela demora em responder especificamente", "essa mensagem — esperar até a reabertura do expediente é esperado e não é falha do atendente.", "Continue avaliando normalmente a qualidade e o conteúdo da resposta dada.", "Baseie-se APENAS no que está na conversa; não invente fatos, nomes ou desfechos.", "", "Tempo de resposta NÃO é o critério principal da nota. Intervalos de minutos até cerca de uma hora", "entre mensagens são comuns na operação e, sozinhos, não devem derrubar o score — priorize sempre se", "o problema foi entendido, se a solução foi correta e clara, e se o cliente saiu satisfeito. Só deixe", "a demora pesar de forma decisiva na nota quando for extrema (várias horas sem resposta, atendimento", "abandonado) ou quando tiver causado um desfecho ruim real (cliente foi embora sem solução por falta", "de retorno). Fora isso, mencione a demora só como detalhe na justificativa/sugestões, sem rebaixar o score.", "", "Primeiro escreva a justificativa_score (1 a 2 frases analisando a atuação do atendente) e só então", "dê o score_atendente (1 a 10), que deve ser coerente com a justificativa e com o desfecho:", "9-10 = exemplar; 7-8 = bom, resolveu com pequenas falhas; 5-6 = mediano; 3-4 = fraco (respostas", "confusas, informação incorreta, cliente insistindo sem retorno adequado); 1-2 = muito ruim (cliente", "abandonado, tratado mal ou induzido a erro).", "Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.", "Se nenhum atendente humano participou, use score_atendente = null.", "", "Exemplos de calibração (conversas fictícias e resumidas — sirvam de referência para o score, não copie o texto):", "", "Exemplo 1 — resolvido e cliente satisfeito, mas processo com falha (score = piso 6):", 'Cliente: "Minha internet caiu de novo, terceira vez essa semana."', "Sistema: atendimento transferido para o setor técnico.", "(Atendente Marcos não responde por mais de 60 minutos.)", 'Cliente: "alguém aí? já fazem 60 minutos"', 'Atendente Marcos: "Desculpe a demora! Vou reiniciar sua conexão remotamente."', 'Cliente: "Voltou, obrigado!"', '→ justificativa_score: "Problema resolvido e cliente satisfeito ao final; atendente demorou mais de', ' 60 minutos para responder, o que vale registrar, mas não compromete o resultado."', "→ score_atendente: 6 — mesmo com o atraso extremo registrado, resolvido=sim e sentimento=positivo", " nunca ficam abaixo de 6; o atraso já está anotado na justificativa e nas sugestões de melhoria.", "", "Exemplo 2 — atendimento exemplar (score 9-10):", 'Cliente: "Minha internet está lenta há dois dias."', 'Atendente Ana: "Pode me mandar um print do teste de velocidade em oiii.com.br/velocimetro?"', 'Cliente: (envia print) "aqui está"', 'Atendente Ana: "Identifiquei um problema no roteador. Reinicie e afaste de outros aparelhos eletrônicos."', 'Cliente: "Funcionou perfeitamente, muito obrigado pela agilidade!"', '→ justificativa_score: "Atendente identificou a causa rapidamente, orientou com clareza em poucas', ' mensagens e o cliente confirmou a solução, satisfeito."', "→ score_atendente: 9", "", "Exemplo 3 — não resolvido, cliente insatisfeito (score 2-3):", 'Cliente: "Preciso da segunda via do meu boleto."', 'Atendente Rafael: "Segue o valor: R$ 350,00" (valor está incorreto, cliente paga R$ 120,00)', 'Cliente: "Esse valor está errado, sempre pago R$ 120."', 'Atendente Rafael: "Vou te transferir para o financeiro."', "(conversa termina sem retorno do financeiro)", '→ justificativa_score: "Atendente informou valor incorreto e não corrigiu o erro; cliente ficou sem', ' solução e insatisfeito, saindo da conversa sem retorno."', "→ score_atendente: 2", "", "Exemplo 4 — cliente pede/menciona cancelar, sem confirmação de que foi concluído", '(status_cancelamento = "ameacou" — este é o caso mais comum de menção a cancelamento; não espere', "o cliente confirmar que já cancelou para usar esse valor):", 'Cliente: "Quero cancelar a internet, não estou satisfeito com o serviço."', 'Atendente: "Vou te transferir para o setor financeiro para dar andamento."', "(conversa termina sem confirmação de que o cancelamento foi processado)", '→ status_cancelamento: "ameacou" — cliente pediu para cancelar, mas nada na conversa confirma que', " o cancelamento foi de fato efetivado.", '→ motivo_cancelamento: "Insatisfação com o serviço."', "", "Exemplo 5 — cancelamento confirmado como concluído dentro da própria conversa", '(status_cancelamento = "cancelou" — só use este valor quando houver confirmação explícita):', 'Cliente: "Vou me mudar de cidade, preciso cancelar meu plano."', 'Atendente: "Sem problemas, já processei o cancelamento do seu contrato, sem multa."', 'Cliente: "Obrigado."', '→ status_cancelamento: "cancelou" — o atendente confirma, na própria conversa, que o cancelamento', " foi processado.", '→ motivo_cancelamento: "Mudança de cidade."', "", "Campos:", '- resolvido: "sim" se o problema do cliente foi claramente resolvido na conversa; "parcial" se houve', ' encaminhamento/paliativo; "nao" se terminou sem solução; "indefinido" se não dá para concluir.', '- sentimento_cliente_final: sentimento do cliente AO FINAL da conversa ("positivo", "neutro", "negativo"', ' ou "indefinido" quando o cliente some/não se manifesta no final).', "- resumo: 2 a 4 frases objetivas: quem procurou, qual o problema, o que foi feito, como terminou.", "- sugestoes_melhoria: 0 a 5 itens curtos e acionáveis para o atendente/equipe melhorar; lista vazia se exemplar.", "- atendentes: nomes dos atendentes humanos que participaram (como aparecem na conversa).", "- visita_agendada: true se ficou combinada visita técnica presencial na conversa.", '- horario_visita_informado: "sim" se o atendente informou data/horário (mesmo aproximado) da visita;', ' "nao" se agendou visita sem informar quando; "nao_se_aplica" se não houve agendamento de visita.', "- horario_visita: a data/horário informado, copiado da conversa (ou null).", '- status_cancelamento: "nao" (padrão) quando não há NENHUMA menção a cancelar o serviço/contrato', ' na conversa; "ameacou" sempre que o cliente pede, menciona ou ameaça cancelar em QUALQUER momento', ' da conversa — mesmo que a conversa não confirme se o cancelamento chegou a ser concluído (este é', ' o valor mais comum quando há menção a cancelamento; na dúvida entre "ameacou" e "nao", prefira', ' "ameacou"); "cancelou" apenas quando a própria conversa confirma que o cancelamento foi de fato', ' processado/efetivado; "indefinido" só se não der para saber se houve menção a cancelamento.', " Não marque \"ameacou\" para reclamações genéricas que não mencionam cancelar o serviço/contrato.", '- motivo_cancelamento: o motivo relatado pelo cliente para cancelar/ameaçar cancelar, resumido em', ' 1 frase copiada/parafraseada da conversa (ou null quando status_cancelamento for "nao").', "", "Responda somente com o JSON pedido, em português brasileiro." ].join("\n"); const AVALIACAO_JSON_SCHEMA = { type: "object", properties: { justificativa_score: { type: "string" }, score_atendente: { type: ["integer", "null"], minimum: 1, maximum: 10 }, resolvido: { type: "string", enum: ["sim", "nao", "parcial", "indefinido"] }, sentimento_cliente_final: { type: "string", enum: ["positivo", "neutro", "negativo", "indefinido"] }, resumo: { type: "string" }, sugestoes_melhoria: { type: "array", items: { type: "string" }, maxItems: 5 }, atendentes: { type: "array", items: { type: "string" } }, visita_agendada: { type: "boolean" }, horario_visita_informado: { type: "string", enum: ["sim", "nao", "nao_se_aplica"] }, horario_visita: { type: ["string", "null"] }, status_cancelamento: { type: "string", enum: ["nao", "ameacou", "cancelou", "indefinido"] }, motivo_cancelamento: { type: ["string", "null"] } }, required: [ "justificativa_score", "score_atendente", "resolvido", "sentimento_cliente_final", "resumo", "sugestoes_melhoria", "atendentes", "visita_agendada", "horario_visita_informado", "horario_visita", "status_cancelamento", "motivo_cancelamento" ] }; const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n"; function truncateConversa(text, maxChars = config.avaliacao.maxChars) { if (text.length <= maxChars) return text; const head = Math.floor(maxChars * 0.45); const tail = maxChars - head; return text.slice(0, head) + TRUNCATION_MARKER + text.slice(-tail); } function normalizeEnum(value, allowed, fallback = "indefinido") { const v = String(value ?? "").trim().toLowerCase(); return allowed.includes(v) ? v : fallback; } function normalizeScore(value) { const n = Number(value); if (!Number.isFinite(n)) return null; return Math.min(10, Math.max(1, Math.round(n))); } const SCORE_MINIMO_RESOLVIDO_POSITIVO = 6; export function aplicarPisoScore(dados) { if ( dados.Resolvido === "sim" && dados.Sentimento === "positivo" && dados.ScoreAtendente !== null && dados.ScoreAtendente < SCORE_MINIMO_RESOLVIDO_POSITIVO ) { return { ...dados, ScoreAtendente: SCORE_MINIMO_RESOLVIDO_POSITIVO }; } return dados; } function normalizeStringArray(value, { maxItems = 10, maxLength = 300 } = {}) { if (!Array.isArray(value)) return []; return value .map((s) => String(s ?? "").trim()) .filter(Boolean) .slice(0, maxItems) .map((s) => s.slice(0, maxLength)); } function parseAvaliacao(content) { const cleaned = String(content ?? "") .trim() .replace(/^```json\s*/i, "") .replace(/^```\s*/, "") .replace(/```$/, "") .trim(); const parsed = JSON.parse(cleaned); const horarioInformado = normalizeEnum(parsed.horario_visita_informado, ["sim", "nao", "nao_se_aplica"], "nao_se_aplica"); const horarioVisita = typeof parsed.horario_visita === "string" && parsed.horario_visita.trim() ? parsed.horario_visita.trim().slice(0, 150) : null; const statusCancelamento = normalizeEnum(parsed.status_cancelamento, ["nao", "ameacou", "cancelou", "indefinido"], "nao"); const motivoCancelamento = statusCancelamento !== "nao" && typeof parsed.motivo_cancelamento === "string" && parsed.motivo_cancelamento.trim() ? parsed.motivo_cancelamento.trim().slice(0, 500) : null; return aplicarPisoScore({ ScoreAtendente: normalizeScore(parsed.score_atendente), JustificativaScore: typeof parsed.justificativa_score === "string" && parsed.justificativa_score.trim() ? parsed.justificativa_score.trim() : null, Resolvido: normalizeEnum(parsed.resolvido, ["sim", "nao", "parcial", "indefinido"]), Sentimento: normalizeEnum(parsed.sentimento_cliente_final, ["positivo", "neutro", "negativo", "indefinido"]), Resumo: typeof parsed.resumo === "string" && parsed.resumo.trim() ? parsed.resumo.trim() : null, Sugestoes: normalizeStringArray(parsed.sugestoes_melhoria, { maxItems: 5 }), Atendentes: normalizeStringArray(parsed.atendentes, { maxItems: 10, maxLength: 100 }), VisitaAgendada: Boolean(parsed.visita_agendada), HorarioVisitaInformado: horarioInformado, HorarioVisita: horarioInformado === "sim" ? horarioVisita : null, StatusCancelamento: statusCancelamento, MotivoCancelamento: motivoCancelamento }); } function contarInteracoes(mensagens) { let cliente = 0; let atendente = 0; for (const m of mensagens) { if (m.Resposta === 0) cliente += 1; else if (m.Resposta === 1) atendente += 1; } return { cliente, atendente }; } async function upsertAvaliacao(atendimentoId, dados) { const now = new Date(); const row = { AtendimentoId: atendimentoId, ...dados, UpdatedAt: now }; const merge = Object.keys(dados).concat("UpdatedAt"); await AtendimentoAvaliacao.query().insert(row).onConflict("AtendimentoId").merge(merge); return AtendimentoAvaliacao.query().findById(atendimentoId); } export async function avaliarAtendimento(atendimentoId) { const atendimento = await Atendimento.query() .findById(atendimentoId) .withGraphFetched("[cliente, mensagens]") .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc")); if (!atendimento) throw new NotFoundError("atendimento_not_found"); const mensagens = atendimento.mensagens ?? []; const ultimaMensagemId = calcularUltimaMensagemId(mensagens); const { cliente: msgsCliente, atendente: msgsAtendente } = contarInteracoes(mensagens); // sem diálogo real não há o que avaliar; grava como não-avaliável para sair da fila // (volta a ficar pendente se chegarem mensagens novas) if (msgsCliente === 0 || msgsAtendente === 0) { const avaliacao = await upsertAvaliacao(atendimento.Id, { Avaliavel: false, ScoreAtendente: null, JustificativaScore: null, Resolvido: "indefinido", Sentimento: "indefinido", Resumo: null, Sugestoes: [], Atendentes: [], VisitaAgendada: null, HorarioVisitaInformado: null, HorarioVisita: null, StatusCancelamento: "nao", MotivoCancelamento: null, Modelo: null, UltimaMensagemId: ultimaMensagemId, TotalMensagens: mensagens.length }); return { avaliado: false, motivo: "sem_dialogo", avaliacao }; } const conversaTexto = buildConversaTexto({ atendimento, cliente: atendimento.cliente, mensagens }); const model = config.avaliacao.model || config.ollama.chatModel; const { content } = await chatCompletion({ model, messages: [ { role: "system", content: AVALIACAO_SYSTEM_PROMPT }, { role: "user", content: truncateConversa(conversaTexto) } ], format: AVALIACAO_JSON_SCHEMA, options: { temperature: 0.1, num_ctx: 8192, num_predict: 1024 } }); let dados; try { dados = parseAvaliacao(content); } catch { const err = new Error("avaliacao_json_invalido"); err.statusCode = 502; throw err; } const avaliacao = await upsertAvaliacao(atendimento.Id, { Avaliavel: true, ...dados, Modelo: model, UltimaMensagemId: ultimaMensagemId, TotalMensagens: mensagens.length }); return { avaliado: true, avaliacao }; } let avaliacaoEmAndamento = false; // pendente = atendimento com mensagens e sem avaliação, ou com mensagens mais novas que a última avaliada function pendentesQuery({ setor } = {}) { const subMax = AtendimentoMensagem.query() .select("AtendimentoId") .max("Id as MaxMsgId") .groupBy("AtendimentoId") .as("m"); const query = Atendimento.query() .select("atendimentos.Id") .join(subMax, "m.AtendimentoId", "atendimentos.Id") .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id") .where((q) => { // av.UltimaMensagemId IS NULL cobre tanto "sem avaliação ainda" quanto "avaliado // quando o atendimento não tinha mensagens" (NULL < MaxMsgId nunca é verdadeiro em SQL) q.whereNull("av.AtendimentoId") .orWhereNull("av.UltimaMensagemId") .orWhereRaw("av.UltimaMensagemId < m.MaxMsgId"); }); if (setor) query.where("atendimentos.Setor", setor); return query; } function buscarPendentes(limite) { return pendentesQuery().orderBy("atendimentos.IngestedAt", "desc").limit(limite); } export async function contarPendentes(params = {}) { return pendentesQuery(params).resultSize(); } export async function avaliarPendentes({ limite = config.avaliacao.batchSize } = {}) { if (avaliacaoEmAndamento) { const err = new Error("avaliacao_em_andamento"); err.statusCode = 409; throw err; } avaliacaoEmAndamento = true; try { const pendentes = await buscarPendentes(limite); const resultado = { pendentes: pendentes.length, avaliados: 0, semDialogo: 0, erros: [] }; // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui for (const { Id } of pendentes) { try { const r = await avaliarAtendimento(Id); if (r.avaliado) resultado.avaliados += 1; else resultado.semDialogo += 1; } catch (err) { resultado.erros.push({ atendimentoId: Id, erro: err?.message ?? "erro_desconhecido" }); } } return resultado; } finally { avaliacaoEmAndamento = false; } } // busca digitada: "07/11/2024" ou "2024-11-07" vira filtro pela data de abertura function parseDataBusca(termo) { let m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(termo); if (m) return `${m[3]}-${m[2]}-${m[1]}`; m = /^\d{4}-\d{2}-\d{2}$/.exec(termo); if (m) return termo; return null; } export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao } = {}) { // ordena por Abertura (data do atendimento, é o que aparece no card) e não por // UpdatedAt (data da avaliação da IA) — os dois divergem e ordenar pelo errado // deixa a lista com datas aparentemente fora de ordem const query = AtendimentoAvaliacao.query() .where("Avaliavel", true) .page(page - 1, pageSize) .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId") .select("atendimento_avaliacoes.*") .orderBy("a.Abertura", ordenacao === "asc" ? "asc" : "desc"); if (resolvido) query.where("Resolvido", resolvido); if (sentimento) query.where("Sentimento", sentimento); if (statusCancelamento) query.where("StatusCancelamento", statusCancelamento); if (scoreMax !== undefined) query.where("ScoreAtendente", "<=", scoreMax); if (setor) query.where("a.Setor", setor); const termo = typeof busca === "string" ? busca.trim() : ""; if (termo) { const like = `%${termo.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`; const data = parseDataBusca(termo); query.leftJoin("atendimento_clientes as c", "c.Id", "a.ClienteId").where((qb) => { qb.where("a.Codigo", "like", like) .orWhere("c.Nome", "like", like) // Atendentes é JSON text (["Luiz","Carla"]) — LIKE cobre busca por nome .orWhere("atendimento_avaliacoes.Atendentes", "like", like); if (data) qb.orWhereRaw("DATE(a.Abertura) = ?", [data]); }); } const { results, total } = await query; // junta código/cliente/setor do atendimento para exibição const ids = results.map((r) => r.AtendimentoId); const atendimentos = ids.length ? await Atendimento.query().findByIds(ids).withGraphFetched("cliente") : []; const byId = new Map(atendimentos.map((a) => [a.Id, a])); const items = results.map((r) => { const a = byId.get(r.AtendimentoId); return { ...r, Codigo: a?.Codigo ?? null, Setor: a?.Setor ?? null, Status: a?.Status ?? null, Abertura: a?.Abertura ?? null, ClienteNome: a?.cliente?.Nome ?? null }; }); return { results: items, total, page, pageSize }; } // setores com pelo menos uma avaliação, para popular o filtro da tela de avaliações export async function listarSetores() { const rows = await AtendimentoAvaliacao.query() .where("Avaliavel", true) .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId") .whereNotNull("a.Setor") .select("a.Setor") .count("* as total") .groupBy("a.Setor") .orderBy("a.Setor"); return rows.map((r) => ({ sigla: r.Setor, nome: nomeSetor(r.Setor), total: Number(r.total) })); } export async function estatisticasAvaliacoes({ setor } = {}) { const base = () => { const q = AtendimentoAvaliacao.query().where("Avaliavel", true); if (setor) { q.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId").where("a.Setor", setor); } return q; }; const [scoreRow, avaliados, porResolvido, porSentimento, porCancelamento, visitas, pendentes, totalAtendimentos] = await Promise.all([ base().avg("ScoreAtendente as media").whereNotNull("ScoreAtendente").first(), base().resultSize(), base().select("Resolvido").count("* as total").groupBy("Resolvido"), base().select("Sentimento").count("* as total").groupBy("Sentimento"), base().select("StatusCancelamento").count("* as total").groupBy("StatusCancelamento"), base() .where("VisitaAgendada", true) .select("HorarioVisitaInformado") .count("* as total") .groupBy("HorarioVisitaInformado"), contarPendentes({ setor }), setor ? Atendimento.query().where("Setor", setor).resultSize() : Atendimento.query().resultSize() ]); const toMap = (rows, key) => Object.fromEntries(rows.map((r) => [r[key] ?? "indefinido", Number(r.total)])); return { scoreMedio: scoreRow?.media !== null && scoreRow?.media !== undefined ? Number(Number(scoreRow.media).toFixed(2)) : null, avaliados: Number(avaliados ?? 0), resolvido: toMap(porResolvido, "Resolvido"), sentimento: toMap(porSentimento, "Sentimento"), cancelamento: toMap(porCancelamento, "StatusCancelamento"), visitasAgendadas: toMap(visitas, "HorarioVisitaInformado"), pendentes, totalAtendimentos }; } // usado pelo chat de atendimentos (atendimentosQueryService.js) para responder perguntas // agregadas de qualidade — dedicado, em vez de reaproveitar estatisticasAvaliacoes (que // não filtra por status/período e mistura métricas do dashboard admin como visitas/pendentes). export const SCORE_RUIM_MAX = 5; // consistente com SCORE_MINIMO_RESOLVIDO_POSITIVO = 6 já existente export async function agregarQualidade({ setor, status, dataInicio, dataFim } = {}) { const base = () => { let q = AtendimentoAvaliacao.query() .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId") .where("atendimento_avaliacoes.Avaliavel", true); if (setor) q = q.where("a.Setor", setor); if (status !== null && status !== undefined) q = q.where("a.Status", status); if (dataInicio) q = q.where("a.Abertura", ">=", dataInicio); if (dataFim) q = q.where("a.Abertura", "<=", `${dataFim} 23:59:59`); return q; }; const [porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota] = await Promise.all([ base().whereNotNull("atendimento_avaliacoes.ScoreAtendente") .select("a.Setor").avg("atendimento_avaliacoes.ScoreAtendente as media").groupBy("a.Setor"), base().whereNotNull("atendimento_avaliacoes.ScoreAtendente") .select("a.Setor").count("* as total").groupBy("a.Setor"), base().select("atendimento_avaliacoes.Resolvido").count("* as total").groupBy("atendimento_avaliacoes.Resolvido"), base().whereNotNull("atendimento_avaliacoes.ScoreAtendente") .where("atendimento_avaliacoes.ScoreAtendente", "<=", SCORE_RUIM_MAX).resultSize(), base().whereNotNull("atendimento_avaliacoes.ScoreAtendente").resultSize() ]); return { porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota, filtros: { setor, status, dataInicio, dataFim } }; } // amostra recente de avaliações com sugestões de melhoria registradas, usada pelo chat de // atendimentos para perguntas sobre padrões/lições aprendidas (sem chamada LLM auxiliar — // o LLM final do modo atendimentos já sintetiza o CONTEXTO bruto). const LICOES_LIMIT = 30; export async function buscarAvaliacoesParaLicoes({ setor, status, dataInicio, dataFim, limit = LICOES_LIMIT } = {}) { let q = AtendimentoAvaliacao.query() .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId") .where("atendimento_avaliacoes.Avaliavel", true) .whereNotNull("atendimento_avaliacoes.ScoreAtendente") .whereRaw("JSON_LENGTH(atendimento_avaliacoes.Sugestoes) > 0") .select("atendimento_avaliacoes.*", "a.Codigo") .orderBy("atendimento_avaliacoes.UpdatedAt", "desc") .limit(limit); if (setor) q = q.where("a.Setor", setor); if (status !== null && status !== undefined) q = q.where("a.Status", status); if (dataInicio) q = q.where("a.Abertura", ">=", dataInicio); if (dataFim) q = q.where("a.Abertura", "<=", `${dataFim} 23:59:59`); return q; }