REFACTOR: Reorganizando a aplicação em pastas e arquivos separados. Services para regras de negócio, app para execução da aplicação e server para inicialização.

This commit is contained in:
tulioperdigao 2025-10-22 14:23:37 -03:00
parent 04080fbd21
commit 8b85a32086
7 changed files with 320 additions and 192 deletions

88
app.js Normal file
View File

@ -0,0 +1,88 @@
const express = require("express");
const path = require("path");
const cors = require("cors");
const { fetchJson } = require("./services/jsonService");
const { getMinDistance } = require("./services/distanceService");
const { geocodeWithGoogle } = require("./services/geocodeService");
function createApp() {
const app = express();
app.use(cors());
app.use(express.static(path.join(__dirname, "public")));
app.use(express.json());
// manual CEP+Numero query: resolves ViaCEP -> Nominatim -> Geogrid
app.get("/consulta-cep", async (req, res) => {
const { cep: rawCep, numero: rawNumero } = req.query;
if (!rawCep) return res.status(400).json({ error: "cep é obrigatório" });
const cep = String(rawCep).replace(/\D/g, "");
const numero = rawNumero ? String(rawNumero).trim() : "";
try {
const viaCepData = await fetchJson(
`https://viacep.com.br/ws/${cep}/json/`
);
if (!viaCepData || viaCepData.erro)
return res.status(404).json({ error: "CEP não encontrado" });
const logradouro = viaCepData.logradouro || "";
const bairro = viaCepData.bairro || "";
const cidade = viaCepData.localidade || "";
const uf = viaCepData.uf || "";
const endereco = `${logradouro}, ${numero}, ${bairro}, ${cidade} - ${uf}`
.replace(/, ,/g, ",")
.replace(/^,\s*/, "");
if (!process.env.GOOGLE_API_KEY)
return res
.status(500)
.json({ error: "GOOGLE_API_KEY não definida no servidor" });
const geo = await geocodeWithGoogle(endereco || `${cidade} ${uf} ${cep}`);
if (!geo)
return res
.status(404)
.json({ error: "geocode não encontrado (Google)" });
const lat = Number(geo.lat);
const lon = Number(geo.lon);
const result = await getMinDistance(lat, lon);
if (result && result.dist !== undefined) {
// preciso criar 2 campos: Link Dedicado e Link Não Dedicado em que o dedicado é viável até 1000m e o não dedicado até 500m
if (result.dist <= 500) {
return res.json({
endereco,
latitude: lat,
longitude: lon,
distancia: result.dist,
dedicado: "Viável",
naoDedicado: "Viável",
});
} else if (result.dist <= 1000) {
return res.json({
endereco,
latitude: lat,
longitude: lon,
distancia: result.dist,
dedicado: "Viável",
naoDedicado: "Não viável",
});
} else {
return res.json({
endereco,
latitude: lat,
longitude: lon,
distancia: result.dist,
dedicado: "Não viável",
naoDedicado: "Não viável",
});
}
}
} catch (err) {
console.error(err);
return res.status(500).json({ error: "erro na consulta" });
}
});
return app;
}
module.exports = createApp;

197
server.js
View File

@ -1,196 +1,9 @@
require('dotenv').config();
const createApp = require('./app');
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const csv = require('csv-parser');
const fastCsv = require('fast-csv');
const axios = require('axios');
const cors = require('cors');
const app = createApp();
const PORT = process.env.PORT || 3000;
const app = express();
const upload = multer({ dest: 'uploads/' });
const PORT = process.env.PORT;
app.use(cors());
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
// Configure sua API_KEY e COOKIE aqui ou via variáveis de ambiente
const API_URL = process.env.API_URL;
const API_KEY = process.env.API_KEY;
const COOKIE = process.env.COOKIE;
const HEADERS = {
'api-key': API_KEY,
'Cookie': COOKIE
};
// small fetch wrapper for external services (ViaCEP etc.) with basic rate-limiting
async function fetchJson(url, opts = {}) {
try {
const r = await axios.get(url, { timeout: 10000, ...opts });
return r.data;
} catch (e) {
console.warn(`fetchJson error ${url}: ${e.message}`);
return null;
}
}
const BASE_BACKOFF_MS = parseInt(process.env.BASE_BACKOFF_MS || '1000', 10); // backoff inicial para retry
const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '5', 10);
const REQUEST_DELAY_MS = parseInt(process.env.REQUEST_DELAY_MS || '250', 10);
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// normalize partner sigla to requested labels
function normalizePartnerSigla(sigla) {
if (!sigla) return sigla;
const s = String(sigla).trim();
if (!s) return s;
const lowered = s.toLowerCase();
// map these two specific variants to 'Sothis'
if (lowered === 'são bernardo do campo - sp' || lowered === 'sao bernardo do campo - sp' || lowered === 'sao bernardo do campo') return 'Sothis';
if (lowered === 'são paulo - sp' || lowered === 'sao paulo - sp' || lowered === 'sao paulo') return 'Sothis';
return s;
}
// Geocode using Google Geocoding API. Returns { lat, lon } or null
async function geocodeWithGoogle(address) {
const key = process.env.GOOGLE_API_KEY;
if (!key) return null;
try {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${encodeURIComponent(key)}`;
const r = await axios.get(url, { timeout: 10000 });
if (r && r.data && Array.isArray(r.data.results) && r.data.results.length > 0) {
const loc = r.data.results[0].geometry && r.data.results[0].geometry.location;
if (loc && loc.lat !== undefined && loc.lng !== undefined) {
return { lat: Number(loc.lat), lon: Number(loc.lng) };
}
}
return null;
} catch (e) {
console.warn(`geocodeWithGoogle error for '${address}': ${e.message}`);
return null;
}
}
async function getMinDistance(lat, lon) {
// tenta várias vezes com backoff exponencial; trata 429 usando Retry-After se disponível
let attempt = 0;
while (attempt < MAX_RETRIES) {
try {
// envia também o raio (em metros) - API espera esse parâmetro em várias rotas
const resp = await axios.get(API_URL, { headers: HEADERS, params: { raio: 5000, latitude: lat, longitude: lon, "itens[]": ["caixa"], consultarPasta: "S" }, timeout: 10000 });
const data = resp.data;
const registros = (data && data.registros) ? data.registros : [];
// find registros that have a numeric distancia and keep original object for robust extraction
const candidates = registros.map(r => ({ raw: r, distanciaRaw: r && r.distancia }))
.map(o => ({ raw: o.raw, num: (o.distanciaRaw !== undefined && o.distanciaRaw !== null && o.distanciaRaw !== '') ? Number(o.distanciaRaw) : null }))
.filter(x => x.num !== null && !Number.isNaN(x.num));
if (candidates.length) {
candidates.sort((a,b) => a.num - b.num);
const best = candidates[0];
const r = best.raw || {};
// robust extraction of pasta sigla with fallbacks
let pastaSigla = null;
try {
if (r.pasta) {
if (typeof r.pasta === 'string' && r.pasta.trim()) pastaSigla = r.pasta.trim();
else if (r.pasta.sigla && String(r.pasta.sigla).trim()) pastaSigla = String(r.pasta.sigla).trim();
else if (r.pasta.cidade && r.pasta.cidade.sigla && String(r.pasta.cidade.sigla).trim()) pastaSigla = String(r.pasta.cidade.sigla).trim();
}
} catch (e) {
pastaSigla = null;
}
// if closest has no pastaSigla, try find any candidate with non-empty sigla
if (!pastaSigla) {
for (let j = 0; j < candidates.length; j++) {
const rr = candidates[j].raw || {};
try {
if (rr.pasta) {
if (typeof rr.pasta === 'string' && rr.pasta.trim()) { pastaSigla = rr.pasta.trim(); break; }
if (rr.pasta.sigla && String(rr.pasta.sigla).trim()) { pastaSigla = String(rr.pasta.sigla).trim(); break; }
if (rr.pasta.cidade && rr.pasta.cidade.sigla && String(rr.pasta.cidade.sigla).trim()) { pastaSigla = String(rr.pasta.cidade.sigla).trim(); break; }
}
} catch (e) {
// continue
}
}
}
if (!pastaSigla) console.warn(`[WARN] Nenhuma pasta.sigla encontrada para coordenadas ${lat},${lon} (closest dist ${best.num})`);
pastaSigla = normalizePartnerSigla(pastaSigla);
return { dist: best.num, pastaSigla };
}
// sem distancias válidas
return null;
} catch (err) {
attempt += 1;
// se for 429, tente respeitar Retry-After quando disponível
if (err.response && err.response.status === 429) {
const ra = err.response.headers && (err.response.headers['retry-after'] || err.response.headers['Retry-After']);
let waitMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1);
if (ra) {
const raSec = parseInt(ra, 10);
if (!isNaN(raSec)) waitMs = raSec * 1000;
}
console.warn(`[WARN] 429 recebido para ${lat},${lon} - aguardando ${waitMs}ms e tentando novamente (attempt ${attempt}/${MAX_RETRIES})`);
await sleep(waitMs);
continue;
}
// para outros erros de rede/timeout, aguarda backoff exponencial e tenta de novo
const waitMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1);
console.warn(`[WARN] Erro ao consultar API para ${lat},${lon}: ${err.message} - backoff ${waitMs}ms (attempt ${attempt}/${MAX_RETRIES})`);
await sleep(waitMs);
}
}
// exauriu tentativas
console.error(`[ERROR] Exauriu retries para ${lat},${lon}`);
return null;
}
// manual CEP+Numero query: resolves ViaCEP -> Nominatim -> Geogrid
app.get('/consulta-cep', async (req, res) => {
const { cep: rawCep, numero: rawNumero } = req.query;
if (!rawCep) return res.status(400).json({ error: 'cep é obrigatório' });
const cep = String(rawCep).replace(/\D/g, '');
const numero = rawNumero ? String(rawNumero).trim() : '';
try {
const viaCepData = await fetchJson(`https://viacep.com.br/ws/${cep}/json/`);
if (!viaCepData || viaCepData.erro) return res.status(404).json({ error: 'CEP não encontrado' });
const logradouro = viaCepData.logradouro || '';
const bairro = viaCepData.bairro || '';
const cidade = viaCepData.localidade || '';
const uf = viaCepData.uf || '';
const endereco = `${logradouro}, ${numero}, ${bairro}, ${cidade} - ${uf}`.replace(/, ,/g, ',').replace(/^,\s*/, '');
if (!process.env.GOOGLE_API_KEY) return res.status(500).json({ error: 'GOOGLE_API_KEY não definida no servidor' });
const geo = await geocodeWithGoogle(endereco || `${cidade} ${uf} ${cep}`);
if (!geo) return res.status(404).json({ error: 'geocode não encontrado (Google)' });
const lat = Number(geo.lat);
const lon = Number(geo.lon);
const result = await getMinDistance(lat, lon);
if (result && result.dist !== undefined) {
// preciso criar 2 campos: Link Dedicado e Link Não Dedicado em que o dedicado é viável até 1000m e o não dedicado até 500m
if (result.dist <= 500) {
return res.json({ endereco, latitude: lat, longitude: lon, distancia: result.dist, dedicado: 'Viável', naoDedicado: 'Viável' });
} else if (result.dist <= 1000) {
return res.json({ endereco, latitude: lat, longitude: lon, distancia: result.dist, dedicado: 'Viável', naoDedicado: 'Não viável' });
} else {
return res.json({ endereco, latitude: lat, longitude: lon, distancia: result.dist, dedicado: 'Não viável', naoDedicado: 'Não viável' });
}
}
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'erro na consulta' });
}
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
});
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));

149
services/distanceService.js Normal file
View File

@ -0,0 +1,149 @@
const dotenv = require("dotenv");
dotenv.config();
const axios = require("axios");
const { sleep } = require("./sleepService");
const { normalizePartnerSigla } = require("./partnerSiglaService");
// Configure sua API_KEY e COOKIE aqui ou via variáveis de ambiente
const API_URL = process.env.API_URL;
const API_KEY = process.env.API_KEY;
const COOKIE = process.env.COOKIE;
const HEADERS = {
"api-key": API_KEY,
Cookie: COOKIE,
};
// small fetch wrapper for external services (ViaCEP etc.) with basic rate-limiting
const BASE_BACKOFF_MS = 10; // backoff inicial para retry
const MAX_RETRIES = 5;
async function getMinDistance(lat, lon) {
// tenta várias vezes com backoff exponencial; trata 429 usando Retry-After se disponível
let attempt = 0;
while (attempt < MAX_RETRIES) {
try {
// envia também o raio (em metros) - API espera esse parâmetro em várias rotas
const resp = await axios.get(API_URL, {
headers: HEADERS,
params: {
raio: 5000,
latitude: lat,
longitude: lon,
"itens[]": ["caixa"],
consultarPasta: "S",
},
timeout: 10000,
});
const data = resp.data;
const registros = data && data.registros ? data.registros : [];
// find registros that have a numeric distancia and keep original object for robust extraction
const candidates = registros
.map((r) => ({ raw: r, distanciaRaw: r && r.distancia }))
.map((o) => ({
raw: o.raw,
num:
o.distanciaRaw !== undefined &&
o.distanciaRaw !== null &&
o.distanciaRaw !== ""
? Number(o.distanciaRaw)
: null,
}))
.filter((x) => x.num !== null && !Number.isNaN(x.num));
if (candidates.length) {
candidates.sort((a, b) => a.num - b.num);
const best = candidates[0];
const r = best.raw || {};
// robust extraction of pasta sigla with fallbacks
let pastaSigla = null;
try {
if (r.pasta) {
if (typeof r.pasta === "string" && r.pasta.trim())
pastaSigla = r.pasta.trim();
else if (r.pasta.sigla && String(r.pasta.sigla).trim())
pastaSigla = String(r.pasta.sigla).trim();
else if (
r.pasta.cidade &&
r.pasta.cidade.sigla &&
String(r.pasta.cidade.sigla).trim()
)
pastaSigla = String(r.pasta.cidade.sigla).trim();
}
} catch (e) {
pastaSigla = null;
}
// if closest has no pastaSigla, try find any candidate with non-empty sigla
if (!pastaSigla) {
for (let j = 0; j < candidates.length; j++) {
const rr = candidates[j].raw || {};
try {
if (rr.pasta) {
if (typeof rr.pasta === "string" && rr.pasta.trim()) {
pastaSigla = rr.pasta.trim();
break;
}
if (rr.pasta.sigla && String(rr.pasta.sigla).trim()) {
pastaSigla = String(rr.pasta.sigla).trim();
break;
}
if (
rr.pasta.cidade &&
rr.pasta.cidade.sigla &&
String(rr.pasta.cidade.sigla).trim()
) {
pastaSigla = String(rr.pasta.cidade.sigla).trim();
break;
}
}
} catch (e) {
// continue
}
}
}
if (!pastaSigla)
console.warn(
`[WARN] Nenhuma pasta.sigla encontrada para coordenadas ${lat},${lon} (closest dist ${best.num})`
);
pastaSigla = normalizePartnerSigla(pastaSigla);
return { dist: best.num, pastaSigla };
}
// sem distancias válidas
return null;
} catch (err) {
attempt += 1;
// se for 429, tente respeitar Retry-After quando disponível
if (err.response && err.response.status === 429) {
const ra =
err.response.headers &&
(err.response.headers["retry-after"] ||
err.response.headers["Retry-After"]);
let waitMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1);
if (ra) {
const raSec = parseInt(ra, 10);
if (!isNaN(raSec)) waitMs = raSec * 1000;
}
console.warn(
`[WARN] 429 recebido para ${lat},${lon} - aguardando ${waitMs}ms e tentando novamente (attempt ${attempt}/${MAX_RETRIES})`
);
await sleep(waitMs);
continue;
}
// para outros erros de rede/timeout, aguarda backoff exponencial e tenta de novo
const waitMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1);
console.warn(
`[WARN] Erro ao consultar API para ${lat},${lon}: ${err.message} - backoff ${waitMs}ms (attempt ${attempt}/${MAX_RETRIES})`
);
await sleep(waitMs);
}
}
// exauriu tentativas
console.error(`[ERROR] Exauriu retries para ${lat},${lon}`);
return null;
}
module.exports = { getMinDistance };

View File

@ -0,0 +1,35 @@
const dotenv = require("dotenv");
dotenv.config();
const axios = require("axios");
// Geocode using Google Geocoding API. Returns { lat, lon } or null
async function geocodeWithGoogle(address) {
const key = process.env.GOOGLE_API_KEY;
if (!key) return null;
try {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
address
)}&key=${encodeURIComponent(key)}`;
const r = await axios.get(url, { timeout: 10000 });
if (
r &&
r.data &&
Array.isArray(r.data.results) &&
r.data.results.length > 0
) {
const loc =
r.data.results[0].geometry && r.data.results[0].geometry.location;
if (loc && loc.lat !== undefined && loc.lng !== undefined) {
return { lat: Number(loc.lat), lon: Number(loc.lng) };
}
}
return null;
} catch (e) {
console.warn(`geocodeWithGoogle error for '${address}': ${e.message}`);
return null;
}
}
module.exports = { geocodeWithGoogle };

15
services/jsonService.js Normal file
View File

@ -0,0 +1,15 @@
const axios = require('axios');
async function fetchJson(url, opts = {}) {
try {
const r = await axios.get(url, { timeout: 10000, ...opts });
return r.data;
} catch (e) {
console.warn(`fetchJson error ${url}: ${e.message}`);
return null;
}
}
module.exports = {
fetchJson
};

View File

@ -0,0 +1,23 @@
// normalize partner sigla to requested labels
function normalizePartnerSigla(sigla) {
if (!sigla) return sigla;
const s = String(sigla).trim();
if (!s) return s;
const lowered = s.toLowerCase();
// map these two specific variants to 'Sothis'
if (
lowered === "são bernardo do campo - sp" ||
lowered === "sao bernardo do campo - sp" ||
lowered === "sao bernardo do campo"
)
return "Sothis";
if (
lowered === "são paulo - sp" ||
lowered === "sao paulo - sp" ||
lowered === "sao paulo"
)
return "Sothis";
return s;
}
module.exports = { normalizePartnerSigla };

5
services/sleepService.js Normal file
View File

@ -0,0 +1,5 @@
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = { sleep };