WIP: O que já foi feito:

- Trazer duas caixas como alternativa.

O que falta:

- Fazer viabilidade a partir da distância do Trajeto.
- Mudar pastas autorizadas do geogrid.
This commit is contained in:
Gabriel Amancio 2026-07-20 09:44:06 -03:00
parent dea436debe
commit 54a81db111
12 changed files with 1015 additions and 226 deletions

445
README.md
View File

@ -2,26 +2,35 @@
## Visão Geral ## 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. ### Funcionalidades Principais
2. **Criação de Prospectos**: Registra um novo cliente potencial (prospect) no sistema de gestão Hubsoft.
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. - **Express.js 5.1**: Framework web moderno para construção de APIs RESTful.
- **Arquitetura Modular**: Lógica de negócio organizada no módulo `contratacao`, facilitando a manutenção e expansão. - **Arquitetura Modular**: Estrutura baseada em MVC com separação clara entre Controller, Service, Repository e Model.
- **Serviços Externos**: Integração com múltiplas APIs de terceiros para consulta de CEP, geolocalização e gestão de clientes. - **Integração com Múltiplas APIs**:
- **Logging Avançado**: Utiliza `winston` para registrar logs da aplicação e de erros em arquivos diários rotacionados. - **Google Maps Geocoding**: Para geocodificação e reverse geocoding de endereços.
- **Gestão de Ambiente**: Usa `dotenv` para gerenciar configurações de desenvolvimento e produção de forma segura e isolada. - **GeoGrid API**: Consulta de pontos de presença e viabilidade técnica.
- **Desenvolvimento Otimizado**: `nodemon` para reinicialização automática do servidor em ambiente de desenvolvimento. - **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 ## Pré-requisitos
- Node.js (versão 18.x ou superior recomendada) - Node.js (versão 18.x ou superior recomendada)
- npm (geralmente instalado junto com o Node.js) - 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 ## 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.development` (para ambiente de desenvolvimento)
- `.env.production` (para ambiente de produção) - `.env.production` (para ambiente de produção)
Preencha os arquivos com as seguintes variáveis de ambiente: ### Variáveis de Ambiente Obrigatórias
```env ```env
# Porta da API (padrão: 3000) # Servidor
PORT=3000 PORT=3000
NODE_ENV=development
# Chave da API do Google Maps Geocoding # Google Maps Geocoding API (para geocodificação e reverse geocoding)
GOOGLE_API_KEY=SUA_CHAVE_AQUI GOOGLE_API_KEY=sua_chave_google_maps_aqui
# Configurações da API GeoGrid # GeoGrid API (para consulta de viabilidade técnica)
GEOGRID_API_URL=URL_DA_API_GEOGRID GEOGRID_API_URL=https://.../viabilidade/raio # URL COMPLETA da consulta por raio
GEOGRID_API_KEY=SUA_CHAVE_GEOGRID_AQUI GEOGRID_API_KEY=sua_chave_geogrid_aqui
GEOGRID_API_COOKIE=SEU_COOKIE_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 # Distância REAL de trajeto (opcionais — têm defaults seguros)
HUBSOFT_URL=URL_DO_HUBSOFT GEOGRID_API_BASE_URL=https://.../vale/api/v3 # base p/ o endpoint de trajeto; se ausente, derivada da URL de consulta
HUBSOFT_CLIENT_ID=SEU_CLIENT_ID_HUBSOFT GEOGRID_TRAJETO_MODO=walking # modo do trajeto
HUBSOFT_CLIENT_SECRET=SEU_CLIENT_SECRET_HUBSOFT GEOGRID_TRAJETO_TOP_N=3 # nº de caixas roteadas na consulta individual (precisão)
HUBSOFT_USERNAME=SEU_USUARIO_HUBSOFT GEOGRID_TRAJETO_TOP_N_LOTE=2 # nº de caixas roteadas por item no lote (a planilha mostra 2 caixas)
HUBSOFT_PASSWORD=SUA_SENHA_HUBSOFT 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 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 ## 3. Rodando a Aplicação
### Modo de Desenvolvimento ### Modo de Desenvolvimento
@ -71,6 +104,11 @@ Este comando inicia a API com `nodemon`, que reinicia o servidor automaticamente
npm run dev:api 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 ### 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`. 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 ## Documentação da API
O prefixo para todos os endpoints é `/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` - **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`): - **Corpo da Requisição** (`application/json`):
```json ```json
{ {
"nome": "João Silva",
"email": "joao@email.com",
"telefone": "11987654321",
"cep": "06419240", "cep": "06419240",
"numero": 303 "numero": "303",
"source": ""
} }
``` ```
@ -103,53 +216,289 @@ Verifica a viabilidade de serviço para um endereço.
```json ```json
{ {
"nome": "João Silva",
"email": "joao@email.com",
"telefone": "11987654321",
"logradouro": "Rua Imirim",
"numero": "303",
"bairro": "Chácaras Marco", "bairro": "Chácaras Marco",
"cidade": "Barueri", "cidade": "Barueri",
"estado": "SP", "estado": "SP",
"logradouro": "Rua Imirim", "cep": "06419240",
"naoDedicado": true, "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**: - **Respostas de Erro**:
- `400 Bad Request`: Se `cep` ou `numero` não forem fornecidos. - `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. - `422 Unprocessable Entity`: Se os dados de endereço estiverem incompletos ou inválidos.
- `500 Internal Server Error`: Para outras falhas no processo. - `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`): - **Corpo da Requisição** (`application/json`):
```json ```json
{ {
"cep": "06419240", "itens": [
"servicoId": 1, { "cep": "06419240", "numero": "303" },
"servicoValor": 100.00, { "latitude": "-23.5070", "longitude": "-46.4036" }
"numero": "303", ],
"endereco": "Rua Imirim", "modo": "walking"
"bairro": "Chácaras Marco",
"tipoPessoa": "F",
"nomeRazaoSocial": "Nome do Cliente",
"cpfCnpj": "123.456.789-00",
"email": "cliente@email.com",
"telefone": "11999998888"
} }
``` ```
- **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`): - **Resposta de Sucesso** (`200 OK`):
```json ```json
{ {
"message": "Prospecto criado com sucesso", "message": "Prospecto criado com sucesso",
"data": { "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**: - **Respostas de Erro**:
- `500 Internal Server Error`: Se ocorrer um erro na comunicação com o Hubsoft. - `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

View File

@ -7,7 +7,7 @@
"dev:api": "cross-env NODE_ENV=development nodemon src/app.js", "dev:api": "cross-env NODE_ENV=development nodemon src/app.js",
"start:worker": "cross-env NODE_ENV=production node src/worker.js", "start:worker": "cross-env NODE_ENV=production node src/worker.js",
"dev:worker": "cross-env NODE_ENV=development nodemon 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": { "repository": {
"type": "git", "type": "git",

View File

@ -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) { async function handleCriarProspecto(req, res) {
const prospectData = req.body; const prospectData = req.body;
@ -52,27 +65,6 @@ async function handleCriarProspecto(req, res) {
module.exports = { module.exports = {
handleViabilidade, handleViabilidade,
handleCriarProspecto, 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.
*/

View File

@ -1,6 +1,8 @@
// classe construtor para o modelo de viabilidade // classe construtor para o modelo de viabilidade
class ViabilidadeModel { 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 // Inicialização de propriedades do modelo pode ser feita aqui
this.nome = nome; this.nome = nome;
this.email = email; this.email = email;
@ -15,17 +17,19 @@ class ViabilidadeModel {
this.dedicado = dedicado; this.dedicado = dedicado;
this.distancia = distancia; this.distancia = distancia;
this.provedor = provedor; this.provedor = provedor;
this.distanciaReta = distanciaReta;
} }
} }
class ViabilidadeReverseGeocodeModel { 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 // Inicialização de propriedades do modelo pode ser feita aqui
this.endereco = endereco; this.endereco = endereco;
this.naoDedicado = naoDedicado; this.naoDedicado = naoDedicado;
this.dedicado = dedicado; this.dedicado = dedicado;
this.distancia = distancia; this.distancia = distancia;
this.provedor = provedor; this.provedor = provedor;
this.distanciaReta = distanciaReta;
} }
} }
@ -156,19 +160,3 @@ class ProspectModel {
} }
module.exports = {ViabilidadeReverseGeocodeModel, ViabilidadeModel, ClientModelPf, ClientModelPj, 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.
*/

View File

@ -46,22 +46,3 @@ async function insertViabilidadeData(viabilidadeData) {
module.exports = { module.exports = {
insertViabilidadeData 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.
*/

View File

@ -1,8 +1,10 @@
const geogridService = require("../../shared/apis/geogridService.js");
const googleService = require("../../shared/apis/googleService.js"); const googleService = require("../../shared/apis/googleService.js");
const hubsoftService = require("../../shared/apis/hubsoftService.js"); const hubsoftService = require("../../shared/apis/hubsoftService.js");
const logger = require('../../shared/utils/logger.js'); const logger = require('../../shared/utils/logger.js');
const apiConfig = require('../../shared/config/apiConfig.js');
const repository = require('./contratacao.repository.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'); const { ViabilidadeModel, ClientModelPf, ClientModelPj, ProspectModel, ViabilidadeReverseGeocodeModel } = require('./contratacao.model');
// utilitário para ler chaves do payload tolerante a ":" e espaços // 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 // 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 verificarViabilidade(rawViabilidadeData) { async function resolverCoordenadasPorCep(rawCep, rawNumero) {
const rawCep = rawViabilidadeData.cep;
const rawNumero = rawViabilidadeData.numero;
const source = rawViabilidadeData.source || '';
let address; 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 { try {
const cep = require('cep-promise'); const cep = require('cep-promise');
address = await cep(rawCep); 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); 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; const { street, neighborhood, city, state, cep } = address;
const addressString = `${street}, ${rawNumero}, ${neighborhood}, ${city}, ${state}, ${rawCep}`;
addressString = `${street}, ${rawNumero}, ${neighborhood}, ${city}, ${state}, ${rawCep}`;
logger.info('Endereço montado para geocodificação', { addressString }); logger.info('Endereço montado para geocodificação', { addressString });
if (!street || !neighborhood || !city || !state) { if (!street || !neighborhood || !city || !state) {
@ -75,31 +61,33 @@ async function verificarViabilidade(rawViabilidadeData) {
} }
logger.info('Coordenadas obtidas com sucesso', { coords }); 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;
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 });
} }
// 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 (!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 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 { coords, address } = await resolverCoordenadasPorCep(rawCep, rawNumero);
const { street, neighborhood, city, state, cep } = address;
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) { if (source) {
return viabilidadeResult; return viabilidadeResult;
@ -121,33 +109,99 @@ async function verificarGeocodeReverso(lat, lon) {
} }
async function verificarViabilidadeLatLong(latitude, longitude) { async function verificarViabilidadeLatLong(latitude, longitude) {
const viabilidade = await geogridService.consultaViabilidade(latitude, longitude); const coords = { lat: Number(latitude), lon: Number(longitude) };
let naoDedicado = false; const avaliacao = await core.avaliarPorCoordenadas(coords);
let dedicado = false; logger.info('Avaliação de viabilidade (lat/long) concluída', { avaliacao });
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 address = await verificarGeocodeReverso(latitude, longitude); 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, verificarViabilidade,
criarProspecto, criarProspecto,
verificarViabilidadeLatLong, verificarViabilidadeLatLong,
verificarViabilidadeLote,
verificarGeocodeReverso, verificarGeocodeReverso,
}; };

View File

@ -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 }; // 10015000m: 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 };

View File

@ -5,6 +5,9 @@ const contratacaoController = require('../modules/contratacao/contratacao.contro
// Rota para consulta de viabilidade // Rota para consulta de viabilidade
router.post('/viabilidade', contratacaoController.handleViabilidade); 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 // Rota para criação de prospecto no Hubsoft
router.post('/prospecto', contratacaoController.handleCriarProspecto); router.post('/prospecto', contratacaoController.handleCriarProspecto);

View File

@ -3,15 +3,9 @@ const axios = require("axios");
const qs = require("qs"); const qs = require("qs");
const logger = require('../utils/logger.js'); const logger = require('../utils/logger.js');
// Busca bruta dos registros (caixas) no GeoGrid por raio. Mantém exatamente os
const consultaViabilidade = async (lat, lon) => { // mesmos parâmetros/serialização/headers da consulta original.
const fetchRegistros = async (lat, lon) => {
const url = apiConfig.geogridApiUrl;
const apiKey = apiConfig.geogridApiKey;
const apiCookie = apiConfig.geogridApiCookie;
try {
// Parâmetros da consulta
const params = { const params = {
raio: 5000, raio: 5000,
latitude: lat, latitude: lat,
@ -23,17 +17,16 @@ const consultaViabilidade = async (lat, lon) => {
consultarIndividual: "S" consultarIndividual: "S"
}; };
const response = await axios.get(url, { const response = await axios.get(apiConfig.geogridApiUrl, {
params, params,
// força a serialização do tipo `itens[]=caixa` // força a serialização do tipo `itens[]=caixa`
paramsSerializer: p => qs.stringify(p, { arrayFormat: 'brackets' }), paramsSerializer: p => qs.stringify(p, { arrayFormat: 'brackets' }),
headers: { headers: {
'api-key': apiKey, 'api-key': apiConfig.geogridApiKey,
Cookie: apiCookie Cookie: apiConfig.geogridApiCookie
} }
}); });
// Extrai o primeiro registro da resposta em que a pasta.sigla seja igual as siglas autorizadas no .env
const registros = response.data?.registros || []; const registros = response.data?.registros || [];
logger.info("Resposta do GeoGrid", { logger.info("Resposta do GeoGrid", {
@ -42,30 +35,123 @@ const consultaViabilidade = async (lat, lon) => {
autorizadas: apiConfig.geogridAuthorizedSiglasPastas autorizadas: apiConfig.geogridAuthorizedSiglasPastas
}); });
// Filtra registros autorizados (ex: SOTHIS, VIVO, OI) return registros;
const registros_autorizados = registros.filter(r => { };
const sigla = r.pasta && r.pasta.sigla;
return apiConfig.geogridAuthorizedSiglasPastas.includes(sigla); // 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 {
const registros = await fetchRegistros(lat, lon);
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 };
}
// 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");
}
};
// 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
};
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" return response.data; // { distancia, pontosIntermediario, pontos }
if (registros_autorizados.length > 0) { };
const resultado = registros_autorizados[0];
// 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"; resultado.pasta.sigla = "Sothis";
return { data: resultado }; return { data: resultado };
} }
if (tipo === "parceiro") {
// Se não encontrar autorizado, tenta buscar parceiro return { data: selecionados[0] };
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] };
} }
// Se não encontrar autorizado nem parceiro, retorna mensagem padrão
return { data: { pasta: { sigla: "Nenhum provedor disponível" } } }; return { data: { pasta: { sigla: "Nenhum provedor disponível" } } };
} catch (error) { } catch (error) {
logger.error("Erro ao consultar viabilidade no GeoGrid", { message: error.message, stack: error.stack, lat, lon }); logger.error("Erro ao consultar viabilidade no GeoGrid", { message: error.message, stack: error.stack, lat, lon });
@ -73,5 +159,4 @@ const consultaViabilidade = async (lat, lon) => {
} }
}; };
module.exports = { consultaViabilidade }; module.exports = { consultaViabilidade, consultarCaixasProximas, consultaTrajeto, mapCaixa };

View File

@ -5,11 +5,22 @@ dotenv.config();
const googleApiKey = process.env.GOOGLE_API_KEY; const googleApiKey = process.env.GOOGLE_API_KEY;
// Geogrid API Configs // 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 geogridApiKey = process.env.GEOGRID_API_KEY;
const geogridApiCookie = process.env.GEOGRID_API_COOKIE; 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()) : []; 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 // Hubsoft API Configs
const hubsoftUrl = process.env.HUBSOFT_URL; const hubsoftUrl = process.env.HUBSOFT_URL;
const hubsoftAuthUrl = `${process.env.HUBSOFT_URL}oauth/token`; const hubsoftAuthUrl = `${process.env.HUBSOFT_URL}oauth/token`;
@ -22,9 +33,14 @@ const hubsoftGrantType = process.env.HUBSOFT_GRANT_TYPE;
module.exports = { module.exports = {
googleApiKey: googleApiKey, googleApiKey: googleApiKey,
geogridApiUrl: geogridApiUrl, geogridApiUrl: geogridApiUrl,
geogridApiBaseUrl: geogridApiBaseUrl,
geogridApiKey: geogridApiKey, geogridApiKey: geogridApiKey,
geogridApiCookie: geogridApiCookie, geogridApiCookie: geogridApiCookie,
geogridAuthorizedSiglasPastas: geogridAuthorizedSiglasPastas, geogridAuthorizedSiglasPastas: geogridAuthorizedSiglasPastas,
geogridTrajetoModo: geogridTrajetoModo,
geogridTrajetoTopN: geogridTrajetoTopN,
geogridTrajetoTopNLote: geogridTrajetoTopNLote,
geogridConcurrency: geogridConcurrency,
hubsoftUrl: hubsoftUrl, hubsoftUrl: hubsoftUrl,
hubsoftAuthUrl: hubsoftAuthUrl, hubsoftAuthUrl: hubsoftAuthUrl,

View File

@ -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 };

207
test/viabilidade.test.js Normal file
View File

@ -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);
});