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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions Lib/GetReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ public function history(string $searchPhrase = '', ?int $offset = null, ?int $li

$recordsFilteredReq = ConnectorDB::invoke('getCountCdr', [$start, $end, $numbers, $additionalNumbers, $additionalFilter, $minBilSec, $ids]);
$view->recordsFiltered = $recordsFilteredReq['cCalls'] ?? 0;
$view->recordsTotal = $recordsFilteredReq['cCalls'] ?? 0;
$view->recordsInner = $recordsFilteredReq['cINNER'] ?? 0;
$view->recordsOutgoing = $recordsFilteredReq['cOUTGOING'] ?? 0;
$view->recordsIncoming = $recordsFilteredReq['cINCOMING'] ?? 0;
Expand Down Expand Up @@ -302,8 +303,24 @@ private function prepareCdrData($selectedRecords):array

$providers = Sip::find("type='friend'");
$providerName = [];
$providerNameByLogin = [];
foreach ($providers as $provider) {
$providerName[$provider->uniqid] = $provider->description;
// Несколько учёток одного провайдера приходят на одну линию и различаются только по DID.
// Строим карту "логин провайдера (username) => название" для приоритетного сопоставления по DID.
$login = (string)$provider->username;
if ($login === '') {
continue;
}
if (array_key_exists($login, $providerNameByLogin)) {
// Один и тот же логин у нескольких учёток — сопоставление по DID неоднозначно,
// отключаем его для этого логина (null), чтобы не показать чужого провайдера.
if ($providerNameByLogin[$login] !== $provider->description) {
$providerNameByLogin[$login] = null;
}
} else {
$providerNameByLogin[$login] = $provider->description;
}
}
unset($providers);

Expand Down Expand Up @@ -335,6 +352,9 @@ private function prepareCdrData($selectedRecords):array
CallHistory::CALL_TYPE_MISSED => Util::translate('repModuleExtendedCDRs_cdr_CALL_TYPE_MISSED', false),
];

// Запоминаем linkedid, у которых имя линии уже определено по DID, чтобы поздние плечи его не затирали.
$lineFixedByDid = [];

foreach ($selectedRecords as $arrRecord) {
$record = (object)$arrRecord;
if (!array_key_exists($record->linkedid, $arrCdr)) {
Expand All @@ -351,10 +371,27 @@ private function prepareCdrData($selectedRecords):array
$linkedRecord->typeCallDesc = $typeCallNames[$record->typeCall];
$linkedRecord->waitTime = intval($record->waitTime);

$newLine = $providerName[$record->line] ?? $record->line;
if(!empty($newLine)){
$linkedRecord->line = $providerName[$record->line] ?? $record->line;
$linkedRecord->lineId = $record->line;
// Приоритет: если DID входящего звонка совпадает с логином (username) учётки провайдера —
// показываем именно её. Так различаются несколько учёток одного провайдера на одной линии
// (ограничение Asterisk: плечи приходят на один peer и отличаются только по DID).
$didName = '';
if ($record->typeCall === CallHistory::CALL_TYPE_INCOMING
&& !empty($record->did)
&& !empty($providerNameByLogin[$record->did])) {
$didName = $providerNameByLogin[$record->did];
}
if ($didName !== '') {
$linkedRecord->line = $didName;
if (!empty($record->line)) {
$linkedRecord->lineId = $record->line;
}
$lineFixedByDid[$record->linkedid] = true;
} elseif (empty($lineFixedByDid[$record->linkedid])) {
$newLine = $providerName[$record->line] ?? $record->line;
if(!empty($newLine)){
$linkedRecord->line = $newLine;
$linkedRecord->lineId = $record->line;
}
}
$linkedRecord->disposition = $linkedRecord->disposition !== 'ANSWERED' ? $disposition : 'ANSWERED';
$linkedRecord->start = $linkedRecord->start === '' ? $record->start : $linkedRecord->start;
Expand Down Expand Up @@ -734,6 +771,7 @@ public function outgoingEmployeeCalls(string $searchPhrase = '', ?int $offset =
unset($resultsCdrData);

$resultView->recordsFiltered = count($staffNumbers);
$resultView->recordsTotal = count($staffNumbers);

if ($offset !== null) {
$staffNumbers = array_slice($staffNumbers, $offset);
Expand Down
58 changes: 56 additions & 2 deletions Lib/HistoryParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,25 @@
class HistoryParser
{
public const LIMIT_CDR = 100;

/**
* Потолок числа строк на один linkedid.
* ВАЖНО: должен совпадать с MikoPBX\...\WorkerCallEvents\SelectCDR::MAX_QUERY_LIMIT (5000).
* Если ядро изменит лимит выборки — синхронизировать это значение.
* linkedid, достигший этого числа строк, считается "раздутым" (зависший канал)
* и исключается из синхронизации, см. Models\OversizedLinkedIds.
*/
public const MAX_LINKEDID_ROWS = 5000;
public const CDR_SYNC_PROGRESS_KEY = "cdrSyncProgress";

/**
* Retrieves all completed temporary CDRs.
* @param array $filter An array of filter parameters.
* @return array An array of CDR data.
*/
public static function getCdr(array $filter = []): array
public static function getCdr(array $filter = [], ?bool &$requestOk = null): array
{
$requestOk = false;
if (empty($filter)) {
$filter = [
'work_completed<>1 AND endtime<>""',
Expand All @@ -61,6 +71,7 @@ public static function getCdr(array $filter = []): array
try {
[$result, $message] = $client->sendRequest(json_encode($filter), 30);
if ($result!==false){
$requestOk = true;
$filename = json_decode($message, true, 512, JSON_THROW_ON_ERROR);
}
} catch (\Throwable $e) {
Expand Down Expand Up @@ -105,7 +116,7 @@ public static function getQueues():array
* @param int $offset
* @return void
*/
public static function getHistoryData(int $offset = 1):array
public static function getHistoryData(int $offset = 1, array $excludeLinkedIds = []):array
{
$filter = [
"type = :extType:",
Expand Down Expand Up @@ -137,6 +148,13 @@ public static function getHistoryData(int $offset = 1):array
'add_pack_query' => $add_query,
];

// Исключаем "раздутые" linkedid (зависшие каналы >= MAX_LINKEDID_ROWS строк),
// иначе они съедают потолок выборки ядра (5000 строк) и блокируют offset.
if (!empty($excludeLinkedIds)) {
$filter[0] .= ' AND linkedid NOT IN ({exclude:array})';
$filter['bind']['exclude'] = array_values($excludeLinkedIds);
}

$cdrData = self::getCdr($filter);
$resultRows = [];
if(count($cdrData)>0){
Expand Down Expand Up @@ -270,6 +288,42 @@ public static function getHistoryData(int $offset = 1):array
return ['data' => $resultRows, 'newOffset' => $calculatedOffset ?? $offset];
}

/**
* Из переданного списка linkedid возвращает те, у которых ещё есть CDR-строки
* с id больше offset (т.е. звонок всё ещё активен/не пройден синхронизацией).
* Используется для очистки списка "раздутых" linkedid: завершённые звонки,
* оставшиеся позади offset, можно удалить из исключений.
*
* @param string[] $linkedIds
* @param int $offset
* @return string[]|null Список активных linkedid, либо null если запрос к ядру не выполнился.
*/
public static function getActiveLinkedIds(array $linkedIds, int $offset):?array
{
if (empty($linkedIds)) {
return [];
}
$filter = [
'id>:id: AND linkedid IN ({linkedid:array})',
'bind' => [
'id' => $offset,
'linkedid' => array_values($linkedIds),
],
'order' => 'linkedid',
'group' => 'linkedid',
'columns' => 'linkedid',
'limit' => count($linkedIds),
];
$requestOk = false;
$rows = self::getCdr($filter, $requestOk);
if (!$requestOk) {
// Запрос к ядру не выполнился — сигнализируем вызывающему, чтобы он
// не принял пустой результат за "нет активных" и не удалил исключения.
return null;
}
return array_values(array_filter(array_column($rows, 'linkedid')));
}

/**
* Возвращает данные последней CDR ID и START.
* @return array
Expand Down
2 changes: 1 addition & 1 deletion Messages/pt_BR.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
'repModuleExtendedCDRs_syncState' => 'Progresso do download do histórico de chamadas: {valor} de {total} registros',
'repModuleExtendedCDRs_outgoingEmployeeCalls_countCalls' => 'Quantidade',
'repModuleExtendedCDRs_CallDetails' => 'Registro de chamadas',
'repModuleExtendedCDRs_OutgoingEmployeeCalls' => 'Funcionários cessantes',
'repModuleExtendedCDRs_OutgoingEmployeeCalls' => 'Chamadas de saída de ramais',
'repModuleExtendedCDRs_Form_titleReportError' => 'O nome não pode estar vazio!',
'repModuleExtendedCDRs_Form_minBillSecError' => 'O valor mínimo de duração deve estar entre 0 e 1000.',
'repModuleExtendedCDRs_Form_minBillSec_s' => 'Com.',
Expand Down
101 changes: 101 additions & 0 deletions Models/OversizedLinkedIds.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php
/**
* Copyright © MIKO LLC - All Rights Reserved
* Unauthorized copying of this file, via any medium is strictly prohibited
* Proprietary and confidential
* Written by Alexey Portnov, 7 2026
*/

namespace Modules\ModuleExtendedCDRs\Models;

use MikoPBX\Common\Models\ModelsBase;
use Modules\ModuleExtendedCDRs\Lib\MikoPBXVersion;
use Modules\ModuleExtendedCDRs\Lib\Providers\CdrDbProvider;

/**
* Class OversizedLinkedIds
*
* Служебный список "раздутых" linkedid — звонков, у которых число CDR-строк
* достигает потолка выборки ядра (MikoPBX SelectCDR MAX_QUERY_LIMIT = 5000).
* Обычно это зависшие каналы (конференции, парковки, MOH, подвисший local-канал),
* которые бесконечно генерируют строки под одним linkedid и блокируют продвижение
* offset синхронизации. Такие linkedid исключаются из запроса истории: их первые
* 5000 строк сохраняются один раз, остальные игнорируются.
*
* @package Modules\ModuleExtendedCDRs\Models
*
* @Indexes(
* [name='linkedid', columns=['linkedid'], type='unique']
* )
*/
class OversizedLinkedIds extends ModelsBase
{
/**
* @Primary
* @Identity
* @Column(type="integer", nullable=false)
*/
public $id;

/**
* Идентификатор звонка (linkedid), исключённый из синхронизации.
* @Column(type="string", nullable=false)
*/
public ?string $linkedid = '';

/**
* Число строк на момент обнаружения (достигает потолка 5000).
* @Column(type="integer", nullable=true)
*/
public ?int $rowCount = 0;

/**
* Максимальный id среди сохранённых (первых 5000) строк.
* @Column(type="integer", nullable=true)
*/
public ?int $maxId = 0;

/**
* Дата обнаружения в формате 'YYYY-MM-DD HH:MM:SS'.
* @Column(type="string", nullable=true)
*/
public ?string $detectedAt = '';

/**
* Создаёт служебную таблицу oversized_linkedids, если её ещё нет.
* Единый источник схемы: вызывается воркерами ConnectorDB и SyncRecords при старте.
* @return void
*/
public static function ensureTableExists(): void
{
$di = MikoPBXVersion::getDefaultDi();
if ($di === null) {
return;
}
if (!$di->has(CdrDbProvider::SERVICE_NAME)) {
$di->register(new CdrDbProvider());
}
$db = $di->getShared(CdrDbProvider::SERVICE_NAME);
$db->execute("CREATE TABLE IF NOT EXISTS oversized_linkedids (
id INTEGER PRIMARY KEY AUTOINCREMENT,
linkedid TEXT NOT NULL UNIQUE,
rowCount INTEGER DEFAULT 0,
maxId INTEGER DEFAULT 0,
detectedAt TEXT DEFAULT ''
)");
}

/**
* Initialize model.
*/
public function initialize(): void
{
$this->setSource('oversized_linkedids');
parent::initialize();
$this->useDynamicUpdate(true);
if (!$this->di->has(CdrDbProvider::SERVICE_NAME)) {
$this->di->register(new CdrDbProvider());
}
$this->setConnectionService(CdrDbProvider::SERVICE_NAME);
}
}
Loading
Loading