Система фиксации контактов с клиентами

Техническая документация: Система фиксации контактов менеджеров с клиентами

1. Обзор системы

1.1 Назначение и бизнес-контекст

Система решает одну задачу — собрать в одном месте всю историю контактов менеджеров с клиентами за месяц и выдать готовую таблицу со статистикой. Источников данных четыре, и у каждого своя роль:

Источник Файл(ы) Роль в системе
МоиЗвонки apiClient.gs Основной источник — реальные звонки: кто звонил, кому, сколько длилось, отвечен или нет. Ядро отчёта.
Битрикс24 bitrixService.gs, integrations.gs Справочник сотрудников (user.get) для сопоставления email из МоиЗвонки с ФИО менеджера; также CRM-контакты (crm.contact.list) и компании (crm.company.list).
ОКБ (общая клиентская база) okbService.gs Отдельная Google-таблица (OKB_SPREADSHEET_ID), обогащает номер телефона названием компании-клиента.
Telegram Logger API telegramService.gs Внешний сервис, агрегирует переписки менеджеров с клиентами по дням — вторая «сущность» отчёта (тип Чат, наравне со Звонок).

1.2 Карта возможностей (что видно в меню)

Меню строится в onOpen() (main.gs) и является фактической точкой входа для пользователя — вся функциональность системы доступна только через него (или через триггер):

Коммуникации
├── ▶  Запустить обработку данных           → runManualProcessing()
├── ─────────────────────────────
├── ☏  Список менеджеров                    → openManagersManager()
├── ✕  Черный список                        → openBlacklistManager()
├── ─────────────────────────────
├── ◷  Настроить автоматический запуск      → setupAutoTrigger()
├── ✗  Удалить все триггеры                 → removeAllTriggers()
├── ─────────────────────────────
├── ▤  Архивация данных                     → openArchiveDialog()
├── ─────────────────────────────
├── ≡  Настройки                            → openSettingsDialog()
├── ⌁  Тестировать подключение к API        → testApiConnectionUI()
└── ↺  Восстановить настройки по умолчанию  → forceReinitializeConfig()

1.3 Ограничения платформы Google Apps Script, которые повлияли на архитектуру

GAS — не полноценный сервер, а событийная модель выполнения с жёсткими рамками, и большая часть решений в коде — это подстройка под эти рамки, а не «как красивее».

Лимит времени выполнения (6 минут на обычный вызов, до 30 минут для триггеров на некоторых типах аккаунтов). Отсюда:

Отсутствие постоянной памяти между вызовами — каждый вызов функции (в том числе через триггер) стартует «с нуля», без глобального состояния предыдущего запуска. Это прямая причина существования progress.gs:

function _setProcessingStatus(obj) {
  PropertiesService.getScriptProperties()
    .setProperty('PROCESSING_STATUS', JSON.stringify(obj));
}

Раз нельзя держать прогресс в памяти между запросами от progressDialog.gs, статус пишется в ScriptProperties и читается через getProcessingStatus() при каждом опросе (poll() раз в секунду на стороне HTML). Это же решение — по сути единственный доступный способ дать пользователю живой прогресс-бар в GAS без WebSocket'ов и Server-Sent Events, которых платформа не поддерживает.

Внутрисессионный кэш через переменные модуля. Раз GAS не гарантирует, что между двумя вызовами функций внутри одного запуска будет переиспользован тот же процесс надолго, но в рамках одного триггера/вызова — переменные уровня файла (let _configCache = null; в config.gs, let _blacklistCache = null; в blacklist.gs, let _companiesMapCache = null; в bitrixService.gs) используются как дешёвый кэш на одну сессию выполнения. Это объясняет комментарий в config.gs:

«GAS-скрипт живёт одну сессию — кэш сбрасывается автоматически»

Ограничение размера PropertiesService (~500 КБ на свойство / ~9 МБ суммарно, по факту в коде фигурирует отметка ~900 КБ). Отсюда защитный код в cache.gs и okbService.gs:

try {
  scriptProps.setProperty(CACHE_KEY, JSON.stringify(freshMap));
  scriptProps.setProperty(CACHE_TIME_KEY, now.toString());
} catch (e) {
  // Если объект слишком большой для PropertiesService (>900 КБ)
  Logger.log('⚠️ Не удалось сохранить кэш: данные слишком большие');
}

Если карта компаний Битрикс24 или индекс ОКБ разрастутся — кэш просто перестанет сохраняться, без явного алерта пользователю. Технически некритично (пайплайн продолжит работать, просто без ускорения от кэша), но это тихий деградационный сценарий, который стоит держать в голове при росте базы клиентов.

Модель HtmlService с IFRAME sandbox. Все диалоги (monthPicker.html, progressDialog.gs, blacklistManager.gs, settingsDialog.gs, managers.html, archiveDialog.html, timePicker.html) открываются через HtmlService.createHtmlOutputFromFile(...).setSandboxMode(HtmlService.SandboxMode.IFRAME), что обязывает использовать google.script.run вместо прямого вызова серверных функций и заставляет городить withSuccessHandler/withFailureHandler callback-цепочки вместо async/await. Это объясняет, почему весь клиентский JS в проекте написан в колбэк-стиле, а не на промисах.

Нет нативного планировщика с точной точкой во времени. ScriptApp.newTrigger(...).atHour(hour).nearMinute(minute) — это приблизительное время (GAS не гарантирует запуск ровно в указанную минуту, а в течение окна). Именно поэтому в getCurrentTriggerInfo() (main.gs) время триггера хранится вручную в Script Property TRIGGER_TIME, а не читается из самого объекта триггера — GAS API попросту не даёт такого геттера:

// GAS не предоставляет прямого геттера времени — читаем через getEventType()
// и храним время в ScriptProperties при создании триггера

2. Архитектура и поток данных

2.1 Диаграмма верхнеуровневого пайплайна

Центральная функция всей системы — runProcessingPipeline() в main.gs. Она вызывается либо вручную (startMonthProcessing() → выбор месяца в monthPicker.html), либо по триггеру (runProcessingPipelineTrigger()).

flowchart TD
    A[Старт: runProcessingPipeline] --> B[getBitrixUserList]
    B --> C{Список пуст?}
    C -->|Да| D[Логируем WARN, продолжаем без него]
    C -->|Нет| E[Определяем период: год/месяц]
    D --> E
    E --> F[fetchCalls: postраничная загрузка звонков МоиЗвонки]
    F --> G{Есть звонки?}
    G -->|Нет| H[progress: 'Нет данных', return false]
    G -->|Да| I[processCalls: фильтр ЧС + нормализация + перезвоны]
    I --> J{Есть данные после обработки?}
    J -->|Нет| K[return false]
    J -->|Да| L[groupByMonth: группировка YYYY-MM]
    L --> M[exportToSheets: ОКБ + Telegram + запись в Sheets]
    M --> N[hideOutdatedSheets: скрыть листы старше 3 мес]
    N --> O{Есть email для отчётов?}
    O -->|Нет| P[Логируем: PDF не генерируем]
    O -->|Да| Q[generatePdfReports]
    P --> R[progress: done, return true]
    Q --> R

Ключевые архитектурные решения, зашитые в эту схему:

2.2 Диаграмма источников данных при экспорте

Самая содержательная точка сборки данных — exportToSheets() в sheetExporter.gs. Здесь пересекаются все три «обогащающих» источника (Битрикс24 для менеджеров, ОКБ для компаний, Telegram для чатов):

flowchart LR
    subgraph Входные данные
        MD[monthlyData из groupByMonth]
        OKB[getCachedOkbPhoneIndex]
        TG[fetchAggregatedMessages]
        MGR[getManagersList]
    end

    MD --> EXP[exportToSheets]
    OKB --> EXP
    TG --> EXP
    MGR --> EXP

    EXP --> LOOP[Для каждого месяца: calls + chatRows → allRows, sort by date/time]
    LOOP --> EM[exportMonthData: запись в лист Звонки_YYYY-MM]
    EM --> RES[resolveManagerName: email → ФИО через managersList]
    EM --> FCB[findCompanyByPhone: телефон → компания через okbPhoneIndex]
    EM --> STATS[generateStatistics: лист Звонки_YYYY-MM_Статистика]

Важные детали, которые не видны на схеме, но критичны для понимания:

  1. ОКБ грузится один раз на весь вызов exportToSheets(), а не на каждый месяц — getCachedOkbPhoneIndex() вызывается один раз до цикла по месяцам. Если доступ к таблице ОКБ упадёт (ACCESS_DENIED), это не прерывает экспорт — просто okbPhoneIndex остаётся пустым объектом {}, и все компании в отчёте будут помечены как «Не найдена в ОКБ».

  2. Telegram-сообщения грузятся за весь диапазон месяцев разом, затем раскладываются по chatRowsByMonth вручную (row.date.substring(0, 7)), а не запрашиваются по месяцам отдельно. Это разумная оптимизация — один HTTP-вызов вместо N, — но одновременно означает, что при сбое запроса к Telegram API (catch вокруг fetchAggregatedMessages) все месяцы разом остаются без чатов, без частичного отката на уровне одного месяца.

  3. Компания резолвится по-разному для звонков и для чатов. Для звонков — findCompanyByPhone(row.clientNumber, okbPhoneIndex) внутри exportMonthData(), то есть каждый раз заново по телефону через индекс ОКБ. Для чатов — компания уже приходит предрассчитанной внутри объекта row.companyName из fetchAggregatedMessages(), которая сама резолвит номер через параметр phoneToContactMap. Здесь и кроется расхождение, разобранное подробно в разделе 6 — вызов fetchAggregatedMessages(dateFrom, dateTo, okbPhoneIndex) в sheetExporter.gs передаёт 3 аргумента, тогда как сама функция в telegramService.gs объявлена с 4-мя (dateFrom, dateTo, phoneToContactMap, companyIdToName). По факту okbPhoneIndex попадает на место phoneToContactMap, а companyIdToName внутри функции окажется undefined — что сломает резолв названия компании через companyIdToName[String(bxContact.companyId)] в блоке, где bxContact.companyId не пуст. Технически это не всегда стреляет (зависит от формы данных, лежащих в okbPhoneIndex), но потенциальный источник TypeError: Cannot read properties of undefined в проде — реальный, актуальный баг, не гипотетический.

2.3 Асинхронный UI и опрос статуса (polling)

Так как GAS не даёт push-уведомлений от сервера к открытому диалогу, прогресс обработки реализован через классический long polling каждую секунду:

sequenceDiagram
    participant U as Пользователь
    participant MP as monthPicker.html
    participant Srv as main.gs (сервер)
    participant PD as progressDialog.gs
    participant PS as ScriptProperties

    U->>MP: Выбирает месяц, жмёт "Запустить"
    MP->>Srv: startMonthProcessing(year, month)
    Srv->>PS: _setProcessingStatus({progress:0, ...})
    Srv->>PD: showModelessDialog (открывается прогресс-диалог)
    Srv->>Srv: runProcessingPipeline(year, month) — синхронный вызов
    loop каждую секунду
        PD->>Srv: getProcessingStatus()
        Srv->>PS: getProperty('PROCESSING_STATUS')
        PS-->>Srv: JSON статуса
        Srv-->>PD: applyStatus(status)
        PD->>PD: обновление прогресс-бара и шагов
    end
    Srv->>PS: _setProcessingStatus({progress:100, done:true})
    PD->>PD: clearInterval(_poll), закрытие через 1.5с

Важный нюанс архитектуры, который стоит проговорить явно: runProcessingPipeline() вызывается синхронно из startMonthProcessing(), то есть сам факт того, что модальное окно прогресса открылось до вызова пайплайна (showModelessDialog идёт раньше runProcessingPipeline(...) по коду), — это не гарантия параллельности, а просто порядок в очереди операций одного и того же серверного вызова. Модальное окно открывается, но реальное обновление статуса, который оно должно отображать, произойдёт только после того, как основная функция допишет очередной _setProcessingStatus(...) — и все эти записи происходят внутри одного и того же выполнения runProcessingPipeline, последовательно, а не из отдельного потока. Пользователь видит прогресс благодаря тому, что PropertiesService — это внешнее относительно самого выполнения хранилище, и progressDialog.gs, опрашивая его каждую секунду отдельным HTTP-запросом от клиента, реально видит промежуточные записи, сделанные ещё выполняющимся серверным скриптом.

2.4 Карта файлов проекта и их ответственность

Файл Тип Зона ответственности
main.gs Server Точка входа, меню, основной пайплайн runProcessingPipeline, триггеры, архивация листов
config.gs Server Конфигурация приложения, чтение/запись Script Properties, инициализация дефолтов
apiClient.gs Server HTTP-клиент к API МоиЗвонки, постраничная загрузка звонков, тест подключения
blacklist.gs Server Чёрный список номеров: нормализация телефонов, добавление/удаление/загрузка
dataProcessor.gs Server Обработка сырых звонков: фильтрация ЧС, анализ перезвонов, группировка по месяцам
sheetExporter.gs Server Экспорт в Google Sheets, объединение звонков+чатов, генерация статистики, скрытие старых листов
pdfExporter.gs Server Формирование и рассылка PDF-отчётов по email, fallback-логика при сбое рассылки
utils.gs Server Вторая реализация нормализации телефона, буферизованное логирование, safeExecute
blacklistManager.gs HTML (Client) UI управления чёрным списком номеров
settingsDialog.gs HTML (Client) UI настроек приложения (API, интеграции, email)
integrations.gs Server Отправка email-отчётов, создание задач в Битрикс24, тест CRM-вебхука
bitrixService.gs Server Получение сотрудников/контактов/компаний Битрикс24, тест обоих вебхуков
testFunctions.gs Server Диагностические функции для ручного запуска из редактора
managers.html HTML (Client) UI управления списком менеджеров и их Telegram Manager ID
managersList.gs Server CRUD списка менеджеров, резолв email→имя, построение карты Telegram ID→имя
cache.gs Server Кэш карты компаний Битрикс24 через ScriptProperties (TTL 1 час)
archiveDialog.html HTML (Client) UI архивации листов в отдельный файл
telegramService.gs Server Загрузка и агрегация сообщений из Telegram Logger API
progressDialog.gs HTML (Client) UI прогресс-бара с long polling статуса пайплайна
progress.gs Server Хранение/чтение статуса обработки через ScriptProperties
balancer.gs Server Прокси-обёртка для обхода лимитов Битрикс24 (HMAC-подписанные запросы)
okbService.gs Server Загрузка таблицы ОКБ, построение и кэширование индекса телефон→компания
monthPicker.html HTML (Client) UI выбора года/месяца для ручного запуска обработки
timePicker.html HTML (Client) UI настройки времени ежедневного автозапуска

Обрати внимание на несостыковку в именовании: файлы blacklistManager.gs и settingsDialog.gs физически содержат HTML-разметку (<!DOCTYPE html>), а не Apps Script код, несмотря на расширение .gs. Это, скорее всего, артефакт выгрузки/именования в репозитории, а не ошибка исполнения (GAS сам разруливает файлы по объявленному типу внутри проекта, а не по расширению в имени) — но при поддержке проекта стоит явно понимать, что это HTML-файлы, иначе легко потерять время, пытаясь найти в них серверный код.


3. Развёртывание и первоначальная настройка

3.1 Требования к Google Sheets и правам доступа

Для корректной работы системы нужны следующие внешние ресурсы и разрешения:

3.2 Порядок инициализации проекта

Первый запуск (вручную либо через установку как Add-on) идёт по цепочке:

flowchart TD
    A[onInstall] --> B[initializeProject]
    B --> C[initializeDefaultConfig]
    C --> D{Ключ уже есть в Script Properties?}
    D -->|Да| E[Пропускаем, не перезаписываем]
    D -->|Нет| F[Устанавливаем дефолт из объекта defaults]
    E --> G[SpreadsheetApp.getActiveSpreadsheet]
    F --> G
    G --> H[Сохраняем SPREADSHEET_ID]
    H --> I[_clearConfigCache]
    I --> J[createRequiredSheets]
    J --> K{Лист 'Черный список' существует?}
    K -->|Нет| L[insertSheet + hideSheet]
    K -->|Да| M[Пропускаем]
    J --> N{Лист 'Менеджеры' существует?}
    N -->|Нет| O[insertSheet + hideSheet]
    N -->|Да| P[Пропускаем]

Ключевая деталь: initializeDefaultConfig() только устанавливает отсутствующие ключи, никогда не перетирает существующие:

const toSet = {};
Object.entries(defaults).forEach(([k, v]) => {
  if (!existing[k]) toSet[k] = v;
});

Это разумное, безопасное поведение для первого разворачивания — повторный вызов initializeProject() (например, после переустановки Add-on) не затрёт уже настроенные пользователем значения API_KEY, USER_EMAIL и т.д.

3.3 Настройка триггера автозапуска

Ежедневный автозапуск обработки настраивается через setupAutoTriggerWithTime(hour, minute) (main.gs), вызываемую из timePicker.html:

flowchart LR
    A[timePicker.html: пользователь выбирает часы/минуты] --> B[setupAutoTriggerWithTime]
    B --> C[removeAllTriggers]
    C --> D[ScriptApp.newTrigger 'runProcessingPipelineTrigger']
    D --> E[.timeBased.everyDays 1 .atHour .nearMinute .inTimezone]
    E --> F[Сохранить TRIGGER_TIME в ScriptProperties]

Два важных технических момента:

  1. removeAllTriggers() вызывается перед созданием нового — то есть система жёстко ограничивает себя одним триггером runProcessingPipelineTrigger в любой момент времени. Это защищает от дублирования запусков (частая проблема в GAS-проектах, где повторный вызов «Настроить автозапуск» без предварительной очистки плодит по два-три идентичных триггера), но одновременно means: если в проекте появятся другие триггеры (например, для очистки логов по расписанию), их тоже снесёт эта функция — стоит иметь в виду при дальнейшем расширении функциональности.

  2. TRIGGER_TIME в ScriptProperties — это единственный источник истины о времени триггера для UI. Как уже отмечалось в разделе 1.3, GAS не даёт способа прочитать точное время существующего time-based триггера через ScriptApp.getProjectTriggers() — методы объекта Trigger не раскрывают час/минуту напрямую. Поэтому getCurrentTriggerInfo() использует двухступенчатую проверку: сначала ищет сам факт существования триггера с нужным getHandlerFunction(), а потом отдельно читает TRIGGER_TIME из свойств. Из этого следует важное ограничение: если триггер был создан не через setupAutoTriggerWithTime() (например, вручную через редактор Apps Script или программно из другого места), UI покажет дефолтное время 16:30, даже если реальный триггер настроен на другое время — TRIGGER_TIME и фактическое расписание триггера могут разойтись, если кто-то обходит штатный UI.

removeAllTriggers() также доступен отдельным пунктом меню и вызывается из timePicker.html (кнопка «Удалить триггеры») — при этом TRIGGER_TIME из ScriptProperties не удаляется, только сами триггеры. Это значит, что при следующем открытии timePicker.html (до создания нового триггера) поля часов/минут всё ещё будут показывать последнее сохранённое время, хотя реального триггера уже не существует — не баг, но потенциально сбивающее с толку поведение UI, которое стоит держать в голове при поддержке.


4. Конфигурация (config.gs)

4.1 Структура объекта конфигурации и кэширование

Всё приложение читает настройки через единственную точку входа — getConfig(). Функция не читает PropertiesService на каждый вызов, а строит один раз объект-конфиг и держит его в переменной модуля _configCache:

let _configCache = null;

function getConfig() {
  if (_configCache) return _configCache;
  // ... сборка конфига из props ...
  return _configCache;
}

Обратная сторона — кэш нужно вручную сбрасывать после любой записи в ScriptProperties в обход updateConfig(). Разработчики это учли: _clearConfigCache() вызывается в трёх местах — после updateConfig(), после initializeDefaultConfig() и после прямой записи SPREADSHEET_ID в initializeProject()/startMonthProcessing()/runProcessingPipeline().

if (!props['BITRIX24_USERS_WEBHOOK'] || !props['BITRIX24_CRM_WEBHOOK']) {
  initializeDefaultConfig();
  Object.assign(props, sp.getProperties());
}

То есть чтение конфига может неявно приводить к записи в Script Properties (самовосстановление дефолтов). Само по себе это удобно для «самозаживления» после ручного удаления свойств через редактор, но для человека, читающего getConfig() первый раз, это неочевидный побочный эффект — геттер, который иногда пишет.

Поле apiUrl в возвращаемом объекте конфигурации — не строка, а функция (() => https://${subdomain}.moizvonki.ru/api/v1), которую нужно вызывать как config.apiUrl(). Это сделано, чтобы URL всегда собирался из актуального subdomain в момент использования, а не «замораживался» на момент вызова getConfig() — хотя при текущей архитектуре (subdomain читается один раз при построении кэша) разница на практике не проявляется, пока сессия не перезапустится.

4.2 Таблица всех Script Properties

Ключ Script Property Где читается Где записывается Дефолт / поведение при отсутствии
API_SUBDOMAIN getConfig() updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() 'test'
USER_EMAIL getConfig() updateConfig(), forceReinitializeConfig() ''
API_KEY getConfig() updateConfig(), forceReinitializeConfig() ''
VERIFY_SSL getConfig() updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() 'true' (сравнение строкой === 'true')
CALLBACK_WINDOW_HOURS getConfig() updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() '24'
MIN_CALL_DURATION getConfig() updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() '10'
SPREADSHEET_ID getConfig() initializeProject(), startMonthProcessing(), forceReinitializeConfig() '' — при пустом значении часть функций (exportToSheets, loadBlacklist, hideOutdatedSheets) молча завершаются с логом, не бросая исключение
REPORTS_FOLDER_ID getConfig() updateConfig() ''
ENABLE_LOGGING getConfig() updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() 'true'
REPORT_EMAIL getConfig() (нет прямого пути через updateConfig() — см. 4.4) '' — устаревшее поле, помечено в коде как «одиночный email (устаревшее поле)»
REPORT_EMAILS getConfig() updateConfig() (с нормализацией списка) '' — актуальное поле-список
BITRIX24_ENABLED getConfig() updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() 'true'
BITRIX24_USERS_WEBHOOK getConfig(), _getBitrixUsersWebhook() updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() захардкоженный реальный вебхук — см. раздел 3.3
BITRIX24_CRM_WEBHOOK getConfig(), _getBitrixCrmWebhook() updateConfig(), initializeDefaultConfig(), forceReinitializeConfig() захардкоженный реальный вебхук — см. раздел 3.3
BITRIX24_WEBHOOK getConfig() (нет пути записи через updateConfig()) '' — помечено как «устаревшее поле, оставлено для обратной совместимости»
TELEGRAM_API_URL getConfig(), _getTelegramApiUrl() updateConfig(), initializeDefaultConfig(), initTelegramApiSettings() значение читается напрямую из PropertiesService в telegramService.gs, минуя getConfig()
TELEGRAM_API_KEY getConfig(), _getTelegramApiKey() updateConfig(), initializeDefaultConfig(), initTelegramApiSettings() дефолт 'supersecretkey' захардкожен в трёх местах — см. раздел 9.3
TRIGGER_TIME getCurrentTriggerInfo() setupAutoTriggerWithTime() не сбрасывается при removeAllTriggers() — см. раздел 3.4

4.3 updateConfig() и whitelist разрешённых настроек

updateConfig(newSettings) — единственная функция, которая записывает в ScriptProperties из UI (settingsDialog.gs → updateSettings() в main.gs → updateConfig()). Она защищена белым списком:

const allowedSettings = new Set([
  'API_SUBDOMAIN', 'USER_EMAIL', 'API_KEY', 'VERIFY_SSL',
  'CALLBACK_WINDOW_HOURS', 'MIN_CALL_DURATION',
  'REPORTS_FOLDER_ID', 'ENABLE_LOGGING', 'REPORT_EMAILS',
  'BITRIX24_USERS_WEBHOOK', 'BITRIX24_CRM_WEBHOOK', 'BITRIX24_ENABLED',
  'TELEGRAM_API_URL', 'TELEGRAM_API_KEY',
]);

Смысл этого списка — не дать случайному/вредоносному вызову из клиентского JS перезаписать произвольный ключ (например, SPREADSHEET_ID через самодельный google.script.run вызов из консоли браузера). Всё, что не входит в allowedSettings, тихо игнорируется циклом Object.entries(newSettings).forEach(...).

Обрати внимание на два прямых следствия этого списка:

  1. REPORT_EMAIL (единственное число) не входит в whitelist. Значит, даже если бы клиентская форма в settingsDialog.gs действительно отправляла REPORT_EMAIL (а она пытается — см. saveSettings()), updateConfig() бы это значение всё равно проигнорировал. Это ещё одно косвенное подтверждение бага из раздела 4.2: старое поле REPORT_EMAIL в текущей версии кода — фактически не работающий путь end-to-end, ни на фронте (несуществующий DOM-элемент), ни на бэке (не в whitelist).

  2. SPREADSHEET_ID и BITRIX24_WEBHOOK нельзя изменить через UI настроек — только напрямую через PropertiesService в редакторе кода или через forceReinitializeConfig(). Для SPREADSHEET_ID это скорее осознанное решение (таблица привязывается автоматически при первом запуске, менять её через UI — редкий и рискованный сценарий), а вот отсутствие пути изменения BITRIX24_WEBHOOK совпадает с тем, что само поле помечено как устаревшее — то есть согласовано с остальным кодом.

Отдельно стоит разобрать нормализацию REPORT_EMAILS внутри updateConfig() — единственное поле, которое обрабатывается не через простое String(value), а через полноценный pipeline:

const emails = String(value)
  .split(',')
  .map(e => e.trim().toLowerCase())
  .filter(e => e && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))
  .filter((e, i, arr) => arr.indexOf(e) === i);

toSet[key] = emails.join(', ');

Трим, приведение к нижнему регистру, фильтрация по простому regex-паттерну email и удаление дублей. Тот же самый паттерн валидации email (/^[^\s@]+@[^\s@]+\.[^\s@]+$/) продублирован ещё в двух местах — getEmailAddresses() в utils.gs и getEmailAddresses() в pdfExporter.gs (разбирается подробнее в разделе 12.4). Здесь это тот же паттерн third-time — то есть по факту в проекте есть три копии одной и той же регулярки для валидации email, которые нужно синхронно поддерживать, если формат когда-нибудь понадобится ужесточить (например, добавить поддержку кириллических доменов).


5. Модель данных

5.1 Структура записи звонка после обработки

Каждый сырой звонок из API МоиЗвонки (fetchCalls()) проходит через processCalls() (dataProcessor.gs) и превращается в объект со следующими полями:

Поле Тип Источник / логика Комментарий
id string/number call.db_call_id Идентификатор звонка из МоиЗвонки, используется только для логов ошибок обработки
date string YYYY-MM-DD timestampToDate(startTimestamp, config.timezone) Часовой пояс — Europe/Moscow, зашит в config.timezone
time string HH:mm:ss timestampToTime(startTimestamp, config.timezone) Используется как вторичный ключ сортировки в groupByMonth()
direction 'Исходящий' | 'Входящий' call.direction === 1 ? 'Исходящий' : 'Входящий' Бинарная логика — направление 1 жёстко зашито как исходящий
clientNumber string normalizePhone(call.client_number) Нормализованный номер, см. раздел 5.4
clientName string call.client_name || 'Неизвестно' Из МоиЗвонки, без сопоставления с CRM на этом шаге
duration number call.duration || 0 В секундах
answered 'Да' | 'Нет' answered === 1 ? 'Да' : 'Нет' Строковое представление для вывода в таблицу
recording string call.recording || '' Ссылка на запись разговора (формат ссылки не описан в коде — см. пробел ниже)
managerEmail string call.user_account || 'unknown@company.com' Ключ для последующего resolveManagerName()
status string 'Отвечен' / 'Пропущен' → далее переопределяется в analyzeCallbacks() Финальное значение зависит от анализа перезвонов, см. ниже
isMissed boolean answered === 0 Внутренний флаг, не идёт в таблицу напрямую
hasCallback boolean по умолчанию false, пересчитывается в analyzeCallbacks() См. раздел 6.4
dateTimeObject Date | null new Date(startTimestamp * 1000) Используется только для сравнения временных окон внутри analyzeCallbacks(), в таблицу не экспортируется

Важный нюанс по полю status: изначально оно принимает значение 'Отвечен' или 'Пропущен' в processCalls(), но для пропущенных звонков analyzeCallbacks() его перезаписывает на 'Пропущен, перезвон совершен' или 'Пропущен, перезвон не совершен'. То есть финальный набор возможных значений status в отчёте — три штуки, а не два, и это стоит иметь в виду при написании любых фильтров или формул поверх листа Звонки_YYYY-MM (простое сравнение status === 'Пропущен' в клиентском коде уже не сработает после обработки — это же используется внутри generateStatistics() через .includes('Пропущен'), а не строгое равенство, именно по этой причине).

Отдельно стоит зафиксировать: фильтрация по чёрному списку происходит до сборки этого объекта (if (blacklist.has(clientNum) || blacklist.has(srcNum)) continue;), то есть звонки от/на номера из ЧС в датасет не попадают вообще — не помечаются каким-то статусом, а физически отсутствуют в отчёте. Если аналитику понадобится позже посчитать «сколько звонков было отфильтровано» — этих данных нет нигде, continue их просто выбрасывает без логирования счётчика.

5.2 Структура записи чата

Telegram-переписки агрегируются в fetchAggregatedMessages() (telegramService.gs) в объекты со схожим, но не идентичным набором полей:

Поле Тип Источник / логика Комментарий
type 'chat' константа Используется в sheetExporter.gs для ветвления логики (row.type === 'chat')
id string 'tg_' + row.dialog_id + '_' + row.message_date Синтетический составной ключ, не пересекается по формату с id звонков
date string YYYY-MM-DD row.message_date Приходит уже в готовом формате от Telegram Logger API, без преобразования часового пояса
time string всегда '—' Чат агрегируется за день, а не по конкретному времени сообщения — точного времени просто нет в модели
direction string всегда 'Чат' Заполняет ту же колонку, что и 'Исходящий'/'Входящий' у звонков
clientNumber string normalizedPhone || ('tg_client_' + row.client_id) Если телефон не резолвится — используется синтетический псевдо-номер на основе client_id
clientName string '—' / 'Неизвестно' / имя из Битрикс24-контакта Зависит от результата поиска в phoneToContactMap, см. раздел 6.7
companyName string из Битрикс24-контакта, либо 'Не найден контакт' / 'Номер скрыт' / 'Нет телефона в БД' Резолвится внутри telegramService.gs, а не в sheetExporter.gs, в отличие от звонков — см. раздел 2.2, пункт 3
messageCount number row.message_count Суммарное количество сообщений за день по этому диалогу
managerName string tgManagersMap[String(row.manager_id)] либо 'Telegram #' + row.manager_id Резолв через лист «Менеджеры», колонка Telegram Manager ID
lastMessage string row.last_message_text, обрезано до 150 символов Может быть пустой строкой, если поле отсутствует в ответе API
status string '{count} сообщ. (исх: {N} / вх: {M})' Человекочитаемая сводка, используется как замена колонке «Статус» у звонков
dateTimeObject Date new Date(row.message_date + 'T00:00:00Z') Синтетическая полночь UTC — не реальное время переписки, только для сортировки по дате

Ключевое структурное отличие от звонков — у чата нет отдельной колонки длительности/количества, вместо неё переиспользуется поле messageCount в той же позиции таблицы, где у звонков стоит duration (см. exportMonthData() в sheetExporter.gs, столбец «Длит. (сек) / Кол-во сообщ.» — это буквально одна колонка на два разных физических смысла). Аналогично колонка «Отвечен» для чата всегда содержит '—', а «Запись» — последнее сообщение дня вместо ссылки на аудиозапись. Решение разумное с точки зрения не плодить два разных листа с разными шапками, но оно означает, что при построении сводных таблиц или графиков поверх листа Звонки_YYYY-MM нельзя слепо суммировать «шестую колонку» — её смысл меняется в зависимости от значения первой колонки («Тип»).

5.3 Листы Google Sheets и их назначение

Лист Создаётся в Видимость Формат
Звонки_YYYY-MM exportMonthData() (sheetExporter.gs), по одному на месяц с данными Виден, пока не скрыт hideOutdatedSheets() (старше 3 месяцев по умолчанию) 12 колонок: Тип, Дата, Время, Направление, Номер контакта, Имя контакта, Компания, Длит./Кол-во сообщ., Отвечен, Запись/Последнее сообщение, Менеджер, Статус
Звонки_YYYY-MM_Статистика generateStatistics() (sheetExporter.gs), автоматически при экспорте соответствующего месяца Виден, скрывается вместе с родительским листом данных [Требует уточнения: hideOutdatedSheets() матчит только паттерн ^Звонки_(\d{4})-(\d{2}), под который лист ..._Статистика тоже подходит, т.к. regex не заякорён в конце строки — нужно подтвердить, что это осознанное поведение, а не побочный эффект] 9 колонок: Менеджер, разделитель «// Звонки //», Всего звонков, Пропущено, Перезвонов, Эффективность (%), разделитель «// Чаты //», Дней с чатами, Сообщений всего
Черный список initializeProject() → createRequiredSheets(), либо лениво в loadBlacklist()/addToBlacklist() Скрыт (hideSheet()) 2 колонки: Номер телефона, Комментарий
Менеджеры initializeProject() → createRequiredSheets(), либо лениво в saveManagersList() Скрыт (hideSheet()) 3 колонки: Email менеджера, Имя менеджера, Telegram Manager ID
Логи _getLogSheet() (utils.gs), лениво при первом вызове logMessage() Скрыт 3 колонки: Дата и время, Уровень, Сообщение; обрезается сверху при превышении LOG_MAX_ROWS = 1000

5.4 Три независимые реализации нормализации телефона

В проекте объявлены три отдельные функции для нормализации номера телефона, и это не разделение по смыслу (например, «строгая» и «мягкая» версии для разных задач), а фактическое дублирование одной и той же идеи с разными деталями реализации:

Функция Файл Ключевая особенность
normalizePhone() blacklist.gs Проверяет HIDDEN_NUMBER_PATTERNS до и после очистки цифр; при цифрах меньше порога — просто возвращает исходные digits без изменений (нет явной обработки «слишком короткого» номера, кроме проверки на скрытый паттерн)
normalizePhone() utils.gs Использует _HIDDEN_PATTERNS (переиспользует HIDDEN_NUMBER_PATTERNS из blacklist.gs, если он уже объявлен, иначе — свою локальную копию списка); явно возвращает '' для номеров короче 7 цифр — поведение, которого нет в версии из blacklist.gs
normalizePhoneStrict() okbService.gs Не проверяет текстовые паттерны (anonymous, скрыт и т.д.) вообще — работает только с цифрами; возвращает null (а не строку 'Номер скрыт' или '') при невалидном номере

Обе функции с именем normalizePhone() (в blacklist.gs и в utils.gs) объявлены как function normalizePhone(...) на верхнем уровне файла — то есть в терминах правил JavaScript/GAS это ровно тот же случай, что уже разбирался в разделе 4.3 с initializeDefaultConfig(): при загрузке всех .gs файлов в общую область видимости одного проекта Apps Script побеждает то объявление, которое было загружено последним. Какое из двух объявлений реально выполняется в проде, зависит от порядка файлов в самом проекте Apps Script (не от порядка, в котором файлы лежат в этой документации или в репозитории) — этот порядок не входит в состав переданных файлов и не может быть установлен по одному лишь содержимому кода.


6. Основной поток обработки (бизнес-логика)

6.1 Определение периода обработки

Период обработки вычисляется в начале runProcessingPipeline(targetYear, targetMonth) (main.gs). Логика опирается на часовой пояс Europe/Moscow вне зависимости от того, в каком часовом поясе физически исполняется сам скрипт (GAS-сервера работают в UTC):

const nowDate       = new Date();
const currentYear   = parseInt(Utilities.formatDate(nowDate, 'Europe/Moscow', 'yyyy'), 10);
const currentMonth  = parseInt(Utilities.formatDate(nowDate, 'Europe/Moscow', 'MM'), 10);

const year  = targetYear  || currentYear;
const month = targetMonth || currentMonth;

Если targetYear/targetMonth не переданы (сценарий автозапуска через runProcessingPipelineTrigger(), где runProcessingPipeline() вызывается без аргументов) — берётся текущий месяц по МСК. Если переданы (ручной запуск через monthPicker.html) — используется выбор пользователя.

Дальше идёт важная развилка — текущий месяц обрабатывается не целиком, а "по сейчас":

const isCurrentMonth = (year === currentYear && month === currentMonth);
const endOfPeriod    = isCurrentMonth
  ? nowTimestamp
  : Math.min(getEndOfMonthTimestamp(year, month), nowTimestamp);

6.2 Постраничная загрузка звонков из API МоиЗвонки

fetchCalls(fromDate, toDate, supervised) в apiClient.gs — единственная точка получения сырых данных о звонках. Особенности реализации:

Пагинация через from_offset/results_next_offset. API МоиЗвонки не отдаёт весь период одним ответом — используется классический паттерн keyset-пагинации:

let allCalls  = [];
let fromOffset = 0;

while (true) {
  const payload = { /* ..., max_results: 100, from_offset: fromOffset };
  // запрос страницы
  const nextOffset = page.results_next_offset || 0;
  allCalls = allCalls.concat(calls);

  if (calls.length === 0 || nextOffset === 0 || nextOffset === fromOffset) {
    break;
  }
  fromOffset = nextOffset;
}

Retry-логика на уровне одной страницы, а не всего запроса целиком — если страница №3 из десяти упадёт по сети, повторно перезапрашиваются не все десять, а только неудавшаяся:

for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
  try {
    const response = UrlFetchApp.fetch(config.apiUrl(), options);
    // ...
    break; // успех — выходим из retry-цикла
  } catch (err) {
    if (attempt < MAX_RETRIES) {
      Utilities.sleep(RETRY_DELAY * attempt); // экспоненциальная задержка
    } else {
      throw new Error(`fetchCalls: не удалось получить страницу после ${MAX_RETRIES} попыток. ${err}`);
    }
  }
}

MAX_RETRIES = 3, задержка растёт линейно (RETRY_DELAY * attempt, то есть 2с/4с/6с) — не совсем экспоненциальный backoff, вопреки комментарию «экспоненциальная задержка» в коде, а линейный. Мелкая неточность в терминологии комментария, не влияющая на поведение, но стоит знать при чтении кода, чтобы не искать экспоненту там, где её нет.

6.3 Фильтрация чёрного списка и построение звонка

processCalls(rawCalls) (dataProcessor.gs) — первый шаг обработки после получения сырых данных. Порядок операций внутри цикла по звонкам важен:

for (const call of rawCalls) {
  const clientNum = normalizePhone(call.client_number || '');
  const srcNum    = normalizePhone(call.src_number    || '');

  if (blacklist.has(clientNum) || blacklist.has(srcNum)) continue;
  // ... построение объекта call ...
}

Фильтрация идёт до построения итогового объекта — отфильтрованные звонки физически не попадают в processed, что уже отмечалось в разделе 5.1. Проверяются оба номера — и client_number, и src_number — то есть в чёрный список можно занести как номер клиента, так и внутренний номер источника вызова (последнее менее очевидно с точки зрения бизнес-смысла, но защищает от, например, случайных технических/тестовых номеров АТС, которые иначе засоряли бы отчёт).

loadBlacklist() (blacklist.gs) читает лист «Черный список» один раз за сессию и кэширует в _blacklistCache как Set<string> — то есть проверка blacklist.has(...) работает за O(1), а не линейным перебором массива на каждый звонок, что важно при тысячах записей в месяц.

6.4 Логика определения перезвона (analyzeCallbacks)

Дальше для каждого пропущенного звонка (call.isMissed) ищется, был ли по этому же номеру клиента отвеченный звонок после пропущенного, в пределах окна callbackWindowHours (настраивается в UI, дефолт 24 часа), и длительностью не меньше minCallDuration (дефолт 10 секунд):

const hasCallback = clientCalls.some(c =>
  c.answered === 'Да' &&
  c.duration >= config.minCallDuration &&
  c.dateTimeObject &&
  c.dateTimeObject > call.dateTimeObject &&
  c.dateTimeObject <= windowEnd
);

Проверка длительности (duration >= minCallDuration) — важная бизнес-деталь: короткий «звонок» на 2 секунды формально отвеченный (answered === 1 в исходных данных), но по факту это может быть случайное поднятие трубки без реального разговора. Такой звонок не засчитывается как перезвон, даже если формально попадает в нужное временное окно.

Ещё одна деталь — windowEnd считается от момента пропущенного звонка, а не от конца рабочего дня/суток:

const windowEnd = new Date(call.dateTimeObject.getTime() + callbackWindowMs);

То есть окно «плавающее» — пропущенный звонок в 23:50 с окном 24 часа даёт дедлайн перезвона на 23:50 следующего дня, а не на конец следующего дня целиком. Это стандартное и предсказуемое поведение, но стоит явно знать, объясняя логику метрики аналитикам, которые могут интуитивно ожидать «в течение следующих суток» в смысле календарных суток.

6.5 Группировка по месяцам и экспорт

groupByMonth(calls) (dataProcessor.gs) — финальный шаг перед экспортом, разбивает единый массив обработанных звонков на объект { 'YYYY-MM': Call[] } по первым 7 символам поля date, с сортировкой внутри каждого месяца по дате, а при совпадении дат — по времени:

grouped[month].sort((a, b) => {
  const dateCmp = a.date.localeCompare(b.date);
  return dateCmp !== 0 ? dateCmp : a.time.localeCompare(b.time);
});

На практике, поскольку период обработки в runProcessingPipeline() всегда укладывается в границы одного календарного месяца (см. раздел 6.1), результат groupByMonth() почти всегда содержит ровно один ключ в объекте — сама возможность мульти-месячной группировки в коде существует, но реальный сценарий использования (ручной или автоматический запуск за один месяц) её не задействует в полной мере. Функция явно спроектирована с запасом на будущее — например, на случай, если понадобится когда-нибудь обрабатывать произвольный диапазон дат, а не строго один месяц.

Финальная запись в Sheets идёт через exportMonthData() (sheetExporter.gs), которая для каждого месяца полностью очищает данные существующего листа перед повторной записью:

const lastRow = sheet.getLastRow();
if (lastRow > 1) {
  sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).clearContent();
}

Это значит, что повторный запуск обработки за один и тот же месяц не дублирует строки, а полностью пересчитывает лист заново — удобно для идемпотентности (можно смело перезапускать обработку после сбоя без риска задвоить данные), но одновременно означает, что любые ручные правки, внесённые аналитиком напрямую в лист Звонки_YYYY-MM (например, дописанные вручную комментарии в свободную колонку, если бы такая была), будут потеряны при следующем автозапуске или ручном перезапуске того же месяца — колонок для «ручных заметок» в текущей структуре листа нет, но стоит держать этот риск в голове, если бизнес когда-нибудь попросит такую возможность.

6.6 Статистика по менеджерам

generateStatistics() (sheetExporter.gs) считается по тому же массиву allRows, что был записан в лист данных — звонки и чаты обрабатываются раздельно внутри одного объекта stats, ключом которого выступает разрешённое имя менеджера, а не email/telegramId:

if (row.type === 'chat') {
  const key = row.managerName || 'Неизвестный менеджер';
  // ...
} else {
  const key = resolveManagerName(row.managerEmail, managersList) || row.managerEmail || 'Неизвестный';
  // ...
}

Эффективность считается только для звонков, чатам эта метрика не присваивается:

const efficiency = s.missed > 0
  ? (s.callbacks / s.missed * 100).toFixed(1)
  : (s.totalCalls > 0 ? '100.0' : '—');

При нуле пропущенных звонков, но при наличии хоть одного звонка вообще — эффективность жёстко выставляется в '100.0%'. Это разумное допущение (нет пропущенных — значит, отвечали на всё), но стоит явно знать: это не результат вычисления 0/0, а захардкоженная константа для этого частного случая.


7. Интеграция с Битрикс24

7.1 Два независимых механизма вебхуков — сравнение

В проекте фактически существуют два параллельных обращения к Битрикс24

Через config (BITRIX24_USERS_WEBHOOK / BITRIX24_CRM_WEBHOOK) Через «Балансировщик» (balancer.gs)
Где настраивается UI настроек (settingsDialog.gs → updateConfig()), либо forceReinitializeConfig() Захардкожен в коде как константа BITRIX_WEBHOOK
Кто использует integrations.gs (createBitrix24Task, testBitrix24Connection), bitrixService.gs (testBitrixConnection, через _getBitrixUsersWebhook()/_getBitrixCrmWebhook()) bitrixService.gs — getBitrixUserList() (user.get), getBitrix24Contacts() (crm.contact.list), getAllCompaniesMap() (crm.company.list)
Метод вызова Прямой UrlFetchApp.fetch() на URL вебхука balancerPost_() / balancerRecurrentPost_() — HTTP-запрос к серверу балансировщика, который сам проксирует запрос в Битрикс24
Обход лимитов Нет — обычный вебхук Битрикс24 со стандартными ограничениями Есть — балансировщик сам разруливает QUERY_LIMIT_EXCEEDED
Используется ли в реальном пайплайне runProcessingPipeline() Только getBitrixUserList() вызывается в пайплайне, но внутри неё используется balancerRecurrentPost_(), а не прямой вебхук Да — это фактический рабочий путь для получения пользователей внутри пайплайна

Эти два конфигурационных вебхука фактически используются только для:

7.2 Балансировщик как обход лимитов Битрикс24

«Балансировщик» — внешний сервис (https://72.56.235.246.nip.io/balancer/api/balancer/send), который используется в этом проекте для того, чтобы получать данные из Битрикс24 CRM (списки пользователей, контактов, компаний) без риска упереться в rate limit самого Битрикс24 при больших объёмах. Логика его внутренней работы (как именно он считает лимиты, сколько запросов пропускает в секунду, как балансирует нагрузку) находится вне зоны ответственности этой документации — сервис используется как чёрный ящик через два метода в balancer.gs:

Запросы к балансировщику подписываются HMAC-SHA256 (generateBalancerSignature_()) по паре ключей BALANCER_PUBLIC/BALANCER_PRIVATE, с таймстампом в заголовках X-Timestamp/X-Signature — то есть это не просто прокси по URL, а авторизованный канал, привязанный к конкретному клиенту (этому проекту). Для целей данной документации важно зафиксировать сам факт использования балансировщика и назначение — обход лимитов Битрикс24 при массовых операциях — без дальнейшего погружения в его собственную инфраструктуру.

7.3 Получение пользователей и контактов

getBitrixUserList() (bitrixService.gs) — вызывается в самом начале runProcessingPipeline() (шаг «Пользователи Битрикс24 загружены» на диаграмме из раздела 2.1) и возвращает список активных сотрудников:

const results = balancerRecurrentPost_('user.get', {});

const users = results
  .filter(u => u.ACTIVE && u.EMAIL)
  .map(u => ({
    id        : u.ID,
    name      : `${u.NAME || ''} ${u.LAST_NAME || ''}`.trim() || `Пользователь #${u.ID}`,
    email     : u.EMAIL.toLowerCase().trim(),
    position  : u.WORK_POSITION || '',
    department: Array.isArray(u.UF_DEPARTMENT) ? u.UF_DEPARTMENT.join(', ') : '',
  }));

Фильтр u.ACTIVE && u.EMAIL отсекает уволенных/заблокированных сотрудников и записи без email — что логично, так как единственное практическое применение этого списка в текущем пайплайне (getUserNameByEmail(), используется в testFunctions.gs для диагностики сопоставления email) требует именно email как ключ поиска. При этом сам список не используется напрямую в основном экспорте — резолв имени менеджера для звонков в sheetExporter.gs идёт не через getBitrixUserList(), а через resolveManagerName() из листа «Менеджеры» (managersList.gs), то есть список сотрудников Битрикс24, полученный в самом начале пайплайна, фактически нужен пайплайну лишь постольку, поскольку его отсутствие логируется как WARN — реального влияния на итоговый отчёт (кроме диагностических функций) он не оказывает.

getBitrix24Contacts() — получает все контакты CRM с телефонами, нормализует каждый номер через normalizePhone() (ещё одно место использования одной из трёх версий этой функции, см. раздел 5.4) и строит плоский массив { phone, name, id, companyId }. Есть защитный лимит:

const CONTACTS_WARN_LIMIT = 5000;
// ...
if (allContacts.length >= CONTACTS_WARN_LIMIT) {
  Logger.log(`⚠️ getBitrix24Contacts: достигнут лимит ${CONTACTS_WARN_LIMIT} контактов.`);
  break;
}

8. Интеграция с ОКБ

8.1 Проверка доступа и загрузка данных

ОКБ — отдельная Google-таблица, которая не принадлежит этому проекту и ведётся отдельно, как справочник компаний-клиентов с привязанными телефонами. Это единственный из четырёх источников данных (МоиЗвонки, Битрикс24, ОКБ, Telegram), к которому обращаются напрямую как к чужой Google-таблице, а не через HTTP API — отсюда и специфика проверки доступа.

loadOkbData() перед полноценным открытием таблицы делает тестовый запрос через export-URL:

const testUrl = `https://docs.google.com/spreadsheets/d/${OKB_SPREADSHEET_ID}/export?format=csv&gid=0`;
const testResponse = UrlFetchApp.fetch(testUrl, {
  muteHttpExceptions: true,
  headers: { 'Authorization': 'Bearer ' + ScriptApp.getOAuthToken() },
});

const code = testResponse.getResponseCode();
if (code === 403 || code === 404) {
  return { success: false, error: 'ACCESS_DENIED' };
}

Учётная запись, от имени которой выполняется сам Apps Script проект (то есть — тот, кто в последний раз авторизовал скрипт, либо владелец триггера при автозапуске, должна иметь права хотя бы на чтение таблицы ОКБ. Если доступа нет — exportToSheets() (см. раздел 2.2) не прерывается, а просто продолжает работу с пустым индексом okbPhoneIndex = {}, и все компании в отчёте помечаются как «Не найдена в ОКБ».

После успешной проверки доступа таблица открывается уже штатно через SpreadsheetApp.openById(OKB_SPREADSHEET_ID), читается первый лист (okbSpreadsheet.getSheets()[0], без проверки имени листа — если структура таблицы ОКБ когда-нибудь изменится и первым листом станет не тот, что нужен, это тихо сломает интеграцию без явной ошибки), и из заголовков ищутся четыре конкретных колонки по точному текстовому совпадению:

const nameIndex = headers.indexOf('Название 1');
const idIndex = headers.indexOf('bitrix ID');
const managerIndex = headers.indexOf('Менеджер');
const phonesIndex = headers.indexOf('Контакты (все данные)');

Обязательными считаются только nameIndex и phonesIndex — если хотя бы одна из этих двух колонок не найдена, функция возвращает явную ошибку MISSING_COLUMNS. Колонки bitrix ID и Менеджер опциональны — их отсутствие не прерывает загрузку, просто соответствующие поля (id, manager) в итоговых записях останутся пустыми строками. Это довольно хрупкая связь с внешней таблицей: переименование любой из этих четырёх колонок в самой таблице ОКБ (человеком, который её ведёт и, вероятно, не подозревает о существовании этого скрипта) тихо сломает интеграцию — в лучшем случае с явной ошибкой MISSING_COLUMNS (для Название 1/Контакты (все данные)), в худшем — без какой-либо ошибки вообще, просто с пустыми id/manager у всех компаний (для bitrix ID/Менеджер).

8.2 Парсинг телефонов и построение индекса

Один из самых нетривиальных участков во всём проекте — разбор колонки «Контакты (все данные)» (parseOkbPhones()), потому что формат этой колонки, судя по коду и комментариям, не строго структурирован, а представляет собой человеко-заполняемое текстовое поле:

// Формат: "79892692631,88616634548" или "79033210072 # 11" или "ТЕСТ"

Разбор идёт по цепочке: сначала строка режется по запятым (несколько телефонов через запятую в одной ячейке — нормальный сценарий для компании с несколькими контактными номерами), затем из каждой части отрезается всё после символа # (судя по примеру "79033210072 # 11", это, вероятно, добавочный номер или комментарий, зашитый прямо в ту же ячейку — [Требует уточнения: что именно означает # в этом контексте — добавочный, приоритет контакта, что-то ещё, — сам код это никак не поясняет]), и наконец каждый остаток нормализуется через normalizePhoneStrict() (третья, отдельная реализация нормализации телефона, разобранная в разделе 5.4).

Отдельная защита от заведомо мусорных значений:

if (cleaned === '' || cleaned.toUpperCase() === 'ТЕСТ') return [];

Наличие в реальных данных буквального значения 'ТЕСТ' в колонке телефонов — прямое свидетельство того, что таблица ОКБ ведётся вручную живыми людьми, а не генерируется автоматически, и в ней встречаются тестовые/заглушечные записи, которые нужно явно отфильтровывать по строковому совпадению. Это не гипотетическая забота о будущем — раз в коде есть специальная проверка именно на это значение, значит, оно уже встречалось в проде и ломало парсинг до того, как проверку добавили.

buildOkbPhoneIndex() строит финальный индекс { [нормализованный_телефон]: { companyName, companyId, manager } }, попутно логируя (но не прерывая построение индекса) обнаруженные дубли телефонов между разными компаниями:

if (index[normalizedPhone]) {
  Logger.log(
    `⚠️ Дубликат телефона ${normalizedPhone}: ` +
    `"${index[normalizedPhone].companyName}" vs "${company.name}"`,
    'WARN'
  );
  continue;
}

Победителем при дубле становится первая по порядку строк таблицы компания — вторая молча отбрасывается (continue) после логирования предупреждения. Если в ОКБ реально попадаются такие коллизии (например, один и тот же корпоративный номер по ошибке привязан к двум разным юрлицам одной группы компаний), то в отчёте вся звонковая/чатовая активность по этому номеру будет приписана только первой найденной записи, а не обеим или не той, что «правильнее» с точки зрения бизнеса. Порядок строк в самой Google-таблице ОКБ, соответственно, косвенно влияет на то, какая компания «выигрывает» — довольно неочевидная зависимость для человека, который просто добавляет новую строку в таблицу ОКБ, не подозревая о влиянии порядка на результат.

8.3 Кэширование индекса

getCachedOkbPhoneIndex() — персистентный кэш через PropertiesService с TTL 1 час (OKB_CACHE_DURATION_MS = 60 * 60 * 1000), структурно идентичный по паттерну кэшированию карты компаний Битрикс24 из cache.gs (раздел 7.5) — обе функции читают timestamp последнего обновления, сравнивают с Date.now(), и либо отдают закэшированный JSON, либо строят индекс заново.

Единственное отличие в обработке ошибок — если сериализованный кэш в PropertiesService оказался повреждён (JSON.parse бросил исключение), функция не падает, а просто логирует предупреждение и продолжает — переходит к перестроению индекса с нуля, как будто кэша не было вообще:

try {
  const parsed = JSON.parse(cachedData);
  return parsed;
} catch (e) {
  Logger.log('⚠️ Кэш ОКБ поврежден, загружаем заново');
}

Как и в случае с картой компаний Битрикс24, здесь есть защита от превышения лимита размера одного свойства PropertiesService (~900 КБ):

try {
  scriptProps.setProperty(OKB_CACHE_KEY, JSON.stringify(freshIndex));
  scriptProps.setProperty(OKB_CACHE_TIME_KEY, now.toString());
} catch (e) {
  Logger.log('⚠️ Не удалось сохранить кэш ОКБ: данные слишком большие', 'WARN');
}

При срабатывании этого catch-блока функция продолжает работать — возвращает freshIndex, построенный в текущем выполнении, просто без сохранения в кэш на будущее. То есть деградация полностью «тихая»: пайплайн отработает корректно в моменте, но каждый следующий запуск (в том числе ежедневный автозапуск) будет заново перестраивать индекс с нуля вместо использования кэша, платя за это временем выполнения (загрузка всей таблицы ОКБ и парсинг телефонов на каждый запуск) — но без единого явного алерта пользователю или в лист «Логи» о том, что кэш ОКБ фактически перестал работать. Единственный способ заметить деградацию — сравнить время выполнения пайплайна до и после того, как таблица ОКБ выросла настолько, что индекс перестал помещаться в лимит PropertiesService.

clearOkbCache() — ручной сброс кэша, вызывается извне только при необходимости принудительно обновить данные раньше истечения часового TTL; в текущем пайплайне (runProcessingPipeline()) не вызывается вообще — это исключительно «служебная» функция для ручного вызова из редактора Apps Script при необходимости, без привязки к какому-либо пункту меню UI.


9. Интеграция с Telegram Logger API

9.1 Загрузка агрегированных сообщений

Telegram Logger API — судя по всему, отдельный внешний сервис (https://45.10.40.213.nip.io/api, значение по умолчанию TELEGRAM_API_URL), который сам где-то на своей стороне логирует переписки менеджеров с клиентами в Telegram и отдаёт уже агрегированные по дням данные, а не сырую историю сообщений. Сам этот сервис, его хранилище и то, как он собирает данные из Telegram, не входят в состав переданных файлов — здесь описывается только клиентская часть интеграции, реализованная в telegramService.gs.

Запрос идёт одним HTTP GET на эндпоинт /messages с параметрами периода:

var url = _getTelegramApiUrl()
  + '/messages'
  + '?date_from=' + fmt(dateFrom)
  + '&date_to='   + fmt(dateTo);

где fmt() форматирует дату в UTC (Utilities.formatDate(d, 'UTC', 'yyyy-MM-dd')) — в отличие от большей части остального проекта, который систематически завязан на Europe/Moscow (см. раздел 6.1). Это единственное место в проекте, где явно используется UTC вместо московского времени при работе с датами, и стоит зафиксировать этот нюанс отдельно: если Telegram Logger API на своей стороне тоже считает границы суток по UTC, всё согласовано, но если он сам где-то внутри переводит даты в московское время до агрегации — на границе суток (примерно 21:00–00:00 по МСК = 00:00–03:00 UTC следующих суток) сообщения теоретически могут попасть не в тот день, в который их отнёс бы человек, смотрящий на часы в Москве

Авторизация — простой статический заголовок:

headers: { 'X-API-Key': _getTelegramApiKey() },

Запрос обёрнут в try/catch на уровне fetchAggregatedMessages(), и при любой ошибке (сетевой сбой, HTTP-код не 200, невалидный JSON) функция не бросает исключение наружу, а логирует и возвращает пустой массив:

try {
  rawData = _telegramApiFetch(url);
} catch (err) {
  Logger.log('❌ fetchAggregatedMessages: ' + err, 'ERROR');
  return [];
}

Это согласуется с общей стратегией мягкой деградации, разобранной в разделе 2.1 — отсутствие данных из Telegram не должно ронять весь пайплайн, звонки всё равно должны попасть в отчёт. Но, как уже отмечалось в разделе 2.2, поскольку запрос идёт одним вызовом за весь диапазон месяцев, а не помесячно, любой сбой (в том числе временный, на 1 запрос из десятков потенциальных, если бы была пагинация) убирает чаты из отчёта целиком за весь запрошенный период, а не только за проблемный день/месяц.

Каждая запись из ответа API проходит через три этапа резолва перед превращением в объект чата (структура которого разобрана в разделе 5.2):

  1. Менеджер — через tgManagersMap[String(row.manager_id)], с fallback на 'Telegram #' + row.manager_id, если сопоставления в листе «Менеджеры» нет (см. 9.2 ниже).
  2. Телефон клиента — берётся из готового поля row.client_phone, которое, судя по комментарию в коде (// JOIN clients.phone в SQL-запросе на бэке), само API уже подтягивает через JOIN на своей стороне — то есть Apps Script не делает отдельного запроса к Telegram API за телефоном клиента, это уже готовое поле ответа /messages.
  3. Контакт и компания — резолвятся через параметр phoneToContactMap (на практике — индекс ОКБ, см. подробный разбор несовпадения в разделе 6.7) и, при заполненном companyId, дополнительно через companyIdToName (который на практике оказывается undefined — тот же баг).

Стоит отдельно отметить подробное построчное логирование телефона на каждую запись:

Logger.log(
  'client_id=' + row.client_id
  + ' | raw_phone=' + (rawPhone || 'NULL')
  + ' | normalized=' + (normalizedPhone || 'пусто'),
  'INFO'
);

При большом количестве переписок за месяц это может ощутимо раздувать лист «Логи» (с учётом LOG_MAX_ROWS = 1000 и буферизации по LOG_FLUSH_SIZE = 20, разобранных в разделе 12.1) — потенциально логи Telegram-резолва телефонов будут вытеснять из листа «Логи» более важные сообщения об ошибках других этапов пайплайна, если общий объём логов за один прогон превысит тысячу строк.

9.2 Сопоставление менеджеров Telegram ↔ МоиЗвонки

Проблема, которую решает этот механизм, прямо сформулирована в самом UI (managers.html): у менеджера есть email в МоиЗвонки и есть числовой идентификатор в Telegram Logger API, и эти два идентификатора никак не связаны на уровне данных — их нужно сопоставить вручную, один раз, через административный интерфейс.

Лист «Менеджеры» (см. структуру в разделе 5.3) содержит три колонки: Email менеджера, Имя менеджера, Telegram Manager ID. Первые два поля используются для резолва имени по email в звонках (resolveManagerName()), третье — специально для чатов. buildTelegramManagersMap() (managersList.gs) строит из этого же листа отдельную карту:

function buildTelegramManagersMap() {
  const list = getManagersList();
  var map = {};
  for (var i = 0; i < list.length; i++) {
    var m = list[i];
    if (m.telegramId) {
      map[m.telegramId] = m.name || m.email;
    }
  }
  return map;
}

Ключевая деталь бизнес-процесса, зашитая в UI (managers.html, блок «ℹ️ Как узнать Telegram Manager ID»): администратор должен вручную открыть Swagger UI документацию Telegram Logger API, вызвать GET /managers, найти нужного человека по какому-то внешнему признаку, скопировать числовой id и вставить его в таблицу. Это полностью ручной, неавтоматизированный процесс сопоставления — никакой синхронизации или автоматического подтягивания списка менеджеров из Telegram Logger API в коде нет.

Практическое следствие: если новый менеджер добавляется в Telegram Logger API (например, подключается к системе логирования переписок), но администратор забывает вручную вписать его telegramId в лист «Менеджеры» этого проекта — все его чаты в отчёте будут подписаны как 'Telegram #<id>' вместо настоящего имени, без какой-либо автоматической эскалации или уведомления об этом несоответствии (кроме строки WARN в логах: manager_id ${row.manager_id} не сопоставлен в листе Менеджеры, которую нужно целенаправленно искать в листе «Логи»).


10. Пользовательские интерфейсы (HTML-диалоги)

10.1 Обзор всех модалок и их вызовов

Все диалоги проекта открываются исключительно из пунктов меню onOpen() (main.gs), через HtmlService.createHtmlOutputFromFile(...) с SandboxMode.IFRAME:

Файл Функция открытия Размер окна Тип открытия
monthPicker.html openMonthPickerDialog() 420×380 showModalDialog
managers.html openManagersManager() 800×600 showModalDialog
timePicker.html setupAutoTrigger() 500×350 showModalDialog
archiveDialog.html openArchiveDialog() 520×560 showModalDialog

10.2 monthPicker.html — выбор периода обработки

Точка входа в ручной запуск обработки. Ключевая клиентская логика — динамическое ограничение выбора месяца текущим:

function rebuildMonthOptions() {
  const selectedYear = parseInt(yearSelect.value, 10);
  const maxMonth = selectedYear === currentYear ? currentMonth : 12;
  // ... заполнение <select> месяцев от 1 до maxMonth ...
}

Если выбран текущий год — список месяцев обрезается текущим месяцем включительно (нельзя выбрать «Декабрь», если сейчас, например, март текущего года). Для прошлых лет доступны все 12 месяцев. Список годов ограничен текущим и двумя предыдущими (for (let y = currentYear; y >= currentYear - 2; y--)) — то есть глубина ручной обработки «в прошлое» жёстко ограничена тремя годами на уровне клиентской формы. Технически runProcessingPipeline(targetYear, targetMonth) на сервере не содержит никакой проверки на этот трёхлетний лимит — если вызвать функцию напрямую с более старым годом (например, из редактора кода), она отработает; ограничение существует только на уровне UI, а не как серверная бизнес-правило.

Эта же клиентская проверка ограничения месяца дублирует серверную проверку в runProcessingPipeline() (if (startOfMonth > nowTimestamp) throw ..., разобранную в разделе 6.1) — то есть защита от выбора будущего периода есть в двух независимых местах: на клиенте (просто не даёт выбрать в <select>) и на сервере (бросает исключение, если такой вызов всё же случится). Это нормальная, а не избыточная практика — клиентская проверка улучшает UX (не даёт пользователю в принципе увидеть недопустимый вариант), серверная защищает от прямого вызова в обход UI.

После выбора и нажатия «Запустить» диалог не закрывается сам — закрытие происходит только в колбэке успеха вызова startMonthProcessing():

google.script.run
  .withSuccessHandler(function() { google.script.host.close(); })
  .withFailureHandler(onError)
  .startMonthProcessing(year, month);

Технически это значит, что monthPicker.html закрывается только после того, как весь синхронный вызов startMonthProcessing() (внутри которого целиком выполняется runProcessingPipeline() — тот самый пайплайн на несколько минут) полностью завершится на сервере. Диалог выбора месяца, таким образом, остаётся открытым на экране пользователя на всё время работы пайплайна, и лишь потом закрывается сам — притом что модальный progressDialog.gs открывается сервером ещё раньше, в начале startMonthProcessing(). На практике пользователь в течение всей обработки видит два наложенных друг на друга модальных/немодальных окна (выбор месяца и прогресс), пока первое не закроется автоматически по завершении вызова.

10.3 progressDialog.gs — визуализация прогресса

Клиентский JS содержит жёстко заданный список шагов и соответствующих им значений прогресса, дублирующий структуру, которая реально выставляется на сервере в _setProcessingStatus() по всему runProcessingPipeline():

const STEPS = ['bitrix','fetch','telegram','process','export','pdf'];
const STEP_PROGRESS = { bitrix:10, fetch:30, telegram:50, process:65, export:85, pdf:100 };

Примечательно, что объект STEP_PROGRESS в клиентском коде фактически не используется для расчёта — applyStatus() берёт готовое числовое значение status.progress напрямую из ответа сервера (document.getElementById('progressBar').style.width = pct + '%'), а не пересчитывает его через локальную таблицу STEP_PROGRESS. То есть эта константа в HTML-файле — мёртвый код, оставшийся, вероятно, от более ранней версии, где прогресс вычислялся на клиенте по номеру текущего шага, а не передавался сервером в готовом виде.

Механизм polling запускается сразу при загрузке диалога и продолжается до явного сигнала done/error в статусе:

window._poll = setInterval(poll, 1000);
poll();

При получении status.done === true — интервал останавливается, и диалог закрывается сам через 1.5 секунды (setTimeout(..., 1500)), давая пользователю время увидеть финальное сообщение «✅ Обработка завершена!». Символично, что раз runProcessingPipeline() выполняется полностью синхронно (см. раздел 2.3), финальный статус done: true физически попадает в PropertiesService только после того, как вся обработка уже завершена — то есть цикл poll() не столько «следит за прогрессом в реальном времени», сколько считывает уже отработавшие, ранее записанные метки прогресса, догоняя их с интервалом в секунду. Пользователь видит красивую последовательную анимацию шагов, но фактически смотрит на журнал уже случившихся событий, а не на живой процесс — разница неощутима на глаз, но важна для понимания архитектуры при отладке.

10.4 Остальные диалоги

blacklistManager.gs — CRUD-интерфейс поверх листа «Черный список». Валидация на клиенте примитивная (только длина в 10+ цифр: digitCount < 10), реальная нормализация и повторная проверка длины (< 10 в addToBlacklist()) происходит уже на сервере — то есть клиентская валидация здесь чисто косметическая, для UX (не даёт нажать кнопку раньше времени), а не единственный барьер.

settingsDialog.gs — самый большой по количеству полей диалог, единая точка правки всех настроек из раздела 4.2. Уже разобранная в разделе 4.2 проблема с несуществующим DOM-элементом #reportEmail в saveSettings() находится именно в этом файле — стоит помнить, что это не абстрактный баг «где-то в конфиге», а конкретная строка кода в форме настроек, которая должна была отправлять письмо на сохранение и вместо этого упадёт на document.getElementById('reportEmail').value.trim(), вернув null.value.

managers.html — единственный диалог с встроенным блоком-инструкцией прямо в разметке («ℹ️ Как узнать Telegram Manager ID»), что напрямую отражает ручной, неавтоматизированный характер процесса сопоставления менеджеров, разобранного в разделе 9.2. UI поддерживает добавление, редактирование через модальное окно (#editModal, отдельный небольшой modal внутри модального диалога — вложенность, специфичная для того, чтобы не городить отдельный полноценный HtmlService-диалог ради формы редактирования одной записи) и удаление с обязательным confirm().

archiveDialog.html — единственный диалог с продуманной эвристикой автогенерации имени файла архива на основе выбранных листов (updateFilename(), парсит YYYY-MM из названий выбранных листов регуляркой /(\d{4}-\d{2})/ и строит диапазон дат). Также содержит кнопку быстрого выбора «Только данные» (selectData()), которая фильтрует список листов по префиксу 'Звонки_' — включая, что стоит отметить, и листы _Статистика, поскольку startsWith('Звонки_') не делает различия между Звонки_2026-01 и Звонки_2026-01_Статистика, в отличие от чуть более строгого фильтра в самом archiveDialog.html при первичной отрисовке списка (isData = sheet.name.startsWith('Звонки_') && !sheet.name.includes('Статистика')) — то есть кнопка «Только данные» и первоначальное состояние чекбоксов при загрузке диалога используют разную логику определения «что считать данными», хоть и с одинаковым намерением.

timePicker.html — самый простой по бизнес-логике диалог, но именно в нём реализован уже разобранный в разделе 3.4 механизм чтения TRIGGER_TIME для отображения текущего состояния автозапуска, и кнопка полного удаления всех триггеров, продублированная с одноимённым пунктом меню.


11. Работа с чёрным списком номеров

Этот раздел фокусируется на CRUD-операциях над листом «Черный список» (blacklist.gs) как таковых — сама роль чёрного списка в фильтрации звонков внутри пайплайна уже разобрана в разделе 6.3, а тройное дублирование логики нормализации телефона — в разделе 5.4.

11.1 Добавление и удаление

addToBlacklist(phoneNumber, comment) — точка входа из UI (blacklistManager.gs). Последовательность проверок нетривиальна и стоит разбора по шагам:

const normalized = normalizePhone(phoneNumber);

if (normalized === 'Номер скрыт') {
  return { success: false, message: 'Некорректный или скрытый номер' };
}
if (normalized.length < 10) {
  return { success: false, message: 'Слишком короткий номер телефона' };
}

Проверка на существующий номер в списке читает весь столбец A одним вызовом, а не построчным перебором через getCell():

const existingValues = sheet.getRange(1, 1, sheet.getLastRow(), 1).getValues();
const exists = existingValues.some((row) => normalizePhone(String(row[0])) === normalized);

11.2 Кэширование и инвалидация

_blacklistCache — тот же паттерн внутрисессионного кэша через переменную модуля, что уже разбирался для _configCache (раздел 4.1) и _companiesMapCache (раздел 7.5), только без персистентности между запусками (в отличие, например, от кэша ОКБ или альтернативной реализации кэша компаний в cache.gs) — то есть каждый новый запуск пайплайна или открытие диалога чёрного списка всегда читает лист заново с нуля, кэш работает только в границах одного выполнения.

Инвалидация (_clearBlacklistCache()) вызывается ровно в двух местах — после addToBlacklist() и после removeFromBlacklist() — оба раза непосредственно перед возвратом успешного результата в UI. Это корректно устраняет тот класс багов, где пользователь добавляет номер через диалог, а затем в рамках того же выполнения скрипта (что в GAS происходит нечасто, но теоретически возможно, если, например, addToBlacklist() и последующий вызов loadBlacklist() оказались бы в одной цепочке выполнения) видел бы устаревшие данные без только что добавленной записи.

Стоит отдельно отметить асимметрию с обработкой ошибок: loadBlacklist() при отсутствии config.spreadsheetId или при возникновении любой другой ошибки чтения листа не бросает исключение, а возвращает пустой Set с логированием предупреждения — то есть при сбое доступа к листу «Черный список» (например, временная недоступность Sheets API) пайплайн продолжит работу так, как будто чёрный список полностью пуст, отфильтровав ноль номеров, а не откажется от обработки вовсе. Это согласуется с общей стратегией мягкой деградации всего проекта (см. раздел 2.1), но именно в этом случае деградация особенно незаметна: отчёт будет выглядеть полностью нормальным, просто в него попадут звонки, которые должны были быть отфильтрованы — обнаружить такую деградацию можно только сверив количество отфильтрованных записей с ожидаемым, а такого счётчика в системе, как уже отмечалось в разделе 5.1, попросту нет.


12. Логирование, обработка ошибок и отправка отчётов

12.1 Буферизованное логирование

logMessage() (utils.gs) — обёртка над Logger.log(), которая дополнительно копит сообщения для записи в лист «Логи», но не пишет в Sheets на каждый вызов, а буферизует:

const _logBuffer = [];
const LOG_FLUSH_SIZE = 20;

function logMessage(message, level = 'INFO') {
  const config = getConfig();
  if (!config.enableLogging && level !== 'ERROR') return;
  // ...
  Logger.log(`[${timestamp}] [${level}] ${message}`);

  if (config.spreadsheetId) {
    _logBuffer.push([timestamp, level, message]);
    if (_logBuffer.length >= LOG_FLUSH_SIZE) {
      flushLogs();
    }
  }
}

Стоит обратить внимание на две детали, зашитые прямо в проверку в начале функции:

  1. ENABLE_LOGGING не отключает Logger.log() — только запись в лист «Логи» через буфер. Встроенный лог GAS (Logger.log, видимый в редакторе выполнения Apps Script) пишется всегда, независимо от настройки — отключается только «пользовательский» лог в Google-таблице. Это логично: Logger.log() — встроенный, бесплатный и не создаёт нагрузки на Sheets API, а запись в лист — это уже дополнительная операция, которую имеет смысл давать отключить при больших объёмах.
  2. Ошибки уровня ERROR пишутся в лист «Логи» всегда, даже если enableLogging === false (level !== 'ERROR' в условии выхода) — то есть отключение логирования в настройках снижает шум от INFO/WARN, но не скрывает критические ошибки от аналитика, который потом придёт разбираться, почему отчёт неполный.

_getLogSheet() реализует ленивое создание/поиск листа с двумя уровнями кэша — успешный результат (_logSheet) и флаг неудачной попытки (_logSheetTried), чтобы не пытаться повторно открывать таблицу при каждом вызове logMessage(), если первая попытка уже провалилась в рамках этого выполнения (например, SPREADSHEET_ID ещё не задан на момент первого лога):

function _getLogSheet() {
  if (_logSheet) return _logSheet;
  if (_logSheetTried) return null;
  _logSheetTried = true;
  // ... попытка открыть/создать лист ...
}

flushLogs() пишет накопленный буфер одним вызовом setValues() вместо N вызовов appendRow() — тот же систематический паттерн батч-записи, что уже отмечался в разделах 6.3 и 11.1. Дополнительно flushLogs() обрезает лист сверху при превышении LOG_MAX_ROWS = 1000:

const totalRows = sheet.getLastRow();
if (totalRows > LOG_MAX_ROWS + 1) {
  sheet.deleteRows(2, totalRows - LOG_MAX_ROWS - 1);
}

То есть лист «Логи» ведёт себя как кольцевой буфер фиксированного размера — старые записи вытесняются новыми, полной истории логов система не хранит. При объёме логирования, разобранном в разделе 9.1 (построчный лог телефона на каждую запись Telegram), эта тысяча строк может исчерпываться в пределах одного-двух прогонов пайплайна, если объём переписок велик — то есть на практике глубина «истории», доступной аналитику в листе «Логи», может составлять не недели, а буквально один-два последних запуска.

safeExecute() (разбирается подробнее в 12.2) вызывает flushLogs() явно после каждого шага пайплайна — это гарантирует, что даже при падении скрипта где-то дальше по цепочке (и, соответственно, без штатного завершения и без вызова flushLogs() в самом конце runProcessingPipeline()), логи уже выполненных шагов не потеряются в буфере, а успеют попасть на лист.

12.2 safeExecute() как единая точка обработки ошибок пайплайна

function safeExecute(func, operationName = 'Операция') {
  try {
    const result = func();
    logMessage(`✅ ${operationName} выполнена`, 'INFO');
    flushLogs();
    return { success: true, data: result };
  } catch (error) {
    const msg = `❌ Ошибка в '${operationName}': ${error}\n${error.stack || ''}`;
    logMessage(msg, 'ERROR');
    flushLogs();
    return { success: false, error: msg };
  }
}

Функция сама по себе никогда не бросает исключение — она перехватывает любую ошибку внутри func() и возвращает структурированный результат { success, data|error }. Ответственность за то, прерывать ли пайплайн при неудаче, полностью лежит на вызывающем коде — и здесь важно понимать, что именно runProcessingPipeline() в main.gs решает превратить неудачу обратно в исключение:

const fetchResult = safeExecute(() => fetchCalls(startOfMonth, endOfPeriod, 1), 'Получение данных из API');
if (!fetchResult.success) throw new Error('Ошибка получения данных из API');

12.3 Рассылка PDF-отчётов

sendPdfReportsByEmail(pdfBlobs, reportInfo) принимает на вход массив PDF-заготовок и параллельный массив метаданных reportInfo, формирует единое письмо с сводкой по всем месяцам и вложениями, и отправляет его через GmailApp.sendEmail() одним вызовом на всех получателей сразу, через строку адресов, объединённую запятой:

const emailString = emailAddresses.join(', ');
GmailApp.sendEmail(emailString, subject, emailBody, { attachments, name: "..." });

При сбое массовой отправки (catch вокруг GmailApp.sendEmail()) включается sendFallbackEmails() — та же рассылка, но поштучно на каждый адрес через MailApp.sendEmail() (не GmailApp, что стоит заметить — fallback сознательно использует другой сервис отправки, а не просто другой цикл вызовов того же GmailApp, вероятно, чтобы исключить сценарий, при котором именно GmailApp временно недоступен, а MailApp — нет):

emailAddresses.forEach(email => {
  try {
    MailApp.sendEmail({ to: email, subject, body, attachments, name: "... [РЕЗЕРВ]" });
    successfulSends++;
  } catch (error) {
    failedSends++;
  }
});

Fallback-письма помечены как «[РЕЗЕРВ]» и в теме, и в имени отправителя, и вложения переименовываются с суффиксом _резерв.pdf — то есть получатель однозначно поймёт, что письмо пришло не по основному, штатному пути, если вдруг возникнет путаница с двумя разными версиями одного отчёта (основная попытка технически могла частично пройти для части получателей до сбоя — сама реализация sendPdfReportsByEmail() не разделяет получателей на «уже получивших» и «не получивших» при частичном сбое, GmailApp.sendEmail() с несколькими адресами в одной строке — это одна операция целиком, либо полностью успешная, либо полностью упавшая, частичной доставки на уровне GAS API здесь не бывает).

12.4 Уведомление об ошибке автозапуска

runProcessingPipelineTrigger() (main.gs) — обёртка над runProcessingPipeline(), вызываемая исключительно по расписанию (ScriptApp.newTrigger). В отличие от ручного запуска через startMonthProcessing(), здесь при падении пайплайна пользователю физически некому показать ошибку в интерфейсе — никто не наблюдает за экраном в момент ночного/дневного автозапуска. Поэтому при перехвате исключения функция дополнительно отправляет письмо на config.userEmail:

} catch (err) {
  Logger.log(`❌ Ошибка авто-обработки: ${err}`, 'ERROR');
  try {
    const email = getConfig().userEmail;
    if (email) {
      MailApp.sendEmail({
        to: email,
        subject: '🚨 Ошибка автозапуска MoiZvonki',
        body: `Ошибка:\n\n${err}\n\nПроверьте логи выполнения.`,
      });
    }
  } catch (mailErr) {
    Logger.log(`❌ Email об ошибке не отправлен: ${mailErr}`, 'ERROR');
  }
  throw err;
}

13. Тестовые и отладочные функции

13.1 UI-тесты подключения

В отличие от testFunctions.gs, эти функции доступны пользователю через штатный интерфейс — либо через пункт меню, либо через кнопки внутри settingsDialog.gs:

Функция Файл Как вызывается Что проверяет
testApiConnection() / testApiConnectionUI() apiClient.gs / main.gs Пункт меню «⌁ Тестировать подключение к API» Один запрос calls.list к МоиЗвонки с окном в 1 час и лимитом в 1 запись — проверяет связку apiSubdomain+userEmail+apiKey
testApiConnectionWithSettings() main.gs Кнопка «🔍 Протестировать подключение» в settingsDialog.gs Временно подменяет конфиг введёнными в форму значениями, тестирует, затем восстанавливает исходные значения — позволяет проверить новые настройки API, не сохраняя их заранее
testBitrixConnection() bitrixService.gs (нигде не вызывается из UI/меню — см. ниже) Оба вебхука Битрикс24 (user.current + crm.contact.list), плюс пробует получить список сотрудников
testBitrix24Connection() integrations.gs (тоже нигде не вызывается из UI/меню) Только CRM-вебхук через crm.contact.list
testEmailConnection() / testEmailConnectionUI() pdfExporter.gs / main.gs Кнопка «📧 Протестировать отправку на email» в settingsDialog.gs Реальная отправка тестового письма через GmailApp.sendEmail() на введённые адреса

14. Термины

Термины, специфичные для этого проекта — общие определения из документации МоиЗвонки/Битрикс24/Telegram здесь не приводятся, только то, как эти понятия используются конкретно в этой кодовой базе.

Термин Значение в контексте проекта
ОКБ Общая клиентская база — отдельная Google-таблица (OKB_SPREADSHEET_ID), которая ведётся вручную и служит источником сопоставления «номер телефона → название компании-клиента» для звонков в отчёте. Не путать с CRM Битрикс24 — это независимый, отдельно поддерживаемый справочник.
МоиЗвонки Внешний сервис телефонии/колл-трекинга ({subdomain}.moizvonki.ru), основной источник данных о звонках. В коде — apiClient.gs.
Балансировщик Сторонний прокси-сервис, обходящий rate limit Битрикс24 при массовых чтениях (user.get, crm.contact.list, crm.company.list). Подписывает запросы HMAC-SHA256. Внутренняя логика вне зоны ответственности этого проекта — используется как чёрный ящик. См. balancer.gs, раздел 7.2.
Callback window / окно перезвона Настраиваемый период (CALLBACK_WINDOW_HOURS, по умолчанию 24 часа), в течение которого отвеченный звонок после пропущенного засчитывается как «перезвон совершён». См. раздел 6.4.
Telegram Manager ID Числовой идентификатор менеджера во внешнем Telegram Logger API (получается через GET /managers на стороне того сервиса), вручную сопоставляется с email менеджера МоиЗвонки в листе «Менеджеры». См. раздел 9.2.
Чат (в контексте отчёта) Не единичное сообщение, а агрегированная за один день переписка одного диалога — с полями «количество сообщений», «последнее сообщение дня» и т.д. Не путать с обычным пониманием «чата» как окна переписки. См. раздел 5.2.
Номер скрыт Специальное строковое значение, в которое нормализуются телефоны, не поддающиеся распознаванию (анонимные звонки, текстовые метки АТС, паттерны-заглушки вроде 77777777777). Используется вместо null/undefined как явный маркер по всей кодовой базе. См. HIDDEN_NUMBER_PATTERNS в разделе 5.4.
Триггер (в контексте GAS) Здесь конкретно — единственный разрешённый в проекте time-based триггер с обработчиком runProcessingPipelineTrigger; система жёстко ограничивает себя одним активным триггером за раз через removeAllTriggers() перед созданием нового. См. раздел 3.4.
Сессия выполнения (в контексте GAS) Один вызов серверной функции целиком (от старта до return/исключения), в границах которого живут кэши на переменных модуля (_configCache, _blacklistCache и т.д.). Не путать с пользовательской сессией браузера — это исполнительная единица на стороне Apps Script. См. раздел 1.3.