diff --git a/CLAUDE.md b/CLAUDE.md index d559489..1506982 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Cron-задача `bin/synchCdr.php` запускается автоматиче - Каналы трансформируются в формат `PJSIP/mts_` - Телефоны: 10-значные номера автодополняются префиксом `7` (российский формат) - Часовой пояс: поле `gap` в настройках задаёт смещение в часах -- Логи: `/core/logs/ModuleMtsPbx/{ClassName}.log`, ротация на 40MB +- Логи: `{Directories::CORE_LOGS_DIR}/ModuleMtsPbx/{ClassName}.log` (на боевой PBX обычно `/storage/usbdisk1/mikopbx/log/ModuleMtsPbx/`), ротация на 40MB - Кеш: Redis с префиксом `ModuleMtsPbx_`, TTL по умолчанию 86400с - Совместимость Phalcon 4/5: через `MikoPBXVersion` — всегда использовать его для получения DI, валидаторов, текстовых утилит - Локализация: `Messages/ru.php` и `Messages/en.php`, ключи `repModuleMtsPbx.*` diff --git a/Lib/MtsPbxConf.php b/Lib/MtsPbxConf.php index d34e55a..42d5bf6 100644 --- a/Lib/MtsPbxConf.php +++ b/Lib/MtsPbxConf.php @@ -34,6 +34,9 @@ public function createCronTasks(&$tasks): void $downloadRecPath = $binDir.'downloadRecords.php'; $phpPath = Util::which('php'); $tasks[] = "*/1 * * * * $phpPath -f $syncCdrPath > /dev/null 2> /dev/null\n"; + // Глубокая дозагрузка: добирает длинные/отложенные звонки, которые попали в индекс MTS + // уже после того, как основное скользящее окно ушло вперёд. Offset не двигает. + $tasks[] = "*/30 * * * * $phpPath -f $syncCdrPath -- --lookback=720 > /dev/null 2> /dev/null\n"; $tasks[] = "*/5 * * * * $phpPath -f $downloadRecPath > /dev/null 2> /dev/null\n"; } diff --git a/bin/downloadRecords.php b/bin/downloadRecords.php index deed3a8..e6389b3 100644 --- a/bin/downloadRecords.php +++ b/bin/downloadRecords.php @@ -18,12 +18,25 @@ require_once 'Globals.php'; -const RECORDS_LOOKBACK_DAYS = 7; +// MTS хранит записи 30 дней. +1 день буфер на часовые пояса / поздний фикс статуса. +const RECORDS_LOOKBACK_DAYS = 31; const RECORDS_BATCH_LIMIT = 200; const RECORDS_PID_FILE = '/var/run/mts-records.pid'; $logger = new Logger('DownloadRecords', 'ModuleMtsPbx'); +// Перехватываем все необработанные исключения и фатальные ошибки — иначе +// MikoPBX может автоматически отключить модуль из-за ошибки в воркере крона. +set_exception_handler(static function (\Throwable $e) use ($logger) { + $logger->writeError([ + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ], 'Uncaught exception in downloadRecords.php'); + exit(1); +}); + function recordsProcessExists(): bool { $pid = posix_getpid(); @@ -75,6 +88,30 @@ function extractTrunkId(?string $chan): string } $since = (new DateTimeImmutable('-' . RECORDS_LOOKBACK_DAYS . ' days'))->format('Y-m-d H:i:s'); + +// Устаревшие pending (запись в MTS уже удалена по истечении хранения) — помечаем gone, +// чтобы они не висели вечно и не попадали в выборку при каждом запуске. +// Обновляем через модель: у модуля собственный SQLite-файл, глобальный DI 'db' указывает +// на основную БД MikoPBX и не знает про mts_cdr. +try { + $expired = CallHistory::find([ + "from_account = 'fs-mts' AND mts_rec_status = 'pending' AND start < :since:", + 'bind' => ['since' => $since], + ]); + $expiredCount = 0; + foreach ($expired as $row) { + $row->mts_rec_status = 'gone'; + if ($row->save()) { + $expiredCount++; + } + } + if ($expiredCount > 0) { + $logger->writeInfo("Marked {$expiredCount} expired pending records as gone (older than " . RECORDS_LOOKBACK_DAYS . " days)"); + } +} catch (\Throwable $e) { + $logger->writeError($e->getMessage(), 'Fail to mark expired pending records'); +} + $pendingCdrs = CallHistory::find([ "from_account = 'fs-mts' AND mts_rec_status = 'pending' AND mts_rec_dur > 0 AND start >= :since:", 'bind' => ['since' => $since], diff --git a/bin/synchCdr.php b/bin/synchCdr.php index 6c73438..0b2f9c0 100644 --- a/bin/synchCdr.php +++ b/bin/synchCdr.php @@ -35,11 +35,39 @@ // CallHistory ищется по linkedid (см. ниже) — повторная обработка идемпотентна. const MTS_SYNC_OVERLAP_MINUTES = 10; -$logger = new Logger('SyncCdr', 'ModuleMtsPbx'); +// Режим глубокой дозагрузки (--lookback=N): запрашиваем CDR за последние N минут, +// НЕ трогая offset. Нужен из-за длинных/отложенных звонков: CDR появляется в индексе +// MTS только после завершения звонка, когда основное скользящее окно уже ушло вперёд. +// Запускается отдельной cron-задачей реже, чем основной проход (см. MtsPbxConf::createCronTasks). +$deepLookbackMinutes = 0; +foreach ($argv as $arg) { + if (preg_match('/^--lookback=(\d+)$/', $arg, $m)) { + $deepLookbackMinutes = (int)$m[1]; + } +} +$isDeep = $deepLookbackMinutes > 0; + +$logger = new Logger($isDeep ? 'SyncCdrDeep' : 'SyncCdr', 'ModuleMtsPbx'); + +// Перехватываем все необработанные исключения — иначе MikoPBX может автоматически +// отключить модуль из-за ошибки в воркере крона. +set_exception_handler(static function (\Throwable $e) use ($logger) { + $logger->writeError([ + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ], 'Uncaught exception in synchCdr.php'); + exit(1); +}); + $haveError = false; function processExists():bool { $pid = posix_getpid(); + // Единый lock для основного и глубокого проходов: они НЕ должны работать одновременно, + // иначе при пересекающихся окнах возникают гонки (дубли строк mts_cdr, порча mp3). + // Глубокий проход короткий и редкий — кратковременная блокировка ежеминутного безвредна. $pidFile = "/var/run/mts-sync.pid"; $result = false; if(file_exists($pidFile)){ @@ -128,7 +156,12 @@ function logHttpException(Logger $logger, \Exception $e, string $label): void $date = new DateTime(); $date->modify('-30 day'); -if(empty($settings->offset)){ +if($isDeep){ + // Глубокая дозагрузка: фиксированное окно назад от текущего времени, offset не используется. + $dt = (new DateTime())->modify('-' . $deepLookbackMinutes . ' minutes'); + $startTime = $dt->format('Y-m-d\TH:i:s'); + $logger->writeInfo('Deep lookback sync from '.$startTime.' ('.$deepLookbackMinutes.'m, offset untouched)...'); +}elseif(empty($settings->offset)){ $logger->writeInfo('Offset is empty start sync -30 day...'); $startTime = $date->format('Y-m-d\TH:i:s'); }else{ @@ -260,9 +293,12 @@ function logHttpException(Logger $logger, \Exception $e, string $label): void } // Window synced successfully. Persist progress and move to next window. - $settings->offset = $windowEndTime; - $settings->save(); - $logger->writeInfo("Update offset {$windowEndTime}..."); + // В режиме глубокой дозагрузки offset не трогаем — им управляет основной проход. + if (!$isDeep) { + $settings->offset = $windowEndTime; + $settings->save(); + $logger->writeInfo("Update offset {$windowEndTime}..."); + } if ($windowEnd >= $now) { break; @@ -287,6 +323,15 @@ function logHttpException(Logger $logger, \Exception $e, string $label): void $logger->writeInfo("Parse CDRs..."); foreach ($fsData as $index => $cdr){ + // Глубокая дозагрузка добирает только ОТСУТСТВУЮЩИЕ звонки. Если звонок уже синхронизирован + // основным проходом — пропускаем целиком (без перекачки записи, без UPDATE и без повторной + // публикации в Beanstalk). Дозагрузкой записей к уже существующим CDR занимается downloadRecords.php. + if($isDeep){ + $exists = CallHistory::findFirst(['linkedid=:linkedid:', 'bind' => [ 'linkedid' => 'fs-mts-'.$cdr['callId']]]); + if($exists){ + continue; + } + } foreach (['via', 'an', 'dn'] as $key){ $cdr[$key] = $cdr[$key]??''; try { @@ -296,11 +341,16 @@ function logHttpException(Logger $logger, \Exception $e, string $label): void } } if(intval($cdr['rel']) === 1){ - // внутренний вызов + // Внутренний вызов между сотрудниками (без внешнего транка). + // Раньше блок не имел else и не задавал каналы: звонок проваливался в ветки ниже + // и получал случайную атрибуцию (а при пустом 'an' условие ''==='' ложно уводило + // его в «исходящий»). Задаём каналы явно и выходим из цепочки через elseif. $src = $cdr['an']; $dst = $cdr['dn']; + $src_chan = 'PJSIP/mts-'.$cdr['callId']; + $dst_chan = 'PJSIP/mts_'.$cdr['trunkId'].'-'.$cdr['callId']; $cdr['via'] = ''; - }if($cdr['via'] === $cdr['an']){ + }elseif($cdr['via'] === $cdr['an']){ // Исходящий с номера сотрудника. $src = $cdr['an']; $dst = $cdr['dn']; diff --git a/module.json b/module.json index 681b4be..dd468d3 100644 --- a/module.json +++ b/module.json @@ -4,9 +4,11 @@ "support_email": "help@miko.ru", "version": "%ModuleVersion%", "min_pbx_version": "2020.2.757", + "lic_product_id": 299, + "lic_feature_id": 70, "release_settings": { "publish_release": true, "changelog_enabled": true, "create_github_release": true } -} \ No newline at end of file +}