Compare commits

..

3 Commits

Author SHA1 Message Date
801d3fa90d REFAC: consumo do endpoint /lote, dedup de helpers e colunas por caixa
- csvService/viabilidadeService passam a consumir o endpoint /viabilidade/lote
  da API em blocos, no lugar de 1 requisição HTTP por linha.
- Helpers de detecção de formato/cabeçalho extraídos para fileFormat.js (remove
  duplicação entre os dois serviços).
- Planilha de saída: 4 colunas por caixa (Provedor, Não Dedicado, Dedicado,
  Distância Trajeto) para as 2 caixas mais próximas; Caixa 2 replica a Caixa 1
  quando não há segunda caixa / sem cobertura.
- parseCoordinate passa a aceitar direção O (Oeste => negativo) e L (Leste).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:49:29 -03:00
c47a7b3a2e feat atualização de textos e posições dos botões 2026-07-07 13:41:13 -03:00
060e387b9f Merge pull request 'REFAC: Refatoração da validação de CSV para ampliar os padrões de planilhas de viabilidade.' (#1) from develop into master
Reviewed-on: http://10.0.120.75:3030/Sothis/viabiliza/pulls/1
2026-06-25 15:14:31 -03:00
5 changed files with 317 additions and 256 deletions

View File

@ -28,7 +28,7 @@
</div>
<div class="description-content">
<p>
A capilaridade da fibra óptica da Sothis Telecom está sempre em expansão, por isso, atualizamos essa página constantemente. Essa página é de uso interno e de canais de revenda, o resultado da consulta informa a distância em em metros da caixa mais próxima, bem como identifica se ela pertence a rede própria ou parceiro last mile. Não sem divulgue as informações desta página sem o consentimento prévio da Sothis Telecom.
A capilaridade da fibra óptica da Sothis Telecom está sempre em expansão, por isso, atualizamos essa página constantemente. Essa página é de uso interno e de canais de revenda, o resultado da consulta informa a distância em metros da caixa mais próxima, bem como identifica se ela pertence a rede própria ou parceiro last mile. Não divulgue as informações desta página sem o consentimento prévio da Sothis Telecom.
</p>
</div>
</div>
@ -37,18 +37,21 @@
<div class="card-body">
<h5>Consulta em lote:</h5>
<p>
Faça um upload de um arquivo .CSV com a primeira coluna com os CEPs e a segunda coluna com o número do logradouro. O resultado da consulta será baixado automaticamente em um arquivo .CSV em seu computador. Também é possível consultar a partir da geolocalização: faça um upload de um arquivo .CSV com a primeira coluna sendo latitude e a segunda longitude.
Faça um upload somente de arquivos .CSV ou .XLSX com a primeira coluna com os CEPs e a segunda coluna com o número do logradouro. O resultado da consulta será baixado automaticamente em um arquivo no seu computador. Também é possível consultar a partir da geolocalização: faça o upload somente de arquivos .CSV ou .XLSX com a primeira coluna sendo latitude e a segunda longitude. Dentro da célula, deve conter apenas números e sem espaço.
</p>
<p>
Caso precise baixar os modelos de arquivo para upload, clique em "Baixar Modelos".
</p>
<div>
<button type="button" class="btn btn-primary button-mobile" id="card__button-download">Baixar Modelos</button>
</div>
<br>
<form id="uploadForm">
<div class="mb-3">
<input class="form-control" type="file" id="csvfile" accept=".csv,.xls,.xlsx" required />
</div>
<div class="card-buttons__container">
<button class="btn btn-primary button-mobile" type="submit">Enviar CSV</button>
<button type="button" class="btn btn-primary button-mobile" id="card__button-download">Baixar Modelos</button>
<button class="btn btn-primary button-mobile" id="card__button-enviar" type="submit">Enviar</button>
</div>
</form>
<div id="uploadResult" class="mt-3"></div>
@ -68,12 +71,23 @@
<div class="row g-2">
<div class="col-md-4"><input id="cep" class="form-control" placeholder="CEP (somente números)"></div>
<div class="col-md-4"><input id="numero" class="form-control" placeholder="Número"></div>
<div class="col-md-4 card-buttons__container"><button id="btnConsultaCep" class="btn btn-secondary">Consultar CEP</button></div>
<div class="col-md-4 consultar-cep__container"><button id="btnConsultaCep" class="btn btn-secondary">Consultar CEP</button></div>
</div>
<div id="consultaResult" class="mt-3"></div>
</div>
</div>
<div>
<div class="aviso-importante__container">
<h4>Aviso Importante</h4>
<p>
A consulta de viabilidade disponibilizada neste sistema possui caráter preliminar e é baseada nas informações cadastrais da rede e no endereço informado.
A confirmação da disponibilidade do serviço dependerá de análise técnica realizada antes da instalação. Nessa etapa, poderão ser identificadas limitações que inviabilizem o atendimento, como indisponibilidade de capacidade da rede, necessidade de expansão da infraestrutura, distância acima do limite técnico, obstáculos para passagem da fibra óptica, restrições de acesso ao imóvel, ausência de autorizações necessárias ou outras condições técnicas e operacionais.
Por esse motivo, o resultado desta consulta não constitui garantia de disponibilidade ou contratação do serviço, estando a instalação condicionada à aprovação da análise técnica final.
</p>
</div>
</div>
</div>
<script src="main.js"></script>

View File

@ -35,6 +35,7 @@
.card-buttons__container {
display: flex;
justify-content: flex-end;
}
button {
@ -50,7 +51,8 @@ button:hover {
background-color: transparent !important;
border: 1px solid #31a3b5 !important;
color: #31a3b5 !important;
margin-left: 10px;
margin-left: 0px;
top: -5px !important;
}
.progress-bar {
@ -61,6 +63,26 @@ button:hover {
margin-top: 40px;
}
.aviso-importante__container {
margin-top: 35px;
padding-top: 15px;
color: #6c757d;
font-size: 12px;
margin-left: 5px;
}
.aviso-importante__container h4 {
font-size: 14px;
font-weight: bold;
margin-bottom: 10px;
color: #495057;
}
.aviso-importante__container p {
line-height: 1.6;
margin-bottom: 10px;
text-align: justify;
}
@media screen and (max-width: 768px) {
.header__container {

View File

@ -1,50 +1,31 @@
const { consultarViabilidade, discoverDataType } = require('./viabilidadeService');
const { consultarViabilidadeLote, discoverDataType } = require('./viabilidadeService');
const fs = require('fs');
const path = require('path');
const XLSX = require('xlsx');
const ExcelJS = require('exceljs');
const { once } = require('events');
const {
normalizeHeader,
isExcelFile,
detectDelimiter,
hasCepHeader,
hasAddressOrNumberHeader,
hasGeoHeaders
} = require('./fileFormat');
const {
incrementProcessed,
incrementErrors,
finishJob
} = require('./jobStore.service');
const RESULT_HEADERS = ['Provedor', 'Distancia', 'Dedicado', 'Nao Dedicado', 'Erro'];
const RESULT_HEADERS = [
'Provedor (C1)', 'Nao Dedicado (C1)', 'Dedicado (C1)', 'Distancia Trajeto (C1)',
'Provedor (C2)', 'Nao Dedicado (C2)', 'Dedicado (C2)', 'Distancia Trajeto (C2)',
'Erro'
];
function normalizeHeader(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
}
function isExcelFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (['.xls', '.xlsx'].includes(ext)) return true;
const fileStart = fs.readFileSync(filePath).subarray(0, 512);
const signature = fileStart.subarray(0, 8);
const isZipBasedXlsx = signature[0] === 0x50 && signature[1] === 0x4b;
const isOleBasedXls = signature[0] === 0xd0
&& signature[1] === 0xcf
&& signature[2] === 0x11
&& signature[3] === 0xe0
&& signature[4] === 0xa1
&& signature[5] === 0xb1
&& signature[6] === 0x1a
&& signature[7] === 0xe1;
const startText = fileStart.toString('latin1').trimStart().toLowerCase();
const isHtmlExcel = startText.startsWith('<html')
|| startText.startsWith('<!doctype html')
|| startText.includes('<table');
return isZipBasedXlsx || isOleBasedXls || isHtmlExcel;
}
// Quantidade de itens enviados por chamada ao endpoint /lote da API.
const LOTE_CHUNK = 25;
function isXlsxFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
@ -54,13 +35,6 @@ function isXlsxFile(filePath) {
return fileStart[0] === 0x50 && fileStart[1] === 0x4b;
}
function detectDelimiter(line) {
const delimiters = [';', '\t', ','];
return delimiters
.map(delimiter => ({ delimiter, count: line.split(delimiter).length }))
.sort((a, b) => b.count - a.count)[0].delimiter;
}
function splitDelimitedLine(line, delimiter) {
const cols = [];
let current = '';
@ -88,7 +62,7 @@ function splitDelimitedLine(line, delimiter) {
}
function readDelimitedRows(filePath) {
const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
const content = fs.readFileSync(filePath, 'utf8').replace(/^/, '');
const lines = content.split(/\r?\n/).filter(line => line.trim());
if (!lines.length) return [];
@ -116,27 +90,6 @@ function findFirstHeaderIndex(headers, predicate) {
return headers.map(normalizeHeader).findIndex(predicate);
}
function hasHeaderAlias(headers, aliases) {
const normalizedAliases = aliases.map(normalizeHeader);
return headers.map(normalizeHeader).some(header => normalizedAliases.includes(header));
}
function hasCepHeader(headers) {
return headers.map(normalizeHeader).some(header => /\bcep\b/.test(header) || header === 'codigo postal');
}
function hasAddressOrNumberHeader(headers) {
return headers.map(normalizeHeader).some(header => ['numero', 'num', 'nº', 'n°'].includes(header)
|| header.includes('endereco')
|| header.includes('logradouro'));
}
function hasGeoHeaders(headers) {
const norm = headers.map(normalizeHeader);
return norm.some(h => /\blat(itude)?\b/.test(h))
&& norm.some(h => /\blo?n[g]?(itude)?\b/.test(h));
}
function findHeaderRowIndex(rows) {
const index = rows.findIndex(row => (hasCepHeader(row) && hasAddressOrNumberHeader(row)) || hasGeoHeaders(row));
return index >= 0 ? index : 0;
@ -153,9 +106,7 @@ function resolveColumnIndexes(headers) {
idxCep: findFirstHeaderIndex(headers, header => /\bcep\b/.test(header) || header === 'codigo postal'),
idxNumero: exactIndex(['numero', 'número', 'num', 'nº', 'n°']),
idxEndereco: findFirstHeaderIndex(headers, header => header.includes('endereco') || header.includes('logradouro')),
// Antes: exactIndex(['latitude', 'lat'])
idxLatitude: findFirstHeaderIndex(headers, header => /\blat(itude)?\b/.test(header)),
// Antes: exactIndex(['longitude', 'long', 'lng', 'lon'])
idxLatitude: findFirstHeaderIndex(headers, header => /\blat(itude)?\b/.test(header)),
idxLongitude: findFirstHeaderIndex(headers, header => /\blo?n[g]?(itude)?\b/.test(header)),
};
}
@ -191,20 +142,25 @@ function buildCepPayload(cols, indexes) {
function parseCoordinate(value) {
const str = String(value ?? '')
.replace(/[\u00A0\u202F\u2000-\u200B\u3000]/g, '') // Bug 2: remove non-breaking spaces
.replace(/[  - ]/g, '') // remove espaços não-quebráveis
.trim()
.replace(',', '.');
// Bug 3: converte DMS "17 38 18.80 S" → -17.638556
const dms = str.match(/^(\d+)[°\s]+(\d+)['\s]+(\d+(?:\.\d+)?)["\s]*([NSEW])?$/i);
// converte DMS "17 38 18.80 S" → -17.638556. Aceita direção N/S/E/W e também
// O (Oeste=West) e L (Leste=East), usadas em pt-BR.
const dms = str.match(/^(\d+)[°\s]+(\d+)['\s]+(\d+(?:\.\d+)?)["\s]*([NSEWOL])?$/i);
if (dms) {
const decimal = parseFloat(dms[1]) + parseFloat(dms[2]) / 60 + parseFloat(dms[3]) / 3600;
const negative = /[SW]/i.test(dms[4] ?? '');
return negative ? -decimal : decimal;
const negativo = /[SWO]/i.test(dms[4] ?? ''); // Sul / West / Oeste => negativo
return negativo ? -decimal : decimal;
}
// decimal, podendo ter direção no fim (ex.: "45.55 O", "8.6 S")
const dir = str.match(/([NSEWOL])\s*$/i);
const parsed = parseFloat(str);
return Number.isFinite(parsed) ? parsed : NaN;
if (!Number.isFinite(parsed)) return NaN;
if (dir) return /[SWO]/i.test(dir[1]) ? -Math.abs(parsed) : Math.abs(parsed);
return parsed;
}
function buildGeoPayload(cols, indexes) {
@ -215,30 +171,80 @@ function buildGeoPayload(cols, indexes) {
return { latitude, longitude };
}
async function consultarComFallback(geoPayload, cepPayload) {
let lastError = null;
// Monta a lista de linhas com payload a consultar. Para cada linha, define o
// payload primário (geo se houver, senão cep) e o de fallback (cep, quando a
// linha tem ambos — preserva a semântica antiga "tenta geo, senão cep").
function buildLinhas(rows, headerRowIndex, indexes) {
const linhas = [];
for (let rowIndex = headerRowIndex + 1; rowIndex < rows.length; rowIndex++) {
const cols = rows[rowIndex];
const geo = buildGeoPayload(cols, indexes);
const cep = buildCepPayload(cols, indexes);
if (!geo && !cep) continue;
linhas.push({ rowIndex, primary: geo || cep, fallback: (geo && cep) ? cep : null });
}
return linhas;
}
if (geoPayload) {
// Avalia todas as linhas via o endpoint /lote da API, em blocos. Retorna um
// Map(rowIndex -> resultado | { __erro }). Atualiza o progresso do job.
async function avaliarLinhas(jobId, linhas) {
const resultadoPorRow = new Map();
for (let start = 0; start < linhas.length; start += LOTE_CHUNK) {
const chunk = linhas.slice(start, start + LOTE_CHUNK);
let resultados;
try {
const result = await consultarViabilidade(geoPayload);
if (!result || !result.error) return result;
lastError = new Error(result.error);
resultados = await consultarViabilidadeLote(chunk.map(l => l.primary));
} catch (err) {
lastError = err;
const msg = formatApiErrorResponse(err);
chunk.forEach(l => {
resultadoPorRow.set(l.rowIndex, { __erro: msg });
incrementErrors(jobId);
incrementProcessed(jobId);
});
continue;
}
const paraFallback = [];
chunk.forEach((linha, i) => {
const r = resultados[i];
if (r && r.erro && linha.fallback) {
paraFallback.push(linha);
} else if (r && r.erro) {
resultadoPorRow.set(linha.rowIndex, { __erro: r.erro });
} else if (r) {
resultadoPorRow.set(linha.rowIndex, r);
} else {
resultadoPorRow.set(linha.rowIndex, { __erro: 'Sem resultado para a linha' });
}
});
if (paraFallback.length) {
let fb = null;
try {
fb = await consultarViabilidadeLote(paraFallback.map(l => l.fallback));
} catch (err) {
const msg = formatApiErrorResponse(err);
paraFallback.forEach(l => resultadoPorRow.set(l.rowIndex, { __erro: msg }));
}
if (fb) {
paraFallback.forEach((linha, k) => {
const r = fb[k];
resultadoPorRow.set(linha.rowIndex, (r && !r.erro) ? r : { __erro: (r && r.erro) || 'Linha sem latitude/longitude ou CEP valido' });
});
}
}
chunk.forEach(l => {
const r = resultadoPorRow.get(l.rowIndex);
if (r && r.__erro) incrementErrors(jobId);
incrementProcessed(jobId);
});
}
if (cepPayload) {
try {
const result = await consultarViabilidade(cepPayload);
if (!result || !result.error) return result;
lastError = new Error(result.error);
} catch (err) {
lastError = err;
}
}
throw lastError || new Error('Linha sem latitude/longitude ou CEP valido');
return resultadoPorRow;
}
function cleanCsvValue(value) {
@ -258,18 +264,45 @@ function formatApiErrorResponse(error) {
return error && (error.message || String(error));
}
// 4 colunas por caixa: Provedor, Nao Dedicado, Dedicado, Distancia Trajeto.
// Caixa inexistente (ex.: só 1 caixa próxima) => colunas em branco.
function caixaCols(caixa) {
if (!caixa) return ['', '', '', ''];
const viavel = v => (v ? 'Viavel' : 'Nao Viavel');
const distancia = (caixa.distancia != null && caixa.distancia !== '') ? caixa.distancia : '';
return [caixa.provedor ?? '', viavel(caixa.naoDedicado), viavel(caixa.dedicado), distancia];
}
function buildSuccessResultColumns(viab) {
const provedor = viab.provedor ?? '';
const distancia = viab.distancia ?? (viab.raw && (viab.raw.distancia || viab.raw.distance)) ?? '';
const dedicado = viab.dedicado ? 'Viavel' : 'Nao Viavel';
const naoDedicado = viab.naoDedicado ? 'Viavel' : 'Nao Viavel';
const caixas = viab.caixas || [];
const error = viab.error ? cleanCsvValue(viab.error) : '';
return [provedor, distancia, dedicado, naoDedicado, error];
let c1;
if (caixas.length === 0) {
// Sem cobertura: monta o status como "Caixa 1" (em vez de deixar em branco).
const viavel = v => (v ? 'Viavel' : 'Nao Viavel');
const distancia = (viab.distancia != null && viab.distancia !== '') ? viab.distancia : '';
c1 = [viab.provedor ?? 'Nenhum provedor disponível', viavel(viab.naoDedicado), viavel(viab.dedicado), distancia];
} else {
c1 = caixaCols(caixas[0]);
}
// Sem 2ª caixa: replica as infos da Caixa 1 (não deixa a Caixa 2 em branco).
const c2 = caixas[1] ? caixaCols(caixas[1]) : c1;
return [...c1, ...c2, error];
}
function buildErrorResultColumns(err) {
return ['', '', '', '', cleanCsvValue(formatApiErrorResponse(err))];
return ['', '', '', '', '', '', '', '', cleanCsvValue(formatApiErrorResponse(err))];
}
// Colunas de resultado para uma linha, a partir do Map de resultados do lote.
function resultColumnsFor(resultadoPorRow, rowIndex) {
const resultado = resultadoPorRow.get(rowIndex) || { __erro: 'Linha não avaliada' };
return resultado.__erro
? buildErrorResultColumns({ message: resultado.__erro })
: buildSuccessResultColumns(resultado);
}
function cloneCellStyle(cell) {
@ -297,7 +330,7 @@ function styleInsertedResultColumns(worksheet, headerRowNumber) {
});
}
async function processXlsxFile(jobId, inputPath, outputPath, rows, headerRowIndex, indexes) {
async function processXlsxFile(inputPath, outputPath, rows, headerRowIndex, indexes, resultadoPorRow) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(inputPath);
@ -313,23 +346,11 @@ async function processXlsxFile(jobId, inputPath, outputPath, rows, headerRowInde
if (!geoPayload && !cepPayload) continue;
const row = worksheet.getRow(rowIndex + 1);
try {
const viab = await consultarComFallback(geoPayload, cepPayload);
buildSuccessResultColumns(viab).forEach((value, index) => {
const cell = row.getCell(index + 1);
cell.value = value;
cell.style = cloneCellStyle(row.getCell(RESULT_HEADERS.length + 1));
});
incrementProcessed(jobId);
} catch (err) {
buildErrorResultColumns(err).forEach((value, index) => {
const cell = row.getCell(index + 1);
cell.value = value;
cell.style = cloneCellStyle(row.getCell(RESULT_HEADERS.length + 1));
});
incrementErrors(jobId);
incrementProcessed(jobId);
}
resultColumnsFor(resultadoPorRow, rowIndex).forEach((value, index) => {
const cell = row.getCell(index + 1);
cell.value = value;
cell.style = cloneCellStyle(row.getCell(RESULT_HEADERS.length + 1));
});
row.commit();
}
@ -427,8 +448,12 @@ async function processCsvFile(jobId, inputPath, originalName) {
const outputPath = path.join(__dirname, '..', 'outputs', outputFilename);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
// Avalia todas as linhas de uma vez (em blocos) via /lote da API.
const linhas = buildLinhas(rows, headerRowIndex, indexes);
const resultadoPorRow = await avaliarLinhas(jobId, linhas);
if (isXlsxFile(inputPath)) {
await processXlsxFile(jobId, inputPath, outputPath, rows, headerRowIndex, indexes);
await processXlsxFile(inputPath, outputPath, rows, headerRowIndex, indexes, resultadoPorRow);
finishJob(jobId, path.basename(outputPath));
return outputPath;
@ -446,15 +471,7 @@ async function processCsvFile(jobId, inputPath, originalName) {
const cepPayload = buildCepPayload(cols, indexes);
if (!geoPayload && !cepPayload) continue;
try {
const viab = await consultarComFallback(geoPayload, cepPayload);
rowResults.push({ rowIndex, values: buildSuccessResultColumns(viab) });
incrementProcessed(jobId);
} catch (err) {
rowResults.push({ rowIndex, values: buildErrorResultColumns(err) });
incrementErrors(jobId);
incrementProcessed(jobId);
}
rowResults.push({ rowIndex, values: resultColumnsFor(resultadoPorRow, rowIndex) });
}
workbook.Sheets[firstSheetName] = prependResultColumnsToWorksheet(worksheet, headerRowIndex, rowResults);
@ -465,25 +482,17 @@ async function processCsvFile(jobId, inputPath, originalName) {
}
const outStream = fs.createWriteStream(outputPath, { encoding: 'utf8' });
outStream.write('\uFEFF');
outStream.write('');
outStream.write([...RESULT_HEADERS, ...headers].join(';') + '\n');
for (const cols of rows.slice(headerRowIndex + 1)) {
for (let rowIndex = headerRowIndex + 1; rowIndex < rows.length; rowIndex++) {
const cols = rows[rowIndex];
const geoPayload = buildGeoPayload(cols, indexes);
const cepPayload = buildCepPayload(cols, indexes);
if (!geoPayload && !cepPayload) continue;
try {
const viab = await consultarComFallback(geoPayload, cepPayload);
const outCols = [...buildSuccessResultColumns(viab), ...cols].map(cleanCsvValue);
outStream.write(outCols.join(';') + '\n');
incrementProcessed(jobId);
} catch (err) {
const outCols = [...buildErrorResultColumns(err), ...cols].map(cleanCsvValue);
outStream.write(outCols.join(';') + '\n');
incrementErrors(jobId);
incrementProcessed(jobId);
}
const outCols = [...resultColumnsFor(resultadoPorRow, rowIndex), ...cols].map(cleanCsvValue);
outStream.write(outCols.join(';') + '\n');
}
outStream.end();

72
service/fileFormat.js Normal file
View File

@ -0,0 +1,72 @@
const fs = require('fs');
const path = require('path');
// Helpers de detecção de formato/cabeçalho compartilhados entre viabilidadeService e csvService.
// Antes estavam duplicados nos dois arquivos. Todos normalizam internamente (normalizeHeader
// é idempotente), então funcionam tanto com cabeçalhos crus quanto já normalizados.
function normalizeHeader(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
}
function isExcelFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (['.xls', '.xlsx'].includes(ext)) return true;
const fileStart = fs.readFileSync(filePath).subarray(0, 512);
const signature = fileStart.subarray(0, 8);
const isZipBasedXlsx = signature[0] === 0x50 && signature[1] === 0x4b;
const isOleBasedXls = signature[0] === 0xd0
&& signature[1] === 0xcf
&& signature[2] === 0x11
&& signature[3] === 0xe0
&& signature[4] === 0xa1
&& signature[5] === 0xb1
&& signature[6] === 0x1a
&& signature[7] === 0xe1;
const startText = fileStart.toString('latin1').trimStart().toLowerCase();
const isHtmlExcel = startText.startsWith('<html')
|| startText.startsWith('<!doctype html')
|| startText.includes('<table');
return isZipBasedXlsx || isOleBasedXls || isHtmlExcel;
}
function detectDelimiter(line) {
const delimiters = [';', '\t', ','];
return delimiters
.map(delimiter => ({ delimiter, count: line.split(delimiter).length }))
.sort((a, b) => b.count - a.count)[0].delimiter;
}
function hasCepHeader(headers) {
return headers.map(normalizeHeader).some(header => /\bcep\b/.test(header) || header === 'codigo postal');
}
function hasAddressOrNumberHeader(headers) {
return headers.map(normalizeHeader).some(header => ['numero', 'num', 'nº', 'n°'].includes(header)
|| header.includes('endereco')
|| header.includes('logradouro'));
}
function hasGeoHeaders(headers) {
const norm = headers.map(normalizeHeader);
return norm.some(h => /\blat(itude)?\b/.test(h))
&& norm.some(h => /\blo?n[g]?(itude)?\b/.test(h));
}
module.exports = {
normalizeHeader,
isExcelFile,
detectDelimiter,
hasCepHeader,
hasAddressOrNumberHeader,
hasGeoHeaders
};

View File

@ -1,61 +1,21 @@
const axios = require('axios');
const fs = require('fs');
const readline = require('readline');
const path = require('path');
const XLSX = require('xlsx');
const { apiConfig, apiViabilidadeUrl, apiUrlBase } = require('../config/apiConfig');
function normalizeHeader(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
}
function hasHeader(headers, aliases) {
const normalizedAliases = aliases.map(normalizeHeader);
return headers.some(header => normalizedAliases.includes(header));
}
function isExcelFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (['.xls', '.xlsx'].includes(ext)) return true;
const fileStart = fs.readFileSync(filePath).subarray(0, 512);
const signature = fileStart.subarray(0, 8);
const isZipBasedXlsx = signature[0] === 0x50 && signature[1] === 0x4b;
const isOleBasedXls = signature[0] === 0xd0
&& signature[1] === 0xcf
&& signature[2] === 0x11
&& signature[3] === 0xe0
&& signature[4] === 0xa1
&& signature[5] === 0xb1
&& signature[6] === 0x1a
&& signature[7] === 0xe1;
const startText = fileStart.toString('latin1').trimStart().toLowerCase();
const isHtmlExcel = startText.startsWith('<html')
|| startText.startsWith('<!doctype html')
|| startText.includes('<table');
return isZipBasedXlsx || isOleBasedXls || isHtmlExcel;
}
function detectDelimiter(line) {
const delimiters = [';', '\t', ','];
return delimiters
.map(delimiter => ({ delimiter, count: line.split(delimiter).length }))
.sort((a, b) => b.count - a.count)[0].delimiter;
}
const { apiUrlBase } = require('../config/apiConfig');
const {
normalizeHeader,
isExcelFile,
detectDelimiter,
hasCepHeader,
hasAddressOrNumberHeader,
hasGeoHeaders
} = require('./fileFormat');
function findHeaderRow(rows) {
return rows.find(row => {
const headers = row.map(normalizeHeader);
const hasCepNumero = hasCepHeader(headers) && hasAddressOrNumberHeader(headers);
const hasGeo = hasGeoHeaders(headers);
const hasCepNumero = hasCepHeader(row) && hasAddressOrNumberHeader(row);
const hasGeo = hasGeoHeaders(row);
return hasCepNumero || hasGeo;
}) || [];
}
@ -79,12 +39,10 @@ async function readDelimitedHeaders(filePath) {
const rl = readline.createInterface({ input: instream, crlfDelay: Infinity });
for await (const rawLine of rl) {
const line = rawLine.replace(/^\uFEFF/, '').replace(/\r$/, '');
const line = rawLine.replace(/^/, '').replace(/\r$/, '');
if (!line.trim()) continue;
const headers = line.split(detectDelimiter(line)).map(normalizeHeader);
const hasCepNumero = hasCepHeader(headers) && hasAddressOrNumberHeader(headers);
const hasGeo = hasGeoHeaders(headers);
if (hasCepNumero || hasGeo) {
if ((hasCepHeader(headers) && hasAddressOrNumberHeader(headers)) || hasGeoHeaders(headers)) {
rl.close();
return headers;
}
@ -94,69 +52,55 @@ async function readDelimitedHeaders(filePath) {
return [];
}
function hasCepHeader(headers) {
return headers.some(header => /\bcep\b/.test(header) || header === 'codigo postal');
}
function hasAddressOrNumberHeader(headers) {
return headers.some(header => ['numero', 'num', 'nº', 'n°'].includes(header)
|| header.includes('endereco')
|| header.includes('logradouro'));
}
function hasGeoHeaders(headers) {
return headers.some(h => /\blat(itude)?\b/.test(h))
&& headers.some(h => /\blo?n[g]?(itude)?\b/.test(h));
}
async function consultarViabilidade(data) {
try {
const dataType = await discoverDataType(data);
let endpoint = apiUrlBase;
if (dataType === 'geolocalizacao') {
endpoint += 'viabilidade/lat-long';
} else {
endpoint += 'viabilidade';
}
const response = await axios.post(endpoint, data, {
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
});
console.log('Resposta da API de viabilidade:', response.data);
return (response.data);
} catch (error) {
throw error;
}
}
// Preciso de uma função para verificar se os dados vindos são de CEP ou de geolocalização
// Verifica se os dados vindos são de CEP ou de geolocalização (aceita caminho de arquivo ou objeto).
async function discoverDataType(input) {
if (typeof input === 'string') {
const headers = isExcelFile(input)
? readExcelHeaders(input)
: await readDelimitedHeaders(input);
const hasCepNumero = hasCepHeader(headers) && hasAddressOrNumberHeader(headers);
if (hasCepNumero) {
return 'cep';
} else if (hasGeoHeaders(headers)) {
return 'geolocalizacao';
} else {
return 'unknown';
}
} else if (typeof input === 'object') {
// Trata como objeto de dados
if (input.cep && input.numero) {
return 'cep';
} else if (input.latitude !== undefined && input.longitude !== undefined) {
return 'geolocalizacao';
} else {
return 'unknown';
}
} else {
if (hasCepHeader(headers) && hasAddressOrNumberHeader(headers)) return 'cep';
if (hasGeoHeaders(headers)) return 'geolocalizacao';
return 'unknown';
}
if (input && typeof input === 'object') {
if (input.cep && input.numero) return 'cep';
if (input.latitude !== undefined && input.longitude !== undefined) return 'geolocalizacao';
return 'unknown';
}
return 'unknown';
}
module.exports = { consultarViabilidade, discoverDataType };
// Consulta individual (manual / uma linha). Escolhe o endpoint conforme o tipo dos dados.
async function consultarViabilidade(data) {
const dataType = await discoverDataType(data);
const endpoint = apiUrlBase + (dataType === 'geolocalizacao' ? 'viabilidade/lat-long' : 'viabilidade');
const response = await axios.post(endpoint, data, {
// A API agora faz geocode + GeoGrid + trajeto (várias chamadas externas);
// o cep-promise também pode ser lento. 30s evita timeout prematuro.
timeout: 30000,
headers: { 'Content-Type': 'application/json' }
});
return response.data;
}
// Consulta em LOTE: envia um array de itens ({cep,numero} ou {latitude,longitude}) de uma só vez.
// Retorna o array `resultados` da API, alinhado por índice ao array enviado.
async function consultarViabilidadeLote(itens, modo) {
const endpoint = apiUrlBase + 'viabilidade/lote';
const body = { itens };
if (modo) body.modo = modo;
const response = await axios.post(endpoint, body, {
timeout: 180000,
headers: { 'Content-Type': 'application/json' }
});
const data = response.data || {};
return Array.isArray(data.resultados) ? data.resultados : [];
}
module.exports = { consultarViabilidade, consultarViabilidadeLote, discoverDataType };