viabiliza/service/csvService.js

507 lines
18 KiB
JavaScript
Raw Permalink Normal View History

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 (C1)', 'Nao Dedicado (C1)', 'Dedicado (C1)', 'Distancia Trajeto (C1)',
'Provedor (C2)', 'Nao Dedicado (C2)', 'Dedicado (C2)', 'Distancia Trajeto (C2)',
'Erro'
];
// Quantidade de itens enviados por chamada ao endpoint /lote da API.
const LOTE_CHUNK = 25;
function isXlsxFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.xlsx') return true;
const fileStart = fs.readFileSync(filePath).subarray(0, 4);
return fileStart[0] === 0x50 && fileStart[1] === 0x4b;
}
function splitDelimitedLine(line, delimiter) {
const cols = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const next = line[i + 1];
if (char === '"' && next === '"') {
current += '"';
i++;
} else if (char === '"') {
inQuotes = !inQuotes;
} else if (char === delimiter && !inQuotes) {
cols.push(current.trim());
current = '';
} else {
current += char;
}
}
cols.push(current.trim());
return cols;
}
function readDelimitedRows(filePath) {
const content = fs.readFileSync(filePath, 'utf8').replace(/^/, '');
const lines = content.split(/\r?\n/).filter(line => line.trim());
if (!lines.length) return [];
const delimiter = detectDelimiter(lines[0]);
return lines.map(line => splitDelimitedLine(line.replace(/\r$/, ''), delimiter));
}
function readExcelRows(filePath) {
const workbook = XLSX.readFile(filePath, { cellDates: false, raw: false });
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) return [];
return XLSX.utils.sheet_to_json(workbook.Sheets[firstSheetName], {
header: 1,
blankrows: true,
defval: ''
}).map(row => row.map(cell => String(cell ?? '').trim()));
}
function readRows(filePath) {
return isExcelFile(filePath) ? readExcelRows(filePath) : readDelimitedRows(filePath);
}
function findFirstHeaderIndex(headers, predicate) {
return headers.map(normalizeHeader).findIndex(predicate);
}
function findHeaderRowIndex(rows) {
const index = rows.findIndex(row => (hasCepHeader(row) && hasAddressOrNumberHeader(row)) || hasGeoHeaders(row));
return index >= 0 ? index : 0;
}
function resolveColumnIndexes(headers) {
const normalizedHeaders = headers.map(normalizeHeader);
const exactIndex = aliases => {
const normalizedAliases = aliases.map(normalizeHeader);
return normalizedHeaders.findIndex(header => normalizedAliases.includes(header));
};
return {
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')),
idxLatitude: findFirstHeaderIndex(headers, header => /\blat(itude)?\b/.test(header)),
idxLongitude: findFirstHeaderIndex(headers, header => /\blo?n[g]?(itude)?\b/.test(header)),
};
}
function extractAddressNumber(address) {
const value = String(address || '').trim();
if (!value) return '1';
const withoutRoadKm = value
.replace(/\b(BR|SP|GO|MT|KM)\s*[-]?\s*\d+[A-Z]?\b/gi, ' ')
.replace(/\b\d+\s*[A-Z]?\b\s*(?=\))/gi, ' ');
const labeledNumber = withoutRoadKm.match(/\b(?:n|no|num|numero|número|nº|n°)\.?\s*[:,-]?\s*(\d+[A-Z]?)\b/i);
if (labeledNumber) return labeledNumber[1];
const commaNumber = withoutRoadKm.match(/,\s*(\d+[A-Z]?)\b/i);
if (commaNumber) return commaNumber[1];
const standaloneNumbers = withoutRoadKm.match(/\b\d+[A-Z]?\b/gi) || [];
return standaloneNumbers.length ? standaloneNumbers[standaloneNumbers.length - 1] : '1';
}
function buildCepPayload(cols, indexes) {
const cepRaw = indexes.idxCep >= 0 ? cols[indexes.idxCep] : '';
const cep = String(cepRaw || '').replace(/\D/g, '');
const numeroRaw = indexes.idxNumero >= 0 ? cols[indexes.idxNumero] : '';
const enderecoRaw = indexes.idxEndereco >= 0 ? cols[indexes.idxEndereco] : '';
const numero = String(numeroRaw || '').trim() || extractAddressNumber(enderecoRaw);
if (!cep) return null;
return { cep, numero };
}
function parseCoordinate(value) {
const str = String(value ?? '')
.replace(/[  - ]/g, '') // remove espaços não-quebráveis
.trim()
.replace(',', '.');
// 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 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);
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) {
const latitude = indexes.idxLatitude >= 0 ? parseCoordinate(cols[indexes.idxLatitude]) : NaN;
const longitude = indexes.idxLongitude >= 0 ? parseCoordinate(cols[indexes.idxLongitude]) : NaN;
if (isNaN(latitude) || isNaN(longitude)) return null;
return { latitude, longitude };
}
// 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;
}
// 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 {
resultados = await consultarViabilidadeLote(chunk.map(l => l.primary));
} catch (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);
});
}
return resultadoPorRow;
}
function cleanCsvValue(value) {
const text = String(value ?? '').replace(/[\r\n;]/g, ' ');
return text.includes('"') ? text.replace(/"/g, "'") : text;
}
function formatApiErrorResponse(error) {
const responseData = error && error.response && error.response.data;
if (responseData !== undefined && responseData !== null) {
if (typeof responseData === 'string') return responseData;
if (responseData.error) return responseData.error;
if (responseData.message) return responseData.message;
return JSON.stringify(responseData);
}
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 caixas = viab.caixas || [];
const error = viab.error ? cleanCsvValue(viab.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))];
}
// 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) {
return {
numFmt: cell.numFmt,
font: cell.font ? { ...cell.font } : undefined,
alignment: cell.alignment ? { ...cell.alignment } : undefined,
border: cell.border ? { ...cell.border } : undefined,
fill: cell.fill ? { ...cell.fill } : undefined,
protection: cell.protection ? { ...cell.protection } : undefined
};
}
function styleInsertedResultColumns(worksheet, headerRowNumber) {
RESULT_HEADERS.forEach((header, index) => {
const columnNumber = index + 1;
const sourceColumn = worksheet.getColumn(RESULT_HEADERS.length + 1);
const targetColumn = worksheet.getColumn(columnNumber);
targetColumn.width = Math.max(16, sourceColumn.width || 0);
const headerCell = worksheet.getRow(headerRowNumber).getCell(columnNumber);
const sourceHeaderCell = worksheet.getRow(headerRowNumber).getCell(RESULT_HEADERS.length + 1);
headerCell.value = header;
headerCell.style = cloneCellStyle(sourceHeaderCell);
});
}
async function processXlsxFile(inputPath, outputPath, rows, headerRowIndex, indexes, resultadoPorRow) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(inputPath);
const worksheet = workbook.worksheets[0];
const headerRowNumber = headerRowIndex + 1;
worksheet.spliceColumns(1, 0, ...RESULT_HEADERS.map(() => []));
styleInsertedResultColumns(worksheet, headerRowNumber);
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;
const row = worksheet.getRow(rowIndex + 1);
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();
}
await workbook.xlsx.writeFile(outputPath);
}
function shiftCellAddress(address, colOffset) {
const decoded = XLSX.utils.decode_cell(address);
decoded.c += colOffset;
return XLSX.utils.encode_cell(decoded);
}
function shiftRange(range, colOffset) {
const decoded = typeof range === 'string' ? XLSX.utils.decode_range(range) : range;
return {
s: { r: decoded.s.r, c: decoded.s.c + colOffset },
e: { r: decoded.e.r, c: decoded.e.c + colOffset }
};
}
function prependResultColumnsToWorksheet(worksheet, headerRowIndex, rowResults) {
const colOffset = RESULT_HEADERS.length;
const shiftedWorksheet = {};
Object.keys(worksheet).forEach(key => {
if (key[0] === '!') return;
shiftedWorksheet[shiftCellAddress(key, colOffset)] = worksheet[key];
});
const originalRange = worksheet['!ref']
? XLSX.utils.decode_range(worksheet['!ref'])
: { s: { r: 0, c: 0 }, e: { r: headerRowIndex, c: 0 } };
shiftedWorksheet['!ref'] = XLSX.utils.encode_range({
s: { r: Math.min(originalRange.s.r, headerRowIndex), c: 0 },
e: { r: originalRange.e.r, c: originalRange.e.c + colOffset }
});
if (worksheet['!cols']) {
shiftedWorksheet['!cols'] = Array(colOffset).fill({ wch: 16 }).concat(worksheet['!cols']);
}
if (worksheet['!merges']) {
shiftedWorksheet['!merges'] = worksheet['!merges'].map(merge => shiftRange(merge, colOffset));
}
if (worksheet['!autofilter'] && worksheet['!autofilter'].ref) {
shiftedWorksheet['!autofilter'] = {
...worksheet['!autofilter'],
ref: XLSX.utils.encode_range(shiftRange(worksheet['!autofilter'].ref, colOffset))
};
}
RESULT_HEADERS.forEach((value, index) => {
const address = XLSX.utils.encode_cell({ r: headerRowIndex, c: index });
shiftedWorksheet[address] = { t: 's', v: value };
});
rowResults.forEach(({ rowIndex, values }) => {
values.forEach((value, index) => {
const address = XLSX.utils.encode_cell({ r: rowIndex, c: index });
shiftedWorksheet[address] = { t: 's', v: String(value ?? '') };
});
});
return shiftedWorksheet;
}
async function countValidLines(inputPath) {
await discoverDataType(inputPath);
const rows = readRows(inputPath);
const headerRowIndex = findHeaderRowIndex(rows);
const headers = rows[headerRowIndex] || [];
const indexes = resolveColumnIndexes(headers);
let total = 0;
for (const cols of rows.slice(headerRowIndex + 1)) {
const geoPayload = buildGeoPayload(cols, indexes);
const cepPayload = buildCepPayload(cols, indexes);
if (geoPayload || cepPayload) total++;
}
return total;
}
async function processCsvFile(jobId, inputPath, originalName) {
await discoverDataType(inputPath);
const rows = readRows(inputPath);
const headerRowIndex = findHeaderRowIndex(rows);
const headers = rows[headerRowIndex] || [];
const indexes = resolveColumnIndexes(headers);
const baseName = path.parse(originalName || inputPath).name;
const isExcel = isExcelFile(inputPath);
const outputFilename = `processed_${Date.now()}_${baseName}${isExcel ? '.xlsx' : '.csv'}`;
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(inputPath, outputPath, rows, headerRowIndex, indexes, resultadoPorRow);
finishJob(jobId, path.basename(outputPath));
return outputPath;
}
if (isExcel) {
const workbook = XLSX.readFile(inputPath, { cellDates: false, raw: false, cellStyles: true });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const rowResults = [];
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;
rowResults.push({ rowIndex, values: resultColumnsFor(resultadoPorRow, rowIndex) });
}
workbook.Sheets[firstSheetName] = prependResultColumnsToWorksheet(worksheet, headerRowIndex, rowResults);
XLSX.writeFile(workbook, outputPath, { bookType: 'xlsx' });
finishJob(jobId, path.basename(outputPath));
return outputPath;
}
const outStream = fs.createWriteStream(outputPath, { encoding: 'utf8' });
outStream.write('');
outStream.write([...RESULT_HEADERS, ...headers].join(';') + '\n');
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;
const outCols = [...resultColumnsFor(resultadoPorRow, rowIndex), ...cols].map(cleanCsvValue);
outStream.write(outCols.join(';') + '\n');
}
outStream.end();
await once(outStream, 'finish');
finishJob(jobId, path.basename(outputPath));
return outputPath;
}
module.exports = { processCsvFile, countValidLines };