diff --git a/README.md b/README.md index 5448685..8202557 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,35 @@ ## Visão Geral -Esta API foi desenvolvida para automatizar e facilitar o processo de novas contratações. Suas duas funcionalidades principais são: +A **Sothis Contratação API** é uma solução robusta para automatizar e facilitar o processo de novas contratações. A aplicação gerencia consultas de viabilidade técnica e a criação de prospectos de clientes no sistema integrado Hubsoft. -1. **Consulta de Viabilidade**: Verifica se um determinado endereço (baseado em CEP e número) possui viabilidade técnica para instalação de serviços. -2. **Criação de Prospectos**: Registra um novo cliente potencial (prospect) no sistema de gestão Hubsoft. +### Funcionalidades Principais -A aplicação é construída em Node.js com Express e segue uma arquitetura modular para separação de responsabilidades. +1. **Consulta de Viabilidade por CEP e Número**: Verifica a viabilidade técnica de um endereço e retorna informações sobre disponibilidade de serviços (dedicado e não-dedicado) com base na proximidade de pontos de presença. +2. **Consulta de Viabilidade por Coordenadas**: Consulta viabilidade usando latitude e longitude com reverse geocoding. +3. **Criação de Prospectos**: Registra novos clientes (Pessoa Física ou Jurídica) no sistema de gestão Hubsoft com suporte a diferentes planos de banda larga. -## Features +## Features Técnicas -- **Servidor Express**: API RESTful robusta e performática. -- **Arquitetura Modular**: Lógica de negócio organizada no módulo `contratacao`, facilitando a manutenção e expansão. -- **Serviços Externos**: Integração com múltiplas APIs de terceiros para consulta de CEP, geolocalização e gestão de clientes. -- **Logging Avançado**: Utiliza `winston` para registrar logs da aplicação e de erros em arquivos diários rotacionados. -- **Gestão de Ambiente**: Usa `dotenv` para gerenciar configurações de desenvolvimento e produção de forma segura e isolada. -- **Desenvolvimento Otimizado**: `nodemon` para reinicialização automática do servidor em ambiente de desenvolvimento. +- **Express.js 5.1**: Framework web moderno para construção de APIs RESTful. +- **Arquitetura Modular**: Estrutura baseada em MVC com separação clara entre Controller, Service, Repository e Model. +- **Integração com Múltiplas APIs**: + - **Google Maps Geocoding**: Para geocodificação e reverse geocoding de endereços. + - **GeoGrid API**: Consulta de pontos de presença e viabilidade técnica. + - **Hubsoft API**: Gestão de prospectos e clientes. +- **CEP Validation**: Integração com `cep-promise` para validação e busca de endereços brasileiros. +- **Logging Avançado**: Sistema de logging estruturado com `winston` e arquivo rotativo diário. +- **Configuração por Ambiente**: Suporte a múltiplos ambientes (desenvolvimento/produção) via `dotenv`. +- **CORS Configurado**: Habilitado para domínios específicos (`sothis.com.br` e `bandalarga.srv.br`). +- **PostgreSQL Ready**: Preparado para persistência de dados com `pg`. +- **Desenvolvimento Otimizado**: Hot-reload automático com `nodemon`. ## Pré-requisitos - Node.js (versão 18.x ou superior recomendada) - npm (geralmente instalado junto com o Node.js) +- PostgreSQL (opcional, para persistência de dados) +- Chaves de API: Google Maps, GeoGrid, Hubsoft ## 1. Instalação @@ -38,29 +47,53 @@ A aplicação utiliza arquivos `.env` para carregar suas configurações. Você - `.env.development` (para ambiente de desenvolvimento) - `.env.production` (para ambiente de produção) -Preencha os arquivos com as seguintes variáveis de ambiente: +### Variáveis de Ambiente Obrigatórias ```env -# Porta da API (padrão: 3000) +# Servidor PORT=3000 +NODE_ENV=development -# Chave da API do Google Maps Geocoding -GOOGLE_API_KEY=SUA_CHAVE_AQUI +# Google Maps Geocoding API (para geocodificação e reverse geocoding) +GOOGLE_API_KEY=sua_chave_google_maps_aqui -# Configurações da API GeoGrid -GEOGRID_API_URL=URL_DA_API_GEOGRID -GEOGRID_API_KEY=SUA_CHAVE_GEOGRID_AQUI -GEOGRID_API_COOKIE=SEU_COOKIE_GEOGRID_AQUI +# GeoGrid API (para consulta de viabilidade técnica) +GEOGRID_API_URL=https://.../viabilidade/raio # URL COMPLETA da consulta por raio +GEOGRID_API_KEY=sua_chave_geogrid_aqui +GEOGRID_API_COOKIE=seu_cookie_geogrid_aqui +GEOGRID_AUTHORIZED_SIGLAS_PASTAS=Sigla A, Sigla B # pastas de rede própria (viram provedor "Sothis") -# Configurações da API Hubsoft -HUBSOFT_URL=URL_DO_HUBSOFT -HUBSOFT_CLIENT_ID=SEU_CLIENT_ID_HUBSOFT -HUBSOFT_CLIENT_SECRET=SEU_CLIENT_SECRET_HUBSOFT -HUBSOFT_USERNAME=SEU_USUARIO_HUBSOFT -HUBSOFT_PASSWORD=SUA_SENHA_HUBSOFT +# Distância REAL de trajeto (opcionais — têm defaults seguros) +GEOGRID_API_BASE_URL=https://.../vale/api/v3 # base p/ o endpoint de trajeto; se ausente, derivada da URL de consulta +GEOGRID_TRAJETO_MODO=walking # modo do trajeto +GEOGRID_TRAJETO_TOP_N=3 # nº de caixas roteadas na consulta individual (precisão) +GEOGRID_TRAJETO_TOP_N_LOTE=2 # nº de caixas roteadas por item no lote (a planilha mostra 2 caixas) +GEOGRID_CONCURRENCY=5 # chamadas externas simultâneas no lote + +# Hubsoft API (para gestão de prospectos) +HUBSOFT_URL=https://seu-dominio.hubsoft.com.br +HUBSOFT_CLIENT_ID=seu_client_id_hubsoft +HUBSOFT_CLIENT_SECRET=seu_client_secret_hubsoft +HUBSOFT_USERNAME=seu_usuario_hubsoft +HUBSOFT_PASSWORD=sua_senha_hubsoft HUBSOFT_GRANT_TYPE=password + +# Database PostgreSQL (opcional, para persistência de viabilidades) +DB_HOST=localhost +DB_PORT=5432 +DB_USER=seu_usuario_postgres +DB_PASSWORD=sua_senha_postgres +DB_NAME=contratacao_db + +# Siglas de Pastas (mapeamento de provedores) +SIGLA_SOTHIS=SOTHIS +SIGLA_OUTRO_PROVEDOR=OUTRO ``` +### Siglas de Pastas + +O sistema mapeia siglas de pastas da GeoGrid para retornar o provedor na resposta. Quando o provedor bater com as siglas configuradas no `.env`, a API retorna `"Sothis"` como provedor. + ## 3. Rodando a Aplicação ### Modo de Desenvolvimento @@ -71,6 +104,11 @@ Este comando inicia a API com `nodemon`, que reinicia o servidor automaticamente npm run dev:api ``` +A API será iniciada na porta configurada (padrão `3000`) e exibirá: +``` +🚀 Servidor API rodando na porta 3000 em modo development +``` + ### Modo de Produção Este comando inicia a API de forma otimizada para produção usando `node`. As configurações serão carregadas do `.env.production`. @@ -81,21 +119,96 @@ npm run start:api --- +## Logging e Monitoramento + +A aplicação utiliza **Winston** para logging estruturado com as seguintes características: + +- **Arquivos de Log**: Criados automaticamente no diretório `logs/` +- **Rotação Diária**: Novos arquivos criados a cada dia com formato `application-YYYY-MM-DD.log` +- **Níveis de Log**: + - `info`: Requisições recebidas, operações bem-sucedidas + - `warn`: Advertências e situações inesperadas + - `error`: Erros e exceções + +### Exemplo de Logs + +``` +2026-05-20T10:30:45.123Z [info]: 🚀 Servidor API rodando na porta 3000 em modo development +2026-05-20T10:31:20.456Z [info]: Requisição recebida: POST /api/viabilidade - IP: 127.0.0.1 +2026-05-20T10:31:21.789Z [info]: Endereço obtido com sucesso via cep-promise +2026-05-20T10:31:23.234Z [info]: Coordenadas obtidas com sucesso - lat: -23.5070, lon: -46.4036 +2026-05-20T10:31:25.567Z [info]: Viabilidade salva com sucesso +``` + +--- + +## Tratamento de Erros + +A API implementa tratamento robusto de erros: + +1. **Validação de Entrada**: CEP e número são obrigatórios na consulta de viabilidade +2. **Erros de Integração**: Erros de terceiros são tratados e retornam respostas HTTP apropriadas +3. **Logging de Erros**: Todos os erros são registrados com stack trace completo +4. **Respostas Consistentes**: Erros retornam status HTTP apropriado e mensagem descritiva + +--- + +## Integração com Serviços Externos + +### Google Maps Geocoding +- **Uso**: Converter endereço em coordenadas e vice-versa (reverse geocoding) +- **Rate Limit**: Respeitar limites da Google API + +### GeoGrid API +- **Uso**: Consultar pontos de presença e viabilidade de instalação +- **Autenticação**: Via API Key e Cookie + +### Hubsoft API +- **Uso**: Criação e gestão de prospectos +- **Autenticação**: OAuth 2.0 com grant_type=password + +--- + +## Configuração de CORS + +A API está configurada para aceitar requisições apenas de domínios específicos: + +``` +✅ https://sothis.com.br +✅ https://bandalarga.srv.br +``` + +**Métodos HTTP Permitidos**: +- GET, POST, PUT, DELETE, OPTIONS + +**Headers Permitidos**: +- Content-Type +- Authorization + +Para adicionar novos domínios, edite o arquivo [app.js:18](src/app.js#L18). + +--- + ## Documentação da API O prefixo para todos os endpoints é `/api`. -### Consulta de Viabilidade +### 1. Consulta de Viabilidade por CEP -Verifica a viabilidade de serviço para um endereço. +Verifica a viabilidade técnica de um endereço usando CEP e número. - **Endpoint**: `POST /viabilidade` +- **Descrição**: Realiza geocodificação do endereço e consulta viabilidade na GeoGrid baseado em pontos de presença. - **Corpo da Requisição** (`application/json`): ```json { + "nome": "João Silva", + "email": "joao@email.com", + "telefone": "11987654321", "cep": "06419240", - "numero": 303 + "numero": "303", + "source": "" } ``` @@ -103,53 +216,289 @@ Verifica a viabilidade de serviço para um endereço. ```json { + "nome": "João Silva", + "email": "joao@email.com", + "telefone": "11987654321", + "logradouro": "Rua Imirim", + "numero": "303", "bairro": "Chácaras Marco", "cidade": "Barueri", "estado": "SP", - "logradouro": "Rua Imirim", + "cep": "06419240", "naoDedicado": true, - "dedicado": true + "dedicado": true, + "distancia": 250, + "provedor": "Sothis" } ``` + **Campos da Resposta**: + - `naoDedicado`: Serviço disponível em banda compartilhada (distância real ≤ 500m) + - `dedicado`: Serviço disponível em banda dedicada (distância real ≤ 1000m) + - `distancia`: Distância **real de trajeto** em metros até a caixa mais próxima (ou "5KM+" se não houver caixa no raio). Se o trajeto falhar, cai para a distância em linha reta. + - `distanciaReta`: Distância em **linha reta** em metros (aditivo, para comparação). + - `provedor`: Identificação do provedor baseado na sigla de pasta + + > **Nota:** a regra de viabilidade e o campo `distancia` passaram a usar a **distância real de trajeto** (via GeoGrid `/integracao/trajeto/cordenada`). Como a real é sempre ≥ a linha reta, endereços de borda podem deixar de viabilizar em relação ao comportamento anterior. + - **Respostas de Erro**: - `400 Bad Request`: Se `cep` ou `numero` não forem fornecidos. - - `404 Not Found`: Se o endereço não for encontrado para o CEP informado. - - `500 Internal Server Error`: Para outras falhas no processo. + - `422 Unprocessable Entity`: Se os dados de endereço estiverem incompletos ou inválidos. + - `502 Bad Gateway`: Se o CEP for inválido ou não encontrado na biblioteca `cep-promise`. + - `500 Internal Server Error`: Para outros erros no processo. -### Criação de Prospecto +--- -Cria um novo cliente potencial no Hubsoft. +### 1.1. Consulta de Viabilidade em Lote -- **Endpoint**: `POST /prospecto` +Consulta viabilidade para vários endereços/coordenadas de uma vez (usado pelo viabiliza). Reusa o mesmo core de viabilidade, com top-N de trajeto reduzido e concorrência limitada. + +- **Endpoint**: `POST /viabilidade/lote` - **Corpo da Requisição** (`application/json`): ```json { - "cep": "06419240", - "servicoId": 1, - "servicoValor": 100.00, - "numero": "303", - "endereco": "Rua Imirim", - "bairro": "Chácaras Marco", - "tipoPessoa": "F", - "nomeRazaoSocial": "Nome do Cliente", - "cpfCnpj": "123.456.789-00", - "email": "cliente@email.com", - "telefone": "11999998888" + "itens": [ + { "cep": "06419240", "numero": "303" }, + { "latitude": "-23.5070", "longitude": "-46.4036" } + ], + "modo": "walking" } ``` +- **Resposta de Sucesso** (`200 OK`): array alinhado por índice ao `itens` enviado. + + ```json + { + "resultados": [ + { + "index": 0, "provedor": "Sothis", + "distancia": 630, "distanciaReta": 300, "distanciaReal": 630, + "dedicado": true, "naoDedicado": false, + "caixas": [ + { "provedor": "Sothis", "sigla": "A", "distancia": 420, "distanciaReta": 300, "distanciaReal": 420, "dedicado": true, "naoDedicado": true }, + { "provedor": "Parceiro - X", "sigla": "B", "distancia": 900, "distanciaReta": 700, "distanciaReal": 900, "dedicado": true, "naoDedicado": false } + ] + }, + { "index": 1, "erro": "Item inválido: informe cep+numero ou latitude+longitude." } + ] + } + ``` + + - `caixas`: detalhe por caixa (as mais próximas em linha reta primeiro), cada uma com **seu próprio provedor**, distância de trajeto e classificação. No lote a seleção é **mista** (as N mais próximas de *qualquer* provedor — rede própria e parceiros), então caixas na mesma linha podem ter provedores diferentes. O viabiliza usa as **2 primeiras** para montar as colunas da planilha (Provedor/Não Dedicado/Dedicado/Distância por caixa). A consulta individual (`/viabilidade`) continua priorizando Sothis. + - Cada item é resolvido de forma independente: um erro em um item não derruba os demais (vem no campo `erro`). + - Coordenadas/CEPs repetidos são deduplicados (uma chamada externa por chave única). + +--- + +### 2. Consulta de Viabilidade por Coordenadas + +Verifica a viabilidade técnica usando latitude e longitude com reverse geocoding. + +- **Endpoint**: `POST /viabilidade/lat-long` +- **Descrição**: Consulta viabilidade baseado em coordenadas geográficas e realiza reverse geocoding. +- **Corpo da Requisição** (`application/json`): + + ```json + { + "latitude": "-23.5070", + "longitude": "-46.4036" + } + ``` + +- **Resposta de Sucesso** (`200 OK`): + + ```json + { + "endereco": "Avenida Paulista, 1000 - Bela Vista - São Paulo, SP", + "naoDedicado": false, + "dedicado": true, + "distancia": 750, + "provedor": "Sothis" + } + ``` + +- **Respostas de Erro**: + - `500 Internal Server Error`: Se ocorrer erro na consulta ou reverse geocoding. + +--- + +### 3. Criação de Prospecto + +Cria um novo cliente (Pessoa Física ou Jurídica) no Hubsoft. + +- **Endpoint**: `POST /prospecto` +- **Descrição**: Registra um novo prospecto no sistema Hubsoft com dados de contato e plano selecionado. +- **Corpo da Requisição** (`application/json`): + + **Para Pessoa Física (CPF não vazio)**: + ```json + { + "prospectData": { + "Nome completo": "João Silva Santos", + "CPF": "123.456.789-00", + "Nome da mãe": "Maria Silva", + "Data de nascimento": "15/05/1985", + "E-mail": "joao@email.com", + "Celular": "(11) 98765-4321", + "CEP": "06419240", + "Logradouro": "Rua Imirim", + "Número": "303", + "Complemento": "Apto 42", + "Bairro": "Chácaras Marco", + "Descrição": "100 Mega + Super WiFi", + "Valor (mensal)": "R$ 119,90" + } + } + ``` + + **Para Pessoa Jurídica (CPF vazio, CNPJ preenchido)**: + ```json + { + "prospectData": { + "Razão Social": "Empresa LTDA", + "CNPJ": "12.345.678/0001-90", + "E-mail": "contato@empresa.com", + "Celular": "(11) 98765-4321", + "CEP": "06419240", + "Logradouro": "Rua Imirim", + "Número": "303", + "Complemento": "", + "Bairro": "Chácaras Marco", + "Descrição": "500 Mega + Super WiFi + IP Fixo", + "Valor (mensal)": "R$ 249,90", + "CPF:": "" + } + } + ``` + + **Planos Disponíveis**: + - `100 Mega + Super WiFi` (ID: 22) + - `200 Mega + Super WiFi` (ID: 94) + - `500 Mega + Super WiFi` (ID: 96) + - `700 Mega + Super WiFi` (ID: 97) + - `1 Gb + Super WiFi` (ID: 32) + - Variações com `+ IP Fixo` + - **Resposta de Sucesso** (`200 OK`): ```json { "message": "Prospecto criado com sucesso", "data": { - // ... dados retornados pela API do Hubsoft + "tipo_pessoa": "pf", + "nome_razao_social": "João Silva Santos", + "cpf_cnpj": "123.456.789-00", + "email": "joao@email.com", + "telefone": "11987654321", + "endereco": "Rua Imirim", + "numero": "303", + "bairro": "Chácaras Marco", + "cep": "06419240", + "servico": { + "id_servico": 22, + "valor": 119.90 + } } } ``` - **Respostas de Erro**: - - `500 Internal Server Error`: Se ocorrer um erro na comunicação com o Hubsoft. \ No newline at end of file + - `400 Bad Request`: Se o plano informado for inválido ou não encontrado. + - `500 Internal Server Error`: Se ocorrer erro na comunicação com Hubsoft. + +--- + +## Estrutura do Projeto + +``` +src/ +├── app.js # Ponto de entrada da aplicação +├── routes/ +│ └── routes.js # Definição de rotas +├── modules/ +│ └── contratacao/ +│ ├── contratacao.controller.js # Controladoras HTTP +│ ├── contratacao.service.js # Lógica de negócio +│ ├── contratacao.repository.js # Acesso a dados +│ └── contratacao.model.js # Modelos de dados +└── shared/ + ├── apis/ + │ ├── googleService.js # Integração Google Maps + │ ├── geogridService.js # Integração GeoGrid + │ └── hubsoftService.js # Integração Hubsoft + ├── config/ + │ ├── environment.js # Carregamento de variáveis + │ ├── apiConfig.js # Configurações de API + │ └── dbConfig.js # Configurações de banco + └── utils/ + └── logger.js # Sistema de logging +``` + +--- + +## Boas Práticas e Considerações + +### Validação de CEP +- A biblioteca `cep-promise` realiza consulta em múltiplas bases de dados brasileiras +- Retorna erro 502 se o CEP for inválido ou não encontrado + +### Distância de Viabilidade +- **≤ 500m**: Ambos serviços disponíveis (dedicado e não-dedicado) +- **500m - 1000m**: Apenas serviço dedicado disponível +- **> 1000m**: Nenhum serviço disponível (retorna "5KM+") + +### Criação de Prospectos +- O tipo de pessoa é detectado automaticamente: CPF não vazio = Pessoa Física, CNPJ com CPF vazio = Pessoa Jurídica +- Números de telefone são sanitizados automaticamente (removem caracteres não-numéricos) +- Valores monetários aceitam diversos formatos (R$ 119,90 ou 119.90) + +### Segurança +- CORS restrito a domínios específicos +- Todas as requisições são logadas com IP e método +- Erros não expõem informações sensíveis ao cliente + +--- + +## Troubleshooting + +### Erro: "CEP não encontrado" +- Verifique se o CEP está correto e tem 8 dígitos +- Alguns CEPs novos podem não estar na base de dados + +### Erro: "Sem caixas dentro de 5km" +- Significa que não há ponto de presença próximo ao endereço +- O serviço não é viável para esta localização + +### Erro: "Plano inválido" +- Verifique se a descrição do plano corresponde exatamente a um dos planos mapeados +- Referência: [contratacao.service.js:159-170](src/modules/contratacao/contratacao.service.js#L159-L170) + +### Logs não aparecem +- Verifique se a pasta `logs/` foi criada (criada automaticamente) +- Verifique permissões de escrita no diretório do projeto + +--- + +## Histórico de Mudanças + +### Recent Updates +- ✅ Adicionado tratamento de erro para casos sem caixa em 5km +- ✅ Implementada variável de sigla de pasta no .env com mapeamento de provedor +- ✅ Refatorada busca de CEP usando `cep-promise` +- ✅ Adicionada URL de banda larga na configuração de CORS +- ✅ Atualizado suporte de CORS para Express 5.x + +--- + +## Suporte e Contribuições + +Para reportar bugs ou sugerir melhorias, entre em contato com a equipe de desenvolvimento Sothis. + +**Contato**: gabriel.amancio@sothis.com.br + +--- + +## Licença + +ISC \ No newline at end of file diff --git a/package.json b/package.json index b6fba49..e36f427 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev:api": "cross-env NODE_ENV=development nodemon src/app.js", "start:worker": "cross-env NODE_ENV=production node src/worker.js", "dev:worker": "cross-env NODE_ENV=development nodemon src/worker.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test" }, "repository": { "type": "git", diff --git a/src/modules/contratacao/contratacao.controller.js b/src/modules/contratacao/contratacao.controller.js index df3c417..4e66120 100644 --- a/src/modules/contratacao/contratacao.controller.js +++ b/src/modules/contratacao/contratacao.controller.js @@ -30,6 +30,19 @@ async function handleViabilidadeLatLong(req, res) { } } +async function handleViabilidadeLote(req, res) { + const { itens, modo } = req.body || {}; + logger.info('Recebida requisição de viabilidade em lote', { total: Array.isArray(itens) ? itens.length : 0, modo }); + try { + const resultados = await contratacaoService.verificarViabilidadeLote(itens, { modo }); + return res.json({ resultados }); + } catch (error) { + logger.error('Erro no controller ao processar viabilidade em lote', { message: error.message, stack: error.stack }); + const statusCode = error.statusCode || 500; + return res.status(statusCode).json({ error: error.message || "Erro interno ao processar a viabilidade em lote." }); + } +} + async function handleCriarProspecto(req, res) { const prospectData = req.body; @@ -52,27 +65,6 @@ async function handleCriarProspecto(req, res) { module.exports = { handleViabilidade, handleCriarProspecto, - handleViabilidadeLatLong + handleViabilidadeLatLong, + handleViabilidadeLote }; - -/* - DESCRIÇÃO: - Este arquivo é o "Controller" do módulo de contratação. Ele atua como a camada intermediária que conecta as rotas da API com a lógica de negócio (serviços). Sua principal responsabilidade é receber as requisições HTTP, extrair os dados necessários (do corpo, parâmetros, etc.), chamar o serviço correspondente e formatar a resposta a ser enviada de volta ao cliente. - - FUNÇÕES: - - handleViabilidade: - 1. É acionado por uma rota (ex: POST /api/contratacao/viabilidade). - 2. Extrai `cep` e `numero` do corpo da requisição (`req.body`). - 3. Chama a função `verificarViabilidade` do `contratacao.service`, passando os dados recebidos. - 4. Se a verificação for bem-sucedida, retorna o resultado como um JSON com status 200. - 5. Se ocorrer um erro (lançado pelo serviço), captura o erro, loga a mensagem e retorna uma resposta de erro em JSON com o `statusCode` apropriado (ex: 400, 404, 500). - - - handleCriarProspecto: - 1. É acionado por uma rota (ex: POST /api/contratacao/prospecto). - 2. Pega todos os dados do corpo da requisição (`req.body`), que representam os dados do prospecto. - 3. Chama a função `criarProspecto` do `contratacao.service`. - 4. Se a criação for bem-sucedida, retorna uma mensagem de sucesso com os dados do resultado. - 5. Em caso de erro, segue um fluxo de tratamento de erro similar ao `handleViabilidade`. - - Este controller garante que a lógica de negócio permaneça desacoplada do Express, focando apenas na orquestração do fluxo da requisição e resposta. -*/ \ No newline at end of file diff --git a/src/modules/contratacao/contratacao.model.js b/src/modules/contratacao/contratacao.model.js index 90486cf..3754dd2 100644 --- a/src/modules/contratacao/contratacao.model.js +++ b/src/modules/contratacao/contratacao.model.js @@ -1,6 +1,8 @@ // classe construtor para o modelo de viabilidade class ViabilidadeModel { - constructor(nome, email, telefone, logradouro, numero, bairro, city, state, cep, naoDedicado, dedicado, distancia, provedor) { + // `distancia` passou a representar a distância REAL de trajeto (fallback: linha reta). + // `distanciaReta` é aditivo (linha reta), para comparação/depuração — não quebra consumidores. + constructor(nome, email, telefone, logradouro, numero, bairro, city, state, cep, naoDedicado, dedicado, distancia, provedor, distanciaReta = null) { // Inicialização de propriedades do modelo pode ser feita aqui this.nome = nome; this.email = email; @@ -15,17 +17,19 @@ class ViabilidadeModel { this.dedicado = dedicado; this.distancia = distancia; this.provedor = provedor; + this.distanciaReta = distanciaReta; } } class ViabilidadeReverseGeocodeModel { - constructor(endereco, naoDedicado, dedicado, distancia, provedor) { + constructor(endereco, naoDedicado, dedicado, distancia, provedor, distanciaReta = null) { // Inicialização de propriedades do modelo pode ser feita aqui this.endereco = endereco; this.naoDedicado = naoDedicado; this.dedicado = dedicado; this.distancia = distancia; this.provedor = provedor; + this.distanciaReta = distanciaReta; } } @@ -156,19 +160,3 @@ class ProspectModel { } module.exports = {ViabilidadeReverseGeocodeModel, ViabilidadeModel, ClientModelPf, ClientModelPj, ProspectModel}; - - - -/* - DESCRIÇÃO: - Este arquivo é destinado a definir o "Model" ou o esquema de dados para o módulo de contratação. - - FLUXO: - - Em uma arquitetura com banco de dados, este arquivo normalmente conteria a definição de um modelo de dados usando um ORM (Object-Relational Mapper) como Sequelize (para SQL) ou Mongoose (para MongoDB). - - O modelo definiria os campos, tipos de dados, validações e relacionamentos para a entidade "contratação" ou "prospecto". - - Por exemplo, poderia definir que um prospecto deve ter campos como `nome` (String, obrigatório), `email` (String, formato de email), `cep` (String), etc. - - Este modelo seria então utilizado pelo `contratacao.repository.js` para realizar operações de CRUD (Create, Read, Update, Delete) no banco de dados de forma estruturada e segura. - - ESTADO ATUAL: - Atualmente, o arquivo está como um placeholder e não contém uma implementação de modelo. A lógica de negócio não depende de uma estrutura de dados formalmente definida aqui. -*/ \ No newline at end of file diff --git a/src/modules/contratacao/contratacao.repository.js b/src/modules/contratacao/contratacao.repository.js index daccbc2..5a1bdfe 100644 --- a/src/modules/contratacao/contratacao.repository.js +++ b/src/modules/contratacao/contratacao.repository.js @@ -46,22 +46,3 @@ async function insertViabilidadeData(viabilidadeData) { module.exports = { insertViabilidadeData }; - -/* - DESCRIÇÃO: - Este arquivo implementa o padrão de "Repository" (Repositório) para o módulo de contratação. A camada de repositório é responsável por toda a comunicação com a fonte de dados, que geralmente é um banco de dados. - - FLUXO: - 1. Seria chamado pelo `contratacao.service.js` para persistir ou recuperar dados. - 2. Utilizaria o `contratacao.model.js` para interagir com o banco de dados de forma estruturada. Por exemplo, usaria um modelo Sequelize ou Mongoose para executar queries. - 3. Conteria métodos para operações de CRUD (Create, Read, Update, Delete), como: - - `create(prospectData)`: Para salvar um novo prospecto no banco de dados. - - `findById(id)`: Para buscar um prospecto pelo seu ID. - - `findAll()`: Para listar todos os prospectos. - - `update(id, newData)`: Para atualizar os dados de um prospecto. - - `delete(id)`: Para remover um prospecto. - 4. Abstrairia os detalhes de implementação do banco de dados, permitindo que o serviço solicite os dados sem se preocupar em como eles são armazenados ou recuperados. - - ESTADO ATUAL: - Atualmente, o arquivo é um placeholder. Como a lógica atual da aplicação se baseia em chamadas a APIs externas (Hubsoft) e não em um banco de dados local para prospectos, este repositório não tem uma implementação ativa. Se a aplicação precisasse armazenar dados localmente, este arquivo seria implementado. -*/ \ No newline at end of file diff --git a/src/modules/contratacao/contratacao.service.js b/src/modules/contratacao/contratacao.service.js index 086a7bd..d40215e 100644 --- a/src/modules/contratacao/contratacao.service.js +++ b/src/modules/contratacao/contratacao.service.js @@ -1,8 +1,10 @@ -const geogridService = require("../../shared/apis/geogridService.js"); const googleService = require("../../shared/apis/googleService.js"); const hubsoftService = require("../../shared/apis/hubsoftService.js"); const logger = require('../../shared/utils/logger.js'); +const apiConfig = require('../../shared/config/apiConfig.js'); const repository = require('./contratacao.repository.js'); +const core = require('../viabilidade/viabilidade.core.js'); +const { mapWithConcurrency } = require('../../shared/utils/concurrency.js'); const { ViabilidadeModel, ClientModelPf, ClientModelPj, ProspectModel, ViabilidadeReverseGeocodeModel } = require('./contratacao.model'); // utilitário para ler chaves do payload tolerante a ":" e espaços @@ -30,21 +32,10 @@ class ServiceError extends Error { } } -// Função para verificar a viabilidade de um endereço - -async function verificarViabilidade(rawViabilidadeData) { - const rawCep = rawViabilidadeData.cep; - const rawNumero = rawViabilidadeData.numero; - const source = rawViabilidadeData.source || ''; - +// Resolve as coordenadas de um endereço a partir de CEP + número: +// cep-promise (dados do endereço) -> Google Geocoding (lat/lon). +async function resolverCoordenadasPorCep(rawCep, rawNumero) { let address; - let addressString; - - if (!rawCep || !rawNumero) { - logger.warn('CEP ou número não fornecidos na verificação de viabilidade.'); - throw new ServiceError('CEP e número são obrigatórios.', 400); - } - try { const cep = require('cep-promise'); address = await cep(rawCep); @@ -54,13 +45,8 @@ async function verificarViabilidade(rawViabilidadeData) { throw new ServiceError('Não foi possível obter endereço para o CEP informado ou o CEP é inválido.', 502); } - - // Obtém as coordenadas geográficas do endereço usando o Google Geocoding API - const { street, neighborhood, city, state, cep } = address; - - - addressString = `${street}, ${rawNumero}, ${neighborhood}, ${city}, ${state}, ${rawCep}`; + const addressString = `${street}, ${rawNumero}, ${neighborhood}, ${city}, ${state}, ${rawCep}`; logger.info('Endereço montado para geocodificação', { addressString }); if (!street || !neighborhood || !city || !state) { @@ -75,31 +61,33 @@ async function verificarViabilidade(rawViabilidadeData) { } logger.info('Coordenadas obtidas com sucesso', { coords }); - const viabilidade = await geogridService.consultaViabilidade(coords.lat, coords.lon); + return { coords, address: { street, neighborhood, city, state, cep } }; +} - let naoDedicado = false; - let dedicado = false; +// Função para verificar a viabilidade de um endereço. +// A distância usada na regra é a REAL (trajeto); `distanciaReta` vai junto no resultado. +async function verificarViabilidade(rawViabilidadeData) { + const rawCep = rawViabilidadeData.cep; + const rawNumero = rawViabilidadeData.numero; + const source = rawViabilidadeData.source || ''; - if (viabilidade.data && viabilidade.data.distancia !== undefined) { - var distancia = viabilidade.data.distancia; - logger.info(`Distância para o ponto de presença: ${distancia}m`); - if (distancia <= 500) { - naoDedicado = true; - dedicado = true; - } else if (distancia <= 1000) { - naoDedicado = false; - dedicado = true; - } - } else { - naoDedicado = false; - dedicado = false; - var distancia = "5KM+"; - logger.warn('Dados de viabilidade não contêm informação de distância', { viabilidadeData: viabilidade.data }); + if (!rawCep || !rawNumero) { + logger.warn('CEP ou número não fornecidos na verificação de viabilidade.'); + throw new ServiceError('CEP e número são obrigatórios.', 400); } - + const { coords, address } = await resolverCoordenadasPorCep(rawCep, rawNumero); + const { street, neighborhood, city, state, cep } = address; - const viabilidadeResult = new ViabilidadeModel(rawViabilidadeData.nome, rawViabilidadeData.email, rawViabilidadeData.telefone, street, rawNumero, neighborhood, city, state, cep, naoDedicado, dedicado, distancia || null, viabilidade.data.pasta.sigla || null); + const avaliacao = await core.avaliarPorCoordenadas(coords); + logger.info('Avaliação de viabilidade concluída', { avaliacao }); + + const viabilidadeResult = new ViabilidadeModel( + rawViabilidadeData.nome, rawViabilidadeData.email, rawViabilidadeData.telefone, + street, rawNumero, neighborhood, city, state, cep, + avaliacao.naoDedicado, avaliacao.dedicado, avaliacao.distancia, avaliacao.provedor, + avaliacao.distanciaReta + ); if (source) { return viabilidadeResult; @@ -121,33 +109,99 @@ async function verificarGeocodeReverso(lat, lon) { } async function verificarViabilidadeLatLong(latitude, longitude) { - const viabilidade = await geogridService.consultaViabilidade(latitude, longitude); + const coords = { lat: Number(latitude), lon: Number(longitude) }; - let naoDedicado = false; - let dedicado = false; - - if (viabilidade.data && viabilidade.data.distancia !== undefined) { - var distancia = viabilidade.data.distancia; - logger.info(`Distância para o ponto de presença: ${distancia}m`); - if (distancia <= 500) { - naoDedicado = true; - dedicado = true; - } else if (distancia <= 1000) { - naoDedicado = false; - dedicado = true; - } - } else { - naoDedicado = false; - dedicado = false; - var distancia = "5KM+"; - logger.warn('Dados de viabilidade não contêm informação de distância', { viabilidadeData: viabilidade.data }); - } + const avaliacao = await core.avaliarPorCoordenadas(coords); + logger.info('Avaliação de viabilidade (lat/long) concluída', { avaliacao }); const address = await verificarGeocodeReverso(latitude, longitude); - const viabilidadeResult = new ViabilidadeReverseGeocodeModel(address, naoDedicado, dedicado, distancia, viabilidade.data.pasta.sigla || null); + return new ViabilidadeReverseGeocodeModel( + address, avaliacao.naoDedicado, avaliacao.dedicado, avaliacao.distancia, avaliacao.provedor, + avaliacao.distanciaReta + ); +} - return viabilidadeResult; +// Formata o resultado do core para o item de resposta do lote. +function formatarResultadoLote(avaliacao) { + return { + provedor: avaliacao.provedor, + distancia: avaliacao.distancia, + distanciaReta: avaliacao.distanciaReta, + distanciaReal: avaliacao.distanciaReal, + dedicado: avaliacao.dedicado, + naoDedicado: avaliacao.naoDedicado, + // detalhe por caixa (mais próxima em linha reta primeiro) — usado nas colunas da planilha + caixas: (avaliacao.caixas || []).map(c => ({ + provedor: c.provedor, + sigla: c.sigla, + distancia: c.distancia, + distanciaReta: c.distanciaReta, + distanciaReal: c.distanciaReal, + dedicado: c.dedicado, + naoDedicado: c.naoDedicado + })) + }; +} + +// Verificação em LOTE (usada pelo viabiliza). Aceita itens {cep, numero} ou +// {latitude, longitude}. Deduplica coordenadas/CEPs iguais, executa com +// concorrência limitada e usa top-N reduzido (mais barato) para o trajeto. +async function verificarViabilidadeLote(itens, { modo } = {}) { + if (!Array.isArray(itens)) { + throw new ServiceError('O campo "itens" deve ser um array.', 400); + } + + const topN = apiConfig.geogridTrajetoTopNLote; + + const chaveDe = (item) => { + if (item && item.latitude !== undefined && item.longitude !== undefined) { + const lat = Number(item.latitude), lon = Number(item.longitude); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return `geo:${lat.toFixed(6)},${lon.toFixed(6)}`; + } + if (item && item.cep && item.numero) { + return `cep:${String(item.cep).replace(/\D/g, '')}-${String(item.numero).trim()}`; + } + return null; + }; + + const avaliarItem = async (item) => { + // Lote/planilha: mistura provedores (2 caixas mais próximas de qualquer provedor). + const opts = { modo, topN, misturarProvedores: true }; + if (item && item.latitude !== undefined && item.longitude !== undefined) { + const a = await core.avaliarPorCoordenadas({ lat: Number(item.latitude), lon: Number(item.longitude) }, opts); + return formatarResultadoLote(a); + } + const { coords } = await resolverCoordenadasPorCep(item.cep, item.numero); + const a = await core.avaliarPorCoordenadas(coords, opts); + return formatarResultadoLote(a); + }; + + // Resolve apenas as chaves únicas (evita chamadas externas repetidas) e mapeia de volta. + const chaves = itens.map(chaveDe); + const unicas = [...new Set(chaves.filter(Boolean))]; + const primeiroItemDaChave = unicas.map(chave => itens[chaves.indexOf(chave)]); + + const resultadosUnicos = await mapWithConcurrency(primeiroItemDaChave, apiConfig.geogridConcurrency, async (item) => { + try { + return await avaliarItem(item); + } catch (e) { + logger.warn('Falha ao avaliar item do lote', { message: e.message }); + return { erro: e.message || 'Erro ao avaliar item.' }; + } + }); + + const porChave = {}; + unicas.forEach((chave, i) => { porChave[chave] = resultadosUnicos[i]; }); + + return itens.map((item, index) => { + const chave = chaves[index]; + if (!chave) { + return { index, erro: 'Item inválido: informe cep+numero ou latitude+longitude.' }; + } + return { index, ...porChave[chave] }; + }); } @@ -264,5 +318,6 @@ module.exports = { verificarViabilidade, criarProspecto, verificarViabilidadeLatLong, + verificarViabilidadeLote, verificarGeocodeReverso, }; diff --git a/src/modules/viabilidade/viabilidade.core.js b/src/modules/viabilidade/viabilidade.core.js new file mode 100644 index 0000000..6298d96 --- /dev/null +++ b/src/modules/viabilidade/viabilidade.core.js @@ -0,0 +1,95 @@ +const geogridService = require('../../shared/apis/geogridService.js'); +const apiConfig = require('../../shared/config/apiConfig.js'); +const logger = require('../../shared/utils/logger.js'); +const { mapWithConcurrency } = require('../../shared/utils/concurrency.js'); + +// Regra de negócio PURA: dada uma distância (em metros), define quais serviços +// estão disponíveis. É a mesma regra de sempre — o que muda é a distância que +// alimenta ela (agora a real de trajeto). Isolada e testável. +function classificarPorDistancia(distancia) { + if (distancia == null || typeof distancia !== 'number' || Number.isNaN(distancia)) { + return { dedicado: false, naoDedicado: false }; + } + if (distancia <= 500) return { dedicado: true, naoDedicado: true }; + if (distancia <= 1000) return { dedicado: true, naoDedicado: false }; + return { dedicado: false, naoDedicado: false }; // 1001–5000m: caixa existe, mas fora do alcance +} + +// Núcleo de orquestração: a partir de coordenadas de origem (endereço), +// 1) acha as N caixas mais próximas em linha reta (GeoGrid), +// 2) calcula a distância REAL de trajeto até cada uma e pega a menor, +// 3) classifica a viabilidade sobre a distância real (com fallback p/ reta). +// +// Retorna um resultado de domínio NEUTRO (sem formato de resposta HTTP): +// { distancia, distanciaReta, distanciaReal, dedicado, naoDedicado, provedor } +// - `distancia` é a EFETIVA usada na regra: real quando disponível, senão reta. +async function avaliarPorCoordenadas(origem, { modo = apiConfig.geogridTrajetoModo, topN, misturarProvedores = false } = {}) { + const { provedor, caixas } = await geogridService.consultarCaixasProximas(origem.lat, origem.lon, { topN, misturarProvedores }); + + // Sem caixa no raio: mantém o comportamento histórico ("5KM+", nada viável). + if (!caixas || caixas.length === 0) { + return { + distancia: "5KM+", + distanciaReta: null, + distanciaReal: null, + dedicado: false, + naoDedicado: false, + provedor, + caixas: [] + }; + } + + // Para cada caixa (na ordem do GeoGrid = por distância em linha reta), + // calcula a distância real de trajeto e classifica individualmente. + const detalhes = await mapWithConcurrency(caixas, apiConfig.geogridConcurrency, async (caixa) => { + let distanciaReal = null; + if (caixa.lat != null && caixa.lon != null) { + try { + const trajeto = await geogridService.consultaTrajeto(origem, caixa, modo); + const d = Number(trajeto && trajeto.distancia); + distanciaReal = Number.isFinite(d) ? d : null; + } catch (err) { + logger.warn('Trajeto falhou para uma caixa, usando linha reta nesta candidata', { + message: err.message, + status: err.response?.status, + data: err.response?.data, // corpo do erro do GeoGrid (motivo do 400) + url: err.config?.url, + caixa: { lat: caixa.lat, lon: caixa.lon } + }); + } + } + const distancia = distanciaReal ?? caixa.distanciaReta; // efetiva por caixa (real, senão reta) + return { + provedor: caixa.provedor, + sigla: caixa.sigla, + lat: caixa.lat, + lon: caixa.lon, + distanciaReta: caixa.distanciaReta, + distanciaReal, + distancia, + ...classificarPorDistancia(distancia) + }; + }); + + // Agregado (compatibilidade WordPress): menor distância efetiva entre as caixas. + const distanciaReta = detalhes[0].distanciaReta ?? null; // caixa mais próxima em linha reta + const reais = detalhes.map(d => d.distanciaReal).filter(d => d != null); + const distanciaReal = reais.length ? Math.min(...reais) : null; + + if (distanciaReal == null) { + logger.warn('Nenhum trajeto disponível; usando distância em linha reta como fallback', { origem, distanciaReta }); + } + + const distanciaEfetiva = distanciaReal ?? distanciaReta; + + return { + distancia: distanciaEfetiva, + distanciaReta, + distanciaReal, + ...classificarPorDistancia(distanciaEfetiva), + provedor, + caixas: detalhes // detalhe por caixa (mais próxima em linha reta primeiro) + }; +} + +module.exports = { classificarPorDistancia, avaliarPorCoordenadas }; diff --git a/src/routes/routes.js b/src/routes/routes.js index 1d3b473..4b7f40a 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -5,6 +5,9 @@ const contratacaoController = require('../modules/contratacao/contratacao.contro // Rota para consulta de viabilidade router.post('/viabilidade', contratacaoController.handleViabilidade); +// Rota para consulta de viabilidade em lote (array de itens cep+numero ou lat/long) +router.post('/viabilidade/lote', contratacaoController.handleViabilidadeLote); + // Rota para criação de prospecto no Hubsoft router.post('/prospecto', contratacaoController.handleCriarProspecto); diff --git a/src/shared/apis/geogridService.js b/src/shared/apis/geogridService.js index b889ae1..664c01f 100644 --- a/src/shared/apis/geogridService.js +++ b/src/shared/apis/geogridService.js @@ -3,75 +3,160 @@ const axios = require("axios"); const qs = require("qs"); const logger = require('../utils/logger.js'); +// Busca bruta dos registros (caixas) no GeoGrid por raio. Mantém exatamente os +// mesmos parâmetros/serialização/headers da consulta original. +const fetchRegistros = async (lat, lon) => { + const params = { + raio: 5000, + latitude: lat, + longitude: lon, + "itens[]": "caixa", + ordenarCampos: ["distancia"], + ordenarPor: ["asc"], + consultarPasta: "S", + consultarIndividual: "S" + }; -const consultaViabilidade = async (lat, lon) => { + const response = await axios.get(apiConfig.geogridApiUrl, { + params, + // força a serialização do tipo `itens[]=caixa` + paramsSerializer: p => qs.stringify(p, { arrayFormat: 'brackets' }), + headers: { + 'api-key': apiConfig.geogridApiKey, + Cookie: apiConfig.geogridApiCookie + } + }); - const url = apiConfig.geogridApiUrl; - const apiKey = apiConfig.geogridApiKey; - const apiCookie = apiConfig.geogridApiCookie; + const registros = response.data?.registros || []; + logger.info("Resposta do GeoGrid", { + totalRegistros: registros.length, + siglas: registros.map(r => r.pasta?.sigla), + autorizadas: apiConfig.geogridAuthorizedSiglasPastas + }); + + return registros; +}; + +// Seleção de registros por prioridade: autorizados (rede própria) > parceiros > nenhum. +// Retorna a LISTA ordenada (por distância, como veio do GeoGrid) + o provedor resolvido. +// Elegibilidade de um registro: rede própria (sigla autorizada) ou parceiro. +// Siglas fora disso (sub-projetos, etc.) NÃO são consideradas viabilidade. +const ehAutorizado = (sigla) => apiConfig.geogridAuthorizedSiglasPastas.includes(sigla); +const ehParceiro = (sigla) => !!sigla && (sigla.toLowerCase().includes("parceiro -") || sigla.toLowerCase().includes("parceiros - outros")); +const ehElegivel = (registro) => { + const sigla = registro && registro.pasta && registro.pasta.sigla; + return ehAutorizado(sigla) || ehParceiro(sigla); +}; + +const selecionarRegistros = (registros) => { + const autorizados = registros.filter(r => ehAutorizado(r.pasta && r.pasta.sigla)); + if (autorizados.length > 0) { + return { provedor: "Sothis", tipo: "autorizado", registros: autorizados }; + } + + const parceiros = registros.filter(r => ehParceiro(r.pasta && r.pasta.sigla)); + if (parceiros.length > 0) { + return { provedor: parceiros[0].pasta.sigla, tipo: "parceiro", registros: parceiros }; + } + + return { provedor: "Nenhum provedor disponível", tipo: "nenhum", registros: [] }; +}; + +// Normaliza um registro do GeoGrid para o que o core precisa: coordenadas da +// caixa (destino do trajeto) + distância em linha reta + sigla da pasta. +// Resolve o provedor de UM registro: rede própria (sigla autorizada) => "Sothis"; +// caso contrário, a própria sigla da pasta (parceiro/sub-projeto/etc.). +const provedorDoRegistro = (registro) => { + const sigla = registro?.pasta?.sigla; + if (apiConfig.geogridAuthorizedSiglasPastas.includes(sigla)) return "Sothis"; + return sigla || "Desconhecido"; +}; + +const mapCaixa = (registro) => { + const lat = Number(registro?.latitude); + const lon = Number(registro?.longitude); + // O GeoGrid retorna a distância como string (ex.: "395.14") — converte para número. + const dReta = Number(registro?.distancia); + return { + lat: Number.isFinite(lat) ? lat : null, + lon: Number.isFinite(lon) ? lon : null, + distanciaReta: Number.isFinite(dReta) ? dReta : null, + sigla: registro?.pasta?.sigla ?? null, + provedor: provedorDoRegistro(registro) + }; +}; + +// NOVO: retorna as N caixas mais próximas (em linha reta) já com coordenadas, +// para que o core possa calcular a distância real via trajeto. +const consultarCaixasProximas = async (lat, lon, { topN = apiConfig.geogridTrajetoTopN, misturarProvedores = false } = {}) => { try { - // Parâmetros da consulta - const params = { - raio: 5000, - latitude: lat, - longitude: lon, - "itens[]": "caixa", - ordenarCampos: ["distancia"], - ordenarPor: ["asc"], - consultarPasta: "S", - consultarIndividual: "S" - }; + const registros = await fetchRegistros(lat, lon); - const response = await axios.get(url, { - params, - // força a serialização do tipo `itens[]=caixa` - paramsSerializer: p => qs.stringify(p, { arrayFormat: 'brackets' }), - headers: { - 'api-key': apiKey, - Cookie: apiCookie - } - }); + if (misturarProvedores) { + // As N caixas mais próximas dentre APENAS autorizadas (Sothis) + parceiros, + // sem priorizar entre elas; cada uma com seu provedor resolvido. + const elegiveis = registros.filter(ehElegivel); + const caixas = elegiveis.slice(0, Math.max(1, topN)).map(mapCaixa); + const provedor = caixas.length ? caixas[0].provedor : "Nenhum provedor disponível"; + return { provedor, tipo: "misto", caixas }; + } - // Extrai o primeiro registro da resposta em que a pasta.sigla seja igual as siglas autorizadas no .env - const registros = response.data?.registros || []; + // Padrão (WordPress): prioriza rede própria (Sothis); só cai para parceiros se não houver. + const { provedor, tipo, registros: selecionados } = selecionarRegistros(registros); + const caixas = selecionados.slice(0, Math.max(1, topN)).map(mapCaixa); + return { provedor, tipo, caixas }; + } catch (error) { + logger.error("Erro ao consultar caixas próximas no GeoGrid", { message: error.message, stack: error.stack, lat, lon }); + throw new Error("Erro ao consultar viabilidade"); + } +}; - logger.info("Resposta do GeoGrid", { - totalRegistros: registros.length, - siglas: registros.map(r => r.pasta?.sigla), - autorizadas: apiConfig.geogridAuthorizedSiglasPastas - }); +// NOVO: distância REAL de trajeto entre origem (endereço) e destino (caixa). +const consultaTrajeto = async (origem, destino, modo = apiConfig.geogridTrajetoModo) => { + const base = apiConfig.geogridApiBaseUrl; + if (!base) { + throw new Error("GEOGRID_API_BASE_URL não configurada para consulta de trajeto."); + } + const url = `${base.replace(/\/$/, '')}/integracao/trajeto/cordenada`; + const body = { + origem: { latitude: String(origem.lat), longitude: String(origem.lon) }, + destino: { latitude: String(destino.lat), longitude: String(destino.lon) }, + modo + }; - // Filtra registros autorizados (ex: SOTHIS, VIVO, OI) - const registros_autorizados = registros.filter(r => { - const sigla = r.pasta && r.pasta.sigla; - return apiConfig.geogridAuthorizedSiglasPastas.includes(sigla); - }); + const response = await axios.post(url, body, { + headers: { + 'api-key': apiConfig.geogridApiKey, + Cookie: apiConfig.geogridApiCookie, + 'Content-Type': 'application/json' + }, + timeout: 10000 + }); - // Se encontrar autorizado, retorna com sigla "Sothis" - if (registros_autorizados.length > 0) { - const resultado = registros_autorizados[0]; + return response.data; // { distancia, pontosIntermediario, pontos } +}; + +// LEGADO: mantido com a forma de retorno original (`{ data: registro }`) para +// preservar compatibilidade. O fluxo novo usa consultarCaixasProximas + consultaTrajeto. +const consultaViabilidade = async (lat, lon) => { + try { + const registros = await fetchRegistros(lat, lon); + const { tipo, registros: selecionados } = selecionarRegistros(registros); + + if (tipo === "autorizado") { + const resultado = selecionados[0]; resultado.pasta.sigla = "Sothis"; return { data: resultado }; } - - // Se não encontrar autorizado, tenta buscar parceiro - const registros_parceiros = registros.filter(r => { - const sigla = r.pasta && r.pasta.sigla; - return sigla && (sigla.toLowerCase().includes("parceiro -") || sigla.toLowerCase().includes("parceiros - outros")); - }); - - if (registros_parceiros.length > 0) { - return { data: registros_parceiros[0] }; + if (tipo === "parceiro") { + return { data: selecionados[0] }; } - - // Se não encontrar autorizado nem parceiro, retorna mensagem padrão return { data: { pasta: { sigla: "Nenhum provedor disponível" } } }; } catch (error) { logger.error("Erro ao consultar viabilidade no GeoGrid", { message: error.message, stack: error.stack, lat, lon }); throw new Error("Erro ao consultar viabilidade"); - } + } }; -module.exports = { consultaViabilidade }; - +module.exports = { consultaViabilidade, consultarCaixasProximas, consultaTrajeto, mapCaixa }; diff --git a/src/shared/config/apiConfig.js b/src/shared/config/apiConfig.js index 3cf24e1..66e0840 100644 --- a/src/shared/config/apiConfig.js +++ b/src/shared/config/apiConfig.js @@ -5,11 +5,22 @@ dotenv.config(); const googleApiKey = process.env.GOOGLE_API_KEY; // Geogrid API Configs -const geogridApiUrl = process.env.GEOGRID_API_URL; +const geogridApiUrl = process.env.GEOGRID_API_URL; // URL completa da consulta por raio (inalterada) const geogridApiKey = process.env.GEOGRID_API_KEY; const geogridApiCookie = process.env.GEOGRID_API_COOKIE; const geogridAuthorizedSiglasPastas = process.env.GEOGRID_AUTHORIZED_SIGLAS_PASTAS ? process.env.GEOGRID_AUTHORIZED_SIGLAS_PASTAS.split(',').map(s => s.trim()) : []; +// Base da API GeoGrid: usada para montar endpoints além da consulta (ex.: trajeto). +// Se GEOGRID_API_BASE_URL não for definida, deriva da URL de consulta removendo o sufixo /viabilidade/raio. +const geogridApiBaseUrl = process.env.GEOGRID_API_BASE_URL + || (geogridApiUrl ? geogridApiUrl.replace(/\/viabilidade\/raio\/?$/, '') : undefined); + +// Trajeto (distância real): configuráveis, com defaults seguros. +const geogridTrajetoModo = process.env.GEOGRID_TRAJETO_MODO || 'walking'; +const geogridTrajetoTopN = Number(process.env.GEOGRID_TRAJETO_TOP_N) || 3; // consulta individual: mais preciso +const geogridTrajetoTopNLote = Number(process.env.GEOGRID_TRAJETO_TOP_N_LOTE) || 2; // lote/CSV: 2 caixas (colunas da planilha) +const geogridConcurrency = Number(process.env.GEOGRID_CONCURRENCY) || 5; // chamadas externas simultâneas + // Hubsoft API Configs const hubsoftUrl = process.env.HUBSOFT_URL; const hubsoftAuthUrl = `${process.env.HUBSOFT_URL}oauth/token`; @@ -22,9 +33,14 @@ const hubsoftGrantType = process.env.HUBSOFT_GRANT_TYPE; module.exports = { googleApiKey: googleApiKey, geogridApiUrl: geogridApiUrl, + geogridApiBaseUrl: geogridApiBaseUrl, geogridApiKey: geogridApiKey, geogridApiCookie: geogridApiCookie, geogridAuthorizedSiglasPastas: geogridAuthorizedSiglasPastas, + geogridTrajetoModo: geogridTrajetoModo, + geogridTrajetoTopN: geogridTrajetoTopN, + geogridTrajetoTopNLote: geogridTrajetoTopNLote, + geogridConcurrency: geogridConcurrency, hubsoftUrl: hubsoftUrl, hubsoftAuthUrl: hubsoftAuthUrl, diff --git a/src/shared/utils/concurrency.js b/src/shared/utils/concurrency.js new file mode 100644 index 0000000..efd37d2 --- /dev/null +++ b/src/shared/utils/concurrency.js @@ -0,0 +1,18 @@ +// Executa `fn` sobre cada item de `items` com no máximo `limit` execuções +// simultâneas, preservando a ordem dos resultados. Sem dependências externas. +async function mapWithConcurrency(items, limit, fn) { + const results = new Array(items.length); + let cursor = 0; + + const workers = new Array(Math.min(Math.max(1, limit), items.length || 1)).fill(null).map(async () => { + while (cursor < items.length) { + const index = cursor++; + results[index] = await fn(items[index], index); + } + }); + + await Promise.all(workers); + return results; +} + +module.exports = { mapWithConcurrency }; diff --git a/test/viabilidade.test.js b/test/viabilidade.test.js new file mode 100644 index 0000000..bdd49a6 --- /dev/null +++ b/test/viabilidade.test.js @@ -0,0 +1,207 @@ +// Define siglas autorizadas ANTES de carregar apiConfig (que lê o env no require). +process.env.GEOGRID_AUTHORIZED_SIGLAS_PASTAS = 'São Paulo - SP, TMC'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// --- Mock das integrações externas (property-access em tempo de chamada => patchável) --- +const geogridService = require('../src/shared/apis/geogridService.js'); +const googleService = require('../src/shared/apis/googleService.js'); + +// Guarda a implementação real de consultarCaixasProximas (outros testes a substituem por mock). +const realConsultarCaixasProximas = geogridService.consultarCaixasProximas; + +// cep-promise é chamado como função default => substitui no cache de módulos +const cepPath = require.resolve('cep-promise'); +require.cache[cepPath] = { + id: cepPath, filename: cepPath, loaded: true, + exports: async () => ({ street: 'Rua Imirim', neighborhood: 'Chácaras Marco', city: 'Barueri', state: 'SP', cep: '06419240' }) +}; +googleService.geocodeWithGoogle = async () => ({ lat: -12.9, lon: -46.9 }); + +const core = require('../src/modules/viabilidade/viabilidade.core.js'); +const service = require('../src/modules/contratacao/contratacao.service.js'); + +function mockGeogrid({ caixas, provedor = 'Sothis', trajeto = 630 } = {}) { + geogridService.consultarCaixasProximas = async () => ({ + provedor, tipo: 'autorizado', + caixas: caixas ?? [{ lat: -12.9, lon: -46.9, distanciaReta: 300, sigla: 'X', provedor: 'Sothis' }] + }); + geogridService.consultaTrajeto = async () => ({ distancia: trajeto }); +} + +test('classificarPorDistancia aplica a regra 500/1000', () => { + assert.deepEqual(core.classificarPorDistancia(0), { dedicado: true, naoDedicado: true }); + assert.deepEqual(core.classificarPorDistancia(500), { dedicado: true, naoDedicado: true }); + assert.deepEqual(core.classificarPorDistancia(501), { dedicado: true, naoDedicado: false }); + assert.deepEqual(core.classificarPorDistancia(1000), { dedicado: true, naoDedicado: false }); + assert.deepEqual(core.classificarPorDistancia(1001), { dedicado: false, naoDedicado: false }); + assert.deepEqual(core.classificarPorDistancia('5KM+'), { dedicado: false, naoDedicado: false }); + assert.deepEqual(core.classificarPorDistancia(null), { dedicado: false, naoDedicado: false }); +}); + +test('core usa a distância REAL (trajeto), não a linha reta', async () => { + mockGeogrid({ caixas: [{ lat: -12.9, lon: -46.9, distanciaReta: 300, sigla: 'X' }], trajeto: 630 }); + const r = await core.avaliarPorCoordenadas({ lat: -12.9, lon: -46.9 }); + assert.equal(r.distanciaReta, 300); + assert.equal(r.distanciaReal, 630); + assert.equal(r.distancia, 630); // efetiva = real + assert.equal(r.dedicado, true); // 630 <= 1000 + assert.equal(r.naoDedicado, false); // 630 > 500 (a reta 300 diria true => prova que usa a real) +}); + +test('core pega a MENOR distância real entre as top-N caixas', async () => { + geogridService.consultarCaixasProximas = async () => ({ + provedor: 'Sothis', tipo: 'autorizado', + caixas: [{ lat: 1, lon: 1, distanciaReta: 100, sigla: 'A' }, { lat: 2, lon: 2, distanciaReta: 200, sigla: 'B' }] + }); + const trajetos = [800, 450]; + let i = 0; + geogridService.consultaTrajeto = async () => ({ distancia: trajetos[i++] }); + const r = await core.avaliarPorCoordenadas({ lat: 0, lon: 0 }); + assert.equal(r.distanciaReal, 450); + assert.equal(r.naoDedicado, true); // 450 <= 500 +}); + +test('core cai para a linha reta se o trajeto falhar', async () => { + geogridService.consultarCaixasProximas = async () => ({ + provedor: 'Sothis', tipo: 'autorizado', caixas: [{ lat: 1, lon: 1, distanciaReta: 480, sigla: 'A' }] + }); + geogridService.consultaTrajeto = async () => { throw new Error('trajeto indisponível'); }; + const r = await core.avaliarPorCoordenadas({ lat: 0, lon: 0 }); + assert.equal(r.distanciaReal, null); + assert.equal(r.distancia, 480); // fallback reta + assert.equal(r.naoDedicado, true); +}); + +test('sem caixa no raio: mantém "5KM+" e nada viável', async () => { + geogridService.consultarCaixasProximas = async () => ({ provedor: 'Nenhum provedor disponível', tipo: 'nenhum', caixas: [] }); + const r = await core.avaliarPorCoordenadas({ lat: 0, lon: 0 }); + assert.equal(r.distancia, '5KM+'); + assert.equal(r.dedicado, false); + assert.equal(r.naoDedicado, false); +}); + +// --- CONTRATO WordPress: a resposta precisa manter TODAS as chaves e tipos --- +const CONTRATO_VIABILIDADE = { + nome: 'string', email: 'string', telefone: 'string', + logradouro: 'string', numero: 'string', bairro: 'string', + cidade: 'string', estado: 'string', cep: 'string', + naoDedicado: 'boolean', dedicado: 'boolean', + distancia: 'number', provedor: 'string', + distanciaReta: 'number' // aditivo, não-quebra +}; + +test('contrato /viabilidade: chaves e tipos preservados', async () => { + mockGeogrid({ trajeto: 630 }); + const resp = await service.verificarViabilidade({ + nome: 'João', email: 'j@x.com', telefone: '11999999999', + cep: '06419240', numero: '303', source: 'teste' // source => não persiste no banco + }); + for (const [campo, tipo] of Object.entries(CONTRATO_VIABILIDADE)) { + assert.ok(campo in resp, `campo "${campo}" sumiu do response`); + assert.equal(typeof resp[campo], tipo, `tipo de "${campo}" mudou`); + } + assert.equal(resp.distancia, 630); // distancia = real + assert.equal(resp.distanciaReta, 300); +}); + +test('contrato /viabilidade/lat-long: chaves preservadas', async () => { + mockGeogrid({ trajeto: 630 }); + googleService.reverseGeocodeWithGoogle = async () => 'Av. Teste, 1000 - SP'; + const resp = await service.verificarViabilidadeLatLong('-12.9', '-46.9'); + for (const campo of ['endereco', 'naoDedicado', 'dedicado', 'distancia', 'provedor', 'distanciaReta']) { + assert.ok(campo in resp, `campo "${campo}" faltando`); + } + assert.equal(resp.distancia, 630); +}); + +test('mapCaixa converte distância/coords string do GeoGrid para número', () => { + const c = geogridService.mapCaixa({ latitude: '-23.66', longitude: '-46.59', distancia: '395.14', pasta: { sigla: 'X' } }); + assert.equal(typeof c.distanciaReta, 'number'); + assert.equal(c.distanciaReta, 395.14); + assert.equal(c.lat, -23.66); + assert.equal(c.lon, -46.59); +}); + +test('classifica corretamente quando a distância vem como string (regressão)', async () => { + // caixa com distanciaReta numérica (já mapeada) e trajeto retornando string + geogridService.consultarCaixasProximas = async () => ({ provedor: 'Sothis', tipo: 'autorizado', caixas: [{ lat: 1, lon: 1, distanciaReta: 395.14, sigla: 'X' }] }); + geogridService.consultaTrajeto = async () => ({ distancia: '420' }); // string + const r = await core.avaliarPorCoordenadas({ lat: 0, lon: 0 }); + assert.equal(r.distanciaReal, 420); // convertido para número + assert.equal(r.dedicado, true); // 420 <= 500 + assert.equal(r.naoDedicado, true); +}); + +test('core retorna detalhe por caixa (caixas[]) com provedor por caixa', async () => { + geogridService.consultarCaixasProximas = async () => ({ + provedor: 'Sothis', tipo: 'misto', + caixas: [ + { lat: 1, lon: 1, distanciaReta: 300, sigla: 'A', provedor: 'Sothis' }, + { lat: 2, lon: 2, distanciaReta: 700, sigla: 'B', provedor: 'Parceiro - X' } + ] + }); + const trajetos = [420, 900]; + let i = 0; + geogridService.consultaTrajeto = async () => ({ distancia: trajetos[i++] }); + const r = await core.avaliarPorCoordenadas({ lat: 0, lon: 0 }); + assert.equal(r.caixas.length, 2); + assert.equal(r.caixas[0].provedor, 'Sothis'); + assert.equal(r.caixas[0].distancia, 420); + assert.equal(r.caixas[0].naoDedicado, true); // 420 <= 500 + assert.equal(r.caixas[1].provedor, 'Parceiro - X'); + assert.equal(r.caixas[1].distancia, 900); + assert.equal(r.caixas[1].dedicado, true); // 900 <= 1000 + assert.equal(r.caixas[1].naoDedicado, false); // 900 > 500 +}); + +test('seleção MISTA: só autorizadas + parceiros (ignora siglas inelegíveis)', async () => { + const axios = require('axios'); + const orig = axios.get; + axios.get = async () => ({ data: { registros: [ + { latitude: '0', longitude: '0', distancia: '50', pasta: { sigla: 'Sub Alameda Santos' } }, // NÃO elegível (mais próxima) + { latitude: '1', longitude: '1', distancia: '100', pasta: { sigla: 'Parceiro - X' } }, // parceiro + { latitude: '2', longitude: '2', distancia: '200', pasta: { sigla: 'São Paulo - SP' } } // autorizada => Sothis + ] } }); + try { + const r = await realConsultarCaixasProximas(0, 0, { topN: 2, misturarProvedores: true }); + assert.equal(r.caixas.length, 2); // sub-alameda excluída + assert.equal(r.caixas[0].provedor, 'Parceiro - X'); + assert.equal(r.caixas[1].provedor, 'Sothis'); + } finally { + axios.get = orig; + } +}); + +test('seleção PADRÃO (WordPress): prioriza Sothis e ignora parceiro mais próximo', async () => { + const axios = require('axios'); + const orig = axios.get; + axios.get = async () => ({ data: { registros: [ + { latitude: '1', longitude: '1', distancia: '100', pasta: { sigla: 'Parceiro - X' } }, + { latitude: '2', longitude: '2', distancia: '200', pasta: { sigla: 'São Paulo - SP' } } + ] } }); + try { + const r = await realConsultarCaixasProximas(0, 0, { topN: 2 }); + assert.equal(r.provedor, 'Sothis'); + assert.equal(r.caixas.length, 1); // só a autorizada + assert.equal(r.caixas[0].provedor, 'Sothis'); + } finally { + axios.get = orig; + } +}); + +test('lote: dedup + resultado por item (inclui item inválido) e traz caixas[]', async () => { + mockGeogrid({ trajeto: 630 }); + const resultados = await service.verificarViabilidadeLote([ + { latitude: '-12.9', longitude: '-46.9' }, + { latitude: '-12.9', longitude: '-46.9' }, // duplicado + { foo: 'bar' } // inválido + ], {}); + assert.equal(resultados.length, 3); + assert.equal(resultados[0].distancia, 630); + assert.ok(Array.isArray(resultados[0].caixas)); + assert.equal(resultados[0].caixas[0].provedor, 'Sothis'); + assert.equal(resultados[1].distancia, 630); + assert.ok(resultados[2].erro); +});