From f023203bbcd2198ec781c8d94a26caf267f521ba Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Tue, 21 Apr 2026 02:58:54 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20lookback=20=D0=B2=20=D0=B2=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=20=D0=B4=D0=BE=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При пересинхронизации за 2 месяца (после обновления модуля) pending-записи старше 7 дней навсегда оставались в очереди: воркер downloadRecords.php отсеивал их фильтром start >= now-7d, а synchCdr.php с overlap 10 мин их тоже не трогал — окно уходило вперёд. - Lookback расширен до 31 дня (MTS хранит записи 30 дней). - Записи со start старше 31 дня помечаются gone при каждом запуске — они в MTS уже физически удалены, нет смысла оставлять их pending. Найдено при тесте на 172.16.32.77: 2 pending от 2026-02-25/27 висели бесконечно при lookback=7. --- bin/downloadRecords.php | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/bin/downloadRecords.php b/bin/downloadRecords.php index deed3a8..f1845a8 100644 --- a/bin/downloadRecords.php +++ b/bin/downloadRecords.php @@ -18,7 +18,8 @@ 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'; @@ -75,6 +76,24 @@ function extractTrunkId(?string $chan): string } $since = (new DateTimeImmutable('-' . RECORDS_LOOKBACK_DAYS . ' days'))->format('Y-m-d H:i:s'); + +// Устаревшие pending (запись в MTS уже удалена по истечении хранения) — помечаем gone, +// чтобы они не висели вечно и не попадали в выборку при каждом запуске. +$expiredCount = CallHistory::find([ + "from_account = 'fs-mts' AND mts_rec_status = 'pending' AND start < :since:", + 'bind' => ['since' => $since], +])->count(); +if ($expiredCount > 0) { + $phalconDi = \Phalcon\Di::getDefault(); + $db = $phalconDi->getShared('db'); + $db->execute( + "UPDATE mts_cdr SET mts_rec_status = 'gone' " + . "WHERE from_account = 'fs-mts' AND mts_rec_status = 'pending' AND start < ?", + [$since] + ); + $logger->writeInfo("Marked {$expiredCount} expired pending records as gone (older than " . RECORDS_LOOKBACK_DAYS . " days)"); +} + $pendingCdrs = CallHistory::find([ "from_account = 'fs-mts' AND mts_rec_status = 'pending' AND mts_rec_dur > 0 AND start >= :since:", 'bind' => ['since' => $since], From 0c75b1cdd832ad964946a2ea3b86b075fdc0ae03 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Tue, 21 Apr 2026 18:56:04 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20no?= =?UTF-8?q?=20such=20table:=20mts=5Fcdr=20=D0=B2=20downloadRecords.php?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Воркер пытался выполнить raw UPDATE через глобальный DI 'db', который указывает на основную БД MikoPBX (astdb.db). Таблица mts_cdr живёт в отдельном SQLite-файле модуля (custom_modules/ModuleMtsPbx/db/module.db), поэтому запрос падал с PDOException. На клиенте ошибка приводила к автоматическому отключению модуля MikoPBX. - Пометка expired pending → gone теперь идёт через ORM (CallHistory::find + save), которая корректно роутит запрос в БД модуля. - Блок защищён локальным try/catch, чтобы случайная ошибка не падала весь воркер. - В оба cron-воркера (synchCdr.php, downloadRecords.php) добавлен set_exception_handler — любая необработанная ошибка теперь логируется и завершает скрипт штатно, а не оставляет module-monitor MikoPBX поводом отключить модуль. Воспроизведение и фикс проверены на 172.16.32.77. --- bin/downloadRecords.php | 44 +++++++++++++++++++++++++++++------------ bin/synchCdr.php | 13 ++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/bin/downloadRecords.php b/bin/downloadRecords.php index f1845a8..e6389b3 100644 --- a/bin/downloadRecords.php +++ b/bin/downloadRecords.php @@ -25,6 +25,18 @@ $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(); @@ -79,19 +91,25 @@ function extractTrunkId(?string $chan): string // Устаревшие pending (запись в MTS уже удалена по истечении хранения) — помечаем gone, // чтобы они не висели вечно и не попадали в выборку при каждом запуске. -$expiredCount = CallHistory::find([ - "from_account = 'fs-mts' AND mts_rec_status = 'pending' AND start < :since:", - 'bind' => ['since' => $since], -])->count(); -if ($expiredCount > 0) { - $phalconDi = \Phalcon\Di::getDefault(); - $db = $phalconDi->getShared('db'); - $db->execute( - "UPDATE mts_cdr SET mts_rec_status = 'gone' " - . "WHERE from_account = 'fs-mts' AND mts_rec_status = 'pending' AND start < ?", - [$since] - ); - $logger->writeInfo("Marked {$expiredCount} expired pending records as gone (older than " . RECORDS_LOOKBACK_DAYS . " days)"); +// Обновляем через модель: у модуля собственный 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([ diff --git a/bin/synchCdr.php b/bin/synchCdr.php index 6c73438..ddf4571 100644 --- a/bin/synchCdr.php +++ b/bin/synchCdr.php @@ -36,6 +36,19 @@ const MTS_SYNC_OVERLAP_MINUTES = 10; $logger = new Logger('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 { From 50c6f4b1552e7ef61a1736e124df97e083700e05 Mon Sep 17 00:00:00 2001 From: boffart <5922739+boffart@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:10:25 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B3=D0=BB=D1=83=D0=B1=D0=BE=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=20=D0=B4=D0=BE=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20CDR=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B4=D0=BB=D0=B8=D0=BD=D0=BD=D1=8B=D1=85/=D0=BE=D1=82?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причина: окно синхронизации привязано к времени начала звонка, а CDR появляется в индексе MTS только после завершения звонка. Длинные звонки (дольше перекрытия в 10 минут) и звонки с отложенной индексацией терялись: к моменту их появления основное окно уже уходило вперёд. Изменения: - synchCdr.php: режим --lookback=N — запрашивает CDR за последние N минут, не двигая offset (им управляет основной проход). - MtsPbxConf.php: вторая cron-задача */30 с --lookback=720 (12 часов). Учтены замечания код-ревью: - Единый pid-lock для основного и глубокого проходов: они не должны работать одновременно, иначе при пересекающихся окнах возникают гонки (дубли строк mts_cdr из-за неатомарного find-or-create, порча mp3 из-за параллельной записи в один файл). - Глубокий проход добирает только отсутствующие звонки: существующие пропускаются целиком (без перекачки записи, UPDATE и повторной публикации в Beanstalk). - Исправлен предсуществующий баг классификации внутренних звонков (rel===1): блок без else проваливался в ветки ниже и давал неверную атрибуцию каналов; при пустом 'an' условие ''==='' ложно уводило звонок в «исходящий». Ветки сделаны взаимоисключающими, каналы заданы явно. --- CLAUDE.md | 2 +- Lib/MtsPbxConf.php | 3 +++ bin/synchCdr.php | 51 +++++++++++++++++++++++++++++++++++++++------- 3 files changed, 48 insertions(+), 8 deletions(-) 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/synchCdr.php b/bin/synchCdr.php index ddf4571..0b2f9c0 100644 --- a/bin/synchCdr.php +++ b/bin/synchCdr.php @@ -35,7 +35,19 @@ // 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 может автоматически // отключить модуль из-за ошибки в воркере крона. @@ -53,6 +65,9 @@ function processExists():bool { $pid = posix_getpid(); + // Единый lock для основного и глубокого проходов: они НЕ должны работать одновременно, + // иначе при пересекающихся окнах возникают гонки (дубли строк mts_cdr, порча mp3). + // Глубокий проход короткий и редкий — кратковременная блокировка ежеминутного безвредна. $pidFile = "/var/run/mts-sync.pid"; $result = false; if(file_exists($pidFile)){ @@ -141,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{ @@ -273,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; @@ -300,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 { @@ -309,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']; From aba0176e2075427b92f437da05cea986fa41726e Mon Sep 17 00:00:00 2001 From: boffart <5922739+boffart@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:08:43 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BE=20=D0=BB=D0=B8=D1=86=D0=B5=D0=BD=D0=B7=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 +}