2026-07-21 15:49:29 -03:00
|
|
|
|
const { consultarViabilidadeLote, discoverDataType } = require('./viabilidadeService');
|
2025-12-30 09:16:07 -03:00
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
const path = require('path');
|
2026-05-04 16:38:37 -03:00
|
|
|
|
const XLSX = require('xlsx');
|
2026-05-13 08:28:09 -03:00
|
|
|
|
const ExcelJS = require('exceljs');
|
2025-12-30 09:16:07 -03:00
|
|
|
|
const { once } = require('events');
|
2026-07-21 15:49:29 -03:00
|
|
|
|
const {
|
|
|
|
|
|
normalizeHeader,
|
|
|
|
|
|
isExcelFile,
|
|
|
|
|
|
detectDelimiter,
|
|
|
|
|
|
hasCepHeader,
|
|
|
|
|
|
hasAddressOrNumberHeader,
|
|
|
|
|
|
hasGeoHeaders
|
|
|
|
|
|
} = require('./fileFormat');
|
2025-12-30 09:16:07 -03:00
|
|
|
|
const {
|
|
|
|
|
|
incrementProcessed,
|
|
|
|
|
|
incrementErrors,
|
2026-05-04 16:38:37 -03:00
|
|
|
|
finishJob
|
2025-12-30 09:16:07 -03:00
|
|
|
|
} = require('./jobStore.service');
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
const RESULT_HEADERS = [
|
|
|
|
|
|
'Provedor (C1)', 'Nao Dedicado (C1)', 'Dedicado (C1)', 'Distancia Trajeto (C1)',
|
|
|
|
|
|
'Provedor (C2)', 'Nao Dedicado (C2)', 'Dedicado (C2)', 'Distancia Trajeto (C2)',
|
|
|
|
|
|
'Erro'
|
|
|
|
|
|
];
|
2026-05-12 17:57:30 -03:00
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
// Quantidade de itens enviados por chamada ao endpoint /lote da API.
|
|
|
|
|
|
const LOTE_CHUNK = 25;
|
2026-05-04 16:38:37 -03:00
|
|
|
|
|
2026-05-13 08:28:09 -03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 16:38:37 -03:00
|
|
|
|
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) {
|
2026-07-21 15:49:29 -03:00
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8').replace(/^/, '');
|
2026-05-04 16:38:37 -03:00
|
|
|
|
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,
|
2026-05-12 17:57:30 -03:00
|
|
|
|
blankrows: true,
|
2026-05-04 16:38:37 -03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 16:52:23 -03:00
|
|
|
|
function findHeaderRowIndex(rows) {
|
|
|
|
|
|
const index = rows.findIndex(row => (hasCepHeader(row) && hasAddressOrNumberHeader(row)) || hasGeoHeaders(row));
|
|
|
|
|
|
return index >= 0 ? index : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 16:38:37 -03:00
|
|
|
|
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')),
|
2026-07-21 15:49:29 -03:00
|
|
|
|
idxLatitude: findFirstHeaderIndex(headers, header => /\blat(itude)?\b/.test(header)),
|
2026-06-23 15:30:29 -03:00
|
|
|
|
idxLongitude: findFirstHeaderIndex(headers, header => /\blo?n[g]?(itude)?\b/.test(header)),
|
2026-05-04 16:38:37 -03:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 16:05:49 -03:00
|
|
|
|
function parseCoordinate(value) {
|
2026-06-23 15:30:29 -03:00
|
|
|
|
const str = String(value ?? '')
|
2026-07-21 15:49:29 -03:00
|
|
|
|
.replace(/[ - ]/g, '') // remove espaços não-quebráveis
|
2026-06-23 15:30:29 -03:00
|
|
|
|
.trim()
|
|
|
|
|
|
.replace(',', '.');
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
// 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);
|
2026-06-23 15:30:29 -03:00
|
|
|
|
if (dms) {
|
|
|
|
|
|
const decimal = parseFloat(dms[1]) + parseFloat(dms[2]) / 60 + parseFloat(dms[3]) / 3600;
|
2026-07-21 15:49:29 -03:00
|
|
|
|
const negativo = /[SWO]/i.test(dms[4] ?? ''); // Sul / West / Oeste => negativo
|
|
|
|
|
|
return negativo ? -decimal : decimal;
|
2026-06-23 15:30:29 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
// decimal, podendo ter direção no fim (ex.: "45.55 O", "8.6 S")
|
|
|
|
|
|
const dir = str.match(/([NSEWOL])\s*$/i);
|
2026-06-23 15:30:29 -03:00
|
|
|
|
const parsed = parseFloat(str);
|
2026-07-21 15:49:29 -03:00
|
|
|
|
if (!Number.isFinite(parsed)) return NaN;
|
|
|
|
|
|
if (dir) return /[SWO]/i.test(dir[1]) ? -Math.abs(parsed) : Math.abs(parsed);
|
|
|
|
|
|
return parsed;
|
2026-05-05 16:05:49 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
// 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);
|
2026-05-05 16:05:49 -03:00
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
let resultados;
|
2026-05-05 16:05:49 -03:00
|
|
|
|
try {
|
2026-07-21 15:49:29 -03:00
|
|
|
|
resultados = await consultarViabilidadeLote(chunk.map(l => l.primary));
|
2026-05-05 16:05:49 -03:00
|
|
|
|
} catch (err) {
|
2026-07-21 15:49:29 -03:00
|
|
|
|
const msg = formatApiErrorResponse(err);
|
|
|
|
|
|
chunk.forEach(l => {
|
|
|
|
|
|
resultadoPorRow.set(l.rowIndex, { __erro: msg });
|
|
|
|
|
|
incrementErrors(jobId);
|
|
|
|
|
|
incrementProcessed(jobId);
|
|
|
|
|
|
});
|
|
|
|
|
|
continue;
|
2026-05-05 16:05:49 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
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' });
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-05 16:05:49 -03:00
|
|
|
|
}
|
2026-07-21 15:49:29 -03:00
|
|
|
|
|
|
|
|
|
|
chunk.forEach(l => {
|
|
|
|
|
|
const r = resultadoPorRow.get(l.rowIndex);
|
|
|
|
|
|
if (r && r.__erro) incrementErrors(jobId);
|
|
|
|
|
|
incrementProcessed(jobId);
|
|
|
|
|
|
});
|
2026-05-05 16:05:49 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
return resultadoPorRow;
|
2026-05-05 16:05:49 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 16:38:37 -03:00
|
|
|
|
function cleanCsvValue(value) {
|
|
|
|
|
|
const text = String(value ?? '').replace(/[\r\n;]/g, ' ');
|
|
|
|
|
|
return text.includes('"') ? text.replace(/"/g, "'") : text;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 16:45:52 -03:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
// 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 17:57:30 -03:00
|
|
|
|
function buildSuccessResultColumns(viab) {
|
2026-07-21 15:49:29 -03:00
|
|
|
|
const caixas = viab.caixas || [];
|
2026-05-12 17:57:30 -03:00
|
|
|
|
const error = viab.error ? cleanCsvValue(viab.error) : '';
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
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];
|
2026-05-12 17:57:30 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildErrorResultColumns(err) {
|
2026-07-21 15:49:29 -03:00
|
|
|
|
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);
|
2026-05-12 17:57:30 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 08:28:09 -03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
async function processXlsxFile(inputPath, outputPath, rows, headerRowIndex, indexes, resultadoPorRow) {
|
2026-05-13 08:28:09 -03:00
|
|
|
|
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);
|
2026-07-21 15:49:29 -03:00
|
|
|
|
resultColumnsFor(resultadoPorRow, rowIndex).forEach((value, index) => {
|
|
|
|
|
|
const cell = row.getCell(index + 1);
|
|
|
|
|
|
cell.value = value;
|
|
|
|
|
|
cell.style = cloneCellStyle(row.getCell(RESULT_HEADERS.length + 1));
|
|
|
|
|
|
});
|
2026-05-13 08:28:09 -03:00
|
|
|
|
row.commit();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await workbook.xlsx.writeFile(outputPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 17:57:30 -03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 09:16:07 -03:00
|
|
|
|
async function countValidLines(inputPath) {
|
2026-05-05 16:05:49 -03:00
|
|
|
|
await discoverDataType(inputPath);
|
2026-05-04 16:38:37 -03:00
|
|
|
|
const rows = readRows(inputPath);
|
2026-05-04 16:52:23 -03:00
|
|
|
|
const headerRowIndex = findHeaderRowIndex(rows);
|
|
|
|
|
|
const headers = rows[headerRowIndex] || [];
|
2026-05-04 16:38:37 -03:00
|
|
|
|
const indexes = resolveColumnIndexes(headers);
|
2025-12-30 09:16:07 -03:00
|
|
|
|
let total = 0;
|
|
|
|
|
|
|
2026-05-04 16:52:23 -03:00
|
|
|
|
for (const cols of rows.slice(headerRowIndex + 1)) {
|
2026-05-05 16:05:49 -03:00
|
|
|
|
const geoPayload = buildGeoPayload(cols, indexes);
|
|
|
|
|
|
const cepPayload = buildCepPayload(cols, indexes);
|
|
|
|
|
|
if (geoPayload || cepPayload) total++;
|
2025-12-30 09:16:07 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return total;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 11:37:42 -03:00
|
|
|
|
async function processCsvFile(jobId, inputPath, originalName) {
|
2026-05-05 16:05:49 -03:00
|
|
|
|
await discoverDataType(inputPath);
|
2026-05-04 16:38:37 -03:00
|
|
|
|
const rows = readRows(inputPath);
|
2026-05-04 16:52:23 -03:00
|
|
|
|
const headerRowIndex = findHeaderRowIndex(rows);
|
|
|
|
|
|
const headers = rows[headerRowIndex] || [];
|
2026-05-04 16:38:37 -03:00
|
|
|
|
const indexes = resolveColumnIndexes(headers);
|
|
|
|
|
|
const baseName = path.parse(originalName || inputPath).name;
|
2026-05-12 17:57:30 -03:00
|
|
|
|
const isExcel = isExcelFile(inputPath);
|
|
|
|
|
|
const outputFilename = `processed_${Date.now()}_${baseName}${isExcel ? '.xlsx' : '.csv'}`;
|
2025-12-30 09:16:07 -03:00
|
|
|
|
const outputPath = path.join(__dirname, '..', 'outputs', outputFilename);
|
2026-05-12 17:57:30 -03:00
|
|
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-05-13 08:28:09 -03:00
|
|
|
|
if (isXlsxFile(inputPath)) {
|
2026-07-21 15:49:29 -03:00
|
|
|
|
await processXlsxFile(inputPath, outputPath, rows, headerRowIndex, indexes, resultadoPorRow);
|
2026-05-13 08:28:09 -03:00
|
|
|
|
finishJob(jobId, path.basename(outputPath));
|
|
|
|
|
|
|
|
|
|
|
|
return outputPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 17:57:30 -03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
rowResults.push({ rowIndex, values: resultColumnsFor(resultadoPorRow, rowIndex) });
|
2026-05-12 17:57:30 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
workbook.Sheets[firstSheetName] = prependResultColumnsToWorksheet(worksheet, headerRowIndex, rowResults);
|
|
|
|
|
|
XLSX.writeFile(workbook, outputPath, { bookType: 'xlsx' });
|
|
|
|
|
|
finishJob(jobId, path.basename(outputPath));
|
|
|
|
|
|
|
|
|
|
|
|
return outputPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 09:16:07 -03:00
|
|
|
|
const outStream = fs.createWriteStream(outputPath, { encoding: 'utf8' });
|
2026-07-21 15:49:29 -03:00
|
|
|
|
outStream.write('');
|
2026-05-12 17:57:30 -03:00
|
|
|
|
outStream.write([...RESULT_HEADERS, ...headers].join(';') + '\n');
|
2025-12-30 09:16:07 -03:00
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
for (let rowIndex = headerRowIndex + 1; rowIndex < rows.length; rowIndex++) {
|
|
|
|
|
|
const cols = rows[rowIndex];
|
2026-05-05 16:05:49 -03:00
|
|
|
|
const geoPayload = buildGeoPayload(cols, indexes);
|
|
|
|
|
|
const cepPayload = buildCepPayload(cols, indexes);
|
|
|
|
|
|
if (!geoPayload && !cepPayload) continue;
|
2025-12-30 09:16:07 -03:00
|
|
|
|
|
2026-07-21 15:49:29 -03:00
|
|
|
|
const outCols = [...resultColumnsFor(resultadoPorRow, rowIndex), ...cols].map(cleanCsvValue);
|
|
|
|
|
|
outStream.write(outCols.join(';') + '\n');
|
2025-12-30 09:16:07 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
outStream.end();
|
|
|
|
|
|
await once(outStream, 'finish');
|
|
|
|
|
|
|
2025-12-30 11:37:42 -03:00
|
|
|
|
finishJob(jobId, path.basename(outputPath));
|
2025-12-30 09:16:07 -03:00
|
|
|
|
|
2025-12-30 11:37:42 -03:00
|
|
|
|
return outputPath;
|
2025-12-30 09:16:07 -03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 16:38:37 -03:00
|
|
|
|
module.exports = { processCsvFile, countValidLines };
|