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>
This commit is contained in:
parent
c47a7b3a2e
commit
801d3fa90d
@ -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'])
|
||||
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;
|
||||
}
|
||||
|
||||
if (cepPayload) {
|
||||
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 {
|
||||
const result = await consultarViabilidade(cepPayload);
|
||||
if (!result || !result.error) return result;
|
||||
lastError = new Error(result.error);
|
||||
fb = await consultarViabilidadeLote(paraFallback.map(l => l.fallback));
|
||||
} catch (err) {
|
||||
lastError = 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' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Linha sem latitude/longitude ou CEP valido');
|
||||
chunk.forEach(l => {
|
||||
const r = resultadoPorRow.get(l.rowIndex);
|
||||
if (r && r.__erro) incrementErrors(jobId);
|
||||
incrementProcessed(jobId);
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
resultColumnsFor(resultadoPorRow, rowIndex).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);
|
||||
}
|
||||
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);
|
||||
const outCols = [...resultColumnsFor(resultadoPorRow, rowIndex), ...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);
|
||||
}
|
||||
}
|
||||
|
||||
outStream.end();
|
||||
|
||||
72
service/fileFormat.js
Normal file
72
service/fileFormat.js
Normal 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
|
||||
};
|
||||
@ -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 {
|
||||
if (hasCepHeader(headers) && hasAddressOrNumberHeader(headers)) return 'cep';
|
||||
if (hasGeoHeaders(headers)) return 'geolocalizacao';
|
||||
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 {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { consultarViabilidade, discoverDataType };
|
||||
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';
|
||||
}
|
||||
|
||||
// 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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user