/
diz
/
spec
Обзор
Документация
Войти
/
diz
/
spec
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/spec-fill.js
952 строки
50 KB
Dima
Иерархия предупреждений о «Состав»: №1 нет листа / №2 лист без данных / №3 часть эталонов не найдена — выводится только одно релевантное сообщение (дубли убраны)
10 авг 2026, 20:33
10 авг 2026, 20:33
71d44fe
Код
Авторство
О чём код?
'use strict'; /** * spec-fill.js — заполнение шаблона Excel (Spec.xlsx) данными из DOCX-листов * питателей (TOP.docx / BOT.docx), распарсенных DocxParser'ом. * * Для линий SMT в листах «Сверка TOP» / «Сверка BOT» строятся блоки по * установщикам 1–5 (порядок появления машин в документе) и сторонам: * F → «Лицевая», R → «Тыльная»; трей (T-позиции) объединяется в начало * тыльной стороны 5-го установщика. Каждый блок: * «Линия: SMT линия N (Hanwha) Проект: …» * «Автомат: Hanwha <имя> № автомата в линии: m» * «Сторона установки питателей: <сторона>» * заголовки (A–J) * строки позиций: [C] Эталон, [D] TOP/BOT (перечень артикулов из «Состав»), * [F] Наименование компонента (из «Спецификация»), [G] Кол-во, * [H] «SMT линия N Hanwha <имя>», [I] № автомата, [J] Этап * «Инженер ТКО…» (подпись) * На листе «Спецификация» пересчитывается столбец C («Заказ»). * Оформление, объединения, ширины и формулы исходного файла сохраняются * (ExcelJS + восстановление формул из SheetJS), полный пересчёт на открытии. * * Зависимости передаются явно (deps), чтобы работало и в браузере * (window.ExcelJS, window.XLSX, window.JSZip, window.DocxParser), и в node. */ (function (root, factory) { 'use strict'; if (typeof module !== 'undefined' && module.exports) { module.exports = factory(); } else if (typeof window !== 'undefined') { window.SpecFill = factory(); } })(this, function () { 'use strict'; const SIDE_LABELS = { F: 'Лицевая', R: 'Тыльная' }; const HEADERS = ['Поз. пит. ', 'Тип питателя ', 'Эталон', 'TOP/BOT', 'На установщике', 'Наименование компонента', 'Кол-во', 'Автомат', '№ автомата в линии', 'Этап']; // Ширины колонок из образца «Сверка BOT» шаблона Spec.xlsx // (C и D — эталон/перечень parts — расширены до 11 по просьбе пользователя). const COL_WIDTHS = [6.57, 6, 11, 11, 10.71, 29.14, 5.86, 33.29, 17.14, 16]; const SIGNATURE = 'Инженер ТКО' + '_'.repeat(49); const BORDER_THIN = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' }, }; // Общие объекты стилей: переиспользуются для всех ячеек листа (свежий объект // на каждую ячейку — лишние аллокации и мусор для GC; ExcelJS их не мутирует). const FONT_ARIAL_6 = { name: 'Arial', size: 6 }; const FONT_ARIAL_8 = { name: 'Arial', size: 8 }; const FONT_ARIAL_10 = { name: 'Arial', size: 10 }; const FONT_ARIAL_11 = { name: 'Arial', size: 11 }; const ALIGN_CENTER_MIDDLE = { horizontal: 'center', vertical: 'middle', wrapText: true }; const ALIGN_CENTER_MIDDLE_FLAT = { horizontal: 'center', vertical: 'middle' }; const ALIGN_LEFT_MIDDLE = { horizontal: 'left', vertical: 'middle' }; const ALIGN_MIDDLE = { vertical: 'middle' }; const ALIGN_TOP = { vertical: 'top' }; const ALIGN_TOP_WRAP = { vertical: 'top', wrapText: true }; const ALIGN_CENTER_TOP_WRAP = { horizontal: 'center', vertical: 'top', wrapText: true }; const ALIGN_CENTER_TOP = { horizontal: 'center', vertical: 'top' }; /* --- Стилизация листа сверки (по образцу Spec.xlsx «Сверка BOT») --- */ /** Заголовки блока: тонкая рамка всех ячеек, Arial 8 (B/E — 6), wrap, высота 33.95. */ function styleHeaderRow(ws, r) { ws.getRow(r).height = 33.95; for (let c = 1; c <= 10; c++) { const cell = ws.getCell(r, c); cell.border = BORDER_THIN; cell.font = (c === 2 || c === 5) ? FONT_ARIAL_6 : FONT_ARIAL_8; cell.alignment = ALIGN_CENTER_MIDDLE; } } /** Строки данных: рамка всех ячеек, Arial 10, top; C/D/E/G/I — center+wrap. */ function styleDataRow(ws, r) { for (let c = 1; c <= 10; c++) { const cell = ws.getCell(r, c); cell.border = BORDER_THIN; cell.font = FONT_ARIAL_10; cell.alignment = ALIGN_TOP; } ws.getCell(r, 1).alignment = ALIGN_TOP_WRAP; // A: Поз. пит. for (const c of [3, 4, 5, 7, 9]) { // C D E G I ws.getCell(r, c).alignment = ALIGN_CENTER_TOP_WRAP; } ws.getCell(r, 10).alignment = ALIGN_CENTER_TOP; // J // Явная высота строки: ровно число строк в D (артикулы «Заказ»), чтобы // 2 аналога = 2 ряда, а не «3» из-за автоподбора высоты Excel. // Перенос по ширине W≈11 символов (ширина колонки D = 11). let dLines = 1; const d = ws.getCell(r, 4).value; if (d != null && String(d).trim() !== '') { dLines = String(d).split('\n').reduce(function (acc, s) { return acc + Math.max(1, Math.ceil(s.length / 11)); }, 0); } ws.getRow(r).height = Math.max(1, dLines) * 14.25; } /** Служебные строки («Линия…/Автомат…/Сторона…»): без рамок, как в образце. */ function styleServiceRow(ws, r) { const a = ws.getCell(r, 1); a.font = FONT_ARIAL_10; a.alignment = ALIGN_LEFT_MIDDLE; for (let c = 2; c <= 10; c++) { const cell = ws.getCell(r, c); cell.font = FONT_ARIAL_11; if (c === 4) { cell.alignment = ALIGN_CENTER_MIDDLE; } else if (c === 6 || c === 8) { cell.alignment = ALIGN_MIDDLE; } else { cell.alignment = ALIGN_CENTER_MIDDLE_FLAT; } } } /** Подпись: без рамок; текст в A (Arial 10, left/middle), остальные ячейки пустые. */ function styleSignatureRow(ws, r) { const a = ws.getCell(r, 1); a.font = FONT_ARIAL_10; a.alignment = ALIGN_LEFT_MIDDLE; } /** Ширины колонок из образца (уже заданным листам тоже перезаписываются). */ function setColumnWidths(ws) { for (let c = 1; c <= 10; c++) ws.getColumn(c).width = COL_WIDTHS[c - 1]; } /** Префикс стороны по позиции: F/R/T. */ function sideOf(pos) { const c = String(pos).charAt(0).toUpperCase(); return c === 'F' ? 'F' : (c === 'R' ? 'R' : 'T'); } /** * Сборка моделей установщиков из результата DocxParser.parseDocx. * Машины в порядке появления (это и есть № в линии 1–5), у каждой — * строки питателей { no, type, part, qty }. */ function parseDocxData(data) { const machines = []; const byName = {}; for (const t of data.tables || []) { if (!t || !t.machine) continue; let m = byName[t.machine]; if (!m) { m = byName[t.machine] = { name: t.machine, pcb: t.pcb, rows: [] }; machines.push(m); } for (const r of t.rows || []) { const no = String(r[0] === undefined ? '' : r[0]).trim(); if (!no) continue; m.rows.push({ no: no, type: String(r[1] === undefined ? '' : r[1]).trim(), part: String(r[2] === undefined ? '' : r[2]).trim(), qty: parseFloat(String(r[3] === undefined ? '0' : r[3]).trim()) || 0, }); } } const warnings = []; if (machines.length !== 5) { warnings.push('В документе найдено установщиков: ' + machines.length + ' (ожидалось 5) — порядок и состав блоков могут отличаться.'); } return { machines: machines, warnings: warnings }; } /** * Строка «Проект: …» из имени PCB: «AQC266SD_2.0_SKUB_BOT_1.PCB» → * « Проект: AQC266SD-MB, версия 2.0, SKU B _BOT». */ function projectLabel(pcb) { const m = /^([A-Za-z0-9]+)_([\d.]+)_SKU([A-Za-z0-9]*)_(TOP|BOT)_\d+\.PCB$/i.exec(String(pcb || '')); if (!m) return ''; return ' Проект: ' + m[1] + '-MB, версия ' + m[2] + ', SKU ' + m[3] + ' _' + m[4].toUpperCase(); } /** * Построение листа сверки (всегда создаёт заново: существующий лист шаблона * удаляется, чтобы стили образца не наследовались и не конфликтовали). * 5 установщиков × стороны F/R (трей T — в «Тыльная» 5-го). * Каждая строка блока заполняется сразу (C/D/F/G/H/I/J). * Возвращает число заполненных строк позиций. */ function buildSverkaSheet(wbE, wsE, sheetName, parsed, maps, opts) { const o = opts || {}; const side = sheetName === 'Сверка TOP' ? 'TOP' : 'BOT'; const lineNumber = o.lineNumber != null ? String(o.lineNumber).trim() : ''; const stage = o.stage != null ? String(o.stage).trim() : ''; const linePrefix = 'Линия: SMT линия ' + (lineNumber || 'X') + ' (Hanwha)'; const hPrefix = lineNumber ? 'SMT линия ' + lineNumber : 'SMT линия X'; const project = parsed.machines.length ? projectLabel(parsed.machines[0].pcb) : ''; // Лист сверки всегда пересоздаётся: перезапись стилей в существующем листе // ExcelJS (загруженном из файла) при большом объёме путает стили ячеек. if (wsE) wbE.removeWorksheet(wsE.id); wsE = wbE.addWorksheet(sheetName); setColumnWidths(wsE); // Параметры печати: A4, книжная, автоподбор по ширине (область печати // ограничена колонкой «Кол-во» (G) — колонки Автомат/№/Этап не печатаем); // каждый блок («Автомат» + сторона) печатается на отдельной странице — // разрыв ставят ручные разрывы строк (wsE.rowBreaks) после каждого блока. wsE.pageSetup = { paperSize: 9, // A4 orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, pageOrder: 'overThenDown', horizontalDpi: 96, verticalDpi: 96, margins: { left: 0.25, right: 0.25, top: 0.6, bottom: 0.6, header: 0.3, footer: 0.3 }, }; let r = 0; function put(cols, kind) { r++; for (let i = 0; i < cols.length; i++) { const v = cols[i]; if (v !== undefined && v !== null && v !== '') wsE.getCell(r, i + 1).value = v; } if (kind === 'service') styleServiceRow(wsE, r); else if (kind === 'header') styleHeaderRow(wsE, r); else if (kind === 'data') styleDataRow(wsE, r); else if (kind === 'signature') styleSignatureRow(wsE, r); return r; } let filled = 0; let skipped = 0; // позиций с некорректным/нулевым Кол-во (не попадают на лист) function block(rows, sideLabel, machineIdx, automaton, isLast) { put([linePrefix + project], 'service'); put(['Автомат: ' + automaton + ' № автомата в линии: ' + machineIdx], 'service'); put(['Сторона установки питателей: ' + sideLabel], 'service'); put(HEADERS.map(function (h) { return h === 'TOP/BOT' ? side : h; }), 'header'); for (const row of rows) { if (!row.qty || row.qty <= 0) { skipped++; continue; } put([ row.no, row.type, /^\d+$/.test(String(row.part)) ? Number(row.part) : row.part, maps.orderOf(row.part), '', maps.descOf(row.part), row.qty, hPrefix + ' ' + automaton, machineIdx, stage, ], 'data'); filled++; } put([SIGNATURE], 'signature'); r += 2; // пустые строки-разделители между блоками (как в образце) // Каждый блок («Автомат» + сторона) печатается на новой странице. // Разрыв ставим ПОСЛЕ последней пустой строки, чтобы подпись // «Инженер ТКО…» и пустые строки остались на странице своего блока. // У последнего блока листа разрыв не нужен (это конец печати). if (!isLast) { wsE.rowBreaks.push({ id: r, man: true, max: 16383, min: 0 }); } } const machines = parsed.machines || []; const blocks = []; machines.forEach(function (m, i) { const automaton = 'Hanwha ' + m.name; const F = [], R = [], T = []; for (const row of m.rows) { const s = sideOf(row.no); if (s === 'F') F.push(row); else if (s === 'R') R.push(row); else T.push(row); } if (F.length) blocks.push({ rows: F, sideLabel: SIDE_LABELS.F, machineIdx: i + 1, automaton: automaton }); // T-позиции трея — в начало тыльной стороны if (R.length || T.length) blocks.push({ rows: T.concat(R), sideLabel: SIDE_LABELS.R, machineIdx: i + 1, automaton: automaton }); }); blocks.forEach(function (b, bi) { block(b.rows, b.sideLabel, b.machineIdx, b.automaton, bi === blocks.length - 1); }); // Область печати: от A1 до последней строки листа, по ширине — до колонки // «Кол-во» (G); колонки H «Автомат», I «№ автомата в линии», J «Этап» // в печать не входят (на странице остаётся блок: шапка + данные + Кол-во). wsE.pageSetup.printArea = 'A1:G' + r; return { filled: filled, skipped: skipped }; } /** Номер колонки (1-based) → буквенное имя ('A'…'Z', 'AA'…). */ function numToLetter(n) { let s = ''; while (n > 0) { const rem = (n - 1) % 26; s = String.fromCharCode(65 + rem) + s; n = Math.floor((n - 1) / 26); } return s; } /** * Поиск номера колонки (1-based) по заголовку в первой строке; нормализация: * trim + lowercase. Возвращает -1, если колонка с таким названием не найдена. */ function findColumnByHeader(wsE, header) { if (!wsE) return -1; const want = String(header).trim().toLowerCase(); const limit = Math.min(wsE.columnCount || 10, 40); for (let c = 1; c <= limit; c++) { const v = wsE.getCell(1, c).value; if (v != null && String(v).trim().toLowerCase() === want) return c; } return -1; } /** * Определяет колонку артикулов в листе без заголовков (PP TOP / PP BOT): * перебирает колонки и возвращает ту, чьи значения чаще всего встречаются * среди knownValues (артикулы «Спецификации»). Заголовков в PP нет, * поэтому привязка идёт к содержимому, а не к номеру колонки. * Возвращает -1, если совпадений нет ни в одной колонке. */ function findPartColumn(wsE, knownValues) { if (!wsE || !knownValues || !knownValues.size) return -1; const limit = Math.min(wsE.columnCount || 10, 12); let best = -1, bestHit = 0; for (let c = 1; c <= limit; c++) { let hit = 0, total = 0; const rows = Math.min(wsE.rowCount, 300); for (let r = 1; r <= rows; r++) { const v = wsE.getCell(r, c).value; if (v == null) continue; const k = String(v).trim(); if (!k) continue; total++; if (knownValues.has(k)) hit++; } if (hit > bestHit) { bestHit = hit; best = c; } } return bestHit > 0 ? best : -1; } /** Множество уникальных непустых значений колонки (для определения колонок PP). */ function collectColumnValues(wsE, col) { const set = new Set(); if (!wsE || col < 1) return set; for (let r = 1; r <= wsE.rowCount; r++) { const v = wsE.getCell(r, col).value; if (v == null) continue; const k = String(v).trim(); if (k) set.add(k); } return set; } /** * Гарантирует наличие столбца «Заказ» в «Спецификации». * Если такой колонки нет — вставляет её ТРЕТЬЕЙ (после N и Артикула), * сдвигая остальные столбцы вправо, НЕ перезаписывая их данные. * Возвращает номер колонки «Заказ». */ function ensureOrderColumn(wsSpecE) { const found = findColumnByHeader(wsSpecE, 'Заказ'); if (found !== -1) return found; const n = wsSpecE.rowCount || 1; const values = ['Заказ']; for (let r = 2; r <= n; r++) values.push(''); wsSpecE.spliceColumns(3, 0, values); return 3; } /** * Заказ и наименование по эталону из «Состав» и «Спецификация». * Колонки «Состава» передаются номерами, найденными по НАЗВАНИЮ заголовка: * colSostEt — столбец «артикул эталонной номенклатуры» (эталон, ключ поиска); * colSostArt — столбец «артикул» (артикулы, входящие в эталон). */ function computeMaps(wsSpec, wsSost, colArt, colDesc, colSostEt, colSostArt) { // эталон («Состав» [артикул эталонной номенклатуры]) → Set уникальных // артикулов («Состав» [артикул]): Set даёт дедуп за O(1) и сохраняет порядок. const artByEt = {}; // «Состав» может отсутствовать (лист/колонки) — тогда карта артикулов пустая, // а descByEt всё равно заполняется из «Спецификации». if (wsSost && colSostEt > 0 && colSostArt > 0) { for (let r = 2; r <= wsSost.rowCount; r++) { const et = wsSost.getCell(r, colSostEt).value; const art = wsSost.getCell(r, colSostArt).value; if (et == null || art == null) continue; const k = String(et).trim(); const v = String(art).trim(); if (!k || !v) continue; let set = artByEt[k]; if (!set) set = artByEt[k] = new Set(); set.add(v); } } const descByEt = {}; // Эталон (Спецификация colArt) → Описание (colDesc) for (let i = 1; i <= wsSpec.rowCount; i++) { const a = wsSpec.getCell(i, colArt).value; if (a == null) continue; const v = colDesc !== -1 ? wsSpec.getCell(i, colDesc).value : null; descByEt[String(a).trim()] = v != null ? String(v).trim() : ''; } return { // «Заказ» (столбец C «Спецификации») и колонка D сверок (артикулы всех // аналогов): единый формат — запятая в конце строки, каждый аналог на // своей строке (`,\n`). (D сверки переносит текст: высота строки в // styleDataRow = число строк; в «Заказе» высота = число аналогов.) orderOf: function (et) { const set = artByEt[String(et).trim()]; return set && set.size ? Array.from(set).join(',\n') : ''; }, descOf: function (et) { return descByEt[String(et).trim()] || ''; }, artByEt: artByEt, }; } /** * Проверка структуры Excel-шаблона перед расчётом: наличие листов и колонок, * используемых для расчёта («Спецификация», «Состав», PP для контроля). * Возвращает { critical: [], warning: [] } — списки сообщений для журнала. * critical = без этих листов/колонок расчёт невозможен или заведомо пуст; * warning = контроль будет неполным, но расчёт продолжится. */ function validateStructure(wbE) { const names = wbE.worksheets.map(function (w) { return w.name; }); const has = function (n) { return names.indexOf(n) !== -1; }; const critical = []; const warning = []; const CRIT_SHEETS = [ { n: 'Спецификация', why: 'из неё берутся эталоны, «Заказ» и наименования компонентов' }, ]; for (const s of CRIT_SHEETS) { if (!has(s.n)) critical.push('В шаблоне НЕТ листа «' + s.n + '» (' + s.why + ')'); } // «Состав» НЕ критичен: без него сверки строятся с пустой колонкой D, // столбец «Заказ» не создаётся и не заполняется (решение пользователя). if (!has('Состав')) { warning.push('В шаблоне НЕТ листа «Состав» — колонка D сверок и столбец «Заказ» останутся пустыми'); } for (const n of ['PP BOT', 'PP TOP']) { if (!has(n)) warning.push('В шаблоне нет листа «' + n + '» — контроль по нему будет пропущен'); } function colCount(sheetName, col) { const ws = wbE.getWorksheet(sheetName); if (!ws) return 0; const values = ws.getColumn(col).values; let n = 0; // Строка 1 — заголовок, данные считаем со строки 2. const limit = Math.min(values ? values.length - 1 : 0, ws.rowCount, 1000); for (let r = 2; r <= limit; r++) { const v = values[r]; if (v != null && String(v).trim() !== '') n++; } return n; } // Колонки «Спецификации» ищем по названию заголовка (не по фиксированным индексам). // «Заказ» не проверяем — при отсутствии он создаётся третьим столбцом автоматически. const specWs = wbE.getWorksheet('Спецификация'); const specColArt = specWs ? findColumnByHeader(specWs, 'Артикул') : -1; const specColDesc = specWs ? findColumnByHeader(specWs, 'Описание') : -1; // Колонки «Состава» тоже ищем по названию заголовка: // «артикул эталонной номенклатуры» — эталон (ключ), // «артикул» — столбец артикулов, входящих в эталон. const sostWs = wbE.getWorksheet('Состав'); const sostColEt = sostWs ? findColumnByHeader(sostWs, 'артикул эталонной номенклатуры') : -1; const sostColArt = sostWs ? findColumnByHeader(sostWs, 'артикул') : -1; // В PP TOP / PP BOT заголовков нет — колонку артикулов определяем по содержимому // (пересечение со значениями «Спецификация.Артикул»), а не по номеру E. const specArts = specColArt !== -1 ? collectColumnValues(specWs, specColArt) : new Set(); const ppPartCol = {}; for (const n of ['PP BOT', 'PP TOP']) { ppPartCol[n] = has(n) ? findPartColumn(wbE.getWorksheet(n), specArts) : -1; } const CHECKS = [ { ws: 'Спецификация', colNum: specColArt, name: '«Артикул»', why: 'без него не рассчитать «Заказ» и наименования', critical: true }, { ws: 'Спецификация', colNum: specColDesc, name: '«Описание»', why: 'из неё берутся наименования компонентов (столбец F сверки)', critical: false }, // «Состав» в CHECKS НЕ проверяется (иерархия предупреждений): отсутствие // листа покрыто выше (warning «НЕТ листа «Состав»»), а отсутствие/пустоту // колонок и пар сообщает fillExcel одним предупреждением — не дублируем. ]; for (const c of CHECKS) { if (!has(c.ws)) continue; // уже покрыто выше const cnt = c.colNum === -1 ? 0 : colCount(c.ws, numToLetter(c.colNum)); if (cnt < 1) { const msg = 'В листе «' + c.ws + '» колонка ' + (c.name || numToLetter(c.colNum)) + ' пуста или не найдена по названию (' + c.why + ')' + (c.critical ? ' — расчёт невозможен' : ''); (c.critical ? critical : warning).push(msg); } } for (const n of ['PP BOT', 'PP TOP']) { if (has(n) && ppPartCol[n] === -1) { warning.push('В листе «' + n + '» не удалось определить колонку артикулов — детальный контроль по parts будет неточным'); } } // Дополнительно возвращаем результаты поиска/сканирования листов, чтобы // fillExcel не повторял эту работу: индексы колонок «Состава», множество // артикулов «Спецификации» и найденные колонки артикулов PP. // ВАЖНО: колонки «Спецификации» (Артикул/Описание) сюда НЕ входят — // ensureOrderColumn может вставить «Заказ» третьим столбцом и сдвинуть их, // поэтому их индексы надёжно искать только ПОСЛЕ вставки. return { critical: critical, warning: warning, cols: { sostColEt: sostColEt, sostColArt: sostColArt, specArts: specArts, ppPartCol: ppPartCol, }, }; } /** Сумма qty по позициям машин (для прогноза «пустых» документов). */ function totalRows(parsed) { let n = 0; for (const m of parsed.machines || []) n += m.rows.length; return n; } /** * Восстанавливает формулы из SheetJS-модели (полный текст + результат), * кроме _xlfn-мастеров, которые ExcelJS умеет записывать сам. */ function restoreFormulas(wbE, wbS) { let rewritten = 0, unchanged = 0, xlfn = 0; for (const sName of wbS.SheetNames) { const wsS = wbS.Sheets[sName]; const wsE = wbE.getWorksheet(sName); if (!wsS || !wsE) continue; for (const addr of Object.keys(wsS)) { const c = wsS[addr]; if (!c || typeof c.f !== 'string') continue; const cell = wsE.getCell(addr); const v = cell.value; if (v && typeof v === 'object' && !Array.isArray(v) && typeof v.formula === 'string' && v.formula.indexOf('_xlfn') === 0) { xlfn++; continue; } const result = (c.v !== undefined && c.v !== null) ? c.v : ''; // Та же формула с тем же результатом уже на месте — не перезаписываем. if (v && typeof v === 'object' && !Array.isArray(v) && typeof v.formula === 'string' && v.formula === c.f && (v.result === result || (v.result == null && result === ''))) { unchanged++; continue; } cell.value = { formula: c.f, result: result }; rewritten++; } } return { rewritten: rewritten, unchanged: unchanged, xlfn: xlfn }; } /** * Число непустых строк листа по колонке A — fallback для PP, когда колонку * артикулов не удалось определить по данным (findPartColumn → -1). */ function countComponents(wsE) { let n = 0; for (let r = 1; r <= wsE.rowCount; r++) { const v = wsE.getCell('A' + r).value; if (v != null && String(v).trim() !== '') n++; } return n; } /** Число строк с непустым значением в конкретной колонке (num — 1-based номер колонки). */ function countNonEmpty(wsE, col) { let n = 0; for (let r = 1; r <= wsE.rowCount; r++) { const v = wsE.getCell(r, col).value; if (v != null && String(v).trim() !== '') n++; } return n; } /** Сумма «Кол-во» (G) по всем числовым ячейкам листа сверки. */ function sumQty(wsE) { let s = 0; for (let r = 1; r <= wsE.rowCount; r++) { const g = wsE.getCell('G' + r).value; if (typeof g === 'number') s += g; } return s; } /** Карта «значение колонки → число строк» (для PP: колонка артикулов, найденная по данным). */ function countByColumn(wsE, col) { const map = {}; for (let r = 1; r <= wsE.rowCount; r++) { const v = wsE.getCell(r, col).value; if (v == null) continue; const k = String(v).trim(); if (!k) continue; map[k] = (map[k] || 0) + 1; } return map; } /** Карта «эталон (C) → сумма Кол-во (G)» листа сверки. */ function sumByEtalon(wsE) { const map = {}; for (let r = 1; r <= wsE.rowCount; r++) { const c = wsE.getCell('C' + r).value; if (c == null) continue; const k = String(c).trim(); if (!k) continue; const g = wsE.getCell('G' + r).value; if (typeof g === 'number') map[k] = (map[k] || 0) + g; } return map; } /** Сравнение карт «part → количество» (PP против сверки), только расхождения. */ function comparePartMaps(ppMap, svMap) { const keys = {}; for (const k of Object.keys(ppMap)) keys[k] = 1; for (const k of Object.keys(svMap)) keys[k] = 1; const mismatches = []; for (const k of Object.keys(keys)) { const pp = ppMap[k] || 0; const sv = svMap[k] || 0; if (pp !== sv) mismatches.push({ part: k, pp: pp, sv: sv, diff: pp - sv }); } mismatches.sort(function (a, b) { return Math.abs(b.diff) - Math.abs(a.diff); }); return mismatches; } /** * Основная операция: заполнение Excel из двух DOCX и возврат нового файла. * @param {Object} deps — { ExcelJS, XLSX, docxparser, JSZip, DOMParserImpl } * @param {Object} files — { excel: ArrayBuffer, top: ArrayBuffer, bot: ArrayBuffer } * @param {Object} [opts] — { lineBot, lineTop, stageBot, stageTop, onLog(msg, kind), onProgress(text) } * @returns {Promise<{ buffer, stats, warnings, orders, formulas, checks }>} */ async function fillExcel(deps, files, opts) { const o = opts || {}; const log = o.onLog || function () {}; const prog = o.onProgress || function () {}; const ExcelJS = deps.ExcelJS; const XLSX = deps.XLSX; const DocxParser = deps.docxparser; if (!ExcelJS || !XLSX) throw new Error('Не переданы Excel-библиотеки (ExcelJS/SheetJS)'); if (!DocxParser || !deps.JSZip) throw new Error('Не переданы DOCX-зависимости (DocxParser/JSZip)'); prog('Чтение Excel-шаблона…'); const wbE = new ExcelJS.Workbook(); await wbE.xlsx.load(files.excel); const wbS = XLSX.read(new Uint8Array(files.excel), { type: 'array' }); const sheetsE = {}; for (let i = 0; i < wbE.worksheets.length; i++) { sheetsE[wbE.worksheets[i].name] = wbE.worksheets[i]; } const wsSpecE = sheetsE['Спецификация']; const wsSostE = sheetsE['Состав']; // Проверка структуры шаблона: листы и колонки, используемые в расчёте. const sErr = validateStructure(wbE); for (const m of sErr.critical) log('Ошибка структуры: ' + m, 'error'); for (const m of sErr.warning) log('Проверка структуры: ' + m, 'warn'); if (sErr.critical.length) { throw new Error('Шаблон не подходит для расчёта: ' + sErr.critical.join('; ')); } const cols = sErr.cols; // результаты поиска колонок из validateStructure if (!wsSpecE) { throw new Error('В Excel нет листа «Спецификация»'); } // .docx — либо оба (TOP + BOT), либо ни одного (режим «только Excel»). const hasDocx = Boolean(files.top && files.bot); if ((files.top && !files.bot) || (!files.top && files.bot)) { throw new Error('Нужны оба документа TOP.docx и BOT.docx (либо ни одного)'); } let botRaw = null, topRaw = null, botParsed = null, topParsed = null; let warnings = []; if (hasDocx) { prog('Парсинг BOT.docx…'); botRaw = await DocxParser.parseDocx(files.bot, { JSZip: deps.JSZip, DOMParserImpl: deps.DOMParserImpl }); botParsed = parseDocxData(botRaw); prog('Парсинг TOP.docx…'); topRaw = await DocxParser.parseDocx(files.top, { JSZip: deps.JSZip, DOMParserImpl: deps.DOMParserImpl }); topParsed = parseDocxData(topRaw); // Проверки распознавания DOCX: документ без таблиц/машин. for (const pair of [['BOT', botParsed], ['TOP', topParsed]]) { const name = pair[0]; const parsed = pair[1]; if (!parsed.machines.length) { log('Ошибка: ' + name + '.docx не содержит распознанных установщиков — проверьте содержимое файла', 'error'); throw new Error(name + '.docx: не найдено таблиц установщиков'); } if (totalRows(parsed) === 0) { log('Ошибка: в ' + name + '.docx нет ни одной строки позиций после парсинга', 'error'); throw new Error(name + '.docx: пустой документ (нет строк позиций)'); } } warnings = (botRaw.validationErrors || []).concat(topRaw.validationErrors || []) .map(function (e) { return e.message; }) .concat(botParsed.warnings, topParsed.warnings); if (warnings.length) log('Внимание: ' + warnings.join(' '), 'warn'); } prog('Расчёт «Заказ» по «Состав»…'); // Колонки «Состава» — по названию заголовка (найдены в validateStructure, // не пересчитываем): // «артикул эталонной номенклатуры» = эталон, «артикул» = входящие в него артикулы. const sostColEt = cols.sostColEt; const sostColArt = cols.sostColArt; const sostSheet = Boolean(wsSostE); // лист «Состав» присутствует в шаблоне const sostOk = sostColEt !== -1 && sostColArt !== -1; if (!hasDocx && !sostOk) { log('Ошибка: в листе «Состав» нет данных «эталон → артикулы» — рассчитывать нечего', 'error'); throw new Error('В листе «Состав» нет данных «эталон → артикулы» — рассчитывать нечего'); } // Колонки «Спецификации» ищем по названию заголовка (не по фиксированным индексам): // «Заказ» — если его нет и есть данные «Состав», создаём третьим столбцом; // «Описание» — для наименований в сверке. // ВАЖНО: если данных «Состав» нет (sostOk = false) — «Заказ» НЕ создаём и НЕ // заполняем (решение пользователя: работа без столбца, из которого берётся // перечень артикулов). let colOrder = -1; let colArt, colDesc; let orderFilled = 0, orderEmpty = 0; if (sostOk) { colOrder = ensureOrderColumn(wsSpecE); // «Спецификация» могла сдвинуться после ensureOrderColumn (вставка «Заказа» // третьим столбцом), поэтому колонки Артикул/Описание ищем ПОСЛЕ вставки. // «Состав» вставкой не затрагивается. colArt = findColumnByHeader(wsSpecE, 'Артикул'); colDesc = findColumnByHeader(wsSpecE, 'Описание'); if (colArt === -1) log('Внимание: в листе «Спецификация» не найден столбец «Артикул»', 'warn'); } else { colArt = findColumnByHeader(wsSpecE, 'Артикул'); colDesc = findColumnByHeader(wsSpecE, 'Описание'); // Иерархия предупреждений: если листа «Состав» в шаблоне НЕТ — №1 уже // выдано в validateStructure («В шаблоне НЕТ листа «Состав»…»), дублировать // не нужно. Если лист ЕСТЬ, но колонок/данных нет — одно предупреждение №2. if (sostSheet) { log('Внимание: в листе «Состав» нет данных (эталон → артикул): сверки строятся без колонки D, столбец «Заказ» не создаётся и не заполняется', 'warn'); } } const maps = computeMaps(wsSpecE, wsSostE, colArt === -1 ? 2 : colArt, colDesc, sostColEt, sostColArt); let etalonCount = 0; let sostHasData = false; // лист есть + колонки есть + есть пары «эталон → артикул» if (sostOk) { // Проверка целостности данных «Состав»: сколько эталонов с артикулами. etalonCount = Object.keys(maps.artByEt).length; if (etalonCount === 0) { if (!hasDocx) { log('Ошибка: в листе «Состав» нет данных «эталон → артикулы» — рассчитывать нечего', 'error'); throw new Error('В листе «Состав» нет данных «эталон → артикулы» — рассчитывать нечего'); } // №2: лист «Состав» есть, но пуст. log('Внимание: в листе «Состав» нет ни одной пары «эталон → артикул»: столбец «Заказ» не создаётся и не заполняется', 'warn'); } else { sostHasData = true; if (!hasDocx) { log('Режим: только Excel-шаблон — будет рассчитан только столбец «Заказ»; сверки BOT/TOP и контроль PP пропущены', 'info'); } for (let r = 2; r <= wsSpecE.rowCount; r++) { if (colArt === -1) break; const art = wsSpecE.getCell(r, colArt).value; if (art == null) continue; const ord = maps.orderOf(String(art).trim()); wsSpecE.getCell(r, colOrder).value = ord; orderFilled++; if (!ord) orderEmpty++; } log('«Заказ» рассчитан для ' + orderFilled + ' строк, из них без перечня: ' + orderEmpty); // Оформление столбца «Заказ»: заголовок — Calibri 11 с синей заливкой (как у // остальных заголовков «Спецификации»), значения — Arial 8. const orderHeader = wsSpecE.getCell(1, colOrder); orderHeader.font = { name: 'Calibri', size: 11, color: { argb: 'FFFFFFFF' } }; // Заливку берём от заголовка «Артикул» (Theme-заливка синий в шаблоне) — // задаём явно solid c тем же fgColor theme, как у соседей: const neighborHeader = wsSpecE.getCell(1, colArt === -1 ? 2 : colArt); const nf2 = neighborHeader.fill; orderHeader.fill = { type: 'pattern', pattern: 'solid', fgColor: (nf2 && (nf2.fgColor || nf2.bgColor)) || {} }; orderHeader.alignment = neighborHeader.alignment ? Object.assign({}, neighborHeader.alignment) : undefined; // Высота строки = число аналогов (строк в «Заказе») × базовая высота строки, // чтобы каждый аналог был на своей строке; у ячейки включён перенос текста. const ORDER_BASE_HEIGHT = 14.25; // базовая высота строки данных (как в сверке) for (let r = 2; r <= wsSpecE.rowCount; r++) { const cell = wsSpecE.getCell(r, colOrder); cell.font = FONT_ARIAL_8; cell.alignment = ALIGN_TOP_WRAP; const v = cell.value; if (v != null && String(v).indexOf('\n') !== -1) { const lines = String(v).split('\n').length; wsSpecE.getRow(r).height = lines * ORDER_BASE_HEIGHT; } } if (orderEmpty) { log('Внимание: ' + orderEmpty + ' строка(и) «Спецификация» остались без перечня артикулов ' + '(в «Состав» для этих эталонов нет данных)', 'warn'); } } } let stats = { filled: 0, skipped: 0 }; if (hasDocx) { prog('Построение листов сверки…'); const sheets = [ ['Сверка BOT', botParsed, o.lineBot, o.stageBot], ['Сверка TOP', topParsed, o.lineTop, o.stageTop], ]; for (const pair of sheets) { const sheetName = pair[0]; const parsed = pair[1]; // У каждого листа свои номер линии и этап (BOT/TOP могут различаться) const res = buildSverkaSheet(wbE, sheetsE[sheetName] || null, sheetName, parsed, maps, Object.assign({}, o, { lineNumber: pair[2] != null ? String(pair[2]).trim() : '', stage: pair[3] != null ? String(pair[3]).trim() : '', })); log('Лист «' + sheetName + '» построен заново, строк позиций — ' + res.filled + (res.skipped ? ', пропущено с некорректным Кол-во — ' + res.skipped : ''), 'info'); stats.filled += res.filled; stats.skipped += res.skipped; } if (stats.skipped) { log('Внимание: пропущено ' + stats.skipped + ' позиций (Кол-во отсутствует или ≤ 0) — они не попали на листы сверки', 'warn'); } } // Проверка покрытия: эталоны из DOCX, для которых нет перечня артикулов в «Состав». // №3 — только если данные «Состав» реально есть (иначе это не «чего-то не хватает», // а отсутствие данных, уже покрытое №2). const noOrder = {}; if (hasDocx && sostHasData) { for (const parsed of [botParsed, topParsed]) { for (const m of parsed.machines || []) { for (const row of m.rows) { if (row.part && !maps.orderOf(row.part)) noOrder[row.part] = true; } } } const noOrderKeys = Object.keys(noOrder); if (noOrderKeys.length) { log('Внимание: ' + noOrderKeys.length + ' эталон(ов) из DOCX не найдены в «Состав» (колонка D пуста): ' + noOrderKeys.slice(0, 10).join(', ') + (noOrderKeys.length > 10 ? ', …' : ''), 'warn'); } } const checks = []; if (hasDocx) { prog('Контрольная сверка количества компонентов…'); // В PP TOP / PP BOT нет заголовков — колонку артикулов определяем по содержимому // (пересечение со значениями «Спецификация.Артикул»). Множество артикулов и // колонки PP уже найдены в validateStructure — не сканируем листы повторно. const specArts = cols.specArts; const checkPairs = [['PP BOT', 'Сверка BOT'], ['PP TOP', 'Сверка TOP']]; for (const pair of checkPairs) { const wsPp = sheetsE[pair[0]] || wbE.getWorksheet(pair[0]); const wsSv = wbE.getWorksheet(pair[1]); if (!wsPp || !wsSv) continue; const ppCol = cols.ppPartCol[pair[0]]; if (ppCol === -1) { log('Внимание: в листе «' + pair[0] + '» не определена колонка артикулов — детальный контроль по parts будет неточным', 'warn'); } const ppCount = ppCol !== -1 ? countNonEmpty(wsPp, ppCol) : countComponents(wsPp); const qtySum = sumQty(wsSv); const diff = ppCount - qtySum; const ok = diff === 0; const msg = '«' + pair[0] + '» ↔ «' + pair[1] + '»: компонентов в «' + pair[0] + '» — ' + ppCount + ', сумма «Кол-во» в сверке — ' + qtySum + (ok ? ' — СОВПАДАЕТ' : ' — РАСХОЖДЕНИЕ (' + (diff > 0 ? '+' : '') + diff + ')'); log('Контроль: ' + msg, ok ? 'ok' : 'warn'); const ppMap = ppCol !== -1 ? countByColumn(wsPp, ppCol) : {}; const svMap = sumByEtalon(wsSv); const mismatches = comparePartMaps(ppMap, svMap); if (mismatches.length) { log('Детальная проверка «' + pair[1] + '»: расхождений по parts — ' + mismatches.length + ' (из ' + Object.keys(ppMap).length + ' parts в PP)', 'warn'); } else { log('Детальная проверка «' + pair[1] + '»: все ' + Object.keys(ppMap).length + ' parts PP сходятся со сверкой', 'ok'); } for (const m of mismatches.slice(0, 20)) { log(' ' + m.part + ': PP=' + m.pp + ', сверка=' + m.sv + ', разница=' + (m.diff > 0 ? '+' : '') + m.diff, 'warn'); } if (mismatches.length > 20) { log(' … ещё ' + (mismatches.length - 20) + ' расхождений', 'warn'); } checks.push({ pair: pair[1], ppComponents: ppCount, qtySum: qtySum, diff: diff, ok: ok, partsCompared: Object.keys(ppMap).length, partMismatches: mismatches.length, mismatchedParts: mismatches.slice(0, 20), }); } } prog('Восстановление формул…'); const formulas = restoreFormulas(wbE, wbS); wbE.calcProperties.fullCalcOnLoad = true; prog('Сохранение файла…'); const buffer = await wbE.xlsx.writeBuffer(); if (hasDocx) { log('Готово: строк позиций — ' + stats.filled + ', «Заказ» — ' + orderFilled + ', формул восстановлено — ' + formulas.rewritten + ' (совпали: ' + formulas.unchanged + ', _xlfn: ' + formulas.xlfn + ')', 'ok'); } else { log('Готово (только Excel): рассчитан столбец «Заказ» — ' + orderFilled + ' строк, из них без перечня: ' + orderEmpty + ', формул восстановлено — ' + formulas.rewritten, 'ok'); } return { buffer: buffer, stats: stats, warnings: warnings, orders: { filled: orderFilled, empty: orderEmpty }, formulas: formulas, checks: checks, }; } return { fillExcel: fillExcel, parseDocxData: parseDocxData, computeMaps: computeMaps, validateStructure: validateStructure, restoreFormulas: restoreFormulas, buildSverkaSheet: buildSverkaSheet, countComponents: countComponents, countNonEmpty: countNonEmpty, sumQty: sumQty, countByColumn: countByColumn, sumByEtalon: sumByEtalon, comparePartMaps: comparePartMaps, findColumnByHeader: findColumnByHeader, findPartColumn: findPartColumn, collectColumnValues: collectColumnValues, }; });