879 lines
51 KiB
JavaScript
879 lines
51 KiB
JavaScript
/*
|
||
Сервис интеграции ПП Парус 8 с WEB API
|
||
Дополнительный модуль: Sheet Parser (SHP)
|
||
*/
|
||
|
||
//------------------------------
|
||
// Подключение внешних библиотек
|
||
//------------------------------
|
||
|
||
const xml2js = require("xml2js"); //Конвертация XML в JSON и JSON в XML
|
||
const XLSX = require("./shp_utils/node_modules/xlsx"); //Парсер табличных данных
|
||
|
||
//---------------------
|
||
// Глобальные константы
|
||
//---------------------
|
||
|
||
//Количество строк, загружаемых за одно обращение к серверу
|
||
const NIMPORT_BUFF_LEN = 500;
|
||
|
||
//Форматы файлов данных */
|
||
const NSHPFL_FORMAT_XLS = 0; //MS Excel (xls, xlsx)
|
||
const NSHPFL_FORMAT_ODS = 1; //OpenDocument Spreadsheet (ods)
|
||
const NSHPFL_FORMAT_CSV = 2; //Comma-Separated Values (csv)
|
||
const NSHPFL_FORMAT_DBF = 3; //Database File (dbf)
|
||
|
||
//Кодировки файлов данных */
|
||
const NSHPFL_ENC_DEFAULT = 0; //По умолчанию
|
||
const NSHPFL_ENC_WIN1251 = 1; //Windows-1251
|
||
const NSHPFL_ENC_UTF8 = 2; //UTF-8
|
||
|
||
//------------------------
|
||
// Вспомогательные функции
|
||
//------------------------
|
||
|
||
//Разбор XML (обёртка для async/await)
|
||
const parseXML = ({ sXML, options }) => {
|
||
//Возвращаем промис для использования async/await
|
||
return new Promise((resolve, reject) => {
|
||
//Разбираем XML
|
||
xml2js.parseString(sXML, options, (err, result) => {
|
||
//Если при разборе возникла ошибка
|
||
if (err) reject(err);
|
||
//Если всё успешно - возвращаем результат
|
||
else resolve(result);
|
||
});
|
||
});
|
||
};
|
||
|
||
//Конвертация в XML
|
||
const toXML = ({ rootName, data, headless = false }) => {
|
||
//Формируем XML
|
||
const builder = new xml2js.Builder({ rootName, headless });
|
||
return builder.buildObject(data);
|
||
};
|
||
|
||
//Получение текстового представления ячейки
|
||
const getCellText = cl => {
|
||
//Если ячейки нет - возвращаем пустую строку
|
||
if (!cl) return "";
|
||
//Если есть значение v - берём его
|
||
if (cl.v !== null && cl.v !== undefined) return String(cl.v);
|
||
//Если есть форматированное значение w - берём его
|
||
if (cl.w !== null && cl.w !== undefined) return String(cl.w);
|
||
//Если значений нет - возвращаем пустую строку
|
||
return "";
|
||
};
|
||
|
||
//Построение индекса колонок по заголовкам (имя заголовка -> номер колонки)
|
||
const buildHeadersIndex = ({ sheet, sheetData, nHeaderLineNo }) => {
|
||
//Если листа нет - индекса заголовков нет
|
||
if (!sheet) return null;
|
||
//Если данных листа нет - индекса заголовков нет
|
||
if (!Array.isArray(sheetData) || sheetData.length === 0) return null;
|
||
//Номер строки заголовков должен быть валидным (1..)
|
||
const nHdrLine = Number.isFinite(nHeaderLineNo) && nHeaderLineNo > 0 ? nHeaderLineNo : null;
|
||
//Если номер строки заголовков невалиден - индекса заголовков нет
|
||
if (!nHdrLine) return null;
|
||
//Строка заголовков
|
||
const headerRow = sheetData[nHdrLine - 1] || [];
|
||
//Если строки заголовков нет - индекса заголовков нет
|
||
if (!Array.isArray(headerRow) || headerRow.length === 0) return null;
|
||
//Создаём резолвер объединённых ячеек
|
||
const { resolveCell } = createMergedCellResolver(sheet);
|
||
//Результат индекса
|
||
const idx = new Map();
|
||
//Обходим все колонки строки заголовков
|
||
for (let c = 0; c < headerRow.length; c++) {
|
||
//Номер колонки
|
||
const colNo = c + 1;
|
||
//Резолвим ячейку заголовка
|
||
const cl = resolveCell(nHdrLine - 1, c, (headerRow || [])[c]);
|
||
//Текст заголовка
|
||
const sName = getCellText(cl).trim();
|
||
//Пустые заголовки не индексируем
|
||
if (!sName) continue;
|
||
//Ключ заголовка
|
||
const key = sName.toUpperCase();
|
||
//Если такой заголовок уже есть - не перезаписываем
|
||
if (idx.has(key)) continue;
|
||
//Сохраняем номер колонки
|
||
idx.set(key, colNo);
|
||
}
|
||
//Если индекс пустой - возвращаем null
|
||
if (idx.size === 0) return null;
|
||
//Возвращаем индекс
|
||
return idx;
|
||
};
|
||
|
||
//Формирование XML заголовков таблицы
|
||
const buildTableHeadersXML = ({ workbook, sSheetName, aColumns, nColumnsCount, nHeaderLineNo }) => {
|
||
//Если книги нет - заголовки построить нельзя
|
||
if (!workbook) return null;
|
||
//Если имя листа не задано - заголовки построить нельзя
|
||
if (!sSheetName) return null;
|
||
//Получаем лист
|
||
const sheet = workbook.Sheets?.[sSheetName] || null;
|
||
//Если листа нет - заголовки построить нельзя
|
||
if (!sheet) return null;
|
||
//Dense-данные листа (строки/ячейки)
|
||
const sheetData = sheet?.["!data"] || [];
|
||
//Если данных нет - заголовки построить нельзя
|
||
if (!Array.isArray(sheetData) || sheetData.length === 0) return null;
|
||
//Номер строки заголовков должен быть валидным (1..)
|
||
const nHdrLine = Number.isFinite(nHeaderLineNo) && nHeaderLineNo > 0 ? nHeaderLineNo : 1;
|
||
//Получаем строку заголовков
|
||
const headerRow = sheetData[nHdrLine - 1] || [];
|
||
//Создаём резолвер объединённых ячеек
|
||
const { hasMerges, resolveCell } = createMergedCellResolver(sheet);
|
||
//Нормализуем количество колонок таблицы
|
||
const nCols = Number.isFinite(nColumnsCount) && nColumnsCount > 0 ? nColumnsCount : 0;
|
||
//Если колонок нет - формируем пустой RHEADERS
|
||
if (!nCols) return toXML({ rootName: "RHEADERS", data: {}, headless: true });
|
||
//Список заголовков (RHEADER)
|
||
const rHeaders = [];
|
||
//Если задан список колонок
|
||
if (Array.isArray(aColumns) && aColumns.length > 0) {
|
||
//Обходим все выбранные колонки в заданном порядке
|
||
for (let i = 0; i < aColumns.length; i++) {
|
||
//Номер исходной колонки
|
||
const colNo = aColumns[i];
|
||
//Если номер колонки невалиден - пропускаем
|
||
if (!Number.isFinite(colNo) || colNo < 1) continue;
|
||
//Резолвим ячейку заголовка
|
||
const cl = resolveCell(nHdrLine - 1, colNo - 1, (headerRow || [])[colNo - 1]);
|
||
//Текст заголовка
|
||
const sName = getCellText(cl).trim();
|
||
//Добавляем RHEADER
|
||
rHeaders.push({ NCOLUMN: colNo, SCOLUMN: sName });
|
||
}
|
||
}
|
||
//Иначе - заголовки строим по фактическому набору колонок (1..nCols)
|
||
else {
|
||
//Определяем количество колонок в строке заголовков
|
||
const nHeaderRowCols = hasMerges ? nCols : (headerRow || []).length;
|
||
const nIter = Math.max(nCols, nHeaderRowCols, 0);
|
||
//Обходим колонки 1..nIter
|
||
for (let c = 0; c < nIter; c++) {
|
||
//Номер колонки
|
||
const colNo = c + 1;
|
||
//Резолвим ячейку заголовка
|
||
const cl = resolveCell(nHdrLine - 1, c, (headerRow || [])[c]);
|
||
//Текст заголовка
|
||
const sName = getCellText(cl).trim();
|
||
//Добавляем RHEADER
|
||
rHeaders.push({ NCOLUMN: colNo, SCOLUMN: sName });
|
||
}
|
||
}
|
||
//Собираем XML заголовков
|
||
return toXML({
|
||
rootName: "RHEADERS",
|
||
data: {
|
||
...(rHeaders.length > 0 ? { RHEADER: rHeaders } : {})
|
||
},
|
||
headless: true
|
||
});
|
||
};
|
||
|
||
//Преобразование "одиночное значение | массив | undefined" -> массив
|
||
const toArray = value => {
|
||
//Если значения нет - возвращаем пустой массив
|
||
if (!value) return [];
|
||
//Если уже массив - возвращаем как есть, иначе оборачиваем
|
||
return Array.isArray(value) ? value : [value];
|
||
};
|
||
|
||
//Разбор параметров выполнения SHP (clOptions: XML -> JSON)
|
||
const parseShpOptionsXML = async sOptionsXML => {
|
||
//Если параметр отсутствует или не строка - параметров выполнения нет
|
||
if (!sOptionsXML || !(sOptionsXML instanceof String || typeof sOptionsXML === "string")) return null;
|
||
//Если строка пустая - параметров выполнения нет
|
||
if (!String(sOptionsXML).trim()) return null;
|
||
//Результат парсинга XML
|
||
let parseRes = null;
|
||
try {
|
||
//Парсим XML
|
||
parseRes = await parseXML({
|
||
sXML: sOptionsXML,
|
||
options: {
|
||
explicitArray: false,
|
||
mergeAttrs: true,
|
||
valueProcessors: [xml2js.processors.parseNumbers, xml2js.processors.parseBooleans]
|
||
}
|
||
});
|
||
} catch (e) {
|
||
//Ошибка разбора параметров выполнения
|
||
throw new Error(`Ошибка разбора XML параметров выполнения: ${e.message || e}`);
|
||
}
|
||
//Корневой элемент параметров выполнения
|
||
const root = parseRes?.OPTIONS || null;
|
||
//Если корня нет - параметров выполнения нет
|
||
if (!root) return null;
|
||
//OPTION может быть одиночным объектом или массивом
|
||
const options = toArray(root.OPTION).filter(Boolean);
|
||
//Возвращаем нормализованный объект
|
||
return { options };
|
||
};
|
||
|
||
//Преобразование номера/имени колонки в число (A->1, B->2, AA->27, ...)
|
||
const normalizeColumn = value => {
|
||
//Если значение отсутствует - возвращаем null
|
||
if (value === null || value === undefined) return null;
|
||
//Приводим к строке и убираем пробелы
|
||
const sVal = String(value).trim();
|
||
//Пустое значение - нечего нормализовать
|
||
if (!sVal) return null;
|
||
//Если это число в строковом представлении
|
||
if (/^\d+$/.test(sVal)) {
|
||
//Преобразуем в число
|
||
const n = parseInt(sVal, 10);
|
||
//Колонки начинаются с 1
|
||
return Number.isFinite(n) && n > 0 ? n : null;
|
||
}
|
||
//Приводим буквы к верхнему регистру
|
||
const s = sVal.toUpperCase();
|
||
//Ожидаем только латинские буквы (A..Z)
|
||
if (!/^[A-Z]+$/.test(s)) return null;
|
||
//Результат в десятичном виде
|
||
let n = 0;
|
||
//Переводим base-26 представление в десятичное
|
||
for (let i = 0; i < s.length; i++) {
|
||
//A=1 ... Z=26
|
||
n = n * 26 + (s.charCodeAt(i) - 64);
|
||
}
|
||
//Возвращаем результат, если он валиден
|
||
return n > 0 ? n : null;
|
||
};
|
||
|
||
//Преобразование имени/номера колонки в число с учётом заголовков
|
||
const normalizeColumnWithHeaders = ({ value, headersIndex }) => {
|
||
//Если значение отсутствует - возвращаем null
|
||
if (value === null || value === undefined) return null;
|
||
//Приводим к строке и убираем пробелы
|
||
const sVal = String(value).trim();
|
||
//Пустое значение - нечего нормализовать
|
||
if (!sVal) return null;
|
||
//Если задан индекс заголовков - сначала ищем по нему
|
||
if (headersIndex && headersIndex instanceof Map) {
|
||
//Ключ поиска
|
||
const key = sVal.toUpperCase();
|
||
//Пробуем найти колонку по заголовку
|
||
const nByHdr = headersIndex.get(key);
|
||
//Если нашли валидный номер - возвращаем
|
||
if (Number.isFinite(nByHdr) && nByHdr > 0) return nByHdr;
|
||
}
|
||
//Если по заголовку не нашли - преобразуем имя колонки в число
|
||
return normalizeColumn(sVal);
|
||
};
|
||
|
||
//Формирование списка индексов (1..max) по описанию (RANGE/ENUM/COUNT)
|
||
const buildIndexList = ({ selector, nMax, normalizeItem, getDefaultFrom }) => {
|
||
//Нормализуем максимум
|
||
const nMaxSafe = Number.isFinite(nMax) && nMax > 0 ? nMax : 0;
|
||
//Если селектора нет или максимум не задан - считаем что фильтра нет
|
||
if (!selector || !nMaxSafe) return null;
|
||
//Тип выборки (RANGE/ENUM/COUNT)
|
||
const sType = String(selector.SADDRESS_TYPE || "")
|
||
.trim()
|
||
.toUpperCase();
|
||
//Если тип не задан - считаем что фильтра нет
|
||
if (!sType) return null;
|
||
//Добавление уникальных значений в множество
|
||
const pushUnique = (set, value) => {
|
||
//Должно быть число
|
||
if (!Number.isFinite(value)) return;
|
||
//Должно попадать в диапазон 1..nMaxSafe
|
||
if (value < 1 || value > nMaxSafe) return;
|
||
//Добавляем уникально
|
||
set.add(value);
|
||
};
|
||
//Множество для уникальных значений
|
||
const res = new Set();
|
||
//Адрес-диапазон
|
||
if (sType === "RANGE") {
|
||
//Узел диапазона
|
||
const range = selector.RADDRESS_RANGE || null;
|
||
//Начало диапазона обязательно
|
||
const nFrom = normalizeItem?.(range?.SVALUE_FROM);
|
||
//Если начала нет - фильтр считаем некорректным
|
||
if (!Number.isFinite(nFrom) || nFrom < 1) return null;
|
||
//Конец диапазона опционален
|
||
let nTo = normalizeItem?.(range?.SVALUE_TO);
|
||
//Если конец не указан - берём до конца
|
||
if (!Number.isFinite(nTo) || nTo < nFrom) nTo = nMaxSafe;
|
||
//Заполняем диапазон
|
||
for (let i = nFrom; i <= Math.min(nTo, nMaxSafe); i++) pushUnique(res, i);
|
||
//Возвращаем отсортированный список
|
||
return Array.from(res).sort((a, b) => a - b);
|
||
}
|
||
//Адрес-перечисление
|
||
if (sType === "ENUM") {
|
||
//Узел перечисления
|
||
const en = selector.RADDRESS_ENUM || null;
|
||
//Значение может быть одиночным значением или массивом
|
||
for (const v of toArray(en?.SVALUE)) pushUnique(res, normalizeItem?.(v));
|
||
//Возвращаем отсортированный список
|
||
return Array.from(res).sort((a, b) => a - b);
|
||
}
|
||
//Адрес-количество
|
||
if (sType === "COUNT") {
|
||
//Узел количества
|
||
const cnt = selector.RADDRESS_COUNT || null;
|
||
//Количество берём из SVALUE
|
||
const nCount = parseInt(String(cnt?.SVALUE ?? "").trim(), 10);
|
||
//Количество должно быть > 0
|
||
if (!Number.isFinite(nCount) || nCount <= 0) return null;
|
||
//Признак, что старт задан явно
|
||
const bHasFrom = cnt?.SVALUE_FROM !== null && cnt?.SVALUE_FROM !== undefined && String(cnt?.SVALUE_FROM).trim() !== "";
|
||
//Старт опционален (если не задан - берём вычисляемое значение, иначе 1)
|
||
const nFromRaw = bHasFrom ? normalizeItem?.(cnt?.SVALUE_FROM) : (getDefaultFrom?.() ?? 1);
|
||
//Если старт некорректен - начинаем с 1
|
||
const nFromSafe = Number.isFinite(nFromRaw) && nFromRaw > 0 ? nFromRaw : 1;
|
||
//Добавляем нужное количество индексов
|
||
for (let i = nFromSafe; i < nFromSafe + nCount; i++) pushUnique(res, i);
|
||
//Возвращаем отсортированный список
|
||
return Array.from(res).sort((a, b) => a - b);
|
||
}
|
||
//Неизвестный тип
|
||
return null;
|
||
};
|
||
|
||
//Формирование списка всех индексов (1..nMax)
|
||
const buildAllIndexList = ({ nMax }) => {
|
||
//Нормализуем максимум
|
||
const nMaxSafe = Number.isFinite(nMax) && nMax > 0 ? nMax : 0;
|
||
//Если максимум некорректен - возвращаем пустой список
|
||
if (!nMaxSafe) return [];
|
||
//Создаём массив нужной длины
|
||
const res = new Array(nMaxSafe);
|
||
//Заполняем индексами
|
||
for (let i = 0; i < nMaxSafe; i++) res[i] = i + 1;
|
||
//Возвращаем результат
|
||
return res;
|
||
};
|
||
|
||
//Проверка наличия данных в листе
|
||
const isSheetNotEmpty = ({ workbook, sSheetName }) =>
|
||
//Лист не пустой, если есть dense-данные и они содержат хотя бы одну строку
|
||
Array.isArray(workbook?.Sheets[sSheetName]?.["!data"]) && workbook?.Sheets[sSheetName]?.["!data"].length > 0;
|
||
|
||
//Подсчёт предельного количества столбцов в листе
|
||
const getSheetColumnsCount = ({ workbook, sSheetName }) => {
|
||
//Получаем dense-данные листа
|
||
const rows = workbook?.Sheets?.[sSheetName]?.["!data"];
|
||
//Если данных нет - возвращаем 0
|
||
if (!Array.isArray(rows) || rows.length === 0) return 0;
|
||
//Возвращаем максимальную длину строки (количество колонок)
|
||
return rows.reduce((cnt, curRow) => Math.max(cnt, (curRow || []).length), 0);
|
||
};
|
||
|
||
//Проверка, что ячейка содержит данные (v/w)
|
||
const isCellHasData = cl => {
|
||
//Если ячейки нет - данных нет
|
||
if (!cl) return false;
|
||
//Если есть значение v - проверяем его на пустоту
|
||
if (cl.v !== null && cl.v !== undefined && String(cl.v) !== "") return true;
|
||
//Если есть текстовое значение w - проверяем его на пустоту
|
||
if (cl.w !== null && cl.w !== undefined && String(cl.w) !== "") return true;
|
||
//Данных нет
|
||
return false;
|
||
};
|
||
|
||
//Поиск первой строки, где есть данные
|
||
const getFirstDataLineNo = ({ sheetData }) => {
|
||
//Если данных листа нет - возвращаем 1
|
||
if (!Array.isArray(sheetData) || sheetData.length === 0) return 1;
|
||
//Обходим строки сверху вниз
|
||
for (let r = 0; r < sheetData.length; r++) {
|
||
//Строка листа (массив ячеек)
|
||
const row = sheetData[r] || [];
|
||
//Обходим ячейки строки
|
||
for (let c = 0; c < row.length; c++) {
|
||
//Если нашли данные - возвращаем номер строки
|
||
if (isCellHasData(row[c])) return r + 1;
|
||
}
|
||
}
|
||
//Если данных не нашли - возвращаем 1
|
||
return 1;
|
||
};
|
||
|
||
//Поиск первой колонки, где есть данные
|
||
const getFirstDataColumnNo = ({ sheetData }) => {
|
||
//Если данных листа нет - возвращаем 1
|
||
if (!Array.isArray(sheetData) || sheetData.length === 0) return 1;
|
||
//Минимальный индекс колонки с данными
|
||
let minCol = null;
|
||
//Обходим строки
|
||
for (let r = 0; r < sheetData.length; r++) {
|
||
//Строка листа (массив ячеек)
|
||
const row = sheetData[r] || [];
|
||
//Обходим ячейки строки
|
||
for (let c = 0; c < row.length; c++) {
|
||
//Если данных нет - пропускаем
|
||
if (!isCellHasData(row[c])) continue;
|
||
//Фиксируем минимальную колонку с данными
|
||
minCol = minCol === null ? c : Math.min(minCol, c);
|
||
}
|
||
}
|
||
//Если данных не нашли - возвращаем 1
|
||
if (minCol === null) return 1;
|
||
//Возвращаем номер колонки
|
||
return minCol + 1;
|
||
};
|
||
|
||
//Построение таблиц для импорта по параметрам выполнения
|
||
const buildDataTablesByOptions = ({ workbook, nShpFileRn, options, sDataSheetName, bIgnoreSheetName, bHasHeaders }) => {
|
||
//Результирующий список таблиц
|
||
const res = [];
|
||
//Обходим все OPTION
|
||
for (const opt of toArray(options)) {
|
||
//Имя листа, к которому относится параметр
|
||
const sSheetName = String(opt?.SSHEET || "").trim();
|
||
//Если имя листа не задано - пропускаем
|
||
if (!sSheetName) continue;
|
||
//Если лист пустой - пропускаем
|
||
if (!isSheetNotEmpty({ workbook, sSheetName: bIgnoreSheetName ? sDataSheetName : sSheetName })) continue;
|
||
//Список таблиц для данного параметра
|
||
const rTables = toArray(opt?.RTABLES?.RTABLE);
|
||
//Если таблицы не указаны - пропускаем
|
||
if (rTables.length === 0) continue;
|
||
//Фактическое количество колонок листа
|
||
const nColumnsCountRaw = getSheetColumnsCount({ workbook, sSheetName: bIgnoreSheetName ? sDataSheetName : sSheetName });
|
||
//Фактическое количество строк листа
|
||
const nLinesCountRaw = (workbook.Sheets[bIgnoreSheetName ? sDataSheetName : sSheetName]["!data"] || []).length;
|
||
//Обходим описанные таблицы
|
||
for (const rt of rTables) {
|
||
//Имя таблицы
|
||
const sTableName = rt?.SNAME ? String(rt.SNAME).trim() : null;
|
||
//Лист, откуда берём данные
|
||
const sheet = workbook?.Sheets?.[bIgnoreSheetName ? sDataSheetName : sSheetName] || null;
|
||
//Dense-данные листа
|
||
const sheetData = sheet?.["!data"] || [];
|
||
//Номер строки заголовков
|
||
const nHeaderLineNo = bHasHeaders ? getFirstDataLineNo({ sheetData }) : null;
|
||
//Индекс заголовков
|
||
const headersIndex = bHasHeaders ? buildHeadersIndex({ sheet, sheetData, nHeaderLineNo }) : null;
|
||
//Формируем список колонок по описателю
|
||
const aCols = buildIndexList({
|
||
selector: rt?.RCOLUMNS || null,
|
||
nMax: nColumnsCountRaw,
|
||
normalizeItem: v => normalizeColumnWithHeaders({ value: v, headersIndex }),
|
||
getDefaultFrom: () => getFirstDataColumnNo({ sheetData })
|
||
});
|
||
//Формируем список строк по описателю
|
||
const aLines = buildIndexList({
|
||
selector: rt?.RLINES || null,
|
||
nMax: nLinesCountRaw,
|
||
normalizeItem: v => {
|
||
//Если значения нет - не нормализуем
|
||
if (v === null || v === undefined) return null;
|
||
//Парсим строку как число
|
||
const n = parseInt(String(v).trim(), 10);
|
||
//Индекс строки должен быть положительным
|
||
return Number.isFinite(n) && n > 0 ? n : null;
|
||
},
|
||
getDefaultFrom: () => getFirstDataLineNo({ sheetData })
|
||
});
|
||
//Добавляем описание таблицы в результат
|
||
res.push({
|
||
sSheetName,
|
||
sDataSheetName: bIgnoreSheetName ? sDataSheetName : sSheetName,
|
||
sTableName,
|
||
nShpFileRn,
|
||
aColumns: aCols,
|
||
aLines
|
||
});
|
||
}
|
||
}
|
||
//Возвращаем список таблиц
|
||
return res;
|
||
};
|
||
|
||
//Преобразование значения ячейки в формат импорта
|
||
const makeImportCell = ({ cl, n }) => {
|
||
//Нормализуем номер колонки (1..)
|
||
const nCol = Number.isFinite(n) && n > 0 ? n : null;
|
||
//Если ячейки нет - пишем пустую ячейку
|
||
if (!cl) return { $: { t: "z", n: nCol }, v: null };
|
||
//Тип ячейки
|
||
const t = cl.t || "z";
|
||
//Значение ячейки (даты приводим к ISO-строке)
|
||
const v = (t === "d" ? cl.v?.toISOString?.() : cl.v) || cl.w || null;
|
||
//Возвращаем формат ячейки для импорта
|
||
return { $: { t, n: nCol }, v };
|
||
};
|
||
|
||
//Резолвер значений для объединённых ячеек
|
||
const createMergedCellResolver = sheet => {
|
||
//Считываем описания объединений ячеек (если они есть)
|
||
const merges = Array.isArray(sheet?.["!merges"]) ? sheet["!merges"] : [];
|
||
//Если объединений нет - возвращаем быстрый резолвер без дополнительной логики
|
||
if (merges.length === 0) {
|
||
return {
|
||
hasMerges: false,
|
||
resolveCell: (_r, _c, cell) => cell || null
|
||
};
|
||
}
|
||
//Создаём индекс объединений по строкам
|
||
const byRow = new Map();
|
||
//Обходим все описания объединений
|
||
for (const m of merges) {
|
||
//Начальная координата диапазона объединения
|
||
const s = m?.s;
|
||
//Конечная координата диапазона объединения
|
||
const e = m?.e;
|
||
//Если координат нет - пропускаем элемент
|
||
if (!s || !e) continue;
|
||
//Нормализуем начало по строкам
|
||
const rFrom = Math.min(s.r ?? 0, e.r ?? 0);
|
||
//Нормализуем конец по строкам
|
||
const rTo = Math.max(s.r ?? 0, e.r ?? 0);
|
||
//Нормализуем начало по колонкам
|
||
const cFrom = Math.min(s.c ?? 0, e.c ?? 0);
|
||
//Нормализуем конец по колонкам
|
||
const cTo = Math.max(s.c ?? 0, e.c ?? 0);
|
||
//Проверяем валидность нормализованных координат
|
||
const bOk = Number.isFinite(rFrom) && Number.isFinite(rTo) && Number.isFinite(cFrom) && Number.isFinite(cTo);
|
||
//Если координаты невалидны - пропускаем объединение
|
||
if (!bOk) continue;
|
||
//Индексируем объединение по всем строкам диапазона
|
||
for (let r = rFrom; r <= rTo; r++) {
|
||
//Текущий список объединений по строке (если уже есть)
|
||
const arr = byRow.get(r);
|
||
//Сохраняем диапазон и “ведущую” ячейку
|
||
const item = { rFrom, rTo, cFrom, cTo, rLead: rFrom, cLead: cFrom };
|
||
//Если массив по строке уже существует - добавляем
|
||
if (arr) arr.push(item);
|
||
//Иначе создаём новый список по строке
|
||
else byRow.set(r, [item]);
|
||
}
|
||
}
|
||
//Получаем dense-данные листа (для доступа к “ведущей” ячейке)
|
||
const data = sheet?.["!data"] || [];
|
||
//Возвращаем резолвер с поддержкой объединений
|
||
return {
|
||
hasMerges: true,
|
||
resolveCell: (r, c, cell) => {
|
||
//Если ячейка уже есть - возвращаем её без изменений
|
||
if (cell) return cell;
|
||
//Получаем список объединений для текущей строки
|
||
const rowMerges = byRow.get(r);
|
||
//Если объединений в этой строке нет - возвращаем null
|
||
if (!rowMerges || rowMerges.length === 0) return null;
|
||
//Проверяем попадание колонки в один из диапазонов объединений
|
||
for (const m of rowMerges) {
|
||
//Если колонка вне диапазона - проверяем следующее объединение
|
||
if (c < m.cFrom || c > m.cTo) continue;
|
||
//Возвращаем “ведущую” ячейку диапазона
|
||
return (data[m.rLead] || [])[m.cLead] || null;
|
||
}
|
||
//Если не попали ни в одно объединение - возвращаем null
|
||
return null;
|
||
}
|
||
};
|
||
};
|
||
|
||
//------------
|
||
// Тело модуля
|
||
//------------
|
||
|
||
//Обработчик "До"
|
||
const importBefore = async ({ queue, dbConn }) => {
|
||
//Если задано тело сообщения
|
||
if (queue.blMsg) {
|
||
//В теле должен быть рег. номер файла данных (EXSEXTSHPFL)
|
||
const nShpFileRn = parseInt(queue.blMsg.toString(), 10);
|
||
//Если там рег. номер (число)
|
||
if (!isNaN(nShpFileRn)) {
|
||
//Читаем по нему запись файла данных
|
||
const shpFileData = await dbConn.executeStored({
|
||
connection: dbConn.connection,
|
||
sName: "PKG_EXS_EXT_SHP.FILE_GET",
|
||
inPrms: { NEXSEXTSHPFL: nShpFileRn },
|
||
outPrms: { RFILE: dbConn.connector.DT_CURSOR }
|
||
});
|
||
//Если запись найдена
|
||
if (shpFileData.RFILE.length === 1) {
|
||
//Разбираем её
|
||
try {
|
||
//Признак наличия заголовков
|
||
const bHasHeaders = String(shpFileData.RFILE[0]?.nHasHeaders ?? "0").trim() === "1";
|
||
//Формат файла данных
|
||
const nFileFormat = shpFileData.RFILE[0]?.nFormat;
|
||
//Заголовки формируем только для CSV/DBF и только если они есть
|
||
const bNeedSendHeaders = bHasHeaders && [NSHPFL_FORMAT_CSV, NSHPFL_FORMAT_DBF].includes(nFileFormat);
|
||
//Объект рабочей книги
|
||
let workbook = null;
|
||
try {
|
||
//Кодировка файла данных
|
||
const nEnc = shpFileData.RFILE[0].nEnc;
|
||
//Кодовая страница (параметры чтения XLSX)
|
||
const cp = {};
|
||
//Байты файла данных
|
||
let blData = shpFileData.RFILE[0].blFileData;
|
||
//Если это CSV
|
||
if (nFileFormat === NSHPFL_FORMAT_CSV) {
|
||
//Если указана Win-1251 - перекодируем CSV в UTF-8
|
||
if (nEnc === NSHPFL_ENC_WIN1251) {
|
||
//Декодируем исходные байты как windows-1251
|
||
const s = new TextDecoder("windows-1251").decode(blData);
|
||
//Преобразуем строку обратно в байты UTF-8
|
||
blData = Buffer.from(s);
|
||
}
|
||
//Задаём codepage UTF-8
|
||
cp.codepage = 65001;
|
||
}
|
||
//Если это DBF
|
||
else if (nFileFormat === NSHPFL_FORMAT_DBF) {
|
||
//Если указана Win-1251
|
||
if (nEnc === NSHPFL_ENC_WIN1251)
|
||
//Задаём codepage 1251
|
||
cp.codepage = 1251;
|
||
else
|
||
//Задаём codepage UTF-8
|
||
cp.codepage = 65001;
|
||
}
|
||
//Выполняем парсинг
|
||
workbook = XLSX.read(blData, { dense: true, cellDates: true, ...cp });
|
||
} catch (e) {
|
||
//Ошибка чтения/парсинга файла данных
|
||
throw new Error(`Ошибка разбора файла данных: ${e.message}`);
|
||
}
|
||
//Если есть сведения о листахх
|
||
if (workbook && workbook?.SheetNames?.length > 0) {
|
||
//Признак формата, где листов фактически нет (csv/dbf) или их не нужно учитывать
|
||
const bIgnoreSheetName = ![NSHPFL_FORMAT_XLS, NSHPFL_FORMAT_ODS].includes(nFileFormat);
|
||
//Имя листа с данными (для csv/dbf используем первый лист книги)
|
||
const sDataSheetName = workbook.SheetNames[0];
|
||
//Разбор параметров выполнения (если есть)
|
||
const shpOptions = await parseShpOptionsXML(shpFileData.RFILE[0]?.clOptions);
|
||
//Определим буфер для хранения таблиц файла данных
|
||
const dataTables = [];
|
||
//Формирование списка таблиц для импорта
|
||
const tablesSpec = shpOptions
|
||
? buildDataTablesByOptions({
|
||
workbook,
|
||
nShpFileRn,
|
||
options: shpOptions.options,
|
||
sDataSheetName,
|
||
bIgnoreSheetName,
|
||
bHasHeaders
|
||
})
|
||
: (bIgnoreSheetName
|
||
? [sDataSheetName]
|
||
: workbook.SheetNames.filter(sSheetName => isSheetNotEmpty({ workbook, sSheetName }))
|
||
).map(sSheetName => ({
|
||
sSheetName,
|
||
sDataSheetName: bIgnoreSheetName ? sDataSheetName : sSheetName,
|
||
sTableName: null,
|
||
nShpFileRn,
|
||
aColumns: null,
|
||
aLines: null
|
||
}));
|
||
//Регистрируем таблицы на сервере
|
||
for (const tbl of tablesSpec) {
|
||
//Считаем фактическое количество колонок листа
|
||
const nColumnsCountRaw = getSheetColumnsCount({ workbook, sSheetName: tbl.sDataSheetName || tbl.sSheetName });
|
||
//Считаем фактическое количество строк листа
|
||
const nLinesCountRaw = (workbook.Sheets[tbl.sDataSheetName || tbl.sSheetName]["!data"] || []).length;
|
||
//Список колонок (или null - значит все)
|
||
const aColumns = Array.isArray(tbl.aColumns) ? tbl.aColumns : null;
|
||
//Список строк (или null - значит все)
|
||
const aLines = Array.isArray(tbl.aLines) ? tbl.aLines : null;
|
||
//Количество колонок для описания таблицы
|
||
const nColumnsCount = aColumns ? aColumns.length : nColumnsCountRaw;
|
||
//Определяем лист, с которого читаем данные
|
||
const sDataSheetName = tbl.sDataSheetName || tbl.sSheetName;
|
||
//Dense-данные листа
|
||
const sheetData = workbook?.Sheets?.[sDataSheetName]?.["!data"] || [];
|
||
//Номер строки заголовков (если заголовки нужны - берём первую строку с данными, иначе null)
|
||
const nHeaderLineNo = bNeedSendHeaders ? getFirstDataLineNo({ sheetData }) : null;
|
||
//Количество строк для описания таблицы
|
||
const nLinesCount = (() => {
|
||
//Если заголовков нет - количество строк
|
||
const nRaw = aLines ? aLines.length : nLinesCountRaw;
|
||
//Если заголовки не требуются - возвращаем как есть
|
||
if (!bNeedSendHeaders) return nRaw;
|
||
//Если строк нет - нечего исключать
|
||
if (!Number.isFinite(nRaw) || nRaw <= 0) return nRaw;
|
||
//Если номер заголовка невалиден
|
||
if (!Number.isFinite(nHeaderLineNo) || nHeaderLineNo <= 0) return nRaw;
|
||
//Если выбран конкретный список строк - исключаем заголовок только если он туда попал
|
||
if (aLines) return aLines.includes(nHeaderLineNo) ? Math.max(nRaw - 1, 0) : nRaw;
|
||
//Если строки не фильтруются - заголовок считаем частью данных листа и исключаем его
|
||
return nHeaderLineNo <= nLinesCountRaw ? Math.max(nRaw - 1, 0) : nRaw;
|
||
})();
|
||
//Формируем XML заголовков (только если требуется отправка заголовков)
|
||
const sHeadersXML = bNeedSendHeaders
|
||
? buildTableHeadersXML({
|
||
workbook,
|
||
sSheetName: sDataSheetName,
|
||
aColumns,
|
||
nColumnsCount,
|
||
nHeaderLineNo
|
||
})
|
||
: null;
|
||
try {
|
||
//Создаём таблицу в БД
|
||
const shpFileDataTable = await dbConn.executeStored({
|
||
connection: dbConn.connection,
|
||
sName: "PKG_EXS_EXT_SHP.FILE_TABLE_ADD",
|
||
inPrms: {
|
||
NEXSEXTSHPFL: nShpFileRn,
|
||
SSHEET: tbl.sSheetName,
|
||
STBL: tbl.sTableName,
|
||
NCOLS: nColumnsCount,
|
||
...(sHeadersXML ? { CHEADERS: sHeadersXML } : {}),
|
||
NLINES: nLinesCount
|
||
},
|
||
outPrms: { NEXSEXTSHPFLTB: dbConn.connector.DT_NUMBER }
|
||
});
|
||
//Сохраняем данные таблицы для дальнейшей загрузки строк
|
||
dataTables.push({
|
||
sSheetName: tbl.sSheetName,
|
||
sDataSheetName: tbl.sDataSheetName || tbl.sSheetName,
|
||
sTableName: tbl.sTableName,
|
||
nTableRn: shpFileDataTable.NEXSEXTSHPFLTB,
|
||
nColumnsCount,
|
||
nLinesCount,
|
||
aColumns,
|
||
aLines,
|
||
nHeaderLineNo
|
||
});
|
||
} catch (e) {
|
||
//Ошибка регистрации таблицы
|
||
throw new Error(
|
||
`Ошибка сохранения описания таблицы "${tbl.sTableName || "<НЕ УКАЗАНА>"}" листа "${tbl.sSheetName || "<НЕ УКАЗАН>"}": ${e.message}`
|
||
);
|
||
}
|
||
}
|
||
//Теперь загружаем таблицы - обходим их последовательно
|
||
for (const dataTable of dataTables) {
|
||
//Буфер строк для порционной загрузки
|
||
let impBuff = [];
|
||
//Сброс буфера
|
||
const flushBuff = async () => {
|
||
//Если буфер пуст - нечего сохранять
|
||
if (impBuff.length === 0) return;
|
||
//Преобразуем буфер в XML
|
||
let sXML = null;
|
||
try {
|
||
//Сборка XML для порции строк
|
||
sXML = toXML({ rootName: "lines", data: impBuff });
|
||
} catch (e) {
|
||
//Ошибка сборки XML
|
||
throw new Error(`Ошибка преобразования буфера в XML: ${e.message}.`);
|
||
}
|
||
//Сохраняем на сервере
|
||
const nLineFrom = impBuff[0].line.$.n;
|
||
const nLineTo = impBuff[impBuff.length - 1].line.$.n;
|
||
try {
|
||
//Сохраняем порцию строк в БД
|
||
await dbConn.executeStored({
|
||
connection: dbConn.connection,
|
||
sName: "PKG_EXS_EXT_SHP.FILE_TABLE_LINE_ADD",
|
||
inPrms: {
|
||
NEXSEXTSHPFLTB: dataTable.nTableRn,
|
||
NLN_FROM: nLineFrom,
|
||
NLN_TO: nLineTo,
|
||
BDT: Buffer.from(sXML)
|
||
}
|
||
});
|
||
} catch (e) {
|
||
//Ошибка сохранения порции строк
|
||
throw new Error(
|
||
`Ошибка сохранения строк (c ${nLineFrom} - по ${nLineTo}) таблицы "${dataTable.sTableName || "<НЕ УКАЗАНА>"}" листа "${dataTable.sSheetName || "<НЕ УКАЗАН>"}": ${e.message}`
|
||
);
|
||
}
|
||
//Очищаем буфер после успешной отправки
|
||
impBuff = [];
|
||
};
|
||
//Получаем объект листа
|
||
const sheet = workbook.Sheets[dataTable.sDataSheetName || dataTable.sSheetName];
|
||
//Получаем dense-данные листа (строки/ячейки)
|
||
const sheetData = sheet?.["!data"] || [];
|
||
//Создаём резолвер объединённых ячеек
|
||
const { hasMerges, resolveCell } = createMergedCellResolver(sheet);
|
||
//Формируем список строк (или берём все)
|
||
const aLines = dataTable.aLines ? dataTable.aLines : buildAllIndexList({ nMax: sheetData.length });
|
||
//Список колонок (или null - значит все)
|
||
const aColumns = dataTable.aColumns;
|
||
//Предыдущая строка для контроля непрерывности (NLN_FROM/NLN_TO)
|
||
let prevLineNo = null;
|
||
for (const lineNo of aLines) {
|
||
//Защита от невалидных индексов
|
||
if (!Number.isFinite(lineNo) || lineNo < 1 || lineNo > sheetData.length) continue;
|
||
//Если это строка заголовков - не отправляем её как данные
|
||
if (Number.isFinite(dataTable.nHeaderLineNo) && lineNo === dataTable.nHeaderLineNo) continue;
|
||
//Если есть разрыв диапазона строк - сбрасываем буфер (важно для NLN_FROM/NLN_TO)
|
||
if (prevLineNo !== null && lineNo !== prevLineNo + 1) await flushBuff();
|
||
//Получаем строку листа
|
||
const sheetLine = sheetData[lineNo - 1] || [];
|
||
//Формируем ячейки строки с учётом фильтра колонок и объединений
|
||
const cells = aColumns
|
||
? aColumns.map(colNo =>
|
||
//Для выбранной колонки получаем ячейку (с учётом объединения) и приводим к формату импорта
|
||
makeImportCell({
|
||
//Резолвим ячейку по исходной колонке
|
||
cl: resolveCell(lineNo - 1, colNo - 1, (sheetLine || [])[colNo - 1]),
|
||
//Пишем номер исходной колонки
|
||
n: colNo
|
||
})
|
||
)
|
||
: (() => {
|
||
//Определяем количество колонок для строки (в режиме объединений берём из описания таблицы)
|
||
const nCols = hasMerges ? dataTable.nColumnsCount : (sheetLine || []).length;
|
||
//Готовим массив ячеек нужной длины
|
||
const res = new Array(nCols);
|
||
//Обходим все колонки строки
|
||
for (let c = 0; c < nCols; c++) {
|
||
//Резолвим ячейку (с учётом объединения) и приводим к формату импорта
|
||
res[c] = makeImportCell({
|
||
//Резолвим ячейку по исходной колонке
|
||
cl: resolveCell(lineNo - 1, c, (sheetLine || [])[c]),
|
||
//Пишем номер исходной колонки
|
||
n: c + 1
|
||
});
|
||
}
|
||
//Возвращаем массив ячеек строки
|
||
return res;
|
||
})();
|
||
//Добавляем строку в буфер импорта
|
||
impBuff.push({
|
||
line: {
|
||
$: { n: lineNo },
|
||
cell: cells
|
||
}
|
||
});
|
||
//Запоминаем предыдущую строку
|
||
prevLineNo = lineNo;
|
||
//Если набрали порцию - отправляем
|
||
if (impBuff.length === NIMPORT_BUFF_LEN) await flushBuff();
|
||
}
|
||
//Сохраняем остаток буфера
|
||
await flushBuff();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
//Любая ошибка импорта
|
||
throw new Error(e.message || "Неожиданная ошибка.");
|
||
}
|
||
}
|
||
//Не нашли запись файла данных
|
||
else throw new Error("Файл данных не определен.");
|
||
}
|
||
//Не смогли считать рег. номер из тела сообщения
|
||
else throw new Error("Некорректный идентификатор файла данных для разбора.");
|
||
}
|
||
//Тело сообщения не задано
|
||
else throw new Error("Нет данных для разбора.");
|
||
//Обработку закончили - продолжать не надо, мы всё сделали в обработчике
|
||
return { bStopPropagation: true };
|
||
};
|
||
|
||
//-----------------
|
||
// Интерфейс модуля
|
||
//-----------------
|
||
|
||
exports.importBefore = importBefore;
|