// ==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 = `
Мастер подписантов
ЭДО: не задано
Единый ЭДО
Не ЭДО
Внешняя площадка
Показать bundle (4 строки)
`;
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 `${lab} `;
}).join('');
section.innerHTML = `
Основные операции (полный ввод)
Повседневные сценарии — в блоке «Рабочий режим» выше. Здесь: все типы, экспорт, JSON и тесты. Замена подписанта (replace_signer) в превью часто только manual-review — смотрите лог и отчёт.
Тип операции
${opOptions}
Расширенно: JSON и номер заявки (необязательно)
JSON и поле «номер заявки» для отчёта. Обычный ввод — без этого блока.
Удалять строку, если контрагент единственный
Пропускать строки «Исключить»
Требовать статус «Черновик»
Разрешить apply, если статус запущенных листов неизвестен
Аффилированность: ${CONFIG.requiredAffiliation}
Лимит строк:
Обновить
Превью (без сохранения)
Применить
Стоп
Диагностика
JSON
CSV
Логи
CSV неоднозначных
Копировать неоднозначные
Копировать пропуски
Копировать ошибки
Тест всего
Драйвер поиска (превью)
Драйвер поиска (применить)
Быстрые triage-действия
ambiguous: 0 · skipped: 0 · errors: 0
Копировать неоднозначные
Копировать пропуски
Копировать ошибки
`;
root.appendChild(section);
const quickMode = document.createElement('div');
quickMode.className = 'mc-compact-mode';
quickMode.innerHTML = `
Показывать только:
Все кнопки раздела
Только превью / применить / тест
Экспорт и отчеты
Triage и копирование
`;
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 = `
risk: ok
?
Risk badge shortcuts
Click: toggle log (all / ambiguous)
Double-click: copy ambiguous (TSV)
Shift+Click: copy errors (TSV)
Alt+Click: copy skipped (TSV)
Close
×
Загрузка...
Log: all
Log: ambiguous
`;
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} # Matrix Row Column Value ${list}
`;
},
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
Preview only (без сохранения)
Toggle preview
Clear preview
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} # Matrix Row Column Value Match ${list}
`;
}
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 = `
Поиск по матрицам
counterparty
user
signer
approver
partial
exact
Запустить поиск
Экспорт HTML
Еще не запускали.
`;
root.appendChild(searchSection);
const checklistSection = document.createElement('section');
checklistSection.innerHTML = `
Чеклист
Запустить чеклист
Export JSON
Чеклист ещё не запускался.
`;
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 = `
Рабочий
Текст заявки
Массовые
Поиск
Подписанты
Чек-лист
Тест всего
Отчеты
Advanced
Вставьте текст письма или заявки. Скрипт предложит черновик операций (без JSON). Проверьте поля и нажмите «Превью черновика».
Разобрать текст Превью черновика
Сюда выведутся подсказки и уверенность.
Сначала прогоняется тестовый контур (превью операций), затем проверка превью по видимым строкам и резервный bundle. Важно: «Тест всего» не сохраняет изменения в матрице, это диагностический прогон.
Режим контура Только превью (без записи в таблицу) С проверками для реальной вставки
Тест всего
Тест не запускался.
Показать технические блоки
`;
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 => `${item.label} `).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 # Matrix Operation Status Reason ${rows.map((row, idx) => `${idx + 1} ${escapeHtml(row.matrixName)} ${escapeHtml(row.operationType)} ${escapeHtml(row.status)} ${escapeHtml(row.reason || row.message || '')} `).join('')}
`;
}
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 JSON CSV HTML Тест всего
`;
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 = `${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 ? `Собрать план в превью (${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} `
+ ``
+ `
`
+ `
`
+ `
`
+ `
`
+ `
`;
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 ``;
}).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 => `→ строка #${i + 1} `).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} в справочнике
Что делаем?
Подписанты
Атрибуты карточки
Карточка
Заявка
Свод
Подписанты
Атрибуты карточки
Проверка карточки
Разобрать заявку
Свод (правила)
Поиск по матрицам
Инвестпроект → MySQL
Тестовый контур
Подписанты
Новый подписант
Кого заменяем (необязательно)
Компании (ЮЛ) Группа Черкизово
Выбирайте из справочника или вставьте списком. Филиалы не попадут в ЮЛ — они уйдут в площадки/ОП.
Распознать и проверить ГЧ
Распознаны как площадки/ОП
Диапазоны подписания От · До · Подписант
От, ₽ До, ₽ Подписант
+ Диапазон
Один диапазон и для лимита, и для суммы
Диапазоны по сумме документа
Сумма от, ₽ Сумма до, ₽ Подписант
+ Диапазон суммы
Формы на каждый диапазон по паттерну матрицы
Укажите дирекцию/функцию/категорию — и скрипт подберёт состав форм по образцу этой матрицы. Без образца — стандарт (4 формы).
Сверка с матрицей согласованный ран · что и почему
Перед созданием форм — сверься с тем, что уже есть в матрице: скрипт по каждому ЮЛ скажет, нужно ли дописать , создать или разделить строку, и покажет существующих подписантов и лимитные дыры.
Сверить с матрицей
Карточка → матрица Ctrl+A · Ctrl+C · Ctrl+V
Скажу, ПОЧЕМУ маршрут не строится: какой строки не хватает (нет ЮЛ / нет категории / нет ВГО-строки / не покрыт лимит), сколько не хватает, и дам текст для пользователя.
ЮЛ, у которых не строится (необязательно; можно несколько)
Распознать карточку
План по матрице мозг: что делать с этой заявкой
Из заявки беру подписанта, ЮЛ, ДФК и диапазоны и сверяю с матрицей: создать / разделить / уже настроено. Кнопка соберёт операции в превью.
Свод полномочий единый шаблон · вставка из Excel
Своды постоянно меняются, поэтому не вшиты в скрипт. Заполните единый шаблон (Своды/_ШАБЛОН_единого_свода.csv), скопируйте таблицу из Excel и вставьте сюда. Правила запомнятся в браузере — повторно вставлять не нужно, пока свод не изменится.
Загрузить свод
Очистить
Свод не загружен.
Боевой прогон проверка всего на ЭТОЙ матрице · preview-only
Прогоняет КАЖДУЮ функцию на твоих реальных данных (резолв подписанта, замена → план v8, формы, атрибуты, диагноз карточки, инвестпроект, поиск). Только превью — продакшн не меняется. Результат выгружается файлом, чтобы было удобно скинуть.
🔴 Прогнать всё на бою → файл
Синтетический контур для отладки
Режимpreview_only real_insert
SQL-скрипт INSERT в zContrReferences Скопировать
Показать превью
Применить
Очистить
Собрать формы
Что изменится Заполните форму слева и нажмите «Показать превью».
planId: нет preview
Действия Применить изменения Экспорт отчёта Отменить применение
Контекст
Страница ${escapeHtml(contextLabel(context))}
Статус ${escapeHtml(context.status || '—')}
ЮЛ / площадок ${dict.legalEntities.length} / ${dict.sites.length}
Человек в матрице ${dict.users.length}
`;
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 => `${escapeHtml(row.matrixName)} ${escapeHtml(row.rowNumber)} ${escapeHtml(row.matchedValue)} `).join('')}
`;
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('') || '0 Подписанты по этим фильтрам не найдены
Проверьте ДФК/ЮЛ или используйте «Поиск по матрицам».
';
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
? `Собрать недостающие формы (${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 ? `Собрать план в превью (${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 => `${escapeHtml(s.t)}: ${escapeHtml(s.d)} `).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);
})();