(function () { const TOOL_NAME = 'GC Sales Report Analytics'; const VERSION_NUMBER = 'v65'; const VERSION = '2026-07-11-sales-report-analytics-v65-help-modal-close-button'; const SUPPORTED_PATHS = ['/pl/sales/deal/index']; if (!SUPPORTED_PATHS.includes(location.pathname)) { console.warn('[GC Sales Report v55] Неподдерживаемая страница:', location.pathname); return; } if (window.GC_SALES_REPORT_ANALYZER && typeof window.GC_SALES_REPORT_ANALYZER.destroy === 'function') { try { window.GC_SALES_REPORT_ANALYZER.destroy(); } catch (e) {} } const BTN_ID = 'gc-utm-source-money-btn'; const WRAP_ID = 'gc-utm-source-money-wrap'; const STATUS_ID = 'gc-utm-source-money-status'; const SUMMARY_ID = 'gc-utm-source-money-summary'; const RESULT_ID = 'gc-utm-source-money-result'; const DEBUG_ID = 'gc-utm-source-money-debug'; const FILTER_ID = 'gc-utm-source-money-filter'; const DRILLDOWN_ID = 'gc-utm-source-money-drilldown'; const DATE_SELECT_ID = 'gc-utm-date-column-select'; const RESET_FILTER_BTN_ID = 'gc-utm-source-money-reset-filter'; const LOAD_BTN_ID = 'gc-utm-load-analytics-btn'; const HELP_MODAL_ID = 'gc-utm-help-modal'; const PROGRESS_WRAP_ID = 'gc-utm-progress-wrap'; const PROGRESS_BAR_ID = 'gc-utm-progress-bar'; const PROGRESS_TEXT_ID = 'gc-utm-progress-text'; const MODE_STORAGE_KEY = 'gc_sales_report_analysis_mode_v1'; const SCALE_STORAGE_KEY = 'gc_sales_report_interface_scale_v1'; const SCALE_VALUES = [80, 90, 100, 110, 120, 130]; const MODE_MARATHON = 'marathon'; const MODE_CLUB_ACTION = 'club-action'; const DEBUG = true; const CONCURRENCY = 4; const CLUB_BALANCE_FIELD_ID = '13861978'; // Deal custom field: Club balance before order days const CLUB_BALANCE_FIRST_ENTRY_CODE = 99999; // спец-код: первый клубный заказ клиента const FIRST_CLUB_BASE_AGE_FIELD_ID = '13862155'; // Deal custom field: Days in base before first club order const CLUB_BALANCE_USER_CONCURRENCY = 4; const CLUB_BALANCE_DEAL_PAGE_CONCURRENCY = 3; const CLUB_BALANCE_WRITE_DELAY_MS = 350; const CLUB_BALANCE_DURATION_MAP = { month1: 31, month2: 62, month3: 92, month6: 183, month12: 365 }; const CLUB_BALANCE_COHORT_DEFS = [ { key: 'first-entry', label: 'Первый клубный заказ', min: CLUB_BALANCE_FIRST_ENTRY_CODE, max: CLUB_BALANCE_FIRST_ENTRY_CODE, note: 'Спец-код 99999: у клиента это первая покупка клуба.' }, { key: 'extension-30-plus', label: 'Сильно заранее продлились', min: 30, max: Infinity, note: 'До окончания клуба оставалось 30+ дней.' }, { key: 'extension-7-29', label: 'Плановое продление', min: 7, max: 29, note: 'До окончания клуба оставалось 7–29 дней.' }, { key: 'extension-0-6', label: 'Продление на грани', min: 0, max: 6, note: 'Покупка в день окончания или за 1–6 дней до конца доступа.' }, { key: 'reactivation-1-14', label: 'Быстрый возврат после окончания', min: -14, max: -1, note: 'Клиент вернулся через 1–14 дней после окончания клуба.' }, { key: 'reactivation-15-60', label: 'Возврат после короткой паузы', min: -60, max: -15, note: 'Клиент вернулся через 15–60 дней после окончания клуба.' }, { key: 'reactivation-61-180', label: 'Возврат после долгой паузы', min: -180, max: -61, note: 'Клиент вернулся через 61–180 дней после окончания клуба.' }, { key: 'reactivation-181-plus', label: 'Старая реактивация', min: -Infinity, max: -181, note: 'Клиент вернулся спустя 181+ дней после окончания клуба.' } ]; const FIRST_CLUB_BASE_AGE_COHORT_DEFS = [ { key: 'base-age-0-7', label: '0–7 дней', min: 0, max: 7, note: 'Быстрая покупка: клиент почти сразу пришёл в клуб.' }, { key: 'base-age-8-30', label: '8–30 дней', min: 8, max: 30, note: 'Покупка в первый месяц после регистрации.' }, { key: 'base-age-31-60', label: '31–60 дней', min: 31, max: 60, note: 'Созревание 1–2 месяца.' }, { key: 'base-age-61-90', label: '61–90 дней', min: 61, max: 90, note: 'Созревание 2–3 месяца.' }, { key: 'base-age-91-180', label: '91–180 дней', min: 91, max: 180, note: 'Долгое созревание.' }, { key: 'base-age-181-plus', label: '181+ дней', min: 181, max: Infinity, note: 'Старая база / длинный путь до первой покупки клуба.' } ]; const EMPTY_SOURCE_LABEL = '— не заполнено'; const EMPTY_TAG_LABEL = '— без тега'; const EMPTY_DAY_LABEL = '— без даты'; const OTHER_GROUP_LABEL = 'Прочее'; const FIRST_MONEY_GROUP_LABEL = 'Первая покупка за деньги'; const FIRST_CLUB_GROUP_LABEL = 'Первый клуб'; const NEWBIE_GROUP_LABEL = FIRST_CLUB_GROUP_LABEL; // Витрины продаж. // Важно: текущая открытая выборка GetCourse остаётся базой сравнения. // Каждая витрина ниже — это пересечение текущей выборки с отдельным offer-фильтром из HAR. const COHORT_REPORTS = [ { key: 'club-button-renewal', title: 'Клуб / продление из клуба', note: 'Текущая выборка + offer-фильтр предложений с пометкой/тегом «клуб»: люди уже в клубе и продлеваются через кнопку внутри клуба, без акции', mode: 'append-rules', appendRules: [ { type: 'deal_offer_id', inverted: 0, params: { value: { selected_id: [6923710, 6923711], selected_tags: [], all_object_with_tags: false }, valueMode: null } } ] }, { key: 'chl-main-site', title: 'CHL / основной сайт', note: 'Текущая выборка + offer-фильтр предложений CHL / основного сайта', mode: 'append-rules', appendRules: [ { type: 'deal_offer_id', inverted: 0, params: { value: { selected_id: [6843271, 6843353, 8042703, 8500734], selected_tags: [], all_object_with_tags: false }, valueMode: null } } ] }, { key: 'light-start', title: 'Марафон «Лёгкий старт»', note: 'Текущая выборка + offer-фильтр марафона «Лёгкий старт»', mode: 'append-rules', appendRules: [ { type: 'deal_offer_id', inverted: 0, params: { value: { selected_id: [6846582, 6846584, 6846588, 8446264, 8446294, 8446299, 6846577], selected_tags: [], all_object_with_tags: false }, valueMode: null } } ] }, { key: 'club-followup', title: 'Догоняющая цепочка клубников', note: 'Текущая выборка + offer-фильтр догоняющей цепочки клубников', mode: 'append-rules', appendRules: [ { type: 'deal_offer_id', inverted: 0, params: { value: { selected_id: [7369431, 7369599, 7370833], selected_tags: ['догоняющая цепочка'], all_object_with_tags: false }, valueMode: null } } ] } ]; // Платёжные срезы. Каждый считается отдельным запросом как пересечение // текущей открытой выборки с deal_haspayment-правилом из HAR. const PAYMENT_REPORTS = [ { key: 'installment-payments', title: 'Оплаты в рассрочку', note: 'Принятые реальные платежи, где способ оплаты не относится к обычным картам/Pay: card_rf, card_non_rf, tinkoff_pay, yandex_payment, sber_pay, alpha_pay, wb_pay.', mode: 'append-rules', appendRules: [ { type: 'deal_haspayment', inverted: 0, params: { linkedRule: { type: 'andrule', inverted: 0, params: { mode: 'and', children: [ { type: 'method', inverted: 1, params: { value: { selected_id: ['card_rf', 'card_non_rf', 'tinkoff_pay', 'yandex_payment', 'sber_pay', 'alpha_pay', 'wb_pay'] }, valueMode: null } }, { type: 'status', inverted: 0, params: { value: { selected_id: ['accepted'] }, valueMode: null } }, { type: 'type', inverted: 1, params: { value: { selected_id: ['VIRTUAL'] }, valueMode: null } } ] } }, countCondition: { checker: 'nlt', numval: '' }, maxSize: '' } } ] }, { key: 'card-non-rf', title: 'Оплаты картой НЕРФ', note: 'Принятые реальные платежи способом card_non_rf. Для управленческой оценки дополнительно показывается комиссия 10% и сумма после этой комиссии.', commissionRate: 0.10, mode: 'append-rules', appendRules: [ { type: 'deal_haspayment', inverted: 0, params: { linkedRule: { type: 'andrule', inverted: 0, params: { mode: 'and', children: [ { type: 'method', inverted: 0, params: { value: { selected_id: ['card_non_rf'] }, valueMode: null } }, { type: 'status', inverted: 0, params: { value: { selected_id: ['accepted'] }, valueMode: null } }, { type: 'type', inverted: 1, params: { value: { selected_id: ['VIRTUAL'] }, valueMode: null } } ] } }, countCondition: { checker: 'nlt', numval: '' }, maxSize: '' } } ] } ]; const SOURCE_COLUMN_NAMES = [ 'gc_system_deal_utm_source', 'GK ассистом UTM source', 'ГК ассистом UTM source', 'gk ассистом utm source', 'гк ассистом utm source' ]; const LEGACY_SOURCE_COLUMN_NAMES = [ 'utm_source', 'UTM Source', 'utm source' ]; const PAID_COLUMN_NAMES = [ 'Оплачено', 'Клиентом оплачено', 'Клиент: оплачено', 'Сумма оплаты' ]; const TAG_COLUMN_NAMES = [ 'Теги', 'Теги заказа', 'Клиент: теги', 'Пользователь: теги' ]; const COMPOSITION_COLUMN_NAMES = [ 'Состав заказа', 'Заказ: состав', 'Состав', 'Состав покупки', 'Продукты', 'Товары', 'Позиции заказа' ]; const CLUB_BALANCE_COLUMN_NAMES = [ 'Club balance before order days', 'club balance before order days', 'Club balance', 'Клуб: balance before order days' ]; const FIRST_CLUB_BASE_AGE_COLUMN_NAMES = [ 'Days in base before first club order', 'days in base before first club order', 'Days in base before first club', 'Дней в базе до первого клуба' ]; const USER_BASE_AGE_COLUMN_NAMES = [ 'Возраст пользователя в базе', 'возраст пользователя в базе', 'Возраст в базе', 'Дней с регистрации: сегмент' ]; const MARATHON_TARIFFS = [ { key: 'anti-hump-31-club', label: '31 день + клуб — 2 990 ₽', shortLabel: '31 день + клуб', price: 2990, offerId: '8526117', days: 31, includesClub: true, clubDays: 31, clubMonths: 1 }, { key: 'anti-hump-15', label: '15 дней — 990 ₽', shortLabel: '15 дней', price: 990, offerId: '8526114', days: 15, includesClub: false, clubDays: 0, clubMonths: 0 }, { key: 'anti-hump-5', label: '5 дней — 490 ₽', shortLabel: '5 дней', price: 490, offerId: '8526103', days: 5, includesClub: false, clubDays: 0, clubMonths: 0 } ]; const CLUB_ACCESS_LABEL = 'Онлайн-йога-клуб'; const EMPTY_COMPOSITION_LABEL = '— состав заказа не найден'; let ORIGINAL_TABLE_HEAD_HTML = ''; let ORIGINAL_TABLE_BODY_HTML = ''; let ORIGINAL_SUMMARY_HTML = ''; let CURRENT_TABLE_FILTER = null; let LAST_SOURCE_INFO = null; let LAST_GROUP_ROWS = []; let LAST_SEGMENT_ROWS = []; let LAST_DAY_ROWS = []; let LAST_COHORT_REPORTS = []; let LAST_PAYMENT_REPORTS = []; let LAST_CLUB_BALANCE_RESULTS = []; let CLUB_BALANCE_STOP_REQUESTED = false; let GROUPING_ENABLED = true; let CURRENT_VIEW = 'overview'; let ANALYSIS_MODE = (function () { try { const saved = localStorage.getItem(MODE_STORAGE_KEY); return saved === MODE_CLUB_ACTION ? MODE_CLUB_ACTION : MODE_MARATHON; } catch (e) { return MODE_MARATHON; } })(); let INTERFACE_SCALE = loadStoredInterfaceScale(); let CURRENT_DATE_COLUMN_KEY = ''; let EXPANDED_GROUPS = {}; let EXPANDED_SOURCE_TAGS = {}; let EXPANDED_SEGMENTS = {}; let EXPANDED_DAYS = {}; let FILTER_ROW_MAP = {}; let FILTER_ROW_COUNTER = 0; const LESSON_TITLE_CACHE_KEY = 'gc_sales_report_lesson_titles_v3'; let LESSON_TITLE_CACHE = null; let LESSON_TITLE_PROMISES = {}; let ANALYTICS_LOADING = false; let ANALYTICS_LOADED = false; const PAGE_TEXT = { button: 'Отчёт по продажам', title: 'Отчёт по продажам: запуск, тарифы, люди, источники', subtitle: 'Выберите режим и запустите сбор. Подсказки по настройке и показателям доступны по значкам «?».', noTable: 'Не нашла таблицу заказов на странице.', noSourceColumn: 'Не нашла источник: включите «gc_system_deal_utm_source» или «utm_source» в настройках таблицы заказов.', noPaidColumn: 'Не нашла колонку «Оплачено». Проверь, что в таблице заказов включена именно колонка «Оплачено», а не только «Стоимость».', collecting: 'Собираю источники, суммы оплат, теги и даты из текущей выборки заказов...', filterLabel: 'UTM аналитика' }; function debugLog() { if (!DEBUG) return; console.log.apply(console, ['[GC UTM MONEY TAGS DAYS]'].concat(Array.from(arguments))); } function normalizeText(text) { return String(text || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim() .toLowerCase(); } function normalizeVisibleText(text) { return String(text || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim(); } function normalizeColumnText(text) { return normalizeText(text) .replace(/[_\-–—]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function normalizeSourceForRule(value) { return String(value || '') .toLowerCase() .replace(/\u00a0/g, ' ') .replace(/[ё]/g, 'е') .replace(/[._\-–—:/\\|]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function normalizeTagForRule(value) { return String(value || '') .toLowerCase() .replace(/\u00a0/g, ' ') .replace(/[ё]/g, 'е') .replace(/[._\-–—:/\\|]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function escapeHtml(value) { return String(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 formatMoney(num) { const value = Number(num) || 0; const hasCoins = Math.abs(value - Math.round(value)) > 0.005; return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: hasCoins ? 2 : 0, maximumFractionDigits: 2 }).format(value) + ' ₽'; } function formatPercentValue(value, total) { if (!total) return '0%'; return (Number(value) / Number(total) * 100).toFixed(1).replace('.', ',') + '%'; } function formatConversion(paidCount, totalCount) { return formatPercentValue(paidCount, totalCount); } function pad(num) { num = Number(num); return num < 10 ? '0' + num : String(num); } function formatDay(date) { if (!date) return EMPTY_DAY_LABEL; return pad(date.getDate()) + '.' + pad(date.getMonth() + 1) + '.' + String(date.getFullYear()).slice(-2); } function formatDayFull(date) { if (!date) return EMPTY_DAY_LABEL; return pad(date.getDate()) + '.' + pad(date.getMonth() + 1) + '.' + date.getFullYear(); } function makeDayKey(date) { if (!date) return 'no-date'; return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()); } function loadHighcharts(callback) { if (window.Highcharts) { callback(); return; } const existing = document.querySelector('script[data-gc-utm-highcharts="1"]'); if (existing) { existing.addEventListener('load', callback, { once: true }); return; } const script = document.createElement('script'); script.src = 'https://code.highcharts.com/highcharts.js'; script.setAttribute('data-gc-utm-highcharts', '1'); script.onload = callback; document.head.appendChild(script); } function addButton() { const existing = document.getElementById(BTN_ID); if (existing && existing.getAttribute('data-gc-utm-version') === VERSION) return; if (existing && existing.parentNode) existing.parentNode.removeChild(existing); // Важно: кнопка хранится прямо внутри JS как HTML-строка. // Это такой же принцип, как в рабочем анализаторе сегмента: внешний файл JS сам // вставляет интерфейс в GetCourse, без отдельного HTML-файла/блока с кнопкой. const buttonHtml = ''; const registrationBtn = document.getElementById('client-registration-chart-btn') || findButtonByText(/график\s+регистрац|график.*регистрац/i); let btn = null; if (registrationBtn && registrationBtn.parentNode) { registrationBtn.insertAdjacentHTML('afterend', buttonHtml); btn = document.getElementById(BTN_ID); debugLog('Кнопка добавлена рядом с графиком регистраций'); } else { const container = document.querySelector('.segment-rule .action-buttons') || document.querySelector('.action-buttons') || document.querySelector('.kv-panel-before') || document.body; if (!container) return; container.insertAdjacentHTML('beforeend', buttonHtml); btn = document.getElementById(BTN_ID); debugLog('Кнопка добавлена'); } if (!btn) return; btn.onclick = function (e) { e.preventDefault(); e.stopPropagation(); ensureBlock(); const wrap = document.getElementById(WRAP_ID); if (wrap && wrap.scrollIntoView) wrap.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; } function findButtonByText(regexp) { return Array.from(document.querySelectorAll('button, a, input[type="button"], input[type="submit"]')) .find(function (el) { const text = normalizeText(el.innerText || el.value || el.textContent || ''); return regexp.test(text); }) || null; } function ensureBlock() { let wrap = document.getElementById(WRAP_ID); if (wrap && wrap.getAttribute('data-gc-utm-version') !== VERSION && wrap.parentNode) { wrap.parentNode.removeChild(wrap); wrap = null; } if (!wrap) { wrap = document.createElement('div'); wrap.id = WRAP_ID; wrap.setAttribute('data-gc-utm-version', VERSION); wrap.style.margin = '15px 0'; wrap.style.padding = '14px'; wrap.style.background = '#fff'; wrap.style.border = '1px solid #ddd'; wrap.style.borderRadius = '8px'; wrap.innerHTML = '' + '
' + '
' + '
' + escapeHtml(PAGE_TEXT.title) + '
' + '
Выберите режим и нажмите «Получить аналитику».
' + '
' + escapeHtml(PAGE_TEXT.subtitle) + '
' + '
' + '
' + '
' + '' + '' + '
' + '
' + '' + '100%' + '' + '' + '
' + '' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '' + '
' + '
' + '
Выберите режим и запустите сбор.
' + '
' + '
' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + '' + '' + '
' + '' + '
' + '' + '
' + '' + escapeHtml(TOOL_NAME) + ' · ' + escapeHtml(VERSION_NUMBER) + ' · ' + escapeHtml(VERSION) + '' + '
'; } const panel = document.querySelector('.kv-panel-before'); const table = getCurrentDataTable(); if (panel && panel.nextSibling !== wrap) { panel.parentNode.insertBefore(wrap, panel.nextSibling); } else if (!panel && table && table.parentNode && table.parentNode.parentNode) { table.parentNode.parentNode.insertBefore(wrap, table.parentNode); } else if (!wrap.isConnected) { document.body.insertBefore(wrap, document.body.firstChild); } bindControls(); applyInterfaceScale(INTERFACE_SCALE, false); updateControlButtons(); updateDateSelector(); } function loadStoredInterfaceScale() { try { const value = Number(localStorage.getItem(SCALE_STORAGE_KEY)); return SCALE_VALUES.indexOf(value) !== -1 ? value : 100; } catch (e) { return 100; } } function applyInterfaceScale(value, persist) { const numeric = Number(value); const scale = SCALE_VALUES.indexOf(numeric) !== -1 ? numeric : 100; INTERFACE_SCALE = scale; const wrap = document.getElementById(WRAP_ID); if (wrap) { // CSS zoom масштабирует весь интерфейс целиком, а обратная ширина // сохраняет итоговую ширину блока в пределах страницы GetCourse. wrap.style.zoom = String(scale / 100); wrap.style.width = (10000 / scale) + '%'; wrap.style.maxWidth = 'none'; wrap.setAttribute('data-interface-scale', String(scale)); const label = wrap.querySelector('[data-utm-scale-value]'); if (label) label.textContent = scale + '%'; } if (persist !== false) { try { localStorage.setItem(SCALE_STORAGE_KEY, String(scale)); } catch (e) {} } } function changeInterfaceScale(direction) { let index = SCALE_VALUES.indexOf(INTERFACE_SCALE); if (index === -1) index = SCALE_VALUES.indexOf(100); const nextIndex = Math.max(0, Math.min(SCALE_VALUES.length - 1, index + (direction < 0 ? -1 : 1))); applyInterfaceScale(SCALE_VALUES[nextIndex], true); } function bindControls() { const wrap = document.getElementById(WRAP_ID); if (!wrap || wrap.getAttribute('data-bound') === '1') return; wrap.setAttribute('data-bound', '1'); wrap.addEventListener('click', function (e) { const viewBtn = e.target.closest('[data-utm-view]'); const copyTableBtn = e.target.closest('[data-utm-copy-table]'); const copyJsonBtn = e.target.closest('[data-utm-copy-json]'); const clubBalanceBtn = e.target.closest('[data-club-balance-enrich]'); const clubBalanceStopBtn = e.target.closest('[data-club-balance-stop]'); const rebuildBtn = e.target.closest('[data-utm-rebuild]'); const groupingBtn = e.target.closest('[data-utm-grouping-toggle]'); const expandAllBtn = e.target.closest('[data-utm-expand-all]'); const collapseAllBtn = e.target.closest('[data-utm-collapse-all]'); const toggleGroupBtn = e.target.closest('[data-utm-toggle-group-index]'); const toggleSourceTagsBtn = e.target.closest('[data-utm-toggle-source-tags]'); const toggleSegmentBtn = e.target.closest('[data-utm-toggle-segment-index]'); const toggleDayBtn = e.target.closest('[data-utm-toggle-day-index]'); const listFilterEl = e.target.closest('[data-utm-list-filter-id]'); const filterEl = e.target.closest('[data-utm-filter-id]'); const lessonLink = e.target.closest('[data-utm-lesson-link]'); const modeBtn = e.target.closest('[data-analysis-mode]'); const drilldownClose = e.target.closest('[data-drilldown-close]'); const crossApply = e.target.closest('[data-cross-apply]'); const crossReset = e.target.closest('[data-cross-reset]'); const loadAnalyticsBtn = e.target.closest('[data-utm-load-analytics]'); const helpBtn = e.target.closest('[data-help-topic]'); const helpClose = e.target.closest('[data-help-close]'); const scaleStepBtn = e.target.closest('[data-utm-scale-step]'); const scaleResetBtn = e.target.closest('[data-utm-scale-reset]'); if (scaleStepBtn) { e.preventDefault(); changeInterfaceScale(Number(scaleStepBtn.getAttribute('data-utm-scale-step')) || 0); return; } if (scaleResetBtn) { e.preventDefault(); applyInterfaceScale(100, true); return; } if (helpClose) { e.preventDefault(); closeHelpModal(); return; } if (helpBtn) { e.preventDefault(); openHelpModal(helpBtn.getAttribute('data-help-topic')); return; } if (drilldownClose) { e.preventDefault(); clearDrilldownAnalytics(); return; } if (lessonLink) { e.stopPropagation(); return; } if (loadAnalyticsBtn) { e.preventDefault(); if (!ANALYTICS_LOADING) loadHighcharts(buildUtmSourceAnalytics); return; } if (modeBtn) { e.preventDefault(); const nextMode = modeBtn.getAttribute('data-analysis-mode') === MODE_CLUB_ACTION ? MODE_CLUB_ACTION : MODE_MARATHON; if (nextMode === ANALYSIS_MODE) return; ANALYSIS_MODE = nextMode; try { localStorage.setItem(MODE_STORAGE_KEY, ANALYSIS_MODE); } catch (e) {} if (ANALYSIS_MODE === MODE_MARATHON && ['cohorts','segments','club','firstclub','clubbalance'].indexOf(CURRENT_VIEW) !== -1) CURRENT_VIEW = 'overview'; ANALYTICS_LOADED = false; updateControlButtons(); setStatus('Режим выбран: ' + (ANALYSIS_MODE === MODE_CLUB_ACTION ? 'Акция клуба' : 'Марафон') + '. Нажмите «Получить аналитику».'); resetProgress('Выберите режим и запустите сбор.'); return; } if (viewBtn) { e.preventDefault(); CURRENT_VIEW = viewBtn.getAttribute('data-utm-view') || 'overview'; updateControlButtons(); if (LAST_SOURCE_INFO) safeRenderResult(LAST_SOURCE_INFO); return; } if (copyTableBtn) { e.preventDefault(); copyResultTable(); return; } if (copyJsonBtn) { e.preventDefault(); copyResultJson(); return; } if (clubBalanceBtn) { e.preventDefault(); enrichClubBalanceForCurrentSegment(); return; } if (clubBalanceStopBtn) { e.preventDefault(); CLUB_BALANCE_STOP_REQUESTED = true; setStatus('Остановка Club balance запрошена. Уже начатые запросы завершатся, новые не будут запускаться.'); return; } if (rebuildBtn) { e.preventDefault(); buildUtmSourceAnalytics(); return; } if (groupingBtn) { e.preventDefault(); GROUPING_ENABLED = !GROUPING_ENABLED; resetExpandedState(); updateControlButtons(); if (LAST_SOURCE_INFO) safeRenderResult(LAST_SOURCE_INFO); return; } if (expandAllBtn) { e.preventDefault(); expandAllCurrentView(); return; } if (collapseAllBtn) { e.preventDefault(); resetExpandedState(); if (LAST_SOURCE_INFO) safeRenderResult(LAST_SOURCE_INFO); return; } if (toggleGroupBtn) { e.preventDefault(); e.stopPropagation(); const groupIndex = Number(toggleGroupBtn.getAttribute('data-utm-toggle-group-index')); const group = LAST_GROUP_ROWS[groupIndex]; if (group) EXPANDED_GROUPS[group.key] = !EXPANDED_GROUPS[group.key]; if (LAST_SOURCE_INFO) safeRenderResult(LAST_SOURCE_INFO); return; } if (toggleSourceTagsBtn) { e.preventDefault(); e.stopPropagation(); const key = toggleSourceTagsBtn.getAttribute('data-utm-toggle-source-tags'); EXPANDED_SOURCE_TAGS[key] = !EXPANDED_SOURCE_TAGS[key]; if (LAST_SOURCE_INFO) safeRenderResult(LAST_SOURCE_INFO); return; } if (toggleSegmentBtn) { e.preventDefault(); e.stopPropagation(); const idx = Number(toggleSegmentBtn.getAttribute('data-utm-toggle-segment-index')); const item = LAST_SEGMENT_ROWS[idx]; if (item) EXPANDED_SEGMENTS[item.key] = !EXPANDED_SEGMENTS[item.key]; if (LAST_SOURCE_INFO) safeRenderResult(LAST_SOURCE_INFO); return; } if (toggleDayBtn) { e.preventDefault(); e.stopPropagation(); const idx = Number(toggleDayBtn.getAttribute('data-utm-toggle-day-index')); const item = LAST_DAY_ROWS[idx]; if (item) EXPANDED_DAYS[item.key] = !EXPANDED_DAYS[item.key]; if (LAST_SOURCE_INFO) safeRenderResult(LAST_SOURCE_INFO); return; } if (crossApply) { e.preventDefault(); const values = {}; Array.from(wrap.querySelectorAll('[data-cross]')).forEach(function (el) { values[el.getAttribute('data-cross')] = el.value || ''; }); const matched = (LAST_SOURCE_INFO ? LAST_SOURCE_INFO.rows : []).filter(function (row) { return (!values.tag || row.segmentTag === values.tag) && (!values.age || row.userBaseAgeSegment === values.age) && (!values.tariff || row.marathonTariffLabel === values.tariff) && (!values.source || (row.group || row.source) === values.source) && (!values.day || row.dayLabelFull === values.day); }); const labels = Object.keys(values).filter(function (k) { return values[k]; }).map(function (k) { return values[k]; }); if (!matched.length) { setStatus('По выбранному пересечению заказов не найдено.'); return; } const crossLabel = 'Пересечение: ' + (labels.join(' + ') || 'вся выборка'); renderDrilldownAnalytics(matched, crossLabel); filterCurrentTableByRows(matched, crossLabel); return; } if (crossReset) { e.preventDefault(); Array.from(wrap.querySelectorAll('[data-cross]')).forEach(function (el) { el.value = ''; }); resetCurrentTableFilter(); return; } if (listFilterEl) { e.preventDefault(); e.stopPropagation(); const listFilterId = listFilterEl.getAttribute('data-utm-list-filter-id'); const listInfo = FILTER_ROW_MAP[listFilterId]; if (!listInfo || !listInfo.rows || !listInfo.rows.length) return; clearDrilldownAnalytics(); filterCurrentTableByRows(listInfo.rows, listInfo.label); const table = getCurrentDataTable(); if (table && table.scrollIntoView) table.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } if (filterEl) { const filterId = filterEl.getAttribute('data-utm-filter-id'); const info = FILTER_ROW_MAP[filterId]; if (!info || !info.rows || !info.rows.length) return; renderDrilldownAnalytics(info.rows, info.label); filterCurrentTableByRows(info.rows, info.label); } }); wrap.addEventListener('keydown', function (e) { if ((e.key === 'Enter' || e.key === ' ') && e.target.closest('[data-utm-list-filter-id],[data-utm-filter-id]')) { e.preventDefault(); e.target.closest('[data-utm-list-filter-id],[data-utm-filter-id]').click(); } if (e.key === 'Escape') { hideClickTooltip(); clearDrilldownAnalytics(); } }); } function resetExpandedState() { EXPANDED_GROUPS = {}; EXPANDED_SOURCE_TAGS = {}; EXPANDED_SEGMENTS = {}; EXPANDED_DAYS = {}; } function updateControlButtons() { const wrap = document.getElementById(WRAP_ID); if (!wrap) return; Array.from(wrap.querySelectorAll('[data-analysis-mode]')).forEach(function (btn) { const active = btn.getAttribute('data-analysis-mode') === ANALYSIS_MODE; btn.classList.toggle('btn-primary', active); btn.classList.toggle('btn-default', !active); }); Array.from(wrap.querySelectorAll('[data-club-mode-only]')).forEach(function (el) { el.setAttribute('data-mode-hidden', ANALYSIS_MODE === MODE_CLUB_ACTION ? '0' : '1'); }); const groupingBtn = wrap.querySelector('[data-utm-grouping-toggle]'); if (groupingBtn) { groupingBtn.innerText = GROUPING_ENABLED ? 'Группировка: вкл' : 'Группировка: выкл'; groupingBtn.classList.toggle('btn-primary', GROUPING_ENABLED); groupingBtn.classList.toggle('btn-default', !GROUPING_ENABLED); } Array.from(wrap.querySelectorAll('[data-utm-view]')).forEach(function (btn) { const active = btn.getAttribute('data-utm-view') === CURRENT_VIEW; btn.classList.toggle('btn-primary', active); btn.classList.toggle('btn-default', !active); }); } function updateDateSelector() { // v54: источник даты больше не выбирается. Всегда используется колонка «Статус». const columns = LAST_SOURCE_INFO ? LAST_SOURCE_INFO.dateColumns : []; CURRENT_DATE_COLUMN_KEY = columns && columns.length ? columns[0].key : ''; } function expandAllCurrentView() { if (CURRENT_VIEW === 'sources') { LAST_GROUP_ROWS.forEach(function (group, groupIndex) { EXPANDED_GROUPS[group.key] = true; (group.children || []).forEach(function (child, sourceIndex) { EXPANDED_SOURCE_TAGS[getSourceExpandKey(groupIndex, sourceIndex, child.source)] = true; }); }); } if (CURRENT_VIEW === 'segments') { LAST_SEGMENT_ROWS.forEach(function (segment) { EXPANDED_SEGMENTS[segment.key] = true; }); } if (CURRENT_VIEW === 'days') { LAST_DAY_ROWS.forEach(function (day) { EXPANDED_DAYS[day.key] = true; }); } if (CURRENT_VIEW === 'overview') { LAST_SEGMENT_ROWS.slice(0, 8).forEach(function (segment) { EXPANDED_SEGMENTS[segment.key] = true; }); LAST_DAY_ROWS.slice(0, 8).forEach(function (day) { EXPANDED_DAYS[day.key] = true; }); LAST_GROUP_ROWS.slice(0, 8).forEach(function (group, groupIndex) { EXPANDED_GROUPS[group.key] = true; (group.children || []).forEach(function (child, sourceIndex) { EXPANDED_SOURCE_TAGS[getSourceExpandKey(groupIndex, sourceIndex, child.source)] = true; }); }); } if (LAST_SOURCE_INFO) safeRenderResult(LAST_SOURCE_INFO); } function getSourceExpandKey(groupIndex, sourceIndex, source) { return groupIndex + ':' + sourceIndex + ':' + String(source || ''); } function openHelpModal(topic, customHtml) { const el = document.getElementById(HELP_MODAL_ID); if (!el) return; const topics = { main: { title: 'Как пользоваться отчётом', body: '1. Выберите режим «Марафон» или «Акция клуба».
2. Нажмите «Получить аналитику».
3. Дождитесь сообщения «Аналитика загружена полностью».
4. Нажимайте на строки, карточки и диаграммы, чтобы проваливаться в выбранную группу.' }, mode: { title: 'Режим анализа', body: 'Марафон — тарифы, дни, источники, возраст базы, оплаты и пересечения.
Акция клуба — дополнительно витрины, Club balance, первые покупки и клубные метрики.' }, columns: { title: 'Нужно настроить колонки таблицы', body: customHtml || '' } }; const item = topics[topic] || topics.main; el.style.display = 'block'; el.innerHTML = '
' + escapeHtml(item.title) + '
' + item.body + '
'; // Модальное окно находится внутри отчёта, но карточка останавливает всплытие клика. // Поэтому кнопка закрытия получает собственный обработчик непосредственно на контейнере модалки. el.onclick = function (event) { const closeBtn = event.target.closest('[data-help-close]'); if (closeBtn) { event.preventDefault(); event.stopPropagation(); closeHelpModal(); return; } if (event.target.closest('[data-help-card]')) return; if (event.target.closest('[data-help-backdrop]')) { event.preventDefault(); closeHelpModal(); } }; } function closeHelpModal() { const el = document.getElementById(HELP_MODAL_ID); if (el) { el.style.display = 'none'; el.innerHTML = ''; el.onclick = null; } } function validateRequiredColumns(table) { const missing = []; const sourceColumns = getSourceColumns(table); if (!getPaidColumn(table)) missing.push('Оплачено'); if (!sourceColumns.any) missing.push('gc_system_deal_utm_source или utm_source'); if (!getCompositionColumn(table)) missing.push('Состав заказа'); if (!getTagsColumn(table)) missing.push('Теги'); if (!getUserBaseAgeColumn(table)) missing.push('Возраст пользователя в базе'); if (!getStatusColumn(table)) missing.push('Статус'); if (ANALYSIS_MODE === MODE_CLUB_ACTION) { if (!getClubBalanceMetricColumn(table)) missing.push('Club balance before order days'); if (!getFirstClubBaseAgeMetricColumn(table)) missing.push('Days in base before first club order'); } return missing; } function showMissingColumns(missing) { const list = (missing || []).map(function (x) { return '
  • ' + escapeHtml(x) + '
  • '; }).join(''); const body = '
    Не хватает колонок:
    ' + '
      ' + '
    1. Нажмите на гаечный ключ справа сверху над таблицей.
    2. ' + '
    3. Перенесите нужные поля в левый столбец.
    4. ' + '
    5. Нажмите «Сохранить».
    6. ' + '
    7. Перезагрузите страницу и снова откройте выборку.
    8. ' + '
    9. Нажмите «Получить аналитику» ещё раз.
    '; openHelpModal('columns', body); setStatus('Нужно добавить обязательные колонки. Открыта подсказка с инструкцией.'); } function setProgressCompleteState(isComplete) { const row = document.querySelector('#' + WRAP_ID + ' .utm-load-row'); if (row) row.classList.toggle('is-complete', Boolean(isComplete)); } function setProgress(percent, text, visible) { const wrap = document.getElementById(PROGRESS_WRAP_ID); const bar = document.getElementById(PROGRESS_BAR_ID); const label = document.getElementById(PROGRESS_TEXT_ID); const value = Math.max(0, Math.min(100, Number(percent) || 0)); if (wrap) wrap.style.display = visible === false ? 'none' : 'block'; if (bar) bar.style.width = value + '%'; if (label) label.innerHTML = text || ''; } function resetProgress(text) { setProgressCompleteState(false); setProgress(0, text || 'Выберите режим и запустите сбор.', true); } function setLoadingState(isLoading) { ANALYTICS_LOADING = Boolean(isLoading); const loadBtn = document.getElementById(LOAD_BTN_ID); if (loadBtn) { loadBtn.disabled = ANALYTICS_LOADING; loadBtn.innerText = ANALYTICS_LOADING ? 'Загружаю аналитику…' : (ANALYTICS_LOADED ? 'Обновить аналитику' : 'Получить аналитику'); } const wrap = document.getElementById(WRAP_ID); if (wrap) Array.from(wrap.querySelectorAll('[data-analysis-mode]')).forEach(function (btn) { btn.disabled = ANALYTICS_LOADING; }); } function setStatus(text) { const el = document.getElementById(STATUS_ID); if (el) el.innerHTML = text; } function setDebug(text) { const el = document.getElementById(DEBUG_ID); if (!el) return; if (!DEBUG || !text) { el.style.display = 'none'; el.innerHTML = ''; return; } el.style.display = 'block'; el.innerHTML = text; } function setFilterInfo(text) { const el = document.getElementById(FILTER_ID); if (!el) return; if (!text) { el.style.display = 'none'; el.innerHTML = ''; return; } el.style.display = 'block'; el.innerHTML = text; const resetBtn = document.getElementById(RESET_FILTER_BTN_ID); if (resetBtn) resetBtn.onclick = resetCurrentTableFilter; } function getCurrentDataTable() { return getDataTable(document); } function getDataTable(doc) { const tables = Array.from(doc.querySelectorAll('table.kv-grid-table, .kv-grid-container table, table')); // Раньше таблица определялась только по gc_system_deal_utm_source. // В свежем GetCourse в HAR осталась только legacy-колонка utm_source, // поэтому ищем любую колонку источника, а не только системную. const withSource = tables.find(function (table) { const sourceColumns = getSourceColumns(table); return Boolean(sourceColumns && sourceColumns.any && getPaidColumn(table)); }); if (withSource) return withSource; // Диагностический fallback: таблица заказов есть, но источник не включён. // Тогда buildUtmSourceAnalytics покажет точную ошибку по колонке источника. return tables.find(function (table) { return Boolean(getPaidColumn(table)); }) || null; } function getSourceColumn(table) { return getColumnByNames(table, SOURCE_COLUMN_NAMES, function (text) { return ( text.indexOf('gc system deal utm source') !== -1 || text.indexOf('gk ассистом utm source') !== -1 || text.indexOf('гк ассистом utm source') !== -1 ); }); } function getLegacySourceColumn(table) { return getColumnByNames(table, LEGACY_SOURCE_COLUMN_NAMES, function (text) { return text === 'utm source' || text === 'utm_source'; }); } function getSourceColumns(table) { const systemColumn = getSourceColumn(table); const legacyColumn = getLegacySourceColumn(table); return { system: systemColumn, legacy: legacyColumn, any: systemColumn || legacyColumn }; } function getSourceCellText(row, sourceColumns) { const systemCell = sourceColumns && sourceColumns.system ? getCellByColumn(row, sourceColumns.system) : null; const legacyCell = sourceColumns && sourceColumns.legacy ? getCellByColumn(row, sourceColumns.legacy) : null; const rawSystem = normalizeVisibleText(systemCell ? systemCell.innerText || systemCell.textContent : ''); const rawLegacy = normalizeVisibleText(legacyCell ? legacyCell.innerText || legacyCell.textContent : ''); const systemValue = normalizeSourceValue(rawSystem); const legacyValue = normalizeSourceValue(rawLegacy); if (systemValue && systemValue !== EMPTY_SOURCE_LABEL) { return { raw: rawSystem, value: systemValue, sourceColumnLabel: sourceColumns.system.text || 'gc_system_deal_utm_source', fallbackRaw: rawLegacy, fallbackValue: legacyValue }; } if (legacyValue && legacyValue !== EMPTY_SOURCE_LABEL) { return { raw: rawLegacy, value: legacyValue, sourceColumnLabel: sourceColumns.legacy.text || 'utm_source', fallbackRaw: rawSystem, fallbackValue: systemValue }; } return { raw: rawSystem || rawLegacy, value: EMPTY_SOURCE_LABEL, sourceColumnLabel: sourceColumns && sourceColumns.any ? sourceColumns.any.text || '' : '', fallbackRaw: rawLegacy, fallbackValue: legacyValue }; } function getPaidColumn(table) { return getColumnByNames(table, PAID_COLUMN_NAMES, function (text) { if (text.indexOf('стоимость') !== -1) return false; if (text.indexOf('клиент денег принес') !== -1) return false; if (text.indexOf('денег принес') !== -1) return false; return text === 'оплачено' || text.indexOf('клиентом оплачено') !== -1 || text.indexOf('клиент оплачено') !== -1 || text.indexOf('сумма оплаты') !== -1; }); } function getTagsColumn(table) { return getColumnByNames(table, TAG_COLUMN_NAMES, function (text) { return text === 'теги' || text.indexOf('теги заказа') !== -1 || text.indexOf('клиент теги') !== -1 || text.indexOf('пользователь теги') !== -1; }); } function getCompositionColumn(table) { return getColumnByNames(table, COMPOSITION_COLUMN_NAMES, function (text) { return text === 'состав заказа' || text === 'состав' || text.indexOf('состав заказа') !== -1 || text.indexOf('состав покупки') !== -1 || text.indexOf('продукт') !== -1 || text.indexOf('товар') !== -1 || text.indexOf('позиции заказа') !== -1; }); } function getClubBalanceMetricColumn(table) { return getColumnByNames(table, CLUB_BALANCE_COLUMN_NAMES, function (text) { return text.indexOf('club balance') !== -1 || text.indexOf('balance before order') !== -1; }); } function getFirstClubBaseAgeMetricColumn(table) { return getColumnByNames(table, FIRST_CLUB_BASE_AGE_COLUMN_NAMES, function (text) { return text.indexOf('days in base before first club') !== -1 || text.indexOf('дней в базе до первого клуба') !== -1; }); } function getUserBaseAgeColumn(table) { return getColumnByNames(table, USER_BASE_AGE_COLUMN_NAMES, function (text) { return text.indexOf('возраст пользователя в базе') !== -1 || text === 'возраст в базе'; }); } function getStatusColumn(table) { return getColumnByNames(table, ['Статус', 'Статус заказа', 'Состояние'], function (text) { return text === 'статус' || text.indexOf('статус заказа') !== -1 || text === 'состояние'; }); } function getDateColumns(table) { if (!table) return []; const statusColumn = getStatusColumn(table); if (!statusColumn) return []; const statusKey = (statusColumn.colSeq !== null && statusColumn.colSeq !== '') ? 'status-date:seq:' + statusColumn.colSeq : 'status-date:idx:' + statusColumn.index; return [{ key: statusKey, index: statusColumn.index, colSeq: statusColumn.colSeq, label: 'Статус: дата оплаты/завершения', sourceLabel: statusColumn.text || 'Статус', score: 220, kind: 'status' }]; } function getDateColumnScore(text) { if (!text) return 0; // Основная дата покупки теперь берётся из колонки «Статус» как виртуальная дата. // Если статус недоступен, следующим лучшим вариантом остаётся «Дата изменения». if (text.indexOf('регистрац') !== -1) return 0; if (text.indexOf('дата изменения') !== -1) return 140; if (text.indexOf('изменен') !== -1 || text.indexOf('изменён') !== -1 || text.indexOf('изменена') !== -1 || text.indexOf('изменено') !== -1) return 138; if (text.indexOf('updated') !== -1 || text.indexOf('modified') !== -1 || text.indexOf('changed') !== -1) return 136; if (text.indexOf('дата оплаты') !== -1) return 120; if (text.indexOf('оплачен') !== -1 && text.indexOf('дата') !== -1) return 118; if (text.indexOf('платеж') !== -1 && text.indexOf('дата') !== -1) return 112; if (text.indexOf('paid') !== -1 && text.indexOf('date') !== -1) return 110; if (text.indexOf('дата создания') !== -1) return 90; if (text.indexOf('создан') !== -1 && text.indexOf('заказ') !== -1) return 88; if (text === 'создан' || text === 'создано') return 86; if (text.indexOf('created') !== -1) return 84; if (text.indexOf('дата заказа') !== -1) return 72; if (text.indexOf('заказ создан') !== -1) return 70; if (text === 'дата') return 50; if (text.indexOf('date') !== -1 && text.indexOf('registration') === -1) return 45; return 0; } function getColumnByNames(table, names, fallback) { if (!table) return null; const headers = Array.from(table.querySelectorAll('thead tr:first-child th')); const aliases = names.map(normalizeColumnText); for (let i = 0; i < headers.length; i++) { const rawText = headers[i].innerText || headers[i].textContent || ''; const text = normalizeColumnText(rawText); if (aliases.indexOf(text) !== -1) { return { index: i, colSeq: headers[i].getAttribute('data-col-seq'), text: rawText }; } } for (let j = 0; j < headers.length; j++) { const fallbackText = normalizeColumnText(headers[j].innerText || headers[j].textContent || ''); if (fallback && fallback(fallbackText)) { return { index: j, colSeq: headers[j].getAttribute('data-col-seq'), text: headers[j].innerText || headers[j].textContent || '' }; } } return null; } function getCellByColumn(row, column) { if (!row || !column) return null; if (column.colSeq !== null && column.colSeq !== '') { const bySeq = row.querySelector('td[data-col-seq="' + column.colSeq + '"]'); if (bySeq) return bySeq; } const cells = Array.from(row.querySelectorAll('td')); return cells[column.index] || null; } function extractDealIdFromRow(row) { if (!row) return ''; const direct = row.getAttribute('data-deal-id') || row.getAttribute('data-key') || row.getAttribute('data-id') || ''; if (/^\d+$/.test(String(direct || ''))) return String(direct); const html = row.outerHTML || ''; const patterns = [ /\/sales\/control\/deal\/update\/id\/(\d+)/i, /\/pl\/sales\/deal\/update\/id\/(\d+)/i, /deal\/update\/id\/(\d+)/i, /[?&]dealId=(\d+)/i, /[?&]deal_id=(\d+)/i, /data-deal-id=["']?(\d+)/i ]; for (let i = 0; i < patterns.length; i++) { const m = html.match(patterns[i]); if (m && m[1]) return m[1]; } return ''; } function extractUserIdFromDealRow(row) { if (!row) return ''; const direct = row.getAttribute('data-user-id') || row.getAttribute('data-client-id') || ''; if (/^\d+$/.test(String(direct || ''))) return String(direct); const html = row.outerHTML || ''; const patterns = [ /\/user\/control\/user\/update\/id\/(\d+)/i, /\/pl\/user\/user\/update\/id\/(\d+)/i, /user_id[=:"'\s]+(\d+)/i, /userId[=:"'\s]+(\d+)/i, /data-user-id=["']?(\d+)/i ]; for (let i = 0; i < patterns.length; i++) { const m = html.match(patterns[i]); if (m && m[1]) return m[1]; } return ''; } function getSummaryTotal(doc) { const text = (doc.querySelector('.summary')?.innerText || '').replace(/\u00a0/g, ' '); const match = text.match(/из\s+([\d\s]+)/i); return match ? Number(match[1].replace(/\s/g, '')) : 0; } function getRuleTotal(doc) { const bodyText = (doc.body?.innerText || '').replace(/\u00a0/g, ' '); const match = bodyText.match(/Под правило попадает:\s*([\d\s]+)\s+(пользовател|запис|заказ)/i); return match ? Number(match[1].replace(/\s/g, '')) : 0; } function getTotal(doc) { return getRuleTotal(doc) || getSummaryTotal(doc); } function getPageSize(doc) { const text = (doc.querySelector('.summary')?.innerText || '').replace(/\u00a0/g, ' '); const match = text.match(/Показаны\s+(\d+)-(\d+)/i); if (match) { return Number(match[2]) - Number(match[1]) + 1; } const table = getDataTable(doc); return table ? table.querySelectorAll('tbody tr').length || 100 : 100; } function makePageUrl(baseUrl, page) { const url = new URL(baseUrl || window.location.href, window.location.origin); if (page && Number(page) > 1) { url.searchParams.set('page', page); } else { url.searchParams.delete('page'); } return url.toString(); } function makeUrl(page) { return makePageUrl(window.location.href, page); } async function fetchPage(page) { return fetchPageFromUrl(window.location.href, page); } async function fetchPageFromUrl(baseUrl, page) { const res = await fetch(makePageUrl(baseUrl, page), { credentials: 'include' }); if (!res.ok) { throw new Error('Не удалось загрузить страницу ' + page + ': HTTP ' + res.status); } const html = await res.text(); return new DOMParser().parseFromString(html, 'text/html'); } function normalizeReportUrl(value) { const url = new URL(value || window.location.href, window.location.origin); url.searchParams.delete('page'); Array.from(url.searchParams.keys()).forEach(function (key) { if (/^r\d+$/i.test(key) || key.indexOf('formParams[') === 0 || key === '_pjax') { url.searchParams.delete(key); } }); const pairs = Array.from(url.searchParams.entries()).sort(function (a, b) { return a[0] === b[0] ? String(a[1]).localeCompare(String(b[1])) : a[0].localeCompare(b[0]); }); return url.pathname + '?' + pairs.map(function (pair) { return encodeURIComponent(pair[0]) + '=' + encodeURIComponent(pair[1]); }).join('&'); } function isSameReportUrl(a, b) { return normalizeReportUrl(a) === normalizeReportUrl(b); } async function runQueue(items, worker, concurrency, onProgress) { const results = []; let index = 0; let done = 0; async function next() { while (index < items.length) { const currentIndex = index++; const item = items[currentIndex]; try { results[currentIndex] = await worker(item, currentIndex); } catch (err) { results[currentIndex] = { error: err?.message || String(err), item: item }; } done++; if (onProgress) onProgress(done, items.length); } } const workers = []; for (let i = 0; i < concurrency; i++) workers.push(next()); await Promise.all(workers); return results; } function parseRowsFromDoc(doc, pageNumber) { const table = getDataTable(doc); if (!table) { return { rows: [], skipped: [{ page: pageNumber, reason: 'Не найдена таблица с колонкой UTM source' }], dateColumns: [] }; } const sourceColumns = getSourceColumns(table); const paidColumn = getPaidColumn(table); const tagsColumn = getTagsColumn(table); const compositionColumn = getCompositionColumn(table); const clubBalanceColumn = getClubBalanceMetricColumn(table); const firstClubBaseAgeColumn = getFirstClubBaseAgeMetricColumn(table); const userBaseAgeColumn = getUserBaseAgeColumn(table); const dateColumns = getDateColumns(table); if (!sourceColumns.any) { return { rows: [], skipped: [{ page: pageNumber, reason: 'Не найдены колонки источника: gc_system_deal_utm_source или utm_source' }], dateColumns: dateColumns }; } if (!paidColumn) { return { rows: [], skipped: [{ page: pageNumber, reason: 'Не найдена колонка Оплачено' }], dateColumns: dateColumns }; } const rows = []; const skipped = []; Array.from(table.querySelectorAll('tbody tr')).forEach(function (tr, rowIndex) { const sourceInfo = getSourceCellText(tr, sourceColumns); const paidCell = getCellByColumn(tr, paidColumn); const tagsCell = tagsColumn ? getCellByColumn(tr, tagsColumn) : null; const compositionCell = compositionColumn ? getCellByColumn(tr, compositionColumn) : null; const clubBalanceCell = clubBalanceColumn ? getCellByColumn(tr, clubBalanceColumn) : null; const firstClubBaseAgeCell = firstClubBaseAgeColumn ? getCellByColumn(tr, firstClubBaseAgeColumn) : null; const userBaseAgeCell = userBaseAgeColumn ? getCellByColumn(tr, userBaseAgeColumn) : null; const cells = Array.from(tr.querySelectorAll('td')); if (!cells.length) { skipped.push({ page: pageNumber, rowIndex: rowIndex + 1, reason: 'Пустая строка без ячеек' }); return; } const rawSource = sourceInfo.raw; const source = sourceInfo.value; const rawPaid = normalizeVisibleText(paidCell ? paidCell.innerText || paidCell.textContent : ''); const paidAmount = parseMoney(rawPaid); const tags = tagsColumn ? parseTagsFromCell(tagsCell) : [EMPTY_TAG_LABEL]; const segmentTag = getSegmentTag(tags); const rawComposition = normalizeVisibleText(compositionCell ? compositionCell.innerText || compositionCell.textContent : ''); const rawClubBalance = normalizeVisibleText(clubBalanceCell ? clubBalanceCell.innerText || clubBalanceCell.textContent : ''); const rawFirstClubBaseAge = normalizeVisibleText(firstClubBaseAgeCell ? firstClubBaseAgeCell.innerText || firstClubBaseAgeCell.textContent : ''); const userBaseAgeSegment = normalizeVisibleText(userBaseAgeCell ? userBaseAgeCell.innerText || userBaseAgeCell.textContent : '') || '— возраст не указан'; const marathonTariff = getMarathonTariff(rawComposition); const clubBalanceValue = parseMetricInteger(rawClubBalance); const firstClubBaseAgeDays = parseMetricInteger(rawFirstClubBaseAge); const clubAccess = parseClubAccessFromComposition(rawComposition); const dates = {}; dateColumns.forEach(function (col) { const cell = getCellByColumn(tr, col); const rawDate = normalizeVisibleText(cell ? cell.innerText || cell.textContent : ''); dates[col.key] = { raw: rawDate, label: col.label, date: col.kind === 'status' ? parseStatusDate(rawDate) : parseDate(rawDate), kind: col.kind || 'date' }; }); rows.push({ source: source, rawSource: rawSource, sourceColumnLabel: sourceInfo.sourceColumnLabel || '', fallbackSourceRaw: sourceInfo.fallbackRaw || '', group: GROUPING_ENABLED ? getSmartSourceGroup(source) : source, paidAmount: paidAmount, isPaid: paidAmount > 0, rawPaid: rawPaid, tags: tags, segmentTag: segmentTag, rawTags: tags.join(', '), rawComposition: rawComposition, marathonTariff: marathonTariff, marathonTariff: marathonTariff, marathonTariffKey: marathonTariff.key, marathonTariffLabel: marathonTariff.label, userBaseAgeColumnPresent: Boolean(userBaseAgeColumn), userBaseAgeSegment: userBaseAgeSegment, clubBalanceColumnPresent: Boolean(clubBalanceColumn), firstClubBaseAgeColumnPresent: Boolean(firstClubBaseAgeColumn), rawClubBalance: rawClubBalance, clubBalanceValue: clubBalanceValue, clubBalanceCohort: getClubBalanceCohort(clubBalanceValue), rawFirstClubBaseAge: rawFirstClubBaseAge, firstClubBaseAgeDays: firstClubBaseAgeDays, firstClubBaseAgeCohort: getFirstClubBaseAgeCohort(firstClubBaseAgeDays), clubAccess: clubAccess, hasClubAccess: clubAccess.hasClubAccess, clubAccessItems: clubAccess.items, clubAccessMonths: clubAccess.totalMonths, dates: dates, activeDate: null, dayKey: 'no-date', dayLabel: EMPTY_DAY_LABEL, page: pageNumber, rowIndex: rowIndex + 1, recordId: extractDealIdFromRow(tr), userId: extractUserIdFromDealRow(tr), rowHTML: tr.outerHTML }); }); return { rows: rows, skipped: skipped, dateColumns: dateColumns }; } function normalizeSourceValue(value) { const text = normalizeVisibleText(value); const lower = text.toLowerCase(); if (!text || text === '-' || text === '—' || lower === 'null' || lower === 'undefined') { return EMPTY_SOURCE_LABEL; } return text; } function parseTagsFromCell(cell) { if (!cell) return [EMPTY_TAG_LABEL]; const tags = []; const labelNodes = Array.from(cell.querySelectorAll('.label, [class*="label-"]')); labelNodes.forEach(function (node) { const text = normalizeTagValue(node.innerText || node.textContent || ''); if (text && text !== EMPTY_TAG_LABEL && tags.indexOf(text) === -1) tags.push(text); }); if (!tags.length) { const rawText = String(cell.innerText || cell.textContent || '') .replace(/\u00a0/g, ' ') .split(/[\n\r]+/) .map(normalizeTagValue) .filter(Boolean); rawText.forEach(function (text) { if (text && text !== EMPTY_TAG_LABEL && tags.indexOf(text) === -1) tags.push(text); }); } return tags.length ? tags : [EMPTY_TAG_LABEL]; } function normalizeTagValue(value) { const text = normalizeVisibleText(value); const lower = text.toLowerCase(); if (!text || text === '-' || text === '—' || lower === 'null' || lower === 'undefined') { return EMPTY_TAG_LABEL; } return text; } function getSegmentTag(tags) { const cleanTags = (tags || []).filter(function (tag) { return tag && tag !== EMPTY_TAG_LABEL; }); if (!cleanTags.length) return EMPTY_TAG_LABEL; const normalized = cleanTags.map(function (tag) { return { original: tag, norm: normalizeTagForRule(tag) }; }); if (normalized.some(function (item) { return /^new\b/i.test(item.norm) || /^new/i.test(item.original) || /\bнович/.test(item.norm); })) return 'Новички'; if (normalized.some(function (item) { return /\b(бывш|экс|ушедш|отвал)/.test(item.norm); })) return 'Бывшие клуб'; if (normalized.some(function (item) { return /\b(актив|действующ|участник\s+клуб|клубник|в\s+клубе)/.test(item.norm); })) return 'Активный клуб'; if (normalized.some(function (item) { return isMarathonTagItem(item); })) return 'Марафонец'; if (normalized.some(function (item) { return /^база$|\bбаза\b/.test(item.norm); })) return 'База'; return cleanTags[0]; } function isMarathonTagItem(item) { const norm = item && item.norm ? item.norm : normalizeTagForRule(item && item.original ? item.original : item); const original = String(item && item.original ? item.original : item || '').toLowerCase(); return norm.indexOf('марафон') !== -1 || original.indexOf('марафон') !== -1 || norm.indexOf('marathon') !== -1 || original.indexOf('marathon') !== -1; } function getNewbieComponent(rowOrTags) { const tags = Array.isArray(rowOrTags) ? rowOrTags : ((rowOrTags && rowOrTags.tags) || []); const normalized = (tags || []) .filter(function (tag) { return tag && tag !== EMPTY_TAG_LABEL; }) .map(function (tag) { return { original: tag, norm: normalizeTagForRule(tag) }; }); if (!normalized.length) return ''; if (normalized.some(function (item) { return /^new\b/i.test(item.norm) || /^new/i.test(item.original) || /\bнович/.test(item.norm); })) return 'New'; if (normalized.some(function (item) { return /^база$|\bбаза\b/.test(item.norm); })) return 'База'; if (normalized.some(function (item) { return isMarathonTagItem(item); })) return 'Марафонец'; return ''; } function isNewbieRow(row) { return Boolean(getNewbieComponent(row)); } function getFirstMoneyComponent(rowOrTags) { const tags = Array.isArray(rowOrTags) ? rowOrTags : ((rowOrTags && rowOrTags.tags) || []); const normalized = (tags || []) .filter(function (tag) { return tag && tag !== EMPTY_TAG_LABEL; }) .map(function (tag) { return { original: tag, norm: normalizeTagForRule(tag) }; }); if (!normalized.length) return ''; if (normalized.some(function (item) { return /^new\b/i.test(item.norm) || /^new/i.test(item.original) || /\bнович/.test(item.norm); })) return 'New'; if (normalized.some(function (item) { return /^база$|\bбаза\b/.test(item.norm); })) return 'База'; return ''; } function isFirstMoneyRow(row) { return Boolean(getFirstMoneyComponent(row)); } function splitCompositionProducts(rawText) { const text = normalizeVisibleText(rawText || ''); if (!text) return []; return text .replace(/\u00a0/g, ' ') .split(/\s+\+\s+|[\n\r;]+/) .map(function (part) { return normalizeVisibleText(part); }) .filter(Boolean); } function isClubAccessProductTitle(title) { const norm = normalizeTagForRule(title); if (!norm) return false; if (norm.indexOf('марафон') !== -1 || norm.indexOf('интенсив') !== -1 || norm.indexOf('чек лист') !== -1 || norm.indexOf('чеклист') !== -1) { if (norm.indexOf('йога клуб') === -1 && norm.indexOf('йога клуба') === -1 && norm.indexOf('онлайн йога клуб') === -1) return false; } const hasClub = norm.indexOf('йога клуб') !== -1 || norm.indexOf('йога клуба') !== -1 || norm.indexOf('онлайн йога клуб') !== -1 || norm.indexOf('online yoga club') !== -1; const hasAccess = norm.indexOf('доступ') !== -1 || norm.indexOf('трениров') !== -1 || norm.indexOf('клуб') !== -1 || norm.indexOf('абонемент') !== -1 || norm.indexOf('подписк') !== -1; return hasClub && hasAccess; } function extractClubAccessMonths(title) { const norm = normalizeTagForRule(title); if (!norm) return 0; if (norm.indexOf('полгода') !== -1 || norm.indexOf('пол года') !== -1) return 6; if (norm.indexOf('12 мес') !== -1 || norm.indexOf('12 месяц') !== -1 || norm.indexOf('1 год') !== -1 || norm === 'год' || norm.indexOf(' год') !== -1) return 12; // Важно: не используем \\b после русских слов. // В JavaScript \\b плохо подходит для кириллицы, из-за этого «1 месяц» мог уходить в «срок не распознан». let match = norm.match(/(?:на\s*)?(\d{1,2})\s*(?:месяц(?:а|ев)?|мес)(?:\s|$)/); if (match && match[1]) return Number(match[1]) || 0; match = norm.match(/(?:на\s*)?(\d{1,3})\s*(?:день|дня|дней)(?:\s|$)/); if (match && match[1]) { const days = Number(match[1]) || 0; const known = [ { days: 31, months: 1 }, { days: 62, months: 2 }, { days: 92, months: 3 }, { days: 183, months: 6 }, { days: 365, months: 12 } ]; for (let i = 0; i < known.length; i++) { if (Math.abs(days - known[i].days) <= 2) return known[i].months; } return days ? Math.max(1, Math.round(days / 31)) : 0; } return 0; } function getClubAccessDurationLabel(months, title) { const num = Number(months) || 0; if (num > 0) return CLUB_ACCESS_LABEL + ' — ' + num + ' ' + pluralMonth(num); return CLUB_ACCESS_LABEL + ' — срок не распознан'; } function getClubAccessGroupKey(months) { const num = Number(months) || 0; return num > 0 ? 'club-months:' + num : 'club-months:unknown'; } function pluralMonth(num) { const n = Math.abs(Number(num) || 0); const mod10 = n % 10; const mod100 = n % 100; if (mod10 === 1 && mod100 !== 11) return 'месяц'; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'месяца'; return 'месяцев'; } function parseClubAccessFromComposition(rawText) { const raw = normalizeVisibleText(rawText || ''); const parts = splitCompositionProducts(raw); const items = []; parts.forEach(function (part) { if (!isClubAccessProductTitle(part)) return; const months = extractClubAccessMonths(part); items.push({ title: part, months: months, label: getClubAccessDurationLabel(months, part) }); }); return { raw: raw, parts: parts, items: items, hasClubAccess: items.length > 0, totalMonths: items.reduce(function (sum, item) { return sum + (Number(item.months) || 0); }, 0) }; } function detectClubBundleInMarathonTitle(rawText) { const text = normalizeVisibleText(rawText || ''); const norm = normalizeTagForRule(text); const hasMarathon = /(?:^|\s)(марафон|интенсив)(?:\s|$)/.test(norm); const hasClub = /(клуб|клуба|клубу|занятиям клуба|занятия клуба|онлайн йога клуб)/.test(norm); const hasAccess = /(доступ|абонемент|подписк)/.test(norm); if (!hasMarathon || !hasClub || !hasAccess) { return { includesClub: false, clubDays: 0, clubMonths: 0 }; } let clubDays = 0; const clubTail = norm.match(/(?:доступ\s+к\s+занятиям\s+клуба|доступ\s+в\s+клуб|клуб(?:а|у)?)\s*(?:на\s*)?(\d{1,3})\s*(?:день|дня|дней|дн)/i); if (clubTail && clubTail[1]) clubDays = Number(clubTail[1]) || 0; if (!clubDays) { const afterPlus = norm.match(/\+[^+]*(?:клуб|занятиям клуба)[^0-9]{0,30}(\d{1,3})\s*(?:день|дня|дней|дн)/i); if (afterPlus && afterPlus[1]) clubDays = Number(afterPlus[1]) || 0; } const clubMonths = clubDays ? Math.max(1, Math.round(clubDays / 31)) : 1; return { includesClub: true, clubDays: clubDays, clubMonths: clubMonths }; } function getMarathonTariff(rawComposition) { const text = normalizeVisibleText(rawComposition || ''); const norm = normalizeTagForRule(text); if (!text) { return { key: 'empty-product', label: EMPTY_COMPOSITION_LABEL, shortLabel: 'Без состава', price: 0, offerId: '', days: 0, includesClub: false, clubDays: 0, clubMonths: 0 }; } const bundleInfo = detectClubBundleInMarathonTitle(text); const isAntiHump = norm.indexOf('антихолка') !== -1 || norm.indexOf('анти холка') !== -1; if (isAntiHump) { const dayMatches = []; const re = /(^|[^0-9])(\d{1,3})\s*(?:день|дня|дней|дн)(?=$|[^а-яё0-9])/gi; let match; while ((match = re.exec(norm))) dayMatches.push(Number(match[2]) || 0); if (bundleInfo.includesClub && dayMatches.indexOf(31) !== -1) { return Object.assign({}, MARATHON_TARIFFS[0], bundleInfo); } for (let i = 1; i < MARATHON_TARIFFS.length; i++) { const tariff = MARATHON_TARIFFS[i]; if (dayMatches.indexOf(tariff.days) !== -1) return tariff; } if (/(^|[^0-9])31(?=$|[^0-9])/.test(norm) && bundleInfo.includesClub) return Object.assign({}, MARATHON_TARIFFS[0], bundleInfo); if (/(^|[^0-9])15(?=$|[^0-9])/.test(norm)) return MARATHON_TARIFFS[1]; if (/(^|[^0-9])5(?=$|[^0-9])/.test(norm)) return MARATHON_TARIFFS[2]; return { key: 'anti-hump-unknown', label: 'Антихолка — тариф не распознан', shortLabel: 'Не распознан', price: 0, offerId: '', days: 0, includesClub: bundleInfo.includesClub, clubDays: bundleInfo.clubDays, clubMonths: bundleInfo.clubMonths }; } const compact = text.length > 64 ? text.slice(0, 61) + '…' : text; return { key: 'other-product:' + normalizeTagForRule(compact), label: compact, shortLabel: compact, price: 0, offerId: '', days: 0, includesClub: bundleInfo.includesClub, clubDays: bundleInfo.clubDays, clubMonths: bundleInfo.clubMonths }; } function buildDimensionStats(rows, keyGetter, labelGetter) { const map = {}; (rows || []).forEach(function (row) { const key = String(keyGetter(row) || '—'); const label = String(labelGetter(row) || key); if (!map[key]) map[key] = makeEmptyStats({ key: key, label: label }); addWithBreakdowns(map[key], row); }); return Object.keys(map).map(function (key) { return finalizeStats(map[key]); }).sort(sortStats); } function buildTariffStats(rows) { return buildDimensionStats(rows, function (r) { return r.marathonTariffKey; }, function (r) { return r.marathonTariffLabel; }); } function buildBaseAgeStats(rows) { return buildDimensionStats(rows, function (r) { return r.userBaseAgeSegment; }, function (r) { return r.userBaseAgeSegment; }); } function renderDimensionMiniTable(items, totals, title, labelTitle, scope) { if (!items || !items.length) return '
    Нет данных.
    '; const isDayScope = scope === 'day'; const titleNorm = normalizeVisibleText(title).toLowerCase(); // Цветные action-chip показываем только в аналитических мини-таблицах, // где пользователь ожидает детализацию: тарифы, возраст, а внутри дня — ещё источники и теги. // Дни, обзорные TOP-блоки и раскрывающиеся строки источников остаются обычными. const showActionChips = /тариф|возраст/.test(titleNorm) || (isDayScope && /источник|тег/.test(titleNorm)); const paidShareTitle = isDayScope ? '% оплат дня' : '% оплат выборки'; const moneyShareTitle = isDayScope ? '% денег дня' : '% денег выборки'; return '
    ' + escapeHtml(title) + '
    ' + items.map(function (item) { const fid = registerFilterRows(item.rows, title + ': ' + item.label); const paidRows = (item.rows || []).filter(function (row) { return row && row.isPaid; }); const allListId = registerFilterRows(item.rows, title + ': ' + item.label + ' — все заказы'); const paidListId = registerFilterRows(paidRows, title + ': ' + item.label + ' — оплаченные заказы'); const analysisChip = showActionChips ? ' data-utm-action-chip="analysis"' : ''; const listChip = showActionChips ? ' data-utm-action-chip="list"' : ''; return ''; }).join('') + '
    ' + escapeHtml(labelTitle) + 'ЗаказовОплат' + paidShareTitle + 'Оплачено' + moneyShareTitle + '
    ' + formatPercentValue(item.paidCount, totals.totalPaidOrders) + '' + formatMoney(item.paidAmount) + '' + formatPercentValue(item.paidAmount, totals.totalPaidAmount) + '
    '; } function parseMoney(value) { let text = String(value || '') .replace(/\u00a0/g, ' ') .replace(/руб\.?|р\.?|₽/gi, '') .replace(/[^\d,\.\-\s]/g, '') .trim(); if (!text || text === '-' || text === '—') return 0; text = text.replace(/\s+/g, ''); const hasComma = text.indexOf(',') !== -1; const hasDot = text.indexOf('.') !== -1; if (hasComma && hasDot) { const lastComma = text.lastIndexOf(','); const lastDot = text.lastIndexOf('.'); if (lastComma > lastDot) { text = text.replace(/\./g, '').replace(',', '.'); } else { text = text.replace(/,/g, ''); } } else if (hasComma && !hasDot) { text = text.replace(',', '.'); } const num = Number(text); return Number.isFinite(num) ? num : 0; } function parseMetricInteger(value) { const raw = String(value ?? '') .replace(/\u00a0/g, ' ') .replace(/[^\d\-+]/g, '') .trim(); if (!raw || raw === '-' || raw === '+' || raw === '—') return null; const num = Number(raw); return Number.isFinite(num) ? Math.round(num) : null; } function getClubBalanceCohort(value) { if (value === null || value === undefined || !Number.isFinite(Number(value))) { return { key: 'unknown', label: 'Не распознано', min: null, max: null, note: 'В поле нет числового значения.' }; } const num = Number(value); for (let i = 0; i < CLUB_BALANCE_COHORT_DEFS.length; i++) { const def = CLUB_BALANCE_COHORT_DEFS[i]; if (num >= def.min && num <= def.max) return def; } return { key: 'unknown', label: 'Не распознано', min: null, max: null, note: 'Значение не попало ни в одну когорту.' }; } function getFirstClubBaseAgeCohort(value) { if (value === null || value === undefined || !Number.isFinite(Number(value))) { return { key: 'base-age-unknown', label: 'Не распознано', min: null, max: null, note: 'В поле нет числового значения.' }; } const num = Number(value); if (num < 0) { return { key: 'base-age-negative', label: 'Ошибка даты: меньше 0 дней', min: -Infinity, max: -1, note: 'Дата первой покупки получилась раньше даты регистрации пользователя.' }; } for (let i = 0; i < FIRST_CLUB_BASE_AGE_COHORT_DEFS.length; i++) { const def = FIRST_CLUB_BASE_AGE_COHORT_DEFS[i]; if (num >= def.min && num <= def.max) return def; } return { key: 'base-age-unknown', label: 'Не распознано', min: null, max: null, note: 'Значение не попало ни в одну когорту.' }; } function getFriendlyErrorMessage(err, context) { const raw = err && err.message ? err.message : String(err || 'Неизвестная ошибка'); const stack = err && err.stack ? String(err.stack) : ''; const text = (raw + ' ' + stack).toLowerCase(); if (text.indexOf('getclubbalancecohort') !== -1 || text.indexOf('getclubbalance cohorts') !== -1) { return 'Не найдена функция распределения Club balance по когортам. Это была ошибка сборки аналитического скрипта v36. Поставьте v37 и удалите из расширения старые версии v34/v35/v36, чтобы они не запускались параллельно.'; } if (text.indexOf('getfirstclubbaseagecohort') !== -1) { return 'Не найдена функция распределения первых клубных покупок по сроку нахождения в базе. Поставьте v37 и удалите старую версию скрипта из расширения.'; } if (text.indexOf('clubbalancecolumn') !== -1) { return 'Ошибка поиска колонки Club balance. Обновите аналитический скрипт до v37. Для анализа также проверьте, что в таблицу заказов добавлены колонки Club balance before order days и Days in base before first club order.'; } if (text.indexOf('parsemetricinteger') !== -1) { return 'Ошибка парсинга числового значения метрики. Обновите аналитический скрипт до v37 и убедитесь, что поле Club balance — числовое.'; } if (text.indexOf('highcharts') !== -1) { return 'Не удалось построить диаграммы Highcharts. Данные могли собраться, но графики не отрисовались. Попробуйте обновить страницу или временно отключить блокировщики скриптов.'; } if (text.indexOf('failed to fetch') !== -1 || text.indexOf('networkerror') !== -1) { return 'Не удалось загрузить одну из страниц GetCourse. Проверьте интернет/авторизацию и попробуйте запустить отчёт ещё раз.'; } return (context ? context + ': ' : '') + raw; } function showFriendlyError(context, err) { const friendly = getFriendlyErrorMessage(err, context); console.error('[GC UTM MONEY TAGS DAYS]', context || 'Ошибка', err); setStatus('Ошибка: ' + escapeHtml(friendly)); setDebug( 'Диагностика ошибки
    ' + escapeHtml(friendly) + '

    ' + 'Техническая ошибка:
    ' + '' + escapeHtml(err && err.stack ? err.stack : (err && err.message ? err.message : String(err || ''))) + '' ); } function decorateInteractiveAnalytics(root) { const scope = root || document.getElementById(WRAP_ID); if (!scope) return; function removeNativeTitles(el) { if (!el) return; el.removeAttribute('title'); Array.from(el.querySelectorAll('[title]')).forEach(function (node) { node.removeAttribute('title'); }); } function wrapAsChip(el, chipClass) { if (!el || el.querySelector(':scope > .utm-action-chip')) return; const chip = document.createElement('span'); chip.className = 'utm-action-chip ' + chipClass; while (el.firstChild) chip.appendChild(el.firstChild); el.appendChild(chip); } Array.from(scope.querySelectorAll('[data-utm-action-chip="analysis"]')).forEach(function (el) { el.classList.add('utm-interactive-analytics'); el.setAttribute('tabindex', '0'); el.setAttribute('aria-label', 'Открыть расширенную аналитику выбранной группы'); removeNativeTitles(el); wrapAsChip(el, 'utm-action-chip-analysis'); }); Array.from(scope.querySelectorAll('[data-utm-action-chip="list"]')).forEach(function (el) { el.classList.add('utm-list-link'); el.setAttribute('tabindex', '0'); el.setAttribute('aria-label', 'Показать список заказов выбранной группы'); removeNativeTitles(el); let isPaid = false; if (el.tagName === 'TD' && el.parentElement && el.parentElement.parentElement) { const table = el.closest('table'); const headCells = table ? Array.from(table.querySelectorAll('thead th')) : []; const index = Array.from(el.parentElement.children).indexOf(el); const headText = headCells[index] ? normalizeVisibleText(headCells[index].textContent).toLowerCase() : ''; isPaid = /оплат/.test(headText); } wrapAsChip(el, isPaid ? 'utm-action-chip-paid' : 'utm-action-chip-orders'); }); } function showClickTooltip() {} function hideClickTooltip() {} function safeRenderResult(sourceInfo) { try { renderResult(sourceInfo); return true; } catch (err) { showFriendlyError('Ошибка отрисовки аналитики', err); return false; } } function parseStatusDate(value) { if (!value) return null; const text = normalizeVisibleText(value) .replace(/,/g, ' ') .replace(/\s+/g, ' ') .trim(); if (!text) return null; const prioritizedParts = []; const paidMatch = text.match(/(?:оплачено|оплачен[ао]?|оплата|paid)\s*[::]?\s*(.+)$/i); const completedMatch = text.match(/(?:заверш[её]н(?:а|о)?|completed|finished)\s*[::]?\s*(.+)$/i); if (paidMatch && paidMatch[1]) prioritizedParts.push(paidMatch[1]); if (completedMatch && completedMatch[1]) prioritizedParts.push(completedMatch[1]); prioritizedParts.push(text); for (let i = 0; i < prioritizedParts.length; i++) { const parsed = parseDateFromFreeText(prioritizedParts[i]); if (parsed) return parsed; } return null; } function parseDateFromFreeText(value) { if (!value) return null; const text = normalizeVisibleText(value) .toLowerCase() .replace(/,/g, ' ') .replace(/\s+/g, ' ') .trim(); const patterns = [ /(сегодня(?:\s+\d{1,2}:\d{2})?)/i, /(вчера(?:\s+\d{1,2}:\d{2})?)/i, /(\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}(?:[t\s]+\d{1,2}:\d{2})?)/i, /(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{2,4}(?:\s+\d{1,2}:\d{2})?)/i, /(\d{1,2}\s+[а-яё.]+\s+\d{4}(?:\s+\d{1,2}:\d{2})?)/i, /(\d{1,2}\s+[а-яё.]+(?:\s+\d{1,2}:\d{2})?)/i, /(\d{1,2}[.\-/]\d{1,2}(?:\s+\d{1,2}:\d{2})?)/i ]; for (let i = 0; i < patterns.length; i++) { const m = text.match(patterns[i]); if (!m || !m[1]) continue; const parsed = parseDate(m[1]); if (parsed) return parsed; } return parseDate(text); } function parseDate(value) { if (!value) return null; const text = normalizeVisibleText(value) .toLowerCase() .replace(/,/g, ' ') .replace(/\s+/g, ' ') .trim(); const now = new Date(); let m; // GetCourse часто показывает самые свежие оплаты как «15 минут назад», // «час назад», «2 часа назад», «только что». Переводим это в реальное // время относительно момента запуска отчета. Если вычитание уходит за полночь, // дата автоматически попадет во вчерашний день. const relativeDate = parseRelativeDate(text, now); if (relativeDate) return relativeDate; // GetCourse также показывает свежие даты как «сегодня 14:25» и «вчера 23:49». // Переводим их в реальную дату относительно дня, когда открыт отчет. m = text.match(/^сегодня(?:\s+(\d{1,2}):(\d{2}))?/i); if (m) return new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(m[1] || 0), Number(m[2] || 0)); m = text.match(/^вчера(?:\s+(\d{1,2}):(\d{2}))?/i); if (m) return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, Number(m[1] || 0), Number(m[2] || 0)); // 2026-06-24 15:37 / 2026-06-24T15:37 // Важно проверять ISO-формат раньше dd.mm.yy, иначе 2026-06-24 можно ошибочно прочитать как 26.06.2024. m = text.match(/(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})(?:[t\s]+(\d{1,2}):(\d{2}))?/i); if (m) return makeDate(m[1], Number(m[2]) - 1, m[3], m[4], m[5]); // 24.06.2026 15:37 / 24-06-2026 15:37 m = text.match(/(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{2,4})(?:\s+(\d{1,2}):(\d{2}))?/); if (m) return makeDate(m[3], Number(m[2]) - 1, m[1], m[4], m[5]); // 24.06 15:37 — если год не показан, считаем текущий год. m = text.match(/(\d{1,2})[.\-/](\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?/); if (m) return makeDate(now.getFullYear(), Number(m[2]) - 1, m[1], m[3], m[4]); // 24 июн. 2026 15:37 m = text.match(/(\d{1,2})\s+([а-яё.]+)\s+(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/i); if (m) { const month = getRuMonth(m[2]); if (month !== null) return makeDate(m[3], month, m[1], m[4], m[5]); } // 24 июн. 15:37 — если год не показан, считаем текущий год. m = text.match(/(\d{1,2})\s+([а-яё.]+)(?:\s+(\d{1,2}):(\d{2}))?/i); if (m) { const month = getRuMonth(m[2]); if (month !== null) return makeDate(now.getFullYear(), month, m[1], m[3], m[4]); } return null; } function parseRelativeDate(text, baseDate) { const value = normalizeVisibleText(text) .toLowerCase() .replace(/,/g, ' ') .replace(/ё/g, 'е') .replace(/\s+/g, ' ') .trim(); if (!value) return null; const now = baseDate ? new Date(baseDate.getTime()) : new Date(); if (/^(только что|сейчас|только\s+что)$/i.test(value) || /\b(только что|сейчас)\b/i.test(value)) { return now; } if (/\bполчаса\s+назад\b/i.test(value)) { return new Date(now.getTime() - 30 * 60000); } let m; m = value.match(/(\d+)\s*(?:сек(?:унда|унды|унд)?|сек\.?|s|sec|second(?:s)?)\s+назад/i); if (m) return new Date(now.getTime() - Number(m[1]) * 1000); m = value.match(/(\d+)\s*(?:мин(?:ута|уты|ут|уту)?|мин\.?|м\.?|m|min|minute(?:s)?)\s+назад/i); if (m) return new Date(now.getTime() - Number(m[1]) * 60000); if (/\bминуту\s+назад\b/i.test(value)) { return new Date(now.getTime() - 60000); } m = value.match(/(\d+)\s*(?:час(?:а|ов)?|ч\.?|h|hour(?:s)?)\s+назад/i); if (m) return new Date(now.getTime() - Number(m[1]) * 3600000); if (/\bчас\s+назад\b/i.test(value)) { return new Date(now.getTime() - 3600000); } m = value.match(/(\d+)\s*(?:д(?:ень|ня|ней)?|дн\.?|day(?:s)?)\s+назад/i); if (m) return new Date(now.getFullYear(), now.getMonth(), now.getDate() - Number(m[1]), now.getHours(), now.getMinutes(), now.getSeconds()); return null; } function makeDate(year, month, day, hour, minute) { year = Number(year); if (year < 100) year += 2000; return new Date(year, Number(month), Number(day), Number(hour || 0), Number(minute || 0)); } function getRuMonth(monthText) { const key = String(monthText || '').toLowerCase().replace('.', '').trim(); const months = { 'янв': 0, 'январь': 0, 'января': 0, 'фев': 1, 'февр': 1, 'февраль': 1, 'февраля': 1, 'мар': 2, 'март': 2, 'марта': 2, 'апр': 3, 'апрель': 3, 'апреля': 3, 'май': 4, 'мая': 4, 'июн': 5, 'июнь': 5, 'июня': 5, 'июл': 6, 'июль': 6, 'июля': 6, 'авг': 7, 'август': 7, 'августа': 7, 'сен': 8, 'сент': 8, 'сентябрь': 8, 'сентября': 8, 'окт': 9, 'октябрь': 9, 'октября': 9, 'ноя': 10, 'нояб': 10, 'ноябрь': 10, 'ноября': 10, 'дек': 11, 'декабрь': 11, 'декабря': 11 }; return months.hasOwnProperty(key) ? months[key] : null; } function getLessonIdFromSource(source) { const raw = String(source || ''); if (!raw) return ''; const lower = raw.toLowerCase(); if (lower.indexOf('урок') === -1 && lower.indexOf('lesson') === -1) return ''; let match = raw.match(/(?:урок|lesson)[^\d]{0,40}(\d{5,})/i); if (match && match[1]) return match[1]; match = raw.match(/(?:id|ид)[^\d]{0,20}(\d{5,})/i); if (match && match[1]) return match[1]; match = raw.match(/(\d{5,})/); return match && match[1] ? match[1] : ''; } function isLessonSource(source) { return Boolean(getLessonIdFromSource(source)); } function getLessonUrl(lessonId) { return '/pl/teach/control/lesson/view?id=' + encodeURIComponent(lessonId); } function getNormalizedSourceKey(source) { const lessonId = getLessonIdFromSource(source); if (lessonId) return 'Урок ' + lessonId; return source || EMPTY_SOURCE_LABEL; } function loadLessonTitleCache() { if (LESSON_TITLE_CACHE) return LESSON_TITLE_CACHE; try { LESSON_TITLE_CACHE = JSON.parse(localStorage.getItem(LESSON_TITLE_CACHE_KEY) || '{}') || {}; } catch (e) { LESSON_TITLE_CACHE = {}; } return LESSON_TITLE_CACHE; } function saveLessonTitleCache() { try { localStorage.setItem(LESSON_TITLE_CACHE_KEY, JSON.stringify(LESSON_TITLE_CACHE || {})); } catch (e) {} } function getCachedLessonTitle(lessonId) { const cache = loadLessonTitleCache(); return cache && cache[lessonId] ? cache[lessonId] : ''; } function cleanLessonTitleText(text, lessonId) { let title = normalizeVisibleText(text || ''); if (!title) return ''; title = title .replace(/\s*-\s*GetCourse\s*$/i, '') .replace(/\s*\/\s*GetCourse\s*$/i, '') .replace(new RegExp('^\\s*(урок|lesson)\\s*#?\\s*' + lessonId + '\\s*[:—–-]?\\s*', 'i'), '') .replace(new RegExp('\\s*#?\\s*' + lessonId + '\\s*$', 'i'), '') .replace(/\s+/g, ' ') .trim(); if (!title) return ''; if (title.length > 180) return ''; // Отсекаем служебные элементы GetCourse, которые тоже могут содержать lesson id. if (/^(просмотр|редактировать|задание|задания|настройки|комментарии|ученики|статистика|новые ответы|ответы|показать в ленте|перенести урок|предыдущий урок|следующий урок|отправить)$/i.test(title)) return ''; if (/^(добавить|сохранить|удалить|отмена|назад|вперёд|вперед)$/i.test(title)) return ''; return title; } function getTextFromElementWithoutChildren(el) { if (!el) return ''; const clone = el.cloneNode(true); Array.from(clone.querySelectorAll('script,style,noscript,svg,.breadcrumbs,.breadcrumb,.btn,.buttons,.dropdown,.menu,.nav,.lesson-navigation,.lesson-list,.lesson-list li,.answers,.comments,.mission-form,.user-answer')).forEach(function (node) { if (node && node.parentNode) node.parentNode.removeChild(node); }); return normalizeVisibleText(clone.innerText || clone.textContent || ''); } function extractLessonTitleFromDoc(doc, lessonId) { if (!doc || !lessonId) return ''; const id = String(lessonId); // 1. Главный источник для страницы просмотра урока GetCourse. // В HAR реальное название лежит здесь: //

    День 1. Зарядка для женского здоровья

    const primary = doc.querySelector('.lesson-title-value'); const primaryTitle = cleanLessonTitleText(getTextFromElementWithoutChildren(primary), id); if (primaryTitle) return primaryTitle; // 2. Другие специфичные элементы именно урока. // Общие h1/page-title не используем: они могут быть названием тренинга. const lessonTitleSelectors = [ '.lesson-title .title', '.lesson-header .lesson-title', '.lesson-header h2', '.lesson-name', '.lesson-name-block', '.lesson-title', '[class*="lesson-title"]', '[class*="lesson_name"]', '[class*="lesson-name"]' ]; for (let j = 0; j < lessonTitleSelectors.length; j++) { const node = doc.querySelector(lessonTitleSelectors[j]); const title = cleanLessonTitleText(getTextFromElementWithoutChildren(node), id); if (title) return title; } // 3. Только после этого пробуем элементы, где явно есть нужный lesson id. // Они менее надёжные, потому что туда попадают служебные ссылки «Задание», «Настройки», «новые ответы». const exactSelectors = [ '[data-lesson-id="' + id + '"]', '[data-lesson_id="' + id + '"]', '[data-id="' + id + '"]', 'a[href*="lesson/view?id=' + id + '"]', 'a[href*="id=' + id + '"]', 'a[href*="lessonId=' + id + '"]', 'a[href*="lesson_id=' + id + '"]' ]; for (let i = 0; i < exactSelectors.length; i++) { const nodes = Array.from(doc.querySelectorAll(exactSelectors[i])); for (let k = 0; k < nodes.length; k++) { const text = cleanLessonTitleText(getTextFromElementWithoutChildren(nodes[k]), id); if (text) return text; } } // 4. Осторожный fallback по . // В нормальной странице урока title часто содержит именно название урока. // Но если он похож на хлебную крошку «Тренинг / Урок», берём последнюю неслужебную часть. const pageTitleRaw = normalizeVisibleText(doc.title || ''); const pageTitle = cleanLessonTitleText(pageTitleRaw, id); if (pageTitle) { const parts = pageTitle .split(/\s[-—–|/]\s/) .map(function (part) { return cleanLessonTitleText(part, id); }) .filter(Boolean); if (parts.length) { // Обычно название урока в title либо единственное, либо первая часть. return parts[0]; } return pageTitle; } return ''; } async function fetchLessonTitle(lessonId) { if (!lessonId) return ''; const cached = getCachedLessonTitle(lessonId); if (cached) return cached; if (LESSON_TITLE_PROMISES[lessonId]) return LESSON_TITLE_PROMISES[lessonId]; LESSON_TITLE_PROMISES[lessonId] = fetch(getLessonUrl(lessonId), { credentials: 'same-origin' }) .then(function (res) { return res.ok ? res.text() : ''; }) .then(function (html) { if (!html) return ''; const doc = new DOMParser().parseFromString(html, 'text/html'); const title = extractLessonTitleFromDoc(doc, lessonId); if (title) { const cache = loadLessonTitleCache(); cache[lessonId] = title; saveLessonTitleCache(); } return title; }) .catch(function () { return ''; }); return LESSON_TITLE_PROMISES[lessonId]; } function renderSourceLabel(value) { const label = value || EMPTY_SOURCE_LABEL; const lessonId = getLessonIdFromSource(label); if (!lessonId) return escapeHtml(label); const cachedTitle = getCachedLessonTitle(lessonId); const title = cachedTitle ? ('Урок ' + lessonId + ': ' + cachedTitle) : ('Урок ' + lessonId); const url = getLessonUrl(lessonId); return '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener" data-utm-lesson-link data-utm-lesson-id="' + escapeHtml(lessonId) + '" style="font-weight:700;">' + escapeHtml(title) + '</a>'; } function enhanceLessonTitleLinks(root) { const scope = root || document; const links = Array.from(scope.querySelectorAll('[data-utm-lesson-id]')); const ids = []; links.forEach(function (link) { const id = link.getAttribute('data-utm-lesson-id'); if (id && ids.indexOf(id) === -1) ids.push(id); }); ids.slice(0, 30).forEach(function (lessonId) { const cached = getCachedLessonTitle(lessonId); if (cached) { Array.from(scope.querySelectorAll('[data-utm-lesson-id="' + lessonId + '"]')).forEach(function (link) { link.textContent = 'Урок ' + lessonId + ': ' + cached; }); return; } fetchLessonTitle(lessonId).then(function (title) { if (!title) return; Array.from(scope.querySelectorAll('[data-utm-lesson-id="' + lessonId + '"]')).forEach(function (link) { link.textContent = 'Урок ' + lessonId + ': ' + title; }); }); }); } function getSmartSourceGroup(source) { if (!source || source === EMPTY_SOURCE_LABEL) return EMPTY_SOURCE_LABEL; const s = normalizeSourceForRule(source); if (!s || s === '-' || s === '—') return EMPTY_SOURCE_LABEL; if (isLessonSource(source)) return 'Урок'; if (/\b(webinar|webinar\s*\d+|webinar\d+|вебинар|вебинар\s*\d+|вебинар\d+)\b/i.test(s)) return 'Вебинар'; if (/\b(mail\s*tg|ml\s*tg|mltg|email\s*tg|e mail\s*tg)\b/i.test(s)) return 'MailTG'; if (/\b(mail\s*vk|ml\s*vk|mlvk|email\s*vk|e mail\s*vk)\b/i.test(s)) return 'MailVK'; if (/\b(куратор|curator|manager|менеджер)\b/i.test(s)) return 'Куратор'; if (/\b(tg|telegram|telegr|телеграм|тг)\b/i.test(s)) return 'ТГ'; if (/\b(vk|vkontakte|вк|контакт)\b/i.test(s)) return 'VK'; if (/\b(mail|email|e mail|eml|мейл|письмо|рассылка)\b/i.test(s)) return 'Mail'; if (/\b(ig|inst|instagram|инст|инстаграм)\b/i.test(s)) return 'Instagram'; if (/\b(yt|youtube|ютуб)\b/i.test(s)) return 'YouTube'; if (/\b(yandex|яндекс|rsya|ya)\b/i.test(s) || (/\b(директ|direct)\b/i.test(s) && /\b(yandex|яндекс|ya)\b/i.test(s))) return 'Яндекс'; if (/\b(google|gads|ads|adwords)\b/i.test(s)) return 'Google'; if (/\bref\s*yogabot\b|\brefyogabot\b|^ref$/i.test(s)) return 'refYogaBot'; if (/\b(referral|partner|партнер|партнерка)\b/i.test(s)) return 'Рефералы'; if (/\b(bot|бот)\b/i.test(s)) return 'Бот'; if (/\b(site|web|organic|direct|прямой|сайт)\b/i.test(s)) return 'Сайт / Direct'; return OTHER_GROUP_LABEL; } async function collectRowsFromAllPages() { const total = getTotal(document); const pageSize = getPageSize(document); const pages = Math.max(1, Math.ceil((total || pageSize) / pageSize)); debugLog('total:', total); debugLog('pageSize:', pageSize); debugLog('pages:', pages); let allRows = []; let allSkipped = []; let dateColumns = []; const dateColumnMap = {}; const pageNumbers = []; for (let p = 1; p <= pages; p++) pageNumbers.push(p); const docs = await runQueue( pageNumbers, async function (page) { const doc = page === 1 ? document : await fetchPage(page); return { page: page, doc: doc }; }, CONCURRENCY, function (done, totalPages) { const pct = 5 + Math.round((done / Math.max(1, totalPages)) * 60); setProgress(pct, 'Шаг 1 из ' + (ANALYSIS_MODE === MODE_CLUB_ACTION ? '4' : '3') + ': загружаю основную выборку — <b>' + done + '</b> из <b>' + totalPages + '</b> страниц.'); setStatus('Загружаю страницы выборки: <b>' + done + '</b> / <b>' + totalPages + '</b>'); } ); docs.forEach(function (item) { if (!item || item.error) { allSkipped.push({ page: item?.item || '?', reason: item?.error || 'Ошибка загрузки страницы' }); return; } const parsed = parseRowsFromDoc(item.doc, item.page); allRows = allRows.concat(parsed.rows); allSkipped = allSkipped.concat(parsed.skipped); (parsed.dateColumns || []).forEach(function (col) { if (!dateColumnMap[col.key]) { dateColumnMap[col.key] = col; dateColumns.push(col); } }); }); dateColumns = dateColumns.sort(function (a, b) { if (b.score !== a.score) return b.score - a.score; return a.index - b.index; }); return { rows: allRows, skipped: allSkipped, totalFromSummary: total, pageSize: pageSize, pages: pages, dateColumns: dateColumns }; } async function collectRowsFromReportUrl(baseUrl, label) { const firstDoc = isSameReportUrl(baseUrl, window.location.href) ? document : await fetchPageFromUrl(baseUrl, 1); const total = getTotal(firstDoc); const pageSize = getPageSize(firstDoc); const pages = Math.max(1, Math.ceil((total || pageSize) / pageSize)); let allRows = []; let allSkipped = []; let dateColumns = []; const dateColumnMap = {}; const pageNumbers = []; for (let p = 1; p <= pages; p++) pageNumbers.push(p); const docs = await runQueue( pageNumbers, async function (page) { const doc = page === 1 ? firstDoc : await fetchPageFromUrl(baseUrl, page); return { page: page, doc: doc }; }, CONCURRENCY, function (done, totalPages) { setStatus('Собираю витрины продаж: <b>' + escapeHtml(label || 'выборка') + '</b> — ' + done + ' / ' + totalPages); } ); docs.forEach(function (item) { if (!item || item.error) { allSkipped.push({ page: item?.item || '?', reason: item?.error || 'Ошибка загрузки страницы' }); return; } const parsed = parseRowsFromDoc(item.doc, item.page); allRows = allRows.concat(parsed.rows); allSkipped = allSkipped.concat(parsed.skipped); (parsed.dateColumns || []).forEach(function (col) { if (!dateColumnMap[col.key]) { dateColumnMap[col.key] = col; dateColumns.push(col); } }); }); dateColumns = dateColumns.sort(function (a, b) { if (b.score !== a.score) return b.score - a.score; return a.index - b.index; }); return { rows: allRows, skipped: allSkipped, totalFromSummary: total, pageSize: pageSize, pages: pages, dateColumns: dateColumns }; } function clonePlain(value) { return JSON.parse(JSON.stringify(value || null)); } function cleanupRuleForReuse(rule) { if (!rule || typeof rule !== 'object') return rule; delete rule.maxSize; delete rule.countCondition; if (rule.params && Array.isArray(rule.params.children)) { rule.params.children.forEach(cleanupRuleForReuse); } return rule; } function getCurrentDealRuleString() { const url = new URL(window.location.href, window.location.origin); const fromUrl = url.searchParams.get('DealContext[rule_string]'); if (fromUrl) return fromUrl; const field = document.querySelector('[name="DealContext[rule_string]"], [name="DealContext\\[rule_string\\]"]'); const fromField = field && (field.value || field.getAttribute('value')); return fromField || ''; } function getCurrentDealContextRule() { const raw = getCurrentDealRuleString(); if (!raw) return null; try { return cleanupRuleForReuse(JSON.parse(raw)); } catch (err) { console.warn('[GC UTM MONEY TAGS DAYS] Не удалось разобрать текущий DealContext[rule_string]', err, raw); return null; } } function makeAndRuleFromBase(baseRule) { const cleanBase = cleanupRuleForReuse(clonePlain(baseRule)); if (cleanBase && cleanBase.type === 'andrule') { if (!cleanBase.params || typeof cleanBase.params !== 'object') cleanBase.params = {}; if (!Array.isArray(cleanBase.params.children)) cleanBase.params.children = []; cleanBase.params.mode = 'and'; return cleanBase; } return { type: 'andrule', inverted: 0, params: { mode: 'and', children: cleanBase ? [cleanBase] : [] } }; } function appendRulesToDealContext(baseRule, appendRules) { const rule = makeAndRuleFromBase(baseRule); const children = rule.params.children || []; (appendRules || []).forEach(function (childRule) { children.push(cleanupRuleForReuse(clonePlain(childRule))); }); rule.params.children = children; rule.params.mode = 'and'; return rule; } function makeCurrentUrlWithRule(rule) { const url = new URL(window.location.href, window.location.origin); url.searchParams.delete('page'); url.searchParams.set('DealContext[rule_string]', JSON.stringify(rule)); return url.toString(); } function buildCohortReportUrl(report, baseRule) { if (!report || report.mode === 'current') return window.location.href; if (!baseRule) throw new Error('Не найден текущий DealContext[rule_string]. Откройте выборку заказов через фильтр/сегмент GetCourse, чтобы можно было накинуть витрину поверх неё.'); const combinedRule = appendRulesToDealContext(baseRule, report.appendRules || []); return makeCurrentUrlWithRule(combinedRule); } async function collectConfiguredReports(reportDefs, currentSourceInfo, progressPrefix) { const reports = []; const baseRule = getCurrentDealContextRule(); for (let i = 0; i < reportDefs.length; i++) { const report = reportDefs[i]; try { if (report.mode === 'current') { prepareRowsForRender(currentSourceInfo.rows); reports.push(Object.assign({}, report, { url: window.location.href, sourceInfo: currentSourceInfo, totals: getTotals(currentSourceInfo.rows), error: '', status: 'current' })); continue; } const url = buildCohortReportUrl(report, baseRule); const sourceInfo = await collectRowsFromReportUrl(url, (progressPrefix || 'Срез') + ': ' + report.title); prepareRowsForRender(sourceInfo.rows); reports.push(Object.assign({}, report, { url: url, sourceInfo: sourceInfo, totals: getTotals(sourceInfo.rows), error: '', status: 'intersection' })); } catch (err) { console.error('[GC UTM MONEY TAGS DAYS] Ошибка сбора отдельного среза', report.title, err); reports.push(Object.assign({}, report, { url: '', sourceInfo: null, totals: null, error: err?.message || String(err), status: 'error' })); } } return reports; } async function collectConfiguredCohortReports(currentSourceInfo) { return collectConfiguredReports(COHORT_REPORTS, currentSourceInfo, 'Витрина'); } async function collectConfiguredPaymentReports(currentSourceInfo) { return collectConfiguredReports(PAYMENT_REPORTS, currentSourceInfo, 'Платёжный срез'); } function prepareRowsForRender(rows) { const activeKey = getActiveDateColumnKey(); rows.forEach(function (row) { row.group = GROUPING_ENABLED ? getSmartSourceGroup(row.source) : row.source; const dateInfo = activeKey && row.dates ? row.dates[activeKey] : null; row.activeDate = dateInfo && dateInfo.date ? dateInfo.date : null; row.dayKey = makeDayKey(row.activeDate); row.dayLabel = row.activeDate ? formatDay(row.activeDate) : EMPTY_DAY_LABEL; row.dayLabelFull = row.activeDate ? formatDayFull(row.activeDate) : EMPTY_DAY_LABEL; }); } function getActiveDateColumnKey() { const columns = LAST_SOURCE_INFO ? LAST_SOURCE_INFO.dateColumns : []; return columns && columns.length ? columns[0].key : ''; } function makeEmptyStats(base) { return Object.assign({ key: '', label: '', source: '', group: '', tag: '', day: '', dayDate: null, count: 0, paidCount: 0, unpaidCount: 0, paidAmount: 0, avgPaidCheck: 0, rows: [], children: [], tagMap: {}, sourceMap: {}, dayMap: {}, tagStats: [], sourceStats: [], dayStats: [] }, base || {}); } function addBasic(stats, row) { stats.count++; stats.rows.push(row); if (row.isPaid) { stats.paidCount++; stats.paidAmount += row.paidAmount; } else { stats.unpaidCount++; } } function addWithBreakdowns(stats, row) { addBasic(stats, row); const tagKey = row.segmentTag || EMPTY_TAG_LABEL; if (!stats.tagMap[tagKey]) stats.tagMap[tagKey] = makeEmptyStats({ key: tagKey, tag: tagKey, label: tagKey }); addBasic(stats.tagMap[tagKey], row); const sourceKey = row.group || row.source || EMPTY_SOURCE_LABEL; if (!stats.sourceMap[sourceKey]) stats.sourceMap[sourceKey] = makeEmptyStats({ key: sourceKey, source: sourceKey, group: sourceKey, label: sourceKey }); addBasic(stats.sourceMap[sourceKey], row); const dayKey = row.dayKey || 'no-date'; const dayLabel = row.dayLabelFull || EMPTY_DAY_LABEL; if (!stats.dayMap[dayKey]) stats.dayMap[dayKey] = makeEmptyStats({ key: dayKey, day: dayLabel, label: dayLabel, dayDate: row.activeDate || null }); addBasic(stats.dayMap[dayKey], row); } function finalizeStats(stats) { stats.avgPaidCheck = stats.paidCount ? stats.paidAmount / stats.paidCount : 0; stats.tagStats = finalizeList(stats.tagMap, sortStats); stats.sourceStats = finalizeList(stats.sourceMap, sortStats); stats.dayStats = finalizeList(stats.dayMap, sortDayStats); return stats; } function finalizeList(map, sorter) { return Object.keys(map || {}).map(function (key) { return finalizeStats(map[key]); }).sort(sorter || sortStats); } function sortStats(a, b) { if (b.paidAmount !== a.paidAmount) return b.paidAmount - a.paidAmount; if (b.paidCount !== a.paidCount) return b.paidCount - a.paidCount; if (b.count !== a.count) return b.count - a.count; return String(a.label || a.source || a.group || a.tag || a.day).localeCompare(String(b.label || b.source || b.group || b.tag || b.day), 'ru'); } function sortDayStats(a, b) { if (a.key === 'no-date') return 1; if (b.key === 'no-date') return -1; return String(a.key).localeCompare(String(b.key)); } function buildSourceStats(rows) { const groupMap = {}; const sourceMap = {}; rows.forEach(function (row) { const rawSourceKey = row.source || EMPTY_SOURCE_LABEL; const sourceKey = getNormalizedSourceKey(rawSourceKey); const groupKey = GROUPING_ENABLED ? getSmartSourceGroup(rawSourceKey) : sourceKey; row.group = groupKey; row.sourceDetail = sourceKey; if (!groupMap[groupKey]) groupMap[groupKey] = makeEmptyStats({ key: groupKey, group: groupKey, source: groupKey, label: groupKey, children: [] }); if (!sourceMap[sourceKey]) sourceMap[sourceKey] = makeEmptyStats({ key: sourceKey, source: sourceKey, group: groupKey, label: sourceKey, rawSource: rawSourceKey }); addWithBreakdowns(groupMap[groupKey], row); addWithBreakdowns(sourceMap[sourceKey], row); }); const sourceItems = Object.keys(sourceMap).map(function (key) { return finalizeStats(sourceMap[key]); }).sort(sortStats); sourceItems.forEach(function (sourceItem) { const groupKey = sourceItem.group || sourceItem.source; if (groupMap[groupKey]) groupMap[groupKey].children.push(sourceItem); }); return Object.keys(groupMap).map(function (key) { const group = finalizeStats(groupMap[key]); group.children = (group.children || []).sort(sortStats); if (!GROUPING_ENABLED) group.children = []; return group; }).sort(sortStats); } function buildSegmentStats(rows) { const map = {}; rows.forEach(function (row) { const key = row.segmentTag || EMPTY_TAG_LABEL; if (!map[key]) map[key] = makeEmptyStats({ key: key, tag: key, label: key }); addWithBreakdowns(map[key], row); }); return Object.keys(map).map(function (key) { return finalizeStats(map[key]); }).sort(sortStats); } function buildDayStats(rows) { const map = {}; rows.forEach(function (row) { const key = row.dayKey || 'no-date'; const label = row.dayLabelFull || EMPTY_DAY_LABEL; if (!map[key]) map[key] = makeEmptyStats({ key: key, day: label, label: label, dayDate: row.activeDate || null }); addWithBreakdowns(map[key], row); }); return Object.keys(map).map(function (key) { return finalizeStats(map[key]); }).sort(sortDayStats); } function buildNewbieStats(rows) { const total = makeEmptyStats({ key: 'first-club-total', label: FIRST_CLUB_GROUP_LABEL, tag: FIRST_CLUB_GROUP_LABEL }); const other = makeEmptyStats({ key: 'not-first-club-total', label: 'Остальные', tag: 'Остальные' }); const componentMap = {}; (rows || []).forEach(function (row) { const component = getNewbieComponent(row); if (component) { addWithBreakdowns(total, row); if (!componentMap[component]) { componentMap[component] = makeEmptyStats({ key: component, label: component, tag: component }); } addWithBreakdowns(componentMap[component], row); } else { addWithBreakdowns(other, row); } }); finalizeStats(total); finalizeStats(other); const components = finalizeList(componentMap, sortStats); total.children = components; total.componentStats = components; return { total: total, other: other, components: components, comparison: [total, other].filter(function (item) { return item.count || item.paidCount || item.paidAmount; }) }; } function buildFirstMoneyStats(rows) { const total = makeEmptyStats({ key: 'first-money-total', label: FIRST_MONEY_GROUP_LABEL, tag: FIRST_MONEY_GROUP_LABEL }); const other = makeEmptyStats({ key: 'not-first-money-total', label: 'Остальные', tag: 'Остальные' }); const componentMap = {}; (rows || []).forEach(function (row) { const component = getFirstMoneyComponent(row); if (component) { addWithBreakdowns(total, row); if (!componentMap[component]) { componentMap[component] = makeEmptyStats({ key: component, label: component, tag: component }); } addWithBreakdowns(componentMap[component], row); } else { addWithBreakdowns(other, row); } }); finalizeStats(total); finalizeStats(other); const components = finalizeList(componentMap, sortStats); total.children = components; total.componentStats = components; return { total: total, other: other, components: components, comparison: [total, other].filter(function (item) { return item.count || item.paidCount || item.paidAmount; }) }; } function buildFirstMoneyVsOtherItems(totals) { return totals && totals.firstMoney ? totals.firstMoney.comparison || [] : []; } function buildNewbieVsOtherItems(totals) { return totals && totals.newbie ? totals.newbie.comparison || [] : []; } function makeEmptyClubAccessStats() { return { rows: [], paidRows: [], accessCount: 0, paidAccessCount: 0, ordersCount: 0, paidOrdersCount: 0, monthsTotal: 0, paidMonthsTotal: 0, unknownDurationCount: 0, products: [], productMap: {}, rowsWithoutClub: [], rowsWithoutComposition: [] }; } function pushUniqueRow(list, row) { if (!row) return; const id = row.recordId || (row.page + ':' + row.rowIndex); if (!list._seen) list._seen = {}; if (list._seen[id]) return; list._seen[id] = true; list.push(row); } function buildClubAccessStats(rows) { const stats = makeEmptyClubAccessStats(); (rows || []).forEach(function (row) { const rawComposition = row.rawComposition || ''; if (!rawComposition) { stats.rowsWithoutComposition.push(row); } if (!row.clubAccessItems || !row.clubAccessItems.length) { stats.rowsWithoutClub.push(row); return; } stats.ordersCount++; pushUniqueRow(stats.rows, row); if (row.isPaid) { stats.paidOrdersCount++; pushUniqueRow(stats.paidRows, row); } row.clubAccessItems.forEach(function (item) { const months = Number(item.months) || 0; const key = getClubAccessGroupKey(months); const label = getClubAccessDurationLabel(months, item.title); const title = item.title || label; if (!stats.productMap[key]) { stats.productMap[key] = { key: key, label: label, title: title, months: months, accessCount: 0, paidAccessCount: 0, monthsTotal: 0, paidMonthsTotal: 0, rows: [], paidRows: [], exampleMap: {}, examples: [] }; } const product = stats.productMap[key]; if (title && !product.exampleMap[title]) { product.exampleMap[title] = true; product.examples.push(title); if (!product.title) product.title = title; } product.accessCount++; product.monthsTotal += months; pushUniqueRow(product.rows, row); stats.accessCount++; stats.monthsTotal += months; if (!months) stats.unknownDurationCount++; if (row.isPaid) { product.paidAccessCount++; product.paidMonthsTotal += months; pushUniqueRow(product.paidRows, row); stats.paidAccessCount++; stats.paidMonthsTotal += months; } }); }); stats.products = Object.keys(stats.productMap).map(function (key) { const product = stats.productMap[key]; product.examples = product.examples || []; return product; }).sort(function (a, b) { if (b.paidMonthsTotal !== a.paidMonthsTotal) return b.paidMonthsTotal - a.paidMonthsTotal; if (b.paidAccessCount !== a.paidAccessCount) return b.paidAccessCount - a.paidAccessCount; if (b.months !== a.months) return b.months - a.months; return String(a.label).localeCompare(String(b.label), 'ru'); }); return stats; } function makeEmptyClubBalanceMetricStats() { return { hasClubBalanceColumn: false, hasFirstClubBaseAgeColumn: false, hasMetricData: false, rows: [], paidRows: [], firstEntryRows: [], extensionRows: [], reactivationRows: [], zeroBalanceRows: [], unknownRows: [], cohortMap: {}, cohorts: [], extensionAvgDays: 0, extensionMedianDays: 0, reactivationAvgGapDays: 0, reactivationMedianGapDays: 0, firstBaseAge: makeEmptyFirstClubBaseAgeStats() }; } function makeEmptyFirstClubBaseAgeStats() { return { hasColumn: false, hasData: false, rows: [], cohortMap: {}, cohorts: [], avgDays: 0, medianDays: 0, minDays: null, maxDays: null, fast0to7Rows: [], firstMonthRows: [], oldBaseRows: [], missingAgeRows: [] }; } function buildClubBalanceMetricStats(rows) { const stats = makeEmptyClubBalanceMetricStats(); (rows || []).forEach(function (row) { if (!row) return; if (row.clubBalanceColumnPresent) stats.hasClubBalanceColumn = true; if (row.firstClubBaseAgeColumnPresent) stats.hasFirstClubBaseAgeColumn = true; if (row.clubBalanceValue === null || row.clubBalanceValue === undefined || !Number.isFinite(Number(row.clubBalanceValue))) return; if (!row.isPaid) return; const value = Number(row.clubBalanceValue); const cohort = getClubBalanceCohort(value); const key = cohort ? cohort.key : 'unknown'; const label = cohort ? cohort.label : 'Не распознано'; stats.hasMetricData = true; stats.rows.push(row); if (row.isPaid) stats.paidRows.push(row); if (!stats.cohortMap[key]) { stats.cohortMap[key] = makeEmptyStats({ key: key, label: label, tag: label, note: cohort && cohort.note ? cohort.note : '', min: cohort ? cohort.min : null, max: cohort ? cohort.max : null, balanceValues: [] }); } stats.cohortMap[key].balanceValues.push(value); addWithBreakdowns(stats.cohortMap[key], row); if (value === CLUB_BALANCE_FIRST_ENTRY_CODE) { stats.firstEntryRows.push(row); } else if (value >= 0) { stats.extensionRows.push(row); if (value === 0) stats.zeroBalanceRows.push(row); } else if (value < 0) { stats.reactivationRows.push(row); } else { stats.unknownRows.push(row); } }); stats.cohorts = CLUB_BALANCE_COHORT_DEFS.map(function (def) { const item = stats.cohortMap[def.key] || makeEmptyStats({ key: def.key, label: def.label, tag: def.label, note: def.note, min: def.min, max: def.max, balanceValues: [] }); item.note = item.note || def.note; return finalizeStats(item); }).filter(function (item) { return item.count || item.key === 'first-entry'; }); Object.keys(stats.cohortMap).forEach(function (key) { if (!CLUB_BALANCE_COHORT_DEFS.some(function (def) { return def.key === key; })) { stats.cohorts.push(finalizeStats(stats.cohortMap[key])); } }); const extensionValues = stats.extensionRows .map(function (row) { return Number(row.clubBalanceValue); }) .filter(function (value) { return Number.isFinite(value); }); const reactivationGaps = stats.reactivationRows .map(function (row) { return Math.abs(Number(row.clubBalanceValue)); }) .filter(function (value) { return Number.isFinite(value); }); stats.extensionAvgDays = averageNumbers(extensionValues); stats.extensionMedianDays = medianNumbers(extensionValues); stats.reactivationAvgGapDays = averageNumbers(reactivationGaps); stats.reactivationMedianGapDays = medianNumbers(reactivationGaps); stats.firstBaseAge = buildFirstClubBaseAgeStats(stats.firstEntryRows); stats.firstBaseAge.hasColumn = stats.hasFirstClubBaseAgeColumn; return stats; } function buildFirstClubBaseAgeStats(rows) { const stats = makeEmptyFirstClubBaseAgeStats(); const values = []; (rows || []).forEach(function (row) { if (!row) return; if (row.firstClubBaseAgeColumnPresent) stats.hasColumn = true; if (row.firstClubBaseAgeDays === null || row.firstClubBaseAgeDays === undefined || !Number.isFinite(Number(row.firstClubBaseAgeDays))) { stats.missingAgeRows.push(row); return; } const value = Number(row.firstClubBaseAgeDays); const cohort = getFirstClubBaseAgeCohort(value); const key = cohort ? cohort.key : 'base-age-unknown'; const label = cohort ? cohort.label : 'Не распознано'; stats.hasData = true; stats.rows.push(row); values.push(value); if (!stats.cohortMap[key]) { stats.cohortMap[key] = makeEmptyStats({ key: key, label: label, tag: label, note: cohort && cohort.note ? cohort.note : '', min: cohort ? cohort.min : null, max: cohort ? cohort.max : null, ageValues: [] }); } stats.cohortMap[key].ageValues.push(value); addWithBreakdowns(stats.cohortMap[key], row); if (value >= 0 && value <= 7) stats.fast0to7Rows.push(row); if (value >= 0 && value <= 30) stats.firstMonthRows.push(row); if (value >= 181) stats.oldBaseRows.push(row); }); stats.cohorts = FIRST_CLUB_BASE_AGE_COHORT_DEFS.map(function (def) { const item = stats.cohortMap[def.key] || makeEmptyStats({ key: def.key, label: def.label, tag: def.label, note: def.note, min: def.min, max: def.max, ageValues: [] }); item.note = item.note || def.note; return finalizeStats(item); }).filter(function (item) { return item.count || item.key === 'base-age-0-7' || item.key === 'base-age-8-30'; }); Object.keys(stats.cohortMap).forEach(function (key) { if (!FIRST_CLUB_BASE_AGE_COHORT_DEFS.some(function (def) { return def.key === key; })) { stats.cohorts.push(finalizeStats(stats.cohortMap[key])); } }); stats.avgDays = averageNumbers(values); stats.medianDays = medianNumbers(values); stats.minDays = values.length ? Math.min.apply(null, values) : null; stats.maxDays = values.length ? Math.max.apply(null, values) : null; return stats; } function averageNumbers(values) { const list = (values || []).filter(function (v) { return Number.isFinite(Number(v)); }).map(Number); if (!list.length) return 0; return list.reduce(function (sum, value) { return sum + value; }, 0) / list.length; } function medianNumbers(values) { const list = (values || []).filter(function (v) { return Number.isFinite(Number(v)); }).map(Number).sort(function (a, b) { return a - b; }); if (!list.length) return 0; const mid = Math.floor(list.length / 2); if (list.length % 2) return list[mid]; return (list[mid - 1] + list[mid]) / 2; } function formatDaysNumber(value) { if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'; const num = Number(value); const rounded = Math.round(num * 10) / 10; return String(rounded).replace('.', ',') + ' дн.'; } function getTotals(rows) { let totalPaidOrders = 0; let totalPaidAmount = 0; const tagSet = {}; const sourceSet = {}; const daySet = {}; rows.forEach(function (row) { if (row.isPaid) { totalPaidOrders++; totalPaidAmount += row.paidAmount; } tagSet[row.segmentTag || EMPTY_TAG_LABEL] = true; sourceSet[row.source || EMPTY_SOURCE_LABEL] = true; daySet[row.dayKey || 'no-date'] = true; }); const firstMoney = buildFirstMoneyStats(rows); const newbie = buildNewbieStats(rows); const clubAccess = buildClubAccessStats(rows); const clubBalance = buildClubBalanceMetricStats(rows); return { totalOrders: rows.length, totalPaidOrders: totalPaidOrders, totalUnpaidOrders: rows.length - totalPaidOrders, totalPaidAmount: totalPaidAmount, avgPaidCheck: totalPaidOrders ? totalPaidAmount / totalPaidOrders : 0, conversion: rows.length ? totalPaidOrders / rows.length : 0, tagsCount: Object.keys(tagSet).length, sourcesCount: Object.keys(sourceSet).length, daysCount: Object.keys(daySet).length, firstMoney: firstMoney, newbie: newbie, clubAccess: clubAccess, clubBalance: clubBalance }; } function renderResult(sourceInfo) { const resultEl = document.getElementById(RESULT_ID); if (!resultEl) return; FILTER_ROW_MAP = {}; FILTER_ROW_COUNTER = 0; prepareRowsForRender(sourceInfo.rows); updateDateSelector(); const totals = getTotals(sourceInfo.rows); const groups = buildSourceStats(sourceInfo.rows); const segments = buildSegmentStats(sourceInfo.rows); const days = buildDayStats(sourceInfo.rows); LAST_GROUP_ROWS = groups; LAST_SEGMENT_ROWS = segments; LAST_DAY_ROWS = days; renderSummary(sourceInfo, totals); updateControlButtons(); if (!sourceInfo.rows.length) { resultEl.innerHTML = '<div class="utm-help">Ничего не найдено.</div>'; return; } let html = ''; if (CURRENT_VIEW === 'cohorts') { html = ANALYSIS_MODE === MODE_CLUB_ACTION ? renderCohortsView(totals) : renderOverviewView(segments, days, groups, totals); } else if (CURRENT_VIEW === 'crossfilters') { html = renderCrossFiltersView(sourceInfo.rows, totals); } else if (CURRENT_VIEW === 'payments') { html = renderPaymentsView(totals); } else if (CURRENT_VIEW === 'segments') { html = renderSegmentsView(segments, totals); } else if (CURRENT_VIEW === 'days') { html = renderDaysView(days, totals); } else if (CURRENT_VIEW === 'sources') { html = renderSourcesView(groups, totals); } else if (CURRENT_VIEW === 'club') { html = renderClubView(totals); } else if (CURRENT_VIEW === 'clubbalance') { html = renderClubBalanceView(totals); } else if (CURRENT_VIEW === 'firstclub') { html = renderFirstClubView(totals); } else if (CURRENT_VIEW === 'matrix') { CURRENT_VIEW = 'days'; html = renderDaysView(days, totals); } else { html = renderOverviewView(segments, days, groups, totals); } resultEl.innerHTML = html; enhanceLessonTitleLinks(resultEl); decorateInteractiveAnalytics(resultEl); schedulePieCharts(groups, segments, days, totals); } function getStatItemLabel(item) { return item ? (item.label || item.tag || item.group || item.source || item.day || '') : ''; } function renderChartPanel(title, chartDefs, compact) { if (!chartDefs || !chartDefs.length) return ''; return '<div class="utm-panel">' + '<div class="utm-panel-title">' + escapeHtml(title) + '</div>' + '<div class="utm-chart-grid">' + chartDefs.map(function (chart) { return '<div class="utm-chart-box">' + '<div class="utm-chart-title">' + escapeHtml(chart.title) + '</div>' + '<div id="' + escapeHtml(chart.id) + '" class="' + (compact ? 'utm-chart-canvas-sm' : 'utm-chart-canvas') + '"></div>' + '<div id="' + escapeHtml(chart.id) + '-legend" class="utm-chart-legend"></div>' + '<div class="utm-chart-note">Клик по сектору или строке легенды фильтрует основную таблицу заказов.</div>' + '</div>'; }).join('') + '</div>' + '</div>'; } function renderInlineChartRow(totalColumns, title, chartDefs) { if (!chartDefs || !chartDefs.length) return ''; return '<tr style="background:#fcfdff;"><td></td><td colspan="' + (totalColumns - 1) + '">' + '<div class="utm-panel" style="margin:8px 0;">' + '<div class="utm-panel-title" style="font-size:14px;">' + escapeHtml(title) + '</div>' + '<div class="utm-chart-grid">' + chartDefs.map(function (chart) { return '<div class="utm-chart-box">' + '<div class="utm-chart-title">' + escapeHtml(chart.title) + '</div>' + '<div id="' + escapeHtml(chart.id) + '" class="utm-chart-canvas-sm"></div>' + '<div id="' + escapeHtml(chart.id) + '-legend" class="utm-chart-legend"></div>' + '<div class="utm-chart-note">Клик по сектору или строке легенды фильтрует основную таблицу заказов.</div>' + '</div>'; }).join('') + '</div>' + '</div>' + '</td></tr>'; } function getRowsLightStats(rows) { const list = rows || []; let paidCount = 0; let paidAmount = 0; list.forEach(function (row) { if (!row || !row.isPaid) return; paidCount++; paidAmount += Number(row.paidAmount) || 0; }); return { count: list.length, paidCount: paidCount, unpaidCount: list.length - paidCount, paidAmount: paidAmount, conversion: list.length ? paidCount / list.length * 100 : 0, avgPaidCheck: paidCount ? paidAmount / paidCount : 0 }; } function makeSliceStatsFromItem(item, rows) { if (item) { return { count: Number(item.count) || 0, paidCount: Number(item.paidCount) || 0, unpaidCount: Number(item.unpaidCount) || 0, paidAmount: Number(item.paidAmount) || 0, conversion: item.count ? (Number(item.paidCount) || 0) / Number(item.count) * 100 : 0, avgPaidCheck: Number(item.avgPaidCheck) || 0 }; } return getRowsLightStats(rows); } function registerPiePointFilter(point, title, metricKey) { const isMoney = metricKey === 'paidAmount'; const rows = point.custom && point.custom.rows ? point.custom.rows : []; const valueText = isMoney ? formatMoney(point.y) : formatNumber(point.y); const label = title + ': ' + point.name + ' — ' + valueText; const filterId = registerFilterRows(rows, label); if (!point.custom) point.custom = {}; point.custom.filterId = filterId; point.custom.filterLabel = label; return filterId; } function buildPiePoints(items, metricKey, totalValue, topN) { const safeTopN = topN || 7; const list = (items || []) .filter(function (item) { return Number(item && item[metricKey]) > 0; }) .map(function (item) { return { name: getStatItemLabel(item), value: Number(item[metricKey]) || 0, rows: item.rows || [], item: item }; }) .sort(function (a, b) { return b.value - a.value; }); if (!list.length) return []; const main = list.slice(0, safeTopN); const rest = list.slice(safeTopN); if (rest.length) { const restValue = rest.reduce(function (sum, item) { return sum + item.value; }, 0); const restRows = rest.reduce(function (acc, item) { return acc.concat(item.rows || []); }, []); main.push({ name: 'Прочее', value: restValue, rows: restRows, item: null }); } return main.map(function (item) { const stats = makeSliceStatsFromItem(item.item, item.rows); return { name: item.name || EMPTY_SOURCE_LABEL, y: item.value, custom: { rows: item.rows, stats: stats, totalValue: totalValue || 0, metricKey: metricKey, originalItem: item.item, label: item.name || EMPTY_SOURCE_LABEL } }; }); } function renderPieLegend(containerId, title, points, metricKey, totalValue) { const legend = document.getElementById(containerId + '-legend'); if (!legend) return; const isMoney = metricKey === 'paidAmount'; legend.innerHTML = (points || []).map(function (point) { const filterId = registerPiePointFilter(point, title, metricKey); const percent = totalValue ? point.y / totalValue * 100 : 0; return '<div class="utm-legend-row" data-utm-filter-id="' + escapeHtml(filterId) + '">' + '<div class="utm-legend-name">' + escapeHtml(point.name) + '</div>' + '<div class="utm-legend-value">' + (isMoney ? formatMoney(point.y) : formatNumber(point.y)) + '</div>' + '<div class="utm-legend-percent">' + percent.toFixed(1).replace('.', ',') + '%</div>' + '</div>'; }).join(''); } function renderPieChart(containerId, title, items, metricKey, totalValue) { const container = document.getElementById(containerId); if (!container) return; if (!window.Highcharts) { container.innerHTML = '<div class="utm-help">Загружаю диаграмму…</div>'; return; } const isMoney = metricKey === 'paidAmount'; const points = buildPiePoints(items, metricKey, totalValue, 6); if (!points.length) { container.innerHTML = '<div class="utm-help">Нет данных для диаграммы.</div>'; const legend = document.getElementById(containerId + '-legend'); if (legend) legend.innerHTML = ''; return; } renderPieLegend(containerId, title, points, metricKey, totalValue); Highcharts.chart(containerId, { chart: { type: 'pie', height: container.classList.contains('utm-chart-canvas-sm') ? 220 : 260, spacing: [2, 2, 2, 2] }, title: { text: null }, tooltip: { useHTML: false, outside: false, animation: false, hideDelay: 30, shadow: false, borderRadius: 4, padding: 8, style: { fontSize: '12px', color: '#222' }, formatter: function () { const valueText = isMoney ? formatMoney(this.y) : formatNumber(this.y); const percentText = (this.percentage || 0).toFixed(1).replace('.', ',') + '%'; const stats = this.point.custom && this.point.custom.stats ? this.point.custom.stats : { count: 0, paidCount: 0, paidAmount: 0 }; return '<b>' + this.point.name + '</b><br/>' + 'Показатель: <b>' + valueText + '</b><br/>' + 'Доля: <b>' + percentText + '</b><br/>' + 'Заказов: <b>' + formatNumber(stats.count) + '</b><br/>' + 'Оплаченных: <b>' + formatNumber(stats.paidCount) + '</b><br/>' + 'Конверсия: <b>' + formatConversion(stats.paidCount, stats.count) + '</b><br/>' + 'Оплачено: <b>' + formatMoney(stats.paidAmount) + '</b><br/>' + 'Клик — показать заказы'; } }, plotOptions: { pie: { innerSize: '58%', allowPointSelect: false, cursor: 'pointer', dataLabels: { enabled: false }, point: { events: { click: function () { const filterId = this.custom && this.custom.filterId ? this.custom.filterId : ''; const info = filterId ? FILTER_ROW_MAP[filterId] : null; if (!info || !info.rows || !info.rows.length) return; filterCurrentTableByRows(info.rows, info.label); } } } } }, legend: { enabled: false }, credits: { enabled: false }, series: [{ name: title, data: points }] }); } function schedulePieCharts(groups, segments, days, totals) { const run = function () { setTimeout(function () { renderPieChartsForView(groups, segments, days, totals); }, 0); }; if (window.Highcharts) { run(); } else { loadHighcharts(run); } } function renderPieChartsForView(groups, segments, days, totals) { if (!window.Highcharts) return; if (CURRENT_VIEW === 'overview') { renderPieChart('utm-overview-firstmoney-count', 'Первая покупка за деньги / остальные — оплаченные заказы', buildFirstMoneyVsOtherItems(totals), 'paidCount', totals.totalPaidOrders); renderPieChart('utm-overview-firstmoney-money', 'Первая покупка за деньги / остальные — деньги', buildFirstMoneyVsOtherItems(totals), 'paidAmount', totals.totalPaidAmount); if (ANALYSIS_MODE === MODE_CLUB_ACTION) { renderPieChart('utm-overview-firstclub-count', 'Первый клуб / остальные — оплаченные заказы', buildNewbieVsOtherItems(totals), 'paidCount', totals.totalPaidOrders); renderPieChart('utm-overview-firstclub-money', 'Первый клуб / остальные — деньги', buildNewbieVsOtherItems(totals), 'paidAmount', totals.totalPaidAmount); } renderPieChart('utm-overview-segments-count', 'Оплаченные заказы по сегментам', segments, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-overview-segments-money', 'Деньги по сегментам', segments, 'paidAmount', totals.totalPaidAmount); renderPieChart('utm-overview-days-count', 'Оплаченные заказы по дням', days, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-overview-days-money', 'Деньги по дням', days, 'paidAmount', totals.totalPaidAmount); renderPieChart('utm-overview-groups-count', 'Оплаченные заказы по источникам', groups, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-overview-groups-money', 'Деньги по источникам', groups, 'paidAmount', totals.totalPaidAmount); return; } if (CURRENT_VIEW === 'segments') { renderPieChart('utm-segments-all-count', 'Оплаченные заказы по сегментам', segments, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-segments-all-money', 'Деньги по сегментам', segments, 'paidAmount', totals.totalPaidAmount); segments.forEach(function (segment, idx) { if (!EXPANDED_SEGMENTS[segment.key]) return; renderPieChart('utm-segment-sources-count-' + idx, 'Источники внутри «' + segment.label + '» — оплаченные заказы', segment.sourceStats || [], 'paidCount', segment.paidCount); renderPieChart('utm-segment-sources-money-' + idx, 'Источники внутри «' + segment.label + '» — деньги', segment.sourceStats || [], 'paidAmount', segment.paidAmount); }); return; } if (CURRENT_VIEW === 'days') { renderPieChart('utm-days-all-count', 'Оплаченные заказы по дням', days, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-days-all-money', 'Деньги по дням', days, 'paidAmount', totals.totalPaidAmount); days.forEach(function (day, idx) { if (!EXPANDED_DAYS[day.key]) return; renderPieChart('utm-day-tags-count-' + idx, 'Сегменты внутри «' + day.label + '» — оплаченные заказы', day.tagStats || [], 'paidCount', day.paidCount); renderPieChart('utm-day-tags-money-' + idx, 'Сегменты внутри «' + day.label + '» — деньги', day.tagStats || [], 'paidAmount', day.paidAmount); renderPieChart('utm-day-sources-count-' + idx, 'Источники внутри «' + day.label + '» — оплаченные заказы', day.sourceStats || [], 'paidCount', day.paidCount); renderPieChart('utm-day-sources-money-' + idx, 'Источники внутри «' + day.label + '» — деньги', day.sourceStats || [], 'paidAmount', day.paidAmount); }); return; } if (CURRENT_VIEW === 'sources') { renderPieChart('utm-sources-all-count', 'Оплаченные заказы по источникам', groups, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-sources-all-money', 'Деньги по источникам', groups, 'paidAmount', totals.totalPaidAmount); groups.forEach(function (group, idx) { if (!EXPANDED_GROUPS[group.key]) return; renderPieChart('utm-group-children-count-' + idx, 'Подисточники внутри «' + group.group + '» — оплаченные заказы', group.children || [], 'paidCount', group.paidCount); renderPieChart('utm-group-children-money-' + idx, 'Подисточники внутри «' + group.group + '» — деньги', group.children || [], 'paidAmount', group.paidAmount); renderPieChart('utm-group-tags-count-' + idx, 'Сегменты внутри «' + group.group + '» — оплаченные заказы', group.tagStats || [], 'paidCount', group.paidCount); renderPieChart('utm-group-tags-money-' + idx, 'Сегменты внутри «' + group.group + '» — деньги', group.tagStats || [], 'paidAmount', group.paidAmount); }); return; } if (CURRENT_VIEW === 'clubbalance') { const balanceStats = totals.clubBalance || makeEmptyClubBalanceMetricStats(); renderPieChart('utm-club-balance-count', 'Club balance — оплаченные заказы', balanceStats.cohorts || [], 'paidCount', (balanceStats.rows || []).length); renderPieChart('utm-club-balance-money', 'Club balance — деньги', balanceStats.cohorts || [], 'paidAmount', totals.totalPaidAmount); renderPieChart('utm-first-club-base-age-count', 'Первая покупка клуба — скорость', (balanceStats.firstBaseAge && balanceStats.firstBaseAge.cohorts) || [], 'paidCount', (balanceStats.firstEntryRows || []).length); renderPieChart('utm-first-club-base-age-money', 'Первая покупка клуба — деньги', (balanceStats.firstBaseAge && balanceStats.firstBaseAge.cohorts) || [], 'paidAmount', totals.totalPaidAmount); } } function renderSummary(sourceInfo, totals) { const el = document.getElementById(SUMMARY_ID); if (!el) return; const paidConversion = formatConversion(totals.totalPaidOrders, totals.totalOrders); const cards = [ { title: 'Оплачено', value: formatMoney(totals.totalPaidAmount), note: 'средний чек ' + formatMoney(totals.avgPaidCheck) }, { title: 'Оплаченных заказов', value: formatNumber(totals.totalPaidOrders), note: 'CR ' + paidConversion + ' · всего ' + formatNumber(totals.totalOrders) } ]; if (ANALYSIS_MODE === MODE_CLUB_ACTION) { const club = totals.clubAccess || makeEmptyClubAccessStats(); const firstMoney = totals.firstMoney && totals.firstMoney.total ? totals.firstMoney.total : makeEmptyStats({ label: FIRST_MONEY_GROUP_LABEL }); const firstClub = totals.newbie && totals.newbie.total ? totals.newbie.total : makeEmptyStats({ label: FIRST_CLUB_GROUP_LABEL }); const firstMoneyFilterId = registerFilterRows(firstMoney.rows, FIRST_MONEY_GROUP_LABEL + ': ' + formatNumber(firstMoney.count) + ' заказов, оплачено ' + formatMoney(firstMoney.paidAmount)); const firstClubFilterId = registerFilterRows(firstClub.rows, FIRST_CLUB_GROUP_LABEL + ': ' + formatNumber(firstClub.count) + ' заказов, оплачено ' + formatMoney(firstClub.paidAmount)); const clubFilterId = registerFilterRows(club.rows, 'Все заказы с доступом к онлайн-йога-клубу: ' + formatNumber(club.ordersCount) + ' заказов'); cards.push( { title: 'Месяцев клуба', value: formatNumber(club.paidMonthsTotal), note: formatNumber(club.paidAccessCount) + ' оплаченных доступов', filterId: clubFilterId }, { title: FIRST_MONEY_GROUP_LABEL, value: formatMoney(firstMoney.paidAmount), note: formatNumber(firstMoney.paidCount) + ' оплат · CR ' + formatConversion(firstMoney.paidCount, firstMoney.count), filterId: firstMoneyFilterId }, { title: FIRST_CLUB_GROUP_LABEL, value: formatMoney(firstClub.paidAmount), note: formatNumber(firstClub.paidCount) + ' оплат · CR ' + formatConversion(firstClub.paidCount, firstClub.count), filterId: firstClubFilterId } ); } else { const tariffs = buildTariffStats(LAST_SOURCE_INFO ? LAST_SOURCE_INFO.rows : []); const paidTariffs = tariffs.reduce(function (sum, item) { return sum + (Number(item.paidCount) || 0); }, 0); cards.push({ title: 'Тарифов в запуске', value: formatNumber(tariffs.length), note: formatNumber(paidTariffs) + ' оплаченных заказов распределено по тарифам' }); } cards.push({ title: 'Глубина данных', value: formatNumber(sourceInfo.pages) + ' стр.', note: formatNumber(totals.daysCount) + ' дней · ' + formatNumber(totals.sourcesCount) + ' источников · ' + formatNumber(totals.tagsCount) + ' сегментов' }); el.innerHTML = cards.map(function (card) { return renderKpiCard(card.title, card.value, card.note, card.filterId); }).join(''); } function renderCohortComparisonPanel(totals) { const reports = LAST_COHORT_REPORTS || []; if (!reports.length) { return '<div class="utm-panel"><div class="utm-panel-title">Витрины продаж / когорты</div><div class="utm-help">Когорты ещё не собраны.</div></div>'; } const totalReport = reports.find(function (item) { return item.key === 'total-club'; }); const totalPaidAmount = totalReport && totalReport.totals ? totalReport.totals.totalPaidAmount : (totals ? totals.totalPaidAmount : 0); const totalPaidOrders = totalReport && totalReport.totals ? totalReport.totals.totalPaidOrders : (totals ? totals.totalPaidOrders : 0); const rows = reports.map(function (report) { const hasLive = report.totals && !report.error; const liveTotals = report.totals || null; const paidOrders = hasLive ? liveTotals.totalPaidOrders : 0; const paidAmount = hasLive ? liveTotals.totalPaidAmount : 0; const avgCheck = paidOrders ? paidAmount / paidOrders : 0; const rowsForFilter = hasLive && report.sourceInfo ? report.sourceInfo.rows : []; const filterId = rowsForFilter.length ? registerFilterRows(rowsForFilter, report.title + ': ' + formatNumber(paidOrders) + ' оплат, ' + formatMoney(paidAmount)) : ''; const statusText = report.status === 'current' ? ('текущая выборка · ' + formatNumber((report.sourceInfo && report.sourceInfo.pages) || 1) + ' стр.') : (hasLive ? ('пересечение · ' + formatNumber((report.sourceInfo && report.sourceInfo.pages) || 1) + ' стр.') : ('ошибка' + (report.error ? ' · ' + report.error : ''))); return '<tr' + (filterId ? ' class="utm-click" data-utm-filter-id="' + filterId + '"' : '') + '>' + '<td><b>' + escapeHtml(report.title) + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(report.note || '') + '</div></td>' + '<td class="num">' + formatNumber(paidOrders) + '</td>' + '<td class="num"><b>' + formatMoney(paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(paidOrders, totalPaidOrders) + '</td>' + '<td class="num">' + formatPercentValue(paidAmount, totalPaidAmount) + '</td>' + '<td class="num">' + formatMoney(avgCheck) + '</td>' + '<td class="num"><span class="utm-muted">' + escapeHtml(statusText) + '</span></td>' + '</tr>'; }).join(''); const knownReports = reports.filter(function (item) { return item.key !== 'total-club'; }); const knownAmount = knownReports.reduce(function (sum, item) { const t = item.totals; return sum + (t ? Number(t.totalPaidAmount) || 0 : 0); }, 0); const knownPaidOrders = knownReports.reduce(function (sum, item) { const t = item.totals; return sum + (t ? Number(t.totalPaidOrders) || 0 : 0); }, 0); const otherAmount = Math.max(0, totalPaidAmount - knownAmount); const otherOrders = Math.max(0, totalPaidOrders - knownPaidOrders); return '<div class="utm-panel">' + '<div class="utm-panel-title">Витрины продаж / когорты</div>' + '<div class="utm-help">Логика v31: <b>база сравнения</b> — текущая открытая выборка заказов на странице. В таблице ниже ровно четыре витрины: продление из клуба, CHL / основной сайт, марафон «Лёгкий старт» и догоняющая цепочка. Каждая витрина считается как пересечение текущей выборки с соответствующим offer-фильтром из HAR. Поэтому период, продукт, статус оплаты и другие условия берутся из того сегмента, который уже открыт в GetCourse.</div>' + '<div class="utm-section-grid" style="margin-bottom:10px;">' + renderKpiCard('Четыре выделенные витрины', formatMoney(knownAmount), formatNumber(knownPaidOrders) + ' оплат · ' + formatPercentValue(knownAmount, totalPaidAmount) + ' денег от текущей выборки', '') + renderKpiCard('Остальной хвост', formatMoney(otherAmount), formatNumber(otherOrders) + ' оплат · ' + formatPercentValue(otherAmount, totalPaidAmount) + ' денег от текущей выборки', '') + '</div>' + '<div style="overflow-x:auto;"><table class="utm-table utm-mini-table">' + '<thead><tr><th>Выборка</th><th class="num">Оплат</th><th class="num">Оплачено</th><th class="num">% оплат</th><th class="num">% денег</th><th class="num">Средний чек</th><th class="num">Статус</th></tr></thead>' + '<tbody>' + rows + '</tbody>' + '</table></div>' + '</div>'; } function renderPaymentsPanel(totals) { const reports = LAST_PAYMENT_REPORTS || []; if (!reports.length) { return '<div class="utm-panel"><div class="utm-panel-title">Способы оплаты</div><div class="utm-help">Платёжные срезы ещё не собраны.</div></div>'; } const totalPaidOrders = totals ? totals.totalPaidOrders : 0; const totalPaidAmount = totals ? totals.totalPaidAmount : 0; const rows = reports.map(function (report) { const t = report.totals; const hasLive = Boolean(t && !report.error); const paidOrders = hasLive ? t.totalPaidOrders : 0; const paidAmount = hasLive ? t.totalPaidAmount : 0; const avgCheck = paidOrders ? paidAmount / paidOrders : 0; const commissionRate = Number(report.commissionRate) || 0; const commission = paidAmount * commissionRate; const afterCommission = paidAmount - commission; const rowsForFilter = hasLive && report.sourceInfo ? report.sourceInfo.rows : []; const filterId = rowsForFilter.length ? registerFilterRows(rowsForFilter, report.title + ': ' + formatNumber(paidOrders) + ' оплат, ' + formatMoney(paidAmount)) : ''; const statusText = hasLive ? ('отдельный запрос · ' + formatNumber((report.sourceInfo && report.sourceInfo.pages) || 1) + ' стр.') : ('ошибка' + (report.error ? ' · ' + report.error : '')); return '<tr' + (filterId ? ' class="utm-click" data-utm-filter-id="' + filterId + '"' : '') + '>' + '<td><b>' + escapeHtml(report.title) + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(report.note || '') + '</div></td>' + '<td class="num">' + formatNumber(paidOrders) + '</td>' + '<td class="num"><b>' + formatMoney(paidAmount) + '</b></td>' + '<td class="num"><b>' + formatPercentValue(paidOrders, totalPaidOrders) + '</b></td>' + '<td class="num">' + formatPercentValue(paidAmount, totalPaidAmount) + '</td>' + '<td class="num">' + formatMoney(avgCheck) + '</td>' + '<td class="num">' + (commissionRate ? formatMoney(commission) : '—') + '</td>' + '<td class="num">' + (commissionRate ? '<b>' + formatMoney(afterCommission) + '</b>' : '—') + '</td>' + '<td class="num"><span class="utm-muted">' + escapeHtml(statusText) + '</span></td>' + '</tr>'; }).join(''); const installment = reports.find(function (item) { return item.key === 'installment-payments'; }); const nonRf = reports.find(function (item) { return item.key === 'card-non-rf'; }); const installmentTotals = installment && installment.totals; const nonRfTotals = nonRf && nonRf.totals; const nonRfAmount = nonRfTotals ? nonRfTotals.totalPaidAmount : 0; return '<div class="utm-panel">' + '<div class="utm-panel-title">Способы оплаты: рассрочка и карта НЕРФ</div>' + '<div class="utm-help">Оба показателя считаются отдельными запросами поверх текущей открытой выборки GetCourse. Поэтому период, продукты, теги и прочие условия полностью сохраняются. «Доля оплат» = количество оплаченных заказов с этим способом / все оплаченные заказы текущей выборки.</div>' + '<div class="utm-section-grid" style="margin-bottom:10px;">' + renderKpiCard('Рассрочка: доля оплат', formatPercentValue(installmentTotals ? installmentTotals.totalPaidOrders : 0, totalPaidOrders), formatNumber(installmentTotals ? installmentTotals.totalPaidOrders : 0) + ' из ' + formatNumber(totalPaidOrders) + ' оплаченных заказов', '') + renderKpiCard('Карта НЕРФ: доля оплат', formatPercentValue(nonRfTotals ? nonRfTotals.totalPaidOrders : 0, totalPaidOrders), formatNumber(nonRfTotals ? nonRfTotals.totalPaidOrders : 0) + ' из ' + formatNumber(totalPaidOrders) + ' оплаченных заказов', '') + renderKpiCard('Комиссия НЕРФ 10%', formatMoney(nonRfAmount * 0.10), 'оценка от суммы платежей ' + formatMoney(nonRfAmount), '') + renderKpiCard('НЕРФ после комиссии', formatMoney(nonRfAmount * 0.90), 'платежи минус 10% комиссии', '') + '</div>' + '<div style="overflow-x:auto;"><table class="utm-table" style="min-width:1280px;">' + '<thead><tr><th>Платёжный срез</th><th class="num">Оплат</th><th class="num">Оплачено</th><th class="num">Доля оплат</th><th class="num">Доля денег</th><th class="num">Средний чек</th><th class="num">Комиссия</th><th class="num">После комиссии</th><th class="num">Статус</th></tr></thead>' + '<tbody>' + rows + '</tbody>' + '</table></div>' + '</div>'; } function renderPaymentsView(totals) { return renderPaymentsPanel(totals); } function renderCohortsView(totals) { return '<div class="utm-help">Витрины считают не фиксированный месяц из HAR, а текущую выборку на странице GetCourse. HAR здесь используется только как источник offer-фильтров для четырёх витрин: продление из клуба, CHL, марафон «Лёгкий старт» и догоняющая цепочка.</div>' + renderCohortComparisonPanel(totals); } function getUniqueOptions(rows, getter) { const map = {}; (rows || []).forEach(function (row) { const v = String(getter(row) || ''); if (v) map[v] = true; }); return Object.keys(map).sort(function (a,b) { return a.localeCompare(b, 'ru'); }); } function renderSelectOptions(values, allLabel) { return '<option value="">' + escapeHtml(allLabel) + '</option>' + values.map(function (v) { return '<option value="' + escapeHtml(v) + '">' + escapeHtml(v) + '</option>'; }).join(''); } function renderCrossFiltersView(rows, totals) { const tags = getUniqueOptions(rows, function (r) { return r.segmentTag; }); const ages = getUniqueOptions(rows, function (r) { return r.userBaseAgeSegment; }); const tariffs = getUniqueOptions(rows, function (r) { return r.marathonTariffLabel; }); const sources = getUniqueOptions(rows, function (r) { return r.group || r.source; }); const days = getUniqueOptions(rows, function (r) { return r.dayLabelFull; }); return '<div class="utm-panel"><div class="utm-panel-title">Пересекаемые фильтры</div><div class="utm-help">Выберите одновременно несколько характеристик. Например: «База» + «0–30 дней» + «15 дней — 990 ₽». После применения в основной таблице останутся только заказы, которые подходят сразу под все выбранные условия.</div>' + '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;align-items:end;">' + '<label>Тег / сегмент<select class="form-control" data-cross="tag">' + renderSelectOptions(tags,'Все теги') + '</select></label>' + '<label>Возраст в базе<select class="form-control" data-cross="age">' + renderSelectOptions(ages,'Любой возраст') + '</select></label>' + '<label>Тариф<select class="form-control" data-cross="tariff">' + renderSelectOptions(tariffs,'Все тарифы') + '</select></label>' + '<label>Источник<select class="form-control" data-cross="source">' + renderSelectOptions(sources,'Все источники') + '</select></label>' + '<label>День<select class="form-control" data-cross="day">' + renderSelectOptions(days,'Все дни') + '</select></label>' + '<div><button type="button" class="btn btn-primary" data-cross-apply>Показать пересечение</button> <button type="button" class="btn btn-default" data-cross-reset>Сбросить</button></div></div>' + '<div class="utm-help" style="margin-top:10px;">В текущей выборке: <b>' + formatNumber(totals.totalOrders) + '</b> заказов, <b>' + formatNumber(totals.totalPaidOrders) + '</b> оплат, <b>' + formatMoney(totals.totalPaidAmount) + '</b>.</div></div>' + '<div class="utm-section-grid">' + renderDimensionMiniTable(buildTariffStats(rows), totals, 'Тарифы по всей выборке', 'Тариф') + renderDimensionMiniTable(buildBaseAgeStats(rows), totals, 'Возраст пользователей в базе', 'Возраст') + '</div>'; } function renderOverviewView(segments, days, groups, totals) { const topSegments = segments.slice(0, 5); const topDays = days.slice().sort(sortStats).slice(0, 5); const topGroups = groups.slice(0, 5); if (ANALYSIS_MODE === MODE_MARATHON) { const rows = LAST_SOURCE_INFO ? LAST_SOURCE_INFO.rows : []; return '<div class="utm-help"><b>Режим «Марафон».</b> Отчёт анализирует один запуск без дополнительных витрин предложений и без клубных метрик. Основные слои: тарифы, дни, источники, теги, возраст базы и способы оплаты.</div>' + renderOverviewMainInsights(segments, days, groups, totals) + '<div class="utm-overview-primary-grid">' + renderDimensionMiniTable(buildTariffStats(rows), totals, 'Какие тарифы купили', 'Тариф') + renderDimensionMiniTable(buildBaseAgeStats(rows), totals, 'Возраст пользователей в базе', 'Возраст') + '</div>' + '<div class="utm-overview-secondary-grid">' + renderBusinessTopList('Откуда деньги', topGroups, 'money', totals, 'Источник') + renderBusinessTopList('Кто купил', topSegments, 'money', totals, 'Сегмент') + renderBusinessTopList('Лучшие дни', topDays, 'money', totals, 'День') + '</div>'; } const club = totals.clubAccess || makeEmptyClubAccessStats(); return '<div class="utm-help"><b>Режим «Акция клуба».</b> Доступны витрины предложений, клубные месяцы, первый клуб и Club balance.</div>' + renderOverviewMainInsights(segments, days, groups, totals) + renderCohortComparisonPanel(totals) + '<div class="utm-section-grid">' + renderBusinessTopList('Откуда деньги', topGroups, 'money', totals, 'Источник') + renderBusinessTopList('Кто купил', topSegments, 'money', totals, 'Сегмент') + renderClubCompactTopList('Что купили по клубу', club.products || [], 'весь период', 'product') + renderBusinessTopList('Лучшие дни', topDays, 'money', totals, 'День') + '</div>' + '<details style="margin-top:12px;"><summary style="cursor:pointer;font-weight:700;color:#4a78a8;">Показать первичные срезы и формулы</summary><div style="margin-top:10px;">' + renderNewbiePanel(totals) + '</div></details>'; } function renderInsightText(segments, days, groups, totals) { const topSegment = segments[0]; const topGroup = groups[0]; const bestDay = days.slice().sort(sortStats)[0]; const pieces = []; if (topSegment) { pieces.push('Главный сегмент по деньгам: <b>' + escapeHtml(topSegment.label) + '</b> — ' + formatMoney(topSegment.paidAmount) + ' (' + formatPercentValue(topSegment.paidAmount, totals.totalPaidAmount) + ' денег).'); } if (topGroup) { pieces.push('Главный источник по деньгам: <b>' + escapeHtml(topGroup.group || topGroup.label) + '</b> — ' + formatMoney(topGroup.paidAmount) + ' (' + formatPercentValue(topGroup.paidAmount, totals.totalPaidAmount) + ' денег).'); } if (bestDay) { pieces.push('Самый денежный день: <b>' + escapeHtml(bestDay.day || bestDay.label) + '</b> — ' + formatMoney(bestDay.paidAmount) + ' (' + formatPercentValue(bestDay.paidAmount, totals.totalPaidAmount) + ' денег).'); } if (!pieces.length) return '<div class="utm-help">Пока недостаточно данных для краткой аналитики.</div>'; return '<div class="utm-help" style="font-size:13px;">' + pieces.join('<br>') + '</div>'; } function renderNewbiePanel(totals) { const firstMoney = totals.firstMoney || buildFirstMoneyStats([]); const firstClub = totals.newbie || buildNewbieStats([]); function renderSystemRows(systemStats, mainLabel, formulaText) { const main = systemStats.total || makeEmptyStats({ label: mainLabel }); const other = systemStats.other || makeEmptyStats({ label: 'Остальные' }); const rows = [main, other].concat(systemStats.components || []).filter(function (item) { return item.count || item.paidCount || item.paidAmount; }); return '<tr style="background:#f5faff;color:#52677a;"><td colspan="6" style="font-size:12px;font-weight:700;">' + escapeHtml(mainLabel + ' = ' + formulaText) + '</td></tr>' + rows.map(function (item) { const label = item.label || item.tag || item.key; const filterId = registerFilterRows(item.rows, label + ' — ' + formatNumber(item.count) + ' заказов, оплачено ' + formatMoney(item.paidAmount)); return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td><b>' + escapeHtml(label) + '</b></td>' + '<td class="num">' + formatNumber(item.count) + '</td>' + '<td class="num">' + formatNumber(item.paidCount) + '</td>' + '<td class="num">' + formatConversion(item.paidCount, item.count) + '</td>' + '<td class="num"><b>' + formatMoney(item.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(item.paidAmount, totals.totalPaidAmount) + '</td>' + '</tr>'; }).join(''); } return '<div class="utm-panel">' + '<div class="utm-panel-title">Первичные аудитории: два разных среза</div>' + '<div class="utm-help"><b>' + escapeHtml(FIRST_MONEY_GROUP_LABEL) + '</b> — люди, которые впервые принесли деньги: New + База. <b>' + escapeHtml(FIRST_CLUB_GROUP_LABEL) + '</b> — люди, которые впервые купили клуб/подписку: New + База + Марафонец. Строки кликабельны и фильтруют основную таблицу.</div>' + '<table class="utm-table utm-mini-table"><thead><tr>' + '<th>Группа</th><th class="num">Заказов</th><th class="num">Оплаченных</th><th class="num">Конверсия</th><th class="num">Оплачено</th><th class="num">% денег</th>' + '</tr></thead><tbody>' + renderSystemRows(firstMoney, FIRST_MONEY_GROUP_LABEL, 'New + База') + renderSystemRows(firstClub, FIRST_CLUB_GROUP_LABEL, 'New + База + Марафонец') + '</tbody></table>' + '</div>'; } function getClubAccessExamplesText(product) { const examples = (product && product.examples ? product.examples : []) .filter(Boolean) .slice(0, 2); if (!examples.length && product && product.title) examples.push(product.title); if (!examples.length) return ''; const suffix = product.examples && product.examples.length > 2 ? ' +' + (product.examples.length - 2) + ' ещё' : ''; return 'Пример: ' + examples.join(' / ') + suffix; } function renderClubAccessPanel(totals) { const stats = totals && totals.clubAccess ? totals.clubAccess : makeEmptyClubAccessStats(); if (!stats.ordersCount && !stats.accessCount) { return '<div class="utm-panel">' + '<div class="utm-panel-title">Что купили: онлайн-йога-клуб</div>' + '<div class="utm-help">В текущей выборке не найден доступ к онлайн-йога-клубу в колонке «Состав заказа». Если такие покупки есть, проверь, что колонка «Состав заказа» включена в таблице.</div>' + '</div>'; } const clubFilterId = registerFilterRows(stats.rows, 'Заказы с доступом к онлайн-йога-клубу: ' + formatNumber(stats.ordersCount) + ' заказов'); const rows = (stats.products || []).map(function (product) { const filterId = registerFilterRows(product.rows, product.label + ': ' + formatNumber(product.accessCount) + ' доступов'); return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td><b>' + escapeHtml(product.label) + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(getClubAccessExamplesText(product)) + '</div></td>' + '<td class="num">' + formatNumber(product.accessCount) + '</td>' + '<td class="num">' + formatNumber(product.paidAccessCount) + '</td>' + '<td class="num">' + formatConversion(product.paidAccessCount, product.accessCount) + '</td>' + '<td class="num"><b>' + formatNumber(product.paidMonthsTotal) + '</b></td>' + '<td class="num">' + (product.months ? formatNumber(product.months) : '—') + '</td>' + '</tr>'; }).join(''); const unknownNote = stats.unknownDurationCount ? '<div class="utm-help" style="margin-top:8px;">Есть доступы клуба, где срок не удалось распознать: <b>' + formatNumber(stats.unknownDurationCount) + '</b>. Они попали в количество доступов, но не добавлены в месяцы.</div>' : ''; return '<div class="utm-panel">' + '<div class="utm-panel-title">Что купили: онлайн-йога-клуб</div>' + '<div class="utm-help">Этот блок смотрит только колонку «Состав заказа» и объединяет покупки по длительности доступа к онлайн-йога-клубу: 1, 2, 3, 6, 12 месяцев и т.д. Если в заказе вместе с клубом есть марафон, интенсив или другой продукт, они здесь игнорируются и в месяцы клуба не попадают.</div>' + '<div class="utm-section-grid" style="margin-bottom:10px;">' + '<div class="utm-card utm-click" data-utm-filter-id="' + clubFilterId + '">' + '<div class="utm-card-title">Заказов с клубом</div>' + '<div class="utm-card-value">' + formatNumber(stats.ordersCount) + '</div>' + '<div class="utm-card-note">из них ' + formatNumber(stats.paidOrdersCount) + ' оплачено · CR ' + formatConversion(stats.paidOrdersCount, stats.ordersCount) + '</div>' + '</div>' + '<div class="utm-card">' + '<div class="utm-card-title">Оплаченных доступов</div>' + '<div class="utm-card-value">' + formatNumber(stats.paidAccessCount) + '</div>' + '<div class="utm-card-note">единиц доступа к клубу</div>' + '</div>' + '<div class="utm-card">' + '<div class="utm-card-title">Продано месяцев клуба</div>' + '<div class="utm-card-value">' + formatNumber(stats.paidMonthsTotal) + '</div>' + '<div class="utm-card-note">считаются только оплаченные доступы</div>' + '</div>' + '</div>' + '<div style="overflow-x:auto;"><table class="utm-table utm-mini-table">' + '<thead><tr>' + '<th>Доступ</th>' + '<th class="num">Найдено</th>' + '<th class="num">Оплачено</th>' + '<th class="num">Конверсия</th>' + '<th class="num">Месяцев продано</th>' + '<th class="num">Мес. в доступе</th>' + '</tr></thead>' + '<tbody>' + rows + '</tbody>' + '</table></div>' + unknownNote + '</div>'; } function renderSimpleStatsTable(items, totals, type, firstColTitle, hint) { if (!items || !items.length) return '<div class="utm-help">Нет данных.</div>'; const rows = items.map(function (item) { const label = item.label || item.tag || item.group || item.source || item.day; const filterId = registerFilterRows(item.rows, hint + ': ' + label + ' — ' + formatNumber(item.count) + ' заказов, оплачено ' + formatMoney(item.paidAmount)); const paidRows = (item.rows || []).filter(function (row) { return row && row.isPaid; }); const allListId = registerFilterRows(item.rows, hint + ': ' + label + ' — все заказы'); const paidListId = registerFilterRows(paidRows, hint + ': ' + label + ' — оплаченные заказы'); return '<tr>' + '<td class="utm-drilldown-link" data-utm-filter-id="' + filterId + '">' + escapeHtml(label) + '</td>' + '<td class="num utm-list-link" data-utm-list-filter-id="' + allListId + '">' + formatNumber(item.count) + '</td>' + '<td class="num utm-list-link" data-utm-list-filter-id="' + paidListId + '">' + formatNumber(item.paidCount) + '</td>' + '<td class="num">' + formatConversion(item.paidCount, item.count) + '</td>' + '<td class="num">' + formatMoney(item.paidAmount) + '</td>' + '<td class="num">' + formatPercentValue(item.paidAmount, totals.totalPaidAmount) + '</td>' + '</tr>'; }).join(''); return '<div class="utm-panel"><div class="utm-panel-title">' + escapeHtml(firstColTitle) + '</div><div class="utm-table-scroll" role="region" aria-label="' + escapeHtml(firstColTitle) + '"><table class="utm-table utm-mini-table">' + '<thead><tr><th>' + escapeHtml(firstColTitle) + '</th><th class="num">Заказов</th><th class="num">Оплаченных</th><th class="num">Конверсия</th><th class="num">Оплачено</th><th class="num">% денег</th></tr></thead>' + '<tbody>' + rows + '</tbody>' + '</table></div></div>'; } function renderSegmentsView(segments, totals) { const body = segments.map(function (segment, idx) { const isExpanded = Boolean(EXPANDED_SEGMENTS[segment.key]); const filterId = registerFilterRows(segment.rows, 'Сегмент: ' + segment.label + ' — ' + formatNumber(segment.count) + ' заказов, оплачено ' + formatMoney(segment.paidAmount)); const clubStats = getClubStatsForRows(segment.rows); const toggle = (segment.sourceStats && segment.sourceStats.length) ? '<button type="button" class="btn btn-xs btn-default" data-utm-toggle-segment-index="' + idx + '" style="min-width:26px;margin-right:6px;">' + (isExpanded ? '−' : '+') + '</button>' : '<span style="display:inline-block;width:32px;"></span>'; let html = '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td>' + (idx + 1) + '</td>' + '<td>' + toggle + '<b>' + escapeHtml(segment.label) + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(getTopBreakdownText(segment.sourceStats, 'источники')) + '</div></td>' + '<td class="num">' + formatNumber(segment.count) + '</td>' + '<td class="num">' + formatNumber(segment.paidCount) + '</td>' + '<td class="num"><b>' + formatConversion(segment.paidCount, segment.count) + '</b></td>' + '<td class="num">' + formatPercentValue(segment.paidCount, totals.totalPaidOrders) + '</td>' + '<td class="num"><b>' + formatMoney(segment.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(segment.paidAmount, totals.totalPaidAmount) + '</td>' + '<td class="num">' + formatClubCellFromStats(clubStats) + '</td>' + '<td class="num">' + formatMoney(segment.avgPaidCheck) + '</td>' + '</tr>'; if (isExpanded) { html += renderInlineChartRow(10, 'Диаграммы по сегменту «' + segment.label + '»', [ { id: 'utm-segment-sources-count-' + idx, title: 'Источники — оплаченные заказы' }, { id: 'utm-segment-sources-money-' + idx, title: 'Источники — деньги' } ]); html += renderNestedRows(segment.sourceStats, segment, totals, 'source', 1, 'Источники внутри «' + segment.label + '»'); } return html; }).join(''); return '<div class="utm-help">Это верхнеуровневая картина: из каких людей состоит выручка. Сверху — две круговые диаграммы: одна по количеству оплаченных заказов, вторая по деньгам. Нажмите на сегмент, чтобы увидеть эти заказы в основной таблице. Плюс раскрывает, из каких источников пришёл сегмент.</div>' + renderNewbiePanel(totals) + renderChartPanel('Сегменты — визуально', [ { id: 'utm-segments-all-count', title: 'Оплаченные заказы по сегментам' }, { id: 'utm-segments-all-money', title: 'Деньги по сегментам' } ]) + '<table class="utm-table utm-wide-table"><thead><tr>' + '<th style="width:44px;">#</th><th>Сегмент / кто купил</th><th class="num">Заказов</th><th class="num">Оплаченных</th><th class="num">Конверсия</th><th class="num">% опл. заказов</th><th class="num">Оплачено</th><th class="num">% денег</th><th class="num">Клуб</th><th class="num">Средний чек</th>' + '</tr></thead><tbody>' + body + '</tbody></table>'; } function getCompactClubProductLabel(product) { const label = product && product.label ? product.label : ''; return label.replace(CLUB_ACCESS_LABEL + ' — ', '') || '—'; } function getTopClubProductText(stats) { const products = stats && stats.products ? stats.products : []; const top = products.filter(function (item) { return item.paidAccessCount || item.accessCount; }).slice(0, 2); if (!top.length) return '—'; return top.map(function (item) { return getCompactClubProductLabel(item) + ': ' + formatNumber(item.paidAccessCount) + ' оплат'; }).join(', '); } function buildClubAccessBreakdown(rows, getKey, getLabel) { const map = {}; (rows || []).forEach(function (row) { if (!row || !row.clubAccessItems || !row.clubAccessItems.length) return; const key = getKey(row) || 'empty'; const label = getLabel(row) || key || '—'; if (!map[key]) { map[key] = { key: key, label: label, rows: [] }; } pushUniqueRow(map[key].rows, row); }); return Object.keys(map).map(function (key) { const item = map[key]; item.clubStats = buildClubAccessStats(item.rows); item.ordersCount = item.clubStats.ordersCount; item.paidOrdersCount = item.clubStats.paidOrdersCount; item.accessCount = item.clubStats.accessCount; item.paidAccessCount = item.clubStats.paidAccessCount; item.paidMonthsTotal = item.clubStats.paidMonthsTotal; item.topProductText = getTopClubProductText(item.clubStats); return item; }).sort(function (a, b) { if (b.paidMonthsTotal !== a.paidMonthsTotal) return b.paidMonthsTotal - a.paidMonthsTotal; if (b.paidAccessCount !== a.paidAccessCount) return b.paidAccessCount - a.paidAccessCount; if (b.paidOrdersCount !== a.paidOrdersCount) return b.paidOrdersCount - a.paidOrdersCount; return String(a.label).localeCompare(String(b.label), 'ru'); }); } function renderClubAccessProductsMiniTable(products, dayLabel) { const list = products || []; if (!list.length) return '<div class="utm-help">Нет доступов клуба в этом дне.</div>'; const rows = list.map(function (product) { const filterId = registerFilterRows(product.rows, 'День ' + dayLabel + ' / ' + product.label + ': ' + formatNumber(product.accessCount) + ' доступов'); return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td><b>' + escapeHtml(getCompactClubProductLabel(product)) + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(getClubAccessExamplesText(product)) + '</div></td>' + '<td class="num">' + formatNumber(product.accessCount) + '</td>' + '<td class="num">' + formatNumber(product.paidAccessCount) + '</td>' + '<td class="num"><b>' + formatNumber(product.paidMonthsTotal) + '</b></td>' + '</tr>'; }).join(''); return '<div style="overflow-x:auto;"><table class="utm-table utm-mini-table">' + '<thead><tr><th>Срок доступа</th><th class="num">Найдено</th><th class="num">Оплачено</th><th class="num">Месяцев</th></tr></thead>' + '<tbody>' + rows + '</tbody></table></div>'; } function renderClubAccessBreakdownMiniTable(items, dayLabel, firstColTitle) { const list = items || []; if (!list.length) return '<div class="utm-help">Нет данных.</div>'; const rows = list.map(function (item) { const filterId = registerFilterRows(item.rows, 'День ' + dayLabel + ' / ' + firstColTitle + ': ' + item.label + ' — ' + formatNumber(item.paidMonthsTotal) + ' мес. клуба'); const isSource = firstColTitle === 'Источник' || firstColTitle === 'Источники'; const labelHtml = isSource ? renderSourceLabel(item.label) : escapeHtml(item.label); return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td><b>' + labelHtml + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(item.topProductText || '') + '</div></td>' + '<td class="num">' + formatNumber(item.ordersCount) + '</td>' + '<td class="num">' + formatNumber(item.paidAccessCount) + '</td>' + '<td class="num"><b>' + formatNumber(item.paidMonthsTotal) + '</b></td>' + '</tr>'; }).join(''); return '<div style="overflow-x:auto;"><table class="utm-table utm-mini-table">' + '<thead><tr><th>' + escapeHtml(firstColTitle) + '</th><th class="num">Заказов с клубом</th><th class="num">Опл. доступов</th><th class="num">Месяцев</th></tr></thead>' + '<tbody>' + rows + '</tbody></table></div>'; } function formatClubAccessFunnel(total, paid) { const totalNum = Number(total) || 0; const paidNum = Number(paid) || 0; if (!totalNum) return 'нет доступов'; return formatNumber(totalNum) + ' всего · ' + formatNumber(paidNum) + ' оплачено · CR ' + formatConversion(paidNum, totalNum); } function getClubStatsForRows(rows) { return buildClubAccessStats(rows || []); } function formatClubCellFromStats(stats) { const data = stats || makeEmptyClubAccessStats(); if (!data.ordersCount && !data.paidMonthsTotal) return '<span class="utm-muted">—</span>'; return '<b>' + formatNumber(data.paidMonthsTotal) + ' мес.</b><br>' + '<span class="utm-muted">' + formatNumber(data.paidAccessCount) + ' доступов · CR ' + formatConversion(data.paidOrdersCount, data.ordersCount) + '</span>'; } function renderKpiCard(title, value, note, filterId) { return '<div class="utm-card' + (filterId ? ' utm-click' : '') + '"' + (filterId ? ' data-utm-filter-id="' + filterId + '"' : '') + '>' + '<div class="utm-card-title">' + escapeHtml(title) + '</div>' + '<div class="utm-card-value">' + escapeHtml(value) + '</div>' + '<div class="utm-card-note">' + escapeHtml(note || '') + '</div>' + '</div>'; } function renderBusinessTopList(title, items, metric, totals, firstColLabel) { const list = (items || []).filter(function (item) { return item && (item.paidAmount || item.paidCount || item.count); }).slice(0, 5); if (!list.length) { return '<div class="utm-panel"><div class="utm-panel-title">' + escapeHtml(title) + '</div><div class="utm-help">Нет данных.</div></div>'; } const rows = list.map(function (item, index) { const label = item.label || item.tag || item.group || item.source || item.day || '—'; const isSourceList = firstColLabel === 'Источник' || title.indexOf('Источник') !== -1 || title.indexOf('источник') !== -1; const labelHtml = isSourceList ? renderSourceLabel(label) : escapeHtml(label); const clubStats = getClubStatsForRows(item.rows); const filterId = registerFilterRows(item.rows, title + ': ' + label + ' — ' + formatMoney(item.paidAmount)); const mainValue = metric === 'club' ? formatNumber(clubStats.paidMonthsTotal) + ' мес.' : formatMoney(item.paidAmount); const note = metric === 'club' ? (formatNumber(clubStats.paidAccessCount) + ' доступов · ' + formatNumber(item.paidCount) + ' оплат') : (formatNumber(item.paidCount) + ' оплат · CR ' + formatConversion(item.paidCount, item.count)); return '<div class="utm-click" data-utm-filter-id="' + filterId + '" style="display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:8px;align-items:center;padding:7px 0;border-bottom:1px solid #eef2f6;">' + '<div style="width:24px;height:24px;border-radius:50%;background:#eef6ff;color:#4078a8;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">' + (index + 1) + '</div>' + '<div style="min-width:0;">' + '<div style="font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="' + escapeHtml(label) + '">' + labelHtml + '</div>' + '<div class="utm-muted" style="font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="' + escapeHtml(note) + '">' + escapeHtml(note) + '</div>' + '</div>' + '<div style="text-align:right;font-weight:800;font-size:15px;white-space:nowrap;">' + escapeHtml(mainValue) + '</div>' + '</div>'; }).join(''); return '<div class="utm-panel">' + '<div class="utm-panel-title">' + escapeHtml(title) + '</div>' + '<div>' + rows + '</div>' + '</div>'; } function renderOverviewMainInsights(segments, days, groups, totals) { const topGroup = (groups || [])[0]; const topSegment = (segments || [])[0]; const bestDay = (days || []).slice().sort(sortStats)[0]; const pieces = []; if (totals.totalPaidAmount) { pieces.push('За период оплачено <b>' + formatMoney(totals.totalPaidAmount) + '</b>, оплат: <b>' + formatNumber(totals.totalPaidOrders) + '</b>, CR: <b>' + formatConversion(totals.totalPaidOrders, totals.totalOrders) + '</b>.'); } if (ANALYSIS_MODE === MODE_MARATHON) { const tariffStats = buildTariffStats((LAST_SOURCE_INFO && LAST_SOURCE_INFO.rows) || []); const byPaidCount = tariffStats.slice().sort(function (a, b) { if (b.paidCount !== a.paidCount) return b.paidCount - a.paidCount; return b.paidAmount - a.paidAmount; }); const byMoney = tariffStats.slice().sort(function (a, b) { if (b.paidAmount !== a.paidAmount) return b.paidAmount - a.paidAmount; return b.paidCount - a.paidCount; }); const topByCount = byPaidCount[0]; const topByMoney = byMoney[0]; if (topByCount && topByCount.paidCount) { pieces.push('Чаще всего покупали тариф <b>' + escapeHtml(topByCount.label) + '</b>: <b>' + formatNumber(topByCount.paidCount) + ' оплат</b> (' + formatPercentValue(topByCount.paidCount, totals.totalPaidOrders) + ' всех оплат).'); } if (topByMoney && topByMoney.paidAmount) { pieces.push('Больше всего денег принёс тариф <b>' + escapeHtml(topByMoney.label) + '</b>: <b>' + formatMoney(topByMoney.paidAmount) + '</b> (' + formatPercentValue(topByMoney.paidAmount, totals.totalPaidAmount) + ' выручки).'); } const clubBundle = tariffStats.find(function (item) { return (item.rows || []).some(function (row) { return row && row.marathonTariff && row.marathonTariff.includesClub; }); }); if (clubBundle && clubBundle.paidCount) { pieces.push('Комбинированный тариф с клубом: <b>' + formatNumber(clubBundle.paidCount) + ' оплат</b>, <b>' + formatMoney(clubBundle.paidAmount) + '</b>.'); } } else { const club = totals.clubAccess || makeEmptyClubAccessStats(); const topClubProduct = club.products && club.products.length ? club.products[0] : null; if (club.paidMonthsTotal) { pieces.push('По клубу продано <b>' + formatNumber(club.paidMonthsTotal) + ' мес.</b>' + (topClubProduct ? ', чаще всего покупали <b>' + escapeHtml(getCompactClubProductLabel(topClubProduct)) + '</b>.' : '.')); } } if (topGroup) { pieces.push('Главный источник по деньгам — <b>' + escapeHtml(topGroup.group || topGroup.label) + '</b>: <b>' + formatMoney(topGroup.paidAmount) + '</b>.'); } if (topSegment) { pieces.push('Главный сегмент — <b>' + escapeHtml(topSegment.label) + '</b>: <b>' + formatMoney(topSegment.paidAmount) + '</b>.'); } if (bestDay) { pieces.push('Самый сильный день по деньгам — <b>' + escapeHtml(bestDay.label) + '</b>: <b>' + formatMoney(bestDay.paidAmount) + '</b>.'); } if (!pieces.length) return ''; return '<div style="background:#fffdf3;border:1px solid #f1e1a8;border-radius:10px;padding:12px;margin:10px 0;">' + '<div style="font-weight:800;margin-bottom:6px;">Главное</div>' + '<div style="font-size:13px;line-height:1.5;">' + pieces.map(function (piece) { return '• ' + piece; }).join('<br>') + '</div>' + '</div>'; } function renderClubCompactTopList(title, items, dayLabel, type) { const list = (items || []).filter(function (item) { return item && (item.paidMonthsTotal || item.paidAccessCount || item.accessCount || item.ordersCount); }).slice(0, 5); if (!list.length) { return '<div class="utm-panel" style="margin:0;"><div class="utm-panel-title" style="font-size:14px;">' + escapeHtml(title) + '</div><div class="utm-help">Нет данных.</div></div>'; } const rows = list.map(function (item, index) { const isProduct = type === 'product'; const isSource = type === 'source'; const label = isProduct ? getCompactClubProductLabel(item) : item.label; const labelHtml = isSource ? renderSourceLabel(label) : escapeHtml(label); const filterLabel = 'День ' + dayLabel + ' / ' + title + ': ' + label + ' — ' + formatNumber(item.paidMonthsTotal) + ' мес. клуба'; const filterId = registerFilterRows(item.rows, filterLabel); const totalAccess = Number(item.accessCount || item.ordersCount || 0) || 0; const paidAccess = Number(item.paidAccessCount || item.paidOrdersCount || 0) || 0; const note = isProduct ? formatClubAccessFunnel(totalAccess, paidAccess) : (formatClubAccessFunnel(totalAccess, paidAccess) + (item.topProductText ? ' · ' + item.topProductText : '')); const share = item.paidMonthsTotal ? formatNumber(item.paidMonthsTotal) + ' мес.' : '0 мес.'; return '<div class="utm-click" data-utm-filter-id="' + filterId + '" style="display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:8px;align-items:center;padding:7px 0;border-bottom:1px solid #eef2f6;">' + '<div style="width:24px;height:24px;border-radius:50%;background:#eef6ff;color:#4078a8;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">' + (index + 1) + '</div>' + '<div style="min-width:0;">' + '<div style="font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="' + escapeHtml(label) + '">' + labelHtml + '</div>' + '<div class="utm-muted" style="font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="' + escapeHtml(note) + '">' + escapeHtml(note) + '</div>' + '</div>' + '<div style="text-align:right;font-weight:800;font-size:15px;white-space:nowrap;">' + escapeHtml(share) + '</div>' + '</div>'; }).join(''); return '<div class="utm-panel" style="margin:0;">' + '<div class="utm-panel-title" style="font-size:14px;margin-bottom:4px;">' + escapeHtml(title) + '</div>' + '<div>' + rows + '</div>' + '</div>'; } function renderDayClubInsights(stats, sourceItems, segmentItems) { const topProduct = stats.products && stats.products.length ? stats.products[0] : null; const topSource = sourceItems && sourceItems.length ? sourceItems[0] : null; const topSegment = segmentItems && segmentItems.length ? segmentItems[0] : null; const pieces = []; if (topSource && topSource.paidMonthsTotal) { pieces.push('<b>' + escapeHtml(topSource.label) + '</b> дал(а) больше всего месяцев клуба — <b>' + formatNumber(topSource.paidMonthsTotal) + ' мес.</b>'); } if (topSegment && topSegment.paidMonthsTotal) { pieces.push('Главный сегмент — <b>' + escapeHtml(topSegment.label) + '</b>: <b>' + formatNumber(topSegment.paidMonthsTotal) + ' мес.</b>'); } if (topProduct && topProduct.paidAccessCount) { pieces.push('Чаще всего покупали <b>' + escapeHtml(getCompactClubProductLabel(topProduct)) + '</b>: <b>' + formatNumber(topProduct.paidAccessCount) + ' оплат</b>.'); } if (!pieces.length) return ''; return '<div style="background:#fffdf3;border:1px solid #f1e1a8;border-radius:10px;padding:10px 12px;margin:10px 0;">' + '<div style="font-weight:800;margin-bottom:5px;">Главное</div>' + '<div style="font-size:13px;line-height:1.45;">' + pieces.map(function (piece) { return '• ' + piece; }).join('<br>') + '</div>' + '</div>'; } function renderDayClubAccessPanel(day) { const stats = buildClubAccessStats(day.rows); if (!stats.ordersCount && !stats.accessCount) { return '<tr style="background:#fcfdff;"><td></td><td colspan="10"><div class="utm-help">В этот день не найден доступ к онлайн-йога-клубу в составе заказа.</div></td></tr>'; } const sourceItems = buildClubAccessBreakdown( day.rows, function (row) { return row.group || row.source || EMPTY_SOURCE_LABEL; }, function (row) { return row.group || row.source || EMPTY_SOURCE_LABEL; } ); const segmentItems = buildClubAccessBreakdown( day.rows, function (row) { return row.segmentTag || EMPTY_TAG_LABEL; }, function (row) { return row.segmentTag || EMPTY_TAG_LABEL; } ); const filterId = registerFilterRows(stats.rows, 'День ' + day.label + ' / все заказы с доступом к онлайн-йога-клубу'); return '<tr style="background:#fcfdff;"><td></td><td colspan="10">' + '<div class="utm-panel" style="margin:8px 0;">' + '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;flex-wrap:wrap;">' + '<div>' + '<div class="utm-panel-title" style="margin-bottom:2px;">Клуб внутри дня «' + escapeHtml(day.label) + '»</div>' + '<div class="utm-muted" style="font-size:12px;">Считаем только доступ к онлайн-йога-клубу из «Состава заказа». Марафон/интенсив внутри заказа не добавляет месяцы.</div>' + '</div>' + '<div class="utm-cell-click" data-utm-filter-id="' + filterId + '" style="font-size:12px;color:#4a78a8;white-space:nowrap;">Показать заказы с клубом</div>' + '</div>' + '<div class="utm-section-grid" style="margin:10px 0;">' + '<div class="utm-card utm-click" data-utm-filter-id="' + filterId + '">' + '<div class="utm-card-title">Месяцев клуба</div>' + '<div class="utm-card-value">' + formatNumber(stats.paidMonthsTotal) + '</div>' + '<div class="utm-card-note">продано оплаченных месяцев</div>' + '</div>' + '<div class="utm-card">' + '<div class="utm-card-title">Оплаченных доступов</div>' + '<div class="utm-card-value">' + formatNumber(stats.paidAccessCount) + '</div>' + '<div class="utm-card-note">единиц доступа к клубу</div>' + '</div>' + '<div class="utm-card">' + '<div class="utm-card-title">Заказов с клубом</div>' + '<div class="utm-card-value">' + formatNumber(stats.ordersCount) + '</div>' + '<div class="utm-card-note">из них ' + formatNumber(stats.paidOrdersCount) + ' оплачено · CR ' + formatConversion(stats.paidOrdersCount, stats.ordersCount) + '</div>' + '</div>' + '</div>' + renderDayClubInsights(stats, sourceItems, segmentItems) + '<div class="utm-section-grid">' + renderClubCompactTopList('Что покупали', stats.products, day.label, 'product') + renderClubCompactTopList('Откуда пришли', sourceItems, day.label, 'source') + renderClubCompactTopList('Кто купил', segmentItems, day.label, 'segment') + '</div>' + '<details style="margin-top:12px;">' + '<summary style="cursor:pointer;font-weight:700;color:#4a78a8;">Показать подробные таблицы</summary>' + '<div class="utm-section-grid" style="margin-top:10px;">' + '<div class="utm-panel"><div class="utm-panel-title" style="font-size:14px;">По срокам доступа</div>' + renderClubAccessProductsMiniTable(stats.products, day.label) + '</div>' + '<div class="utm-panel"><div class="utm-panel-title" style="font-size:14px;">По источникам трафика</div>' + renderClubAccessBreakdownMiniTable(sourceItems, day.label, 'Источник') + '</div>' + '<div class="utm-panel"><div class="utm-panel-title" style="font-size:14px;">По сегментам людей</div>' + renderClubAccessBreakdownMiniTable(segmentItems, day.label, 'Сегмент') + '</div>' + '</div>' + '</details>' + '</div>' + '</td></tr>'; } function renderDaysView(days, totals) { const body = days.map(function (day, idx) { const isExpanded = Boolean(EXPANDED_DAYS[day.key]); const filterId = registerFilterRows(day.rows, 'День: ' + day.label); const tariffs = buildTariffStats(day.rows); const ages = buildBaseAgeStats(day.rows); const topTariffs = tariffs.slice(0, 3).map(function (t) { const known = MARATHON_TARIFFS.find(function (x) { return x.key === t.key; }); const label = known ? known.shortLabel : t.label; return '<div class="utm-tariff-summary"><span class="utm-tariff-summary-name" title="' + escapeHtml(t.label) + '">' + escapeHtml(label) + '</span><span class="utm-tariff-summary-value">' + formatNumber(t.paidCount) + ' · ' + formatPercentValue(t.paidCount, day.paidCount) + '</span></div>'; }).join(''); const toggle = '<button type="button" class="btn btn-xs btn-default" data-utm-toggle-day-index="' + idx + '" style="min-width:26px;margin-right:6px;">' + (isExpanded ? '−' : '+') + '</button>'; let html = '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td>' + (idx + 1) + '</td><td>' + toggle + '<b>' + escapeHtml(day.label) + '</b></td>' + '<td class="num">' + formatNumber(day.count) + '</td><td class="num">' + formatNumber(day.paidCount) + '</td>' + '<td class="num"><b>' + formatConversion(day.paidCount, day.count) + '</b></td>' + '<td class="num"><b>' + formatMoney(day.paidAmount) + '</b><br><span class="utm-muted">' + formatPercentValue(day.paidAmount, totals.totalPaidAmount) + ' денег периода</span></td>' + '<td>' + (topTariffs || '—') + '</td>' + '<td class="num">' + formatMoney(day.avgPaidCheck) + '</td></tr>'; if (isExpanded) { const dayTotals = getTotals(day.rows); html += '<tr><td></td><td colspan="7"><div class="utm-day-details-grid">' + renderDimensionMiniTable(tariffs, dayTotals, 'Тарифы внутри дня', 'Тариф', 'day') + renderDimensionMiniTable(ages, dayTotals, 'Возраст в базе внутри дня', 'Возраст', 'day') + renderDimensionMiniTable(day.sourceStats || [], dayTotals, 'Источники внутри дня', 'Источник', 'day') + renderDimensionMiniTable(day.tagStats || [], dayTotals, 'Теги внутри дня', 'Тег', 'day') + '</div></td></tr>'; } return html; }).join(''); return '<div class="utm-help">Главный дневной экран для запуска: в каждой строке видны продажи тарифов и их доля среди оплат дня. Плюс раскрывает четыре среза этого дня: тарифы, возраст пользователя в базе, источники и теги. Каждая строка кликабельна.<button type="button" class="utm-help-icon" data-help-topic="main" title="Как читать отчёт">?</button></div>' + renderChartPanel('Дни — визуально', [{ id: 'utm-days-all-count', title: 'Оплаченные заказы по дням' }, { id: 'utm-days-all-money', title: 'Деньги по дням' }]) + '<div class="utm-responsive-table"><table class="utm-table utm-wide-table"><thead><tr><th>#</th><th>День</th><th class="num">Заказов</th><th class="num">Оплат</th><th class="num">CR</th><th class="num">Оплачено</th><th style="min-width:240px;">Тарифы: оплат и % дня</th><th class="num">Средний чек</th></tr></thead><tbody>' + body + '</tbody></table></div>' + '<details style="margin-top:12px;"><summary style="cursor:pointer;font-weight:700;color:#4a78a8;">Матрица: дни × сегменты</summary><div class="utm-responsive-table" style="margin-top:8px;">' + renderMatrixView(days, LAST_SEGMENT_ROWS, totals) + '</div></details>'; } function renderSourcesView(groups, totals) { const body = groups.map(function (group, groupIndex) { const canExpand = GROUPING_ENABLED && group.children && group.children.length && !(group.children.length === 1 && group.children[0].source === group.group); const isExpanded = Boolean(EXPANDED_GROUPS[group.key]); let html = renderGroupRow(group, groupIndex, totals, canExpand, isExpanded); if (isExpanded) { html += renderInlineChartRow(13, 'Диаграммы по источнику «' + group.group + '»', [ { id: 'utm-group-children-count-' + groupIndex, title: 'Подисточники — оплаченные заказы' }, { id: 'utm-group-children-money-' + groupIndex, title: 'Подисточники — деньги' }, { id: 'utm-group-tags-count-' + groupIndex, title: 'Сегменты — оплаченные заказы' }, { id: 'utm-group-tags-money-' + groupIndex, title: 'Сегменты — деньги' } ]); } if (isExpanded) html += renderNestedRows(group.tagStats, group, totals, 'tag-group', 1, 'Сегменты внутри «' + group.group + '»'); if (canExpand && isExpanded) { html += group.children.map(function (child, sourceIndex) { const sourceKey = getSourceExpandKey(groupIndex, sourceIndex, child.source); const sourceTagsExpanded = Boolean(EXPANDED_SOURCE_TAGS[sourceKey]); const rowHtml = renderChildRow(child, group, groupIndex, sourceIndex, totals, sourceKey, sourceTagsExpanded); const tagRows = sourceTagsExpanded ? renderNestedRows(child.tagStats, child, totals, 'tag-source', 2, 'Сегменты внутри «' + child.source + '»') : ''; return rowHtml + tagRows; }).join(''); } return html; }).join(''); return '<div class="utm-help">Здесь детализация источников: группа → подисточники → сегменты внутри каждого источника. Сверху — две общие круговые диаграммы по источникам, а внутри каждого раскрытого источника есть компактные диаграммы по подисточникам и сегментам. Нажимайте на строку или сектор для фильтра в основной таблице.</div>' + renderChartPanel('Источники — визуально', [ { id: 'utm-sources-all-count', title: 'Оплаченные заказы по источникам' }, { id: 'utm-sources-all-money', title: 'Деньги по источникам' } ]) + '<table class="utm-table utm-wide-table"><thead><tr>' + '<th style="width:44px;">#</th><th>Источник / сегмент</th><th>Кто внутри</th><th class="num">Заказов</th><th class="num">Оплаченных</th><th class="num">Конверсия</th><th class="num">% опл. заказов<br>от общего</th><th class="num">% опл. заказов<br>от родителя</th><th class="num">Оплачено</th><th class="num">% денег<br>от общего</th><th class="num">% денег<br>от родителя</th><th class="num">Клуб</th><th class="num">Средний чек</th>' + '</tr></thead><tbody>' + body + '</tbody></table>'; } function renderGroupRow(group, groupIndex, totals, canExpand, isExpanded) { const filterId = registerFilterRows(group.rows, 'Источник: ' + group.group + ' — ' + formatNumber(group.count) + ' заказов, оплачено ' + formatMoney(group.paidAmount)); const clubStats = getClubStatsForRows(group.rows); const toggle = canExpand || (group.tagStats && group.tagStats.length) ? '<button type="button" class="btn btn-xs btn-default" data-utm-toggle-group-index="' + groupIndex + '" style="min-width:26px;margin-right:6px;">' + (isExpanded ? '−' : '+') + '</button>' : '<span style="display:inline-block;width:32px;"></span>'; return '<tr class="utm-click" data-utm-filter-id="' + filterId + '" style="font-weight:700;background:#fbfbfb;">' + '<td>' + (groupIndex + 1) + '</td>' + '<td>' + toggle + renderSourceLabel(group.group) + '</td>' + '<td>' + renderTagSummary(group, group.paidCount) + '</td>' + '<td class="num">' + formatNumber(group.count) + '</td>' + '<td class="num">' + formatNumber(group.paidCount) + '</td>' + '<td class="num"><b>' + formatConversion(group.paidCount, group.count) + '</b></td>' + '<td class="num">' + formatPercentValue(group.paidCount, totals.totalPaidOrders) + '</td>' + '<td class="num utm-muted">—</td>' + '<td class="num">' + formatMoney(group.paidAmount) + '</td>' + '<td class="num">' + formatPercentValue(group.paidAmount, totals.totalPaidAmount) + '</td>' + '<td class="num utm-muted">—</td>' + '<td class="num">' + formatClubCellFromStats(clubStats) + '</td>' + '<td class="num">' + formatMoney(group.avgPaidCheck) + '</td>' + '</tr>'; } function renderChildRow(child, group, groupIndex, sourceIndex, totals, sourceKey, sourceTagsExpanded) { const filterId = registerFilterRows(child.rows, 'Подисточник: ' + child.source + ' — ' + formatNumber(child.count) + ' заказов, оплачено ' + formatMoney(child.paidAmount)); const clubStats = getClubStatsForRows(child.rows); const canExpandTags = child.tagStats && child.tagStats.length; const toggle = canExpandTags ? '<button type="button" class="btn btn-xs btn-default" data-utm-toggle-source-tags="' + escapeHtml(sourceKey) + '" style="min-width:26px;margin-right:6px;">' + (sourceTagsExpanded ? '−' : '+') + '</button>' : '<span style="display:inline-block;width:32px;"></span>'; return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td class="utm-muted"></td>' + '<td style="padding-left:28px;">' + toggle + '↳ ' + renderSourceLabel(child.source) + '</td>' + '<td>' + renderTagSummary(child, child.paidCount) + '</td>' + '<td class="num">' + formatNumber(child.count) + '</td>' + '<td class="num">' + formatNumber(child.paidCount) + '</td>' + '<td class="num"><b>' + formatConversion(child.paidCount, child.count) + '</b></td>' + '<td class="num">' + formatPercentValue(child.paidCount, totals.totalPaidOrders) + '</td>' + '<td class="num">' + formatPercentValue(child.paidCount, group.paidCount) + '</td>' + '<td class="num"><b>' + formatMoney(child.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(child.paidAmount, totals.totalPaidAmount) + '</td>' + '<td class="num">' + formatPercentValue(child.paidAmount, group.paidAmount) + '</td>' + '<td class="num">' + formatClubCellFromStats(clubStats) + '</td>' + '<td class="num">' + formatMoney(child.avgPaidCheck) + '</td>' + '</tr>'; } function renderNestedRows(items, parent, totals, type, indentLevel, title) { if (!items || !items.length) return ''; const colspan = CURRENT_VIEW === 'sources' ? 13 : (CURRENT_VIEW === 'days' ? 11 : (CURRENT_VIEW === 'segments' ? 10 : 9)); const indent = 26 + indentLevel * 24; const parentPaidCount = parent.paidCount || 0; const parentPaidAmount = parent.paidAmount || 0; let html = '<tr style="background:#f5faff;color:#52677a;"><td></td><td colspan="' + (colspan - 1) + '" style="font-size:12px;font-weight:700;">' + escapeHtml(title || 'Детализация') + '</td></tr>'; html += items.map(function (item) { const label = item.label || item.tag || item.source || item.day || item.group; const filterId = registerFilterRows(item.rows, label + ' — ' + formatNumber(item.count) + ' заказов, оплачено ' + formatMoney(item.paidAmount)); if (CURRENT_VIEW === 'sources') { return '<tr class="utm-click" data-utm-filter-id="' + filterId + '" style="background:#fcfdff;">' + '<td></td>' + '<td style="padding-left:' + indent + 'px;">• ' + (type && String(type).indexOf('source') !== -1 ? renderSourceLabel(label) : escapeHtml(label)) + '</td>' + '<td class="utm-muted">' + escapeHtml(getRawTagsHint(item.rows)) + '</td>' + '<td class="num">' + formatNumber(item.count) + '</td>' + '<td class="num">' + formatNumber(item.paidCount) + '</td>' + '<td class="num"><b>' + formatConversion(item.paidCount, item.count) + '</b></td>' + '<td class="num">' + formatPercentValue(item.paidCount, totals.totalPaidOrders) + '</td>' + '<td class="num">' + formatPercentValue(item.paidCount, parentPaidCount) + '</td>' + '<td class="num"><b>' + formatMoney(item.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(item.paidAmount, totals.totalPaidAmount) + '</td>' + '<td class="num">' + formatPercentValue(item.paidAmount, parentPaidAmount) + '</td>' + '<td class="num">' + formatClubCellFromStats(getClubStatsForRows(item.rows)) + '</td>' + '<td class="num">' + formatMoney(item.avgPaidCheck) + '</td>' + '</tr>'; } return '<tr class="utm-click" data-utm-filter-id="' + filterId + '" style="background:#fcfdff;">' + '<td></td>' + '<td style="padding-left:' + indent + 'px;">• ' + (type && String(type).indexOf('source') !== -1 ? renderSourceLabel(label) : escapeHtml(label)) + '</td>' + '<td class="num">' + formatNumber(item.count) + '</td>' + '<td class="num">' + formatNumber(item.paidCount) + '</td>' + '<td class="num"><b>' + formatConversion(item.paidCount, item.count) + '</b></td>' + '<td class="num">' + formatPercentValue(item.paidCount, totals.totalPaidOrders) + '</td>' + '<td class="num"><b>' + formatMoney(item.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(item.paidAmount, totals.totalPaidAmount) + '</td>' + (CURRENT_VIEW === 'days' ? '<td class="num">' + (function () { const ns = buildNewbieStats(item.rows).total; return '<b>' + formatMoney(ns.paidAmount) + '</b><br><span class="utm-muted">' + formatNumber(ns.paidCount) + ' оплат / ' + formatConversion(ns.paidCount, ns.count) + '</span>'; })() + '</td>' : '') + (CURRENT_VIEW === 'days' ? '<td class="num">' + (function () { const cs = buildClubAccessStats(item.rows); return cs.ordersCount ? '<b>' + formatNumber(cs.paidMonthsTotal) + ' мес.</b><br><span class="utm-muted">' + formatNumber(cs.paidAccessCount) + ' доступов</span>' : '<span class="utm-muted">—</span>'; })() + '</td>' : '') + (CURRENT_VIEW === 'segments' ? '<td class="num">' + formatClubCellFromStats(getClubStatsForRows(item.rows)) + '</td>' : '') + '<td class="num">' + formatMoney(item.avgPaidCheck) + '</td>' + '</tr>'; }).join(''); return html; } function getFirstClubOrderedComponents(components) { const order = ['New', 'База', 'Марафонец']; const map = {}; (components || []).forEach(function (item) { map[item.label || item.tag || item.key] = item; }); return order.map(function (label) { return map[label] || makeEmptyStats({ key: 'firstclub-component-' + label, label: label, tag: label }); }).filter(function (item) { return item.count || item.paidCount || item.paidAmount || item.label === 'New' || item.label === 'База' || item.label === 'Марафонец'; }); } function renderFirstClubComponentCards(components) { const items = getFirstClubOrderedComponents(components); return '<div class="utm-section-grid" style="margin-bottom:10px;">' + items.map(function (item) { const label = item.label || item.tag || item.key; const clubStats = buildClubAccessStats(item.rows || []); const filterId = registerFilterRows(item.rows || [], FIRST_CLUB_GROUP_LABEL + ' / компонент ' + label + ': ' + formatNumber(item.count) + ' заказов'); return renderKpiCard( label, formatMoney(item.paidAmount || 0), formatNumber(item.paidCount || 0) + ' оплат · CR ' + formatConversion(item.paidCount || 0, item.count || 0) + ' · ' + formatNumber(clubStats.paidMonthsTotal || 0) + ' мес. клуба', (item.rows && item.rows.length) ? filterId : '' ); }).join('') + '</div>'; } function renderFirstClubInsights(totalStats, components, days, sources, clubStats) { const topComponent = (components || []).slice().sort(sortStats)[0]; const topDay = (days || []).slice().sort(sortStats)[0]; const topSource = (sources || []).slice().sort(sortStats)[0]; const topProduct = clubStats && clubStats.products && clubStats.products.length ? clubStats.products[0] : null; const pieces = []; if (totalStats && totalStats.paidAmount) { pieces.push('Новички клуба принесли <b>' + formatMoney(totalStats.paidAmount) + '</b>: <b>' + formatNumber(totalStats.paidCount) + '</b> оплат, CR <b>' + formatConversion(totalStats.paidCount, totalStats.count) + '</b>.'); } if (topComponent && topComponent.paidAmount) { pieces.push('Самый сильный компонент когорты — <b>' + escapeHtml(topComponent.label || topComponent.tag) + '</b>: <b>' + formatMoney(topComponent.paidAmount) + '</b>.'); } if (topSource && topSource.paidAmount) { pieces.push('Главный источник новичков клуба — <b>' + escapeHtml(topSource.group || topSource.label) + '</b>: <b>' + formatMoney(topSource.paidAmount) + '</b>.'); } if (topDay && topDay.paidAmount) { pieces.push('Самый сильный день входа — <b>' + escapeHtml(topDay.label) + '</b>: <b>' + formatMoney(topDay.paidAmount) + '</b>.'); } if (clubStats && clubStats.paidMonthsTotal) { pieces.push('Эта когорта купила <b>' + formatNumber(clubStats.paidMonthsTotal) + ' мес. клуба</b>' + (topProduct ? ', чаще всего — <b>' + escapeHtml(getCompactClubProductLabel(topProduct)) + '</b>.' : '.')); } if (!pieces.length) return ''; return '<div style="background:#fffdf3;border:1px solid #f1e1a8;border-radius:10px;padding:12px;margin:10px 0;">' + '<div style="font-weight:800;margin-bottom:6px;">Главное по новичкам клуба</div>' + '<div style="font-size:13px;line-height:1.5;">' + pieces.map(function (piece) { return '• ' + piece; }).join('<br>') + '</div>' + '</div>'; } function renderFirstClubComponentTable(components, totalStats) { const items = getFirstClubOrderedComponents(components).filter(function (item) { return item.count || item.paidCount || item.paidAmount; }); if (!items.length) return '<div class="utm-help">Нет данных по компонентам.</div>'; const rows = items.map(function (item) { const label = item.label || item.tag || item.key; const clubStats = buildClubAccessStats(item.rows || []); const filterId = registerFilterRows(item.rows || [], FIRST_CLUB_GROUP_LABEL + ' / ' + label + ': ' + formatNumber(item.count) + ' заказов'); return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td><b>' + escapeHtml(label) + '</b></td>' + '<td class="num">' + formatNumber(item.count) + '</td>' + '<td class="num">' + formatNumber(item.paidCount) + '</td>' + '<td class="num">' + formatConversion(item.paidCount, item.count) + '</td>' + '<td class="num"><b>' + formatMoney(item.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(item.paidAmount, totalStats.paidAmount) + '</td>' + '<td class="num">' + formatClubCellFromStats(clubStats) + '</td>' + '</tr>'; }).join(''); return '<div style="overflow-x:auto;"><table class="utm-table utm-mini-table">' + '<thead><tr><th>Компонент</th><th class="num">Заказов</th><th class="num">Оплаченных</th><th class="num">CR</th><th class="num">Оплачено</th><th class="num">% денег</th><th class="num">Клуб</th></tr></thead>' + '<tbody>' + rows + '</tbody></table></div>'; } function renderFirstClubDayTable(days, totalStats) { const list = (days || []).filter(function (day) { return day.count || day.paidCount || day.paidAmount; }); if (!list.length) return '<div class="utm-help">Нет данных по дням.</div>'; const rows = list.map(function (day) { const clubStats = buildClubAccessStats(day.rows || []); const topSource = (day.sourceStats || [])[0]; const filterId = registerFilterRows(day.rows || [], FIRST_CLUB_GROUP_LABEL + ' / день ' + day.label + ': ' + formatNumber(day.count) + ' заказов'); return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td><b>' + escapeHtml(day.label) + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(topSource ? 'топ источник: ' + (topSource.group || topSource.label || topSource.source) : '') + '</div></td>' + '<td class="num">' + formatNumber(day.count) + '</td>' + '<td class="num">' + formatNumber(day.paidCount) + '</td>' + '<td class="num">' + formatConversion(day.paidCount, day.count) + '</td>' + '<td class="num"><b>' + formatMoney(day.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(day.paidAmount, totalStats.paidAmount) + '</td>' + '<td class="num">' + formatClubCellFromStats(clubStats) + '</td>' + '</tr>'; }).join(''); return '<div style="overflow-x:auto;"><table class="utm-table utm-mini-table">' + '<thead><tr><th>День входа</th><th class="num">Заказов</th><th class="num">Оплаченных</th><th class="num">CR</th><th class="num">Оплачено</th><th class="num">% денег</th><th class="num">Клуб</th></tr></thead>' + '<tbody>' + rows + '</tbody></table></div>'; } function renderFirstClubView(totals) { const cohort = totals && totals.newbie ? totals.newbie : buildNewbieStats([]); const total = cohort.total || makeEmptyStats({ label: FIRST_CLUB_GROUP_LABEL }); const rows = total.rows || []; if (!rows.length) { const metricFirstAge = totals && totals.clubBalance ? totals.clubBalance.firstBaseAge : makeEmptyFirstClubBaseAgeStats(); return '<div class="utm-panel">' + '<div class="utm-panel-title">Новички клуба</div>' + '<div class="utm-help">В текущей выборке нет заказов, которые попали в старую теговую когорту первого вступления в клуб: New + База + Марафонец.</div>' + '</div>' + renderFirstClubBaseAgePanel(metricFirstAge, totals, 'Факт первой клубной покупки по Club balance'); } const components = cohort.components || []; const sources = buildSourceStats(rows); const days = buildDayStats(rows); const clubStats = buildClubAccessStats(rows); const filterId = registerFilterRows(rows, FIRST_CLUB_GROUP_LABEL + ': вся когорта первого вступления в клуб'); const metricFirstAge = totals && totals.clubBalance ? totals.clubBalance.firstBaseAge : makeEmptyFirstClubBaseAgeStats(); return '<div class="utm-help">Эта вкладка отдельно показывает приток свежей аудитории в клуб. Верхний блок — факт первой клубной покупки по допполям заказа. Ниже остаётся старый теговый срез New + База + Марафонец.</div>' + renderFirstClubBaseAgePanel(metricFirstAge, totals, 'Факт первой клубной покупки по Club balance') + '<div class="utm-section-grid" style="margin-bottom:10px;">' + renderKpiCard('Деньги новичков клуба', formatMoney(total.paidAmount), formatNumber(total.paidCount) + ' оплат · CR ' + formatConversion(total.paidCount, total.count), filterId) + renderKpiCard('Заказов в когорте', formatNumber(total.count), 'из них ' + formatNumber(total.paidCount) + ' оплачено', filterId) + renderKpiCard('Месяцев клуба', formatNumber(clubStats.paidMonthsTotal), formatNumber(clubStats.paidAccessCount) + ' оплаченных доступов', filterId) + renderKpiCard('Доля в деньгах', formatPercentValue(total.paidAmount, totals.totalPaidAmount), 'от всей суммы оплат периода', filterId) + '</div>' + renderFirstClubInsights(total, components, days, sources, clubStats) + renderFirstClubComponentCards(components) + '<div class="utm-section-grid">' + renderBusinessTopList('Дни входа новичков', days.slice().sort(sortStats), 'money', totals, 'День') + renderBusinessTopList('Источники новичков', sources, 'money', totals, 'Источник') + renderClubCompactTopList('Что купили по клубу', clubStats.products || [], 'новички клуба', 'product') + '</div>' + '<details style="margin-top:12px;">' + '<summary style="cursor:pointer;font-weight:700;color:#4a78a8;">Показать подробные таблицы по новичкам клуба</summary>' + '<div class="utm-section-grid" style="margin-top:10px;">' + '<div class="utm-panel"><div class="utm-panel-title">Компоненты когорты</div>' + renderFirstClubComponentTable(components, total) + '</div>' + '<div class="utm-panel"><div class="utm-panel-title">По дням</div>' + renderFirstClubDayTable(days, total) + '</div>' + '<div class="utm-panel"><div class="utm-panel-title">По источникам</div>' + renderClubAccessBreakdownMiniTable(buildClubAccessBreakdown(rows, function (row) { return row.group || row.source || EMPTY_SOURCE_LABEL; }, function (row) { return row.group || row.source || EMPTY_SOURCE_LABEL; }), 'новички клуба', 'Источник') + '</div>' + '</div>' + '</details>'; } function renderClubBalanceView(totals) { const stats = totals && totals.clubBalance ? totals.clubBalance : makeEmptyClubBalanceMetricStats(); const firstAge = stats.firstBaseAge || makeEmptyFirstClubBaseAgeStats(); if (!stats.hasMetricData) { return '<div class="utm-panel">' + '<div class="utm-panel-title">Club balance</div>' + '<div class="utm-help">В текущей собранной выборке пока нет значений <b>Club balance before order days</b>. Для анализа прошлых месяцев добавь в таблицу заказов две колонки: <b>Club balance before order days</b> и <b>Days in base before first club order</b>. Обогащение теперь вынесено в отдельный админский инструмент: сначала заполни поля там, затем обнови отчёт.</div>' + '<div class="utm-section-grid">' + renderKpiCard('Поле Club balance', CLUB_BALANCE_FIELD_ID, '99999 = первый клубный заказ', '') + renderKpiCard('Поле дней в базе', FIRST_CLUB_BASE_AGE_FIELD_ID, 'заполняется только для первых клубных заказов', '') + '</div>' + '</div>'; } const firstEntryFilterId = registerFilterRows(stats.firstEntryRows, 'Первый клубный заказ: ' + formatNumber(stats.firstEntryRows.length) + ' заказов'); const extensionFilterId = registerFilterRows(stats.extensionRows, 'Продления до окончания доступа: ' + formatNumber(stats.extensionRows.length) + ' заказов'); const reactivationFilterId = registerFilterRows(stats.reactivationRows, 'Возвраты после паузы: ' + formatNumber(stats.reactivationRows.length) + ' заказов'); const zeroFilterId = registerFilterRows(stats.zeroBalanceRows, 'Покупка в день окончания доступа: ' + formatNumber(stats.zeroBalanceRows.length) + ' заказов'); return '<div class="utm-help">Эта вкладка анализирует допполя заказа: <b>Club balance before order days</b> и <b>Days in base before first club order</b>. Код <b>' + CLUB_BALANCE_FIRST_ENTRY_CODE + '</b> считается отдельной категорией и не участвует в средних значениях по дням.</div>' + '<div class="utm-section-grid" style="margin-bottom:10px;">' + renderKpiCard('Заказов с Club balance', formatNumber(stats.rows.length), (stats.hasClubBalanceColumn ? 'значения взяты из колонки таблицы' : 'значения рассчитаны в текущей сессии'), '') + renderKpiCard('Первые клубные покупки', formatNumber(stats.firstEntryRows.length), formatMoney(sumPaidAmount(stats.firstEntryRows)) + ' · код ' + CLUB_BALANCE_FIRST_ENTRY_CODE, firstEntryFilterId) + renderKpiCard('Продления до окончания', formatNumber(stats.extensionRows.length), 'медиана остатка ' + formatDaysNumber(stats.extensionMedianDays), extensionFilterId) + renderKpiCard('Возвраты после паузы', formatNumber(stats.reactivationRows.length), 'медиана паузы ' + formatDaysNumber(stats.reactivationMedianGapDays), reactivationFilterId) + renderKpiCard('В день окончания', formatNumber(stats.zeroBalanceRows.length), 'Club balance = 0', zeroFilterId) + renderKpiCard('Дней в базе / первые', firstAge.hasData ? formatDaysNumber(firstAge.medianDays) : '—', 'медиана до первого клуба', '') + '</div>' + renderClubBalanceInsights(stats, firstAge, totals) + renderChartPanel('Club balance — визуально', [ { id: 'utm-club-balance-count', title: 'Оплаченные заказы по состоянию клуба' }, { id: 'utm-club-balance-money', title: 'Деньги по состоянию клуба' }, { id: 'utm-first-club-base-age-count', title: 'Первые покупки: скорость' }, { id: 'utm-first-club-base-age-money', title: 'Первые покупки: деньги' } ], true) + renderClubBalanceCohortTable(stats, totals) + renderFirstClubBaseAgePanel(firstAge, totals, 'Скорость первой покупки клуба'); } function renderClubBalanceInsights(stats, firstAge, totals) { const pieces = []; const totalMoney = totals && totals.totalPaidAmount ? totals.totalPaidAmount : 0; const firstMoney = sumPaidAmount(stats.firstEntryRows); const extensionMoney = sumPaidAmount(stats.extensionRows); const reactivationMoney = sumPaidAmount(stats.reactivationRows); if (stats.rows.length) { pieces.push('В выборке найдено <b>' + formatNumber(stats.rows.length) + '</b> оплаченных заказов с Club balance.'); } if (stats.firstEntryRows.length) { pieces.push('Первые клубные покупки: <b>' + formatNumber(stats.firstEntryRows.length) + '</b> заказов, <b>' + formatMoney(firstMoney) + '</b> (' + formatPercentValue(firstMoney, totalMoney) + ' денег).'); } if (stats.extensionRows.length) { pieces.push('Продления до окончания доступа: <b>' + formatNumber(stats.extensionRows.length) + '</b> заказов, медианный остаток <b>' + formatDaysNumber(stats.extensionMedianDays) + '</b>.'); } if (stats.reactivationRows.length) { pieces.push('Возвраты после паузы: <b>' + formatNumber(stats.reactivationRows.length) + '</b> заказов, медианная пауза <b>' + formatDaysNumber(stats.reactivationMedianGapDays) + '</b>.'); } if (firstAge && firstAge.hasData) { pieces.push('До первой покупки клуба медианно проходит <b>' + formatDaysNumber(firstAge.medianDays) + '</b>; в первые 30 дней покупают <b>' + formatNumber(firstAge.firstMonthRows.length) + '</b> человек.'); } if (!pieces.length) return ''; return '<div style="background:#fffdf3;border:1px solid #f1e1a8;border-radius:10px;padding:12px;margin:10px 0;">' + '<div style="font-weight:800;margin-bottom:6px;">Главное по Club balance</div>' + '<div style="font-size:13px;line-height:1.5;">' + pieces.map(function (piece) { return '• ' + piece; }).join('<br>') + '</div>' + '</div>'; } function renderClubBalanceCohortTable(stats, totals) { const list = stats && stats.cohorts ? stats.cohorts : []; if (!list.length) return '<div class="utm-panel"><div class="utm-panel-title">Состояние клуба на момент покупки</div><div class="utm-help">Нет данных.</div></div>'; const rows = list.map(function (item) { const filterId = registerFilterRows(item.rows, 'Club balance / ' + item.label + ': ' + formatNumber(item.count) + ' заказов'); const values = (item.balanceValues || []).filter(function (value) { return Number(value) !== CLUB_BALANCE_FIRST_ENTRY_CODE; }); const avg = averageNumbers(values.map(function (v) { return Number(v) < 0 ? Math.abs(Number(v)) : Number(v); })); const median = medianNumbers(values.map(function (v) { return Number(v) < 0 ? Math.abs(Number(v)) : Number(v); })); const valueText = item.key === 'first-entry' ? String(CLUB_BALANCE_FIRST_ENTRY_CODE) : (values.length ? ('средн. ' + formatDaysNumber(avg) + ' / медиана ' + formatDaysNumber(median)) : '—'); return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td><b>' + escapeHtml(item.label) + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(item.note || '') + '</div></td>' + '<td class="num">' + formatNumber(item.count) + '</td>' + '<td class="num">' + formatNumber(item.paidCount) + '</td>' + '<td class="num">' + formatConversion(item.paidCount, item.count) + '</td>' + '<td class="num"><b>' + formatMoney(item.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(item.paidAmount, totals.totalPaidAmount) + '</td>' + '<td class="num">' + formatMoney(item.avgPaidCheck) + '</td>' + '<td class="num">' + escapeHtml(valueText) + '</td>' + '</tr>'; }).join(''); return '<div class="utm-panel">' + '<div class="utm-panel-title">Состояние клуба на момент покупки</div>' + '<div class="utm-help">Группировка по числу в поле Club balance. Положительные значения — человек продлился до окончания доступа. Отрицательные — вернулся после паузы. 99999 — первая клубная покупка.</div>' + '<div style="overflow-x:auto;"><table class="utm-table utm-mini-table">' + '<thead><tr><th>Когорта</th><th class="num">Заказов</th><th class="num">Оплаченных</th><th class="num">CR</th><th class="num">Оплачено</th><th class="num">% денег</th><th class="num">Средний чек</th><th class="num">Дни</th></tr></thead>' + '<tbody>' + rows + '</tbody>' + '</table></div>' + '</div>'; } function renderFirstClubBaseAgePanel(firstAge, totals, title) { const stats = firstAge || makeEmptyFirstClubBaseAgeStats(); const panelTitle = title || 'Скорость первой покупки клуба'; if (!stats.hasData) { return '<div class="utm-panel">' + '<div class="utm-panel-title">' + escapeHtml(panelTitle) + '</div>' + '<div class="utm-help">Нет заполненных значений <b>Days in base before first club order</b>. Это поле заполняется только у заказов, где Club balance = <b>' + CLUB_BALANCE_FIRST_ENTRY_CODE + '</b>. Проверь, что в таблицу добавлена колонка с этим допполем или сначала заполни это поле отдельным админским инструментом.</div>' + '</div>'; } const fastFilterId = registerFilterRows(stats.fast0to7Rows, 'Первая покупка клуба за 0–7 дней: ' + formatNumber(stats.fast0to7Rows.length)); const monthFilterId = registerFilterRows(stats.firstMonthRows, 'Первая покупка клуба за 0–30 дней: ' + formatNumber(stats.firstMonthRows.length)); const oldFilterId = registerFilterRows(stats.oldBaseRows, 'Первая покупка клуба из старой базы 181+ дней: ' + formatNumber(stats.oldBaseRows.length)); const cards = '<div class="utm-section-grid" style="margin-bottom:10px;">' + renderKpiCard('Первые покупки с возрастом базы', formatNumber(stats.rows.length), 'значений в поле ' + FIRST_CLUB_BASE_AGE_FIELD_ID, '') + renderKpiCard('Медиана дней в базе', formatDaysNumber(stats.medianDays), 'среднее ' + formatDaysNumber(stats.avgDays), '') + renderKpiCard('0–7 дней', formatNumber(stats.fast0to7Rows.length), formatPercentValue(stats.fast0to7Rows.length, stats.rows.length) + ' первых покупок', fastFilterId) + renderKpiCard('0–30 дней', formatNumber(stats.firstMonthRows.length), formatPercentValue(stats.firstMonthRows.length, stats.rows.length) + ' первых покупок', monthFilterId) + renderKpiCard('181+ дней', formatNumber(stats.oldBaseRows.length), 'старая база / реактивация', oldFilterId) + '</div>'; const rows = (stats.cohorts || []).map(function (item) { const filterId = registerFilterRows(item.rows, 'Days in base / ' + item.label + ': ' + formatNumber(item.count) + ' заказов'); const values = (item.ageValues || []).map(Number).filter(function (value) { return Number.isFinite(value); }); return '<tr class="utm-click" data-utm-filter-id="' + filterId + '">' + '<td><b>' + escapeHtml(item.label) + '</b><div class="utm-muted" style="font-size:12px;">' + escapeHtml(item.note || '') + '</div></td>' + '<td class="num">' + formatNumber(item.count) + '</td>' + '<td class="num">' + formatNumber(item.paidCount) + '</td>' + '<td class="num"><b>' + formatMoney(item.paidAmount) + '</b></td>' + '<td class="num">' + formatPercentValue(item.count, stats.rows.length) + '</td>' + '<td class="num">' + formatDaysNumber(medianNumbers(values)) + '</td>' + '<td class="num">' + formatMoney(item.avgPaidCheck) + '</td>' + '</tr>'; }).join(''); return '<div class="utm-panel">' + '<div class="utm-panel-title">' + escapeHtml(panelTitle) + '</div>' + '<div class="utm-help">Анализируются только первые клубные покупки: Club balance = <b>' + CLUB_BALANCE_FIRST_ENTRY_CODE + '</b>. Поле <b>Days in base before first club order</b> показывает, сколько дней прошло от регистрации клиента в базе до первой покупки клуба.</div>' + cards + '<div style="overflow-x:auto;"><table class="utm-table utm-mini-table">' + '<thead><tr><th>Дней в базе до первого клуба</th><th class="num">Заказов</th><th class="num">Оплаченных</th><th class="num">Оплачено</th><th class="num">% первых</th><th class="num">Медиана</th><th class="num">Средний чек</th></tr></thead>' + '<tbody>' + rows + '</tbody>' + '</table></div>' + '</div>'; } function sumPaidAmount(rows) { return (rows || []).reduce(function (sum, row) { return sum + (row && row.isPaid ? Number(row.paidAmount) || 0 : 0); }, 0); } function renderClubView(totals) { const stats = totals && totals.clubAccess ? totals.clubAccess : makeEmptyClubAccessStats(); if (!stats.ordersCount && !stats.accessCount) { return '<div class="utm-panel">' + '<div class="utm-panel-title">Клуб</div>' + '<div class="utm-help">В текущей выборке не найден доступ к онлайн-йога-клубу в колонке «Состав заказа».</div>' + '</div>'; } const sourceItems = buildClubAccessBreakdown( stats.rows, function (row) { return row.group || row.source || EMPTY_SOURCE_LABEL; }, function (row) { return row.group || row.source || EMPTY_SOURCE_LABEL; } ); const segmentItems = buildClubAccessBreakdown( stats.rows, function (row) { return row.segmentTag || EMPTY_TAG_LABEL; }, function (row) { return row.segmentTag || EMPTY_TAG_LABEL; } ); const filterId = registerFilterRows(stats.rows, 'Все заказы с доступом к онлайн-йога-клубу'); return '<div class="utm-help">Вкладка «Клуб» отвечает на отдельный бизнес-вопрос: сколько именно доступа к клубу продали. Главный показатель здесь — месяцы клуба, деньги показываются в других вкладках.</div>' + '<div class="utm-section-grid" style="margin-bottom:10px;">' + renderKpiCard('Месяцев клуба', formatNumber(stats.paidMonthsTotal), 'продано оплаченных месяцев', filterId) + renderKpiCard('Оплаченных доступов', formatNumber(stats.paidAccessCount), formatClubAccessFunnel(stats.accessCount, stats.paidAccessCount), filterId) + renderKpiCard('Заказов с клубом', formatNumber(stats.ordersCount), 'из них ' + formatNumber(stats.paidOrdersCount) + ' оплачено · CR ' + formatConversion(stats.paidOrdersCount, stats.ordersCount), filterId) + '</div>' + renderDayClubInsights(stats, sourceItems, segmentItems) + '<div class="utm-section-grid">' + renderClubCompactTopList('Что покупали', stats.products || [], 'весь период', 'product') + renderClubCompactTopList('Откуда пришли', sourceItems, 'весь период', 'source') + renderClubCompactTopList('Кто купил', segmentItems, 'весь период', 'segment') + '</div>' + '<details style="margin-top:12px;">' + '<summary style="cursor:pointer;font-weight:700;color:#4a78a8;">Показать подробные таблицы по клубу</summary>' + '<div class="utm-section-grid" style="margin-top:10px;">' + '<div class="utm-panel"><div class="utm-panel-title">По срокам доступа</div>' + renderClubAccessProductsMiniTable(stats.products, 'весь период') + '</div>' + '<div class="utm-panel"><div class="utm-panel-title">По источникам</div>' + renderClubAccessBreakdownMiniTable(sourceItems, 'весь период', 'Источник') + '</div>' + '<div class="utm-panel"><div class="utm-panel-title">По сегментам</div>' + renderClubAccessBreakdownMiniTable(segmentItems, 'весь период', 'Сегмент') + '</div>' + '</div>' + '</details>'; } function renderMatrixView(days, segments, totals) { const paidSegments = segments.filter(function (segment) { return segment.paidAmount > 0 || segment.paidCount > 0; }).slice(0, 10); if (!days.length || !paidSegments.length) { return '<div class="utm-help">Недостаточно данных для матрицы.</div>'; } const header = paidSegments.map(function (segment) { return '<th class="num">' + escapeHtml(segment.label) + '<br><span class="utm-muted">' + formatPercentValue(segment.paidAmount, totals.totalPaidAmount) + '</span></th>'; }).join(''); const body = days.map(function (day) { const cells = paidSegments.map(function (segment) { const rows = day.rows.filter(function (row) { return row.segmentTag === segment.label; }); const stats = makeEmptyStats({ label: segment.label }); rows.forEach(function (row) { addBasic(stats, row); }); finalizeStats(stats); const filterId = registerFilterRows(rows, 'День ' + day.label + ' / сегмент ' + segment.label + ' — ' + formatNumber(stats.count) + ' заказов, оплачено ' + formatMoney(stats.paidAmount)); if (!rows.length) return '<td class="num utm-muted">—</td>'; return '<td class="num utm-cell-click" data-utm-filter-id="' + filterId + '"><b>' + formatMoney(stats.paidAmount) + '</b><br><span class="utm-muted">' + formatNumber(stats.paidCount) + ' опл.</span></td>'; }).join(''); const dayFilterId = registerFilterRows(day.rows, 'День: ' + day.label + ' — ' + formatNumber(day.count) + ' заказов, оплачено ' + formatMoney(day.paidAmount)); return '<tr><td class="utm-click" data-utm-filter-id="' + dayFilterId + '"><b>' + escapeHtml(day.label) + '</b><br><span class="utm-muted">' + formatMoney(day.paidAmount) + '</span></td>' + cells + '</tr>'; }).join(''); return '<div class="utm-help">Матрица показывает, какие сегменты дали деньги в каждый день. В ячейке сверху сумма «Оплачено», снизу количество оплаченных заказов. Любая непустая ячейка кликабельна.</div>' + '<table class="utm-table utm-matrix"><thead><tr><th>День</th>' + header + '</tr></thead><tbody>' + body + '</tbody></table>'; } function renderTagSummary(stats, parentPaidCount) { if (!stats.tagStats || !stats.tagStats.length) return '<span class="utm-muted">—</span>'; return stats.tagStats.slice(0, 3).map(function (tag) { const base = parentPaidCount || stats.paidCount || stats.count; const percent = parentPaidCount ? formatPercentValue(tag.paidCount, base) : formatPercentValue(tag.count, base); return '<span class="utm-pill">' + escapeHtml(tag.tag || tag.label) + ' <span class="utm-muted">' + percent + '</span></span>'; }).join('') + (stats.tagStats.length > 3 ? '<span class="utm-muted"> + ещё ' + formatNumber(stats.tagStats.length - 3) + '</span>' : ''); } function getTopBreakdownText(items, name) { if (!items || !items.length) return ''; const top = items.slice(0, 3).map(function (item) { return (item.label || item.tag || item.source || item.day) + ' ' + formatMoney(item.paidAmount); }).join(', '); return name + ': ' + top + (items.length > 3 ? ', ...' : ''); } function getRawTagsHint(rows) { const set = {}; (rows || []).slice(0, 40).forEach(function (row) { (row.tags || []).forEach(function (tag) { if (tag && tag !== row.segmentTag) set[tag] = true; }); }); const list = Object.keys(set); if (!list.length) return ''; return 'исходные: ' + list.slice(0, 3).join(', ') + (list.length > 3 ? ', ...' : ''); } function registerFilterRows(rows, label) { const id = 'f' + (++FILTER_ROW_COUNTER); FILTER_ROW_MAP[id] = { rows: rows || [], label: label || 'Фильтр' }; return id; } function renderDrilldownAnalytics(rows, label) { const el = document.getElementById(DRILLDOWN_ID); if (!el) return; const list = rows || []; if (!list.length) { el.style.display = 'none'; el.innerHTML = ''; return; } prepareRowsForRender(list); const totals = getTotals(list); const tariffs = buildTariffStats(list); const sources = buildSourceStats(list); const tags = buildSegmentStats(list); const ages = buildBaseAgeStats(list); el.style.display = 'block'; el.innerHTML = '<div class="utm-panel" style="border:2px solid #9bc8ef;background:#fbfdff;">' + '<div class="utm-drilldown-header">' + '<div><div class="utm-panel-title" style="margin-bottom:2px;">Аналитика выбранной группы</div>' + '<div class="utm-help" style="margin:0;"><b>' + escapeHtml(label || 'Выбранная группа') + '</b></div></div>' + '<button type="button" class="btn btn-default btn-sm" data-drilldown-close>Закрыть аналитику</button>' + '</div>' + '<div class="utm-drilldown-body">' + '<div class="utm-section-grid" style="margin-top:0;">' + renderKpiCard('Заказов', formatNumber(totals.totalOrders), 'в выбранной группе', '') + renderKpiCard('Оплаченных', formatNumber(totals.totalPaidOrders), 'CR ' + formatConversion(totals.totalPaidOrders, totals.totalOrders), '') + renderKpiCard('Оплачено', formatMoney(totals.totalPaidAmount), 'средний чек ' + formatMoney(totals.avgPaidCheck), '') + '</div>' + '<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px;">' + '<div class="utm-panel" style="margin:0;">' + '<div class="utm-panel-title">Какие тарифы купили</div>' + '<div class="utm-chart-box" style="margin-bottom:10px;">' + '<div id="utm-drilldown-tariffs" class="utm-chart-canvas-sm"></div>' + '<div id="utm-drilldown-tariffs-legend" class="utm-chart-legend"></div>' + '<div class="utm-chart-note">Клик по сектору или строке легенды фильтрует основную таблицу заказов.</div>' + '</div>' + renderDimensionMiniTable(tariffs, totals, 'Тарифы', 'Тариф') + '</div>' + '<div class="utm-panel" style="margin:0;">' + '<div class="utm-panel-title">Возраст пользователей в базе</div>' + '<div class="utm-chart-box" style="margin-bottom:10px;">' + '<div id="utm-drilldown-ages" class="utm-chart-canvas-sm"></div>' + '<div id="utm-drilldown-ages-legend" class="utm-chart-legend"></div>' + '<div class="utm-chart-note">Клик по сектору или строке легенды фильтрует основную таблицу заказов.</div>' + '</div>' + renderDimensionMiniTable(ages, totals, 'Возраст в базе', 'Возраст') + '</div>' + '<div class="utm-panel" style="margin:0;">' + '<div class="utm-panel-title">Сегменты</div>' + '<div class="utm-chart-box" style="margin-bottom:10px;">' + '<div id="utm-drilldown-tags" class="utm-chart-canvas-sm"></div>' + '<div id="utm-drilldown-tags-legend" class="utm-chart-legend"></div>' + '<div class="utm-chart-note">Клик по сектору или строке легенды фильтрует основную таблицу заказов.</div>' + '</div>' + renderSimpleStatsTable(tags, totals, 'tag', 'Тег / сегмент', 'Выбранная группа / тег') + '</div>' + '<div class="utm-panel" style="margin:0;">' + '<div class="utm-panel-title">Источники</div>' + '<div class="utm-chart-box" style="margin-bottom:10px;">' + '<div id="utm-drilldown-sources" class="utm-chart-canvas-sm"></div>' + '<div id="utm-drilldown-sources-legend" class="utm-chart-legend"></div>' + '<div class="utm-chart-note">Клик по сектору или строке легенды фильтрует основную таблицу заказов.</div>' + '</div>' + renderSimpleStatsTable(sources, totals, 'source', 'Источник', 'Выбранная группа / источник') + '</div>' + '</div>' + '</div>' + '</div>'; decorateInteractiveAnalytics(el); setTimeout(function () { renderPieChart('utm-drilldown-tariffs', 'Тарифы выбранной группы', tariffs, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-drilldown-sources', 'Источники выбранной группы', sources, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-drilldown-tags', 'Сегменты выбранной группы', tags, 'paidCount', totals.totalPaidOrders); renderPieChart('utm-drilldown-ages', 'Возраст выбранной группы', ages, 'paidCount', totals.totalPaidOrders); }, 0); el.setAttribute('aria-live', 'polite'); } function clearDrilldownAnalytics() { const el = document.getElementById(DRILLDOWN_ID); if (!el) return; el.style.display = 'none'; el.innerHTML = ''; } function saveOriginalTableState() { const table = getCurrentDataTable(); 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 filterCurrentTableByRows(rows, label) { const table = getCurrentDataTable(); if (!table) return; saveOriginalTableState(); const tbody = table.querySelector('tbody'); const summary = document.querySelector('.summary'); if (!tbody) return; tbody.innerHTML = rows.map(function (row) { return row.rowHTML; }).join(''); if (summary) { summary.innerHTML = 'Показан виртуальный фильтр из UTM-аналитики: <b>' + formatNumber(rows.length) + '</b> заказов'; } CURRENT_TABLE_FILTER = { label: label, count: rows.length }; setFilterInfo( '<b>' + escapeHtml(label) + '</b><br>' + 'В основную таблицу подставлены строки из всей собранной выборки. Это виртуальный фильтр: он не меняет сегмент GetCourse и его можно сбросить. ' + '<button id="' + RESET_FILTER_BTN_ID + '" class="btn btn-xs btn-default" style="margin-left:8px;">Вернуть исходную таблицу</button>' ); } function resetCurrentTableFilter() { const table = getCurrentDataTable(); 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(''); clearDrilldownAnalytics(); } async function buildUtmSourceAnalytics() { ensureBlock(); if (CURRENT_TABLE_FILTER) resetCurrentTableFilter(); const btn = document.getElementById(BTN_ID); if (btn) btn.disabled = true; setLoadingState(true); setProgressCompleteState(false); setProgress(2, 'Подготавливаю сбор данных…'); const table = getCurrentDataTable(); if (!table) { setStatus(PAGE_TEXT.noTable); if (btn) btn.disabled = false; return; } const missingColumns = validateRequiredColumns(table); if (missingColumns.length) { showMissingColumns(missingColumns); setProgress(0, 'Загрузка не началась: сначала добавьте обязательные колонки.', true); setLoadingState(false); if (btn) btn.disabled = false; return; } saveOriginalTableState(); setStatus(PAGE_TEXT.collecting); setDebug(''); try { const sourceInfo = await collectRowsFromAllPages(); setProgress(66, 'Основная выборка загружена. Обрабатываю строки и даты…'); LAST_SOURCE_INFO = sourceInfo; updateDateSelector(); if (!sourceInfo.rows.length) { setStatus('Не удалось собрать ни одной строки заказов.'); setDebug(sourceInfo.skipped.length ? 'Пропущено строк/страниц: <b>' + formatNumber(sourceInfo.skipped.length) + '</b>. Подробности в консоли.' : ''); debugLog('sourceInfo:', sourceInfo); return; } if (ANALYSIS_MODE === MODE_CLUB_ACTION) { setProgress(70, 'Шаг 2 из 4: собираю дополнительные витрины клубной акции…'); LAST_COHORT_REPORTS = await collectConfiguredCohortReports(sourceInfo); setProgress(84, 'Витрины собраны. Перехожу к способам оплаты…'); } else { LAST_COHORT_REPORTS = []; setProgress(76, 'Шаг 2 из 3: основная выборка готова. Перехожу к способам оплаты…'); } LAST_PAYMENT_REPORTS = await collectConfiguredPaymentReports(sourceInfo); setProgress(93, 'Последний шаг: строю таблицы, карточки и диаграммы…'); safeRenderResult(sourceInfo); const totals = getTotals(sourceInfo.rows); let dateWarning = ''; if (!sourceInfo.dateColumns.length) { dateWarning = ' Колонка даты не найдена — вкладки по дням будут неполными. Добавь в таблицу «Статус» или «Дата изменения». '; } ANALYTICS_LOADED = true; setProgressCompleteState(true); setProgress(100, '✓ Аналитика полностью готова: <b>' + formatNumber(sourceInfo.pages) + '</b> стр., <b>' + formatNumber(sourceInfo.rows.length) + '</b> заказов. Можно приступать к разбору.'); setStatus( '<span style="color:#267a36;font-weight:700;">✓ Аналитика готова.</span> Заказов: <b>' + formatNumber(sourceInfo.rows.length) + '</b>. ' + 'Оплаченных: <b>' + formatNumber(totals.totalPaidOrders) + '</b>. ' + 'Оплачено: <b>' + formatMoney(totals.totalPaidAmount) + '</b>. ' + 'Сегментов: <b>' + formatNumber(totals.tagsCount) + '</b>. ' + 'Дней: <b>' + formatNumber(totals.daysCount) + '</b>. ' + 'Страниц: <b>' + formatNumber(sourceInfo.pages) + '</b>. ' + 'Режим: <b>' + (ANALYSIS_MODE === MODE_CLUB_ACTION ? 'Акция клуба' : 'Марафон') + '</b>. ' + (ANALYSIS_MODE === MODE_CLUB_ACTION ? ('Витрин: <b>' + formatNumber((LAST_COHORT_REPORTS || []).length) + '</b>. ') : '') + 'Платёжных срезов: <b>' + formatNumber((LAST_PAYMENT_REPORTS || []).length) + '</b>.' + (dateWarning ? '<br><span style="color:#a66;">' + escapeHtml(dateWarning) + '</span>' : '') ); if (sourceInfo.skipped.length) { setDebug( '<b>Диагностика</b><br>' + 'Пропущено строк/страниц: <b>' + formatNumber(sourceInfo.skipped.length) + '</b><br>' + '<span style="color:#777;">Подробности смотри в консоли: [GC UTM MONEY TAGS DAYS]</span>' ); debugLog('Пропущенные строки:', sourceInfo.skipped); } else { setDebug(''); } } catch (err) { setProgressCompleteState(false); setProgress(100, '<span style="color:#a94442;">Загрузка завершилась с ошибкой. Посмотрите сообщение выше.</span>'); showFriendlyError('Ошибка сбора UTM-аналитики', err); } finally { setLoadingState(false); if (btn) btn.disabled = false; } } async function enrichClubBalanceForCurrentSegment() { ensureBlock(); CLUB_BALANCE_STOP_REQUESTED = false; const mainBtn = document.querySelector('[data-club-balance-enrich]'); if (mainBtn) mainBtn.disabled = true; try { const sourceInfo = await getOrCollectSourceInfoForClubBalance(); if (!sourceInfo || !sourceInfo.rows || !sourceInfo.rows.length) { setStatus('Club balance: не удалось собрать строки заказов из текущей выборки.'); return; } prepareRowsForRender(sourceInfo.rows); const targetPlan = await buildClubBalanceTargetPlan(sourceInfo.rows); if (!targetPlan.targets.length) { setStatus('Club balance: нет подходящих заказов для обогащения. Нужны оплаченные заказы с доступом к клубу, dealId и userId.'); setDebug(renderClubBalanceDiagnostics(targetPlan)); return; } setStatus( 'Club balance: найдено подходящих оплаченных клубных заказов: <b>' + formatNumber(targetPlan.targets.length) + '</b>. ' + 'Уникальных клиентов: <b>' + formatNumber(targetPlan.uniqueUserIds.length) + '</b>. Считаю историю клуба по клиентам…' ); setDebug(renderClubBalanceDiagnostics(targetPlan)); const calculationPlan = await calculateClubBalanceForTargets(targetPlan.targets); const writeItems = calculationPlan.writeItems; LAST_CLUB_BALANCE_RESULTS = calculationPlan; applyClubBalanceWriteItemsToCurrentRows(writeItems); if (LAST_SOURCE_INFO && CURRENT_VIEW === 'clubbalance') safeRenderResult(LAST_SOURCE_INFO); if (!writeItems.length) { setStatus('Club balance: расчёт завершён, но нет значений для записи. Вероятно, найденные заказы не сопоставились с историей клиента.'); setDebug(renderClubBalanceCalculationDiagnostics(targetPlan, calculationPlan)); return; } const preview = writeItems.slice(0, 35).map(function (item) { return '#' + (item.number || item.dealId) + ' / user ' + item.userId + ' = ' + item.value + (item.firstClubBaseAgeDays !== null && item.firstClubBaseAgeDays !== undefined ? ' / дней в базе: ' + item.firstClubBaseAgeDays : ''); }).join('\n'); const ok = window.confirm( 'Записать Club balance before order days в оплаченные клубные заказы?\n\n' + 'К записи: ' + writeItems.length + '\n' + 'Клиентов: ' + calculationPlan.uniqueUsersCalculated + '\n' + 'Поле Club balance: ' + CLUB_BALANCE_FIELD_ID + '\n' + 'Код первого клубного заказа: ' + CLUB_BALANCE_FIRST_ENTRY_CODE + '\n' + 'Поле дней в базе до первого клуба: ' + (FIRST_CLUB_BASE_AGE_FIELD_ID ? FIRST_CLUB_BASE_AGE_FIELD_ID : 'не задано, будет только рассчитано') + '\n\n' + preview + (writeItems.length > 35 ? '\n…ещё ' + (writeItems.length - 35) : '') + '\n\nНеоплаченные заказы не будут записаны.' ); if (!ok) { setStatus('Club balance: запись отменена. Расчёт сохранён в window.GC_UTM_CLUB_BALANCE_RESULTS.'); setDebug(renderClubBalanceCalculationDiagnostics(targetPlan, calculationPlan)); return; } const writeResults = await writeClubBalanceValues(writeItems); calculationPlan.writeResults = writeResults; LAST_CLUB_BALANCE_RESULTS = calculationPlan; const okCount = writeResults.filter(function (item) { return item.ok; }).length; const skippedCount = writeResults.filter(function (item) { return item.skipped; }).length; const failCount = writeResults.length - okCount - skippedCount; setStatus( 'Club balance: запись завершена. Успешно: <b>' + formatNumber(okCount) + '</b>, ' + 'пропущено: <b>' + formatNumber(skippedCount) + '</b>, ошибок: <b>' + formatNumber(failCount) + '</b>.' ); setDebug(renderClubBalanceWriteDiagnostics(targetPlan, calculationPlan, writeResults)); if (LAST_SOURCE_INFO && CURRENT_VIEW === 'clubbalance') safeRenderResult(LAST_SOURCE_INFO); console.table(writeResults); } catch (err) { console.error('[GC UTM MONEY TAGS DAYS] Club balance error', err); setStatus('Club balance: ошибка — ' + escapeHtml(err && err.message ? err.message : String(err))); setDebug('<b>Club balance error</b><br><pre style="white-space:pre-wrap;">' + escapeHtml(err && err.stack ? err.stack : String(err)) + '</pre>'); } finally { if (mainBtn) mainBtn.disabled = false; } } async function getOrCollectSourceInfoForClubBalance() { if (LAST_SOURCE_INFO && LAST_SOURCE_INFO.rows && LAST_SOURCE_INFO.rows.length) return LAST_SOURCE_INFO; const table = getCurrentDataTable(); if (!table) throw new Error(PAGE_TEXT.noTable); const sourceColumns = getSourceColumns(table); const paidColumn = getPaidColumn(table); if (!sourceColumns.any) throw new Error(PAGE_TEXT.noSourceColumn); if (!paidColumn) throw new Error(PAGE_TEXT.noPaidColumn); saveOriginalTableState(); setStatus('Club balance: сначала собираю все страницы текущей выборки заказов…'); const sourceInfo = await collectRowsFromAllPages(); LAST_SOURCE_INFO = sourceInfo; updateDateSelector(); return sourceInfo; } async function buildClubBalanceTargetPlan(rows) { const plan = { rowsTotal: (rows || []).length, targets: [], uniqueUserIds: [], skipped: { unpaid: 0, noClubAccess: 0, noDealId: 0, noUserId: 0, resolvedUserIdFromDealPage: 0 } }; const rawTargets = []; (rows || []).forEach(function (row) { if (!row || !row.isPaid) { plan.skipped.unpaid++; return; } if (!row.hasClubAccess) { plan.skipped.noClubAccess++; return; } if (!row.recordId) { plan.skipped.noDealId++; return; } rawTargets.push(row); }); const withoutUser = rawTargets.filter(function (row) { return !row.userId; }); if (withoutUser.length) { setStatus('Club balance: у части заказов нет userId в таблице, пытаюсь достать его со страницы заказа… ' + formatNumber(withoutUser.length)); const resolved = await runQueue( withoutUser, async function (row) { if (CLUB_BALANCE_STOP_REQUESTED) return { row: row, userId: '' }; const userId = await resolveUserIdFromDealPage(row.recordId); return { row: row, userId: userId }; }, CLUB_BALANCE_DEAL_PAGE_CONCURRENCY, function (done, total) { setStatus('Club balance: уточняю userId по страницам заказов: <b>' + done + '</b> / <b>' + total + '</b>'); } ); resolved.forEach(function (item) { if (!item || item.error || !item.row) return; if (item.userId) { item.row.userId = item.userId; plan.skipped.resolvedUserIdFromDealPage++; } }); } rawTargets.forEach(function (row) { if (!row.userId) { plan.skipped.noUserId++; return; } plan.targets.push(row); }); const userMap = {}; plan.targets.forEach(function (row) { userMap[String(row.userId)] = true; }); plan.uniqueUserIds = Object.keys(userMap); return plan; } function applyClubBalanceWriteItemsToCurrentRows(writeItems) { if (!LAST_SOURCE_INFO || !LAST_SOURCE_INFO.rows || !LAST_SOURCE_INFO.rows.length || !writeItems || !writeItems.length) return; const byDealId = {}; (writeItems || []).forEach(function (item) { if (item && item.dealId) byDealId[String(item.dealId)] = item; }); LAST_SOURCE_INFO.rows.forEach(function (row) { const item = byDealId[String(row.recordId || '')]; if (!item) return; row.clubBalanceColumnPresent = row.clubBalanceColumnPresent || false; row.rawClubBalance = String(item.value); row.clubBalanceValue = parseMetricInteger(item.value); row.clubBalanceCohort = getClubBalanceCohort(row.clubBalanceValue); if (item.firstClubBaseAgeDays !== null && item.firstClubBaseAgeDays !== undefined && Number.isFinite(Number(item.firstClubBaseAgeDays))) { row.firstClubBaseAgeColumnPresent = row.firstClubBaseAgeColumnPresent || false; row.rawFirstClubBaseAge = String(item.firstClubBaseAgeDays); row.firstClubBaseAgeDays = parseMetricInteger(item.firstClubBaseAgeDays); row.firstClubBaseAgeCohort = getFirstClubBaseAgeCohort(row.firstClubBaseAgeDays); } }); } async function calculateClubBalanceForTargets(targetRows) { const targetByUser = {}; const targetDealIds = {}; (targetRows || []).forEach(function (row) { const userId = String(row.userId || ''); if (!targetByUser[userId]) targetByUser[userId] = []; targetByUser[userId].push(row); targetDealIds[String(row.recordId)] = true; }); const userIds = Object.keys(targetByUser); const calc = { fieldId: CLUB_BALANCE_FIELD_ID, firstEntryCode: CLUB_BALANCE_FIRST_ENTRY_CODE, firstClubBaseAgeFieldId: FIRST_CLUB_BASE_AGE_FIELD_ID || '', generatedAt: new Date().toISOString(), usersTotal: userIds.length, uniqueUsersCalculated: 0, targetOrdersTotal: targetRows.length, writeItems: [], counted: { firstEntry: 0, firstEntryWithBaseAge: 0, firstEntryWithoutBaseAge: 0 }, skipped: { noCalculationForDeal: 0, userLoadError: 0, stopped: 0 }, userResults: [] }; const userResults = await runQueue( userIds, async function (userId) { if (CLUB_BALANCE_STOP_REQUESTED) return { userId: userId, stopped: true }; const model = await loadClubBalanceUserModel(userId); const userCreatedAt = extractClubBalanceUserCreatedAt(model); const deals = parseClubBalanceDealsHtml(model.deals_html || ''); const rows = calculateClubBalanceRows(deals, userCreatedAt); return { userId: userId, userCreatedAt: userCreatedAt, deals: deals, calculations: rows }; }, CLUB_BALANCE_USER_CONCURRENCY, function (done, total) { setStatus('Club balance: считаю клубную историю клиентов: <b>' + done + '</b> / <b>' + total + '</b>'); } ); userResults.forEach(function (result) { if (!result || result.error) { calc.skipped.userLoadError++; calc.userResults.push({ userId: result && result.item ? result.item : '', error: result && result.error ? result.error : 'unknown error' }); return; } if (result.stopped) { calc.skipped.stopped++; return; } calc.uniqueUsersCalculated++; const byDealId = {}; (result.calculations || []).forEach(function (row) { if (row && row.deal && row.deal.id) byDealId[String(row.deal.id)] = row; }); (targetByUser[String(result.userId)] || []).forEach(function (targetRow) { const dealId = String(targetRow.recordId || ''); const calculation = byDealId[dealId]; if (!calculation) { calc.skipped.noCalculationForDeal++; return; } const isFirstEntry = calculation.balanceBefore === null || calculation.balanceBefore === undefined || calculation.mode === 'first_entry'; const value = isFirstEntry ? CLUB_BALANCE_FIRST_ENTRY_CODE : calculation.balanceBefore; const firstClubBaseAgeDays = isFirstEntry ? calculation.daysInBaseBeforeFirstClubOrder : null; const extraFieldValues = {}; if (isFirstEntry) { calc.counted.firstEntry++; if (firstClubBaseAgeDays !== null && firstClubBaseAgeDays !== undefined && Number.isFinite(Number(firstClubBaseAgeDays))) { calc.counted.firstEntryWithBaseAge++; if (FIRST_CLUB_BASE_AGE_FIELD_ID) { extraFieldValues[String(FIRST_CLUB_BASE_AGE_FIELD_ID)] = String(firstClubBaseAgeDays); } } else { calc.counted.firstEntryWithoutBaseAge++; } } calc.writeItems.push({ userId: String(result.userId), dealId: dealId, number: targetRow.recordId, tableRow: { page: targetRow.page, rowIndex: targetRow.rowIndex, source: targetRow.source, paidAmount: targetRow.paidAmount, rawComposition: targetRow.rawComposition }, value: value, mode: calculation.mode, isFirstEntry: isFirstEntry, firstEntryCode: isFirstEntry ? CLUB_BALANCE_FIRST_ENTRY_CODE : null, firstClubBaseAgeDays: firstClubBaseAgeDays, extraFieldValues: extraFieldValues, previousClubEnd: calculation.previousClubEnd ? formatDayFull(calculation.previousClubEnd) : '', newClubEnd: calculation.newClubEnd ? formatDayFull(calculation.newClubEnd) : '', durationDays: calculation.durationDays, title: calculation.deal.title || (calculation.deal.items || []).join(', ') }); }); calc.userResults.push({ userId: String(result.userId), userCreatedAt: result.userCreatedAt ? result.userCreatedAt.toISOString() : '', deals: (result.deals || []).length, calculations: (result.calculations || []).length }); }); calc.writeItems.sort(function (a, b) { if (a.userId !== b.userId) return String(a.userId).localeCompare(String(b.userId)); return String(a.dealId).localeCompare(String(b.dealId)); }); return calc; } async function writeClubBalanceValues(writeItems) { const results = []; for (let i = 0; i < writeItems.length; i++) { if (CLUB_BALANCE_STOP_REQUESTED) { results.push(Object.assign({}, writeItems[i], { ok: false, skipped: true, status: 'STOPPED' })); continue; } const item = writeItems[i]; setStatus('Club balance: запись ' + (i + 1) + ' / ' + writeItems.length + ' — заказ ' + escapeHtml(item.dealId) + ', значение ' + escapeHtml(item.value) + (item.firstClubBaseAgeDays !== null && item.firstClubBaseAgeDays !== undefined ? ', дней в базе ' + escapeHtml(item.firstClubBaseAgeDays) : '')); try { const fieldValues = Object.assign({}, item.extraFieldValues || {}); fieldValues[String(CLUB_BALANCE_FIELD_ID)] = String(item.value); const result = await writeClubBalanceDealCustomFields(item.dealId, fieldValues); results.push(Object.assign({}, item, result)); } catch (err) { results.push(Object.assign({}, item, { ok: false, skipped: false, status: 'ERROR', error: err && err.message ? err.message : String(err) })); } await sleep(CLUB_BALANCE_WRITE_DELAY_MS); } return results; } async function resolveUserIdFromDealPage(dealId) { const html = await loadDealMainPartHtml(dealId); return extractUserIdFromDealPageHtml(html); } function extractUserIdFromDealPageHtml(html) { const text = String(html || ''); const patterns = [ /\/user\/control\/user\/update\/id\/(\d+)/i, /\/pl\/user\/user\/update\/id\/(\d+)/i, /user_id[=:"'\s]+(\d+)/i, /userId[=:"'\s]+(\d+)/i ]; for (let i = 0; i < patterns.length; i++) { const m = text.match(patterns[i]); if (m && m[1]) return m[1]; } return ''; } async function loadClubBalanceUserModel(userId) { const res = await fetch('/pl/user/user/model?id=' + encodeURIComponent(userId), { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch (e) {} if (!res.ok || !json || !json.success) { throw new Error('Не удалось загрузить модель клиента ' + userId + ': HTTP ' + res.status + ' ' + text.slice(0, 200)); } return json; } function extractClubBalanceUserCreatedAt(modelResponse) { const raw = modelResponse && modelResponse.model ? modelResponse.model.created_at : ''; if (!raw) return null; let m = String(raw).match(/(20\d{2})-(\d{2})-(\d{2})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?/); if (m) { return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] || 0), Number(m[5] || 0), Number(m[6] || 0), 0); } return parseDate(raw); } function parseClubBalanceDealsHtml(html) { const doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); return Array.from(doc.querySelectorAll('.open-deal')).map(function (el) { const link = el.querySelector('a[href*="deal/update"]'); const moneyEl = el.querySelector('.pull-right b'); const timeEl = el.querySelector('.time'); const statusEl = el.querySelector('.deal-status'); const productBlock = Array.from(el.querySelectorAll('div.small')) .find(function (div) { return !div.classList.contains('pull-right') && !div.classList.contains('text-right'); }) || null; const productEl = productBlock ? productBlock.querySelector('b') : null; const itemEls = productBlock ? Array.from(productBlock.querySelectorAll('li')) : []; const href = link ? link.getAttribute('href') || '' : ''; const id = el.getAttribute('data-key') || extractClubBalanceDealId(href); const number = normalizeVisibleText(link ? link.textContent : ''); const title = normalizeVisibleText(productEl ? productEl.textContent : ''); const items = itemEls.map(function (li) { return normalizeVisibleText(li.textContent); }).filter(Boolean); const amountText = normalizeVisibleText(moneyEl ? moneyEl.textContent : ''); const amount = parseMoney(amountText); const dateText = normalizeVisibleText(timeEl ? timeEl.textContent : ''); const status = normalizeVisibleText(statusEl ? statusEl.textContent : ''); const durationDays = inferClubBalanceDaysFromDeal(title, items); const deal = { id: id, number: number, href: href, title: title, items: items, amountText: amountText, amount: amount, dateText: dateText, dateObj: parseClubBalanceGcDate(dateText), status: status, durationDays: durationDays, rawText: normalizeVisibleText(el.textContent || '') }; deal.isCompleted = /завершен|завершён|оплачен|оплачено/i.test(status); deal.isClubAccess = isClubBalanceAccessDeal(deal); return deal; }).filter(function (d) { return d.id && d.dateObj; }).sort(function (a, b) { return a.dateObj - b.dateObj; }); } function calculateClubBalanceRows(deals, userCreatedAt) { const paidClubDeals = (deals || []) .filter(function (d) { return d.isCompleted && d.isClubAccess && d.amount > 0 && d.durationDays > 0; }) .sort(function (a, b) { return a.dateObj - b.dateObj; }); let clubEnd = null; const rows = []; paidClubDeals.forEach(function (deal) { const orderDate = startOfDay(deal.dateObj); const durationDays = Number(deal.durationDays || 0); let balanceBefore = null; let mode = 'first_entry'; if (clubEnd) { const diff = diffCalendarDays(orderDate, clubEnd); balanceBefore = diff; mode = diff >= 0 ? 'extension' : 'reactivation_after_gap'; } const baseStart = clubEnd && clubEnd >= orderDate ? clubEnd : orderDate; const newClubEnd = addDays(baseStart, durationDays); const daysInBaseBeforeFirstClubOrder = mode === 'first_entry' && userCreatedAt ? diffCalendarDays(startOfDay(userCreatedAt), orderDate) : null; rows.push({ deal: deal, durationDays: durationDays, previousClubEnd: clubEnd, balanceBefore: balanceBefore, mode: mode, newClubEnd: newClubEnd, userCreatedAt: userCreatedAt || null, daysInBaseBeforeFirstClubOrder: daysInBaseBeforeFirstClubOrder }); clubEnd = newClubEnd; }); return rows; } async function loadDealMainPartHtml(dealId) { const pageUrl = '/sales/control/deal/update/id/' + encodeURIComponent(dealId) + '/part/main'; const res = await fetch(pageUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); const html = await res.text(); if (!res.ok) throw new Error('Не удалось открыть заказ ' + dealId + ': HTTP ' + res.status); return html; } async function writeClubBalanceDealCustomFields(dealId, fieldValues) { const pageHtml = await loadDealMainPartHtml(dealId); const doc = new DOMParser().parseFromString(pageHtml, 'text/html'); const form = doc.querySelector('#dealForm') || doc.querySelector('form.page-main-form') || doc.querySelector('form'); if (!form) throw new Error('В заказе ' + dealId + ' не найдена форма сохранения.'); const formData = new FormData(form); const currentValues = extractClubBalanceDealCustomValues(pageHtml); Object.keys(fieldValues || {}).forEach(function (fieldId) { if (!fieldId) return; currentValues[String(fieldId)] = String(fieldValues[fieldId]); }); formData.set('Deal[valueSetString]', JSON.stringify(currentValues)); if (!formData.has('save')) formData.append('save', 'Сохранить'); const postUrl = '/sales/control/deal/update/id/' + encodeURIComponent(dealId); const res = await fetch(postUrl, { method: 'POST', credentials: 'same-origin', body: formData, redirect: 'follow' }); return { ok: res.ok, skipped: false, status: res.status, url: res.url }; } function extractClubBalanceDealCustomValues(html) { const result = {}; const re = /let\s+values\d+\s*=\s*(\{[\s\S]*?\});/g; let m; while ((m = re.exec(String(html || ''))) !== null) { try { const obj = JSON.parse(m[1]); Object.keys(obj || {}).forEach(function (k) { if (obj[k] !== null && obj[k] !== undefined && obj[k] !== '') result[String(k)] = String(obj[k]); }); } catch (e) {} } return result; } function isClubBalanceAccessDeal(deal) { const title = String(deal.title || '').toLowerCase().replace(/ё/g, 'е'); const itemsText = String((deal.items || []).join(' ')).toLowerCase().replace(/ё/g, 'е'); const text = [title, itemsText].join(' '); if (isClubBalanceFreeNamedActivation(deal)) return false; if (hasClubBalanceExplicitClubProduct(itemsText)) return true; if (hasClubBalanceExplicitAccessPhrase(title)) return true; if (inferClubBalanceDays(text) > 0 && /клуб|youclub|доступ к занятиям|доступ к тренировкам/i.test(text)) return true; return false; } function isClubBalanceFreeNamedActivation(deal) { const text = [deal.title, (deal.items || []).join(' ')].join(' ').toLowerCase().replace(/ё/g, 'е'); return Number(deal.amount || 0) === 0 && /день\s+открытых\s+дверей|марафон|пробные\s+занятия|бесплатн|шея\s+без\s+боли|счастливый\s+год/i.test(text); } function hasClubBalanceExplicitClubProduct(text) { const t = String(text || '').toLowerCase().replace(/ё/g, 'е'); return /\[\s*онлайн\s+(йога|ева)\s*[- ]?\s*клуб\s*\]/i.test(t) || /(^|\s)онлайн\s+(йога|ева)\s*[- ]?\s*клуб($|\s)/i.test(t) || /(^|\s)you\s*club($|\s)/i.test(t) || /(^|\s)youclub($|\s)/i.test(t); } function hasClubBalanceExplicitAccessPhrase(text) { const t = String(text || '').toLowerCase().replace(/ё/g, 'е'); return /доступ\s+к\s+(тренировкам|занятиям)\s+((йога|ева)\s+)?клуба/i.test(t) || /доступ\s+в\s+((йога|ева)\s+)?клуб/i.test(t) || /подписк[аиуы]?\s+(онлайн\s+)?((йога|ева)\s+)?клуб/i.test(t) || /онлайн\s+(йога|ева)\s*[- ]?\s*клуб\s+на\s+/i.test(t) || /you\s*club\s+на\s+/i.test(t); } function inferClubBalanceDaysFromDeal(title, items) { const text = [title, (items || []).join(' ')].join(' '); return inferClubBalanceDays(text); } function inferClubBalanceDays(text) { const t = String(text || '').toLowerCase().replace(/ё/g, 'е'); let m = t.match(/(\d+)\s*(?:день|дня|дней|дн\.?)/i); if (m) return Number(m[1]); m = t.match(/(\d+)\s*(?:месяц|месяца|месяцев|мес\.?)/i); if (m) { const n = Number(m[1]); if (n === 1) return CLUB_BALANCE_DURATION_MAP.month1; if (n === 2) return CLUB_BALANCE_DURATION_MAP.month2; if (n === 3) return CLUB_BALANCE_DURATION_MAP.month3; if (n === 6) return CLUB_BALANCE_DURATION_MAP.month6; if (n === 12) return CLUB_BALANCE_DURATION_MAP.month12; return Math.round(n * 30.5); } if (/год|12\s*мес/i.test(t)) return CLUB_BALANCE_DURATION_MAP.month12; return 0; } function addDays(date, days) { const d = new Date(date.getTime()); d.setDate(d.getDate() + Number(days || 0)); return d; } function startOfDay(date) { if (!date) return null; return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); } function diffCalendarDays(fromDate, toDate) { if (!fromDate || !toDate) return 0; const a = startOfDay(fromDate).getTime(); const b = startOfDay(toDate).getTime(); return Math.round((b - a) / 86400000); } function parseClubBalanceGcDate(text) { const raw = normalizeVisibleText(text || '').replace(',', ' '); const now = new Date(); let m = raw.match(/(?:(?:пн|вт|ср|чт|пт|сб|вс)\s+)?(\d{1,2})\s+([А-Яа-яЁё]{3,})\s+(?:(20\d{2})\s+)?(\d{1,2}):(\d{2})/i); if (m) { const day = Number(m[1]); const month = getRuMonth(m[2]); if (month === null || month === undefined) return null; let year = m[3] ? Number(m[3]) : now.getFullYear(); const hour = Number(m[4]); const min = Number(m[5]); let d = new Date(year, month, day, hour, min, 0, 0); if (!m[3] && d > addDays(now, 30)) { year -= 1; d = new Date(year, month, day, hour, min, 0, 0); } return d; } m = raw.match(/(20\d{2})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})/); if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), 0, 0); return parseDate(raw); } function extractClubBalanceDealId(href) { const m = String(href || '').match(/deal\/update\/id\/(\d+)/); return m ? m[1] : ''; } function renderClubBalanceDiagnostics(plan) { return '<b>Club balance / предварительный отбор</b><br>' + 'Всего строк в текущей выборке: <b>' + formatNumber(plan.rowsTotal) + '</b><br>' + 'Кандидатов к расчёту: <b>' + formatNumber(plan.targets.length) + '</b><br>' + 'Уникальных клиентов: <b>' + formatNumber(plan.uniqueUserIds.length) + '</b><br>' + 'Пропущено неоплаченных: <b>' + formatNumber(plan.skipped.unpaid) + '</b><br>' + 'Пропущено не-клубных: <b>' + formatNumber(plan.skipped.noClubAccess) + '</b><br>' + 'Без dealId: <b>' + formatNumber(plan.skipped.noDealId) + '</b><br>' + 'Без userId: <b>' + formatNumber(plan.skipped.noUserId) + '</b><br>' + 'userId восстановлен со страницы заказа: <b>' + formatNumber(plan.skipped.resolvedUserIdFromDealPage) + '</b>'; } function renderClubBalanceCalculationDiagnostics(plan, calc) { return renderClubBalanceDiagnostics(plan) + '<br><br>' + '<b>Club balance / расчёт</b><br>' + 'К записи: <b>' + formatNumber((calc.writeItems || []).length) + '</b><br>' + 'Первые клубные покупки записываются кодом ' + CLUB_BALANCE_FIRST_ENTRY_CODE + ': <b>' + formatNumber(calc.counted && calc.counted.firstEntry) + '</b><br>' + 'Из них с рассчитанным возрастом базы: <b>' + formatNumber(calc.counted && calc.counted.firstEntryWithBaseAge) + '</b><br>' + 'Поле возраста базы: <b>' + escapeHtml(FIRST_CLUB_BASE_AGE_FIELD_ID || 'не задано') + '</b><br>' + 'Не сопоставились с историей клиента: <b>' + formatNumber(calc.skipped.noCalculationForDeal) + '</b><br>' + 'Ошибки загрузки клиентов: <b>' + formatNumber(calc.skipped.userLoadError) + '</b><br>' + '<span style="color:#777;">Полный результат доступен в window.GC_UTM_CLUB_BALANCE_RESULTS.</span>'; } function renderClubBalanceWriteDiagnostics(plan, calc, writeResults) { const okCount = (writeResults || []).filter(function (item) { return item.ok; }).length; const skippedCount = (writeResults || []).filter(function (item) { return item.skipped; }).length; const failCount = (writeResults || []).length - okCount - skippedCount; const failed = (writeResults || []).filter(function (item) { return !item.ok && !item.skipped; }).slice(0, 8); return renderClubBalanceCalculationDiagnostics(plan, calc) + '<br><br>' + '<b>Club balance / запись</b><br>' + 'Успешно: <b>' + formatNumber(okCount) + '</b><br>' + 'Пропущено: <b>' + formatNumber(skippedCount) + '</b><br>' + 'Ошибок: <b>' + formatNumber(failCount) + '</b>' + (failed.length ? '<br><br><b>Первые ошибки:</b><br>' + failed.map(function (item) { return escapeHtml(item.dealId + ': ' + (item.error || item.status)); }).join('<br>') : ''); } function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } window.GC_UTM_CLUB_BALANCE_RESULTS = function () { return LAST_CLUB_BALANCE_RESULTS; }; function copyResultTable() { if (!LAST_SOURCE_INFO || !LAST_SOURCE_INFO.rows.length) { setStatus('Сначала соберите аналитику.'); return; } prepareRowsForRender(LAST_SOURCE_INFO.rows); const totals = getTotals(LAST_SOURCE_INFO.rows); const groups = buildSourceStats(LAST_SOURCE_INFO.rows); const segments = buildSegmentStats(LAST_SOURCE_INFO.rows); const days = buildDayStats(LAST_SOURCE_INFO.rows); const lines = [[ 'Раздел', 'Уровень', 'День', 'Сегмент', 'Группа источника', 'Источник', 'Заказов', 'Оплаченных заказов', '% оплаченных заказов от общего', 'Оплачено', '% денег от общего', 'Средний чек по оплаченным' ].join('\t')]; (LAST_COHORT_REPORTS || []).forEach(function (report) { if (!report || !report.totals) return; lines.push(makeCopyLine('Когорты', report.title, '', '', '', report.key, { count: report.totals.totalOrders, paidCount: report.totals.totalPaidOrders, paidAmount: report.totals.totalPaidAmount, avgPaidCheck: report.totals.avgPaidCheck }, totals)); }); (LAST_PAYMENT_REPORTS || []).forEach(function (report) { if (!report || !report.totals) return; lines.push(makeCopyLine('Оплаты', report.title, '', '', '', report.key, { count: report.totals.totalOrders, paidCount: report.totals.totalPaidOrders, paidAmount: report.totals.totalPaidAmount, avgPaidCheck: report.totals.avgPaidCheck }, totals)); }); if (totals.clubBalance && totals.clubBalance.cohorts) { totals.clubBalance.cohorts.forEach(function (item) { lines.push(makeCopyLine('Club balance', item.label, '', '', '', item.key, item, totals)); }); } if (totals.clubBalance && totals.clubBalance.firstBaseAge && totals.clubBalance.firstBaseAge.cohorts) { totals.clubBalance.firstBaseAge.cohorts.forEach(function (item) { lines.push(makeCopyLine('Days in base before first club', item.label, '', '', '', item.key, item, totals)); }); } segments.forEach(function (segment) { lines.push(makeCopyLine('Сегменты', 'Сегмент', '', segment.label, '', '', segment, totals)); (segment.sourceStats || []).forEach(function (source) { lines.push(makeCopyLine('Сегменты', 'Источник внутри сегмента', '', segment.label, source.label, source.label, source, totals)); }); }); days.forEach(function (day) { lines.push(makeCopyLine('Дни', 'День', day.label, '', '', '', day, totals)); (day.tagStats || []).forEach(function (tag) { lines.push(makeCopyLine('Дни', 'Сегмент внутри дня', day.label, tag.label, '', '', tag, totals)); }); (day.sourceStats || []).forEach(function (source) { lines.push(makeCopyLine('Дни', 'Источник внутри дня', day.label, '', source.label, source.label, source, totals)); }); }); groups.forEach(function (group) { lines.push(makeCopyLine('Источники', 'Группа', '', '', group.group, group.group, group, totals)); (group.tagStats || []).forEach(function (tag) { lines.push(makeCopyLine('Источники', 'Сегмент внутри группы', '', tag.label, group.group, group.group, tag, totals)); }); (group.children || []).forEach(function (child) { lines.push(makeCopyLine('Источники', 'Подисточник', '', '', group.group, child.source, child, totals)); (child.tagStats || []).forEach(function (tag) { lines.push(makeCopyLine('Источники', 'Сегмент внутри подисточника', '', tag.label, group.group, child.source, tag, totals)); }); }); }); copyText(lines.join('\n'), 'Таблица скопирована.'); } function makeCopyLine(section, level, day, segment, groupName, sourceName, item, totals) { return [ section, level, day || '', segment || '', groupName || '', sourceName || '', item.count, item.paidCount, formatPercentValue(item.paidCount, totals.totalPaidOrders), formatMoney(item.paidAmount), formatPercentValue(item.paidAmount, totals.totalPaidAmount), formatMoney(item.avgPaidCheck) ].join('\t'); } function copyResultJson() { if (!LAST_SOURCE_INFO || !LAST_SOURCE_INFO.rows.length) { setStatus('Сначала соберите аналитику.'); return; } prepareRowsForRender(LAST_SOURCE_INFO.rows); const totals = getTotals(LAST_SOURCE_INFO.rows); const groups = buildSourceStats(LAST_SOURCE_INFO.rows); const segments = buildSegmentStats(LAST_SOURCE_INFO.rows); const days = buildDayStats(LAST_SOURCE_INFO.rows); const payload = { generatedAt: new Date().toISOString(), dateColumn: getActiveDateColumnKey(), totals: mapStatsForJson(totals), cohorts: mapCohortsForJson(totals), payments: mapPaymentReportsForJson(totals), clubBalance: mapClubBalanceForJson(totals.clubBalance || makeEmptyClubBalanceMetricStats(), totals), firstClubBaseAge: mapFirstClubBaseAgeForJson((totals.clubBalance && totals.clubBalance.firstBaseAge) || makeEmptyFirstClubBaseAgeStats(), totals), segments: segments.map(function (segment) { return Object.assign(mapItemForJson(segment, totals), { segment: segment.label, sources: (segment.sourceStats || []).map(function (source) { return mapItemForJson(source, totals); }), days: (segment.dayStats || []).map(function (day) { return mapItemForJson(day, totals); }) }); }), days: days.map(function (day) { return Object.assign(mapItemForJson(day, totals), { day: day.label, segments: (day.tagStats || []).map(function (tag) { return mapItemForJson(tag, totals); }), sources: (day.sourceStats || []).map(function (source) { return mapItemForJson(source, totals); }) }); }), sources: groups.map(function (group) { return Object.assign(mapItemForJson(group, totals), { group: group.group, tags: (group.tagStats || []).map(function (tag) { return mapItemForJson(tag, totals); }), children: (group.children || []).map(function (child) { return Object.assign(mapItemForJson(child, totals), { source: child.source, tags: (child.tagStats || []).map(function (tag) { return mapItemForJson(tag, totals); }) }); }) }); }) }; copyText(JSON.stringify(payload, null, 2), 'JSON скопирован.'); } function mapClubBalanceForJson(stats, totals) { const data = stats || makeEmptyClubBalanceMetricStats(); return { fieldId: CLUB_BALANCE_FIELD_ID, firstEntryCode: CLUB_BALANCE_FIRST_ENTRY_CODE, hasColumn: Boolean(data.hasClubBalanceColumn), hasData: Boolean(data.hasMetricData), ordersWithMetric: (data.rows || []).length, firstEntryOrders: (data.firstEntryRows || []).length, extensionOrders: (data.extensionRows || []).length, reactivationOrders: (data.reactivationRows || []).length, extensionMedianDays: Number((data.extensionMedianDays || 0).toFixed(2)), reactivationMedianGapDays: Number((data.reactivationMedianGapDays || 0).toFixed(2)), cohorts: (data.cohorts || []).map(function (item) { return mapItemForJson(item, totals); }) }; } function mapFirstClubBaseAgeForJson(stats, totals) { const data = stats || makeEmptyFirstClubBaseAgeStats(); return { fieldId: FIRST_CLUB_BASE_AGE_FIELD_ID, hasColumn: Boolean(data.hasColumn), hasData: Boolean(data.hasData), ordersWithMetric: (data.rows || []).length, avgDays: Number((data.avgDays || 0).toFixed(2)), medianDays: Number((data.medianDays || 0).toFixed(2)), minDays: data.minDays, maxDays: data.maxDays, fast0to7Orders: (data.fast0to7Rows || []).length, firstMonthOrders: (data.firstMonthRows || []).length, oldBase181PlusOrders: (data.oldBaseRows || []).length, cohorts: (data.cohorts || []).map(function (item) { return mapItemForJson(item, totals); }) }; } function mapCohortsForJson(totals) { return (LAST_COHORT_REPORTS || []).map(function (report) { const t = report.totals; const paidOrders = t ? t.totalPaidOrders : 0; const paidAmount = t ? t.totalPaidAmount : 0; return { key: report.key, title: report.title, note: report.note || '', status: report.status || (t ? 'live' : 'error'), error: report.error || '', logic: 'current_open_segment_plus_offer_filter', orders: t ? t.totalOrders : null, paidOrders: paidOrders, paidAmount: Number((paidAmount || 0).toFixed(2)), paidOrdersPercentOfTotal: totals.totalPaidOrders ? Number((paidOrders / totals.totalPaidOrders * 100).toFixed(2)) : 0, paidAmountPercentOfTotal: totals.totalPaidAmount ? Number((paidAmount / totals.totalPaidAmount * 100).toFixed(2)) : 0, avgPaidCheck: paidOrders ? Number((paidAmount / paidOrders).toFixed(2)) : 0 }; }); } function mapPaymentReportsForJson(totals) { return (LAST_PAYMENT_REPORTS || []).map(function (report) { const t = report.totals; const paidOrders = t ? t.totalPaidOrders : 0; const paidAmount = t ? t.totalPaidAmount : 0; const commissionRate = Number(report.commissionRate) || 0; return { key: report.key, title: report.title, note: report.note || '', status: report.status || (t ? 'live' : 'error'), error: report.error || '', logic: 'current_open_segment_plus_deal_haspayment_filter', paidOrders: paidOrders, paidAmount: Number((paidAmount || 0).toFixed(2)), paidOrdersPercentOfTotal: totals.totalPaidOrders ? Number((paidOrders / totals.totalPaidOrders * 100).toFixed(2)) : 0, paidAmountPercentOfTotal: totals.totalPaidAmount ? Number((paidAmount / totals.totalPaidAmount * 100).toFixed(2)) : 0, avgPaidCheck: paidOrders ? Number((paidAmount / paidOrders).toFixed(2)) : 0, commissionRate: commissionRate, commissionAmount: Number((paidAmount * commissionRate).toFixed(2)), amountAfterCommission: Number((paidAmount * (1 - commissionRate)).toFixed(2)) }; }); } function mapStatsForJson(totals) { return { orders: totals.totalOrders, paidOrders: totals.totalPaidOrders, unpaidOrders: totals.totalUnpaidOrders, paidAmount: Number(totals.totalPaidAmount.toFixed(2)), avgPaidCheck: Number(totals.avgPaidCheck.toFixed(2)), tagsCount: totals.tagsCount, sourcesCount: totals.sourcesCount, daysCount: totals.daysCount }; } function mapItemForJson(item, totals) { return { label: item.label || item.tag || item.source || item.group || item.day, orders: item.count, paidOrders: item.paidCount, paidOrdersPercentOfTotal: totals.totalPaidOrders ? Number((item.paidCount / totals.totalPaidOrders * 100).toFixed(2)) : 0, paidAmount: Number(item.paidAmount.toFixed(2)), paidAmountPercentOfTotal: totals.totalPaidAmount ? Number((item.paidAmount / totals.totalPaidAmount * 100).toFixed(2)) : 0, avgPaidCheck: Number(item.avgPaidCheck.toFixed(2)) }; } function copyText(text, successMessage) { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function () { setStatus(successMessage || 'Скопировано.'); }).catch(function () { fallbackCopy(text, successMessage); }); return; } fallbackCopy(text, successMessage); } function fallbackCopy(text, successMessage) { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; textarea.style.top = '-9999px'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); document.execCommand('copy'); textarea.remove(); setStatus(successMessage || 'Скопировано.'); } let tries = 0; let timer = null; let observer = null; function startButtonWatcher() { if (timer || observer) return; addButton(); timer = setInterval(function () { addButton(); if (document.getElementById(BTN_ID)) clearInterval(timer); if (++tries > 50) clearInterval(timer); }, 400); observer = new MutationObserver(function () { addButton(); }); if (document.body) { observer.observe(document.body, { childList: true, subtree: true }); } } function destroy() { if (timer) { clearInterval(timer); timer = null; } if (observer) { observer.disconnect(); observer = null; } const btn = document.getElementById(BTN_ID); if (btn) btn.remove(); const wrap = document.getElementById(WRAP_ID); if (wrap) wrap.remove(); delete window.GC_SALES_REPORT_ANALYZER; } window.GC_SALES_REPORT_ANALYZER = { toolName: TOOL_NAME, versionNumber: VERSION_NUMBER, version: VERSION, getInterfaceScale: function () { return INTERFACE_SCALE; }, setInterfaceScale: function (value) { applyInterfaceScale(Number(value), true); }, addButton: addButton, build: function () { loadHighcharts(buildUtmSourceAnalytics); }, destroy: destroy }; if (document.body) { startButtonWatcher(); } else { document.addEventListener('DOMContentLoaded', startButtonWatcher, { once: true }); } })();