/* Сервис интеграции ПП Парус 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;