cancelamentosQueryService.js 9.0 KB

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