cancelamentosQueryService.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. import { buscarBi } from "./ixcBiService.js";
  2. import { buscarOsAvancado } from "./ixcIntegradorService.js";
  3. import { ASSUNTOS_CANCELAMENTO_RETENCAO } from "./atendimentoIxcContextoService.js";
  4. import { Atendimento } from "../models/Atendimento.model.js";
  5. import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
  6. import { formatDateTime } from "../utils/atendimentoFormat.js";
  7. const STATUS_RISCO = ["ameacou", "cancelou"];
  8. const LISTA_LIMIT = 20;
  9. const STATUS_IA_LIMIT = 3;
  10. const JANELA_DIAS_OS_CANCELAMENTO_CLIENTE = 180;
  11. const OS_STATUS_RELEVANTES = ["A", "AN", "EN", "AS", "AG", "DS", "EX", "RAG", "F"];
  12. export async function buscarContratosCancelados({ dataInicio, dataFim }) {
  13. const query = {};
  14. if (dataInicio) query.inicio = dataInicio;
  15. if (dataFim) query.fim = dataFim;
  16. const resultado = await buscarBi("cancelamentos/detalhe", query);
  17. return resultado?.contratos ?? [];
  18. }
  19. // Busca OS de cancelamento/retenção AO VIVO no ERP para um cliente, independente de já constarem
  20. // em "cancelamentos/detalhe" — esse endpoint de BI só reflete cancelamento já EFETIVADO/fechado no
  21. // contrato; uma OS de cancelamento aberta/em andamento (ex.: aguardando retirada de equipamento)
  22. // pode não aparecer lá ainda, mas já é sinal real de que o cliente pediu para cancelar.
  23. export async function buscarOsCancelamentoPorNomeCliente(clienteNome) {
  24. const termo = String(clienteNome ?? "").trim();
  25. if (!termo) return [];
  26. const clientes = await AtendimentoCliente.query()
  27. .where("Nome", "like", `%${termo}%`)
  28. .whereNotNull("IdClienteIxc")
  29. .select("IdClienteIxc")
  30. .groupBy("IdClienteIxc");
  31. if (!clientes.length) return [];
  32. const fim = new Date().toISOString().slice(0, 10);
  33. const inicio = new Date(Date.now() - JANELA_DIAS_OS_CANCELAMENTO_CLIENTE * 86400000).toISOString().slice(0, 10);
  34. const listas = await Promise.all(
  35. clientes.map(async ({ IdClienteIxc }) => {
  36. try {
  37. const resultado = await buscarOsAvancado({
  38. id_cliente: IdClienteIxc,
  39. assunto: ASSUNTOS_CANCELAMENTO_RETENCAO,
  40. status: OS_STATUS_RELEVANTES,
  41. inicio,
  42. fim,
  43. limit: 20
  44. });
  45. return Array.isArray(resultado?.data) ? resultado.data : [];
  46. } catch (err) {
  47. console.warn("[cancelamentosQueryService] buscarOsCancelamentoPorNomeCliente falhou:", err.message);
  48. return [];
  49. }
  50. })
  51. );
  52. return listas.flat().map((os) => ({
  53. assunto: os.assunto?.assunto ?? null,
  54. status: os.status ?? null,
  55. dataAbertura: os.data_abertura ?? null,
  56. dataFechamento: os.data_fechamento ?? null,
  57. notaResolucao: os.mensagem_resposta ?? null
  58. }));
  59. }
  60. export function formatarOsCancelamento(osList) {
  61. if (!osList.length) return null;
  62. const linhas = osList.map(
  63. (os) =>
  64. `- ${os.assunto ?? "?"}: status ${os.status ?? "?"}, aberta em ${os.dataAbertura ?? "?"}` +
  65. (os.dataFechamento ? `, fechada em ${os.dataFechamento}` : ", ainda em aberto") +
  66. (os.notaResolucao ? ` — "${os.notaResolucao}"` : "")
  67. );
  68. return [
  69. "Ordens de serviço (OS) reais no ERP (IXC) relacionadas a cancelamento/retenção/retirada de " +
  70. "equipamento encontradas para este cliente — podem indicar um cancelamento SOLICITADO/EM ANDAMENTO " +
  71. "mesmo quando ainda não consta como cancelamento EFETIVADO no registro acima (o contrato só fecha " +
  72. "formalmente depois que o processo é concluído, ex.: após a retirada do equipamento):",
  73. ...linhas
  74. ].join("\n");
  75. }
  76. function normalizar(texto) {
  77. return String(texto ?? "")
  78. .toLowerCase()
  79. .normalize("NFD")
  80. .replace(/[̀-ͯ]/g, "");
  81. }
  82. export function aplicarFiltrosCancelamento(contratos, { clienteNome, motivo, cidade, bairro, vendedor }) {
  83. return contratos.filter((c) => {
  84. if (clienteNome && !normalizar(c.cliente).includes(normalizar(clienteNome))) return false;
  85. if (motivo && !normalizar(c.motivo).includes(normalizar(motivo))) return false;
  86. if (cidade && !normalizar(c.cidade).includes(normalizar(cidade))) return false;
  87. if (bairro && !normalizar(c.bairro).includes(normalizar(bairro))) return false;
  88. if (vendedor && !normalizar(c.vendedor).includes(normalizar(vendedor))) return false;
  89. return true;
  90. });
  91. }
  92. function descreverFiltros(filtros) {
  93. const partes = [];
  94. if (filtros.clienteNome) partes.push(`cliente "${filtros.clienteNome}"`);
  95. if (filtros.motivo) partes.push(`motivo contendo "${filtros.motivo}"`);
  96. if (filtros.cidade) partes.push(`cidade "${filtros.cidade}"`);
  97. if (filtros.bairro) partes.push(`bairro "${filtros.bairro}"`);
  98. if (filtros.vendedor) partes.push(`vendedor "${filtros.vendedor}"`);
  99. if (filtros.dataInicio || filtros.dataFim) {
  100. partes.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
  101. }
  102. return partes.length ? `filtrando por ${partes.join(", ")}` : null;
  103. }
  104. function formatarValor(v) {
  105. return typeof v === "number" ? `R$ ${v.toFixed(2)}` : String(v ?? "não informado");
  106. }
  107. function formatarContrato(c) {
  108. return [
  109. `Cliente: ${c.cliente}`,
  110. `Plano: ${c.plano ?? "não informado"} (${formatarValor(c.valorContrato)}/mês)`,
  111. `Ativado em: ${c.ativacao ?? "?"} — Cancelado em: ${c.cancelamento ?? "?"} (${c.mesesContrato ?? "?"} meses de contrato)`,
  112. `Motivo do cancelamento: ${c.motivo ?? "não informado"}`,
  113. `Vendedor: ${c.vendedor ?? "não informado"} — Local: ${c.bairro ?? "?"}, ${c.cidade ?? "?"}/${c.uf ?? "?"}`
  114. ].join("\n");
  115. }
  116. export function formatarListaCancelamentos(contratos, filtros) {
  117. const descricao = descreverFiltros(filtros);
  118. if (!contratos.length) {
  119. return descricao
  120. ? `Nenhum cancelamento encontrado no ERP (IXC) para esse filtro (${descricao}).`
  121. : "Nenhum cancelamento encontrado no ERP (IXC).";
  122. }
  123. const amostra = contratos.slice(0, LISTA_LIMIT);
  124. const cabecalho =
  125. `Cancelamentos reais encontrados no ERP (dados ao vivo do IXC)${descricao ? ` ${descricao}` : ""}: ` +
  126. `${contratos.length} contrato${contratos.length === 1 ? "" : "s"}` +
  127. (contratos.length > amostra.length ? ` — mostrando os ${amostra.length} primeiros` : "") +
  128. ".";
  129. return [cabecalho, "", ...amostra.map((c, i) => `--- ${i + 1} ---\n${formatarContrato(c)}`)].join("\n");
  130. }
  131. function agrupar(contratos, campo) {
  132. const mapa = new Map();
  133. for (const c of contratos) {
  134. const chave = c[campo] || "não informado";
  135. mapa.set(chave, (mapa.get(chave) ?? 0) + 1);
  136. }
  137. return [...mapa.entries()].sort((a, b) => b[1] - a[1]);
  138. }
  139. function formatarRanking(pares, limit = 10) {
  140. return pares
  141. .slice(0, limit)
  142. .map(([chave, total]) => `${chave}: ${total}`)
  143. .join("\n");
  144. }
  145. export function formatarResumoCancelamentos(contratos, filtros) {
  146. const descricao = descreverFiltros(filtros);
  147. if (!contratos.length) {
  148. return descricao
  149. ? `Nenhum cancelamento encontrado no ERP (IXC) para esse filtro (${descricao}).`
  150. : "Nenhum cancelamento encontrado no ERP (IXC).";
  151. }
  152. const cabecalho =
  153. `Cancelamentos reais no ERP (dados ao vivo do IXC)${descricao ? ` ${descricao}` : ""}: ` +
  154. `${contratos.length} contrato${contratos.length === 1 ? "" : "s"}.`;
  155. return [
  156. cabecalho,
  157. "",
  158. "Por motivo:",
  159. formatarRanking(agrupar(contratos, "motivo")),
  160. "",
  161. "Por cidade:",
  162. formatarRanking(agrupar(contratos, "cidade")),
  163. "",
  164. "Por vendedor:",
  165. formatarRanking(agrupar(contratos, "vendedor"))
  166. ].join("\n");
  167. }
  168. export async function buscarStatusCancelamentoIA(clienteNome) {
  169. const termo = String(clienteNome ?? "").trim();
  170. if (!termo) return [];
  171. return Atendimento.query()
  172. .join("atendimento_clientes", "atendimento_clientes.Id", "atendimentos.ClienteId")
  173. .join("atendimento_avaliacoes", "atendimento_avaliacoes.AtendimentoId", "atendimentos.Id")
  174. .where("atendimento_clientes.Nome", "like", `%${termo}%`)
  175. .whereIn("atendimento_avaliacoes.StatusCancelamento", STATUS_RISCO)
  176. .select(
  177. "atendimentos.Codigo as codigo",
  178. "atendimentos.Abertura as abertura",
  179. "atendimento_clientes.Nome as clienteNome",
  180. "atendimento_avaliacoes.StatusCancelamento as status",
  181. "atendimento_avaliacoes.MotivoCancelamento as motivo"
  182. )
  183. .orderBy("atendimentos.Abertura", "desc")
  184. .limit(STATUS_IA_LIMIT);
  185. }
  186. const STATUS_LABEL = { ameacou: "ameaçou cancelar", cancelou: "cancelou (segundo a IA)" };
  187. export function formatarStatusIA(linhas) {
  188. if (!linhas.length) return null;
  189. const cabecalho =
  190. "Histórico de risco de cancelamento inferido por IA em atendimentos deste cliente " +
  191. "(avaliação automática de conversas de suporte — não é o registro real confirmado pelo ERP):";
  192. const itens = linhas.map((l) => {
  193. const data = formatDateTime(l.abertura);
  194. const status = STATUS_LABEL[l.status] ?? l.status;
  195. return `- Atendimento ${l.codigo}${data ? ` (${data})` : ""}: cliente ${status}${l.motivo ? ` — motivo apontado pela IA: ${l.motivo}` : ""}`;
  196. });
  197. return [cabecalho, ...itens].join("\n");
  198. }