Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*`
Expand Down
3 changes: 3 additions & 0 deletions Lib/MtsPbxConf.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand Down
39 changes: 38 additions & 1 deletion bin/downloadRecords.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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],
Expand Down
64 changes: 57 additions & 7 deletions bin/synchCdr.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)){
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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'];
Expand Down
4 changes: 3 additions & 1 deletion module.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Loading