From 89b45658fe82d3707433c1f41a4f7e9bbb65bcb5 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Thu, 30 Apr 2026 14:21:53 +0300 Subject: [PATCH 01/20] =?UTF-8?q?=D0=9A=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20CALL=5FID=20=D0=B2=20batch-=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=D1=85=20finish/attachRecor?= =?UTF-8?q?d/show/hide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Когда register и finish (или attachRecord) уходят одним batch в Bitrix24, ранее в payload подставлялся "голый" ключ register-метода (telephony.externalcall.register_...) вместо batch-ссылки $result[][CALL_ID]. Bitrix24 не разрешал эту псевдо-ссылку и отвечал "Call is not found (call should be registered prior to finishing)" — звонки оставались в журнале телефонии без длительности и без записи разговора, хотя лиды успешно создавались. Добавлен хелпер Bitrix24Integration::resolveBatchCallId(), который оборачивает register-ключ в $result[...][CALL_ID] и пропускает уже разрешённые/реальные значения. Хелпер применяется в telephonyExternalCallFinish, telephonyExternalCallAttachRecord, telephonyExternalCallHide, telephonyExternalCallShow, а также в WorkerBitrix24IntegrationHTTP — там ручная обёртка $result[...] заменена на единый вызов хелпера. --- Lib/Bitrix24Integration.php | 29 +++++++++++++++++++++++++++ bin/WorkerBitrix24IntegrationHTTP.php | 9 +-------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/Lib/Bitrix24Integration.php b/Lib/Bitrix24Integration.php index 04df39b..9061749 100644 --- a/Lib/Bitrix24Integration.php +++ b/Lib/Bitrix24Integration.php @@ -1291,6 +1291,30 @@ protected function fillPropertyValues($options, array &$params): void } } + /** + * Готовит CALL_ID для batch-параметра. Если значение — ключ register-метода + * (register ещё не выполнился, реального CALL_ID нет), оборачивает его в + * $result[][CALL_ID], чтобы Bitrix24 разрешил ссылку на результат + * предыдущего вызова в этой же транзакции. Уже разрешённые/реальные значения + * (externalCall.xxx или $result[...]) возвращаются без изменений. + * + * @param string $callId + * @return string + */ + public function resolveBatchCallId(string $callId): string + { + if ($callId === '') { + return $callId; + } + if (stripos($callId, '$result[') === 0) { + return $callId; + } + if (stripos($callId, self::API_CALL_REGISTER) === 0) { + return '$result['.$callId.'][CALL_ID]'; + } + return $callId; + } + /** * Регистрация факта завершения звонка в b24. * @param $options @@ -1350,6 +1374,8 @@ public function telephonyExternalCallFinish($options, &$tmpCallsData): array // /////////////////////////////////////////////////////////////// $userId = (intval($callDataFromDB['answer']??0) === 1) ? $callDataFromDB['user_id']??'' : ''; + // Если register уйдёт в одном batch — finish должен ссылаться на его результат. + $callId = $this->resolveBatchCallId((string)$callId); $params = [ 'CALL_ID' => $callId, 'USER_ID' => $userId, @@ -1646,6 +1672,7 @@ private function telephonyExternalCallAttachRecord($filename, $callId, &$arg): v if (!in_array($ext, ['mp3', 'wav'], true)) { $FILENAME = pathinfo($FILENAME, PATHINFO_FILENAME) . '.mp3'; } + $callId = $this->resolveBatchCallId((string)$callId); $params = [ 'CALL_ID' => $callId, 'FILENAME' => $FILENAME, @@ -1692,6 +1719,7 @@ public function telephonyExternalCallHide($options): array 'auth' => $this->getAccessToken(), ]; $this->fillPropertyValues($options, $params); + $params['CALL_ID'] = $this->resolveBatchCallId((string)$params['CALL_ID']); $arg = []; $arg[self::API_CALL_HIDE.'_'.$options['linkedid'].'_'.uniqid('', true)] = self::API_CALL_HIDE.'?' . http_build_query($params); @@ -1714,6 +1742,7 @@ public function telephonyExternalCallShow($options): array 'auth' => $this->getAccessToken(), ]; $this->fillPropertyValues($options, $params); + $params['CALL_ID'] = $this->resolveBatchCallId((string)$params['CALL_ID']); $arg = []; $arg[self::API_CALL_SHOW.$options['linkedid'].'_'.uniqid('', true)] = self::API_CALL_SHOW.'?' . http_build_query($params); diff --git a/bin/WorkerBitrix24IntegrationHTTP.php b/bin/WorkerBitrix24IntegrationHTTP.php index e1b9e15..0e4a0ea 100644 --- a/bin/WorkerBitrix24IntegrationHTTP.php +++ b/bin/WorkerBitrix24IntegrationHTTP.php @@ -259,17 +259,10 @@ private function addDataToQueue(array $data): void // Это Метод register $callId = $key; } - }elseif(stripos($callId,Bitrix24Integration::API_CALL_REGISTER) === false){ - $this->tmpCallsData[$data['linkedid']]['ARG_REGISTER_USER_'.$data['UNIQUEID']] = $data['USER_ID']??''; - $this->tmpCallsData[$data['linkedid']]['ARGS_REGISTER_'.$data['UNIQUEID']] = $this->b24->telephonyExternalCallRegister($data); - $data['CALL_ID'] = $callId; - if($this->needShowCardDirectly($data['USER_ID'])){ - $arg = $this->b24->telephonyExternalCallShow($data); - } }else{ $this->tmpCallsData[$data['linkedid']]['ARG_REGISTER_USER_'.$data['UNIQUEID']] = $data['USER_ID']??''; $this->tmpCallsData[$data['linkedid']]['ARGS_REGISTER_'.$data['UNIQUEID']] = $this->b24->telephonyExternalCallRegister($data); - $data['CALL_ID'] = '$result['.$callId.'][CALL_ID]'; + $data['CALL_ID'] = $this->b24->resolveBatchCallId((string)$callId); if($this->needShowCardDirectly($data['USER_ID'])){ $arg = $this->b24->telephonyExternalCallShow($data); } From 8027984fd24c00b4e2492b36fc7a2cd76f9016c0 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Thu, 30 Apr 2026 14:33:50 +0300 Subject: [PATCH 02/20] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B0=D1=80=D1=88=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=B0=20voicemail=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B1=D0=B5=D0=B7=20UF=5FPHONE=5FIN?= =?UTF-8?q?NER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При синхронизации пользователей все «безномерные» (UF_PHONE_INNER, PERSONAL_MOBILE, WORK_PHONE пустые или нечисловые) индексировались в inner_numbers/mobile_numbers под общим пустым ключом и перезаписывали друг друга — оставался последний обработанный. Любой dst_num, который после getPhoneIndex превращался в '' (например 'voicemail'), приземлялся именно на этого «случайного» пользователя: модуль слал register с его USER_ID, и Bitrix24 создавал новый лид, игнорируя ответственного из карточки существующего контакта. — Lib/Bitrix24Integration.php: пропускаем запись с пустым ключом при наполнении inner_numbers и mobile_numbers. — WorkerBitrix24IntegrationAMI::getInnerNum: ранний return на пустом входе и на пустом результате getPhoneIndex. — WorkerBitrix24IntegrationAMI::actionCompleteCdr: голосовая почта формально ANSWERED, но для CRM это пропущенный входящий — переопределяем isMissed=true, чтобы звонок ушёл на responsibleMissedCalls (если задан) вместо случайного пользователя. --- Lib/Bitrix24Integration.php | 27 ++++++++++++++++++++------- bin/WorkerBitrix24IntegrationAMI.php | 16 +++++++++++++++- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/Lib/Bitrix24Integration.php b/Lib/Bitrix24Integration.php index 9061749..fefd379 100644 --- a/Lib/Bitrix24Integration.php +++ b/Lib/Bitrix24Integration.php @@ -875,19 +875,32 @@ public function b24GetPhones($user_id = null): array $user['EMAIL'] = $value['EMAIL'] ?? ''; $user['UF_PHONE_INNER'] = preg_replace('/(\D)/', '', $value['UF_PHONE_INNER']??''); + // Не индексируем пользователей с пустым ключом: иначе все «безномерные» + // (UF_PHONE_INNER/PERSONAL_MOBILE/WORK_PHONE пустой или нечисловой) + // перезаписывают друг друга под ключом "" и любой звонок без + // явного маппинга (например, на voicemail) будет приземляться + // на «последнего созданного пользователя без UF_PHONE_INNER». if (!empty($user['PERSONAL_MOBILE'])) { - $mobile_key = self::getPhoneIndex($user['PERSONAL_MOBILE']); - $this->mobile_numbers[$mobile_key] = $user; + $mobile_key = self::getPhoneIndex($user['PERSONAL_MOBILE']); + if ($mobile_key !== '') { + $this->mobile_numbers[$mobile_key] = $user; + } } if (!empty($value['WORK_PHONE'])) { - $mobile_key = self::getPhoneIndex($user['WORK_PHONE']); - $this->mobile_numbers[$mobile_key] = $user; + $mobile_key = self::getPhoneIndex($user['WORK_PHONE']); + if ($mobile_key !== '') { + $this->mobile_numbers[$mobile_key] = $user; + } } if(isset($pbx_numbers[$user['UF_PHONE_INNER']])){ - $mobile_key = self::getPhoneIndex($pbx_numbers[$user['UF_PHONE_INNER']]); - $this->mobile_numbers[$mobile_key] = $user; + $mobile_key = self::getPhoneIndex($pbx_numbers[$user['UF_PHONE_INNER']]); + if ($mobile_key !== '') { + $this->mobile_numbers[$mobile_key] = $user; + } + } + if (!empty($user['UF_PHONE_INNER'])) { + $this->inner_numbers[$user['UF_PHONE_INNER']] = $user; } - $this->inner_numbers[$user['UF_PHONE_INNER']] = $user; $this->b24Users[$value['ID']] = $user['UF_PHONE_INNER']; } diff --git a/bin/WorkerBitrix24IntegrationAMI.php b/bin/WorkerBitrix24IntegrationAMI.php index d8c1cc7..3e71f9c 100644 --- a/bin/WorkerBitrix24IntegrationAMI.php +++ b/bin/WorkerBitrix24IntegrationAMI.php @@ -627,6 +627,12 @@ private function actionCompleteCdr($data):void $responsible = ''; $isMissed = ($data['GLOBAL_STATUS'] ?? '') !== 'ANSWERED'; + // Голосовая почта формально ANSWERED, но для CRM это пропущенный + // входящий: реального оператора не было — направляем на ответственного + // по умолчанию (responsibleMissedCalls), а не на «случайного» пользователя. + if (!$isOutgoing && ($dstNum === 'voicemail' || ($data['dst_chan'] ?? '') === 'VOICEMAIL')) { + $isMissed = true; + } $finishKeyID = 'finish-cdr-'.$uniqueId; if(!empty($USER_ID) && !$isMissed) { @@ -725,8 +731,16 @@ public function actionDialCreateChan($data):void{ */ private function getInnerNum(string $number):string { - $userId = ''; + if ($number === '') { + return ''; + } $number = Bitrix24Integration::getPhoneIndex($number); + // После getPhoneIndex нечисловой ввод (например 'voicemail') превращается + // в '' — обращение по такому ключу подцепило бы «безномерных» пользователей. + if ($number === '') { + return ''; + } + $userId = ''; if(isset($this->inner_numbers[$number])){ $userId = $this->inner_numbers[$number]['ID']; } elseif(isset($this->b24->mobile_numbers[$number])){ From 0bae3988c4ebb30d3687bf351305fd493906c310 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Mon, 4 May 2026 14:20:05 +0300 Subject: [PATCH 03/20] =?UTF-8?q?=D0=97=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BE=D1=82=20OOM=20=D0=B2=20HTTP-=D0=B2=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=20=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20=D1=83=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D0=BD=D1=8C=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решает issue #135 (Sentry MIKOPBX-MH7): WorkerBitrix24IntegrationHTTP падал с OOM на 128M при логировании batch-ответов crm.*.list. - Logger: уровни NONE/ERROR/INFO/DEBUG, лимит 512KB на запись, убран лишний urldecode (двойная копия payload в памяти). - Bitrix24Integration::query(): для list-команд на INFO пишется только summary (count/first_id/last_id/result_next), полный payload — на DEBUG. - WorkerBitrix24IntegrationHTTP: memory_limit поднят до 256M. - Web-форма: выбор уровня логирования на вкладке "Прочее"; применяется без рестарта при следующем updateSettings. --- App/Forms/ModuleBitrix24IntegrationForm.php | 11 ++ App/Views/index.volt | 5 + Lib/Bitrix24Integration.php | 62 ++++++++- Lib/Logger.php | 137 +++++++++++++++++--- Messages/en.php | 7 + Messages/ru.php | 7 + Models/ModuleBitrix24Integration.php | 25 ++++ Setup/PbxExtensionSetup.php | 3 + bin/ConnectorDb.php | 4 + bin/UploaderB24.php | 3 + bin/WorkerBitrix24IntegrationAMI.php | 3 + bin/WorkerBitrix24IntegrationHTTP.php | 4 + 12 files changed, 249 insertions(+), 22 deletions(-) diff --git a/App/Forms/ModuleBitrix24IntegrationForm.php b/App/Forms/ModuleBitrix24IntegrationForm.php index cadd787..9ec6194 100644 --- a/App/Forms/ModuleBitrix24IntegrationForm.php +++ b/App/Forms/ModuleBitrix24IntegrationForm.php @@ -99,6 +99,17 @@ public function initialize($entity = null, $options = null): void 'useEmpty' => false, 'class' => 'ui selection dropdown b24_regions-select', ])); + + $logLevels = []; + foreach (ModuleBitrix24Integration::getAvailableLogLevels() as $level) { + $logLevels[$level] = $this->translation->_('mod_b24_i_logLevel_' . $level); + } + $this->add(new Select('logLevel', $logLevels, [ + 'using' => ['id', 'name'], + 'value' => empty($entity->logLevel) ? ModuleBitrix24Integration::LOG_LEVEL_INFO : $entity->logLevel, + 'useEmpty' => false, + 'class' => 'ui selection dropdown', + ])); } /** * Adds a checkbox to the form field with the given name. diff --git a/App/Views/index.volt b/App/Views/index.volt index 6350470..288e367 100644 --- a/App/Views/index.volt +++ b/App/Views/index.volt @@ -34,6 +34,11 @@ +
+ + {{ form.render('logLevel') }} +
{{ t._('mod_b24_i_logLevelHint') }}
+
+ {% if isMtsModuleInstalled %} +
+
+ {{ form.render('import_mts_calls') }} + +
+
+ {% endif %}
{{ form.render('logLevel') }} diff --git a/Lib/Bitrix24IntegrationConf.php b/Lib/Bitrix24IntegrationConf.php index 5f2c83a..d6cf6de 100644 --- a/Lib/Bitrix24IntegrationConf.php +++ b/Lib/Bitrix24IntegrationConf.php @@ -56,7 +56,7 @@ public function modelsEventChangeData($data): void { if ($data['model'] === ModuleBitrix24Integration::class) { $changedFields = count($data['changedFields']); - $syncKeys = ['lastLeadId', 'lastCompanyId', 'lastContactId', 'lastDealId', 'session']; + $syncKeys = ['lastLeadId', 'lastCompanyId', 'lastContactId', 'lastDealId', 'session', 'mts_import_last_id']; if ($changedFields === 1 && in_array($data['changedFields'][0],$syncKeys, true)) { return; } @@ -250,5 +250,6 @@ public function createCronTasks(array &$tasks): void $phpPath = Util::which('php'); $tasks[] = "*/1 * * * * $phpPath -f {$this->moduleDir}/bin/safe.php > /dev/null 2>&1".PHP_EOL; + $tasks[] = "*/5 * * * * $phpPath -f {$this->moduleDir}/bin/MtsImporter.php > /dev/null 2>&1".PHP_EOL; } } \ No newline at end of file diff --git a/Messages/en.php b/Messages/en.php index 2196340..00a2e10 100644 --- a/Messages/en.php +++ b/Messages/en.php @@ -22,6 +22,7 @@ 'mod_b24_i_Auth' => 'Login to Bitrix24', 'mod_b24_i_NotifyOnCall' => 'Upload call history to the portal', 'mod_b24_i_ExportRecords' => 'To upload on the portal copies of records of conversations', + 'mod_b24_i_ImportMtsCalls' => 'Import call history from the «MTS Auto-secretary 2.0» module', 'mod_b24_i_UpdateStatus' => 'Getting status', 'mod_b24_i_Connected' => 'Connected to Bitrix24', 'mod_b24_i_Disconnected' => 'Disconnected or unauthorized', diff --git a/Messages/ru.php b/Messages/ru.php index 55e878d..4bc7fa0 100644 --- a/Messages/ru.php +++ b/Messages/ru.php @@ -24,6 +24,7 @@ 'mod_b24_i_NotifyOnCall' => 'Загружать на портал историю звонков', 'mod_b24_i_CrmCreate' => 'Создавать в CRM сущности, связанные со звонком (Лид / сделка / контакт).', 'mod_b24_i_ExportRecords' => 'Загружать на портал копии записей разговоров', + 'mod_b24_i_ImportMtsCalls' => 'Импорт истории звонков из модуля «МТС Автосекретарь 2.0»', 'mod_b24_i_StatusError' => 'Ошибка работы модуля', 'mod_b24_i_Connected' => 'Авторизован', 'mod_b24_i_Connected_v2' => 'Авторизован', diff --git a/Models/ModuleBitrix24Integration.php b/Models/ModuleBitrix24Integration.php index 11861b6..c38e872 100644 --- a/Models/ModuleBitrix24Integration.php +++ b/Models/ModuleBitrix24Integration.php @@ -160,6 +160,20 @@ public static function getAvailableLogLevels(): array */ public $logLevel = 'INFO'; + /** + * Импортировать историю звонков из ModuleMtsPbx в Bitrix24. + * + * @Column(type="integer", nullable=true, default="0") + */ + public $import_mts_calls = '0'; + + /** + * Курсор (mts_cdr.id) последней записи, поставленной в очередь импорта. + * + * @Column(type="integer", nullable=true, default="0") + */ + public $mts_import_last_id = '0'; + public function initialize(): void { diff --git a/bin/ConnectorDb.php b/bin/ConnectorDb.php index 66b27f8..c7d3bab 100644 --- a/bin/ConnectorDb.php +++ b/bin/ConnectorDb.php @@ -50,6 +50,7 @@ class ConnectorDb extends WorkerBase public const FUNC_FIND_CDR_BY_UID = "findCdrByUID"; public const FUNC_UPDATE_CDR_BY_UID = "updateCdrByUID"; public const FUNC_GET_CDR_BY_LINKED_ID = "getCdrDataByLinkedId"; + public const FUNC_GET_EXPORTED_CALL_ID = "getExportedCallIdByLinkedId"; public const FUNC_GET_CDR_BY_FILTER = "getCdrByFilter"; public const FUNC_UPDATE_FROM_ARRAY_CDR_BY_UID = "updateCdrFromArrayByUID"; public const FUNC_SAVE_EXTERNAL_LINES = "saveExternalLinesData"; @@ -78,6 +79,7 @@ class ConnectorDb extends WorkerBase 'findCdrByUID' => 2, 'updateCdrByUID' => 2, 'getCdrDataByLinkedId' => 2, + 'getExportedCallIdByLinkedId' => 2, 'getCdrByFilter' => 2, 'updateCdrFromArrayByUID' => 2, 'updateGeneralSettings' => 10, @@ -1184,6 +1186,37 @@ private function getCdrDataByLinkedId(array $options): array return $result; } + /** + * Идемпотентная проверка для импорта исторических звонков: + * возвращает ['call_id' => ''] если звонок с таким linkedid + * уже был успешно отправлен в Bitrix24, или [] в противном случае. + * + * Отличие от getCdrDataByLinkedId: нет фильтра по UNIQUEID (leg-семантика + * не применима к импорту) и нет fallback на «первый попавшийся row». + * Возвращается ровно один ряд с непустым call_id; если такого нет — + * пустой массив. + * + * Результат обёрнут в массив, потому что unpackResult всегда требует + * массив от Beanstalk-RPC. + * + * @param string $linkedid + * @return array + */ + private function getExportedCallIdByLinkedId(string $linkedid): array + { + if ($linkedid === '') { + return []; + } + $row = ModuleBitrix24CDR::findFirst([ + 'conditions' => "linkedid = :linkedid: AND call_id IS NOT NULL AND call_id <> ''", + 'bind' => ['linkedid' => $linkedid], + ]); + if (!$row) { + return []; + } + return ['call_id' => (string)$row->call_id]; + } + /** * Возвращает все CDR по фильтру. * @param array $filter diff --git a/bin/MtsImporter.php b/bin/MtsImporter.php new file mode 100644 index 0000000..826a04e --- /dev/null +++ b/bin/MtsImporter.php @@ -0,0 +1,240 @@ +. + */ + +namespace Modules\ModuleBitrix24Integration\bin; +require_once 'Globals.php'; + +use MikoPBX\Modules\PbxExtensionUtils; +use Modules\ModuleBitrix24Integration\Lib\Bitrix24InvokeRest; +use Modules\ModuleBitrix24Integration\Lib\Logger; + +// ВАЖНО: модель ModuleMtsPbx\Models\CallHistory НЕ импортируется через `use`. +// Если соседний модуль не установлен, любое упоминание этого класса (включая +// resolve через use) приведёт к ошибке автозагрузки. Доступ — только через +// FQCN-строку после class_exists(). +const MTS_CALL_HISTORY_FQCN = '\\Modules\\ModuleMtsPbx\\Models\\CallHistory'; + +const MAX_PER_RUN = 300; +/** + * Размер порции, посылаемой одним invoke в HTTP-воркер. + * ВАЖНО: значение должно совпадать с + * WorkerBitrix24IntegrationHTTP::MAX_HISTORICAL_CALLS_PER_INVOKE — воркер + * срезает по своему потолку, и при рассинхроне часть звонков молча + * отбрасывается. Если меняете — поднимите оба. + */ +const BATCH_SIZE = 10; +const MIN_PAUSE = 2; +const MAX_PAUSE = 5; + +$logger = new Logger('MtsImporter', 'ModuleBitrix24Integration'); + +if (!PbxExtensionUtils::isEnabled('ModuleBitrix24Integration')) { + exit(0); +} + +// Защита от повторного запуска (cron каждые 5 мин, скрипт может занять до ~150 сек). +$lockFile = ConnectorDb::getTempDir() . '/mts_importer.lock'; +$lockHandle = @fopen($lockFile, 'c'); +if (!$lockHandle || !@flock($lockHandle, LOCK_EX | LOCK_NB)) { + // Уже работает другой инстанс — тихо выходим. + if ($lockHandle) { + @fclose($lockHandle); + } + exit(0); +} +register_shutdown_function(static function () use (&$lockHandle, $lockFile) { + if (is_resource($lockHandle)) { + @flock($lockHandle, LOCK_UN); + @fclose($lockHandle); + } + @unlink($lockFile); +}); + +// Проверяем доступность модели соседнего модуля. +$callHistoryClass = MTS_CALL_HISTORY_FQCN; +if (!class_exists($callHistoryClass)) { + // ModuleMtsPbx не установлен — это штатная ситуация. Тихо выходим. + exit(0); +} + +$settings = ConnectorDb::invoke(ConnectorDb::FUNC_GET_GENERAL_SETTINGS); +if (!is_array($settings) || empty($settings)) { + $logger->writeError('Settings unavailable, exiting'); + exit(0); +} + +if (((string)($settings['import_mts_calls'] ?? '0')) !== '1') { + // Опция выключена. + exit(0); +} + +$cursor = (int)($settings['mts_import_last_id'] ?? 0); +$crmCreateFlag = (((string)($settings['crmCreateLead'] ?? '0')) === '1') ? '1' : '0'; +$exportRecordsFlag = (((string)($settings['export_records'] ?? '0')) === '1') ? '1' : '0'; + +$logger->writeInfo( + [ + 'cursor' => $cursor, + 'crm_create' => $crmCreateFlag, + 'export_records' => $exportRecordsFlag, + ], + 'MTS importer started' +); + +$invoker = new Bitrix24InvokeRest(); +$processed = 0; +$todayStart = (new \DateTime())->setTime(0, 0)->format('Y-m-d H:i:s'); + +while ($processed < MAX_PER_RUN) { + $rows = $callHistoryClass::find([ + 'id > :id: OR start >= :start:', + 'bind' => [ + 'id' => $cursor, + 'start' => $todayStart, + ], + 'order' => 'id', + 'limit' => BATCH_SIZE, + ])->toArray(); + + if (empty($rows)) { + break; + } + + $batch = []; + $cursorCandidate = $cursor; // курсор сдвинется только после успешного invoke + $stopAtPending = false; + foreach ($rows as $row) { + $rowId = (int)($row['id'] ?? 0); + $recStatus = (string)($row['mts_rec_status'] ?? ''); + // Pending — отправлять преждевременно. Откладываем (не сдвигаем курсор, + // если запись из «новой» части, id > cursor; повторно попадёт в + // следующем тике через ветку фильтра). + if ($recStatus === 'pending' && $rowId > $cursor) { + $logger->writeInfo( + ['id' => $rowId, 'linkedid' => $row['linkedid'] ?? ''], + 'Skip pending without advancing cursor' + ); + $stopAtPending = true; + break; + } + + // Чужие источники в mts_cdr — пропускаем без отправки в B24, но курсор двигаем. + $fromAccount = (string)($row['from_account'] ?? ''); + if ($fromAccount !== 'fs-mts') { + if ($rowId > $cursorCandidate) { + $cursorCandidate = $rowId; + } + continue; + } + + $payload = [ + 'linkedid' => (string)($row['linkedid'] ?? ''), + 'src_num' => (string)($row['src_num'] ?? ''), + 'dst_num' => (string)($row['dst_num'] ?? ''), + 'did' => (string)($row['did'] ?? ''), + 'start' => (string)($row['start'] ?? ''), + 'duration' => (string)($row['duration'] ?? '0'), + 'disposition' => (string)($row['disposition'] ?? 'NOANSWER'), + 'recordingfile' => (string)($row['recordingfile'] ?? ''), + 'mts_rec_status' => $recStatus, + 'from_account' => $fromAccount, + 'crm_create' => $crmCreateFlag, + 'export_records_setting'=> $exportRecordsFlag, + ]; + $batch[] = $payload; + if ($rowId > $cursorCandidate) { + $cursorCandidate = $rowId; + } + } + + $invokeStatus = WorkerBitrix24IntegrationHTTP::IMPORT_ACK_OK; + if (!empty($batch)) { + try { + $ack = $invoker->invoke('importHistoricalCalls', ['calls' => $batch], 5); + } catch (\Throwable $e) { + $logger->writeError( + ['error' => $e->getMessage(), 'batch_size' => count($batch)], + 'Invoke failed, stopping run' + ); + // Не двигаем курсор; следующий тик cron'а вернётся к этой партии. + break; + } + $invokeStatus = (string)($ack['status'] ?? ''); + if ($invokeStatus === WorkerBitrix24IntegrationHTTP::IMPORT_ACK_NOT_READY) { + // HTTP-воркер сообщил, что его карты пользователей B24 пусты + // (например, после рестарта). НЕ двигаем курсор, ждём следующего тика. + $logger->writeInfo( + ['batch_size' => count($batch)], + 'HTTP worker not ready, leaving cursor untouched' + ); + break; + } + if ($invokeStatus !== WorkerBitrix24IntegrationHTTP::IMPORT_ACK_OK) { + $logger->writeError( + ['ack' => $ack, 'batch_size' => count($batch)], + 'Unexpected ack from HTTP worker, stopping run' + ); + break; + } + $processed += count($batch); + } + + // Курсор продвигаем только если HTTP-воркер подтвердил приём + // (или партия была пустой — все записи фильтровались как «чужие»). + if ($cursorCandidate > $cursor) { + $cursor = $cursorCandidate; + ConnectorDb::invoke( + ConnectorDb::FUNC_UPDATE_GENERAL_SETTINGS, + [['mts_import_last_id' => $cursor]], + false + ); + } + + if ($stopAtPending) { + // Перед нами «застрявшая» pending-запись; продолжать выборку + // бессмысленно (тот же фильтр повторно её вернёт). Завершаем прогон. + break; + } + if ($processed >= MAX_PER_RUN) { + break; + } + if (count($rows) < BATCH_SIZE) { + // Получили меньше лимита — данных больше нет. + break; + } + if (empty($batch) && $cursorCandidate === $cursor) { + // Защита от бесконечного цикла: вернулись BATCH_SIZE рядов из + // ветки «start >= today» с id <= cursor (например, не fs-mts), + // курсор не сдвинулся и сдвинуть не сможем. Прерываем прогон. + $logger->writeInfo( + ['cursor' => $cursor, 'rows_fetched' => count($rows)], + 'No progress possible (all rows filtered, cursor at ceiling), stopping run' + ); + break; + } + if (empty($batch)) { + // Все строки этой выборки были отфильтрованы как «чужие» — курсор + // уже двинут на rowId последней записи; следующая итерация + // продолжит с этого места. Пауза не нужна. + continue; + } + sleep(random_int(MIN_PAUSE, MAX_PAUSE)); +} + +$logger->writeInfo(['processed' => $processed, 'cursor' => $cursor], 'MTS importer finished'); diff --git a/bin/WorkerBitrix24IntegrationHTTP.php b/bin/WorkerBitrix24IntegrationHTTP.php index d034b80..4a17170 100644 --- a/bin/WorkerBitrix24IntegrationHTTP.php +++ b/bin/WorkerBitrix24IntegrationHTTP.php @@ -30,6 +30,20 @@ class WorkerBitrix24IntegrationHTTP extends WorkerBase { + /** + * Максимум исторических звонков в одном invoke. 10×2 cmd (register+finish) + * = 20 элементов, безопасно для лимита B24 batch в 50 cmd. + * + * ВАЖНО: значение должно совпадать с BATCH_SIZE в bin/MtsImporter.php — + * cron-импортёр шлёт пачками по BATCH_SIZE, а воркер срезает по этому + * потолку. Если меняете одно — поднимите оба. + */ + public const MAX_HISTORICAL_CALLS_PER_INVOKE = 10; + + /** Статусы ACK для action 'importHistoricalCalls'. */ + public const IMPORT_ACK_OK = 'ok'; + public const IMPORT_ACK_NOT_READY = 'not_ready'; + private Bitrix24Integration $b24; private $pidSyncProcContacts; private $timeSyncProcContacts; @@ -164,6 +178,11 @@ public function invokeRest($client): void $arg = $this->b24->getScopeAsync($data['inbox_tube']??''); }elseif($action === 'needRestart'){ $this->needRestart = true; + }elseif($action === 'importHistoricalCalls'){ + $this->handleImportHistoricalCalls( + $data['args']['calls'] ?? [], + (string)($data['inbox_tube'] ?? '') + ); } if(!empty($arg)){ $this->b24->mainLogger->writeInfo($data, "Add action $action in queue..."); @@ -171,6 +190,328 @@ public function invokeRest($client): void } } + /** + * Публикует ответ инициатору invoke (Bitrix24InvokeRest::invoke). + * Без публикации клиент будет ждать timeout и логировать ошибку, поэтому + * для всех веток `invokeRest` с указанным inbox_tube нужно отправлять + * хотя бы пустой/статусный ACK. + * + * @param string $tube Имя tube'а из payload (data['inbox_tube']). + * @param array $body Тело ответа: например ['status' => self::IMPORT_ACK_OK]. + */ + private function publishInvokeAck(string $tube, array $body): void + { + if ($tube === '') { + return; + } + $resFile = ConnectorDb::saveResultInTmpFile($body); + if ($resFile === '') { + return; + } + $this->queueAgent->publish($resFile, $tube); + } + + /** + * Импорт пачки исторических звонков (например, из ModuleMtsPbx). + * Каждый звонок проходит штатный путь register+finish и попадает в общую + * очередь $this->q_req, которая batch'ем уходит в Bitrix24 через sendBatch(). + * + * Жёсткий потолок MAX_HISTORICAL_CALLS_PER_INVOKE звонков на один invoke: + * 10×2 cmd = 20 элементов в batch, безопасно для лимита B24 в 50 cmd. + * Cron-импортёр сам не превышает этот лимит, но подстраховываемся на случай + * ручных вызовов. + * + * Контракт ACK: + * - self::IMPORT_ACK_NOT_READY пока inner_numbers / mobile_numbers ещё + * не наполнены (стартап). Импортёр должен НЕ двигать курсор и попробовать позже. + * - self::IMPORT_ACK_OK штатная обработка завершена. Импортёр двигает курсор. + * + * @param array $calls + * @param string $inboxTube tube для ACK инициатору invoke. + */ + private function handleImportHistoricalCalls(array $calls, string $inboxTube = ''): void + { + // Защита от обработки до синхронизации пользователей B24: + // findMtsEmployee опирается на inner_numbers/mobile_numbers, заполняемые + // в Bitrix24Integration::b24GetPhones(). Если оба пусты, мы не можем + // достоверно разрешить USER_ID и ошибочно пропустим все звонки. + if (empty($this->b24->inner_numbers) && empty($this->b24->mobile_numbers)) { + $this->b24->mainLogger->writeError( + ['count' => count($calls)], + 'MTS import: employee maps are empty (cold worker?), instructing caller to retry' + ); + $this->publishInvokeAck($inboxTube, [ + 'status' => self::IMPORT_ACK_NOT_READY, + 'received' => count($calls), + ]); + return; + } + + if (count($calls) > self::MAX_HISTORICAL_CALLS_PER_INVOKE) { + $this->b24->mainLogger->writeError( + ['received' => count($calls)], + 'MTS import: too many calls in single invoke, truncating to ' + . self::MAX_HISTORICAL_CALLS_PER_INVOKE + ); + $calls = array_slice($calls, 0, self::MAX_HISTORICAL_CALLS_PER_INVOKE); + } + $accepted = 0; + $skipped = 0; + foreach ($calls as $call) { + if (!is_array($call)) { + $skipped++; + continue; + } + if ($this->enqueueHistoricalCall($call)) { + $accepted++; + } else { + $skipped++; + } + } + if (!$this->insideExecuteTasks && count($this->q_req) >= 49) { + $this->executeTasks(); + } + $this->publishInvokeAck($inboxTube, [ + 'status' => self::IMPORT_ACK_OK, + 'accepted' => $accepted, + 'skipped' => $skipped, + ]); + } + + /** + * Помещает один исторический звонок в очередь q_req: + * 1) резолвит сотрудника (USER_ID/USER_PHONE_INNER) и направление (TYPE) + * поиском в inner_numbers и mobile_numbers; + * 2) проверяет, не отправлялся ли уже звонок (по linkedid в b24_cdr_data); + * 3) формирует register, кладёт в q_req, сохраняет ключ register + * в tmpCallsData[linkedid]['CALL_ID'] — это активирует механизм + * resolveBatchCallId() и finish сошлётся на результат register + * внутри одной batch-транзакции; + * 4) формирует finish (с FILE/export_records, если запись доступна локально). + * + * @param array $call Сырые поля звонка из mts_cdr. + * @return bool true если оба запроса (register+finish или хотя бы register) + * положены в q_req; false если запись пропущена (internal, + * сотрудник не найден, уже отправлена, dedup-cache hit и т.п.). + */ + private function enqueueHistoricalCall(array $call): bool + { + $linkedId = (string)($call['linkedid'] ?? ''); + if ($linkedId === '') { + $this->b24->mainLogger->writeError($call, 'MTS import: empty linkedid, skipping'); + return false; + } + // Защита от чужих источников, если когда-нибудь в mts_cdr появятся. + if (($call['from_account'] ?? '') !== 'fs-mts') { + $this->b24->mainLogger->writeError($call, "MTS import: unexpected from_account, skipping ($linkedId)"); + return false; + } + + // 1) Резолв сотрудника. + $srcEmp = $this->findMtsEmployee((string)($call['src_num'] ?? '')); + $dstEmp = $this->findMtsEmployee((string)($call['dst_num'] ?? '')); + + $type = ''; + $userId = ''; + $userPhoneInner = ''; + $clientPhone = ''; + if ($srcEmp !== null && $dstEmp !== null) { + $this->b24->mainLogger->writeInfo($call, "MTS import: internal call, skipping ($linkedId)"); + return false; + } + if ($srcEmp !== null) { + // Звонит сотрудник наружу — исходящий. + $type = '1'; + $userId = (string)($srcEmp['ID'] ?? ''); + $userPhoneInner = (string)($srcEmp['UF_PHONE_INNER'] ?? ''); + $clientPhone = (string)($call['dst_num'] ?? ''); + } elseif ($dstEmp !== null) { + // Звонят сотруднику снаружи — входящий. + $type = '2'; + $userId = (string)($dstEmp['ID'] ?? ''); + $userPhoneInner = (string)($dstEmp['UF_PHONE_INNER'] ?? ''); + $clientPhone = (string)($call['src_num'] ?? ''); + } else { + $this->b24->mainLogger->writeError( + $call, + "MTS import: employee not found among inner_numbers/mobile_numbers, skipping ($linkedId)" + ); + return false; + } + + if ($userId === '' || $userPhoneInner === '') { + $this->b24->mainLogger->writeError( + $call, + "MTS import: resolved employee has empty ID/UF_PHONE_INNER, skipping ($linkedId)" + ); + return false; + } + + // 2) Строгий dedup по linkedid: ищем CDR с непустым call_id. + // Используем специальный метод (FUNC_GET_EXPORTED_CALL_ID), а не общий + // FUNC_GET_CDR_BY_LINKED_ID — у последнего leg-семантика и fallback + // на «первую попавшуюся CDR», что для импорта некорректно. + $exported = ConnectorDb::invoke( + ConnectorDb::FUNC_GET_EXPORTED_CALL_ID, + [$linkedId] + ); + if (!empty($exported['call_id'])) { + $this->b24->mainLogger->writeDebug( + ['linkedid' => $linkedId, 'call_id' => $exported['call_id']], + 'MTS import: call already sent, skipping' + ); + return false; + } + + // 3) Подготовка tmpCallsData. wait=false, чтобы при последующих AMI-эвентах + // не запускалась логика поиска контактов по этому linkedid. + if (!isset($this->tmpCallsData[$linkedId])) { + $this->tmpCallsData[$linkedId] = [ + 'wait' => false, + 'events' => [], + 'search' => 1, + 'lead' => -1, + 'list-lead' => -1, + 'company' => -1, + 'data' => $call, + 'crm-data' => [], + 'inbox_tube' => '', + 'responsible'=> '', + 'CALL_ID' => '', + ]; + } + + // Флаги настроек прокидывает cron-импортёр (он уже читает их для своей логики). + $crmCreate = (((string)($call['crm_create'] ?? '0')) === '1') ? '1' : '0'; + $exportRecordsSetting = (((string)($call['export_records_setting'] ?? '0')) === '1'); + + $disposition = (string)($call['disposition'] ?? 'NOANSWER'); + $statusCode = ($disposition === 'ANSWERED') ? '200' : '304'; + + // 4) Register. + $payloadRegister = [ + 'linkedid' => $linkedId, + 'UNIQUEID' => $linkedId, + 'USER_ID' => $userId, + 'USER_PHONE_INNER' => $userPhoneInner, + 'PHONE_NUMBER' => $clientPhone, + 'CALL_START_DATE' => $this->formatHistoricalDate($call['start'] ?? ''), + 'TYPE' => $type, + 'CRM_CREATE' => $crmCreate, + 'SHOW' => '0', + 'LINE_NUMBER' => (string)($call['did'] ?? ''), + ]; + [$argRegister, $registerKey] = $this->b24->telephonyExternalCallRegister($payloadRegister); + if (!empty($argRegister)) { + $this->q_req = array_merge($this->q_req, $argRegister); + } + if ($registerKey === '') { + // Дубликат по кешу register (180 сек) — finish тоже не имеет смысла + // в этой итерации, так как CALL_ID в batch получить неоткуда. + $this->b24->mainLogger->writeDebug( + ['linkedid' => $linkedId], + 'MTS import: register dedup-cache hit, skipping' + ); + return false; + } + $this->tmpCallsData[$linkedId]['CALL_ID'] = $registerKey; + + // 5) Finish. + $recordFile = ''; + $exportRecords = false; + $recStatus = (string)($call['mts_rec_status'] ?? ''); + $recPath = (string)($call['recordingfile'] ?? ''); + if ($recStatus === 'ok' && $recPath !== '' && file_exists($recPath)) { + $recordFile = $recPath; + $exportRecords = $exportRecordsSetting; + } + + $payloadFinish = [ + 'linkedid' => $linkedId, + 'UNIQUEID' => $linkedId, + 'USER_ID' => $userId, + 'DURATION' => (string)($call['duration'] ?? '0'), + 'STATUS_CODE' => $statusCode, + 'GLOBAL_STATUS' => $disposition, + 'disposition' => $disposition, + 'FILE' => $recordFile, + 'export_records' => $exportRecords, + 'ADD_TO_CHAT' => 0, + ]; + [$argFinish, $finishKey] = $this->b24->telephonyExternalCallFinish($payloadFinish, $this->tmpCallsData); + if (!empty($argFinish)) { + $this->q_req = array_merge($this->q_req, $argFinish); + } + $this->b24->mainLogger->writeInfo( + [ + 'linkedid' => $linkedId, + 'type' => $type, + 'user_id' => $userId, + 'inner' => $userPhoneInner, + 'phone' => $clientPhone, + 'rec' => $recStatus, + 'registerKey' => $registerKey, + 'finishKey' => $finishKey, + ], + 'MTS import: enqueued' + ); + return true; + } + + /** + * Ищет сотрудника по номеру телефона из mts_cdr. + * Сначала по внутренним номерам (UF_PHONE_INNER) — для коротких номеров. + * Потом по mobile_numbers (PERSONAL_MOBILE / WORK_PHONE / соответствие + * UF_PHONE_INNER → внешний PBX-номер) — для мобильных сотрудников. + * + * @param string $number + * @return array|null Запись пользователя из b24->inner_numbers/mobile_numbers либо null. + */ + private function findMtsEmployee(string $number): ?array + { + $clean = preg_replace('/\D+/', '', $number); + if ($clean === '') { + return null; + } + // Внутренние номера индексируются по UF_PHONE_INNER без нормализации. + if (isset($this->b24->inner_numbers[$clean])) { + return $this->b24->inner_numbers[$clean]; + } + // Мобильные индексируются по getPhoneIndex (последние 10 цифр). + $key = Bitrix24Integration::getPhoneIndex($clean); + if ($key !== '' && isset($this->b24->mobile_numbers[$key])) { + return $this->b24->mobile_numbers[$key]; + } + return null; + } + + /** + * Преобразует время старта звонка из mts_cdr в ISO 8601. + * synchCdr.php уже применяет gap-сдвиг, поэтому значение в БД хранится + * как локальное время PBX. Привязываем DateTime к default-таймзоне + * (date_default_timezone_get) — иначе при работе без явной TZ-инфо во + * входной строке Bitrix24 может получить смещённое время. + * + * @param string $start + * @return string + */ + private function formatHistoricalDate(string $start): string + { + if ($start === '') { + return ''; + } + try { + $tz = new \DateTimeZone(date_default_timezone_get() ?: 'UTC'); + return (new \DateTime($start, $tz))->format('c'); + } catch (\Throwable $e) { + $this->b24->mainLogger->writeError( + ['start' => $start, 'error' => $e->getMessage()], + 'MTS import: failed to parse start date' + ); + return ''; + } + } + /** * Обработка ответа API внешнему скрипту. * @param $response From 09b011147c600c58380790c8035368088d5a55da Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Tue, 12 May 2026 16:17:26 +0300 Subject: [PATCH 09/20] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20orphan-finish?= =?UTF-8?q?=20=D0=BE=D1=82=20IVR/queue-leg=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=B0=D0=BB=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=20=D0=BE=D1=82=D0=B2=D0=B5=D1=87=D0=B5=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После c13a034 AMI-воркер для каждого CDR-плеча, не дошедшего до оператора, шлёт telephonyExternalCallFinish с USER_ID=responsibleMissedCalls, GLOBAL_STATUS=NOANSWER, DURATION=0. Эти orphan-эвенты прилетали в HTTP- воркер раньше ANSWERED-финиша реального оператора. Первый orphan-finish уходил в B24, дедуп finishOneKey по CALL_ID блокировал второй (правильный) — звонок оказывался «пропущенным» с переписанным ответственным, а к карточке прикреплялся IVR-фрагмент записи вместо разговора. HTTP-воркер теперь ставит маркер b24-answered- в Redis при action_dial_answer (TTL 3 ч, переживает рестарт воркера) и пропускает NOANSWER-finish'и от orphan-leg'ов, если этот маркер стоит. Полностью пропущенные звонки (никто не ответил, action_dial_answer не приходил) работают как раньше — маркера нет, orphan-finish уходит штатно. Корневая логика подмены USER_ID в AMI оставлена без изменений. Подтверждено логами: 79245067790@2026-05-08 12:52, 79636988999@16:36. --- bin/WorkerBitrix24IntegrationHTTP.php | 52 ++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/bin/WorkerBitrix24IntegrationHTTP.php b/bin/WorkerBitrix24IntegrationHTTP.php index 4a17170..bab8bfc 100644 --- a/bin/WorkerBitrix24IntegrationHTTP.php +++ b/bin/WorkerBitrix24IntegrationHTTP.php @@ -629,6 +629,26 @@ private function addDataToQueue(array $data): void $this->q_req = array_merge($this->q_req, $arg); } } elseif ('action_dial_answer' === $data['action']) { + // Помечаем звонок как реально отвеченный оператором. + // Это блокирует orphan-finish'и (USER_ID=responsibleMissedCalls, + // GLOBAL_STATUS=NOANSWER) от IVR/queue-leg'ов, которые AMI-воркер + // шлёт в той же серии CDR — без этого они приходят в B24 раньше + // ANSWERED-finish'а реального оператора и переписывают статус + // звонка на «пропущен», а ответственного — на «ответственного + // за пропущенные» (см. AMI:actionCompleteCdr ветка isOrphanIncoming). + // + // Маркер хранится в Redis, а не в $tmpCallsData: при крашах + // HTTP-воркера (см. OOM-фиксы 0bae398) очередь Beanstalk переживает + // рестарт, RAM — нет. Без persistence orphan-finish после рестарта + // прошёл бы фильтр и баг возродился. + // + // TTL = 3 часа: маркер должен пережить весь разговор до hangup'а. + // 3 часа покрывают практически все реальные кейсы (конференции, + // длинная техподдержка); звонки длиннее — редкое исключение, + // в нём фильтр перестанет работать, но критичной регрессии нет: + // вернётся прежнее поведение. Asterisk-linkedid (unixtime.seq) + // не переиспользуется, «протекание» в чужой звонок исключено. + $this->b24->saveCache('b24-answered-' . $data['linkedid'], true, 10800); $tmpArr = []; $userId = $this->tmpCallsData[$data['linkedid']]['ARG_REGISTER_USER_'.$data['UNIQUEID']]??''; $dealId = ''; @@ -677,12 +697,34 @@ private function addDataToQueue(array $data): void $this->q_req = array_merge($this->q_req, ...$tmpArr); } } elseif ('telephonyExternalCallFinish' === $data['action']) { - [$arg,$finishKey] = $this->b24->telephonyExternalCallFinish($data, $this->tmpCallsData); - $this->q_req = array_merge($this->q_req, $arg); - - if(!empty($finishKey)){ - $arg = $this->b24->crmActivityUpdate('$result['.$finishKey.'][CRM_ACTIVITY_ID]', $data['linkedid'], $data['linkedid']); + // Защита от orphan-leg'ов: если звонок уже был реально отвечен + // (action_dial_answer пришёл), NOANSWER-finish для другого + // (IVR/queue) leg'а — это служебный «осколок», не звонок. + // Пропускаем его целиком — иначе он уходит в B24 первым с + // USER_ID=responsibleMissedCalls и DURATION=0, перетирая статус + // и ответственного у уже-отвеченного звонка. Запись разговора + // для orphan-leg'а (короткий IVR-фрагмент) тоже не прикрепляется — + // это устраняет «фантомные» аудио у пропущенных. См. логи + // 79245067790@2026-05-08 12:52 и 79636988999@16:36. + // + // Источник маркера answered — Redis (через b24->getCache): + // переживает рестарт HTTP-воркера, согласован с Beanstalk-очередью, + // которая тоже persistent. + $alreadyAnswered = !empty($this->b24->getCache('b24-answered-' . $data['linkedid'])); + $globalStatus = (string)($data['GLOBAL_STATUS'] ?? ''); + if ($alreadyAnswered && $globalStatus !== 'ANSWERED') { + $this->b24->mainLogger->writeInfo( + $data, + "Skip orphan NOANSWER finish (call was answered): {$data['linkedid']}" + ); + } else { + [$arg,$finishKey] = $this->b24->telephonyExternalCallFinish($data, $this->tmpCallsData); $this->q_req = array_merge($this->q_req, $arg); + + if(!empty($finishKey)){ + $arg = $this->b24->crmActivityUpdate('$result['.$finishKey.'][CRM_ACTIVITY_ID]', $data['linkedid'], $data['linkedid']); + $this->q_req = array_merge($this->q_req, $arg); + } } }else{ $this->b24->mainLogger->writeInfo($data, "The event handler was not found ($data[linkedid])"); From 1cb207ce97ebc577d4426783d0411f9e0a0332c1 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Tue, 12 May 2026 16:45:02 +0300 Subject: [PATCH 10/20] =?UTF-8?q?AMI-=D0=B2=D0=BE=D1=80=D0=BA=D0=B5=D1=80:?= =?UTF-8?q?=20=D1=83=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=86=D0=B8=D0=BA=D0=BB=D0=B0=20=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D0=B2=20+=20=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=20MtsImporter=20Settings=20unavailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AMI: listener регистрировался ПОСЛЕ тяжёлого new Bitrix24Integration('_ami') (синхронный REST к B24, секунды). WorkerSafeScriptsCore::checkWorkerAMI шлёт ping каждые ~5 сек и через 3 промаха кидает SIGUSR1 — воркер успевал прокрутиться циклом «Start daemon → Need SHUTDOWN (state=wait_ami)... 10». setFilter() + addEventHandler('userevent') перенесены СРАЗУ после createAstManager() — до тяжёлого init. callback() использует processState==='init' как маркер «warming up», ping-pong отвечается безусловно (replyOnPingRequest зависит только от $am), updateSettings и CDR-handlers пропускаются до окончания init. MtsImporter: ConnectorDb::invoke(FUNC_GET_GENERAL_SETTINGS) возвращает stdClass, а не массив — старая проверка is_array() ронила скрипт каждые 5 минут с "Settings unavailable, exiting". Заменено на is_object() + обращение к полям через ->. Развели логирование между cache/RPC fail и B24-portal-not-configured. CLAUDE.md: добавлены инварианты — порядок init AMI-воркера, контракт ConnectorDb::invoke, фильтр orphan-leg, обзор MTS-импорта. --- CLAUDE.md | 32 ++++++++++++++++++++++ bin/MtsImporter.php | 24 ++++++++++++----- bin/WorkerBitrix24IntegrationAMI.php | 40 +++++++++++++++++++++++++--- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0229b0f..3d12d24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,38 @@ GET /pbxcore/api/bitrix-integration/workers/state (без авторизации - `telephony.externalcall.show` — отдельный вызов для открытия карточки остальным участникам очереди - `open_card_mode`: `DIRECTLY` (сразу), `ANSWERED` (при ответе), `NONE` (никогда) — настройка per-user в `ModuleBitrix24Users` +### Orphan-leg фильтр в HTTP-воркере + +Для входящих звонков AMI-воркер шлёт `telephonyExternalCallFinish` на каждое CDR-плечо. Плечи, не дошедшие до оператора (IVR/queue), помечаются `USER_ID=responsibleMissedCalls`, `GLOBAL_STATUS=NOANSWER`. Эти эвенты могут опередить ANSWERED-finish реального оператора и переписать статус звонка на «пропущен». + +HTTP-воркер фильтрует их: при `action_dial_answer` ставит маркер `b24-answered-` в Redis (TTL 3 ч, через `CacheManager`/`b24->saveCache` — переживает рестарт воркера). При обработке `telephonyExternalCallFinish` с `GLOBAL_STATUS!=ANSWERED`, если маркер стоит — эвент пропускается. Полностью пропущенные звонки (никто не ответил) идут штатно — маркера нет. + +## MTS Historical Import + +Опциональный импорт CDR из соседнего модуля `ModuleMtsPbx` (таблица `mts_cdr`). Cron `bin/MtsImporter.php` каждые 5 минут читает `Modules\ModuleMtsPbx\Models\CallHistory` через ORM (без HTTP к соседу), шлёт пачки по 10 звонков через `Bitrix24InvokeRest::invoke('importHistoricalCalls', ...)`. HTTP-воркер кладёт `register+finish` в общую `q_req` — звонки уходят обычным batch'ем как AMI-эвенты. + +- Опция: `import_mts_calls` в `ModuleBitrix24Integration` (галка в админке, видна только при установленном ModuleMtsPbx). +- Курсор: `mts_import_last_id` (в той же таблице, добавлен в `$syncKeys` в `Bitrix24IntegrationConf::modelsEventChangeData` — иначе апдейты курсора триггерили бы `onAfterModuleEnable`). +- ACK-протокол invoke: `IMPORT_ACK_OK` / `IMPORT_ACK_NOT_READY` (если карты сотрудников `b24->inner_numbers`/`mobile_numbers` пусты после рестарта). Cron при `not_ready` не двигает курсор. +- Защита от повторного запуска: `flock(LOCK_EX|LOCK_NB)` на `tmp/.../mts_importer.lock`. +- Dedup: `ConnectorDb::FUNC_GET_EXPORTED_CALL_ID` — строгий поиск по `linkedid` в `b24_cdr_data` с непустым `call_id` (без leg-семантики `getCdrDataByLinkedId`). + +Модель `Modules\ModuleMtsPbx\Models\CallHistory` доступна через PSR-4 (соседний модуль регистрирует свой namespace). НО при отсутствии модуля любое `use` приведёт к fatal — используем только FQCN-строки + `class_exists($fqcn)` перед обращением. + +## Worker initialization patterns + +**AMI-воркер: listener регистрируется ДО тяжёлого init.** `WorkerSafeScriptsCore::checkWorkerAMI` (core MikoPBX) шлёт ping через AMI UserEvent. После `MAX_PING_FAILURES` промахов подряд — SIGUSR1 → воркер крашится → safe.php стартует заново. Окно типично 15 секунд. `new Bitrix24Integration('_ami')` делает синхронный REST к B24 и может занимать дольше, поэтому `setFilter()` и `addEventHandler('userevent', [$this, 'callback'])` нужно вызвать **сразу после `createAstManager()`**, до тяжёлого init. `replyOnPingRequest` (WorkerBase) зависит только от `$this->am`, поэтому отвечать на ping можно без `$b24`. `callback()` использует `$this->processState === 'init'` как маркер «не готов» — пока state='init', события (кроме pong) игнорируются. + +## ConnectorDb::invoke contract + +`ConnectorDb::invoke(FUNC_GET_GENERAL_SETTINGS)` возвращает гетерогенное значение: +- cache-hit → `stdClass` (через `(object)$cached`) +- cache-miss + RPC success → `stdClass` +- cache-miss + RPC fail/timeout → `[]` (пустой массив) +- DB-empty (модель отсутствует) → может быть `false` (Phalcon 3/4) + +Проверять: `is_object($settings) && !empty($settings->portal)` — это и есть инвариант «настройки готовы». Обращаться к полям через `->`, **не** через `[...]`. + ## Logs Логи воркеров: `ConnectorDb.log`, `HttpConnection.log`, `HttpConnection_SYNC.log`, `IntegrationAMI.log`. diff --git a/bin/MtsImporter.php b/bin/MtsImporter.php index 826a04e..b35cb90 100644 --- a/bin/MtsImporter.php +++ b/bin/MtsImporter.php @@ -73,20 +73,32 @@ exit(0); } +// ConnectorDb::FUNC_GET_GENERAL_SETTINGS возвращает stdClass (см. invoke(): +// `return (object)$cached;` для cache-hit и upstream-сериализацию модели). +// Раньше здесь была проверка is_array — она роняла скрипт каждые 5 минут +// с "Settings unavailable, exiting". $settings = ConnectorDb::invoke(ConnectorDb::FUNC_GET_GENERAL_SETTINGS); -if (!is_array($settings) || empty($settings)) { - $logger->writeError('Settings unavailable, exiting'); +if (!is_object($settings)) { + // RPC-таймаут или fallback в invoke() вернул пустой массив. Cron повторит + // через 5 мин; пишем явно, чтобы отличать от «нет настроек в БД». + $logger->writeError(['type' => gettype($settings)], 'Settings RPC returned non-object (likely timeout), exiting'); + exit(0); +} +if (empty($settings->portal)) { + // Модуль установлен, но не привязан к порталу B24 — OAuth не пройден. + // Импортировать всё равно некуда; молча выходим, чтобы не шуметь в логах. + $logger->writeInfo('B24 portal is not configured yet, exiting'); exit(0); } -if (((string)($settings['import_mts_calls'] ?? '0')) !== '1') { +if (((string)($settings->import_mts_calls ?? '0')) !== '1') { // Опция выключена. exit(0); } -$cursor = (int)($settings['mts_import_last_id'] ?? 0); -$crmCreateFlag = (((string)($settings['crmCreateLead'] ?? '0')) === '1') ? '1' : '0'; -$exportRecordsFlag = (((string)($settings['export_records'] ?? '0')) === '1') ? '1' : '0'; +$cursor = (int)($settings->mts_import_last_id ?? 0); +$crmCreateFlag = (((string)($settings->crmCreateLead ?? '0')) === '1') ? '1' : '0'; +$exportRecordsFlag = (((string)($settings->export_records ?? '0')) === '1') ? '1' : '0'; $logger->writeInfo( [ diff --git a/bin/WorkerBitrix24IntegrationAMI.php b/bin/WorkerBitrix24IntegrationAMI.php index c5c1409..83522f5 100644 --- a/bin/WorkerBitrix24IntegrationAMI.php +++ b/bin/WorkerBitrix24IntegrationAMI.php @@ -96,6 +96,18 @@ public function start($argv):void $this->client = new BeanstalkClient(Bitrix24Integration::B24_INTEGRATION_CHANNEL); $this->am = $this->createAstManager(); + + // ВАЖНО: AMI-фильтр и обработчик регистрируем ДО тяжёлого new + // Bitrix24Integration (он делает синхронный REST к B24 и может занимать + // секунды). safe.php (WorkerSafeScriptsCore::checkWorkerAMI) шлёт ping + // через UserEvent каждые ~5 сек и засчитывает 3 промаха подряд → SIGUSR1. + // Если listener не зарегистрирован к этому моменту, воркер крашится + // в цикле «Start daemon → Need SHUTDOWN... 10 → Start daemon». Ping- + // обработчик (replyOnPingRequest) зависит только от $this->am, поэтому + // безопасно выводить его регистрацию вперёд. + $this->setFilter(); + $this->am->addEventHandler('userevent', [$this, 'callback']); + $this->b24 = new Bitrix24Integration('_ami'); if (!$this->b24->initialized) { @@ -105,15 +117,19 @@ public function start($argv):void // Bitrix24Integration уже подгрузил настройки через ConnectorDb::invoke // и применил уровень к своему логгеру — синхронизируемся. $this->logger->setLevel($this->b24->mainLogger->getLevel()); - $this->setFilter(); $this->updateSettings(); $config = new MikoPBXConfig(); $this->extensionLength = $config->getGeneralSettings('PBXInternalExtensionLength'); - $this->am->addEventHandler("userevent", [$this, "callback"]); if ($this->needRestart) { - $this->logger->writeInfo('Restart signal received during init, deferring — entering main loop.'); + // SIGUSR1 пришёл во время init (например, safe.php засчитал нас + // мёртвыми по ping'у до того, как listener успел зарегистрироваться). + // Мы УЖЕ восстановили обработку — игнорируем этот сигнал, чтобы не + // рестартоваться сразу после старта. Реальный shutdown потребует + // нового SIGUSR1, который придёт через ping-механизм если воркер + // снова перестанет отвечать. + $this->logger->writeInfo('Ignoring early SIGUSR1 received during init — listener is now ready, entering main loop.'); $this->needRestart = false; } $this->processState = 'idle'; @@ -280,6 +296,16 @@ private function setFilter():void public function callback($parameters):void{ if ($this->replyOnPingRequest($parameters)){ + // Pong через replyOnPingRequest зависит только от $this->am и уже + // отправлен выше. Дальше начинается работа на $this->b24 / + // updateSettings — её НЕ дёргаем пока воркер в фазе init. + // processState='init' выставлен на уровне поля и меняется на + // 'idle' только в конце start() — это надёжный маркер + // «warming up», см. start(): между регистрацией listener + // и завершением new Bitrix24Integration окно несколько секунд. + if ($this->processState === 'init') { + return; + } $this->counter++; // Пинг приходит раз в минуту. Обычный интервал обновления — 5 минут. // Если inner_numbers пуст (токен был невалиден) — обновляем каждый пинг для быстрого восстановления. @@ -296,6 +322,14 @@ public function callback($parameters):void{ if(!isset($parameters['AgiData'])){ return; } + // CDR-event может прийти до окончания инициализации (b24, extensions, + // external_lines, extensionLength — всё ставится последовательно + // в start()). Обработчики ниже требуют их. Тот же маркер processState. + // Asterisk переотправит CDR при следующем звонке; во время первичного + // запуска воркера активных звонков обычно нет. + if ($this->processState === 'init') { + return; + } $agiData = $parameters['AgiData']; if (strpos($agiData, 'GZ:') === 0) { $stringData = gzdecode(base64_decode(substr($agiData, 3))); From 96d66965aeb1baf204322a5333dc160f92942a93 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Tue, 12 May 2026 17:05:53 +0300 Subject: [PATCH 11/20] =?UTF-8?q?MtsImporter:=20pending-guard=20=D1=81?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изначально pending-проверка стояла условно: `recStatus==='pending' && rowId > cursor`. Двусторонний курсор `id > cursor OR start >= today` мог вернуть pending-запись с id <= cursor через ветку start>=today; guard её пропускал; запись уходила в B24 без FILE; после докачки MP3 dedup (FUNC_GET_EXPORTED_CALL_ID) не давал переотправить — запись разговора терялась навсегда. Pending теперь останавливает прогон безусловно. На следующем тике cron'а запись будет переразобрана; если mts_rec_status стал 'ok' — отправится с FILE. Также возвращён mts_cdr (Modules\ModuleMtsPbx\Models\CallHistory) как источник данных вместо короткоживущего эксперимента с cdr_general (CallDetailRecords): у cdr_general нет индекса на from_account, и mts_rec_status/recordingfile там не живут — pending-state-machine пришлось бы переизобретать у нас. CLAUDE.md обновлён. --- CLAUDE.md | 3 +++ bin/MtsImporter.php | 45 ++++++++++++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3d12d24..329f7b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,8 +115,11 @@ HTTP-воркер фильтрует их: при `action_dial_answer` став Опциональный импорт CDR из соседнего модуля `ModuleMtsPbx` (таблица `mts_cdr`). Cron `bin/MtsImporter.php` каждые 5 минут читает `Modules\ModuleMtsPbx\Models\CallHistory` через ORM (без HTTP к соседу), шлёт пачки по 10 звонков через `Bitrix24InvokeRest::invoke('importHistoricalCalls', ...)`. HTTP-воркер кладёт `register+finish` в общую `q_req` — звонки уходят обычным batch'ем как AMI-эвенты. +Почему mts_cdr, а не общая `cdr_general` (cdr.db): cdr_general — большая Asterisk-таблица без индекса на `from_account`, на крупных инсталляциях первичный скан медленный. `mts_cdr` — узкая таблица соседнего модуля, заполняемая `ModuleMtsPbx/bin/synchCdr.php` через MTS API, содержит только fs-mts звонки и MTS-API метаданные (включая `mts_rec_status` для отслеживания скачивания MP3). + - Опция: `import_mts_calls` в `ModuleBitrix24Integration` (галка в админке, видна только при установленном ModuleMtsPbx). - Курсор: `mts_import_last_id` (в той же таблице, добавлен в `$syncKeys` в `Bitrix24IntegrationConf::modelsEventChangeData` — иначе апдейты курсора триггерили бы `onAfterModuleEnable`). +- Pending-логика: `mts_rec_status='pending'` означает, что MP3 ещё скачивается ModuleMtsPbx (`downloadRecords.php`). Cron не двигает курсор через такую запись, ждёт следующего тика. - ACK-протокол invoke: `IMPORT_ACK_OK` / `IMPORT_ACK_NOT_READY` (если карты сотрудников `b24->inner_numbers`/`mobile_numbers` пусты после рестарта). Cron при `not_ready` не двигает курсор. - Защита от повторного запуска: `flock(LOCK_EX|LOCK_NB)` на `tmp/.../mts_importer.lock`. - Dedup: `ConnectorDb::FUNC_GET_EXPORTED_CALL_ID` — строгий поиск по `linkedid` в `b24_cdr_data` с непустым `call_id` (без leg-семантики `getCdrDataByLinkedId`). diff --git a/bin/MtsImporter.php b/bin/MtsImporter.php index b35cb90..2c0b6e3 100644 --- a/bin/MtsImporter.php +++ b/bin/MtsImporter.php @@ -24,10 +24,18 @@ use Modules\ModuleBitrix24Integration\Lib\Bitrix24InvokeRest; use Modules\ModuleBitrix24Integration\Lib\Logger; -// ВАЖНО: модель ModuleMtsPbx\Models\CallHistory НЕ импортируется через `use`. -// Если соседний модуль не установлен, любое упоминание этого класса (включая -// resolve через use) приведёт к ошибке автозагрузки. Доступ — только через -// FQCN-строку после class_exists(). +// Источник данных — таблица mts_cdr соседнего модуля ModuleMtsPbx, +// которая содержит только MTS-звонки и обогащённые MTS API метаданные +// (recordingfile, mts_rec_status). Это узкая таблица без посторонних +// записей — индекс на from_account не нужен (по сути все строки fs-mts). +// +// Альтернатива (cdr_general в cdr.db) была отвергнута: на крупных +// инсталляциях миллионы записей без индекса на from_account делают +// первичный скан медленным, а индекс на чужую БД мы добавлять не хотим. +// +// FQCN-строка вместо `use`, потому что Modules\ModuleMtsPbx\Models\CallHistory +// может отсутствовать (соседний модуль не установлен) — `use` уронит +// автозагрузку. Доступ — только через переменную после class_exists(). const MTS_CALL_HISTORY_FQCN = '\\Modules\\ModuleMtsPbx\\Models\\CallHistory'; const MAX_PER_RUN = 300; @@ -66,7 +74,8 @@ @unlink($lockFile); }); -// Проверяем доступность модели соседнего модуля. +// Проверяем доступность модели соседнего модуля — это и источник данных, +// и смысловой guard «MTS-trunk сконфигурирован». $callHistoryClass = MTS_CALL_HISTORY_FQCN; if (!class_exists($callHistoryClass)) { // ModuleMtsPbx не установлен — это штатная ситуация. Тихо выходим. @@ -114,6 +123,10 @@ $todayStart = (new \DateTime())->setTime(0, 0)->format('Y-m-d H:i:s'); while ($processed < MAX_PER_RUN) { + // mts_cdr содержит только MTS-звонки — фильтр по from_account не нужен. + // Двусторонний курсор (id > cursor OR start >= today) — на случай + // повторной синхронизации MTS API за текущий день (мerging обновлений + // mts_rec_status для уже виденных id). $rows = $callHistoryClass::find([ 'id > :id: OR start >= :start:', 'bind' => [ @@ -132,21 +145,26 @@ $cursorCandidate = $cursor; // курсор сдвинется только после успешного invoke $stopAtPending = false; foreach ($rows as $row) { - $rowId = (int)($row['id'] ?? 0); + $rowId = (int)($row['id'] ?? 0); $recStatus = (string)($row['mts_rec_status'] ?? ''); - // Pending — отправлять преждевременно. Откладываем (не сдвигаем курсор, - // если запись из «новой» части, id > cursor; повторно попадёт в - // следующем тике через ветку фильтра). - if ($recStatus === 'pending' && $rowId > $cursor) { + // Pending — MP3 ещё не докачан ModuleMtsPbx (downloadRecords.php). + // Останавливаемся БЕЗОГОВОРОЧНО (без проверки rowId > cursor): иначе + // запись с id<=cursor, попавшая в выборку через ветку `start>=today`, + // ускользнёт от pending-guard и уйдёт в B24 без FILE. После докачки + // MP3 dedup (FUNC_GET_EXPORTED_CALL_ID) не пустит её повторно — + // запись разговора потеряется навсегда. См. ревью. + if ($recStatus === 'pending') { $logger->writeInfo( ['id' => $rowId, 'linkedid' => $row['linkedid'] ?? ''], - 'Skip pending without advancing cursor' + 'Skip pending and stop run (wait for MP3 download next tick)' ); $stopAtPending = true; break; } - // Чужие источники в mts_cdr — пропускаем без отправки в B24, но курсор двигаем. + // mts_cdr содержит только MTS-звонки, но защищаемся от поломок ORM + // или будущих изменений модели — другой from_account пропускаем со + // сдвигом курсора. $fromAccount = (string)($row['from_account'] ?? ''); if ($fromAccount !== 'fs-mts') { if ($rowId > $cursorCandidate) { @@ -220,7 +238,8 @@ if ($stopAtPending) { // Перед нами «застрявшая» pending-запись; продолжать выборку - // бессмысленно (тот же фильтр повторно её вернёт). Завершаем прогон. + // бессмысленно (тот же фильтр повторно её вернёт). Завершаем прогон, + // ждём следующего тика — MP3 успеет докачаться. break; } if ($processed >= MAX_PER_RUN) { From 3763a073222ca8205c4c90310b4f23614672f4f5 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Tue, 12 May 2026 17:32:23 +0300 Subject: [PATCH 12/20] =?UTF-8?q?=D0=A1=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA:=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D1=8B=D0=B9=20=C2=ABFail=20save=20externalLi?= =?UTF-8?q?nes=C2=BB=20=D0=B8=20=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Контроллер: проверка `if(!$resultSaveLines)` всегда срабатывала ложно. saveExternalLinesData возвращает bool, но через RPC (invokePriority → unpackResult) bool превращается в `[]` (json_decode(true|false) → bool → is_array(bool)===false). Итог: «Fail save externalLines...» при каждом сохранении даже при успешной записи. Проверка убрана — реальные ошибки логируются на стороне ConnectorDb. ConnectorDb::saveExternalLinesData: ветка пустого POST больше не удаляет все строки. Раньше пустой массив запускал find([])->delete() → стирание всей таблицы. Если JS на форме по любой причине не передаст externalLines (race / sessionStorage / баг) — пользователь одним кликом «Сохранить» терял все линии. Теперь пустой POST с непустой таблицей пишет warning в лог и пропускает удаление. Удаление линий должно быть отдельным осознанным экшном. Восстановить таблицу можно перезапуском HTTP-воркера — на старте он вызывает Bitrix24Integration::syncExternalLines() и подтягивает линии с B24-портала. --- .../ModuleBitrix24IntegrationController.php | 13 ++++++----- bin/ConnectorDb.php | 23 +++++++++++++------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/App/Controllers/ModuleBitrix24IntegrationController.php b/App/Controllers/ModuleBitrix24IntegrationController.php index a418c64..82e8cd8 100644 --- a/App/Controllers/ModuleBitrix24IntegrationController.php +++ b/App/Controllers/ModuleBitrix24IntegrationController.php @@ -369,12 +369,13 @@ public function saveAction(): void return; } $externalLinesPost = json_decode($data['externalLines'],true); - $resultSaveLines = ConnectorDb::invokePriority(ConnectorDb::FUNC_SAVE_EXTERNAL_LINES, [$externalLinesPost]); - if(!$resultSaveLines){ - $this->view->error = 'Fail save externalLines...'; - $this->view->success = false; - return; - } + // Результат saveExternalLinesData(bool) теряется при RPC-сериализации: + // unpackResult всегда возвращает array, json_decode(true|false) → bool + // → is_array(bool) === false → возврат `[]`. Старая проверка + // `if(!$resultSaveLines)` ложно срабатывала на каждом сохранении, + // показывая «Fail save externalLines...» даже при успешной записи. + // Полагаемся на лог ConnectorDb для диагностики реальных ошибок. + ConnectorDb::invokePriority(ConnectorDb::FUNC_SAVE_EXTERNAL_LINES, [$externalLinesPost]); $this->flash->success($this->translation->_('ms_SuccessfulSaved')); $this->view->success = true; } diff --git a/bin/ConnectorDb.php b/bin/ConnectorDb.php index c7d3bab..b262164 100644 --- a/bin/ConnectorDb.php +++ b/bin/ConnectorDb.php @@ -593,14 +593,23 @@ public function getFirstExternalLines(array $filter= []):array public function saveExternalLinesData(array $externalLinesPost):bool { if(empty($externalLinesPost)){ - // Больше нет внешних линий, чистим все. - $filter = []; - }else{ - $filter = [ - 'conditions' => 'id NOT IN ({ids:array})', - 'bind' => ['ids' => array_column($externalLinesPost, 'id')] - ]; + // Пустой POST — НЕ удаляем существующие линии. Раньше тут было + // `$filter = []` → find().delete() стирало все строки безусловно; + // при сбое JS на форме (если он не передал externalLines) таблица + // обнулялась за один клик «Сохранить». Удаление линий должно + // быть осознанным действием — отдельный экшн/кнопка, а не + // побочный эффект сохранения настроек. + if (ModuleBitrix24ExternalLines::count() > 0) { + $this->logger->writeError( + 'External lines POST is empty while table is non-empty — skipping destructive delete-all to preserve data' + ); + } + return true; } + $filter = [ + 'conditions' => 'id NOT IN ({ids:array})', + 'bind' => ['ids' => array_column($externalLinesPost, 'id')] + ]; $externalLines = ModuleBitrix24ExternalLines::find($filter); foreach ($externalLines as $externalLine){ $externalLine->delete(); From 0a31dec02184e63bdf4e426a658100f6e2829bef Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Tue, 12 May 2026 17:40:11 +0300 Subject: [PATCH 13/20] =?UTF-8?q?UI-=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5:=20import=5Fmts=5Fcalls=20=D0=B8=20mts?= =?UTF-8?q?=5Fimport=5Flast=5Fid=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=B0?= =?UTF-8?q?=D1=82=D1=8B=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В switch контроллера было два бага по полям, добавленным для MTS-импорта: 1. `import_mts_calls` (checkbox) попадал в `default`-ветку, не в checkbox- список. HTML отправляет 'on' если установлен, ничего — если снят. Default-ветка писала $data['import_mts_calls']='on' прямо в INTEGER-поле; SQLite type affinity приводил к 0, и галка никогда не сохранялась как 1. 2. `mts_import_last_id` (служебный курсор импорта) тоже попадал в default. В форме его нет → array_key_exists false → присваивается '' → INTEGER ← '' → 0. Каждое сохранение настроек через UI сбрасывало прогресс MTS-импорта и заставляло гонять всё с начала. Фикс: import_mts_calls добавлен в checkbox-список, mts_import_last_id — в защитный no-op блок вместе с lastContactId/lastCompanyId/lastLeadId. --- .../ModuleBitrix24IntegrationController.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/App/Controllers/ModuleBitrix24IntegrationController.php b/App/Controllers/ModuleBitrix24IntegrationController.php index 82e8cd8..a8e311c 100644 --- a/App/Controllers/ModuleBitrix24IntegrationController.php +++ b/App/Controllers/ModuleBitrix24IntegrationController.php @@ -326,6 +326,12 @@ public function saveAction(): void case 'lastCompanyId': case 'lastLeadId': case 'lastDealId': + case 'mts_import_last_id': + // Служебные/системные поля, не отображаются в форме. + // НЕ трогаем — без break-ветки default обнулил бы их + // на каждом сохранении настроек ('' → INTEGER → 0). + // Для mts_import_last_id это бы сбрасывало прогресс + // MTS-импорта в 0 при каждом сохранении через UI. break; case 'callbackQueue': $record->$key = trim($data[$key]); @@ -341,6 +347,11 @@ public function saveAction(): void case 'crmCreateLead': case 'backgroundUpload': case 'export_records': + case 'import_mts_calls': + // Checkbox-поля: HTML отправляет 'on' если установлен, + // не отправляет вовсе если снят. Раньше import_mts_calls + // попадал в default → $record->key = 'on' → SQLite-affinity + // INTEGER приводил к 0, и галка никогда не сохранялась как 1. if (array_key_exists($key, $data)) { $record->$key = ($data[$key] === 'on') ? '1' : '0'; } else { From d45de805d3201e48a632df832e1112e8efde0cde Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Wed, 13 May 2026 11:45:30 +0300 Subject: [PATCH 14/20] =?UTF-8?q?inner=5Fnumbers:=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B9=D1=87=D0=B8=D0=B2=D0=BE=D1=81=D1=82=D1=8C=20=D0=BA?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=BC=D1=83=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=8E=20user.get=20+=20=D0=B4=D0=B8=D1=84=D1=84/?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=B2=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень проблемы (кейс 12.05 у ЯРВЕТ, linkedid mikopbx-1778589598.15299): b24GetPhones безусловно обнулял $this->inner_numbers/$mobile_numbers перед foreach и в конце сохранял результат в кеш. Если ответ user.get оказывался пустым (тайм-аут/невалидный токен/ACTIVE-фильтр без записей), карта в кеше затиралась пустотой; AMI-воркер на следующем тике видел inner_numbers=[], getInnerNum(99111) возвращал '', и CDR-плечо на оператора уходило в ветку "The responsible person was not found. cancellation" без отправки register в B24. Изменения: - Lib/Bitrix24Integration.php: userGet не перетирает кеш при пустом ответе b24, отдаёт предыдущий _LONG-снимок; _LONG получил явный TTL 7 дней (раньше 3600 — protect перекрывался коротким TTL). - Lib/Bitrix24Integration.php: b24GetPhones держит снимок $prevInner до перестройки; если новая карта пуста, а предыдущая нет — восстанавливает и не пишет кеш. Добавлены inner_numbers_LONG/mobile_numbers_LONG (7 дней). При изменении состава пишет inner_numbers_diff (added/removed inner/id/name) — нужен для последующих разборов. - bin/WorkerBitrix24IntegrationAMI.php: в start() fallback на inner_numbers_LONG, если основной кеш пуст после updateSettings. - bin/WorkerBitrix24IntegrationAMI.php: перед "cancellation" в actionCompleteCdr пишет CancellationContext (linkedid, src/dst, USER_ID, isMissed, isVoicemail, isOrphanIncoming, GLOBAL_STATUS, disposition, responsibleMissedCalls, inner_numbers_count, dst/src_in_inner_numbers) — диагностика причины. --- Lib/Bitrix24Integration.php | 65 ++++++++++++++++++++++++++-- bin/WorkerBitrix24IntegrationAMI.php | 36 ++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/Lib/Bitrix24Integration.php b/Lib/Bitrix24Integration.php index 03746a9..5ba2af4 100644 --- a/Lib/Bitrix24Integration.php +++ b/Lib/Bitrix24Integration.php @@ -918,6 +918,11 @@ public function b24GetPhones($user_id = null): array } } } else { + // Сохраняем предыдущий снимок до полной перестройки: + // если новый ответ user.get окажется пустым/некорректным, + // безопасно восстановим состояние и не перетрем кеш. + $prevInner = $this->inner_numbers; + $prevMobile = $this->mobile_numbers; $pbx_numbers = $this->getPbxNumbers(); $this->inner_numbers = []; $this->mobile_numbers = []; @@ -962,8 +967,40 @@ public function b24GetPhones($user_id = null): array $data = $this->mobile_numbers; + // Защита от обнуления кеша при сбое user.get: если новый снимок + // пустой, оставляем предыдущий. Без этого один неудачный запрос к + // B24 «прячет» всех операторов на следующем 5-минутном тике AMI. + if (empty($this->inner_numbers) && !empty($prevInner)) { + $this->logger->writeError('b24GetPhones: empty inner_numbers built, restoring previous snapshot. prev_count=' . count($prevInner)); + $this->inner_numbers = $prevInner; + $this->mobile_numbers = $prevMobile; + $data = $this->mobile_numbers; + return $data; + } + + // Лог диффа: какие внутренние номера пропали/появились между + // снимками. Полезно для постфактум-разбора кейсов "оператора + // временно не было в карте". + $addedKeys = array_diff(array_keys($this->inner_numbers), array_keys($prevInner)); + $removedKeys = array_diff(array_keys($prevInner), array_keys($this->inner_numbers)); + if (!empty($prevInner) && (!empty($addedKeys) || !empty($removedKeys))) { + $added = []; + foreach ($addedKeys as $k) { + $added[] = ['inner' => $k, 'id' => $this->inner_numbers[$k]['ID'] ?? '', 'name' => $this->inner_numbers[$k]['NAME'] ?? '']; + } + $removed = []; + foreach ($removedKeys as $k) { + $removed[] = ['inner' => $k, 'id' => $prevInner[$k]['ID'] ?? '', 'name' => $prevInner[$k]['NAME'] ?? '']; + } + $this->logger->writeInfo(['added' => $added, 'removed' => $removed], 'inner_numbers_diff'); + } + $this->saveCache('inner_numbers', $this->inner_numbers); $this->saveCache('mobile_numbers', $this->mobile_numbers); + // Долгоживущий fallback (по аналогии с userGet_LONG). + // Используется AMI-воркером, если основной кеш протух/пуст. + $this->saveCache('inner_numbers_LONG', $this->inner_numbers, 7 * 24 * 3600); + $this->saveCache('mobile_numbers_LONG', $this->mobile_numbers, 7 * 24 * 3600); } return $data; @@ -1058,9 +1095,31 @@ public function userGet(bool $fromCache = false): array $res_data[] = $res; } } - // Сохраняем в кэше - $this->saveCache(__FUNCTION__, $res_data, 90); - $this->saveCache(__FUNCTION__."_LONG", $res_data); + // Сохраняем в кэше. Если ответ b24 пустой — НЕ перетираем + // предыдущий снимок: иначе при разовом сбое user.get + // (тайм-аут/невалидный токен) карты inner_numbers/mobile_numbers + // на следующем тике b24GetPhones обнулятся, и AMI-воркер + // перестанет распознавать операторов до следующего успеха. + $userCount = 0; + foreach ($res_data as $page) { + if (is_array($page)) { + $userCount += count($page); + } + } + if ($userCount > 0) { + $this->saveCache(__FUNCTION__, $res_data, 90); + $this->saveCache(__FUNCTION__."_LONG", $res_data, 7 * 24 * 3600); + } else { + $this->logger->writeError( + 'userGet: empty result from user.get, keeping previous cache. response=' . json_encode($response['result']['result_error'] ?? $response['error'] ?? 'no_error_info') + ); + // Возвращаем предыдущий снимок, чтобы вызывающий код + // (b24GetPhones) не построил пустую карту inner_numbers. + $fallback = $this->getCache(__FUNCTION__."_LONG"); + if (is_array($fallback) && !empty($fallback)) { + $res_data = $fallback; + } + } }elseif ($res_data === null ){ $res_data = []; } diff --git a/bin/WorkerBitrix24IntegrationAMI.php b/bin/WorkerBitrix24IntegrationAMI.php index 83522f5..4fe2ba2 100644 --- a/bin/WorkerBitrix24IntegrationAMI.php +++ b/bin/WorkerBitrix24IntegrationAMI.php @@ -217,8 +217,19 @@ private function updateSettings():void{ // Обновляем данные B24 (внутри вызывается b24GetPhones → обновляет кеш inner_numbers) $this->b24->updateSettings($settings); - // Читаем inner_numbers из кеша (заполнен b24GetPhones выше) + // Читаем inner_numbers из кеша (заполнен b24GetPhones выше). + // Если основной кеш пуст/протух (типичный кейс: разовый сбой + // user.get → b24GetPhones построил пустую карту в более ранней + // ветке без защиты, либо TTL=3600 истёк раньше следующего тика + // updateSettings), пробуем долгоживущий снимок inner_numbers_LONG. $innerNumbers = $this->b24->getCache('inner_numbers'); + if (!is_array($innerNumbers) || count($innerNumbers) === 0) { + $longSnapshot = $this->b24->getCache('inner_numbers_LONG'); + if (is_array($longSnapshot) && count($longSnapshot) > 0) { + $innerNumbers = $longSnapshot; + $this->logger->writeInfo('inner_numbers restored from LONG snapshot. count=' . count($longSnapshot)); + } + } if (is_array($innerNumbers) && count($innerNumbers) > 0) { $this->inner_numbers = (array)$innerNumbers; } else { @@ -704,6 +715,29 @@ private function actionCompleteCdr($data):void // Рандомно назначаем ответственного для пропущенного. $responsible = $USER_ID; }else{ + // Расширенная диагностика причины: чаще всего сюда попадают + // плечи входящих звонков, у которых dst_num — реальный + // внутренний номер оператора, но он отсутствует в карте + // inner_numbers/mobile_numbers на момент обработки CDR + // (см. inner_numbers_diff в IntegrationAMI.log). + $this->logger->writeInfo([ + 'linkedid' => $linkedId, + 'src_num' => $srcNum, + 'dst_num' => $dstNum, + 'USER_ID' => $USER_ID, + 'srsUserId' => $srsUserId, + 'dstUserId' => $dstUserId, + 'isMissed' => $isMissed, + 'isOutgoing' => $isOutgoing, + 'isVoicemail' => $isVoicemail, + 'isOrphanIncoming' => $isOrphanIncoming, + 'GLOBAL_STATUS' => $data['GLOBAL_STATUS'] ?? '', + 'disposition' => $data['disposition'] ?? '', + 'responsibleMissedCalls' => $this->responsibleMissedCalls, + 'inner_numbers_count' => count($this->inner_numbers), + 'dst_in_inner_numbers' => isset($this->inner_numbers[$dstNum]), + 'src_in_inner_numbers' => isset($this->inner_numbers[$srcNum]), + ], 'CancellationContext'); $this->logger->writeInfo("The responsible person was not found. cancellation ".$linkedId); return; } From 4d0520e1ea2d35a234a565ef26a1be627702a9cd Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Wed, 13 May 2026 16:04:28 +0300 Subject: [PATCH 15/20] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4:=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=20per-leg?= =?UTF-8?q?=20CALL=5FID=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BB=D0=B5=D1=87=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Откат регрессии c0de8d3 (24.02.2026 "Перенос проверки дедупликации finish до обработки ARGS_REGISTER"). После того коммита finishOneKey-дедуп сравнивал ОРИГИНАЛЬНЫЙ CALL_ID (первого плеча) ДО подмены через ARGS_REGISTER, поэтому второе плечо при переводе всегда уходило в "Finish already sent, attaching record" — на одном CALL_ID в B24 оставались: USER_ID первого ответившего (а не реального собеседника после перевода) и только одна запись (последний attachRecord на тот же CALL_ID затирал предыдущий → терялась запись плеча до перевода). Кейс из лога 13.05.2026: - 79101755605: 298 (52с) → перевод → 220 (202с). В B24 уезжал finish с USER_ID=22 DURATION=52 и две attachRecord на один CALL_ID; в карточке звонка оставалась запись плеча после перевода и USER_ID плеча до перевода — клиент видел "неверного ответственного" и "запись звонка до перевода отсутствует". - 79535451601: 216 (53с) → перевод → 223 (72с) — то же самое. Изменения: - Lib/Bitrix24Integration::telephonyExternalCallFinish: блок ARGS_REGISTER_ вернулся на место — ДО finishOneKey-дедупа. Для второго (и далее) плеча HTTP-воркер уже шлёт отдельный telephony.externalcall.register (WorkerBitrix24IntegrationHTTP:609), B24 возвращает свой CALL_ID; теперь finishOneKey сравнивает именно его, finish уходит в B24, и на один MikoPBX-звонок создаётся отдельная карточка звонка на каждое плечо со своим USER_ID, DURATION и записью. - Проверка file_exists ослаблена до file_exists || backgroundUpload (синхронизировано с веткой attachRecord ниже), иначе при включённом асинхронном UploaderB24 второе плечо снова попадало бы в дедуп по старому CALL_ID. - bin/WorkerBitrix24IntegrationHTTP: DESCRIPTION карточки звонка обогащён метаданными плеча (USER_PHONE_INNER, DURATION, disposition) — чтобы при переводе разные activity в B24 визуально различались. --- Lib/Bitrix24Integration.php | 61 +++++++++++++++++---------- bin/WorkerBitrix24IntegrationHTTP.php | 19 ++++++++- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/Lib/Bitrix24Integration.php b/Lib/Bitrix24Integration.php index 5ba2af4..20d45de 100644 --- a/Lib/Bitrix24Integration.php +++ b/Lib/Bitrix24Integration.php @@ -1483,36 +1483,37 @@ public function telephonyExternalCallFinish($options, &$tmpCallsData): array } $this->saveCache($legDedupKey, true, 30); - // 2) Учитываем плечо в "best leg" (по DURATION) — даже если finish в B24 не уйдёт - // (B24 принимает только первый externalcall.finish на CALL_ID, остальные отвергает - // с "Call is not found"). best leg используется в PostFinish для назначения - // ASSIGNED_BY_ID реальному собеседнику, а не первому ответившему. + // 2) Учитываем плечо в "best leg" (по DURATION). Best leg используется в + // PostFinish для назначения ASSIGNED_BY_ID лида/контакта/сделки реальному + // собеседнику, а не первому ответившему до перевода. $duration = isset($options['DURATION']) ? (int)$options['DURATION'] : 0; $userId = isset($options['USER_ID']) ? (string)$options['USER_ID'] : ''; $this->updateBestLegForCall($id, $userId, $duration); - // 3) Дедуп ЗВОНКА (CALL_ID): в B24 уходит только первый finish для этого звонка. - // Для последующих плеч прикрепляем только запись (запись хранится по CALL_ID). - $finishOneKey = self::API_CALL_FINISH.'_'.$callId; - if($this->getCache($finishOneKey)){ - if ($options['export_records'] && !empty($options['FILE']) && ($this->backgroundUpload || file_exists($options['FILE']))) { - $this->mainLogger->writeInfo($options, "Finish already sent, attaching record ($callId)."); - $this->telephonyExternalCallAttachRecord($options['FILE'], $callId, $arg); - } else { - $this->mainLogger->writeInfo($options, "The challenge has already been finish earlier ($callId)."); - } - return [$arg,$finishKey]; - } - $this->saveCache($finishOneKey, true, 30); - /////////////////////////////////////////////////////////////// - // Проверим, была ли уже отправлена запись разговора. + // 3) Подмена $callId через ARGS_REGISTER_ ДО дедупа finishOneKey. + // Для второго (и далее) плеча HTTP-воркер уже отправил отдельный + // telephony.externalcall.register (см. WorkerBitrix24IntegrationHTTP.php:609 + // ветка transfer: $tmpCallsData[...]['ARGS_REGISTER_'.$UNIQUEID]) — на каждое + // плечо B24 возвращает СВОЙ CALL_ID. Без подмены ниже finishOneKey-дедуп + // сравнивает оригинальный CALL_ID (первого плеча) и схлопывает все + // последующие плечи в attachRecord (Bitrix24 хранит один файл на CALL_ID, + // и записи плеч до перевода теряются, а в карточке звонка USER_ID остаётся + // от первого ответившего). С per-leg CALL_ID в B24 на один MikoPBX-звонок + // создаётся отдельная карточка звонка на каждое плечо: свой USER_ID, своя + // запись, свой DURATION. Регрессия c0de8d3 (24.02.2026) отменена. $callData = &$tmpCallsData[$id]; - $regData = $tmpCallsData[$id]['ARGS_REGISTER_'.$options['UNIQUEID']]??[]; - if( file_exists($options['FILE']) && + $regData = $tmpCallsData[$id]['ARGS_REGISTER_'.$options['UNIQUEID']]??[]; + // file_exists || backgroundUpload — выравниваем с проверкой ниже в attachRecord: + // при асинхронной загрузке (backgroundUpload=1) запись плеча ещё не лежит на + // диске в момент finish, но UploaderB24 догрузит её позже. Без || backgroundUpload + // блок не срабатывал бы, и второе плечо снова попадало бы в дедуп по первому + // CALL_ID и теряло свою карточку. + $fileReady = !empty($options['FILE']) && ($this->backgroundUpload || file_exists($options['FILE'])); + if( $fileReady && isset($callData['MAIN_FILE']) && !isset($callData['FILE_'.$options['UNIQUEID']] ) && !empty($regData)) { - // Нужно зарегистрировать новый вызов + // Подключаем register этого плеча к нашему batch и берём из него CALL_ID. [$arg, $key] = $tmpCallsData[$id]['ARGS_REGISTER_'.$options['UNIQUEID']]; $callId = '$result['.$key.'][CALL_ID]'; $callData['FILE_'.$options['UNIQUEID']] = $options['FILE']; @@ -1520,7 +1521,21 @@ public function telephonyExternalCallFinish($options, &$tmpCallsData): array if(!isset($callData['MAIN_FILE'])){ $callData['MAIN_FILE'] = $options['FILE']; } - // + + // 4) Дедуп ЗВОНКА по подменённому CALL_ID. Для первого плеча — оригинальный + // CALL_ID; для второго — новый (из ARGS_REGISTER), значит в кеше его нет, + // и finish пройдёт в B24 как самостоятельный звонок. + $finishOneKey = self::API_CALL_FINISH.'_'.$callId; + if($this->getCache($finishOneKey)){ + if ($options['export_records'] && !empty($options['FILE']) && ($this->backgroundUpload || file_exists($options['FILE']))) { + $this->mainLogger->writeInfo($options, "Finish already sent, attaching record ($callId)."); + $this->telephonyExternalCallAttachRecord($options['FILE'], $callId, $arg); + } else { + $this->mainLogger->writeInfo($options, "The challenge has already been finish earlier ($callId)."); + } + return [$arg,$finishKey]; + } + $this->saveCache($finishOneKey, true, 30); /////////////////////////////////////////////////////////////// $userId = (intval($callDataFromDB['answer']??0) === 1) ? $callDataFromDB['user_id']??'' : ''; // Если register уйдёт в одном batch — finish должен ссылаться на его результат. diff --git a/bin/WorkerBitrix24IntegrationHTTP.php b/bin/WorkerBitrix24IntegrationHTTP.php index bab8bfc..b55ae3e 100644 --- a/bin/WorkerBitrix24IntegrationHTTP.php +++ b/bin/WorkerBitrix24IntegrationHTTP.php @@ -722,7 +722,24 @@ private function addDataToQueue(array $data): void $this->q_req = array_merge($this->q_req, $arg); if(!empty($finishKey)){ - $arg = $this->b24->crmActivityUpdate('$result['.$finishKey.'][CRM_ACTIVITY_ID]', $data['linkedid'], $data['linkedid']); + // Обогащаем DESCRIPTION плеча, чтобы при переводе разные + // карточки звонка в B24 (per-leg, см. Bitrix24Integration:: + // telephonyExternalCallFinish: ARGS_REGISTER + finishOneKey) + // визуально различались: видно, какой внутренний номер + // обработал плечо, сколько говорил и чем закончилось. + $descParts = [(string)$data['linkedid']]; + $inner = (string)($data['USER_PHONE_INNER'] ?? ''); + $duration = (string)($data['DURATION'] ?? ''); + $dispo = (string)($data['disposition'] ?? ''); + if ($inner !== '' || $duration !== '' || $dispo !== '') { + $legParts = []; + if ($inner !== '') { $legParts[] = "leg=$inner"; } + if ($duration !== '') { $legParts[] = "{$duration}s"; } + if ($dispo !== '') { $legParts[] = $dispo; } + $descParts[] = implode(', ', $legParts); + } + $description = implode(' | ', $descParts); + $arg = $this->b24->crmActivityUpdate('$result['.$finishKey.'][CRM_ACTIVITY_ID]', $data['linkedid'], $description); $this->q_req = array_merge($this->q_req, $arg); } } From dc895a149e7899815013807029401dddbb7be678 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Wed, 13 May 2026 16:10:58 +0300 Subject: [PATCH 16/20] =?UTF-8?q?per-leg=20finish:=20=D0=B7=D0=B0=D1=89?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=20=D0=BE=D1=82=20=D0=BF=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20ARGS=5FREGISTER=20(=D0=B4=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=20register)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit telephonyExternalCallRegister при повторном вызове с тем же USER_ID/CALL_START_DATE/PHONE_NUMBER/USER_PHONE_INNER возвращает [[],''] из-за дедупа tmp180_call_register_*. HTTP-воркер всё равно кладёт этот кортеж в $tmpCallsData[$id]['ARGS_REGISTER_'], поэтому проверка !empty($regData) проходила, и в подмене callId получалось $result[][CALL_ID] — невалидная batch-ссылка, B24 ответил бы "Call is not found". Защита: hasFreshRegister = !empty($regData[1]) (т.е. $key из register не пустой). Если ключ пуст — оставляем оригинальный $callId и идём по старой fallback-ветке с attachRecord на главный CALL_ID звонка. Также деструктуризацию переписал на уже прочитанный $regData, чтобы не ходить ещё раз в $tmpCallsData[$id][...] (мелкая чистка). --- Lib/Bitrix24Integration.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Lib/Bitrix24Integration.php b/Lib/Bitrix24Integration.php index 20d45de..4d956a5 100644 --- a/Lib/Bitrix24Integration.php +++ b/Lib/Bitrix24Integration.php @@ -1510,11 +1510,17 @@ public function telephonyExternalCallFinish($options, &$tmpCallsData): array // блок не срабатывал бы, и второе плечо снова попадало бы в дедуп по первому // CALL_ID и теряло свою карточку. $fileReady = !empty($options['FILE']) && ($this->backgroundUpload || file_exists($options['FILE'])); + // Проверяем, что register не отдедупился: при повторе он возвращает [[],''] — + // если подставить $key='' в "$result[][CALL_ID]", получится невалидная + // batch-ссылка, и B24 отвергнет finish с "Call is not found". В таком случае + // оставляем оригинальный $callId и проваливаемся в обычный дедуп-attachRecord + // (поведение fallback, как до возврата per-leg). + $hasFreshRegister = !empty($regData[1] ?? ''); if( $fileReady && isset($callData['MAIN_FILE']) && !isset($callData['FILE_'.$options['UNIQUEID']] ) && - !empty($regData)) { + $hasFreshRegister) { // Подключаем register этого плеча к нашему batch и берём из него CALL_ID. - [$arg, $key] = $tmpCallsData[$id]['ARGS_REGISTER_'.$options['UNIQUEID']]; + [$arg, $key] = $regData; $callId = '$result['.$key.'][CALL_ID]'; $callData['FILE_'.$options['UNIQUEID']] = $options['FILE']; } From a36b82a24cc441cc32497efe53cc4902ba138d40 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Wed, 13 May 2026 16:20:10 +0300 Subject: [PATCH 17/20] =?UTF-8?q?finish-=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20=D0=B2=20B24:=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B8=D0=B2=D1=88=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=B5=D1=87=D0=B8=20+=20=D0=B4=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=20=D0=B7=D0=B2=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бизнес-требование клиента после повторного тестирования 13.05: - если звонок отвечен сотрудником → в B24 уходят ТОЛЬКО ответившие плечи; - если звонок никем не отвечен → в B24 уходит РОВНО ОДИН пропущенный. До правки на entity-журнал B24 текли «фантомные» карточки с DURATION=0: plечи IVR/queue, второй оператор при переводе с disposition=NOANSWER — все они уходили через telephonyExternalCallFinish и появлялись отдельной строкой с неверным RESPONSIBLE_ID, при этом register для них уже не успевал прицепиться к одному CALL_ID и портил картину. Изменения: 1) WorkerBitrix24IntegrationHTTP, ветка telephonyExternalCallFinish: - старая защита (alreadyAnswered && GLOBAL_STATUS!=ANSWERED) расширена до alreadyAnswered && disposition!=ANSWERED — теперь отбрасываются и те плечи, у которых AMI оставил GLOBAL_STATUS=ANSWERED (статус звонка-целого), но само плечо disposition=NOANSWER (плечо-перевод, которое не взяли). register для них тоже не уходит — он попадает в q_req только из telephonyExternalCallFinish через ARGS_REGISTER. - Добавлен дедуп пропущенных по 'b24-missed-finish-'. При втором (и далее) NOANSWER-плече того же звонка finish пропускается. TTL 3 часа — выровнен с b24-answered. 2) WorkerBitrix24IntegrationAMI, actionCompleteCdr ветка "$isMissed && !empty($USER_ID)": $finishKeyID переведён с UNIQUEID на linkedid — дедуп срабатывает уже на стороне AMI, в beanstalk-очереди не плодятся N missed-эвентов на один звонок. --- bin/WorkerBitrix24IntegrationAMI.php | 5 +++ bin/WorkerBitrix24IntegrationHTTP.php | 59 ++++++++++++++++++++------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/bin/WorkerBitrix24IntegrationAMI.php b/bin/WorkerBitrix24IntegrationAMI.php index 4fe2ba2..429b109 100644 --- a/bin/WorkerBitrix24IntegrationAMI.php +++ b/bin/WorkerBitrix24IntegrationAMI.php @@ -714,6 +714,11 @@ private function actionCompleteCdr($data):void }elseif ($isMissed && !empty($USER_ID) ){ // Рандомно назначаем ответственного для пропущенного. $responsible = $USER_ID; + // Дедуп — по linkedid, а не по UNIQUEID: при нескольких NOANSWER- + // плечах одного звонка в B24 должна попадать ровно одна карточка + // «пропущенного», иначе для очереди из N операторов уехало бы + // N missed-карточек. + $finishKeyID = 'finish-cdr-'.$linkedId; }else{ // Расширенная диагностика причины: чаще всего сюда попадают // плечи входящих звонков, у которых dst_num — реальный diff --git a/bin/WorkerBitrix24IntegrationHTTP.php b/bin/WorkerBitrix24IntegrationHTTP.php index b55ae3e..7bb7735 100644 --- a/bin/WorkerBitrix24IntegrationHTTP.php +++ b/bin/WorkerBitrix24IntegrationHTTP.php @@ -697,27 +697,56 @@ private function addDataToQueue(array $data): void $this->q_req = array_merge($this->q_req, ...$tmpArr); } } elseif ('telephonyExternalCallFinish' === $data['action']) { - // Защита от orphan-leg'ов: если звонок уже был реально отвечен - // (action_dial_answer пришёл), NOANSWER-finish для другого - // (IVR/queue) leg'а — это служебный «осколок», не звонок. - // Пропускаем его целиком — иначе он уходит в B24 первым с - // USER_ID=responsibleMissedCalls и DURATION=0, перетирая статус - // и ответственного у уже-отвеченного звонка. Запись разговора - // для orphan-leg'а (короткий IVR-фрагмент) тоже не прикрепляется — - // это устраняет «фантомные» аудио у пропущенных. См. логи - // 79245067790@2026-05-08 12:52 и 79636988999@16:36. + // Политика отправки finish в B24 для входящих звонков: // - // Источник маркера answered — Redis (через b24->getCache): - // переживает рестарт HTTP-воркера, согласован с Beanstalk-очередью, - // которая тоже persistent. + // 1) Если звонок В ЦЕЛОМ был отвечен сотрудником (action_dial_answer + // пришёл хотя бы для одного плеча → маркер b24-answered-): + // в B24 должны попасть ТОЛЬКО плечи, которые сами ответили + // (disposition=ANSWERED). Любое плечо с disposition!=ANSWERED + // (IVR/queue-orphan, второй оператор-перевод, который не взял + // трубку) — отбрасываем целиком: register для него тоже не + // уйдёт (он лежит в tmpCallsData[ARGS_REGISTER_] + // и попадает в q_req только из telephonyExternalCallFinish). + // Без этого в журнале телефонии B24 появлялись «фантомные» + // карточки с DURATION=0 и неверным RESPONSIBLE_ID, перетирающие + // реальный статус. См. кейсы 79245067790@2026-05-08 12:52, + // 79636988999@16:36 и 79101755605@2026-05-13 10:01. + // + // 2) Если звонок ни один сотрудник не отвечал — отправляем РОВНО + // ОДИН finish на linkedid (на первое NOANSWER-плечо), остальные + // плечи того же звонка дедуплицируются по + // 'b24-missed-finish-'. Иначе в B24 на один MikoPBX- + // звонок плодились бы N карточек «пропущенных». + // + // Маркеры в Redis (через b24->getCache/saveCache) переживают + // рестарт HTTP-воркера и согласованы с persistent Beanstalk-очередью. $alreadyAnswered = !empty($this->b24->getCache('b24-answered-' . $data['linkedid'])); - $globalStatus = (string)($data['GLOBAL_STATUS'] ?? ''); - if ($alreadyAnswered && $globalStatus !== 'ANSWERED') { + $disposition = (string)($data['disposition'] ?? ''); + $globalStatus = (string)($data['GLOBAL_STATUS'] ?? ''); + $isLegAnswered = ($disposition === 'ANSWERED'); + $missedDedupKey = 'b24-missed-finish-' . $data['linkedid']; + + $skipReason = ''; + if ($alreadyAnswered && !$isLegAnswered) { + // Случай (1): звонок ответил другой leg, этот — лишний. + $skipReason = 'call was answered, this leg disp='.$disposition.' global='.$globalStatus; + } elseif (!$isLegAnswered && $this->b24->getCache($missedDedupKey)) { + // Случай (2): missed-finish для linkedid уже отправлен. + $skipReason = 'missed finish already sent for linkedid (one per call)'; + } + + if ($skipReason !== '') { $this->b24->mainLogger->writeInfo( $data, - "Skip orphan NOANSWER finish (call was answered): {$data['linkedid']}" + "Skip leg finish ($skipReason): {$data['linkedid']}" ); } else { + if (!$isLegAnswered) { + // Маркер для последующих missed-плеч того же звонка. + // TTL 3 часа — как у b24-answered (одна и та же логическая + // «продолжительность звонка»). + $this->b24->saveCache($missedDedupKey, true, 10800); + } [$arg,$finishKey] = $this->b24->telephonyExternalCallFinish($data, $this->tmpCallsData); $this->q_req = array_merge($this->q_req, $arg); From 35977fab66d5bb7ce36afb97fe3a14233d4c0051 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Wed, 13 May 2026 16:46:24 +0300 Subject: [PATCH 18/20] =?UTF-8?q?queue/transfer=20UI:=20hide=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B5=20+=20register=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бизнес-требования после ревью клиента: 1) Звонок в очередь: один register + show у каждого участника (по open_card_mode). Когда оператор ответил — у остальных карточка должна сразу закрыться (hide), не дожидаясь их action_hangup_chan. В очередях, где Asterisk не успевает прислать CANCEL → hangup_chan (или этот эвент теряется), карточка у не-ответивших висела до самого конца разговора. 2) Дополнительный register создаётся ТОЛЬКО при переадресации, чтобы у каждого transfer-плеча в B24 была своя карточка звонка с правильным USER_ID/DURATION/записью. Для участников очереди дополнительный register не нужен — это только засоряет tmpCallsData воркера и потенциально давал второй register-запрос в B24. Признак transfer'а — наличие маркера b24-answered- в Redis в момент прихода register-эвента (action_dial_answer хотя бы одного плеча уже прошёл). До первого ответа звонок считается «в очереди». bin/WorkerBitrix24IntegrationHTTP.php: - Ветка register-эвента второго+ плеча: ARGS_REGISTER_ сохраняется только при isTransfer=true (b24-answered есть). - Ветка action_dial_answer: после установки b24-answered обходим все ARG_REGISTER_USER_ и шлём telephony.externalcall.hide для каждого участника, кроме ответившего (по UNIQUEID и по USER_ID, если один сотрудник зарегистрирован под несколькими UNIQUEID). Дедуп USER_ID через локальный map $hiddenUsers. Маркер b24-hide-others- (TTL 3 ч) блокирует повтор при втором action_dial_answer (transferee ответил — операторов очереди второй раз скрывать не надо). --- bin/WorkerBitrix24IntegrationHTTP.php | 61 ++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/bin/WorkerBitrix24IntegrationHTTP.php b/bin/WorkerBitrix24IntegrationHTTP.php index 7bb7735..21d1fd6 100644 --- a/bin/WorkerBitrix24IntegrationHTTP.php +++ b/bin/WorkerBitrix24IntegrationHTTP.php @@ -606,7 +606,20 @@ private function addDataToQueue(array $data): void } }else{ $this->tmpCallsData[$data['linkedid']]['ARG_REGISTER_USER_'.$data['UNIQUEID']] = $data['USER_ID']??''; - $this->tmpCallsData[$data['linkedid']]['ARGS_REGISTER_'.$data['UNIQUEID']] = $this->b24->telephonyExternalCallRegister($data); + // Политика "один register на звонок": + // - очередь (звонок ещё не отвечен): доп. register НЕ создаём, + // для каждого участника очереди открываем общую карточку + // через telephony.externalcall.show на оригинальном CALL_ID; + // - переадресация (b24-answered- уже стоит — был + // action_dial_answer): создаём дополнительный register, чтобы + // у плеча-получателя перевода в B24 появилась СВОЯ карточка + // звонка со своим USER_ID/DURATION/записью. Этот register + // попадает в q_req только в момент finish'а через ARGS_REGISTER + // в Lib/Bitrix24Integration::telephonyExternalCallFinish. + $isTransfer = !empty($this->b24->getCache('b24-answered-' . $data['linkedid'])); + if ($isTransfer) { + $this->tmpCallsData[$data['linkedid']]['ARGS_REGISTER_'.$data['UNIQUEID']] = $this->b24->telephonyExternalCallRegister($data); + } $data['CALL_ID'] = $this->b24->resolveBatchCallId((string)$callId); if($this->needShowCardDirectly($data['USER_ID'])){ $arg = $this->b24->telephonyExternalCallShow($data); @@ -651,6 +664,52 @@ private function addDataToQueue(array $data): void $this->b24->saveCache('b24-answered-' . $data['linkedid'], true, 10800); $tmpArr = []; $userId = $this->tmpCallsData[$data['linkedid']]['ARG_REGISTER_USER_'.$data['UNIQUEID']]??''; + + // Очередь: при ответе одного оператора у всех остальных участников, + // которым через register (TYPE=2/первое плечо) и show открыта общая + // карточка звонка, она должна сразу закрыться. Раньше hide шёл + // только в action_hangup_chan канала каждого участника — в очередях, + // где Asterisk не успевал прислать CANCEL → hangup_chan, карточка + // у не-ответивших висела до самого конца звонка. + // + // Маркер b24-hide-others- защищает от повторной рассылки + // при втором action_dial_answer (после перевода ответит ещё один + // сотрудник, но «остальных» в очереди скрывать второй раз не надо). + $linkedCallId = $this->tmpCallsData[$data['linkedid']]['CALL_ID'] ?? ''; + $hideOthersKey = 'b24-hide-others-' . $data['linkedid']; + if (!empty($linkedCallId) && !$this->b24->getCache($hideOthersKey)) { + $resolvedCallId = $this->b24->resolveBatchCallId((string)$linkedCallId); + $answeredUniqueId = (string)($data['UNIQUEID'] ?? ''); + $hiddenUsers = []; + $hideEmitted = false; + foreach ($this->tmpCallsData[$data['linkedid']] as $tKey => $tValue) { + if (strpos($tKey, 'ARG_REGISTER_USER_') !== 0) { + continue; + } + $uid = substr($tKey, strlen('ARG_REGISTER_USER_')); + if ($uid === $answeredUniqueId) { + continue; // плечо, которое только что ответило + } + $regUserId = (string)$tValue; + if ($regUserId === '' || $regUserId === (string)$userId) { + continue; // тот же сотрудник по другому UNIQUEID — не скрываем + } + if (isset($hiddenUsers[$regUserId])) { + continue; + } + $hiddenUsers[$regUserId] = true; + $tmpArr[] = $this->b24->telephonyExternalCallHide([ + 'CALL_ID' => $resolvedCallId, + 'USER_ID' => (int)$regUserId, + 'linkedid' => $data['linkedid'], + ]); + $hideEmitted = true; + } + if ($hideEmitted) { + $this->b24->saveCache($hideOthersKey, true, 10800); + } + } + $dealId = ''; $leadId = ''; $filter = [ From 25a99cce2baf200fc7dc04aed633e7bc86456ee3 Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Wed, 13 May 2026 20:10:44 +0300 Subject: [PATCH 19/20] =?UTF-8?q?register/show:=20=D0=BF=D1=80=D0=BE=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=20=D0=BF=D0=BB=D0=B5=D1=87=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20B24-=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AMI-воркер: если dst_num настроен в модуле (usersSettingsB24), но в B24 не привязан к пользователю — USER_ID пуст, register не отправляется (бесхозная карточка в B24 не создаётся). HTTP-воркер: страховка — telephony.externalcall.show не вызывается при пустом USER_ID (аналогично защите в action_hangup_chan). CLAUDE.md: задокументированы register policy, расхождение inner_numbers vs usersSettingsB24 и Bitrix24Integration.log с inner_numbers_diff. --- CLAUDE.md | 16 +++++++++++++++- bin/WorkerBitrix24IntegrationAMI.php | 7 +++++++ bin/WorkerBitrix24IntegrationHTTP.php | 4 +++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 329f7b8..0e39d5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,20 @@ GET /pbxcore/api/bitrix-integration/workers/state (без авторизации - `telephony.externalcall.show` — отдельный вызов для открытия карточки остальным участникам очереди - `open_card_mode`: `DIRECTLY` (сразу), `ANSWERED` (при ответе), `NONE` (никогда) — настройка per-user в `ModuleBitrix24Users` +### Register policy: one register per call + +`addDataToQueue` (HTTP-воркер): первый `telephonyExternalCallRegister` для `linkedid` → реальный `telephony.externalcall.register` в B24 (создаёт CALL_ID). Последующие события того же linkedid: +- очередь (нет маркера `b24-answered-`) → `telephony.externalcall.show` на оригинальном CALL_ID; +- перевод (маркер стоит) → новый register, кладётся в `ARGS_REGISTER_` и уходит только при finish'е этого плеча (per-leg CALL_ID). + +При отладке: «много register» в `IntegrationAMI.log` ≠ много REST-запросов в B24. Считать REST по REQUEST'ам в `HttpConnection.log`. + +### Two sources of "internal number → user" mapping + +- **`inner_numbers`** — карта B24 user.get (`UF_PHONE_INNER` → `{ID, NAME, EMAIL, ...}`). Источник USER_ID для register/finish/show/hide. Обновляется `b24GetPhones` раз в 5 мин на ping AMI. Защита от полного обнуления есть, от частичного пропуска сотрудника — нет (см. `inner_numbers_diff` в `Bitrix24Integration.log`). +- **`usersSettingsB24`** — таблица модуля (`ModuleBitrix24Users`): `inner_phone` → `{user_id: MikoPBX-id, open_card_mode}`. Источник режима открытия карточки. +- Расхождение возможно: модуль знает номер (есть в `usersSettingsB24`), а в B24 этому номеру не присвоен `UF_PHONE_INNER`/`PERSONAL_MOBILE`/`WORK_PHONE` → `inner_numbers[$num]` пуст → USER_ID="" в register. AMI-воркер `actionCreateCdr` фильтрует такие плечи: `Skip register` (`WorkerBitrix24IntegrationAMI.php:518`). В `addDataToQueue` есть зеркальная страховка: `show` не отправляется с пустым USER_ID. + ### Orphan-leg фильтр в HTTP-воркере Для входящих звонков AMI-воркер шлёт `telephonyExternalCallFinish` на каждое CDR-плечо. Плечи, не дошедшие до оператора (IVR/queue), помечаются `USER_ID=responsibleMissedCalls`, `GLOBAL_STATUS=NOANSWER`. Эти эвенты могут опередить ANSWERED-finish реального оператора и переписать статус звонка на «пропущен». @@ -142,5 +156,5 @@ HTTP-воркер фильтрует их: при `action_dial_answer` став ## Logs -Логи воркеров: `ConnectorDb.log`, `HttpConnection.log`, `HttpConnection_SYNC.log`, `IntegrationAMI.log`. +Логи воркеров: `ConnectorDb.log`, `HttpConnection.log`, `HttpConnection_SYNC.log`, `IntegrationAMI.log`, `Bitrix24Integration.log` (сюда пишется `inner_numbers_diff` — added/removed между снимками b24GetPhones). Формат: `[ISO8601][level] message(pid): JSON`. Для анализа звонка — искать по `linkedid` во всех логах. diff --git a/bin/WorkerBitrix24IntegrationAMI.php b/bin/WorkerBitrix24IntegrationAMI.php index 429b109..8fdb955 100644 --- a/bin/WorkerBitrix24IntegrationAMI.php +++ b/bin/WorkerBitrix24IntegrationAMI.php @@ -508,6 +508,13 @@ private function actionCreateCdr($data, $general_src_num):void $userId = $this->inner_numbers[$data['dst_num']]['ID'] ?? ''; } $inner = $data['dst_num']; + // dst_num настроен в модуле, но в B24 не привязан к пользователю — + // open карточки некому. Register с пустым USER_ID создаёт бесхозную + // карточку в B24; в очереди остальные плечи отработают своими dial'ами. + if ($userId === '') { + $this->logger->writeInfo("Internal $inner is configured in module but not assigned to any B24 user (empty USER_ID). Skip register. $linkedId"); + return; + } $this->logger->writeInfo("This is an incoming call to an employee's internal number. $linkedId"); $createLead = ($this->leadType !== Bitrix24Integration::API_LEAD_TYPE_OUT && $this->crmCreateLead)?'1':'0'; if (strlen($general_src_num) > $this->extensionLength && ! in_array($general_src_num, $this->extensions, true)) { diff --git a/bin/WorkerBitrix24IntegrationHTTP.php b/bin/WorkerBitrix24IntegrationHTTP.php index 21d1fd6..18eda05 100644 --- a/bin/WorkerBitrix24IntegrationHTTP.php +++ b/bin/WorkerBitrix24IntegrationHTTP.php @@ -621,7 +621,9 @@ private function addDataToQueue(array $data): void $this->tmpCallsData[$data['linkedid']]['ARGS_REGISTER_'.$data['UNIQUEID']] = $this->b24->telephonyExternalCallRegister($data); } $data['CALL_ID'] = $this->b24->resolveBatchCallId((string)$callId); - if($this->needShowCardDirectly($data['USER_ID'])){ + // Страховка: show с пустым USER_ID не имеет получателя + // (аналогичная защита есть в ветке action_hangup_chan). + if(!empty($data['USER_ID']) && $this->needShowCardDirectly($data['USER_ID'])){ $arg = $this->b24->telephonyExternalCallShow($data); } } From 0fd46dc85039d50e65ce1ea22212772b9fdf13cf Mon Sep 17 00:00:00 2001 From: Alexey Portnov Date: Thu, 14 May 2026 15:29:08 +0300 Subject: [PATCH 20/20] =?UTF-8?q?MTS=20import:=20=D0=BF=D1=80=D0=B8=D0=BA?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=BC=20mts=5Frec=5Fstatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Старые строки mts_cdr, записанные ModuleMtsPbx до апдейта схемы, имеют NULL в mts_rec_status — запись разговора не прикреплялась, хотя MP3 есть на диске. Решающим признаком сделан реально существующий файл. --- bin/WorkerBitrix24IntegrationHTTP.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bin/WorkerBitrix24IntegrationHTTP.php b/bin/WorkerBitrix24IntegrationHTTP.php index 18eda05..c83c7af 100644 --- a/bin/WorkerBitrix24IntegrationHTTP.php +++ b/bin/WorkerBitrix24IntegrationHTTP.php @@ -421,7 +421,12 @@ private function enqueueHistoricalCall(array $call): bool $exportRecords = false; $recStatus = (string)($call['mts_rec_status'] ?? ''); $recPath = (string)($call['recordingfile'] ?? ''); - if ($recStatus === 'ok' && $recPath !== '' && file_exists($recPath)) { + // Прикрепляем запись при status='ok' ИЛИ при пустом status: старые + // строки mts_cdr (записанные ModuleMtsPbx до апдейта схемы) имеют + // NULL в mts_rec_status, но recordingfile заполнен и MP3 лежит на + // диске. Решающий признак — реально существующий файл. 'pending' и + // 'gone' при этом отсекаются: у них file_exists даёт false либо путь пуст. + if (($recStatus === 'ok' || $recStatus === '') && $recPath !== '' && file_exists($recPath)) { $recordFile = $recPath; $exportRecords = $exportRecordsSetting; }