// ==UserScript== // @name OpenText Matrix Cleaner Compact Safe // @namespace https://chat.openai.com/ // @version 2026.6.29.36 // @description Эволюционная автоматизация матриц OpenText: catalog, dry-run, rule engine, batch import, signer wizard // @match *://*/otcs/cs.exe* // @homepageURL https://github.com/ShapArt/Matrtix-Cleaner // @supportURL https://github.com/ShapArt/Matrtix-Cleaner/issues // @updateURL https://raw.githubusercontent.com/ShapArt/Matrtix-Cleaner/main/dist/matrix-cleaner.user.js // @downloadURL https://raw.githubusercontent.com/ShapArt/Matrtix-Cleaner/main/dist/matrix-cleaner.user.js // @run-at document-idle // @grant GM_addStyle // @grant unsafeWindow // ==/UserScript== /** Tampermonkey sandbox: `window` !== `unsafeWindow`; API и страница — на host window. */ function __otMatrixCleanerHost() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } (function () { 'use strict'; const CONFIG = { version: '8.0.0', requiredAffiliation: 'Группа Черкизово', partnerAliases: ['partner_id', 'partners_internal_id'], operationTypes: { REPLACE_APPROVER: 'replace_approver', REMOVE_APPROVER: 'remove_approver', REPLACE_SIGNER: 'replace_signer', ADD_SIGNER_BUNDLE: 'add_signer_bundle', CHANGE_LIMITS: 'change_limits', EXPAND_LEGAL_ENTITIES: 'expand_legal_entities', EXPAND_SITES: 'expand_sites', PATCH_DOC_TYPES: 'patch_doc_types', ADD_DOC_TYPE_TO_MATCHING_ROWS: 'add_doc_type_to_matching_rows', ADD_CHANGE_CARD_FLAG_TO_MATCHING_ROWS: 'add_change_card_flag_to_matching_rows', ADD_LEGAL_ENTITY_TO_MATCHING_ROWS: 'add_legal_entity_to_matching_rows', REMOVE_COUNTERPARTY: 'remove_counterparty_from_rows', DELETE_IF_SINGLE_COUNTERPARTY: 'delete_rows_if_single_counterparty', FIND_COUNTERPARTY_EVERYWHERE: 'find_counterparty_everywhere', FIND_USER_EVERYWHERE: 'find_user_everywhere', CHECKLIST_ROUTE_FAILURE: 'checklist_route_failure', CHECKLIST_CARD_VALIDATION: 'checklist_card_validation', CHECKLIST_SIGNING_RULES: 'checklist_signing_rules', MATRIX_AUDIT: 'matrix_audit', }, actionTypes: { REMOVE_TOKEN: 'remove-token', DELETE_ROW: 'delete-row', ADD_ROW: 'add-row', PATCH_ROW: 'patch-row', SKIP: 'skip', MANUAL_REVIEW: 'manual-review', }, status: { OK: 'ok', SKIPPED: 'skipped', ERROR: 'error', AMBIGUOUS: 'ambiguous', MANUAL_REVIEW: 'manual review required', }, safety: { defaultMaxAffectedRows: 200, defaultSkipExclude: true, defaultRequireDraft: true, defaultFailOnUnknownRunningSheets: true, }, selectors: { matrixTable: '#sc_ApprovalMatrix', matrixRows: '#sc_ApprovalMatrix tbody tr[itemid], #sc_ApprovalMatrix tbody tr[itemID]', matrixStatus: '#sc_approvalmatrixStatus', matrixForm: '#sc_approvalForm', matrixSaveBtn: 'button[onclick*="sc_submitMatrix"]', matrixFilterCell: '#sc_ApprovalMatrix thead .sc_filter.partner, #sc_ApprovalMatrix thead .sc_filter', matrixPartnerFilterCell: '#sc_ApprovalMatrix thead td.sc_filter.partner', matrixFilterPopup: '.sc_tableFilter', matrixFilterSearch: '#sc_filterForFilterValues', matrixFilterOptions: '.sc_filterPropsList li', matrixFilterCheckboxes: '.sc_filterPropsList input[type="checkbox"]', matrixFilterApplyButtons: '.sc_tableFilter .sc_filterButton button', listTable: '#browseViewCoreTable', listRows: '#browseViewCoreTable tr.browseRow1, #browseViewCoreTable tr.browseRow2', listName: 'a.browseItemNameContainer[data-otname="itemContainer"]', listNodeId: 'input[name="nodeID"][data-otname="objSelector"]', popupSearchName: '#Partner_Name', popupSearchBtn: '#searchBtn', popupGrid: '#reportGrid', popupGridRows: '#reportGrid tr.BrowseRow1, #reportGrid tr.BrowseRow2', popupPartnerCheckbox: 'input.partneritem', popupSelectBtn: '#selectpartners', }, operationLabels: { remove_counterparty_from_rows: 'Удаление контрагента из строк', delete_rows_if_single_counterparty: 'Удаление строки при единственном контрагенте', replace_approver: 'Замена согласующего', remove_approver: 'Снятие согласующего', replace_signer: 'Замена подписанта', add_signer_bundle: 'Добавление подписанта (4 строки)', change_limits: 'Изменение лимитов', expand_legal_entities: 'Расширение юрлиц', expand_sites: 'Расширение площадок', patch_doc_types: 'Правка типов документов (legacy)', add_doc_type_to_matching_rows: 'Массово: добавить тип документа', add_change_card_flag_to_matching_rows: 'Массово: признак карточки', add_legal_entity_to_matching_rows: 'Массово: добавить юрлицо', }, }; const state = { panel: null, logEl: null, statsEl: null, riskBadgeEl: null, triageEl: null, tabEl: null, running: false, stopRequested: false, plan: [], lastReport: [], logs: [], logFilter: 'all', partnerCatalog: [], matrixCatalog: [], selectedPartnerName: '', selectedMatrixName: '', columnIdx: null, filterDiagnostics: null, lastApplySnapshot: null, mode: 'matrix', booted: false, runningSheetsGuardHintLogged: false, xlsxLoaderPromise: null, signerPresetConfig: { presetName: 'configurable_4_row_bundle', enabled: true, rows: [ { label: 'row_1', required: true, mapping: {} }, { label: 'row_2', required: true, mapping: {} }, { label: 'row_3', required: true, mapping: {} }, { label: 'row_4', required: true, mapping: {} }, ], confidence: 'unknown', source: 'manual-config', }, }; function hostWindow() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } function sc() { return hostWindow().sc_ApprovalMatrix; } function $() { return hostWindow().jQuery || window.jQuery; } function normalize(text) { return String(text || '') .replace(/\u00A0/g, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function unique(arr) { return Array.from(new Set(arr)); } function wait(ms) { return new Promise(resolve => setTimeout(resolve, ms || 0)); } function timestamp() { const now = new Date(); const pad = v => String(v).padStart(2, '0'); return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; } function isMatrixPage() { try { const url = new URL(window.location.href); const action = url.searchParams.get('objAction') || ''; if (/OpenMatrix/i.test(action)) return true; } catch (_) {} return Boolean(document.querySelector(CONFIG.selectors.matrixTable)); } function isMatrixCatalogPage() { if (document.querySelector(CONFIG.selectors.listTable)) return true; const title = normalize(document.title); return title.indexOf('матриц') >= 0 && title.indexOf('согласования') >= 0; } function log(message, kind) { const type = kind || 'info'; const line = `[${new Date().toLocaleTimeString()}] ${message}`; state.logs.unshift({ type, line }); renderLogBox(); } function getAmbiguousLogEntries() { const rows = splitReportBuckets(state.lastReport).ambiguous; return rows .map((row, idx) => ({ type: 'warn', line: `[AMB ${idx + 1}] ${row.operationType || '-'} · ${row.actionType || '-'} · ${row.reason || row.message || 'manual review required'}`, })) .reverse(); } function getVisibleLogs() { if (state.logFilter === 'ambiguous') return getAmbiguousLogEntries(); return state.logs.slice(); } function renderLogBox() { if (!state.logEl) return; const logs = getVisibleLogs(); state.logEl.innerHTML = ''; logs.slice(0, 500).forEach(item => { const el = document.createElement('div'); el.className = `mc-log mc-log--${item.type || 'info'}`; el.textContent = item.line || ''; state.logEl.appendChild(el); }); } function setLogFilter(mode) { state.logFilter = mode === 'ambiguous' ? 'ambiguous' : 'all'; if (state.panel) { state.panel.querySelectorAll('[data-log-filter]').forEach(btn => { btn.classList.toggle('is-active', btn.getAttribute('data-log-filter') === state.logFilter); }); } renderLogBox(); } function toggleRiskBadgeFilter() { const severity = getTriageSeverity(); const target = (severity === 'warn' || severity === 'error') ? (state.logFilter === 'ambiguous' ? 'all' : 'ambiguous') : 'all'; setLogFilter(target); return state.logFilter; } async function triggerRiskBadgeCopy() { return copyAmbiguousToClipboard(); } async function triggerRiskBadgeCopyErrors() { return copyErrorsToClipboard(); } async function triggerRiskBadgeCopySkipped() { return copySkippedToClipboard(); } async function handleRiskBadgeClick(event) { if (event && event.shiftKey) { if (event.preventDefault) event.preventDefault(); return triggerRiskBadgeCopyErrors(); } if (event && event.altKey) { if (event.preventDefault) event.preventDefault(); return triggerRiskBadgeCopySkipped(); } return toggleRiskBadgeFilter(); } function setStats(text) { if (state.statsEl) state.statsEl.textContent = text; renderStatsSeverity(); } function renderStatsSeverity() { const severity = getTriageSeverity(); if (state.statsEl) { state.statsEl.classList.remove('mc-stats--ok', 'mc-stats--warn', 'mc-stats--error'); state.statsEl.classList.add(`mc-stats--${severity}`); } if (state.riskBadgeEl) { state.riskBadgeEl.classList.remove('mc-risk-badge--ok', 'mc-risk-badge--warn', 'mc-risk-badge--error'); state.riskBadgeEl.classList.add(`mc-risk-badge--${severity}`); state.riskBadgeEl.textContent = `risk: ${severity}`; state.riskBadgeEl.title = [ `Risk level: ${severity}`, 'Click: toggle log all/ambiguous', 'Double-click: copy ambiguous (TSV)', 'Shift+Click: copy errors (TSV)', 'Alt+Click: copy skipped (TSV)', ].join('\n'); } } function closeRiskHelpPop() { if (!state.panel) return; const pop = state.panel.querySelector('#mc-risk-help-pop'); if (!pop) return; const wasOpen = !pop.hidden; pop.hidden = true; if (wasOpen) { const helpBtn = state.panel.querySelector('#mc-risk-help'); if (helpBtn && typeof helpBtn.focus === 'function') { window.requestAnimationFrame(() => { try { helpBtn.focus(); } catch (_) {} }); } } } function closeMatrixPanel() { if (!state.panel) return; state.panel.classList.remove('mc-panel--open'); closeRiskHelpPop(); const openBtn = document.getElementById('mc-open-btn'); if (openBtn && typeof openBtn.focus === 'function') { try { openBtn.focus(); } catch (_) {} } } function isMatrixPanelOpen() { return Boolean(state.panel && state.panel.classList.contains('mc-panel--open')); } function toggleRiskHelpPop() { if (!state.panel) return; const pop = state.panel.querySelector('#mc-risk-help-pop'); if (!pop) return; pop.hidden = !pop.hidden; if (!pop.hidden) { const closeBtn = pop.querySelector('[data-role="risk-help-close"]'); if (closeBtn && typeof closeBtn.focus === 'function') { window.requestAnimationFrame(() => { try { closeBtn.focus(); } catch (_) {} }); } } else { const helpBtn = state.panel.querySelector('#mc-risk-help'); if (helpBtn && typeof helpBtn.focus === 'function') { window.requestAnimationFrame(() => { try { helpBtn.focus(); } catch (_) {} }); } } } function setRunning(running) { state.running = running; const root = state.panel; if (!root) return; root.querySelectorAll('[data-role="run"], [data-role="preview"], [data-role="refresh"], [data-role="batch-preview"], [data-role="batch-run"]') .forEach(btn => { btn.disabled = running; }); const stop = root.querySelector('[data-role="stop"]'); if (stop) stop.disabled = !running; } function ensureMatrixInit() { const matrix = sc(); const jq = $(); if (!matrix) throw new Error('sc_ApprovalMatrix не найден.'); if ((!matrix.element || !matrix.element.length) && typeof jq === 'function') matrix.element = jq(CONFIG.selectors.matrixTable); if ((!matrix.cols || !matrix.cols.length) && typeof matrix.initCols === 'function') matrix.initCols(); if (!matrix.filter || !matrix.filter.colsFilterArray || !matrix.filter.colsFilterArray.length) { if (typeof matrix.initFilters === 'function') matrix.initFilters(); } if (!matrix.visibleItems || !matrix.visibleItems.length) { if (typeof matrix.filterItems === 'function') matrix.filterItems(); } return matrix; } async function waitForReady(maxMs) { const deadline = Date.now() + (maxMs || 30000); while (Date.now() < deadline) { if (document.querySelector(CONFIG.selectors.matrixRows) && sc() && $()) return true; await wait(250); } throw new Error('Матрица не готова: не найдены строки, sc_ApprovalMatrix или jQuery.'); } function matrixRows() { return Array.from(document.querySelectorAll(CONFIG.selectors.matrixRows)); } function isVisibleRow(row) { if (!row) return false; const style = getComputedStyle(row); return row.offsetParent !== null && style.display !== 'none' && style.visibility !== 'hidden'; } function visibleRows() { return matrixRows().filter(isVisibleRow); } function getRowsByItemId(itemId) { return Array.from(document.querySelectorAll(`${CONFIG.selectors.matrixTable} tbody tr[itemid="${String(itemId)}"], ${CONFIG.selectors.matrixTable} tbody tr[itemID="${String(itemId)}"]`)); } function pickPreferredRow(rows, options) { const opts = options || {}; let candidates = Array.isArray(rows) ? rows.slice() : []; if (!candidates.length) return null; if (opts.preferEdit === true) { const editRows = candidates.filter(row => row.classList.contains('sc_editMode')); if (editRows.length) candidates = editRows; } else if (opts.preferEdit === false) { const viewRows = candidates.filter(row => !row.classList.contains('sc_editMode')); if (viewRows.length) candidates = viewRows; } const visible = candidates.filter(isVisibleRow); return visible[0] || candidates[0] || null; } function getRowByItemId(itemId, options) { return pickPreferredRow(getRowsByItemId(itemId), options); } function findItemIdByRecId(recId) { const matrix = sc(); if (!matrix || !Array.isArray(matrix.mRecsID)) return -1; return matrix.mRecsID.indexOf(recId); } function getRecIdByItemId(itemId) { const matrix = sc(); if (!matrix || !Array.isArray(matrix.mRecsID)) return null; return matrix.mRecsID[itemId]; } function getRowNo(row) { if (!row) return ''; const cells = Array.from(row.querySelectorAll('td')); if (!cells.length) return ''; return String(cells[cells.length - 1].textContent || '').replace(/\s+/g, ' ').trim(); } function getPartnerColumnIdx() { const matrix = ensureMatrixInit(); if (matrix.elementsFiltr && typeof matrix.elementsFiltr.get === 'function') { for (const alias of CONFIG.partnerAliases) { const idx = matrix.elementsFiltr.get(alias); if (idx !== undefined && idx !== null && Number.isFinite(Number(idx))) return Number(idx); } } if (Array.isArray(matrix.cols)) { const idx = matrix.cols.findIndex(col => col && CONFIG.partnerAliases.includes(col.alias)); if (idx >= 0) return idx; } throw new Error('Не удалось определить колонку «Контрагент».'); } function getPartnerColumnAlias(columnIdx) { const matrix = ensureMatrixInit(); const col = matrix.cols && matrix.cols[columnIdx] ? matrix.cols[columnIdx] : null; return col && col.alias ? String(col.alias) : CONFIG.partnerAliases[0]; } function findPartnerFilterCell(columnIdx) { const idx = String(columnIdx); const direct = document.querySelector(`${CONFIG.selectors.matrixPartnerFilterCell}[itemcolidx="${idx}"], ${CONFIG.selectors.matrixPartnerFilterCell}[itemColIdx="${idx}"]`); if (direct) return direct; const partnerCells = Array.from(document.querySelectorAll(CONFIG.selectors.matrixPartnerFilterCell)); const byIdx = partnerCells.find(cell => String(cell.getAttribute('itemcolidx') || cell.getAttribute('itemColIdx') || '') === idx); if (byIdx) return byIdx; const generic = Array.from(document.querySelectorAll(`${CONFIG.selectors.matrixTable} thead td.sc_filter[itemcolidx="${idx}"], ${CONFIG.selectors.matrixTable} thead td.sc_filter[itemColIdx="${idx}"]`)); return generic.find(cell => !cell.classList.contains('condition')) || generic[0] || null; } function getFilterPopup() { return document.querySelector(CONFIG.selectors.matrixFilterPopup); } function openNativePartnerFilter(columnIdx) { const matrix = ensureMatrixInit(); const jq = $(); const cell = findPartnerFilterCell(columnIdx); if (!cell) throw new Error('Partner filter header cell was not found.'); let popup = null; let lastError = null; for (let attempt = 0; attempt < 3; attempt += 1) { if (typeof matrix.filterHide === 'function') { try { matrix.filterHide(); } catch (error) { lastError = error; } } cell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); popup = getFilterPopup(); if (popup) break; if (typeof matrix.filterShow === 'function' && typeof jq === 'function') { try { hostWindow().event = { target: cell }; matrix.filterShow(jq(cell)); popup = getFilterPopup(); if (popup) break; } catch (error) { lastError = error; } } } if (!popup) { const suffix = lastError && lastError.message ? ` ${lastError.message}` : ''; throw new Error(`Native OpenText filter popup was not opened.${suffix}`); } return { cell, popup }; } function setNativeFilterSearch(popup, query) { const input = popup.querySelector(CONFIG.selectors.matrixFilterSearch) || document.querySelector(CONFIG.selectors.matrixFilterSearch); if (!input) return false; input.value = String(query || ''); ['input', 'change', 'keyup'].forEach(type => { input.dispatchEvent(new Event(type, { bubbles: true, cancelable: true })); }); return true; } function choosePartnerFilterCheckboxes(popup, partnerEntry) { const wantedIds = new Set((partnerEntry.ids || []).map(id => String(Math.abs(Number(id))))); const wantedName = normalize(partnerEntry.name || ''); const checkboxes = Array.from((popup || document).querySelectorAll(CONFIG.selectors.matrixFilterCheckboxes)); const selectedValues = []; checkboxes.forEach(input => { if (!input.value) { input.checked = false; return; } const label = input.closest('label'); const labelText = normalize(label ? label.textContent : ''); const value = String(input.value || ''); const byId = wantedIds.has(String(Math.abs(Number(value)))); const byExactName = wantedName && labelText === wantedName; const checked = byId || byExactName; input.checked = checked; if (checked) selectedValues.push(value); }); return unique(selectedValues); } function verifyPartnerFilterSelection(popup, selectedValues) { const selected = new Set((selectedValues || []).map(String)); const checked = Array.from((popup || document).querySelectorAll(CONFIG.selectors.matrixFilterCheckboxes)) .filter(input => input.checked && input.value) .map(input => String(input.value || '')); const unexpected = checked.filter(value => !selected.has(value)); const missing = Array.from(selected).filter(value => checked.indexOf(value) < 0); return { ok: selected.size > 0 && !unexpected.length && !missing.length, checkedValues: checked, unexpected, missing, }; } function clickNativeFilterApply(popup) { const matrix = ensureMatrixInit(); const buttons = Array.from((popup || document).querySelectorAll(CONFIG.selectors.matrixFilterApplyButtons)); const applyText = normalize(matrix.lang && matrix.lang.apply ? matrix.lang.apply : 'apply'); const button = buttons.find(btn => normalize(btn.textContent || '') === applyText) || buttons.find(btn => /apply|примен/i.test(String(btn.textContent || ''))) || buttons[0]; if (button) { button.click(); return 'button'; } if (typeof matrix.filterApply === 'function') { if (typeof matrix.filterHide === 'function') matrix.filterHide(); matrix.filterApply(); return 'api'; } throw new Error('Native filter Apply button was not found.'); } function applyPartnerFilterInternal(partnerEntry, reason) { const matrix = ensureMatrixInit(); const columnIdx = state.columnIdx != null ? state.columnIdx : getPartnerColumnIdx(); matrix.filter.colsFilterArray[columnIdx] = partnerEntry.ids.map(String); matrix.filterItems(); const diagnostics = { mode: 'internal_fallback', reason: reason || '', columnIdx, columnAlias: getPartnerColumnAlias(columnIdx), matchedIds: partnerEntry.ids.map(String), popupOpened: false, searched: false, appliedBy: 'filterItems', visibleRows: visibleRows().length, }; state.filterDiagnostics = diagnostics; return { rows: visibleRows(), diagnostics }; } function getPartnerIdsByItemId(itemId, columnIdx) { const raw = sc().items && sc().items[itemId] ? sc().items[itemId][columnIdx] : null; if (!Array.isArray(raw)) return []; return raw.map(v => Number(v)).filter(Number.isFinite); } function getPartnerNamesFromSignedIds(ids) { const matrix = sc(); return ids .map(v => Math.abs(Number(v))) .filter(Boolean) .map(id => matrix.partnerCacheObject && matrix.partnerCacheObject[id] ? matrix.partnerCacheObject[id] : String(id)); } function getConditionBySignedIds(ids) { if (!ids.length) return ''; return Number(ids[0]) < 0 ? 'Исключить' : 'Использовать'; } function collectPartnerCatalog() { const matrix = ensureMatrixInit(); const columnIdx = getPartnerColumnIdx(); const bucket = {}; state.columnIdx = columnIdx; matrix.items.forEach(item => { const raw = item && item[columnIdx]; if (!Array.isArray(raw)) return; raw.forEach(value => { const absId = Math.abs(Number(value)); const name = matrix.partnerCacheObject && matrix.partnerCacheObject[absId] ? matrix.partnerCacheObject[absId] : String(absId); if (!absId || !name) return; const key = normalize(name); if (!bucket[key]) bucket[key] = { key, name: String(name).trim(), ids: [], affiliation: CONFIG.requiredAffiliation }; bucket[key].ids.push(absId); }); }); if (matrix.filtrCol && typeof matrix.filtrCol.get === 'function') { (matrix.filtrCol.get(columnIdx) || []).forEach(item => { const absId = Math.abs(Number(item.DataID != null ? item.DataID : item.id)); const name = String(item.name || item.title || '').trim(); if (!absId || !name) return; const key = normalize(name); if (!bucket[key]) bucket[key] = { key, name, ids: [], affiliation: CONFIG.requiredAffiliation }; bucket[key].ids.push(absId); }); } state.partnerCatalog = Object.keys(bucket).map(key => ({ key: bucket[key].key, name: bucket[key].name, ids: unique(bucket[key].ids).sort((a, b) => a - b), affiliation: bucket[key].affiliation || CONFIG.requiredAffiliation, })).sort((l, r) => l.name.localeCompare(r.name, 'ru')); return state.partnerCatalog; } function resolvePartnerByName(name) { const key = normalize(name); return state.partnerCatalog.find(entry => entry.key === key) || null; } /** Первый контрагент из каталога, чьё имя встречается в тексте видимых строк (не «первый в списке»). */ function pickPartnerEntryVisibleInMatrix(catalog) { if (!Array.isArray(catalog) || !catalog.length) return null; const rows = visibleRows(); if (!rows.length) return null; const blob = normalize(rows.map(r => String(r.textContent || '')).join('\n')); for (let i = 0; i < catalog.length; i += 1) { const name = String(catalog[i].name || '').trim(); if (!name) continue; const key = normalize(name); if (key && blob.indexOf(key) >= 0) return catalog[i]; } return null; } function operationTypeLabel(type) { return (CONFIG.operationLabels && CONFIG.operationLabels[type]) ? CONFIG.operationLabels[type] : String(type || ''); } /** Черновик операций из свободного текста заявки (без JSON). */ function parseFreeformRequestText(rawText) { const text = String(rawText || '').trim(); if (!text) { return { confidence: 0, operations: [], reasons: ['Пустой текст.'] }; } const lower = text.toLowerCase(); const operations = []; const reasons = []; const pickName = (re) => { const m = text.match(re); return m && m[1] ? m[1].replace(/\s+/g, ' ').trim() : ''; }; if (/замен[а-я]*\s+подписант|подписант[а-я]*\s+с|replace.*signer/i.test(text)) { operations.push({ type: CONFIG.operationTypes.REPLACE_SIGNER, matrixName: document.title, scope: {}, filters: {}, payload: { currentSigner: pickName(/текущ[а-я]*\s*[:]?\s*([^\n,;]+)/i) || pickName(/с\s+([^\n,]+?)\s+на/i), newSigner: pickName(/на\s+([^\n,;]+?)(?:\s|$|\.)/i) || pickName(/нов[а-я]*\s*[:]?\s*([^\n,;]+)/i) }, options: { sourceRule: 'freeform_text' }, }); reasons.push('Найден сценарий замены подписанта.'); } if (/добавить\s+тип|тип\s+документ|add\s+doc/i.test(text)) { const doc = pickName(/тип[а-я]*\s*[:]?\s*([^\n,;]+)/i) || pickName(/«([^»]+)»/); operations.push({ type: CONFIG.operationTypes.ADD_DOC_TYPE_TO_MATCHING_ROWS, matrixName: document.title, scope: {}, filters: { rowGroup: lower.indexOf('доп') >= 0 ? 'supplemental_rows' : 'all' }, payload: { newDocType: doc || 'Уточнить тип', rowGroup: lower.indexOf('доп') >= 0 ? 'supplemental_rows' : 'all', matchMode: 'any', requiredDocTypes: [], affiliation: CONFIG.requiredAffiliation }, options: { sourceRule: 'freeform_text' }, }); reasons.push('Найдено добавление типа документа (проверь поле вручную).'); } if (/юр[а-я]*\s*лиц|legal\s*ent/i.test(text)) { operations.push({ type: CONFIG.operationTypes.ADD_LEGAL_ENTITY_TO_MATCHING_ROWS, matrixName: document.title, scope: {}, filters: { rowGroup: 'all' }, payload: { legalEntity: pickName(/ооо[а-яa-z0-9«»\s-]{3,60}/i) || 'Уточнить юрлицо', matchMode: 'any', requiredDocTypes: [], affiliation: CONFIG.requiredAffiliation }, options: { sourceRule: 'freeform_text' }, }); reasons.push('Найдено упоминание юрлица (проверь название).'); } const conf = operations.length ? 0.55 + Math.min(0.35, text.length / 2000) : 0.2; if (!operations.length) reasons.push('Мало явных сигналов. Вставь тикет целиком или выбери сценарий вручную.'); return { confidence: conf, operations, reasons }; } function buildRequestDraft(rawText, options) { const opts = options || {}; const parsed = parseFreeformRequestText(rawText); const text = normalize(rawText || ''); const requiredMissingFields = []; let operation = parsed.operations[0] || null; if (!operation) { const hasCounterpartySignal = /контрагент|counterparty|partner/.test(text); const hasRemoveSignal = /удал|убра|remove/.test(text); const hasRouteSignal = /маршрут|лист согласования|не стро|route/.test(text); if (hasCounterpartySignal && hasRemoveSignal) { operation = normalizeOperation({ type: CONFIG.operationTypes.REMOVE_COUNTERPARTY, payload: { partnerName: opts.partnerName || '', affiliation: CONFIG.requiredAffiliation }, options: { skipExclude: true }, }); if (!operation.payload.partnerName) requiredMissingFields.push('counterparty name'); } else if (hasRouteSignal) { operation = normalizeOperation({ type: CONFIG.operationTypes.CHECKLIST_ROUTE_FAILURE, payload: { rawText: rawText || '' }, }); } } if (!operation) { if (/контрагент|counterparty|partner/.test(text) && /удал|убра|remove/.test(text)) { operation = normalizeOperation({ type: CONFIG.operationTypes.REMOVE_COUNTERPARTY, payload: { partnerName: opts.partnerName || '', affiliation: CONFIG.requiredAffiliation }, options: { skipExclude: true }, }); if (!operation.payload.partnerName) requiredMissingFields.push('counterparty name'); } else if (/маршрут|route|лист согласования|не стро/.test(text)) { operation = normalizeOperation({ type: CONFIG.operationTypes.CHECKLIST_ROUTE_FAILURE, payload: { rawText: rawText || '' }, }); } } if (!operation) requiredMissingFields.push('operation type'); const confidence = requiredMissingFields.length ? Math.min(parsed.confidence || 0.3, 0.49) : Math.max(parsed.confidence || 0.5, 0.55); return { confidence, reasons: (parsed.reasons || []).concat(requiredMissingFields.length ? ['missing_required_fields'] : []), requiredMissingFields, operation, autoApplyAllowed: false, suggestedFirstLineResponse: requiredMissingFields.length ? `Запросить недостающие данные: ${requiredMissingFields.join(', ')}.` : 'Построить preview в Matrix Cleaner и приложить JSON/CSV отчёт перед apply.', }; } function applyPartnerFilter(partnerEntry) { const matrix = ensureMatrixInit(); const columnIdx = state.columnIdx != null ? state.columnIdx : getPartnerColumnIdx(); if (String(window.location.protocol || '').toLowerCase() === 'file:') { return applyPartnerFilterInternal(partnerEntry, 'fixture/offline page: native UI filter skipped'); } const diagnostics = { mode: 'ui_first', reason: '', columnIdx, columnAlias: getPartnerColumnAlias(columnIdx), matchedIds: [], popupOpened: false, searched: false, appliedBy: '', visibleRows: 0, }; try { const opened = openNativePartnerFilter(columnIdx); diagnostics.popupOpened = true; diagnostics.searched = setNativeFilterSearch(opened.popup, partnerEntry.name); diagnostics.matchedIds = choosePartnerFilterCheckboxes(opened.popup, partnerEntry); if (!diagnostics.matchedIds.length) { throw new Error('Partner was not found in native filter checkbox list.'); } const selectionCheck = verifyPartnerFilterSelection(opened.popup, diagnostics.matchedIds); diagnostics.selectionCheck = selectionCheck; if (!selectionCheck.ok) { throw new Error(`Native filter checkbox selection mismatch: ${JSON.stringify(selectionCheck)}`); } diagnostics.appliedBy = clickNativeFilterApply(opened.popup); diagnostics.visibleRows = visibleRows().length; state.filterDiagnostics = diagnostics; return { rows: visibleRows(), diagnostics }; } catch (error) { log(`Counterparty column filter fallback: ${error.message}`, 'warn'); return applyPartnerFilterInternal(partnerEntry, error.message); } } function clearMatrixFilters() { const matrix = ensureMatrixInit(); if (typeof matrix.filterHide === 'function') { try { matrix.filterHide(); } catch (_) {} } if (typeof matrix.initFilters === 'function') matrix.initFilters(); if (matrix.element && typeof matrix.element.find === 'function') { matrix.element.find('.sc_filterHasCondition').removeClass('sc_filterHasCondition'); } else { document.querySelectorAll(`${CONFIG.selectors.matrixTable} .sc_filterHasCondition`).forEach(node => node.classList.remove('sc_filterHasCondition')); } if (typeof matrix.filterItems === 'function') matrix.filterItems(); state.filterDiagnostics = null; return { cleared: true, visibleRows: visibleRows().length }; } function switchRowMode(rowOrJq, dontSave) { const jq = $(); const row = rowOrJq && rowOrJq.jquery ? rowOrJq : jq(rowOrJq); if (!row.length) return jq(); const itemId = Number(row.attr('itemid') || row.attr('itemID')); const wasEditMode = row.hasClass('sc_editMode'); const result = sc().toggleItemRenderState(row, dontSave); if (result === false) return jq(); return jq(getRowByItemId(itemId, { preferEdit: dontSave ? !wasEditMode : false })); } function reindexAllItemRows() { const rows = Array.from(document.querySelectorAll(`${CONFIG.selectors.matrixTable} tbody > tr[itemid], ${CONFIG.selectors.matrixTable} tbody > tr[itemID]`)); const remap = new Map(); let nextItemId = 0; rows.forEach(row => { const raw = row.getAttribute('itemid') || row.getAttribute('itemID'); if (!remap.has(raw)) { remap.set(raw, nextItemId); nextItemId += 1; } row.setAttribute('itemid', remap.get(raw)); }); } function deleteLogicalRowByRecId(recId) { const matrix = sc(); const itemId = findItemIdByRecId(recId); if (itemId < 0) return false; const rows = getRowsByItemId(itemId); if (!rows.length) throw new Error('Не найдены DOM-строки для удаления.'); const deletedRecId = matrix.mRecsID[itemId]; matrix.items.splice(itemId, 1); matrix.itemsDel.push(deletedRecId); matrix.mRecsID.splice(itemId, 1); if (Array.isArray(matrix.mRecsStatus) && matrix.mRecsStatus.length > itemId) matrix.mRecsStatus.splice(itemId, 1); rows.forEach(row => row.remove()); reindexAllItemRows(); if (matrix.hoverActions && matrix.hoverActions.el && typeof matrix.hoverActionsHide === 'function') matrix.hoverActionsHide(); return true; } function removeTokens(editRow, matchedIds) { const ids = new Set(matchedIds.map(v => Math.abs(Number(v)))); const tokens = Array.from(editRow.querySelectorAll('td.attrAlias_partner_id li.token-input-token, td.attrAlias_partners_internal_id li.token-input-token')); let removed = 0; tokens.forEach(token => { const tokenIdRaw = token.getAttribute('partnerid'); const tokenId = tokenIdRaw ? Math.abs(Number(tokenIdRaw)) : null; if (!tokenId || !ids.has(tokenId)) return; const del = token.querySelector('.token-input-delete-token'); if (!del) return; del.click(); removed += 1; }); return removed; } function detectMatrixCatalog() { const rows = Array.from(document.querySelectorAll(CONFIG.selectors.listRows)); const catalog = []; rows.forEach((row, idx) => { const nameEl = row.querySelector(CONFIG.selectors.listName); const idEl = row.querySelector(CONFIG.selectors.listNodeId); if (!nameEl) return; const href = nameEl.getAttribute('href') || ''; catalog.push({ index: idx + 1, name: String(nameEl.textContent || '').trim(), objId: idEl ? String(idEl.value || '').trim() : '', openUrl: href, }); }); if (catalog.length) { state.matrixCatalog = catalog; return catalog; } const html = document.documentElement.innerHTML; const match = html.match(/DataStringToVariables\(\s*'((?:\\'|[^'])*)'\s*\);/); if (!match || !match[1]) { state.matrixCatalog = []; return []; } try { const payload = match[1] .replace(/\\'/g, '\'') .replace(/\\\//g, '/') .replace(/\\"/g, '"'); const json = JSON.parse(payload); const rowsFromJson = Array.isArray(json.myRows) ? json.myRows : []; state.matrixCatalog = rowsFromJson .filter(row => String(row.type) === '54703' || normalize(row.typeName).indexOf('матрица согласования') >= 0) .map((row, idx) => ({ index: idx + 1, name: String(row.name || '').trim(), objId: String(row.dataId || row.objid || ''), openUrl: String(row.link || ''), })); return state.matrixCatalog; } catch (_) { state.matrixCatalog = []; return []; } } function ensureDraftStatus() { const statusSelect = document.querySelector(CONFIG.selectors.matrixStatus); if (!statusSelect) return { ok: false, message: 'Не найден селектор статуса матрицы.' }; const value = String(statusSelect.value || '').toLowerCase(); if (value !== 'draft') return { ok: false, message: 'Матрица не в статусе «Черновик». Применение запрещено.' }; return { ok: true }; } function detectRunningSheetsState() { const statusSelect = document.querySelector(CONFIG.selectors.matrixStatus); const statusValue = normalize(statusSelect ? statusSelect.value || '' : ''); const statusText = normalize(statusSelect && statusSelect.options && statusSelect.selectedIndex >= 0 ? statusSelect.options[statusSelect.selectedIndex].text || '' : ''); const bodyText = normalize(document.body ? document.body.textContent || '' : ''); const approvalLinks = Array.from(document.querySelectorAll('a[href]')) .map(link => String(link.getAttribute('href') || '')) .filter(href => /approvallist|openapprovallist|approvalid|approval/i.test(href)); const runningTextSignals = [ 'лист согласования', 'запущен', 'запущенные листы', 'на согласовании', 'approvallist', 'approval id', ].filter(signal => bodyText.indexOf(normalize(signal)) >= 0); const activeStatus = Boolean(statusValue && statusValue !== 'draft' && statusValue !== 'черновик'); const hasRunningSheets = approvalLinks.length > 0 || runningTextSignals.length > 0 || activeStatus; return { known: true, hasRunningSheets, statusValue, statusText, evidence: { approvalLinks: approvalLinks.slice(0, 10), runningTextSignals, activeStatus, }, message: hasRunningSheets ? 'Найдены признаки уже запущенных листов/маршрута. Apply требует override.' : 'Признаков уже запущенных листов в текущем DOM не найдено.', }; } function isLikelyLiveOpenTextPage() { const protocol = String(window.location.protocol || '').toLowerCase(); if (protocol === 'file:' || protocol === 'about:' || protocol === 'data:') return false; const href = String(window.location.href || ''); if (/otcs|cs\.exe|opentext/i.test(href)) return true; const win = hostWindow(); return Boolean(win.sc && win.sc.urlPrefix && /^https?:/i.test(String(win.sc.urlPrefix || ''))); } function normalizeOperation(raw) { const op = raw || {}; return { type: String(op.type || '').trim(), matrixName: String(op.matrixName || document.title || '').trim(), scope: op.scope || {}, filters: op.filters || {}, payload: op.payload || {}, options: op.options || {}, }; } function cleanCellText(value) { return String(value == null ? '' : value) .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .replace(/;+\s*$/g, '') .trim(); } function getRowCellText(row, aliases) { const list = Array.isArray(aliases) ? aliases : [aliases]; for (const alias of list) { const cells = Array.from(row.querySelectorAll(`td.attrAlias_${alias}`)); const text = cleanCellText(cells.map(cell => cell.textContent || '').join('; ')); if (text && text !== '*' && !/^(пустое значение|empty value)$/i.test(text)) return text; } return ''; } function countRowNarrowingSignals(row) { if (!row) return { score: 0, signals: [] }; const signalDefs = [ ['document_type', ['document_type']], ['legal_entity', ['legal_entity', 'entity', 'juridical_person']], ['direction', ['direction']], ['function', ['functions']], ['category', ['category']], ['amount', ['sum_rub']], ['limit', ['limit_contract']], ['edo_mode', ['eds']], ['change', ['change']], ['partner_op', ['partner_op']], ['affiliation', ['affiliation']], ]; const signals = []; signalDefs.forEach(([name, aliases]) => { if (getRowCellText(row, aliases)) signals.push(name); }); return { score: signals.length, signals }; } function evaluateBroadnessRisk(row, remainingPartners, options) { const opts = options || {}; const minimumSignals = Number.isFinite(Number(opts.minimumNarrowingSignals)) ? Number(opts.minimumNarrowingSignals) : 2; const remaining = Array.isArray(remainingPartners) ? remainingPartners.filter(Boolean) : []; if (!remaining.length) { return { level: 'manual_review', reason: 'Counterparty removal would leave the row without a counterparty condition.', signals: [], score: 0, }; } if (remaining.some(name => normalize(name) === '*' || /пустое значение|empty value|null/i.test(String(name)))) { return { level: 'manual_review', reason: 'Remaining counterparty condition looks empty or wildcard-like.', signals: [], score: 0, }; } const counted = countRowNarrowingSignals(row); if (counted.score < minimumSignals) { return { level: 'manual_review', reason: `Too few narrowing conditions after counterparty removal (${counted.score}/${minimumSignals}).`, signals: counted.signals, score: counted.score, }; } return { level: 'ok', reason: '', signals: counted.signals, score: counted.score, }; } function planCounterpartyMutation(op, context) { const partnerName = op.payload.partnerName || op.payload.currentPartner || ''; const requiredAffiliation = CONFIG.requiredAffiliation; const providedAffiliation = String(op.payload.affiliation || '').trim(); if (providedAffiliation && normalize(providedAffiliation) !== normalize(requiredAffiliation)) { return [{ operationType: op.type, actionType: CONFIG.actionTypes.MANUAL_REVIEW, status: CONFIG.status.MANUAL_REVIEW, reason: `Аффилированность контрагента должна быть "${requiredAffiliation}". Передано: "${providedAffiliation}".`, }]; } const entry = resolvePartnerByName(partnerName); if (!entry) { return [{ actionType: CONFIG.actionTypes.MANUAL_REVIEW, status: CONFIG.status.MANUAL_REVIEW, reason: `Контрагент «${partnerName}» не найден в каталоге матрицы.` }]; } const filterResult = applyPartnerFilter(entry); const rows = filterResult.rows || []; const filterDiagnostics = filterResult.diagnostics || state.filterDiagnostics || {}; const skipExclude = op.options.skipExclude !== undefined ? Boolean(op.options.skipExclude) : CONFIG.safety.defaultSkipExclude; const actions = []; rows.forEach(row => { const itemId = Number(row.getAttribute('itemid') || row.getAttribute('itemID')); const recId = getRecIdByItemId(itemId); const rowNo = getRowNo(row); const signedIds = getPartnerIdsByItemId(itemId, state.columnIdx); const uniqueIds = unique(signedIds.map(v => Math.abs(Number(v))).filter(Boolean)); const matchedIds = uniqueIds.filter(id => entry.ids.indexOf(id) >= 0); const beforePartners = unique(getPartnerNamesFromSignedIds(signedIds)); const remainingPartners = beforePartners.filter(name => normalize(name) !== normalize(entry.name)); const condition = getConditionBySignedIds(signedIds); const base = { matrixName: op.matrixName || document.title || '', operationType: op.type, affiliation: requiredAffiliation, itemId, recId, recordId: recId, rowNo, condition, before: { partners: beforePartners.slice() }, after: {}, matchedIds, matchedPartnerName: entry.name, remainingPartners, filterMode: filterDiagnostics.mode || '', filterColumnAlias: filterDiagnostics.columnAlias || getPartnerColumnAlias(state.columnIdx), filterMatchedIds: (filterDiagnostics.matchedIds || []).slice ? filterDiagnostics.matchedIds.slice() : [], whyMatched: matchedIds.length ? `Counterparty filter matched ids: ${matchedIds.join(', ')}` : 'Counterparty filter returned the row, but partner ids did not match after re-check.', }; if (!matchedIds.length) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: 'Совпадение не найдено.', })); return; } if (skipExclude && condition === 'Исключить') { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: 'Строка пропущена: условие «Исключить».', })); return; } const onlyThisPartner = matchedIds.length === uniqueIds.length; if (op.type === CONFIG.operationTypes.DELETE_IF_SINGLE_COUNTERPARTY && onlyThisPartner) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.DELETE_ROW, status: CONFIG.status.OK, reason: 'Удаление строки: единственный контрагент в строке.', after: { deleted: true }, applyMode: 'ot_native_delete_row', })); return; } if (op.type === CONFIG.operationTypes.REMOVE_COUNTERPARTY) { if (onlyThisPartner && op.options.deleteIfSingle) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.DELETE_ROW, status: CONFIG.status.OK, reason: 'Удаление строки: режим deleteIfSingle.', after: { deleted: true }, applyMode: 'ot_native_delete_row', })); return; } if (onlyThisPartner) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: 'Единственный контрагент в строке: удаление токена пропущено.', })); return; } const broadnessRisk = evaluateBroadnessRisk(row, remainingPartners, op.options && op.options.broadnessGuard); if (broadnessRisk.level !== 'ok') { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.MANUAL_REVIEW, status: CONFIG.status.MANUAL_REVIEW, reason: broadnessRisk.reason, broadnessRisk, })); return; } actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.REMOVE_TOKEN, status: CONFIG.status.OK, broadnessRisk, reason: 'Будет удален контрагент из строки.', applyMode: 'ot_native_row_edit_token', after: { partners: remainingPartners.slice(), }, })); } }); return actions; } function planGenericManualReview(op, reason) { const batchMeta = op && op.options && op.options.batchMeta ? op.options.batchMeta : null; const extraReason = batchMeta && Array.isArray(batchMeta.reasons) && batchMeta.reasons.length ? ` Batch hints: ${batchMeta.reasons.join('; ')}.` : ''; return [{ operationType: op.type, actionType: CONFIG.actionTypes.MANUAL_REVIEW, status: CONFIG.status.MANUAL_REVIEW, reason: `${reason}${extraReason}`, before: {}, after: {}, }]; } function parseSemiList(value) { return unique(String(value || '') .split(/[;,]/) .map(v => String(v || '').trim()) .filter(Boolean)); } function getRowFacts(row) { const text = String(row ? row.textContent || '' : ''); const itemId = Number(row && (row.getAttribute('itemid') || row.getAttribute('itemID')) || 0); const rowNo = getRowNo(row); const docTypeText = row ? getRowCellText(row, ['document_type']) : ''; const legalEntityText = row ? getRowCellText(row, ['legal_entity', 'legal_entities', 'legal_entity_id', 'legal_entities_id']) : ''; const limitText = row ? getRowCellText(row, ['limit_contract']) : ''; const amountText = row ? getRowCellText(row, ['sum_rub']) : ''; const docTypes = parseSemiList(docTypeText || (text.match(/(?:типы?\s*документов?|doc\s*types?)[:\s-]*([^\n]+)/i) || [])[1] || text); const legalEntities = parseSemiList(legalEntityText || (text.match(/(?:юр\.?\s*лиц[а]?|legal\s*entities?)[:\s-]*([^\n]+)/i) || [])[1] || ''); const hasChangeCard = /ранее\s+подписан|change\s*card|карточк/i.test(text); return { row, text, itemId, rowNo, docTypes, legalEntities, limitText, amountText, hasChangeCard }; } function matchRowGroup(facts, rowGroup) { const group = String(rowGroup || 'all').toLowerCase(); const txt = normalize(facts.text); if (group === 'all') return true; if (group === 'main_contract_rows') return /основн|main contract|договор/.test(txt) && !/доп|дс|supplemental/.test(txt); if (group === 'supplemental_rows') return /доп|дс|supplemental/.test(txt); if (group === 'custom') return true; return true; } function hasTypesByMode(existing, required, mode) { const requiredNorm = required.map(normalize).filter(Boolean); if (!requiredNorm.length) return true; const existingNorm = existing.map(normalize); if (String(mode || 'all').toLowerCase() === 'any') { return requiredNorm.some(type => existingNorm.indexOf(type) >= 0); } return requiredNorm.every(type => existingNorm.indexOf(type) >= 0); } function patchRowText(row, beforeValue, afterValue) { if (!row) return false; const aliasesByKind = { docType: ['document_type'], legalEntity: ['legal_entity', 'legal_entities', 'legal_entity_id', 'legal_entities_id'], changeCard: ['change', 'note'], limits: ['limit_contract', 'sum_rub'], amount: ['sum_rub'], }; const kind = arguments.length > 3 ? arguments[3] : ''; const aliases = aliasesByKind[kind] || []; for (const alias of aliases) { const cell = row.querySelector(`td.attrAlias_${alias}`); if (!cell) continue; cell.innerHTML = String(afterValue || '') .split(/\s*;\s*/) .filter(Boolean) .map(value => `${value};`) .join('
'); return true; } const direct = row.querySelector('[data-field="doc-types"], [data-field="legal-entities"], [data-field="change-card-flag"]'); if (direct) { const source = 'value' in direct ? String(direct.value || '') : String(direct.textContent || ''); if (normalize(source) !== normalize(beforeValue)) { if ('value' in direct) direct.value = afterValue; else direct.textContent = afterValue; return true; } } const text = String(row.textContent || ''); if (!text) return false; const firstCell = row.querySelector('td:last-child') || row.querySelector('td'); if (!firstCell) return false; firstCell.textContent = `${firstCell.textContent || ''} | ${afterValue}`; return true; } function planDocTypeOrLegalEntityPatch(op, kind) { const rows = visibleRows(); const group = op.payload.rowGroup || op.filters.rowGroup || 'all'; const requiredDocTypes = parseSemiList(op.payload.requiredDocTypes || op.filters.requiredDocTypes || ''); const matchMode = String(op.payload.matchMode || op.filters.matchMode || 'all').toLowerCase(); const newDocType = String(op.payload.newDocType || op.payload.docType || '').trim(); const legalEntity = String(op.payload.legalEntity || op.payload.newLegalEntity || '').trim(); const changeCardFlag = String(op.payload.changeCardFlag || 'Ранее не подписан').trim(); const actions = []; rows.forEach(row => { const facts = getRowFacts(row); const base = { operationType: op.type, itemId: facts.itemId, recId: getRecIdByItemId(facts.itemId), rowNo: facts.rowNo, before: { docTypes: facts.docTypes.slice(), legalEntities: facts.legalEntities.slice(), hasChangeCard: facts.hasChangeCard, }, sourceRule: op.options.sourceRule || op.payload.sourceRule || '', }; if (!matchRowGroup(facts, group)) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: `Строка не входит в выбранный group=${group}.`, })); return; } if (!hasTypesByMode(facts.docTypes, requiredDocTypes, matchMode)) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: `Не выполнен doc type match (${matchMode.toUpperCase()}).`, })); return; } if (kind === 'docType') { if (!newDocType) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.MANUAL_REVIEW, status: CONFIG.status.MANUAL_REVIEW, reason: 'Не указан newDocType.', })); return; } if (facts.docTypes.map(normalize).indexOf(normalize(newDocType)) >= 0) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: `Тип документа "${newDocType}" уже присутствует.`, })); return; } actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.PATCH_ROW, status: CONFIG.status.OK, reason: `Будет добавлен тип документа "${newDocType}".`, after: { docTypes: facts.docTypes.concat([newDocType]) }, domPatch: { kind: 'docType', beforeValue: facts.docTypes.join('; '), afterValue: facts.docTypes.concat([newDocType]).join('; ') }, applyMode: 'fixture_dom_patch', rollbackHint: 'Remove the added document type from this row or restore before.docTypes from the report.', })); return; } if (kind === 'legalEntity') { if (!legalEntity) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.MANUAL_REVIEW, status: CONFIG.status.MANUAL_REVIEW, reason: 'Не указан legalEntity.', })); return; } if (facts.legalEntities.map(normalize).indexOf(normalize(legalEntity)) >= 0) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: `Юрлицо "${legalEntity}" уже присутствует.`, })); return; } actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.PATCH_ROW, status: CONFIG.status.OK, reason: `Будет добавлено юрлицо "${legalEntity}".`, after: { legalEntities: facts.legalEntities.concat([legalEntity]) }, domPatch: { kind: 'legalEntity', beforeValue: facts.legalEntities.join('; '), afterValue: facts.legalEntities.concat([legalEntity]).join('; ') }, applyMode: 'fixture_dom_patch', rollbackHint: 'Remove the added legal entity from this row or restore before.legalEntities from the report.', })); return; } if (kind === 'changeCard') { if (facts.hasChangeCard) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: 'Флаг изменения карточки уже задан.', })); return; } actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.PATCH_ROW, status: CONFIG.status.OK, reason: `Будет проставлен флаг "${changeCardFlag}".`, after: { changeCardFlag }, domPatch: { kind: 'changeCard', beforeValue: '', afterValue: changeCardFlag }, applyMode: 'fixture_dom_patch', rollbackHint: 'Remove the added change-card flag or restore the row from the before snapshot.', })); } }); return actions; } function planLimitPatch(op) { const requestedLimit = Number(op.payload.limitRows || op.payload.maxRows || op.options.maxRows || 0); const rows = requestedLimit > 0 ? visibleRows().slice(0, requestedLimit) : visibleRows(); const group = op.payload.rowGroup || op.filters.rowGroup || 'all'; const requiredDocTypes = parseSemiList(op.payload.requiredDocTypes || op.filters.requiredDocTypes || ''); const matchMode = String(op.payload.matchMode || op.filters.matchMode || 'all').toLowerCase(); const target = String(op.payload.target || op.payload.valueMode || 'limit').toLowerCase(); const nextValue = String(op.payload.value || op.payload.limit || op.payload.amount || '').trim(); const kind = target.indexOf('amount') >= 0 || target.indexOf('sum') >= 0 ? 'amount' : 'limit'; const actions = []; rows.forEach(row => { const facts = getRowFacts(row); const beforeValue = kind === 'amount' ? facts.amountText : facts.limitText; const base = { matrixName: op.matrixName || document.title || '', operationType: op.type, itemId: facts.itemId, recId: getRecIdByItemId(facts.itemId), recordId: getRecIdByItemId(facts.itemId), rowNo: facts.rowNo, before: { limit: facts.limitText, amount: facts.amountText, docTypes: facts.docTypes.slice() }, sourceRule: op.options.sourceRule || op.payload.sourceRule || '', whyMatched: `rowGroup=${group}, docTypeMatch=${matchMode}, target=${kind}`, }; if (!matchRowGroup(facts, group)) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: `Строка не входит в выбранный group=${group}.`, })); return; } if (!hasTypesByMode(facts.docTypes, requiredDocTypes, matchMode)) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: `Не выполнен doc type match (${matchMode.toUpperCase()}).`, })); return; } if (!nextValue) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.MANUAL_REVIEW, status: CONFIG.status.MANUAL_REVIEW, reason: 'Не указано новое значение лимита/суммы.', })); return; } if (normalize(beforeValue) === normalize(nextValue)) { actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.SKIP, status: CONFIG.status.SKIPPED, reason: `Значение ${kind} уже равно "${nextValue}".`, })); return; } actions.push(Object.assign(base, { actionType: CONFIG.actionTypes.PATCH_ROW, status: CONFIG.status.OK, reason: `Будет изменено поле ${kind}: "${beforeValue || '(пусто)'}" -> "${nextValue}".`, after: kind === 'amount' ? { amount: nextValue } : { limit: nextValue }, domPatch: { kind: kind === 'amount' ? 'amount' : 'limits', beforeValue, afterValue: nextValue }, applyMode: 'fixture_dom_patch', rollbackHint: `Restore ${kind} from before.${kind} in the apply snapshot/report.`, })); }); return actions; } function getProjectSignerPresetRows(op) { const payload = op.payload || {}; const currentSigner = payload.currentSigner || payload.currentApprover || ''; const newSigner = payload.newSigner || payload.newApprover || ''; const limit = payload.limit || payload.limits || ''; const amount = payload.amount || payload.amounts || ''; const legalEntities = parseSemiList(payload.legalEntities || payload.legalEntity || ''); const sites = parseSemiList(payload.sites || payload.site || ''); const docTypes = parseSemiList(payload.docTypes || payload.docType || ''); const base = { currentSigner, newSigner, legalEntities, sites, docTypes, }; return [ Object.assign({ rowKey: 'main_limit_edo', rowGroup: 'main_contract_rows', edoMode: 'edo', valueMode: 'limit', value: limit }, base), Object.assign({ rowKey: 'main_limit_non_edo', rowGroup: 'main_contract_rows', edoMode: 'non_edo', valueMode: 'limit', value: limit }, base), Object.assign({ rowKey: 'supp_amount_edo', rowGroup: 'supplemental_rows', edoMode: 'edo', valueMode: 'amount', value: amount }, base), Object.assign({ rowKey: 'supp_amount_non_edo', rowGroup: 'supplemental_rows', edoMode: 'non_edo', valueMode: 'amount', value: amount }, base), ]; } function planSignerBundle(op) { const rows = getProjectSignerPresetRows(op); if (rows.length !== 4) { return planGenericManualReview(op, 'Signer preset invalid: ожидается ровно 4 строки.'); } return rows.map((rowPayload, idx) => ({ operationType: op.type, actionType: CONFIG.actionTypes.ADD_ROW, status: CONFIG.status.OK, rowNo: `new-${idx + 1}`, reason: `Signer bundle row ${idx + 1}/4 (${rowPayload.rowKey}).`, sourceRule: op.options.sourceRule || 'project_default_4_rows', before: {}, after: rowPayload, generatedRow: rowPayload, applyMode: 'fixture_generated_row', rollbackHint: 'Remove the generated signer row if apply was incorrect.', })); } function collectApproverDirectory() { const out = new Map(); const push = (id, title) => { const num = Number(id); const name = String(title || '').trim(); if (!Number.isFinite(num) || !name) return; out.set(normalize(name), num); }; const win = hostWindow(); const matrix = sc(); if (matrix && matrix.userCacheObject) { Object.keys(matrix.userCacheObject).forEach(id => push(id, matrix.userCacheObject[id])); } ['sc_ModelUser', 'sc_ModelUser2'].forEach(key => { const model = win[key]; if (!model || !Array.isArray(model.items)) return; model.items.forEach(item => push(item.id, item.title)); }); return out; } function resolveApproverId(raw, directory) { if (raw == null) return null; const asNum = Number(raw); if (Number.isFinite(asNum) && asNum > 0) return asNum; const key = normalize(raw); if (!key) return null; return directory.get(key) || null; } function collectNativeApproverColumns(includeSigning) { const matrix = ensureMatrixInit(); const cols = []; if (!Array.isArray(matrix.cols)) return cols; matrix.cols.forEach((col, idx) => { if (!col || col.colType !== 'function') return; if (!includeSigning && (col.type === 'signing' || col.type === 'confirmation')) return; cols.push({ idx, type: col.type, title: col.title || '' }); }); return cols; } function planNativeApproverMutation(op) { const columns = collectNativeApproverColumns(false); if (!columns.length) { return planGenericManualReview(op, 'Не найдены подходящие колонки функций для OT-native replace/remove.'); } const directory = collectApproverDirectory(); const currentRaw = op.payload.currentApprover || op.payload.currentSigner || ''; const newRaw = op.payload.newApprover || op.payload.newSigner || ''; const currentId = resolveApproverId(currentRaw, directory); const newId = op.type === CONFIG.operationTypes.REMOVE_APPROVER ? null : resolveApproverId(newRaw, directory); if (!currentId) { return planGenericManualReview(op, `Не удалось определить ID текущего согласующего: ${currentRaw || '(пусто)'}`); } if (op.type !== CONFIG.operationTypes.REMOVE_APPROVER && !newId) { return planGenericManualReview(op, `Не удалось определить ID нового согласующего: ${newRaw || '(пусто)'}`); } return [{ operationType: op.type, actionType: CONFIG.actionTypes.PATCH_ROW, status: CONFIG.status.SKIPPED, reason: 'Будет выполнен OT-native replace/remove согласующего по performerList.', before: {}, after: { currentApproverId: currentId, newApproverId: newId, columns: columns.map(col => ({ idx: col.idx, type: col.type, title: col.title })), }, nativePatch: { currentId, newId, columns, }, applyMode: 'ot_native_performer_list', rollbackHint: 'Restore performerList values from the before snapshot or rerun the inverse approver operation.', }]; } function classifyOperationToPlan(op, context) { switch (op.type) { case CONFIG.operationTypes.REMOVE_COUNTERPARTY: case CONFIG.operationTypes.DELETE_IF_SINGLE_COUNTERPARTY: return planCounterpartyMutation(op, context); case CONFIG.operationTypes.ADD_SIGNER_BUNDLE: return planSignerBundle(op); case CONFIG.operationTypes.REPLACE_APPROVER: case CONFIG.operationTypes.REMOVE_APPROVER: return planNativeApproverMutation(op); case CONFIG.operationTypes.REPLACE_SIGNER: return planGenericManualReview(op, 'Для replace_signer auto-apply отключен: требуется отдельный signer mapping/подтверждение.'); case CONFIG.operationTypes.CHANGE_LIMITS: return planLimitPatch(op); case CONFIG.operationTypes.EXPAND_LEGAL_ENTITIES: case CONFIG.operationTypes.EXPAND_SITES: case CONFIG.operationTypes.PATCH_DOC_TYPES: return planGenericManualReview(op, 'Операция классифицирована, но требует row mapping из данных матрицы. Помечено для manual review.'); case CONFIG.operationTypes.ADD_DOC_TYPE_TO_MATCHING_ROWS: return planDocTypeOrLegalEntityPatch(op, 'docType'); case CONFIG.operationTypes.ADD_LEGAL_ENTITY_TO_MATCHING_ROWS: return planDocTypeOrLegalEntityPatch(op, 'legalEntity'); case CONFIG.operationTypes.ADD_CHANGE_CARD_FLAG_TO_MATCHING_ROWS: return planDocTypeOrLegalEntityPatch(op, 'changeCard'); case CONFIG.operationTypes.FIND_COUNTERPARTY_EVERYWHERE: case CONFIG.operationTypes.FIND_USER_EVERYWHERE: case CONFIG.operationTypes.CHECKLIST_ROUTE_FAILURE: case CONFIG.operationTypes.CHECKLIST_CARD_VALIDATION: case CONFIG.operationTypes.CHECKLIST_SIGNING_RULES: case CONFIG.operationTypes.MATRIX_AUDIT: return planGenericManualReview(op, 'Операция должна выполняться через v5 dedicated UI/API режим.'); default: return planGenericManualReview(op, 'Неизвестный тип операции.'); } } function buildRulePlan(operations, context) { const ops = operations.map(normalizeOperation); const entries = []; ops.forEach(op => { const part = classifyOperationToPlan(op, context); part.forEach(entry => entries.push(entry)); }); return entries; } async function executePlanEntry(entry, options) { if (entry.actionType === CONFIG.actionTypes.SKIP || entry.actionType === CONFIG.actionTypes.MANUAL_REVIEW) { return { status: entry.status, message: entry.reason }; } if (entry.actionType === CONFIG.actionTypes.DELETE_ROW) { if (sc().items.length === 1) return { status: CONFIG.status.SKIPPED, message: 'Нельзя удалить последнюю строку матрицы.' }; deleteLogicalRowByRecId(entry.recId); await wait(100); if (findItemIdByRecId(entry.recId) >= 0) throw new Error(`Строка ${entry.rowNo || entry.itemId}: удаление не подтвердилось.`); return { status: CONFIG.status.OK, message: `Строка ${entry.rowNo || entry.itemId} удалена.` }; } if (entry.actionType === CONFIG.actionTypes.REMOVE_TOKEN) { const jq = $(); const row = getRowByItemId(entry.itemId, { preferEdit: false }); const $row = jq(row); if (!$row.length) throw new Error(`Строка itemid=${entry.itemId} не найдена.`); let $editRow = switchRowMode($row, true); if (!$editRow.length || !$editRow.hasClass('sc_editMode')) throw new Error(`Строка ${entry.rowNo || entry.itemId}: не удалось открыть edit-mode.`); const removed = removeTokens($editRow.get(0), entry.matchedIds || []); if (!removed) { switchRowMode($editRow, true); throw new Error(`Строка ${entry.rowNo || entry.itemId}: токен контрагента не найден.`); } const $savedRow = switchRowMode($editRow, false); if (!$savedRow.length || $savedRow.hasClass('sc_editMode')) throw new Error(`Строка ${entry.rowNo || entry.itemId}: не удалось сохранить.`); await wait(150); return { status: CONFIG.status.OK, message: `Строка ${entry.rowNo || entry.itemId}: контрагент удален.` }; } if (entry.actionType === CONFIG.actionTypes.PATCH_ROW && entry.nativePatch) { const matrix = ensureMatrixInit(); const patch = entry.nativePatch; let affected = 0; matrix.items.forEach(item => { if (!Array.isArray(item)) return; patch.columns.forEach(col => { const cell = item[col.idx]; if (!cell || !Array.isArray(cell.performerList)) return; const original = cell.performerList.slice(); if (patch.newId == null) { cell.performerList = original.filter(id => Number(id) !== Number(patch.currentId)); } else { let changed = false; cell.performerList = original.map(id => { if (Number(id) === Number(patch.currentId)) { changed = true; return Number(patch.newId); } return id; }); if (!changed) return; } if (JSON.stringify(original) !== JSON.stringify(cell.performerList)) affected += 1; }); }); if (!affected) { return { status: CONFIG.status.SKIPPED, message: 'OT-native patch не нашёл совпадений performerList.' }; } if (typeof matrix.filterItems === 'function') matrix.filterItems(); return { status: CONFIG.status.OK, message: `OT-native patch применен. Изменено ячеек функций: ${affected}.` }; } if (entry.actionType === CONFIG.actionTypes.PATCH_ROW && entry.domPatch) { if (isLikelyLiveOpenTextPage() && !(options && options.allowDomPatchOnLive)) { return { status: CONFIG.status.MANUAL_REVIEW, message: 'Live DOM-only patch blocked: no confirmed OpenText native writer for this field yet. Use preview/report or pass allowDomPatchOnLive only in an explicit test profile.', }; } const row = getRowByItemId(entry.itemId, { preferEdit: false }); if (!row) return { status: CONFIG.status.SKIPPED, message: `Строка itemid=${entry.itemId} не найдена для patch.` }; const ok = patchRowText(row, entry.domPatch.beforeValue || '', entry.domPatch.afterValue || '', entry.domPatch.kind || ''); if (!ok) return { status: CONFIG.status.SKIPPED, message: `DOM patch для itemid=${entry.itemId} не применен.` }; return { status: CONFIG.status.OK, message: `DOM patch применен (itemid=${entry.itemId}, ${entry.domPatch.kind}).` }; } if (entry.actionType === CONFIG.actionTypes.ADD_ROW && entry.generatedRow) { const tbody = document.querySelector('#sc_ApprovalMatrix tbody'); if (!tbody) return { status: CONFIG.status.SKIPPED, message: 'Не найден tbody для добавления строки.' }; const template = visibleRows()[0]; if (!template) return { status: CONFIG.status.SKIPPED, message: 'Не найден template row для добавления.' }; const clone = template.cloneNode(true); clone.removeAttribute('itemid'); clone.removeAttribute('itemID'); clone.setAttribute('data-generated-row', '1'); const lastCell = clone.querySelector('td:last-child') || clone.querySelector('td'); if (lastCell) { const gr = entry.generatedRow || {}; lastCell.textContent = `[GENERATED] ${gr.rowKey || ''}; group=${gr.rowGroup || ''}; edo=${gr.edoMode || ''}; ${gr.valueMode || ''}=${gr.value || ''}; signer=${gr.newSigner || ''}`; } tbody.appendChild(clone); return { status: CONFIG.status.OK, message: `Generated row добавлена: ${entry.generatedRow.rowKey}.` }; } return { status: CONFIG.status.SKIPPED, message: 'Тип действия пока не исполняется автоматически.' }; } function resolveApplyMode(entry) { if (!entry) return ''; if (entry.applyMode) return entry.applyMode; if (entry.nativePatch) return 'ot_native_performer_list'; if (entry.domPatch) return 'fixture_dom_patch'; if (entry.generatedRow) return 'fixture_generated_row'; if (entry.actionType === CONFIG.actionTypes.REMOVE_TOKEN) return 'ot_native_row_edit_token'; if (entry.actionType === CONFIG.actionTypes.DELETE_ROW) return 'ot_native_delete_row'; return ''; } function toReportEntry(entry, result, dryRun) { const beforePartners = entry.before && Array.isArray(entry.before.partners) ? entry.before.partners.slice() : []; const matchedPartnerName = entry.matchedPartnerName || ''; return { matrixName: entry.matrixName || document.title || '', operationType: entry.operationType || '', itemId: entry.itemId != null ? entry.itemId : '', itemid: entry.itemId != null ? entry.itemId : '', recId: entry.recId != null ? entry.recId : '', recordId: entry.recordId != null ? entry.recordId : (entry.recId != null ? entry.recId : ''), rowNo: entry.rowNo || '', actionType: entry.actionType, status: result && result.status ? result.status : entry.status || CONFIG.status.SKIPPED, reason: entry.reason || '', whyMatched: entry.whyMatched || entry.reason || '', affiliation: entry.affiliation || CONFIG.requiredAffiliation, sourceRule: entry.sourceRule || '', skippedReason: (result && result.status === CONFIG.status.SKIPPED) ? (result.message || entry.reason || '') : '', ambiguousReason: ((result && String(result.status || '').indexOf('manual') >= 0) || String(entry.status || '').indexOf('manual') >= 0) ? (entry.reason || result.message || '') : '', message: dryRun ? `dry-run: ${entry.reason || ''}` : (result && result.message ? result.message : ''), before: entry.before || {}, after: entry.after || {}, condition: entry.condition || '', matchedPartnerName, remainingPartners: Array.isArray(entry.remainingPartners) ? entry.remainingPartners.slice() : ((entry.after && Array.isArray(entry.after.partners)) ? entry.after.partners.slice() : []), filterMode: entry.filterMode || '', filterColumnAlias: entry.filterColumnAlias || '', filterMatchedIds: Array.isArray(entry.filterMatchedIds) ? entry.filterMatchedIds.slice() : [], broadnessRisk: entry.broadnessRisk || null, applyMode: resolveApplyMode(entry), rollbackHint: entry.rollbackHint || buildRollbackHint(entry), error: result && result.status === CONFIG.status.ERROR ? (result.message || '') : '', // Legacy compatibility fields. originalPartners: beforePartners, removedPartner: matchedPartnerName, }; } function reportToCsv(report) { const headers = ['matrixName', 'operationType', 'itemId', 'itemid', 'recId', 'recordId', 'rowNo', 'actionType', 'status', 'reason', 'whyMatched', 'affiliation', 'sourceRule', 'skippedReason', 'ambiguousReason', 'message', 'condition', 'matchedPartnerName', 'remainingPartners', 'filterMode', 'filterColumnAlias', 'filterMatchedIds', 'broadnessRisk', 'applyMode', 'rollbackHint', 'error', 'before', 'after']; const escape = value => `"${String(value == null ? '' : value).replace(/"/g, '""')}"`; const lines = [headers.join(',')]; report.forEach(row => { lines.push([ row.matrixName, row.operationType, row.itemId, row.itemid, row.recId, row.recordId, row.rowNo, row.actionType, row.status, row.reason, row.whyMatched, row.affiliation, row.sourceRule, row.skippedReason, row.ambiguousReason, row.message, row.condition, row.matchedPartnerName, JSON.stringify(row.remainingPartners || []), row.filterMode, row.filterColumnAlias, JSON.stringify(row.filterMatchedIds || []), JSON.stringify(row.broadnessRisk || null), row.applyMode, row.rollbackHint, row.error, JSON.stringify(row.before || {}), JSON.stringify(row.after || {}), ].map(escape).join(',')); }); return lines.join('\n'); } function downloadText(filename, content, contentType) { const blob = new Blob([content], { type: contentType }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function buildDefaultOperationFromUi(options) { const opts = options || {}; const partnerNameInput = state.panel.querySelector('[data-field="partner-name"]'); const payloadJsonInput = state.panel.querySelector('[data-field="operation-payload-json"]'); const sourceRuleInput = state.panel.querySelector('[data-field="source-rule"]'); const typeSelect = state.panel.querySelector('[data-field="operation-type"]'); const deleteIfSingle = state.panel.querySelector('[data-field="delete-if-single"]'); const skipExclude = state.panel.querySelector('[data-field="skip-exclude"]'); const type = opts.type || (typeSelect ? typeSelect.value : CONFIG.operationTypes.REMOVE_COUNTERPARTY); const partnerName = opts.partnerName || (partnerNameInput ? partnerNameInput.value : ''); let payloadJson = {}; if (payloadJsonInput && String(payloadJsonInput.value || '').trim()) { try { payloadJson = JSON.parse(payloadJsonInput.value); } catch (error) { log(`Некорректный payload JSON: ${error.message}`, 'error'); } } const sourceRule = opts.sourceRule || (sourceRuleInput ? sourceRuleInput.value : ''); const op = normalizeOperation({ type, matrixName: document.title, scope: { pageMode: state.mode }, filters: {}, payload: Object.assign({}, payloadJson, opts.payload || {}, { partnerName }), options: { skipExclude: skipExclude ? skipExclude.checked : CONFIG.safety.defaultSkipExclude, deleteIfSingle: deleteIfSingle ? deleteIfSingle.checked : false, maxAffectedRows: opts.maxAffectedRows || CONFIG.safety.defaultMaxAffectedRows, forceApply: Boolean(opts.forceApply), sourceRule: sourceRule || (payloadJson && payloadJson.sourceRule) || '', }, }); const requiredAff = CONFIG.requiredAffiliation; if (!op.payload.affiliation) op.payload.affiliation = requiredAff; return op; } function collectSafetyOptions() { const maxRowsInput = state.panel.querySelector('[data-field="max-rows"]'); const requireDraftInput = state.panel.querySelector('[data-field="require-draft"]'); const allowUnknownRunningInput = state.panel.querySelector('[data-field="allow-unknown-running"]'); return { maxRows: Number(maxRowsInput ? maxRowsInput.value : CONFIG.safety.defaultMaxAffectedRows) || CONFIG.safety.defaultMaxAffectedRows, requireDraft: requireDraftInput ? requireDraftInput.checked : CONFIG.safety.defaultRequireDraft, allowUnknownRunning: allowUnknownRunningInput ? allowUnknownRunningInput.checked : false, }; } async function previewOperations(operations, options) { const opts = options || {}; if (state.running) return []; if (isMatrixPage()) { await waitForReady(); ensureMatrixInit(); collectPartnerCatalog(); } const plan = buildRulePlan(operations, {}); state.plan = plan; const report = plan.map(entry => toReportEntry(entry, null, true)); const summary = buildReportSummary(report); state.lastReport = report; setStats(`preview: ${summary.total} · actionable: ${summary.actionable} · ambiguous: ${summary.ambiguous}`); renderTriageCounters(); renderLogBox(); log(`Preview построен: ${plan.length} записей.`, 'ok'); plan.slice(0, 40).forEach(entry => log(`${entry.actionType}: ${entry.reason || ''}`, entry.actionType === CONFIG.actionTypes.MANUAL_REVIEW ? 'warn' : 'info')); if (plan.length > 40) log(`Показаны первые 40 записей из ${plan.length}.`, 'warn'); const addRowPlan = plan.filter(e => e.actionType === CONFIG.actionTypes.ADD_ROW); if (addRowPlan.length && isMatrixPage()) { const templateRow = document.querySelector(`${CONFIG.selectors.matrixTable} tbody tr[itemid], ${CONFIG.selectors.matrixTable} tbody tr[itemID]`); if (!templateRow) { log('Визуальное превью новых строк: в таблице нет ни одной строки-шаблона — ghost-строки не могут быть нарисованы.', 'warn'); } else { log(`Визуальное превью: ожидается ${addRowPlan.length} ghost-строк внизу таблицы матрицы (прокрутите вниз). Если не видно — откройте Advanced и блок «Визуальный diff v5», проверьте что включён preview.`, 'ok'); } } return report; } async function runOperations(operations, options) { const opts = options || {}; if (state.running) return []; const safety = collectSafetyOptions(); if (isMatrixPage() && safety.requireDraft) { const draft = ensureDraftStatus(); if (!draft.ok) { log(draft.message, 'error'); return []; } } if (isMatrixPage()) { const runningSheetsState = detectRunningSheetsState(); if (!(opts.allowRunningSheetsUnknown || safety.allowUnknownRunning) && runningSheetsState.hasRunningSheets === true && CONFIG.safety.defaultFailOnUnknownRunningSheets) { if (!state.runningSheetsGuardHintLogged) { state.runningSheetsGuardHintLogged = true; log(`${runningSheetsState.message} Для применения включите override или пользуйтесь только превью.`, 'warn'); } else { log('Применение остановлено: найдены признаки уже запущенных листов/маршрута.', 'warn'); } return []; } if (runningSheetsState.hasRunningSheets === false) { log(runningSheetsState.message, 'info'); } await waitForReady(); ensureMatrixInit(); collectPartnerCatalog(); } const plan = buildRulePlan(operations, {}); const actionable = plan.filter(entry => isActionableAction(entry.actionType)); if (actionable.length > safety.maxRows && !opts.overrideMaxRows) { log(`Превышен лимит затронутых строк (${actionable.length} > ${safety.maxRows}).`, 'error'); return []; } if (actionable.some(entry => entry.actionType === CONFIG.actionTypes.DELETE_ROW) && !opts.skipDeleteConfirm) { const ok = window.confirm(`Будет удалено строк: ${actionable.filter(a => a.actionType === CONFIG.actionTypes.DELETE_ROW).length}\nПродолжить?`); if (!ok) { log('Операция отменена пользователем.', 'warn'); return []; } } if (actionable.length) { state.lastApplySnapshot = buildApplySnapshot(plan, operations); if (!opts.skipSnapshotDownload) { downloadText(`ot-matrix-apply-snapshot-${timestamp()}.json`, JSON.stringify(state.lastApplySnapshot, null, 2), 'application/json;charset=utf-8'); } log(`Apply snapshot сохранён: ${state.lastApplySnapshot.entries.length} действий.`, 'ok'); } setRunning(true); state.stopRequested = false; const report = []; let okCount = 0; let errorCount = 0; let skippedCount = 0; try { for (let i = 0; i < plan.length; i += 1) { if (state.stopRequested) break; const entry = plan[i]; try { const result = await executePlanEntry(entry, opts); report.push(toReportEntry(entry, result, false)); if (result.status === CONFIG.status.OK) { okCount += 1; log(result.message, 'ok'); } else { skippedCount += 1; const statusText = String(result.status || ''); log(result.message, entry.actionType === CONFIG.actionTypes.MANUAL_REVIEW || statusText.indexOf('manual') >= 0 ? 'warn' : 'info'); } } catch (error) { report.push(toReportEntry(entry, { status: CONFIG.status.ERROR, message: error.message }, false)); errorCount += 1; log(error.message, 'error'); } } if (state.stopRequested) log('Выполнение остановлено пользователем.', 'warn'); state.lastReport = report; const summary = buildReportSummary(report); setStats(`ok: ${summary.ok} · skipped: ${summary.skipped} · error: ${summary.errors} · ambiguous: ${summary.ambiguous}`); renderTriageCounters(); renderLogBox(); return report; } finally { setRunning(false); state.stopRequested = false; } } async function runPartnerSearchDriver(partnerName, options) { const opts = options || {}; const result = { dryRun: Boolean(opts.dryRun), steps: [], selectedIds: [], status: 'pending', message: '', }; const push = (step, status, details) => { result.steps.push({ step, status, details: details || '' }); log(`[PartnerDriver] ${step}: ${status}${details ? ` (${details})` : ''}`, status === 'error' ? 'error' : 'info'); }; try { const winRef = hostWindow().sc && hostWindow().sc.partner && hostWindow().sc.partner.w && !hostWindow().sc.partner.w.closed ? hostWindow().sc.partner.w : null; let popup = winRef; if (!popup) { push('openPopup', 'ok'); if (opts.dryRun) { result.status = 'dry-run'; result.message = 'Dry-run завершен.'; return result; } popup = window.open(`${hostWindow().sc.urlPrefix}?func=zdoc.searchpartners&multiselect=1`, 'SimpleSearch', 'height=640,width=800,resizable=yes,menubar=no,scrollbars=yes'); } else { push('reusePopup', 'ok'); } if (!popup) throw new Error('Не удалось открыть popup поиска контрагента.'); let ready = false; const deadline = Date.now() + 15000; while (Date.now() < deadline) { try { if (popup.document && popup.document.querySelector(CONFIG.selectors.popupSearchName)) { ready = true; break; } } catch (_) {} await wait(150); } if (!ready) throw new Error('Popup не готов для поиска.'); push('fillName', 'ok', partnerName); if (opts.dryRun) { result.status = 'dry-run'; result.message = 'Dry-run завершен.'; return result; } popup.document.querySelector(CONFIG.selectors.popupSearchName).value = partnerName; popup.document.querySelector(CONFIG.selectors.popupSearchBtn).click(); push('runSearch', 'ok'); let rows = []; const tableDeadline = Date.now() + 15000; while (Date.now() < tableDeadline) { rows = Array.from(popup.document.querySelectorAll(CONFIG.selectors.popupGridRows)); if (rows.length) break; await wait(150); } if (!rows.length) throw new Error('Результаты поиска не найдены.'); push('waitGrid', 'ok', `rows=${rows.length}`); const first = rows[0]; const checkbox = first.querySelector(CONFIG.selectors.popupPartnerCheckbox); const dataCell = first.querySelector('td[data-dataid]'); if (!checkbox || !dataCell) throw new Error('Не найдены checkbox/data-dataid в строке результата.'); checkbox.checked = true; const id = String(dataCell.getAttribute('data-dataid') || ''); result.selectedIds.push(id); push('chooseRow', 'ok', id); const selectBtn = popup.document.querySelector(CONFIG.selectors.popupSelectBtn); if (!selectBtn) throw new Error('Кнопка «Выбрать» не найдена.'); selectBtn.click(); push('clickSelect', 'ok'); result.status = 'ok'; result.message = 'Контрагент выбран через popup.'; return result; } catch (error) { result.status = 'error'; result.message = error.message; push('driverFailed', 'error', error.message); return result; } } function parseDelimited(text) { const lines = String(text || '').split(/\r?\n/).filter(Boolean); if (!lines.length) return []; const delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : ','; const splitCsvLine = (line, delim) => { const out = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i += 1) { const ch = line[i]; if (ch === '"') { if (inQuotes && line[i + 1] === '"') { current += '"'; i += 1; } else { inQuotes = !inQuotes; } continue; } if (ch === delim && !inQuotes) { out.push(current); current = ''; continue; } current += ch; } out.push(current); return out.map(v => String(v || '').trim()); }; const cells = lines.map(line => splitCsvLine(line, delimiter)); const header = cells[0].map(h => normalize(h)); const body = cells.slice(1); return body.map(row => { const get = aliases => { const idx = header.findIndex(h => aliases.some(a => normalize(a) === h)); return idx >= 0 ? row[idx] : ''; }; return { type: get(['type', 'operation', 'операция']), matrixName: get(['matrix', 'matrixname', 'матрица']), currentPartner: get(['current_partner', 'current', 'текущий контрагент', 'контрагент']), newPartner: get(['new_partner', 'new', 'новый контрагент']), currentApprover: get(['current_approver', 'approver_current', 'текущий согласующий', 'согласующий текущий']), newApprover: get(['new_approver', 'approver_new', 'новый согласующий', 'согласующий новый']), currentSigner: get(['current_signer', 'signer_current', 'текущий подписант', 'подписант текущий']), newSigner: get(['new_signer', 'signer_new', 'новый подписант', 'подписант новый']), comment: get(['comment', 'label', 'комментарий']), raw: row, }; }); } function classifyBatchRows(rows) { return rows.map(row => { const typeKey = normalize(row.type); let opType = ''; let confidence = 0.3; const reasons = []; if (typeKey.indexOf('replace signer') >= 0 || typeKey.indexOf('replace_signer') >= 0 || typeKey.indexOf('замена подписанта') >= 0) { opType = CONFIG.operationTypes.REPLACE_SIGNER; confidence = 0.8; } else if (typeKey.indexOf('replace approver') >= 0 || typeKey.indexOf('replace_approver') >= 0 || typeKey.indexOf('замена согласующего') >= 0) { opType = CONFIG.operationTypes.REPLACE_APPROVER; confidence = 0.8; } else if (typeKey.indexOf('remove') >= 0 || typeKey.indexOf('удал') >= 0) { opType = CONFIG.operationTypes.REMOVE_COUNTERPARTY; confidence = 0.7; } else if (typeKey.indexOf('bundle') >= 0 || typeKey.indexOf('пакет подписанта') >= 0) { opType = CONFIG.operationTypes.ADD_SIGNER_BUNDLE; confidence = 0.5; } else if (typeKey.indexOf('лимит') >= 0 || typeKey.indexOf('limit') >= 0) { opType = CONFIG.operationTypes.CHANGE_LIMITS; confidence = 0.7; } else if (typeKey.indexOf('площад') >= 0 || typeKey.indexOf('site') >= 0) { opType = CONFIG.operationTypes.EXPAND_SITES; confidence = 0.7; } else if (typeKey.indexOf('юл') >= 0 || typeKey.indexOf('entity') >= 0) { opType = CONFIG.operationTypes.EXPAND_LEGAL_ENTITIES; confidence = 0.7; } if (!opType) reasons.push(`Не распознан type: "${row.type || ''}"`); const currentApprover = row.currentApprover || row.currentPartner || ''; const newApprover = row.newApprover || row.newPartner || ''; const currentSigner = row.currentSigner || row.currentPartner || ''; const newSigner = row.newSigner || row.newPartner || ''; if (opType === CONFIG.operationTypes.REPLACE_APPROVER && (!currentApprover || !newApprover)) { confidence = Math.min(confidence, 0.5); reasons.push('Для replace_approver нужны current_approver и new_approver'); } if (opType === CONFIG.operationTypes.REPLACE_SIGNER && (!currentSigner || !newSigner)) { confidence = Math.min(confidence, 0.5); reasons.push('Для replace_signer нужны current_signer и new_signer'); } if ((opType === CONFIG.operationTypes.REMOVE_COUNTERPARTY || opType === CONFIG.operationTypes.DELETE_IF_SINGLE_COUNTERPARTY) && !row.currentPartner) { confidence = Math.min(confidence, 0.5); reasons.push('Для операций по контрагенту нужно поле current_partner'); } const manual = confidence < 0.7 || !opType; return { source: row, operation: normalizeOperation({ // Unrecognized type must stay empty so classifyOperationToPlan hits default // (manual review + batch hints), not add_signer_bundle. type: opType || '', matrixName: row.matrixName, payload: { partnerName: row.currentPartner, currentPartner: row.currentPartner, newPartner: row.newPartner, currentApprover, newApprover, currentSigner, newSigner, label: row.comment, }, options: { batchMeta: { confidence, manualReviewRequired: manual, reasons, sourceType: row.type || '', }, }, }), confidence, reasons, manualReviewRequired: manual, }; }); } async function ensureXlsxLib() { if (hostWindow().XLSX) return hostWindow().XLSX; if (state.xlsxLoaderPromise) return state.xlsxLoaderPromise; state.xlsxLoaderPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'; script.onload = () => resolve(hostWindow().XLSX); script.onerror = () => reject(new Error('Не удалось загрузить XLSX библиотеку.')); document.head.appendChild(script); }); return state.xlsxLoaderPromise; } async function parseXlsxFile(file) { const XLSX = await ensureXlsxLib(); const buffer = await file.arrayBuffer(); const workbook = XLSX.read(buffer, { type: 'array' }); const first = workbook.SheetNames[0]; const sheet = workbook.Sheets[first]; const csv = XLSX.utils.sheet_to_csv(sheet); return parseDelimited(csv); } function collectDiagnostics() { const matrix = sc(); const hasJq = Boolean($()); const hasMatrixTable = Boolean(document.querySelector(CONFIG.selectors.matrixTable)); const hasMatrixRows = document.querySelectorAll(CONFIG.selectors.matrixRows).length; const statusEl = document.querySelector(CONFIG.selectors.matrixStatus); const listRows = document.querySelectorAll(CONFIG.selectors.listRows).length; const popupOpen = Boolean(hostWindow().sc && hostWindow().sc.partner && hostWindow().sc.partner.w && !hostWindow().sc.partner.w.closed); return { timestamp: new Date().toISOString(), href: window.location.href, mode: state.mode, env: { hasJquery: hasJq, hasScApprovalMatrix: Boolean(matrix), hasMatrixTable, matrixRowCount: hasMatrixRows, matrixStatus: statusEl ? statusEl.value : null, matrixStatusText: statusEl ? statusEl.options[statusEl.selectedIndex].text : null, matrixCatalogRows: listRows, popupOpen, }, config: { partnerAliases: CONFIG.partnerAliases.slice(), safety: Object.assign({}, CONFIG.safety), }, filterDiagnostics: state.filterDiagnostics ? Object.assign({}, state.filterDiagnostics) : null, runningSheetsState: isMatrixPage() ? detectRunningSheetsState() : null, lastApplySnapshot: state.lastApplySnapshot ? { generatedAt: state.lastApplySnapshot.generatedAt, entries: state.lastApplySnapshot.entries.length } : null, }; } function isActionableAction(actionType) { return [CONFIG.actionTypes.DELETE_ROW, CONFIG.actionTypes.REMOVE_TOKEN, CONFIG.actionTypes.PATCH_ROW, CONFIG.actionTypes.ADD_ROW].includes(actionType); } function buildRollbackHint(entry) { if (!entry) return ''; if (entry.actionType === CONFIG.actionTypes.DELETE_ROW) return 'Restore the deleted row from the exported before snapshot or OpenText version history.'; if (entry.actionType === CONFIG.actionTypes.REMOVE_TOKEN) return 'Re-add the removed counterparty token using the originalPartners report field.'; if (entry.actionType === CONFIG.actionTypes.PATCH_ROW) return 'Restore the before value from this snapshot/report for the patched row.'; if (entry.actionType === CONFIG.actionTypes.ADD_ROW) return 'Remove the generated row if apply was incorrect.'; return 'No rollback action required.'; } function buildApplySnapshot(plan, operations) { const entries = (plan || []).filter(entry => isActionableAction(entry.actionType)).map(entry => ({ matrixName: entry.matrixName || document.title || '', operationType: entry.operationType || '', actionType: entry.actionType, itemId: entry.itemId != null ? entry.itemId : '', itemid: entry.itemId != null ? entry.itemId : '', recId: entry.recId != null ? entry.recId : '', recordId: entry.recordId != null ? entry.recordId : (entry.recId != null ? entry.recId : ''), rowNo: entry.rowNo || '', before: entry.before || {}, plannedAfter: entry.after || {}, reason: entry.reason || '', applyMode: resolveApplyMode(entry), rollbackHint: entry.rollbackHint || buildRollbackHint(entry), })); return { generatedAt: new Date().toISOString(), href: window.location.href, matrixName: document.title || '', operations: (operations || []).map(op => normalizeOperation(op)), entries, }; } function exportJson() { if (!state.lastReport.length) { log('Отчет пустой.', 'warn'); return; } downloadText(`ot-matrix-report-${timestamp()}.json`, JSON.stringify(state.lastReport, null, 2), 'application/json;charset=utf-8'); log('JSON отчет экспортирован.', 'ok'); } function exportCsv() { if (!state.lastReport.length) { log('Отчет пустой.', 'warn'); return; } downloadText(`ot-matrix-report-${timestamp()}.csv`, reportToCsv(state.lastReport), 'text/csv;charset=utf-8'); log('CSV отчет экспортирован.', 'ok'); } function splitReportBuckets(report) { const rows = Array.isArray(report) ? report : []; return { errors: rows.filter(row => row.status === CONFIG.status.ERROR), skipped: rows.filter(row => row.status === CONFIG.status.SKIPPED), ambiguous: rows.filter(row => row.status === CONFIG.status.AMBIGUOUS || row.status === CONFIG.status.MANUAL_REVIEW), ok: rows.filter(row => row.status === CONFIG.status.OK), }; } function buildReportSummary(report) { const rows = Array.isArray(report) ? report : []; const buckets = splitReportBuckets(rows); return { total: rows.length, ok: buckets.ok.length, skipped: buckets.skipped.length, errors: buckets.errors.length, ambiguous: buckets.ambiguous.length, actionable: rows.filter(row => isActionableAction(row.actionType)).length, }; } function diagnoseCurrentCard() { const text = normalize(document.body ? document.body.textContent || '' : ''); const title = document.title || ''; const href = window.location.href; const links = Array.from(document.querySelectorAll('a[href]')).map(link => ({ href: link.href || link.getAttribute('href') || '', text: String(link.textContent || '').replace(/\s+/g, ' ').trim(), })).slice(0, 50); const fieldHints = [ [/тип\s+документ|document\s+type/, 'documentType'], [/юр\.?\s*лиц|legal\s+entity/, 'legalEntity'], [/контрагент|counterparty|partner/, 'counterparty'], [/сумм|amount/, 'amount'], [/лимит|limit/, 'limit'], [/эдо|edo|эп|eds/, 'edoMode'], [/матриц|matrix/, 'matrixName'], [/этап|stage|лист\s+согласования|approvallist/, 'approvalStage'], [/согласующ|подписант|approver|signer/, 'stuckApprover'], ].filter(([pattern]) => pattern.test(text)).map(([, id]) => id); const currentStageMatch = text.match(/(?:этап|stage|статус|status)\s*[:\-]?\s*([^.;]{3,80})/); const stuckApproverMatch = text.match(/(?:согласующ|подписант|approver|signer)\s*[:\-]?\s*([^.;]{3,80})/); const currentStage = currentStageMatch ? currentStageMatch[1].trim() : ''; const stuckApprover = stuckApproverMatch ? stuckApproverMatch[1].trim() : ''; const checks = [ { id: 'approval_list', status: /approvallist|лист согласования|approval/.test(`${text} ${href}`) ? 'pass' : 'warn', reason: 'Approval list signals in current page.', }, { id: 'route_not_built', status: /маршрут|route/.test(text) && /не\s*стро|не\s*форм|ошиб/.test(text) ? 'fail' : 'warn', reason: 'Route build failure text signals.', }, { id: 'card_required_fields', status: /обязат|красн|required|validation/.test(text) ? 'fail' : 'pass', reason: 'Required card field / validation signals.', }, { id: 'matrix_match', status: /матриц/.test(text) ? 'warn' : 'warn', reason: 'Matrix match needs matrix preview/search cross-check.', }, { id: 'signer_checklist', status: /подпис|согласующ|sign/.test(text) ? 'warn' : 'pass', reason: 'Signer/checklist signals.', }, ]; const requiredFields = [ 'document type', 'legal entity', 'counterparty + affiliation', 'amount/limit', 'EDO mode', 'route stage / approval list screenshot', ]; const missingFields = requiredFields.filter(field => { if (/document type/i.test(field)) return fieldHints.indexOf('documentType') < 0; if (/legal entity/i.test(field)) return fieldHints.indexOf('legalEntity') < 0; if (/counterparty/i.test(field)) return fieldHints.indexOf('counterparty') < 0; if (/amount\/limit/i.test(field)) return fieldHints.indexOf('amount') < 0 && fieldHints.indexOf('limit') < 0; if (/EDO/i.test(field)) return fieldHints.indexOf('edoMode') < 0; if (/route stage/i.test(field)) return fieldHints.indexOf('approvalStage') < 0; return false; }); return { generatedAt: new Date().toISOString(), title, href, detectedSystem: /assyst|itcm|incident|инцидент/.test(text) ? 'ITCM/assyst' : 'OpenText', checks, requiredFields, missingFields, extracted: { fieldHints, currentStage, stuckApprover, links, matrixMatchHints: links.filter(link => /matrix|матриц|openmatrix/i.test(`${link.href} ${link.text}`)).slice(0, 10), }, escalationReason: checks.some(item => item.status === 'fail') ? 'Route/card validation failure detected.' : '', suggestedFirstLineScript: 'Ask for card link, matrix name, document type, legal entity, counterparty affiliation, amount/limit, EDO mode, and approval-list screenshot.', selfCheckScript: 'Open the card, check required red fields, compare card values with Matrix Cleaner preview, then open approval list and identify the stuck stage/approver.', escalationWhen: 'Escalate when required fields are present, Matrix Cleaner preview has no matching safe row, or approval list shows a failed/stuck stage after route rebuild.', suggestedDslDraft: { schemaVersion: '8.0.0', operation: /approvallist|лист согласования|approval/.test(`${text} ${href}`) ? { type: CONFIG.operationTypes.CHECKLIST_ROUTE_FAILURE, payload: { currentStage, stuckApprover } } : { type: CONFIG.operationTypes.CHECKLIST_CARD_VALIDATION, payload: { missingFields } }, }, }; } function getTriageCounts() { const buckets = splitReportBuckets(state.lastReport); return { ambiguous: buckets.ambiguous.length, skipped: buckets.skipped.length, errors: buckets.errors.length, }; } function getTriageSeverity(counts) { const c = counts || getTriageCounts(); if (c.errors > 0) return 'error'; if (c.ambiguous > 0) return 'warn'; return 'ok'; } function renderTriageCounters() { if (!state.triageEl) return; const counts = getTriageCounts(); const el = state.triageEl.querySelector('[data-role="triage-counts"]'); if (el) { el.textContent = `ambiguous: ${counts.ambiguous} · skipped: ${counts.skipped} · errors: ${counts.errors}`; const severity = getTriageSeverity(counts); el.classList.remove('mc-triage__counts--ok', 'mc-triage__counts--warn', 'mc-triage__counts--error'); el.classList.add(`mc-triage__counts--${severity}`); } } function exportLogsBundle() { if (!state.lastReport.length) { log('Отчет пустой.', 'warn'); return; } const buckets = splitReportBuckets(state.lastReport); const ts = timestamp(); downloadText(`ot-matrix-logs-${ts}.json`, JSON.stringify(buckets, null, 2), 'application/json;charset=utf-8'); downloadText(`ot-matrix-errors-${ts}.csv`, reportToCsv(buckets.errors), 'text/csv;charset=utf-8'); downloadText(`ot-matrix-skipped-${ts}.csv`, reportToCsv(buckets.skipped), 'text/csv;charset=utf-8'); downloadText(`ot-matrix-ambiguous-${ts}.csv`, reportToCsv(buckets.ambiguous), 'text/csv;charset=utf-8'); log('Логи экспортированы: JSON bundle + CSV (errors/skipped/ambiguous).', 'ok'); } function exportAmbiguousCsv() { if (!state.lastReport.length) { log('Отчет пустой.', 'warn'); return; } const rows = splitReportBuckets(state.lastReport).ambiguous; if (!rows.length) { log('Ambiguous/manual-review записей нет.', 'warn'); return; } downloadText(`ot-matrix-ambiguous-${timestamp()}.csv`, reportToCsv(rows), 'text/csv;charset=utf-8'); log(`Ambiguous CSV экспортирован (${rows.length} строк).`, 'ok'); } function reportRowsToTsv(rows) { const headers = ['operationType', 'actionType', 'status', 'reason', 'message', 'itemid', 'recId', 'rowNo', 'condition', 'matchedPartnerName']; const esc = value => String(value == null ? '' : value).replace(/\t/g, ' ').replace(/\r?\n/g, ' '); const lines = [headers.join('\t')]; rows.forEach(row => { lines.push(headers.map(key => esc(row[key])).join('\t')); }); return lines.join('\n'); } async function copyAmbiguousToClipboard() { if (!state.lastReport.length) { log('Отчет пустой.', 'warn'); return false; } const rows = splitReportBuckets(state.lastReport).ambiguous; if (!rows.length) { log('Ambiguous/manual-review записей нет.', 'warn'); return false; } const text = reportRowsToTsv(rows); try { if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') { throw new Error('Clipboard API недоступен.'); } await navigator.clipboard.writeText(text); log(`Ambiguous скопирован в clipboard (${rows.length} строк, TSV).`, 'ok'); return true; } catch (error) { log(`Не удалось скопировать ambiguous в clipboard: ${error.message}`, 'error'); return false; } } async function copyReportBucketToClipboard(bucketName) { if (!state.lastReport.length) { log('Отчет пустой.', 'warn'); return false; } const buckets = splitReportBuckets(state.lastReport); const rows = Array.isArray(buckets[bucketName]) ? buckets[bucketName] : []; if (!rows.length) { log(`Записей в bucket "${bucketName}" нет.`, 'warn'); return false; } const text = reportRowsToTsv(rows); try { if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') { throw new Error('Clipboard API недоступен.'); } await navigator.clipboard.writeText(text); log(`${bucketName} скопирован в clipboard (${rows.length} строк, TSV).`, 'ok'); return true; } catch (error) { log(`Не удалось скопировать ${bucketName} в clipboard: ${error.message}`, 'error'); return false; } } async function copySkippedToClipboard() { return copyReportBucketToClipboard('skipped'); } async function copyErrorsToClipboard() { return copyReportBucketToClipboard('errors'); } function stopRun() { state.stopRequested = true; log('Остановка запрошена. Скрипт остановится после текущего шага.', 'warn'); } async function runAllUiDiagnostics(options) { const opts = options || {}; const humanTestMode = opts.humanTestMode === 'real_insert' ? 'real_insert' : 'preview_only'; const checks = []; const push = (name, ok, details) => { const det = details || ''; checks.push({ name, ok, details: det }); const tail = det ? ` (${det})` : ''; log(`[Тест всего] ${name}: ${ok ? 'OK' : 'FAIL'}${tail}`, ok ? 'ok' : 'error'); }; const pushInfo = (name, details) => { checks.push({ name, ok: true, details: details || '' }); log(`[Тест всего] ${name}: ${details || ''}`, 'info'); }; const pushSkip = (name, details) => { checks.push({ name, ok: true, details: `ПРОПУСК: ${details || ''}` }); log(`[Тест всего] ${name}: пропуск — ${details || ''}`, 'warn'); }; try { log(`[Тест всего] Старт: synthetic-контур (${humanTestMode}) и проверка превью по матрице.`, 'ok'); const ready = Boolean(document.querySelector(CONFIG.selectors.matrixRows)); push('Матрица загружена', ready); await waitForReady(5000).then(() => push('waitForReady', true)).catch(err => push('waitForReady', false, err.message)); try { ensureMatrixInit(); push('sc_ApprovalMatrix доступен', true); } catch (error) { push('sc_ApprovalMatrix доступен', false, error.message); } const diag = collectDiagnostics(); push('jQuery доступен', Boolean(diag.env && diag.env.hasJquery)); const hasDraftGuard = collectSafetyOptions().requireDraft; push('Draft guard включен', hasDraftGuard, hasDraftGuard ? '' : 'Рекомендуется включить'); const catalog = collectPartnerCatalog(); push('Каталог контрагентов', Array.isArray(catalog) && catalog.length > 0, `count=${catalog ? catalog.length : 0}`); const api = hostWindow().__OT_MATRIX_CLEANER__; if (api && typeof api.runAllHumanTests === 'function') { try { const syn = await api.runAllHumanTests({ mode: humanTestMode }); const synOk = syn && Number(syn.fail) === 0; push('Synthetic-контур (preview)', synOk, syn ? `OK=${syn.ok} FAIL=${syn.fail} всего=${syn.total}` : ''); if (api.getLastReport) { const last = api.getLastReport() || []; pushInfo('После synthetic отчёт', `записей в последнем preview=${Array.isArray(last) ? last.length : 0}`); } } catch (e) { push('Synthetic-контур (preview)', false, e.message || String(e)); } } else { pushSkip('Synthetic-контур', 'API runAllHumanTests недоступен (перезагрузите страницу или откройте панель позже).'); } let hadPreviewRows = false; if (catalog && catalog.length > 0) { const picked = pickPartnerEntryVisibleInMatrix(catalog); if (picked) { const report = await previewOperations([normalizeOperation({ type: CONFIG.operationTypes.REMOVE_COUNTERPARTY, matrixName: document.title, payload: { partnerName: picked.name, affiliation: CONFIG.requiredAffiliation }, options: { skipExclude: true, deleteIfSingle: false, sourceRule: 'test-all' }, })], {}); const n = Array.isArray(report) ? report.length : 0; hadPreviewRows = n > 0; push('Preview: контрагент в видимых строках', hadPreviewRows, `rows=${n} «${String(picked.name).slice(0, 48)}»`); } else { pushSkip('Preview: контрагент', 'ни одно имя из каталога не найдено в видимых строках (фильтр/срез).'); } } else { pushSkip('Preview: контрагент', 'пустой каталог.'); } if (!hadPreviewRows) { const bundle = await previewOperations([normalizeOperation({ type: CONFIG.operationTypes.ADD_SIGNER_BUNDLE, matrixName: document.title, scope: {}, filters: {}, payload: { currentSigner: 'TEST_CURRENT', newSigner: 'TEST_NEW', limit: '1', amount: '1', affiliation: CONFIG.requiredAffiliation }, options: { sourceRule: 'test-all-bundle-fallback' }, })], {}); const bn = Array.isArray(bundle) ? bundle.length : 0; hadPreviewRows = bn > 0; push('Preview: резерв (4-строчный bundle)', hadPreviewRows, `rows=${bn}`); } if (!hadPreviewRows) { log('[Тест всего] Превью по-прежнему 0 записей: проверьте фильтры матрицы и статус черновика.', 'warn'); push('Итог preview (не пусто)', false, 'rows=0 после контрагент+rescue bundle'); } else { push('Итог preview (не пусто)', true, 'ok'); } } catch (error) { push('Внутренняя ошибка тестов', false, error.message); } const failed = checks.filter(item => !item.ok).length; log(`[Тест всего] Завершено: ${checks.length - failed} OK / ${failed} FAIL.`, failed ? 'error' : 'ok'); return { checks, failed, humanTestMode }; } function buildMatrixCatalogSection(root) { const section = document.createElement('section'); section.setAttribute('data-module', 'catalog'); section.innerHTML = `

Каталог матриц

`; root.appendChild(section); const select = section.querySelector('[data-field="matrix-select"]'); const render = query => { const q = normalize(query || ''); const list = state.matrixCatalog.filter(entry => !q || normalize(entry.name).indexOf(q) >= 0); select.innerHTML = ''; const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = list.length ? 'Выбери матрицу...' : 'Матрицы не найдены'; select.appendChild(placeholder); list.forEach(entry => { const opt = document.createElement('option'); opt.value = entry.name; opt.textContent = entry.objId ? `${entry.name} [${entry.objId}]` : entry.name; opt.dataset.url = entry.openUrl; select.appendChild(opt); }); }; render(''); section.querySelector('[data-field="matrix-search"]').addEventListener('input', e => render(e.target.value)); section.querySelector('[data-role="open-matrix"]').addEventListener('click', () => { const option = select.options[select.selectedIndex]; if (!option || !option.dataset.url) { log('Матрица не выбрана.', 'warn'); return; } const href = option.dataset.url.replace(/&/g, '&'); window.location.href = href; }); } function buildSignerWizardSection(root) { const section = document.createElement('section'); section.setAttribute('data-module', 'signer'); section.innerHTML = `

Мастер подписантов

`; root.appendChild(section); section.querySelector('[data-role="signer-preview"]').addEventListener('click', async () => { const payload = {}; section.querySelectorAll('[data-signer]').forEach(el => { payload[el.getAttribute('data-signer')] = el.value; }); const operation = normalizeOperation({ type: CONFIG.operationTypes.ADD_SIGNER_BUNDLE, matrixName: document.title, payload, options: { configurablePreset: true }, }); await previewOperations([operation], {}); }); } function buildBatchSection(root) { const section = document.createElement('section'); section.setAttribute('data-module', 'batch'); section.innerHTML = `

Пакетный импорт

`; root.appendChild(section); section.querySelector('[data-role="batch-paste"]').addEventListener('click', async () => { try { if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') { throw new Error('Clipboard API недоступен.'); } const text = await navigator.clipboard.readText(); section.querySelector('[data-field="batch-text"]').value = text; log('Текст из clipboard вставлен в Batch Import.', 'ok'); } catch (error) { log(`Не удалось прочитать clipboard: ${error.message}`, 'error'); } }); section.querySelector('[data-role="batch-preview"]').addEventListener('click', async () => { const text = section.querySelector('[data-field="batch-text"]').value; let rows = parseDelimited(text); const file = section.querySelector('[data-field="batch-xlsx"]').files[0]; if (!rows.length && file) rows = await parseXlsxFile(file); const classified = classifyBatchRows(rows); const operations = classified.map(item => item.operation); await previewOperations(operations, {}); const manual = classified.filter(item => item.manualReviewRequired).length; const topReasons = classified .filter(item => item.manualReviewRequired) .slice(0, 10) .map((item, idx) => `${idx + 1}) ${item.reasons.join(' | ') || 'manual review required'}`); log(`Batch preview: ${classified.length} заявок, manual review required: ${manual}.`, manual ? 'warn' : 'ok'); topReasons.forEach(line => log(`[Batch hints] ${line}`, 'warn')); }); section.querySelector('[data-role="batch-run"]').addEventListener('click', async () => { const text = section.querySelector('[data-field="batch-text"]').value; let rows = parseDelimited(text); const file = section.querySelector('[data-field="batch-xlsx"]').files[0]; if (!rows.length && file) rows = await parseXlsxFile(file); const classified = classifyBatchRows(rows); const operations = classified.filter(item => !item.manualReviewRequired).map(item => item.operation); const manualItems = classified.filter(item => item.manualReviewRequired); if (!operations.length) { log('Нет заявок с достаточной уверенностью для авто-применения.', 'warn'); manualItems.slice(0, 10).forEach((item, idx) => { log(`[Batch skipped #${idx + 1}] ${item.reasons.join(' | ') || 'manual review required'}`, 'warn'); }); return; } await runOperations(operations, {}); }); } function buildMainMatrixSection(root) { const section = document.createElement('section'); section.setAttribute('data-module', 'core'); const opList = [ CONFIG.operationTypes.REMOVE_COUNTERPARTY, CONFIG.operationTypes.DELETE_IF_SINGLE_COUNTERPARTY, CONFIG.operationTypes.REPLACE_APPROVER, CONFIG.operationTypes.REMOVE_APPROVER, CONFIG.operationTypes.REPLACE_SIGNER, CONFIG.operationTypes.ADD_SIGNER_BUNDLE, CONFIG.operationTypes.CHANGE_LIMITS, CONFIG.operationTypes.EXPAND_LEGAL_ENTITIES, CONFIG.operationTypes.EXPAND_SITES, CONFIG.operationTypes.PATCH_DOC_TYPES, CONFIG.operationTypes.ADD_DOC_TYPE_TO_MATCHING_ROWS, CONFIG.operationTypes.ADD_CHANGE_CARD_FLAG_TO_MATCHING_ROWS, CONFIG.operationTypes.ADD_LEGAL_ENTITY_TO_MATCHING_ROWS, ]; const opOptions = opList.map((t, idx) => { const lab = operationTypeLabel(t); return ``; }).join(''); section.innerHTML = `

Основные операции (полный ввод)

Повседневные сценарии — в блоке «Рабочий режим» выше. Здесь: все типы, экспорт, JSON и тесты. Замена подписанта (replace_signer) в превью часто только manual-review — смотрите лог и отчёт.

Расширенно: JSON и номер заявки (необязательно)

JSON и поле «номер заявки» для отчёта. Обычный ввод — без этого блока.

Быстрые triage-действия
ambiguous: 0 · skipped: 0 · errors: 0
`; root.appendChild(section); const quickMode = document.createElement('div'); quickMode.className = 'mc-compact-mode'; quickMode.innerHTML = ` `; section.insertBefore(quickMode, section.querySelector('.mc-actions')); const allButtons = Array.from(section.querySelectorAll('.mc-actions button')); const actionRoles = new Set(['refresh', 'preview', 'run', 'stop', 'partner-driver-dry', 'partner-driver-run', 'run-all-tests']); const exportRoles = new Set(['diag', 'export-json', 'export-csv', 'export-logs', 'export-ambiguous']); const triageRoles = new Set(['copy-ambiguous', 'copy-skipped', 'copy-errors', 'triage-copy-ambiguous', 'triage-copy-skipped', 'triage-copy-errors']); const applyCompact = mode => { allButtons.forEach(btn => { const role = btn.getAttribute('data-role'); if (mode === 'all') { btn.style.display = ''; return; } if (mode === 'action') { btn.style.display = actionRoles.has(role) ? '' : 'none'; return; } if (mode === 'export') { btn.style.display = exportRoles.has(role) ? '' : 'none'; return; } if (mode === 'triage') { btn.style.display = triageRoles.has(role) ? '' : 'none'; return; } btn.style.display = ''; }); }; const compactSelectCore = quickMode.querySelector('[data-role="core-compact-mode"]'); compactSelectCore.addEventListener('change', e => applyCompact(e.target.value)); applyCompact('all'); const fillPartnerDatalist = () => { const dl = section.querySelector('#mc-partner-datalist'); if (!dl) return; dl.innerHTML = ''; const seen = new Set(); const pushVal = (v) => { const s = String(v || '').trim(); if (!s || seen.has(s.toLowerCase())) return; seen.add(s.toLowerCase()); const o = document.createElement('option'); o.value = s; dl.appendChild(o); }; (state.partnerCatalog || []).forEach(p => { if (p && p.name) pushVal(p.name); }); const apiRef = __otMatrixCleanerHost().__OT_MATRIX_CLEANER__; if (apiRef && typeof apiRef.getHumanDictionaries === 'function') { try { const dict = apiRef.getHumanDictionaries(); (dict.signersAndApprovers || []).forEach(pushVal); } catch (_) { /* human UI ещё не смонтирован */ } } }; section.querySelector('[data-role="refresh"]').addEventListener('click', async () => { await waitForReady(); ensureMatrixInit(); collectPartnerCatalog(); fillPartnerDatalist(); setStats(`Контрагентов в матрице: ${state.partnerCatalog.length}`); log('Список контрагентов обновлен.', 'ok'); }); fillPartnerDatalist(); section.querySelector('[data-role="preview"]').addEventListener('click', async () => { const op = buildDefaultOperationFromUi({}); await previewOperations([op], {}); }); section.querySelector('[data-role="run"]').addEventListener('click', async () => { const op = buildDefaultOperationFromUi({}); await runOperations([op], {}); }); section.querySelector('[data-role="stop"]').addEventListener('click', stopRun); section.querySelector('[data-role="diag"]').addEventListener('click', () => { const diag = collectDiagnostics(); downloadText(`ot-matrix-diagnostics-${timestamp()}.json`, JSON.stringify(diag, null, 2), 'application/json;charset=utf-8'); log('Diagnostics экспортирован.', 'ok'); }); section.querySelector('[data-role="export-json"]').addEventListener('click', exportJson); section.querySelector('[data-role="export-csv"]').addEventListener('click', exportCsv); section.querySelector('[data-role="export-logs"]').addEventListener('click', exportLogsBundle); section.querySelector('[data-role="export-ambiguous"]').addEventListener('click', exportAmbiguousCsv); section.querySelector('[data-role="copy-ambiguous"]').addEventListener('click', async () => { await copyAmbiguousToClipboard(); }); section.querySelector('[data-role="copy-skipped"]').addEventListener('click', async () => { await copySkippedToClipboard(); }); section.querySelector('[data-role="copy-errors"]').addEventListener('click', async () => { await copyErrorsToClipboard(); }); section.querySelector('[data-role="partner-driver-dry"]').addEventListener('click', async () => { const name = section.querySelector('[data-field="partner-name"]').value; await runPartnerSearchDriver(name, { dryRun: true }); }); section.querySelector('[data-role="partner-driver-run"]').addEventListener('click', async () => { const name = section.querySelector('[data-field="partner-name"]').value; await runPartnerSearchDriver(name, { dryRun: false }); }); section.querySelector('[data-role="run-all-tests"]').addEventListener('click', async () => { await runAllUiDiagnostics(); }); state.triageEl = section.querySelector('[data-role="triage-tools"]'); section.querySelector('[data-role="triage-copy-ambiguous"]').addEventListener('click', async () => { await copyAmbiguousToClipboard(); }); section.querySelector('[data-role="triage-copy-skipped"]').addEventListener('click', async () => { await copySkippedToClipboard(); }); section.querySelector('[data-role="triage-copy-errors"]').addEventListener('click', async () => { await copyErrorsToClipboard(); }); renderTriageCounters(); state.refillPartnerDatalist = fillPartnerDatalist; } function buildUI() { if (document.querySelector('#mc-open-btn')) return; const openBtn = document.createElement('button'); openBtn.id = 'mc-open-btn'; openBtn.type = 'button'; openBtn.setAttribute('aria-label', 'OpenText Toolkit'); openBtn.title = 'OpenText Toolkit'; // Стеклянный знак (две наложенные плитки) — тот же мотив, что и логотип в шапке, без букв «MC». openBtn.innerHTML = ''; const panel = document.createElement('aside'); panel.id = 'mc-panel'; panel.className = 'mc-panel'; panel.setAttribute('tabindex', '-1'); panel.innerHTML = `
Matrix Cleaner ${CONFIG.version}
Артём Шаповалов · ShapArt
GitHub: Matrtix-Cleaner
risk: ok
Загрузка...
`; document.body.appendChild(openBtn); document.body.appendChild(panel); state.panel = panel; state.logEl = panel.querySelector('#mc-log'); state.statsEl = panel.querySelector('#mc-stats'); state.riskBadgeEl = panel.querySelector('#mc-risk-badge'); const root = panel.querySelector('#mc-root'); const setCompactModule = moduleId => { const selected = String(moduleId || 'core'); root.querySelectorAll('section[data-module]').forEach(section => { section.style.display = (selected === 'all' || section.getAttribute('data-module') === selected) ? '' : 'none'; }); const modSel = root.querySelector('[data-role="compact-module-select"]'); if (modSel && modSel.querySelector(`option[value="${selected}"]`)) modSel.value = selected; const navHint = root.querySelector('[data-role="compact-module-hint"]'); if (navHint) { if (selected === 'all') { navHint.hidden = true; } else { navHint.hidden = false; navHint.textContent = selected === 'signer' ? 'Открыт только «Мастер подписантов». Чтобы вернуть остальные блоки: кнопка «Все разделы» или пункт «Показать все разделы» в списке.' : 'Показан один раздел панели. Вернитесь: кнопка «Все разделы» или «Показать все разделы» в списке.'; } } log(`Активный интерфейс: ${selected === 'all' ? 'все функции' : selected}.`, 'info'); }; const compactSection = document.createElement('section'); compactSection.setAttribute('data-module', 'compact'); compactSection.innerHTML = `

Режим интерфейса

Сценарии — в human-first блоке. Здесь можно открыть отдельный раздел.

`; root.appendChild(compactSection); const compactShowAllBtn = compactSection.querySelector('[data-role="compact-show-all"]'); if (compactShowAllBtn) { compactShowAllBtn.addEventListener('click', () => { const sel = compactSection.querySelector('[data-role="compact-module-select"]'); if (sel) sel.value = 'all'; setCompactModule('all'); }); } if (isMatrixCatalogPage() && !isMatrixPage()) { state.mode = 'catalog'; buildMatrixCatalogSection(root); } else { state.mode = 'matrix'; buildMainMatrixSection(root); buildSignerWizardSection(root); buildBatchSection(root); } const compactSelect = compactSection.querySelector('[data-role="compact-module-select"]'); if (compactSelect) compactSelect.addEventListener('change', e => setCompactModule(e.target.value)); setCompactModule(isMatrixCatalogPage() && !isMatrixPage() ? 'catalog' : 'all'); openBtn.addEventListener('click', () => { panel.classList.add('mc-panel--open'); try { panel.focus(); } catch (_) {} }); panel.querySelector('[data-role="close"]').addEventListener('click', () => { closeMatrixPanel(); }); panel.querySelectorAll('[data-log-filter]').forEach(btn => { btn.addEventListener('click', () => setLogFilter(btn.getAttribute('data-log-filter'))); }); if (state.riskBadgeEl) { state.riskBadgeEl.addEventListener('click', async event => { await handleRiskBadgeClick(event); }); state.riskBadgeEl.addEventListener('dblclick', async event => { event.preventDefault(); await triggerRiskBadgeCopy(); }); } const riskHelpBtn = panel.querySelector('#mc-risk-help'); if (riskHelpBtn) { riskHelpBtn.addEventListener('click', event => { event.stopPropagation(); toggleRiskHelpPop(); }); } const riskHelpCloseBtn = panel.querySelector('[data-role="risk-help-close"]'); if (riskHelpCloseBtn) { riskHelpCloseBtn.addEventListener('click', event => { event.stopPropagation(); closeRiskHelpPop(); }); } panel.addEventListener('click', event => { if (event.target.closest('.mc-risk-wrap')) return; closeRiskHelpPop(); }); panel.addEventListener('keydown', event => { if (event.key !== 'Escape') return; const pop = state.panel.querySelector('#mc-risk-help-pop'); if (pop && !pop.hidden) { event.preventDefault(); closeRiskHelpPop(); return; } if (panel.classList.contains('mc-panel--open')) { event.preventDefault(); closeMatrixPanel(); } }); setLogFilter('all'); renderStatsSeverity(); } function installStyles() { const css = ` #mc-open-btn { position: fixed; right: 14px; bottom: 14px; z-index: 999999; width: 44px; height: 44px; border: 1px solid rgba(255,255,255,.38); background: linear-gradient(160deg, #3b8cff 0%, #2360d8 100%); color: #fff; border-radius: 999px; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 8px 22px rgba(43,111,224,.5), inset 0 1px 0 rgba(255,255,255,.45); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); transition: transform .12s, box-shadow .15s; } #mc-open-btn:hover { transform: translateY(-1px); box-shadow: 0 11px 26px rgba(43,111,224,.6), inset 0 1px 0 rgba(255,255,255,.5); } #mc-panel { position: fixed; right: 14px; bottom: 66px; z-index: 999999; width: min(420px, calc(100vw - 28px)); max-width: 100%; max-height: calc(100vh - 90px); background: #fff; color: #111; border: 2px solid #111; box-shadow: 8px 8px 0 #111; font: 12px/1.35 Arial, Helvetica, sans-serif; display: none; overflow: hidden; } #mc-panel.mc-panel--open { display: block; } #mc-panel * { box-sizing: border-box; } .mc-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: #111; color: #fff; } .mc-head-left { display: flex; align-items: center; gap: 8px; } .mc-risk-wrap { position: relative; display: flex; align-items: center; gap: 4px; } .mc-risk-help { width: 18px; height: 18px; padding: 0; border: 1px solid #fff; background: #333; color: #fff; font-size: 11px; font-weight: 700; line-height: 1; cursor: pointer; } .mc-risk-help:hover { background: #555; } .mc-risk-help-pop { position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 248px; max-width: 300px; padding: 8px 10px; background: #fff; color: #111; border: 2px solid #111; box-shadow: 4px 4px 0 #111; font-size: 11px; font-weight: 400; line-height: 1.45; text-align: left; text-transform: none; } .mc-risk-help-pop strong { display: block; margin-bottom: 4px; } .mc-risk-help-pop ul { margin: 0; padding-left: 16px; } .mc-risk-help-close { margin-top: 8px; padding: 4px 8px; border: 1px solid #111; background: #fff; color: #111; font-size: 11px; font-weight: 700; cursor: pointer; } .mc-title { font-size: 12px; font-weight: 700; text-transform: none; line-height: 1.2; } .mc-subtitle { font-size: 10px; font-weight: 400; opacity: 0.9; max-width: 200px; } .mc-core-hint { font-size: 11px; color: #444; margin: 0 0 8px; line-height: 1.35; } .mc-advanced-block { margin: 0 0 8px; border: 1px dashed #999; padding: 6px; background: #fafafa; } .mc-advanced-block summary { cursor: pointer; font-weight: 700; } .mc-risk-badge { border: 1px solid #fff; padding: 1px 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; cursor: pointer; } .mc-risk-badge--ok { background: #235f23; color: #fff; } .mc-risk-badge--warn { background: #9a6a00; color: #fff; } .mc-risk-badge--error { background: #9d1111; color: #fff; } .mc-close { border: 0; background: transparent; color: #fff; font-size: 22px; line-height: 1; cursor: pointer; } .mc-body { padding: 10px; max-height: calc(100vh - 130px); overflow: auto; } .mc-body section { border: 1px solid #ddd; padding: 8px; margin: 0 0 10px; } .mc-body h4 { margin: 0 0 8px; } .mc-input, .mc-select { width: 100%; padding: 7px 8px; margin: 0 0 8px; border: 1px solid #111; } .mc-check { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; } .mc-check input[type="number"] { width: 100px; } .mc-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; align-items: stretch; } .mc-actions--single { } .mc-actions button, section > button { padding: 7px 6px; min-width: 0; flex: 1 1 calc(50% - 4px); max-width: 100%; border: 1px solid #111; background: #fff; color: #111; font-size: 11px; font-weight: 700; cursor: pointer; word-wrap: break-word; hyphens: auto; } .mc-actions button:hover:not(:disabled), section > button:hover:not(:disabled) { background: #111; color: #fff; } .mc-actions button:disabled, section > button:disabled { opacity: .45; cursor: not-allowed; } .mc-stats { margin-bottom: 8px; font-weight: 700; padding: 4px 6px; border: 1px solid #111; } .mc-stats--ok { background: #f2fff2; } .mc-stats--warn { background: #fff9e6; } .mc-stats--error { background: #ffecec; } .mc-logbox { max-height: 220px; overflow: auto; border: 1px solid #111; background: #fafafa; } .mc-logtools { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; margin-bottom: 6px; } .mc-logtools button { padding: 5px 7px; border: 1px solid #111; background: #fff; color: #111; font-weight: 700; cursor: pointer; } .mc-logtools button.is-active { background: #111; color: #fff; } .mc-triage { border: 1px dashed #777; padding: 8px; margin-top: 8px; } .mc-triage__title { font-weight: 700; margin-bottom: 4px; } .mc-triage__counts { margin-bottom: 6px; padding: 4px 6px; border: 1px solid #111; } .mc-triage__counts--ok { background: #f2fff2; } .mc-triage__counts--warn { background: #fff9e6; } .mc-triage__counts--error { background: #ffecec; } .mc-log { padding: 6px 8px; border-bottom: 1px solid #ddd; white-space: pre-wrap; word-break: break-word; } .mc-log--ok { background: #fff; } .mc-log--warn { background: #f3f3f3; } .mc-log--error { background: #111; color: #fff; } `; if (typeof GM_addStyle === 'function') GM_addStyle(css); else { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } } function exposeApi() { hostWindow().__OT_MATRIX_CLEANER__ = { refreshPartners: async function () { if (!isMatrixPage()) return []; await waitForReady(); ensureMatrixInit(); return collectPartnerCatalog(); }, getPartnerCatalog: function () { return state.partnerCatalog.slice(); }, applyCounterpartyColumnFilter: function (query) { if (!isMatrixPage()) return { rows: [], diagnostics: { mode: 'not_matrix_page' } }; ensureMatrixInit(); if (!state.partnerCatalog.length) collectPartnerCatalog(); const name = typeof query === 'string' ? query : (query && query.name ? query.name : ''); let entry = resolvePartnerByName(name); if (!entry && query && Array.isArray(query.ids)) { entry = { name, key: normalize(name), ids: query.ids.map(id => Math.abs(Number(id))).filter(Number.isFinite), affiliation: query.affiliation || CONFIG.requiredAffiliation, }; } if (!entry || !entry.ids.length) throw new Error('Counterparty was not resolved for filter application.'); const result = applyPartnerFilter(entry); return { rows: (result.rows || []).map(row => ({ itemid: Number(row.getAttribute('itemid') || row.getAttribute('itemID') || 0), rowNo: getRowNo(row), })), diagnostics: result.diagnostics, }; }, clearMatrixFilters: function () { return clearMatrixFilters(); }, getCounterpartyFilterDiagnostics: function () { return state.filterDiagnostics ? Object.assign({}, state.filterDiagnostics) : null; }, getMatrixCatalog: function () { return state.matrixCatalog.slice(); }, preview: function (operation) { return previewOperations([operation], {}); }, apply: function (operation, opts) { return runOperations([operation], opts || {}); }, previewRun: async function (opts) { const op = normalizeOperation({ type: CONFIG.operationTypes.REMOVE_COUNTERPARTY, matrixName: document.title, payload: { partnerName: opts && opts.partnerName ? opts.partnerName : '' }, options: { deleteIfSingle: opts && opts.actionMode === 'remove_or_delete_single', skipExclude: opts && Object.prototype.hasOwnProperty.call(opts, 'skipExclude') ? opts.skipExclude : true, }, }); return previewOperations([op], {}); }, runCleanup: async function (opts) { const op = normalizeOperation({ type: CONFIG.operationTypes.REMOVE_COUNTERPARTY, matrixName: document.title, payload: { partnerName: opts && opts.partnerName ? opts.partnerName : '' }, options: { deleteIfSingle: opts && opts.actionMode === 'remove_or_delete_single', skipExclude: opts && Object.prototype.hasOwnProperty.call(opts, 'skipExclude') ? opts.skipExclude : true, }, }); return runOperations([op], { skipDeleteConfirm: opts && opts.skipDeleteConfirm, allowRunningSheetsUnknown: true, overrideMaxRows: true, }); }, runRuleBatch: function (operations, opts) { return runOperations(operations || [], opts || {}); }, previewRuleBatch: function (operations, opts) { return previewOperations(operations || [], opts || {}); }, runPartnerSearchDriver: function (partnerName, opts) { return runPartnerSearchDriver(partnerName, opts || {}); }, getDiagnostics: function () { return collectDiagnostics(); }, getLastReport: function () { return state.lastReport.slice(); }, getLastApplySnapshot: function () { return state.lastApplySnapshot ? JSON.parse(JSON.stringify(state.lastApplySnapshot)) : null; }, getRunningSheetsState: function () { return detectRunningSheetsState(); }, exportReport: function (format) { const kind = String(format || 'json').toLowerCase(); if (kind === 'csv') return reportToCsv(state.lastReport); return JSON.stringify(state.lastReport, null, 2); }, diagnoseCurrentCard: function () { return diagnoseCurrentCard(); }, getReportBuckets: function () { return splitReportBuckets(state.lastReport); }, getReportSummary: function () { return buildReportSummary(state.lastReport); }, getTriageCounts: function () { return getTriageCounts(); }, getTriageSeverity: function () { return getTriageSeverity(); }, getPanelSeverity: function () { return getTriageSeverity(); }, toggleRiskBadgeFilter: function () { return toggleRiskBadgeFilter(); }, triggerRiskBadgeCopy: function () { return triggerRiskBadgeCopy(); }, triggerRiskBadgeCopyErrors: function () { return triggerRiskBadgeCopyErrors(); }, triggerRiskBadgeCopySkipped: function () { return triggerRiskBadgeCopySkipped(); }, toggleRiskHelpPopover: function () { toggleRiskHelpPop(); }, closeRiskHelpPopover: function () { closeRiskHelpPop(); }, isRiskHelpPopoverOpen: function () { const pop = state.panel && state.panel.querySelector('#mc-risk-help-pop'); return Boolean(pop && !pop.hidden); }, getAmbiguousReport: function () { return splitReportBuckets(state.lastReport).ambiguous.slice(); }, copyAmbiguousToClipboard: function () { return copyAmbiguousToClipboard(); }, copySkippedToClipboard: function () { return copySkippedToClipboard(); }, copyErrorsToClipboard: function () { return copyErrorsToClipboard(); }, setLogFilter: function (mode) { setLogFilter(mode); return state.logFilter; }, getLogFilter: function () { return state.logFilter; }, closePanel: function () { closeMatrixPanel(); }, isPanelOpen: function () { return isMatrixPanelOpen(); }, stopRun: stopRun, getConfig: function () { return JSON.parse(JSON.stringify(CONFIG)); }, getOperationLabels: function () { return Object.assign({}, CONFIG.operationLabels || {}); }, parseFreeformRequestText: function (raw) { return parseFreeformRequestText(raw); }, buildRequestDraft: function (raw, opts) { return buildRequestDraft(raw, opts || {}); }, getReleaseInfo: function () { return { version: '8.0.0', channel: 'production', modules: ['legacy-core', 'native-counterparty-filter', 'running-sheet-detector', 'apply-snapshot', 'visual-preview', 'rule-engine-v2', 'search', 'checklist', 'dsl-v6', 'route-doctor'], }; }, validateDslConfig: function (config) { const errors = []; if (!config || typeof config !== 'object') errors.push('DSL должен быть объектом.'); ['schemaVersion', 'sourceMetadata'].forEach(key => { if (!config || !Object.prototype.hasOwnProperty.call(config, key)) errors.push(`Отсутствует обязательное поле: ${key}`); }); if (config && !Array.isArray(config.operations) && !config.operation) { errors.push('Either operations[] or operation must be provided.'); } if (config && config.schemaVersion && !/^(2|6|7|8)\./.test(String(config.schemaVersion))) { errors.push('schemaVersion must be 2.x.x, 6.x.x, 7.x.x or 8.x.x'); } if (config && Array.isArray(config.operations)) { config.operations.forEach((op, idx) => { if (!op || typeof op !== 'object') errors.push(`operations[${idx}] должен быть объектом.`); else { if (!op.type) errors.push(`operations[${idx}].type обязателен`); if (!op.payload || typeof op.payload !== 'object') errors.push(`operations[${idx}].payload обязателен`); } }); } return { valid: !errors.length, errors, humanMessage: errors.length ? `Ошибок: ${errors.length}` : 'DSL валиден' }; }, parseRequestTemplate: function (rawText) { const text = String(rawText || '').trim(); if (!text) return { confidence: 0, operations: [], reasons: ['Пустой запрос.'] }; if (text[0] === '{' || text[0] === '[') { try { const parsed = JSON.parse(text); const operations = Array.isArray(parsed.operations) ? parsed.operations : (Array.isArray(parsed) ? parsed : [parsed]); return { confidence: 0.95, operations, reasons: ['Structured JSON распознан'] }; } catch (error) { return { confidence: 0.2, operations: [], reasons: [`JSON parse error: ${error.message}`] }; } } if (text.indexOf('\t') >= 0 || text.indexOf(',') >= 0) { const lines = text.split(/\r?\n/).filter(Boolean); const delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : ','; const header = lines[0].split(delimiter).map(h => normalize(h)); const body = lines.slice(1).map(line => line.split(delimiter)); const operations = body.map(row => { const pick = key => { const i = header.indexOf(normalize(key)); return i >= 0 ? row[i] : ''; }; return { type: pick('type') || CONFIG.operationTypes.ADD_DOC_TYPE_TO_MATCHING_ROWS, payload: { rowGroup: pick('row_group') || 'all', newDocType: pick('new_doc_type') || pick('doc_type'), legalEntity: pick('legal_entity'), }, options: { sourceRule: pick('request_id') || 'request_template' }, }; }); return { confidence: 0.8, operations, reasons: ['TSV/CSV распознан'] }; } return { confidence: 0.5, operations: [], reasons: ['Human text parsing требует проверки вручную'] }; }, runChecklistEngine: function () { const text = normalize((document.querySelector(CONFIG.selectors.matrixTable) || document.body).textContent || ''); const checks = [ { id: 'route_failure', severity: 'error', title: 'Маршрут не формируется', ok: /маршрут|route/.test(text) }, { id: 'card_validation', severity: 'error', title: 'Красные поля / валидация карточки', ok: /валидац|обяз|красн/.test(text) }, { id: 'counterparty_error', severity: 'warn', title: 'Ошибка по контрагентам', ok: /контрагент|partner/.test(text) }, { id: 'sum_limits', severity: 'warn', title: 'Сумма / лимиты', ok: /сумм|лимит|amount|limit/.test(text) }, { id: 'main_pattern', severity: 'error', title: 'Паттерн основных договоров', ok: /договор|main/.test(text) }, { id: 'supp_pattern', severity: 'error', title: 'Паттерн доп соглашений', ok: /доп|дс|supplemental/.test(text) }, { id: 'signer_bundle_4_rows', severity: 'error', title: '4-строчный signer bundle', ok: true }, ].map(item => ({ id: item.id, title: item.title, severity: item.severity, status: item.ok ? 'pass' : (item.severity === 'error' ? 'fail' : 'warning'), sourceRule: `checklist:${item.id}`, recommendation: item.ok ? 'OK' : `Проверь правило "${item.title}" перед apply.`, })); return { generatedAt: new Date().toISOString(), summary: { total: checks.length, passed: checks.filter(c => c.status === 'pass').length, failed: checks.filter(c => c.status === 'fail').length, warnings: checks.filter(c => c.status === 'warning').length, }, checks, }; }, searchAcrossMatrices: function (query, opts) { const options = opts || {}; const mode = options.mode || 'counterparty'; const matchMode = options.matchMode || 'partial'; const q = normalize(query || ''); const rows = visibleRows(); const found = []; rows.forEach((row, idx) => { const value = normalize(row.textContent || ''); const ok = matchMode === 'exact' ? value === q : value.indexOf(q) >= 0; if (!ok) return; found.push({ matrixName: document.title, matrixId: '', openUrl: window.location.href, rowNumber: idx + 1, itemId: Number(row.getAttribute('itemid') || row.getAttribute('itemID') || 0), column: mode, matchedValue: String(row.textContent || '').trim(), matchType: matchMode, matrixState: document.querySelector(CONFIG.selectors.matrixStatus) ? document.querySelector(CONFIG.selectors.matrixStatus).value : '', }); }); return { mode, query, total: found.length, deduped: unique(found.map(item => JSON.stringify(item))).map(row => JSON.parse(row)), progress: { scanned: 1, total: 1, done: true }, cancelled: false, generatedAt: new Date().toISOString(), }; }, exportHtmlReport: function (rows, title) { const t = String(title || 'Matrix Report'); const list = (rows || []).map((row, idx) => `${idx + 1}${row.matrixName || ''}${row.rowNumber || ''}${row.column || ''}${String(row.matchedValue || '').replace(/`).join(''); return `${t}

${t}

${list}
#MatrixRowColumnValue
`; }, clearPreview: function () { document.querySelectorAll('.mc-v5-preview-create, .mc-v5-preview-update, .mc-v5-preview-delete').forEach(node => { node.classList.remove('mc-v5-preview-create', 'mc-v5-preview-update', 'mc-v5-preview-delete'); if (node.getAttribute('data-preview-ghost') === '1') node.remove(); }); }, togglePreviewMode: function (enabled) { if (hostWindow().__OT_MATRIX_PREVIEW_ENABLED__ == null) hostWindow().__OT_MATRIX_PREVIEW_ENABLED__ = true; hostWindow().__OT_MATRIX_PREVIEW_ENABLED__ = enabled == null ? !hostWindow().__OT_MATRIX_PREVIEW_ENABLED__ : Boolean(enabled); return hostWindow().__OT_MATRIX_PREVIEW_ENABLED__; }, runAllUiDiagnostics: function (opts) { return runAllUiDiagnostics(opts || {}); }, }; hostWindow().MatrixCleaner = hostWindow().__OT_MATRIX_CLEANER__; (function relinkPostExposeExtensions() { const w = hostWindow(); if (typeof w.__otV5Reinstall === 'function') { try { w.__otV5Reinstall(); } catch (e) { void 0; } } if (typeof w.__otHumanReinstall === 'function') { try { w.__otHumanReinstall(); } catch (e) { void 0; } } setTimeout(() => { if (typeof state.refillPartnerDatalist === 'function') { try { state.refillPartnerDatalist(); } catch (e2) { void 0; } } }, 0); }()); } async function boot() { if (state.booted) return; if (!isMatrixPage() && !isMatrixCatalogPage()) return; state.booted = true; installStyles(); if (isMatrixCatalogPage()) detectMatrixCatalog(); if (isMatrixPage()) { try { await waitForReady(); ensureMatrixInit(); collectPartnerCatalog(); } catch (error) { log(error.message, 'error'); } } buildUI(); exposeApi(); if (isMatrixCatalogPage() && !isMatrixPage()) { setStats(`Matrix catalog: ${state.matrixCatalog.length}`); log('Режим каталога матриц активирован.', 'ok'); } else { setStats(`Контрагентов: ${state.partnerCatalog.length}`); log('Скрипт активирован. Сначала запускай превью, затем применение.', 'ok'); } } boot(); })(); (() => { 'use strict'; const INSTALL_FLAG = '__OT_MATRIX_CLEANER_V5_PREVIEW_INSTALLED__'; if (window[INSTALL_FLAG]) return; window[INSTALL_FLAG] = true; const extState = { previewEnabled: true, diffPanel: null, previewSection: null, countersEl: null, currentReport: [], markerNodes: [], ghostNodes: [], }; function getApi() { return __otMatrixCleanerHost().__OT_MATRIX_CLEANER__; } function getMatrixTable() { return document.querySelector('#sc_ApprovalMatrix'); } function getMatrixRows() { return Array.from(document.querySelectorAll('#sc_ApprovalMatrix tbody tr[itemid], #sc_ApprovalMatrix tbody tr[itemID]')); } function readItemId(row) { return Number(row.getAttribute('itemid') || row.getAttribute('itemID') || 0); } function ensurePreviewUi() { if (extState.previewSection && document.body.contains(extState.previewSection)) return; const root = document.querySelector('#mc-root'); if (!root) return; const section = document.createElement('section'); section.setAttribute('data-role', 'v5-preview-diff'); section.innerHTML = `

Визуальный diff v5

created: 0 · updated: 0 · deleted: 0 · skipped: 0 · ambiguous: 0
`; root.appendChild(section); extState.previewSection = section; extState.countersEl = section.querySelector('[data-role="v5-preview-counters"]'); extState.diffPanel = section.querySelector('[data-role="v5-diff-panel"]'); section.querySelector('[data-role="v5-preview-toggle"]').addEventListener('click', () => { extState.previewEnabled = !extState.previewEnabled; if (!extState.previewEnabled) clearPreview(); renderDiffPanel(extState.currentReport); }); section.querySelector('[data-role="v5-preview-clear"]').addEventListener('click', () => { clearPreview(); renderDiffPanel([]); }); section.querySelector('[data-role="v5-preview-only"]').addEventListener('change', e => { extState.previewEnabled = Boolean(e.target.checked); if (!extState.previewEnabled) clearPreview(); }); } function clearPreview() { extState.markerNodes.forEach(el => { if (!el || !el.classList) return; el.classList.remove('mc-v5-preview-create', 'mc-v5-preview-update', 'mc-v5-preview-delete'); const badges = el.querySelectorAll('.mc-v5-badge'); badges.forEach(node => node.remove()); }); extState.ghostNodes.forEach(node => node.remove()); extState.markerNodes = []; extState.ghostNodes = []; } function createBadge(text, kind) { const badge = document.createElement('span'); badge.className = `mc-v5-badge mc-v5-badge--${kind}`; badge.textContent = text; return badge; } function renderPreviewPatches(entries, rowMap) { entries.forEach(entry => { const row = rowMap.get(Number(entry.itemId)); if (!row) return; row.classList.add('mc-v5-preview-update'); if (!row.querySelector('.mc-v5-badge--update')) { row.firstElementChild && row.firstElementChild.prepend(createBadge('PATCH', 'update')); } extState.markerNodes.push(row); }); } function renderPreviewDeletes(entries, rowMap) { entries.forEach(entry => { const row = rowMap.get(Number(entry.itemId)); if (!row) return; row.classList.add('mc-v5-preview-delete'); if (!row.querySelector('.mc-v5-badge--delete')) { row.firstElementChild && row.firstElementChild.prepend(createBadge('DELETE', 'delete')); } extState.markerNodes.push(row); }); } function renderPreviewRows(entries) { const tbody = document.querySelector('#sc_ApprovalMatrix tbody'); if (!tbody) return; const template = getMatrixRows()[0]; if (!template) return; entries.forEach((entry, idx) => { const ghost = template.cloneNode(true); ghost.classList.add('mc-v5-preview-create'); ghost.setAttribute('data-preview-ghost', '1'); ghost.removeAttribute('itemid'); ghost.removeAttribute('itemID'); const cells = Array.from(ghost.querySelectorAll('td')); if (cells.length) { cells[0].prepend(createBadge('CREATE', 'create')); } const infoCell = cells[cells.length - 1] || ghost; const reason = entry.reason || entry.message || 'Будет создана строка по preset'; infoCell.textContent = `[PREVIEW] ${reason}`; ghost.style.opacity = '0.85'; ghost.style.filter = 'saturate(1.2)'; tbody.appendChild(ghost); extState.ghostNodes.push(ghost); if (idx === 0) ghost.scrollIntoView({ block: 'nearest' }); }); } function countBuckets(report) { const counters = { created: 0, updated: 0, deleted: 0, skipped: 0, ambiguous: 0 }; report.forEach(entry => { if (entry.actionType === 'add-row') counters.created += 1; else if (entry.actionType === 'patch-row' || entry.actionType === 'remove-token') counters.updated += 1; else if (entry.actionType === 'delete-row') counters.deleted += 1; if (String(entry.status || '').includes('manual') || entry.status === 'ambiguous') counters.ambiguous += 1; if (entry.status === 'skipped') counters.skipped += 1; }); return counters; } function renderDiffPanel(report) { ensurePreviewUi(); if (!extState.diffPanel || !extState.countersEl) return; const counters = countBuckets(report); extState.countersEl.textContent = `created: ${counters.created} · updated: ${counters.updated} · deleted: ${counters.deleted} · skipped: ${counters.skipped} · ambiguous: ${counters.ambiguous}`; if (!report.length) { extState.diffPanel.innerHTML = '
Превью пусто. Запусти превью операции.
'; return; } const lines = report.slice(0, 120).map((entry, idx) => { const itemPart = entry.itemId ? `itemid=${entry.itemId}` : 'itemid=-'; const reason = entry.reason || entry.message || ''; return `
${idx + 1}. ${entry.actionType || '-'} · ${entry.status || '-'} · ${itemPart}
${reason}
`; }); extState.diffPanel.innerHTML = lines.join(''); } function renderFromReport(report) { extState.currentReport = Array.isArray(report) ? report.slice() : []; renderDiffPanel(extState.currentReport); clearPreview(); if (!extState.previewEnabled || !Array.isArray(report) || !report.length) return; const rowMap = new Map(getMatrixRows().map(row => [readItemId(row), row])); renderPreviewPatches(report.filter(r => r.actionType === 'patch-row' || r.actionType === 'remove-token'), rowMap); renderPreviewDeletes(report.filter(r => r.actionType === 'delete-row'), rowMap); renderPreviewRows(report.filter(r => r.actionType === 'add-row')); } function installStyles() { if (document.querySelector('#mc-v5-preview-style')) return; const style = document.createElement('style'); style.id = 'mc-v5-preview-style'; style.textContent = ` .mc-v5-counters { padding: 4px 6px; border: 1px solid #111; margin-bottom: 6px; font-weight: 700; } .mc-v5-diff { max-height: 160px; overflow: auto; border: 1px solid #111; background: #fafafa; } .mc-v5-line { border-bottom: 1px dashed #ccc; padding: 6px; font-size: 11px; } .mc-v5-empty { padding: 8px; color: #444; } #sc_ApprovalMatrix tr.mc-v5-preview-update { outline: 2px solid #ad7a00 !important; outline-offset: -2px; background: #fff8dd !important; } #sc_ApprovalMatrix tr.mc-v5-preview-delete { outline: 2px solid #9d1111 !important; outline-offset: -2px; background: #ffecec !important; } #sc_ApprovalMatrix tr.mc-v5-preview-create { outline: 2px dashed #235f23 !important; outline-offset: -2px; background: #efffef !important; } .mc-v5-badge { display: inline-block; margin-right: 6px; padding: 1px 5px; border: 1px solid #111; font-size: 10px; font-weight: 700; } .mc-v5-badge--create { background: #235f23; color: #fff; border-color: #235f23; } .mc-v5-badge--update { background: #9a6a00; color: #fff; border-color: #9a6a00; } .mc-v5-badge--delete { background: #9d1111; color: #fff; border-color: #9d1111; } `; document.head.appendChild(style); } function wrapPreviewApis(api) { if (api.__v5PreviewWrapped) return; const originalPreviewRuleBatch = api.previewRuleBatch ? api.previewRuleBatch.bind(api) : null; const originalPreviewRun = api.previewRun ? api.previewRun.bind(api) : null; const originalRunRuleBatch = api.runRuleBatch ? api.runRuleBatch.bind(api) : null; const originalRunCleanup = api.runCleanup ? api.runCleanup.bind(api) : null; if (originalPreviewRuleBatch) { api.previewRuleBatch = async (operations, opts) => { const report = await originalPreviewRuleBatch(operations, opts); renderFromReport(report); return report; }; } if (originalPreviewRun) { api.previewRun = async opts => { const report = await originalPreviewRun(opts); renderFromReport(report); return report; }; } if (originalRunRuleBatch) { api.runRuleBatch = async (operations, opts) => { const report = await originalRunRuleBatch(operations, opts); renderFromReport(report); return report; }; } if (originalRunCleanup) { api.runCleanup = async opts => { const report = await originalRunCleanup(opts); renderFromReport(report); return report; }; } api.renderPreviewRows = entries => renderPreviewRows(entries || []); api.renderPreviewPatches = entries => { const rowMap = new Map(getMatrixRows().map(row => [readItemId(row), row])); return renderPreviewPatches(entries || [], rowMap); }; api.renderPreviewDeletes = entries => { const rowMap = new Map(getMatrixRows().map(row => [readItemId(row), row])); return renderPreviewDeletes(entries || [], rowMap); }; api.clearPreview = clearPreview; api.togglePreviewMode = enabled => { extState.previewEnabled = enabled == null ? !extState.previewEnabled : Boolean(enabled); if (!extState.previewEnabled) clearPreview(); return extState.previewEnabled; }; api.__v5PreviewWrapped = true; } function install() { const api = getApi(); if (!api) return false; installStyles(); ensurePreviewUi(); wrapPreviewApis(api); if (typeof api.getLastReport === 'function') renderFromReport(api.getLastReport()); return true; } if (install()) return; const timer = setInterval(() => { if (!install()) return; clearInterval(timer); }, 300); setTimeout(() => clearInterval(timer), 30000); })(); (() => { 'use strict'; const INSTALL_FLAG = '__OT_MATRIX_CLEANER_V5_FEATURES_INSTALLED__'; if (window[INSTALL_FLAG]) return; window[INSTALL_FLAG] = true; const FEATURE_SCHEMA = { requiredRoot: ['schemaVersion', 'sourceMetadata'], supportedTypes: [ 'replace_approver', 'remove_approver', 'replace_signer', 'add_signer_bundle', 'change_limits', 'expand_legal_entities', 'expand_sites', 'patch_doc_types', 'add_doc_type_to_matching_rows', 'add_change_card_flag_to_matching_rows', 'add_legal_entity_to_matching_rows', 'create_category_from_template', 'remove_counterparty_from_rows', 'delete_rows_if_single_counterparty', 'find_counterparty_everywhere', 'find_user_everywhere', 'checklist_route_failure', 'checklist_card_validation', 'checklist_signing_rules', 'matrix_audit', ], }; const checklistRules = [ { id: 'route_failure', title: 'Маршрут не формируется', severity: 'error', test: text => /маршрут|route/.test(text) }, { id: 'card_validation', title: 'Красные поля / валидация карточки', severity: 'error', test: text => /обяз|красн|validation/.test(text) }, { id: 'counterparty_error', title: 'Ошибка по контрагентам', severity: 'warn', test: text => /контрагент|partner/.test(text) }, { id: 'sum_limits', title: 'Сумма / лимиты по своду', severity: 'warn', test: text => /лимит|сумм|amount|limit/.test(text) }, { id: 'main_pattern', title: 'Корректность паттерна основных договоров', severity: 'error', test: text => /договор|main/.test(text) }, { id: 'supp_pattern', title: 'Корректность паттерна доп соглашений', severity: 'error', test: text => /доп|дс|supplemental/.test(text) }, { id: 'signer_bundle_4_rows', title: 'Корректность 4-строчного bundle', severity: 'error', test: () => true }, ]; function getApi() { return __otMatrixCleanerHost().__OT_MATRIX_CLEANER__; } function normalize(value) { return String(value || '').toLowerCase().replace(/\s+/g, ' ').trim(); } function parseSemiList(value) { return String(value || '').split(/[;,]/).map(v => String(v || '').trim()).filter(Boolean); } function rowText(row) { return normalize(row ? row.textContent || '' : ''); } function getRows() { return Array.from(document.querySelectorAll('#sc_ApprovalMatrix tbody tr[itemid], #sc_ApprovalMatrix tbody tr[itemID]')); } function getItemId(row) { return Number(row.getAttribute('itemid') || row.getAttribute('itemID') || 0); } function validateDslConfig(config) { const errors = []; if (!config || typeof config !== 'object') errors.push('DSL должен быть объектом.'); FEATURE_SCHEMA.requiredRoot.forEach(key => { if (!config || !(key in config)) errors.push(`Отсутствует обязательное поле: ${key}`); }); if (config && !Array.isArray(config.operations) && !config.operation) { errors.push('Either operations[] or operation must be provided.'); } if (config && config.schemaVersion && !/^(2|6|7|8)\./.test(String(config.schemaVersion))) { errors.push('schemaVersion must start with 2.x.x, 6.x.x, 7.x.x or 8.x.x'); } if (config && Array.isArray(config.operations)) { config.operations.forEach((op, idx) => { if (!op || typeof op !== 'object') { errors.push(`operations[${idx}] должен быть объектом.`); return; } if (!op.type) errors.push(`operations[${idx}].type обязателен.`); else if (FEATURE_SCHEMA.supportedTypes.indexOf(op.type) < 0) errors.push(`operations[${idx}].type не поддержан: ${op.type}`); if (!op.payload || typeof op.payload !== 'object') errors.push(`operations[${idx}].payload обязателен и должен быть объектом.`); }); } return { valid: !errors.length, errors, humanMessage: errors.length ? `Найдено ошибок в DSL: ${errors.length}` : 'DSL валиден.', }; } function parseRequestTemplate(rawText) { const text = String(rawText || '').trim(); if (!text) return { confidence: 0, operations: [], reasons: ['Пустой запрос.'] }; if (text.startsWith('{') || text.startsWith('[')) { try { const parsed = JSON.parse(text); const operations = Array.isArray(parsed.operations) ? parsed.operations : (Array.isArray(parsed) ? parsed : [parsed]); return { confidence: 0.95, operations, reasons: ['Structured JSON распознан.'] }; } catch (error) { return { confidence: 0.2, operations: [], reasons: [`JSON parse error: ${error.message}`] }; } } if (text.indexOf('\t') >= 0 || text.indexOf(',') >= 0) { const lines = text.split(/\r?\n/).filter(Boolean); const delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : ','; const header = lines[0].split(delimiter).map(h => normalize(h)); const body = lines.slice(1).map(line => line.split(delimiter)); const operations = body.map(row => { const pick = key => { const idx = header.indexOf(normalize(key)); return idx >= 0 ? row[idx] : ''; }; return { type: pick('type') || 'add_doc_type_to_matching_rows', payload: { partnerName: pick('partner'), newDocType: pick('new_doc_type') || pick('doc_type'), legalEntity: pick('legal_entity'), rowGroup: pick('row_group') || 'all', }, options: { sourceRule: pick('request_id') || 'template_csv' }, }; }); return { confidence: 0.8, operations, reasons: ['TSV/CSV формат распознан.'] }; } const operations = []; const lower = normalize(text); if (lower.includes('добав') && lower.includes('тип')) { operations.push({ type: 'add_doc_type_to_matching_rows', payload: { rowGroup: lower.includes('доп') ? 'supplemental_rows' : 'all' }, options: { sourceRule: 'human_template' }, }); } if (lower.includes('юрлиц') || lower.includes('legal entity')) { operations.push({ type: 'add_legal_entity_to_matching_rows', payload: { rowGroup: 'all' }, options: { sourceRule: 'human_template' }, }); } return { confidence: operations.length ? 0.6 : 0.25, operations, reasons: operations.length ? ['Human request распознан частично. Проверь payload вручную.'] : ['Не удалось однозначно распознать заявку.'], }; } function runChecklistEngine(options) { const text = normalize(document.querySelector('#sc_ApprovalMatrix') ? document.querySelector('#sc_ApprovalMatrix').textContent : ''); const api = getApi(); const report = checklistRules.map(rule => { const passed = rule.test(text); return { id: rule.id, title: rule.title, severity: rule.severity, status: passed ? 'pass' : (rule.severity === 'error' ? 'fail' : 'warning'), sourceRule: `checklist:${rule.id}`, recommendation: passed ? 'OK' : `Проверь блок "${rule.title}" и исправь данные перед apply.`, }; }); const signerRows = Array.isArray(api.getLastReport ? api.getLastReport() : []) ? api.getLastReport().filter(row => row.operationType === 'add_signer_bundle' && row.actionType === 'add-row') : []; const signerRule = report.find(row => row.id === 'signer_bundle_4_rows'); if (signerRule) { if (options && Array.isArray(options.generatedRows)) { signerRule.status = options.generatedRows.length === 4 ? 'pass' : 'fail'; } else if (signerRows.length) { signerRule.status = signerRows.length === 4 ? 'pass' : 'fail'; } if (signerRule.status !== 'pass') signerRule.recommendation = 'Signer bundle должен содержать ровно 4 строки (2 main + 2 supplemental).'; } return { generatedAt: new Date().toISOString(), summary: { total: report.length, passed: report.filter(x => x.status === 'pass').length, failed: report.filter(x => x.status === 'fail').length, warnings: report.filter(x => x.status === 'warning').length, }, checks: report, }; } async function searchAcrossMatrices(query, options) { const api = getApi(); const opts = options || {}; const normalizedQuery = normalize(query); const mode = opts.mode || 'counterparty'; const strategy = opts.matchMode || 'partial'; const matrixName = document.title; const rows = getRows(); const results = []; rows.forEach((row, idx) => { const value = rowText(row); const isMatch = strategy === 'exact' ? value === normalizedQuery : value.indexOf(normalizedQuery) >= 0; if (!isMatch) return; results.push({ matrixName, matrixId: null, openUrl: window.location.href, rowNumber: idx + 1, itemId: getItemId(row), column: mode, matchedValue: String(row.textContent || '').trim().slice(0, 300), matchType: strategy, matrixState: document.querySelector('#sc_approvalmatrixStatus') ? document.querySelector('#sc_approvalmatrixStatus').value : null, }); }); if (api && typeof api.setLogFilter === 'function') { api.setLogFilter(results.length ? 'all' : 'ambiguous'); } return { mode, query, total: results.length, deduped: Array.from(new Map(results.map(item => [`${item.matrixName}:${item.itemId}:${item.column}`, item])).values()), progress: { scanned: 1, total: 1, done: true }, cancelled: false, generatedAt: new Date().toISOString(), }; } function toHtmlReport(title, rows) { const list = rows.map((row, idx) => `${idx + 1}${row.matrixName || ''}${row.rowNumber || ''}${row.column || ''}${String(row.matchedValue || '').replace(/${row.matchType || ''}`).join(''); return `${title}

${title}

${list}
#MatrixRowColumnValueMatch
`; } function downloadText(filename, content, contentType) { const blob = new Blob([content], { type: contentType || 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function buildV5Sections() { const root = document.querySelector('#mc-root'); if (!root || root.querySelector('[data-role="v5-request-template"]')) return; const searchSection = document.createElement('section'); searchSection.innerHTML = `

Поиск по матрицам

Еще не запускали.
`; root.appendChild(searchSection); const checklistSection = document.createElement('section'); checklistSection.innerHTML = `

Чеклист

Чеклист ещё не запускался.
`; root.appendChild(checklistSection); const requestSection = document.createElement('section'); requestSection.setAttribute('data-role', 'v5-request-template'); requestSection.innerHTML = `

Шаблон заявки

Ожидается ввод.
`; root.appendChild(requestSection); let lastSearch = []; let lastChecklist = null; let lastParsed = null; searchSection.querySelector('[data-role="v5-search-run"]').addEventListener('click', async () => { const query = searchSection.querySelector('[data-role="v5-search-query"]').value; const mode = searchSection.querySelector('[data-role="v5-search-mode"]').value; const matchMode = searchSection.querySelector('[data-role="v5-search-match"]').value; const result = await searchAcrossMatrices(query, { mode, matchMode }); lastSearch = result.deduped || []; searchSection.querySelector('[data-role="v5-search-result"]').textContent = `Найдено: ${result.total}. Dedupe: ${result.deduped.length}.`; }); searchSection.querySelector('[data-role="v5-search-export"]').addEventListener('click', () => { const html = toHtmlReport('Matrix Search Report', lastSearch || []); downloadText(`ot-matrix-search-${Date.now()}.html`, html, 'text/html;charset=utf-8'); }); checklistSection.querySelector('[data-role="v5-checklist-run"]').addEventListener('click', () => { lastChecklist = runChecklistEngine({}); checklistSection.querySelector('[data-role="v5-checklist-result"]').textContent = `pass=${lastChecklist.summary.passed} fail=${lastChecklist.summary.failed} warn=${lastChecklist.summary.warnings}`; }); checklistSection.querySelector('[data-role="v5-checklist-export"]').addEventListener('click', () => { if (!lastChecklist) lastChecklist = runChecklistEngine({}); downloadText(`ot-matrix-checklist-${Date.now()}.json`, JSON.stringify(lastChecklist, null, 2), 'application/json;charset=utf-8'); }); requestSection.querySelector('[data-role="v5-request-parse"]').addEventListener('click', () => { const text = requestSection.querySelector('[data-role="v5-request-text"]').value; lastParsed = parseRequestTemplate(text); requestSection.querySelector('[data-role="v5-request-result"]').textContent = `confidence=${lastParsed.confidence}; operations=${lastParsed.operations.length}; ${lastParsed.reasons.join(' | ')}`; }); requestSection.querySelector('[data-role="v5-request-preview"]').addEventListener('click', async () => { const api = getApi(); if (!lastParsed) { const text = requestSection.querySelector('[data-role="v5-request-text"]').value; lastParsed = parseRequestTemplate(text); } if (!api || typeof api.previewRuleBatch !== 'function') return; const operations = (lastParsed.operations || []).map(op => ({ type: op.type, matrixName: document.title, scope: op.scope || {}, filters: op.filters || {}, payload: op.payload || {}, options: Object.assign({ sourceRule: 'request_template' }, op.options || {}), })); await api.previewRuleBatch(operations, {}); }); } function ensureStyles() { if (document.querySelector('#mc-v5-features-style')) return; const style = document.createElement('style'); style.id = 'mc-v5-features-style'; style.textContent = ` .mc-v5-search-result { border: 1px solid #111; padding: 6px; background: #f8f8f8; font-size: 11px; } `; document.head.appendChild(style); } function installApi() { const api = getApi(); if (!api || api.__v5FeaturesInstalled) return false; api.validateDslConfig = validateDslConfig; api.parseRequestTemplate = parseRequestTemplate; api.runChecklistEngine = runChecklistEngine; api.searchAcrossMatrices = searchAcrossMatrices; api.exportHtmlReport = (rows, title) => toHtmlReport(title || 'OT Matrix Report', rows || []); api.getDslSchema = () => JSON.parse(JSON.stringify(FEATURE_SCHEMA)); api.__v5FeaturesInstalled = true; return true; } function install() { if (!installApi()) return false; ensureStyles(); buildV5Sections(); return true; } const wgt = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; wgt.__otV5Reinstall = install; if (install()) return; const timer = setInterval(() => { if (!install()) return; clearInterval(timer); }, 250); setTimeout(() => clearInterval(timer), 25000); })(); (() => { 'use strict'; const FLAG = '__OT_MATRIX_CLEANER_HUMAN_FIRST_UI__'; if (window[FLAG]) return; window[FLAG] = true; const state = { dictionaries: null, lastSearchResult: null, }; const SCENARIOS = [ { key: 'replace_signer', label: 'Замена подписанта' }, { key: 'replace_approver', label: 'Замена согласующего' }, { key: 'remove_approver', label: 'Удаление согласующего' }, { key: 'remove_counterparty_from_rows', label: 'Удаление контрагента из строк' }, { key: 'delete_rows_if_single_counterparty', label: 'Удаление строки с единственным контрагентом' }, { key: 'add_signer_bundle', label: 'Подписант по 4-строчному preset' }, { key: 'add_doc_type_to_matching_rows', label: 'Добавить тип документа' }, { key: 'add_change_card_flag_to_matching_rows', label: 'Изменение карточки' }, { key: 'add_legal_entity_to_matching_rows', label: 'Добавить юрлицо' }, ]; const QUICK_PRESETS = { signer_smoke: { label: 'Подписант: быстрый smoke 4 строки', values: { 'hf-scenario': 'add_signer_bundle', 'hf-limit': '1000', 'hf-amount': '500', }, }, bulk_doc_ds: { label: 'Массово: тип документа для ДС', values: { 'hf-row-group': 'supplemental_rows', 'hf-required-doc-types': 'ДС', 'hf-match-mode': 'all', 'hf-doc-type': 'Тестовый тип', }, }, bulk_legal_main: { label: 'Массово: юрлицо для основных', values: { 'hf-row-group': 'main_contract_rows', 'hf-match-mode': 'any', 'hf-legal-entity': 'ООО Тестовое ЮЛ', }, }, replace_approver_demo: { label: 'Замена согласующего: заготовка полей', values: { 'hf-scenario': 'replace_approver', 'hf-current-user': '', 'hf-new-user': '', }, }, }; function applyQuickPreset(root, presetId) { const preset = QUICK_PRESETS[presetId]; if (!preset) return false; Object.keys(preset.values || {}).forEach(role => { const el = root.querySelector(`[data-role="${role}"]`); if (!el) return; el.value = preset.values[role]; el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('input', { bubbles: true })); }); return true; } function getApi() { return __otMatrixCleanerHost().__OT_MATRIX_CLEANER__; } function getRows() { return Array.from(document.querySelectorAll('#sc_ApprovalMatrix tbody tr[itemid], #sc_ApprovalMatrix tbody tr[itemID]')); } function parseSemi(value) { return String(value || '').split(/[;,]/).map(v => String(v || '').trim()).filter(Boolean); } function unique(values) { return Array.from(new Set(values)); } function collectDictionaries() { const api = getApi(); const rawPartners = api && typeof api.getPartnerCatalog === 'function' ? api.getPartnerCatalog() : null; const partners = Array.isArray(rawPartners) ? rawPartners : []; const rows = getRows(); const docTypes = []; const legalEntities = []; const actors = []; rows.forEach(row => { const txt = String(row.textContent || ''); const doc = txt.match(/(?:типы?\s*документов?|doc\s*types?)[:\s-]*([^\n]+)/i); const legal = txt.match(/(?:юр\.?\s*лиц[а]?|legal\s*entit(?:y|ies))[:\s-]*([^\n]+)/i); parseSemi(doc ? doc[1] : '').forEach(v => docTypes.push(v)); parseSemi(legal ? legal[1] : '').forEach(v => legalEntities.push(v)); row.querySelectorAll('li.token-input-token, .token-input-token').forEach(node => { const rawT = (node.getAttribute('title') || node.textContent || '').replace(/[\u00A0\u2007]/g, ' ').replace(/\s*x\s*$/i, '').trim(); if (rawT && rawT.length > 1 && !/^\d+$/.test(rawT)) actors.push(rawT); }); }); return { counterparties: partners.map(item => (item && item.name) || '').filter(Boolean), signersAndApprovers: unique(actors).sort((a, b) => a.localeCompare(b, 'ru')), docTypes: unique(docTypes).sort((a, b) => a.localeCompare(b, 'ru')), legalEntities: unique(legalEntities).sort((a, b) => a.localeCompare(b, 'ru')), rowGroups: ['all', 'main_contract_rows', 'supplemental_rows', 'custom'], requiredAffiliation: 'Группа Черкизово', }; } function buildOperation(root, overrideType) { const type = overrideType || root.querySelector('[data-role="hf-scenario"]').value; const payload = { partnerName: root.querySelector('[data-role="hf-counterparty"]').value || '', currentApprover: root.querySelector('[data-role="hf-current-user"]').value || '', currentSigner: root.querySelector('[data-role="hf-current-user"]').value || '', newApprover: root.querySelector('[data-role="hf-new-user"]').value || '', newSigner: root.querySelector('[data-role="hf-new-user"]').value || '', rowGroup: root.querySelector('[data-role="hf-row-group"]').value || 'all', newDocType: root.querySelector('[data-role="hf-doc-type"]').value || '', legalEntity: root.querySelector('[data-role="hf-legal-entity"]').value || '', requiredDocTypes: parseSemi(root.querySelector('[data-role="hf-required-doc-types"]').value || ''), matchMode: root.querySelector('[data-role="hf-match-mode"]').value || 'all', affiliation: 'Группа Черкизово', limit: root.querySelector('[data-role="hf-limit"]').value || '', amount: root.querySelector('[data-role="hf-amount"]').value || '', }; return { type, matrixName: document.title, scope: {}, filters: { rowGroup: payload.rowGroup, requiredDocTypes: payload.requiredDocTypes, matchMode: payload.matchMode }, payload, options: { sourceRule: 'human_first_ui' }, }; } async function runSyntheticContour(mode) { const api = getApi(); const ops = [ { type: 'add_doc_type_to_matching_rows', payload: { rowGroup: 'supplemental_rows', requiredDocTypes: ['ДС'], matchMode: 'all', newDocType: 'Тестовый тип', affiliation: 'Группа Черкизово' } }, { type: 'add_legal_entity_to_matching_rows', payload: { rowGroup: 'main_contract_rows', requiredDocTypes: [], matchMode: 'any', legalEntity: 'ООО Тестовое ЮЛ', affiliation: 'Группа Черкизово' } }, { type: 'add_change_card_flag_to_matching_rows', payload: { rowGroup: 'all', requiredDocTypes: [], matchMode: 'any', changeCardFlag: 'Ранее не подписан', affiliation: 'Группа Черкизово' } }, { type: 'add_signer_bundle', payload: { currentSigner: 'Тестовый', newSigner: 'Новый', limit: '1000', amount: '500', affiliation: 'Группа Черкизово' } }, ].map(op => ({ type: op.type, matrixName: document.title, scope: {}, filters: {}, payload: op.payload, options: { sourceRule: `synthetic_${mode}` }, })); const checks = []; const preview = await api.previewRuleBatch(ops, {}); checks.push({ name: 'Synthetic preview', ok: Array.isArray(preview) && preview.length > 0 }); const signer = await api.previewRuleBatch([ops[3]], {}); const signerRows = (signer || []).filter(item => item.actionType === 'add-row'); checks.push({ name: 'Signer 4 rows', ok: signerRows.length === 4, details: `rows=${signerRows.length}` }); const checklist = api.runChecklistEngine ? api.runChecklistEngine({ generatedRows: signerRows }) : null; checks.push({ name: 'Checklist', ok: Boolean(checklist && checklist.summary && checklist.summary.total > 0) }); const search = api.searchAcrossMatrices ? await api.searchAcrossMatrices('договор', { mode: 'counterparty', matchMode: 'partial' }) : null; checks.push({ name: 'Global search', ok: Boolean(search && typeof search.total === 'number') }); if (mode === 'real_insert') { const tableRows = getRows(); checks.push({ name: 'Real insert guard', ok: tableRows.length > 0 }); } const fail = checks.filter(item => !item.ok).length; return { total: checks.length, ok: checks.length - fail, fail, checks, mode }; } function installApi(api) { if (!api || api.__humanFirstUiInstalled) return; api.getHumanDictionaries = () => { state.dictionaries = collectDictionaries(); return JSON.parse(JSON.stringify(state.dictionaries)); }; api.runAllHumanTests = options => runSyntheticContour(options && options.mode ? options.mode : 'preview_only'); api.__humanFirstUiInstalled = true; } function installStyles() { if (document.querySelector('#mc-human-first-style')) return; const style = document.createElement('style'); style.id = 'mc-human-first-style'; style.textContent = ` .mc-hf-root { border:1px solid #111; padding:8px; margin-bottom:10px; background:#fff; } .mc-hf-header { display:flex; justify-content:space-between; gap:8px; flex-wrap:wrap; margin-bottom:8px; } .mc-hf-tabs { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:5px; margin-bottom:8px; } @media (min-width: 400px) { .mc-hf-tabs { grid-template-columns: repeat(4, minmax(0, 1fr)); } } .mc-hf-tabs button { border:1px solid #111; background:#fff; padding:5px 4px; font-size:10px; font-weight:700; cursor:pointer; word-wrap:break-word; } .mc-hf-tabs button.is-active { background:#111; color:#fff; } .mc-hf-panel label { display:block; margin-bottom:6px; } .mc-hf-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; margin:8px 0; } .mc-hf-actions button { border:1px solid #111; background:#fff; padding:6px; font-size:11px; font-weight:700; cursor:pointer; } .mc-hf-result { border:1px solid #111; padding:6px; background:#fafafa; } .mc-hf-check-card { border:1px solid #ccc; padding:6px; margin-top:6px; } .mc-hf-pass { background:#efffef; } .mc-hf-warning { background:#fff8e8; } .mc-hf-fail { background:#ffecec; } .mc-hf-guide { font-size:11px; line-height:1.35; color:#333; margin:0 0 8px; } `; document.head.appendChild(style); } function hideLegacySections(root) { root.querySelectorAll('section').forEach(section => { const role = section.getAttribute('data-role'); if (role === 'hf-root' || role === 'otk-root') return; section.hidden = true; section.style.display = 'none'; }); } function showLegacySections(root) { root.querySelectorAll('section').forEach(section => { const role = section.getAttribute('data-role'); if (role === 'hf-root' || role === 'otk-root') return; section.hidden = false; section.style.display = ''; }); } function fillDataLists(root, dict) { const cpList = root.querySelector('#hf-counterparty-list'); cpList.innerHTML = ''; (dict.counterparties || []).forEach(item => { const option = document.createElement('option'); option.value = item; cpList.appendChild(option); }); const docList = root.querySelector('#hf-doc-list'); docList.innerHTML = ''; (dict.docTypes || []).forEach(item => { const option = document.createElement('option'); option.value = item; docList.appendChild(option); }); const legalList = root.querySelector('#hf-legal-list'); legalList.innerHTML = ''; (dict.legalEntities || []).forEach(item => { const option = document.createElement('option'); option.value = item; legalList.appendChild(option); }); const actList = root.querySelector('#hf-actors-list'); if (actList) { actList.innerHTML = ''; (dict.signersAndApprovers || []).forEach(item => { const option = document.createElement('option'); option.value = item; actList.appendChild(option); }); } } function renderChecklist(container, result) { if (!result || !Array.isArray(result.checks)) { container.textContent = 'Чек-лист недоступен.'; return; } const rows = result.checks.map(check => `
${check.title}
Статус: ${check.status}
${check.recommendation || ''}
`).join(''); container.innerHTML = `
pass=${result.summary.passed} fail=${result.summary.failed} warn=${result.summary.warnings}
${rows}`; } function buildUi(root) { if (root.querySelector('[data-role="hf-root"]')) return; const shell = document.createElement('section'); shell.setAttribute('data-role', 'hf-root'); shell.className = 'mc-hf-root'; shell.innerHTML = `
Рабочий режим Matrix Cleaner
1) Сценарий → 2) Данные → 3) Превью → 4) Применить
Автор: Артём Шаповалов (ShapArt)

Шаг 1: тип сценария. Шаг 2: выберите значения из списков (после «Обновить» внизу в разделе «Основные») или введите вручную. Шаг 3: «Показать превью».

Подсказка: заготовка заполняет поля, затем можно сразу запускать превью.
`; root.prepend(shell); const tabs = Array.from(shell.querySelectorAll('[data-tab]')); function switchTab(id) { tabs.forEach(btn => btn.classList.toggle('is-active', btn.getAttribute('data-tab') === id)); shell.querySelectorAll('[data-panel]').forEach(panel => { panel.hidden = panel.getAttribute('data-panel') !== id; }); } tabs.forEach(btn => btn.addEventListener('click', () => switchTab(btn.getAttribute('data-tab')))); const scenarioSelect = shell.querySelector('[data-role="hf-scenario"]'); scenarioSelect.innerHTML = SCENARIOS.map(item => ``).join(''); const api = getApi(); const dict = api.getHumanDictionaries(); fillDataLists(shell, dict); hideLegacySections(root); shell.querySelector('[data-role="hf-show-legacy"]').addEventListener('click', () => showLegacySections(root)); shell.querySelector('[data-role="hf-apply-preset"]').addEventListener('click', () => { const presetId = shell.querySelector('[data-role="hf-quick-preset"]').value; const info = shell.querySelector('[data-role="hf-preset-result"]'); if (!presetId) { info.textContent = 'Выберите заготовку из списка.'; return; } const ok = applyQuickPreset(shell, presetId); info.textContent = ok ? `Заготовка применена: ${QUICK_PRESETS[presetId].label}. Дальше нажмите «Показать превью».` : 'Не удалось применить заготовку.'; }); shell.querySelector('[data-role="hf-preview"]').addEventListener('click', () => api.previewRuleBatch([buildOperation(shell)], {})); shell.querySelector('[data-role="hf-apply"]').addEventListener('click', () => api.runRuleBatch([buildOperation(shell)], {})); shell.querySelector('[data-role="hf-preview-bulk"]').addEventListener('click', () => api.previewRuleBatch([ buildOperation(shell, 'add_doc_type_to_matching_rows'), buildOperation(shell, 'add_change_card_flag_to_matching_rows'), buildOperation(shell, 'add_legal_entity_to_matching_rows'), ], {})); shell.querySelector('[data-role="hf-apply-bulk"]').addEventListener('click', () => api.runRuleBatch([ buildOperation(shell, 'add_doc_type_to_matching_rows'), buildOperation(shell, 'add_change_card_flag_to_matching_rows'), buildOperation(shell, 'add_legal_entity_to_matching_rows'), ], {})); let searchCancelled = false; shell.querySelector('[data-role="hf-search-stop"]').addEventListener('click', () => { searchCancelled = true; shell.querySelector('[data-role="hf-search-result"]').textContent = 'Поиск остановлен пользователем.'; }); shell.querySelector('[data-role="hf-search-run"]').addEventListener('click', async () => { searchCancelled = false; const query = shell.querySelector('[data-role="hf-search-query"]').value; const mode = shell.querySelector('[data-role="hf-search-mode"]').value; const type = shell.querySelector('[data-role="hf-search-type"]').value; if (searchCancelled) return; state.lastSearchResult = await api.searchAcrossMatrices(query, { matchMode: mode, mode: type }); shell.querySelector('[data-role="hf-search-result"]').textContent = `Найдено: ${state.lastSearchResult.total}; dedupe: ${state.lastSearchResult.deduped.length}`; }); shell.querySelector('[data-role="hf-search-export"]').addEventListener('click', () => { const html = api.exportHtmlReport((state.lastSearchResult && state.lastSearchResult.deduped) || [], 'Результат поиска'); const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `matrix-search-${Date.now()}.html`; document.body.appendChild(a); a.click(); a.remove(); }); shell.querySelector('[data-role="hf-signer-preview"]').addEventListener('click', async () => { const report = await api.previewRuleBatch([buildOperation(shell, 'add_signer_bundle')], {}); const rows = (report || []).filter(item => item.actionType === 'add-row'); const tpl = document.querySelector('#sc_ApprovalMatrix tbody tr[itemid], #sc_ApprovalMatrix tbody tr[itemID]'); const ghostHint = tpl && rows.length ? ' Внизу таблицы матрицы должны появиться подсвеченные строки-превью (прокрутите).' : (rows.length ? ' Если таблица пуста — ghost-строки не рисуются; см. лог панели.' : ''); shell.querySelector('[data-role="hf-signer-result"]').textContent = `Сгенерировано записей плана: ${rows.length} из 4 ожидаемых.${ghostHint}`; }); shell.querySelector('[data-role="hf-signer-apply"]').addEventListener('click', () => api.runRuleBatch([buildOperation(shell, 'add_signer_bundle')], {})); shell.querySelector('[data-role="hf-checklist-run"]').addEventListener('click', () => renderChecklist(shell.querySelector('[data-role="hf-checklist-result"]'), api.runChecklistEngine({}))); let lastTicketParse = null; shell.querySelector('[data-role="hf-ticket-parse"]').addEventListener('click', () => { const text = shell.querySelector('[data-role="hf-ticket-text"]').value; if (typeof api.parseFreeformRequestText !== 'function') { shell.querySelector('[data-role="hf-ticket-result"]').textContent = 'API parseFreeformRequestText недоступен. Обновите userscript.'; return; } lastTicketParse = api.parseFreeformRequestText(text); const r = lastTicketParse; const pct = r && r.confidence != null ? (Number(r.confidence) * 100).toFixed(0) : '0'; shell.querySelector('[data-role="hf-ticket-result"]').textContent = `Уверенность: ${pct}%. ${(r.reasons || []).join(' ')} Операций: ${(r.operations || []).length}.`; }); shell.querySelector('[data-role="hf-ticket-preview"]').addEventListener('click', async () => { const text = shell.querySelector('[data-role="hf-ticket-text"]').value; let parsed = lastTicketParse; if (!parsed || !Array.isArray(parsed.operations) || !parsed.operations.length) { parsed = api.parseFreeformRequestText ? api.parseFreeformRequestText(text) : { operations: [] }; } if (!parsed.operations || !parsed.operations.length) { shell.querySelector('[data-role="hf-ticket-result"]').textContent = 'Сначала нажмите «Разобрать текст» или вставьте явный сценарий (замена подписанта, тип документа, юрлицо).'; return; } const ops = parsed.operations.map(op => { const base = op && typeof op === 'object' ? op : {}; return { type: base.type, matrixName: document.title, scope: base.scope || {}, filters: base.filters || {}, payload: base.payload || {}, options: Object.assign({ sourceRule: 'freeform_ticket' }, base.options || {}), }; }); await api.previewRuleBatch(ops, {}); const rep = (api.getLastReport && api.getLastReport()) || []; shell.querySelector('[data-role="hf-ticket-result"]').textContent = `Превью: записей в отчёте ${Array.isArray(rep) ? rep.length : 0}. Проверьте подсветку строк.`; }); shell.querySelector('[data-role="hf-test-all"]').addEventListener('click', async () => { const mode = shell.querySelector('[data-role="hf-test-mode"]').value; if (typeof api.runAllUiDiagnostics !== 'function') { const result = await api.runAllHumanTests({ mode }); shell.querySelector('[data-role="hf-test-result"]').textContent = `Итог контура: OK=${result.ok}, FAIL=${result.fail} из ${result.total} (старый API без runAllUiDiagnostics).`; return; } const diag = await api.runAllUiDiagnostics({ humanTestMode: mode }); shell.querySelector('[data-role="hf-test-result"]').textContent = `Проверок: ${diag.checks.length}, сбоев: ${diag.failed}. Режим контура: ${diag.humanTestMode}. Тест не вносит постоянные изменения, это проверка перед реальным apply.`; }); shell.querySelector('[data-role="hf-report-json"]').addEventListener('click', () => { const report = api.getLastReport ? api.getLastReport() : []; const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `matrix-report-${Date.now()}.json`; document.body.appendChild(a); a.click(); a.remove(); }); shell.querySelector('[data-role="hf-report-csv"]').addEventListener('click', () => { const report = api.getLastReport ? api.getLastReport() : []; const headers = ['operationType', 'actionType', 'status', 'reason', 'itemid']; const csv = [headers.join(',')].concat(report.map(row => headers.map(k => `"${String(row[k] || '').replace(/"/g, '""')}"`).join(','))).join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `matrix-report-${Date.now()}.csv`; document.body.appendChild(a); a.click(); a.remove(); }); shell.querySelector('[data-role="hf-report-html"]').addEventListener('click', () => { const report = api.getLastReport ? api.getLastReport() : []; const htmlRows = report.map((item, idx) => ({ matrixName: document.title, rowNumber: idx + 1, column: item.operationType, matchedValue: item.reason || item.message || '' })); const html = api.exportHtmlReport(htmlRows, 'Отчет Matrix Cleaner'); const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `matrix-report-${Date.now()}.html`; document.body.appendChild(a); a.click(); a.remove(); }); } function install() { const api = getApi(); const root = document.querySelector('#mc-root'); if (!api) return false; installApi(api); if (!root) return false; installStyles(); buildUi(root); return true; } const wHf = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; wHf.__otHumanReinstall = install; if (install()) return; const timer = setInterval(() => { install(); }, 250); setTimeout(() => clearInterval(timer), 30000); })(); /* ===== Matrix Cleaner v8 runtime (generated) ===== */ (() => { 'use strict'; const host = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const FLAG = '__OT_MATRIX_CLEANER_V8_RUNTIME__'; if (host[FLAG]) return; host[FLAG] = true; const REQUIRED_AFFILIATION = 'Группа Черкизово'; const VERSION = '8.1.0'; const DOC_GROUP_A = [ 'Основной договор', 'Перемена лица в обязательстве', 'ДС на пролонгацию', ]; const DOC_GROUP_B = [ 'ДС', 'Спецификация', 'Спецификация по качеству', 'Соглашение о бонусах', 'Перемена лица в обязательстве', 'Соглашение о зачете', 'Соглашение по ЭДО', 'ДС к спецификации', 'Заверение об обстоятельствах', 'Соглашение о расторжении', 'ДС на пролонгацию', 'Соглашение о штрафах', 'Уведомление о факторинге', ]; const ACTION = { ADD_ROW: 'add-row', PATCH_ROW: 'patch-row', SPLIT_ROW: 'split-row', CARVE_BAND: 'carve-band', DELETE_ROW: 'delete-row', REMOVE_TOKEN: 'remove-token', SKIP: 'skip', MANUAL: 'manual-review', LEGACY: 'legacy-delegate', }; const STATUS = { OK: 'ok', SKIPPED: 'skipped', ERROR: 'error', MANUAL: 'manual_review', WARN: 'warn', PASS: 'pass', FAIL: 'fail', }; const SUPPORTED_V8 = new Set([ 'add_signer_bundle', 'add_doc_type_to_matching_rows', 'add_legal_entity_to_matching_rows', 'add_site_to_matching_rows', 'add_change_card_flag_to_matching_rows', 'remove_doc_type_from_matching_rows', 'remove_legal_entity_from_matching_rows', 'remove_site_from_matching_rows', 'remove_category_from_matching_rows', 'remove_change_card_flag_from_matching_rows', 'create_category_from_template', 'replace_signer_by_name', 'split_legal_entity_to_new_row', 'carve_limit_band', 'adjust_signer_limit', ]); const state = { installedApi: false, installedUi: false, plans: new Map(), lastPlanId: '', lastReport: [], lastApplySnapshot: null, lastSearch: null, previewEnabled: true, searchCancelled: false, }; const original = {}; function getApi() { return host.__OT_MATRIX_CLEANER__; } function getMatrix() { return host.sc_ApprovalMatrix || window.sc_ApprovalMatrix || null; } function getJq() { return host.jQuery || host.$ || window.jQuery || window.$ || null; } function normalize(value) { return String(value == null ? '' : value) .replace(/[\u00A0\u2007]/g, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function matchKey(value) { return normalize(value) .replace(/[a]/g, 'а') .replace(/[e]/g, 'е') .replace(/[k]/g, 'к') .replace(/[m]/g, 'м') .replace(/[o]/g, 'о') .replace(/[p]/g, 'р') .replace(/[c]/g, 'с') .replace(/[t]/g, 'т') .replace(/[y]/g, 'у') .replace(/[x]/g, 'х') .replace(/[b]/g, 'в') .replace(/[h]/g, 'н') .replace(/&/g, ' ') .replace(/[‐‑‒–—-]+/g, ' ') .replace(/[.,:;()[\]{}"«»]+/g, ' ') .replace(/\b(?:дирекция|функция|категория|направление)\b/g, ' ') .replace(/\b(?:и|and)\b/g, ' ') .replace(/\s+/g, ' ') .trim(); } function textMatches(haystack, needle) { const got = matchKey(haystack); const want = matchKey(needle); if (!want) return true; if (!got) return false; if (got === want || got.includes(want) || want.includes(got)) return true; const gotTokens = got.split(/\s+/).filter(Boolean); const wantTokens = want.split(/\s+/).filter(Boolean); return wantTokens.length > 0 && wantTokens.every(token => gotTokens.some(value => value === token || value.startsWith(token) || token.startsWith(value))); } function unique(values) { const seen = new Set(); const out = []; (values || []).forEach(value => { const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim(); const key = normalize(text); if (!key || seen.has(key)) return; seen.add(key); out.push(text); }); return out; } function parseList(value) { if (Array.isArray(value)) return unique(value); return unique(String(value || '').split(/[;,|\n]/)); } // Ячейка «Условия применения» в модели = массив условий; одно условие = [[Тип],[ВН]]. // Напр. "Тип = Расходная, ВН = Нет" → [["Расходная"],["Нет"]]; "Тип = Все" → [[""],["Да/Нет"]]. function conditionToCell(str) { const s = String(str || ''); const typeMatch = /тип\s*=\s*([^,;]+)/i.exec(s); const vnMatch = /вн\s*=\s*([^,;]+)/i.exec(s); let type = typeMatch ? typeMatch[1].trim() : ''; const vn = vnMatch ? vnMatch[1].trim() : 'Нет'; if (/^все$/i.test(type)) type = ''; // «Все» = любой тип = пустая строка, как в существующих строках return [[type], [vn]]; } function cloneValue(value) { try { return JSON.parse(JSON.stringify(value)); } catch (_) { return value; } } function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } function getColumns() { const matrix = getMatrix(); return matrix && Array.isArray(matrix.cols) ? matrix.cols : []; } function getColumnIndex(aliases) { const wanted = Array.isArray(aliases) ? aliases : [aliases]; const normalized = wanted.map(normalize); return getColumns().findIndex(col => col && normalized.includes(normalize(col.alias || col.title || ''))); } function getColumnInfo(alias) { const idx = getColumnIndex(alias); return idx >= 0 ? { idx, column: getColumns()[idx] } : null; } function matrixItems() { const matrix = getMatrix(); return matrix && Array.isArray(matrix.items) ? matrix.items : []; } function matrixRows() { return Array.from(document.querySelectorAll('#sc_ApprovalMatrix tbody tr[itemid], #sc_ApprovalMatrix tbody tr[itemID]')); } function rowByIndex(index) { return document.querySelector(`#sc_ApprovalMatrix tbody tr[itemid="${index}"], #sc_ApprovalMatrix tbody tr[itemID="${index}"]`); } function valueAsList(value) { if (Array.isArray(value)) return value.filter(item => item != null && item !== '').map(String); if (isObject(value) && Array.isArray(value.performerList)) return value.performerList.map(String); if (value == null || value === '') return []; return [String(value)]; } function namesForIds(ids, cache) { const source = cache || {}; return (ids || []).map(id => { const abs = Math.abs(Number(id)); return source[abs] || source[id] || String(id); }); } function buildUserDirectory() { const out = new Map(); const push = (id, title) => { const name = String(title || '').trim(); const num = Number(id); if (!name || !Number.isFinite(num)) return; out.set(normalize(name), num); }; const matrix = getMatrix(); if (matrix && matrix.userCacheObject) { Object.keys(matrix.userCacheObject).forEach(id => push(id, matrix.userCacheObject[id])); } ['sc_ModelUser', 'sc_ModelUser2'].forEach(key => { const model = host[key] || window[key]; if (!model || !Array.isArray(model.items)) return; model.items.forEach(item => push(item.id, item.title || item.name)); }); return out; } function resolveUserId(nameOrId) { const raw = String(nameOrId == null ? '' : nameOrId).trim(); if (/^-?\d{3,}$/.test(raw)) return Math.abs(Number(raw)); const asNumber = Number(nameOrId); if (Number.isFinite(asNumber) && asNumber > 0) return asNumber; const directory = buildUserDirectory(); const key = normalize(nameOrId); const exact = directory.get(key); if (exact) return exact; const shortKey = normalize(raw.replace(/\s*\([^)]*\)\s*$/, '')); const shortExact = shortKey ? directory.get(shortKey) : null; if (shortExact) return shortExact; const matches = []; directory.forEach((id, titleKey) => { const titleShort = normalize(String(titleKey || '').replace(/\s*\([^)]*\)\s*$/, '')); if (!shortKey || !titleShort) return; if (titleShort === shortKey || titleShort.startsWith(`${shortKey} `) || shortKey.startsWith(`${titleShort} `)) matches.push(id); }); const uniqueMatches = Array.from(new Set(matches)); return uniqueMatches.length === 1 ? uniqueMatches[0] : null; } function cleanSignerId(value) { const raw = String(value == null ? '' : value).trim(); if (!/^-?\d{3,}$/.test(raw)) return ''; return String(Math.abs(Number(raw))); } function signerAliasKey(value) { return normalize(value).replace(/[\s_-]+/g, ''); } function isInitiatorAlias(value) { const key = signerAliasKey(value); return [ 'initiator', 'requester', 'author', 'cardinitiator', 'requestinitiator', 'инициатор', 'заявитель', 'автор', ].includes(key); } function resolveSignerFilterPayload(payload) { const source = payload || {}; const raw = String(source.signerFilter == null ? '' : source.signerFilter).trim(); const name = String(source.signerFilterName == null ? '' : source.signerFilterName).trim(); const rawIsAlias = isInitiatorAlias(raw); const nameIsAlias = isInitiatorAlias(name); const candidates = []; const push = value => { const text = String(value == null ? '' : value).trim(); if (text) candidates.push(text); }; push(source.signerFilterId); push(source.signerFilterUserId); push(source.signerUserId); if (!rawIsAlias) push(raw); if (!nameIsAlias) push(name); if (rawIsAlias || nameIsAlias) { push(source.initiatorId); push(source.initiatorUserId); push(source.initiator); push(source.initiatorName); push(source.requestInitiator); push(source.requester); push(source.cardInitiator); push(source.author); push(source.createdBy); push(source.creator); } for (const candidate of unique(candidates)) { const clean = cleanSignerId(candidate); if (clean) return { id: clean, label: name || raw || candidate, alias: rawIsAlias || nameIsAlias }; const resolved = resolveUserId(candidate); if (resolved) return { id: String(Math.abs(Number(resolved))), label: candidate, alias: rawIsAlias || nameIsAlias }; } return { id: '', label: name || raw, alias: rawIsAlias || nameIsAlias, }; } function hasSignerFilterPayload(payload) { const source = payload || {}; return [ source.signerFilter, source.signerFilterName, source.signerFilterId, source.signerFilterUserId, source.signerUserId, ].some(value => String(value == null ? '' : value).trim()); } function unresolvedSignerFilterReason(filter) { const label = filter && filter.label ? filter.label : 'signerFilter'; if (filter && filter.alias) { return `Фильтр "${label}" не раскрыт в реального подписанта. Передайте payload.initiator / payload.initiatorId или выберите подписанта из автоподсказки.`; } return `Подписант "${label}" не найден в справочнике этой матрицы. Выберите его из автоподсказки или передайте signerFilterId.`; } function toModelId(value) { const clean = cleanSignerId(value); return clean ? Number(clean) : value; } function normalizePerformerList(values) { return (Array.isArray(values) ? values : []) .map(value => toModelId(value)) .filter(value => value != null && value !== ''); } function columnKey(column) { return normalize(column && (column.alias || column.title || '')); } function isPartnerIdColumn(column) { const key = columnKey(column); return key === 'partner_id' || key === 'partners_internal_id'; } function isSigningColumn(column) { return Boolean(column && column.colType === 'level' && column.type === 'signing'); } function normalizeCellForColumn(columnIndex, value) { const column = getColumns()[columnIndex] || {}; if (isObject(value) && Array.isArray(value.performerList)) { const copy = Object.assign({}, value); copy.performerList = normalizePerformerList(value.performerList); return copy; } if (Array.isArray(value)) { if (isPartnerIdColumn(column)) return value.map(toModelId); return value.map(item => normalizeCellForColumn(columnIndex, item)); } if (isPartnerIdColumn(column)) return toModelId(value); return value; } function normalizeRowForModel(item) { if (!Array.isArray(item)) return item; for (let i = 0; i < item.length; i += 1) item[i] = normalizeCellForColumn(i, item[i]); return item; } function validateNativeSaveReadiness(matrix, options = {}) { const m = matrix || getMatrix(); const errors = []; const cols = (m && Array.isArray(m.cols)) ? m.cols : getColumns(); const items = (m && Array.isArray(m.items)) ? m.items : null; const scopedRows = Array.isArray(options.rowIndexes) ? new Set(options.rowIndexes.map(value => Number(value)).filter(value => Number.isInteger(value) && value >= 0)) : null; const hasRowScope = Boolean(scopedRows && scopedRows.size); if (!m || !items) { errors.push('OpenText matrix model is not available.'); return { ok: false, errors, rowCount: 0, colCount: cols.length }; } if (Array.isArray(m.mRecsID) && m.mRecsID.length !== items.length) { errors.push(`mRecsID length ${m.mRecsID.length} does not match items length ${items.length}`); } if (Array.isArray(m.mRecsStatus) && m.mRecsStatus.length !== items.length) { errors.push(`mRecsStatus length ${m.mRecsStatus.length} does not match items length ${items.length}`); } items.forEach((row, rowIdx) => { if (hasRowScope && !scopedRows.has(rowIdx)) return; if (!Array.isArray(row)) { errors.push(`row #${rowIdx + 1} is not an array`); return; } if (cols.length && row.length < cols.length) { errors.push(`row #${rowIdx + 1} has ${row.length} cells, expected at least ${cols.length}`); } row.forEach((cell, colIdx) => { const column = cols[colIdx] || {}; const label = column.alias || column.title || `col_${colIdx}`; if (isPartnerIdColumn(column)) { const values = Array.isArray(cell) ? cell : (cell == null || cell === '' ? [] : [cell]); values.forEach(value => { if (value == null || value === '') return; if (typeof value !== 'number' || !Number.isFinite(value)) { errors.push(`row #${rowIdx + 1} ${label} contains non-number id ${JSON.stringify(value)}`); } }); } if (isObject(cell) && Array.isArray(cell.performerList)) { cell.performerList.forEach(value => { const numericId = cleanSignerId(value); const mustBeNumeric = isSigningColumn(column) || numericId; if (mustBeNumeric && (typeof value !== 'number' || !Number.isFinite(value))) { errors.push(`row #${rowIdx + 1} ${label}.performerList contains non-number id ${JSON.stringify(value)}`); } }); } }); }); let serial = ''; try { serial = JSON.stringify(items); } catch (error) { errors.push(`matrix items are not serializable: ${error && error.message ? error.message : error}`); } if (/__mcV8/.test(serial)) errors.push('internal __mcV8 marker leaked into serialized matrix items'); return { ok: errors.length === 0, errors, rowCount: items.length, colCount: cols.length, scopedRows: hasRowScope ? Array.from(scopedRows) : null }; } function resolveGeneratedSignerId(row) { const explicit = cleanSignerId(row && (row.newSignerId || row.signerId || row.userId || row.performerId)); if (explicit) return explicit; const resolved = resolveUserId(row && (row.newSigner || row.signer)); return resolved ? cleanSignerId(resolved) : ''; } function factsForIndex(index) { const matrix = getMatrix(); const item = matrixItems()[index] || []; const col = { partner: getColumnIndex(['partner_id', 'partners_internal_id', 'Контрагент']), site: getColumnIndex(['partner_op', 'site', 'op', 'Обособленное подразделение']), docType: getColumnIndex(['document_type', 'Тип документа']), legalEntity: getColumnIndex(['legal_entity', 'legal_entities', 'legal_entity_id', 'legal_entities_id', 'Юрлицо', 'Юр. лицо']), direction: getColumnIndex(['direction', 'Дирекция']), functions: getColumnIndex(['functions', 'Функция']), category: getColumnIndex(['category', 'Категория']), amount: getColumnIndex(['sum_rub', 'amount', 'Сумма документа в рублях (включая налоги)']), limit: getColumnIndex(['limit_contract', 'limit', 'Лимит по договору в рублях (без НДС)']), affiliation: getColumnIndex(['affiliation', 'Аффилированность']), eds: getColumnIndex(['eds', 'ЭЦП', 'ЭДО']), change: getColumnIndex(['change', 'Изменения']), }; const docTypes = col.docType >= 0 ? valueAsList(item[col.docType]) : []; const legalEntities = col.legalEntity >= 0 ? valueAsList(item[col.legalEntity]) : []; const directions = col.direction >= 0 ? valueAsList(item[col.direction]) : []; const functions = col.functions >= 0 ? valueAsList(item[col.functions]) : []; const categories = col.category >= 0 ? valueAsList(item[col.category]) : []; const sites = col.site >= 0 ? valueAsList(item[col.site]) : []; const affiliations = col.affiliation >= 0 ? valueAsList(item[col.affiliation]) : []; const eds = col.eds >= 0 ? valueAsList(item[col.eds]) : []; const amount = col.amount >= 0 ? valueAsList(item[col.amount]) : []; const limit = col.limit >= 0 ? valueAsList(item[col.limit]) : []; const partnerIds = col.partner >= 0 ? valueAsList(item[col.partner]) : []; const partnerNames = matrix && matrix.partnerCacheObject ? namesForIds(partnerIds, matrix.partnerCacheObject) : partnerIds; const text = [ docTypes.join('; '), legalEntities.join('; '), directions.join('; '), functions.join('; '), categories.join('; '), eds.join('; '), partnerNames.join('; '), ].join(' '); const textNorm = normalize(text); const groups = []; if (/основн|main/.test(textNorm)) groups.push('main_contract_rows'); if (/дс|доп|спецификац|подчин|supplement/.test(textNorm)) groups.push('supplemental_rows'); if (!groups.length) groups.push('custom'); return { index, itemId: index, recordId: matrix && Array.isArray(matrix.mRecsID) ? matrix.mRecsID[index] : '', rowNumber: index + 1, columns: col, docTypes, legalEntities, directions, functions, categories, sites, affiliations, eds, amount, limit, partnerNames, groups, text, }; } function allRowFacts() { return matrixItems().map((_, index) => factsForIndex(index)); } function rowFingerprint(facts) { return JSON.stringify({ index: facts.index, recordId: facts.recordId, docTypes: facts.docTypes, legalEntities: facts.legalEntities, directions: facts.directions, functions: facts.functions, categories: facts.categories, affiliations: facts.affiliations, eds: facts.eds, amount: facts.amount, limit: facts.limit, }); } function matchRowGroup(facts, group) { const wanted = String(group || 'all'); if (wanted === 'all') return true; if (wanted === 'custom') return true; return facts.groups.includes(wanted); } function hasTypesByMode(existing, required, mode) { const wanted = parseList(required).filter(Boolean); if (!wanted.length) return true; const got = existing || []; if (String(mode || 'all').toLowerCase() === 'any') return wanted.some(item => got.some(value => textMatches(value, item))); return wanted.every(item => got.some(value => textMatches(value, item))); } function matchesOptionalValues(existing, required) { const wanted = parseList(required).filter(Boolean); if (!wanted.length) return true; const got = (existing || []).filter(Boolean); // In OpenText matrix semantics an empty condition cell means "all values". if (!got.length) return true; return wanted.every(needle => got.some(value => textMatches(value, needle))); } function reportBase(op, facts) { return { matrixName: document.title || '', operationType: op.type, itemId: facts ? facts.itemId : '', itemid: facts ? facts.itemId : '', recordId: facts ? facts.recordId : '', recId: facts ? facts.recordId : '', rowNo: facts ? facts.rowNumber : '', affiliation: op.payload.affiliation || REQUIRED_AFFILIATION, sourceRule: op.options.sourceRule || op.payload.sourceRule || 'v8', filterMode: '', filterColumnAlias: '', filterMatchedIds: [], remainingPartners: [], beforeFingerprint: facts ? rowFingerprint(facts) : '', before: facts ? { docTypes: facts.docTypes.slice(), legalEntities: facts.legalEntities.slice(), sites: facts.sites.slice(), directions: facts.directions.slice(), functions: facts.functions.slice(), categories: facts.categories.slice(), partnerNames: facts.partnerNames.slice(), groups: facts.groups.slice(), eds: facts.eds.slice(), amount: facts.amount.slice(), limit: facts.limit.slice(), } : {}, }; } function normalizeOperation(raw) { const op = raw || {}; const nested = op.operation && typeof op.operation === 'object' ? op.operation : {}; return { type: op.type || nested.type || '', matrixName: op.matrixName || document.title || '', matrixQuery: op.matrixQuery || {}, selection: Object.assign({}, op.selection || {}, nested.selection || {}), filters: Object.assign({}, op.filters || {}, op.selection || {}, nested.selection || {}), payload: Object.assign({}, nested.payload || {}, op.payload || {}), options: Object.assign({}, nested.options || {}, op.options || {}), preview: Object.assign({}, op.preview || nested.preview || {}), apply: Object.assign({}, op.apply || nested.apply || {}), reporting: Object.assign({}, op.reporting || nested.reporting || {}), }; } function signerPresetRows(op) { const payload = op.payload || {}; // Prefer the forms learned from the matrix (2 for a combined package, 4 for split A/B, etc.) // instead of a hardcoded 4. The toolkit passes these in payload.signerForms. if (Array.isArray(payload.signerForms) && payload.signerForms.length) { const rows = payload.signerForms .filter(form => (form.newSigner || form.signer) && (form.value != null && String(form.value).trim() !== '' || form.to != null && String(form.to).trim() !== '')) .map((form, i) => ({ rowKey: form.rowKey || `form_${i + 1}`, rowGroup: form.rowGroup || (form.packageClass === 'supplemental' ? 'supplemental_rows' : 'main_contract_rows'), docTypes: Array.isArray(form.documentTypes) ? form.documentTypes.slice() : [], edoMode: form.edoMode, valueMode: form.valueMode || (form.packageClass === 'supplemental' ? 'amount' : 'limit'), from: form.from != null ? String(form.from) : '0', value: form.value != null ? form.value : form.to, newSigner: form.newSigner || form.signer || payload.newSigner || '', signer: form.newSigner || form.signer || payload.newSigner || '', newSignerId: form.newSignerId || form.signerId || payload.newSignerId || payload.signerId || '', signerId: form.signerId || form.newSignerId || payload.signerId || payload.newSignerId || '', currentSigner: form.currentSigner || payload.currentSigner || '', direction: form.direction || payload.direction || '', functionName: form.functionName || form.functions || payload.functionName || payload.functions || '', category: form.category || payload.category || '', legalEntities: parseList(form.legalEntities || payload.legalEntities || payload.legalEntity || ''), sites: parseList(form.sites || payload.sites || payload.site || ''), conditions: parseList(form.conditions || payload.conditions || ''), dealInternal: form.dealInternal === true || payload.dealInternal === true, affiliation: payload.affiliation || REQUIRED_AFFILIATION, })); if (rows.length) return { rows }; } const ranges = Array.isArray(payload.ranges) && payload.ranges.length ? payload.ranges.map(range => ({ from: String(range.from || '0').trim(), limit: String(range.limit || range.to || payload.limit || payload.limits || '').trim(), amount: String(range.amount || range.to || payload.amount || payload.amounts || payload.limit || payload.limits || '').trim(), newSigner: String(range.signer || range.newSigner || payload.newSigner || payload.signer || payload.newApprover || '').trim(), newSignerId: String(range.signerId || range.newSignerId || payload.newSignerId || payload.signerId || '').trim(), })) : [{ from: String(payload.from || '0').trim(), limit: String(payload.limit || payload.limits || '').trim(), amount: String(payload.amount || payload.amounts || payload.limit || payload.limits || '').trim(), newSigner: String(payload.newSigner || payload.signer || payload.newApprover || '').trim(), newSignerId: String(payload.newSignerId || payload.signerId || '').trim(), }]; const invalid = ranges.find(range => !range.limit || !range.amount || !range.newSigner); if (invalid || !ranges.length) { return { error: 'Для пакета подписания нужны подписант и диапазон: от/до для лимита и суммы.', rows: [], }; } const commonBase = { currentSigner: payload.currentSigner || payload.currentApprover || '', docTypes: parseList(payload.docTypes || payload.docType || ''), legalEntities: parseList(payload.legalEntities || payload.legalEntity || ''), sites: parseList(payload.sites || payload.site || ''), conditions: parseList(payload.conditions || ''), dealInternal: payload.dealInternal === true, direction: payload.direction || '', functionName: payload.functionName || payload.functions || '', category: payload.category || '', affiliation: payload.affiliation || REQUIRED_AFFILIATION, }; return { rows: ranges.flatMap((range, rangeIndex) => { const suffix = ranges.length === 1 ? '' : `_r${rangeIndex + 1}`; const common = Object.assign({}, commonBase, { newSigner: range.newSigner, signer: range.newSigner, newSignerId: range.newSignerId || payload.newSignerId || payload.signerId || '', signerId: range.newSignerId || payload.newSignerId || payload.signerId || '', from: range.from || '0', to: range.limit, }); return [ Object.assign({}, common, { rowKey: `main_limit_edo${suffix}`, rowGroup: 'main_contract_rows', docTypes: DOC_GROUP_A.slice(), edoMode: 'edo', valueMode: 'limit', value: range.limit }), Object.assign({}, common, { rowKey: `main_limit_non_edo${suffix}`, rowGroup: 'main_contract_rows', docTypes: DOC_GROUP_A.slice(), edoMode: 'non_edo', valueMode: 'limit', value: range.limit }), Object.assign({}, common, { rowKey: `supp_amount_edo${suffix}`, rowGroup: 'supplemental_rows', docTypes: DOC_GROUP_B.slice(), edoMode: 'edo', valueMode: 'amount', value: range.amount }), Object.assign({}, common, { rowKey: `supp_amount_non_edo${suffix}`, rowGroup: 'supplemental_rows', docTypes: DOC_GROUP_B.slice(), edoMode: 'non_edo', valueMode: 'amount', value: range.amount }), ]; }), }; } function planSignerBundle(op) { const preset = signerPresetRows(op); const manual = (reason, whyMatched) => [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason, whyMatched, after: {}, applyMode: 'manual_review', rollbackHint: 'Resolve the signer/counterparty from the OpenText dictionaries, then run preview again.', })]; // Forms-per-signer is learned from the matrix (2 for a combined package, 4 for split A/B), // so we only require at least one valid generated row rather than a strict multiple of 4. if (preset.error || preset.rows.length < 1) { return [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: preset.error || 'Signer preset invalid: не удалось собрать ни одной формы подписания.', whyMatched: 'v8 signer preset validation failed', after: {}, applyMode: 'manual_review', rollbackHint: 'Не применять, пока подписант/диапазон не дают хотя бы одну форму.', })]; } const signIdx = signerColumnIndex(); if (signIdx < 0) return manual('Signing column was not found. Native signer row creation is blocked.', 'missing signing column'); const rows = preset.rows.map(row => Object.assign({}, row, { newSignerId: resolveGeneratedSignerId(row) })); const unresolvedSigner = rows.find(row => !row.newSignerId); if (unresolvedSigner) { return manual(`Signer "${unresolvedSigner.newSigner || unresolvedSigner.signer || ''}" was not resolved to an OpenText user id. Choose the signer from autocomplete or pass payload.newSignerId.`, 'unresolved generated signer'); } const legalAliases = ['legal_entity', 'legal_entities', 'legal_entity_id', 'legal_entities_id', 'Юрлицо', 'Юр. лицо']; const partnerAliases = ['partner_id', 'partners_internal_id', 'Контрагент']; const hasLegalColumn = getColumnIndex(legalAliases) >= 0; const hasPartnerColumn = getColumnIndex(partnerAliases) >= 0; if (!hasLegalColumn && hasPartnerColumn) { const unresolvedLegal = []; rows.forEach(row => { const explicitLegalIds = parseList(row.legalEntityIds || row.legalEntityId || row.partnerIds || row.partnerId).map(cleanSignerId).filter(Boolean); const names = parseList(row.legalEntities); names.forEach((name, idx) => { const hasExplicitId = Boolean(explicitLegalIds[idx] || (names.length === 1 && explicitLegalIds.length === 1)); if (!hasExplicitId && !partnerNameToId(name) && !/^\d+$/.test(String(name))) unresolvedLegal.push(name); }); }); if (unresolvedLegal.length) { return manual(`Legal entity "${unique(unresolvedLegal)[0]}" was not resolved to a partner_id in this matrix. Add/select the counterparty in OpenText first, then run preview again.`, 'unresolved generated legal entity'); } } return rows.map((row, idx) => Object.assign(reportBase(op, null), { actionType: ACTION.ADD_ROW, status: STATUS.OK, rowNo: `new-${idx + 1}`, reason: `Будет создана строка ${idx + 1}/${preset.rows.length}: ${row.rowGroup}, ${row.edoMode}, ${row.valueMode}=${row.value}.`, whyMatched: 'validated signer preset (learned forms)', after: row, generatedRow: row, applyMode: 'ot_native_add_record_model', rollbackHint: 'Удалить созданную строку или восстановить матрицу из apply snapshot.', })); } // Reverse-lookup ЮЛ name -> internal partner id via the matrix partner catalog. // Lets ЮЛ be added on partner-based matrices (no legal_entity column, e.g. Правовая). function partnerNameToId(name) { const matrix = getMatrix(); const catalog = (matrix && matrix.partnerCacheObject) || {}; const directId = cleanSignerId(name); if (directId) return directId; const wanted = normalize(name); if (!wanted) return ''; const ids = Object.keys(catalog); const exact = ids.find(id => normalize(catalog[id]) === wanted); if (exact) return exact; const partial = ids.find(id => { const got = normalize(catalog[id]); return got && (got.includes(wanted) || wanted.includes(got)); }); return partial || ''; } function ensurePartnerCache(id, name) { const matrix = getMatrix(); const cleanId = cleanSignerId(id); const title = String(name || '').trim(); if (!matrix || !cleanId || !title) return; if (!matrix.partnerCacheObject) matrix.partnerCacheObject = {}; if (!matrix.partnerCacheObject[cleanId]) matrix.partnerCacheObject[cleanId] = title; } function planListPatch(op, kind, mode = 'add') { const payload = op.payload || {}; const group = payload.rowGroup || op.filters.rowGroup || op.selection.rowGroup || 'all'; const requiredDocTypes = payload.requiredDocTypes || op.filters.requiredDocTypes || []; const matchMode = payload.matchMode || op.filters.matchMode || 'all'; const maxRows = Number(payload.maxRows || op.options.maxRows || 0); const isRemove = mode === 'remove'; let actionableRows = 0; const firstFilled = (...values) => { for (const raw of values) { const list = Array.isArray(raw) ? raw : [raw]; for (const item of list) { const text = String(item == null ? '' : item).trim(); if (text) return text; } } return ''; }; const targetAlias = kind === 'docType' ? ['document_type', 'Тип документа'] : kind === 'legalEntity' ? ['legal_entity', 'legal_entities', 'legal_entity_id', 'legal_entities_id', 'Юрлицо', 'Юр. лицо'] : kind === 'site' ? ['partner_op', 'partner_ops', 'site', 'sites', 'op', 'Обособленное подразделение', 'Площадка'] : kind === 'category' ? ['category', 'Категория'] : ['change', 'Изменения']; let target = getColumnInfo(targetAlias); let patchMode = kind; const displayValue = kind === 'docType' ? firstFilled(isRemove ? payload.removeDocType : payload.newDocType, payload.targetDocType, payload.docType) : kind === 'legalEntity' ? firstFilled(isRemove ? payload.removeLegalEntity : payload.newLegalEntity, payload.targetLegalEntity, payload.legalEntity, payload.legalEntities) : kind === 'site' ? firstFilled(isRemove ? payload.removeSite : payload.newSite, payload.targetSite, payload.site, payload.sites, payload.op) : kind === 'category' ? firstFilled(payload.removeCategory, payload.targetCategory, payload.categoryValue, isRemove ? payload.category : payload.newCategory) : (firstFilled(isRemove ? payload.removeChangeCardFlag : payload.changeCardFlag, payload.targetChangeCardFlag, payload.changeFlag) || 'Ранее не подписан'); let value = displayValue; if (kind === 'legalEntity' && !target) { const partnerTarget = getColumnInfo(['partner_id', 'partners_internal_id', 'Контрагент']); if (partnerTarget) { target = partnerTarget; patchMode = 'partner'; const id = cleanSignerId(firstFilled(payload.legalEntityId, payload.legalEntityIds, payload.partnerId, payload.partner_id)) || partnerNameToId(displayValue); if (!id) { return [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: `ЮЛ "${displayValue}" не найдено в каталоге контрагентов этой матрицы.`, whyMatched: 'partner not in partnerCacheObject', after: {}, applyMode: 'manual_review', rollbackHint: 'Проверьте название ЮЛ (как в карточке контрагентов) или добавьте контрагента в матрицу вручную.', })]; } value = id; ensurePartnerCache(id, displayValue); } } if (!target) { return [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: `В матрице не найдена колонка для ${kind}. Native apply заблокирован.`, whyMatched: `missing column alias: ${targetAlias.join(', ')}`, after: {}, applyMode: 'manual_review', rollbackHint: 'Проверить alias колонки по HTML fixture и добавить writer только после подтверждения.', })]; } if (!value) { return [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: `Не указано значение для ${kind}.`, whyMatched: 'empty patch value', after: {}, applyMode: 'manual_review', rollbackHint: 'Заполнить значение и повторить preview.', })]; } const directionFilter = payload.direction || op.filters.direction || ''; const functionFilter = payload.functionName || payload.function || op.filters.functionName || op.filters.function || ''; const categoryFilter = (kind === 'category' && isRemove) ? (payload.categoryFilter || payload.filterCategory || op.filters.category || '') : (payload.categoryFilter || payload.filterCategory || payload.category || op.filters.category || ''); const signerFilter = resolveSignerFilterPayload(payload); const signerFilterId = signerFilter.id; if (hasSignerFilterPayload(payload) && !signerFilterId) { return [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: unresolvedSignerFilterReason(signerFilter), whyMatched: 'unresolved signer filter', after: {}, applyMode: 'manual_review', rollbackHint: 'Выберите подписанта из автоподсказки или передайте signerFilterId / initiatorId.', })]; } const filterSignIdx = signerFilterId ? signerColumnIndex() : -1; if (signerFilterId && filterSignIdx < 0) { return [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: 'Signing column was not found, so signerFilter cannot be applied.', whyMatched: 'missing signing column', after: {}, applyMode: 'manual_review', rollbackHint: 'Check that this page is an OpenText approval matrix with a signing level.', })]; } const out = []; allRowFacts().forEach(facts => { const base = reportBase(op, facts); if (signerFilterId && filterSignIdx >= 0) { const cell = matrixItems()[facts.index][filterSignIdx]; const pl = cell && Array.isArray(cell.performerList) ? cell.performerList.map(String) : []; if (!pl.includes(String(signerFilterId))) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `Подписант "${payload.signerFilterName || payload.signerFilter}" не стоит в этой строке.`, whyMatched: `signerFilter=${signerFilterId}`, after: base.before, applyMode: '' })); return; } } if (!matchRowGroup(facts, group)) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `Строка не входит в группу ${group}.`, whyMatched: `rowGroup=${group}`, after: base.before, applyMode: '', })); return; } if (!hasTypesByMode(facts.docTypes, requiredDocTypes, matchMode)) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `Не выполнено условие по типам документов (${String(matchMode).toUpperCase()}).`, whyMatched: `requiredDocTypes=${parseList(requiredDocTypes).join('; ') || '(empty)'}`, after: base.before, applyMode: '', })); return; } if (!matchesOptionalValues(facts.directions, directionFilter) || !matchesOptionalValues(facts.functions, functionFilter) || !matchesOptionalValues(facts.categories, categoryFilter)) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: 'Строка не подходит под фильтр ДФК.', whyMatched: `direction=${directionFilter || '(any)'}; function=${functionFilter || '(any)'}; category=${categoryFilter || '(any)'}`, after: base.before, applyMode: '', })); return; } const current = patchMode === 'partner' ? valueAsList(matrixItems()[facts.index][target.idx]) : kind === 'docType' ? facts.docTypes : kind === 'legalEntity' ? facts.legalEntities : kind === 'category' ? facts.categories : valueAsList(matrixItems()[facts.index][target.idx]); if (isRemove) { const next = current.filter(item => normalize(item) !== normalize(value)); if (next.length === current.length) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `"${displayValue}" отсутствует в строке — удалять нечего.`, whyMatched: `row matched filters; ${patchMode} value not present`, after: base.before, applyMode: '', })); return; } if (maxRows > 0 && actionableRows >= maxRows) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `Лимит preview maxRows=${maxRows} уже исчерпан.`, whyMatched: 'maxRows guard', after: base.before, applyMode: '', })); return; } actionableRows += 1; out.push(Object.assign(base, { actionType: ACTION.REMOVE_TOKEN, status: STATUS.OK, reason: patchMode === 'partner' ? `Будет удалено ЮЛ "${displayValue}" (контрагент ${value}) из строки.` : `Будет удалено "${displayValue}" из колонки "${target.column.title || target.column.alias}".`, whyMatched: `rowGroup=${group}; docTypeMatch=${String(matchMode).toUpperCase()}; DFK filters matched${patchMode === 'partner' ? '; via partner_id' : ''}`, after: patchMode === 'partner' ? Object.assign({}, base.before, { partnerNames: (base.before.partnerNames || []).filter(name => normalize(name) !== normalize(displayValue)) }) : kind === 'docType' ? Object.assign({}, base.before, { docTypes: next }) : kind === 'legalEntity' ? Object.assign({}, base.before, { legalEntities: next }) : kind === 'site' ? Object.assign({}, base.before, { sites: next }) : kind === 'category' ? Object.assign({}, base.before, { categories: next }) : Object.assign({}, base.before, { change: next }), patch: { kind: patchMode, operation: 'remove', columnIndex: target.idx, columnAlias: target.column.alias || target.column.title, beforeValue: current, afterValue: next, removeValue: value, removeDisplayValue: displayValue }, applyMode: 'ot_model_attribute_array_remove', rollbackHint: `Вернуть значение колонки "${target.column.title || target.column.alias}" из before в apply snapshot.`, })); return; } if (current.map(normalize).includes(normalize(value))) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `"${displayValue}" уже есть в строке.`, whyMatched: 'duplicate prevention', after: base.before, applyMode: '', })); return; } if (maxRows > 0 && actionableRows >= maxRows) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `Лимит preview maxRows=${maxRows} уже исчерпан.`, whyMatched: 'maxRows guard', after: base.before, applyMode: '', })); return; } actionableRows += 1; const next = current.concat([value]); out.push(Object.assign(base, { actionType: ACTION.PATCH_ROW, status: STATUS.OK, reason: patchMode === 'partner' ? `Будет добавлено ЮЛ "${displayValue}" (контрагент ${value}) в строку.` : kind === 'site' ? `Будет добавлена площадка/ОП "${displayValue}" в строку.` : `Будет добавлено "${displayValue}" в колонку "${target.column.title || target.column.alias}".`, whyMatched: `rowGroup=${group}; docTypeMatch=${String(matchMode).toUpperCase()}; DFK filters matched${patchMode === 'partner' ? '; via partner_id' : ''}`, after: patchMode === 'partner' ? Object.assign({}, base.before, { partnerNames: (base.before.partnerNames || []).concat([displayValue]) }) : kind === 'docType' ? Object.assign({}, base.before, { docTypes: next }) : kind === 'legalEntity' ? Object.assign({}, base.before, { legalEntities: next }) : kind === 'site' ? Object.assign({}, base.before, { sites: next }) : kind === 'category' ? Object.assign({}, base.before, { categories: next }) : Object.assign({}, base.before, { change: next }), patch: { kind: patchMode, operation: 'add', columnIndex: target.idx, columnAlias: target.column.alias || target.column.title, beforeValue: current, afterValue: next }, applyMode: 'ot_model_attribute_array', rollbackHint: `Вернуть значение колонки "${target.column.title || target.column.alias}" из before в apply snapshot.`, })); }); if (isRemove && !out.some(entry => entry.actionType === ACTION.REMOVE_TOKEN && entry.status === STATUS.OK)) { out.push(Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: `Не найдено строк, где можно удалить "${displayValue}" по заданным фильтрам.`, whyMatched: 'no removable rows', after: {}, applyMode: 'manual_review', rollbackHint: 'Проверьте значение, ДФК/подписанта/типы документов или расширьте срез строк.', })); } return out; } function planCreateCategoryFromTemplate(op) { const payload = op.payload || {}; const group = payload.rowGroup || op.filters.rowGroup || op.selection.rowGroup || 'all'; const requiredDocTypes = [payload.requiredDocTypes, payload.docTypes, op.filters.requiredDocTypes, op.filters.docTypes] .find(value => parseList(value).length) || []; const matchMode = payload.matchMode || op.filters.matchMode || 'all'; const maxRows = Number(payload.maxRows || op.options.maxRows || 0); const newCategory = String(payload.newCategory || payload.category || '').trim(); const rows = allRowFacts(); const categoryCol = (rows[0] || factsForIndex(0)).columns.category; const categoryColumn = getColumns()[categoryCol] || {}; const manual = (reason, whyMatched) => [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason, whyMatched, after: {}, applyMode: 'manual_review', rollbackHint: 'Resolve the blocking condition, then run preview again.', })]; if (categoryCol < 0) { return manual('Category column was not found. Native clone is blocked.', 'missing category column'); } if (!newCategory) { return manual('New category is empty. Provide payload.category or payload.newCategory.', 'empty category'); } const signerKey = value => { const n = Number(value); return Number.isFinite(n) && n !== 0 ? String(Math.abs(n)) : normalize(value); }; let signerFilterId = ''; let filterSignIdx = -1; if (hasSignerFilterPayload(payload)) { const signerFilter = resolveSignerFilterPayload(payload); signerFilterId = signerFilter.id ? signerKey(signerFilter.id) : ''; if (!signerFilterId) { return manual(unresolvedSignerFilterReason(signerFilter), 'unresolved signer filter'); } filterSignIdx = signerColumnIndex(); if (filterSignIdx < 0) { return manual('Signing column was not found, so signerFilter cannot be applied.', 'missing signing column'); } } const out = []; let matchedRows = 0; let actionableRows = 0; rows.forEach(facts => { const base = reportBase(op, facts); const before = Object.assign({}, base.before, { directions: facts.directions.slice(), functions: facts.functions.slice(), categories: facts.categories.slice(), sites: facts.sites.slice(), affiliations: facts.affiliations.slice(), }); const skip = (reason, whyMatched) => { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason, whyMatched, before, after: before, applyMode: '', })); }; if (signerFilterId) { const cell = matrixItems()[facts.index][filterSignIdx]; const performers = cell && Array.isArray(cell.performerList) ? cell.performerList.map(signerKey) : []; if (!performers.includes(signerFilterId)) { skip(`Signer "${payload.signerFilterName || payload.signerFilter}" is absent in this source row.`, `signerFilter=${signerFilterId}`); return; } } if (!matchRowGroup(facts, group)) { skip(`Source row is outside rowGroup=${group}.`, `rowGroup=${group}`); return; } if (!hasTypesByMode(facts.docTypes, requiredDocTypes, matchMode)) { skip(`Document type filter did not match (${String(matchMode).toUpperCase()}).`, `requiredDocTypes=${parseList(requiredDocTypes).join('; ') || '(empty)'}`); return; } if (!matchesOptionalValues(facts.directions, payload.direction) || !matchesOptionalValues(facts.functions, payload.functionName || payload.function)) { skip('Source row did not match direction/function filters.', 'df filter'); return; } matchedRows += 1; if (facts.categories.map(normalize).includes(normalize(newCategory))) { skip(`Category "${newCategory}" is already present in this source row.`, 'duplicate category prevention'); return; } if (maxRows > 0 && actionableRows >= maxRows) { skip(`Preview maxRows=${maxRows} limit is exhausted.`, 'maxRows guard'); return; } actionableRows += 1; const after = Object.assign({}, before, { categories: [newCategory] }); const marker = { type: 'create_category_from_template', rowKey: `category_template_${facts.rowNumber}`, sourceIndex: facts.index, sourceRowNo: facts.rowNumber, sourceRecordId: facts.recordId, category: newCategory, }; out.push(Object.assign(base, { actionType: ACTION.ADD_ROW, status: STATUS.OK, reason: `Clone source row #${facts.rowNumber} and replace only "${categoryColumn.title || categoryColumn.alias || 'category'}" with "${newCategory}".`, whyMatched: `rowGroup=${group}; docTypeMatch=${String(matchMode).toUpperCase()}${signerFilterId ? `; signerFilter=${signerFilterId}` : ''}`, sourceRow: { itemId: facts.itemId, recordId: facts.recordId, rowNo: facts.rowNumber }, before, after, cloneRow: { sourceIndex: facts.index, categoryColumnIndex: categoryCol, categoryColumnAlias: categoryColumn.alias || categoryColumn.title || 'category', category: newCategory, marker, }, applyMode: 'ot_native_clone_row_model', rollbackHint: 'Delete the created row or restore the matrix from the apply snapshot.', })); }); if (!actionableRows && !matchedRows) { out.push(manual('No template rows matched the filters. Native clone produced no rows.', `rowGroup=${group}; docTypeMatch=${String(matchMode).toUpperCase()}`)[0]); } return out; } function signerColumnIndex() { return getColumns().findIndex(col => col && col.colType === 'level' && col.type === 'signing'); } // Replace a signer (by resolved id) in the performerList of the "Подписание" cell of matching rows. function planReplaceSigner(op) { const payload = op.payload || {}; const group = payload.rowGroup || op.filters.rowGroup || 'all'; const requiredDocTypes = payload.requiredDocTypes || op.filters.requiredDocTypes || []; const matchMode = payload.matchMode || op.filters.matchMode || 'all'; const currentId = String(payload.currentSignerId || '').trim(); const newId = String(payload.newSignerId || '').trim(); const currentName = payload.currentSignerName || payload.currentSigner || currentId; const newName = payload.newSignerName || payload.newSigner || newId; const signIdx = signerColumnIndex(); if (signIdx < 0) { return [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: 'В матрице не найдена колонка подписания.', whyMatched: 'no signing level column', after: {}, applyMode: 'manual_review', rollbackHint: 'Проверить наличие уровня "Подписание".', })]; } if (!currentId || !newId) { return [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: 'Не удалось определить ID текущего/нового подписанта. Выберите людей из автоподсказки.', whyMatched: 'unresolved signer id', after: {}, applyMode: 'manual_review', rollbackHint: 'Выбрать подписанта из списка, чтобы определился ID.', })]; } // ВНИМАНИЕ: для WRITE-операции пустая ячейка строки НЕ считается «ВСЁ» (иначе операция задела бы все // вайлдкард-строки). Семантику «пусто=ВСЁ» применяем только в ДИАГНОСТИКЕ (toolkit softMatch), где нужно // показать оператору, что вайлдкард-строка покрывает его случай. const softMatch = (values, want) => { if (!want) return true; const w = normalize(want); return (values || []).map(normalize).some(v => v && (v.includes(w) || w.includes(v))); }; const out = []; allRowFacts().forEach(facts => { const base = reportBase(op, facts); const dfkOk = softMatch(facts.directions, payload.direction) && softMatch(facts.functions, payload.functionName || payload.function) && softMatch(facts.categories, payload.category); if (!matchRowGroup(facts, group) || !hasTypesByMode(facts.docTypes, requiredDocTypes, matchMode) || !dfkOk) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: 'Строка не подходит под фильтры.', whyMatched: `rowGroup=${group}`, after: base.before, applyMode: '' })); return; } const cell = matrixItems()[facts.index][signIdx]; const pl = cell && Array.isArray(cell.performerList) ? cell.performerList.map(String) : []; if (!pl.includes(currentId)) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `Подписант "${currentName}" не найден в строке.`, whyMatched: 'current signer absent', after: base.before, applyMode: '' })); return; } if (pl.includes(newId)) { out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason: `Новый подписант "${newName}" уже в строке.`, whyMatched: 'duplicate prevention', after: base.before, applyMode: '' })); return; } const afterPl = pl.map(id => (id === currentId ? newId : id)); out.push(Object.assign(base, { actionType: ACTION.PATCH_ROW, status: STATUS.OK, reason: `Будет заменён подписант: "${currentName}" → "${newName}".`, whyMatched: `signer ${currentId} -> ${newId}`, after: Object.assign({}, base.before), patch: { kind: 'signerReplace', columnIndex: signIdx, columnAlias: 'Подписание', beforeValue: pl, afterValue: afterPl }, applyMode: 'ot_model_attribute_array', rollbackHint: 'Вернуть performerList ячейки подписания из before snapshot.', })); }); return out; } // РАЗДЕЛЕНИЕ СТРОКИ: вынести одно ЮЛ из комбинации в новую строку (клон исходной), опционально // убрав на новой строке категорию/тип или сменив подписанта. Остальным ЮЛ исходной строки ничего // не теряется — категория у них сохраняется. Это и есть то самое «разделение», которого не было. function planSplitLegalEntity(op) { const payload = op.payload || {}; const manual = reason => [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason, whyMatched: 'split validation', after: {}, applyMode: 'manual_review', rollbackHint: 'Уточнить параметры разделения и повторить preview.', })]; const group = payload.rowGroup || op.filters.rowGroup || 'all'; const requiredDocTypes = payload.requiredDocTypes || op.filters.requiredDocTypes || []; const matchMode = payload.matchMode || op.filters.matchMode || 'all'; const leName = String(payload.legalEntity || payload.splitLegalEntity || '').trim(); if (!leName) return manual('Не указано ЮЛ для выноса в отдельную строку.'); const removeCategories = parseList(payload.removeCategories || payload.removeCategory || ''); const removeDocTypes = parseList(payload.removeDocTypes || payload.removeDocType || ''); const newSignerName = String(payload.newSigner || '').trim(); const newSignerId = String(payload.newSignerId || '').replace(/^-/, '').trim() || (newSignerName ? (resolveUserId(newSignerName) || '') : ''); if (newSignerName && !newSignerId) return manual(`Signer "${newSignerName}" was not resolved to an OpenText user id. Choose the signer from autocomplete or pass payload.newSignerId.`); const partnerCol = getColumnIndex(['partner_id', 'partners_internal_id', 'Контрагент']); const legalCol = getColumnIndex(['legal_entity', 'legal_entities', 'legal_entity_id', 'legal_entities_id', 'Юрлицо', 'Юр. лицо']); let mode; let colIdx; let matchValue; if (partnerCol >= 0) { const id = cleanSignerId(payload.legalEntityId || payload.partnerId || payload.partner_id) || partnerNameToId(leName) || (/^\d+$/.test(leName) ? leName : ''); if (!id) return manual(`ЮЛ "${leName}" не найдено в каталоге контрагентов этой матрицы.`); mode = 'partner'; colIdx = partnerCol; matchValue = String(id); ensurePartnerCache(id, leName); } else if (legalCol >= 0) { mode = 'legal'; colIdx = legalCol; matchValue = normalize(leName); } else { return manual('В матрице нет колонки ЮЛ/контрагента — разделение невозможно.'); } const out = []; allRowFacts().forEach(facts => { const base = reportBase(op, facts); const skip = reason => out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason, whyMatched: `rowGroup=${group}`, after: base.before, applyMode: '' })); if (!matchRowGroup(facts, group) || !hasTypesByMode(facts.docTypes, requiredDocTypes, matchMode)) { skip('Строка не подходит под фильтры.'); return; } const cell = valueAsList(matrixItems()[facts.index][colIdx]); const contains = mode === 'partner' ? cell.map(String).includes(matchValue) : cell.map(normalize).includes(matchValue); if (!contains) { skip(`ЮЛ "${leName}" не входит в эту строку.`); return; } if (cell.length <= 1) { skip(`ЮЛ "${leName}" — единственное в строке; разделение не нужно (меняйте строку напрямую).`); return; } const remaining = mode === 'partner' ? cell.map(String).filter(value => value !== matchValue) : cell.filter(value => normalize(value) !== matchValue); const extracted = mode === 'partner' ? [matchValue] : [cell.find(value => normalize(value) === matchValue)]; out.push(Object.assign(base, { actionType: ACTION.SPLIT_ROW, status: STATUS.OK, reason: `Вынесем ЮЛ "${leName}" из комбинации (${cell.length} ЮЛ) в НОВУЮ строку` + `${removeCategories.length ? `, без категорий: ${removeCategories.join(', ')}` : ''}` + `${removeDocTypes.length ? `, без типов: ${removeDocTypes.join(', ')}` : ''}` + `${newSignerName ? `, подписант: ${newSignerName}` : ''}. В исходной строке ЮЛ убрано, остальные ${remaining.length} ЮЛ сохранены (категория им не теряется).`, whyMatched: `split ${leName} from combo of ${cell.length}`, after: Object.assign({}, base.before), split: { sourceIndex: facts.index, colIdx, extracted, remaining, removeCategories, removeDocTypes, newSignerId: String(newSignerId || ''), categoryCol: facts.columns.category, docTypeCol: facts.columns.docType }, applyMode: 'ot_model_split_row', rollbackHint: 'Удалить новую строку и вернуть ЮЛ в исходную строку из apply snapshot.', })); }); return out; } // null = 0 (нижняя граница) или ∞ (верхняя), как в модели матрицы. function parseMoney(raw) { const n = Number(String(raw == null ? '' : raw).replace(/[^\d.-]/g, '').replace(',', '.')); return Number.isFinite(n) && n !== 0 ? n : null; } function limitFromCell(cell) { const arr = Array.isArray(cell) ? cell : []; const from = parseMoney(arr[0]); const to = parseMoney(arr[1]); return { from: from == null ? 0 : from, to: to == null ? Infinity : to }; } function toLimitCell(from, to) { return [from && from > 0 ? from : null, to != null && to !== Infinity ? to : null]; } // ЛИМИТНАЯ ЛЕСЕНКА: вырезать банду [A,B] новому подписанту из строки соседа, который покрывает шире. // Сосед сохраняет нижний [F,A] и верхний [B,T] остатки; новому подписанту — ровно [A,B]. // Режем ТОЛЬКО когда банда строго вложена в существующую (F≤A, T≥B, и строка шире). Иначе — manual. function planCarveLimitBand(op) { const payload = op.payload || {}; const manual = reason => [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason, whyMatched: 'carve validation', after: {}, applyMode: 'manual_review', rollbackHint: 'Уточнить банду/подписанта и повторить preview.', })]; const group = payload.rowGroup || op.filters.rowGroup || 'all'; const requiredDocTypes = payload.requiredDocTypes || op.filters.requiredDocTypes || []; const matchMode = payload.matchMode || op.filters.matchMode || 'all'; const A = parseMoney(payload.bandFrom) == null ? 0 : parseMoney(payload.bandFrom); const B = parseMoney(payload.bandTo) == null ? Infinity : parseMoney(payload.bandTo); if (!(B > A)) return manual('Банда задана неверно: «до» должно быть больше «от».'); const newSignerName = String(payload.newSigner || '').trim(); // Тулкит может прислать уже резолвнутый ID (поиск по фамилии в матрице/справочнике) — берём его, иначе // пробуем резолвить по имени. Так нарезка работает «в один клик», без ручного выбора из списка. const newSignerId = String(payload.newSignerId || '').replace(/^-/, '').trim() || (newSignerName ? (resolveUserId(newSignerName) || '') : ''); if (!newSignerId) return manual(`Не нашёл подписанта «${newSignerName || '—'}» в справочнике (несколько однофамильцев или другое написание) — выбери из автоподсказки.`); const limitCol = getColumnIndex(['limit_contract', 'limit', 'Лимит по договору в рублях (без НДС)']); if (limitCol < 0) return manual('В матрице нет колонки лимита — нарезка невозможна.'); const signIdx = signerColumnIndex(); const le = String(payload.legalEntity || '').trim(); const dir = payload.direction || ''; const fn = payload.functionName || payload.function || ''; const cat = payload.category || ''; const sm = (vals, want) => { if (!want) return true; const w = normalize(want); return (vals || []).map(normalize).some(v => v && (v.includes(w) || w.includes(v))); }; // WRITE: пусто-ячейка ≠ ВСЁ (не задеть вайлдкарды) const out = []; allRowFacts().forEach(facts => { const base = reportBase(op, facts); const skip = reason => out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason, whyMatched: `band [${A}-${B}]`, after: base.before, applyMode: '' })); if (!matchRowGroup(facts, group) || !hasTypesByMode(facts.docTypes, requiredDocTypes, matchMode)) { skip('Строка не подходит под фильтры.'); return; } if (!(sm(facts.directions, dir) && sm(facts.functions, fn) && sm(facts.categories, cat))) { skip('ДФК не совпадает.'); return; } if (le && !facts.legalEntities.concat(facts.partnerNames).some(x => sm([x], le))) { skip(`ЮЛ "${le}" не в строке.`); return; } const lim = limitFromCell(matrixItems()[facts.index][limitCol]); if (!(lim.from <= A && lim.to >= B && (lim.from < A || lim.to > B))) { skip(`Лимит строки [${lim.from}-${lim.to === Infinity ? '∞' : lim.to}] не содержит банду строго.`); return; } const cell = signIdx >= 0 ? matrixItems()[facts.index][signIdx] : null; const pl = cell && Array.isArray(cell.performerList) ? cell.performerList.map(String) : []; if (pl.includes(String(newSignerId))) { skip('Новый подписант уже стоит на этой строке.'); return; } out.push(Object.assign(base, { actionType: ACTION.CARVE_BAND, status: STATUS.OK, reason: `Нарезка лимита строки #${facts.rowNumber} [${lim.from}–${lim.to === Infinity ? '∞' : lim.to}]: новому подписанту «${newSignerName}» — банда [${A}–${B === Infinity ? '∞' : B}]; соседу остаются ${lim.from < A ? `[${lim.from}–${A}]` : ''}${(lim.from < A && lim.to > B) ? ' и ' : ''}${lim.to > B ? `[${B}–${lim.to === Infinity ? '∞' : lim.to}]` : ''}.`, whyMatched: `carve [${A}-${B}] from [${lim.from}-${lim.to}]`, after: Object.assign({}, base.before), carve: { sourceIndex: facts.index, limitCol, F: lim.from, T: lim.to, A, B, newSignerId: String(newSignerId) }, applyMode: 'ot_model_carve_band', rollbackHint: 'Удалить созданные строки и вернуть лимит исходной строки из apply snapshot.', })); }); if (!out.some(e => e.actionType === ACTION.CARVE_BAND)) { out.push(manual(`Нет строки, которая строго содержит банду [${A}–${B === Infinity ? '∞' : B}] по этим фильтрам — возможно, нужно просто создать строку.`)[0]); } return out; } // ПЕРЕБАЛАНСИРОВКА ЛЕСЕНКИ: изменить ДИАПАЗОН существующего подписанта (патч лимит-ячейки его строк), // чтобы после вставки нового подписанта на [A,B] не было наслойки. Напр. X держал [0–10М], новому дали // [5М–15М] → X сузить до [0–5М]. Патчим ТОЛЬКО строки этого подписанта (опц. на конкретном старом диапазоне). function planAdjustSignerLimit(op) { const payload = op.payload || {}; const manual = reason => [Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason, whyMatched: 'adjust-limit validation', after: {}, applyMode: 'manual_review', rollbackHint: 'Уточнить подписанта/диапазон и повторить preview.', })]; const group = payload.rowGroup || op.filters.rowGroup || 'all'; const requiredDocTypes = payload.requiredDocTypes || op.filters.requiredDocTypes || []; const matchMode = payload.matchMode || op.filters.matchMode || 'all'; const signerName = String(payload.signer || payload.signerFilter || '').trim(); const signerId = String(payload.signerId || '').replace(/^-/, '').trim() || (signerName ? (resolveUserId(signerName) || '') : ''); if (!signerId) return manual(`Не нашёл подписанта «${signerName || '—'}» в справочнике — выбери из автоподсказки.`); const newFrom = parseMoney(payload.newFrom); // null = 0 const newTo = parseMoney(payload.newTo); // null = ∞ const nf = newFrom == null ? 0 : newFrom; const nt = newTo == null ? Infinity : newTo; if (!(nt > nf)) return manual('Новый диапазон задан неверно: «до» должно быть больше «от».'); const limitCol = getColumnIndex(['limit_contract', 'limit', 'Лимит по договору в рублях (без НДС)']); if (limitCol < 0) return manual('В матрице нет колонки лимита — изменить диапазон нельзя.'); const signIdx = signerColumnIndex(); if (signIdx < 0) return manual('В матрице нет колонки подписания.'); const le = String(payload.legalEntity || '').trim(); const dir = payload.direction || ''; const fn = payload.functionName || payload.function || ''; const cat = payload.category || ''; const hasOld = (payload.fromOld != null && payload.fromOld !== '') || (payload.toOld != null && payload.toOld !== ''); const oF = parseMoney(payload.fromOld) == null ? 0 : parseMoney(payload.fromOld); const oT = parseMoney(payload.toOld) == null ? Infinity : parseMoney(payload.toOld); const sm = (vals, want) => { if (!want) return true; const w = normalize(want); return (vals || []).map(normalize).some(v => v && (v.includes(w) || w.includes(v))); }; // WRITE: пусто-ячейка ≠ ВСЁ const out = []; allRowFacts().forEach(facts => { const base = reportBase(op, facts); const skip = reason => out.push(Object.assign(base, { actionType: ACTION.SKIP, status: STATUS.SKIPPED, reason, whyMatched: `adjust ${signerName}→[${nf}-${nt === Infinity ? '∞' : nt}]`, after: base.before, applyMode: '' })); if (!matchRowGroup(facts, group) || !hasTypesByMode(facts.docTypes, requiredDocTypes, matchMode)) { skip('Строка не подходит под фильтры.'); return; } if (!(sm(facts.directions, dir) && sm(facts.functions, fn) && sm(facts.categories, cat))) { skip('ДФК не совпадает.'); return; } if (le && !facts.legalEntities.concat(facts.partnerNames).some(x => sm([x], le))) { skip(`ЮЛ "${le}" не в строке.`); return; } const cell = matrixItems()[facts.index][signIdx]; const pl = cell && Array.isArray(cell.performerList) ? cell.performerList.map(x => String(Math.abs(Number(x)))) : []; if (!pl.includes(String(Math.abs(Number(signerId))))) { skip(`Подписант "${signerName}" не стоит в этой строке.`); return; } const lim = limitFromCell(matrixItems()[facts.index][limitCol]); if (hasOld && !(lim.from === oF && lim.to === oT)) { skip(`Лимит строки [${lim.from}-${lim.to === Infinity ? '∞' : lim.to}] ≠ старому диапазону [${oF}-${oT === Infinity ? '∞' : oT}].`); return; } if (lim.from === nf && lim.to === nt) { skip('Диапазон уже равен целевому.'); return; } const beforeCell = Array.isArray(matrixItems()[facts.index][limitCol]) ? matrixItems()[facts.index][limitCol].slice() : matrixItems()[facts.index][limitCol]; const afterCell = toLimitCell(nf, nt); out.push(Object.assign(base, { actionType: ACTION.PATCH_ROW, status: STATUS.OK, reason: `Изменим диапазон подписанта «${signerName}» в строке #${facts.rowNumber}: [${lim.from}–${lim.to === Infinity ? '∞' : lim.to}] → [${nf}–${nt === Infinity ? '∞' : nt}] (перебалансировка лесенки).`, whyMatched: `adjust limit of ${signerName}`, after: Object.assign({}, base.before, { limit: afterCell.slice() }), patch: { kind: 'limit', columnIndex: limitCol, columnAlias: 'limit', beforeValue: beforeCell, afterValue: afterCell }, applyMode: 'ot_model_attribute_array', rollbackHint: 'Вернуть лимит строки из before в apply snapshot.', })); }); if (!out.some(e => e.actionType === ACTION.PATCH_ROW)) { out.push(manual(`Не нашёл строк подписанта «${signerName}»${hasOld ? ` на диапазоне [${oF}-${oT === Infinity ? '∞' : oT}]` : ''} по этим фильтрам.`)[0]); } return out; } function buildPlan(operations) { const entries = []; const legacyOps = []; (operations || []).map(normalizeOperation).forEach(op => { if (op.payload && op.payload.affiliation && normalize(op.payload.affiliation) !== normalize(REQUIRED_AFFILIATION)) { entries.push(Object.assign(reportBase(op, null), { actionType: ACTION.MANUAL, status: STATUS.MANUAL, reason: `Аффилированность должна быть "${REQUIRED_AFFILIATION}".`, whyMatched: 'mandatory affiliation guard', after: {}, applyMode: 'manual_review', rollbackHint: 'Исправить affiliation и повторить preview.', })); return; } if (op.type === 'add_signer_bundle') entries.push(...planSignerBundle(op)); else if (op.type === 'add_doc_type_to_matching_rows') entries.push(...planListPatch(op, 'docType')); else if (op.type === 'add_legal_entity_to_matching_rows') entries.push(...planListPatch(op, 'legalEntity')); else if (op.type === 'add_site_to_matching_rows') entries.push(...planListPatch(op, 'site')); else if (op.type === 'remove_doc_type_from_matching_rows') entries.push(...planListPatch(op, 'docType', 'remove')); else if (op.type === 'remove_legal_entity_from_matching_rows') entries.push(...planListPatch(op, 'legalEntity', 'remove')); else if (op.type === 'remove_site_from_matching_rows') entries.push(...planListPatch(op, 'site', 'remove')); else if (op.type === 'remove_category_from_matching_rows') entries.push(...planListPatch(op, 'category', 'remove')); else if (op.type === 'remove_change_card_flag_from_matching_rows') entries.push(...planListPatch(op, 'changeCard', 'remove')); else if (op.type === 'create_category_from_template') entries.push(...planCreateCategoryFromTemplate(op)); else if (op.type === 'replace_signer_by_name') entries.push(...planReplaceSigner(op)); else if (op.type === 'add_change_card_flag_to_matching_rows') entries.push(...planListPatch(op, 'changeCard')); else if (op.type === 'split_legal_entity_to_new_row') entries.push(...planSplitLegalEntity(op)); else if (op.type === 'carve_limit_band') entries.push(...planCarveLimitBand(op)); else if (op.type === 'adjust_signer_limit') entries.push(...planAdjustSignerLimit(op)); else legacyOps.push(op); }); legacyOps.forEach(op => { entries.push(Object.assign(reportBase(op, null), { actionType: ACTION.LEGACY, status: STATUS.MANUAL, reason: `Операция "${op.type}" пока выполняется через legacy API после отдельного preview.`, whyMatched: 'legacy-compatible operation', legacyOperation: op, after: {}, applyMode: 'legacy_delegate', rollbackHint: 'Использовать legacy report/snapshot для отката.', })); }); return entries; } function summarize(report) { const rows = Array.isArray(report) ? report : []; return { total: rows.length, ok: rows.filter(row => row.status === STATUS.OK).length, skipped: rows.filter(row => row.status === STATUS.SKIPPED).length, manual: rows.filter(row => String(row.status || '').includes('manual') || row.actionType === ACTION.MANUAL).length, errors: rows.filter(row => row.status === STATUS.ERROR).length, actionable: rows.filter(row => [ACTION.ADD_ROW, ACTION.PATCH_ROW, ACTION.DELETE_ROW, ACTION.REMOVE_TOKEN].includes(row.actionType)).length, }; } function makePlanId() { const rnd = host.crypto && host.crypto.getRandomValues ? Array.from(host.crypto.getRandomValues(new Uint32Array(2))).map(n => n.toString(36)).join('') : String(Math.random()).slice(2); return `v8-${Date.now().toString(36)}-${rnd}`; } function clearV8Preview() { document.querySelectorAll('.mc-v8-preview-row').forEach(row => row.classList.remove('mc-v8-preview-row', 'mc-v8-preview-update', 'mc-v8-preview-delete')); document.querySelectorAll('.mc-v8-preview-badge').forEach(node => node.remove()); const create = document.querySelector('[data-role="v8-create-preview"]'); if (create) create.textContent = ''; } function renderPreview(report) { clearV8Preview(); if (!state.previewEnabled) return; const created = []; (report || []).forEach(entry => { if (entry.actionType === ACTION.ADD_ROW) { created.push(entry); return; } if (![ACTION.PATCH_ROW, ACTION.DELETE_ROW, ACTION.REMOVE_TOKEN].includes(entry.actionType)) return; const row = rowByIndex(entry.itemId); if (!row) return; row.classList.add('mc-v8-preview-row', entry.actionType === ACTION.DELETE_ROW ? 'mc-v8-preview-delete' : 'mc-v8-preview-update'); const badge = document.createElement('span'); badge.className = 'mc-v8-preview-badge'; badge.textContent = entry.actionType === ACTION.DELETE_ROW ? 'DELETE preview' : 'PATCH preview'; const first = row.querySelector('td') || row; first.prepend(badge); }); const create = document.querySelector('[data-role="v8-create-preview"]'); if (create && created.length) { create.innerHTML = created.map((entry, index) => `
Черновая строка ${index + 1}: ${escapeHtml(entry.reason)} Это только preview; apply создаёт строку через модель OpenText.
`).join(''); } } function escapeHtml(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } async function preview(operations, options = {}) { const entries = buildPlan(operations || []); const planId = makePlanId(); const report = entries.map(entry => Object.assign({}, entry, { planId, message: `preview: ${entry.reason || ''}`, previewOnly: true, })); state.plans.set(planId, { planId, operations: (operations || []).map(normalizeOperation), entries, createdAt: new Date().toISOString(), options, matrixSignature: matrixSignature(), }); state.lastPlanId = planId; state.lastReport = report; renderPreview(report); return { schemaVersion: VERSION, planId, summary: summarize(report), report }; } function markChanged(index) { const matrix = getMatrix(); if (!matrix) return; const recId = Array.isArray(matrix.mRecsID) ? matrix.mRecsID[index] : 0; if (Array.isArray(matrix.itemsEdit) && !matrix.itemsEdit.includes(recId)) matrix.itemsEdit.push(recId); if (Array.isArray(matrix.mRecsStatus) && matrix.mRecsStatus[index] == null) matrix.mRecsStatus[index] = 1; matrix.isChangedItemsWasSave = false; } function rerenderRow(index) { const matrix = getMatrix(); const jq = getJq(); if (!matrix || typeof matrix.renderItemView !== 'function') return; const current = rowByIndex(index); const rendered = matrix.renderItemView(index, false); if (current && rendered) { if (jq && rendered.jquery) jq(current).replaceWith(rendered); else if (rendered.nodeType) current.replaceWith(rendered); } if (typeof matrix.recalculateItems === 'function') matrix.recalculateItems(); } // Подсветка изменённых строк в самой матрице (а не в панели): жёлтая вспышка ~3с + прокрутка к первой. // Лечит «изменения фиктивные / приходится бегать по матрице» — видно, ЧТО и ГДЕ поменялось. function ensureHighlightStyle() { if (document.getElementById('mc-v8-hl-style')) return; const style = document.createElement('style'); style.id = 'mc-v8-hl-style'; style.textContent = '@keyframes mcV8Flash{0%{background:#fff3b0}100%{background:transparent}}' + 'tr.mc-v8-changed{animation:mcV8Flash 3s ease-out;outline:2px solid #f0b400;outline-offset:-2px}'; (document.head || document.documentElement).appendChild(style); } function highlightChangedRows(indices) { ensureHighlightStyle(); const rows = []; (indices || []).forEach(i => { const r = rowByIndex(Number(i)); if (r && rows.indexOf(r) < 0) rows.push(r); }); rows.forEach(r => { r.classList.remove('mc-v8-changed'); void r.offsetWidth; r.classList.add('mc-v8-changed'); }); if (rows[0] && typeof rows[0].scrollIntoView === 'function') { try { rows[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) { rows[0].scrollIntoView(); } } setTimeout(() => rows.forEach(r => r.classList.remove('mc-v8-changed')), 3200); return rows.length; } function setCellArray(index, columnIndex, values) { const matrix = getMatrix(); if (!matrix || !matrix.items || !matrix.items[index]) throw new Error('Матрица или строка не найдены.'); matrix.items[index][columnIndex] = normalizeCellForColumn(columnIndex, values.slice()); markChanged(index); rerenderRow(index); } function setSignerPerformerList(index, columnIndex, performerList) { const matrix = getMatrix(); if (!matrix || !matrix.items || !matrix.items[index]) throw new Error('Матрица или строка не найдены.'); matrix.items[index][columnIndex] = normalizeCellForColumn( columnIndex, Object.assign({}, matrix.items[index][columnIndex] || {}, { performerList: performerList.slice() }), ); markChanged(index); rerenderRow(index); } function applyPatchEntry(entry) { const currentFacts = factsForIndex(Number(entry.itemId)); if (entry.beforeFingerprint && rowFingerprint(currentFacts) !== entry.beforeFingerprint) { return { status: STATUS.SKIPPED, message: 'Строка изменилась после preview; нужен новый preview.' }; } if (entry.patch.kind === 'signerReplace') { setSignerPerformerList(Number(entry.itemId), entry.patch.columnIndex, entry.patch.afterValue); return { status: STATUS.OK, message: 'Модель OpenText обновлена: подписант (performerList).' }; } setCellArray(Number(entry.itemId), entry.patch.columnIndex, entry.patch.afterValue); return { status: STATUS.OK, message: `Модель OpenText обновлена: ${entry.patch.columnAlias}.` }; } function setGeneratedAliases(item, row) { const set = (aliases, value) => { const idx = getColumnIndex(aliases); if (idx < 0) return; item[idx] = normalizeCellForColumn(idx, Array.isArray(value) ? value.slice() : [value]); }; const toMoney = raw => { const n = Number(String(raw == null ? '' : raw).replace(/[^\d.-]/g, '').replace(',', '.')); return Number.isFinite(n) && n !== 0 ? n : null; // null = 0 (start) or ∞ (no upper), per matrix convention }; const toNum = toMoney(row.value); const fromNum = toMoney(row.from); const isEdo = row.edoMode === 'edo'; set(['document_type', 'Тип документа'], row.docTypes && row.docTypes.length ? row.docTypes : (row.rowGroup === 'main_contract_rows' ? DOC_GROUP_A : DOC_GROUP_B)); // ДФК — комбинации (несколько значений через мультивыбор), как в реальных матрицах. set(['direction', 'Дирекция'], parseList(row.direction)); set(['functions', 'Функция'], parseList(row.functionName)); set(['category', 'Категория'], parseList(row.category)); if (row.applyAffiliation === true) set(['affiliation', 'Аффилированность'], [row.affiliation || REQUIRED_AFFILIATION]); // ВГО (внутригрупповая, ВН=Да): колонка ЭЦП пустая. Иначе — Единый ЭДО (edo) / // «ЭДО на внешней площадке» + «Нет» (non-edo). set(['eds', 'ЭЦП', 'ЭДО'], row.dealInternal === true ? [] : (isEdo ? ['Единый ЭДО'] : ['ЭДО на внешней площадке', 'Нет'])); // limit/sum are [from, to] ranges; the controlling column carries the range, the other stays unbounded. set(['limit_contract', 'Лимит по договору в рублях (без НДС)'], row.valueMode === 'limit' ? [fromNum, toNum] : [null, null]); set(['sum_rub', 'Сумма документа в рублях (включая налоги)'], row.valueMode === 'amount' ? [fromNum, toNum] : [null, null]); // Условия применения: структурированная ячейка [[ [Тип],[ВН] ], ...]. Для ВГО тип пуст, ВН=Да. const condStrings = parseList(row.conditions); if (condStrings.length) set(['condition', 'conditions', 'Условия применения'], condStrings.map(conditionToCell)); // ЮЛ-комбинация: в legal_entity (имена) или, если такой колонки нет, в partner_id (внутренние id). const legalList = parseList(row.legalEntities); if (legalList.length) { const leAlias = ['legal_entity', 'legal_entities', 'legal_entity_id', 'legal_entities_id', 'Юрлицо', 'Юр. лицо']; if (getColumnIndex(leAlias) >= 0) { set(leAlias, legalList); } else { const explicitIds = parseList(row.legalEntityIds || row.legalEntityId || row.partnerIds || row.partnerId).map(cleanSignerId).filter(Boolean); const ids = unique(legalList.map(name => partnerNameToId(name)).concat(explicitIds).filter(Boolean)); ids.forEach((id, idx) => ensurePartnerCache(id, legalList[idx] || row.legalEntity || '')); if (ids.length) set(['partner_id', 'partners_internal_id', 'Контрагент'], ids); } } // Площадки / ОП — тоже комбинация. const siteList = parseList(row.sites); if (siteList.length) set(['partner_op', 'partner_ops', 'site', 'sites', 'op', 'Обособленное подразделение', 'Площадка'], siteList); const signerId = resolveGeneratedSignerId(row); const signIdx = getColumns().findIndex(col => col && col.colType === 'level' && col.type === 'signing'); if (signIdx >= 0 && signerId) { // signer cell encodes the ЭДО variant via edsDocTypeCode: CS = Единый ЭДО, no = не единый. item[signIdx] = normalizeCellForColumn(signIdx, Object.assign({}, item[signIdx] || {}, { edsDocTypeCode: isEdo ? 'CS' : 'no', performerList: [toModelId(signerId)] })); } // Маркер строки — НЕПЕРЕЧИСЛИМЫЙ (enumerable:false): тулкит/тесты читают его как item.__mcV8Generated, // но JSON.stringify / Object.keys / for-in его НЕ видят. Иначе посторонний объект на item-массиве // попадал в сериализацию модели OpenText при сохранении и ломал матрицу («не может быть отображена»). Object.defineProperty(item, '__mcV8Generated', { value: row, enumerable: false, configurable: true, writable: true }); } function appendGeneratedRow(entry) { const matrix = getMatrix(); const jq = getJq(); if (!matrix || !Array.isArray(matrix.items)) throw new Error('sc_ApprovalMatrix.items недоступен.'); const beforeLength = matrix.items.length; const beforeRows = matrix.items.slice(); const anchor = matrixRows().slice(-1)[0]; if (typeof matrix.addRecord === 'function' && jq && anchor) { matrix.addRecord(jq(anchor)); } else { // Фолбэк без addRecord: НЕ пустой массив (иначе строка без ячеек = битая матрица) — клонируем // структуру последней строки, чтобы у новой были ВСЕ колонки правильной формы. const tmpl = matrix.items[matrix.items.length - 1]; const item = typeof matrix.generateEmptyItem === 'function' ? matrix.generateEmptyItem() : (Array.isArray(tmpl) ? tmpl.map(cloneCell) : []); matrix.items.push(item); if (Array.isArray(matrix.mRecsID)) matrix.mRecsID.push(0); if (Array.isArray(matrix.mRecsStatus)) matrix.mRecsStatus.push(1); const tbody = document.querySelector('#sc_ApprovalMatrix tbody'); if (tbody && typeof matrix.renderItemView === 'function') { const rendered = matrix.renderItemView(matrix.items.length - 1, false); if (rendered && rendered.jquery) tbody.appendChild(rendered.get(0)); else if (rendered && rendered.nodeType) tbody.appendChild(rendered); } } const insertedIndex = matrix.items.findIndex(row => beforeRows.indexOf(row) < 0); const newIndex = insertedIndex >= 0 ? insertedIndex : Math.min(beforeLength, matrix.items.length - 1); const item = matrix.items[newIndex]; if (!item) throw new Error('Не удалось создать строку через модель OpenText.'); setGeneratedAliases(item, entry.generatedRow); normalizeRowForModel(item); markChanged(newIndex); rerenderRow(newIndex); return { status: STATUS.OK, message: `Создана строка ${entry.generatedRow.rowKey} через модель OpenText.`, itemId: newIndex }; } // Deep-ish copy of one matrix cell so the clone can be edited without mutating the source row. function cloneCell(cell) { if (Array.isArray(cell)) return cell.slice(); if (isObject(cell)) { const copy = Object.assign({}, cell); if (Array.isArray(cell.performerList)) copy.performerList = cell.performerList.slice(); return copy; } return cell; } // Create a brand-new model row and copy the given cell values into it (used for row splitting). function appendClonedRow(cells) { const matrix = getMatrix(); const jq = getJq(); if (!matrix || !Array.isArray(matrix.items)) throw new Error('sc_ApprovalMatrix.items недоступен.'); const beforeLength = matrix.items.length; const beforeRows = matrix.items.slice(); const anchor = matrixRows().slice(-1)[0]; if (typeof matrix.addRecord === 'function' && jq && anchor) { matrix.addRecord(jq(anchor)); } else { const tmpl = matrix.items[matrix.items.length - 1]; const blank = typeof matrix.generateEmptyItem === 'function' ? matrix.generateEmptyItem() : (Array.isArray(tmpl) ? tmpl.map(cloneCell) : []); matrix.items.push(blank); if (Array.isArray(matrix.mRecsID)) matrix.mRecsID.push(0); if (Array.isArray(matrix.mRecsStatus)) matrix.mRecsStatus.push(1); } const insertedIndex = matrix.items.findIndex(row => beforeRows.indexOf(row) < 0); const newIndex = insertedIndex >= 0 ? insertedIndex : Math.min(beforeLength, matrix.items.length - 1); const item = matrix.items[newIndex]; if (!item) throw new Error('Не удалось создать строку через модель OpenText.'); for (let i = 0; i < cells.length; i += 1) item[i] = normalizeCellForColumn(i, cloneCell(cells[i])); normalizeRowForModel(item); Object.defineProperty(item, '__mcV8Split', { value: true, enumerable: false, configurable: true, writable: true }); // неперечислимый — не попадёт в сохранение markChanged(newIndex); rerenderRow(newIndex); return newIndex; } function applySplitEntry(entry) { const split = entry.split; const source = matrixItems()[split.sourceIndex]; if (!source) throw new Error('Исходная строка для разделения не найдена.'); const clone = source.map(cloneCell); clone[split.colIdx] = split.extracted.slice(); // на клоне — только вынесенное ЮЛ if (split.removeCategories && split.removeCategories.length && split.categoryCol >= 0) { const drop = split.removeCategories.map(normalize); clone[split.categoryCol] = valueAsList(clone[split.categoryCol]).filter(value => !drop.includes(normalize(value))); } if (split.removeDocTypes && split.removeDocTypes.length && split.docTypeCol >= 0) { const drop = split.removeDocTypes.map(normalize); clone[split.docTypeCol] = valueAsList(clone[split.docTypeCol]).filter(value => !drop.includes(normalize(value))); } if (split.newSignerId) { const signIdx = signerColumnIndex(); if (signIdx >= 0) clone[signIdx] = normalizeCellForColumn(signIdx, Object.assign({}, clone[signIdx] || {}, { performerList: [toModelId(split.newSignerId)] })); } const newIndex = appendClonedRow(clone); setCellArray(split.sourceIndex, split.colIdx, split.remaining); // в исходной — без вынесенного ЮЛ return { status: STATUS.OK, message: `Строка разделена: ЮЛ вынесено в новую строку #${newIndex + 1}; в исходной осталось ${split.remaining.length} ЮЛ.`, itemId: newIndex }; } function applyCategoryTemplateEntry(entry) { const clone = entry.cloneRow || {}; const sourceIndex = Number(clone.sourceIndex); const categoryCol = Number(clone.categoryColumnIndex); const source = matrixItems()[sourceIndex]; if (!source) throw new Error('Source row for category template clone was not found.'); if (!Number.isInteger(categoryCol) || categoryCol < 0) throw new Error('Category column for template clone was not found.'); const cells = source.map(cloneCell); cells[categoryCol] = [String(clone.category || '')]; const newIndex = appendClonedRow(cells); const item = matrixItems()[newIndex]; if (item) { Object.defineProperty(item, '__mcV8Generated', { value: Object.assign({ type: 'create_category_from_template', rowKey: `category_template_${newIndex + 1}`, category: clone.category, sourceIndex, }, clone.marker || {}), enumerable: false, configurable: true, writable: true, }); } return { status: STATUS.OK, message: `Created row #${newIndex + 1} from template row #${sourceIndex + 1}.`, itemId: newIndex }; } function applyCarveEntry(entry) { const carve = entry.carve; const source = matrixItems()[carve.sourceIndex]; if (!source) throw new Error('Исходная строка для нарезки не найдена.'); const signIdx = signerColumnIndex(); const setSigner = (cells, id) => { if (signIdx >= 0) cells[signIdx] = normalizeCellForColumn(signIdx, Object.assign({}, cells[signIdx] || {}, { performerList: [toModelId(id)] })); }; const created = []; // 1) новая строка новому подписанту на банду [A,B] const bandRow = source.map(cloneCell); bandRow[carve.limitCol] = toLimitCell(carve.A, carve.B); setSigner(bandRow, carve.newSignerId); created.push(appendClonedRow(bandRow) + 1); const hasLower = carve.A > carve.F; const hasUpper = carve.B < carve.T; if (hasLower && hasUpper) { const upper = source.map(cloneCell); // верхний остаток — соседу upper[carve.limitCol] = toLimitCell(carve.B, carve.T); created.push(appendClonedRow(upper) + 1); setCellArray(carve.sourceIndex, carve.limitCol, toLimitCell(carve.F, carve.A)); // нижний остаток — урезать исходную } else if (hasLower) { setCellArray(carve.sourceIndex, carve.limitCol, toLimitCell(carve.F, carve.A)); } else if (hasUpper) { setCellArray(carve.sourceIndex, carve.limitCol, toLimitCell(carve.B, carve.T)); } return { status: STATUS.OK, message: `Банда нарезана: создана строка(и) #${created.join(', ')}; исходная строка #${carve.sourceIndex + 1} урезана под остаток.`, itemId: created[0] - 1 }; } // Подпись матрицы: структура колонок + первый record id. Стабильна при добавлении строк (новые id уходят // в конец), но различает РАЗНЫЕ матрицы/страницы — чтобы план, собранный на одной матрице, не применился // к другой (открыл другую заявку/матрицу и нажал «Применить» по инерции). function matrixSignature() { const m = getMatrix(); if (!m) return ''; const cols = (getColumns() || []).map(c => (c && (c.alias || c.title)) || '').join('|'); const firstId = Array.isArray(m.mRecsID) && m.mRecsID.length ? m.mRecsID[0] : ''; return `${cols}#${firstId}`; } async function apply(planId, options = {}) { const plan = state.plans.get(planId || state.lastPlanId); if (!plan) { return { schemaVersion: VERSION, planId: planId || '', summary: summarize([]), report: [] }; } // Идемпотентность: уже применённый план повторно НЕ применяем (двойной клик не плодит дубли строк). if (plan.applied) { const prior = Array.isArray(plan.appliedReport) ? plan.appliedReport : []; return { schemaVersion: VERSION, planId: plan.planId, summary: summarize(prior), report: prior, safety: plan.appliedSafety || null, alreadyApplied: true }; } const matrixRef = getMatrix(); // Сторож матрицы: план собран под конкретную матрицу. Сменилась страница/матрица — НЕ трогаем, просим пересобрать. if (plan.matrixSignature != null && plan.matrixSignature !== matrixSignature()) { const skipped = plan.entries.map(entry => Object.assign({}, entry, { planId: plan.planId, status: STATUS.SKIPPED, message: 'Матрица сменилась после preview — пересоберите preview на нужной матрице.', previewOnly: false, })); return { schemaVersion: VERSION, planId: plan.planId, summary: summarize(skipped), report: skipped, matrixChanged: true, safety: { isolated: true, serializable: true, leakedMarkers: false, collateral: [], touched: [] } }; } // «Безприкосновенно»: снимок ДО применения (сериализация каждой существующей строки) + множество // НАМЕЧЕННЫХ индексов. apply имеет право менять только их: patch → entry.itemId; split/carve → их // sourceIndex; новые строки всегда добавляются в конец (index >= длины снимка). Любая другая // пре-существующая строка, изменившаяся после apply, — побочная мутация (баг), и мы её ловим. const isoBefore = (matrixRef && Array.isArray(matrixRef.items)) ? matrixRef.items.map(row => { try { return JSON.stringify(row); } catch (_) { return null; } }) : []; const intendedIdx = new Set(); for (const entry of plan.entries) { if (entry.actionType === ACTION.PATCH_ROW && entry.itemId != null) intendedIdx.add(Number(entry.itemId)); if (entry.actionType === ACTION.REMOVE_TOKEN && entry.itemId != null) intendedIdx.add(Number(entry.itemId)); if (entry.split && entry.split.sourceIndex != null) intendedIdx.add(Number(entry.split.sourceIndex)); if (entry.carve && entry.carve.sourceIndex != null) intendedIdx.add(Number(entry.carve.sourceIndex)); } // Снимок для отката (Undo): сериализованные исходные строки + длины параллельных массивов. // Откат любой операции = усечь добавленные строки + восстановить исходные из этого снимка. state.revertSnapshot = { planId: plan.planId, rowsJson: isoBefore, beforeLength: isoBefore.length, mRecsIDLen: matrixRef && Array.isArray(matrixRef.mRecsID) ? matrixRef.mRecsID.length : null, mRecsStatusLen: matrixRef && Array.isArray(matrixRef.mRecsStatus) ? matrixRef.mRecsStatus.length : null, }; const out = []; state.lastApplySnapshot = { schemaVersion: VERSION, planId: plan.planId, generatedAt: new Date().toISOString(), entries: plan.entries.map(entry => ({ operationType: entry.operationType, actionType: entry.actionType, itemId: entry.itemId, recordId: entry.recordId, applyMode: entry.applyMode, before: entry.before, after: entry.after, rollbackHint: entry.rollbackHint, })), }; for (const entry of plan.entries) { let result; try { if ((entry.actionType === ACTION.PATCH_ROW || entry.actionType === ACTION.REMOVE_TOKEN) && entry.patch) result = applyPatchEntry(entry); else if (entry.actionType === ACTION.SPLIT_ROW && entry.split) result = applySplitEntry(entry); else if (entry.actionType === ACTION.CARVE_BAND && entry.carve) result = applyCarveEntry(entry); else if (entry.actionType === ACTION.ADD_ROW && entry.cloneRow) result = applyCategoryTemplateEntry(entry); else if (entry.actionType === ACTION.ADD_ROW && entry.generatedRow) result = appendGeneratedRow(entry); else if (entry.actionType === ACTION.LEGACY && original.runRuleBatch && options.allowLegacyDelegate) { const legacyReport = await original.runRuleBatch([entry.legacyOperation], options); result = { status: STATUS.OK, message: `Legacy delegate вернул ${Array.isArray(legacyReport) ? legacyReport.length : 0} записей.` }; } else if (entry.actionType === ACTION.SKIP || entry.actionType === ACTION.MANUAL || entry.actionType === ACTION.LEGACY) { result = { status: entry.status, message: entry.reason }; } else { result = { status: STATUS.MANUAL, message: 'Для действия нет подтверждённого native writer.' }; } } catch (error) { result = { status: STATUS.ERROR, message: error.message || String(error) }; } out.push(Object.assign({}, entry, { planId: plan.planId, status: result.status, message: result.message, appliedItemId: result.itemId != null ? result.itemId : '', previewOnly: false, })); } state.lastReport = out; // Изоляция: пре-существующие НЕнамеченные строки должны быть байт-в-байт равны снимку ДО. const collateral = []; if (matrixRef && Array.isArray(matrixRef.items)) { for (let i = 0; i < isoBefore.length; i += 1) { if (intendedIdx.has(i)) continue; const item = matrixRef.items[i]; let now = null; try { now = JSON.stringify(item); } catch (_) { now = null; } if (now !== isoBefore[i]) collateral.push(i); } } const changedSet = new Set(); out.forEach(entry => { if (typeof entry.appliedItemId === 'number' && entry.appliedItemId >= 0) changedSet.add(entry.appliedItemId); }); intendedIdx.forEach(i => { if (i != null && i >= 0) changedSet.add(Number(i)); }); if (matrixRef && Array.isArray(matrixRef.items)) { for (let i = isoBefore.length; i < matrixRef.items.length; i += 1) changedSet.add(i); } // Сериализуемость: матрица целиком сериализуется (как при сохранении) — без throw и без утечки маркеров. let serializable = true; let serial = ''; try { serial = JSON.stringify(matrixRef ? matrixRef.items : []); } catch (_) { serializable = false; } const safety = { isolated: collateral.length === 0, serializable, leakedMarkers: /__mcV8/.test(serial), collateral, touched: Array.from(changedSet), }; const nativeSave = changedSet.size ? validateNativeSaveReadiness(matrixRef, { rowIndexes: Array.from(changedSet) }) : { ok: true, errors: [], rowCount: matrixRef && matrixRef.items ? matrixRef.items.length : 0, colCount: getColumns().length, scopedRows: [] }; safety.nativeSaveReady = nativeSave.ok; safety.nativeSaveErrors = nativeSave.errors.slice(0, 20); safety.nativeSaveRowCount = nativeSave.rowCount; safety.nativeSaveColCount = nativeSave.colCount; safety.nativeSaveRows = nativeSave.scopedRows || []; state.lastApplySafety = safety; if (!safety.isolated || !safety.serializable || safety.leakedMarkers || !safety.nativeSaveReady) { try { console.warn('[toolkit] apply safety violation', safety); } catch (_) {} } if (!safety.serializable || safety.leakedMarkers || !safety.nativeSaveReady) { const rollback = revertLastApply(); safety.rolledBack = Boolean(rollback && rollback.ok); safety.rollback = rollback; const reason = !safety.nativeSaveReady ? `Native save guard blocked apply: ${safety.nativeSaveErrors.join('; ')}` : 'Native save guard blocked apply because matrix serialization is unsafe.'; const blocked = out.map(entry => Object.assign({}, entry, { status: entry.status === STATUS.OK ? STATUS.ERROR : entry.status, message: entry.status === STATUS.OK ? reason : entry.message, previewOnly: false, })); state.lastReport = blocked; state.lastApplySafety = safety; plan.applied = false; plan.appliedReport = blocked; plan.appliedSafety = safety; return { schemaVersion: VERSION, planId: plan.planId, summary: summarize(blocked), report: blocked, safety, changedRows: [], rolledBack: true }; } // Помечаем план применённым (для идемпотентности) и кэшируем отчёт/безопасность. plan.applied = true; plan.appliedReport = out; plan.appliedSafety = safety; // Затронутые строки: новые (appliedItemId) + изменённые/исходные (intendedIdx). Подсвечиваем в матрице. const changedRows = Array.from(changedSet).sort((a, b) => a - b); plan.changedRows = changedRows; try { highlightChangedRows(changedRows); } catch (_) { /* подсветка не критична */ } clearV8Preview(); return { schemaVersion: VERSION, planId: plan.planId, summary: summarize(out), report: out, safety, changedRows }; } // Откат последнего применения в один клик: усекаем добавленные строки и восстанавливаем исходные // байт-в-байт из снимка, снятого ДО apply. Универсально для add/patch/split/carve — «матрица как была». function revertLastApply() { const snap = state.revertSnapshot; const matrix = getMatrix(); if (!snap || !matrix || !Array.isArray(matrix.items)) { return { ok: false, removed: 0, restored: 0, message: 'Откатывать нечего — последнего применения нет.' }; } const removed = Math.max(0, matrix.items.length - snap.beforeLength); // 1) Удаляем добавленные строки из модели (всё за пределами исходной длины) и их DOM-строки. if (matrix.items.length > snap.beforeLength) matrix.items.length = snap.beforeLength; if (Array.isArray(matrix.mRecsID) && snap.mRecsIDLen != null && matrix.mRecsID.length > snap.mRecsIDLen) matrix.mRecsID.length = snap.mRecsIDLen; if (Array.isArray(matrix.mRecsStatus) && snap.mRecsStatusLen != null && matrix.mRecsStatus.length > snap.mRecsStatusLen) matrix.mRecsStatus.length = snap.mRecsStatusLen; const tbody = document.querySelector('#sc_ApprovalMatrix tbody'); if (tbody) { while (tbody.children.length > snap.beforeLength) tbody.removeChild(tbody.lastElementChild); } // 2) Восстанавливаем изменённые исходные строки байт-в-байт из снимка + перерисовываем их. let restored = 0; for (let i = 0; i < snap.rowsJson.length; i += 1) { if (snap.rowsJson[i] == null) continue; let now = null; try { now = JSON.stringify(matrix.items[i]); } catch (_) { now = null; } if (now === snap.rowsJson[i]) continue; // строка не менялась — не трогаем try { matrix.items[i] = JSON.parse(snap.rowsJson[i]); restored += 1; rerenderRow(i); } catch (_) { /* пропуск */ } } if (typeof matrix.recalculateItems === 'function') matrix.recalculateItems(); // Снимаем applied-флаг с плана (откатили — можно осознанно пересобрать/повторить) и гасим снимок. if (snap.planId) { const pl = state.plans.get(snap.planId); if (pl) { pl.applied = false; pl.appliedReport = null; } } state.revertSnapshot = null; // откат одноразовый return { ok: true, removed, restored, message: `Откат выполнен: удалено новых строк ${removed}, восстановлено изменённых ${restored}.` }; } function collectDictionaries() { const matrix = getMatrix(); const dict = { counterparties: [], signers: [], approvers: [], specialExperts: [], performers: [], usersByColumn: {}, legalEntities: [], sites: [], docTypes: [], directions: [], functions: [], categories: [], rowGroups: ['all', 'main_contract_rows', 'supplemental_rows', 'custom'], requiredAffiliation: REQUIRED_AFFILIATION, }; if (!matrix) return dict; if (matrix.partnerCacheObject) dict.counterparties = unique(Object.keys(matrix.partnerCacheObject).map(id => matrix.partnerCacheObject[id])).sort((a, b) => a.localeCompare(b, 'ru')); const userNames = {}; const userCache = matrix.userCacheObject || {}; allRowFacts().forEach(facts => { dict.docTypes.push(...facts.docTypes); dict.legalEntities.push(...facts.legalEntities); dict.sites.push(...facts.sites); dict.directions.push(...facts.directions); dict.functions.push(...facts.functions); dict.categories.push(...facts.categories); }); getColumns().forEach((col, idx) => { if (!col || !['function', 'level'].includes(col.colType)) return; const names = []; matrixItems().forEach(item => { const cell = item[idx]; if (!cell || !Array.isArray(cell.performerList)) return; names.push(...cell.performerList.map(id => userCache[id] || userCache[Math.abs(Number(id))] || String(id))); }); const list = unique(names).sort((a, b) => a.localeCompare(b, 'ru')); if (!list.length) return; const title = col.title || col.type || `column_${idx}`; dict.usersByColumn[title] = list; list.forEach(name => { userNames[name] = true; }); if (col.type === 'signing' || /подпис/i.test(title)) dict.signers.push(...list); else if (/спец/i.test(title)) dict.specialExperts.push(...list); else if (/исполнитель/i.test(title) || col.type === 'performer') dict.performers.push(...list); else dict.approvers.push(...list); }); dict.signers = unique(dict.signers).sort((a, b) => a.localeCompare(b, 'ru')); dict.approvers = unique(dict.approvers).sort((a, b) => a.localeCompare(b, 'ru')); dict.specialExperts = unique(dict.specialExperts).sort((a, b) => a.localeCompare(b, 'ru')); dict.performers = unique(dict.performers).sort((a, b) => a.localeCompare(b, 'ru')); dict.users = unique(Object.keys(userNames)).sort((a, b) => a.localeCompare(b, 'ru')); dict.signersAndApprovers = unique([].concat(dict.signers, dict.approvers)).sort((a, b) => a.localeCompare(b, 'ru')); ['legalEntities', 'sites', 'docTypes', 'directions', 'functions', 'categories'].forEach(key => { dict[key] = unique(dict[key]).sort((a, b) => a.localeCompare(b, 'ru')); }); return dict; } function parseRequestText(text, options = {}) { const raw = String(text || '').trim(); const lower = normalize(raw); const extracted = { counterparties: unique(Array.from(raw.matchAll(/(?:контрагент|counterparty)\s*[:\-]?\s*([^\n;,]+)/gi)).map(m => m[1])), docTypes: unique(Array.from(raw.matchAll(/(?:тип(?:\s+документа)?|doc\s*type)\s*[:\-]?\s*([^\n;,]+)/gi)).map(m => m[1])), legalEntities: unique(Array.from(raw.matchAll(/(?:ооо|ао|пао)\s+[«"\wа-яё\s.-]{2,80}/gi)).map(m => m[0])), users: unique(Array.from(raw.matchAll(/(?:подписант|согласующ|пользователь|user)\s*[:\-]?\s*([^\n;,]+)/gi)).map(m => m[1])), amounts: unique(Array.from(raw.matchAll(/(?:сумм|amount)\D{0,20}([\d\s.,]+)/gi)).map(m => m[1])), limits: unique(Array.from(raw.matchAll(/(?:лимит|limit)\D{0,20}([\d\s.,]+)/gi)).map(m => m[1])), }; const proposedOperations = []; const checklistSuggestions = []; const manualReviewFlags = []; let caseType = 'manual_review'; let confidence = 0.35; if (/маршрут|лист согласования|не стро|не форм|route/.test(lower)) { caseType = 'route_or_card_diagnosis'; confidence = 0.72; checklistSuggestions.push('route_failure', 'card_validation', 'sum_limits', 'counterparty_before_list'); proposedOperations.push({ type: 'checklist_route_failure', payload: { rawText: raw } }); } if (/добав|добавить|включить/.test(lower) && /тип документ|тип\s*:|doc type/.test(lower)) { caseType = 'doc_type_patch'; confidence = Math.max(confidence, 0.68); proposedOperations.push({ type: 'add_doc_type_to_matching_rows', payload: { newDocType: options.docType || extracted.docTypes[0] || '', requiredDocTypes: options.requiredDocTypes || [], rowGroup: /доп|дс/.test(lower) ? 'supplemental_rows' : 'all', matchMode: options.matchMode || 'all', affiliation: REQUIRED_AFFILIATION, }, }); } if (/юр.?лиц|legal entit/.test(lower)) { caseType = caseType === 'manual_review' ? 'legal_entity_patch' : caseType; confidence = Math.max(confidence, 0.62); proposedOperations.push({ type: 'add_legal_entity_to_matching_rows', payload: { legalEntity: options.legalEntity || extracted.legalEntities[0] || '', requiredDocTypes: options.requiredDocTypes || [], rowGroup: options.rowGroup || 'all', matchMode: options.matchMode || 'all', affiliation: REQUIRED_AFFILIATION, }, }); } if (/подписант|signer/.test(lower) && (/лимит|limit/.test(lower) || /сумм|amount/.test(lower))) { caseType = 'signer_bundle'; confidence = Math.max(confidence, 0.7); proposedOperations.push({ type: 'add_signer_bundle', payload: { newSigner: options.newSigner || extracted.users[0] || '', limit: options.limit || extracted.limits[0] || '', amount: options.amount || extracted.amounts[0] || '', affiliation: REQUIRED_AFFILIATION, }, }); } if (/контрагент|counterparty/.test(lower) && /удал|убра|remove/.test(lower)) { caseType = 'counterparty_cleanup'; confidence = Math.max(confidence, 0.66); proposedOperations.push({ type: 'remove_counterparty_from_rows', payload: { partnerName: options.partnerName || extracted.counterparties[0] || '', affiliation: REQUIRED_AFFILIATION }, options: { skipExclude: true }, }); } proposedOperations.forEach(op => { if (op.type === 'add_doc_type_to_matching_rows' && !op.payload.newDocType) manualReviewFlags.push('new document type required'); if (op.type === 'add_legal_entity_to_matching_rows' && !op.payload.legalEntity) manualReviewFlags.push('legal entity required'); if (op.type === 'add_signer_bundle') { if (!op.payload.newSigner) manualReviewFlags.push('new signer required'); if (!op.payload.limit) manualReviewFlags.push('limit required'); if (!op.payload.amount) manualReviewFlags.push('amount required'); } }); if (!proposedOperations.length) manualReviewFlags.push('operation type required'); return { schemaVersion: VERSION, caseType, confidence: manualReviewFlags.length ? Math.min(confidence, 0.49) : confidence, extractedEntities: extracted, proposedOperations, operations: proposedOperations, checklistSuggestions: unique(checklistSuggestions), manualReviewFlags: unique(manualReviewFlags), autoApplyAllowed: false, recommendation: manualReviewFlags.length ? `Запросить недостающие данные: ${unique(manualReviewFlags).join(', ')}.` : 'Построить preview, проверить причины попадания строк и приложить отчёт перед apply.', }; } function diagnoseCurrentCard(input = {}) { const text = normalize(input.text || (document.body ? document.body.textContent : '')); const checks = [ ['route_failure', 'Маршрут / лист согласования', /маршрут|лист согласования|route/.test(text), /не стро|не форм|ошиб|error|fail/.test(text)], ['card_validation', 'Обязательные поля / красные поля', /обяз|красн|required|validation|не заполн/.test(text), /обяз|красн|required|validation|не заполн/.test(text)], ['counterparty_before_list', 'Контрагенты и аффилированность', /контрагент|аффилирован|partner/.test(text), false], ['dfk', 'ДФК', /дфк|для целей функции/.test(text), false], ['profitability_type', 'Тип сделки по доходности', /доходн|расходн|тип сделки/.test(text), false], ['sum_limits', 'Сумма и лимиты по своду', /сумм|лимит|amount|limit/.test(text), false], ['standard_form_robot', 'Стандартная форма / робот', /стандартн.*форм|word|папк[аи]\s*01|робот/.test(text), false], ['confidentiality', 'Конфиденциальность', /конфиденциальн|соглашение о сотрудничестве/.test(text), false], ['offer_quasi_contract', 'Квазидоговор / оферта', /квазидоговор|оферт/.test(text), false], ['main_supp_patterns', 'Основные и доп. соглашения', /основн|доп|дс|подчин/.test(text), false], ].map(([id, title, signal, fail]) => ({ id, title, status: fail ? STATUS.FAIL : (signal ? STATUS.PASS : STATUS.WARN), recommendation: fail ? 'Исправить карточку/маршрут до проверки матрицы.' : signal ? 'Сигнал найден, сверить с матрицей и сводом.' : 'Запросить подтверждающие данные у пользователя.', })); const missingFields = []; [ ['matrix name', /матриц|matrix/], ['document type', /тип документ|document type/], ['legal entity', /юр.?лиц|legal entity/], ['counterparty affiliation', /аффилирован|контрагент|counterparty/], ['amount/limit', /сумм|лимит|amount|limit/], ['EDO mode', /эдо|эцп|edo/], ].forEach(([field, pattern]) => { if (!pattern.test(text)) missingFields.push(field); }); return { schemaVersion: VERSION, generatedAt: new Date().toISOString(), summary: { total: checks.length, pass: checks.filter(c => c.status === STATUS.PASS).length, warn: checks.filter(c => c.status === STATUS.WARN).length, fail: checks.filter(c => c.status === STATUS.FAIL).length, }, checks, requiredFields: ['matrix name', 'document type', 'legal entity', 'counterparty affiliation', 'amount/limit', 'EDO mode'], missingFields, recommendation: missingFields.length ? `Для triage запросить: ${missingFields.join(', ')}.` : 'Данных достаточно для preview матрицы и сверки маршрута.', }; } function runChecklistEngine(options = {}) { const diagnosis = diagnoseCurrentCard({ text: options.text || '' }); return { generatedAt: diagnosis.generatedAt, summary: { total: diagnosis.summary.total, passed: diagnosis.summary.pass, warnings: diagnosis.summary.warn, failed: diagnosis.summary.fail, }, checks: diagnosis.checks.map(check => ({ id: check.id, title: check.title, severity: check.status === STATUS.FAIL ? 'error' : 'warn', status: check.status === STATUS.PASS ? 'pass' : check.status, recommendation: check.recommendation, sourceRule: `v8-checklist:${check.id}`, })), missingFields: diagnosis.missingFields, recommendation: diagnosis.recommendation, }; } function detectCatalogEntries() { const api = getApi(); const fromApi = api && original.getMatrixCatalog ? original.getMatrixCatalog() : (api && api.getMatrixCatalog ? api.getMatrixCatalog() : []); if (Array.isArray(fromApi) && fromApi.length) return fromApi.map(item => ({ matrixName: item.matrixName || item.name || '', matrixId: item.matrixId || item.dataId || '', openUrl: item.openUrl || item.link || item.href || '', })); return Array.from(document.querySelectorAll('#browseViewCoreTable tr.browseRow1, #browseViewCoreTable tr.browseRow2')).map(row => { const link = row.querySelector('a[href*="OpenMatrix"]'); const name = row.getAttribute('tnode') || (row.querySelector('.browseItemNameContainer') ? row.querySelector('.browseItemNameContainer').textContent : ''); return link ? { matrixName: String(name || '').trim(), matrixId: (link.href.match(/objid=(\d+)/i) || [])[1] || '', openUrl: link.href, } : null; }).filter(Boolean); } function scanMatrixHtml(html, entry, query, options) { const normQuery = normalize(query); const matchMode = options.matchMode || 'partial'; const rows = []; const pushMatch = (text, rowNumber, column) => { const norm = normalize(text); const ok = matchMode === 'exact' ? norm === normQuery : norm.includes(normQuery); if (!ok) return; rows.push({ matrixName: entry.matrixName, matrixId: entry.matrixId || '', openUrl: entry.openUrl, rowNumber, itemId: rowNumber ? rowNumber - 1 : '', column: column || options.mode || 'all', matchedValue: String(text || '').trim().slice(0, 500), matchType: matchMode, scanMode: 'catalog_fetch', }); }; const match = String(html || '').match(/DataStringToVariables\(\s*'((?:\\'|[^'])*)'\s*\);/); if (match) { try { const payload = match[1].replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, '\\'); const json = JSON.parse(payload); const sourceRows = Array.isArray(json.myRows) ? json.myRows : Array.isArray(json.rows) ? json.rows : []; sourceRows.forEach((row, index) => pushMatch(JSON.stringify(row), index + 1, options.mode || 'json')); } catch (_) { // fall through to DOM parsing } } if (!rows.length && host.DOMParser) { const doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); Array.from(doc.querySelectorAll('#sc_ApprovalMatrix tbody tr, tr[itemid], tr[itemID]')).forEach((row, index) => { pushMatch(row.textContent || '', index + 1, options.mode || 'dom'); }); } if (!rows.length) pushMatch(`${entry.matrixName} ${html}`.slice(0, 2000), '', 'matrix'); return rows; } async function searchAcrossMatrices(query, options = {}) { state.searchCancelled = false; const entries = detectCatalogEntries(); const normQuery = normalize(query); if (!normQuery) { return { schemaVersion: VERSION, mode: options.mode || 'all', query, total: 0, deduped: [], progress: { scanned: 0, total: entries.length || 1, done: true }, cancelled: false, failures: [] }; } if (!entries.length) { const rows = allRowFacts().flatMap(facts => { const text = facts.text; const ok = options.matchMode === 'exact' ? normalize(text) === normQuery : normalize(text).includes(normQuery); return ok ? [{ matrixName: document.title || '', matrixId: '', openUrl: location.href, rowNumber: facts.rowNumber, itemId: facts.itemId, column: options.mode || 'current_matrix', matchedValue: text.slice(0, 500), matchType: options.matchMode || 'partial', scanMode: 'current_matrix', }] : []; }); return { schemaVersion: VERSION, mode: options.mode || 'all', query, total: rows.length, deduped: rows, progress: { scanned: 1, total: 1, done: true }, cancelled: false, failures: [] }; } const limit = Number(options.limit || entries.length); const results = []; const failures = []; let scanned = 0; for (const entry of entries.slice(0, limit)) { if (state.searchCancelled) break; scanned += 1; try { const url = new URL(entry.openUrl, location.href).href; const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timeout = controller ? setTimeout(() => controller.abort(), Number(options.fetchTimeoutMs || 2500)) : null; const response = await fetch(url, { credentials: 'include', signal: controller ? controller.signal : undefined }); if (timeout) clearTimeout(timeout); if (!response.ok) throw new Error(`HTTP ${response.status}`); const html = await response.text(); results.push(...scanMatrixHtml(html, Object.assign({}, entry, { openUrl: url }), query, options)); } catch (error) { failures.push({ matrixName: entry.matrixName, openUrl: entry.openUrl, error: error.message || String(error) }); const nameMatch = options.matchMode === 'exact' ? normalize(entry.matrixName) === normQuery : normalize(entry.matrixName).includes(normQuery); if (nameMatch) { results.push({ matrixName: entry.matrixName, matrixId: entry.matrixId || '', openUrl: entry.openUrl, rowNumber: '', itemId: '', column: 'matrixName', matchedValue: entry.matrixName, matchType: options.matchMode || 'partial', scanMode: 'catalog_name_fallback', }); } } } const deduped = Array.from(new Map(results.map(item => [`${item.matrixId}:${item.rowNumber}:${item.column}:${item.matchedValue}`, item])).values()); const payload = { schemaVersion: VERSION, mode: options.mode || 'all', query, total: deduped.length, deduped, progress: { scanned, total: Math.min(limit, entries.length), done: !state.searchCancelled }, cancelled: state.searchCancelled, catalogSize: entries.length, failures, scanMode: 'catalog_fetch', }; state.lastSearch = payload; return payload; } function exportReport(format = 'json') { const rows = state.lastReport || []; if (format === 'csv') { const headers = ['planId', 'matrixName', 'operationType', 'actionType', 'status', 'rowNo', 'itemId', 'recordId', 'whyMatched', 'reason', 'message', 'applyMode', 'rollbackHint']; return [headers.join(',')].concat(rows.map(row => headers.map(key => `"${String(row[key] == null ? '' : row[key]).replace(/"/g, '""')}"`).join(','))).join('\n'); } if (format === 'html') { return `Matrix Cleaner v8 report

Matrix Cleaner v8 report

${rows.map((row, idx) => ``).join('')}
#MatrixOperationStatusReason
${idx + 1}${escapeHtml(row.matrixName)}${escapeHtml(row.operationType)}${escapeHtml(row.status)}${escapeHtml(row.reason || row.message || '')}
`; } return JSON.stringify({ schemaVersion: VERSION, generatedAt: new Date().toISOString(), report: rows }, null, 2); } function splitReportBuckets(report) { const rows = Array.isArray(report) ? report : []; return { ok: rows.filter(row => row.status === STATUS.OK), skipped: rows.filter(row => row.status === STATUS.SKIPPED || row.actionType === ACTION.SKIP), errors: rows.filter(row => row.status === STATUS.ERROR), ambiguous: rows.filter(row => String(row.status || '').includes('manual') || row.actionType === ACTION.MANUAL || row.actionType === ACTION.LEGACY), }; } function rowsToTsv(rows) { const headers = ['operationType', 'actionType', 'status', 'reason', 'message', 'itemId', 'recordId']; return [headers.join('\t')].concat((rows || []).map(row => headers.map(key => String(row[key] == null ? '' : row[key]).replace(/\t/g, ' ')).join('\t'))).join('\n'); } async function copyBucket(name) { const source = state.lastReport.length ? state.lastReport : (original.getLastReport ? original.getLastReport() : []); const rows = splitReportBuckets(source)[name] || []; if (!rows.length || !navigator.clipboard || !navigator.clipboard.writeText) return false; await navigator.clipboard.writeText(rowsToTsv(rows)); return true; } function firstMatrixSignerId() { const signIdx = signerColumnIndex(); if (signIdx < 0) return ''; for (const item of matrixItems()) { const cell = item && item[signIdx]; const list = cell && Array.isArray(cell.performerList) ? cell.performerList : []; const id = list.map(cleanSignerId).find(Boolean); if (id) return id; } return ''; } async function runSyntheticContour(options = {}) { const mode = typeof options === 'string' ? options : (options.mode || 'preview_only'); const syntheticSignerId = firstMatrixSignerId(); const ops = [ { type: 'add_signer_bundle', payload: { newSigner: syntheticSignerId || 'Synthetic Signer', newSignerId: syntheticSignerId, limit: '1000', amount: '500', affiliation: REQUIRED_AFFILIATION }, options: { sourceRule: `v8_synthetic_${mode}` } }, { type: 'add_doc_type_to_matching_rows', payload: { rowGroup: 'all', requiredDocTypes: [], matchMode: 'all', newDocType: 'V8 Synthetic Doc', affiliation: REQUIRED_AFFILIATION }, options: { sourceRule: `v8_synthetic_${mode}` } }, { type: 'add_change_card_flag_to_matching_rows', payload: { rowGroup: 'all', requiredDocTypes: [], matchMode: 'all', changeCardFlag: 'Ранее не подписан', affiliation: REQUIRED_AFFILIATION }, options: { sourceRule: `v8_synthetic_${mode}` } }, ]; const previewResult = await preview(ops); const checks = [ { name: 'preview planId', ok: Boolean(previewResult.planId), details: previewResult.planId }, { name: 'signer 4 rows', ok: previewResult.report.filter(row => row.operationType === 'add_signer_bundle' && row.actionType === ACTION.ADD_ROW).length === 4 }, { name: 'checklist report', ok: runChecklistEngine({ text: 'маршрут карточка контрагент сумма лимит ЭДО тип документа юрлицо' }).summary.total > 0 }, { name: 'search/report contract', ok: typeof exportReport('json') === 'string' }, ]; if (mode === 'real_insert') { const applyResult = await apply(previewResult.planId, { skipDraftCheck: true }); checks.push({ name: 'real insert guarded apply', ok: applyResult.summary.total > 0, details: `ok=${applyResult.summary.ok}` }); } const failed = checks.filter(check => !check.ok).length; return { schemaVersion: VERSION, mode, total: checks.length, ok: checks.length - failed, fail: failed, failed, checks }; } function fillDatalist(id, values) { const list = document.getElementById(id); if (!list) return; list.innerHTML = (values || []).slice(0, 300).map(value => ``).join(''); } function buildOperationFromUi(root) { const scenario = root.querySelector('[data-role="v8-scenario"]').value; const payload = { partnerName: root.querySelector('[data-role="v8-counterparty"]').value, currentApprover: root.querySelector('[data-role="v8-current-user"]').value, newApprover: root.querySelector('[data-role="v8-new-user"]').value, currentSigner: root.querySelector('[data-role="v8-current-user"]').value, newSigner: root.querySelector('[data-role="v8-new-user"]').value, rowGroup: root.querySelector('[data-role="v8-row-group"]').value, requiredDocTypes: parseList(root.querySelector('[data-role="v8-required-doc-types"]').value), matchMode: root.querySelector('[data-role="v8-match-mode"]').value, newDocType: root.querySelector('[data-role="v8-doc-type"]').value, legalEntity: root.querySelector('[data-role="v8-legal-entity"]').value, limit: root.querySelector('[data-role="v8-limit"]').value, amount: root.querySelector('[data-role="v8-amount"]').value, affiliation: REQUIRED_AFFILIATION, }; return { type: scenario, payload, options: { sourceRule: 'v8_operator_ui' } }; } function renderReportBox(root, result) { const box = root.querySelector('[data-role="v8-result"]'); if (!box) return; const report = result && result.report ? result.report : state.lastReport; const summary = result && result.summary ? result.summary : summarize(report); box.innerHTML = `planId: ${escapeHtml(result && result.planId ? result.planId : state.lastPlanId)} · всего ${summary.total} · apply ${summary.actionable || summary.ok} · ручная проверка ${summary.manual || 0}
${(report || []).slice(0, 8).map((row, idx) => `
${idx + 1}. ${escapeHtml(row.actionType)} / ${escapeHtml(row.status)} — ${escapeHtml(row.reason || row.message || '')}
`).join('')}`; } function installStyles() { if (document.getElementById('mc-v8-style')) return; const style = document.createElement('style'); style.id = 'mc-v8-style'; style.textContent = ` [data-role="v8-root"]{border:1px solid #111;background:#fff;margin:8px 0;padding:8px;font-family:Arial,sans-serif;color:#111} .mc-v8-head{display:flex;justify-content:space-between;gap:8px;align-items:flex-start;border-bottom:1px solid #ddd;padding-bottom:6px;margin-bottom:6px} .mc-v8-title{font-weight:700;font-size:13px}.mc-v8-author{font-size:11px;color:#555;text-align:right} .mc-v8-modes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;margin-bottom:8px} .mc-v8-modes button{border:1px solid #111;background:#fff;color:#111;padding:6px 4px;font-size:11px;font-weight:700;cursor:pointer} .mc-v8-modes button.is-active{background:#111;color:#fff} .mc-v8-panel{display:grid;gap:6px}.mc-v8-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px} .mc-v8-panel label{display:grid;gap:2px;font-size:11px}.mc-v8-panel input,.mc-v8-panel select,.mc-v8-panel textarea{width:100%;box-sizing:border-box;border:1px solid #aaa;padding:4px;font-size:12px;background:#fff;color:#111} .mc-v8-actions{display:flex;gap:6px;flex-wrap:wrap}.mc-v8-actions button{border:1px solid #111;background:#111;color:#fff;padding:6px 8px;font-size:11px;cursor:pointer}.mc-v8-actions button.secondary{background:#fff;color:#111} .mc-v8-result{border:1px solid #ddd;background:#fafafa;min-height:42px;max-height:170px;overflow:auto;padding:6px;font-size:11px;line-height:1.35} .mc-v8-create-preview{border:1px dashed #777;background:#fff;padding:6px;font-size:11px;max-height:120px;overflow:auto} .mc-v8-preview-update{outline:2px solid #333}.mc-v8-preview-delete{outline:2px solid #900}.mc-v8-preview-badge{display:inline-block;border:1px solid #111;background:#fff;color:#111;font-size:10px;padding:1px 3px;margin-right:3px} details.mc-v8-advanced{border-top:1px solid #ddd;padding-top:6px}details.mc-v8-advanced summary{cursor:pointer;font-size:11px;font-weight:700} @media(max-width:520px){.mc-v8-head{display:block}.mc-v8-grid,.mc-v8-modes{grid-template-columns:1fr}.mc-v8-author{text-align:left;margin-top:4px}} `; document.head.appendChild(style); } function installUi(api) { const root = document.querySelector('#mc-root'); if (!root || root.querySelector('[data-role="v8-root"]')) return Boolean(root); installStyles(); const oldHuman = root.querySelector('[data-role="hf-root"]'); if (oldHuman) oldHuman.hidden = true; const shell = document.createElement('section'); shell.setAttribute('data-role', 'v8-root'); shell.innerHTML = `
Matrix Cleaner v8
Сценарий → значения → preview → apply
Автор: Артём Шаповалов / ShapArt
Preview ещё не запускался.
Отчёты / экспорт / debug / raw JSON
`; root.prepend(shell); const dict = collectDictionaries(); fillDatalist('v8-counterparties', dict.counterparties); fillDatalist('v8-users', dict.users || [].concat(dict.signers, dict.approvers, dict.performers, dict.specialExperts)); fillDatalist('v8-doc-types', dict.docTypes); fillDatalist('v8-legal', dict.legalEntities); const tabs = Array.from(shell.querySelectorAll('[data-v8-tab]')); tabs.forEach(tab => tab.addEventListener('click', () => { const id = tab.getAttribute('data-v8-tab'); tabs.forEach(item => item.classList.toggle('is-active', item === tab)); shell.querySelectorAll('[data-v8-panel]').forEach(panel => { panel.hidden = panel.getAttribute('data-v8-panel') !== id; }); })); shell.querySelector('[data-role="v8-preview"]').addEventListener('click', async () => renderReportBox(shell, await api.preview([buildOperationFromUi(shell)]))); shell.querySelector('[data-role="v8-apply"]').addEventListener('click', async () => renderReportBox(shell, await api.apply(state.lastPlanId))); shell.querySelector('[data-role="v8-clear"]').addEventListener('click', () => api.clearPreview()); shell.querySelector('[data-role="v8-doctor-run"]').addEventListener('click', () => { const result = api.diagnoseCurrentCard({ text: shell.querySelector('[data-role="v8-doctor-text"]').value }); shell.querySelector('[data-role="v8-doctor-result"]').textContent = `pass=${result.summary.pass}, warn=${result.summary.warn}, fail=${result.summary.fail}. ${result.recommendation}`; }); shell.querySelector('[data-role="v8-search-run"]').addEventListener('click', async () => { const result = await api.searchAcrossMatrices(shell.querySelector('[data-role="v8-search-query"]').value, { mode: shell.querySelector('[data-role="v8-search-type"]').value, matchMode: shell.querySelector('[data-role="v8-search-match"]').value, }); shell.querySelector('[data-role="v8-search-result"]').textContent = `Просканировано ${result.progress.scanned}/${result.progress.total}; найдено ${result.total}; ошибок fetch ${result.failures.length}.`; }); shell.querySelector('[data-role="v8-search-stop"]').addEventListener('click', () => { state.searchCancelled = true; }); let lastParsed = null; shell.querySelector('[data-role="v8-request-parse"]').addEventListener('click', () => { lastParsed = api.parseRequestText(shell.querySelector('[data-role="v8-request-text"]').value); shell.querySelector('[data-role="v8-request-result"]').textContent = `${lastParsed.caseType}; confidence=${Math.round(lastParsed.confidence * 100)}%; actions=${lastParsed.proposedOperations.length}; ${lastParsed.recommendation}`; }); shell.querySelector('[data-role="v8-request-preview"]').addEventListener('click', async () => { if (!lastParsed) lastParsed = api.parseRequestText(shell.querySelector('[data-role="v8-request-text"]').value); renderReportBox(shell, await api.preview(lastParsed.proposedOperations || [])); }); ['json', 'csv', 'html'].forEach(format => { shell.querySelector(`[data-role="v8-export-${format}"]`).addEventListener('click', () => { shell.querySelector('[data-role="v8-advanced-result"]').textContent = api.exportReport(format).slice(0, 5000); }); }); shell.querySelector('[data-role="v8-test-all"]').addEventListener('click', async () => { const result = await api.runSyntheticContour({ mode: 'preview_only' }); shell.querySelector('[data-role="v8-advanced-result"]').textContent = `Тест всего: OK=${result.ok}, FAIL=${result.fail} из ${result.total}.`; }); state.installedUi = true; return true; } function installApi(api) { if (!api || state.installedApi) return false; original.previewRuleBatch = api.previewRuleBatch ? api.previewRuleBatch.bind(api) : null; original.runRuleBatch = api.runRuleBatch ? api.runRuleBatch.bind(api) : null; original.getLastReport = api.getLastReport ? api.getLastReport.bind(api) : null; original.getLastApplySnapshot = api.getLastApplySnapshot ? api.getLastApplySnapshot.bind(api) : null; original.clearPreview = api.clearPreview ? api.clearPreview.bind(api) : null; original.getMatrixCatalog = api.getMatrixCatalog ? api.getMatrixCatalog.bind(api) : null; const oldRelease = api.getReleaseInfo ? api.getReleaseInfo.bind(api) : null; api.getReleaseInfo = () => ({ version: VERSION, channel: 'production', build: 'operator-rebuild-v8', generatedAt: new Date().toISOString(), previous: oldRelease ? oldRelease() : null, modules: ['operator-ui-v8', 'matrix-adapter', 'honest-preview-plan', 'native-model-apply', 'signer-4-row-preset', 'patchers', 'catalog-fetch-search', 'route-doctor-v8', 'request-parser-v8', 'synthetic-contour'], }); api.preview = preview; api.apply = apply; api.revertLastApply = revertLastApply; api.validateNativeSaveReadiness = validateNativeSaveReadiness; api.highlightRows = highlightChangedRows; api.clearPreview = () => { clearV8Preview(); if (original.clearPreview) original.clearPreview(); state.lastPlanId = ''; }; api.getDictionaries = collectDictionaries; api.getHumanDictionaries = collectDictionaries; api.getLastReport = () => state.lastReport.length ? state.lastReport.slice() : (original.getLastReport ? original.getLastReport() : []); api.getLastApplySnapshot = () => state.lastApplySnapshot || (original.getLastApplySnapshot ? original.getLastApplySnapshot() : null); api.getReportBuckets = () => splitReportBuckets(api.getLastReport()); api.copySkippedToClipboard = () => copyBucket('skipped'); api.copyAmbiguousToClipboard = () => copyBucket('ambiguous'); api.copyErrorsToClipboard = () => copyBucket('errors'); api.getLastPreviewPlan = () => state.plans.get(state.lastPlanId) || null; api.searchAcrossMatrices = searchAcrossMatrices; api.cancelMatrixSearch = () => { state.searchCancelled = true; }; api.diagnoseCurrentCard = diagnoseCurrentCard; api.runChecklistEngine = runChecklistEngine; api.parseRequestText = parseRequestText; api.parseFreeformRequestText = parseRequestText; api.exportReport = exportReport; api.runSyntheticContour = runSyntheticContour; api.runAllHumanTests = runSyntheticContour; api.previewRuleBatch = async (operations, opts) => { const ops = operations || []; if (ops.length && ops.every(op => !SUPPORTED_V8.has(normalizeOperation(op).type)) && original.previewRuleBatch) { return original.previewRuleBatch(ops, opts || {}); } const result = await preview(operations, opts || {}); const report = result.report.slice(); report.planId = result.planId; return report; }; api.runRuleBatch = async (operationsOrPlanId, opts) => { if (typeof operationsOrPlanId === 'string') { return (await apply(operationsOrPlanId, opts || {})).report; } const ops = operationsOrPlanId || []; if (ops.length && ops.every(op => !SUPPORTED_V8.has(normalizeOperation(op).type)) && original.runRuleBatch) { return original.runRuleBatch(ops, opts || {}); } const result = await preview(operationsOrPlanId || [], opts || {}); return (await apply(result.planId, opts || {})).report; }; host.MatrixCleaner = api; state.installedApi = true; return true; } function install() { const api = getApi(); if (!api) return false; installApi(api); installUi(api); return true; } if (install()) return; const timer = setInterval(() => { if (install()) clearInterval(timer); }, 200); setTimeout(() => clearInterval(timer), 30000); })(); /* ===== OpenText Toolkit human-first runtime (generated) ===== */ (() => { 'use strict'; const host = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const FLAG = '__OPENTEXT_TOOLKIT_RUNTIME__'; if (host[FLAG]) return; host[FLAG] = true; const REQUIRED_AFFILIATION = 'Группа Черкизово'; const VERSION = '10.45.0-toolkit'; const DOC_GROUP_A = [ 'Основной договор', 'Перемена лица в обязательстве', 'ДС на пролонгацию', ]; const DOC_GROUP_B = [ 'ДС', 'Спецификация', 'Спецификация по качеству', 'Соглашение о бонусах', 'Перемена лица в обязательстве', 'Соглашение о зачете', 'Соглашение по ЭДО', 'ДС к спецификации', 'Заверение об обстоятельствах', 'Соглашение о расторжении', 'ДС на пролонгацию', 'Соглашение о штрафах', 'Уведомление о факторинге', ]; const DEVELOPMENT_CATEGORIES = ['СМР', 'ПИР', 'Оборудование и запчасти']; // Real ЮЛ → внутренний ID (из корпуса заявок/матриц). Используется для подсказки ID в пикере. const LEGAL_ENTITY_IDS = { 'КУРИНОЕ ЦАРСТВО АО': '37922', 'ЧЕРКИЗОВО-РАСТЕНИЕВОДСТВО ООО': '33703', 'ТАМБОВСКАЯ ИНДЕЙКА ООО': '63281', 'ЧЕРКИЗОВО-СВИНОВОДСТВО ООО': '85958', 'ПКХП ОАО': '32131', 'ЧЕРКИЗОВО-КАШИРА АО': '38698', 'ЧЕРКИЗОВО-ОЦО ООО': '86121', 'ЧЕРКИЗОВО-МАСЛА ООО': '240646', 'ЧЕРКИЗОВО-МП ООО': '241580', 'ЧЕРКИЗОВО ТЭК ООО': '282830', 'ЧМПЗ АО': '32275', 'ТД ЧЕРКИЗОВО ООО': '64001', 'КРАСНОБОР АО': '205138', 'КОМПАС ФУДС ООО': '275637', 'ПИТ-ПРОДУКТ ООО': '26430', 'ЧЕРКИЗОВО-МАСЛА-ПЕНЗА ООО': '327076', 'ГРУППА ЧЕРКИЗОВО ПАО': '32188', 'ЧЕРКИЗОВО-ИНФОТЕХ ООО': '315355', 'ВАСИЛЬЕВСКАЯ ПТИЦЕФАБРИКА АО': '63557', 'СГЦ ВИШНЕВСКИЙ ООО': '315996', 'ТУРБАСЛИНСКИЕ БРОЙЛЕРЫ АО': '242138', 'ПЕТЕЛИНСКАЯ ПТИЦЕФАБРИКА АО': '63283', 'УРАЛЬСКАЯ МЯСНАЯ КОМПАНИЯ ООО': '318296', 'УРАЛБРОЙЛЕР АО': '245999', 'КУРСКАЯ ПТИЦЕФАБРИКА АО': '35266', 'НИЦ ЧЕРКИЗОВО ООО': '90145', 'АПК МИХАЙЛОВСКИЙ ООО': '33723', 'ВЕНТА-ОЙЛ ООО': '342244', 'ЗЕМЕЛЬНАЯ КОМПАНИЯ ЧЕРКИЗОВО ООО': '79676', }; // Сегментные пресеты ЮЛ — частые группы, которые оператор добавляет вместе. const LEGAL_ENTITY_BUNDLES = [ { id: 'poultry', label: 'Птицеводство', entities: ['КУРИНОЕ ЦАРСТВО АО', 'ВАСИЛЬЕВСКАЯ ПТИЦЕФАБРИКА АО', 'ПЕТЕЛИНСКАЯ ПТИЦЕФАБРИКА АО', 'ТУРБАСЛИНСКИЕ БРОЙЛЕРЫ АО', 'УРАЛБРОЙЛЕР АО', 'КУРСКАЯ ПТИЦЕФАБРИКА АО', 'ТАМБОВСКАЯ ИНДЕЙКА ООО'] }, { id: 'pork', label: 'Свиноводство', entities: ['ЧЕРКИЗОВО-СВИНОВОДСТВО ООО', 'СГЦ ВИШНЕВСКИЙ ООО'] }, { id: 'oils', label: 'Масла', entities: ['ЧЕРКИЗОВО-МАСЛА ООО', 'ЧЕРКИЗОВО-МАСЛА-ПЕНЗА ООО', 'ВЕНТА-ОЙЛ ООО'] }, { id: 'meat', label: 'Мясопереработка', entities: ['ЧМПЗ АО', 'ЧЕРКИЗОВО-МП ООО', 'ПКХП ОАО', 'КРАСНОБОР АО', 'ПИТ-ПРОДУКТ ООО', 'КОМПАС ФУДС ООО'] }, { id: 'crop', label: 'Растениеводство', entities: ['ЧЕРКИЗОВО-РАСТЕНИЕВОДСТВО ООО', 'АПК МИХАЙЛОВСКИЙ ООО', 'ЗЕМЕЛЬНАЯ КОМПАНИЯ ЧЕРКИЗОВО ООО'] }, { id: 'service', label: 'Сервис / ОЦО', entities: ['ЧЕРКИЗОВО-ОЦО ООО', 'ЧЕРКИЗОВО ТЭК ООО', 'ЧЕРКИЗОВО-ИНФОТЕХ ООО', 'НИЦ ЧЕРКИЗОВО ООО'] }, ]; function legalEntityId(name) { const key = compact(name).toUpperCase(); if (LEGAL_ENTITY_IDS[key]) return LEGAL_ENTITY_IDS[key]; const found = Object.keys(LEGAL_ENTITY_IDS).find(entry => normalize(entry) === normalize(name)); return found ? LEGAL_ENTITY_IDS[found] : ''; } const CONDITIONS_STANDARD = ['Тип = Расходная, ВН = Нет', 'Тип = Иное, ВН = Нет']; // Категории бывают доходные и расходные — условия применения подбираются под тип сделки. const CONDITIONS_PRESETS = { standard: { label: 'Расходная + Иное (стандарт)', conditions: ['Тип = Расходная, ВН = Нет', 'Тип = Иное, ВН = Нет'] }, income: { label: 'Доходная', conditions: ['Тип = Доходная, ВН = Нет'] }, expense: { label: 'Только расходная', conditions: ['Тип = Расходная, ВН = Нет'] }, all: { label: 'Все типы', conditions: ['Тип = Расходная, ВН = Нет', 'Тип = Доходная, ВН = Нет', 'Тип = Иное, ВН = Нет'] }, }; function conditionsForDealType(dealType) { if (/доходн/i.test(dealType || '')) return 'income'; return 'standard'; } // ВГО (внутригрупповая, ВН = Да) перекрывает тип сделки: условия = «ВН = Да». const CONDITIONS_INTERNAL = ['Тип = Все, ВН = Да']; function effectiveConditions(presetKey, internal) { if (internal) return CONDITIONS_INTERNAL.slice(); return (CONDITIONS_PRESETS[presetKey] || CONDITIONS_PRESETS.standard).conditions.slice(); } const COMPANY_ALIASES = [ { aliases: ['черкизово-масла', 'масла'], target: 'ЧЕРКИЗОВО-МАСЛА ООО' }, { aliases: ['черкизово-свиноводство', 'свиноводство'], target: 'ЧЕРКИЗОВО-СВИНОВОДСТВО ООО' }, { aliases: ['куриное царство'], target: 'КУРИНОЕ ЦАРСТВО АО' }, { aliases: ['пкхп', 'пензенский комбинат хлебопродуктов'], target: 'ПКХП ОАО' }, { aliases: ['тамбовская индейка'], target: 'ТАМБОВСКАЯ ИНДЕЙКА ООО' }, { aliases: ['сгц вишневский'], target: 'СГЦ ВИШНЕВСКИЙ ООО' }, { aliases: ['вента-ойл', 'вента ойл'], target: 'ВЕНТА-ОЙЛ ООО' }, { aliases: ['уральская мясная компания'], target: 'УРАЛЬСКАЯ МЯСНАЯ КОМПАНИЯ ООО' }, { aliases: ['уралбройлер', 'урал бройлер'], target: 'УРАЛБРОЙЛЕР АО' }, { aliases: ['турбаслинские бройлеры'], target: 'ТУРБАСЛИНСКИЕ БРОЙЛЕРЫ АО' }, { aliases: ['гк здоровая ферма'], target: 'ГК ЗДОРОВАЯ ФЕРМА ООО' }, { aliases: ['здоровая ферма'], target: 'ТД ЗДОРОВАЯ ФЕРМА ООО' }, { aliases: ['черкизово-растениеводство', 'растениеводство'], target: 'ЧЕРКИЗОВО-РАСТЕНИЕВОДСТВО ООО' }, { aliases: ['черкизово-мп'], target: 'ЧЕРКИЗОВО-МП ООО' }, { aliases: ['чмпз'], target: 'ЧМПЗ АО' }, { aliases: ['васильевская пф', 'васильевская птицефабрика'], target: 'ВАСИЛЬЕВСКАЯ ПТИЦЕФАБРИКА АО' }, ]; const REQUEST_CLASSES = [ { id: 'replace_people', label: 'Замена людей', score: 0.72, tests: [/замен|поменя|вместо|уволен|заблок|делегирован/i, /подписант|согласующ|спец.?эксперт|руководител|исполнител|пользовател/i] }, { id: 'add_signer_forms', label: 'Добавить / изменить подписантов', score: 0.7, tests: [/подписант|подписание/i, /лимит|сумм|диапазон|до\s+\d|от\s+\d/i] }, { id: 'add_doc_type', label: 'Типы документов', score: 0.68, tests: [/тип документ|изменение карточки|дс|спецификац/i, /добав|замен|удал|отсутств|найти/i] }, { id: 'add_legal_entity', label: 'Добавить ЮЛ / ОП', score: 0.66, tests: [/добав|включ|расшир/i, /юр.?лиц|юл|оп|площадк|филиал|компан/i] }, { id: 'create_category', label: 'Создать категорию / маршрут', score: 0.64, tests: [/созда|нов/i, /категор|маршрут|шаблон|прочие уровни/i] }, { id: 'route_diagnostics', label: 'Маршрут не формируется / диагностика карточки', score: 0.74, tests: [/маршрут|лист согласован|карточк|робот|стандартн|красн/i, /не форм|не стро|ошиб|не тот|не видит|не проходит|отклон/i] }, { id: 'constructor_issue', label: 'Конструктор / вложения', score: 0.62, tests: [/конструктор|вложен|протокол разноглас|передан/i, /не передан|не там|некоррект|не видит|ошиб/i] }, ]; const state = { original: {}, installedApi: false, installedUi: false, lastPreview: null, lastOperation: null, lastRequestParse: null, svod: null, lastReconcileOps: null, lastUserMessage: '', capturedErrors: [], logs: [], }; // Ранний сбор JS-ошибок страницы для самодиагностики (чтобы я видел реальные ошибки с боя). try { host.addEventListener('error', event => { if (state.capturedErrors.length >= 60) return; state.capturedErrors.push({ at: new Date().toISOString(), kind: 'error', msg: String((event && (event.message || (event.error && event.error.message))) || event), where: `${(event && event.filename) || ''}:${(event && event.lineno) || ''}` }); }); host.addEventListener('unhandledrejection', event => { if (state.capturedErrors.length >= 60) return; state.capturedErrors.push({ at: new Date().toISOString(), kind: 'promise', msg: String((event && event.reason && (event.reason.message || event.reason)) || event) }); }); } catch (_) { /* ignore */ } function api() { return host.__OT_MATRIX_CLEANER__ || window.__OT_MATRIX_CLEANER__ || null; } function matrix() { return host.sc_ApprovalMatrix || window.sc_ApprovalMatrix || null; } function normalize(value) { return String(value == null ? '' : value) .replace(/[«»"]/g, '') .replace(/[\u00A0\u2007]/g, ' ') .replace(/ё/g, 'е') .replace(/Ё/g, 'Е') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function matchKey(value) { return normalize(value) .replace(/[a]/g, 'а') .replace(/[e]/g, 'е') .replace(/[k]/g, 'к') .replace(/[m]/g, 'м') .replace(/[o]/g, 'о') .replace(/[p]/g, 'р') .replace(/[c]/g, 'с') .replace(/[t]/g, 'т') .replace(/[y]/g, 'у') .replace(/[x]/g, 'х') .replace(/[b]/g, 'в') .replace(/[h]/g, 'н') .replace(/&/g, ' ') .replace(/[‐‑‒–—-]+/g, ' ') .replace(/[.,:;()[\]{}"«»]+/g, ' ') .replace(/\b(?:дирекция|функция|категория|направление)\b/g, ' ') .replace(/\b(?:и|and)\b/g, ' ') .replace(/\s+/g, ' ') .trim(); } function textMatches(haystack, needle) { const got = matchKey(haystack); const want = matchKey(needle); if (!want) return true; if (!got) return false; if (got === want || got.includes(want) || want.includes(got)) return true; const gotTokens = got.split(/\s+/).filter(Boolean); const wantTokens = want.split(/\s+/).filter(Boolean); return wantTokens.length > 0 && wantTokens.every(token => gotTokens.some(value => value === token || value.startsWith(token) || token.startsWith(value))); } function compact(value) { return String(value == null ? '' : value).replace(/\s+/g, ' ').trim(); } function escapeHtml(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function unique(values) { const seen = new Set(); const out = []; (values || []).forEach(value => { const text = compact(value); const key = normalize(text); if (!key || seen.has(key)) return; seen.add(key); out.push(text); }); return out; } function parseList(value) { if (Array.isArray(value)) return unique(value); return unique(String(value || '').split(/[;,\n|]+/)); } function stripId(value) { return compact(value).replace(/\s*\(\d+\)\s*$/, ''); } function valueAsList(value) { if (Array.isArray(value)) return value.map(String).filter(Boolean); if (value && typeof value === 'object' && Array.isArray(value.performerList)) return value.performerList.map(String); if (value == null || value === '') return []; return [String(value)]; } function extractLinks(text) { return unique(Array.from(String(text || '').matchAll(/https?:\/\/[^\s"'<>]+/gi)).map(match => match[0].replace(/[),.;]+$/, ''))); } // Суммы заявки. ВАЖНО: возвращаем ТОЛЬКО валидные числовые значения (через moneyToNumber), иначе коды // проектов УИП («ИП-4832, 3.030.2.21…») и даты («11.10.2024») утекали как «суммы» и попадали в лимиты // строк (range.to = мусор). moneyToNumber отбрасывает коды/даты (нерегулярные группы) и нормализует // сгруппированные тысячи. Голое число без единицы/контекста принимаем, только если оно «круглое» (кратно // 1000) — иначе это, скорее, код/номер, а не сумма. function extractAmounts(text) { const out = []; for (const m of String(text || '').matchAll(/(?:(до|от|лимит\w*|сумм\w*|порог\w*|руб|₽)\s*)?(\d[\d\s.,]*\d|\d)\s*(млрд|млн\.?|миллион\w*|мил\.?|мл\.?|тыс\.?|тысяч\w*|руб\w*|₽|р\.)?/gi)) { const token = compact(m[2]); if (/[.,]\d+[.,]\d+[.,]/.test(token)) continue; // ≥3 разделителя = код проекта/перечень, не сумма if (/^\d{1,2}[.,]\d{1,2}[.,]\d{2,4}$/.test(token)) continue; // дата ДД.ММ.ГГГГ const value = moneyToNumber(token, m[3]); if (value == null || value < 1000) continue; if (!m[1] && !m[3] && value % 1000 !== 0) continue; // без единицы/контекста — только круглые суммы out.push(String(value)); } return unique(out); } // Срезаем роль-ярлык перед именем («Подписант Павел Филатов» → «Павел Филатов»), но НЕ форму «Роль: Имя» // (там двоеточие — её разбирает extractRoleUsersFromText). Без \b: он ASCII и перед кириллицей не срабатывает. // ВНИМАНИЕ: \w в JS — ASCII даже с флагом u, кириллицу не матчит, поэтому суффикс роли пишем [а-яё]*. // Срезаем не только роль-ярлыки, но и ярлыки полей («Функция Логистика», «Категория Транспорт» — не ФИО). const ROLE_LABEL_STEMS = 'подписант|подписани|руководител|согласующ|спецэксперт|эксперт|исполнител|директор|функци|категори|дирекци|направлени|подразделени|должност'; // Глаголы-действия перед именем тоже срезаем («Заменить Евгения Касаткина» → «Евгения Касаткина»). // Формы инфинитива/императива, чтобы не задеть фамилии (Удалов≠удалить, удали≠удалов). const ACTION_VERB_STEMS = 'заменит|замени|исключ|удалит|удали|поменя|снят|снима|убрат|постав|добав|назнач|перевест|прошу|просьб|просим|нужно|необходимо'; function stripRoleLabels(text) { // Цикл: сняв «поставить», «Прошу» становится перед именем — нужен повторный проход до стабилизации. const re = new RegExp(`(?:${ROLE_LABEL_STEMS}|${ACTION_VERB_STEMS})[а-яё]*\\s+(?=[А-ЯЁ][а-яё])`, 'giu'); let s = String(text || ''); let prev; do { prev = s; s = s.replace(re, ''); } while (s !== prev && s.length < prev.length); return s; } // Имя-кандидат бракуем, если хоть одно слово в нём — роль/поле-ярлык («Ефремов Руководитель», «Функция Логистика»). function hasRoleWord(name) { return new RegExp(`(?:^|\\s)(?:${ROLE_LABEL_STEMS})[а-яё]*(?:\\s|$)`, 'iu').test(' ' + String(name || '') + ' '); } function extractUsersFromText(text) { const out = []; const source = stripRoleLabels(text); // [ \t ] вместо \s — НЕ склеивать имена через перенос строки («Петрович\nСтарый» в карточке с полями). for (const match of source.matchAll(/[А-ЯЁ][а-яё-]+[ \t ]+[А-ЯЁ][а-яё-]+(?:[ \t ]+[А-ЯЁ][а-яё-]+)?/gu)) { const value = compact(match[0]); if (!/Группа Черкизово|Куриное Царство|Основной договор/i.test(value)) out.push(value); } for (const match of source.matchAll(/[А-ЯЁ][а-яё-]+[ \t ]+[А-ЯЁ]\.[А-ЯЁ]\./gu)) out.push(compact(match[0])); return unique(out).filter(name => !hasRoleWord(name)).slice(0, 20); } function userSearchCandidates(input, dictionaries, limit = 8) { const dict = dictionaries || DictionaryBuilder.build(); const query = normalize(input); if (!query) return []; const tokens = query.split(/\s+/).filter(Boolean); const pool = (dict.users || []).slice(); // Allow picking a brand-new person who is not yet in this matrix by searching the // full OpenText directory. Opt-in so unit tests with a custom dict stay deterministic. if (dict.directoryEnabled) { const seen = new Set(pool.map(user => normalize(user.id || user.fio))); for (const item of collectOpenTextModelUsers()) { const title = item.title || ''; if (!/[А-ЯЁ][а-яё]/.test(title)) continue; const nt = normalize(title); if (!tokens.every(token => nt.includes(token))) continue; const key = normalize(item.id || title); if (seen.has(key)) continue; seen.add(key); pool.push(userObject(item, 'directory', 'opentext_model')); if (pool.length > 600) break; } } return pool.map(user => { if (user.technical || isLikelyTechnicalUser(user)) return null; const fio = normalize(user.fio || ''); const display = normalize(user.display || ''); const login = normalize(user.login || ''); if (!tokens.every(token => userTokenMatches(token, user, fio, login))) return null; const surname = fio.split(/\s+/)[0] || ''; let score = 0; if (fio === query || display === query) score += 120; if (surname === query) score += 110; if (surname.startsWith(query)) score += 95; if (fio.startsWith(query)) score += 80; if (fio.includes(` ${query}`)) score += 45; if (display.includes(query)) score += 30; if (login && login.includes(query)) score += 10; if (user.position) score += 3; if (user.unresolved) score -= 200; return { user, score }; }).filter(Boolean).sort((a, b) => b.score - a.score || String(a.user.fio).localeCompare(String(b.user.fio), 'ru')).slice(0, limit).map(item => item.user); } function extractKnownUsersFromText(text, dictionaries) { const source = normalize(text); const dict = dictionaries || DictionaryBuilder.build(); return unique((dict.users || []).filter(user => { const fio = normalize(user.fio || ''); if (!fio || user.unresolved) return false; const parts = fio.split(/\s+/).filter(Boolean); const surname = parts[0] || ''; if (surname.length < 3 || !source.includes(surname)) return false; if (source.includes(fio)) return true; const initials = parts.slice(1).map(part => part[0]).filter(Boolean).join(''); return Boolean(initials && source.includes(`${surname} ${initials[0] || ''}`)); }).map(user => user.fio)).slice(0, 30); } function extractRoleUsersFromText(text, rolePattern, dictionaries) { const source = String(text || ''); const dict = dictionaries || DictionaryBuilder.build(); const matches = []; source.split(/\n|;/).forEach(line => { if (!rolePattern.test(line)) return; matches.push(...extractKnownUsersFromText(line, dict), ...extractUsersFromText(line)); }); const inline = source.match(new RegExp(`${rolePattern.source}[^\\n::-]*[::-]?([^\\n]{0,180})`, 'i')); if (inline && inline[1]) matches.push(...extractKnownUsersFromText(inline[1], dict), ...extractUsersFromText(inline[1])); return unique(matches).slice(0, 12); } function extractLabeledValue(text, labels) { const source = String(text || ''); const labelPattern = Array.isArray(labels) ? labels.join('|') : String(labels || ''); const match = source.match(new RegExp(`(?:${labelPattern})\\s*[::-]\\s*([^\\n;,.]{2,100})`, 'i')); return match ? compact(match[1]) : ''; } const CARD_FIELD_LABEL_RE = /^(?:дирекция|функция|категория|категория затрат|тип документа|тип сделки|тип сделки по доходности|сумма|сумма документа|сумма документа в рублях|сумма документа в рублях \(включая налоги\)|сумма обязательств|лимит|лимит по договору|обособленное подразделение|площадка|оп|контрагент|наименование|статус|инн|эдо|эцп|резидент|аффилированность|дата документа|номер договора|проект|бюджетная статья|ставка|штрихкод|курс|срок|условия|стандартная форма)$/i; function isCardFieldLabel(value) { const key = normalize(value); return CARD_FIELD_LABEL_RE.test(key) || /^(?:номер договор|сумм[а-яё]*|лимит[а-яё]*|итого обязательств|дата|тип(?:\s|$)|вид(?:\s|$)|бюджет|стать[яи]|бддс|проект|ставк[а-яё]*|штрихкод|статус|срок|услови[а-яё]*|стандартн[а-яё]*|автомат|требует|сопровод|действующ|резидент|аффилирован|доверенность|наименование|орг\.?\s*форма|инн|эдо|эцп)(?:\s|$)/i.test(key); } function extractAdjacentFieldValue(text, labels) { const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n').map(compact).filter(Boolean); const plainLabels = (Array.isArray(labels) ? labels : [labels]).map(label => String(label || '').replace(/\\/g, '')).filter(Boolean); const wanted = plainLabels.map(label => matchKey(label)).filter(Boolean); if (!wanted.length) return ''; for (let i = 0; i < lines.length; i += 1) { const lineKey = matchKey(lines[i]); for (const label of plainLabels) { const sameLine = lines[i].match(new RegExp(`^(?:${label})\\s+(.{2,100})$`, 'i')); if (sameLine && sameLine[1] && !isCardFieldLabel(sameLine[1])) return compact(sameLine[1]); } if (!wanted.some(label => lineKey === label || lineKey === `${label}:`)) continue; const candidate = lines[i + 1] || ''; if (!candidate || isCardFieldLabel(candidate)) return ''; if (/^(0|1|да|нет)$/i.test(candidate) && !/тип|сумм|лимит/i.test(lines[i])) return ''; return candidate; } return ''; } // ДФК-значение: «Метка: X» ИЛИ прозой «по дирекции X / функции Y / категории Z» (без двоеточия). // [^\S\n\r] (не перенос строки), чтобы в дампе карточки «Дирекция\nФункция» НЕ дало значение «Функция». function extractDfkValue(text, labels, proseStems) { const labeled = extractLabeledValue(text, labels); if (labeled) return labeled; const adjacent = extractAdjacentFieldValue(text, labels); if (adjacent) return adjacent; const m = String(text || '').match(new RegExp(`(?:по[^\\S\\n\\r]+)?(?:${proseStems})[а-яё]*[^\\S\\n\\r]+([А-ЯЁ][А-Яа-яё0-9./-]+(?:[^\\S\\n\\r]+[А-ЯЁ]?[а-яё0-9./-]+){0,2})`, 'i')); const val = m ? compact(m[1]) : ''; // значение не должно быть само меткой-полем (Функция/Категория/Тип/Сумма…) return /^(функци|категори|дирекци|направлени|тип|сумм|номер|дата|статус|вид|лимит|бюджет|ставк)/i.test(normalize(val)) ? '' : val; } function extractSignerRanges(text, dictionaries) { const source = String(text || ''); const dict = dictionaries || DictionaryBuilder.build(); // Множитель захватываем рядом с числом и сразу масштабируем («до 30 млн» → 30000000), иначе тот же // диапазон, прочитанный extractSignerLimits в рублях, не схлопывался при дедупе (выходили дубли 30 и 30000000). const U = '(млрд|млн\\.?|миллион\\w*|мил\\.?|мл\\.?|тыс\\.?|тысяч\\w*)?'; const numP = '(\\d[\\d\\s.,]*\\d|\\d)'; const re = new RegExp(`(?:от\\s*)?${numP}\\s*${U}\\s*(?:→|->|-|—|до)\\s*${numP}\\s*${U}|до\\s*${numP}\\s*${U}`, 'giu'); const ranges = []; source.split(/\n|;/).forEach(line => { const normalized = normalize(line); if (!/(подпис|лимит|сумм|диапазон|до\s*\d|от\s*\d)/i.test(normalized)) return; const users = unique([].concat(extractKnownUsersFromText(line, dict)).concat(extractUsersFromText(line))); const amountMatches = Array.from(line.matchAll(re)); if (!users.length || !amountMatches.length) return; amountMatches.slice(0, users.length).forEach((match, index) => { const isBand = match[1] != null; // «от A до B» const toRaw = isBand ? match[3] : match[5]; const toUnit = isBand ? match[4] : match[6]; const toN = moneyToNumber(toRaw, toUnit); const fromN = isBand ? moneyToNumber(match[1], match[2]) : 0; const from = fromN == null ? 0 : fromN; // Деньги правдоподобны, только если есть множитель ИЛИ значение ≥ 1000. Иначе это код/дата // («ИП-12247, 1.040.4.25.0.5» → банды вроде «24-23») — отсекаем. И отбрасываем to= 1000); if (toN == null || !plausible || toN < from) return; ranges.push({ from: String(from), to: String(toN), signer: users[index] || users[0] }); }); }); return ranges.filter(range => range.to && range.signer).slice(0, 12); } function extractLegalEntitiesFromText(text) { const source = String(text || ''); const out = []; // form-first: «ООО Имя» (имя — с ЗАГЛАВНОЙ/кавычки, иначе ловит табличный мусор «ООО рекомендован»). // Граница слева — НЕ \b (ASCII-граница не срабатывает у кириллицы), а «не-буква или начало». for (const match of source.matchAll(/(?:^|[^А-ЯЁа-яёA-Za-z])(?:ООО|АО|ОАО|ПАО|ЗАО|ТОО|НАО|АНО)\s+[«"]?[А-ЯЁ][^,;:.\n\r()]{1,79}/gu)) out.push(compact(match[0])); // form-last: «ИМЯ ООО» (КУРИНОЕ ЦАРСТВО АО, М - ТРАНС ООО). NB: завершаем НЕ \b — ASCII-граница после // кириллицы НЕ срабатывает, из-за чего regex раньше не ловил ВООБЩЕ ничего (только каталог спасал). for (const match of source.matchAll(/[«"]?[А-ЯЁ][А-ЯЁA-Z0-9«»"().\- ]{2,70}?\s(?:ООО|АО|ОАО|ПАО|ЗАО|ТОО|НАО|АНО)(?![А-ЯЁа-яёA-Za-z])/gu)) out.push(compact(match[0])); for (const match of source.matchAll(/[«"]?(?:Черкизово|Куриное Царство|ПКХП|Тамбовская Индейка)[^,;:.\n\r()]{0,80}/giu)) out.push(compact(match[0])); // cross-check against the known catalog + aliases const norm = normalize(source); Object.keys(LEGAL_ENTITY_IDS).forEach(name => { if (norm.includes(normalize(name))) out.push(name); }); COMPANY_ALIASES.forEach(item => { if (item.aliases.some(alias => norm.includes(normalize(alias)))) out.push(item.target); }); // drop noisy greedy matches (real ЮЛ names don't contain these words or join two entities with "и") const clean = out.map(name => compact(name) // обрезаем хвост после правовой формы, если перед ней есть слово («ЧЕРКИЗОВО-МАСЛА ООО Доверенность…» → «… ООО») .replace(/^(.*\S\s+(?:ООО|АО|ОАО|ПАО|ЗАО|ТОО))\b.*$/i, '$1') .replace(/\s+(для|на|по|в|при|до|от|доверенность|дов\.|с\s+лимит[а-яё]*)\s.*$/i, '')).filter(name => { const n = ` ${normalize(name)} `; if (n.trim().length < 3 || n.trim().length > 45) return false; // длинное = почти всегда жадный мусор if (/ (для|договор[а-яё]*|основной|прошу|добав[а-яё]*|лимит[а-яё]*|сумм[а-яё]*|подпис[а-яё]*|контрагент|юрлицо|юл|вго|вместо|групп[а-яё]*|и) /.test(n)) return false; return true; }); return unique(clean).slice(0, 30); } function extractDocTypesFromText(text) { const norm = normalize(text); const found = []; DOC_GROUP_A.concat(DOC_GROUP_B, ['Изменение карточки']).forEach(docType => { if (normalize(docType) && norm.includes(normalize(docType))) found.push(docType); }); return unique(found); } function classifyRequestText(text) { const source = String(text || ''); const hit = REQUEST_CLASSES.find(item => item.tests.every(regex => regex.test(source))); return hit || { id: 'manual_review', label: 'Нужно разобрать вручную', score: 0.35, tests: [] }; } // ===== Усиленный разбор реальных заявок (как люди реально пишут) ===== const PEOPLE_STOP = /^(?:Группа Черкизово|Куриное Царство|Основной договор|Тамбовская Индейка|Дирекция|Управлен|Спец|Тип Документа|ДС|Юр|Обособленн|Прочие|Управление|Закупки|Качество|Здания|Сооружения|Оборудование)/i; // Имена в реальных заявках: «Фамилия И.О.», «Фамилия И.», «Фамилия Имя» (часто в косвенном падеже). function extractPeople(text) { const src = stripRoleLabels(text); const out = []; // NB: без \b — в JS это ASCII-граница, перед кириллицей после пробела она НЕ срабатывает. for (const m of src.matchAll(/([А-ЯЁ][а-яё-]{2,})[^\S\n\r]+([А-ЯЁ]\.\s?[А-ЯЁ]\.|[А-ЯЁ]\.|[А-ЯЁ][а-яё-]{2,})/gu)) { const v = compact(m[0]); if (PEOPLE_STOP.test(v) || PEOPLE_STOP.test(m[1])) continue; out.push(v.replace(/\s+/g, ' ')); } return unique(out).filter(name => !hasRoleWord(name)).slice(0, 20); } function moneyToNumber(numStr, unit) { const raw = String(numStr || '').replace(/[^\d.,\s]/g, '').trim(); let n; // Сгруппированные тысячи: «1 500 000», «1.500.000», «1,500,000» — разделитель строго каждые 3 цифры. // Это ОТЛИЧАЕТ суммы от кодов проектов/дат («3.030.2.21», «11.10.2024») — там группы не по 3. if (/^\d{1,3}(?:[.,\s]\d{3})+$/.test(raw)) n = Number(raw.replace(/[.,\s]/g, '')); else n = Number(raw.replace(/\s/g, '').replace(/,/g, '.')); if (!Number.isFinite(n) || n === 0) return null; const u = normalize(unit || ''); if (/млрд|миллиард/.test(u)) n *= 1e9; else if (/млн|миллион|мил|мл/.test(u)) n *= 1e6; // «мл» — частое сокращение млн в заявках («100мл») else if (/тыс|тысяч/.test(u)) n *= 1e3; return Math.round(n); } // Суммы/лимиты с множителями: «3 мил», «30 млн», «до 100 000 руб». Голые числа (коды, года) отсекаем. function extractMoney(text) { const src = String(text || ''); const out = []; for (const m of src.matchAll(/(?:(до|от|лимит\w*|сумм\w*|порог\w*)\s*)?(\d[\d  .,]*\d|\d)\s*(млрд|млн\.?|миллион\w*|мил\.?|мл\.?|тыс\.?|тысяч\w*|руб\w*|₽|р\.)?/gi)) { const hasUnit = Boolean(m[3]); const hasCtx = Boolean(m[1]); if (!hasUnit && !hasCtx) continue; const value = moneyToNumber(m[2], m[3]); if (value != null && value >= 1000) out.push(value); } return unique(out.map(String)).map(Number); } // Пары «подписант → лимит»: «Комаров И.Д. до 30 млн, Измайлов Л.Г. - до 100 млн». function extractSignerLimits(text) { const out = []; String(text || '').split(/[,;\n]/).forEach(seg => { const person = extractPeople(seg)[0]; const money = extractMoney(seg); if (person && money.length) { const to = String(money[money.length - 1]); out.push({ from: '0', to, amount: to, signer: person }); } }); return out.slice(0, 12); } // Площадки/ОП: «ОП Пенза-6 и Пенза-14», «площадка ВПФ», токены вида «Пенза-6». function extractSites(text) { const src = String(text || ''); const out = []; // После «ОП/площадка/обособленное подразделение» берём ОДИН токен (или закавыченное имя), а не всё // до запятой — иначе «площадку ВПФ подписанту Иванов И.И.» захватывало «ВПФ подписанту Иванов И». for (const m of src.matchAll(/(?:ОП|обособленн[а-яё]*\s+подразделен[а-яё]*|площадк[а-яё]+)\s+([«"][^«»"\n]{2,40}["»]|[А-ЯЁA-Z][А-Яа-яёA-Za-z0-9.-]{1,30})/giu)) out.push(compact(m[1]).replace(/^[«"]|["»]$/g, '')); for (const m of src.matchAll(/[А-ЯЁ][а-яё]+-\d+/gu)) out.push(compact(m[0])); return unique(out.map(s => compact(s).replace(/\s+(?:и|по|для|на|до|от|только)\s.*$/i, ''))).filter(s => s.length >= 2 && !/^ОП$/i.test(s)).slice(0, 20); } // Замена подписанта во всех формах, как пишут люди: // «X изменить на Y», «заменить/перевести … с X на Y», «Заменить X … на Y», «вместо X … Y». function extractReplacePair(text) { const src = String(text || ''); const nameRe = '[А-ЯЁ][а-яё-]{2,}\\s+(?:[А-ЯЁ]\\.\\s?[А-ЯЁ]\\.|[А-ЯЁ]\\.|[А-ЯЁ][а-яё-]{2,})'; // БЕЗ флага 'i': имена матчим СТРОГО с заглавной. С 'i' класс [А-ЯЁ] ловил и строчные, поэтому // роль/титул-ярлыки строчными («директора завода», «подписанта», «согласующего») утекали в from/to. // Глаголы и «вместо» допускаем с заглавной в начале предложения — делаем заглавной только ПЕРВУЮ букву. const ci = w => `[${w[0].toUpperCase()}${w[0].toLowerCase()}]${w.slice(1)}`; const v1 = `(?:${ci('изменить')}|${ci('заменить')}|${ci('поменять')}|${ci('замени')}(?:ть)?)`; const v2 = `(?:${ci('изменить')}|${ci('заменить')}|${ci('перевест')}[а-яё]*|${ci('перевод')}[а-яё]*|${ci('поменять')}|${ci('сменить')})`; const v3 = `(?:${ci('заменить')}|${ci('замени')}[а-яё]*|${ci('поменять')}|${ci('сменить')})`; const v4 = `(?:${ci('поставить')}|${ci('поставь')}|${ci('назначить')}|${ci('установ')}[а-яё]*|${ci('должн')}[а-яё]*|${ci('будет')})`; const v7 = `(?:${ci('изменить')}|${ci('заменить')}|${ci('поменять')}|${ci('сменить')})`; const vmesto = ci('вместо'); const patterns = [ `(${nameRe})\\s*${v1}\\s+на\\s+(${nameRe})`, // X изменить на Y `${v2}[^\\n]{0,140}?\\sс\\s+(${nameRe})[^\\n]{0,140}?\\sна\\s+(${nameRe})`, // … (перевод) с X … на Y `${v3}\\s+(${nameRe})[^\\n]{0,140}?\\sна\\s+(${nameRe})`, // Заменить X … на Y `${vmesto}\\s+(${nameRe})[^.\\n]*?${v4}\\s+(${nameRe})`, `${vmesto}\\s+(${nameRe})[^\\n]{0,90}?\\sна\\s+(${nameRe})`, // «вместо X … заменить/поставить на Y» // «Заменить в строчке/ячейке где … X … на Y» — глагол замены и имя разнесены служебными словами. `${v3}[^\\n]{0,80}?(${nameRe})[^\\n]{0,140}?\\sна\\s+(${nameRe})`, // «X заменить на Y», где X с инициалами без финальной точки («Булычева Р.С заменить …»). Структура // «имя→глагол», поэтому не конфликтует с «Заменить X …» (p7). `([А-ЯЁ][а-яё-]{2,}\\s+[А-ЯЁ]\\.\\s?[А-ЯЁ]\\.?)\\s*${v7}\\s+на\\s+(${nameRe})`, ]; for (const p of patterns) { const m = src.match(new RegExp(p)); // без 'i' — см. выше // Страховка: даже если шаблон что-то перехватил, имя с роль-ярлыком («подписанта Лукашевич») бракуем. if (m && compact(m[1]) && compact(m[2]) && normalize(m[1]) !== normalize(m[2]) && !hasRoleWord(m[1]) && !hasRoleWord(m[2])) return { from: compact(m[1]), to: compact(m[2]) }; } return null; } // Фамилия в косвенном падеже совпадает с фамилией из матрицы по ОСНОВЕ: общий префикс ≥4 и отличие лишь // в коротком падежном окончании (≤3 с каждой стороны). «Соснину»~«Соснина», «Никифорову»~«Никифоров». function surnamesMatch(input, candidateSurname) { const a = normalize(input), b = normalize(candidateSurname); if (!a || !b) return false; if (a === b) return true; let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i += 1; return i >= 4 && (a.length - i) <= 3 && (b.length - i) <= 3; } // Артём: «легче всего искать фамилию по тем, кто УЖЕ есть в матрице». Резолвим голую фамилию в людей // матрицы (подписанты/согласующие/пользователи). Возвращаем всех кандидатов и единственного, если он один. function resolveSurnameInMatrix(surname, dictionaries) { const dict = dictionaries || DictionaryBuilder.build(); const pool = [].concat(dict.signers || [], dict.approvers || [], dict.specialExperts || [], dict.users || []); const matches = []; const seen = new Set(); pool.forEach(u => { if (!u || !u.fio || u.unresolved) return; // Порядок ФИО в OpenText бывает разный («Фамилия Имя» и «Имя Фамилия»), а отчество — отдельный токен. // Поэтому сверяем введённую фамилию с ЛЮБЫМ словом-токеном ФИО (≥3 букв). Несколько совпадений → спросим. const tokens = String(u.fio).split(/\s+/).filter(t => t.length >= 3 && /[А-Яа-яЁё]/.test(t)); if (!tokens.some(t => surnamesMatch(surname, t))) return; const key = String(u.id || u.fio); if (seen.has(key)) return; seen.add(key); matches.push(u); }); return { surname: compact(surname), matches, unique: matches.length === 1 ? matches[0] : null }; } // Самый длинный кириллический токен ≥3 букв — обычно это фамилия (инициалы «О.Е.» отбрасываются). function surnameToken(name) { return (String(name || '').split(/\s+/).filter(t => /[А-Яа-яЁё]{3,}/.test(t)).sort((a, b) => b.length - a.length)[0] || ''); } // ВНУТРЕННИЙ ID подписанта по имени, если он ОДНОЗНАЧНО есть в матрице (иначе '' — оператор выберет сам). function matrixSignerId(name, dict) { const r = resolveSurnameInMatrix(surnameToken(name), dict); return r.unique ? String(r.unique.id || '') : ''; } // Резолв ФИО → внутренний ID для apply (нарезка/сплит/диапазон «в один клик», без ручного выбора из списка): // 1) уже число; 2) точно/фаззи по полному вводу; 3) по фамилии в матрице; 4) по фамилии в справочнике — // но ТОЛЬКО если кандидат ровно один (несколько однофамильцев → '' → движок попросит выбрать). function resolveSignerId(name, dictionaries) { const dict = dictionaries || DictionaryBuilder.build(); const raw = compact(name); if (!raw) return ''; if (/^-?\d{3,}$/.test(raw)) return raw.replace(/^-/, ''); const direct = UserResolver.resolve(raw, dict); if (direct && !direct.unresolved && direct.id) return String(direct.id).replace(/^-/, ''); const sur = surnameToken(raw) || raw.split(/\s+/)[0]; if (sur && sur.length >= 4) { const inMatrix = resolveSurnameInMatrix(sur, dict); if (inMatrix.unique && inMatrix.unique.id) return String(inMatrix.unique.id).replace(/^-/, ''); const seen = new Set(); const cands = []; userSearchCandidates(sur, dict, 8).forEach(c => { if (c && c.id) { const k = String(c.id); if (!seen.has(k)) { seen.add(k); cands.push(c); } } }); if (cands.length === 1) return String(cands[0].id).replace(/^-/, ''); } return ''; } // «Заменить/поменять (подписанта) X на Y», где X и Y — ОДИНОЧНЫЕ фамилии без инициалов. Обе ищем в матрице. // Возвращаем null, если это не про подписантов (ни одна «фамилия» не похожа на человека из матрицы). function detectSingleSurnameReplace(text, dictionaries) { const src = String(text || ''); const re = /(?:замен|помен|смен|подписант|согласующ|вместо)[а-яё]*\s+(?:[а-яёa-z0-9«».,/()-]+\s+){0,5}?([А-ЯЁ][а-яё]{3,})\s+на\s+([А-ЯЁ][а-яё]{3,})/u; const m = src.match(re); if (!m) return null; const fromS = compact(m[1]), toS = compact(m[2]); if (normalize(fromS) === normalize(toS)) return null; const dict = dictionaries || DictionaryBuilder.build(); const from = resolveSurnameInMatrix(fromS, dict); const to = resolveSurnameInMatrix(toS, dict); if (!from.matches.length && !to.matches.length) return null; // не про подписантов матрицы return { from, to }; } // Структурные поля полной карточки/заявки: «Инициатор», «Старый/Новый пользователь». Это НАДЁЖНЕЕ, // чем угадывать «X на Y» из текста. Формат: «Метка: значение» (или метка и значение на соседних строках). function labeledNameField(raw, labels) { const src = String(raw || ''); const pat = labels.map(l => l.replace(/\s+/g, '\\s+')).join('|'); const m = src.match(new RegExp(`(?:${pat})\\s*(?:[::=\\-—]\\s*|\\n\\s*)([А-ЯЁ][А-Яа-яёA-Za-z.\\- ]{2,70})`, 'i')); if (!m) return ''; // обрезаем хвост, если зацепили следующую метку/служебное return compact(m[1]).replace(/\s+(старый|нов(?:ый|ого)|инициатор|должност|табельн|логин|email|почт|телефон|подразделен|отдел|групп).*$/i, ''); } function extractStructuredFields(raw) { return { initiator: labeledNameField(raw, ['инициатор', 'заявитель', 'автор заявки']), oldUser: labeledNameField(raw, ['стар(?:ый|ого) пользовател[а-яё]*', 'стар(?:ый|ого) сотрудник[а-яё]*', 'текущ(?:ий|его) пользовател[а-яё]*', 'текущ(?:ий|его) подписант[а-яё]*', 'действующ[а-яё]* подписант[а-яё]*', 'заменяем[а-яё]* сотрудник[а-яё]*', 'кого меняем']), newUser: labeledNameField(raw, ['нов(?:ый|ого) пользовател[а-яё]*', 'нов(?:ый|ого) сотрудник[а-яё]*', 'нов(?:ый|ого) подписант[а-яё]*', 'на кого менять', 'кому передать']), }; } // ===== БАЗА УТОЧНЯЮЩИХ ВОПРОСОВ ============================================================== // Где данных не хватает или неоднозначно — задаём оператору прицельный вопрос (Артём: «пусть // задаёт много вопросов, чтобы не было недосказанностей»). Каждое правило: when(ctx)→вопрос. // ctx нормализуют оба парсера (заявка ITSMIntakeEngine и карточка CardIntake). const CLARIFY_RULES = [ // — Замена подписанта — { id: 'replace_incomplete', topic: 'замена', test: c => c.replace && (!c.has(c.replace.from) || !c.has(c.replace.to)), ask: () => 'Замена распознана не до конца — уточни, КОГО меняем и НА КОГО.' }, { id: 'replace_surname_unclear', topic: 'замена', test: c => c.singleSurname && (!c.singleSurname.from.unique || !c.singleSurname.to.unique), ask: c => { const part = (label, r) => { if (!r || r.unique) return ''; if (r.matches && r.matches.length) return `${label} «${r.surname}» — в матрице несколько: ${r.matches.slice(0, 4).map(u => u.fio).join(', ')}. Какой именно?`; return `${label} «${r.surname}» в матрице не нашёл — впиши полное ФИО (или это новый человек?).`; }; return ['Замена по одной фамилии — уточни:', part('Кого меняем', c.singleSurname.from), part('На кого', c.singleSurname.to)].filter(Boolean).join(' '); } }, { id: 'replace_ambiguous', topic: 'замена', test: c => !c.replace && !c.isRemoval && c.replaceVerb && c.onName && !c.singleSurname, ask: () => 'Похоже на замену подписанта, но не уверен КОГО на КОГО (одна фамилия без инициалов / старый не назван). Уточни старого и нового — или впиши поля «Старый пользователь» / «Новый пользователь».' }, { id: 'replace_no_slice', topic: 'замена', test: c => c.replace && c.has(c.replace.from) && c.has(c.replace.to) && !c.dfk && !c.has(c.legalEntities) && c.source !== 'card', ask: () => 'Замену делаем на каком срезе — по какой ДФК/ЮЛ? (меняем того, кто там сейчас стоит).' }, // — Удаление / исключение — { id: 'remove_or_replace', topic: 'удаление', test: c => c.isRemoval && (c.onName || c.vmesto), ask: () => 'В тексте есть и удаление, и «на/вместо». Это удаление, замена или и то, и другое? Уточни.' }, { id: 'remove_no_scope', topic: 'удаление', test: c => c.isRemoval && !c.dfk && !c.has(c.legalEntities) && !c.level, ask: () => 'Откуда убираем — с какого уровня / ЮЛ / среза? Уточни, чтобы не задеть лишнее.' }, // — Добавление подписанта — { id: 'signer_no_name', topic: 'подписант', test: c => c.addsSigner && !c.has(c.signers) && !c.has(c.signerRanges) && !c.replace, ask: () => 'Кого ставим подписантом? Не вижу ФИО.' }, { id: 'signer_no_amount', topic: 'подписант', test: c => c.addsSigner && !c.has(c.signerRanges) && !c.has(c.amounts), ask: () => 'На какой диапазон сумм / лимит ставим подписанта? (от–до)' }, { id: 'signer_multi_no_ranges', topic: 'подписант', test: c => c.addsSigner && (c.signersCount || 0) >= 2 && !c.has(c.signerRanges), ask: c => `Несколько подписантов (${(c.signers || []).slice(0, 4).join(', ')}) — у каждого свой лимит? Уточни от–до для каждого.` }, { id: 'signer_no_dfk_le', topic: 'срез', test: c => c.addsSigner && !c.dfk && !c.has(c.legalEntities), ask: () => 'На каком срезе ставим — Дирекция / Функция / Категория и ЮЛ? (из карточки ДФК не копируется — впиши вручную).' }, { id: 'signer_no_dfk', topic: 'срез', test: c => c.addsSigner && !c.dfk && c.has(c.legalEntities), ask: () => 'Не вижу ДФК (Дирекция / Функция / Категория) — на каких строках ставить? Впиши срез.' }, { id: 'signer_no_le', topic: 'срез', test: c => c.addsSigner && c.dfk && !c.has(c.legalEntities) && c.source !== 'card', ask: () => 'По каким ЮЛ ставим подписанта? (или «все ЮЛ»).' }, { id: 'signer_no_vgo', topic: 'тип сделки', test: c => c.addsSigner && c.dfk && c.internal == null && !c.has(c.dealType), ask: () => 'Сделка доходная или расходная? И внутригрупповая (ВГО, ВН=Да) или внешняя? От этого зависят условия применения и ЭЦП.' }, // — Атрибуты / тип документа / ЮЛ / ОП — { id: 'doc_no_type', topic: 'тип документа', test: c => c.kind === 'add_doc_type' && !c.has(c.docTypes), ask: () => 'Какой тип документа добавить?' }, { id: 'attr_no_target', topic: 'срез', test: c => (c.kind === 'add_doc_type' || c.kind === 'add_legal_entity') && !c.dfk && !c.has(c.legalEntities) && !c.signerFilter, ask: () => 'К каким строкам применить — по ДФК, подписанту или ЮЛ? Уточни цель.' }, // — Карточка: что НЕ скопировалось из OpenText — { id: 'card_no_dfk', topic: 'карточка', test: c => c.source === 'card' && !c.dfk, ask: () => 'Из карточки НЕ скопировались Дирекция / Функция / Категория (в OpenText эти поля не копируются) — впиши их вручную.' }, { id: 'card_no_amount', topic: 'карточка', test: c => c.source === 'card' && !c.has(c.amounts) && c.amountValue == null, ask: () => 'Не вижу суммы / лимита (поле не скопировалось) — впиши сумму договора или лимит.' }, { id: 'card_no_deal', topic: 'карточка', test: c => c.source === 'card' && !c.has(c.dealType), ask: () => 'Тип сделки (доходная / расходная / иное) не скопировался — уточни.' }, // — Совсем непонятно — { id: 'unknown_intent', topic: 'общее', test: c => c.unknown, ask: () => 'Не понял, что нужно сделать. Сформулируй: добавить / заменить / удалить подписанта? тип документа? ЮЛ / ОП? на каком срезе (ДФК + ЮЛ)?' }, ]; function buildClarifications(ctx) { const c = Object.assign({ has: v => Boolean(v && (Array.isArray(v) ? v.length : String(v).trim())) }, ctx || {}); c.dfk = c.has(c.direction) || c.has(c.functionName) || c.has(c.category); // Намерение поставить подписанта (даже без ФИО) трактуем как add — чтобы спросить «кого?», а не «не понял». // Но НЕ при удалении/замене (там «подписание» — это про снять/заменить, а не поставить нового). if (c.signerIntent && !c.isRemoval && !c.replace) c.addsSigner = true; const out = []; for (const rule of CLARIFY_RULES) { try { if (rule.test(c)) out.push(typeof rule.ask === 'function' ? rule.ask(c) : rule.ask); } catch (_) { /* ignore */ } } // «общее» показываем только если других вопросов нет return unique(out).filter((q, _i, arr) => arr.length === 1 || !/Не понял, что нужно/.test(q) || arr.every(x => /Не понял/.test(x))); } function getColumns() { const m = matrix(); return m && Array.isArray(m.cols) ? m.cols : []; } function getItems() { const m = matrix(); return m && Array.isArray(m.items) ? m.items : []; } function colIndex(aliases) { const wanted = (Array.isArray(aliases) ? aliases : [aliases]).map(normalize); return getColumns().findIndex(col => col && wanted.includes(normalize(col.alias || col.title || col.type || ''))); } function namesForIds(ids, cache) { const source = cache || {}; return (ids || []).map(id => { const abs = Math.abs(Number(id)); return source[id] || source[abs] || String(id); }); } function rowFacts() { const m = matrix(); const cols = { partner: colIndex(['partner_id', 'partners_internal_id', 'Контрагент']), site: colIndex(['partner_op', 'site', 'op', 'Обособленное подразделение']), docType: colIndex(['document_type', 'Тип документа']), legalEntity: colIndex(['legal_entity', 'legal_entities', 'legal_entity_id', 'legal_entities_id', 'Юрлицо', 'Юр. лицо']), direction: colIndex(['direction', 'Дирекция']), functions: colIndex(['functions', 'Функция']), category: colIndex(['category', 'Категория']), conditions: colIndex(['condition', 'conditions', 'Условия применения']), edo: colIndex(['eds', 'edo', 'ЭДО', 'ЭЦП']), amount: colIndex(['amount', 'sum_rub', 'Сумма документа в рублях (включая налоги)']), limit: colIndex(['limit', 'limit_contract', 'Лимит по договору в рублях (без НДС)']), }; return getItems().map((item, index) => { const docTypes = cols.docType >= 0 ? valueAsList(item[cols.docType]) : []; const legalEntities = cols.legalEntity >= 0 ? valueAsList(item[cols.legalEntity]) : []; const partnerIds = cols.partner >= 0 ? valueAsList(item[cols.partner]) : []; const partnerNames = m && m.partnerCacheObject ? namesForIds(partnerIds, m.partnerCacheObject) : partnerIds; const text = [ docTypes.join('; '), legalEntities.join('; '), partnerNames.join('; '), cols.site >= 0 ? valueAsList(item[cols.site]).join('; ') : '', cols.direction >= 0 ? valueAsList(item[cols.direction]).join('; ') : '', cols.functions >= 0 ? valueAsList(item[cols.functions]).join('; ') : '', cols.category >= 0 ? valueAsList(item[cols.category]).join('; ') : '', ].join(' '); const norm = normalize(text); const groups = []; if (/основн|main/.test(norm)) groups.push('main_contract_rows'); if (/(^|[\s;])дс($|[\s;])|доп|специфик|соглаш|supp/.test(norm)) groups.push('supplemental_rows'); if (!groups.length) groups.push('custom'); return { index, rowNumber: index + 1, docTypes, legalEntities, partnerNames, sites: cols.site >= 0 ? valueAsList(item[cols.site]) : [], directions: cols.direction >= 0 ? valueAsList(item[cols.direction]) : [], functions: cols.functions >= 0 ? valueAsList(item[cols.functions]) : [], categories: cols.category >= 0 ? valueAsList(item[cols.category]) : [], conditions: cols.conditions >= 0 ? valueAsList(item[cols.conditions]) : [], edo: cols.edo >= 0 ? valueAsList(item[cols.edo]) : [], amount: cols.amount >= 0 ? valueAsList(item[cols.amount]) : [], limit: cols.limit >= 0 ? valueAsList(item[cols.limit]) : [], groups, text, }; }); } function isNumericUser(value) { return /^-?\d{3,}$/.test(compact(value)); } function looksLikePersonName(value) { const words = stripId(value).split(/\s+/).filter(Boolean); if (words.length < 2 || words.length > 3) return false; return words.every(word => /^[A-ZА-ЯЁ][a-zа-яё-]+$/u.test(word)); } function normalizedWords(value) { return normalize(value).replace(/[^a-zа-яё0-9_-]+/gi, ' ').split(/\s+/).filter(Boolean); } function userTokenMatches(token, user, fio, login) { if ((normalizedWords(fio) || []).some(word => word === token || word.startsWith(token))) return true; if (login && login.includes(token)) return true; const id = normalize(user.id || ''); return Boolean(id && id === token); } function isLikelyTechnicalUser(value) { const raw = value && typeof value === 'object' ? (value.fio || value.display || value.title || value.name || value.login || value.id || '') : value; const text = compact(raw); if (!text || looksLikePersonName(text)) return false; const key = normalize(text); const hasLowercase = /[a-zа-яё]/u.test(text); const hasLegalSuffix = /(^|\s)(ооо|ао|зао|оао|пао|ип|нко)(\s|$)/i.test(key); const looksLikeRoleCode = /^[A-ZА-ЯЁ0-9\s()./-]+$/u.test(text) && !hasLowercase && text.split(/\s+/).filter(Boolean).length > 1; if (hasLegalSuffix || looksLikeRoleCode || key.includes('орелсельпром')) return true; return text.includes('_') || /^(contracts?|contract)_/i.test(text) || key.includes('архив') || key.includes('обособленное подразделение') || key.includes('акты сверки') || key.includes('больничные листы') || key.includes('contracts edit') || key.includes('contracts see'); } function userObject(value, role, source) { const rawObject = value && typeof value === 'object' ? value : null; let raw = compact(rawObject ? (rawObject.title || rawObject.display || rawObject.fio || rawObject.name || rawObject.id || '') : value); const objectId = rawObject && rawObject.id != null && rawObject.id !== '' ? String(rawObject.id).replace(/^-/, '') : ''; let numeric = isNumericUser(raw); const id = objectId || (numeric ? raw.replace(/^-/, '') : ''); // Matrix cells keep only numeric ids; resolve them to "Имя Фамилия (Должность)" via the model store. if (numeric && id) { const resolved = resolveUserId(id); if (resolved) { raw = resolved; numeric = false; } } const titleMatch = raw.match(/^(.*?)\s*\(([^()]+)\)\s*$/); const fioText = titleMatch ? compact(titleMatch[1]) : raw; const position = rawObject && rawObject.position ? compact(rawObject.position) : (titleMatch ? compact(titleMatch[2]) : ''); const fio = numeric ? `Не найдено имя (ID ${id})` : fioText; const display = position && !numeric ? `${fio} — ${position}` : fio; return { id, fio, position, login: rawObject && rawObject.login ? compact(rawObject.login) : '', role: role || '', source: source || 'matrix', unresolved: numeric, technical: isLikelyTechnicalUser(rawObject || raw), display, }; } function uniqueUsers(values, role, source) { const seen = new Set(); return (values || []).map(value => userObject(value, role, source)).filter(user => { const key = normalize(user.id || user.fio); if (!key || seen.has(key)) return false; seen.add(key); return true; }); } function collectOpenTextModelUsers() { if (state.modelUsers) return state.modelUsers; const stores = [host.sc_ModelUser, host.sc_ModelUser2, window.sc_ModelUser, window.sc_ModelUser2].filter(Boolean); const seen = new Set(); const out = []; stores.forEach(store => (Array.isArray(store.items) ? store.items : []).forEach(item => { const id = String(item.id == null ? '' : item.id); const title = compact(item.title || item.name || item.fio || ''); if (!id || seen.has(id)) return; seen.add(id); out.push({ id, title, source: 'opentext_model' }); })); state.modelUsers = out; return out; } // id -> "Имя Фамилия (Должность)" from the OpenText model store. This is the real // resolution map; matrix cells store only numeric ids, so without this people show as ids. function modelUserIndex() { if (state.modelIndex) return state.modelIndex; const map = new Map(); collectOpenTextModelUsers().forEach(item => { if (!item.title) return; map.set(String(item.id), item.title); const abs = String(Math.abs(Number(item.id))); if (abs !== 'NaN' && !map.has(abs)) map.set(abs, item.title); }); state.modelIndex = map; return map; } function resolveUserId(id) { const map = modelUserIndex(); const raw = String(id == null ? '' : id); return map.get(raw) || map.get(raw.replace(/^-/, '')) || ''; } function cleanPeople(list) { return (list || []).filter(user => user && user.fio && user.fio !== '-1' && !user.unresolved && !user.technical && !/^-?\d+$/.test(user.fio)); } function isSite(value, sites) { const text = compact(value); const key = normalize(text); return (sites || []).some(site => normalize(site) === key) || /(^|\s)[А-ЯЁA-Z][а-яёa-z-]+-\d+\b/.test(text) || /^(москва|липецк|воронеж|белгород|пенза|санкт-петербург|алтай|пермская)-\d+/i.test(text); } function isLegalEntity(value) { return /(^|[\s"«»])(ооо|ао|оао|пао|тоо|зао)(?=$|[\s"«»])/i.test(String(value || '')) || /группа\s+черкизово/i.test(String(value || '')); } const ContextDetector = { detect() { const bodyText = compact(document.body ? document.body.textContent : ''); const title = document.title || ''; const url = location.href; const technicalUrl = (() => { try { const parsed = new URL(url); return `${parsed.search || ''} ${parsed.hash || ''}`; } catch (_) { return ''; } })(); const m = matrix(); const visibleText = `${title} ${bodyText}`; const isCounterpartySearch = /zdoc\.searchpartners|searchpartners/i.test(technicalUrl) || (/поиск\s+контрагента/i.test(visibleText) && /критерии\s+поиска|правовая\s+форма|кпп|огрн/i.test(visibleText)); let kind = 'unknown'; if (m || document.querySelector('#sc_ApprovalMatrix')) kind = 'matrix'; else if (/лист согласования/i.test(title) || document.querySelector('#ApprovalListForm')) kind = 'approval_list'; else if (isCounterpartySearch) kind = 'counterparty_search'; else if (document.querySelector('#browseViewCoreTable') && document.querySelector('a[href*="OpenMatrix"]')) kind = 'catalog'; else if (/assyst|itcm|itsm/i.test(visibleText) || /^\s*\d{5,}\s*\(/.test(title)) kind = 'itsm'; else if (/карточк|договор\s*(?:№|отд)/i.test(visibleText) || /zdoc|document/i.test(technicalUrl)) kind = 'card'; else if (/ApprovalList|лист согласования/i.test(visibleText)) kind = 'approval_list'; const statusNode = document.querySelector('#sc_approvalmatrixStatus, select[name*="status" i], [data-status]'); const status = statusNode && statusNode.options && statusNode.selectedIndex >= 0 ? statusNode.options[statusNode.selectedIndex].text : compact((statusNode && (statusNode.value || statusNode.textContent)) || (kind === 'matrix' ? 'Матрица' : '')); const matrixIdMatch = url.match(/[?&](?:matrixId|objId|nodeid)=([^&#]+)/i); return { kind, title: title || (kind === 'matrix' ? 'Матрица OpenText' : 'OpenText'), status: status || (kind === 'unknown' ? 'Контекст не определён' : kind), matrixId: matrixIdMatch ? decodeURIComponent(matrixIdMatch[1]) : '', urls: { current: url, card: Array.from(document.querySelectorAll('a[href*="zdoc"], a[href*="objId"]')).map(a => a.href).slice(0, 5), approvalList: Array.from(document.querySelectorAll('a[href*="ApprovalList"], a[href*="approvallist"]')).map(a => a.href).slice(0, 5), matrix: Array.from(document.querySelectorAll('a[href*="OpenMatrix"]')).map(a => a.href).slice(0, 5), }, }; }, }; const DictionaryBuilder = { build(options = {}) { const legacy = state.original.getHumanDictionaries && !options.skipLegacy ? state.original.getHumanDictionaries() : {}; const facts = rowFacts(); const m = matrix(); const counterparties = unique([] .concat(legacy.counterparties || []) .concat(m && m.partnerCacheObject ? Object.keys(m.partnerCacheObject).map(id => m.partnerCacheObject[id]) : []) .concat(facts.flatMap(fact => fact.partnerNames))) .map(stripId) .filter(Boolean) .sort((a, b) => a.localeCompare(b, 'ru')); const sites = unique([].concat(legacy.sites || []).concat(facts.flatMap(fact => fact.sites))).sort((a, b) => a.localeCompare(b, 'ru')); const inferredLegal = counterparties.filter(item => isLegalEntity(item) && !isSite(item, sites)); const legalEntities = unique([].concat(legacy.legalEntities || []).concat(facts.flatMap(fact => fact.legalEntities)).concat(inferredLegal)) .sort((a, b) => a.localeCompare(b, 'ru')); const usersRaw = [] .concat(legacy.users || []) .concat(legacy.signers || []) .concat(legacy.approvers || []) .concat(legacy.specialExperts || []) .concat(legacy.performers || []); if (m && m.userCacheObject) { Object.keys(m.userCacheObject).forEach(id => usersRaw.push(m.userCacheObject[id] || id)); } // The 18k OpenText model store is kept only as an id->ФИО resolver and as a // searchable directory, never dumped into the operator-facing people lists. const users = cleanPeople(uniqueUsers(usersRaw, 'matrix_user', 'matrix')); const signers = cleanPeople(uniqueUsers(legacy.signers || [], 'signer', 'matrix')); const approvers = cleanPeople(uniqueUsers(legacy.approvers || [], 'approver', 'matrix')); const specialExperts = cleanPeople(uniqueUsers(legacy.specialExperts || [], 'special_expert', 'matrix')); return { schemaVersion: VERSION, refreshedAt: new Date().toISOString(), requiredAffiliation: REQUIRED_AFFILIATION, users, userDisplays: users.map(user => user.display), directoryEnabled: true, directorySize: collectOpenTextModelUsers().filter(item => /[А-ЯЁ][а-яё]/.test(item.title || '')).length, signers, approvers, specialExperts, performers: cleanPeople(uniqueUsers(legacy.performers || [], 'performer', 'matrix')), signersAndApprovers: cleanPeople(uniqueUsers([].concat(legacy.signers || []).concat(legacy.approvers || []), 'signer_or_approver', 'matrix')), counterparties, legalEntities, sites, docTypes: unique([].concat(legacy.docTypes || []).concat(facts.flatMap(fact => fact.docTypes)).concat(DOC_GROUP_A).concat(DOC_GROUP_B)).sort((a, b) => a.localeCompare(b, 'ru')), directions: unique([].concat(legacy.directions || []).concat(facts.flatMap(fact => fact.directions))).sort((a, b) => a.localeCompare(b, 'ru')), functions: unique([].concat(legacy.functions || []).concat(facts.flatMap(fact => fact.functions))).sort((a, b) => a.localeCompare(b, 'ru')), categories: unique([].concat(legacy.categories || []).concat(facts.flatMap(fact => fact.categories))).sort((a, b) => a.localeCompare(b, 'ru')), conditions: unique([].concat(facts.flatMap(fact => fact.conditions)).concat(CONDITIONS_STANDARD)).sort((a, b) => a.localeCompare(b, 'ru')), edo: unique([].concat(facts.flatMap(fact => fact.edo)).concat(['Единый ЭДО', 'Нет', 'ЭДО на внешней площадке'])).sort((a, b) => a.localeCompare(b, 'ru')), currencies: unique(facts.flatMap(fact => fact.text.match(/\bRUB\b|рубл[ьяей]*/ig) || [])), vatRates: unique(facts.flatMap(fact => fact.text.match(/НДС\s*\d+%?/ig) || [])), rowGroups: ['all', 'main_contract_rows', 'supplemental_rows', 'custom'], documentTypeGroups: { main_contract_rows: DOC_GROUP_A.slice(), supplemental_rows: DOC_GROUP_B.slice(), }, developmentProjectCategories: DEVELOPMENT_CATEGORIES.slice(), }; }, }; const UserResolver = { search(input, dictionaries, limit) { return userSearchCandidates(input, dictionaries, limit || 8); }, resolve(input, dictionaries) { const dict = dictionaries || DictionaryBuilder.build(); const key = normalize(input); return (dict.users || []).find(user => normalize(user.fio) === key || normalize(user.display) === key || normalize(user.id) === key) || userSearchCandidates(input, dict, 1)[0] || userObject(input, '', 'manual'); }, }; const LegalEntityResolver = { parseList, resolve(input, dictionaries) { const dict = dictionaries || DictionaryBuilder.build(); const legalEntities = []; const sites = []; const conflicts = []; const warnings = []; parseList(input).forEach(raw => { const text = stripId(raw); if (!text) return; if (isSite(text, dict.sites)) { sites.push(text); warnings.push(`"${text}" распознано как площадка/ОП и не будет добавлено в ЮЛ.`); return; } const aliasTarget = COMPANY_ALIASES.find(item => item.aliases.some(alias => normalize(alias) === normalize(text))); const wanted = aliasTarget ? aliasTarget.target : text; const exact = (dict.legalEntities || []).filter(item => normalize(item) === normalize(wanted)); if (exact.length === 1) legalEntities.push(exact[0]); else if (exact.length > 1) conflicts.push({ input: text, candidates: exact }); else if (aliasTarget) { const nearAlias = (dict.legalEntities || []).filter(item => normalize(item).includes(normalize(aliasTarget.target.replace(/\b(ООО|АО|ОАО|ПАО|ЗАО)\b/ig, '').trim()))).slice(0, 5); if (nearAlias.length === 1) legalEntities.push(nearAlias[0]); else legalEntities.push(aliasTarget.target); } else if (isLegalEntity(text)) legalEntities.push(text); else { const near = (dict.legalEntities || []).filter(item => normalize(item).includes(normalize(text))).slice(0, 5); if (near.length === 1) legalEntities.push(near[0]); else conflicts.push({ input: text, candidates: near }); } }); return { affiliation: REQUIRED_AFFILIATION, legalEntities: unique(legalEntities), sites: unique(sites), conflicts, warnings, }; }, }; const DocumentTypePresetEngine = { groups() { return { main_contract_rows: DOC_GROUP_A.slice(), supplemental_rows: DOC_GROUP_B.slice(), }; }, }; // ===== Свод как правила (единый шаблон, вставляется оператором, не вшит статически) ===== const SVOD_STORAGE_KEY = 'otk_svod_rules_v1'; function svodStorage() { try { return (host && host.localStorage) || window.localStorage || null; } catch (_) { return null; } } function parseDelimited(line, delim) { if (delim === '\t') return line.split('\t'); const out = []; let cur = ''; let quoted = false; for (let i = 0; i < line.length; i += 1) { const ch = line[i]; if (quoted) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i += 1; } else quoted = false; } else cur += ch; } else if (ch === '"') quoted = true; else if (ch === delim) { out.push(cur); cur = ''; } else cur += ch; } out.push(cur); return out; } const SvodTemplate = { // Разбирает вставленный свод в едином формате (TSV из Excel или CSV). Колонки — по заголовкам. parse(text) { const raw = String(text || '').replace(/\r\n?/g, '\n').replace(/^/, ''); const lines = raw.split('\n').filter(line => line.trim() !== ''); if (!lines.length) return { ok: false, error: 'Пусто — вставьте таблицу свода.', rows: [] }; const delim = lines[0].includes('\t') ? '\t' : (lines[0].indexOf(',') < 0 && lines[0].indexOf(';') >= 0 ? ';' : ','); const header = parseDelimited(lines[0], delim).map(normalize); const find = (...keys) => header.findIndex(cell => keys.some(key => cell.includes(key))); const col = { direction: find('дирекци'), func: find('функци'), category: find('категори'), type: find('тип'), internal: find('вго', 'вн '), limit: find('лимит', 'услови'), signer: find('подписант'), chain: find('согласу', 'цепочк'), }; if (col.category < 0) return { ok: false, error: 'Не найдена колонка «Категория» в заголовке свода.', rows: [] }; const rows = []; for (let i = 1; i < lines.length; i += 1) { const cells = parseDelimited(lines[i], delim); const get = idx => (idx >= 0 ? compact(cells[idx] || '') : ''); const category = get(col.category); if (!category) continue; const internalRaw = get(col.internal); rows.push({ direction: get(col.direction), function: get(col.func), category, dealType: get(col.type), limit: get(col.limit), signer: get(col.signer), internal: /(^|[^а-я])да([^а-я]|$)|вго|внутригрупп|внутрихолдинг/i.test(internalRaw) || /вго/i.test(category), chain: parseList(get(col.chain).replace(/,/g, ';')), }); } return { ok: rows.length > 0, error: rows.length ? '' : 'В свод не попало ни одной строки с категорией.', rows }; }, }; function buildSvodIndex(rows) { const byCategory = {}; const functions = new Set(); const directions = new Set(); (rows || []).forEach(row => { const key = normalize(row.category); if (!key) return; (byCategory[key] = byCategory[key] || []).push(row); if (row.function) functions.add(row.function); if (row.direction) directions.add(row.direction); }); return { rows: rows || [], byCategory, functions: Array.from(functions), directions: Array.from(directions), count: (rows || []).length }; } const SvodStore = { load() { const store = svodStorage(); if (!store) return state.svod; try { const raw = store.getItem(SVOD_STORAGE_KEY); if (raw) { const data = JSON.parse(raw); state.svod = buildSvodIndex(data.rows || []); } } catch (_) { /* ignore */ } return state.svod; }, setFromText(text) { const parsed = SvodTemplate.parse(text); if (!parsed.ok) return { ok: false, error: parsed.error, count: 0 }; const index = buildSvodIndex(parsed.rows); state.svod = index; const store = svodStorage(); if (store) { try { store.setItem(SVOD_STORAGE_KEY, JSON.stringify({ rows: index.rows, savedAt: new Date().toISOString() })); } catch (_) { /* ignore */ } } return { ok: true, count: index.count, functions: index.functions.length, directions: index.directions.length }; }, clear() { state.svod = null; const store = svodStorage(); if (store) { try { store.removeItem(SVOD_STORAGE_KEY); } catch (_) { /* ignore */ } } }, get() { if (state.svod && !state.svod.byCategory) state.svod = buildSvodIndex(state.svod.rows || []); return state.svod; }, classify(category) { const index = SvodStore.get(); if (!index || !category) return null; const entries = index.byCategory[normalize(category)]; if (!entries || !entries.length) return null; return { internal: entries.some(entry => entry.internal), dealType: unique(entries.map(entry => entry.dealType).filter(Boolean))[0] || '', functions: unique(entries.map(entry => entry.function).filter(Boolean)), directions: unique(entries.map(entry => entry.direction).filter(Boolean)), signers: unique(entries.map(entry => entry.signer).filter(Boolean)), chain: unique([].concat.apply([], entries.map(entry => entry.chain || []))), }; }, // Мягкая валидация: известна ли категория и совпадают ли дирекция/функция с заявкой. validate(category, ctx) { const cls = SvodStore.classify(category); if (!cls) return { known: false }; const ctxDir = compact((ctx && ctx.direction) || ''); const ctxFn = compact((ctx && ctx.function) || ''); return { known: true, internal: cls.internal, dealType: cls.dealType, chain: cls.chain, functions: cls.functions, directions: cls.directions, dirOk: !ctxDir || cls.directions.some(dir => softMatch([dir], ctxDir)), fnOk: !ctxFn || cls.functions.some(fn => softMatch([fn], ctxFn)), }; }, }; const SignerFormsEngine = { build(payload = {}) { const ranges = Array.isArray(payload.ranges) && payload.ranges.length ? payload.ranges.map(range => ({ from: compact(range.from || '0'), to: compact(range.to || range.limit || payload.limit || payload.rangeTo || ''), signer: compact(range.signer || range.newSigner || payload.newSigner || payload.signer || ''), signerId: compact(range.signerId || range.newSignerId || payload.newSignerId || payload.signerId || ''), amountFrom: compact(range.amountFrom || range.from || '0'), amount: compact(range.amount || range.amountTo || payload.amount || range.to || payload.limit || payload.rangeTo || ''), })) : [{ from: compact(payload.from || '0'), to: compact(payload.limit || payload.rangeTo || payload.to || ''), signer: compact(payload.newSigner || payload.signer || ''), signerId: compact(payload.newSignerId || payload.signerId || ''), amountFrom: compact(payload.amountFrom || payload.from || '0'), amount: compact(payload.amount || payload.limit || payload.rangeTo || payload.to || ''), }]; const validRanges = ranges.filter(range => range.signer && range.to && range.amount); if (!validRanges.length) return []; const common = { currentSigner: compact(payload.currentSigner || ''), legalEntities: parseList(payload.legalEntities || payload.legalEntity || ''), legalEntityIds: parseList(payload.legalEntityIds || payload.legalEntityId || ''), sites: parseList(payload.sites || payload.site || ''), conditions: payload.conditions || CONDITIONS_STANDARD, dealInternal: payload.dealInternal === true, affiliation: REQUIRED_AFFILIATION, direction: compact(payload.direction || ''), functionName: compact(payload.functionName || payload.functions || ''), category: compact(payload.category || ''), }; // Template = the learned matrix pattern (per ДФК), else the standard 4 forms. const template = Array.isArray(payload.templateForms) && payload.templateForms.length ? payload.templateForms : [ { edo: 'edo', packageClass: 'main', label: 'Основной пакет', documentTypes: DOC_GROUP_A.slice() }, { edo: 'non_edo', packageClass: 'main', label: 'Основной пакет', documentTypes: DOC_GROUP_A.slice() }, { edo: 'edo', packageClass: 'supplemental', label: 'Подчинённый пакет', documentTypes: DOC_GROUP_B.slice() }, { edo: 'non_edo', packageClass: 'supplemental', label: 'Подчинённый пакет', documentTypes: DOC_GROUP_B.slice() }, ]; return validRanges.flatMap((range, rangeIndex) => { const rangeCommon = Object.assign({}, common, { newSigner: range.signer, signer: range.signer, newSignerId: range.signerId, signerId: range.signerId, }); const suffix = validRanges.length === 1 ? '' : `_r${rangeIndex + 1}`; const amountFrom = range.amountFrom || range.from || '0'; const amountTo = range.amount || range.to; return template.map((form, formIndex) => { const isSupp = form.packageClass === 'supplemental'; const valueMode = isSupp ? 'amount' : 'limit'; return Object.assign({}, rangeCommon, { rowKey: `${form.packageClass}_${form.edo}${suffix}_${formIndex}`, rowGroup: isSupp ? 'supplemental_rows' : 'main_contract_rows', packageClass: form.packageClass, packageLabel: form.label, documentTypes: (form.documentTypes || (isSupp ? DOC_GROUP_B : DOC_GROUP_A)).slice(), edoMode: form.edo, valueMode, from: isSupp ? amountFrom : (range.from || '0'), to: isSupp ? amountTo : range.to, value: isSupp ? amountTo : range.to, }); }); }); }, toLegacyBundle(payload = {}) { const forms = SignerFormsEngine.build(payload); const firstForm = forms[0] || {}; return { type: 'add_signer_bundle', payload: Object.assign({}, payload, { newSigner: compact(payload.newSigner || payload.signer || firstForm.newSigner || ''), limit: compact(payload.limit || payload.rangeTo || firstForm.to || firstForm.value || ''), amount: compact(payload.amount || payload.limit || payload.rangeTo || firstForm.to || firstForm.value || ''), affiliation: REQUIRED_AFFILIATION, signerForms: forms, ranges: Array.isArray(payload.ranges) ? payload.ranges : [], documentTypeGroups: DocumentTypePresetEngine.groups(), conditions: payload.conditions || CONDITIONS_STANDARD, }), options: Object.assign({ sourceRule: 'opentext_toolkit_signer_forms' }, payload.options || {}), }; }, }; const ChecklistEngine = { run(options = {}) { const target = api(); if (target && state.original.runChecklistEngine) return state.original.runChecklistEngine(options); return { summary: { total: 1, pass: 0, warn: 1, fail: 0 }, checks: [{ id: 'fallback', title: 'Проверка карточки', status: 'warn', recommendation: 'Базовый checklist runtime недоступен.' }], }; }, }; const ITSMIntakeEngine = { parse(text = '') { const target = api(); const raw = String(text || ''); const dict = DictionaryBuilder.build(); const parsed = target && state.original.parseRequestText ? state.original.parseRequestText(raw) : {}; const classification = classifyRequestText(raw); const links = extractLinks(raw); const incident = raw.match(/\b(?:INC|#)?(\d{6,})\b/i); const knownUsers = extractKnownUsersFromText(raw, dict); // Имя/ФИО ВСЕГДА с заглавной. Легаси-парсер (parseRequestText) иногда отдаёт мусорный спан со строчной // («а Соснину на Никифорову и лимит до 200мл.») — отсекаем его, чтобы он не стал подписантом-фолбэком. const users = unique([].concat(parsed.extracted ? parsed.extracted.users || [] : []).concat(knownUsers).concat(extractUsersFromText(raw))) .filter(n => /^[А-ЯЁA-Z]/.test(compact(n))); const roleUsers = { signers: extractRoleUsersFromText(raw, /подписант|подписание/i, dict), specialExperts: extractRoleUsersFromText(raw, /спец.?эксперт|эксперт/i, dict), managers: extractRoleUsersFromText(raw, /руководител|директор/i, dict), performers: extractRoleUsersFromText(raw, /исполнител|ответственн/i, dict), }; const people = extractPeople(raw); const money = extractMoney(raw); const sites = extractSites(raw); // Структурные поля карточки (Старый/Новый пользователь) НАДЁЖНЕЕ free-text «X на Y» → им приоритет. const structured = extractStructuredFields(raw); let replace = (structured.oldUser && structured.newUser) ? { from: structured.oldUser, to: structured.newUser, fromStructured: true } : extractReplacePair(raw); // Одна фамилия без инициалов («заменить Соснину на Никифорову»): ищем ОБЕ среди людей матрицы. // Если каждая нашлась ОДНОЗНАЧНО — делаем замену с ID (применится сразу). Иначе вопрос (с кандидатами). let singleSurname = null; if (!replace && /(?:заменить|замени|поменять|сменить|вместо)/i.test(raw) && !/удал|исключ|убрать|снять/i.test(raw)) { singleSurname = detectSingleSurnameReplace(raw, dict); if (singleSurname && singleSurname.from.unique && singleSurname.to.unique) { replace = { from: singleSurname.from.unique.fio, to: singleSurname.to.unique.fio, fromId: singleSurname.from.unique.id, toId: singleSurname.to.unique.id, viaMatrix: true }; singleSurname = null; // решили однозначно — вопрос не нужен } } // подписант ловится не только по слову «подписант», но и по глаголам действия и парам «имя→лимит» const signerVerb = /подпис|поставить|поставь|установ|назнач|по\s+подписант/i.test(raw); const rangeSeen = new Set(); const signerRanges = [].concat(extractSignerRanges(raw, dict), extractSignerLimits(raw)).filter(range => { const key = normalize(range.signer) + '|' + range.to; if (!range.signer || !range.to || rangeSeen.has(key)) return false; rangeSeen.add(key); return true; }); if (signerVerb || signerRanges.length) { roleUsers.signers = unique([].concat(roleUsers.signers, signerRanges.map(range => range.signer), signerVerb ? people : [])); } const legalEntities = unique([].concat(parsed.extracted ? parsed.extracted.legalEntities || [] : []).concat(extractLegalEntitiesFromText(raw))); const docTypes = unique([].concat(parsed.extracted ? parsed.extracted.docTypes || [] : []).concat(extractDocTypesFromText(raw))); const amounts = unique([].concat(parsed.extracted ? parsed.extracted.amounts || [] : []).concat(money.map(String)).concat(extractAmounts(raw))); const limits = unique([].concat(parsed.extracted ? parsed.extracted.limits || [] : []).concat(amounts)); const direction = extractDfkValue(raw, ['дирекция', 'направление'], 'дирекци|направлени'); const functionName = extractDfkValue(raw, ['функция', 'дф', 'дфк'], 'функци'); const category = extractDfkValue(raw, ['категория'], 'категори'); const isDevelopmentProject = /дирекц[а-яё]*\s+развит|проект/i.test(raw); const missing = []; if (/signer|подпис/i.test(classification.id) || classification.id === 'add_signer_forms' || signerRanges.length || roleUsers.signers.length) { if (!users.length && !roleUsers.signers.length && !signerRanges.length) missing.push('ФИО подписанта'); if (!amounts.length) missing.push('диапазон суммы/лимита'); } if (classification.id === 'add_doc_type' && !docTypes.length) missing.push('тип документа'); if (classification.id === 'add_legal_entity' && !legalEntities.length) missing.push('ЮЛ / внутренняя компания'); if (classification.id === 'route_diagnostics' && !links.length) missing.push('ссылка на карточку или лист согласования'); // «по подписанту X добавить ОП/тип» — это ПАТЧ строк X, а не создание подписанта. const resolvedFilter = roleUsers.signers[0] ? UserResolver.resolve(roleUsers.signers[0], dict) : null; // фильтр по ВНУТРЕННЕМУ ID (в v8 справочник по полному титулу с должностью — голое ФИО не резолвится) const signerFilter = resolvedFilter && !resolvedFilter.unresolved && resolvedFilter.id ? String(resolvedFilter.id) : ''; const signerFilterName = resolvedFilter && resolvedFilter.fio ? resolvedFilter.fio : (roleUsers.signers[0] || ''); const addAttrToSigner = Boolean(roleUsers.signers.length && (sites.length || docTypes.length) && /добав/i.test(raw) && !/поставить|поставь|установ|создать|нов[а-яё]+\s+(?:маршрут|строк)/i.test(raw)); // УДАЛЕНИЕ/ИСКЛЮЧЕНИЕ: «исключить/удалить подписанта X» — НЕ создавать add_* (иначе добавим того, // кого просят убрать!). Авто-операции удаления пока нет → manual. Если есть «на Y» — это замена. const isRemoval = /удал|исключ|убрать|снять|снимите/i.test(raw) && /подпис|согласующ|согласован|эксперт|уров|функци|маршрут|матриц|подразделен|из\s+оп/i.test(raw) && !replace; // «уров» (не «уровн»): «уровень» = уров+ень, «уровн» туда не входит const proposedOperations = []; if (isRemoval) { proposedOperations.push({ type: 'remove_request_manual', payload: { rawText: raw, signer: (roleUsers.signers[0] || people[0] || ''), legalEntities, sites, note: 'Удаление/исключение из маршрута — авто-операции нет, убрать вручную (или Этап 2). Добавление НЕ предлагается.' } }); } // Перенос категории: «перенести/передать/отдать категорию X подписанту Y» → split + новая строка Y. // Автоматом кладём ОБЕ операции в план (без отдельной вкладки «Разделить»). Гейтим ЖЁСТКО: нужен // транспортный глагол ВПЛОТНУЮ к слову «категори» (в любом порядке) + ЮЛ + получатель. Без этого // обычное «добавить подписанта на категорию» НЕ должно превратиться в разбиение строки. const transferVerb = /(?:перенес|перенос|переда|отда)[а-яё]*[^.\n]{0,40}категор/i.test(raw) || /категор[а-яё]+[^.\n]{0,40}(?:перенес|перенос|переда|отда)[а-яё]*/i.test(raw); const transferCat = compact(category) || extractLabeledValue(raw, ['категория', 'категории', 'категорию']); const transferToSigner = roleUsers.signers[0] || ''; const isTransfer = Boolean(transferVerb && transferCat && legalEntities[0] && transferToSigner && !isRemoval && !replace); if (isTransfer) { buildTransferOps({ legalEntity: legalEntities[0], transferCategories: transferCat, toSigner: transferToSigner, fromSigner: roleUsers.signers[1] || '', limit: (signerRanges[0] && signerRanges[0].to) || amounts[0] || '', direction, functionName, rowGroup: 'all', }).forEach(op => proposedOperations.push(op)); } if (replace) { // Подставляем ВНУТРЕННИЙ ID из матрицы для ЛЮБОЙ замены (не только одно-фамильной) — тогда патч // применяется без ручного выбора из автоподсказки. Если ЮЛ/фамилия не однозначна — ID пустой. const fromId = replace.fromId || matrixSignerId(replace.from, dict); const toId = replace.toId || matrixSignerId(replace.to, dict); proposedOperations.push({ type: 'replace_signer_by_name', payload: { currentSigner: replace.from, newSigner: replace.to, currentSignerName: replace.from, newSignerName: replace.to, currentSignerId: fromId, newSignerId: toId, legalEntities, direction, functionName, category: extractLabeledValue(raw, ['категория']), rowGroup: 'all', matchMode: 'all', affiliation: REQUIRED_AFFILIATION } }); } if (sites.length && /(?:^|[^А-Яа-яёЁ])ОП(?:[^А-Яа-яёЁ]|$)|обособленн|площадк/i.test(raw) && !replace && !isRemoval) { proposedOperations.push({ type: 'add_site_to_matching_rows', payload: { site: sites[0], sites, legalEntities, direction, functionName, signerFilter: addAttrToSigner ? signerFilter : '', signerFilterName: addAttrToSigner ? signerFilterName : '', rowGroup: 'all', matchMode: 'all', affiliation: REQUIRED_AFFILIATION } }); } if (addAttrToSigner && docTypes.length && !replace && !isRemoval) { proposedOperations.push({ type: 'add_doc_type_to_matching_rows', payload: { newDocType: docTypes[docTypes.length - 1] || docTypes[0], requiredDocTypes: [], matchMode: 'all', signerFilter, signerFilterName, legalEntities, direction, functionName, affiliation: REQUIRED_AFFILIATION } }); } const primarySigner = (signerRanges[0] && signerRanges[0].signer) || roleUsers.signers[0] || users[0] || ''; if (!replace && !addAttrToSigner && !isRemoval && !isTransfer && compact(primarySigner) && (classification.id === 'add_signer_forms' || signerRanges.length || roleUsers.signers.length)) { proposedOperations.push({ type: 'add_signer_forms', payload: { newSigner: primarySigner, signer: primarySigner, limit: (signerRanges[0] && signerRanges[0].to) || amounts[0] || '', amount: (signerRanges[0] && signerRanges[0].amount) || amounts[0] || '', ranges: signerRanges.length ? signerRanges : roleUsers.signers.map((signer, index) => ({ from: index === 0 ? '0' : '', to: amounts[index] || amounts[0] || '', amount: amounts[index] || amounts[0] || '', signer })), legalEntities, direction, functionName, category, conditions: CONDITIONS_STANDARD, affiliation: REQUIRED_AFFILIATION, }, }); // Замену роли предлагаем ТОЛЬКО когда глагол замены стоит РЯДОМ с ролью (а не «замена // воздухоотбойников» — это про оборудование, и не «(Руководитель направления)» — это должность). // Глагол замены должен СТОЯТЬ ВПЛОТНУЮ к роли (0–1 слово между) — «заменить руководителя», а не // «замена воздухоотбойников … Руководитель - Комаров» (там «замена» про оборудование). const roleWord = '(?:руководител|согласующ|спецэксперт|эксперт)[а-яё]*'; const replVerb = '(?:замен|помен|смен|перевест|вместо)[а-яё]*'; const gap = '(?:\\s+[а-яёa-z]+){0,1}\\s+'; if (new RegExp(`${replVerb}${gap}${roleWord}|${roleWord}${gap}${replVerb}`, 'iu').test(raw)) { roleUsers.specialExperts.forEach(user => proposedOperations.push({ type: 'replace_special_expert', payload: { newApprover: user, role: 'Спецэксперт', legalEntities, direction, functionName, category } })); roleUsers.managers.forEach(user => proposedOperations.push({ type: 'replace_manager', payload: { newApprover: user, role: 'Руководитель', legalEntities, direction, functionName, category } })); } } else if (!isRemoval && !addAttrToSigner && classification.id === 'add_doc_type') { proposedOperations.push({ type: 'add_doc_type_to_matching_rows', payload: { newDocType: docTypes[0] || '', requiredDocTypes: docTypes, matchMode: 'all', affiliation: REQUIRED_AFFILIATION } }); } else if (!isRemoval && classification.id === 'add_legal_entity') { proposedOperations.push({ type: 'add_legal_entity_to_matching_rows', payload: { legalEntity: legalEntities[0] || '', legalEntities, affiliation: REQUIRED_AFFILIATION } }); } else if (classification.id === 'route_diagnostics' || classification.id === 'constructor_issue') { proposedOperations.push({ type: 'checklist_route_failure', payload: { rawText: raw, links } }); } else if (classification.id === 'create_category') { proposedOperations.push({ type: 'create_category_from_template', payload: { rawText: raw, legalEntities, docTypes, affiliation: REQUIRED_AFFILIATION } }); } if (isDevelopmentProject && !isRemoval) { proposedOperations.push({ type: 'create_development_project', payload: { project: extractLabeledValue(raw, ['проект']) || '', categories: DEVELOPMENT_CATEGORIES.slice(), legalEntities, direction: direction || 'Дирекция развития', functionName, affiliation: REQUIRED_AFFILIATION, }, }); } // ВОПРОСЫ ОПЕРАТОРУ из единой базы (Артём: «пусть задаёт много вопросов, чтобы не было недосказанностей»). const addsSigner = proposedOperations.some(op => op.type === 'add_signer_forms'); const clarifications = buildClarifications({ source: 'zayavka', kind: classification.id, addsSigner, isRemoval, replace, signerIntent: signerVerb, singleSurname, replaceVerb: /(?:заменить|замени|поменять|сменить|вместо|перевод|перевест)/i.test(raw), onName: /\sна\s+[А-ЯЁ][а-яё]/u.test(raw), vmesto: /вместо/i.test(raw), level: /уров/i.test(raw), signers: roleUsers.signers, signersCount: roleUsers.signers.length, signerRanges, direction, functionName, category, legalEntities, sites, amounts, docTypes, signerFilter, internal: /вго|внутригрупп|внутрихолдинг|вн\s*=?\s*да/i.test(raw) ? true : null, dealType: /доходн/i.test(raw) ? 'Доходная' : /расходн/i.test(raw) ? 'Расходная' : '', unknown: !proposedOperations.length && !isRemoval && classification.id === 'manual_review', }); return Object.assign({}, parsed, { caseType: parsed.caseType || (isDevelopmentProject ? 'development_project' : classification.id), confidence: Math.min(0.98, Math.max(Number(parsed.confidence) || 0, classification.score || 0) + (signerRanges.length ? 0.12 : 0) + (roleUsers.specialExperts.length || roleUsers.managers.length ? 0.08 : 0)), links, incidentId: incident ? incident[1] : '', missing, clarifications, initiator: structured.initiator, needsClarification: missing, // Наш разбор (подписант/замена/ЮЛ/ОП) приоритетнее legacy-диагностики маршрута; // legacy используем только когда своих операций нет — И только просеяв его от мусорных подписантов // (легаси-парсер иногда кладёт newSigner со связками: «а Соснину на Никифорову и лимит до 200мл.»). proposedOperations: proposedOperations.length ? proposedOperations : (parsed.proposedOperations || []).filter(op => { const s = compact((op && op.payload && (op.payload.newSigner || op.payload.signer)) || ''); if (!s) return true; // операции без подписанта (ЮЛ/тип/диагностика) не трогаем return /^[А-ЯЁA-Z]/.test(s) && s.split(/\s+/).length <= 5 && !/\s(?:на|и|или|до|от|лимит[а-яё]*|где|строчк[а-яё]*)\s/i.test(' ' + s + ' '); }), suggestedFirstLineResponse: missing.length ? `Нужно уточнить: ${missing.join(', ')}.` : 'Данных достаточно для preview. Перед apply проверьте найденные строки и отчёт.', understood: { requestType: parsed.caseType || classification.label, users, signers: roleUsers.signers, specialExperts: roleUsers.specialExperts, managers: roleUsers.managers, signerRanges, legalEntities, sites, replace, initiator: structured.initiator, oldUser: structured.oldUser, newUser: structured.newUser, clarifications, docTypes, limits, amounts, links, incidentId: incident ? incident[1] : '', direction, functionName, category, defaultConditions: CONDITIONS_STANDARD.slice(), documentTypeGroups: DocumentTypePresetEngine.groups(), developmentProjectCategories: isDevelopmentProject ? DEVELOPMENT_CATEGORIES.slice() : [], }, }); }, }; // ===== Pattern engine: reads the real signer-row structure of an OpenText matrix ===== // Signer cell = { edsDocTypeCode, performerList:[id] }: CS => Единый ЭДО, no => не единый. // Ranges live in limit_contract/sum_rub as [from,to]; null = 0 или ∞. function toNumber(value) { if (value == null || value === '') return null; const n = Number(String(value).replace(/[^\d.-]/g, '')); return Number.isFinite(n) ? n : null; } function rangeOf(cell) { if (Array.isArray(cell)) return { from: toNumber(cell[0]), to: toNumber(cell[1]) }; return { from: null, to: null }; } function edoVariant(code, edsValues) { const c = String(code || '').toLowerCase(); if (c === 'cs') return 'edo'; if (c === 'no') return 'non_edo'; const joined = normalize((edsValues || []).join(' ')); if (joined.includes('единый')) return 'edo'; if (joined.includes('внешн') || joined === 'нет' || joined.includes(' нет')) return 'non_edo'; return 'other'; } function fmtMoney(value) { if (value == null) return '∞'; return Number(value).toLocaleString('ru-RU'); } // Человеко-читаемый диапазон. ВАЖНО: нижняя граница null = 0 (а не ∞!), верхняя null = ∞. // Так «[∞–5 000 000]» (пугало оператора) превращается в понятное «до 5 000 000 ₽». function fmtBand(from, to) { const f = from == null ? 0 : Number(from); const t = to == null ? Infinity : Number(to); if (f === 0 && !isFinite(t)) return 'любая сумма'; if (f === 0) return `до ${fmtMoney(t)} ₽`; if (!isFinite(t)) return `от ${fmtMoney(f)} ₽`; return `${fmtMoney(f)}–${fmtMoney(t)} ₽`; } function signerRows() { const m = matrix(); if (!m) return []; const cols = { dir: colIndex(['direction', 'Дирекция']), fn: colIndex(['functions', 'Функция']), cat: colIndex(['category', 'Категория']), le: colIndex(['legal_entity', 'legal_entities', 'legal_entity_id', 'Юрлицо', 'Юр. лицо']), partner: colIndex(['partner_id', 'partners_internal_id']), site: colIndex(['partner_op', 'partner_ops', 'site', 'sites', 'op', 'Обособленное подразделение', 'Площадка']), dt: colIndex(['document_type', 'Тип документа']), sum: colIndex(['sum_rub', 'amount']), lim: colIndex(['limit_contract', 'limit']), eds: colIndex(['eds', 'edo', 'ЭДО']), sign: colIndex(['Подписание', 'signing']), }; const out = []; getItems().forEach((item, index) => { const cell = cols.sign >= 0 ? item[cols.sign] : null; const performerList = cell && Array.isArray(cell.performerList) ? cell.performerList : []; if (!performerList.length) return; const edsValues = cols.eds >= 0 ? valueAsList(item[cols.eds]) : []; const legalEntities = cols.le >= 0 ? valueAsList(item[cols.le]) : []; const partnerIds = cols.partner >= 0 ? valueAsList(item[cols.partner]) : []; const partnerNames = partnerIds.map(id => (m.partnerCacheObject && (m.partnerCacheObject[id] || m.partnerCacheObject[Math.abs(Number(id))])) || id); const docTypes = cols.dt >= 0 ? valueAsList(item[cols.dt]) : []; const packageClass = classifyDocPackage(docTypes).key; const groups = packageClass === 'main' ? ['main_contract_rows'] : packageClass === 'supplemental' ? ['supplemental_rows'] : packageClass === 'full' ? ['main_contract_rows', 'supplemental_rows'] : ['custom']; out.push({ rowNumber: index + 1, index, code: cell ? cell.edsDocTypeCode : '', edo: edoVariant(cell ? cell.edsDocTypeCode : '', edsValues), signers: performerList.map(id => userObject(id, 'signer', 'matrix')), directions: cols.dir >= 0 ? valueAsList(item[cols.dir]) : [], functions: cols.fn >= 0 ? valueAsList(item[cols.fn]) : [], categories: cols.cat >= 0 ? valueAsList(item[cols.cat]) : [], legalEntities, partnerIds, partnerNames, legalAll: unique([].concat(legalEntities, partnerNames)), sites: cols.site >= 0 ? valueAsList(item[cols.site]) : [], docTypes, groups, limit: rangeOf(cols.lim >= 0 ? item[cols.lim] : null), amount: rangeOf(cols.sum >= 0 ? item[cols.sum] : null), edsValues, }); }); return out; } function hasRangeValue(range) { return Boolean(range && (range.from != null || range.to != null)); } function isSupplementalDocType(value) { return /(^|[\s;])дс($|[\s;])|доп|спецификац|соглаш|уведомлен/i.test(normalize(value)); } function rowRangeForDocType(row, docType) { const docs = Array.isArray(row && row.docTypes) ? row.docTypes : []; const supplemental = docType ? isSupplementalDocType(docType) : docs.some(isSupplementalDocType); const primary = supplemental ? row.amount : row.limit; const fallback = supplemental ? row.limit : row.amount; return hasRangeValue(primary) ? primary : (hasRangeValue(fallback) ? fallback : (primary || fallback || { from: null, to: null })); } function rangeCoversAmount(range, amount) { if (!amount) return true; const band = range || { from: null, to: null }; return (band.from == null ? 0 : band.from) <= amount && (band.to == null ? Infinity : band.to) >= amount; } // Multi-value aware: `want` may be a single value OR a comma/semicolon list (multi-select). // A row qualifies if ANY of the wanted values touches ANY of the row's values. function softMatch(values, want) { if (!want) return true; const wants = parseList(want).map(normalize).filter(Boolean); if (!wants.length) return true; const got = (values || []).map(normalize).filter(Boolean); // Пустая ячейка СТРОКИ = «ВСЁ» (применяется к любому значению) — как в матрице: пусто = все. if (!got.length) return true; return wants.some(w => (values || []).some(v => textMatches(v, w))); } // Check that a set of [from,to] ranges covers 0..∞ without GAPS (overlaps are normal: // a senior signer can also sign smaller amounts, so we only flag uncovered bands). function coverageIssues(ranges) { const sorted = ranges .map(r => ({ from: r.from == null ? 0 : r.from, to: r.to == null ? Infinity : r.to })) .sort((a, b) => a.from - b.from || a.to - b.to); if (!sorted.length) return ['нет ни одного диапазона.']; const issues = []; if (sorted[0].from > 0) issues.push(`не покрыт старт: нет диапазона от 0 (первый — от ${fmtMoney(sorted[0].from)} ₽).`); let reach = sorted[0].to; for (let i = 1; i < sorted.length; i += 1) { const cur = sorted[i]; if (cur.from > reach) issues.push(`дыра в диапазонах: ${fmtMoney(reach)} → ${fmtMoney(cur.from)} ₽ никем не покрыт.`); reach = Math.max(reach, cur.to); } if (reach !== Infinity) issues.push(`не покрыт хвост: после ${fmtMoney(reach)} ₽ нет диапазона «до ∞».`); return issues; } // «Основной договор» is an A-only marker, «Уведомление о факторинге» a B-only marker, // so each signer row classifies cleanly into a document package. function classifyDocPackage(docTypes) { const set = (docTypes || []).map(normalize); const hasMain = set.includes(normalize('Основной договор')); const hasSupp = set.includes(normalize('Уведомление о факторинге')); if (hasMain && hasSupp) return { key: 'full', label: 'Полный пакет', documentTypes: unique(DOC_GROUP_A.concat(DOC_GROUP_B)) }; if (hasMain) return { key: 'main', label: 'Основной пакет', documentTypes: DOC_GROUP_A.slice() }; if (hasSupp) return { key: 'supplemental', label: 'Подчинённый пакет', documentTypes: DOC_GROUP_B.slice() }; return { key: 'other', label: 'Прочее', documentTypes: (docTypes || []).slice() }; } function edoFormLabel(edo) { return edo === 'edo' ? 'Единый ЭДО' : 'Не единый ЭДО'; } const MatrixPatternEngine = { signerRows, audit(filter = {}) { const rows = signerRows().filter(row => softMatch(row.directions, filter.direction) && softMatch(row.functions, filter.function || filter.functionName) && softMatch(row.categories, filter.category) && (!filter.legalEntity || softMatch(row.legalEntities.concat(row.directions), filter.legalEntity) || !row.legalEntities.length)); return MatrixPatternEngine.auditRows(rows, filter); }, // Per-signer аудит по УЖЕ отфильтрованным строкам (ЮЛ-корректным). Считает на каждого подписанта // ожидаемые 4/2 формы (по образцу матрицы), недостающие формы и дыры лесенки 0→∞. auditRows(rows, filter = {}) { const isJunkSigner = signer => !signer || !signer.fio || signer.fio === '-1' || signer.unresolved || signer.technical || /^-?\d+$/.test(signer.fio); const bySigner = new Map(); rows.forEach(row => { row.signers.forEach(signer => { if (isJunkSigner(signer)) return; const key = normalize(signer.fio || signer.id); if (!bySigner.has(key)) bySigner.set(key, { signer, rows: [] }); bySigner.get(key).rows.push(row); }); }); const edoPresent = new Set(rows.map(row => row.edo)); const expectedVariants = []; if (edoPresent.has('edo')) expectedVariants.push('edo'); if (edoPresent.has('non_edo')) expectedVariants.push('non_edo'); // Expected forms per signer come from the LEARNED matrix pattern (e.g. 2 vs 4 forms). const pattern = MatrixPatternEngine.learnSignerPattern(filter); const usePattern = pattern.learned && pattern.forms.length > 0; const template = usePattern ? pattern.forms.map(form => ({ edo: form.edo, packageClass: form.packageClass, formLabel: form.formLabel, documentTypes: form.documentTypes })) : expectedVariants.map(variant => ({ edo: variant, packageClass: 'any', formLabel: edoFormLabel(variant), documentTypes: [] })); const signers = Array.from(bySigner.values()).map(entry => { const variants = new Set(entry.rows.map(row => row.edo)); const have = new Set(entry.rows.map(row => `${row.edo}|${classifyDocPackage(row.docTypes).key}`)); // subset-aware: «Полный пакет» covers main+supplemental; main+supplemental together cover full. const covers = (edo, pkg) => { if (!usePattern) return variants.has(edo); const has = key => have.has(`${edo}|${key}`); if (pkg === 'main') return has('main') || has('full'); if (pkg === 'supplemental') return has('supplemental') || has('full'); if (pkg === 'full') return has('full') || (has('main') && has('supplemental')); return has(pkg) || has('full'); }; const missingForms = template.filter(form => !covers(form.edo, form.packageClass)); return { fio: entry.signer.fio, display: entry.signer.display, unresolved: entry.signer.unresolved, count: entry.rows.length, hasEdo: variants.has('edo'), hasNonEdo: variants.has('non_edo'), expectedForms: template.length, missingForms, missingVariants: missingForms.map(form => form.formLabel), ranges: entry.rows.map(row => ({ edo: row.edo, limit: row.limit, amount: row.amount, rowNumber: row.rowNumber, })).sort((a, b) => (a.limit.from || 0) - (b.limit.from || 0)), }; }).sort((a, b) => (a.ranges[0] && a.ranges[0].limit.from || 0) - (b.ranges[0] && b.ranges[0].limit.from || 0)); const issues = []; signers.forEach(signer => { if (signer.missingVariants.length) issues.push(`${signer.fio}: не хватает — ${signer.missingVariants.join('; ')}.`); }); expectedVariants.forEach(variant => { const seen = new Set(); const variantRanges = []; rows.filter(row => row.edo === variant).forEach(row => { const key = `${row.limit.from == null ? 0 : row.limit.from}-${row.limit.to == null ? '∞' : row.limit.to}`; if (seen.has(key)) return; seen.add(key); variantRanges.push(row.limit); }); coverageIssues(variantRanges).forEach(text => { issues.push(`${edoFormLabel(variant)}: ${text}`); }); }); return { matchedRows: rows.length, expectedVariants, expectedFormsPerSigner: template.length, patternNote: usePattern ? pattern.note : '', signers, issues, ok: issues.length === 0 && rows.length > 0, }; }, // Learn the actual signer-row pattern of a ДФК slice from existing rows, instead of // assuming a fixed "4 forms". «Основной договор» is an A-only marker, «Уведомление о // факторинге» a B-only marker, so each row classifies cleanly into a package. learnSignerPattern(filter = {}) { const hasFilter = [filter.direction, filter.function || filter.functionName, filter.category, filter.legalEntity].some(value => compact(value)); if (!hasFilter) { return { matchedRows: 0, signersConsidered: 0, forms: [], formsPerSigner: 0, learned: false, note: 'Укажите дирекцию/функцию/категорию — и формы подберутся по образцу этой матрицы. Сейчас — стандарт (4 формы).' }; } const rows = signerRows().filter(row => softMatch(row.directions, filter.direction) && softMatch(row.functions, filter.function || filter.functionName) && softMatch(row.categories, filter.category) && (!filter.legalEntity || softMatch(row.legalEntities, filter.legalEntity) || !row.legalEntities.length)); const combos = new Map(); let considered = 0; rows.forEach(row => { if (row.signers.every(signer => !signer.fio || signer.unresolved || signer.technical || /^-?\d+$/.test(signer.fio))) return; considered += 1; const cls = classifyDocPackage(row.docTypes); if (cls.key === 'other') return; const key = `${row.edo}|${cls.key}`; if (!combos.has(key)) combos.set(key, { edo: row.edo, packageClass: cls.key, label: cls.label, documentTypes: cls.documentTypes, count: 0 }); combos.get(key).count += 1; }); const all = Array.from(combos.values()); const maxCount = all.reduce((max, item) => Math.max(max, item.count), 0); const forms = all .filter(item => item.count >= Math.max(2, maxCount * 0.15)) .sort((a, b) => a.packageClass.localeCompare(b.packageClass) || a.edo.localeCompare(b.edo)) .map(item => Object.assign({}, item, { edoLabel: item.edo === 'edo' ? 'Единый ЭДО' : item.edo === 'non_edo' ? 'Не единый ЭДО' : 'ЭДО', formLabel: `${item.label} · ${item.edo === 'edo' ? 'Единый ЭДО' : 'Не единый ЭДО'}`, })); const packages = unique(forms.map(form => form.packageClass)); let note; if (!forms.length) note = 'В матрице нет образца по этим фильтрам — будет использован стандарт (4 формы).'; else if (packages.length === 1 && packages[0] === 'full') note = `Матрица использует объединённый пакет документов: ${forms.length} формы (Единый / не единый ЭДО).`; else note = `Матрица разделяет пакеты документов: ${forms.length} форм по образцу матрицы.`; return { matchedRows: rows.length, signersConsidered: considered, forms, formsPerSigner: forms.length, note, learned: forms.length > 0 }; }, // Turn audit gaps into the concrete rows that should be created to make the slice complete: // every signer gets the exact missing form(s) — package + ЭДО — for each of their ranges. buildMissingForms(audit) { const out = []; (audit.signers || []).forEach(signer => { (signer.missingForms || []).forEach(form => { const label = form.formLabel; const docs = (form.documentTypes || []).join(', '); const seen = new Set(); signer.ranges.forEach(range => { const key = `${range.limit.from == null ? 0 : range.limit.from}-${range.limit.to == null ? '∞' : range.limit.to}`; if (seen.has(key)) return; seen.add(key); out.push({ actionType: 'add-row', status: 'ok', title: `${signer.fio} · ${label}`, rowNo: `${signer.fio}`, reason: `Добавить строку: ${label}, лимит ${range.limit.from == null ? '0' : fmtMoney(range.limit.from)}–${fmtMoney(range.limit.to)} ₽${docs ? ` · документы: ${docs}` : ''}.`, }); }); }); }); return out; }, }; // ===== «Мозг»: сверка намерения с матрицей → прозрачный план (дописать / создать / разделить) ===== function signerOpPayload(intent, le) { const signerId = intent.signerId ? String(intent.signerId).replace(/^-/, '') : resolveSignerId(intent.signer || intent.newSigner || ''); return { newSigner: intent.signer || intent.newSigner || '', signer: intent.signer || intent.newSigner || '', newSignerId: signerId, signerId, legalEntities: le === ANY_LE ? parseList(intent.legalEntities || intent.legalEntity || '') : [le], legalEntityIds: (le === ANY_LE ? parseList(intent.legalEntities || intent.legalEntity || '') : [le]).map(name => legalEntityId(name)).filter(Boolean), direction: intent.direction || '', functionName: intent.function || intent.functionName || '', category: intent.category || '', dealInternal: intent.internal === true, ranges: Array.isArray(intent.ranges) && intent.ranges.length ? intent.ranges.map(range => Object.assign({}, range, { signerId: range.signerId || signerId })) : [], affiliation: REQUIRED_AFFILIATION, }; } const ANY_LE = '(любое ЮЛ)'; const ReconcileEngine = { analyze(intent = {}) { const dir = compact(intent.direction || ''); const fn = compact(intent.function || intent.functionName || ''); const cat = compact(intent.category || ''); const signerName = compact(intent.signer || intent.newSigner || ''); // Резолвим ID нового подписанта СРАЗУ — чтобы нарезка/сплит применялись без ручного выбора из списка. const signerId = intent.signerId ? String(intent.signerId).replace(/^-/, '') : resolveSignerId(signerName); const ranges = Array.isArray(intent.ranges) && intent.ranges.length ? intent.ranges.map(r => ({ from: toNumber(r.from), to: toNumber(r.to == null ? r.limit : r.to) })) : []; const legals = parseList(intent.legalEntities || intent.legalEntity || ''); const dfkRows = signerRows().filter(row => softMatch(row.directions, dir) && softMatch(row.functions, fn) && softMatch(row.categories, cat)); const targets = legals.length ? legals : [ANY_LE]; const sameSigner = row => signerName && row.signers.some(s => normalize(s.fio) === normalize(signerName)); const isComboRow = row => (row.partnerIds.length + row.legalEntities.length) > 1; const perLegalEntity = targets.map(le => { const isAny = le === ANY_LE; const rows = dfkRows.filter(row => isAny || !row.legalAll.length || row.legalAll.some(x => softMatch([x], le))); const existingSigners = unique([].concat.apply([], rows.map(row => row.signers.map(s => s.fio)))).filter(Boolean); const comboRows = rows.filter(isComboRow); const bands = rows.map(row => row.limit).filter(b => b && (b.from != null || b.to != null)); const gaps = bands.length ? coverageIssues(bands) : []; const decisions = []; // Лимитная лесенка: запрошенная банда строго внутри более широкой банды ДРУГОГО подписанта → вырезать. const carves = []; if (signerName && ranges.length) { ranges.forEach(rng => { const A = rng.from == null ? 0 : rng.from; const B = rng.to == null ? Infinity : rng.to; rows.forEach(row => { const F = row.limit.from == null ? 0 : row.limit.from; const T = row.limit.to == null ? Infinity : row.limit.to; const otherHolds = row.signers.length && row.signers.every(s => normalize(s.fio) !== normalize(signerName)); if (otherHolds && F <= A && T >= B && (F < A || T > B)) { carves.push({ action: 'carve', reason: `Нарезка: «${(row.signers[0] || {}).fio || 'другой'}» держит ${fmtBand(F, T)} (строка #${row.rowNumber}) → вырезать «${signerName}» под ${fmtBand(A, B)}; соседу остаются края.`, op: { type: 'carve_limit_band', payload: { legalEntity: isAny ? '' : le, direction: dir, functionName: fn, category: cat, bandFrom: A, bandTo: B === Infinity ? '' : B, newSigner: signerName, newSignerId: signerId, rowGroup: 'all', matchMode: 'all', affiliation: REQUIRED_AFFILIATION } } }); } }); }); } // Наложение: банда нового подписанта ЧАСТИЧНО пересекается с диапазоном другого (не чистый carve) → предупредить. const overlaps = []; if (signerName && ranges.length) { ranges.forEach(rng => { const A = rng.from == null ? 0 : rng.from; const B = rng.to == null ? Infinity : rng.to; rows.forEach(row => { const F = row.limit.from == null ? 0 : row.limit.from; const T = row.limit.to == null ? Infinity : row.limit.to; const otherHolds = row.signers.length && row.signers.every(s => normalize(s.fio) !== normalize(signerName)); const intersects = A < T && F < B; const cleanCarve = F <= A && T >= B && (F < A || T > B); if (otherHolds && intersects && !cleanCarve) { const neighbor = row.signers[0] || {}; const neighborName = neighbor.fio || 'другой'; // Перебалансировка лесенки: подвинуть границу соседа на непересекающийся остаток. let newF = null; let newT = null; if (F < A && B >= T) { newF = F; newT = A; } // новый забирает верх → сосед сужается до [F–A] else if (A <= F && B < T) { newF = B; newT = T; } // новый забирает низ → сосед сдвигается до [B–T] const canRebalance = newF != null && newT != null && newT > newF && !(newF === F && newT === T); const tail = canRebalance ? ` Чтобы лесенка осталась чистой — измените «${neighborName}» диапазон [${fmtMoney(F)}–${T === Infinity ? '∞' : fmtMoney(T)}] → [${fmtMoney(newF)}–${newT === Infinity ? '∞' : fmtMoney(newT)}].` : ' Чтобы его не задеть — аккуратно разбейте строки или сузьте банду.'; const entry = { action: 'overlap', reason: `⚠ Наложение: банда [${fmtMoney(A)}–${B === Infinity ? '∞' : fmtMoney(B)}] пересекается с диапазоном [${fmtMoney(F)}–${T === Infinity ? '∞' : fmtMoney(T)}] подписанта «${neighborName}» (строка #${row.rowNumber}).${tail}` }; if (canRebalance && (neighbor.id || neighbor.fio)) { entry.op = { type: 'adjust_signer_limit', payload: { legalEntity: isAny ? '' : le, direction: dir, functionName: fn, category: cat, signer: neighbor.fio || neighbor.id, signerId: neighbor.id ? String(neighbor.id).replace(/^-/, '') : '', fromOld: F === 0 ? '' : F, toOld: T === Infinity ? '' : T, newFrom: newF === 0 ? '' : newF, newTo: newT === Infinity ? '' : newT, rowGroup: 'all', matchMode: 'all', affiliation: REQUIRED_AFFILIATION, } }; } overlaps.push(entry); } }); }); } // «Два подписанта на одном бэнде»: на одном ЮЛ×ДФК×ЭДО×пакете×[from,to] не должно стоять ≥2 разных подписанта. const conflicts = []; const bandKey = row => `${row.edo}|${classifyDocPackage(row.docTypes).key}|${row.limit.from == null ? 0 : row.limit.from}|${row.limit.to == null ? '∞' : row.limit.to}`; const byBand = new Map(); rows.forEach(row => { const key = bandKey(row); if (!byBand.has(key)) byBand.set(key, { rows: [], signers: new Map(), from: row.limit.from, to: row.limit.to }); const g = byBand.get(key); g.rows.push(row.rowNumber); row.signers.forEach(s => { if (s.fio) g.signers.set(normalize(s.fio), s.fio); }); }); // (1) уже существующий дубль на одном бэнде. byBand.forEach(g => { if (g.signers.size >= 2) { const sigList = [...g.signers.values()]; const shown = sigList.slice(0, 3).join(', ') + (sigList.length > 3 ? ` и ещё ${sigList.length - 3}` : ''); conflicts.push({ action: 'conflict', reason: `⚠ Дубль на диапазоне ${fmtBand(g.from, g.to)} (ЮЛ «${le}»): ${g.signers.size} подписанта — ${shown}. На одном диапазоне должен быть один. Строки: ${g.rows.join(', ')}.` }); } }); // (2) наш новый подписант встанет ВТОРЫМ ровно на занятый кем-то бэнд. if (signerName && ranges.length) { ranges.forEach(rng => { const A = rng.from == null ? 0 : rng.from; const B = rng.to == null ? Infinity : rng.to; rows.forEach(row => { const F = row.limit.from == null ? 0 : row.limit.from; const T = row.limit.to == null ? Infinity : row.limit.to; const otherHolds = row.signers.length && row.signers.every(s => normalize(s.fio) !== normalize(signerName)); if (otherHolds && F === A && T === B) { conflicts.push({ action: 'conflict', reason: `⚠ Появится второй подписант: диапазон ${fmtBand(A, B)} на «${le}» уже держит «${(row.signers[0] || {}).fio || 'другой'}» (строка #${row.rowNumber}). Если «${signerName}» не замена — это дубль.` }); } }); }); } if (!rows.length) { decisions.push({ action: 'create', reason: `Нет ни одной строки на ЮЛ «${le}» × ДФК — создать формы подписания${signerName ? ` для «${signerName}»` : ''}.`, op: { type: 'add_signer_forms', payload: signerOpPayload(intent, le) } }); } else if (sameSigner(rows[0]) || rows.some(sameSigner)) { decisions.push({ action: 'skip', reason: `«${signerName}» уже стоит на «${le}» × ДФК (строки ${rows.filter(sameSigner).map(r => r.rowNumber).join(', ')}). Проверьте диапазоны.` }); } else if (carves.length) { carves.slice(0, 3).forEach(c => decisions.push(c)); // нарезка приоритетнее простого создания } else if (signerName && comboRows.length) { decisions.push({ action: 'split', reason: `ЮЛ «${le}» в комбинации с другими (строки ${comboRows.map(r => r.rowNumber).join(', ')}). Чтобы поставить «${signerName}» только на него — вынесу в отдельную строку и назначу подписанта (разбивка попадёт в план автоматически); остальным ЮЛ ничего не меняется.`, op: { type: 'split_legal_entity_to_new_row', payload: { legalEntity: le, newSigner: signerName, newSignerId: signerId, rowGroup: 'all', matchMode: 'all', affiliation: REQUIRED_AFFILIATION } } }); } else if (signerName) { decisions.push({ action: 'create', reason: `На «${le}» × ДФК уже есть подписант(ы): ${existingSigners.join(', ') || '—'}. Добавить «${signerName}» — создать формы (или заменить на экране «Подписанты»).`, op: { type: 'add_signer_forms', payload: signerOpPayload(intent, le) } }); } const seenConflict = new Set(); conflicts.forEach(c => { if (!seenConflict.has(c.reason)) { seenConflict.add(c.reason); decisions.push(c); } }); // дубль-подписант — критично, показываем всегда overlaps.slice(0, 3).forEach(o => decisions.push(o)); // предупреждения о наложении показываем всегда if (ranges.length && gaps.length && !/нет ни одного/.test(gaps[0] || '')) { decisions.push({ action: 'ladder', reason: `Лимитная лесенка на «${le}»: ${gaps.join(' ')} Рассмотрите создание строки на непокрытую банду.` }); } return { legalEntity: le, rows: rows.map(row => ({ rowNumber: row.rowNumber, signers: row.signers.map(s => s.fio), limit: fmtBand(row.limit.from, row.limit.to), edo: row.edo })), existingSigners, isCombo: comboRows.length > 0, decisions, }; }); const summary = { create: 0, split: 0, carve: 0, overlap: 0, conflict: 0, skip: 0, ladder: 0 }; perLegalEntity.forEach(le => le.decisions.forEach(d => { summary[d.action] = (summary[d.action] || 0) + 1; })); return { signer: signerName, direction: dir, function: fn, category: cat, internal: intent.internal === true, perLegalEntity, summary }; }, }; // Собрать намерение из разобранной заявки/письма (understood) для сверки с матрицей. function intentFromUnderstood(understood, text) { const u = understood || {}; const signer = (u.replace && u.replace.to) || (u.signers && u.signers[0]) || (u.signerRanges && u.signerRanges[0] && u.signerRanges[0].signer) || ''; const internal = /вго|внутригрупп|внутрихолдинг|вн\s*=?\s*да/i.test(String(text || '')) || (u.category ? Boolean((SvodStore.classify(u.category) || {}).internal) : false); return { signer, legalEntities: u.legalEntities || [], direction: u.direction || '', function: u.functionName || '', category: u.category || '', internal, ranges: (u.signerRanges || []).map(r => ({ from: r.from, to: r.to })), }; } // Человеческое объяснение операции: ЧТО сделаю и ПОЧЕМУ (мозг «проговаривает» логику — Артём просил, // чтобы скрипт подробно описывал, как он рассуждает, а не просто выдавал тип операции). function explainOperation(op) { const p = (op && op.payload) || {}; const slice = [p.direction, p.functionName || p.function, p.category].filter(Boolean).join(' / '); const le = [].concat(p.legalEntities || [], p.legalEntity || []).filter(Boolean).join(', '); const where = [slice && `ДФК ${slice}`, le && `ЮЛ ${le}`, p.signerFilterName && `подписант ${p.signerFilterName}`].filter(Boolean).join('; '); const cond = (p.requiredDocTypes && p.requiredDocTypes.length) ? ` при условии, что строка содержит ${p.matchMode === 'any' ? 'любой из' : 'все'}: ${p.requiredDocTypes.join(', ')}` : ''; switch (op && op.type) { case 'replace_signer_by_name': return `Замена подписанта ${p.currentSignerName || p.currentSigner || '—'} → ${p.newSignerName || p.newSigner || '—'}.${(p.currentSignerId && p.newSignerId) ? ' Обоих нашёл в матрице по фамилии (есть ID) — применю без ручного выбора.' : ' ID не определён однозначно — выбери людей из автоподсказки перед применением.'} Патчу ТОЛЬКО колонку подписанта в строках${where ? ` (${where})` : ''}; колонки Договор/Нет, лимиты и типы документов не трогаю.`; case 'add_signer_forms': return `Создаю формы подписания для «${p.newSigner || p.signer || 'подписанта'}»${p.limit || p.amount ? ` (лимит до ${p.limit || p.amount} ₽)` : ''}${where ? ` на срезе ${where}` : ''}. ${p.patternLearned ? 'Строю по образцу ЭТОЙ матрицы (выученный паттерн ДФК), не «4 формы вслепую».' : 'Стандартный набор форм ЕЭДО / не-ЕЭДО.'}`; case 'split_legal_entity_to_new_row': return `Выношу ЮЛ «${p.legalEntity || le}» в отдельную строку-клон${p.newSigner ? `, ставлю на неё «${p.newSigner}»` : ''}. Так меняю только это ЮЛ — у остальных в комбинированной строке ничего не теряется.`; case 'add_doc_type_to_matching_rows': return `Добавляю тип документа «${p.newDocType || p.docType || ''}»${where ? ` в строки по срезу ${where}` : ' в подходящие строки'}${cond}.`; case 'remove_doc_type_from_matching_rows': return `Удаляю тип документа «${p.removeDocType || p.docType || ''}»${where ? ` из строк по срезу ${where}` : ' из подходящих строк'}${cond}.`; case 'add_site_to_matching_rows': return `Добавляю ОП/площадку «${p.site || (p.sites || []).join(', ')}»${where ? ` (${where})` : ''}${cond}.`; case 'remove_site_from_matching_rows': return `Удаляю ОП/площадку «${p.removeSite || p.site || (p.sites || []).join(', ')}»${where ? ` (${where})` : ''}${cond}.`; case 'add_legal_entity_to_matching_rows': return `Добавляю ЮЛ «${p.legalEntity || le}» в подходящие строки${where ? ` (${where})` : ''}${cond}.`; case 'remove_legal_entity_from_matching_rows': return `Удаляю ЮЛ «${p.removeLegalEntity || p.legalEntity || le}» из подходящих строк${where ? ` (${where})` : ''}${cond}.`; case 'add_change_card_flag_to_matching_rows': return `Проставляю признак изменения карточки «${p.changeCardFlag || 'Ранее не подписан'}»${where ? ` (${where})` : ''}${cond}.`; case 'remove_change_card_flag_from_matching_rows': return `Удаляю признак изменения карточки «${p.removeChangeCardFlag || p.changeCardFlag || 'Ранее не подписан'}»${where ? ` (${where})` : ''}${cond}.`; case 'create_category_from_template': return `Создаю строки под новую категорию «${p.category || p.newCategory || ''}» по образцу ЭТОЙ матрицы (паттерн ДФК — напр. отдельные строки на функции в УИП/птицеводстве).`; case 'remove_category_from_matching_rows': return `Удаляю категорию «${p.removeCategory || p.targetCategory || p.category || ''}» из подходящих строк${where ? ` (${where})` : ''}${cond}.`; case 'create_development_project': return `Проект развития: разворачиваю стандартный набор категорий проекта${p.signer ? ` для «${p.signer}»` : ''}.`; case 'remove_request_manual': return `Удаление/исключение из маршрута. Авто-операции удаления нет — помечаю на РУЧНУЮ правку и СОЗНАТЕЛЬНО не предлагаю add_* (иначе добавил бы того, кого просят убрать).`; case 'replace_manager': case 'replace_special_expert': return `Замена согласующего (${p.role || 'роль'}) на «${p.newApprover || ''}»${where ? ` (${where})` : ''}.`; case 'checklist_route_failure': return 'Похоже на разбор «почему не строится маршрут» — прогоняю карточку через диагностику и показываю, какой строки не хватает.'; default: return `Операция «${(op && op.type) || 'manual_review'}» — требует ручной проверки.`; } } // Сборка пошаговой «логики разбора»: как мозг пришёл к плану (тип → сущности → план → уверенность). function buildReasoning(parsed, reco) { const u = (parsed && parsed.understood) || {}; const steps = []; steps.push({ t: 'Что за запрос', d: `${u.requestType || parsed.caseType || 'требует разбора'} · уверенность ${Math.round((parsed.confidence || 0) * 100)}%.` }); const facts = []; if (u.replace) facts.push(`замена ${u.replace.from} → ${u.replace.to}`); if ((u.signers || []).length) facts.push(`подписанты: ${u.signers.join(', ')}`); if ((u.legalEntities || []).length) facts.push(`ЮЛ: ${u.legalEntities.join(', ')}`); const dfk = [u.direction, u.functionName, u.category].filter(Boolean).join(' / '); if (dfk) facts.push(`ДФК: ${dfk}`); if ((u.signerRanges || []).length) facts.push(`диапазоны: ${u.signerRanges.map(r => `${r.from || '0'}→${r.to}`).join(', ')}`); if ((u.sites || []).length) facts.push(`ОП: ${u.sites.join(', ')}`); if ((u.docTypes || []).length) facts.push(`типы док-тов: ${u.docTypes.join(', ')}`); steps.push({ t: 'Что извлёк из текста', d: facts.length ? facts.join('; ') + '.' : 'ключевых сущностей не нашёл — нужен более полный текст.' }); const ops = (parsed.proposedOperations || []); if (ops.length) ops.forEach((op, i) => steps.push({ t: `План, шаг ${i + 1}`, d: explainOperation(op) })); else steps.push({ t: 'План', d: 'Однозначной операции не собрал — см. вопросы ниже, лучше уточнить.' }); if (reco && reco.summary) steps.push({ t: 'Сверка с матрицей', d: `создать ${reco.summary.create}, разделить ${reco.summary.split}, уже настроено ${reco.summary.skip}.` }); if ((u.clarifications || []).length) steps.push({ t: 'В чём не уверен', d: `${u.clarifications.length} вопрос(ов) ниже — отвечаю «спрошу, а не сделаю наугад».` }); return steps; } // Реальные значения ПОЛЕЙ карточки OpenText лежат в input/select (class="valueEditable", title=метка), // а НЕ в тексте — copy/paste и innerText их не видят. Читаем их прямо из DOM: { метка → значение }. function liveFieldLabel(el) { const direct = compact(el.getAttribute('title') || el.getAttribute('aria-label') || el.getAttribute('data-title') || ''); if (direct) return direct; const id = el.getAttribute('id') || ''; if (id) { const label = document.querySelector(`label[for="${CSS.escape(id)}"]`); if (label && compact(label.textContent || '')) return compact(label.textContent || ''); } const row = el.closest('tr'); if (row) { const cells = Array.from(row.querySelectorAll('th,td')); const idx = cells.findIndex(cell => cell.contains(el)); for (let i = idx - 1; i >= 0; i -= 1) { const txt = compact((cells[i].textContent || '').replace(String(el.value || ''), '')); if (txt && txt.length <= 120) return txt; } } let prev = el.previousElementSibling; for (let i = 0; prev && i < 3; i += 1, prev = prev.previousElementSibling) { const txt = compact(prev.textContent || ''); if (txt && txt.length <= 120) return txt; } return ''; } function readLiveCardFields() { const fields = {}; try { const nodes = document.querySelectorAll('input.valueEditable, textarea.valueEditable, select.valueEditable, input[id^="scAttr"], select[id^="scAttr"], textarea[id^="scAttr"], input[name^="scAttr"], select[name^="scAttr"], textarea[name^="scAttr"]'); nodes.forEach(el => { const key = liveFieldLabel(el); if (!key) return; let val = ''; if (el.tagName === 'SELECT') { const opt = el.options && el.options[el.selectedIndex]; val = opt ? compact(opt.textContent || opt.value || '') : ''; } else val = compact(el.value || ''); if (val && !fields[key]) fields[key] = val; }); // Таблица «Контрагенты» (#sc_partnerSet): ЮЛ — ссылки open//; ОП — колонка «Обособленное подразделение». const table = document.querySelector('#sc_partnerSet'); if (table) { const rows = Array.from(table.querySelectorAll('tr')); const header = rows.length ? Array.from(rows[0].querySelectorAll('th,td')).map(c => normalize(c.textContent || '')) : []; const opCol = header.findIndex(h => h.includes('обособленн')); const le = []; const sites = []; rows.forEach(tr => { const a = tr.querySelector('a[href*="/open/"]'); if (a) { const nm = compact(a.textContent || ''); if (nm && /(?:ООО|АО|ПАО|ОАО|ЗАО|НАО|АНО)(?![А-ЯЁа-яё])/.test(nm)) le.push(nm); } const tds = Array.from(tr.querySelectorAll('td')); if (opCol >= 0 && tds[opCol]) (compact(tds[opCol].textContent || '').match(/[А-ЯЁ][а-яё]+-\d+/g) || []).forEach(s => sites.push(s)); }); if (le.length) fields.__le = unique(le); if (sites.length) fields.__sites = unique(sites); } } catch (_) { /* DOM полей карточки недоступен */ } return fields; } // ===== Кеш матриц: при открытии матрицы кладём её срез (ДФК/ЮЛ/подписанты/лимиты на строку) в // localStorage, чтобы потом с КАРТОЧКИ (где матрицы нет) находить маршруты. Ключ otk_mcache_v1:. ===== const MATRIX_CACHE_PREFIX = 'otk_mcache_v1:'; const MATRIX_CACHE_MAX = 20; const MatrixCache = { save() { const store = svodStorage(); if (!store) return { ok: false, reason: 'no-storage' }; const ctx = ContextDetector.detect() || {}; if (ctx.kind !== 'matrix') return { ok: false, reason: 'not-matrix' }; const rows = signerRows().map(r => ({ n: r.rowNumber, dir: r.directions, fn: r.functions, cat: r.categories, le: r.legalAll, docTypes: r.docTypes, sites: r.sites, edo: r.edo, groups: r.groups, signers: (r.signers || []).map(s => s.fio).filter(Boolean), from: r.limit ? r.limit.from : null, to: r.limit ? r.limit.to : null, amountFrom: r.amount ? r.amount.from : null, amountTo: r.amount ? r.amount.to : null, })); if (!rows.length) return { ok: false, reason: 'no-rows' }; const id = compact(ctx.matrixId) || normalize(ctx.title) || 'm'; const entry = { id, name: ctx.title || 'Матрица', url: (host && host.location ? host.location.href : ''), savedAt: Date.now(), rows }; try { store.setItem(MATRIX_CACHE_PREFIX + id, JSON.stringify(entry)); } catch (_) { return { ok: false, reason: 'quota' }; } MatrixCache.prune(store); return { ok: true, id, rows: rows.length, name: entry.name }; }, all() { const store = svodStorage(); const out = []; if (!store) return out; try { for (let i = 0; i < store.length; i += 1) { const k = store.key(i); if (k && k.indexOf(MATRIX_CACHE_PREFIX) === 0) { try { out.push(JSON.parse(store.getItem(k))); } catch (_) { /* битый */ } } } } catch (_) { /* ignore */ } return out.filter(Boolean).sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0)); }, prune(store) { try { const keys = []; for (let i = 0; i < store.length; i += 1) { const k = store.key(i); if (k && k.indexOf(MATRIX_CACHE_PREFIX) === 0) keys.push(k); } if (keys.length <= MATRIX_CACHE_MAX) return; keys.map(k => { let t = 0; try { t = (JSON.parse(store.getItem(k)) || {}).savedAt || 0; } catch (_) { /* */ } return { k, t }; }) .sort((a, b) => a.t - b.t).slice(0, keys.length - MATRIX_CACHE_MAX) .forEach(e => { try { store.removeItem(e.k); } catch (_) { /* */ } }); } catch (_) { /* ignore */ } }, }; // Найти маршруты для карточки в КЕШЕ матриц (по ДФК + ЮЛ). По каждой подходящей матрице: какие строки // совпали и под какими ЮЛ строк не хватает. function findRoutesInCache(card) { const c = card || {}; const dir = c.direction || ''; const fn = c.functionName || ''; const cat = c.category || ''; const dfkProvided = Boolean(compact(dir) || compact(fn) || compact(cat)); const les = unique((c.legalEntities || []).filter(Boolean)); const docType = c.contractType || c.docType || ''; const sites = unique((c.sites || []).filter(Boolean)); const amount = c.amountValue != null ? c.amountValue : toNumber(c.amount || c.limit || ''); const matrices = MatrixCache.all(); const results = []; matrices.forEach(m => { const dfkRows = (m.rows || []).filter(r => softMatch(r.dir, dir) && softMatch(r.fn, fn) && softMatch(r.cat, cat) && (!docType || softMatch(r.docTypes || [], docType)) && (!sites.length || !(r.sites || []).length || sites.some(site => softMatch(r.sites || [], site)))); if (!dfkRows.length) return; const perLe = (les.length ? les : ['(любое ЮЛ)']).map(le => { const isAny = le === '(любое ЮЛ)'; const leRows = dfkRows.filter(r => isAny || !(r.le || []).length || (r.le || []).some(x => softMatch([x], le))); let covered = true; if (amount && leRows.length) { covered = leRows.some(r => { const range = rowRangeForDocType({ docTypes: r.docTypes || [], limit: { from: r.from, to: r.to }, amount: { from: r.amountFrom, to: r.amountTo } }, docType); return rangeCoversAmount(range, amount); }); } return { le, rowsFound: leRows.length, rowNumbers: leRows.map(r => r.n).slice(0, 15), covered, docTypeMatched: !docType || leRows.some(r => softMatch(r.docTypes || [], docType)), siteMatched: !sites.length || leRows.some(r => !(r.sites || []).length || sites.some(site => softMatch(r.sites || [], site))), }; }); results.push({ matrixName: m.name, matrixId: m.id, url: m.url, savedAt: m.savedAt, dfkRows: dfkRows.length, docType, sites, perLe }); }); results.sort((a, b) => b.perLe.filter(x => x.rowsFound).length - a.perLe.filter(x => x.rowsFound).length); return { dfkProvided, matricesCached: matrices.length, dfkLabel: [dir, fn, cat].filter(Boolean).join(' / '), results }; } // ===== Card intake: paste the raw card text, get ДФК/ЮЛ/тип сделки/суммы to filter the matrix ===== const CardIntake = { parse(text = '', domFields = {}) { const raw = String(text || ''); // domFields — реальные значения из ПОЛЕЙ карточки (input[title]=value), которых нет в тексте/копипасте. // Если переданы — перебивают текстовую выемку для ДФК/лимита/типа. const dom = domFields || {}; const domVal = (...keys) => { for (const k of keys) { const v = compact(dom[k] || ''); if (v) return v; } const entries = Object.keys(dom).map(key => ({ key, value: dom[key], match: matchKey(key) })).filter(item => item.match); for (const wanted of keys.map(matchKey).filter(Boolean)) { const exact = entries.find(item => item.match === wanted); if (exact && compact(exact.value || '')) return compact(exact.value); const loose = entries.find(item => item.match.includes(wanted) || wanted.includes(item.match)); if (loose && compact(loose.value || '')) return compact(loose.value); } return ''; }; const dict = DictionaryBuilder.build(); const rawDictKey = matchKey(raw); const findFromDict = (list) => (list || []).find(value => { const key = matchKey(value); return key.length >= 4 && rawDictKey.includes(key); }) || ''; const direction = domVal('Дирекция') || extractDfkValue(raw, ['дирекция'], 'дирекци|направлени') || findFromDict(dict.directions); const functionName = domVal('Функция') || extractDfkValue(raw, ['функция', 'дф', 'направление'], 'функци') || findFromDict(dict.functions); const category = domVal('Категория', 'Категория затрат') || extractDfkValue(raw, ['категория', 'категория затрат'], 'категори') || findFromDict(dict.categories); // ЮЛ: из DOM-таблицы «Контрагенты» (надёжно), иначе из текста. Убираем мусор-холдинг «Группа Черкизово». const leSource = (Array.isArray(dom.__le) && dom.__le.length) ? dom.__le : extractLegalEntitiesFromText(raw); const legalEntities = leSource.filter(le => !/^[«"]?черкизово[»"]?$/i.test(normalize(le)) && !/группа\s*[«"]?черкизово/i.test(normalize(le))); const counterparties = legalEntities; // Тип сделки берём по ЗНАЧЕНИЮ («Доходная/Расходная»), а не по слову-метке «доходности». const dealType = /(?:^|[^а-яё])доходн(?:ая|ый|ое)/i.test(raw) ? 'Доходная' : /(?:^|[^а-яё])расходн(?:ая|ый|ое)/i.test(raw) ? 'Расходная' : /(?:^|[^а-яё])иное(?:[^а-яё]|$)/i.test(raw) ? 'Иное' : ''; // ВГО — по ЗНАЧЕНИЮ аффилированности «Группа Черкизово» (а не по метке «Аффилированность», что есть всегда). const vnMatch = raw.match(/В[НH]\s*[:=]?\s*(да|нет|yes|no)/i); const internal = vnMatch ? /да|yes/i.test(vnMatch[1]) : /группа\s*[«"]?черкизово|внутригрупп|внутрихолдинг|(?:^|[^а-яё])вго(?:[^а-яё]|$)/i.test(raw); const amounts = extractAmounts(raw); const money = extractMoney(raw); const sites = (Array.isArray(dom.__sites) && dom.__sites.length) ? dom.__sites : extractSites(raw); // Тип карточки/документа: договор (Основной) / ДС / спецификация / прочее. // NB: \w в JS — ASCII и кириллицу не матчит, поэтому суффиксы пишем [а-яё]* (иначе «Основной договор» // не распознаётся: «основн\w*\s+» обрывается перед «ой»). Сиблинг бага \b-перед-кириллицей. // Тип документа — в первую очередь по ЗАГОЛОВКУ карточки (первая строка), иначе по тексту. Карточка // «Договор № … (с приложенным ДС № 2)» = Основной договор, а не ДС (упоминание «ДС №» не должно перебивать). const titleLine = (raw.split('\n').map(s => compact(s)).find(Boolean) || ''); // Сначала ДС/Спецификация, потом Договор: «Доп. соглашение к договору» содержит «договор», но это ДС. const typeFrom = s => /доп\.?\s*соглашен|дополнительн[а-яё]*\s+соглашен|(?:^|[^а-яё])ДС(?:[^а-яё]|$)/i.test(s) ? 'ДС' : /спецификац/i.test(s) ? 'Спецификация' : /(?:^|[^а-яё])договор/i.test(s) ? 'Основной договор' : ''; const contractType = domVal('Тип документа') || extractAdjacentFieldValue(raw, ['тип документа']) || typeFrom(titleLine) || ( /доп\.?\s*соглашен|дополнительн[а-яё]*\s+соглашен|(?:^|[^а-яё])ДС(?:[^а-яё]|$)/i.test(raw) ? 'ДС' : /спецификац/i.test(raw) ? 'Спецификация' : /основн[а-яё]*\s+договор|(?:^|[^а-яё])договор/i.test(raw) ? 'Основной договор' : ''); const limitMatch = raw.match(/лимит[^\d]{0,24}(\d[\d\s.,]{3,})/i); const sumMatch = raw.match(/сумм[аи][^\d]{0,24}(\d[\d\s.,]{3,})/i); // Лимит/сумма из ПОЛЯ карточки (в тексте их нет). Берём как amountValue, если текст молчит. const fieldLimit = domVal('Лимит по договору в рублях (без НДС)', 'Лимит по договору в рублях', 'Лимит по договору', 'Лимит') || extractAdjacentFieldValue(raw, ['лимит по договору в рублях \\(без НДС\\)', 'лимит по договору в рублях', 'лимит по договору', 'лимит']); const fieldAmount = domVal('Сумма документа в рублях (включая налоги)', 'Сумма обязательств по документу в рублях (без НДС)', 'Сумма документа', 'Сумма') || extractAdjacentFieldValue(raw, ['сумма документа в рублях \\(включая налоги\\)', 'сумма обязательств по документу в рублях \\(без НДС\\)', 'сумма документа', 'сумма']); const domLimitNum = (() => { const n = fieldLimit ? moneyToNumber(fieldLimit) : null; return Number.isFinite(n) ? n : null; })(); const domAmountNum = (() => { const n = fieldAmount ? moneyToNumber(fieldAmount) : null; return Number.isFinite(n) ? n : null; })(); const amountValue = money.length ? money[money.length - 1] : (domAmountNum != null ? domAmountNum : (domLimitNum != null ? domLimitNum : (amounts.length ? Number(amounts[amounts.length - 1]) : null))); // Люди из карточки (копируются как «Метка: ФИО»): Инициатор и Ответственный по договору. const initiator = labeledNameField(raw, ['инициатор']); const responsible = labeledNameField(raw, ['ответственн[а-яё]* по договору', 'ответственн[а-яё]*']); // ОП: чистим мусор-метки таблицы («Тип», «Сумма» и т.п.) — оставляем «Город-NN» и нормальные значения. const siteJunk = /^(тип|сумм|статус|резидент|наименован|доверенност|орг|инн|эдо|эцп|един|внешн|групп|вид|дата|курс|валют|проект|штрихкод|номер|лимит|дирекц|функци|категори|бюджет|ставк|порядок|срок|услови|стандарт|протокол|автомат)/i; const cleanSites = unique(sites).filter(s => /-\d+$/.test(s) || (s.length >= 4 && !siteJunk.test(normalize(s)))); // ЧТО НЕ СКОПИРОВАЛОСЬ из карточки (значения полей справа в OpenText не копируются) → спросим оператора. const cardClarifications = buildClarifications({ source: 'card', direction, functionName, category, legalEntities, sites, dealType, internal, contractType, amounts: (amountValue != null || limitMatch || sumMatch) ? [String(amountValue || 'ok')] : [], amountValue, }); return { direction: compact(direction), functionName: compact(functionName), category: compact(category), legalEntities: unique(legalEntities), counterparties: unique(counterparties), sites: cleanSites, contractType, dealType, internal, initiator, responsible, cardClarifications, edoExpected: internal ? 'Единый ЭДО (внутрихолдинг) — ВГО, ЭЦП пустой' : 'Единый ЭДО + не единый ЭДО', // НЕ берём amounts[0] как сумму (там бывает номер договора «ОТД-192014» → «192014»). Только // money-валидированное значение (с множителем/≥1000) или явный «лимит/сумма: N». limit: limitMatch ? compact(limitMatch[1]) : (amountValue != null ? String(amountValue) : ''), amount: sumMatch ? compact(sumMatch[1]) : (amountValue != null ? String(amountValue) : ''), amountValue, amounts, filter: { direction: compact(direction), function: compact(functionName), category: compact(category), legalEntity: unique(legalEntities)[0] || '' }, }; }, }; const CardDoctor = { diagnose(options = {}) { const target = api(); if (target && state.original.diagnoseCurrentCard) return state.original.diagnoseCurrentCard(options); return ChecklistEngine.run(options); }, auditFromCard(text, domFields) { const card = CardIntake.parse(text, domFields || readLiveCardFields()); const audit = MatrixPatternEngine.audit(card.filter); return { card, audit }; }, }; const ClosestMatchSearch = { find(criteria = {}) { const facts = rowFacts(); const wanted = { direction: normalize(criteria.direction), functionName: normalize(criteria.functionName || criteria.function), category: normalize(criteria.category), legalEntity: normalize(criteria.legalEntity), docType: normalize(criteria.docType), }; return facts.map(fact => { const diffs = []; let score = 0; if (wanted.direction) fact.directions.some(value => textMatches(value, criteria.direction)) ? score += 3 : diffs.push('Дирекция'); if (wanted.functionName) fact.functions.some(value => textMatches(value, criteria.functionName || criteria.function)) ? score += 3 : diffs.push('Функция'); if (wanted.category) fact.categories.some(value => textMatches(value, criteria.category)) ? score += 2 : diffs.push('Категория'); if (wanted.legalEntity) fact.legalEntities.some(value => textMatches(value, criteria.legalEntity)) || fact.partnerNames.some(value => textMatches(value, criteria.legalEntity)) ? score += 2 : diffs.push('ЮЛ'); if (wanted.docType) fact.docTypes.some(value => textMatches(value, criteria.docType)) ? score += 2 : diffs.push('Тип документа'); return { rowNumber: fact.rowNumber, score, differs: diffs, summary: fact.text.slice(0, 240), }; }).filter(row => row.score > 0).sort((a, b) => b.score - a.score).slice(0, 15); }, }; // ===== Доктор маршрута: почему «маршрут не строится» — какой строки не хватает / что сломано ===== const ROUTE_ANY_LE = '(любое ЮЛ)'; const RouteDoctor = { diagnose(card, problemLEs) { const c = card || {}; const dir = c.direction || ''; const fn = c.functionName || ''; const cat = c.category || ''; // ДФК часто НЕ копируется из карточки OpenText. Без него точную нехватку строк не покажешь — // иначе аудит хватает ВСЕ строки ЮЛ (сотни) и выдаёт шум по десяткам подписантов. Тогда — просим ДФК. const dfkProvided = Boolean(compact(dir) || compact(fn) || compact(cat)); const internal = c.internal === true; const docType = c.contractType || ''; const amount = c.amountValue != null ? c.amountValue : toNumber(c.amount || c.limit || ''); const les = unique([].concat(parseList(problemLEs || ''), c.legalEntities || [])).filter(Boolean); const targets = les.length ? les : [ROUTE_ANY_LE]; const rowsAll = signerRows(); const dfkLabel = [dir, fn, cat].filter(Boolean).join(' / ') || 'дирекция/функция/категория'; const perLegalEntity = targets.map(le => { const isAny = le === ROUTE_ANY_LE; const rows = rowsAll.filter(r => softMatch(r.directions, dir) && softMatch(r.functions, fn) && softMatch(r.categories, cat) && (isAny || !r.legalAll.length || r.legalAll.some(x => softMatch([x], le)))); const closest = ClosestMatchSearch.find({ direction: dir, function: fn, category: cat, legalEntity: isAny ? '' : le, docType }); const near = closest[0]; const verdicts = []; const ops = []; if (!rows.length) { if (near && near.differs.length === 1 && near.differs[0] === 'ЮЛ' && !isAny) { verdicts.push(`Строка #${near.rowNumber} почти подходит, но в ней нет ЮЛ «${le}». Достаточно ДОБАВИТЬ это ЮЛ в строку — маршрут построится.`); ops.push({ type: 'add_legal_entity_to_matching_rows', payload: { legalEntity: le, legalEntities: [le], direction: dir, functionName: fn, category: cat, rowGroup: 'all', matchMode: 'all', affiliation: REQUIRED_AFFILIATION } }); } else if (near && near.differs.includes('Категория') && !near.differs.includes('ЮЛ')) { verdicts.push(`Для ЮЛ «${le}» строки есть, но НЕ настроена категория «${cat}» (похоже, новая категория). Её надо ЗАВЕСТИ — согласовав подписанта с владельцем функции.`); } else { verdicts.push(`Для ЮЛ «${le}» × «${dfkLabel}» в матрице НЕТ ни одной строки подписания — маршруту не из чего строиться. Нужно СОЗДАТЬ формы подписания (укажите подписанта и диапазон на экране «Подписанты»).`); } } else { if (internal) { const hasVgo = rows.some(r => r.edo === 'other' || !(r.edsValues || []).length); if (!hasVgo) verdicts.push(`Сделка ВГО (внутригрупповая), но среди строк ЮЛ «${le}» нет ВГО-варианта (с пустым ЭЦП) — не хватает ВГО-строки.`); } if (amount) { const bands = rows.map(r => rowRangeForDocType(r, docType)).filter(Boolean); const covered = bands.some(b => rangeCoversAmount(b, amount)); if (!covered) { const gaps = coverageIssues(bands); verdicts.push(`Сумма/лимит ${fmtMoney(amount)} ₽ не попадает ни в один диапазон строк ЮЛ «${le}» — лимит не покрыт. ${gaps.join(' ')} Нужна строка на этот диапазон.`); } } if (!verdicts.length && dfkProvided) verdicts.push(`Для ЮЛ «${le}» строки есть и покрывают ДФК${amount ? '/сумму' : ''}. Если маршрут всё равно не строится — проверьте тип документа «${docType || '—'}», стандартную форму и статус ЮЛ/ОП.`); if (!verdicts.length && !dfkProvided) verdicts.push(`Для ЮЛ «${le}» строки есть (${rows.length}). Но ДФК (дирекция/функция/категория) НЕ задан — точную нехватку строк не покажу. В карточке ДФК не копируется: впиши Дирекцию/Функцию/Категорию (или поле «ЮЛ, у которого не строится»), и я покажу, каких форм не хватает на этом срезе.`); } // Глубокий разбор НА КАЖДОГО подписанта — ТОЛЬКО когда задан ДФК (иначе это весь срез ЮЛ = шум). let signersInfo = []; if (rows.length && dfkProvided) { const audit = MatrixPatternEngine.auditRows(rows, { direction: dir, function: fn, category: cat, legalEntity: isAny ? '' : le }); signersInfo = (audit.signers || []).map(s => ({ fio: s.fio, count: s.count, expectedForms: s.expectedForms, missingCount: (s.missingForms || []).length, missingVariants: s.missingVariants || [], bands: (s.ranges || []).map(r => `${r.limit.from == null ? 0 : fmtMoney(r.limit.from)}–${r.limit.to == null ? '∞' : fmtMoney(r.limit.to)}`), })); signersInfo.filter(s => s.missingCount > 0).slice(0, 8).forEach(s => { verdicts.push(`Подписант «${s.fio}»: есть ${s.count} из ${s.expectedForms} форм — не хватает ${s.missingCount} (${s.missingVariants.join('; ')}). Достройте недостающие строки.`); }); (audit.issues || []).filter(t => /дыра|не покрыт/i.test(t)).forEach(t => verdicts.push(`Лесенка лимитов: ${t}`)); // Строки есть, но настоящего подписанта нет (технический/пустой) → решение не в матрице, а в своде. if (!signersInfo.length) verdicts.push(`Для ЮЛ «${le}» строки есть, но подписант не определён (технический/пустой). Это вопрос к СВОДУ: кто должен подписывать «${dfkLabel}» — уточните по своду полномочий.`); } return { legalEntity: le, rowsFound: rows.length, closest: closest.slice(0, 3).map(x => ({ rowNumber: x.rowNumber, differs: x.differs })), verdicts, ops, signers: signersInfo }; }); const missingCount = perLegalEntity.filter(le => le.rowsFound === 0).length; const ops = [].concat.apply([], perLegalEntity.map(le => le.ops)); return { card: c, dfkLabel, perLegalEntity, missingCount, ops, userMessage: RouteDoctor.userMessage(perLegalEntity) }; }, userMessage(perLegalEntity) { const problems = (perLegalEntity || []).filter(le => le.verdicts.some(v => /нет|не настроена|не хватает|не покрыт|создать|добавить/i.test(v))); if (!problems.length) return 'По карточке подходящие строки в матрице есть — маршрут должен строиться. Если ошибка сохраняется, пришлите номер карточки и текст ошибки.'; const parts = problems.map(le => `• ЮЛ «${le.legalEntity}»: ${le.verdicts[0]}`); return `Добрый день! По вашей карточке лист согласования не строится по причине:\n${parts.join('\n')}\nЗаведём недостающие настройки в матрице и сообщим, когда можно пересоздать ЛС.`; }, }; // ===== Самодиагностика на бою: проверяет вёрстку + логику + окружение → отчёт-файл ===== async function collectDiagnostics(shell) { const root = shell || document.querySelector('[data-role="otk-root"]'); const target = api(); const m = matrix(); const safe = (fn, fb) => { try { const v = fn(); return v === undefined ? fb : v; } catch (e) { return `ERR:${(e && e.message) || e}`; } }; const dict = safe(() => DictionaryBuilder.build(), {}); const checks = []; const check = (name, fn) => { const c = { name, ok: false, detail: '' }; try { const r = fn(); if (r === true) c.ok = true; else if (r && typeof r === 'object') { c.ok = r.ok !== false; c.detail = r.detail || ''; } } catch (e) { c.ok = false; c.detail = `throw: ${(e && e.message) || e}`; } checks.push(c); }; const env = { version: VERSION, at: new Date().toISOString(), url: safe(() => location.href, ''), userAgent: safe(() => navigator.userAgent, ''), backdropFilter: safe(() => Boolean(typeof CSS !== 'undefined' && CSS.supports && (CSS.supports('backdrop-filter', 'blur(1px)') || CSS.supports('-webkit-backdrop-filter', 'blur(1px)'))), null), matrixPresent: Boolean(m), matrixTitle: safe(() => (ContextDetector.detect() || {}).title || '', ''), cols: safe(() => (m && m.cols || []).length, 0), colAliases: safe(() => (m && m.cols || []).map(c => c && (c.alias || c.title)).filter(Boolean).slice(0, 70), []), items: safe(() => (m && m.items || []).length, 0), partnerCacheSize: safe(() => Object.keys((m && m.partnerCacheObject) || {}).length, 0), modelUsers: safe(() => collectOpenTextModelUsers().length, 0), signingColumn: safe(() => colIndex(['Подписание', 'signing']) >= 0, false), svodLoaded: safe(() => { const s = SvodStore.get(); return s ? s.count : 0; }, 0), dict: safe(() => ({ users: (dict.users || []).length, legalEntities: (dict.legalEntities || []).length, sites: (dict.sites || []).length, directions: (dict.directions || []).length, functions: (dict.functions || []).length, categories: (dict.categories || []).length, docTypes: (dict.docTypes || []).length }), {}), }; // Вёрстка: каждый экран рендерится + нет горизонтального переполнения let overflow = false; const prevScenario = safe(() => activeScreen(root), 'signers'); ['signers', 'attributes', 'search', 'doctor', 'request', 'svod', 'invest', 'test'].forEach(sc => { check(`экран: ${sc}`, () => { if (root) showScreen(root, sc); const el = root && root.querySelector(`[data-screen="${sc}"]`); const ov = root ? (root.scrollWidth > root.clientWidth + 6) : false; if (ov) overflow = true; return { ok: Boolean(el), detail: ov ? 'ПЕРЕПОЛНЕНИЕ по ширине' : '' }; }); }); if (root && prevScenario) safe(() => showScreen(root, prevScenario)); // Логика check('синтетический контур (preview)', async () => true); // (async проверим ниже) let contour = null; try { contour = target && target.runSyntheticContour ? await target.runSyntheticContour({ mode: 'preview_only' }) : null; } catch (e) { contour = { fail: 1, error: String((e && e.message) || e) }; } checks.push({ name: 'синтетический контур (preview)', ok: Boolean(contour && contour.fail === 0), detail: contour ? `ok=${contour.ok}/${contour.total}` : 'нет API' }); check('разбор заявки', () => { const p = ITSMIntakeEngine.parse('Прошу поставить Иванов И.И. до 5 млн'); return { ok: (p.understood.signers || []).length > 0 || (p.understood.signerRanges || []).length > 0, detail: `signers=${(p.understood.signers || []).length}` }; }); check('диагноз карточки', () => { const d = RouteDoctor.diagnose(CardIntake.parse('Договор. Контрагент ФЕЙК ООО. Сумма 5 млн.'), 'ФЕЙК ООО'); return { ok: Boolean(d && d.perLegalEntity && d.perLegalEntity.length) }; }); check('сверка с матрицей', () => { const r = ReconcileEngine.analyze({ signer: 'Тест Тестов', legalEntities: ['X'], direction: '', function: '', category: '' }); return { ok: Boolean(r && r.perLegalEntity) }; }); check('детектор второго подписанта', () => { const probe = signerRows().find(x => x.signers.length && x.legalAll && x.legalAll.length); if (!probe) return { ok: true, detail: 'нет строк с подписантом на матрице' }; const r = ReconcileEngine.analyze({ signer: 'Зеро Дубль Тестов', legalEntities: [probe.legalAll[0]], direction: '', function: '', category: '', ranges: [{ from: probe.limit.from, to: probe.limit.to }] }); return { ok: (r.summary.conflict || 0) > 0, detail: `conflict=${r.summary.conflict}` }; }); // ===== ПОЛНЫЙ КОНВЕЙЕР: тест-заявка и тест-карточка проходят весь путь; каждый аспект — ok/ошибка, без падения. const pipeline = { steps: [], ok: 0, fail: 0 }; const step = async (name, fn) => { try { const r = await fn(); const okv = !(r && r.ok === false); pipeline.steps.push({ name, ok: okv, detail: (r && r.detail) || '' }); if (okv) pipeline.ok += 1; else pipeline.fail += 1; return r; } catch (e) { pipeline.steps.push({ name, ok: false, detail: `throw: ${(e && e.message) || e}` }); pipeline.fail += 1; return null; } }; const skipStep = (name, detail) => { pipeline.steps.push({ name, ok: true, skipped: true, detail: detail || 'skipped' }); pipeline.ok += 1; }; const canPreviewMatrix = Boolean(m && state.original.preview); // 1) Заявка → разбор → намерение → сверка → превью КАЖДОЙ предложенной операции. const reqText = 'Прошу настроить подписанта Иванов Иван Иванович на ЮЛ Куриное Царство АО до 30 000 000 руб. Дирекция операционная, функция логистика, категория транспорт.'; let understood = null; let intent = null; let reco = null; await step('конвейер: разбор заявки', () => { const p = ITSMIntakeEngine.parse(reqText); understood = p.understood; return { ok: Boolean(p && p.understood), detail: `signers=${(p.understood.signers || []).length}, ranges=${(p.understood.signerRanges || []).length}` }; }); await step('конвейер: намерение из заявки', () => { intent = intentFromUnderstood(understood || {}, reqText); return { ok: Boolean(intent), detail: `signer=${intent && intent.signer || '—'}` }; }); await step('конвейер: сверка с матрицей', () => { reco = ReconcileEngine.analyze(intent || {}); return { ok: Boolean(reco && reco.perLegalEntity), detail: `create=${reco.summary.create} split=${reco.summary.split} carve=${reco.summary.carve} conflict=${reco.summary.conflict} overlap=${reco.summary.overlap}` }; }); const recoOps = [].concat.apply([], ((reco && reco.perLegalEntity) || []).map(le => (le.decisions || []).filter(d => d.op).map(d => d.op))); for (let i = 0; i < recoOps.length; i += 1) { const op = recoOps[i]; const name = `конвейер: превью операции сверки #${i + 1} (${op.type})`; if (!canPreviewMatrix) { skipStep(name, 'нет матрицы/preview API на этой странице'); continue; } await step(name, async () => { const pr = await target.previewToolkit([op]); const rows = (pr && (pr.report || pr.entries)) || []; return { ok: Boolean(pr), detail: `entries=${rows.length}` }; }); } // 2) Карточка → диагноз → превью операций диагноза. const cardText = 'Договор. Контрагент Куриное Царство АО. Дирекция операционная, функция логистика, категория транспорт. Сумма 12 000 000 руб.'; let diag = null; await step('конвейер: разбор+диагноз карточки', () => { const card = CardIntake.parse(cardText); diag = RouteDoctor.diagnose(card, ''); return { ok: Boolean(diag && diag.perLegalEntity), detail: `missing=${diag.missingCount}, ops=${(diag.ops || []).length}` }; }); const diagOps = (diag && diag.ops) || []; for (let i = 0; i < diagOps.length; i += 1) { const op = diagOps[i]; const name = `конвейер: превью операции диагноза #${i + 1} (${op.type})`; if (!canPreviewMatrix) { skipStep(name, 'нет матрицы/preview API на этой странице'); continue; } await step(name, async () => { const pr = await target.previewToolkit([op]); return { ok: Boolean(pr), detail: `entries=${((pr && (pr.report || pr.entries)) || []).length}` }; }); } // 3) Перенос категории (две операции: split + отдать новому) проходит превью. await step('конвейер: перенос категории (split+отдать)', async () => { if (!canPreviewMatrix) return { ok: true, detail: 'skipped: нет матрицы/preview API на этой странице' }; const ops = target.buildTransferOps ? target.buildTransferOps({ legalEntity: 'Куриное Царство АО', transferCategories: 'Транспорт', toSigner: 'Петров Пётр Петрович', limit: '10000000' }) : []; const pr = ops.length ? await target.previewToolkit(ops) : null; return { ok: ops.length === 2 && Boolean(pr), detail: `ops=${ops.length}` }; }); // 4) Выгрузка отчёта (json/csv) — проверяем, что экспорт собирается. await step('конвейер: экспорт JSON', () => { const j = target.exportToolkitReport ? target.exportToolkitReport('json') : ''; return { ok: typeof j === 'string' && j.length > 0, detail: `${(j || '').length} символов` }; }); await step('конвейер: экспорт CSV', () => { const cvs = target.exportToolkitReport ? target.exportToolkitReport('csv') : ''; return { ok: typeof cvs === 'string', detail: `${(cvs || '').length} символов` }; }); const failed = checks.filter(c => !c.ok); return { summary: { version: VERSION, total: checks.length, ok: checks.length - failed.length, fail: failed.length, overflow, jsErrors: (state.capturedErrors || []).length, pipelineOk: pipeline.ok, pipelineFail: pipeline.fail, green: failed.length === 0 && !overflow && pipeline.fail === 0 }, env, checks, pipeline, failedChecks: failed.map(c => `${c.name}${c.detail ? ' — ' + c.detail : ''}`).concat(pipeline.steps.filter(s => !s.ok).map(s => `${s.name}${s.detail ? ' — ' + s.detail : ''}`)), capturedErrors: (state.capturedErrors || []).slice(-60), recentLogs: (state.logs || []).slice(-120), }; } function downloadDiagnostics(report) { const text = JSON.stringify(report, null, 2); const name = `opentext-toolkit-diag-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; try { const blob = new Blob([text], { type: 'application/json' }); const url = (host.URL || URL).createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => { try { (host.URL || URL).revokeObjectURL(url); } catch (_) { /* ignore */ } }, 4000); return { ok: true, name, text }; } catch (_) { return { ok: false, name, text }; } } // ===== БОЕВОЙ ПРОГОН: прогоняет КАЖДУЮ функцию на РЕАЛЬНЫХ данных текущей матрицы (preview-only, без // записи) и говорит, что реально работает на боевом сайте. Артём: «кнопка для тестов всего на бою + // выгрузка результата». Всё через previewToolkit (превью), apply НЕ вызывается — продакшн не меняется. async function runLiveSelfTest() { const target = api(); const m = matrix(); const dict = safeBuildDict(); const checks = []; const add = async (name, fn) => { try { const r = await fn(); const ok = !(r && r.ok === false); checks.push({ name, ok, detail: (r && r.detail) || '', skipped: Boolean(r && r.skipped) }); } catch (e) { checks.push({ name, ok: false, detail: `сбой: ${(e && e.message) || e}` }); } }; const realSigners = (dict.signers || []).filter(s => s && s.fio && s.id && !/^-?\d+$/.test(s.fio)); const sampleRow = () => { const c = InvestProjectEngine.columns(); for (const it of getItems()) { const v = String(InvestProjectEngine.cell(it, c.sum)).replace(/[^\d]/g, ''); if (v) return { sum: v, dir: InvestProjectEngine.cell(it, colIndex(['direction', 'Дирекция'])), fn: InvestProjectEngine.cell(it, colIndex(['functions', 'Функция'])), cat: InvestProjectEngine.cell(it, colIndex(['category', 'Категория'])) }; } return null; }; const row = sampleRow(); await add('Матрица загружена и читается', () => ({ ok: Boolean(m && (m.items || []).length && (m.cols || []).length), detail: `строк ${(m && m.items || []).length}, колонок ${(m && m.cols || []).length}` })); await add('Справочник людей собран', () => ({ ok: realSigners.length > 0, detail: `подписантов в матрице: ${realSigners.length}, всего людей: ${(dict.users || []).length}` })); await add('Колонки матрицы распознаны (ЭЦП/Подписание/Проект/Сумма)', () => { const has = a => colIndex(a) >= 0; const okSign = (m && m.cols || []).some(c => c && c.colType === 'level' && c.type === 'signing'); return { ok: okSign && has(['sum_rub', 'Сумма документа в рублях (включая налоги)']), detail: `подписание=${okSign}, проект=${has(['project', 'Проект (вкл. инвестиционные)'])}, сумма=${has(['sum_rub'])}` }; }); await add('Резолв фамилии среди людей матрицы', () => { if (!realSigners.length) return { ok: true, skipped: true, detail: 'нет подписантов в матрице' }; const s = realSigners[0]; const sur = surnameToken(s.fio) || s.fio.split(/\s+/)[0]; const r = target.resolveSurnameInMatrix(sur); return { ok: r.matches.some(u => String(u.id) === String(s.id)), detail: `«${sur}» → ${r.matches.length} совпадений в матрице` }; }); await add('Замена реального подписанта → план v8 (превью)', async () => { if (realSigners.length < 2) return { ok: true, skipped: true, detail: 'нужно ≥2 подписантов' }; const a = realSigners[0], b = realSigners[1]; const pr = await target.previewToolkit([{ type: 'replace_signer_by_name', payload: { currentSignerId: String(a.id), newSignerId: String(b.id), currentSignerName: a.fio, newSignerName: b.fio, rowGroup: 'all', matchMode: 'all', affiliation: REQUIRED_AFFILIATION } }]); return { ok: Boolean(pr && /^v8-/.test(pr.planId || '')), detail: `planId=${pr && pr.planId || '—'}, затронуто строк ${(pr && pr.report || []).length}` }; }); await add('Создание форм подписанта → план v8 (превью)', async () => { if (!realSigners.length) return { ok: true, skipped: true, detail: 'нет подписантов' }; const pr = await target.previewToolkit([{ type: 'add_signer_forms', payload: { newSigner: realSigners[0].fio, signer: realSigners[0].fio, limit: '1000000', amount: '1000000', ranges: [{ from: '0', to: '1000000', amount: '1000000', signer: realSigners[0].fio }], affiliation: REQUIRED_AFFILIATION } }]); return { ok: Boolean(pr && /^v8-/.test(pr.planId || '')), detail: `planId=${pr && pr.planId || '—'}, форм ${(pr && pr.report || []).length}` }; }); await add('Атрибут (тип документа) на срезе матрицы → план v8 (превью)', async () => { const pr = await target.previewToolkit([{ type: 'add_doc_type_to_matching_rows', payload: { newDocType: 'ДС к спецификации', rowGroup: 'all', matchMode: 'all', requiredDocTypes: [], direction: (row && row.dir) || '', affiliation: REQUIRED_AFFILIATION } }]); return { ok: Boolean(pr && pr.planId), detail: `planId=${pr && pr.planId || '—'}, строк ${(pr && pr.report || []).length}` }; }); await add('Карточка → диагноз маршрута на реальных ДФК', () => { if (!row || !(row.dir || row.fn)) return { ok: true, skipped: true, detail: 'в матрице нет заполненных ДФК' }; const le = (dict.legalEntities || [])[0] || (dict.counterparties || [])[0] || ''; const card = CardIntake.parse(`Договор. Дирекция: ${row.dir}. Функция: ${row.fn}. Категория: ${row.cat}. Контрагент ${le}. Сумма ${row.sum} руб.`); const d = RouteDoctor.diagnose(card, le); return { ok: Boolean(d && d.perLegalEntity), detail: `ЮЛ-разрезов ${(d.perLegalEntity || []).length}, нехваток ${d.missingCount}` }; }); await add('Инвестпроект: SQL в zContrReferences + контекст по матрице', () => { const res = target.buildInvestProjectScript({ sum: row && row.sum }, '1.016.4.25.0.3.5053 Тестовый проект'); const okSql = /INSERT INTO .*zContrReferences \(\[Type\], \[Name\]\)/.test(res.sql) && /N'1\.016\.4\.25\.0\.3\.5053 Тестовый проект'/.test(res.sql); return { ok: okSql, detail: `записей ${res.entries.length}, похожих строк ${res.rows.length}, SQL ${res.sql.length} симв.` }; }); await add('Поиск по матрицам на реальном значении', async () => { const q = (row && row.dir) || (realSigners[0] && surnameToken(realSigners[0].fio)) || 'договор'; const r = await target.searchAcrossMatrices(q, { mode: 'legal_entity', matchMode: 'partial' }); return { ok: Boolean(r && typeof r.total === 'number'), detail: `запрос «${q}» → найдено ${r && r.total}` }; }); await add('Синтетический контур v8 (preview_only)', async () => { const c = target.runSyntheticContour ? await target.runSyntheticContour({ mode: 'preview_only' }) : null; return { ok: Boolean(c && c.fail === 0), detail: c ? `ok=${c.ok}/${c.total}` : 'нет API' }; }); const failed = checks.filter(c => !c.ok && !c.skipped); const skipped = checks.filter(c => c.skipped); return { kind: 'live_self_test', version: VERSION, at: new Date().toISOString(), url: safe2(() => location.href, ''), matrix: { title: safe2(() => (ContextDetector.detect() || {}).title || '', ''), rows: (m && m.items || []).length, cols: (m && m.cols || []).length, signers: realSigners.length }, summary: { total: checks.length, ok: checks.filter(c => c.ok).length, fail: failed.length, skipped: skipped.length, green: failed.length === 0 }, checks, jsErrors: (state.capturedErrors || []).slice(-40), }; } function safe2(fn, fb) { try { const v = fn(); return v === undefined ? fb : v; } catch (e) { return fb; } } function safeBuildDict() { try { return DictionaryBuilder.build(); } catch (e) { return {}; } } // Человеко-читаемый отчёт боевого прогона (txt) + он же скачивается; так «удобнее», как просил Артём. function downloadLiveReport(report) { const r = report || {}; const s = r.summary || {}; const lines = []; lines.push('OpenText Toolkit — БОЕВОЙ ПРОГОН (preview-only, продакшн не менялся)'); lines.push(`Версия: ${r.version} Время: ${r.at}`); lines.push(`Матрица: ${r.matrix && r.matrix.title || '—'} · строк ${r.matrix && r.matrix.rows} · колонок ${r.matrix && r.matrix.cols} · подписантов ${r.matrix && r.matrix.signers}`); lines.push(`URL: ${r.url || '—'}`); lines.push(''); lines.push(`ИТОГ: ${s.green ? '✅ ВСЁ РАБОТАЕТ' : '⚠ ЕСТЬ ЗАМЕЧАНИЯ'} — ОК ${s.ok}/${s.total}, ошибок ${s.fail}, пропущено ${s.skipped}.`); lines.push(''); (r.checks || []).forEach(c => lines.push(`${c.skipped ? '∅ ПРОПУСК' : (c.ok ? '✅ ОК ' : '❌ ОШИБКА ')} ${c.name}${c.detail ? ' — ' + c.detail : ''}`)); if ((r.jsErrors || []).length) { lines.push(''); lines.push('JS-ошибки в сессии:'); (r.jsErrors || []).forEach(e => lines.push(' • ' + (e && (e.message || e) || ''))); } lines.push(''); lines.push('— — — RAW JSON — — —'); lines.push(JSON.stringify(r, null, 2)); const text = lines.join('\n'); const name = `opentext-боевой-прогон-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; try { const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = (host.URL || URL).createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => { try { (host.URL || URL).revokeObjectURL(url); } catch (_) { /* ignore */ } }, 4000); return { ok: true, name, text }; } catch (_) { return { ok: false, name, text }; } } const MatrixNavigator = { currentContext: () => ContextDetector.detect(), closestMatch: criteria => ClosestMatchSearch.find(criteria), }; // ===== Дирекция развития: новый инвестпроект → SQL (T-SQL / MSSQL) =========================== // Реальная вставка проекта (пример Артёма) — это запись в справочник проектов: // INSERT INTO ContentServer.csadmin.zContrReferences ([Type], [Name]) // VALUES (1, N'1.016.2.26.0.5.5699 Общежитие ППК Алтай'), (1, N'1.016.4.25.0.3.4835 ПИР БЦ Брянск'); // Type=1, Name = «<номер> <название>». Из заявки берём строки «номер название» и собираем такой INSERT. // Доп. контекст (опц.): найти строки матрицы с тем же руководителем/суммой/лимитом/подписантом и их проекты. const INVEST_DEFAULT_TABLE = 'ContentServer.csadmin.zContrReferences'; const InvestProjectEngine = { // Номера инвестпроектов из текста: «1.016.4.25.0.3.5053», «3.030.2.22.29.4.890», «ИП-5331». extractProjectNumbers(text) { const out = []; String(text || '').split(/[\s,;|]+/).forEach(tok => { const dotted = tok.match(/^(?:ИП[-–]?)?(\d{1,3}(?:\.\d+){3,})\.?$/); if (dotted) { out.push(dotted[1]); return; } const ip = tok.match(/^ИП[-–]?(\d{3,})$/i); if (ip) out.push('ИП-' + ip[1]); }); return unique(out); }, // Записи проекта «номер + название» (для колонки Name справочника): из заявки построчно. // «1.016.2.26.0.5.5699 Общежитие ППК Алтай» → { code, name, full }. Дубли по full отсекаются. extractProjectEntries(text) { const out = []; const seen = new Set(); String(text || '').split(/\n|;/).forEach(seg => { const line = compact(seg); const m = line.match(/(?:ИП[-–]?\s*)?(\d{1,3}(?:\.\d+){3,})\s*(.*)$/); if (!m) return; const code = m[1]; const name = compact(m[2]).replace(/^[-–—:.,]\s*/, ''); const full = name ? `${code} ${name}` : code; const key = normalize(full); if (seen.has(key)) return; seen.add(key); out.push({ code, name, full }); }); return out; }, // Индексы колонок-критериев и колонки проекта. columns() { const cols = getColumns(); const at = aliases => cols.findIndex(c => (Array.isArray(aliases) ? aliases : [aliases]).map(normalize).includes(normalize((c && (c.alias || c.title)) || ''))); return { cols, sum: at(['sum_rub', 'Сумма документа в рублях (включая налоги)']), limit: at(['limit_contract', 'Лимит по договору в рублях (без НДС)']), project: at(['project', 'Проект (вкл. инвестиционные)']), signer: cols.findIndex(c => c && c.colType === 'level' && c.type === 'signing'), manager: cols.findIndex(c => c && c.colType === 'function' && /руководител/i.test(c.title || '')), }; }, cell(item, idx, kind) { if (idx == null || idx < 0 || !item) return ''; const m = matrix(); const list = valueAsList(item[idx]); if (kind === 'people') return unique(namesForIds(list, (m && m.userCacheObject) || {}).map(stripId)).join(', '); return list.join('; '); }, // Строки-образцы: совпадают ВСЕ заданные критерии (сумма/лимит — по числу, подписант/руководитель — подстрока). findTemplateRows(criteria = {}) { const c = this.columns(); const items = getItems(); const numEq = (a, b) => { const x = String(a).replace(/[^\d]/g, ''), y = String(b).replace(/[^\d]/g, ''); return x && y && x === y; }; const want = { manager: normalize(criteria.manager || ''), signer: normalize(criteria.signer || ''), sum: compact(criteria.sum || criteria.sumRub || ''), limit: compact(criteria.limit || criteria.limitContract || '') }; if (!want.manager && !want.signer && !want.sum && !want.limit) return []; const rows = []; items.forEach((item, i) => { if (want.sum && !numEq(this.cell(item, c.sum), want.sum)) return; if (want.limit && !numEq(this.cell(item, c.limit), want.limit)) return; if (want.signer && !normalize(this.cell(item, c.signer, 'people')).includes(want.signer)) return; if (want.manager && !normalize(this.cell(item, c.manager, 'people')).includes(want.manager)) return; rows.push({ index: i, rowNo: i + 1, project: this.cell(item, c.project), sum: this.cell(item, c.sum), limit: this.cell(item, c.limit), signer: this.cell(item, c.signer, 'people'), manager: this.cell(item, c.manager, 'people') }); }); return rows; }, // INSERT в справочник проектов (как в боевом скрипте Артёма). T-SQL: N'…', [Type]/[Name], '' экранирует '. buildReferencesSql(entries, options = {}) { const table = options.table || INVEST_DEFAULT_TABLE; const type = options.type == null || options.type === '' ? 1 : options.type; const esc = s => String(s == null ? '' : s).replace(/'/g, "''"); const list = (entries || []).map(e => (e && typeof e === 'object' ? e.full : e)).filter(Boolean); if (!list.length) return ''; const values = list.map(full => ` (${type}, N'${esc(full)}')`).join(',\n'); return `INSERT INTO ${table} ([Type], [Name])\nVALUES \n${values};`; }, // Полный сценарий: текст заявки (+ опц. критерии для контекста) → {entries, projects, rows, sql}. build(criteria = {}, requestText = '', options = {}) { const entries = this.extractProjectEntries(requestText).concat(criteria.entries || []); const projects = unique([].concat(criteria.projects || []).concat(entries.map(e => e.code)).concat(this.extractProjectNumbers(requestText))); const rows = this.findTemplateRows(criteria); // Если названий нет (только номера) — собираем по номерам; оператор допишет названия. const sqlEntries = entries.length ? entries : projects.map(p => ({ full: p })); return { entries, projects, rows, existingProjects: unique(rows.map(r => r.project).filter(Boolean)), sql: this.buildReferencesSql(sqlEntries, options) }; }, }; function translateOperation(operation) { const op = operation || {}; if (op.type === 'add_signer_forms') return SignerFormsEngine.toLegacyBundle(op.payload || {}); if (op.type === 'create_development_project') { return { type: 'add_signer_bundle', payload: { newSigner: op.payload && op.payload.signer || 'Уточнить подписанта', limit: op.payload && op.payload.limit || '30000000', amount: op.payload && op.payload.amount || op.payload && op.payload.limit || '30000000', categories: DEVELOPMENT_CATEGORIES.slice(), affiliation: REQUIRED_AFFILIATION, }, options: { sourceRule: 'development_project_preview' }, }; } return op; } function normalizePreviewResult(result) { const out = result || {}; const report = Array.isArray(out.report) ? out.report : []; out.entries = Array.isArray(out.entries) ? out.entries : report; out.warnings = Array.isArray(out.warnings) ? out.warnings : report.filter(row => /warn|manual|skip/i.test(String(row.status || row.actionType || ''))); return out; } function log(message, level = 'info') { state.logs.push({ at: new Date().toISOString(), level, message: String(message || '') }); if (state.logs.length > 250) state.logs.shift(); const box = document.querySelector('[data-role="otk-log-box"]'); if (box) { box.innerHTML = state.logs.slice(-80).map(item => `
${escapeHtml(item.level)} ${escapeHtml(item.message)}
`).join(''); box.scrollTop = box.scrollHeight; } } function installApi() { let target = api(); if (state.installedApi) return false; if (!target) { if (matrix() || document.querySelector('#sc_ApprovalMatrix')) return false; target = {}; host.__OT_MATRIX_CLEANER__ = target; window.__OT_MATRIX_CLEANER__ = target; } else if (typeof target.runSyntheticContour !== 'function') { // Легаси-API уже есть, но v8-движок ещё НЕ дописал preview/apply (planId) — гонка инициализации. // Если снять снимок сейчас, state.original.preview окажется ЛЕГАСИ-превью без planId (v8 потом // перезапишет api.preview, но наш bind уже указывает на старое). Маркер v8 — runSyntheticContour, // он ставится в самом КОНЦЕ v8.installApi (после api.preview). Ждём v8; но не вечно — через ~3 c // берём что есть, чтобы не зависнуть на странице, где v8-рантайма нет. state.v8Waits = (state.v8Waits || 0) + 1; if (state.v8Waits < 16) return false; } state.original.preview = target.preview ? target.preview.bind(target) : null; state.original.apply = target.apply ? target.apply.bind(target) : null; state.original.previewRuleBatch = target.previewRuleBatch ? target.previewRuleBatch.bind(target) : null; state.original.runRuleBatch = target.runRuleBatch ? target.runRuleBatch.bind(target) : null; state.original.getHumanDictionaries = target.getHumanDictionaries ? target.getHumanDictionaries.bind(target) : null; state.original.exportReport = target.exportReport ? target.exportReport.bind(target) : null; state.original.searchAcrossMatrices = target.searchAcrossMatrices ? target.searchAcrossMatrices.bind(target) : null; state.original.diagnoseCurrentCard = target.diagnoseCurrentCard ? target.diagnoseCurrentCard.bind(target) : null; state.original.runChecklistEngine = target.runChecklistEngine ? target.runChecklistEngine.bind(target) : null; state.original.parseRequestText = target.parseRequestText ? target.parseRequestText.bind(target) : null; target.ContextDetector = ContextDetector; target.DictionaryBuilder = DictionaryBuilder; target.UserResolver = UserResolver; target.LegalEntityResolver = LegalEntityResolver; target.DocumentTypePresetEngine = DocumentTypePresetEngine; // Тест-хук: собрать операцию из текущего состояния UI (без скрытого state) — для e2e проверки маппинга экранов. target.buildOperationFromUi = root => buildOperation(root || document.querySelector('[data-role="otk-root"]')); target.SignerFormsEngine = SignerFormsEngine; target.ChecklistEngine = ChecklistEngine; target.ITSMIntakeEngine = ITSMIntakeEngine; target.InvestProjectEngine = InvestProjectEngine; target.buildInvestProjectScript = (criteria, requestText, options) => InvestProjectEngine.build(criteria || {}, requestText || '', options || {}); target.CardDoctor = CardDoctor; target.CardIntake = CardIntake; target.readLiveCardFields = readLiveCardFields; target.MatrixCache = MatrixCache; target.cacheCurrentMatrix = () => MatrixCache.save(); target.findRoutesInCache = findRoutesInCache; target.RouteDoctor = RouteDoctor; target.diagnoseRoute = (text, problemLEs) => RouteDoctor.diagnose(CardIntake.parse(text || ''), problemLEs || ''); target.collectDiagnostics = shell => collectDiagnostics(shell || document.querySelector('[data-role="otk-root"]')); target.runLiveSelfTest = () => runLiveSelfTest(); target.MatrixPatternEngine = MatrixPatternEngine; target.MatrixNavigator = MatrixNavigator; target.ClosestMatchSearch = ClosestMatchSearch; target.auditCardAgainstMatrix = text => CardDoctor.auditFromCard(text || ''); target.learnSignerPattern = filter => MatrixPatternEngine.learnSignerPattern(filter || {}); target.getToolkitContext = () => ContextDetector.detect(); target.getHumanDictionaries = options => DictionaryBuilder.build(options || {}); target.resolveLegalEntities = (input, dictionaries) => LegalEntityResolver.resolve(input, dictionaries); // Поиск подписанта по голой фамилии среди людей матрицы (Артём: «искать среди тех, кто уже есть в матрице»). target.resolveSurnameInMatrix = (surname, dictionaries) => resolveSurnameInMatrix(surname, dictionaries); target.buildSignerForms = payload => SignerFormsEngine.build(payload || {}); target.buildTransferOps = spec => buildTransferOps(spec || {}); target.previewTransfer = async spec => target.previewToolkit(buildTransferOps(spec || {})); target.findClosestMatrixRows = criteria => ClosestMatchSearch.find(criteria || {}); target.parseITSMIntake = text => ITSMIntakeEngine.parse(text || ''); target.loadSvod = text => SvodStore.setFromText(text || ''); target.clearSvod = () => SvodStore.clear(); target.getSvod = () => SvodStore.get(); target.classifyCategoryBySvod = category => SvodStore.classify(category || ''); target.validateAgainstSvod = (category, ctx) => SvodStore.validate(category || '', ctx || {}); target.reconcileWithMatrix = intent => ReconcileEngine.analyze(intent || {}); // «Кинуть в матрицу»: физически отфильтровать таблицу OpenText по контрагенту (ЮЛ) через легаси-фильтр, // прокрутить к ней. Легаси-фильтр — по ОДНОМУ контрагенту, поэтому фильтруем по выбранному ЮЛ. target.showRowsInMatrix = (legalEntity) => { const a = api(); if (!a || typeof a.applyCounterpartyColumnFilter !== 'function') return { ok: false, error: 'Фильтр матрицы недоступен (нет легаси-API).' }; const le = compact(legalEntity); if (!le) return { ok: false, error: 'Не указано ЮЛ для фильтра.' }; let res; try { res = a.applyCounterpartyColumnFilter(le); } catch (e) { return { ok: false, error: String((e && e.message) || e) }; } try { const m = document.querySelector('#sc_ApprovalMatrix') || document.querySelector('[id*="ApprovalMatrix"]'); if (m && m.scrollIntoView) m.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) { /* ignore */ } return { ok: true, legalEntity: le, rows: (res && res.rows || []).length, diagnostics: res && res.diagnostics }; }; target.clearMatrixView = () => { const a = api(); if (a && typeof a.clearMatrixFilters === 'function') { try { a.clearMatrixFilters(); return { ok: true }; } catch (e) { return { ok: false, error: String((e && e.message) || e) }; } } return { ok: false, error: 'Нет легаси-API сброса.' }; }; SvodStore.load(); // восстановить свод из localStorage (вставляли ранее — не нужно вставлять заново) // Кешируем текущую матрицу (тихо), чтобы потом находить маршруты с карточек, где матрицы нет. try { if ((ContextDetector.detect() || {}).kind === 'matrix') MatrixCache.save(); } catch (_) { /* кеш не критичен */ } target.previewToolkit = async (operations, options = {}) => { const translated = (operations || []).map(translateOperation); if (!state.original.preview) throw new Error('Base preview API is unavailable.'); const result = normalizePreviewResult(await state.original.preview(translated, options)); state.lastPreview = result; return result; }; target.preview = async (operations, options = {}) => target.previewToolkit(operations || [], options || {}); target.previewRuleBatch = async (operations, options = {}) => { const translated = (operations || []).map(translateOperation); if (translated.length && translated.every(op => op.type !== 'add_signer_forms') && state.original.previewRuleBatch) { return state.original.previewRuleBatch(translated, options || {}); } const result = await target.previewToolkit(translated, options || {}); const report = (result.report || []).slice(); report.planId = result.planId; return report; }; target.runRuleBatch = async (operationsOrPlanId, options = {}) => { if (typeof operationsOrPlanId === 'string') { return state.original.apply ? (await state.original.apply(operationsOrPlanId, options || {})).report : []; } const result = await target.previewToolkit(operationsOrPlanId || [], options || {}); return state.original.apply ? (await state.original.apply(result.planId, options || {})).report : result.report || []; }; target.exportToolkitReport = format => { if (state.original.exportReport) return state.original.exportReport(format || 'json'); return JSON.stringify({ report: state.lastPreview ? state.lastPreview.report : [] }, null, 2); }; host.__OPENTEXT_TOOLKIT__ = target; host.MatrixCleaner = target; state.installedApi = true; return true; } function installStyles() { if (document.getElementById('otk-style')) return; const style = document.createElement('style'); style.id = 'otk-style'; style.textContent = ` @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;450;500;600;700&display=swap'); /* Дизайн-токены объявляем И на самой панели (#mc-panel), И на корне: переменные наследуются только вниз, а фон панели берёт var(--glass) — раньше он был не определён на #mc-panel → панель прозрачная, белый текст на светлой странице OpenText = нечитаемо. Теперь токены доходят и до панели. */ /* ЧБ-минимал (Артём): чёрный хедер, белый низ, прямые углы, без градиентов/свечений, минимум шума. */ :where(#mc-panel.otk-shell-active,[data-role="otk-root"]){--paper:#fafafa;--surface:#ffffff;--ink:#0a0a0a;--ink-2:#3a3a3a;--ink-soft:#141414;--muted:#8c8c8c;--line:#e4e4e7;--line-2:#d0d0d4;--accent:#0a0a0a;--accent-press:#000;--accent-weak:#f1f1f3;--glass:#ffffff;--glass-2:#ffffff;--hair-top:transparent;--shadow:0 1px 2px rgba(0,0,0,.05),0 14px 34px rgba(0,0,0,.08);--display:'Inter',-apple-system,system-ui,sans-serif;--otk-gap:14px;--otk-pad:18px;--otk-control:42px;--otk-radius:8px} #mc-open-btn{position:fixed;right:14px;bottom:14px;z-index:2147483646;width:44px;height:44px;border:1px solid rgba(255,255,255,.38);background:linear-gradient(160deg,#3b8cff 0%,#2360d8 100%);color:#fff;border-radius:999px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 22px rgba(43,111,224,.5),inset 0 1px 0 rgba(255,255,255,.45);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);transition:transform .12s,box-shadow .15s} #mc-open-btn:hover{transform:translateY(-1px);box-shadow:0 11px 26px rgba(43,111,224,.6),inset 0 1px 0 rgba(255,255,255,.5)} #mc-panel{position:fixed;right:14px;bottom:66px;z-index:2147483645;width:min(420px,calc(100vw - 28px));max-width:100%;max-height:calc(100vh - 90px);background:#fff;color:#111;border:2px solid #111;box-shadow:8px 8px 0 #111;font:12px/1.35 Arial,Helvetica,sans-serif;display:none;overflow:hidden} #mc-panel.mc-panel--open{display:block} #mc-panel *{box-sizing:border-box} [data-role="otk-root"]{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:var(--ink);margin:0;padding:0;max-width:none;-webkit-font-smoothing:antialiased;letter-spacing:0;position:relative;background:#fff;overflow-x:clip;isolation:isolate} [data-role="otk-root"] .otk-title,[data-role="otk-root"] .otk-card>h3,[data-role="otk-root"] .otk-subsection>h4,[data-role="otk-root"] .otk-stat .n,[data-role="otk-root"] .otk-logo{font-family:var(--display);letter-spacing:-.02em} /* Непрозрачная сине-графит база (background-color, чтобы getComputedStyle видел её как непрозрачную), холодный градиент-картинкой поверх, blur — лишь украшение на ~7% просвете. Читается всегда. */ #mc-panel.otk-shell-active{width:min(920px,calc(100vw - 28px));border:1px solid #d8d8dc;box-shadow:var(--shadow);background:#ffffff;background-image:none;-webkit-backdrop-filter:none;backdrop-filter:none;color:var(--ink);border-radius:8px;overflow:hidden} #mc-panel.otk-shell-active .mc-head,#mc-panel.otk-shell-active #mc-stats,#mc-panel.otk-shell-active .mc-logtools,#mc-panel.otk-shell-active #mc-log,#mc-panel.otk-shell-active .mc-logbox{display:none!important} #mc-panel.otk-shell-active .otk-close{display:flex!important;align-items:center;justify-content:center;position:static!important;width:32px;height:32px;border:1px solid rgba(255,255,255,.28)!important;background:transparent!important;color:#fff!important;border-radius:6px!important;font-size:17px!important;line-height:1!important;box-shadow:none!important;margin:0!important;padding:0!important} #mc-panel.otk-shell-active .otk-close:hover{background:rgba(255,255,255,.14)!important} #mc-panel.otk-shell-active .mc-body{padding:0;max-height:calc(100vh - 84px);overflow:auto;background:#fff;scrollbar-gutter:stable} #mc-root.otk-clean > :not([data-role="otk-root"]) { display:none !important; } [data-role="otk-root"] *{box-sizing:border-box} /* Хедер — глубокий чёрный, текст белый. */ .otk-head{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:15px 20px;background:#0a0a0a;color:#fff;border-bottom:1px solid #0a0a0a} .otk-head-brand{display:flex;align-items:center;gap:12px;min-width:0} .otk-head .otk-title{color:#fff}.otk-head .otk-sub{color:rgba(255,255,255,.6)} .otk-logo{width:30px;height:30px;display:block;border-radius:6px;filter:none} .otk-title{font-size:18px;font-weight:600;line-height:1.08;letter-spacing:0}.otk-sub{font-size:11.5px;color:var(--muted);margin-top:3px;display:flex;align-items:center;gap:6px;min-width:0;overflow-wrap:anywhere} .otk-dot{width:6px;height:6px;border-radius:50%;background:#fff} .otk-head-actions{display:flex;align-items:center;gap:8px;margin-left:auto} .otk-menu-wrap{position:relative} .otk-icon-btn{border:1px solid rgba(255,255,255,.28);background:transparent;color:#fff;width:32px;height:32px;cursor:pointer;font-weight:600;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:18px;transition:background .12s}.otk-icon-btn:hover{background:rgba(255,255,255,.14)} .otk-menu{position:absolute;right:0;top:42px;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow);z-index:30;min-width:236px;padding:6px;display:grid;gap:1px;border-radius:6px;color:var(--ink)} .otk-menu[hidden]{display:none}.otk-menu button{border:0;background:transparent;text-align:left;padding:9px 10px;font-size:12.5px;cursor:pointer;border-radius:4px;color:var(--ink-2)}.otk-menu button:hover{background:#f2f2f4;color:var(--ink)} .otk-status{padding:12px 20px 2px;font-size:12px;display:flex;gap:6px;flex-wrap:wrap;align-items:center} .otk-pill{border:1px solid var(--line);background:#fff;min-height:28px;padding:5px 10px;border-radius:6px;color:var(--muted);font-weight:500;display:inline-flex;gap:5px;align-items:center;line-height:1.15} .otk-pill b{color:var(--ink);font-weight:700;font-variant-numeric:tabular-nums} .otk-pill.ctx{background:#0a0a0a;border-color:#0a0a0a;color:#fff;font-weight:600;box-shadow:none;margin-right:3px} .otk-scenario{padding:11px 20px 0}.otk-scenario>label{display:none} .otk-tabs{display:flex;flex-wrap:nowrap;gap:14px;overflow-x:auto;padding-bottom:0;border-bottom:1px solid var(--line);scrollbar-width:thin} .otk-tabs::-webkit-scrollbar{height:4px}.otk-tabs::-webkit-scrollbar-thumb{background:var(--line-2);border-radius:99px} .otk-tab{border:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);min-height:38px;padding:8px 2px 10px;font-size:12.5px;font-weight:500;border-radius:0;cursor:pointer;white-space:nowrap;flex:0 0 auto;transition:color .12s,border-color .12s} .otk-tab:hover{color:var(--ink)} .otk-tab.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:600} select.otk-scenario-native{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none} .otk-select,.otk-input,.otk-textarea{box-sizing:border-box;width:100%;border:1px solid var(--line);background:#fff;color:var(--ink);font-size:13px;border-radius:7px;outline:none;transition:border-color .15s,box-shadow .15s;font-family:inherit} .otk-select,.otk-input{min-height:var(--otk-control);padding:0 12px;line-height:1.25} .otk-select{color:var(--ink)}.otk-select option{background:#fff;color:#0a0a0a} .otk-select:focus,.otk-input:focus,.otk-textarea:focus{border-color:#0a0a0a;background:#fff;box-shadow:0 0 0 2px rgba(10,10,10,.12)} .otk-input::placeholder,.otk-textarea::placeholder{color:var(--muted)} .otk-textarea{resize:vertical;min-height:92px;line-height:1.45;padding:10px 12px}.otk-help{font-size:11px;color:var(--muted);line-height:1.45}.otk-span-2{grid-column:1/-1} .otk-svod-table{width:100%;border-collapse:collapse;font-size:11.5px;margin-top:10px}.otk-svod-table th{text-align:left;font-weight:600;color:var(--ink-2);border-bottom:1px solid var(--ink);padding:5px 8px 5px 0}.otk-svod-table td{padding:4px 8px 4px 0;border-bottom:1px solid var(--line);vertical-align:top;color:var(--ink)} .otk-reco-summary{font-size:12px;font-weight:600;color:var(--ink);margin:10px 0 6px;padding-bottom:6px;border-bottom:1px solid var(--ink)}.otk-reco-le{margin:8px 0;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--surface)}.otk-reco-item{font-size:12px;line-height:1.5;margin:3px 0;padding-left:8px;border-left:2px solid var(--line)}.otk-reco-create{border-left-color:var(--accent)}.otk-reco-split{border-left-color:var(--accent)}.otk-reco-carve{border-left-color:var(--accent)}.otk-reco-overlap{border-left-color:#ffb020;background:rgba(255,176,32,.06)}.otk-reco-skip{border-left-color:var(--line);color:var(--muted)}.otk-reco-ladder{border-left-color:var(--ink-2)} .otk-body{display:grid;grid-template-columns:minmax(0,1fr) 292px;gap:18px;padding:18px 20px 20px;background:#fff}.otk-left{min-width:0;display:grid;gap:var(--otk-gap)}.otk-right{position:sticky;top:14px;align-self:start;display:grid;gap:var(--otk-gap)} .otk-card{border:1px solid var(--line);background:#fff;-webkit-backdrop-filter:none;backdrop-filter:none;padding:16px;display:grid;gap:13px;border-radius:var(--otk-radius);box-shadow:none} .otk-card>h3{font-size:15px;margin:0;color:var(--ink);font-weight:650;display:flex;align-items:center;gap:8px;justify-content:space-between;flex-wrap:wrap} .otk-reasoning{border-color:#0a0a0a;background:#fafafa} .otk-reasoning-list{margin:2px 0 0;padding-left:18px;display:grid;gap:7px;font-size:12px;color:var(--ink-2);line-height:1.45} .otk-reasoning-list>li{padding-left:2px}.otk-reasoning-list>li>b{color:var(--ink);font-weight:700} .otk-reasoning-list>li::marker{color:#0a0a0a;font-weight:700} .otk-sql{margin:0;max-height:340px;overflow:auto;background:#0a0a0a;border:1px solid #0a0a0a;border-radius:6px;padding:14px;font:11.5px/1.5 ui-monospace,Menlo,Consolas,monospace;color:#f5f5f5;white-space:pre;tab-size:2} .otk-copy-sql{font-size:11px;padding:4px 10px} .otk-eyebrow{font-size:10px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--muted)} .otk-screen{display:grid;gap:var(--otk-gap)}.otk-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.otk-form>label{display:grid;gap:6px;font-size:11.5px;color:var(--ink-2);font-weight:600} .otk-subsection{border:1px solid var(--line);background:#fbfbfc;padding:14px;display:grid;gap:12px;border-radius:var(--otk-radius);box-shadow:none}.otk-subsection>h4{font-size:12px;margin:0;color:var(--ink);font-weight:750;text-transform:uppercase;letter-spacing:.4px;display:flex;align-items:center;gap:8px;justify-content:space-between;flex-wrap:wrap} .otk-form-tiles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} .otk-form-tile{border:1px solid var(--line);background:#fafafa;padding:12px 12px 12px 14px;border-radius:6px;font-size:12px;display:grid;gap:7px;position:relative;overflow:hidden;box-shadow:none} .otk-form-tile::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--accent)} .otk-form-tile.b::before{background:var(--line-2)} .otk-form-tile b{font-size:12px;color:var(--ink);font-weight:600}.otk-form-tile small{color:var(--muted);line-height:1.45} .otk-screen[hidden],.otk-card[hidden],[data-role="otk-root"] [hidden]{display:none!important} .otk-ms-store{display:none!important} .otk-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center} .otk-btn,.otk-actions button{border:1px solid #0a0a0a;background:#0a0a0a;color:#fff;min-height:42px;padding:0 16px;font-size:13px;line-height:1.15;cursor:pointer;border-radius:7px;font-weight:650;box-shadow:none;transition:background .15s,transform .08s;display:inline-flex;align-items:center;justify-content:center;text-align:center;white-space:normal} .otk-actions button:hover{background:#000}.otk-actions button:active{transform:translateY(.5px)} .otk-actions button.secondary{background:#fff;color:var(--ink);border-color:var(--line);-webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none}.otk-actions button.secondary:hover{background:#f2f2f4;border-color:var(--line-2)} .otk-actions button.ghost{background:transparent;color:var(--ink-2);border-color:transparent;box-shadow:none}.otk-actions button.ghost:hover{background:#f2f2f4;color:var(--ink)} .otk-chips{display:flex;flex-wrap:wrap;gap:6px;min-height:22px} .otk-chip{border:1px solid var(--line);background:#fff;padding:5px 10px;border-radius:5px;font-size:11px;max-width:100%;overflow-wrap:anywhere;color:var(--ink-2)}.otk-chip.warn{border:1px solid var(--ink);background:#fff;color:var(--ink)}.otk-chip.ok{border-color:#0a0a0a;background:#0a0a0a;color:#fff} button.otk-chip{cursor:pointer;font-weight:600;font-family:inherit;transition:background .12s,border-color .12s,color .12s}button.otk-chip:hover{background:#0a0a0a;border-color:#0a0a0a;color:#fff} .otk-kv{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:6px 0;font-size:12.5px}.otk-kv span{color:var(--muted)}.otk-kv strong{text-align:right;color:var(--ink);font-weight:600} .otk-preview-list{display:grid;gap:8px;max-height:320px;overflow:auto;padding-right:2px} .otk-preview-row{border:1px solid var(--line);border-left:3px solid var(--line-2);padding:11px 12px;font-size:12px;border-radius:8px;background:var(--surface);display:grid;gap:4px} .otk-preview-row .ttl{font-weight:600;display:flex;align-items:center;gap:8px;color:var(--ink)}.otk-preview-row .badge{font-size:9px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:2px 7px;border-radius:5px;border:1px solid var(--line)} .otk-preview-row.ok{border-left-color:var(--line-2)}.otk-preview-row.ok .badge{background:var(--paper);color:var(--ink)} .otk-preview-row.warn{border-left-color:var(--accent)}.otk-preview-row.warn .badge{background:var(--accent);color:#fff;border-color:transparent} .otk-preview-row.skip{border-left-color:var(--line)}.otk-preview-row.skip .badge{background:var(--surface);color:var(--muted)} .otk-preview-row small{color:var(--muted)} .otk-done-banner{border:1px solid #0a0a0a;background:#0a0a0a;color:#fff;border-radius:6px;padding:11px 12px;margin-bottom:10px;display:grid;gap:7px} .otk-done-banner .otk-done-head{font-weight:700;font-size:13px} .otk-done-banner .otk-done-rows{display:flex;flex-wrap:wrap;gap:6px} .otk-done-banner .otk-jump-row{background:#fff;color:#0a0a0a;border:0;border-radius:5px;padding:4px 9px;font-size:11.5px;font-weight:600;cursor:pointer} .otk-done-banner .otk-jump-row:hover{background:#f0b400} .otk-done-banner .otk-done-save{font-size:11.5px;color:#e4e4e7}.otk-done-banner .otk-done-save b{color:#fff} .otk-done-banner .otk-help{color:#bdbdbd} .otk-summary-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:0;border:1px solid var(--line);border-radius:8px;overflow:hidden} .otk-stat{padding:11px 13px;background:var(--surface);border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.otk-stat:nth-child(2n){border-right:0}.otk-stat:nth-last-child(-n+2){border-bottom:0}.otk-stat .n{font-size:22px;font-weight:600;color:var(--ink);line-height:1}.otk-stat .l{font-size:10px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px;font-weight:600} .otk-stat.warn .n{color:var(--ink)} .otk-log-drawer{border-top:1px solid var(--line);padding:11px 20px;background:var(--surface)} .otk-log-handle{display:flex;align-items:center;gap:9px;cursor:pointer;font-size:12.5px;font-weight:600;color:var(--ink-2)} .otk-log-drawer .otk-icon-btn{background:var(--surface);color:var(--ink-2);border-color:var(--line);width:28px;height:26px;font-size:14px} .otk-log-body{border:1px solid var(--line);background:var(--paper);max-height:230px;overflow:auto;padding:10px;font-size:11px;margin-top:9px;border-radius:8px}.otk-log-line{padding:3px 0;border-bottom:1px solid var(--line)}.otk-log-warn b{color:var(--ink)}.otk-log-error b{color:var(--ink)} .otk-range-head,.otk-range{display:grid;grid-template-columns:1fr 1fr 1.5fr;gap:9px;align-items:center} .otk-range-head{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);padding:0 2px} .otk-range{position:relative} .otk-table{width:100%;border-collapse:collapse;font-size:11.5px}.otk-table th,.otk-table td{border:1px solid var(--line);padding:7px 8px;text-align:left}.otk-table th{background:var(--paper);font-weight:600;color:var(--ink-2)} .otk-ms{border:1px solid var(--line);background:#fff;border-radius:6px;padding:7px;display:grid;gap:7px;transition:border-color .15s,box-shadow .15s}.otk-ms.focus{border-color:#0a0a0a;box-shadow:0 0 0 2px rgba(10,10,10,.12)} .otk-ms-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center} .otk-ms-chip{display:inline-flex;align-items:center;gap:6px;background:#0a0a0a;border:1px solid transparent;color:#fff;border-radius:5px;padding:4px 6px 4px 10px;font-size:11.5px;font-weight:500;max-width:100%;box-shadow:none} .otk-ms-chip.warn{background:var(--surface);border:1px dashed var(--ink);color:var(--ink)} .otk-ms-chip span{overflow-wrap:anywhere} .otk-ms-x{border:0;background:rgba(255,255,255,.25);color:inherit;width:17px;height:17px;border-radius:4px;cursor:pointer;font-size:12px;line-height:1;display:flex;align-items:center;justify-content:center;font-weight:700}.otk-ms-x:hover{background:rgba(255,255,255,.42)} .otk-ms-chip.warn .otk-ms-x{background:rgba(17,17,17,.12)} .otk-ms-input{border:0;outline:0;flex:1;min-width:130px;min-height:30px;font-size:13px;padding:5px 4px;background:transparent;color:var(--ink);font-family:inherit} .otk-ms-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted)} .otk-ms-count{font-weight:600;color:var(--ink-2)} .otk-ms-paste{display:flex;gap:6px;border:0;background:transparent;color:var(--ink);font-size:11px;font-weight:700;cursor:pointer;padding:0} .otk-ac-wrap{position:relative} .otk-ac-list{position:absolute;left:0;right:0;top:calc(100% + 5px);z-index:80;background:#fff;-webkit-backdrop-filter:none;backdrop-filter:none;border:1px solid var(--line);border-radius:7px;box-shadow:var(--shadow);max-height:280px;overflow:auto;padding:5px}.otk-ac-list[hidden]{display:none} .otk-ac-item{width:100%;border:0;background:transparent;text-align:left;padding:9px 10px;border-radius:4px;cursor:pointer;display:grid;gap:2px;color:var(--ink)}.otk-ac-item:hover,.otk-ac-item.active{background:#f2f2f4}.otk-ac-item b{font-size:12.5px;font-weight:600}.otk-ac-item small{font-size:11px;color:var(--muted)}.otk-ac-empty{padding:10px;color:var(--muted);font-size:12px} .mc-v8-create-preview{margin-top:6px} .otk-tech{font-size:11px;color:var(--muted)} .otk-toggle{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--ink-2);font-weight:500;cursor:pointer} .otk-toggle input{appearance:none;-webkit-appearance:none;width:38px;height:22px;border-radius:999px;background:var(--line-2);position:relative;cursor:pointer;transition:background .16s;flex:none} .otk-toggle input:checked{background:var(--accent)} .otk-toggle input::after{content:"";position:absolute;width:18px;height:18px;border-radius:50%;background:#fff;top:2px;left:2px;transition:left .16s;box-shadow:0 1px 2px rgba(0,0,0,.2)} .otk-toggle input:checked::after{left:18px} /* Холодные тонкие скроллбары по всей панели (был дефолтный серый) */ #mc-panel.otk-shell-active{scrollbar-width:thin;scrollbar-color:#c8c8cc transparent} #mc-panel.otk-shell-active ::-webkit-scrollbar{width:10px;height:10px} #mc-panel.otk-shell-active ::-webkit-scrollbar-track{background:transparent} #mc-panel.otk-shell-active ::-webkit-scrollbar-thumb{background:#cfcfd4;border-radius:99px;border:2px solid transparent;background-clip:padding-box} #mc-panel.otk-shell-active ::-webkit-scrollbar-thumb:hover{background:#b0b0b6;background-clip:padding-box} #mc-panel.otk-shell-active ::-webkit-scrollbar-corner{background:transparent} @media(max-width:900px){.otk-body{grid-template-columns:1fr}.otk-right{position:static}.otk-form,.otk-form-tiles{grid-template-columns:1fr}} @media(max-width:620px){:where(#mc-panel.otk-shell-active,[data-role="otk-root"]){--otk-gap:12px;--otk-pad:14px;--otk-control:40px}#mc-panel.otk-shell-active{width:calc(100vw - 12px);right:6px;bottom:58px;max-height:calc(100vh - 70px);border-radius:8px}#mc-panel.otk-shell-active .mc-body{max-height:calc(100vh - 70px)}.otk-body,.otk-head,.otk-status,.otk-scenario,.otk-log-drawer{padding-left:14px;padding-right:14px}.otk-body{padding-top:14px;padding-bottom:14px}.otk-card{padding:14px}.otk-subsection{padding:12px}.otk-range-head,.otk-range{grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(92px,1.15fr);gap:7px}.otk-tabs{flex-wrap:wrap;overflow-x:visible;gap:10px}.otk-tab{min-height:34px;padding-top:6px;padding-bottom:8px}.otk-actions button{flex:1 1 138px}.otk-menu{right:-44px;max-width:calc(100vw - 40px)}} `; document.head.appendChild(style); } function fillOptions(list, values) { if (!list) return; list.innerHTML = ''; (values || []).slice(0, 700).forEach(value => { const option = document.createElement('option'); option.value = typeof value === 'string' ? value : (value.display || value.fio || value.name || ''); list.appendChild(option); }); } function labelForAutocompleteItem(item) { if (!item) return ''; if (typeof item === 'string') return item; return item.fio || item.display || item.name || item.title || item.id || ''; } function detailForAutocompleteItem(item) { if (!item || typeof item === 'string') return ''; return [item.position, item.login || item.id, item.role, item.source].filter(Boolean).join(' · '); } function attachAutocomplete(input, getItems, onSelect) { if (!input) return; input._otkAutocompleteItems = getItems; if (input.dataset.otkAcReady === '1') return; input.dataset.otkAcReady = '1'; const wrap = document.createElement('div'); wrap.className = 'otk-ac-wrap'; input.parentNode.insertBefore(wrap, input); wrap.appendChild(input); const list = document.createElement('div'); list.className = 'otk-ac-list'; list.hidden = true; wrap.appendChild(list); const hide = () => { list.hidden = true; }; const render = () => { const query = input.value; const items = (input._otkAutocompleteItems ? input._otkAutocompleteItems(query) : []).slice(0, 8); list.innerHTML = ''; if (!items.length && !query) { hide(); return; } if (!items.length) { list.innerHTML = '
Ничего не найдено
'; list.hidden = false; return; } items.forEach(item => { const button = document.createElement('button'); button.type = 'button'; button.className = 'otk-ac-item'; button.innerHTML = `${escapeHtml(labelForAutocompleteItem(item))}${escapeHtml(detailForAutocompleteItem(item))}`; button.addEventListener('mousedown', event => { event.preventDefault(); onSelect(item, input); hide(); }); list.appendChild(button); }); list.hidden = false; }; input.addEventListener('input', () => { const chosen = input.dataset && (input.dataset.userDisplay || input.dataset.userFio); if (chosen && input.value !== chosen) { delete input.dataset.userId; delete input.dataset.userDisplay; delete input.dataset.userFio; } render(); }); input.addEventListener('focus', render); input.addEventListener('click', render); input.addEventListener('pointerdown', () => setTimeout(render, 0)); input.addEventListener('keydown', event => { if (event.key === 'Escape') hide(); if (event.key === 'Enter' && !list.hidden) { const first = list.querySelector('.otk-ac-item'); if (first) { event.preventDefault(); first.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); } } }); document.addEventListener('mousedown', event => { if (!wrap.contains(event.target)) hide(); }); } // Legacy-style multi-select: chip picker with live search dropdown + a paste field. // The provided textarea stays the source of truth (so existing handlers/tests keep working). function buildMultiSelect(textarea, options = {}) { if (!textarea || textarea.dataset.otkMsReady === '1') return null; textarea.dataset.otkMsReady = '1'; const opts = options || {}; const wrap = document.createElement('div'); wrap.className = 'otk-ac-wrap'; const ms = document.createElement('div'); ms.className = 'otk-ms'; const row = document.createElement('div'); row.className = 'otk-ms-row'; const input = document.createElement('input'); input.type = 'text'; input.className = 'otk-ms-input'; input.placeholder = opts.placeholder || 'Введите или вставьте'; if (opts.inputRole) input.setAttribute('data-role', opts.inputRole); row.appendChild(input); const list = document.createElement('div'); list.className = 'otk-ac-list'; list.hidden = true; const foot = document.createElement('div'); foot.className = 'otk-ms-foot'; const count = document.createElement('span'); count.className = 'otk-ms-count'; const hint = document.createElement('span'); hint.textContent = opts.hint || 'Enter или запятая — добавить'; foot.appendChild(count); foot.appendChild(hint); ms.appendChild(row); ms.appendChild(list); ms.appendChild(foot); textarea.parentNode.insertBefore(wrap, textarea); wrap.appendChild(ms); wrap.appendChild(textarea); textarea.classList.add('otk-ms-store'); const getValues = () => parseList(textarea.value); const sync = values => { textarea.value = unique(values).join(', '); textarea.dispatchEvent(new Event('input', { bubbles: true })); }; const addValues = values => sync(getValues().concat(values)); const removeValue = value => sync(getValues().filter(item => normalize(item) !== normalize(value))); const hide = () => { list.hidden = true; }; const renderChips = () => { Array.from(row.querySelectorAll('.otk-ms-chip')).forEach(node => node.remove()); const values = getValues(); values.forEach(value => { const kind = opts.classify ? opts.classify(value) : ''; const chip = document.createElement('span'); chip.className = 'otk-ms-chip' + (kind === 'warn' ? ' warn' : ''); const label = document.createElement('span'); label.textContent = kind === 'warn' && opts.warnLabel ? `${value} · ${opts.warnLabel}` : value; const x = document.createElement('button'); x.type = 'button'; x.className = 'otk-ms-x'; x.textContent = '×'; x.title = 'Убрать'; x.addEventListener('click', () => { removeValue(value); if (document.activeElement === input) setTimeout(renderList, 0); }); chip.appendChild(label); chip.appendChild(x); row.insertBefore(chip, input); }); count.textContent = `Выбрано: ${values.length}`; if (opts.onChange) opts.onChange(values, textarea); }; const renderList = () => { const query = input.value; list.innerHTML = ''; const chosen = getValues(); const suggestions = (opts.suggest ? opts.suggest(query) : []) .filter(item => !chosen.some(value => normalize(value) === normalize(typeof item === 'string' ? item : item.value))) .slice(0, 8); if (!suggestions.length) { list.innerHTML = '
Нет совпадений — нажмите Enter, чтобы добавить как есть
'; list.hidden = false; return; } suggestions.forEach(item => { const value = typeof item === 'string' ? item : item.value; const label = typeof item === 'string' ? item : (item.label || item.value); const detail = typeof item === 'string' ? '' : (item.detail || ''); const button = document.createElement('button'); button.type = 'button'; button.className = 'otk-ac-item'; button.innerHTML = `${escapeHtml(label)}${detail ? `${escapeHtml(detail)}` : ''}`; button.addEventListener('mousedown', event => { event.preventDefault(); addValues([value]); input.value = ''; input.focus(); setTimeout(renderList, 0); }); list.appendChild(button); }); list.hidden = false; }; input.addEventListener('input', renderList); input.addEventListener('focus', () => { ms.classList.add('focus'); renderList(); }); input.addEventListener('click', renderList); input.addEventListener('pointerdown', () => setTimeout(renderList, 0)); input.addEventListener('blur', () => ms.classList.remove('focus')); input.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ',') { event.preventDefault(); const raw = input.value.replace(/,\s*$/, ''); if (raw.trim()) { addValues(parseList(raw)); input.value = ''; hide(); } } else if (event.key === 'Backspace' && !input.value) { const values = getValues(); if (values.length) removeValue(values[values.length - 1]); } else if (event.key === 'Escape') { hide(); } }); input.addEventListener('paste', event => { const text = (event.clipboardData || host.clipboardData || { getData: () => '' }).getData('text'); if (text && /[,;\n|]/.test(text)) { event.preventDefault(); addValues(parseList(text)); input.value = ''; hide(); } }); textarea.addEventListener('input', renderChips); document.addEventListener('mousedown', event => { if (!wrap.contains(event.target)) hide(); }); renderChips(); return { getValues, setValues: sync, addValues, refresh: renderChips, input }; } function renderLegalBundles(shell) { shell.querySelectorAll('[data-role="otk-legal-bundles"]').forEach(container => { if (container.dataset.otkReady === '1') return; container.dataset.otkReady = '1'; const targetRole = container.getAttribute('data-target'); LEGAL_ENTITY_BUNDLES.forEach(bundle => { const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'otk-chip'; chip.textContent = `+ ${bundle.label} (${bundle.entities.length})`; chip.title = bundle.entities.join(', '); chip.addEventListener('click', () => { const textarea = shell.querySelector(`[data-role="${targetRole}"]`); if (!textarea) return; textarea.value = unique(parseList(textarea.value).concat(bundle.entities)).join(', '); textarea.dispatchEvent(new Event('input', { bubbles: true })); }); container.appendChild(chip); }); }); } function setupAutocomplete(shell, dict) { shell.querySelectorAll('[data-user-autocomplete]').forEach(input => { attachAutocomplete(input, query => { if (!normalize(query)) return [].concat(dict.signers || [], dict.users || []).slice(0, 8); return UserResolver.search(query, dict, 8); }, (user, target) => { target.value = user.fio || user.display || ''; target.dataset.userId = user.id || ''; target.dataset.userDisplay = user.display || user.fio || ''; target.dataset.userFio = user.fio || user.display || ''; target.dispatchEvent(new Event('change', { bubbles: true })); }); }); const legalSuggest = query => { const q = normalize(query); const seen = new Set(); const out = []; [].concat(dict.legalEntities || [], dict.counterparties || []).forEach(item => { const key = normalize(item); if ((q && !key.includes(q)) || seen.has(key)) return; seen.add(key); const id = legalEntityId(item) || (dict.partnerIds && dict.partnerIds[key]) || ''; const kind = isSite(item, dict.sites) ? 'площадка/ОП' : 'ЮЛ'; out.push({ value: item, label: item, detail: id ? `${kind} · ID ${id}` : kind }); }); return out.slice(0, 12); }; buildMultiSelect(shell.querySelector('[data-role="otk-legal-input"]'), { placeholder: 'Введите ЮЛ или вставьте список', inputRole: 'otk-legal-ms', suggest: legalSuggest, classify: value => (isSite(value, dict.sites) ? 'warn' : ''), warnLabel: 'ОП', onChange: () => renderSignerLegalResolution(shell, LegalEntityResolver.resolve(shell.querySelector('[data-role="otk-legal-input"]').value, dict)), }); buildMultiSelect(shell.querySelector('[data-role="otk-doctor-problem-le"]'), { placeholder: 'ЮЛ с проблемой маршрута', inputRole: 'otk-doctor-problem-le-ms', suggest: legalSuggest, classify: value => (isSite(value, dict.sites) ? 'warn' : ''), warnLabel: 'ОП', }); // ДФК и площадки — мультивыбор (в матрице это комбинации значений через «;» в одной ячейке). const dictSuggest = source => query => { const q = normalize(query); return (source() || []).filter(item => !q || normalize(item).includes(q)).slice(0, 12); }; buildMultiSelect(shell.querySelector('[data-role="otk-direction"]'), { placeholder: 'Любая / несколько', inputRole: 'otk-direction-ms', suggest: dictSuggest(() => dict.directions), onChange: () => refreshSignerPattern(shell), }); buildMultiSelect(shell.querySelector('[data-role="otk-function"]'), { placeholder: 'Любая / несколько', inputRole: 'otk-function-ms', suggest: dictSuggest(() => dict.functions), onChange: () => refreshSignerPattern(shell), }); buildMultiSelect(shell.querySelector('[data-role="otk-category"]'), { placeholder: 'Любая / несколько', inputRole: 'otk-category-ms', suggest: dictSuggest(() => dict.categories), onChange: () => { refreshSignerPattern(shell); applySvodClassification(shell); }, }); buildMultiSelect(shell.querySelector('[data-role="otk-site-input"]'), { placeholder: 'ОП / площадки', inputRole: 'otk-site-ms', suggest: dictSuggest(() => dict.sites), }); // ФАЗА 4: ДФК-выпадашки галочками на «Атрибутах карточки» — наполнение из словаря матрицы. buildMultiSelect(shell.querySelector('[data-role="otk-attr-direction"]'), { placeholder: 'Любая / несколько', inputRole: 'otk-attr-direction-ms', suggest: dictSuggest(() => dict.directions), }); buildMultiSelect(shell.querySelector('[data-role="otk-attr-function"]'), { placeholder: 'Любая / несколько', inputRole: 'otk-attr-function-ms', suggest: dictSuggest(() => dict.functions), }); buildMultiSelect(shell.querySelector('[data-role="otk-attr-category"]'), { placeholder: 'Любая / несколько', inputRole: 'otk-attr-category-ms', suggest: dictSuggest(() => dict.categories), }); // Предупреждение «категория уже существует»: при типе «Категория» и совпадении значения с имеющейся. const attrCatNote = () => { const type = (shell.querySelector('[data-role="otk-attr-type"]') || {}).value; const val = compact((shell.querySelector('[data-role="otk-attr-value"]') || {}).value || ''); const note = shell.querySelector('[data-role="otk-attr-cat-note"]'); if (!note) return; if (type === 'category' && val && (dict.categories || []).some(c => normalize(c) === normalize(val))) { note.textContent = `⚠ Категория «${val}» уже есть в матрице — новые строки добавятся к существующей.`; note.hidden = false; } else { note.hidden = true; note.textContent = ''; } }; const attrTypeEl = shell.querySelector('[data-role="otk-attr-type"]'); const attrValEl = shell.querySelector('[data-role="otk-attr-value"]'); if (attrTypeEl) attrTypeEl.addEventListener('change', attrCatNote); if (attrValEl) attrValEl.addEventListener('input', attrCatNote); } function contextLabel(context) { if (context.kind === 'matrix') return `Матрица: ${context.title.replace(/^Матрица согласования:\s*/i, '')}`; if (context.kind === 'approval_list') return 'Лист согласования'; if (context.kind === 'itsm') return 'ITSM заявка'; if (context.kind === 'card') return 'Карточка договора'; if (context.kind === 'catalog') return 'Каталог матриц'; if (context.kind === 'counterparty_search') return 'Поиск контрагента'; return 'OpenText'; } function defaultScenario(context) { if (context.kind === 'itsm') return 'request'; if (context.kind === 'card' || context.kind === 'approval_list') return 'doctor'; if (context.kind === 'catalog' || context.kind === 'counterparty_search') return 'search'; return 'signers'; } function screenTitle(id) { return { signers: 'Подписанты', attributes: 'Атрибуты карточки', search: 'Поиск по матрицам', doctor: 'Проверка карточки', request: 'Разобрать заявку', svod: 'Свод (правила)', invest: 'Инвестпроект → MySQL', test: 'Тестовый контур', }[id] || 'Подписанты'; } const STANDARD_TEMPLATE = [ { edo: 'edo', packageClass: 'main', formLabel: 'Основной пакет · Единый ЭДО', documentTypes: DOC_GROUP_A.slice() }, { edo: 'non_edo', packageClass: 'main', formLabel: 'Основной пакет · Не единый ЭДО', documentTypes: DOC_GROUP_A.slice() }, { edo: 'edo', packageClass: 'supplemental', formLabel: 'Подчинённый пакет · Единый ЭДО', documentTypes: DOC_GROUP_B.slice() }, { edo: 'non_edo', packageClass: 'supplemental', formLabel: 'Подчинённый пакет · Не единый ЭДО', documentTypes: DOC_GROUP_B.slice() }, ]; function renderSignerForms(shell, forms, note) { const tiles = shell.querySelector('[data-role="otk-form-tiles"]'); const noteEl = shell.querySelector('[data-role="otk-pattern-note"]'); const title = shell.querySelector('[data-role="otk-forms-title"]'); if (!tiles) return; const list = forms && forms.length ? forms : STANDARD_TEMPLATE; if (title) title.innerHTML = `${list.length} ${list.length === 1 ? 'форма' : list.length < 5 ? 'формы' : 'форм'} на каждый диапазон по паттерну матрицы`; if (noteEl && note) noteEl.textContent = note; tiles.innerHTML = list.map((form, index) => { const supp = form.packageClass === 'supplemental'; const label = form.formLabel || `${supp ? 'Подчинённый пакет' : 'Основной пакет'} · ${form.edo === 'edo' ? 'Единый ЭДО' : 'Не единый ЭДО'}`; return `
${index + 1} · ${escapeHtml(label)}${escapeHtml((form.documentTypes || []).join(', '))}
`; }).join(''); } function renderSvodStatus(shell) { const status = shell.querySelector('[data-role="otk-svod-status"]'); const preview = shell.querySelector('[data-role="otk-svod-preview"]'); const index = SvodStore.get(); if (!status) return; if (!index || !index.count) { status.textContent = 'Свод не загружен.'; if (preview) preview.innerHTML = ''; return; } const internalCount = (index.rows || []).filter(row => row.internal).length; status.innerHTML = `Загружено: ${index.count} строк · ${index.functions.length} функций · ${index.directions.length} дирекций · ${internalCount} ВГО-категорий.`; if (preview) { const head = 'ДирекцияФункцияКатегорияТипВГОПодписант'; const body = (index.rows || []).slice(0, 40).map(row => `${escapeHtml(row.direction)}${escapeHtml(row.function)}${escapeHtml(row.category)}${escapeHtml(row.dealType)}${row.internal ? 'Да' : ''}${escapeHtml(row.signer)}`).join(''); preview.innerHTML = `${head}${body}
${index.count > 40 ? `…и ещё ${index.count - 40} строк` : ''}`; } } // Свод-классификация выбранных категорий: подсветить ВГО/тип, мягко провалидировать дирекцию/функцию, // показать ожидаемую цепочку и авто-проставить ВН=Да / тип сделки по своду. function applySvodClassification(shell) { const hint = shell.querySelector('[data-role="otk-svod-hint"]'); if (!hint) return; const index = SvodStore.get(); const catField = shell.querySelector('[data-role="otk-category"]'); const cats = parseList(catField ? catField.value : ''); if (!index || !index.count || !cats.length) { hint.innerHTML = ''; return; } const ctx = { direction: (shell.querySelector('[data-role="otk-direction"]') || {}).value || '', function: (shell.querySelector('[data-role="otk-function"]') || {}).value || '', }; const parts = []; let anyInternal = false; let anyIncome = false; cats.forEach(cat => { const v = SvodStore.validate(cat, ctx); if (!v.known) { parts.push(`«${escapeHtml(cat)}» — не найдена в своде`); return; } if (v.internal) anyInternal = true; if (/доходн/i.test(v.dealType)) anyIncome = true; const flags = []; if (v.internal) flags.push('ВГО'); if (v.dealType) flags.push(escapeHtml(v.dealType)); if (!v.fnOk) flags.push('функция не из свода'); if (!v.dirOk) flags.push('дирекция не из свода'); const chain = (v.chain || []).length ? ` · согласующие: ${escapeHtml(v.chain.join(', '))}` : ''; parts.push(`«${escapeHtml(cat)}» — ${flags.join(', ') || 'ОК'}${chain}`); }); const toggle = shell.querySelector('[data-role="otk-deal-internal"]'); if (anyInternal && toggle && !toggle.checked) { toggle.checked = true; toggle.dispatchEvent(new Event('change', { bubbles: true })); parts.unshift('Свод: это ВГО — включил ВН=Да (ЭЦП будет пустой)'); } const preset = shell.querySelector('[data-role="otk-conditions-preset"]'); if (anyIncome && !anyInternal && preset && preset.value !== 'income') { preset.value = 'income'; preset.dispatchEvent(new Event('change', { bubbles: true })); parts.unshift('Свод: доходная — поставил тип «Доходная»'); } hint.innerHTML = parts.join('
'); } function renderReconcile(shell, result, boxRole) { const box = shell.querySelector(`[data-role="${boxRole || 'otk-reconcile-result'}"]`); if (!box) return; if (!result || !result.perLegalEntity || !result.perLegalEntity.length) { box.innerHTML = '
Нет данных для сверки — укажите подписанта, ЮЛ и ДФК.
'; return; } const labelMap = { create: 'Создать', split: 'Разделить', carve: 'Нарезать банду', overlap: 'Наложение', skip: 'Уже настроено', ladder: 'Лимит' }; const parts = result.perLegalEntity.map(le => { const decisions = le.decisions.length ? le.decisions.map(d => `
${labelMap[d.action] || d.action}: ${escapeHtml(d.reason)}
`).join('') : '
Решений нет.
'; const rows = le.rows.length ? `Текущие строки: ${le.rows.map(r => `#${r.rowNumber} ${escapeHtml(r.signers.join(', '))} [${escapeHtml(r.limit)}] ${escapeHtml(r.edo)}`).join(' · ')}` : 'В матрице нет строк под этот ЮЛ×ДФК.'; return `
ЮЛ: ${escapeHtml(le.legalEntity)}
${decisions}${rows}
`; }).join(''); const s = result.summary || {}; const ops = [].concat.apply([], result.perLegalEntity.map(le => le.decisions.filter(d => d.op).map(d => d.op))); state.lastReconcileOps = ops; const applyBtn = ops.length ? `
` : ''; box.innerHTML = `
Создать: ${s.create || 0} · Разделить: ${s.split || 0} · Нарезать: ${s.carve || 0} · ⚠ Наложений: ${s.overlap || 0} · Уже есть: ${s.skip || 0} · Лесенки: ${s.ladder || 0}
${applyBtn}${parts}`; } function refreshSignerPattern(shell) { const field = role => shell.querySelector(`[data-role="${role}"]`); if (!field('otk-form-tiles')) return null; const legalText = field('otk-legal-input') ? field('otk-legal-input').value : ''; const resolved = LegalEntityResolver.resolve(legalText, DictionaryBuilder.build()); const learned = MatrixPatternEngine.learnSignerPattern({ direction: field('otk-direction') ? field('otk-direction').value : '', function: field('otk-function') ? field('otk-function').value : '', category: field('otk-category') ? field('otk-category').value : '', legalEntity: resolved.legalEntities[0] || '', }); renderSignerForms(shell, learned.learned ? learned.forms : null, learned.note); return learned; } function previewActionMeta(row) { const type = String(row.actionType || row.operationType || '').toLowerCase(); if (type === 'add-row' || /add|create|insert/.test(type)) return { cls: 'ok', badge: 'создать' }; if (type === 'remove-token' || /remove|delete/.test(type)) return { cls: 'warn', badge: 'удалить' }; if (type === 'patch-row' || /patch|update|replace/.test(type)) return { cls: 'warn', badge: 'изменить' }; if (type === 'skip' || row.status === 'skipped') return { cls: 'skip', badge: 'пропуск' }; if (row.status === 'ok') return { cls: 'ok', badge: 'готово' }; return { cls: 'warn', badge: 'внимание' }; } function renderPreview(root, result) { const preview = normalizePreviewResult(result || {}); state.lastPreview = preview; const summary = preview.summary || {}; const rows = preview.entries && preview.entries.length ? preview.entries : (preview.report || []); const created = summary.created || rows.filter(row => /add|create/.test(String(row.actionType || ''))).length; const updated = summary.updated || rows.filter(row => /patch|update/.test(String(row.actionType || ''))).length; const skipped = summary.skipped || rows.filter(row => row.status === 'skipped' || row.actionType === 'skip').length; const warned = (preview.warnings || []).length; const box = root.querySelector('[data-role="otk-preview"]'); const actions = root.querySelector('[data-role="otk-preview-actions"]'); root.querySelector('[data-role="otk-plan-id"]').textContent = preview.planId || 'нет preview'; root.querySelector('[data-role="otk-preview-summary"]').innerHTML = `Создаст: ${created}; изменит: ${updated}; пропустит: ${skipped}; предупреждений: ${warned}` + `
` + `
${created}
Создать
` + `
${updated}
Изменить
` + `
${skipped}
Пропустить
` + `
${warned}
Предупреждений
` + `
`; const actionTitle = type => ({ 'add-row': 'Новая строка', 'patch-row': 'Изменение строки', 'remove-token': 'Удаление атрибута', skip: 'Пропущено', }[String(type || '').toLowerCase()] || compact(type) || 'Запись'); box.innerHTML = rows.slice(0, 24).map(row => { const meta = previewActionMeta(row); const where = compact(row.rowNo || row.itemId || row.rowKey || ''); return `
${escapeHtml(meta.badge)}${escapeHtml(row.title || actionTitle(row.actionType || row.operationType))}
${escapeHtml(row.reason || row.message || '')}
${where ? `${escapeHtml(where)}` : ''}
`; }).join('') || ''; actions.hidden = !preview.planId; // Подпись кнопок применения: применённый план → «Применено ✓»; новый/неприменённый → дефолт // (если он не «взведён» на подтверждение). Защита от двойного apply держится на state.appliedPlanId. if (preview.planId && preview.planId === state.appliedPlanId) { const b1 = root.querySelector('[data-role="otk-apply-button"]'); if (b1) b1.textContent = 'Применено ✓'; const b2 = root.querySelector('[data-role="otk-apply-side"]'); if (b2) b2.textContent = 'Применено ✓'; } else if (preview.planId !== state.armedPlanId) { resetApplyButtons(root); } log(`Preview: ${preview.planId || 'без planId'}, записей ${rows.length}`, 'info'); } function resetApplyButtons(root) { const b1 = root.querySelector('[data-role="otk-apply-button"]'); if (b1) b1.textContent = 'Применить'; const b2 = root.querySelector('[data-role="otk-apply-side"]'); if (b2) b2.textContent = 'Применить изменения'; } // Баннер после применения: «✓ Применено» + кликабельные чипы строк (прыжок+подсветка в матрице) + // явный призыв нажать «Сохранить». Закрывает «изменения фиктивные / бегать по матрице». function renderApplyDone(root, changedRows) { const summary = root.querySelector('[data-role="otk-preview-summary"]'); if (!summary) return; const old = summary.querySelector('.otk-done-banner'); if (old) old.remove(); const rows = (changedRows || []).filter(i => i != null && i >= 0); const chips = rows.map(i => ``).join(' '); const banner = document.createElement('div'); banner.className = 'otk-done-banner'; banner.innerHTML = `
✓ Применено${rows.length ? ` · строк: ${rows.length}` : ''}
` + (rows.length ? `
${chips}
` : '
Изменений не внесено.
') + `
Теперь нажми «Сохранить» в OpenText (вверху), чтобы изменения легли.
`; summary.insertBefore(banner, summary.firstChild); } function addChip(container, text, warn, ok) { if (!container || !text) return; const chip = document.createElement('span'); chip.className = 'otk-chip' + (warn ? ' warn' : ok ? ' ok' : ''); chip.textContent = text; container.appendChild(chip); } function renderLegalResolution(root, resolved) { const legalBox = root.querySelector('[data-role="otk-legal-chips"]'); const siteBox = root.querySelector('[data-role="otk-site-chips"]'); const conflictBox = root.querySelector('[data-role="otk-conflict-chips"]'); [legalBox, siteBox, conflictBox].forEach(box => { if (box) box.innerHTML = ''; }); (resolved.legalEntities || []).forEach(item => addChip(legalBox, item)); (resolved.sites || []).forEach(item => addChip(siteBox, item, true)); (resolved.warnings || []).forEach(item => addChip(conflictBox, item, true)); (resolved.conflicts || []).forEach(item => addChip(conflictBox, `${item.input}: уточнить`, true)); } function renderSignerLegalResolution(root, resolved) { const legalBox = root.querySelector('[data-role="otk-signer-legal-chips"]'); const siteBox = root.querySelector('[data-role="otk-signer-site-chips"]'); const conflictBox = root.querySelector('[data-role="otk-signer-conflict-chips"]'); [legalBox, siteBox, conflictBox].forEach(box => { if (box) box.innerHTML = ''; }); (resolved.legalEntities || []).forEach(item => addChip(legalBox, item, false, true)); (resolved.sites || []).forEach(item => addChip(siteBox, item, true)); (resolved.warnings || []).forEach(item => addChip(conflictBox, item, true)); (resolved.conflicts || []).forEach(item => addChip(conflictBox, `${item.input}: уточнить`, true)); const wrap = root.querySelector('[data-role="otk-signer-conflict-wrap"]'); if (wrap) wrap.hidden = !((resolved.warnings || []).length || (resolved.conflicts || []).length); const block = root.querySelector('[data-role="otk-signer-resolution"]'); if (block) block.hidden = !((resolved.legalEntities || []).length || (resolved.sites || []).length || (resolved.warnings || []).length || (resolved.conflicts || []).length); } function activeScreen(root) { return root.querySelector('[data-role="otk-scenario"]').value; } function resolveUserInput(input, dict) { if (!input) return ''; const resolved = UserResolver.resolve(input.value, dict); return resolved && !resolved.unresolved ? resolved.fio : compact(input.value); } function resolveUserIdInput(input, dict) { if (!input) return ''; const datasetId = input.dataset && input.dataset.userId ? String(input.dataset.userId).replace(/^-/, '') : ''; if (datasetId) return datasetId; const resolved = UserResolver.resolve(input.value, dict); return resolved && !resolved.unresolved && resolved.id ? String(resolved.id).replace(/^-/, '') : ''; } function collectSignerRanges(root, dict) { const unified = root.querySelector('[data-role="otk-unified-range"]').checked; const sumLines = Array.from(root.querySelectorAll('[data-role="otk-sum-range-line"]')); return Array.from(root.querySelectorAll('[data-role="otk-range-line"]')).map((line, index) => { const fromInput = line.querySelector('[data-range-field="from"], [data-role="otk-range-from"]'); const toInput = line.querySelector('[data-range-field="to"], [data-role="otk-range-to"]'); const signerInput = line.querySelector('[data-range-field="signer"], [data-role="otk-range-signer"]'); const from = compact(fromInput && fromInput.value) || '0'; const to = compact(toInput && toInput.value); const sumLine = sumLines[index]; const sumFromInput = sumLine && sumLine.querySelector('[data-range-field="sumfrom"]'); const sumToInput = sumLine && sumLine.querySelector('[data-range-field="sumto"], [data-role="otk-amount-to"]'); const amountFrom = unified ? from : (compact(sumFromInput && sumFromInput.value) || from); const amountTo = unified ? to : (compact(sumToInput && sumToInput.value) || to); return { from, to, amountFrom, amount: amountTo, signer: resolveUserInput(signerInput, dict), signerId: resolveUserIdInput(signerInput, dict), }; }).filter(range => range.to || range.signer); } // ПЕРЕНОС категории: «не тупо удалить, а разбить + отдать новому». Возвращает ДВЕ операции: // 1) split вынесенного ЮЛ без переносимых категорий (у остальных ЮЛ категория сохраняется); // 2) add_signer_forms — новая строка вынесенному ЮЛ с этой категорией для НОВОГО подписанта. // Если перенос неполный (нет подписанта/лимита) — категорию НЕ убираем (чтобы ничего не потерять). function buildTransferOps(spec) { const s = spec || {}; const extractLegal = compact(s.legalEntity || s.extractLegal || ''); const transferCats = parseList(s.transferCategories || s.categories || ''); const toSigner = compact(s.toSigner || s.newSigner || ''); const toSignerId = compact(s.toSignerId || s.newSignerId || '') || resolveSignerId(toSigner); const limitTo = compact(s.limit || s.to || s.transferLimit || ''); const removeCats = parseList(s.removeCategories || ''); const fromSigner = compact(s.fromSigner || s.splitSigner || ''); // подписант на вынесенной строке (обычно прежний) const direction = compact(s.direction || ''); const functionName = compact(s.functionName || s.function || ''); const rowGroup = s.rowGroup || 'all'; const doTransfer = Boolean(toSigner && transferCats.length && limitTo); const splitOp = { type: 'split_legal_entity_to_new_row', payload: { legalEntity: extractLegal, removeCategories: unique([].concat(removeCats, doTransfer ? transferCats : [])), removeDocTypes: parseList(s.removeDocTypes || ''), newSigner: fromSigner, rowGroup, matchMode: 'all', affiliation: REQUIRED_AFFILIATION, }, }; if (!doTransfer) return [splitOp]; const learned = MatrixPatternEngine.learnSignerPattern({ direction, function: functionName, category: transferCats[0] || '', legalEntity: extractLegal }); const giveOp = { type: 'add_signer_forms', payload: { newSigner: toSigner, signer: toSigner, newSignerId: toSignerId, signerId: toSignerId, legalEntities: [extractLegal], legalEntityIds: [legalEntityId(extractLegal)].filter(Boolean), category: transferCats.join(', '), direction, functionName, ranges: [{ from: '0', to: limitTo, amount: limitTo, amountFrom: '0', signer: toSigner, signerId: toSignerId }], limit: limitTo, amount: limitTo, documentTypeGroups: DocumentTypePresetEngine.groups(), templateForms: learned.learned ? learned.forms : null, patternLearned: learned.learned, patternNote: learned.note, affiliation: REQUIRED_AFFILIATION, }, }; return [splitOp, giveOp]; } function buildOperation(root) { const scenario = activeScreen(root); const dict = DictionaryBuilder.build(); const field = role => root.querySelector(`[data-role="${role}"]`); const common = { rowGroup: field('otk-row-group') ? field('otk-row-group').value : 'all', matchMode: field('otk-match-mode') ? field('otk-match-mode').value : 'all', affiliation: REQUIRED_AFFILIATION, }; if (scenario === 'signers') { // "Кого заменяем" filled => replace existing signer by ФИО (patch performerList), not add forms. const currentSignerRaw = field('otk-current-signer') ? compact(field('otk-current-signer').value) : ''; if (currentSignerRaw) { const curUser = UserResolver.resolve(currentSignerRaw, dict); const newUser = UserResolver.resolve(field('otk-new-signer') ? field('otk-new-signer').value : '', dict); const curUserId = resolveUserIdInput(field('otk-current-signer'), dict) || ((curUser && !curUser.unresolved) ? curUser.id : ''); const newUserId = resolveUserIdInput(field('otk-new-signer'), dict) || ((newUser && !newUser.unresolved) ? newUser.id : ''); const resolvedLegalRep = LegalEntityResolver.resolve(field('otk-legal-input').value, dict); renderSignerLegalResolution(root, resolvedLegalRep); return { type: 'replace_signer_by_name', payload: Object.assign({}, common, { currentSignerId: curUserId, newSignerId: newUserId, currentSignerName: curUser ? curUser.fio : currentSignerRaw, newSignerName: newUser ? newUser.fio : (field('otk-new-signer') ? field('otk-new-signer').value : ''), rowGroup: 'all', direction: field('otk-direction').value, functionName: field('otk-function').value, category: field('otk-category').value, requiredDocTypes: [], matchMode: 'all', }), }; } const resolvedLegal = LegalEntityResolver.resolve(field('otk-legal-input').value, dict); renderSignerLegalResolution(root, resolvedLegal); const manualSites = parseList(field('otk-site-input').value); const ranges = collectSignerRanges(root, dict); const signerName = resolveUserInput(field('otk-new-signer'), dict) || (ranges[0] && ranges[0].signer) || resolveUserInput(field('otk-range-signer'), dict); const signerId = resolveUserIdInput(field('otk-new-signer'), dict) || (ranges[0] && ranges[0].signerId) || resolveUserIdInput(field('otk-range-signer'), dict); const rangeTo = (ranges[0] && ranges[0].to) || field('otk-range-to').value; const amountTo = (ranges[0] && ranges[0].amount) || (field('otk-unified-range').checked ? rangeTo : field('otk-amount-to').value); const learned = MatrixPatternEngine.learnSignerPattern({ direction: field('otk-direction').value, function: field('otk-function').value, category: field('otk-category').value, legalEntity: resolvedLegal.legalEntities[0] || '', }); return { type: 'add_signer_forms', payload: Object.assign({}, common, { currentSigner: resolveUserInput(field('otk-current-signer'), dict), newSigner: signerName, signer: signerName, newSignerId: signerId, signerId, limit: rangeTo, amount: amountTo, from: field('otk-range-from').value, to: rangeTo, ranges: ranges.length ? ranges : [{ from: field('otk-range-from').value, to: rangeTo, amountFrom: field('otk-range-from').value, amount: amountTo, signer: signerName, signerId }], legalEntities: resolvedLegal.legalEntities, legalEntityIds: resolvedLegal.legalEntities.map(name => legalEntityId(name)).filter(Boolean), sites: unique([].concat(resolvedLegal.sites || []).concat(manualSites)), direction: field('otk-direction').value, functionName: field('otk-function').value, category: field('otk-category').value, dealInternal: field('otk-deal-internal') ? field('otk-deal-internal').checked : false, conditions: effectiveConditions(field('otk-conditions-preset') ? field('otk-conditions-preset').value : 'standard', field('otk-deal-internal') && field('otk-deal-internal').checked), documentTypeGroups: DocumentTypePresetEngine.groups(), templateForms: learned.learned ? learned.forms : null, patternNote: learned.note, patternLearned: learned.learned, }), }; } if (scenario === 'attributes') { const attrType = field('otk-attr-type') ? field('otk-attr-type').value : 'doc_type'; const attrAction = field('otk-attr-action') ? field('otk-attr-action').value : 'add'; const isRemove = attrAction === 'remove'; const value = field('otk-attr-value') ? compact(field('otk-attr-value').value) : ''; const signerName = field('otk-attr-signer') ? field('otk-attr-signer').value : ''; // Срез строк: пакет + ДФК + (необязательно) конкретный подписант + условие «строка содержит типы». const baseAttr = Object.assign({}, common, { rowGroup: field('otk-attr-row-group') ? field('otk-attr-row-group').value : 'all', requiredDocTypes: parseList(field('otk-attr-required-docs') ? field('otk-attr-required-docs').value : ''), matchMode: (field('otk-attr-match-all') && field('otk-attr-match-all').checked) ? 'all' : 'any', direction: field('otk-attr-direction') ? field('otk-attr-direction').value : '', functionName: field('otk-attr-function') ? field('otk-attr-function').value : '', category: field('otk-attr-category') ? field('otk-attr-category').value : '', signerFilter: resolveUserIdInput(field('otk-attr-signer'), dict) || resolveUserInput(field('otk-attr-signer'), dict), signerFilterName: signerName, affiliation: REQUIRED_AFFILIATION, }); const resolutionBlock = root.querySelector('[data-role="otk-attr-resolution"]'); if (resolutionBlock) resolutionBlock.hidden = attrType !== 'legal'; if (isRemove) { if (attrType === 'doc_type') { return { type: 'remove_doc_type_from_matching_rows', payload: Object.assign({}, baseAttr, { removeDocType: value, docType: value }) }; } if (attrType === 'site') { return { type: 'remove_site_from_matching_rows', payload: Object.assign({}, baseAttr, { removeSite: value, site: value, sites: parseList(value) }) }; } if (attrType === 'change_field') { return { type: 'remove_change_card_flag_from_matching_rows', payload: Object.assign({}, baseAttr, { removeChangeCardFlag: value || 'Ранее не подписан', changeCardFlag: value || 'Ранее не подписан' }) }; } if (attrType === 'category') { return { type: 'remove_category_from_matching_rows', payload: Object.assign({}, baseAttr, { categoryFilter: baseAttr.category, removeCategory: value, targetCategory: value }) }; } const resolved = LegalEntityResolver.resolve(value, dict); renderLegalResolution(root, resolved); const ops = []; unique(resolved.legalEntities).forEach(name => ops.push({ type: 'remove_legal_entity_from_matching_rows', payload: Object.assign({}, baseAttr, { removeLegalEntity: name, legalEntity: name, legalEntities: [name], legalEntityId: legalEntityId(name), legalEntityIds: [legalEntityId(name)].filter(Boolean) }), })); unique(resolved.sites).forEach(name => ops.push({ type: 'remove_site_from_matching_rows', payload: Object.assign({}, baseAttr, { removeSite: name, site: name, sites: [name] }), })); if (!ops.length) { ops.push({ type: 'remove_legal_entity_from_matching_rows', payload: Object.assign({}, baseAttr, { removeLegalEntity: value, legalEntity: value, legalEntities: parseList(value), legalEntityId: legalEntityId(value), legalEntityIds: parseList(value).map(name => legalEntityId(name)).filter(Boolean) }), }); } return ops; } if (attrType === 'doc_type') { return { type: 'add_doc_type_to_matching_rows', payload: Object.assign({}, baseAttr, { newDocType: value }) }; } if (attrType === 'site') { return { type: 'add_site_to_matching_rows', payload: Object.assign({}, baseAttr, { site: value, sites: parseList(value) }) }; } if (attrType === 'change_field') { return { type: 'add_change_card_flag_to_matching_rows', payload: Object.assign({}, baseAttr, { changeCardFlag: value || 'Ранее не подписан' }) }; } if (attrType === 'category') { // Новая категория → создаём строки по образцу ЭТОЙ матрицы (паттерн ДФК), не «4 формы вслепую». const learned = MatrixPatternEngine.learnSignerPattern({ direction: baseAttr.direction, function: baseAttr.functionName, category: value }); return { type: 'create_category_from_template', payload: Object.assign({}, baseAttr, { category: value, newCategory: value, docTypes: parseList(field('otk-attr-required-docs') ? field('otk-attr-required-docs').value : ''), templateForms: learned.learned ? learned.forms : null, patternNote: learned.note, patternLearned: learned.learned, }) }; } // attrType === 'legal' — резолвим ЮЛ/ОП и каждый кладём своей операцией (как раньше на «ЮЛ и ОП»). const resolved = LegalEntityResolver.resolve(value, dict); renderLegalResolution(root, resolved); const ops = []; unique(resolved.legalEntities).forEach(name => ops.push({ type: 'add_legal_entity_to_matching_rows', payload: Object.assign({}, baseAttr, { legalEntity: name, legalEntities: [name], legalEntityId: legalEntityId(name), legalEntityIds: [legalEntityId(name)].filter(Boolean) }), })); unique(resolved.sites).forEach(name => ops.push({ type: 'add_site_to_matching_rows', payload: Object.assign({}, baseAttr, { site: name, sites: [name] }), })); if (!ops.length) { ops.push({ type: 'add_legal_entity_to_matching_rows', payload: Object.assign({}, baseAttr, { legalEntity: value, legalEntities: parseList(value), legalEntityId: legalEntityId(value), legalEntityIds: parseList(value).map(name => legalEntityId(name)).filter(Boolean) }), }); } return ops; } if (scenario === 'test') { return { type: 'add_signer_forms', payload: { newSigner: 'Тестовый Подписант', limit: '1000', amount: '1000', affiliation: REQUIRED_AFFILIATION } }; } return { type: 'checklist_card_validation', payload: { rawText: document.body ? document.body.textContent : '' } }; } function showScreen(root, id) { root.querySelectorAll('[data-screen]').forEach(screen => { screen.hidden = screen.getAttribute('data-screen') !== id; }); const native = root.querySelector('[data-role="otk-scenario"]'); if (native && native.value !== id) native.value = id; root.querySelectorAll('[data-role="otk-tab"]').forEach(tab => { tab.classList.toggle('active', tab.getAttribute('data-scenario') === id); }); const titleNode = root.querySelector('[data-role="otk-active-title"]'); if (titleNode) titleNode.textContent = screenTitle(id); if (id === 'doctor') maybeAutofillCard(root); } // Блок 3: при открытии «Карточки» — авто-забор дампа из живой страницы (если поле пустое и мы НЕ на // матрице/каталоге). Работает даже если договор-карточка классифицирована как itsm (так часто и бывает). function maybeAutofillCard(root) { const docNode = root.querySelector('[data-role="otk-doctor-text"]'); if (!docNode || compact(docNode.value)) return; const ctx = ContextDetector.detect() || {}; if (ctx.kind === 'matrix' || ctx.kind === 'catalog') return; // на матрице/каталоге карточки нет // innerText (а не textContent) — только ВИДИМЫЙ текст, без инлайн-скриптов/стилей: чище для распознавания. const live = compact(document.body ? (document.body.innerText || document.body.textContent || '') : ''); if (live.length <= 40) return; docNode.value = live.slice(0, 8000); const recog = root.querySelector('[data-role="otk-recognize-card"]'); if (recog) setTimeout(() => { try { recog.click(); } catch (_) { /* авто-распознавание не критично */ } }, 80); } function ensureToolkitShell() { let root = document.querySelector('#mc-root'); if (root) return root; const mount = document.body || document.documentElement; if (!mount) return null; let openBtn = document.querySelector('#mc-open-btn'); if (!openBtn) { openBtn = document.createElement('button'); openBtn.id = 'mc-open-btn'; openBtn.type = 'button'; openBtn.setAttribute('aria-label', 'OpenText Toolkit'); openBtn.title = 'OpenText Toolkit'; openBtn.innerHTML = ''; mount.appendChild(openBtn); } let panel = document.querySelector('#mc-panel'); if (!panel) { panel = document.createElement('aside'); panel.id = 'mc-panel'; panel.className = 'mc-panel'; panel.setAttribute('tabindex', '-1'); mount.appendChild(panel); } root = panel.querySelector('#mc-root'); if (!root) { root = document.createElement('div'); root.id = 'mc-root'; root.className = 'mc-body'; panel.appendChild(root); } let closeBtn = root.querySelector('[data-role="close"]') || panel.querySelector('[data-role="close"]'); if (!closeBtn) { closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'otk-close'; closeBtn.setAttribute('data-role', 'close'); closeBtn.setAttribute('aria-label', 'Закрыть'); closeBtn.title = 'Закрыть'; closeBtn.textContent = '×'; root.appendChild(closeBtn); } if (openBtn.dataset.otkShellReady !== '1') { openBtn.dataset.otkShellReady = '1'; openBtn.addEventListener('click', () => { panel.classList.add('mc-panel--open'); setTimeout(() => { try { panel.focus(); } catch (_) { /* focus is best-effort */ } }, 0); }); } if (closeBtn.dataset.otkShellReady !== '1') { closeBtn.dataset.otkShellReady = '1'; closeBtn.addEventListener('click', () => { panel.classList.remove('mc-panel--open'); setTimeout(() => { try { openBtn.focus(); } catch (_) { /* focus is best-effort */ } }, 0); }); } return root; } function installUi() { const target = api(); if (!target) return false; installStyles(); const root = ensureToolkitShell(); if (!root) return false; const existingShell = root.querySelector('[data-role="otk-root"]'); if (existingShell) { existingShell.hidden = false; existingShell.style.display = ''; root.classList.add('otk-clean'); const existingPanel = root.closest('#mc-panel'); if (existingPanel) existingPanel.classList.add('otk-shell-active'); return true; } const dict = DictionaryBuilder.build(); const context = ContextDetector.detect(); root.classList.add('otk-clean'); const panel = root.closest('#mc-panel'); if (panel) panel.classList.add('otk-shell-active'); const shell = document.createElement('section'); shell.setAttribute('data-role', 'otk-root'); shell.hidden = false; shell.style.display = ''; shell.innerHTML = `
OpenText Toolkit
${escapeHtml(contextLabel(context))}
${escapeHtml(context.status || context.kind || 'статус не найден')} ${dict.legalEntities.length} ЮЛ ${dict.sites.length} площадок ${dict.users.length} человек ${dict.directorySize || 0} в справочнике

Подписанты

Компании (ЮЛ) Группа Черкизово

Выбирайте из справочника или вставьте списком. Филиалы не попадут в ЮЛ — они уйдут в площадки/ОП.

Диапазоны подписания От · До · Подписант

От, ₽До, ₽Подписант

Формы на каждый диапазон по паттерну матрицы

Укажите дирекцию/функцию/категорию — и скрипт подберёт состав форм по образцу этой матрицы. Без образца — стандарт (4 формы).

Сверка с матрицей согласованный ран · что и почему

Перед созданием форм — сверься с тем, что уже есть в матрице: скрипт по каждому ЮЛ скажет, нужно ли дописать, создать или разделить строку, и покажет существующих подписантов и лимитные дыры.
Логи и диагностика
`; root.prepend(shell); if (panel) { const nativeClose = panel.querySelector('.mc-head [data-role="close"], #mc-root > [data-role="close"]'); const menuWrap = shell.querySelector('.otk-menu-wrap'); if (nativeClose && menuWrap) { nativeClose.classList.add('otk-close'); menuWrap.insertAdjacentElement('afterend', nativeClose); } } fillOptions(shell.querySelector('#otk-users'), dict.users); fillOptions(shell.querySelector('#otk-legal'), dict.legalEntities); fillOptions(shell.querySelector('#otk-sites'), dict.sites); fillOptions(shell.querySelector('#otk-docs'), dict.docTypes); fillOptions(shell.querySelector('#otk-directions'), dict.directions); fillOptions(shell.querySelector('#otk-functions'), dict.functions); fillOptions(shell.querySelector('#otk-categories'), dict.categories); setupAutocomplete(shell, dict); renderLegalBundles(shell); renderSignerForms(shell, null); ['otk-direction', 'otk-function', 'otk-category', 'otk-legal-input'].forEach(role => { const node = shell.querySelector(`[data-role="${role}"]`); if (node) node.addEventListener('change', () => refreshSignerPattern(shell)); if (node && role === 'otk-legal-input') node.addEventListener('input', () => refreshSignerPattern(shell)); }); const scenario = shell.querySelector('[data-role="otk-scenario"]'); scenario.value = defaultScenario(context); showScreen(shell, scenario.value); // если контекст — карточка, showScreen сам авто-заполнит дамп (Блок 3) scenario.addEventListener('change', () => showScreen(shell, scenario.value)); shell.querySelectorAll('[data-role="otk-tab"]').forEach(tab => { tab.addEventListener('click', () => showScreen(shell, tab.getAttribute('data-scenario'))); }); const attrType = shell.querySelector('[data-role="otk-attr-type"]'); const attrAction = shell.querySelector('[data-role="otk-attr-action"]'); if (attrType) { const syncAttrType = () => { const v = attrType.value; const action = attrAction ? attrAction.value : 'add'; const remove = action === 'remove'; const valueInput = shell.querySelector('[data-role="otk-attr-value"]'); if (valueInput) { valueInput.setAttribute('list', { doc_type: 'otk-docs', legal: 'otk-legal', site: 'otk-sites', category: 'otk-categories', change_field: '' }[v] || 'otk-docs'); valueInput.placeholder = { doc_type: remove ? 'Тип документа для удаления' : 'Тип документа', legal: remove ? 'ЮЛ/ОП для удаления' : 'ЮЛ или список', site: remove ? 'Площадка / ОП для удаления' : 'Площадка / ОП', category: remove ? 'Категория для удаления' : 'Новая категория', change_field: remove ? 'Текст/флаг для удаления' : 'Текст/флаг в «Изменениях»', }[v] || ''; } const resBlock = shell.querySelector('[data-role="otk-attr-resolution"]'); if (resBlock) resBlock.hidden = v !== 'legal'; }; attrType.addEventListener('change', syncAttrType); if (attrAction) attrAction.addEventListener('change', syncAttrType); syncAttrType(); } shell.addEventListener('click', event => { if (!event.target.closest) return; const jump = event.target.closest('.otk-jump-row'); if (jump) { const idx = Number(jump.getAttribute('data-row-index')); if (target.highlightRows) { const n = target.highlightRows([idx]); log(n ? `Прыгнул к строке #${idx + 1} — подсвечена жёлтым.` : `Строка #${idx + 1} не найдена в таблице.`, n ? 'info' : 'warn'); } return; } const showBtn = event.target.closest('[data-role="otk-show-in-matrix"]'); if (showBtn) { const le = showBtn.getAttribute('data-le') || ''; const res = target.showRowsInMatrix(le); if (res.ok) log(`Матрица отфильтрована по «${le}» — показано ${res.rows} строк(и). Прокрутил к таблице. Сбросить — кнопкой «Сбросить фильтр матрицы».`, 'info'); else log(`Не получилось отфильтровать матрицу по «${le}»: ${res.error}`, 'warn'); return; } if (event.target.closest('[data-role="otk-clear-matrix-view"]')) { const res = target.clearMatrixView(); log(res.ok ? 'Фильтр матрицы сброшен — показаны все строки.' : `Сброс фильтра не сработал: ${res.error}`, res.ok ? 'info' : 'warn'); return; } if (event.target.closest('[data-role="otk-build-missing"]')) { const forms = state.lastMissingForms || []; renderPreview(shell, { entries: forms, summary: { created: forms.length } }); log(`Собрано недостающих форм: ${forms.length}. Проверьте превью справа.`, forms.length ? 'info' : 'warn'); return; } if (event.target.closest('[data-role="otk-card-to-signer"]')) { const card = state.lastCard || {}; // ФАЗА 4.2: авто-забор из карточки. dispatch input ОБЯЗАТЕЛЕН — ДФК/ЮЛ теперь мультивыбор, // без события чипы не отрисуются (значение легло бы только в скрытый store). const set = (role, value) => { const node = shell.querySelector(`[data-role="${role}"]`); if (node) { node.value = value || ''; node.dispatchEvent(new Event('input', { bubbles: true })); } }; set('otk-direction', card.direction); set('otk-function', card.functionName); set('otk-category', card.category); set('otk-legal-input', (card.legalEntities || []).join(', ')); set('otk-site-input', (card.sites || []).join(', ')); const conditionsPresetNode = shell.querySelector('[data-role="otk-conditions-preset"]'); if (conditionsPresetNode) { conditionsPresetNode.value = conditionsForDealType(card.dealType); conditionsPresetNode.dispatchEvent(new Event('change', { bubbles: true })); } const legal = shell.querySelector('[data-role="otk-legal-input"]'); const cardAmount = card.limit || card.amountValue; if (cardAmount) set('otk-range-to', String(cardAmount).replace(/[^\d]/g, '')); showScreen(shell, 'signers'); const learned = refreshSignerPattern(shell); renderSignerLegalResolution(shell, LegalEntityResolver.resolve(legal ? legal.value : '', DictionaryBuilder.build())); const newSigner = shell.querySelector('[data-role="otk-new-signer"]'); if (newSigner) newSigner.focus(); log(`Перенёс срез из карточки в «Подписанты». ${learned && learned.learned ? learned.note : 'Образца нет — стандарт 4 формы.'}`, 'info'); return; } }); shell.querySelector('[data-role="otk-menu-button"]').addEventListener('click', () => { const menu = shell.querySelector('[data-role="otk-menu"]'); menu.hidden = !menu.hidden; }); // Esc закрывает панель откуда угодно (легаси-обработчик ловил Esc только при фокусе ВНУТРИ панели — // на кнопке/в матрице он не срабатывал). Capture-фаза: успеваем СНАЧАЛА проверить, не открыто ли // ⋯-меню или выпадашка автокомплита — тогда Esc закрывает их, а не всю панель. document.addEventListener('keydown', event => { if (event.key !== 'Escape') return; if (!panel || !panel.classList.contains('mc-panel--open') || !panel.classList.contains('otk-shell-active')) return; const menuOpen = shell.querySelector('[data-role="otk-menu"]:not([hidden])'); if (menuOpen) { menuOpen.hidden = true; event.preventDefault(); event.stopPropagation(); return; } // Сначала закрываются «временные» оверлеи, а не вся панель: выпадашка автокомплита и риск-поповер // (легаси-обработчик закроет их по всплытию — мы лишь не перехватываем закрытие панели). if (shell.querySelector('.otk-ac-list:not([hidden])')) return; if (panel.querySelector('#mc-risk-help-pop:not([hidden])')) return; event.preventDefault(); const closeBtn = panel.querySelector('[data-role="close"]'); if (closeBtn) closeBtn.click(); else panel.classList.remove('mc-panel--open'); }, true); const menuSearch = shell.querySelector('[data-role="otk-menu-search"]'); if (menuSearch) menuSearch.addEventListener('click', () => { const menu = shell.querySelector('[data-role="otk-menu"]'); if (menu) menu.hidden = true; showScreen(shell, 'search'); }); const menuTest = shell.querySelector('[data-role="otk-menu-test"]'); if (menuTest) menuTest.addEventListener('click', () => { const menu = shell.querySelector('[data-role="otk-menu"]'); if (menu) menu.hidden = true; showScreen(shell, 'test'); }); const menuInvest = shell.querySelector('[data-role="otk-menu-invest"]'); if (menuInvest) menuInvest.addEventListener('click', () => { const menu = shell.querySelector('[data-role="otk-menu"]'); if (menu) menu.hidden = true; showScreen(shell, 'invest'); }); const investBuild = shell.querySelector('[data-role="otk-invest-build"]'); if (investBuild) investBuild.addEventListener('click', () => { const val = role => { const el = shell.querySelector(`[data-role="${role}"]`); return el ? el.value : ''; }; const criteria = { manager: val('otk-invest-manager'), signer: val('otk-invest-signer'), sum: val('otk-invest-sum'), limit: val('otk-invest-limit') }; const table = compact(val('otk-invest-table')); const res = target.buildInvestProjectScript(criteria, val('otk-invest-request'), { table: table || undefined }); // Type=1 зашит в движке const resultBox = shell.querySelector('[data-role="otk-invest-result"]'); const projList = res.entries.length ? res.entries.map(e => `${escapeHtml(e.full)}`).join('') : (res.projects.length ? res.projects.map(p => `${escapeHtml(p)} — без названия`).join('') : 'проектов не нашёл — вставь строки «номер название»'); const existing = res.existingProjects.length ? res.existingProjects.slice(0, 12).map(p => `${escapeHtml(p)}`).join('') : ''; const rowsHead = res.rows.slice(0, 6).map(r => `
№${r.rowNo}${escapeHtml(r.project || 'без проекта')}
сумма ${escapeHtml(r.sum || '—')} · лимит ${escapeHtml(r.limit || '—')} · подписант ${escapeHtml(r.signer || '—')} · рук. ${escapeHtml(r.manager || '—')}
`).join(''); resultBox.innerHTML = `
Проекты для вставки (${res.entries.length || res.projects.length})
${projList}
${res.rows.length ? `Похожие строки матрицы: ${res.rows.length}
${rowsHead}
${existing ? `Их инвестпроекты
${existing}
` : ''}` : ''}
`; const sqlWrap = shell.querySelector('[data-role="otk-invest-sql-wrap"]'); const sqlBox = shell.querySelector('[data-role="otk-invest-sql"]'); if (res.sql) { sqlBox.textContent = res.sql; sqlWrap.hidden = false; } else { sqlWrap.hidden = true; } log(`Инвестпроект: записей ${res.entries.length || res.projects.length}, похожих строк ${res.rows.length}. SQL ${res.sql ? 'собран' : 'не собран (нет проектов)'}.`, res.sql ? 'info' : 'warn'); }); const investCopy = shell.querySelector('[data-role="otk-invest-copy"]'); if (investCopy) investCopy.addEventListener('click', async () => { const sql = (shell.querySelector('[data-role="otk-invest-sql"]') || {}).textContent || ''; if (!sql) return; let ok = false; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(sql); ok = true; } } catch (_) { ok = false; } log(ok ? 'SQL-скрипт скопирован в буфер.' : 'Не удалось скопировать — выдели текст вручную.', ok ? 'info' : 'warn'); }); const liveTest = shell.querySelector('[data-role="otk-live-test"]'); if (liveTest) liveTest.addEventListener('click', async () => { const box = shell.querySelector('[data-role="otk-live-result"]'); liveTest.disabled = true; const oldLabel = liveTest.textContent; liveTest.textContent = 'Прогоняю на матрице…'; log('Боевой прогон: проверяю все функции на реальной матрице (только превью)…', 'info'); let report; try { report = await target.runLiveSelfTest(); } catch (e) { log(`Боевой прогон упал: ${(e && e.message) || e}`, 'warn'); liveTest.disabled = false; liveTest.textContent = oldLabel; return; } const s = report.summary; if (box) { const rowsHtml = (report.checks || []).map(c => `
${c.skipped ? '∅' : (c.ok ? '✓' : '✗')}${escapeHtml(c.name)}
${c.detail ? `${escapeHtml(c.detail)}` : ''}
`).join(''); box.innerHTML = `
${s.green ? '✅ Всё работает на этой матрице' : '⚠ Есть замечания'} — ОК ${s.ok}/${s.total}, ошибок ${s.fail}, пропущено ${s.skipped}${rowsHtml}
`; } const dl = downloadLiveReport(report); log(`Боевой прогон: ${s.green ? 'ВСЁ ОК ✅' : 'ЕСТЬ ЗАМЕЧАНИЯ ⚠'} — ${s.ok}/${s.total}. ${dl.ok ? `Файл скачан: ${dl.name} — скинь его мне.` : 'Скачать не вышло — открой «Логи».'}`, s.green ? 'info' : 'warn'); liveTest.disabled = false; liveTest.textContent = oldLabel; }); const selfTest = shell.querySelector('[data-role="otk-self-test"]'); if (selfTest) selfTest.addEventListener('click', async () => { const menu = shell.querySelector('[data-role="otk-menu"]'); if (menu) menu.hidden = true; log('Самодиагностика: проверяю вёрстку, логику и окружение…', 'info'); const report = await collectDiagnostics(shell); const s = report.summary; log(`Самодиагностика: ${s.green ? 'ВСЁ ОК ✅' : 'ЕСТЬ ЗАМЕЧАНИЯ ⚠'} — проверок ${s.ok}/${s.total}, конвейер ${s.pipelineOk}/${s.pipelineOk + s.pipelineFail}, переполнение: ${s.overflow ? 'да' : 'нет'}, JS-ошибок: ${s.jsErrors}.`, s.green ? 'info' : 'warn'); (report.failedChecks || []).forEach(f => log(` ✗ ${f}`, 'warn')); const dl = downloadDiagnostics(report); if (dl.ok) { log(`Файл-отчёт скачан: ${dl.name}. Скинь его мне.`, 'info'); } else { let copied = false; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(dl.text); copied = true; } } catch (_) { copied = false; } log(copied ? 'Скачивание заблокировано — отчёт скопирован в буфер, вставь и пришли мне.' : 'Не удалось скачать/скопировать — открой «Логи и диагностика».', copied ? 'info' : 'warn'); } }); shell.querySelector('[data-role="otk-show-legacy"]').addEventListener('click', () => { root.classList.remove('otk-clean'); const panel = root.closest('#mc-panel'); if (panel) panel.classList.remove('otk-shell-active'); log('Legacy/debug panels shown from menu.', 'warn'); }); shell.querySelector('[data-role="otk-about"]').addEventListener('click', () => { log('OpenText Toolkit. Автор: Артём Шаповалов / ShapArt.', 'info'); }); shell.querySelector('[data-role="otk-refresh-dicts"]').addEventListener('click', () => { const fresh = DictionaryBuilder.build({ refresh: true }); fillOptions(shell.querySelector('#otk-users'), fresh.users); fillOptions(shell.querySelector('#otk-legal'), fresh.legalEntities); fillOptions(shell.querySelector('#otk-sites'), fresh.sites); fillOptions(shell.querySelector('#otk-docs'), fresh.docTypes); setupAutocomplete(shell, fresh); log(`Словари обновлены: ЮЛ ${fresh.legalEntities.length}, пользователи ${fresh.users.length}.`, 'info'); }); shell.querySelector('[data-role="otk-unified-range"]').addEventListener('change', event => { shell.querySelector('[data-role="otk-split-ranges"]').hidden = event.target.checked; }); const conditionsPreset = shell.querySelector('[data-role="otk-conditions-preset"]'); const dealInternalToggle = shell.querySelector('[data-role="otk-deal-internal"]'); const syncConditions = () => { const display = shell.querySelector('[data-role="otk-conditions-display"]'); if (display) display.value = effectiveConditions(conditionsPreset ? conditionsPreset.value : 'standard', dealInternalToggle && dealInternalToggle.checked).join('; '); }; if (conditionsPreset) conditionsPreset.addEventListener('change', syncConditions); if (dealInternalToggle) dealInternalToggle.addEventListener('change', syncConditions); const svodLoad = shell.querySelector('[data-role="otk-svod-load"]'); if (svodLoad) svodLoad.addEventListener('click', () => { const text = shell.querySelector('[data-role="otk-svod-text"]').value; const res = SvodStore.setFromText(text); if (!res.ok) { log(`Свод не загружен: ${res.error}`, 'warn'); } else { log(`Свод загружен: ${res.count} строк, ${res.functions} функций, ${res.directions} дирекций.`, 'info'); } renderSvodStatus(shell); }); const svodClear = shell.querySelector('[data-role="otk-svod-clear"]'); if (svodClear) svodClear.addEventListener('click', () => { SvodStore.clear(); const box = shell.querySelector('[data-role="otk-svod-text"]'); if (box) box.value = ''; log('Свод очищен.', 'info'); renderSvodStatus(shell); }); renderSvodStatus(shell); // Намерение со «Подписантов»: ФИО (+ID из автоподсказки), ЮЛ, ДФК, диапазоны. const signerIntentFromForm = () => { const dict = DictionaryBuilder.build(); const newSignerEl = shell.querySelector('[data-role="otk-new-signer"]'); const resolvedLegal = LegalEntityResolver.resolve((shell.querySelector('[data-role="otk-legal-input"]') || {}).value || '', dict); return { signer: resolveUserInput(newSignerEl, dict), signerId: resolveUserIdInput(newSignerEl, dict), legalEntities: resolvedLegal.legalEntities, direction: (shell.querySelector('[data-role="otk-direction"]') || {}).value || '', function: (shell.querySelector('[data-role="otk-function"]') || {}).value || '', category: (shell.querySelector('[data-role="otk-category"]') || {}).value || '', internal: shell.querySelector('[data-role="otk-deal-internal"]') ? shell.querySelector('[data-role="otk-deal-internal"]').checked : false, ranges: collectSignerRanges(shell, dict), }; }; const runSignerReconcile = () => { const result = ReconcileEngine.analyze(signerIntentFromForm()); renderReconcile(shell, result); log(`Сверка с матрицей: создать ${result.summary.create}, разделить ${result.summary.split}, нарезать ${result.summary.carve}, уже есть ${result.summary.skip}, конфликтов ${result.summary.conflict}.`, result.summary.conflict ? 'warn' : 'info'); return result; }; const reconcileBtn = shell.querySelector('[data-role="otk-reconcile"]'); if (reconcileBtn) reconcileBtn.addEventListener('click', runSignerReconcile); // Делегированный обработчик для кнопки «Собрать план в превью» — работает на любом экране // (панель сверки есть и на «Подписанты», и на «Разобрать заявку»). shell.addEventListener('click', async event => { if (!event.target.closest('[data-role="otk-reconcile-apply"]')) return; const ops = state.lastReconcileOps || []; if (!ops.length) { log('В плане сверки нет операций для применения.', 'warn'); return; } const result = await target.previewToolkit(ops); renderPreview(shell, result); log(`План сверки собран в превью: ${ops.length} операций. Проверь и нажми «Применить».`, 'info'); }); shell.addEventListener('click', async event => { if (!event.target.closest('[data-role="otk-copy-user-message"]')) return; const msg = state.lastUserMessage || ''; if (!msg) return; let ok = false; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(msg); ok = true; } } catch (_) { ok = false; } if (!ok) { try { const a = document.createElement('textarea'); a.value = msg; a.style.cssText = 'position:fixed;left:-9999px'; document.body.appendChild(a); a.select(); ok = document.execCommand && document.execCommand('copy'); a.remove(); } catch (_) { ok = false; } } log(ok ? 'Сообщение для пользователя скопировано в буфер.' : 'Не удалось скопировать — выделите текст вручную.', ok ? 'info' : 'warn'); }); const recognizeCard = shell.querySelector('[data-role="otk-recognize-card"]'); if (recognizeCard) recognizeCard.addEventListener('click', () => { // Тот же путь, что и «Показать превью» на экране «Карточка» (id==='doctor') — явная кнопка для ясности. const previewBtn = shell.querySelector('[data-role="otk-preview-button"]'); if (activeScreen(shell) === 'doctor' && previewBtn) previewBtn.click(); }); shell.querySelector('[data-role="otk-recognize-signer-legal"]').addEventListener('click', () => { const resolved = LegalEntityResolver.resolve(shell.querySelector('[data-role="otk-legal-input"]').value, DictionaryBuilder.build()); renderSignerLegalResolution(shell, resolved); log(`Для подписантов распознано ЮЛ ${resolved.legalEntities.length}, ОП ${resolved.sites.length}, конфликтов ${resolved.conflicts.length}.`, resolved.conflicts.length ? 'warn' : 'info'); }); shell.querySelector('[data-role="otk-add-range"]').addEventListener('click', () => { const list = shell.querySelector('[data-role="otk-range-list"]'); const line = document.createElement('div'); line.className = 'otk-range otk-range-line'; line.setAttribute('data-role', 'otk-range-line'); line.innerHTML = ''; list.appendChild(line); setupAutocomplete(shell, DictionaryBuilder.build()); line.querySelector('[data-range-field="from"]').focus(); }); const addSumRange = shell.querySelector('[data-role="otk-add-sum-range"]'); if (addSumRange) addSumRange.addEventListener('click', () => { const list = shell.querySelector('[data-role="otk-sum-range-list"]'); const line = document.createElement('div'); line.className = 'otk-range otk-sum-range-line'; line.setAttribute('data-role', 'otk-sum-range-line'); line.innerHTML = ''; list.appendChild(line); setupAutocomplete(shell, DictionaryBuilder.build()); line.querySelector('[data-range-field="sumfrom"]').focus(); }); shell.querySelector('[data-role="otk-build"]').addEventListener('click', () => { if (activeScreen(shell) === 'signers') refreshSignerPattern(shell); state.lastOperation = buildOperation(shell); log(`Собран сценарий: ${screenTitle(activeScreen(shell))}.`, 'info'); if (activeScreen(shell) === 'signers') { const learned = state.lastOperation.payload.patternLearned; log(learned ? `Паттерн выучен из матрицы: ${state.lastOperation.payload.patternNote}` : 'Образца в матрице нет — стандарт 4 формы.', 'info'); renderPreview(shell, { report: SignerFormsEngine.build(state.lastOperation.payload).map((form, index) => ({ actionType: 'add-row', status: 'ok', rowNo: `${form.newSigner || 'подписант'} · ${form.edoMode === 'edo' ? 'Единый ЭДО' : 'Не единый ЭДО'}`, title: `${form.packageLabel || (form.rowGroup === 'main_contract_rows' ? 'Основной пакет' : 'Подчинённый пакет')} · ${form.valueMode === 'limit' ? 'лимит' : 'сумма'}`, reason: `${form.valueMode === 'limit' ? 'Лимит' : 'Сумма'}: от ${form.from || '0'} до ${form.value || form.to || '—'} ₽ · документы: ${(form.documentTypes || []).join(', ')}`, })), entries: [] }); } }); shell.querySelector('[data-role="otk-preview-button"]').addEventListener('click', async () => { const id = activeScreen(shell); if (id === 'search') { const result = await target.searchAcrossMatrices(shell.querySelector('[data-role="otk-search-query"]').value, { mode: shell.querySelector('[data-role="otk-search-type"]').value, matchMode: shell.querySelector('[data-role="otk-search-match"]').value, }); shell.querySelector('[data-role="otk-search-result"]').innerHTML = `${(result.deduped || []).slice(0, 20).map(row => ``).join('')}
МатрицаСтрокаНайдено
${escapeHtml(row.matrixName)}${escapeHtml(row.rowNumber)}${escapeHtml(row.matchedValue)}
`; log(`Поиск: просканировано ${result.progress ? result.progress.scanned : 0}, найдено ${result.total}.`, 'info'); return; } if (id === 'doctor') { const text = shell.querySelector('[data-role="otk-doctor-text"]').value; const { card, audit } = CardDoctor.auditFromCard(text); const problemLeField = shell.querySelector('[data-role="otk-doctor-problem-le"]'); const diag = RouteDoctor.diagnose(card, problemLeField ? problemLeField.value : ''); state.lastReconcileOps = diag.ops; state.lastUserMessage = diag.userMessage; const chip = (value, warn) => (value ? `${escapeHtml(value)}` : 'не найдено'); const chips = (items, warn) => (items && items.length ? items.map(item => chip(item, warn)).join('') : 'не найдено'); const variantBadge = (has, label) => `${has ? '✓' : '✗'} ${escapeHtml(label)}`; // Без ДФК аудит хватает ВЕСЬ срез ЮЛ (десятки подписантов) = шум. Тогда не показываем стену, а просим ДФК. const dfkProvided = Boolean(compact(card.direction) || compact(card.functionName) || compact(card.category) || compact(problemLeField ? problemLeField.value : '')); const signerCards = !dfkProvided ? `
ДФК?Сначала укажи Дирекцию/Функцию/Категорию
Из карточки ДФК не копируется. Без него по этому ЮЛ ${audit.signers.length} подписантов на всех ДФК — это не про твою строку. Впиши ДФК (или ЮЛ в поле выше) — покажу точную нехватку.
` : audit.signers.map(signer => `
${signer.count}×${escapeHtml(signer.fio)}
${variantBadge(signer.hasEdo, 'Единый ЭДО')}${variantBadge(signer.hasNonEdo, 'Не единый ЭДО')}
${signer.ranges.map(range => `${range.edo === 'edo' ? 'ЕЭДО' : 'не ЕЭДО'} ${range.limit.from == null ? '0' : fmtMoney(range.limit.from)}–${fmtMoney(range.limit.to)} ₽`).join(' · ')}
`).join('') || ''; const issues = !dfkProvided ? '' : audit.issues.length ? audit.issues.map(text2 => `
нехватка${escapeHtml(text2)}
`).join('') : `
окСтроки полные: все варианты ЭДО на месте, диапазоны покрывают 0 → ∞.
`; const missingForms = dfkProvided ? MatrixPatternEngine.buildMissingForms(audit) : []; state.lastMissingForms = missingForms; state.lastCard = card; // Поиск маршрутов В МАТРИЦАХ (по кешу) — на карточке матрицы нет, ищем в закешированных. Главный ответ // на «не нашёл ни один маршрут»: показываем, в какой матрице есть строки под ДФК+ЮЛ этой карточки. const routes = findRoutesInCache(card); const routesCard = (() => { const head = `

Маршруты в матрицах по кешу${routes.matricesCached ? ` · ${routes.matricesCached} матриц` : ''}

`; if (!routes.matricesCached) return `
${head}
Кеш матриц пуст. Открой нужную матрицу один раз — тулкит её закеширует, и здесь покажу, где есть маршрут для этой карточки.
`; if (!routes.dfkProvided) return `
${head}
ДФК карточки не определён — без него точную матрицу не найду. Проверь Дирекцию/Функцию/Категорию.
`; if (!routes.results.length) return `
${head}
В кеше нет матрицы под «${escapeHtml(routes.dfkLabel)}». Открой соответствующую матрицу один раз — закеширую и найду маршрут.
`; const rowsHtml = routes.results.slice(0, 5).map(m => { const found = m.perLe.filter(x => x.rowsFound); const missing = m.perLe.filter(x => !x.rowsFound && x.le !== ROUTE_ANY_LE); const open = m.url ? `открыть матрицу →` : ''; const foundTxt = found.map(x => `${escapeHtml(x.le)}: строки #${x.rowNumbers.join(', #')}${x.covered === false ? ' ⚠ лимит не покрыт' : ''}`).join('; '); const missTxt = missing.length ? `
Под ЮЛ ${missing.map(x => `«${escapeHtml(x.le)}»`).join(', ')} строк нет — нужно добавить.
` : ''; return `
${found.length ? '✓' : '∅'}${escapeHtml(m.matrixName)} ${open}
${found.length ? `
${escapeHtml(foundTxt)}
` : '
ДФК совпал, но под ЮЛ карточки строк нет.
'}${missTxt}
`; }).join(''); return `
${head}${rowsHtml}
`; })(); const missingButton = missingForms.length ? `` : ''; const diagCard = `

Диагноз маршрута почему не строится

Тип карточки${escapeHtml(card.contractType || '—')}
Доходное / расходное${escapeHtml(card.dealType || '—')}${card.internal ? ' · ВГО (ВН=Да)' : ''}
Сумма / лимит${card.amountValue != null ? fmtMoney(card.amountValue) + ' ₽' : escapeHtml(card.amount || card.limit || '—')}
${card.sites && card.sites.length ? `
Площадки / ОП
${chips(card.sites)}
` : ''}
Не хватает строк под ЮЛ: ${diag.missingCount} из ${diag.perLegalEntity.length}
${diag.perLegalEntity.filter(le => le.legalEntity && le.legalEntity !== ROUTE_ANY_LE).map(le => `
ЮЛ: ${escapeHtml(le.legalEntity)} · строк найдено: ${le.rowsFound}
${le.verdicts.map(v => `
${escapeHtml(v)}
`).join('')}
`).join('')}
${diag.ops.length ? `` : ''}
${escapeHtml(diag.userMessage)}
`; shell.querySelector('[data-role="otk-doctor-result"]').innerHTML = routesCard + diagCard + `

Из карточки понял

${card.initiator ? `
Инициатор${escapeHtml(card.initiator)}
` : ''} ${card.responsible ? `
Ответственный по договору${escapeHtml(card.responsible)}
` : ''}
Тип документа${escapeHtml(card.contractType || '—')}
Дирекция${escapeHtml(card.direction || '— (не скопировалось)')}
Функция${escapeHtml(card.functionName || '— (не скопировалось)')}
Категория${escapeHtml(card.category || '— (не скопировалось)')}
ЮЛ (контрагенты)
${chips(card.legalEntities)}
${card.sites && card.sites.length ? `
Площадки / ОП
${chips(card.sites)}
` : ''}
Тип сделки${escapeHtml(card.dealType || '— (не скопировалось)')}${card.internal ? ' · ВГО (Группа Черкизово)' : ''}
Лимит / сумма${escapeHtml(card.limit || '—')} / ${escapeHtml(card.amount || '—')}
Ожидаемый ЭДО${escapeHtml(card.edoExpected)}
${(card.cardClarifications && card.cardClarifications.length) ? `

⚠ Не скопировалось из карточки — впиши вручную

${card.cardClarifications.map(q => `
${escapeHtml(q)}
`).join('')} В OpenText значения полей справа (ДФК, сумма, тип сделки) не попадают в Ctrl+C. ЮЛ, ОП, ВГО, инициатор — взял из карточки.
` : ''}

Аудит строк ${audit.matchedRows} строк · ${audit.signers.length} подписантов

${audit.patternNote ? `
Паттерн матрицы: ${escapeHtml(audit.patternNote)} Ожидается ${audit.expectedFormsPerSigner} ${audit.expectedFormsPerSigner === 1 ? 'форма' : audit.expectedFormsPerSigner < 5 ? 'формы' : 'форм'} на подписанта.
` : ''} ${issues} ${missingButton ? `
${missingButton}
` : ''}
Подписанты по этим фильтрам
${signerCards}
`; log(`Аудит карточки: ${audit.matchedRows} строк, ${audit.signers.length} подписантов, проблем ${audit.issues.length}.`, audit.issues.length ? 'warn' : 'info'); return; } if (id === 'request') { const parsed = ITSMIntakeEngine.parse(shell.querySelector('[data-role="otk-request-text"]').value); state.lastRequestParse = parsed; const understood = parsed.understood || {}; const reco = ReconcileEngine.analyze(intentFromUnderstood(understood, shell.querySelector('[data-role="otk-request-text"]').value)); const reasoning = buildReasoning(parsed, reco); const line = items => (items && items.length ? items.map(item => `${escapeHtml(item)}`).join('') : 'не найдено'); shell.querySelector('[data-role="otk-request-result"]').innerHTML = `
🧠 Как я рассуждал
    ${reasoning.map(s => `
  1. ${escapeHtml(s.t)}: ${escapeHtml(s.d)}
  2. `).join('')}
Прозрачно: тип запроса → что извлёк → план по шагам (что и почему) → сверка → в чём не уверен.
Я понял
Тип запроса${escapeHtml(understood.requestType || parsed.caseType || 'manual_review')}
Уверенность${escapeHtml(Math.round((parsed.confidence || 0) * 100))}%
${understood.initiator ? `
Инициатор${escapeHtml(understood.initiator)}
` : ''} ${understood.replace ? `
Замена подписанта${escapeHtml(understood.replace.from)} → ${escapeHtml(understood.replace.to)}${understood.replace.fromStructured ? ' (из полей карточки)' : ''}
` : ''}
Пользователи
${line(understood.users)}
Подписанты
${line(understood.signers)}
Спецэксперты
${line(understood.specialExperts)}
Руководители
${line(understood.managers)}
ЮЛ / компании
${line(understood.legalEntities)}
Типы документов
${line(understood.docTypes)}
Суммы / лимиты
${line((understood.amounts || []).concat(understood.limits || []))}
Диапазоны
${line((understood.signerRanges || []).map(range => `${range.from || '0'} → ${range.to}: ${range.signer}`))}
Дирекция / функция / категория
${line([understood.direction, understood.functionName, understood.category].filter(Boolean))}
Ссылки
${line(understood.links)}
${(understood.clarifications && understood.clarifications.length) ? `
⚠ Уточни у пользователя ${understood.clarifications.map(q => `
${escapeHtml(q)}
`).join('')} Лучше переспросить, чем сделать наугад. Ответы заведи руками в форму.
` : ''}
Нужно уточнить (поля)
${line(parsed.needsClarification || parsed.missing || [])}
${escapeHtml(parsed.suggestedFirstLineResponse || '')}
Предлагаемое действие
${escapeHtml((parsed.proposedOperations || []).map(op => op.type).join(', ') || 'manual_review')}
`; renderReconcile(shell, reco, 'otk-request-reconcile'); if (parsed.proposedOperations && parsed.proposedOperations.length) renderPreview(shell, await target.previewToolkit(parsed.proposedOperations)); log(`Заявка разобрана. План: создать ${reco.summary.create}, разделить ${reco.summary.split}, уже есть ${reco.summary.skip}.`, 'info'); return; } if (id === 'test') { const mode = shell.querySelector('[data-role="otk-test-mode"]').value; const result = await target.runSyntheticContour({ mode }); shell.querySelector('[data-role="otk-test-result"]').textContent = `OK=${result.ok}, FAIL=${result.fail}, всего=${result.total}`; log(`Тестовый контур: OK=${result.ok}, FAIL=${result.fail}.`, result.fail ? 'warn' : 'info'); return; } state.lastOperation = buildOperation(shell); const opList = [].concat(state.lastOperation); const result = await target.previewToolkit(opList); if (id === 'signers') { const forms = SignerFormsEngine.build(opList[0].payload); if (forms.length) { result.entries = forms.map(form => ({ actionType: 'add-row', status: 'ok', title: `${form.rowGroup === 'main_contract_rows' ? 'Основной пакет' : 'Подчинённый пакет'} · ${form.edoMode === 'edo' ? 'Единый ЭДО' : 'Не единый ЭДО'}`, rowNo: `${form.newSigner || 'подписант'} · ${form.valueMode === 'limit' ? 'лимит' : 'сумма'}`, reason: `${form.valueMode === 'limit' ? 'Лимит' : 'Сумма'}: от ${form.from || '0'} до ${form.value || form.to || '—'} ₽ · документы: ${(form.documentTypes || []).join(', ')}`, })); result.summary = Object.assign({}, result.summary, { created: forms.length }); } // АВТО-СВЕРКА: сразу показываем, что говорит матрица (создать/нарезать/конфликты) — без отдельной кнопки. try { runSignerReconcile(); } catch (_) { /* сверка не критична для превью */ } } renderPreview(shell, result); }); let applyArmTimer = null; const applyLatest = async () => { if (!state.lastPreview || !state.lastPreview.planId) { log('Apply заблокирован: сначала нужен preview.', 'warn'); return; } const planId = state.lastPreview.planId; // 1.3 Защита от двойного применения: применённый план повторно не трогаем. if (state.appliedPlanId === planId) { log('Этот план уже применён — повторно не применяю (защита от дублей). Нужен новый preview.', 'warn'); return; } // 1.6 Подтверждение прямо в панели (без модалок): 1-й клик «взводит», 2-й — применяет. if (state.armedPlanId !== planId) { state.armedPlanId = planId; const n = (state.lastPreview.report || state.lastPreview.entries || []).length || 0; const label = `Подтвердить применение (${n})`; const b1 = shell.querySelector('[data-role="otk-apply-button"]'); if (b1) b1.textContent = label; const b2 = shell.querySelector('[data-role="otk-apply-side"]'); if (b2) b2.textContent = label; if (applyArmTimer) clearTimeout(applyArmTimer); applyArmTimer = setTimeout(() => { state.armedPlanId = null; resetApplyButtons(shell); }, 6000); log(`Готов применить ${n} операц. Нажми «Подтвердить применение» ещё раз — или подожди, отменю.`, 'info'); return; } // Подтверждено — применяем. if (applyArmTimer) clearTimeout(applyArmTimer); state.armedPlanId = null; const result = await target.apply(planId); // 1.4 Сторож матрицы: применение отменено, если матрица сменилась после preview. if (result && result.matrixChanged) { resetApplyButtons(shell); log('Матрица сменилась после preview — применение ОТМЕНЕНО. Пересобери preview на нужной матрице.', 'warn'); renderPreview(shell, result); return; } state.appliedPlanId = planId; renderPreview(shell, result); const undoBtn = shell.querySelector('[data-role="otk-undo-side"]'); if (undoBtn) undoBtn.hidden = false; // 1.5 показать «Отменить» const okN = (result.report || []).filter(r => r.status === 'ok').length; const rowsLabel = (result.changedRows || []).map(i => `#${i + 1}`).join(', '); // Видимость: строки уже подсвечены в матрице самим apply; явно зовём «Сохранить» (Артём жмёт сам). log(`Готово: применено ${okN}${rowsLabel ? `, строки ${rowsLabel} подсвечены в матрице (жёлтым)` : ''}. Теперь нажми «Сохранить» в OpenText (вверху), чтобы изменения легли.`, 'info'); renderApplyDone(shell, result.changedRows || []); }; shell.querySelector('[data-role="otk-apply-button"]').addEventListener('click', applyLatest); shell.querySelector('[data-role="otk-apply-side"]').addEventListener('click', applyLatest); // 1.5 Undo: откат последнего применения в один клик — матрица возвращается как была. shell.querySelector('[data-role="otk-undo-side"]').addEventListener('click', () => { const res = target.revertLastApply ? target.revertLastApply() : { ok: false, message: 'Откат недоступен.' }; log(res.message || (res.ok ? 'Откат выполнен.' : 'Откатывать нечего.'), res.ok ? 'info' : 'warn'); if (res.ok) { state.appliedPlanId = null; const undoBtn = shell.querySelector('[data-role="otk-undo-side"]'); if (undoBtn) undoBtn.hidden = true; resetApplyButtons(shell); } }); shell.querySelector('[data-role="otk-clear-button"]').addEventListener('click', () => { if (target.clearPreview) target.clearPreview(); state.lastPreview = null; state.armedPlanId = null; state.appliedPlanId = null; if (applyArmTimer) clearTimeout(applyArmTimer); resetApplyButtons(shell); renderPreview(shell, { report: [], entries: [] }); log('Preview очищен.', 'info'); }); shell.querySelector('[data-role="otk-export-side"]').addEventListener('click', () => { const text = target.exportToolkitReport ? target.exportToolkitReport('html') : ''; log(`Экспорт отчёта подготовлен (${text.length} символов).`, 'info'); }); shell.querySelector('[data-role="otk-export-json"]').addEventListener('click', () => log((target.exportToolkitReport ? target.exportToolkitReport('json') : '').slice(0, 800), 'info')); shell.querySelector('[data-role="otk-export-csv"]').addEventListener('click', () => log((target.exportToolkitReport ? target.exportToolkitReport('csv') : '').slice(0, 800), 'info')); shell.querySelector('[data-role="otk-log-toggle"]').addEventListener('click', () => { const panel = shell.querySelector('[data-role="otk-log-panel"]'); panel.hidden = !panel.hidden; }); shell.querySelector('[data-role="otk-show-logs"]').addEventListener('click', () => { shell.querySelector('[data-role="otk-log-panel"]').hidden = false; shell.querySelector('[data-role="otk-menu"]').hidden = true; }); shell.querySelector('[data-role="otk-show-raw"]').addEventListener('click', () => { const raw = shell.querySelector('[data-role="otk-raw-plan"]'); raw.hidden = !raw.hidden; raw.textContent = JSON.stringify(state.lastPreview || {}, null, 2); }); shell.querySelector('[data-role="otk-copy-logs"]').addEventListener('click', async () => { const text = JSON.stringify(state.logs, null, 2); let copied = false; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); copied = true; } } catch (_) { copied = false; } if (!copied) { try { const area = document.createElement('textarea'); area.value = text; area.style.cssText = 'position:fixed;left:-9999px;top:0'; document.body.appendChild(area); area.select(); copied = document.execCommand && document.execCommand('copy'); area.remove(); } catch (_) { copied = false; } } log(copied ? 'Логи скопированы в буфер обмена.' : 'Не удалось скопировать в буфер — логи показаны ниже для копирования вручную.', copied ? 'info' : 'warn'); if (!copied) { const raw = shell.querySelector('[data-role="otk-raw-plan"]'); if (raw) { raw.hidden = false; raw.textContent = text; } } }); log(`Toolkit loaded: ${contextLabel(context)}.`, 'info'); state.installedUi = true; return true; } function install() { const okApi = installApi(); const okUi = installUi(); return okApi && okUi; } // Анти-FOUC: пока тулкит не успел применить otk-shell-active, легаси-панель — белая брутал-карточка // (#mc-panel{background:#fff;border:2px solid #111}). На тяжёлой матрице легаси-API приходит чуть позже // первого открытия → кадр старой вёрстки. Прячем панель ДО активации (visibility), но НЕ display, чтобы // не ломать раскладку. Снимаем правило, как только тулкит встал (иначе сломался бы режим Debug/Legacy, // который намеренно убирает otk-shell-active), либо по таймауту — тогда легаси остаётся рабочим фолбэком. function installPrebootMask() { if (document.getElementById('otk-preboot-hide')) return; const style = document.createElement('style'); style.id = 'otk-preboot-hide'; style.textContent = '#mc-panel:not(.otk-shell-active){visibility:hidden!important}'; (document.head || document.documentElement).appendChild(style); } function dropPrebootMask() { const el = document.getElementById('otk-preboot-hide'); if (el) el.remove(); } installPrebootMask(); if (install()) { dropPrebootMask(); return; } const timer = setInterval(() => { if (install()) { dropPrebootMask(); clearInterval(timer); } }, 200); setTimeout(() => { clearInterval(timer); dropPrebootMask(); }, 30000); })(); /* ===== Matrix Cleaner compatibility extension (generated) ===== */ (() => { 'use strict'; const INSTALL_FLAG = '__OT_MATRIX_CLEANER_COMPAT_EXTENSION__'; if (window[INSTALL_FLAG]) return; window[INSTALL_FLAG] = true; function install() { const api = window.__OT_MATRIX_CLEANER__; if (!api) return false; if (api.getReleaseInfo) { const current = api.getReleaseInfo(); if (current && /^8\./.test(String(current.version || ''))) return true; } const baseGetConfig = api.getConfig ? api.getConfig.bind(api) : () => ({}); api.getReleaseInfo = () => ({ version: '8.0.0', channel: 'production', build: 'modular-compatibility-extension-v8', generatedAt: new Date().toISOString(), modules: [ 'preview', 'dsl', 'checklists', 'search-everywhere', 'patchers', 'audit', 'native-counterparty-filter', 'running-sheet-detector', 'apply-snapshot', 'route-doctor', 'corpus-inventory', ], }); api.getExtendedConfig = () => { const base = baseGetConfig(); base.v5 = { featureFlags: { visualPreview: true, jsonDslV2: true, jsonDslV6: true, checklistEngine: true, globalSearchMode: true, nativeCounterpartyFilter: true, runningSheetDetector: true, applySnapshot: true, routeDoctor: true, }, }; return base; }; return true; } if (install()) return; const timer = setInterval(() => { if (!install()) return; clearInterval(timer); }, 200); setTimeout(() => clearInterval(timer), 15000); })();