Перейти к основному контенту

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

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


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 минут для триггеров на некоторых типах аккаунтов). Отсюда:

  • FETCH_TIMEOUT_SECONDS = 30 в apiClient.gs — искусственное ограничение одного HTTP-запроса, чтобы зависший запрос к МоиЗвонки не съел весь бюджет времени скрипта.
  • Постраничная загрузка звонков в fetchCalls() с MAX_RESULTS = 100 — вместо одного гигантского запроса, который рискует не уложиться в таймаут.

Отсутствие постоянной памяти между вызовами — каждый вызов функции (в том числе через триггер) стартует «с нуля», без глобального состояния предыдущего запуска. Это прямая причина существования 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

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

  • Отсутствие пользователей Битрикс24 или пустой результат от МоиЗвонки не роняют весь пайплайн — система деградирует мягко (звонки без резолва имён менеджеров, либо ранний return false с логом «нет данных»). Это осознанное решение — судя по количеству проверок if (bitrixUsers.length === 0) и аналогичных по коду.
  • Каждый крупный шаг обёрнут в safeExecute() (utils.gs) — единая точка перехвата ошибок с логированием и flush логов, но при этом ошибка одного шага (fetchCalls, processCalls, groupByMonth, exportToSheets) прерывает весь пайплайн через throw — то есть частичного отчёта при сбое экспорта не получится: либо всё, либо ничего (кроме PDF-шага, который через safeExecute не пробрасывает ошибку наверх и не ломает финальный статус done: true).

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 и правам доступа

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

  • Основная Google-таблица — привязывается автоматически при первом запуске через PropertiesService.getScriptProperties().setProperty('SPREADSHEET_ID', ...) в initializeProject() и startMonthProcessing() (если свойство ещё не установлено). Требует, чтобы Apps Script проект был привязан (bound) к этой таблице (Container-bound script), так как onOpen()/SpreadsheetApp.getActiveSpreadsheet() подразумевают именно такой сценарий.
  • Доступ к таблице ОКБ (OKB_SPREADSHEET_ID = '1F1ansQeSuAwhe3Y_mhfm4ja370k5hq67RruE-CVp83w', см. okbService.gs) — учётная запись, под которой выполняется скрипт, должна иметь доступ на чтение к этой конкретной таблице. Проверка идёт через тестовый запрос с ScriptApp.getOAuthToken(), и при коде ответа 403/404 система продолжает работать без ОКБ, но с пометкой «Не найдена в ОКБ» у всех компаний.
  • Права на DriveApp — нужны для перемещения файла-архива в нужную папку при архивации (archiveSheets() в main.gs) и потенциально для доступа к REPORTS_FOLDER_ID, если PDF-отчёты складываются в папку Google Drive (сама логика сохранения в папку — за пределами предоставленных файлов, см. пробел ниже).
  • Права на MailApp / GmailApp — используются параллельно в разных местах: MailApp.sendEmail() в sendEmailReport() (integrations.gs) и в fallback-рассылке (pdfExporter.gs), а GmailApp.sendEmail() — в основной рассылке PDF-отчётов (sendPdfReportsByEmail()) и в testEmailConnection(). Смешанное использование двух разных email-сервисов GAS в одном проекте — не проблема с точки зрения работоспособности (оба доступны одновременно), но стоит иметь в виду при настройке квот: у MailApp и GmailApp разные суточные лимиты на отправку.
  • Внешний сетевой доступ (UrlFetchApp) — по умолчанию доступен в GAS без дополнительных разрешений, но целевые домены (МоиЗвонки, Balancer, Telegram Logger API) должны быть доступны из дата-центров Google (без белых списков IP на стороне этих сервисов, иначе запросы будут падать).

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 ⚠️ Проблема с хардкодом реальных credentials в forceReinitializeConfig()

В отличие от мягкой инициализации выше, функция forceReinitializeConfig() (config.gs) ведёт себя принципиально иначе — она безусловно перезаписывает все свойства, включая уже настроенные, значениями, зашитыми прямо в код:

const allDefaults = {
  'API_SUBDOMAIN'         : /* маскировано — субдомен конкретной организации */,
  'USER_EMAIL'            : /* маскировано — реальный email аккаунта МоиЗвонки */,
  'API_KEY'               : /* маскировано — реальный API-ключ МоиЗвонки */,
  'SPREADSHEET_ID'        : /* маскировано — ID конкретной боевой таблицы */,
  'BITRIX24_USERS_WEBHOOK': /* маскировано — реальный вебхук с токеном доступа */,
  'BITRIX24_CRM_WEBHOOK'  : /* маскировано — реальный вебхук с токеном доступа */,
  // ...
};

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

  1. Узнать (при чтении кода) реальный API-ключ МоиЗвонки, email аккаунта, ID вебхуков Битрикс24 и ID боевой таблицы.
  2. Перезаписать текущую конфигурацию этими значениями, если по какой-то причине она была изменена на другую (например, при тестировании на отдельном стенде).

Это стоит расценивать как проблему безопасности при передаче исходников третьим лицам (подрядчикам, в открытый репозиторий, в этот же документ) — а также, попутно, как отсутствие разделения между «конфигурацией для разработки/восстановления» и «секретами». Рекомендация для дальнейшего рефакторинга: вынести все значения allDefaults из кода в защищённое хранилище (PropertiesService уже используется — можно было бы просто не публиковать сам код с этими значениями, либо читать их из отдельного, не версионируемого файла).

То же самое, только с чуть меньшим набором полей, продублировано и в исходном initializeDefaultConfig() — там тоже присутствуют захардкоженные BITRIX24_USERS_WEBHOOK и BITRIX24_CRM_WEBHOOK (реальные вебхуки), только они хотя бы защищены проверкой «устанавливать, если ключ отсутствует».

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

Ежедневный автозапуск обработки настраивается через 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;
}

Это тот же приём внутрисессионного кэша, что уже разбирался в разделе 1.3 — GAS не гарантирует переиспользование памяти между разными запусками, но в рамках одного выполнения (например, одного прохода runProcessingPipeline()) переменная модуля живёт стабильно, и не имеет смысла дёргать PropertiesService.getProperties() на каждый чих. С учётом того, что getConfig() вызывается практически из каждого файла проекта (dataProcessor.gs, sheetExporter.gs, bitrixService.gs, blacklist.gs и т.д.), экономия реальная — без кэша один запуск пайплайна делал бы десятки одинаковых обращений к Property Store.

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

Ещё деталь: getConfig() при обнаружении отсутствующих ключей Битрикс24 (BITRIX24_USERS_WEBHOOK/BITRIX24_CRM_WEBHOOK) сама вызывает initializeDefaultConfig() и перечитывает свойства:

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

Отдельно стоит обратить внимание на несогласованность полей email-отчётов. В config.gs объявлены сразу два ключа — REPORT_EMAIL (единственное число, устаревший, судя по комментарию) и REPORT_EMAILS (множественное, «актуальное»). При этом:

  • updateSettings()/getCurrentSettings() в main.gs возвращают в UI поле reportEmail (единственное), а не reportEmails.
  • settingsDialog.gs на клиенте читает и пишет как reportEmail, так и reportEmails в разных местах формы (populateForm() заполняет reportEmails, а saveSettings() отправляет REPORT_EMAIL из поля с id reportEmail, которого в HTML-разметке settingsDialog.gs физически нет — в форме есть только input#reportEmails).
  • Реальная рассылка PDF (sendPdfReportsByEmail() в pdfExporter.gs) использует config.reportEmails (множественное число) через getEmailAddresses(config.reportEmails).

Это значит, что попытка сохранить email через UI настроек, скорее всего, не будет работать так, как ожидает пользователь — форма пытается прочитать несуществующий DOM-элемент #reportEmail в saveSettings(), что в браузере даст null и упадёт на .value.trim(). Это баг, а не гипотетический риск — деталь, которую стоит зафиксировать в разделе 14 (сводка техдолга) и, вероятно, поправить в первую очередь, раз он напрямую ломает сохранение настроек email-рассылки через UI.

4.3 Найденный дубль функции initializeDefaultConfig()

В config.gs функция initializeDefaultConfig() объявлена дважды:

Первое объявление (ближе к началу файла) — содержит только настройки МоиЗвонки и Битрикс24 (API_SUBDOMAIN, VERIFY_SSL, CALLBACK_WINDOW_HOURS, MIN_CALL_DURATION, ENABLE_LOGGING, BITRIX24_ENABLED, BITRIX24_USERS_WEBHOOK, BITRIX24_CRM_WEBHOOK).

Второе объявление (ниже по файлу, сразу перед updateConfig()) — содержит тот же набор ключей плюс TELEGRAM_API_URL и TELEGRAM_API_KEY, и дополнительно вызывает _clearConfigCache() в конце.

В JavaScript (а GAS транспилирует/выполняет файлы .gs как обычный JS в общей глобальной области видимости) при двух объявлениях функции с одним именем через function foo() {} побеждает последнее объявление — то есть в реальности выполняется именно второе, с Telegram-настройками и сбросом кэша. Первое объявление в файле становится мёртвым кодом: оно физически присутствует, компилируется, но никогда не вызывается — GAS его просто перезатирает при загрузке скрипта.

Почему это важно, а не просто «немного некрасиво»:

  1. Риск при рефакторинге. Если кто-то из разработчиков решит поправить логику инициализации и отредактирует первое вхождение функции (более заметное, ближе к началу файла, идёт сразу после getConfig()), правки не будут иметь никакого эффекта — выполняться продолжит нетронутое второе объявление. Классический сценарий «починил баг, а он никуда не делся» — и минут 20 недоумения, пока не найдёшь дубль ниже по файлу.
  2. Путаница при код-ревью. Дубль не выдаёт синтаксической ошибки (GAS это разрешает), поэтому CI/линтер по умолчанию не подсветит проблему, если явно не настроен no-redeclare или аналог.
  3. Комментарии над первой версией вводят в заблуждение — тот блок JSDoc про «инициализацию по умолчанию» относится, по факту, к мёртвой функции.

Рекомендация: удалить первое объявление целиком, оставить только второе (с Telegram-полями) как единственный источник истины, и явно прогнать initializeDefaultConfig() в проде один раз после чистки, чтобы убедиться, что TELEGRAM_API_URL/TELEGRAM_API_KEY уже физически стоят в Script Properties (маловероятно, что это не так, раз второе объявление и так уже всегда выполнялось, но проверка дешевле, чем разбор инцидента).

4.4 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() Не скрывается явно — [Требует уточнения: нигде в коде нет hideSheet() для листа «Логи», в отличие от «Черный список» и «Менеджеры» — уточнить, осознанно ли он оставлен видимым] 3 колонки: Дата и время, Уровень, Сообщение; обрезается сверху при превышении LOG_MAX_ROWS = 1000

Обрати внимание на паттерн распознавания устаревших листов в hideOutdatedSheets():

const pattern = /^Звонки_(\d{4})-(\d{2})/;

Регулярка не заякорена символом $ в конце, поэтому она матчит и Звонки_2026-01, и Звонки_2026-01_Статистика — оба варианта проходят проверку name.match(pattern), и sheetYear/sheetMonth извлекаются из них одинаково. То есть скрытие листов статистики за старые месяцы — не отдельная логика, а побочный эффект незаякоренного regex. Технически результат совпадает с тем, что нужно бизнесу (статистику логично скрывать вместе с данными), но это неявная зависимость: если кто-то в будущем переименует шаблон листа статистики (например, в Звонки_2026-01 (стат)), поведение молча изменится, и это будет трудно связать с этим самым regex без чтения кода.

Листы Черный список и Менеджеры не хардкожены по строковому литералу в большинстве мест — их имена читаются из config.blacklistSheetName/config.managersSheetName (config.gs), что удобно, если понадобится когда-то переименовать листы без правки десятка файлов. Исключение — getSheetListForArchive() в main.gs, где в excluded жёстко вписана строка 'Логи' напрямую, а не через какое-либо поле конфига (у листа логов в принципе нет отдельного поля в getConfig(), имя 'Логи' встречается как литерал сразу в нескольких файлах — utils.gs и main.gs).

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 (не от порядка, в котором файлы лежат в этой документации или в репозитории) — этот порядок не входит в состав переданных файлов и не может быть установлен по одному лишь содержимому кода.

Почему это не абстрактная придирка, а конкретный операционный риск:

  • Версия из utils.gs на порядок агрессивнее к коротким номерам — она отфильтровывает всё короче 7 цифр как мусор (return ''), тогда как версия из blacklist.gs в этом случае просто прогоняет цифры дальше без явной проверки длины. Если в проде реально выполняется версия из blacklist.gs, то короткие «мусорные» номера (например, внутренние трёхзначные добавочные, случайно попавшие в client_number) могут пройти нормализацию и попасть в clientNumber в почти исходном виде — а значит, потенциально засорить и сам отчёт, и индекс чёрного списка, если кто-то попробует такой номер туда добавить.
  • Обе функции возвращают разный набор «специальных» значений при ошибке ('Номер скрыт' в обеих, но с разными условиями срабатывания) — то есть код, который где-то в будущем начнёт полагаться на конкретное поведение одной версии (например, тесты или новый модуль импорта), рискует получить другую версию при следующем деплое, если файлы будут переупорядочены в редакторе GAS.
  • Комментарий в самом utils.gs прямо признаёт проблему и пытается её обойти во время выполнения:
const _HIDDEN_PATTERNS = typeof HIDDEN_NUMBER_PATTERNS !== 'undefined'
  ? HIDDEN_NUMBER_PATTERNS
  : [ /* локальная копия того же списка */ ];

Это защита от ReferenceError, если blacklist.gs почему-то не загрузился раньше utils.gs, но она не решает основную проблему — какая из двух функций normalizePhone реально вызывается по всему проекту, эта защита никак не контролирует.

Третья функция, normalizePhoneStrict() в okbService.gs, по счастью названа иначе и коллизии имён не создаёт — но она дублирует ту же самую бизнес-логику (нормализация российского номера через паттерны 8→7 и 10-значный→7) в третий раз, с третьим набором граничных случаев. Единой точки истины для «что такое нормализованный телефон в этой системе» в кодовой базе нет — есть три параллельных мнения на этот счёт, местами противоречащих друг другу.

Рекомендация для рефакторинга: выбрать одну реализацию (по бизнес-смыслу ближе всего к варианту из utils.gs, так как он единственный явно обрабатывает мусорные короткие номера), вынести её в отдельный файл без конфликтов имён, и переиспользовать во всех трёх местах (blacklist.gs, dataProcessor.gs/main.gs, okbService.gs) вместо трёх независимых копий. До этого рефакторинга — любое изменение в логике нормализации требует ручной синхронизации сразу в трёх местах, что уже само по себе является источником будущих расхождений.


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);

Это осмысленное решение: если сегодня 15 число, а пользователь запрашивает обработку текущего месяца — бессмысленно (и физически невозможно) запрашивать у МоиЗвонки данные за ещё не наступившие 16–31 число. Math.min(..., nowTimestamp) в ветке для прошлых месяцев на первый взгляд избыточен (прошлый месяц по определению уже полностью в прошлом), но служит защитой от граничного случая — если по какой-то причине getEndOfMonthTimestamp() для указанного месяца окажется в будущем (например, из-за ошибки пользователя в выборе года на клиенте, monthPicker.html такое теоретически не должен пропустить, но защита на сервере не помешает).

Отдельная проверка защищает от совсем некорректного запроса будущего периода:

if (startOfMonth > nowTimestamp) {
  throw new Error('Нельзя обработать данные за будущий период');
}

Расчёт границ месяца (getStartOfMonthTimestamp()/getEndOfMonthTimestamp()) сделан вручную через Date.UTC(...) - 3 * 3600 * 1000 — жёстко зашитое смещение МСК (UTC+3) вместо использования часового пояса через Utilities.formatDate. Само API МоиЗвонки принимает Unix-timestamp, для которого часовой пояс не имеет значения на уровне протокола — но границы этого timestamp (начало и конец месяца) обязаны быть посчитаны в конкретном часовом поясе, иначе «01 февраля 00:00» для отчёта и «01 февраля 00:00» по UTC — это два разных момента времени, отличающихся на 3 часа, и в отчёт попадут или выпадут звонки на границе суток. Комментарий в коде это прямо объясняет:

«Часовой пояс не влияет на границы — API принимает Unix-timestamp» — имеется в виду, что после того, как границы уже посчитаны правильно с учётом МСК, дальше передача через timestamp универсальна.

Стоит отметить и мелкий, но реальный технический долг: в main.gs функция getStartOfCurrentMonthTimestamp() объявлена дважды — один раз как простая обёртка над getStartOfMonthTimestamp(), второй раз (ниже по файлу) как самостоятельная реализация с ручным построением Date.UTC(...). Итог идентичный (обе версии считают одно и то же), но это тот же паттерн дублирования по имени функции, что уже подробно разбирался в разделах 4.3 и 5.4 — при повторном рефакторинге одной из копий вторая может незаметно разойтись логикой при формально одинаковом результате сегодня.

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;
}

Условие выхода из цикла тройное: пустая страница, next_offset === 0 (штатный признак «больше данных нет» от самого API) или next_offset === fromOffset — последнее защищает от зацикливания, если API вдруг вернёт тот же самый offset повторно (баг на стороне API, который иначе привёл бы к бесконечному циклу и неизбежному падению по таймауту FETCH_TIMEOUT_SECONDS/6-минутному лимиту GAS). Комментарий в коде явно фиксирует, что раньше здесь стоял return вместо break, что обрывало бы функцию до финального return allCalls — то есть это уже исправленный баг, оставленный как заметка для будущих разработчиков.

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, вопреки комментарию «экспоненциальная задержка» в коде, а линейный. Мелкая неточность в терминологии комментария, не влияющая на поведение, но стоит знать при чтении кода, чтобы не искать экспоненту там, где её нет.

Жёсткий таймаут одного HTTP-запроса — deadline: FETCH_TIMEOUT_SECONDS (30 секунд), что при MAX_RETRIES = 3 и максимальной задержке даёт верхнюю границу одной страницы порядка полутора минут в худшем случае (3 попытки × 30с таймаут + суммарная задержка retry). При большом количестве страниц (крупная организация, много звонков за месяц) это может реально упереться в 6-минутный лимит выполнения GAS — в коде нет отдельного контроля бюджета времени на уровне всего fetchCalls(), только на уровне одного запроса.

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), а не линейным перебором массива на каждый звонок, что важно при тысячах записей в месяц.

Комментарий в коде прямо поясняет, почему нет отдельной проверки на 'Номер скрыт':

// 'Номер скрыт' никогда не будет в blacklist (Set реальных номеров),
// поэтому дополнительная проверка не нужна

Это верно постольку, поскольку loadBlacklist() при построении Set явно исключает записи, которые нормализуются в 'Номер скрыт' (if (norm && norm !== 'Номер скрыт') { _blacklistCache.add(norm); }) — то есть инвариант «в чёрном списке нет строки 'Номер скрыт'» поддерживается на этапе загрузки, а не проверяется каждый раз заново. Работает, но неявно — если в будущем логику загрузки поменяют, а этот комментарий не обновят, инвариант может тихо нарушиться.

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

Это самая содержательная бизнес-логика во всём пайплайне — отсюда берётся статус «Пропущен, перезвон совершен», который аналитики, судя по всему, используют как ключевую метрику качества работы менеджеров.

Индекс строится один раз на весь массив звонков месяца:

const callsByClient = {};
for (const call of calls) {
  const key = call.clientNumber || '';
  (callsByClient[key] = callsByClient[key] || []).push(call);
}

Дальше для каждого пропущенного звонка (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 в исходных данных), но по факту это может быть случайное поднятие трубки без реального разговора. Такой звонок не засчитывается как перезвон, даже если формально попадает в нужное временное окно.

Обратная сторона этой логики — она не различает направление перезвона. Условие hasCallback не проверяет direction найденного отвеченного звонка: если клиент сам перезвонил менеджеру в течение суток после пропущенного вызова, это будет засчитано точно так же, как если бы менеджер перезвонил клиенту. С точки зрения кода это не баг (никакой явной проверки направления в требованиях не заложено), но с точки зрения бизнес-смысла метрики «перезвон совершён» это довольно существенное допущение — [Требует уточнения: было ли осознанным решением не различать, кто именно инициировал повторный звонок, или это упущение при реализации].

Ещё одна деталь — 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 || 'Неизвестный';
  // ...
}

Здесь скрыт нюанс, который стоит явно проговорить: если один и тот же человек в реальности значится под разными именами в листе «Менеджеры» для звонков (колонка «Имя менеджера») и в Telegram Logger API/маппинге telegramId (тоже колонка «Имя менеджера», но резолвится отдельной функцией buildTelegramManagersMap()), то в статистике он попадёт в две разные строки — stats['Иван Иванов'] для звонков и stats['И. Иванов'] для чатов, если имена не совпадают дословно. Ключ группировки — это просто строка, без какой-либо нормализации регистра или пробелов, и без сверки с email как со стабильным идентификатором. На практике это означает, что при заполнении листа «Менеджеры» критично вписывать одинаковое значение в колонку «Имя менеджера» для звонков и для сопоставления Telegram ID — благо оба поля физически лежат в одной и той же строке одного и того же листа (см. раздел 5.3), так что рассинхрон возможен только при ручной опечатке при вводе одного значения, используемого затем в двух разных ролях.

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

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

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

6.7 ⚠️ Несовпадение сигнатуры fetchAggregatedMessages()

Это самая критичная находка в разделе пайплайна — реальный, воспроизводимый баг, не гипотетический риск.

Функция объявлена в telegramService.gs с четырьмя параметрами:

function fetchAggregatedMessages(dateFrom, dateTo, phoneToContactMap, companyIdToName) {

А вызывается из exportToSheets() в sheetExporter.gs с тремя:

const chatRows = fetchAggregatedMessages(dateFrom, dateTo, okbPhoneIndex);

В JavaScript отсутствующий четвёртый аргумент означает, что внутри функции companyIdToName будет равен undefined — не пустому объекту {}, а именно undefined. Дальше по коду telegramService.gs это значение используется так:

if (bxContact.companyId) {
  var title = companyIdToName[String(bxContact.companyId)];
  companyName = title || ('Компания ID ' + bxContact.companyId);
}

Обращение companyIdToName[...] к undefined бросает TypeError: Cannot read properties of undefined (reading 'ID_компании') — и это произойдёт в тот момент, когда для конкретного чата найден контакт в phoneToContactMap (в реальности это okbPhoneIndex, попавший на чужое место — см. ниже) и у этого контакта заполнен companyId.

Здесь стоит отдельно подчеркнуть, что баг вложенный, двухслойный:

  1. Сам факт нехватки аргумента приводит к companyIdToName === undefined.
  2. Но даже если бы четвёртый аргумент передавался — на третье место (phoneToContactMap) сейчас попадает okbPhoneIndex (индекс ОКБ вида { [телефон]: { companyName, companyId, manager } }), тогда как по названию параметра и по коду внутри telegramService.gs ожидается совсем другая структура — карта контактов Битрикс24 вида { [телефон]: { name, companyId } } (именно такую структуру строит getBitrix24Contacts() в bitrixService.gs, но эта функция нигде не вызывается в текущем пайплайне runProcessingPipeline()). То есть даже без TypeError, семантически bxContact.name внутри telegramService.gs в реальности читает поле companyName из объекта ОКБ, а не имя контакта из Битрикс24 — структуры этих двух источников похожи по форме (оба содержат companyId), но не идентичны по смыслу полей.

Практическое следствие для отчёта: строки типа «Чат» в листе Звонки_YYYY-MM для клиентов, чей телефон найден в индексе ОКБ и у которых заполнено поле companyId в этом индексе, либо приведут к падению всего экспорта Telegram-сообщений (если TypeError не будет перехвачен), либо — если где-то выше по цепочке есть try/catch, глотающий эту ошибку — приведут к тому, что весь месяц останется без чатов вообще (это уже описано в разделе 2.2, пункт 2: catch вокруг fetchAggregatedMessages() в sheetExporter.gs перехватывает любую ошибку и просто логирует «Ошибка загрузки Telegram-сообщений… Продолжаем без чатов», не показывая пользователю, что причина — внутренняя ошибка типа, а не сетевой сбой).

Стоит зафиксировать в разделе 14 как приоритетный баг к исправлению: нужно либо привести сигнатуру вызова в соответствие с объявлением функции (передать оба параметра — актуальную карту контактов Битрикс24 и карту компаний), либо, если реальный бизнес-сценарий уже перешёл на использование индекса ОКБ вместо Битрикс24-контактов для резолва чатов, — обновить саму функцию fetchAggregatedMessages() и её внутреннюю логику под новую, уже фактически используемую структуру данных, и убрать путаницу с именами параметров.


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

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

В проекте фактически существуют два параллельных, слабо связанных способа обращения к Битрикс24, и это не историческая случайность, а по всей видимости — следствие того, что массовые операции упёрлись в квоту Битрикс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_(), а не прямой вебхук Да — это фактический рабочий путь для получения пользователей внутри пайплайна

Ключевой вывод: несмотря на то что getBitrixUserList() физически лежит в файле bitrixService.gs, где определены и _getBitrixUsersWebhook()/_getBitrixCrmWebhook() для работы с конфигом, сама функция вызывает balancerRecurrentPost_('user.get', {}) — то есть массовые операции чтения (список сотрудников, список контактов, список компаний) идут через Балансировщик и его собственный, захардкоженный BITRIX_WEBHOOK, полностью в обход конфигурационных вебхуков BITRIX24_USERS_WEBHOOK/BITRIX24_CRM_WEBHOOK.

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

  • testBitrixConnection() (bitrixService.gs) — тестовый запрос user.current и crm.contact.list (одна запись) при нажатии «Тестировать подключение к API» в меню.
  • testBitrix24Connection() и createBitrix24Task() (integrations.gs) — тестовый запрос crm.contact.list и потенциальное создание задачи (последнее сейчас нигде не вызывается, см. раздел 14).

Получается странная на первый взгляд картина: пользователь настраивает в UI два вебхука Битрикс24 (BITRIX24_USERS_WEBHOOK, BITRIX24_CRM_WEBHOOK), нажимает «Тестировать подключение» — тест реально проходит через эти самые вебхуки и подтверждает, что они рабочие. Но реальный пайплайн при этом их не использует — он идёт через BITRIX_WEBHOOK внутри balancer.gs, к которому у пользователя из UI вообще нет доступа для изменения. Если у организации когда-нибудь сменится вебхук Битрикс24 (например, при перевыпуске токена), обновление через UI настроек создаст ложное чувство «всё починил», хотя реальный рабочий путь останется на старом, захардкоженном значении в balancer.gs.

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

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

  • balancerPost_(method, body) — одиночный запрос без пагинации, для методов, которые не возвращают список с продолжением (add, update, delete и т.п.).
  • balancerRecurrentPost_(method, body, resultKey) — запрос с флагом is_recurrent: true, при котором балансировщик сам обходит пагинацию Битрикс24 и возвращает собранный результат целиком одним массивом (data.body).

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

7.3 ⚠️ Захардкоженные секреты в balancer.gs

В отличие от Битрикс24-вебхуков, которые хотя бы частично управляются через config.gs/UI (см. 7.1), три ключевых значения в balancer.gs — константы прямо в исходном коде, без какого-либо пути изменения через UI или Script Properties:

  • BALANCER_PUBLIC — публичный ключ для подписи запросов к балансировщику.
  • BALANCER_PRIVATE — приватный ключ, участвующий в HMAC-подписи (Utilities.computeHmacSha256Signature(message, BALANCER_PRIVATE)).
  • BITRIX_WEBHOOK — реальный вебхук Битрикс24, используемый для всех массовых операций через balancerRecurrentPost_().

Значения здесь намеренно не приводятся (замаскированы под имя переменной) — то же самое ограничение, что уже было явно зафиксировано в разделе 3.3 для config.gs. Логика проблемы идентична: любой человек с доступом к исходному коду проекта (в том числе к этой документации, если бы в неё попали реальные значения) получает достаточно данных, чтобы напрямую обращаться к балансировщику и к Битрикс24 организации, минуя весь остальной пайплайн и его защитные механизмы (rate-limit'ы, логирование, авторизацию через сам Apps Script проект).

Дополнительный риск именно для этих трёх констант — они лежат в отдельном файле balancer.gs, который не упомянут вообще нигде в UI настроек (settingsDialog.gs не содержит полей для балансировщика). Значит, ротация ключей (например, при подозрении на утечку) потребует прямого редактирования исходного кода через редактор Apps Script, а не смены значения через интерфейс — в отличие от API_KEY/вебхуков Битрикс24, для которых такой путь хотя бы теоретически предусмотрен.

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

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;
}

Обрати внимание: несмотря на название функции и на то, что она полноценно реализована и покрывает ровно тот сценарий, который описывался как «ожидаемый» в разделе 6.7 (карта контактов Битрикс24 для резолва компаний в чатах) — эта функция нигде не вызывается в текущем runProcessingPipeline(). Это прямое подтверждение того, что баг из 6.7 — не просто опечатка в количестве аргументов, а признак незавершённого перехода с одного источника обогащения данных (Битрикс24-контакты через getBitrix24Contacts()) на другой (индекс ОКБ через getCachedOkbPhoneIndex()), при котором старая функция осталась в коде, а вызовы её результата в telegramService.gs не были приведены в соответствие с новым источником.

7.5 ⚠️ Дублирующиеся реализации getCachedCompaniesMap()

Функция с именем getCachedCompaniesMap() объявлена в двух разных файлах:

  • В bitrixService.gs — использует переменную модуля _companiesMapCache (тот же паттерн внутрисессионного кэша, что и у _configCache/_blacklistCache), вызывает getAllCompaniesMap() при промахе кэша.
  • В cache.gs — использует персистентный кэш через PropertiesService с TTL 1 час (CACHE_DURATION_MS = 60 * 60 * 1000), сериализует карту компаний в JSON и хранит её между запусками скрипта, а не только в рамках одной сессии.

Это тот же класс проблемы, что и с normalizePhone() (раздел 5.4) и initializeDefaultConfig() (раздел 4.3) — две функции с идентичным именем на верхнем уровне разных файлов одного GAS-проекта, при этом реально выполняется только одна из них (та, что загрузилась последней), а какая именно — зависит от порядка файлов в самом проекте Apps Script, что не установлено по содержимому кода.

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

  • Версия из bitrixService.gs кэширует только на время одного выполнения пайплайна — при следующем запуске (в том числе ежедневном автозапуске) карта компаний будет запрошена заново.
  • Версия из cache.gs кэширует на час между запусками — если основной пайплайн запускается ежедневно, а карта компаний Битрикс24 меняется нечасто, эта версия ощутимо экономит HTTP-вызовы (crm.company.list через балансировщик) и потенциально снижает нагрузку на квоту Битрикс24, ради обхода которой балансировщик изначально и был внедрён (см. раздел 7.2).

Если в проде реально выполняется версия из bitrixService.gs (внутрисессионная, без TTL) — часть смысла существования cache.gs теряется, а сам файл cache.gs становится мёртвым кодом наравне с примерами из разделов 4.3/6.1. Если же выполняется версия из cache.gs — то функция clearCompaniesCache() из bitrixService.gs, которая явно предназначена для сброса кэша компаний вручную (сбрасывает _companiesMapCache = null), окажется бесполезной — она чистит не тот кэш, который реально используется, и вызов «сбросить кэш компаний» ничего не даст, если реальные данные лежат в PropertiesService под ключом BITRIX24_COMPANIES_MAP.

Определить, какая версия реально исполняется в проде, по содержимому кода невозможно — нужно смотреть порядок файлов непосредственно в редакторе Apps Script проекта (как и в случае с normalizePhone(), этот порядок не был передан для анализа). Рекомендация для рефакторинга та же: оставить одну реализацию — по бизнес-смыслу логичнее версию из cache.gs с TTL, так как она реально экономит вызовы к внешнему сервису, — переименовать или удалить вторую, и убедиться, что clearCompaniesCache() (если он остаётся нужен) чистит именно тот кэш, который используется по факту.


8. Интеграция с ОКБ (общая клиентская база)

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

ОКБ — отдельная Google-таблица (OKB_SPREADSHEET_ID = '1F1ansQeSuAwhe3Y_mhfm4ja370k5hq67RruE-CVp83w', константа в okbService.gs), которая не принадлежит этому проекту и ведётся, судя по всему, отдельно — как справочник компаний-клиентов с привязанными телефонами. Это единственный из четырёх источников данных (МоиЗвонки, Битрикс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' };
}

Причина именно такого способа проверки, а не прямого try { SpreadsheetApp.openById(...) } catch, скорее всего в том, что SpreadsheetApp.openById() на таблицу без доступа выбрасывает исключение с не всегда предсказуемым текстом ошибки (зависит от типа блокировки — таблица не существует, доступ запрещён, удалена и т.д.), тогда как HTTP-код 403/404 от export-эндпоинта — предсказуемый, машинно-читаемый сигнал именно про отсутствие доступа. Это рабочий, хоть и не самый очевидный с первого взгляда приём: по сути, скрипт делает лишний HTTP-запрос ради того, чтобы получить чистый статус-код вместо разбора текста исключения.

Учётная запись, от имени которой выполняется сам Apps Script проект (то есть — тот, кто в последний раз авторизовал скрипт, либо владелец триггера при автозапуске — [Требует уточнения: чей именно OAuth-токен используется при выполнении по триггеру — владельца проекта или пользователя, настроившего автозапуск, это стандартное поведение GAS, но стоит явно зафиксировать, кто именно должен иметь доступ к таблице ОКБ]), должна иметь права хотя бы на чтение таблицы ОКБ. Если доступа нет — 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. [Требует уточнения: предполагается ли, что аналитик или администратор системы должен сам заходить в редактор кода и руками вызывать clearOkbCache() при обновлении данных в ОКБ, или это упущение — стоило бы вынести такую кнопку в 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 следующих суток) сообщения теоретически могут попасть не в тот день, в который их отнёс бы человек, смотрящий на часы в Москве. Прямой проверки в переданном коде нет — [Требует уточнения: в каком часовом поясе Telegram Logger API агрегирует сообщения по дням на своей стороне].

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

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, найти нужного человека по какому-то внешнему признаку (вероятно, по username в Telegram — [Требует уточнения: по какому именно полю ответа GET /managers администратор должен искать нужного менеджера — это не описано в самом коде, только инструкция «откройте и найдите»]), скопировать числовой id и вставить его в таблицу. Это полностью ручной, неавтоматизированный процесс сопоставления — никакой синхронизации или автоматического подтягивания списка менеджеров из Telegram Logger API в коде нет.

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

9.3 ⚠️ TELEGRAM_API_KEY захардкожен как дефолт

Значение TELEGRAM_API_KEY встречается захардкоженным сразу в трёх местах кодовой базы:

  1. config.gs, внутри getConfig() — как fallback-значение, если свойство ещё не установлено: props['TELEGRAM_API_KEY'] || /* захардкоженное значение */.
  2. config.gs, внутри (актуальной, второй по порядку — см. раздел 4.3) initializeDefaultConfig() — как значение, которое реально запишется в ScriptProperties при первой инициализации.
  3. telegramService.gs, внутри initTelegramApiSettings() — отдельная функция, дублирующая ту же самую пару TELEGRAM_API_URL/TELEGRAM_API_KEY, судя по всему, предназначенная для ручного разового запуска из редактора Apps Script (аналогично одноимённой функции, дублированной и в config.gs — да, initTelegramApiSettings() тоже объявлена дважды, в двух разных файлах, с идентичным содержимым; это уже четвёртый по счёту случай коллизии имён функций в проекте, наравне с initializeDefaultConfig, normalizePhone и getCachedCompaniesMap).

В отличие от API_KEY МоиЗвонки или вебхуков Битрикс24 (которые тоже захардкожены, но хотя бы точечно, в одной функции восстановления настроек — раздел 3.3), ключ Telegram API захардкожен именно как дефолтное значение, подставляемое автоматически при обычном чтении конфига через getConfig(), если свойство почему-либо не задано. То есть если администратор случайно удалит TELEGRAM_API_KEY из Script Properties (например, при ручной чистке через редактор), система не откажет в доступе и не потребует повторной настройки — она молча продолжит работать с захардкоженным в исходнике значением, без единого предупреждения в логах о том, что используется значение по умолчанию, а не осознанно заданное.

С точки зрения безопасности это ослабляет значимость самого факта существования этого ключа как секрета — он не столько «секрет, который можно сконфигурировать», сколько «постоянная величина, вписанная в код с возможностью переопределения», и хранение его в открытом виде в исходниках создаёт тот же риск, что и в разделах 3.3/7.3: любой, кто получает доступ к коду проекта, получает и рабочий ключ доступа к Telegram Logger API этой организации.


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

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

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

Диалог (файл) Функция открытия Размер окна Тип открытия
monthPicker.html openMonthPickerDialog() 420×380 showModalDialog
progressDialog.gs* вызывается изнутри startMonthProcessing() 420×320 showModelessDialog
blacklistManager.gs* openBlacklistManager() 850×600 showModalDialog
managers.html openManagersManager() 800×600 showModalDialog
timePicker.html setupAutoTrigger() 500×350 showModalDialog
archiveDialog.html openArchiveDialog() 520×560 showModalDialog
settingsDialog.gs* openSettingsDialog() 750×650 showModalDialog

* Отмечены файлы, которые физически содержат HTML-разметку, несмотря на расширение .gs — подробнее в разделе 10.5.

Разделение на showModalDialog и showModelessDialog не случайно и напрямую завязано на бизнес-сценарий: все диалоги, которые собирают ввод пользователя (выбор месяца, настройки, редактирование справочников), блокируют интерфейс таблицы до закрытия (showModalDialog) — это стандартное поведение для форм, где нужно дождаться решения пользователя. Единственное исключение — progressDialog.gs, открытый как немодальный (showModelessDialog): пользователь должен иметь возможность видеть прогресс-бар, не будучи заблокированным от остального интерфейса Google Sheets, пока идёт длительная обработка данных (сама обработка при этом всё равно исполняется синхронно на сервере — см. диаграмму long polling в разделе 2.3, немодальность окна касается только клиентского UI, а не серверного выполнения).

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 для отображения текущего состояния автозапуска, и кнопка полного удаления всех триггеров, продублированная с одноимённым пунктом меню.

10.5 ⚠️ Расхождение имени файла и содержимого

Как уже отмечалось в разделе 2.4, файлы blacklistManager.gs и settingsDialog.gs физически содержат <!DOCTYPE html> и полноценную HTML/CSS/JS разметку, а не серверный Apps Script код — несмотря на расширение .gs. Для сравнения, все остальные HTML-диалоги в проекте корректно названы с расширением .html (monthPicker.html, managers.html, archiveDialog.html, timePicker.html), и лишь эти два — исключение.

Технически на работу системы это не влияет: HtmlService.createHtmlOutputFromFile('blacklistManager') в main.gs обращается к файлу по имени без расширения, а сам Apps Script хранит информацию о типе файла (Server JS / HTML / JSON) отдельно от текстового расширения, которое видно в этой файловой выгрузке — то есть внутри реального проекта в редакторе Apps Script эти файлы, вероятнее всего, отображаются с иконкой HTML и корректно определяются платформой как HTML-файлы, а расширение .gs — это, по всей видимости, просто артефакт того, как файлы были экспортированы/выгружены для передачи (например, через clasp с неверным маппингом типов, или вручную при подготовке файлов к этой документации).

Тем не менее для читателя этой документации и для любого нового разработчика, впервые открывающего файлы проекта локально (а не через встроенный редактор Apps Script с его собственной файловой панелью), это создаёт реальный риск потерять время: увидев blacklistManager.gs, естественно ожидать внутри серверный JS-код с бизнес-логикой, а не HTML-разметку интерфейса. [Требует уточнения: является ли это расхождение особенностью только этой конкретной выгрузки файлов для документации, или оно так же выглядит непосредственно в самом проекте Apps Script — стоит проверить в реальном редакторе и, если нужно, просто переименовать эти два файла в .html для консистентности с остальными].


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: 'Слишком короткий номер телефона' };
}

Обрати внимание на порядок: сначала номер нормализуется через normalizePhone() (версия из blacklist.gs, см. раздел 5.4), и только потом проверяется на признак «скрытого» номера и на минимальную длину. Это значит, что пользователь, пытающийся добавить в чёрный список, например, строку "anonymous" (текстовая метка, которую HIDDEN_NUMBER_PATTERNS распознаёт как скрытый номер), получит осмысленную ошибку «Некорректный или скрытый номер» — но клиентская форма в blacklistManager.gs до этого момента уже пропустила бы такой ввод дальше, поскольку её собственная валидация (validateForm()) проверяет только количество цифр в исходной строке, а не факт совпадения с текстовым паттерном скрытого номера. То есть при вводе, например, чисто цифрового «скрытого» паттерна вроде 77777777777 (11 семёрок) клиентская проверка на длину (digitCount >= 10) пройдёт успешно, кнопка разблокируется, запрос уйдёт на сервер — и только там, после нормализации, всплывёт отказ. Рабочее, но не самое отзывчивое для пользователя поведение: ошибка про «скрытый номер» видна только после полного цикла запроса к серверу, а не сразу при вводе.

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

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

Это явная оптимизация под ограничения GAS, аналогичная той, что уже встречалась в loadBlacklist() (раздел 6.3) — каждый вызов getRange()/getValues() в Google Sheets API это относительно дорогая операция сама по себе (сетевой вызов к бэкенду Sheets), и сведение множества точечных обращений к ячейкам в один пакетный вызов — систематический паттерн, повторяющийся практически во всех файлах проекта, где идёт работа с листами (sheetExporter.gs, managersList.gs, blacklist.gs — везде видна одна и та же дисциплина «читай/пиши весь диапазон одним вызовом»).

Примечательно, что при добавлении номер повторно нормализуется внутри normalizePhone() при сравнении с каждой существующей строкой (existingValues.some(row => normalizePhone(String(row[0])) === normalized)) — то есть предполагается, что в листе теоретически могут храниться ещё не нормализованные номера (например, если кто-то вписал номер напрямую в таблицу руками, минуя UI). Это разумная защита от рассинхрона формата, но одновременно означает, что при большом чёрном списке (сотни записей) каждое добавление нового номера повторно прогоняет нормализацию по всем существующим строкам — при текущих объёмах, судя по всему, не критично, но стоит иметь в виду как потенциальное узкое место при кратном росте списка.

removeFromBlacklist(phoneNumber) работает симметрично — читает весь столбец A одним вызовом, ищет совпадение по нормализованному номеру, и удаляет первую найденную строку через sheet.deleteRow(found). Если бы в списке оказались случайные дубли одного и того же номера (что теоретически возможно, если запись была добавлена в обход addToBlacklist(), например, вручную через редактор таблицы, где проверка на существование не применяется), удаление уберёт только одну из копий, а не все — [Требует уточнения: ожидается ли, что в чёрном списке в принципе не может быть дублей, раз добавление всегда идёт только через UI с проверкой exists, или стоит сделать removeFromBlacklist() устойчивым и к этому случаю].

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');

То есть safeExecute() сама по себе — это просто «безопасный вызов с логированием», а не механизм graceful degradation. Именно повторяющийся паттерн if (!result.success) throw ... сразу после каждого вызова safeExecute() в runProcessingPipeline() и создаёт то самое поведение «всё или ничего», разобранное в разделе 2.1 — для шагов fetchCalls, processCalls, groupByMonth, exportToSheets. Единственное исключение — шаг hideOutdatedSheets() и generatePdfReports(), которые вызываются через safeExecute() без последующей проверки if (!result.success) throw, то есть их сбой действительно проглатывается и не прерывает пайплайн — что явно согласуется с комментарием из раздела 2.1 про то, что ошибка PDF-шага не ломает финальный статус done: true.

Стоит отдельно отметить, что при неудаче safeExecute() теряет исходный тип ошибки — error.toString() конкатенируется в единую строку msg, а сам объект Error (со стеком, именем класса ошибки и т.д.) наружу не передаётся, только его текстовое представление внутри поля error. Для целей логирования этого достаточно, но если бы вызывающему коду потребовалось программно различать разные типы ошибок (например, «нет сети» vs «невалидные данные» vs «квота исчерпана») для разной последующей логики — текущая структура результата safeExecute() этого не позволяет, всё сведено к одной строке.

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

Здесь стоит явно зафиксировать существенный пробел в переданных файлах: runProcessingPipeline() вызывает generatePdfReports(monthlyData) как финальный шаг пайплайна (см. диаграмму в разделе 2.1), но сама функция generatePdfReports() не входит в состав ни одного из переданных файлов проекта. Файл pdfExporter.gs содержит только вспомогательные функции более низкого уровня — sendPdfReportsByEmail(), getEmailAddresses(), logEmailDistribution(), sendFallbackEmails(), testEmailConnection(), — но не саму функцию, которая должна была бы: собрать данные по пропущенным звонкам без перезвона за месяц, сформировать из них PDF-документ(ы) (вероятно, через HtmlService + конвертацию в PDF, или через SpreadsheetApp-экспорт — сам механизм генерации PDF нигде не описан), и вызвать sendPdfReportsByEmail(pdfBlobs, reportInfo) с готовыми блобами. [Требует уточнения: реализация generatePdfReports() отсутствует в переданных файлах — необходимо запросить этот файл отдельно, чтобы задокументировать фактическую логику формирования PDF-отчётов (какие именно данные попадают в отчёт, как выглядит вёрстка, откуда берётся report.callCount для каждого месяца)]. Раздел 12.3 в этой документации далее описывает только то, что можно достоверно установить по коду pdfExporter.gs — саму рассылку уже готовых PDF-вложений, а не их генерацию.

sendPdfReportsByEmail(pdfBlobs, reportInfo) принимает на вход массив PDF-блобов и параллельный массив метаданных reportInfo (ожидаемая структура, судя по обращениям к полям внутри функции — объекты вида { month, callCount }), формирует единое письмо с сводкой по всем месяцам и вложениями, и отправляет его через 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 ⚠️ Дублирование getEmailAddresses()

Функция валидации/нормализации списка email-адресов реализована дважды, в pdfExporter.gs и в utils.gs, с практически идентичной логикой:

// pdfExporter.gs
function getEmailAddresses(emailString) {
  if (!emailString || typeof emailString !== 'string') return [];
  return emailString
    .split(',')
    .map(email => email.trim().toLowerCase())
    .filter(email => email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
    .filter((email, index, self) => self.indexOf(email) === index);
}

// utils.gs
function getEmailAddresses(emailsString) {
  if (!emailsString) return [];
  return emailsString
    .split(',')
    .map(e => e.trim().toLowerCase())
    .filter(e => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
}

Это тот же класс проблемы совпадения имён функций в разных файлах одного GAS-проекта, что уже подробно разбирался для normalizePhone (5.4), initializeDefaultConfig (4.3), getCachedCompaniesMap (7.5) и initTelegramApiSettings (9.3) — то есть это уже пятый подтверждённый случай такого дублирования в проекте, что само по себе говорит о системной, а не случайной проблеме организации кода (вероятно, отсутствие единого соглашения о том, куда класть общие утилитарные функции, и/или несколько разработчиков, писавших код параллельно без синхронизации по неймингу).

Разница между версиями здесь менее драматична, чем в других случаях, но не нулевая:

  • Версия pdfExporter.gs убирает дубли через filter(... self.indexOf(email) === index).
  • Версия utils.gs не убирает дубли — просто фильтрует по формату email.
  • Проверка typeof emailString !== 'string' есть только в версии pdfExporter.gs — версия utils.gs при передаче, например, числа или объекта вместо строки упадёт на вызове .split(','), тогда как первая версия корректно вернёт пустой массив.

Если в проде выполняется версия из utils.gs (более простая, без дедупликации) — при рассылке PDF-отчётов теоретически возможна повторная отправка одного и того же вложения на один и тот же адрес, если он случайно продублирован в поле REPORT_EMAILS (хотя нормализация в updateConfig(), см. раздел 4.4, уже убирает дубли на этапе сохранения настройки — то есть для email-полей, прошедших именно через updateConfig(), дублей быть не должно, но если значение REPORT_EMAILS когда-либо было записано в обход UI напрямую через PropertiesService, риск дублей в самой рассылке становится реальным).

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

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;
}

Несколько важных деталей:

  • Письмо уходит на config.userEmail — то есть на email аккаунта МоиЗвонки (USER_EMAIL из конфига, см. таблицу в разделе 4.2), а не на, например, отдельно настроенные REPORT_EMAILS для аналитического отдела. Это означает, что уведомление об аварии автозапуска получает конкретно тот человек/ящик, что указан как логин МоиЗвонки — вероятно, администратор системы, а не аналитики, которые обычно смотрят на готовые отчёты. Если администратор и получатели отчётов — разные люди, аналитики узнают о сбое обработки лишь тогда, когда заметят отсутствие свежего листа Звонки_YYYY-MM, а не из явного письма.
  • Отправка обёрнута в собственный try/catch — если письмо не удаётся отправить (например, MailApp временно недоступен или превышена дневная квота на отправку), эта вторичная ошибка не заменяет и не перекрывает исходную, а лишь логируется отдельно, после чего исходная ошибка (err) всё равно пробрасывается дальше через throw err.
  • throw err в самом конце гарантирует, что даже несмотря на попытку уведомления по email, сам факт ошибки будет виден в истории выполнения триггеров Apps Script (Triggers → Executions в редакторе) — то есть это не «поглощённая» ошибка, а по-прежнему полноценный сбой с точки зрения платформы, просто с дополнительным каналом уведомления поверх стандартного.

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

13.1 testFunctions.gs — назначение и риски

Файл не подключён ни к одному пункту меню и ни к одному UI-диалогу — обе функции внутри (testEmailBitrixMapping(), testGetBitrix24Contacts()) предназначены исключительно для ручного запуска из редактора Apps Script разработчиком, который открывает конкретную функцию в выпадающем списке редактора и жмёт «Выполнить». Это стандартная для GAS-проектов практика диагностики, но стоит явно зафиксировать: пользователи системы (аналитический отдел, см. раздел 1.1) не имеют и не должны иметь повода запускать эти функции — они существуют для разработчика/администратора при отладке проблем сопоставления данных.

testEmailBitrixMapping() — самая длинная функция во всём проекте, объёмный диагностический скрипт, который:

  1. Получает список сотрудников Битрикс24 через getBitrixUserList().
  2. Получает звонки МоиЗвонки за последние 30 дней (fetchCalls(thirtyDaysAgo, now, 1) — жёстко заданный период, без возможности параметризовать через аргументы функции).
  3. Строит статистику по каждому уникальному email менеджера, встретившемуся в звонках (emailStats — Map, где ключ — нормализованный email, значение — количество звонков, направления, доли ответов).
  4. Пытается сопоставить каждый email с сотрудником Битрикс24 по трём уровням: точное совпадение email → совпадение по домену с попыткой сматчить по имени (domain) → единственный сотрудник в этом домене, если имя не совпало (domain_single) → полный провал с генерацией «человекочитаемого» имени из локальной части email (none).
  5. Выводит подробную таблицу в Logger.log() в виде псевдо-табличного текста с ручным padEnd().

Практическая ценность этой функции — она напрямую диагностирует ту самую задачу, которую в проде решает лист «Менеджеры» вручную (см. раздел 9.2, только там сопоставление идёт email ↔ Telegram ID, а здесь — email ↔ сотрудник Битрикс24). То есть testEmailBitrixMapping(), по всей видимости, была написана как разовый инструмент на этапе внедрения системы — чтобы понять, какие email из МоиЗвонки не резолвятся автоматически в сотрудников Битрикс24, и на основе этого решить, какие записи потребуют ручного добавления в какой-либо справочник. При этом сам результат её работы (сопоставление email → сотрудник Битрикс24) в реальном пайплайне runProcessingPipeline() не используется вообще — резолв имени менеджера для звонков идёт исключительно через лист «Менеджеры» (resolveManagerName()), который заполняется вручную через UI managers.html, а не через какое-либо автоматическое сопоставление с Битрикс24. Функция полезна как разовый диагностический отчёт, но не как часть работающей системы.

testGetBitrix24Contacts() — ещё один диагностический скрипт, но с отдельным, третьим захардкоженным вебхуком Битрикс24 прямо внутри функции:

const WEBHOOK_URL = 'https://thebest.bitrix24.ru/rest/292/e8gft5xlpudz7nxb/crm.contact.list';

Это значение отличается от BITRIX_WEBHOOK в balancer.gs и от BITRIX24_CRM_WEBHOOK в конфиге — то есть в проекте потенциально фигурируют три разных вебхука Битрикс24 (или как минимум три разных места, где вебхук указан явным текстом), что стоит зафиксировать отдельно как усугубление проблемы из раздела 7.3: не только ключи разбросаны по нескольким файлам без единой точки управления, но и сами значения вебхуков между этими местами могут не совпадать друг с другом. [Требует уточнения: это три реально разных вебхука (например, выданных разным пользователям Битрикс24 с разными правами), либо тестовая функция просто использует устаревшее/тестовое значение, оставшееся от более раннего этапа разработки]. Функция реализует собственную, отдельную от balancer.gs/bitrixService.gs пагинацию (while (hasMore), поле data.next), с искусственным ограничением в 20 контактов для целей теста (if (allContacts.length >= 20) break;), и выводит в лог полный JSON первых пяти контактов — то есть предназначена для визуальной сверки, какие поля реально отдаёт Битрикс24 по конкретному контакту, а не для регулярного использования.

13.2 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() на введённые адреса

Стоит явно выделить находку: и testBitrixConnection() (bitrixService.gs), и testBitrix24Connection() (integrations.gs) — полноценно реализованные функции проверки подключения к Битрикс24, но ни одна из них не подключена ни к пункту меню, ни к кнопке в settingsDialog.gs. В settingsDialog.gs нет отдельной кнопки «Тестировать подключение к Битрикс24», хотя в разделе с интеграциями (bitrix24Enabled, bitrix24Webhook, reportEmails) логично было бы её ожидать — вместо этого есть только кнопка «📧 Протестировать отправку на email». Это ещё один штрих к общей картине: тестирование Битрикс24-подключения либо предполагается делать через прямой запуск функции из редактора Apps Script (как и файлы из testFunctions.gs), либо это недоделанный участок UI, который должен был появиться, но не появился. [Требует уточнения: планировалось ли добавить кнопку тестирования Битрикс24-подключения в settingsDialog.gs, либо проверка подключения к Битрикс24 сознательно оставлена только для разработчика через редактор кода].

Также стоит заметить смысловую разницу между testBitrixConnection() и testBitrix24Connection() — при том что оба существуют независимо и оба нигде не используются из UI, они не полностью дублируют друг друга: первая проверяет оба вебхука (пользовательский и CRM), вторая — только CRM-вебхук. Если бы предполагалось когда-нибудь добавить кнопку тестирования в settingsDialog.gs, разумно было бы использовать именно testBitrixConnection() как более полную проверку, а testBitrix24Connection() в таком случае стал бы избыточным — ещё один кандидат на удаление при рефакторинге, наравне с уже перечисленными в предыдущих разделах дублирующимися функциями.


14. Известные проблемы и технический долг (сводный раздел)

Ниже — все находки, зафиксированные по ходу разделов 1–13, сведённые в единый чек-лист с оценкой критичности. Три категории:

  • 🔴 Security — риск, связанный с раскрытием секретов/доступов.
  • 🟠 Functional bug — реальная, воспроизводимая ошибка в логике, которая уже сейчас или потенциально ломает данные в отчёте.
  • 🟡 Maintainability — не ломает работу системы сегодня, но создаёт риск при дальнейшей поддержке/рефакторинге.

14.1 Мета-паттерн: коллизии имён функций между файлами

Прежде чем перечислять отдельные случаи — стоит явно зафиксировать, что это не пять независимых мелких недочётов, а один системный паттерн, повторяющийся по всему проекту. В GAS все файлы .gs выполняются в одной общей глобальной области видимости, и при объявлении одноимённой функции в двух файлах побеждает та, что была загружена последней — порядок этот определяется исключительно порядком файлов внутри самого проекта Apps Script (Project Settings → порядок файлов), а не порядком в этой документации или в репозитории. Найдено пять таких коллизий:

Функция Файлы-дублёры Разбор
normalizePhone() blacklist.gs, utils.gs Раздел 5.4
initializeDefaultConfig() дважды в config.gs (одном файле!) Раздел 4.3
getStartOfCurrentMonthTimestamp() дважды в main.gs (одном файле!) Раздел 6.1
getCachedCompaniesMap() bitrixService.gs, cache.gs Раздел 7.5
initTelegramApiSettings() config.gs, telegramService.gs Раздел 9.3
getEmailAddresses() pdfExporter.gs, utils.gs Раздел 12.4

Рекомендация уровня проекта, а не отдельного файла: провести разовый аудит всех объявлений функций верхнего уровня по всем .gs-файлам (простой grep -h "^function " по всем файлам с последующей сортировкой и поиском повторов решит задачу за пять минут), выбрать по одной версии на каждое имя, удалить дублирующие объявления, и в идеале ввести соглашение о неймспейсинге утилитарных функций (например, префиксом по файлу или через единый объект-модуль), чтобы такие коллизии в принципе не могли повториться незаметно при дальнейшей разработке.

14.2 Чек-лист по критичности

🔴 Security

  1. forceReinitializeConfig() (config.gs) — безусловно перезаписывает боевые API_KEY, USER_EMAIL, SPREADSHEET_ID, оба вебхука Битрикс24 захардкоженными в коде значениями; вызывается однокликовым пунктом меню «↺ Восстановить настройки по умолчанию». Раздел 3.3.
  2. BALANCER_PUBLIC/BALANCER_PRIVATE/BITRIX_WEBHOOK в balancer.gs — захардкоженные ключи авторизации к стороннему прокси и реальный вебхук Битрикс24, без какого-либо пути ротации через UI. Раздел 7.3.
  3. TELEGRAM_API_KEY — захардкожен как тихий fallback в getConfig(), срабатывающий без предупреждения, если свойство удалено из Script Properties. Раздел 9.3.
  4. Третий, отдельный вебхук Битрикс24 в testGetBitrix24Contacts() (testFunctions.gs) — усугубляет проблему №2, поскольку это уже третье место с явным значением вебхука, не совпадающим с двумя другими. Раздел 13.1.

🟠 Functional bug

  1. Несовпадение сигнатуры fetchAggregatedMessages() — вызов с 3 аргументами против объявления с 4; companyIdToName внутри окажется undefined, что бросает TypeError при заполненном companyId у найденного контакта. Двухслойный баг: даже при исправлении числа аргументов, третий параметр (okbPhoneIndex) семантически не совпадает по структуре с ожидаемым phoneToContactMap. Раздел 6.7, подтверждено разделом 7.4 (getBitrix24Contacts() реализована, но не используется — явный след незавершённого перехода на другой источник данных).
  2. Рассинхрон REPORT_EMAIL/REPORT_EMAILS — форма в settingsDialog.gs (saveSettings()) пытается прочитать несуществующий DOM-элемент #reportEmail, что даст ошибку в браузере при попытке сохранить настройки email для отчётов через штатный UI. Раздел 4.2.
  3. Незаякоренный regex в hideOutdatedSheets() (^Звонки_(\d{4})-(\d{2}) без $) — случайно матчит и _Статистика-листы, что технически совпадает с ожидаемым поведением, но является побочным эффектом, а не осознанной логикой. Раздел 5.3.

🟡 Maintainability

  1. Пять коллизий имён функций (см. 14.1 выше).
  2. Отсутствие generatePdfReports() в переданных файлах — функция вызывается из runProcessingPipeline(), но её реализация не найдена ни в одном файле проекта. Раздел 12.3.
  3. clearCompaniesCache() может чистить не тот кэш, если в проде реально используется версия getCachedCompaniesMap() из cache.gs, а не из bitrixService.gs. Раздел 7.5.
  4. clearOkbCache() не привязана ни к одному пункту UI — чисто ручная функция для вызова из редактора кода. Раздел 8.3.
  5. createBitrix24Task() (integrations.gs) реализована, но не используется — подтверждено пользователем как неактивная функциональность. Требует решения: либо подключить к пайплайну (например, автоматическая постановка задачи на пропущенный звонок без перезвона), либо удалить как мёртвый код.
  6. testBitrixConnection()/testBitrix24Connection() реализованы, но не вызываются ни из меню, ни из UI настроек — в settingsDialog.gs нет кнопки тестирования Битрикс24-подключения, хотя есть все данные для этого. Раздел 13.2.
  7. Несогласованность часовых поясов — весь проект работает в Europe/Moscow, кроме telegramService.gs, который форматирует границы периода в UTC. Раздел 9.1.
  8. Три независимые реализации нормализации телефона с разной строгостью (blacklist.gs, utils.gs, okbService.gs — normalizePhoneStrict) — не коллизия имён (кроме первых двух, см. №8), но дублирование бизнес-логики, которое требует ручной синхронизации при любом изменении правил. Раздел 5.4.
  9. archiveDialog.html: несогласованная логика фильтрации листов между первоначальной отрисовкой списка и кнопкой «Только данные» — при отрисовке используется startsWith('Звонки_') && !includes('Статистика'), кнопка использует только startsWith('Звонки_'). Раздел 10.4.
  10. STEP_PROGRESS в progressDialog.gs — неиспользуемая константа, вероятно, остаток более ранней реализации клиентского расчёта прогресса. Раздел 10.3.
  11. Файлы blacklistManager.gs и settingsDialog.gs физически содержат HTML, а не серверный код, несмотря на расширение .gs — требует проверки, воспроизводится ли это в самом проекте Apps Script или это артефакт выгрузки файлов. Раздел 10.5.
  12. analyzeCallbacks() не различает направление повторного звонка — засчитывает как «перезвон совершён» в том числе случай, когда клиент сам перезвонил менеджеру, а не наоборот. Требует подтверждения, было ли это осознанным решением. Раздел 6.4.
  13. Ключ группировки статистики по менеджерам — просто строка имени, без нормализации и без привязки к email/telegramId как стабильному идентификатору — риск рассинхрона, если имя менеджера вписано по-разному для звонков и для сопоставления Telegram ID. Раздел 6.6.
  14. Уведомление об ошибке автозапуска идёт только на userEmail (аккаунт МоиЗвонки), а не на REPORT_EMAILS — аналитический отдел может не узнать о сбое обработки вовремя, если администратор и аналитики — разные люди. Раздел 12.5.

15. Глоссарий терминов

Термины, специфичные для этого проекта — общие определения из документации МоиЗвонки/Битрикс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.
Supervised-звонок Параметр supervised в запросе к API МоиЗвонки (fetchCalls(fromDate, toDate, supervised)), в пайплайне всегда передаётся как 1. [Требует уточнения: что именно означает supervised на стороне API МоиЗвонки — «звонок под контролем/прослушкой руководителя» или что-то иное, значение параметра нигде в проекте не документировано].
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.

Документ завершён: разделы 1–15. Ниже — краткое резюме для дальнейшей работы над проектом.

Итоговое резюме для дальнейшей работы

Технически система рабочая и покрывает заявленную бизнес-задачу — сборка отчёта из четырёх источников с деградацией при недоступности любого из них. Основные зоны риска для дальнейшей поддержки:

  1. Безопасность — минимум четыре места с захардкоженными боевыми секретами (пункты 14.2.1–14.2.4), требующие выноса в защищённое хранилище до передачи проекта кому-либо ещё, включая подрядчиков.
  2. Реальный, воспроизводимый баг с резолвом компаний в чатах (14.2.5) — стоит исправить в первую очередь среди функциональных проблем, так как он либо ломает экспорт Telegram-данных целиком, либо даёт неверные названия компаний в части случаев.
  3. Сломанное сохранение email-настроек через UI (14.2.6) — вероятно, наиболее заметный пользователю баг, раз он проявляется прямо при попытке сохранить настройки в штатном диалоге.
  4. Системная проблема коллизий имён функций (14.1) — не создаёт видимых проблем прямо сейчас (так как какая-то одна версия каждой пары стабильно выполняется), но превращает любой будущий рефакторинг в минное поле, где правка «явной» копии функции может не иметь эффекта.
  5. Пробел в документации — отсутствие generatePdfReports() в переданных файлах не позволяет описать существенную часть системы (формирование PDF-отчётов) и должно быть закрыто отдельным запросом файла у автора.

Список открытых вопросов к автору проекта (сведён из всех разделов):

  • Реализация generatePdfReports().
  • Актуальность/статус трёх разных значений вебхуков Битрикс24 (balancer.gs, config.gs, testFunctions.gs).
  • Осознанность решения не различать направление перезвона в analyzeCallbacks().
  • Значение символа # в телефонных записях таблицы ОКБ.
  • Чей OAuth-токен используется при выполнении по триггеру для доступа к ОКБ.
  • Планировалась ли кнопка тестирования Битрикс24-подключения в settingsDialog.gs.
  • Статус createBitrix24Task() — подключать к пайплайну или удалять.
  • Значение параметра supervised в запросах к API МоиЗвонки.