/
mikopbx
/
ModuleBeelinePbx
Обзор
Документация
Войти
/
mikopbx
/
ModuleBeelinePbx
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
master
Lib/BeelineCdrHelper.php
299 строк
14 KB
boffart
Инициализация модуля ModuleBeelinePbx
08 июл 2026, 17:11
08 июл 2026, 17:11
1cc4e1a
Код
Авторство
О чём код?
<?php /* * MikoPBX - free phone system for small business * Copyright © 2017-2024 Alexey Portnov and Nikolay Beketov * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program. * If not, see <https://www.gnu.org/licenses/>. */ namespace Modules\ModuleBeelinePbx\Lib; use DateTime; /** * Чистые преобразования записи статистики Beeline (/v2/statistics) в строку CDR MikoPBX. * * Класс намеренно не обращается к БД/сети — это упрощает модульное тестирование. */ class BeelineCdrHelper { /** * Префикс UNIQUEID/linkedid — единственный признак «своих» строк в общей CDR MikoPBX. */ public const UNIQUEID_PREFIX = 'fs-beeline-'; /** * Аккаунт-маркер источника. */ public const FROM_ACCOUNT = 'fs-beeline'; /** * userId-суффикс SIP-транков Beeline (например SIP00KKBU00BP9@ip.beeline.ru). * Такие «абоненты» — это транковые ноги вызова, а не реальные сотрудники. */ public const TRUNK_USER_SUFFIX = '@ip.beeline.ru'; /** * Режимы отображения номера сотрудника в CDR. * EXTENSION — внутренний (добавочный) номер; MOBILE — мобильный (11-значный, 10→7XXXXXXXXXX). */ public const EMPLOYEE_FIELD_EXTENSION = 'extension'; public const EMPLOYEE_FIELD_MOBILE = 'mobile'; /** * Возвращает номер сотрудника для CDR согласно выбранному режиму. * Мобильный нормализуется (10 цифр → 11 добавлением 7). Если выбранного номера нет — * используется альтернативный (extension ↔ mobile), чтобы строка не осталась без атрибуции. * * ВАЖНО: мобильный сотрудника берём из userId (часть до «@» — это msisdn абонента), * а НЕ из abonent.phone: в ответе /v2/statistics поле abonent.phone содержит ВНЕШНИЙ * номер (phone_from/phone_to), а не собственный мобильный сотрудника. * * @param array $abonent объект abonent из ответа Beeline * @param string $mode EMPLOYEE_FIELD_EXTENSION | EMPLOYEE_FIELD_MOBILE */ public static function employeeNumber(array $abonent, string $mode): string { $extension = (string)($abonent['extension'] ?? ''); $mobile = self::employeeMobileFromUserId((string)($abonent['userId'] ?? '')); if ($mode === self::EMPLOYEE_FIELD_MOBILE) { return $mobile !== '' ? $mobile : $extension; } return $extension !== '' ? $extension : $mobile; } /** * Возвращает отображаемое имя сотрудника Beeline для CDR (src_name/dst_name). * * Имя в /v2/statistics лежит в abonent.firstName (напр. «m-Наталия», «m-Елизавета Пуглеева»); * lastName у сотрудников обычно пуст, у SIP-транков — наоборот (напр. «Sip-Art»). Склеиваем * оба поля через пробел и подчищаем крайние пробелы, чтобы покрыть оба случая. * * @param array $abonent объект abonent из ответа Beeline */ public static function employeeName(array $abonent): string { $first = trim((string)($abonent['firstName'] ?? '')); $last = trim((string)($abonent['lastName'] ?? '')); return trim($first . ' ' . $last); } /** * Извлекает мобильный номер сотрудника из userId (часть до «@»). * Для SIP-транков (userId вида SIP…@ip.beeline.ru) вернёт '' — там мобильного нет. */ public static function employeeMobileFromUserId(string $userId): string { $local = $userId; $at = strpos($userId, '@'); if ($at !== false) { $local = substr($userId, 0, $at); } if ($local === '' || !ctype_digit($local)) { return ''; } return BeelineApi::normalizePhone($local); } /** * Возвращает уникальный идентификатор звонка. * Предпочтительно externalTrackingId; при его отсутствии — детерминированный хэш * из стабильных полей (совпадающие строки дают одинаковый ключ → идемпотентность). */ public static function callUid(array $row, string $externalPhone): string { $ext = (string)($row['externalTrackingId'] ?? ''); if ($ext !== '') { return $ext; } $userId = (string)($row['abonent']['userId'] ?? ''); $start = (string)($row['startDate'] ?? ''); $direction = (string)($row['direction'] ?? ''); return 'h' . substr(md5($start . '_' . $userId . '_' . $externalPhone . '_' . $direction), 0, 20); } /** * Разбирает строку чёрного списка номеров в набор для сравнения. * * Разделители: перевод строки, запятая, точка с запятой. Для каждого номера: * - длинный (>=10 цифр) — сохраняется по последним 10 цифрам (внешние номера), * - короткий (<10 цифр) — сохраняется как есть (добавочные, напр. 2500). * * @param string $raw произвольный текст со списком номеров * @return array<string,bool> набор нормализованных ключей */ public static function parseExcludedNumbers(string $raw): array { $excluded = []; foreach (preg_split('/[\r\n,;]+/', $raw) as $token) { $digits = preg_replace('/\D+/', '', (string)$token); if ($digits === '') { continue; } $key = strlen($digits) >= 10 ? substr($digits, -10) : $digits; $excluded[$key] = true; } return $excluded; } /** * Проверяет, попадает ли отдельный номер в чёрный список. */ public static function isNumberExcluded(string $number, array $excluded): bool { if (empty($excluded)) { return false; } $digits = preg_replace('/\D+/', '', $number); if ($digits === '') { return false; } if (isset($excluded[$digits])) { return true; } return strlen($digits) >= 10 && isset($excluded[substr($digits, -10)]); } /** * Проверяет, участвует ли в звонке (добавочный, номер А или Б) исключённый номер. * * @param array $row запись статистики Beeline * @param array<string,bool> $excluded набор из parseExcludedNumbers() */ public static function isRowExcluded(array $row, array $excluded): bool { if (empty($excluded)) { return false; } $candidates = [ (string)($row['abonent']['extension'] ?? ''), (string)($row['phone_from'] ?? ''), (string)($row['phone_to'] ?? ''), ]; foreach ($candidates as $number) { if ($number !== '' && self::isNumberExcluded($number, $excluded)) { return true; } } return false; } /** * Определяет, является ли абонент строки SIP-транком (а не сотрудником). */ public static function isTrunkAbonent(array $row): bool { $userId = (string)($row['abonent']['userId'] ?? ''); $suffix = self::TRUNK_USER_SUFFIX; // strpos-based endsWith для совместимости с PHP 7.4 (str_ends_with — PHP 8+). return $userId !== '' && substr($userId, -strlen($suffix)) === $suffix; } /** * Строит строку CDR из записи статистики Beeline. * * @param array $row запись из /v2/statistics * @param int $gap сдвиг времени в часах (компенсация TZ) * @param string $employeeMode какой номер сотрудника отображать (EMPLOYEE_FIELD_*) * @return array|null массив полей CDR или null, если запись не пригодна (нет номера сотрудника) */ public static function buildCdrRow(array $row, int $gap, string $employeeMode = self::EMPLOYEE_FIELD_EXTENSION): ?array { $abonent = $row['abonent'] ?? []; $ext = self::employeeNumber($abonent, $employeeMode); if ($ext === '') { // Нет ни добавочного, ни мобильного номера — строку невозможно атрибутировать. return null; } $userId = (string)($abonent['userId'] ?? ''); $direction = strtoupper((string)($row['direction'] ?? '')); $status = strtoupper((string)($row['status'] ?? '')); $answered = $status !== 'MISSED'; // Имя сотрудника ставим на его сторону (src при исходящем, dst при входящем); // имени внешнего абонента статистика не отдаёт — оставляем пустым. $employeeName = self::employeeName($abonent); // Внешний абонент и раскладка сторон по направлению вызова. if ($direction === 'INBOUND') { $externalPhone = BeelineApi::normalizePhone((string)($row['phone_from'] ?? '')); $did = BeelineApi::normalizePhone((string)($row['phone_to'] ?? '')); $src = $externalPhone; $dst = $ext; $srcName = ''; $dstName = $employeeName; } else { $externalPhone = BeelineApi::normalizePhone((string)($row['phone_to'] ?? '')); $did = ''; $src = $ext; $dst = $externalPhone; $srcName = $employeeName; $dstName = ''; } $extTrackingId = (string)($row['externalTrackingId'] ?? ''); $uid = self::callUid($row, $externalPhone); $uniqueId = self::UNIQUEID_PREFIX . $uid; // Каналы: внешняя нога — PJSIP/beeline-{uid}, внутренняя — PJSIP/beeline-{ext}-{uid}. $externalChan = 'PJSIP/beeline-' . $uid; $internalChan = 'PJSIP/beeline-' . $ext . '-' . $uid; if ($direction === 'INBOUND') { $srcChan = $externalChan; $dstChan = $internalChan; } else { $srcChan = $internalChan; $dstChan = $externalChan; } $durationSec = (int)round(((int)($row['duration'] ?? 0)) / 1000); $startMs = (int)($row['startDate'] ?? 0); $startDate = (new DateTime())->setTimestamp((int)floor($startMs / 1000)); if ($gap !== 0) { $startDate->modify($gap . ' hour'); } $endAt = (clone $startDate)->modify('+' . $durationSec . ' seconds'); // Времени ожидания (ring) статистика не отдаёт: answer совпадает с началом. $answerAt = clone $startDate; return [ 'UNIQUEID' => $uniqueId, 'linkedid' => $uniqueId, 'start' => $startDate->format('Y-m-d H:i:s.u'), 'answer' => $answered ? $answerAt->format('Y-m-d H:i:s.u') : '', 'endtime' => $endAt->format('Y-m-d H:i:s.u'), 'did' => $did, 'src_num' => $src, 'src_name' => $srcName, 'src_chan' => $srcChan, 'dst_num' => $dst, 'dst_name' => $dstName, 'dst_chan' => $dstChan, 'duration' => $durationSec, 'billsec' => $answered ? $durationSec : 0, 'disposition' => $answered ? 'ANSWERED' : 'NOANSWER', // recordingfile НЕ включаем: его владелец — downloadRecords.php. Иначе overlap-ре-синк // публиковал бы insert_cdr с пустым recordingfile и затирал уже привязанную запись // в основной CDR (ActionInsertCdr — upsert по UNIQUEID). 'from_account' => self::FROM_ACCOUNT, 'work_completed' => '1', 'is_app' => '0', 'transfer' => '0', 'bee_ext_tracking_id' => $extTrackingId, 'bee_user_id' => $userId, // Статус записи ('ok' при привязке mp3) проставляет downloadRecords.php по /records. 'bee_rec_status' => 'none', ]; } }