Skip to content

Develop#136

Merged
boffart merged 20 commits into
masterfrom
develop
May 18, 2026
Merged

Develop#136
boffart merged 20 commits into
masterfrom
develop

Conversation

@boffart
Copy link
Copy Markdown
Contributor

@boffart boffart commented May 18, 2026

No description provided.

Alexey Portnov and others added 20 commits April 30, 2026 14:21
…ow/hide

Когда register и finish (или attachRecord) уходят одним batch в Bitrix24,
ранее в payload подставлялся "голый" ключ register-метода
(telephony.externalcall.register_...) вместо batch-ссылки
$result[<key>][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[...] заменена на единый вызов хелпера.
…_PHONE_INNER

При синхронизации пользователей все «безномерные» (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 (если задан) вместо случайного
  пользователя.
Решает 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.
…пропущенные

Если входящий внешний звонок завершается до распределения на сотрудника
(абонент сбросил в IVR/очереди — GLOBAL_STATUS=ANSWERED, USER_ID пуст),
ранее actionCompleteCdr уходил в ветку "responsible person was not found"
и в B24 ничего не отправлялось. Лид и карточка пропущенного не появлялись.

Теперь такие звонки помечаются как пропущенные и регистрируются в B24
на пользователя из настройки responsibleMissedCalls. В finish-event
форсируем GLOBAL_STATUS=NOANSWER, чтобы B24 показал их в "Пропущенных".
Voicemail имеет dst_num='voicemail' (или dst_chan='VOICEMAIL') и пустой
USER_ID — без этого исключения он попал бы в новую ветку orphan-incoming
и потерял бы DURATION/disposition в finish-event. Voicemail уже
обрабатывается своим путём через isMissed=true и responsibleMissedCalls.
Дедупликация finish переведена с CALL_ID обратно на UNIQUEID
(как было после 751a39c) — иначе при переводе срабатывает только
finish первого ответившего, и ASSIGNED_BY_ID лида/контакта/сделки
остаётся за ним вместо того, кто реально завершил разговор.

Чтобы не плодить дубли activity в карточке (мотив d9bd25f), в
PostFinish трекаем "лучшее" плечо по DURATION через Redis: для
не-лучших плеч удаляем созданное activity и не трогаем CRM.
Запись разговора прикрепляется к общему CALL_ID для всех плеч —
обе части (до и после перевода) остаются доступны в карточке.
В 1.273 PostFinish удалял activity для "не-лучших" плеч. На практике
B24 принимает только первый externalcall.finish на CALL_ID (остальные
отвергает с "Call is not found") и activity создаёт только для него.
Получалось, что мой код удалял единственную existing activity —
звонок пропадал из B24 через несколько секунд.

Новая стратегия:
- дедуп ПЛЕЧА по UNIQUEID — блокирует только повторы AMI-эвента;
- updateBestLegForCall зовётся для каждого плеча (до дедупа CALL_ID),
  накапливая лучшее плечо по DURATION в Redis;
- дедуп ЗВОНКА по CALL_ID — finish в B24 уходит ровно один;
- PostFinish больше не удаляет activity. ASSIGNED_BY_ID лида/контакта/
  сделки обновляется на best USER_ID (а не на response.PORTAL_USER_ID),
  если best известен — иначе fallback на PORTAL_USER_ID.

Итог: звонок в B24 не пропадает (activity сохраняется), а ответственным
в CRM-сущностях становится реально завершивший разговор оператор.
Опциональный фоновый импорт CDR из mts_cdr в Bitrix24 (галка в админке,
видна только при установленном ModuleMtsPbx). Cron каждые 5 минут читает
ORM ModuleMtsPbx\Models\CallHistory напрямую, пачками по 10 шлёт invoke
в HTTP-воркер, тот кладёт register+finish в общую q_req — звонки уходят
обычным sendBatch'ем как обычные AMI-эвенты. До 300 записей за прогон,
пауза 2-5 сек между пачками, защита от повторного запуска через flock.

Резолв сотрудника/направления делает HTTP-воркер по inner_numbers +
mobile_numbers (в MTS-CDR могут фигурировать мобильные номера). ACK-
протокол: not_ready при холодных картах B24 (cron не двигает курсор);
ok после приёма. Строгий dedup через новый
ConnectorDb::getExportedCallIdByLinkedId — без leg-семантики.

mts_import_last_id добавлен в $syncKeys, чтобы апдейты курсора не
триггерили onAfterModuleEnable каждые 5 минут.
После 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-<linkedid> в 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.
…available

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-импорта.
Изначально 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 обновлён.
…е удаление

Контроллер: проверка `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-портала.
…орректно

В 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.
…в логах

Корень проблемы (кейс 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) — диагностика причины.
Откат регрессии 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_<UNIQUEID> вернулся на место — ДО 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 визуально различались.
telephonyExternalCallRegister при повторном вызове с тем же
USER_ID/CALL_START_DATE/PHONE_NUMBER/USER_PHONE_INNER возвращает [[],'']
из-за дедупа tmp180_call_register_*. HTTP-воркер всё равно кладёт этот
кортеж в $tmpCallsData[$id]['ARGS_REGISTER_<UNIQUEID>'], поэтому
проверка !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][...] (мелкая чистка).
…звонку

Бизнес-требование клиента после повторного тестирования 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-<linkedid>'. При
     втором (и далее) NOANSWER-плече того же звонка finish пропускается.
     TTL 3 часа — выровнен с b24-answered.
2) WorkerBitrix24IntegrationAMI, actionCompleteCdr ветка
   "$isMissed && !empty($USER_ID)": $finishKeyID переведён с UNIQUEID на
   linkedid — дедуп срабатывает уже на стороне AMI, в beanstalk-очереди
   не плодятся N missed-эвентов на один звонок.
…реводе

Бизнес-требования после ревью клиента:

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-<linkedid> в Redis
в момент прихода register-эвента (action_dial_answer хотя бы одного
плеча уже прошёл). До первого ответа звонок считается «в очереди».

bin/WorkerBitrix24IntegrationHTTP.php:
- Ветка register-эвента второго+ плеча: ARGS_REGISTER_<UNIQUEID>
  сохраняется только при isTransfer=true (b24-answered есть).
- Ветка action_dial_answer: после установки b24-answered обходим все
  ARG_REGISTER_USER_<UNIQUEID> и шлём telephony.externalcall.hide для
  каждого участника, кроме ответившего (по UNIQUEID и по USER_ID,
  если один сотрудник зарегистрирован под несколькими UNIQUEID).
  Дедуп USER_ID через локальный map $hiddenUsers. Маркер
  b24-hide-others-<linkedid> (TTL 3 ч) блокирует повтор при втором
  action_dial_answer (transferee ответил — операторов очереди второй
  раз скрывать не надо).
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.
Старые строки mts_cdr, записанные ModuleMtsPbx до апдейта схемы, имеют
NULL в mts_rec_status — запись разговора не прикреплялась, хотя MP3 есть
на диске. Решающим признаком сделан реально существующий файл.
@boffart boffart merged commit 0ff6839 into master May 18, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant