(function () { 'use strict'; // =============================== // ВХОДНЫЕ ОГРАНИЧЕНИЯ ВИДИМОСТИ // =============================== // Инструмент должен показываться только на странице пользователей GetCourse. const SUPPORTED_PATHS = [ '/pl/user/user/index' ]; if (!SUPPORTED_PATHS.includes(location.pathname)) { console.warn('[VSM] Неподдерживаемая страница:', location.pathname); return; } const CONFIG = { version: '0.9', // Оставь пустым, чтобы кнопку видели все сотрудники, у кого загружен этот HTML-блок. // Когда понадобится ограничить доступ, впиши ID через запятую или пробел: // allowedViewerUserIds: '123456, 789012' allowedViewerUserIds: '', // Если allowedViewerUserIds заполнен, а ID текущего пользователя определить не удалось, // кнопка будет скрыта. hideIfViewerIdUnknown: true, buttonText: 'Анализ новичков', panelTitle: 'Анализ вовлечённости новичков: недели, общая активность и группы', endpoint: '/pl/logic/rule/test', productId: '749494', fromNDays: 7, toNDays: 60, fromWeek: 1, toWeek: 8, ruleConcurrency: 1, listConcurrency: 6, batchSize: 50, jobConcurrency: 80, maxBaseUsers: 0, requestDelayMs: 0, maxListPages: 40, stopAfterEmptyPages: 2, activityDateType: 'this_week', activityMaxListPages: 2, weekThresholds: { 0: 1, 1: 4, 2: 3, 3: 3, 4: 3, 5: 3, 6: 2, 7: 2, 8: 3 } }; const WRAP_ID = 'vivo-segment-matrix-wrap'; const BTN_ID = 'vivo-segment-matrix-open'; const STATUS_ID = 'vsm-status'; const FILTER_ID = 'vsm-filter-info'; const RESULT_ID = 'vsm-results'; const DEBUG_ID = 'vsm-debug'; const RESET_FILTER_BTN_ID = 'vsm-reset-main-table-filter'; let ORIGINAL_TABLE_HEAD_HTML = ''; let ORIGINAL_TABLE_BODY_HTML = ''; let ORIGINAL_SUMMARY_HTML = ''; let CURRENT_TABLE_FILTER = null; let CURRENT_VIEW = 'overview'; let CURRENT_USER_FILTER = 'all'; const BEGINNER_LESSONS = [ { "week": 0, "weekTitle": "Стартовый урок", "streamId": "934007244", "lessonId": "340285310", "title": "Первый обязательный шаг - знакомство" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340456073", "title": "Программа занятий для первой недели новичка" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340551627", "title": "Оборудуем домашний йога зал" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340423192", "title": "5 советов для начинающих" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340448934", "title": "Дыхательный тест" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340455843", "title": "Полное йоговское дыхание" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340308293", "title": "Бодрая зарядка на каждый день" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340308294", "title": "Занятие 1" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340308295", "title": "Занятие 2" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340308296", "title": "Занятие 3" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340471342", "title": "Эфир с Ларисой для новичков \"Ответы на вопросы\"" }, { "week": 1, "weekTitle": "Неделя №1", "streamId": "934011387", "lessonId": "340448368", "title": "Анкета по результатам первой недели занятий" }, { "week": 2, "weekTitle": "Неделя №2", "streamId": "934043915", "lessonId": "340478314", "title": "Программа занятий для второй недели новичка" }, { "week": 2, "weekTitle": "Неделя №2", "streamId": "934043915", "lessonId": "340483559", "title": "Тест на гибкость" }, { "week": 2, "weekTitle": "Неделя №2", "streamId": "934043915", "lessonId": "340478317", "title": "Знакомство с Шавасаной" }, { "week": 2, "weekTitle": "Неделя №2", "streamId": "934043915", "lessonId": "340478318", "title": "Бодрая зарядка на каждый день" }, { "week": 2, "weekTitle": "Неделя №2", "streamId": "934043915", "lessonId": "340478319", "title": "Занятие 1" }, { "week": 2, "weekTitle": "Неделя №2", "streamId": "934043915", "lessonId": "340478320", "title": "Занятие 2." }, { "week": 2, "weekTitle": "Неделя №2", "streamId": "934043915", "lessonId": "340480595", "title": "Занятие 3." }, { "week": 2, "weekTitle": "Неделя №2", "streamId": "934043915", "lessonId": "340478324", "title": "Анкета по результатам второй недели занятий" }, { "week": 3, "weekTitle": "Неделя №3", "streamId": "934058425", "lessonId": "340552849", "title": "Программа занятий для третьей недели новичка" }, { "week": 3, "weekTitle": "Неделя №3", "streamId": "934058425", "lessonId": "340557824", "title": "Приветствие Солнцу с пояснениями" }, { "week": 3, "weekTitle": "Неделя №3", "streamId": "934058425", "lessonId": "340552852", "title": "Бодрая зарядка на каждый день" }, { "week": 3, "weekTitle": "Неделя №3", "streamId": "934058425", "lessonId": "340552853", "title": "Занятие 1" }, { "week": 3, "weekTitle": "Неделя №3", "streamId": "934058425", "lessonId": "340552854", "title": "Занятие 2." }, { "week": 3, "weekTitle": "Неделя №3", "streamId": "934058425", "lessonId": "340552855", "title": "Занятие 3." }, { "week": 3, "weekTitle": "Неделя №3", "streamId": "934058425", "lessonId": "340749646", "title": "Эфир с Ларисой для новичков \"Ответы на вопросы\"" }, { "week": 3, "weekTitle": "Неделя №3", "streamId": "934058425", "lessonId": "340552857", "title": "Анкета по результатам третьей недели занятий" }, { "week": 4, "weekTitle": "Неделя №4", "streamId": "934068239", "lessonId": "340605192", "title": "Программа занятий для четвертой недели новичка" }, { "week": 4, "weekTitle": "Неделя №4", "streamId": "934068239", "lessonId": "340605194", "title": "Бодрая зарядка на каждый день" }, { "week": 4, "weekTitle": "Неделя №4", "streamId": "934068239", "lessonId": "340754741", "title": "2 минуты для красивой осанки" }, { "week": 4, "weekTitle": "Неделя №4", "streamId": "934068239", "lessonId": "340605195", "title": "Занятие 1" }, { "week": 4, "weekTitle": "Неделя №4", "streamId": "934068239", "lessonId": "340605196", "title": "Занятие 2." }, { "week": 4, "weekTitle": "Неделя №4", "streamId": "934068239", "lessonId": "340605197", "title": "Занятие 3." }, { "week": 4, "weekTitle": "Неделя №4", "streamId": "934068239", "lessonId": "348239225", "title": "Эфир с Ларисой для новичков \"Ответы на вопросы\"" }, { "week": 4, "weekTitle": "Неделя №4", "streamId": "934068239", "lessonId": "340605199", "title": "Анкета по результатам четвертой недели занятий" }, { "week": 5, "weekTitle": "Неделя №5", "streamId": "934911404", "lessonId": "345121820", "title": "Программа занятий для начинающих на второй месяц" }, { "week": 5, "weekTitle": "Неделя №5", "streamId": "934911404", "lessonId": "345314998", "title": "Бодрая зарядка на каждый день" }, { "week": 5, "weekTitle": "Неделя №5", "streamId": "934911404", "lessonId": "347268796", "title": "Суставная разминка" }, { "week": 5, "weekTitle": "Неделя №5", "streamId": "934911404", "lessonId": "344993440", "title": "Занятие 1" }, { "week": 5, "weekTitle": "Неделя №5", "streamId": "934911404", "lessonId": "344993457", "title": "Занятие 2" }, { "week": 5, "weekTitle": "Неделя №5", "streamId": "934911404", "lessonId": "344993087", "title": "Занятие 3" }, { "week": 5, "weekTitle": "Неделя №5", "streamId": "934911404", "lessonId": "344993086", "title": "Бонусная медитация" }, { "week": 6, "weekTitle": "Неделя №6", "streamId": "934911405", "lessonId": "345315034", "title": "Бодрая зарядка на каждый день" }, { "week": 6, "weekTitle": "Неделя №6", "streamId": "934911405", "lessonId": "344993092", "title": "Занятие 1" }, { "week": 6, "weekTitle": "Неделя №6", "streamId": "934911405", "lessonId": "344993090", "title": "Занятие 2" }, { "week": 6, "weekTitle": "Неделя №6", "streamId": "934911405", "lessonId": "344993091", "title": "Занятие 3" }, { "week": 6, "weekTitle": "Неделя №6", "streamId": "934911405", "lessonId": "345315042", "title": "Бонусное занятие" }, { "week": 7, "weekTitle": "Неделя №7", "streamId": "934911407", "lessonId": "345315086", "title": "Бодрая зарядка на каждый день" }, { "week": 7, "weekTitle": "Неделя №7", "streamId": "934911407", "lessonId": "344993100", "title": "Занятие 1" }, { "week": 7, "weekTitle": "Неделя №7", "streamId": "934911407", "lessonId": "344993103", "title": "Занятие 2" }, { "week": 7, "weekTitle": "Неделя №7", "streamId": "934911407", "lessonId": "344993104", "title": "Занятие 3" }, { "week": 7, "weekTitle": "Неделя №7", "streamId": "934911407", "lessonId": "345219817", "title": "Бонусная медитация" }, { "week": 8, "weekTitle": "Неделя №8", "streamId": "934911410", "lessonId": "345315100", "title": "Бодрая зарядка на каждый день" }, { "week": 8, "weekTitle": "Неделя №8", "streamId": "934911410", "lessonId": "344993108", "title": "Занятие 1" }, { "week": 8, "weekTitle": "Неделя №8", "streamId": "934911410", "lessonId": "344993148", "title": "Занятие 2" }, { "week": 8, "weekTitle": "Неделя №8", "streamId": "934911410", "lessonId": "344993155", "title": "Занятие 3" }, { "week": 8, "weekTitle": "Неделя №8", "streamId": "934911410", "lessonId": "345315112", "title": "Бонусное занятие" }, { "week": 8, "weekTitle": "Неделя №8", "streamId": "934911410", "lessonId": "345377445", "title": "Анкета по результатам второго месяца занятий" }, { "week": 8, "weekTitle": "Неделя №8", "streamId": "934911410", "lessonId": "345315578", "title": "Что дальше?" } ]; init(); function init() { if (!isViewerAllowed()) { const existing = document.getElementById(BTN_ID); if (existing && existing.parentNode) existing.parentNode.removeChild(existing); console.warn('[VSM] Кнопка скрыта: текущий пользователь не входит в allowedViewerUserIds. viewerId=', getCurrentViewerId() || 'не определён'); return; } injectStyles(); addButton(); const observer = new MutationObserver(addButton); try { observer.observe(document.body, { childList: true, subtree: true }); } catch (e) {} console.log('[VSM] Кнопка добавлена, v' + CONFIG.version); } function parseAllowedViewerIds() { const raw = String(CONFIG.allowedViewerUserIds || '').trim(); if (!raw) return []; return raw .split(/[\s,;]+/) .map(function (id) { return String(id || '').trim(); }) .filter(Boolean); } function getCurrentViewerId() { const windowKeys = [ 'accountUserId', 'currentUserId', 'gcUserId', 'GC_USER_ID', 'userId', 'USER_ID' ]; for (let i = 0; i < windowKeys.length; i++) { const key = windowKeys[i]; const value = window[key]; if (value === null || value === undefined || value === '') continue; const match = String(value).match(/\d{3,}/); if (match) return match[0]; } const dataCandidates = [ document.body?.getAttribute('data-user-id'), document.body?.getAttribute('data-current-user-id'), document.documentElement?.getAttribute('data-user-id'), document.documentElement?.getAttribute('data-current-user-id') ]; for (let j = 0; j < dataCandidates.length; j++) { const value = dataCandidates[j]; if (!value) continue; const match = String(value).match(/\d{3,}/); if (match) return match[0]; } const metaCandidates = Array.from(document.querySelectorAll( 'meta[name="user-id"], meta[name="current-user-id"], meta[name="gc-user-id"], meta[property="user-id"]' )); for (let k = 0; k < metaCandidates.length; k++) { const content = metaCandidates[k].getAttribute('content') || ''; const match = content.match(/\d{3,}/); if (match) return match[0]; } // Пробуем только верхнее меню/панель аккаунта, чтобы не перепутать текущего сотрудника // с ID пользователей из самой таблицы сегмента. const menuLink = document.querySelector( '.gc-account-user-menu a[href*="/user/control/user/update/id/"], ' + '.gc-user-menu a[href*="/user/control/user/update/id/"], ' + '.user-menu a[href*="/user/control/user/update/id/"], ' + '.navbar a[href*="/user/control/user/update/id/"], ' + 'header a[href*="/user/control/user/update/id/"]' ); const href = menuLink?.getAttribute('href') || ''; const hrefMatch = href.match(/\/user\/control\/user\/update\/id\/(\d+)/); if (hrefMatch && hrefMatch[1]) return hrefMatch[1]; return ''; } function isViewerAllowed() { const allowedIds = parseAllowedViewerIds(); if (!allowedIds.length) return true; const viewerId = getCurrentViewerId(); if (!viewerId) return !CONFIG.hideIfViewerIdUnknown; return allowedIds.indexOf(String(viewerId)) !== -1; } function addButton() { if (!isViewerAllowed()) return; const existing = document.getElementById(BTN_ID); if (existing && existing.getAttribute('data-vsm-version') === CONFIG.version) return; if (existing && existing.parentNode) existing.parentNode.removeChild(existing); const btn = document.createElement('button'); btn.id = BTN_ID; btn.type = 'button'; btn.className = 'btn btn-success'; btn.style.marginLeft = '10px'; btn.textContent = CONFIG.buttonText; btn.setAttribute('data-vsm-version', CONFIG.version); btn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); openPanel(); }); const container = document.querySelector('.segment-rule .action-buttons') || document.querySelector('.action-buttons') || document.querySelector('.kv-panel-before') || document.body; if (container && container !== document.body) { container.appendChild(btn); } else { document.body.insertBefore(btn, document.body.firstChild); } } function openPanel() { const panel = ensurePanel(); panel.style.display = 'block'; try { panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {} } function closePanel() { const panel = document.getElementById(WRAP_ID); if (panel) panel.style.display = 'none'; } function ensurePanel() { let panel = document.getElementById(WRAP_ID); if (panel && panel.getAttribute('data-vsm-version') !== CONFIG.version && panel.parentNode) { panel.parentNode.removeChild(panel); panel = null; } if (!panel) { panel = document.createElement('div'); panel.id = WRAP_ID; panel.setAttribute('data-vsm-version', CONFIG.version); panel.innerHTML = renderInitial(); insertPanel(panel); bindEvents(panel); renderPlanPreview(); } return panel; } function insertPanel(panel) { const kvPanel = document.querySelector('.kv-panel-before'); const table = getCurrentUsersTable(document); if (kvPanel && kvPanel.parentNode) { if (kvPanel.nextSibling !== panel) kvPanel.parentNode.insertBefore(panel, kvPanel.nextSibling); return; } if (table && table.parentNode && table.parentNode.parentNode) { table.parentNode.parentNode.insertBefore(panel, table.parentNode); return; } document.body.insertBefore(panel, document.body.firstChild); } function bindEvents(panel) { if (panel.getAttribute('data-bound') === '1') return; panel.setAttribute('data-bound', '1'); panel.addEventListener('click', function (event) { const actionEl = event.target && event.target.closest('[data-action]'); const action = actionEl ? actionEl.getAttribute('data-action') : ''; if (!action) return; if (action === 'close') closePanel(); if (action === 'run') runMatrixAnalysis().catch(handleFatalError); if (action === 'copy-csv') copyCsv(); if (action === 'download-csv') downloadCsv(); if (action === 'copy-ids') copyFilteredIds(); if (action === 'reset-main-table') resetCurrentTableFilter(); if (action === 'filter-cohort') filterMainTableByCohort(actionEl.getAttribute('data-cohort') || CURRENT_USER_FILTER); if (action === 'copy-cohort') copyCohortIds(actionEl.getAttribute('data-cohort') || CURRENT_USER_FILTER); if (action === 'open-cohort') openCohortAsSegment(actionEl.getAttribute('data-cohort') || CURRENT_USER_FILTER); if (action === 'add-cohort-group') addCohortToNewGroup(actionEl.getAttribute('data-cohort') || CURRENT_USER_FILTER); if (action === 'view') { CURRENT_VIEW = actionEl.getAttribute('data-view') || 'overview'; updateViewButtons(); renderActiveView(); } }); panel.addEventListener('change', function (event) { const id = event.target && event.target.id; if (['vsm-product', 'vsm-from', 'vsm-to', 'vsm-from-week', 'vsm-to-week', 'vsm-max-pages', 'vsm-activity-period', 'vsm-filter'].includes(id)) { if (id === 'vsm-filter') { CURRENT_USER_FILTER = event.target.value || 'all'; renderUserTable(); } else { renderPlanPreview(); } } if (['vsm-batch-size', 'vsm-job-concurrency', 'vsm-max-base-users'].includes(id)) renderPlanPreview(); }); } function handleFatalError(error) { console.error('[VSM] Ошибка расчёта', error); const status = document.getElementById('vsm-status'); if (status) status.innerHTML = 'Ошибка: ' + escapeHtml(error && error.message || String(error)) + ''; const runBtn = document.querySelector('[data-action="run"]'); if (runBtn) runBtn.disabled = false; } function renderInitial() { return [ '
', '', '
', '
', '
' + escapeHtml(CONFIG.panelTitle) + '
', '
База берётся из текущей выборки пользователей GetCourse. Дальше инструмент строит матрицу входов в уроки, раскладывает людей по когортам и даёт виртуальные фильтры основной таблицы.
', '
', '
', '', '', '', '
', '
', '
', '', '', '', '', '', '', '
', '
', '
Важно: откройте нужный сегмент на странице пользователей. Расчётный режим по стресс‑тесту: пачка 50 ID, параллельных задач 80. Стартовый урок исключён из расчёта; анализ идёт по неделям 1–8. Дополнительно инструмент проверяет общую активность пользователя в GetCourse, чтобы отделять людей, которые не идут по треку, но занимаются в других разделах. Клики по когортам/неделям подставляют людей в основную таблицу как виртуальный фильтр, а кнопка «В группу» создаёт группу GetCourse.
', '
', '', '', '', '', '', '', '', '', '
', '
', '
', '', '', '
', '
Готово к запуску.
', '
', '', '
', '
' ].join(''); } function readSettings() { const rawFrom = Number(document.getElementById('vsm-from-week')?.value || CONFIG.fromWeek || 1); const rawTo = Number(document.getElementById('vsm-to-week')?.value || CONFIG.toWeek || 8); const fromWeek = Math.max(1, Math.min(8, Math.min(rawFrom, rawTo))); const toWeek = Math.max(1, Math.min(8, Math.max(rawFrom, rawTo))); return { productId: String(document.getElementById('vsm-product')?.value || CONFIG.productId).trim(), fromNDays: String(document.getElementById('vsm-from')?.value || CONFIG.fromNDays).trim(), toNDays: String(document.getElementById('vsm-to')?.value || CONFIG.toNDays).trim(), fromWeek, toWeek, maxListPages: Math.max(1, Number(document.getElementById('vsm-max-pages')?.value || CONFIG.maxListPages)), batchSize: Math.max(1, Number(document.getElementById('vsm-batch-size')?.value || CONFIG.batchSize)), jobConcurrency: Math.max(1, Number(document.getElementById('vsm-job-concurrency')?.value || CONFIG.jobConcurrency)), maxBaseUsers: Math.max(0, Number(document.getElementById('vsm-max-base-users')?.value || CONFIG.maxBaseUsers)), activityDateType: String(document.getElementById('vsm-activity-period')?.value || CONFIG.activityDateType || 'this_week') }; } function selectedLessons(settingsOrDepth) { const settings = typeof settingsOrDepth === 'object' ? settingsOrDepth : { fromWeek: 1, toWeek: Number(settingsOrDepth || CONFIG.toWeek || 8) }; const fromWeek = Math.max(1, Number(settings.fromWeek || 1)); const toWeek = Math.min(8, Number(settings.toWeek || CONFIG.toWeek || 8)); // Стартовый урок / анкета week=0 специально исключён из матрицы. // Его можно анализировать отдельно, но он не должен искажать вовлечённость по неделям. return BEGINNER_LESSONS.filter(l => Number(l.week) >= fromWeek && Number(l.week) <= toWeek); } function groupByWeek(list) { const map = new Map(); list.forEach(item => { const key = Number(item.week); if (!map.has(key)) map.set(key, { week: key, title: item.weekTitle, lessons: [] }); map.get(key).lessons.push(item); }); return Array.from(map.values()).sort((a, b) => a.week - b.week); } function renderPlanPreview() { const s = readSettings(); const lessons = selectedLessons(s); const groups = groupByWeek(lessons); const totalRequests = 1 + lessons.length; const el = document.getElementById('vsm-plan'); if (!el) return; el.innerHTML = [ 'План: база берётся из текущей таблицы пользователей GetCourse с учётом открытого фильтра/сегмента и пагинации.', '
Диапазон: неделя ' + s.fromWeek + ' — неделя ' + s.toWeek + '. Уроков: ' + lessons.length + ', недель: ' + groups.length + '. После загрузки базы инструмент разобьёт пользователей на пачки по ' + escapeHtml(s.batchSize) + ' ID и проверит каждую пачку по каждому уроку.' + '
Ориентир по тесту: 800 пользователей × 60 уроков при batchSize=50 и concurrency=80 ≈ 4,2 мин. Фактическое время зависит от нагрузки GetCourse.', '
Запрос урока теперь легче: idsrule пачки + вход в урок. Продуктовый фильтр повторно не добавляется, потому что база уже взята из нужного сегмента.', '
Стартовый урок не входит в расчёт. Неделя считается затронутой, если был вход хотя бы в 1 урок. Неделя считается вовлечённой, если набран порог 40–50% уроков недели.' + '
Общая активность: ' + escapeHtml(activityDateLabel(s.activityDateType)) + '. Она нужна, чтобы разделить “не идут по треку, но активны в клубе” и “вообще не заходили”.' ].join(''); } async function runMatrixAnalysis() { const settings = readSettings(); const lessons = selectedLessons(settings); const groups = groupByWeek(lessons); const runBtn = document.querySelector('[data-action="run"]'); const copyBtn = document.getElementById('vsm-copy-csv'); const downloadBtn = document.getElementById('vsm-download-csv'); const resultsEl = document.getElementById('vsm-results'); const progressEl = document.getElementById('vsm-progress'); window.VIVO_SEGMENT_MATRIX_LAST = null; window.VIVO_SEGMENT_MATRIX_LAST_REQUEST = null; window.VIVO_SEGMENT_MATRIX_LAST_ERRORS = []; if (runBtn) runBtn.disabled = true; if (copyBtn) copyBtn.disabled = true; if (downloadBtn) downloadBtn.disabled = true; if (resultsEl) resultsEl.innerHTML = ''; if (progressEl) progressEl.innerHTML = ''; const startedAt = new Date().toISOString(); setStatus('Быстро собираю базу из текущей таблицы пользователей…'); const baseSource = await collectBaseUsersFromCurrentTable(settings); let baseUsers = baseSource.users; const baseJson = { success: true, count: baseSource.totalFromSummary || baseUsers.size, countStr: formatNumber(baseSource.totalFromSummary || baseUsers.size) + ' пользователей', objectsLink: window.location.href, source: 'current_table_pages', pages: baseSource.pages, pageSize: baseSource.pageSize, skipped: baseSource.skipped || [] }; if (!baseUsers.size) { throw new Error('Не удалось извлечь ID пользователей из текущей таблицы. Откройте страницу /pl/user/user/index с нужным сегментом и таблицей пользователей.'); } setStatus('База собрана из текущей таблицы: ' + baseUsers.size + ' ID, страниц: ' + baseSource.pages + '.'); if (settings.maxBaseUsers > 0 && baseUsers.size > settings.maxBaseUsers) { const limited = new Map(); Array.from(baseUsers.entries()).slice(0, settings.maxBaseUsers).forEach(([id, user]) => limited.set(id, user)); baseUsers = limited; setStatus('База ограничена для теста: ' + baseUsers.size + ' пользователей.'); } const baseIds = Array.from(baseUsers.keys()).map(String); const batches = chunkArray(baseIds, settings.batchSize); const matrix = new Map(); baseUsers.forEach((user, id) => { matrix.set(String(id), { id: String(id), name: user.name || '', email: user.email || '', lessonCount: 0, lessons: new Set(), weekCounts: {}, touchedWeeks: 0, engagedWeeks: 0, maxTouchedWeek: 0, maxEngagedWeek: 0, cohort: '', rowHTML: user.rowHTML || '', generalActive: false, generalActivityLabel: '' }); }); const lessonMap = new Map(); lessons.forEach(lesson => { lessonMap.set(String(lesson.lessonId), Object.assign({}, lesson, { count: 0, loadedIds: 0, percent: 0, countStr: '', objectsLink: '', error: '', users: new Set(), batchCounts: [] })); }); const jobs = []; lessons.forEach(lesson => { batches.forEach((ids, batchIndex) => { jobs.push({ lesson, ids, batchIndex }); }); }); setStatus('Матрица: ' + baseIds.length + ' пользователей, ' + lessons.length + ' уроков, ' + batches.length + ' пачек, задач: ' + jobs.length + '.'); let done = 0; await runWithConcurrency(jobs, settings.jobConcurrency, async (job) => { const lesson = job.lesson; const stat = lessonMap.get(String(lesson.lessonId)); const label = 'lesson ' + lesson.lessonId + ' batch ' + (job.batchIndex + 1) + '/' + batches.length; try { const rule = buildAndRule([buildIdsRule(job.ids), buildLessonRule(lesson)]); const json = await runRule(rule, label); let users = new Map(); if (json.objectsLink && Number(json.count || 0) > 0) { users = await fetchUsersFromObjectsLink(json.objectsLink, label, Object.assign({}, settings, { maxListPages: 2 })); } stat.count += Number(json.count || users.size || 0); stat.batchCounts.push(Number(json.count || users.size || 0)); users.forEach((user, id) => { id = String(id); if (!matrix.has(id)) return; stat.users.add(id); const row = matrix.get(id); if (!row.lessons.has(String(lesson.lessonId))) { row.lessons.add(String(lesson.lessonId)); row.lessonCount += 1; const w = String(lesson.week); row.weekCounts[w] = (row.weekCounts[w] || 0) + 1; } if (!row.name && user.name) row.name = user.name; if (!row.email && user.email) row.email = user.email; }); } catch (error) { const msg = String(error && error.message || error); console.error('[VSM] ' + label + ' ошибка', error); stat.error = stat.error ? (stat.error + ' | ' + msg) : msg; window.VIVO_SEGMENT_MATRIX_LAST_ERRORS.push({ label, error: msg, request: window.VIVO_SEGMENT_MATRIX_LAST_REQUEST }); } done += 1; const lessonStatsLive = Array.from(lessonMap.values()).map(finalizeLessonStat(baseUsers.size)); renderLiveSummary(baseJson, baseUsers, lessonStatsLive, matrix, lessons, groups, false); updateProgress(done, jobs.length, 'Пачки уроков'); if (done % Math.max(1, settings.jobConcurrency) === 0) { setStatus('Готово задач: ' + done + ' / ' + jobs.length + '. Ошибок: ' + window.VIVO_SEGMENT_MATRIX_LAST_ERRORS.length + '.'); } if (CONFIG.requestDelayMs) await delay(CONFIG.requestDelayMs); }); updateProgress(jobs.length, jobs.length, 'Пачки уроков'); let activitySummary = { checked: false, active: 0, inactive: matrix.size, errors: [] }; if (settings.activityDateType && settings.activityDateType !== 'off') { activitySummary = await collectGeneralActivityForMatrix(matrix, batches, settings); } const lessonStats = Array.from(lessonMap.values()).map(finalizeLessonStat(baseUsers.size)); const analysis = buildAnalysis(baseJson, baseUsers, matrix, lessons, groups, lessonStats, settings, startedAt); analysis.batches = { size: settings.batchSize, count: batches.length, jobs: jobs.length, concurrency: settings.jobConcurrency, errors: window.VIVO_SEGMENT_MATRIX_LAST_ERRORS || [] }; analysis.generalActivity = activitySummary; window.VIVO_SEGMENT_MATRIX_LAST = analysis; renderFinal(analysis); setStatus('Готово: база ' + analysis.base.loaded + ', задач ' + jobs.length + ', ошибок ' + analysis.batches.errors.length + ', пользователей в матрице ' + analysis.users.length + '.'); if (runBtn) runBtn.disabled = false; if (copyBtn) copyBtn.disabled = false; if (downloadBtn) downloadBtn.disabled = false; } function finalizeLessonStat(baseSize) { return function(stat) { const loadedIds = stat.users ? stat.users.size : Number(stat.loadedIds || 0); return Object.assign({}, stat, { loadedIds, count: Number(stat.count || loadedIds || 0), percent: baseSize ? Math.round(loadedIds / baseSize * 100) : 0, countStr: loadedIds + ' пользователей', objectsLink: stat.objectsLink || '', users: undefined }); }; } function buildAnalysis(baseJson, baseUsers, matrix, lessons, groups, lessonStats, settings, startedAt) { const users = Array.from(matrix.values()).map(row => { const touched = []; const engaged = []; groups.forEach(group => { const count = Number(row.weekCounts[String(group.week)] || 0); const threshold = getWeekThreshold(group.week, group.lessons.length); if (count > 0) touched.push(group.week); if (count >= threshold) engaged.push(group.week); }); row.touchedWeeks = touched.length; row.engagedWeeks = engaged.length; row.maxTouchedWeek = touched.length ? Math.max.apply(null, touched) : 0; row.maxEngagedWeek = engaged.length ? Math.max.apply(null, engaged) : 0; row.cohort = getCohort(row.lessonCount, lessons.length); row.generalActivityLabel = row.generalActive ? activityDateLabel(settings.activityDateType) : 'нет активности в выбранном периоде'; return { id: row.id, name: row.name, email: row.email, lessonCount: row.lessonCount, touchedWeeks: row.touchedWeeks, engagedWeeks: row.engagedWeeks, maxTouchedWeek: row.maxTouchedWeek, maxEngagedWeek: row.maxEngagedWeek, cohort: row.cohort, generalActive: Boolean(row.generalActive), generalActivityLabel: row.generalActivityLabel || '', rowHTML: row.rowHTML || '', weekCounts: row.weekCounts, lessons: Array.from(row.lessons) }; }).sort((a, b) => b.lessonCount - a.lessonCount || String(a.id).localeCompare(String(b.id))); const cohorts = cohortDefinitions(lessons.length).map(c => { const list = users.filter(u => u.cohort === c.key); return Object.assign({}, c, { count: list.length, percent: users.length ? Math.round(list.length / users.length * 100) : 0 }); }); const weekStats = groups.map(group => { const threshold = getWeekThreshold(group.week, group.lessons.length); const touchedUsers = users.filter(u => Number(u.weekCounts[String(group.week)] || 0) > 0); const engagedUsers = users.filter(u => Number(u.weekCounts[String(group.week)] || 0) >= threshold); return { week: group.week, title: group.title, lessons: group.lessons.length, threshold, touched: touchedUsers.length, touchedPercent: users.length ? Math.round(touchedUsers.length / users.length * 100) : 0, engaged: engagedUsers.length, engagedPercent: users.length ? Math.round(engagedUsers.length / users.length * 100) : 0 }; }); const activityStats = buildActivityStats(users, settings); const dropoff = []; groups.forEach(group => { if (group.week === 0) return; const current = users.filter(u => Number(u.weekCounts[String(group.week)] || 0) >= getWeekThreshold(group.week, group.lessons.length)); const nextGroup = groups.find(g => Number(g.week) === Number(group.week) + 1); if (!nextGroup) return; const dropped = current.filter(u => Number(u.weekCounts[String(nextGroup.week)] || 0) < getWeekThreshold(nextGroup.week, nextGroup.lessons.length)); dropoff.push({ afterWeek: group.week, label: 'После ' + group.title, count: dropped.length, percentOfBase: users.length ? Math.round(dropped.length / users.length * 100) : 0 }); }); return { version: CONFIG.version, startedAt, finishedAt: new Date().toISOString(), settings, base: { count: Number(baseJson.count || 0), countStr: baseJson.countStr || '', loaded: users.length, objectsLink: baseJson.objectsLink || '', source: baseJson.source || '', pages: baseJson.pages || 0, pageSize: baseJson.pageSize || 0, skipped: baseJson.skipped || [] }, lessons: lessonStats, weekStats, cohorts, activityStats, dropoff, users }; } function getCohort(lessonCount, totalLessons) { const n = Number(lessonCount || 0); const total = Math.max(1, Number(totalLessons || 1)); const pct = n / total; if (n === 0) return 'not_started'; if (pct < 0.20) return 'touched'; if (pct < 0.40) return 'tried'; if (pct < 0.60) return 'in_process'; if (pct < 0.80) return 'active'; return 'strong'; } function cohortDefinitions(totalLessons) { const payload = window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW; const total = Math.max(1, Number(totalLessons || (payload && payload.lessons && payload.lessons.length) || 60)); const range = function (fromPct, toPct) { const from = Math.max(1, Math.floor(total * fromPct)); const to = Math.max(from, Math.ceil(total * toPct) - 1); return from + '–' + to + ' уроков'; }; return [ { key: 'not_started', title: 'Не стартовали', range: '0 уроков', hint: 'В выбранном диапазоне недель не открывали ни одного урока' }, { key: 'touched', title: 'Только коснулись', range: range(0.001, 0.20), hint: 'Есть 1–20% уроков выбранного диапазона' }, { key: 'tried', title: 'Попробовали', range: range(0.20, 0.40), hint: 'Есть 20–40% уроков выбранного диапазона' }, { key: 'in_process', title: 'Вошли в процесс', range: range(0.40, 0.60), hint: 'Есть 40–60% уроков выбранного диапазона' }, { key: 'active', title: 'Активно занимаются', range: range(0.60, 0.80), hint: 'Есть 60–80% уроков выбранного диапазона' }, { key: 'strong', title: 'Сильно вовлечены', range: Math.ceil(total * 0.80) + '+ уроков', hint: '80%+ уроков выбранного диапазона; лучшие кандидаты на продление/апселл/отзыв' } ]; } function getWeekThreshold(week, lessonCount) { if (CONFIG.weekThresholds.hasOwnProperty(Number(week))) return CONFIG.weekThresholds[Number(week)]; return Math.max(1, Math.ceil(Number(lessonCount || 0) * 0.45)); } async function runRule(rule, label) { const params = new URLSearchParams(); params.set('context', 'UserContext'); appendRule(params, 'rule', rule); params.set('baseRule', ''); addGcMeta(params); const body = params.toString(); window.VIVO_SEGMENT_MATRIX_LAST_REQUEST = { label: label || '', body, decoded: decodeURIComponent(body) }; const res = await fetch(CONFIG.endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', 'Accept': '*/*' }, body }); const text = await res.text(); let json = null; try { json = JSON.parse(text); } catch (e) {} if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 240)); if (!json || !json.success) throw new Error('Не success JSON: ' + text.slice(0, 500)); return json; } async function collectBaseUsersFromCurrentTable(settings) { if (location.pathname !== '/pl/user/user/index') { throw new Error('Быстрый сбор базы работает на странице пользователей /pl/user/user/index. Сейчас: ' + location.pathname); } const total = getCurrentListTotal(document); const pageSize = getCurrentListPageSize(document); const pagesByTotal = Math.max(1, Math.ceil((total || pageSize) / Math.max(1, pageSize))); const pages = Math.min(Math.max(1, settings.maxListPages || CONFIG.maxListPages), pagesByTotal); const pageNumbers = []; for (let p = 1; p <= pages; p++) pageNumbers.push(p); setStatus('База: найдено страниц ' + pagesByTotal + ', загружаю ' + pages + ' стр. текущей выборки…'); const pageDocs = await runWithConcurrency(pageNumbers, CONFIG.listConcurrency || 4, async (page) => { const url = makeCurrentListPageUrl(page); const html = await fetchFullPageHtml(url); const doc = new DOMParser().parseFromString(html, 'text/html'); return { page, doc, html }; }, function(done, totalPages) { setStatus('Загружаю страницы базы: ' + done + ' / ' + totalPages); }); const users = new Map(); const skipped = []; pageDocs.forEach(item => { if (!item || item.error) { skipped.push({ page: item && item.item ? item.item : '?', reason: item && item.error ? item.error : 'Ошибка загрузки страницы' }); return; } const parsed = parseUsersFromCurrentListDoc(item.doc, item.page); parsed.users.forEach((user, id) => { users.set(String(id), Object.assign(users.get(String(id)) || {}, user)); }); if (parsed.skipped && parsed.skipped.length) skipped.push.apply(skipped, parsed.skipped); }); return { users, skipped, totalFromSummary: total, pageSize, pages, pagesByTotal }; } function getCurrentListTotal(doc) { const bodyText = String(doc.body && doc.body.innerText || '').replace(/\u00a0/g, ' '); let m = bodyText.match(/Под правило попадает:\s*([\d\s]+)\s+(пользовател|запис|заказ)/i); if (m) return Number(m[1].replace(/\s/g, '')) || 0; const summary = String(doc.querySelector('.summary') && doc.querySelector('.summary').innerText || '').replace(/\u00a0/g, ' '); m = summary.match(/из\s+([\d\s]+)/i); if (m) return Number(m[1].replace(/\s/g, '')) || 0; return 0; } function getCurrentListPageSize(doc) { const summary = String(doc.querySelector('.summary') && doc.querySelector('.summary').innerText || '').replace(/\u00a0/g, ' '); const m = summary.match(/Показаны\s+(\d+)\s*[—\-]\s*(\d+)/i); if (m) return Math.max(1, Number(m[2]) - Number(m[1]) + 1); const table = getCurrentUsersTable(doc); const rows = table ? table.querySelectorAll('tbody tr').length : 0; return rows || 100; } function makeCurrentListPageUrl(page) { const url = new URL(window.location.href); url.searchParams.set('page', page); url.searchParams.set('sort', 'id'); return url.toString(); } function getCurrentUsersTable(doc) { const tables = Array.from(doc.querySelectorAll('table.kv-grid-table, .kv-grid-container table, table')); return tables.find(table => { if (table.querySelector('tbody tr[data-user-id]')) return true; if (table.querySelector('a[href*="/user/control/user/update/id/"], a[href*="/pl/user/user/update?id="], a[href*="/pl/user/user/view?id="]')) return true; return false; }) || null; } function parseUsersFromCurrentListDoc(doc, pageNumber) { const users = new Map(); const skipped = []; const table = getCurrentUsersTable(doc); if (!table) { return { users, skipped: [{ page: pageNumber, reason: 'Не найдена таблица пользователей' }] }; } const headers = Array.from(table.querySelectorAll('thead th')).map((th, index) => ({ index, seq: th.getAttribute('data-col-seq'), text: normalizeText(th.innerText || th.textContent) })); const emailCol = headers.find(h => h.text === 'эл. почта' || h.text === 'email' || h.text.indexOf('почт') !== -1); const nameCol = headers.find(h => h.text === 'отображаемое имя' || h.text === 'имя') || headers.find(h => h.text.indexOf('имя') !== -1); const idCol = headers.find(h => h.text === 'id'); Array.from(table.querySelectorAll('tbody tr')).forEach((tr, rowIndex) => { let id = tr.getAttribute('data-user-id') || ''; const link = tr.querySelector('a[href*="/user/control/user/update/id/"], a[href*="/pl/user/user/update?id="], a[href*="/pl/user/user/view?id="]'); if (!id && link) id = extractUserId(link.getAttribute('href') || ''); if (!id && idCol) id = parseIdFromTableCell(getCellByHeader(tr, idCol)); if (!id) { skipped.push({ page: pageNumber, row: rowIndex + 1, reason: 'Не найден ID пользователя' }); return; } const nameCell = nameCol ? getCellByHeader(tr, nameCol) : null; const emailCell = emailCol ? getCellByHeader(tr, emailCol) : null; const rowText = normalizeText(tr.textContent || ''); const emailMatch = rowText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); const name = normalizeText(nameCell ? (nameCell.innerText || nameCell.textContent) : (link ? link.textContent : '')).slice(0, 120); const email = normalizeText(emailCell ? (emailCell.innerText || emailCell.textContent) : (emailMatch ? emailMatch[0] : '')); users.set(String(id), { id: String(id), name, email, rowHTML: tr.outerHTML }); }); return { users, skipped }; } function getCellByHeader(tr, header) { if (!tr || !header) return null; if (header.seq !== null && header.seq !== '') { const bySeq = tr.querySelector('td[data-col-seq="' + header.seq + '"]'); if (bySeq) return bySeq; } return Array.from(tr.querySelectorAll('td'))[header.index] || null; } function parseIdFromTableCell(cell) { const text = normalizeText(cell ? (cell.innerText || cell.textContent) : ''); const m = text.match(/\d{5,}/); return m ? String(m[0]) : ''; } async function fetchUsersFromObjectsLink(objectsLink, label, settings) { const users = new Map(); const visited = new Set(); let nextUrl = absoluteUrl(objectsLink); let pageNo = 0; let emptyStreak = 0; while (nextUrl && pageNo < settings.maxListPages && !visited.has(nextUrl)) { visited.add(nextUrl); pageNo += 1; setStatus('Загружаю список: ' + label + ', стр. ' + pageNo + ', найдено ID: ' + users.size); const html = await fetchHtml(nextUrl); const parsed = parseUsersFromHtml(html); let added = 0; parsed.users.forEach((u, id) => { if (!users.has(id)) added += 1; users.set(id, Object.assign(users.get(id) || {}, u)); }); emptyStreak = added ? 0 : emptyStreak + 1; if (emptyStreak >= CONFIG.stopAfterEmptyPages) break; nextUrl = findNextPageUrl(html, nextUrl, visited); if (CONFIG.requestDelayMs) await delay(CONFIG.requestDelayMs); } return users; } async function fetchFullPageHtml(url) { const res = await fetch(url, { credentials: 'include' }); const text = await res.text(); if (!res.ok) throw new Error('Ошибка загрузки страницы HTTP ' + res.status + ': ' + text.slice(0, 180)); return text; } async function fetchHtml(url) { const res = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const text = await res.text(); if (!res.ok) throw new Error('Ошибка загрузки списка HTTP ' + res.status + ': ' + text.slice(0, 180)); return text; } function parseUsersFromHtml(html) { const users = new Map(); const patterns = [ /\/user\/control\/user\/update\/id\/(\d+)/g, /\/pl\/user\/user\/update\?id=(\d+)/g, /\/pl\/user\/user\/view\?id=(\d+)/g, /data-user-id=["']?(\d+)/g, /data-id=["']?(\d+)/g ]; patterns.forEach(re => { let m; while ((m = re.exec(html))) { const id = String(m[1]); if (id.length >= 5) users.set(id, Object.assign(users.get(id) || {}, { id })); } }); try { const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('tr').forEach(tr => { const link = tr.querySelector('a[href*="/user/control/user/update/id/"], a[href*="/pl/user/user/update?id="], a[href*="/pl/user/user/view?id="]'); if (!link) return; const id = extractUserId(link.getAttribute('href') || ''); if (!id) return; const text = normalizeText(tr.textContent || ''); const emailMatch = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); const name = normalizeText(link.textContent || '').slice(0, 120); users.set(id, Object.assign(users.get(id) || {}, { id, name, email: emailMatch ? emailMatch[0] : '' })); }); } catch (e) {} return { users }; } function extractUserId(href) { let m = String(href || '').match(/\/user\/control\/user\/update\/id\/(\d+)/); if (m) return String(m[1]); m = String(href || '').match(/[?&]id=(\d+)/); if (m) return String(m[1]); return ''; } function findNextPageUrl(html, currentUrl, visited) { try { const doc = new DOMParser().parseFromString(html, 'text/html'); const anchors = Array.from(doc.querySelectorAll('a[href]')); const explicitNext = anchors.find(a => { const t = normalizeText(a.textContent || '').toLowerCase(); const cls = String(a.className || '').toLowerCase(); return /след|next|›|»/.test(t) || cls.includes('next'); }); if (explicitNext) { const u = absoluteUrl(explicitNext.getAttribute('href')); if (u && !visited.has(u)) return u; } const cur = new URL(currentUrl, location.origin); const curPage = Number(cur.searchParams.get('page') || cur.searchParams.get('p') || 1); const candidates = anchors.map(a => absoluteUrl(a.getAttribute('href'))).filter(Boolean).filter(u => !visited.has(u)).map(u => { try { const x = new URL(u, location.origin); const p = Number(x.searchParams.get('page') || x.searchParams.get('p') || 0); return { url: u, page: p }; } catch (e) { return null; } }).filter(Boolean).filter(x => x.page > curPage).sort((a, b) => a.page - b.page); if (candidates.length) return candidates[0].url; } catch (e) {} return ''; } function absoluteUrl(url) { if (!url) return ''; try { return new URL(url, location.origin).toString(); } catch (e) { return ''; } } function buildAndRule(children) { return { type: 'andrule', inverted: 0, className: 'app::components::logic::rule::AndRule', params: { children, mode: 'and' } }; } function buildProductRule(settings) { return { type: 'user_hasuserproduct', inverted: 0, className: 'app::modules::user::models::rule::HasUserProductRule', params: { linkedRule: { type: 'andrule', inverted: 0, className: 'app::components::logic::rule::AndRule', params: { mode: 'and', children: [ { type: 'userproduct_productrule', inverted: 0, className: 'app::components::logic::rule::CustomFieldRule', params: { value: { selected_id: [String(settings.productId)] }, valueMode: '' } }, { type: 'userproduct_startatrule', inverted: 0, className: 'app::components::logic::rule::CustomFieldRule', params: { value: { from: '', to: '', toNDays: String(settings.toNDays), fromNDays: String(settings.fromNDays), dateType: 'last_n_days', withTime: 'false' }, valueMode: '' } } ] } }, countCondition: '' } }; } function buildLastActivityRule(dateType) { const type = String(dateType || 'this_week'); return { type: 'user_last_activity_at', inverted: 0, params: { value: { from: '', to: '', toNDays: '', fromNDays: '', dateType: type === 'off' ? 'this_week' : type, withTime: 'false' }, valueMode: '' } }; } function activityDateLabel(dateType) { const type = String(dateType || CONFIG.activityDateType || 'this_week'); if (type === 'off') return 'не проверяется'; if (type === 'this_week') return 'активен на этой неделе'; return 'активность: ' + type; } async function collectGeneralActivityForMatrix(matrix, batches, settings) { const summary = { checked: true, active: 0, inactive: matrix.size, errors: [], dateType: settings.activityDateType }; setStatus('Проверяю общую активность пользователей: ' + activityDateLabel(settings.activityDateType) + '…'); const jobs = batches.map(function(ids, batchIndex) { return { ids: ids, batchIndex: batchIndex }; }); await runWithConcurrency(jobs, settings.jobConcurrency, async function(job) { const label = 'general activity batch ' + (job.batchIndex + 1) + '/' + batches.length; try { const rule = buildAndRule([buildIdsRule(job.ids), buildLastActivityRule(settings.activityDateType)]); const json = await runRule(rule, label); let users = new Map(); if (json.objectsLink && Number(json.count || 0) > 0) { users = await fetchUsersFromObjectsLink(json.objectsLink, label, Object.assign({}, settings, { maxListPages: CONFIG.activityMaxListPages || 2 })); } users.forEach(function(user, id) { id = String(id); if (!matrix.has(id)) return; const row = matrix.get(id); row.generalActive = true; row.generalActivityLabel = activityDateLabel(settings.activityDateType); if (!row.name && user.name) row.name = user.name; if (!row.email && user.email) row.email = user.email; }); } catch (error) { const msg = String(error && error.message || error); console.error('[VSM] ' + label + ' ошибка общей активности', error); summary.errors.push({ label: label, error: msg }); } }, function(done, total) { setStatus('Проверяю общую активность: ' + done + ' / ' + total + '. Ошибок: ' + summary.errors.length + '.'); }); const rows = Array.from(matrix.values()); summary.active = rows.filter(function(row) { return row.generalActive; }).length; summary.inactive = rows.length - summary.active; return summary; } function buildActivityStats(users, settings) { const notStarted = users.filter(function(u) { return u.cohort === 'not_started'; }); const active = users.filter(function(u) { return Boolean(u.generalActive); }); const notStartedActive = notStarted.filter(function(u) { return Boolean(u.generalActive); }); return { checked: Boolean(settings && settings.activityDateType && settings.activityDateType !== 'off'), dateType: settings ? settings.activityDateType : CONFIG.activityDateType, label: activityDateLabel(settings && settings.activityDateType), active: active.length, inactive: users.length - active.length, notStarted: notStarted.length, notStartedActive: notStartedActive.length, notStartedInactive: notStarted.length - notStartedActive.length }; } function buildLessonRule(lesson) { return { type: 'user_visitlessonrule', inverted: 0, className: 'app::modules::user::models::rule::VisitLessonRule', params: { value: { selected_id: String(lesson.streamId) }, lessonId: { selected_id: String(lesson.lessonId) }, status: { selected_id: '' }, checkerType: { selected_id: 'enter' } } }; } function buildIdsRule(ids) { return { type: 'idsrule', inverted: 0, className: 'app::components::logic::rule::IdsRule', params: { value: ids.map(String).join(','), valueMode: '' } }; } function chunkArray(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } async function runWithConcurrency(items, concurrency, worker, onProgress) { const results = []; let index = 0; let done = 0; const workers = Array.from({ length: Math.max(1, Number(concurrency || 1)) }, async () => { while (index < items.length) { const currentIndex = index++; const current = items[currentIndex]; try { results[currentIndex] = await worker(current, currentIndex); } catch (error) { results[currentIndex] = { error: error && error.message || String(error), item: current }; } done += 1; if (onProgress) onProgress(done, items.length); } }); await Promise.all(workers); return results; } function appendRule(params, prefix, value) { if (Array.isArray(value)) { value.forEach((item, index) => { const isSelectedIdArray = /\[selected_id\]$/.test(prefix); const key = isSelectedIdArray ? prefix + '[]' : prefix + '[' + index + ']'; appendRule(params, key, item); }); return; } if (value && typeof value === 'object') { Object.keys(value).forEach(key => appendRule(params, prefix + '[' + key + ']', value[key])); return; } params.set(prefix, value == null ? '' : String(value)); } function addGcMeta(params) { const gcSession = window.gcSession || getCookie('gcSession'); const gcVisit = window.gcVisit || getCookie('gcVisit'); const gcVisitor = window.gcVisitor || getCookie('gcVisitor'); const gcSessionHash = window.gcSessionHash || getCookie('gcSessionHash'); if (gcSession) params.set('gcSession', typeof gcSession === 'string' ? gcSession : JSON.stringify(gcSession)); if (gcVisit) params.set('gcVisit', typeof gcVisit === 'string' ? gcVisit : JSON.stringify(gcVisit)); if (gcVisitor) params.set('gcVisitor', typeof gcVisitor === 'string' ? gcVisitor : JSON.stringify(gcVisitor)); if (gcSessionHash) params.set('gcSessionHash', String(gcSessionHash)); } function getCookie(name) { const safe = name.replace(/[.$?*|{}()\[\]\\/\+^]/g, '\$&'); const m = document.cookie.match(new RegExp('(?:^|; )' + safe + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : ''; } function renderLiveSummary(baseJson, baseUsers, lessonStats, matrix, lessons, groups, final) { const pseudo = buildAnalysis(baseJson, baseUsers, matrix, lessons, groups, lessonStats, {}, new Date().toISOString()); window.VIVO_SEGMENT_MATRIX_PREVIEW = pseudo; renderSummary(pseudo, final); } function renderFinal(analysis) { renderSummary(analysis, true); } function renderSummary(analysis, final) { window.VIVO_SEGMENT_MATRIX_LAST = final ? analysis : (window.VIVO_SEGMENT_MATRIX_LAST || analysis); renderActiveView(analysis); } function renderActiveView(analysisArg) { const analysis = analysisArg || window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW; const resultsEl = document.getElementById(RESULT_ID); if (!resultsEl || !analysis) return; updateViewButtons(); let html = renderTopCards(analysis); if (CURRENT_VIEW === 'cohorts') html += renderCohortsView(analysis); else if (CURRENT_VIEW === 'weeks') html += renderWeeksView(analysis); else if (CURRENT_VIEW === 'lessons') html += renderLessonsView(analysis); else if (CURRENT_VIEW === 'users') html += renderUsersView(analysis); else if (CURRENT_VIEW === 'groups') html += renderGroupsView(analysis); else html += renderOverviewView(analysis); resultsEl.innerHTML = html; if (CURRENT_VIEW === 'users') renderUserTable(); } function updateViewButtons() { const wrap = document.getElementById(WRAP_ID); if (!wrap) return; Array.from(wrap.querySelectorAll('[data-view]')).forEach(btn => { const active = btn.getAttribute('data-view') === CURRENT_VIEW; btn.classList.toggle('is-active', active); }); } function renderTopCards(analysis) { const strong = analysis.cohorts.find(c => c.key === 'strong'); const notStarted = analysis.cohorts.find(c => c.key === 'not_started'); return [ '
', '
' + formatNumber(analysis.base.loaded) + 'пользователей загруженостраниц: ' + escapeHtml(analysis.base.pages) + ', pageSize: ' + escapeHtml(analysis.base.pageSize) + '
', '
' + formatNumber(analysis.lessons.length) + 'уроков проверено' + (analysis.batches ? ('задач: ' + formatNumber(analysis.batches.jobs) + ', ошибки: ' + formatNumber(analysis.batches.errors.length)) : '') + '
', '
' + formatNumber(strong ? strong.count : 0) + 'сильно вовлеченыклик — показать в таблице
', '
' + formatNumber(notStarted ? notStarted.count : 0) + 'не стартовали в трекевсе без входов в выбранные уроки
', '
' + formatNumber(analysis.activityStats ? analysis.activityStats.notStartedActive : 0) + 'не в треке, но активны' + escapeHtml(activityDateLabel(analysis.settings && analysis.settings.activityDateType)) + '
', '
' + formatNumber(analysis.activityStats ? analysis.activityStats.notStartedInactive : 0) + 'не в треке и не заходилиглавный риск выпадения
', '
' ].join(''); } function renderOverviewView(analysis) { return [ '
', '

Обзор

', '
Здесь общий управленческий срез: сколько людей реально начали, где падают недели и какие когорты стоит отдельно догревать/продавать.
', renderCohortsCards(analysis), renderActivitySplit(analysis), '

Недельная картина

', renderWeeksList(analysis.weekStats), '
' ].join(''); } function renderCohortsView(analysis) { return [ '
', '

Когорты вовлечённости

', '
Клик по карточке подставляет людей этой когорты в основную таблицу GetCourse. Кнопки позволяют скопировать ID или открыть когорту отдельным сегментом.
', renderCohortsCards(analysis), renderActivitySplit(analysis), '
' ].join(''); } function renderCohortsCards(analysis) { return '
' + analysis.cohorts.map(c => [ '
', '' + formatNumber(c.count) + '', '' + escapeHtml(c.title) + '', '' + escapeHtml(c.range) + ' · ' + c.percent + '%', '
', '', '', '', '
', '
' ].join('')).join('') + '
'; } function renderActivitySplit(analysis) { const s = analysis.activityStats || {}; if (!s.checked) { return '

Общая активность

Проверка общей активности отключена.
'; } const cards = [ { key: 'general_active', title: 'Активны в целом', count: s.active || 0, hint: activityDateLabel(analysis.settings && analysis.settings.activityDateType) }, { key: 'general_inactive', title: 'Нет общей активности', count: s.inactive || 0, hint: 'не было входов в выбранном периоде' }, { key: 'not_started_active', title: 'Не в треке, но активны', count: s.notStartedActive || 0, hint: 'вероятно занимаются в основных уроках' }, { key: 'not_started_inactive', title: 'Не в треке и не заходили', count: s.notStartedInactive || 0, hint: 'главная тревожная когорта' } ]; return [ '
', '

Общая активность вне новичкового трека

', '
Эта проверка отделяет людей, которые не идут по выбранным неделям новичка, но при этом были активны в GetCourse. Для них проблема не обязательно в оттоке: они могли уйти сразу в основные занятия клуба.
', '
' + cards.map(c => [ '
', '' + formatNumber(c.count) + '', '' + escapeHtml(c.title) + '', '' + escapeHtml(c.hint) + '', '
', '', '', '
', '
' ].join('')).join('') + '
', '
' ].join(''); } function renderWeeksView(analysis) { return [ '
', '

Недели

', '
Неделя считается затронутой, если был вход хотя бы в один урок. Вовлечённой — если набран порог 40–50% уроков недели.
', renderWeeksList(analysis.weekStats), '
' ].join(''); } function renderWeeksList(weekStats) { return '
' + weekStats.map(w => [ '
', '
' + escapeHtml(w.title) + 'Порог вовлечения: ' + w.threshold + ' из ' + w.lessons + '
', '
' + formatNumber(w.touched) + 'коснулись · ' + w.touchedPercent + '%
', '
' + formatNumber(w.engaged) + 'вовлеклись · ' + w.engagedPercent + '%
', '
', '
' ].join('')).join('') + '
'; } function renderLessonsView(analysis) { const rows = analysis.lessons.slice().sort((a,b) => (Number(a.week)-Number(b.week)) || (Number(b.loadedIds)-Number(a.loadedIds))).map(l => [ '', '' + escapeHtml(l.weekTitle) + '', '' + escapeHtml(l.title) + '
' + escapeHtml(l.lessonId) + '
', '' + formatNumber(l.loadedIds) + '', '' + escapeHtml(l.percent) + '%', '' + (l.error ? 'ошибка
' + escapeHtml(l.error).slice(0, 160) + '
' : 'ok') + '', ' ', '' ].join('')).join(''); return '

Уроки

' + rows + '
НеделяУрокID% базыСтатусДействия
'; } function renderUsersView(analysis) { const payload = window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW; const cohortFilters = cohortDefinitions(payload ? payload.lessons.length : undefined).map(c => '').join(''); const activityFilters = ''; const filters = cohortFilters + activityFilters; return [ '
', '

Пользователи

', '
Фильтр ниже управляет таблицей внутри отчёта. Кнопка «Показать в GetCourse» подставляет эту когорту в основную таблицу пользователей.
', '
', '', '', '', '', '', '
', '
', '
' ].join(''); } function renderGroupsView(analysis) { const activityRows = [ { key: 'not_started_active', title: 'Не в треке, но активны', hint: 'Не входили в выбранные уроки, но были активны в GetCourse', count: analysis.activityStats ? analysis.activityStats.notStartedActive : 0, percent: analysis.base.loaded ? Math.round(((analysis.activityStats ? analysis.activityStats.notStartedActive : 0) / analysis.base.loaded) * 100) : 0 }, { key: 'not_started_inactive', title: 'Не в треке и не заходили', hint: 'Не входили в выбранные уроки и не проявляли общей активности', count: analysis.activityStats ? analysis.activityStats.notStartedInactive : 0, percent: analysis.base.loaded ? Math.round(((analysis.activityStats ? analysis.activityStats.notStartedInactive : 0) / analysis.base.loaded) * 100) : 0 }, { key: 'general_active', title: 'Активны в целом', hint: activityDateLabel(analysis.settings && analysis.settings.activityDateType), count: analysis.activityStats ? analysis.activityStats.active : 0, percent: analysis.base.loaded ? Math.round(((analysis.activityStats ? analysis.activityStats.active : 0) / analysis.base.loaded) * 100) : 0 } ]; const rows = analysis.cohorts.concat(activityRows).map(c => [ '', '' + escapeHtml(c.title) + '
' + escapeHtml(c.hint || '') + '
', '' + formatNumber(c.count) + '', '' + c.percent + '%', '', ' ', ' ', ' ', '', '', '' ].join('')).join(''); return [ '
', '

Группы / действия

', '
Здесь можно показать когорту в таблице, скопировать ID, открыть её отдельным сегментом или создать новую группу GetCourse и добавить туда пользователей этой когорты.
', '
' + rows + '
КогортаЛюдей%Действия
', '
' ].join(''); } function renderUserTable() { const payload = window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW; const el = document.getElementById('vsm-user-table'); if (!payload || !el) return; const select = document.getElementById('vsm-filter'); const filter = select ? select.value : CURRENT_USER_FILTER || 'all'; CURRENT_USER_FILTER = filter; const all = getUsersByCohort(filter, payload); const users = all.slice(0, 300); const rows = users.map(u => [ '', '' + escapeHtml(u.id) + '
' + escapeHtml(u.name || '') + (u.email ? ' · ' + escapeHtml(u.email) : '') + '
', '' + escapeHtml(u.lessonCount) + ' / ' + payload.lessons.length + '', '' + escapeHtml(u.touchedWeeks) + '', '' + escapeHtml(u.engagedWeeks) + '', '' + (u.generalActive ? 'активен' : 'нет') + '', '' + escapeHtml(cohortTitle(u.cohort)) + '', '' ].join('')).join(''); el.innerHTML = '
Показано ' + formatNumber(users.length) + ' из ' + formatNumber(all.length) + '. Полный список — в CSV или через ID.
' + rows + '
ID / имяУроковНедель коснулсяНедель вовлечёнОбщая активностьКогорта
'; } function cohortTitle(key) { const c = cohortDefinitions().find(x => x.key === key); return c ? c.title : key; } function makeCsv() { const payload = window.VIVO_SEGMENT_MATRIX_LAST; if (!payload) return ''; const lessonIds = payload.lessons.map(l => String(l.lessonId)); const headers = ['user_id','name','email','lesson_count','touched_weeks','engaged_weeks','max_touched_week','max_engaged_week','cohort','general_activity'].concat(lessonIds.map(id => 'lesson_' + id)); const lines = [headers.join(';')]; payload.users.forEach(u => { const has = new Set(u.lessons.map(String)); const row = [u.id,u.name,u.email,u.lessonCount,u.touchedWeeks,u.engagedWeeks,u.maxTouchedWeek,u.maxEngagedWeek,cohortTitle(u.cohort),u.generalActive ? '1' : '0'].concat(lessonIds.map(id => has.has(id) ? '1' : '0')); lines.push(row.map(csvCell).join(';')); }); return lines.join('\\n'); } function csvCell(v) { const s = String(v == null ? '' : v); if (/[;\"\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; return s; } function copyCsv() { const text = makeCsv(); if (!text) return; navigator.clipboard.writeText(text).then(() => alert('CSV скопирован')); } function downloadCsv() { const text = makeCsv(); if (!text) return; const blob = new Blob([text], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'beginner-track-matrix-' + new Date().toISOString().slice(0,10) + '.csv'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000); } function copyFilteredIds() { const filter = document.getElementById('vsm-filter')?.value || CURRENT_USER_FILTER || 'all'; copyCohortIds(filter); } function getUsersByCohort(cohortKey, payloadArg) { const payload = payloadArg || window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW; if (!payload) return []; const key = String(cohortKey || 'all'); const users = payload.users || []; if (!key || key === 'all') return users; if (key.indexOf('single:') === 0) { const id = key.replace('single:', ''); return users.filter(u => String(u.id) === id); } if (key.indexOf('week_touch:') === 0) { const week = key.replace('week_touch:', ''); return users.filter(u => Number((u.weekCounts || {})[String(week)] || 0) > 0); } if (key.indexOf('week_engaged:') === 0) { const week = key.replace('week_engaged:', ''); const stat = (payload.weekStats || []).find(w => String(w.week) === String(week)); const threshold = stat ? Number(stat.threshold || 1) : 1; return users.filter(u => Number((u.weekCounts || {})[String(week)] || 0) >= threshold); } if (key.indexOf('lesson:') === 0) { const lessonId = key.replace('lesson:', ''); return users.filter(u => (u.lessons || []).map(String).includes(String(lessonId))); } if (key === 'not_started_active') return users.filter(u => u.cohort === 'not_started' && Boolean(u.generalActive)); if (key === 'not_started_inactive') return users.filter(u => u.cohort === 'not_started' && !Boolean(u.generalActive)); if (key === 'general_active') return users.filter(u => Boolean(u.generalActive)); if (key === 'general_inactive') return users.filter(u => !Boolean(u.generalActive)); return users.filter(u => u.cohort === key); } function getCohortLabel(cohortKey) { const payload = window.VIVO_SEGMENT_MATRIX_LAST || window.VIVO_SEGMENT_MATRIX_PREVIEW; const key = String(cohortKey || 'all'); if (!key || key === 'all') return 'Все пользователи'; if (key.indexOf('single:') === 0) return 'Пользователь ' + key.replace('single:', ''); if (key.indexOf('week_touch:') === 0) return 'Коснулись недели ' + key.replace('week_touch:', ''); if (key.indexOf('week_engaged:') === 0) return 'Вовлечены в неделю ' + key.replace('week_engaged:', ''); if (key.indexOf('lesson:') === 0) { const lessonId = key.replace('lesson:', ''); const lesson = payload && (payload.lessons || []).find(l => String(l.lessonId) === String(lessonId)); return 'Входили в урок: ' + (lesson ? lesson.title : lessonId); } if (key === 'not_started_active') return 'Не в треке, но активны в GetCourse'; if (key === 'not_started_inactive') return 'Не в треке и нет общей активности'; if (key === 'general_active') return 'Активны в GetCourse'; if (key === 'general_inactive') return 'Нет общей активности в GetCourse'; return cohortTitle(key); } function saveOriginalTableState() { const table = getCurrentUsersTable(document); if (!table) return; const theadRow = table.querySelector('thead tr'); const tbody = table.querySelector('tbody'); const summary = document.querySelector('.summary'); if (theadRow && !ORIGINAL_TABLE_HEAD_HTML) ORIGINAL_TABLE_HEAD_HTML = theadRow.innerHTML; if (tbody && !ORIGINAL_TABLE_BODY_HTML) ORIGINAL_TABLE_BODY_HTML = tbody.innerHTML; if (summary && !ORIGINAL_SUMMARY_HTML) ORIGINAL_SUMMARY_HTML = summary.innerHTML; } function filterMainTableByCohort(cohortKey) { const users = getUsersByCohort(cohortKey); const rows = users.filter(u => u.rowHTML).map(u => u.rowHTML); if (!rows.length) { alert('Для этой когорты не нашла сохранённые строки таблицы. Можно скопировать ID или открыть сегмент.'); return; } filterCurrentTableByRowHtml(rows, getCohortLabel(cohortKey) + ' — ' + formatNumber(rows.length) + ' пользователей'); } function filterCurrentTableByRowHtml(rowHtmlList, label) { const table = getCurrentUsersTable(document); if (!table) return; saveOriginalTableState(); const tbody = table.querySelector('tbody'); const summary = document.querySelector('.summary'); if (!tbody) return; tbody.innerHTML = rowHtmlList.join(''); if (summary) summary.innerHTML = 'Показан виртуальный фильтр из матрицы новичков: ' + formatNumber(rowHtmlList.length) + ' пользователей'; CURRENT_TABLE_FILTER = { label, count: rowHtmlList.length }; setFilterInfo('' + escapeHtml(label) + '
В основную таблицу подставлены строки из собранной выборки. Это виртуальный фильтр: он не меняет сегмент GetCourse. '); try { table.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) {} } function resetCurrentTableFilter() { const table = getCurrentUsersTable(document); if (!table) return; const theadRow = table.querySelector('thead tr'); const tbody = table.querySelector('tbody'); const summary = document.querySelector('.summary'); if (theadRow && ORIGINAL_TABLE_HEAD_HTML) theadRow.innerHTML = ORIGINAL_TABLE_HEAD_HTML; if (tbody && ORIGINAL_TABLE_BODY_HTML) tbody.innerHTML = ORIGINAL_TABLE_BODY_HTML; if (summary && ORIGINAL_SUMMARY_HTML) summary.innerHTML = ORIGINAL_SUMMARY_HTML; CURRENT_TABLE_FILTER = null; setFilterInfo(''); } function setFilterInfo(html) { const el = document.getElementById(FILTER_ID); if (!el) return; if (!html) { el.style.display = 'none'; el.innerHTML = ''; return; } el.style.display = 'block'; el.innerHTML = html; } function copyCohortIds(cohortKey) { const ids = getUsersByCohort(cohortKey).map(u => u.id).filter(Boolean); navigator.clipboard.writeText(ids.join('\n')).then(() => alert('ID скопированы: ' + ids.length)); } function openCohortAsSegment(cohortKey) { const ids = getUsersByCohort(cohortKey).map(u => String(u.id)).filter(Boolean); if (!ids.length) { alert('В этой выборке нет ID.'); return; } if (ids.length > 450) { navigator.clipboard.writeText(ids.join('\n')).then(() => alert('ID слишком много для безопасной ссылки (' + ids.length + '). Я скопировал их в буфер. Лучше создать группу/сегмент через штатное массовое действие GetCourse.')); return; } const rule = buildIdsRule(ids); const url = new URL('/pl/user/user/index', location.origin); url.searchParams.set('uc[rule_string]', JSON.stringify(rule)); window.open(url.toString(), '_blank'); } function nowString() { const d = new Date(); return [d.getFullYear(), pad2(d.getMonth() + 1), pad2(d.getDate())].join('-') + ' ' + [pad2(d.getHours()), pad2(d.getMinutes()), pad2(d.getSeconds())].join(':'); } function pad2(value) { value = Number(value); return value < 10 ? '0' + value : String(value); } function getStoredSessionParam(key) { try { return localStorage.getItem(key) || ''; } catch (err) { return ''; } } function appendGetCourseSessionParams(body) { const session = getStoredSessionParam('session'); const visit = getStoredSessionParam('visit'); const visitor = getStoredSessionParam('visitor'); const hash = getStoredSessionParam('hash'); if (session) body.set('gcSession', session); if (visit) body.set('gcVisit', visit); if (visitor) body.set('gcVisitor', visitor); if (hash) body.set('gcSessionHash', hash); } function parseGroupFromResponse(raw, groupName) { const possibleIds = [ raw && raw.value, raw && raw.id, raw && raw.group_id, raw && raw.groupId, raw && raw.data && raw.data.id, raw && raw.data && raw.data.value, raw && raw.data && raw.data.group_id, raw && raw.data && raw.data.groupId, raw && raw.result && raw.result.id, raw && raw.result && raw.result.value, raw && raw.result && raw.result.group_id, raw && raw.result && raw.result.groupId ]; let id = ''; for (const value of possibleIds) { if (value === null || value === undefined) continue; if (typeof value === 'object') { const nested = value.id || value.value || value.group_id || value.groupId; if (nested) { id = String(nested); break; } } else { const text = String(value); const match = text.match(/\d+/); if (match) { id = match[0]; break; } } } return { id, name: (raw && (raw.name || raw.title || (raw.data && (raw.data.name || raw.data.title)))) || groupName, raw }; } function createGetCourseGroup(groupName) { return new Promise(function (resolve, reject) { const name = normalizeText(groupName); if (!name) { reject(new Error('Название группы пустое')); return; } if (typeof window.ajaxCall === 'function') { window.ajaxCall('/pl/user/group/new', { 'UserGroup[name]': name }, {}, function (response) { if (!response || response.success === false) { reject(new Error((response && response.message) || 'GetCourse не создал группу')); return; } resolve(parseGroupFromResponse(response, name)); }); return; } const body = new URLSearchParams(); body.set('UserGroup[name]', name); appendGetCourseSessionParams(body); fetch('/pl/user/group/new', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body: body.toString() }) .then(function (response) { return response.text().then(function (text) { let json = null; try { json = text ? JSON.parse(text) : null; } catch (err) { throw new Error('GetCourse вернул не JSON при создании группы: ' + text.slice(0, 300)); } if (!response.ok || (json && json.success === false)) { throw new Error((json && json.message) || 'GetCourse не создал группу'); } return parseGroupFromResponse(json, name); }); }) .then(resolve) .catch(reject); }); } function getClarityUid() { try { const url = new URL(location.href); const fromUrl = url.searchParams.get('formParams[clarity_uid]'); if (fromUrl) return fromUrl; } catch (err) {} if (window.clarity_uid) return String(window.clarity_uid); if (window.clarityUid) return String(window.clarityUid); return ''; } async function addUserIdsToGroup(ids, groupId) { if (!ids.length) throw new Error('Нет ID клиентов для добавления в группу'); if (!groupId) throw new Error('Нет ID группы'); const formData = new FormData(); formData.append('segmentId', ''); formData.append('rule', JSON.stringify(buildIdsRule(ids))); formData.append('AddToGroupOperation[groupIds][]', String(groupId)); formData.append('UserGroup[name]', ''); formData.append('execute', '1'); const clarityUid = getClarityUid(); if (clarityUid) formData.append('formParams[clarity_uid]', clarityUid); const response = await fetch('/pl/logic/operation/prepare?contextType=UserContext&operationType=user_addtogroup', { method: 'POST', credentials: 'include', body: formData }); const html = await response.text(); if (!response.ok) { throw new Error('GetCourse не выполнил добавление в группу: HTTP ' + response.status); } const doc = new DOMParser().parseFromString(html, 'text/html'); const pageText = normalizeText(doc.body && doc.body.innerText || ''); const title = normalizeText(doc.querySelector('title') && doc.querySelector('title').textContent || ''); const hasResultPage = title.toLowerCase().includes('результат') || pageText.toLowerCase().includes('результат выполнения') || pageText.toLowerCase().includes('выполнен') || pageText.toLowerCase().includes('успешно'); const errorCount = Array.from(doc.querySelectorAll('.label-danger, .label-error, .text-danger, .alert-danger')).filter(function (el) { return normalizeText(el.textContent).length > 0; }).length; return { success: hasResultPage || errorCount === 0, requested: ids.length, errorCount, title, pageText: pageText.slice(0, 2000), html }; } async function addCohortToNewGroup(cohortKey) { const users = getUsersByCohort(cohortKey); const ids = Array.from(new Set(users.map(u => String(u.id || '')).filter(Boolean))); if (!ids.length) { alert('В этой выборке не найдено ID клиентов.'); return; } const label = getCohortLabel(cohortKey); const defaultName = 'Новички · ' + label + ' · ' + nowString(); const groupName = prompt('Введите название новой группы для выборки «' + label + '» (' + ids.length + ' клиентов):', defaultName); if (groupName === null) return; const cleanName = normalizeText(groupName); if (!cleanName) { alert('Название группы не может быть пустым.'); return; } const ok = confirm('Создать группу «' + cleanName + '» и добавить туда ' + ids.length + ' клиентов?'); if (!ok) return; try { setStatus('Создаю группу «' + cleanName + '» для ' + ids.length + ' клиентов...'); const group = await createGetCourseGroup(cleanName); if (!group.id) { console.warn('[VSM] Ответ создания группы без ID:', group.raw); throw new Error('Группа создана, но GetCourse не вернул ID группы'); } setStatus('Группа создана: «' + group.name + '». Добавляю клиентов...'); const result = await addUserIdsToGroup(ids, group.id); if (!result.success) { throw new Error('GetCourse вернул страницу результата, но успешное выполнение не распознано'); } setStatus('Готово. Группа «' + group.name + '», отправлено ID: ' + ids.length + '.'); alert('Готово.\n\nГруппа: ' + group.name + '\nID группы: ' + group.id + '\nОтправлено клиентов: ' + ids.length); } catch (err) { console.error('[VSM] Ошибка добавления в группу:', err); setStatus('Ошибка добавления в группу: ' + (err && err.message || String(err))); alert('Ошибка добавления в группу: ' + (err && err.message || String(err))); } } function setStatus(text) { const el = document.getElementById('vsm-status'); if (el) el.innerHTML = escapeHtml(text); } function updateProgress(done, total, label) { const el = document.getElementById('vsm-progress'); if (!el) return; const pct = total ? Math.round(done / total * 100) : 0; el.innerHTML = '
' + escapeHtml(label) + ': ' + done + ' / ' + total + '
'; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function normalizeText(v) { return String(v == null ? '' : v).replace(/\s+/g, ' ').trim(); } function escapeHtml(value) { return String(value == null ? '' : value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function formatNumber(num) { return new Intl.NumberFormat('ru-RU').format(Math.round(Number(num) || 0)); } function injectStyles() { if (document.getElementById('vivo-segment-matrix-styles')) return; const style = document.createElement('style'); style.id = 'vivo-segment-matrix-styles'; style.textContent = ` #vivo-segment-matrix-open.btn { font-weight:700; } #vivo-segment-matrix-wrap { display:none; margin:15px 0; padding:14px; background:#fff; border:1px solid #ddd; border-radius:8px; font-family:Arial, sans-serif; } #vivo-segment-matrix-wrap h3 { font-size:16px; margin:16px 0 8px; } `; document.head.appendChild(style); } })();