require("dotenv").config(); 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 session = require("express-session"); // adiciona session const { geocodeWithGoogle } = require("./service/geocodeService"); const { fetchJson } = require("./service/fetchService"); const { BASE_BACKOFF_MS, MAX_RETRIES, REQUEST_DELAY_MS, sleep } = require("./service/retryService"); const { API_URL, HEADERS } = require("./config/apiConfig"); const { normalizePartnerSigla } = require("./service/normalizeService"); const authRoutes = require("./routes/authRoutes.js"); function createApp() { const upload = multer({ dest: "uploads/" }); const app = express(); // se estiver atrás de um reverse proxy (nginx/traefik) em produção, habilite: app.set("trust proxy", 1); app.use(cors()); app.use(express.static(path.join(__dirname, "public"))); app.use(express.json()); app.use( session({ secret: process.env.SESSION_SECRET || "change-me", resave: false, saveUninitialized: false, cookie: { maxAge: 24 * 60 * 60 * 1000, secure: process.env.NODE_ENV === "production", // true em produção HTTPS sameSite: "lax", }, }) ); 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; } // upload CSV endpoint const jobs = {}; // jobId -> { status, total, processed, download, error } app.post("/upload", upload.single("csvfile"), (req, res) => { if (!req.file) return res.status(400).json({ error: "Nenhum arquivo enviado" }); const filePath = req.file.path; const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; jobs[jobId] = { status: "queued", total: 0, processed: 0, download: null, error: null, }; (async () => { jobs[jobId].status = "processing"; try { const rows = []; await new Promise((resolve, reject) => { fs.createReadStream(filePath) .pipe(csv({ separator: ";" })) .on("data", (data) => rows.push(data)) .on("end", resolve) .on("error", reject); }); jobs[jobId].total = rows.length; const coordCache = new Map(); const outRows = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; // normalize keys to avoid duplicates caused by different headers const norm = {}; Object.keys(row).forEach((k) => { // normalize header: lowercase, remove diacritics and non-alphanumeric const kn = k .trim() .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[^a-z0-9]/g, ""); norm[kn] = row[k]; }); // Input columns (from normalized map) const rawCep = norm["cep"] ? String(norm["cep"]).replace(/\D/g, "") : ""; const rawNumero = norm["numero"] ? String(norm["numero"]).trim() : ""; // prefer lat/lon from normalized input if available const rawLat = norm["latitude"] || norm["lat"] || null; const rawLon = norm["longitude"] || norm["lon"] || norm["long"] || null; // Prefer existing lat/lon if provided from normalized fields let lat = null, lon = null; if (rawLat && rawLon) { lat = Number(String(rawLat).replace(",", ".")); lon = Number(String(rawLon).replace(",", ".")); } let builtAddress = ""; // If no coords, try ViaCEP -> Google if (!Number.isFinite(lat) || !Number.isFinite(lon)) { if (rawCep) { const cep8 = rawCep.padStart(8, "0"); const viaCepData = await fetchJson( `https://viacep.com.br/ws/${cep8}/json/` ); if (viaCepData && !viaCepData.erro) { const logradouro = viaCepData.logradouro || ""; const bairro = viaCepData.bairro || ""; const cidade = viaCepData.localidade || ""; const uf = viaCepData.uf || ""; if (logradouro) { builtAddress = `${logradouro}, ${rawNumero}, ${bairro}, ${cidade} - ${uf}` .replace(/, ,/g, ",") .replace(/^,\s*/, ""); } else { // fallback: use neighborhood/city builtAddress = `${bairro || ""} ${ cidade ? ", " + cidade : "" } ${uf ? "- " + uf : ""}`.trim(); } // build addressToUse (builtAddress already assembled above) if (!process.env.GOOGLE_API_KEY) { console.error( "[ERROR] GOOGLE_API_KEY não definida. Não será possível geocodificar. Defina a chave no .env ou em process.env" ); } else { const addressToUse = builtAddress || `${cidade} ${uf} ${cep8}`; const geo = await geocodeWithGoogle(addressToUse); if (geo) { lat = geo.lat; lon = geo.lon; } else console.warn( `Google Geocoding não retornou resultado para '${addressToUse}' (CEP ${cep8}, row ${ i + 1 })` ); } } else { console.warn(`ViaCEP erro for CEP ${rawCep} (row ${i + 1})`); } } else { console.log(`Row ${i + 1}: missing/invalid CEP -> '${rawCep}'`); } } // Prepare explicit output row to avoid extra columns const out = { CEP: rawCep || "", Número: rawNumero || "", Endereço: builtAddress || "", // write lat/lon as strings with dot decimal and fixed precision to avoid locale swaps Latitude: Number.isFinite(lat) ? Number(lat).toFixed(6) : "", Longitude: Number.isFinite(lon) ? Number(lon).toFixed(6) : "", "Não dedicado": "", Dedicado: "", Distancia: "", "Parceiro/Sothis": "", }; if (Number.isFinite(lat) && Number.isFinite(lon)) { const coordKey = `${lat.toFixed(6)},${lon.toFixed(6)}`; if (coordCache.has(coordKey)) { const cached = coordCache.get(coordKey); // cached is either null or { dist, pastaSigla } if (cached !== null) { const d = cached.dist; const di = Math.round(Number(d)); out["Não dedicado"] = di <= 500 ? "viável" : "Não viável"; out["Dedicado"] = di <= 1000 ? "viável" : "Não viável"; out["Distancia"] = `${di}M`; out["Parceiro/Sothis"] = normalizePartnerSigla(cached.pastaSigla) || ""; } else { out["Não dedicado"] = "Não viável"; out["Dedicado"] = "Não viável"; out["Distancia"] = "5km +"; out["Parceiro/Sothis"] = ""; } } else { const minResult = await getMinDistance(lat, lon); // { dist, pastaSigla } or null coordCache.set(coordKey, minResult); if (minResult !== null) { const di = Math.round(Number(minResult.dist)); out["Não dedicado"] = di <= 500 ? "viável" : "Não viável"; out["Dedicado"] = di <= 1000 ? "viável" : "Não viável"; out["Distancia"] = `${di}M`; out["Parceiro/Sothis"] = normalizePartnerSigla(minResult.pastaSigla) || ""; } else { out["Não dedicado"] = "Não viável"; out["Dedicado"] = "Não viável"; out["Distancia"] = "5km +"; out["Parceiro/Sothis"] = ""; } await sleep(REQUEST_DELAY_MS); } } else { // no coords available -> keep defaults } outRows.push(out); jobs[jobId].processed = i + 1; } // write output csv - use explicit outRows and fixed header order const outPath = path.join(__dirname, "outputs"); if (!fs.existsSync(outPath)) fs.mkdirSync(outPath); const originalName = req.file && req.file.originalname ? req.file.originalname : `upload_${Date.now()}.csv`; const parsed = path.parse(originalName); let outBase = `${parsed.name}_output`; let outFile = path.join(outPath, `${outBase}.csv`); if (fs.existsSync(outFile)) { outFile = path.join(outPath, `${outBase}_${Date.now()}.csv`); } const headers = [ "CEP", "Número", "Endereço", "Latitude", "Longitude", "Não dedicado", "Dedicado", "Distancia", "Parceiro/Sothis", ]; await new Promise((resolve, reject) => { const ws = fs.createWriteStream(outFile); ws.write("\uFEFF"); fastCsv .write(outRows, { headers: headers, delimiter: ";" }) .pipe(ws) .on("finish", resolve) .on("error", reject); }); try { fs.unlinkSync(filePath); } catch (e) {} jobs[jobId].status = "done"; jobs[jobId].download = `/download/${path.basename(outFile)}`; } catch (err) { console.error(err); jobs[jobId].status = "error"; jobs[jobId].error = String(err.message || err); } })(); return res.json({ jobId }); }); // download endpoint app.get("/download/:name", (req, res) => { const name = req.params.name; const p = path.join(__dirname, "outputs", name); if (!fs.existsSync(p)) return res.status(404).send("Arquivo não encontrado"); res.download(p); }); // job status endpoint app.get("/status/:jobId", (req, res) => { const job = jobs[req.params.jobId]; if (!job) return res.status(404).json({ error: "job não encontrado" }); return res.json(job); }); // manual query endpoint // /consulta now accepts either latitude+longitude OR cep+numero. If cep is provided we resolve ViaCEP -> Google -> Geogrid app.get("/consulta", async (req, res) => { const { latitude: rawLat, longitude: rawLon, cep: rawCep, numero: rawNumero, } = req.query; // If cep provided, use ViaCEP -> Google geocoding -> Geogrid if (rawCep) { 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) { return res.json({ endereco, latitude: lat, longitude: lon, distancia: result.dist, parceiro: result.pastaSigla || "", }); } return res.json({ endereco, latitude: lat, longitude: lon, distancia: "5km +", }); } catch (err) { console.error(err); return res.status(500).json({ error: "Erro na consulta" }); } } // Otherwise require latitude+longitude if (!rawLat || !rawLon) return res.status(400).json({ error: "latitude e longitude são obrigatórios (ou forneça cep)", }); const latitude = Number(String(rawLat).replace(",", ".")); const longitude = Number(String(rawLon).replace(",", ".")); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { console.warn( `Consulta manual com parâmetros inválidos: lat='${rawLat}' lon='${rawLon}'` ); return res.status(400).json({ error: "latitude ou longitude inválidos" }); } try { console.log(`Consulta manual: lat=${latitude} lon=${longitude}`); const result = await getMinDistance(latitude, longitude); console.log(`Resultado consulta manual: ${JSON.stringify(result)}`); if (result && result.dist !== undefined) { return res.json({ distancia: result.dist, parceiro: result.pastaSigla || "", }); } return res.json({ distancia: "5km +" }); } catch (err) { console.error(err); return res.status(500).json({ error: "Erro na consulta" }); } }); // 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) { return res.json({ endereco, latitude: lat, longitude: lon, distancia: result.dist, parceiro: result.pastaSigla || "", }); } return res.json({ endereco, latitude: lat, longitude: lon, distancia: "5km +", }); } catch (err) { console.error(err); return res.status(500).json({ error: "erro na consulta" }); } }); //////////////////////////////////////////////////// // Servir arquivos estáticos (index.html) app.use("/public", express.static(path.join(__dirname, "public"))); // Usa as rotas de autenticação app.use("/", authRoutes); // Middleware para proteger rotas app.use((req, res, next) => { if (!req.session.user && req.path !== "/login" && !req.path.startsWith("/auth")) { return res.redirect("/login"); } next(); }); ///////////////////////////////////////////////////// return app; } module.exports = { createApp, };