|
|
@@ -0,0 +1,270 @@
|
|
|
+import {
|
|
|
+ buscarLoginsPorNome,
|
|
|
+ buscarLoginDadosCompletos,
|
|
|
+ buscarClientes,
|
|
|
+ buscarContratosCliente,
|
|
|
+ buscarFaturasAbertas,
|
|
|
+ buscarFaturasPagas,
|
|
|
+ buscarOsAvancado
|
|
|
+} from "../services/ixcIntegradorService.js";
|
|
|
+
|
|
|
+// comando explícito, à parte dos 3 modos do roteador por LLM — mesmo espírito do
|
|
|
+// /cto em ctoCommand.js: intercepta antes de classificarModoChat. Sem palavra-chave:
|
|
|
+// "/<pppoe>" sozinho (uma palavra, sem espaço) já é tratado como busca de login —
|
|
|
+// diferencia de "/cto <busca>" porque esse sempre tem espaço depois do comando.
|
|
|
+const LOGIN_COMMAND_RE = /^\/([^\s/]+)$/;
|
|
|
+const LIST_LIMIT = 10;
|
|
|
+const OS_LIMIT = 5;
|
|
|
+const FATURAS_PAGAS_LIMIT = 5;
|
|
|
+
|
|
|
+// não existe rota no Alfred que vá de login -> id do cliente diretamente (o campo
|
|
|
+// existe na tabela mas nenhum endpoint exposto retorna); a ponte é o nome (razao)
|
|
|
+// que já vem embutido nos dados completos do login.
|
|
|
+const OS_STATUS_ABERTOS = ["A", "AN", "EN", "AS", "AG", "DS", "EX", "RAG"];
|
|
|
+const OS_STATUS_FECHADO = ["F"];
|
|
|
+const OS_ABERTAS_LIMIT_MAX = 20; // teto de segurança — na prática nenhum cliente tem tantas OS abertas ao mesmo tempo
|
|
|
+const OS_INICIO_PADRAO = "2015-01-01";
|
|
|
+
|
|
|
+export function parseLoginCommand(message) {
|
|
|
+ const match = LOGIN_COMMAND_RE.exec(String(message ?? "").trim());
|
|
|
+ return match ? match[1].trim() : null;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizar(texto) {
|
|
|
+ return String(texto ?? "").trim().toLowerCase();
|
|
|
+}
|
|
|
+
|
|
|
+function mascararDocumento(doc) {
|
|
|
+ const limpo = String(doc ?? "").replace(/\D/g, "");
|
|
|
+ if (limpo.length < 6) return "não informado";
|
|
|
+ return `${limpo.slice(0, 3)}***${limpo.slice(-2)}`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatarDataCurta(valor) {
|
|
|
+ if (!valor) return "?";
|
|
|
+ const [dataParte] = String(valor).split(" ");
|
|
|
+ const [ano, mes, dia] = dataParte.split("-");
|
|
|
+ return ano && mes && dia ? `${dia}/${mes}/${ano}` : dataParte;
|
|
|
+}
|
|
|
+
|
|
|
+const STATUS_CONTRATO = { A: "Ativo", CM: "Cancelado", I: "Inativo" };
|
|
|
+const STATUS_OS = { A: "Aberta", F: "Finalizada" };
|
|
|
+
|
|
|
+function formatarLogin(login) {
|
|
|
+ const status = login.online === "S" ? "Online" : "Offline";
|
|
|
+ const onu = login.onu_completa;
|
|
|
+ const sinal = onu ? ` · sinal RX ${onu.sinal_rx ?? "?"}dBm` : "";
|
|
|
+ const cto = login.cto_completa?.descricao ? ` · ${login.cto_completa.descricao}` : "";
|
|
|
+ const projeto = login.projeto?.nome ? ` · ${login.projeto.nome}` : "";
|
|
|
+ return `Login **${login.pppoe}** — ${status}${sinal}${cto}${projeto}`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatarContratos(contratos) {
|
|
|
+ if (!contratos.length) return "Nenhum contrato encontrado.";
|
|
|
+ return contratos
|
|
|
+ .map((c) => `- ${c.contrato ?? `Contrato ${c.id}`} (${STATUS_CONTRATO[c.status] ?? c.status ?? "status desconhecido"})`)
|
|
|
+ .join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function formatarFinanceiro(faturasAbertas, faturasPagas) {
|
|
|
+ const linhas = [];
|
|
|
+ if (faturasAbertas.length === 0) {
|
|
|
+ linhas.push("Sem faturas em aberto.");
|
|
|
+ } else {
|
|
|
+ linhas.push(`${faturasAbertas.length} fatura(s) em aberto:`);
|
|
|
+ faturasAbertas.forEach((f) => {
|
|
|
+ const valor = Number(f.valor ?? 0).toFixed(2).replace(".", ",");
|
|
|
+ linhas.push(`- R$ ${valor}, vencimento ${formatarDataCurta(f.data_vencimento)}`);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (faturasPagas.length > 0) {
|
|
|
+ const resumo = faturasPagas
|
|
|
+ .map((f) => `R$ ${Number(f.valor ?? 0).toFixed(2).replace(".", ",")} (${formatarDataCurta(f.baixa_data ?? f.pagamento_data)})`)
|
|
|
+ .join(", ");
|
|
|
+ linhas.push(`Últimos pagamentos: ${resumo}`);
|
|
|
+ }
|
|
|
+ return linhas.join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+// mensagens puramente de workflow (troca de responsável, edição de campo) não
|
|
|
+// ajudam o atendente a entender o que aconteceu na OS — só o motivo e a resolução importam.
|
|
|
+const MENSAGEM_OS_RUIDO_RE = /^(ordem de servi[cç]o assumida|altera[cç][aã]o de dados da o\.s\.)/i;
|
|
|
+
|
|
|
+// a mensagem de abertura sempre vem com prefixo de workflow interno repetido (nome
|
|
|
+// do processo/tarefa, redundante com o assunto já exibido no cabeçalho da OS) —
|
|
|
+// pega só o texto depois do último marcador reconhecido de conteúdo real.
|
|
|
+const MARCADORES_CONTEUDO_OS = [/descri[cç][aã]o os anterior:\s*/i, /mensagem do cliente:\s*/i];
|
|
|
+
|
|
|
+function limparTextoMensagemOs(texto) {
|
|
|
+ const semQuebras = String(texto ?? "").replace(/[\r\n]+/g, " ").trim();
|
|
|
+ let melhorCorte = null;
|
|
|
+ for (const marcador of MARCADORES_CONTEUDO_OS) {
|
|
|
+ const match = [...semQuebras.matchAll(new RegExp(marcador, "gi"))].pop();
|
|
|
+ if (match && (melhorCorte === null || match.index > melhorCorte)) {
|
|
|
+ melhorCorte = match.index + match[0].length;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return (melhorCorte !== null ? semQuebras.slice(melhorCorte) : semQuebras).trim();
|
|
|
+}
|
|
|
+
|
|
|
+const MENSAGEM_OS_MAX_CHARS = 220;
|
|
|
+
|
|
|
+function truncar(texto, max) {
|
|
|
+ return texto.length > max ? `${texto.slice(0, max)}...` : texto;
|
|
|
+}
|
|
|
+
|
|
|
+// mostra só a mensagem de abertura (motivo) e a última relevante (resolução) — o
|
|
|
+// meio do histórico costuma ser só andamento de workflow sem valor pro atendimento.
|
|
|
+function resumirMensagensOs(mensagens = []) {
|
|
|
+ const relevantes = mensagens
|
|
|
+ .map((m) => limparTextoMensagemOs(m.mensagem))
|
|
|
+ .filter((texto) => texto && !MENSAGEM_OS_RUIDO_RE.test(texto));
|
|
|
+ if (relevantes.length === 0) return null;
|
|
|
+ const abertura = truncar(relevantes[0], MENSAGEM_OS_MAX_CHARS);
|
|
|
+ const resolucao = relevantes.length > 1 ? truncar(relevantes[relevantes.length - 1], MENSAGEM_OS_MAX_CHARS) : null;
|
|
|
+ return resolucao && resolucao !== abertura ? `${abertura} → ${resolucao}` : abertura;
|
|
|
+}
|
|
|
+
|
|
|
+function formatarPeriodoOs(os) {
|
|
|
+ const abertura = formatarDataCurta(os.data_abertura);
|
|
|
+ if (!os.data_fechamento) return abertura;
|
|
|
+ const fechamento = formatarDataCurta(os.data_fechamento);
|
|
|
+ return fechamento === abertura ? abertura : `${abertura} a ${fechamento}`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatarOs(osList) {
|
|
|
+ if (!osList.length) return "Nenhuma OS encontrada.";
|
|
|
+ return osList
|
|
|
+ .map((os) => {
|
|
|
+ const assunto = os.assunto?.assunto?.trim() ?? "sem assunto registrado";
|
|
|
+ const status = STATUS_OS[os.status] ?? os.status ?? "?";
|
|
|
+ const resumo = resumirMensagensOs(os.mensagens);
|
|
|
+ const cabecalho = `**[${status}] ${assunto}** — ${formatarPeriodoOs(os)}`;
|
|
|
+ return resumo ? `${cabecalho}\n${resumo}` : cabecalho;
|
|
|
+ })
|
|
|
+ .join("\n\n");
|
|
|
+}
|
|
|
+
|
|
|
+function formatarListaLogins(logins, termoOriginal) {
|
|
|
+ const linhas = [
|
|
|
+ `Encontrei ${logins.length} logins para "${termoOriginal}". Responda com \`/<login exato>\` de um deles:`,
|
|
|
+ ""
|
|
|
+ ];
|
|
|
+ logins.slice(0, LIST_LIMIT).forEach((l) => {
|
|
|
+ const status = l.online === "S" ? "online" : "offline";
|
|
|
+ linhas.push(`- **${l.login}** (${status})${l.cto?.descricao ? ` — CTO ${l.cto.descricao}` : ""}`);
|
|
|
+ });
|
|
|
+ if (logins.length > LIST_LIMIT) {
|
|
|
+ linhas.push(`\n_...e mais ${logins.length - LIST_LIMIT}. Refine a busca pra ver menos opções._`);
|
|
|
+ }
|
|
|
+ return linhas.join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function formatarListaClientes(clientes, nome) {
|
|
|
+ const linhas = [
|
|
|
+ `O login está vinculado a "${nome}", mas encontrei ${clientes.length} clientes com esse nome — não dá pra saber qual sem confundir dados. Confirme pelo endereço ou documento:`,
|
|
|
+ ""
|
|
|
+ ];
|
|
|
+ clientes.forEach((c) => {
|
|
|
+ linhas.push(`- id ${c.id} — ${c.endereco ?? "endereço não informado"} (doc. ${mascararDocumento(c.cnpj_cpf)})`);
|
|
|
+ });
|
|
|
+ return linhas.join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function buscarOsPorStatus(idCliente, status, limit, fim) {
|
|
|
+ return buscarOsAvancado({
|
|
|
+ id_cliente: idCliente,
|
|
|
+ status,
|
|
|
+ inicio: OS_INICIO_PADRAO,
|
|
|
+ fim,
|
|
|
+ orderBy: "data_abertura",
|
|
|
+ order: "DESC",
|
|
|
+ limit,
|
|
|
+ incluirMensagens: true
|
|
|
+ })
|
|
|
+ .then((r) => (Array.isArray(r?.data) ? r.data : []))
|
|
|
+ .catch(() => []);
|
|
|
+}
|
|
|
+
|
|
|
+// OS aberta é sempre relevante pro atendimento (chamado em andamento agora), então
|
|
|
+// nunca pode ficar de fora só porque uma fechada mais antiga "ganhou" a vaga no
|
|
|
+// limite — busca as duas listas separadas e garante que toda aberta apareça.
|
|
|
+async function buscarOsRecentes(idCliente) {
|
|
|
+ // "fim" como só a data (sem hora) faz o Alfred comparar como "00:00:00" desse dia,
|
|
|
+ // cortando qualquer OS aberta depois da meia-noite de hoje — usa amanhã como
|
|
|
+ // limite pra sempre cobrir o dia inteiro de hoje, independente da hora atual.
|
|
|
+ const amanha = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
|
|
|
+ const [osAbertas, osFechadas] = await Promise.all([
|
|
|
+ buscarOsPorStatus(idCliente, OS_STATUS_ABERTOS, OS_ABERTAS_LIMIT_MAX, amanha),
|
|
|
+ buscarOsPorStatus(idCliente, OS_STATUS_FECHADO, OS_LIMIT, amanha)
|
|
|
+ ]);
|
|
|
+ const vagas = Math.max(OS_LIMIT - osAbertas.length, 0);
|
|
|
+ return [...osAbertas, ...osFechadas.slice(0, vagas)].sort(
|
|
|
+ (a, b) => new Date(b.data_abertura) - new Date(a.data_abertura)
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+async function buscarClienteIdPeloNome(nome) {
|
|
|
+ const candidatos = await buscarClientes(nome);
|
|
|
+ const exatos = candidatos.filter((c) => normalizar(c.razao) === normalizar(nome));
|
|
|
+ if (exatos.length === 1) return { idCliente: exatos[0].id, ambiguo: null };
|
|
|
+ if (exatos.length > 1) return { idCliente: null, ambiguo: exatos };
|
|
|
+ return { idCliente: null, ambiguo: null };
|
|
|
+}
|
|
|
+
|
|
|
+async function montarRespostaCliente(login) {
|
|
|
+ const nomeCliente = login.cliente?.razao;
|
|
|
+ if (!nomeCliente) {
|
|
|
+ return {
|
|
|
+ answer: `Achei o login **${login.pppoe}**, mas ele não tem cliente vinculado no cadastro.\n\n${formatarLogin(login)}`,
|
|
|
+ sources: []
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const { idCliente, ambiguo } = await buscarClienteIdPeloNome(nomeCliente);
|
|
|
+ if (ambiguo) {
|
|
|
+ return { answer: formatarListaClientes(ambiguo, nomeCliente), sources: [] };
|
|
|
+ }
|
|
|
+ if (!idCliente) {
|
|
|
+ return {
|
|
|
+ answer:
|
|
|
+ `Achei o login **${login.pppoe}** vinculado a "${nomeCliente}", mas não encontrei o cadastro desse ` +
|
|
|
+ `cliente no ERP.\n\n${formatarLogin(login)}`,
|
|
|
+ sources: []
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const [contratos, faturasAbertas, faturasPagas, osList] = await Promise.all([
|
|
|
+ buscarContratosCliente(idCliente).catch(() => []),
|
|
|
+ buscarFaturasAbertas(idCliente).catch(() => []),
|
|
|
+ buscarFaturasPagas(idCliente, FATURAS_PAGAS_LIMIT).catch(() => []),
|
|
|
+ buscarOsRecentes(idCliente)
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const linhas = [
|
|
|
+ `## ${nomeCliente}`,
|
|
|
+ formatarLogin(login),
|
|
|
+ "",
|
|
|
+ "### Contratos",
|
|
|
+ formatarContratos(Array.isArray(contratos) ? contratos : []),
|
|
|
+ "",
|
|
|
+ "### Financeiro",
|
|
|
+ formatarFinanceiro(faturasAbertas, faturasPagas),
|
|
|
+ "",
|
|
|
+ `### Últimas OS (${osList.length})`,
|
|
|
+ formatarOs(osList)
|
|
|
+ ];
|
|
|
+
|
|
|
+ return { answer: linhas.join("\n"), sources: [] };
|
|
|
+}
|
|
|
+
|
|
|
+export async function resolverComandoLogin(termo) {
|
|
|
+ const logins = await buscarLoginsPorNome(termo);
|
|
|
+ if (logins.length === 0) return { answer: `Não encontrei nenhum login com "${termo}".`, sources: [] };
|
|
|
+
|
|
|
+ const exato = logins.length > 1 ? logins.find((l) => normalizar(l.login) === normalizar(termo)) : logins[0];
|
|
|
+ if (!exato) return { answer: formatarListaLogins(logins, termo), sources: [] };
|
|
|
+
|
|
|
+ const loginCompleto = await buscarLoginDadosCompletos(exato.id);
|
|
|
+ return montarRespostaCliente(loginCompleto);
|
|
|
+}
|