Jump to content

Search the Community

Showing results for tags 'suitecrm'.

  • Search By Tags

    Type tags separated by commas.
  • Search By Author

Content Type


Categories

  • Перевод официального мануала SuiteCRM
  • Обучающие статьи о SuiteCRM
    • Для программиста
  • Доводим напильником SuiteCRM
  • Расширения для SuiteCRM
    • Патчи с исправлениями ошибок в SuiteCRM
  • Программист за работой
  • Диалоги о SuiteCRM

Categories

  • Records
  • CRM-система для застройщика
    • Manual
  • CRM-система для кредитного брокера
  • CRM for Programmer
  • CRM-система для салонов красоты
    • Руководство

Forums

  • SugarCRM/SuiteCRM
    • Все вопросы пока сюда
    • Заметки по ходу разработки
    • Нам пишут
    • Работа
  • CRMHosting.io
    • SuiteCRM последней версии
    • CRM для продажи пиццы/суши/ролл
    • CRM для Застройщика
    • CRM для Кредитного брокера
    • CRM для Салонов красоты
    • CRM для Разработчика ПО / Веб-студии
  • Другие CRM-системы
    • AmoCRM
    • Bitrix24
    • BPM Online
    • Прочие CRM
  • Всего по немногу
    • Программисту
    • Arduino
    • Без систематики

Categories

  • Модули SuiteCRM/SuiteCRM
  • Manuals

Find results in...

Find results that contain...


Date Created

  • Start

    End


Last Updated

  • Start

    End


Filter by number of...

Joined

  • Start

    End


Group


About Me

  1. Скачали и установили новую SuiteCRM 7.9.4 При проверке работоспособности выяснилось: Модуль Email адски тормозит при наличии даже 2к писем В этом модуле судя по всему все переделали Многие названия отправителей в неправильной кодировке типа Экспресс-разбирательство данной ситуации выявило, что судя по всему теперь сделали чтение почты напрямую из почтового сервера без предварительной ее загрузки в базу CRM-системы. В целом это круто. И так и должно быть. Ведь IMAP именно такое поведение и предполагает. Но что же так долго то загружаются страницы? Ну да ладно, с этим еще разберемся, или пофиксят в следующих релизах... А неправильная кодировка писем лечится следующим образом: Находим файл modules\Emails\include\ListView\ListViewDataEmails.php находим в нем строку $ret = $emailHeader['from']; меняем на строку $ret = html_entity_decode($inboundEmail->handleMimeHeaderDecode($emailHeader['from'])); Теперь все норм: Может кому пригодиться ))
  2. Хочу разместить тут одну из вариаций решения задачи: При добавлении нового Контрагента, если заполнено поле "ИНН", надо определить, нет ли в базе уже существующего Контрагента с таким же ИНН. И если есть - не дать сохранить выведя соответствующую надпись о этом. Реализация сделана на странице добавления новой записи в модуле Accounts. Если в вкратце, то алгоритм следующий: Добавляем на страницу создания/редактирования карточки Accounts произвольный js-файл В этом js-файле для кнопки "Сохранить" прописываем свой обработчик. Когда пользователь нажимает кнопку "Сохранить" обработчик получает введенное значение из поля "ИНН", и отправляет AJAX-запрос на сервер с этим ИНН, чтобы проверить его в базе. Если ИНН в базе найден, то подсвечиваем красным поле с ИНН и пишем о том, что введенное значение не уникально Если ИНН не найден, то продолжаем стандартное сохранение карточки. Теперь давайте более предметно посмотрим, как это работает. В meta-файле, описывающем editview для Account custom/modules/Accounts/metadata/editviewdefs.php, добавляем блок, который подключит js-файл: <?php $viewdefs ['Accounts'] = array ( 'EditView' => array ( 'templateMeta' => array ( 'form' => array ( 'enctype' => 'multipart/form-data', 'buttons' => array ( 0 => 'SAVE', 1 => 'CANCEL', ), ), 'maxColumns' => '2', 'widths' => array ( 0 => array ( 'label' => '10', 'field' => '30', ), 1 => array ( 'label' => '10', 'field' => '30', ), ), 'includes' => array ( 1 => array ( 'file' => 'modules/Accounts/Account.js', ), 2 => array ( 'file' => 'modules/Accounts/edit.view.js', ), ), ), Это лишь кусок файла с мета-данными. Непосредственно наша вставка, это: 2 => array ( 'file' => 'modules/Accounts/edit.view.js', ), Таким образом нам надо добавить пустой файл modules/Accounts/edit.view.js, и заполнить его примерно так: $(document).ready(function(){ // Ставим для кнопки Сохранения свой обработчик $('#EditView').find('input[type=submit][id=saveButton]').click(function(){ // Ставим действие после срабатывания формы $('#EditView').find('input[type=hidden][name=action]').val('Save'); var checkForm = check_form('EditView'); if($('#inn').val() != '') { // Проверям на уникальность ИНН $.ajax({ url: "index.php", type: "POST", data: { module: 'Accounts', action: 'checkINN', record: $('#EditView').find('input[name=record]').val(), inn: $('#inn').val(), to_pdf: true } }).done(function(responce) { // Обработка полученных данных var accounts = jQuery.parseJSON(responce); if(accounts.length > 0) { // Найдены дубликаты var list = ''; for(var i = 0; i < accounts.length; i++) { list += '<li><A href="index.php?module=Accounts&action=DetailView&record='+accounts[i]['id']+'" style="color: red;" target="_blank">'+accounts[i]['name']+'</A></li>'; } list = '<B>Найдены дубликаты по ИНН</B>:<BR>' + list; if(isAccessDoubleINN) { list += '<BR>Если желаете все равно сохранить Контрагента с текущим ИНН нажмите кнопку: <button class="button" onclick="if(check_form(\'EditView\')){$(\'#EditView\').submit();}">Пропустить</button>'; } add_error_style('EditView','inn', list); } else { // Дубликатов по ИНН нет, можем прождолжать // Стандартная проверка остальных полей if(checkForm) { $('#EditView').submit(); } } }); } else { if(checkForm) { $('#EditView').submit(); } } return false; }); // Удаляем уже ранее установленный обработчик $('#EditView').find('input[type=submit][id=saveButton]').removeAttr("onclick"); }); Здесь в блоке $.ajax({ url: "index.php", type: "POST", data: { module: 'Accounts', action: 'checkINN', record: $('#EditView').find('input[name=record]').val(), inn: $('#inn').val(), to_pdf: true } }) как раз и происходит AJAX-запрос к серверу на проверку уникальности введенного значения. Запрос происходит к action=checkINN. Нам надо добавить php-файл с таким названием по адресу modules/Accounts/checkINN.php: <?php if(!defined('sugarEntry') || !sugarEntry) die('Not A Valid Entry Point'); /** * Created by JetBrains PhpStorm. * User: Evgen * Date: 22.08.13 * Time: 12:57 * To change this template use File | Settings | File Templates. */ global $db; $sql = " SELECT `id`, `name` FROM `accounts` WHERE `inn` = '".$_REQUEST["inn"]."' AND `deleted` = 0 "; if(isset($_REQUEST['record']) AND $_REQUEST['record'] != '') { $sql .= " AND `id` != '".$_REQUEST["record"]."' "; } $result = $db->query($sql, true); $return_array = array(); while($row = $db->fetchByAssoc($result)) { $return_array[] = $row; } $json = new JSON(); echo $json->encode($return_array); Вот и все! Проверка на уникальность готова.
  3. Сегодня столкнулся с ошибкой, присутствовавшей в SuiteCRM 7.3.2 (а возможно и в более ранних и более поздних модификациях этой CRM): Если создать новый отчет в модуле Отчеты (AOR_Reports), и одним из параметров фильтрации указать поле типа Datetime, то при построении отчета записи по этому полю не ищутся. У меня это проявилось, когда я хотел найти все Задачи, созданные за определенный день (по календарику). Вот как выглядел отчет: Таким образом у нас отчет будет состоять из трех колонок: ID записи, Название и Дата создания записи. А фильтровать данные мы по идее должны по Дате создания записей: Но если заполнить поле в колонке "Значение" и нажать на кнопку "Обновить", то ничего не происходит. Записи не ищутся, хотя я точно знаю, что они есть на указанную дату. В результате разбора модуля AOR_Reports было выявлено, что непосредственно генерацией WHERE-условия для выборки по отчету занимается функция build_report_query_where, находящаяся в классе AOR_Report в файле modules/AOR_Reports/AOR_Report.php Результатом работы функции build_report_query_where являлся массив: Array ( [select] => Array ( [0] => `project_task`.id AS 'project_task_id' [1] => `project_task`.id AS 'ID0' [2] => `project_task`.name AS 'Название1' [3] => `project_task`.date_entered AS 'Дата_создания2' ) [where] => Array ( [0] => `project_task`.date_entered = '23.12.2015' [1] => project_task.deleted = 0 который потом уже далее собирался в конечный SQL-запрос: SELECT `project_task`.id AS 'project_task_id', `project_task`.id AS 'ID0', `project_task`.name AS 'Название1', `project_task`.date_entered AS 'Дата_создания2' FROM `project_task` WHERE `project_task`.date_entered = '23.12.2015' AND project_task.deleted = 0 Как мы видим, `project_task`.date_entered = '23.12.2015' - абсолютно не то, что нам надо, чтобы корректно найти данные. Мало того, что формат даты указан не верный, так еще подвешенным остается что делать с временем. Ведь date_entered (дата создания) у нас в базе имеет значения что то типа такого: 2015-12-23 12:34:02, что никак не идентично 23.12.2015, хотя по логике это должно быть наше значение. В общем нам надо преобразовать наш запрос таким образом, чтобы занчения в базе уравнялись в формате с вводимыми значениями. Я решил, что самым оптимальным будет введенное значение оставить как есть, а модернизации подвергнуть указание поля в базе данных: у нас должен на выходе получиться вот такой запрос: SELECT `project_task`.id AS 'project_task_id', `project_task`.id AS 'ID0', `project_task`.name AS 'Название1', `project_task`.date_entered AS 'Дата_создания2' FROM `project_task` WHERE DATE_FORMAT(`project_task`.date_entered, '%d.%m.%Y') = '23.12.2015' AND project_task.deleted = 0 Таким образом прибегнув к функции DATE_FORMAT мы сможем сделать эти значения идентичными по своему формату, что позволит их начать сравнивать. Реализация: Во первых в функцию build_report_query_where в самый верх добавляем строку с подключением глобальной переменной $timedate, из которой мы в последствии сможем извлечь текущие настройки формата даты для текущего пользователя: /** * @param array $query * @return array */ function build_report_query_where($query = array()){ global $beanList, $app_list_strings, $sugar_config; global $timedate; а чуть дальше в этой же функции ищем строчки: if ((isset($data['source']) && $data['source'] == 'custom_fields')) { $field = $this->db->quoteIdentifier($table_alias . '_cstm') . '.' . $condition->field; $query = $this->build_report_query_join($table_alias . '_cstm', $table_alias . '_cstm', $oldAlias, $condition_module, 'custom', $query); } else { $field = $this->db->quoteIdentifier($table_alias) . '.' . $condition->field; $condition->value = date($timedate->get_date_format(), strtotime("+{$timedate->getUserUTCOffset()} minutes", strtotime($condition->value))); } Здесь переменная $field - это название нашего поля в базе данных (`project_task`.date_entered). Нам надо указать его не как просто `project_task`.date_entered, а как DATE_FORMAT(`project_task`.date_entered, '%d.%m.%Y') Для этого модернизируем этот участок кода следующим образом: if ((isset($data['source']) && $data['source'] == 'custom_fields')) { $field = $this->db->quoteIdentifier($table_alias . '_cstm') . '.' . $condition->field; $query = $this->build_report_query_join($table_alias . '_cstm', $table_alias . '_cstm', $oldAlias, $condition_module, 'custom', $query); } elseif($condition_module->field_defs[$condition->field]['type'] == 'datetime') { // Для случая, когда поле с типом Datetime $field = "DATE_FORMAT(". $this->db->quoteIdentifier($table_alias) . '.' . $condition->field.", '" . $timedate->get_cal_date_format() . "')"; } else { // Все остальные стандартные случаи $field = $this->db->quoteIdentifier($table_alias) . '.' . $condition->field; } Теперь у нас для всех полей с типом Datetime будет происходить корректное применение фильтров в Отчетах. Ну а в нашем случае получился запрос: SELECT `project_task`.id AS 'project_task_id', `project_task`.id AS 'ID0', `project_task`.name AS 'Название1', `project_task`.date_entered AS 'Дата_создания2' FROM `project_task` WHERE DATE_FORMAT(`project_task`.date_entered, '%d.%m.%Y') = '23.12.2015' AND project_task.deleted = 0 И вот его результат работы: Патч для SuiteCRM, который исправляет описанную ошибку:
  4. Сегодня столкнулся с ошибкой, присутствовавшей в SuiteCRM 7.3.2 (а возможно и в более ранних и более поздних модификациях этой CRM): Если создать новый отчет в модуле Отчеты (AOR_Reports), и одним из параметров фильтрации указать поле типа Datetime, то при построении отчета записи по этому полю не ищутся. У меня это проявилось, когда я хотел найти все Задачи, созданные за определенный день (по календарику). Вот как выглядел отчет: Таким образом у нас отчет будет состоять из трех колонок: ID записи, Название и Дата создания записи. А фильтровать данные мы по идее должны по Дате создания записей: Но если заполнить поле в колонке "Значение" и нажать на кнопку "Обновить", то ничего не происходит. Записи не ищутся, хотя я точно знаю, что они есть на указанную дату. В результате разбора модуля AOR_Reports было выявлено, что непосредственно генерацией WHERE-условия для выборки по отчету занимается функция build_report_query_where, находящаяся в классе AOR_Report в файле modules/AOR_Reports/AOR_Report.php Результатом работы функции build_report_query_where являлся массив: Array ( [select] => Array ( [0] => `project_task`.id AS 'project_task_id' [1] => `project_task`.id AS 'ID0' [2] => `project_task`.name AS 'Название1' [3] => `project_task`.date_entered AS 'Дата_создания2' ) [where] => Array ( [0] => `project_task`.date_entered = '23.12.2015' [1] => project_task.deleted = 0 который потом уже далее собирался в конечный SQL-запрос: SELECT `project_task`.id AS 'project_task_id', `project_task`.id AS 'ID0', `project_task`.name AS 'Название1', `project_task`.date_entered AS 'Дата_создания2' FROM `project_task` WHERE `project_task`.date_entered = '23.12.2015' AND project_task.deleted = 0 Как мы видим, `project_task`.date_entered = '23.12.2015' - абсолютно не то, что нам надо, чтобы корректно найти данные. Мало того, что формат даты указан не верный, так еще подвешенным остается что делать с временем. Ведь date_entered (дата создания) у нас в базе имеет значения что то типа такого: 2015-12-23 12:34:02, что никак не идентично 23.12.2015, хотя по логике это должно быть наше значение. В общем нам надо преобразовать наш запрос таким образом, чтобы занчения в базе уравнялись в формате с вводимыми значениями. Я решил, что самым оптимальным будет введенное значение оставить как есть, а модернизации подвергнуть указание поля в базе данных: у нас должен на выходе получиться вот такой запрос: SELECT `project_task`.id AS 'project_task_id', `project_task`.id AS 'ID0', `project_task`.name AS 'Название1', `project_task`.date_entered AS 'Дата_создания2' FROM `project_task` WHERE DATE_FORMAT(`project_task`.date_entered, '%d.%m.%Y') = '23.12.2015' AND project_task.deleted = 0 Таким образом прибегнув к функции DATE_FORMAT мы сможем сделать эти значения идентичными по своему формату, что позволит их начать сравнивать. Реализация: Во первых в функцию build_report_query_where в самый верх добавляем строку с подключением глобальной переменной $timedate, из которой мы в последствии сможем извлечь текущие настройки формата даты для текущего пользователя: /** * @param array $query * @return array */ function build_report_query_where($query = array()){ global $beanList, $app_list_strings, $sugar_config; global $timedate; а чуть дальше в этой же функции ищем строчки: if ((isset($data['source']) && $data['source'] == 'custom_fields')) { $field = $this->db->quoteIdentifier($table_alias . '_cstm') . '.' . $condition->field; $query = $this->build_report_query_join($table_alias . '_cstm', $table_alias . '_cstm', $oldAlias, $condition_module, 'custom', $query); } else { $field = $this->db->quoteIdentifier($table_alias) . '.' . $condition->field; $condition->value = date($timedate->get_date_format(), strtotime("+{$timedate->getUserUTCOffset()} minutes", strtotime($condition->value))); } Здесь переменная $field - это название нашего поля в базе данных (`project_task`.date_entered). Нам надо указать его не как просто `project_task`.date_entered, а как DATE_FORMAT(`project_task`.date_entered, '%d.%m.%Y') Для этого модернизируем этот участок кода следующим образом: if ((isset($data['source']) && $data['source'] == 'custom_fields')) { $field = $this->db->quoteIdentifier($table_alias . '_cstm') . '.' . $condition->field; $query = $this->build_report_query_join($table_alias . '_cstm', $table_alias . '_cstm', $oldAlias, $condition_module, 'custom', $query); } elseif($condition_module->field_defs[$condition->field]['type'] == 'datetime') { // Для случая, когда поле с типом Datetime $field = "DATE_FORMAT(". $this->db->quoteIdentifier($table_alias) . '.' . $condition->field.", '" . $timedate->get_cal_date_format() . "')"; } else { // Все остальные стандартные случаи $field = $this->db->quoteIdentifier($table_alias) . '.' . $condition->field; } Теперь у нас для всех полей с типом Datetime будет происходить корректное применение фильтров в Отчетах. Ну а в нашем случае получился запрос: SELECT `project_task`.id AS 'project_task_id', `project_task`.id AS 'ID0', `project_task`.name AS 'Название1', `project_task`.date_entered AS 'Дата_создания2' FROM `project_task` WHERE DATE_FORMAT(`project_task`.date_entered, '%d.%m.%Y') = '23.12.2015' AND project_task.deleted = 0 И вот его результат работы:
  5. В ходе разработки системы появилась не обходимость сделать логику, кторую не получится реализовать стандартным функционалом ролей. В данном случае было необходимо, чтоб контрагентов со статусом "архив" видели только администраторы системы. Для реализации данной логики нам понадобится файл modules/Accounts/Account.php Ищем в нём метод create_new_list_query, если не находим то создаём. В ней мы ограничиваем доступ к записям в listview модуля Контрагенты. Фактически дописываем допольнительное условие в запрос по которому строится список в listview. function create_new_list_query($order_by, $where,$filter=array(),$params=array(), $show_deleted = 0,$join_type='', $return_array = false,$parentbean=null, $singleSelect = false) { global $current_user; $ret_array = parent::create_new_list_query($order_by, $where, $filter, $params, $show_deleted, $join_type, $return_array, $parentbean, $singleSelect); //если текущий пользовательне админ, то запрещам просмотр записей со статусом "в архиве" if(!$current_user->is_admin){ $ret_array['where'] .= " AND accounts.contact_status <> 'archive'"; } if($return_array) { return $ret_array; } return $ret_array['select'] . $ret_array['from'] . $ret_array['where']. $ret_array['order_by']; } Далее нам необходимо ограничить доступ для просмотра и редактирования архивных контрагентов. Для этого воспользуемся методом ACLAccess (так же ищем и дописываем код или создаём) Здесь для редактирования, просмотра и удаления доступ запрещён. А сохранение закомментировано, для того чтоб обычный пользователь имел возможность поменять статус контрагента с какого-то значения на "архив" и сохранить. function ACLAccess($view, $is_owner='not_set', $in_group='not_set'){ global $current_user; $view = strtolower($view); switch($view){ case 'edit': case 'detail': // case 'save': case 'editview': case 'delete': if(!empty($this->contact_status) && $this->contact_status == "archive" && !$current_user->is_admin){ return false; } } return parent::ACLAccess($view,$is_owner,$in_group); }
  6. Иногда возникает ситуация когда необходимо отключить "сохранение" данных в полях фильтра (в полях поиска). Т.е., например, в модуле Leads отфильтровали по полю name и теперь возвращаясь этот модуль, поле name в фильтре будет заполнено значением, которое искали, иногда это бывает неудобно. Чтоб отключить данный функционал идём в файл: modules/MySettings/StoreQuery.php и в самом конце файла находим метод getStoredQueryForUser. public static function getStoredQueryForUser($module) { global $current_user; return $current_user->getPreference($module . 'Q'); } Меняем на: public static function getStoredQueryForUser($module) { global $current_user; // return $current_user->getPreference($module.'Q'); return array(); } В данном случае я закоментировал возврат получаемых данных из таблицы user_preferences, которые отвечают за то что отобразится в фильтре и вместо этого возвращаю пустой массив, т.к. исходные данные возвращаются в виде массива. При таких изменениях коде: Пока мы находимся на странице модуля, то фильтр будет хранить выбранные варианты, т.е. будет работать пагинация и не сбрасывать значения фильтров, так же обновление страницы по нажатию клавиши F5 не сбрасывает значения фильтров. При этом, если покинуть страницу с текущим модулем, перейдя на другую, а потом вернуться, то ранее введённые в фильтр данные сбросятся.
  7. В этом видео я чиню модуль E-mail: Когда приходит письмо от клиента, который есть в базе, автоматически не добавляются связи между письмом и соответствующим контактом и конрагентом.
  8. Суть в следующем: Стояла задача добавить несколько полей в модуль Сотрудники. Поля добавил, вывел их в макете редактирования и отображения. Но вводимые значения не сохранялись. То есть переходишь в режим редактирования карточки Сотрудника, указываешь значения, нажимаешь кнопку "Сохранить". Карточка сохраняется, но новые указанные значения не применяются. Остаются не заполненнями. В результате, как выяснилось, в модуле Сотрудники есть файл Save.php, который собой перехватывает стандартный процесс выполнения сохранения, и задает свой режим этого действа. Вот в этом файле надо было новые поля прописать в массиве разрешенных к сохранению полей. Сделано видимо это было в силу того, что модуль Сотрудники использует ту же самую таблицу, что и доступный только Админам модуль Пользователи. И чтобы рядовые сотрудники, имеющие доступ на запись к модулю Сотрудники, но не имеющие доступ к модулю Пользователи, не насохраняли там чего им сохранять нельзя, сделано было такое ограничение: к изменению принимаются только жестко заданный набор переменных. Ну а теперь по порядку что и как делал: Нужно было добавить 3 поля, характеризующих зарплату сотрудника: Тип зарплатных отношений (тут выпадающий список с единственным значением "Фиксированная ставка в зависимости от выработанных часов" Кол-во часов в день Стоимость месяца Таким образом можно было указать за какое количество отработанных часов тот или иной сотрудник получит определенную сумму денег. Выпадающий список с типом отношений добавил в файл с выпадающими списками custom/include/language/ru_ru.lang.php: $GLOBALS['app_list_strings']['employee_cost_type_list'] = array( '' => '', 'fix_time' => 'Фиксированная ставка в зависимости от выработанных часов', ); 3 новых поля добавил в файл custom/Extension/modules/Users/Ext/Vardefs/employee_payment.php Да, именно в модуле Users, а не Employee. У модулей есть такая вот специфика в силу того, что модуль Employee некоторым образом зависит от модуля User. Если такого файла нет, надо его создать: <?php /** * Created by PhpStorm. * User: evgen * Date: 19.12.15 * Time: 12:56 */ $dictionary["User"]["fields"]["cost_type"] = array( 'required' => false, 'name' => 'cost_type', 'vname' => 'LBL_COST_TYPE', 'type' => 'enum', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => true, 'inline_edit' => '', 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => 100, 'size' => '20', 'options' => 'employee_cost_type_list', 'studio' => 'visible', 'dependency' => false, ); $dictionary["User"]["fields"]["fix_time_day_count"] = array( 'required' => false, 'name' => 'fix_time_day_count', 'vname' => 'LBL_FIX_TIME_DAY_COUNT', 'type' => 'varchar', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => true, 'inline_edit' => '', 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => '4', 'size' => '20', ); $dictionary["User"]["fields"]["fix_time_day_count_month_cost"] = array( 'required' => false, 'name' => 'fix_time_day_count_month_cost', 'vname' => 'LBL_FIX_TIME_DAY_COUNT_MONTH_COST', 'type' => 'currency', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => true, 'inline_edit' => '', 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => 26, 'size' => '20', 'enable_range_search' => false, 'precision' => 6, ); Лейблы LBL_COST_TYPE, LBL_FIX_TIME_DAY_COUNT и LBL_FIX_TIME_DAY_COUNT_MONTH_COST описал в файле custom/Extension/modules/Employees/Ext/Language/ru_ru.employee_payment.php также предварительно его создав: <?php $mod_strings = array_merge($mod_strings, array( 'LBL_COST_TYPE' => "Тип оплаты", 'LBL_FIX_TIME_DAY_COUNT' => "Кол-во часов в день", 'LBL_FIX_TIME_DAY_COUNT_MONTH_COST' => "Стоимость месяца", ) ); ?> А также добавил 3 новых свойства в объект Employee в файле modules/Employees/Employee.php // Employee is used to store customer information. class Employee extends Person { // Stored fields var $name = ''; var $id; var $is_admin; var $first_name; var $last_name; var $full_name; var $user_name; var $title; var $description; var $department; var $reports_to_id; var $reports_to_name; var $phone_home; var $phone_mobile; var $phone_work; var $phone_other; var $phone_fax; var $email1; var $email2; var $address_street; var $address_city; var $address_state; var $address_postalcode; var $address_country; var $date_entered; var $date_modified; var $modified_user_id; var $created_by; var $created_by_name; var $modified_by_name; var $status; var $messenger_id; var $messenger_type; var $employee_status; var $error_string; public $person_id; var $module_dir = "Employees"; var $table_name = "users"; var $object_name = "Employee"; var $user_preferences; var $encodeFields = Array("first_name", "last_name", "description"); // This is used to retrieve related fields from form posts. var $additional_column_fields = Array('reports_to_name'); var $new_schema = true; // Custom fields var $cost_type; var $fix_time_day_count; var $fix_time_day_count_month_cost; Теперь надо обновить базу данных, чтобы в таблице `users` появилось наши новые 3 поля. Для этого переходим в Администрирование -> Восстановление -> Быстрое восстановление: После этого SuiteCRM проверит структуру базы данных, сравнит ее с конфигурационными настройками, указанными в SuiteCRM, и, если найдет расхождения, выдаст список SQL-запрос на устранение этих расхождений. В нашем случае это будет SQL-запрос на добавление 3-х полей в таблицу `users`: Нажимаем кнопку "Выполнить". После этого SQL-запросы выполняются, и у таблицы `users` появляются 3 наших поля. Далее в студии добавляем поля в макеты редактирования и детального просмотра. Я эти три поля вынес в отдельный таб "Зарплата": И вот что мы теперь видим при переходе в редактирвание сотрудника: Нам осталось добавить эти 3 поля в список полей, которые разрешено править в модуле Сотрудники. Для этого переходим в файл /modules/Employees/Save.php, и ищем там такой блок: //only employee specific field values need to be copied. $e_fields=array('first_name','last_name','reports_to_id','description','phone_home','phone_mobile','phone_work','phone_other','phone_fax','address_street','address_city','address_state','address_country','address_country', 'address_postalcode', 'messenger_id','messenger_type'); И прям под ним добавляем такую строку: $e_fields = array_merge($e_fields, ['cost_type','fix_time_day_count','fix_time_day_count_month_cost']); тем самым мы расширили массив e_fields новыми значениями с нашими полями. Теперь мы можем сохранять значения в полях, и как то дальше с ними работать: Просмотреть полную запись
  9. Суть в следующем: Стояла задача добавить несколько полей в модуль Сотрудники. Поля добавил, вывел их в макете редактирования и отображения. Но вводимые значения не сохранялись. То есть переходишь в режим редактирования карточки Сотрудника, указываешь значения, нажимаешь кнопку "Сохранить". Карточка сохраняется, но новые указанные значения не применяются. Остаются не заполненнями. В результате, как выяснилось, в модуле Сотрудники есть файл Save.php, который собой перехватывает стандартный процесс выполнения сохранения, и задает свой режим этого действа. Вот в этом файле надо было новые поля прописать в массиве разрешенных к сохранению полей. Сделано видимо это было в силу того, что модуль Сотрудники использует ту же самую таблицу, что и доступный только Админам модуль Пользователи. И чтобы рядовые сотрудники, имеющие доступ на запись к модулю Сотрудники, но не имеющие доступ к модулю Пользователи, не насохраняли там чего им сохранять нельзя, сделано было такое ограничение: к изменению принимаются только жестко заданный набор переменных. Ну а теперь по порядку что и как делал: Нужно было добавить 3 поля, характеризующих зарплату сотрудника: Тип зарплатных отношений (тут выпадающий список с единственным значением "Фиксированная ставка в зависимости от выработанных часов" Кол-во часов в день Стоимость месяца Таким образом можно было указать за какое количество отработанных часов тот или иной сотрудник получит определенную сумму денег. Выпадающий список с типом отношений добавил в файл с выпадающими списками custom/include/language/ru_ru.lang.php: $GLOBALS['app_list_strings']['employee_cost_type_list'] = array( '' => '', 'fix_time' => 'Фиксированная ставка в зависимости от выработанных часов', ); 3 новых поля добавил в файл custom/Extension/modules/Users/Ext/Vardefs/employee_payment.php Да, именно в модуле Users, а не Employee. У модулей есть такая вот специфика в силу того, что модуль Employee некоторым образом зависит от модуля User. Если такого файла нет, надо его создать: <?php /** * Created by PhpStorm. * User: evgen * Date: 19.12.15 * Time: 12:56 */ $dictionary["User"]["fields"]["cost_type"] = array( 'required' => false, 'name' => 'cost_type', 'vname' => 'LBL_COST_TYPE', 'type' => 'enum', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => true, 'inline_edit' => '', 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => 100, 'size' => '20', 'options' => 'employee_cost_type_list', 'studio' => 'visible', 'dependency' => false, ); $dictionary["User"]["fields"]["fix_time_day_count"] = array( 'required' => false, 'name' => 'fix_time_day_count', 'vname' => 'LBL_FIX_TIME_DAY_COUNT', 'type' => 'varchar', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => true, 'inline_edit' => '', 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => '4', 'size' => '20', ); $dictionary["User"]["fields"]["fix_time_day_count_month_cost"] = array( 'required' => false, 'name' => 'fix_time_day_count_month_cost', 'vname' => 'LBL_FIX_TIME_DAY_COUNT_MONTH_COST', 'type' => 'currency', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => true, 'inline_edit' => '', 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => 26, 'size' => '20', 'enable_range_search' => false, 'precision' => 6, ); Лейблы LBL_COST_TYPE, LBL_FIX_TIME_DAY_COUNT и LBL_FIX_TIME_DAY_COUNT_MONTH_COST описал в файле custom/Extension/modules/Employees/Ext/Language/ru_ru.employee_payment.php также предварительно его создав: <?php $mod_strings = array_merge($mod_strings, array( 'LBL_COST_TYPE' => "Тип оплаты", 'LBL_FIX_TIME_DAY_COUNT' => "Кол-во часов в день", 'LBL_FIX_TIME_DAY_COUNT_MONTH_COST' => "Стоимость месяца", ) ); ?> А также добавил 3 новых свойства в объект Employee в файле modules/Employees/Employee.php // Employee is used to store customer information. class Employee extends Person { // Stored fields var $name = ''; var $id; var $is_admin; var $first_name; var $last_name; var $full_name; var $user_name; var $title; var $description; var $department; var $reports_to_id; var $reports_to_name; var $phone_home; var $phone_mobile; var $phone_work; var $phone_other; var $phone_fax; var $email1; var $email2; var $address_street; var $address_city; var $address_state; var $address_postalcode; var $address_country; var $date_entered; var $date_modified; var $modified_user_id; var $created_by; var $created_by_name; var $modified_by_name; var $status; var $messenger_id; var $messenger_type; var $employee_status; var $error_string; public $person_id; var $module_dir = "Employees"; var $table_name = "users"; var $object_name = "Employee"; var $user_preferences; var $encodeFields = Array("first_name", "last_name", "description"); // This is used to retrieve related fields from form posts. var $additional_column_fields = Array('reports_to_name'); var $new_schema = true; // Custom fields var $cost_type; var $fix_time_day_count; var $fix_time_day_count_month_cost; Теперь надо обновить базу данных, чтобы в таблице `users` появилось наши новые 3 поля. Для этого переходим в Администрирование -> Восстановление -> Быстрое восстановление: После этого SuiteCRM проверит структуру базы данных, сравнит ее с конфигурационными настройками, указанными в SuiteCRM, и, если найдет расхождения, выдаст список SQL-запрос на устранение этих расхождений. В нашем случае это будет SQL-запрос на добавление 3-х полей в таблицу `users`: Нажимаем кнопку "Выполнить". После этого SQL-запросы выполняются, и у таблицы `users` появляются 3 наших поля. Далее в студии добавляем поля в макеты редактирования и детального просмотра. Я эти три поля вынес в отдельный таб "Зарплата": И вот что мы теперь видим при переходе в редактирвание сотрудника: Нам осталось добавить эти 3 поля в список полей, которые разрешено править в модуле Сотрудники. Для этого переходим в файл /modules/Employees/Save.php, и ищем там такой блок: //only employee specific field values need to be copied. $e_fields=array('first_name','last_name','reports_to_id','description','phone_home','phone_mobile','phone_work','phone_other','phone_fax','address_street','address_city','address_state','address_country','address_country', 'address_postalcode', 'messenger_id','messenger_type'); И прям под ним добавляем такую строку: $e_fields = array_merge($e_fields, ['cost_type','fix_time_day_count','fix_time_day_count_month_cost']); тем самым мы расширили массив e_fields новыми значениями с нашими полями. Теперь мы можем сохранять значения в полях, и как то дальше с ними работать:
  10. Всем добрый день! Хочу рассказать об одной интересной работе, которую делали последнюю неделю и которую закрыли вот прям буквально сегодня. К нам обратилась некая компания, которая попросила показать демо-стенд реализации интеграции Camunda с CRM-системой на базе SuiteCRM. В SuiteCRM есть свой собственный модуль "Процессы". Но его наглядность - не самое его сильное качество. Да и функциональность конечно весьма не плоха, но очень далеко до "отличная". По этому компания приняла решение в качестве BPM-платформы выбрать стороннее решение в виде Camunda. Вот что пишут про Camunda: Погуглив по Camunda ничего толком не нашел в русском сегменте интернета. Какие то общие описания. В английском секторе чуть больше, и есть более менее не плохое описание и REST API. Так же очень помогла наработка от ребят из hardsoft321: при помощи этого класса получилось достаточно быстро "поднять протокол" - использовать API Camunda. Постановка задачи Итак, далее опишу что нужно было сделать, что было сделано и как это все работает. По задумке надо было организовать: Прием заявки с сайта (сработала форма) Заявка назначается на ответственного менеджера Менеджер обрабатывает новую заявку проверяя хватает ли данных для оказания услуги Если данных достаточно, то менеджер передает заявку в другой отдел (в службу безопасности) В службе безопасности заявка назначается на сотрудника, он проверяет заявку Если проверка выявила проблемы, то сотрудник службы безопасности возвращает заявку обратно менеджеру Если проверка проблем не выявила, то сотрудник службы безопасности отправляет заявку на следующий этап - в юридический отдел для предоставления непосредственно самой услуги В юридическом отделе заявка назначается на сотрудника этого отдела Сотрудник оказывает услугу по заявке Если по заявке были выявлены проблемы - сотрудник юридического отдела должен иметь возможность вернуть заявку обратно менеджеру После того, как услуга по заявка была оказана, сотрудник юридического отдела возвращает заявку обратно менеджеру, чтобы тот уведомил клиента о оказании ему услуги. После уведомления клиента заявка уходит в архив Если графически, то бизнес-процесс должен выглядеть примерно так: В связке SuiteCRM + Camunda предполагалось, что все логические действия по движению заявки по отделам и статусам будет выполнять Camunda, а SuiteCRM будет выступать в виде некоего UI, в котором будут работать сотрудники: видеть список своих заявок в работе, иметь возможность отправлять заявки дальше по цепочке. Чтобы реализовать подобное взаимодействие потребуется двухсторонняя синхронизация: В момент создания заявки, а также в моменты, когда сотрудники готовы отдать заявку дальше по цепочке, SuiteCRM должна сообщать о этом Camunda, чтобы та уже определяла дальнейшее действие. В момент, когда Camunda определяет что же дальше должно происходить с заявкой, она должна свое решение передавать обратно в SuiteCRM, чтобы та соответствующим образом меня статус заявки, ответственного по заявке и прочие действия, характерные для прохождения той или иной стадии. С первым пунктом интеграции было все более или менее понятно: Необходимо в Camunda разработать и загрузить процесс, который будет описывать всю логику. При помощи Camunda Rest API мы будем должны стартовать этот разработанный процесс в момент прихода новой заявки из формы на сайте В процессе работы над заявкой, когда сотрудники отмечают свою работу выполненной, мы должны передавать в процесс информацию о этом, чтобы процесс двигал заявку дальше С вторым пунктом было больше вопросов: Как передать из Camunda в SuiteCRM информацию о очередном этапе? Умеет ли Camunda отправлять Webhooks, или используется какая то другая технология? Есть ли в Camunda встроенные возможности такого взаимодействия, или необходимо привлекать разработчиков чтобы реализовать подобные возможности? Поверхностное гугление не давало четкого ответа на эти вопросы. Решение задачи Со стороны SuiteCRM: За основу была взята CRM-система для салонов красоты и парикмахерских. В ней мы переделали модуль "Посещения" на "Заявки": Для списка предоставляемых услуг подошел модуль "Прейскурант": Клиенты так и остались в карточках модуля "Клиенты": Для тестирования получения данных из формы при помощи модуля "Компании" была сгенерирована форма тестовой заявки: Данные на нее попадали прямиком в CRM-систему. Первоначально весь бизнес-процесс мы настроили силами самой CRM при помощи модуля "Процессы": Таким образом Заявка, при попадании в CRM-систему, автоматически создавала как запись с Клиентом, так и с Заявкой. В заявке назначался ответственный менеджер из списка сотрудников, находящихся в роли "Менеджер". Кстати были созданы 3 роли: Менеджер, СБ и Юрист: Сотрудники, имеющие ту или иную роль, назначаются Заявке в соответствующий момент времени. Движение заявки по статусам осуществляется при помощи кнопок, находящихся вверху карточки Заявки. Передача заявки в службу безопасности: Заявка назначается в работу сотруднику из службы безопасности. По результатам работы он может или вернуть заявку обратно менеджеру с своими комментариями, или отправить заявку дальше по цепочке: Если сотрудник службы безопасности подтвердил заявку, то она уходит сотруднику с ролью Юрист. Юрист также может вернуть обратно в работу заявку менеджеру (если в ней чего-либо не хватает), или выполнить свою работу и отметить заявку выполненной: После того, как Юрист выполнит свою работу и отметит заявку выполненной, она возвращается Менеджеру в статусе "Уведомление клиента" и менеджеру необходимо уведомить клиента о выполнении его заявки, после чего менеджер может закрыть заявку: Со стороны Camunda: Сначала мы развернули демо-стенд с Camunda. Самый быстрый и простой способ это сделать оказалось загрузить на сервер Docker-образ с официального репозитория: # docker pull camunda/camunda-bpm-platform После этого появляется возможность развернуть уже настроенную систему с предустановленной Camunda на борту. Я воспользовался системой разворачивания виртуальных хостов, которая работает на нашем сайте crmhosting.ru, но вы можете самостоятельно посмотреть про работу с docker. Если будет совсем не понятно, то пишите под статьей, дополню подобной информацией. Оставлю тут ссылку на демо-стенд: http://camunda.crmhosting.ru/camunda/ логин/пароль = demo/demo Путем изучения Camunda пришло понимание, что данное ПО умеет обрабатывать диаграммы BPMN, но не умеет их создавать. Для создания диаграмм используется распространяемый Modeler от Camunda: В изучении возможностей моделера и Camunda в том числе очень помог процесс, присутствующий в Camunda по умолчанию: обработка счетов: Настройка взаимодействия: Задачей нашего демо-стенда было показать принципиальную возможность взаимодействия между SuiteCRM и Camunda. Для этого мы решили не реализовывать весь процесс на связке Camunda+SuiteCRM, а добавить 1-2 звена для отображения того, как SuiteCRM отправляет данные в Camunda и как Camunda уведомляет SuiteCRM. В Modeler от Camunda мы разработали простенький процесс, задачей которого было стартовать при срабатывании формы и назначить заявке ответственного менеджера по заданному алгоритму, а затем вернуть назначенного менеджера в SuiteCRM, чтобы та, в свою очередь, назначила этого ответственного за Заявкой. Таким образом мы убрали из наших ранее настроенных бизнес-процессов определение ответственного силами SuiteCRM и перевели этот блок на плечи Camunda. Вот сам получившийся процесс в Camunda: Для формирования логики блока "Определение ответственного менеджера" существует множество разных способов. Мы выбрали указание пользователя при помощи Модели принятия решений (DMN). Настройки блока "Определение ответственного менеджера": Сама модель идет в виде отдельного файла *.dmn В ней мы явно указываем, что не зависимо не от чего результат будет равен строке "manager1": Таким образом при старте процесса Camunda назначает ответственным сотрудником юзера "manager1" и уходит в блок "Утверждение введенных данных менеджером" (этот блок никак не был автоматизирован). Чтобы стартовать процесс Camunda из SuiteCRM мы создали в SuiteCRM Hook для события before_save, который через API стартует наш процесс. В настройках LogicHooks: $hook_array['before_save'][] = Array( 10, 'Стартуем процесс', 'modules/Opportunities/Hooks.php', 'OpportunitiesLogicHooks', 'camundaStartProcess' ); Сам хук: public function camundaStartProcess($bean, $event, $arguments) { global $sugar_config; if(isset($GLOBALS['OpportunityCamundaStartProcess'][$bean->id])) return; print_array("*** camundaStartProcess - start ***",0,1); require_once 'custom/include/PestJSON.php'; require_once 'modules/CamundaProcesses/SugarCamunda.php'; print_array('$bean->id = ' . $bean->id,0,1); print_array('$bean->new_with_id = ' . var_export($bean->new_with_id,1),0,1); print_array('$bean->fetched_row = ' . var_export($bean->fetched_row,1),0,1); if(empty($bean->camunda_id)) { // Создание новой Заявки print_array("* Создание новой Заявки",0,1); if(empty($bean->id)) { $bean->id = create_guid(); $bean->new_with_id = true; } if($bean->lead_source == 'Form') { // Если источник Заявки = Форма print_array("* источник Заявки = Форма",0,1); // Подключаемся к Camunda $camunda = SugarCamunda::getJsonClient(); // Получаем список процессов $processes = $camunda->get("/process-definition"); if(isset($sugar_config['camunda_new_opportunity_process_key'])) { // Если в конфигурации указан процесс для входящего запроса с формы print_array("* в конфигурации указан процесс для входящего запроса с формы = " . $sugar_config['camunda_new_opportunity_process_key'],0,1); foreach ($processes as $processInfo) { if($processInfo['key'] == $sugar_config['camunda_new_opportunity_process_key']) { // Нашли процесс, указанный в конфиге print_array("* Нашли процесс, указанный в конфиге",0,1); // Стартуем процесс print_array("* Стартуем процесс",0,1); $GLOBALS['OpportunityCamundaStartProcess'][$bean->id] = true; $resultStart = $camunda->post("/process-definition/{$processInfo['id']}/start",[ 'opportunity_id' => $bean->id, ]); if(isset($resultStart['id'])) { // Записываем ID процесса в Заявку print_array("* Записываем ID процесса в Заявку: " . $resultStart['id'],0,1); print_array("** resultStart: " . var_export($resultStart,1),0,1); $bean->camunda_id = $resultStart['id']; } } } } } } } В Camunda может быть описано множество разных процессов. В конфигурационной переменной SuiteCRM мы указали название нашего нужного процесса. $sugar_config['camunda_new_opportunity_process_key'] = 'testSuite'; Обращаясь через API Camunda к списку всех доступных процессов мы их перебираем, и как только находим процесс с нужным названием, то сразу запускаем его через вызов метода "/process-definition/{$processInfo['id']}/start" В результате вызова мы получаем ID запущенного процесса (именно не ID схемы процесса, а ID именно текущего старта), и записываем его в Заявку в поле camunda_id: $bean->camunda_id = $resultStart['id']; Так как данное действие выполняется во время before_save, то данное поле естественным образом запишется в Заявку без каких-либо дополнительных действий с нашей стороны. Дальше начинается самая интересная и сложная часть, на которую ушло больше всего времени - мы заставляем Camunda отдать в SuiteCRM логин выбранного менеджера. После долгого гугления и попыток осознать предоставляемые Camunda возможности пришло понимание, что встроенных нативных средств в Camunda для передачи данных в внешнюю среду нет. Вроде "пичаль", но не совсем. У Camunda зато есть ряд других возможностей, подразумевающих программирование. И реализуются они при помощи Listeners в Modeler при редактировании свойств блока "Определение ответственного менеджера". Listeners - это возможность вставить свою логику в тот или иной блок диаграммы используя или специально разработанные Java-классы, или другие языки программирования. Существующего опыта программирования на Java нам не хватало для анализа существующих возможностей и разворачивания действующего демо-стенда в заданные сроки. Вроде как есть поддержка JavaScript, но попытки использовать XMLHttpRequest закончились неудачей: Camunda отказалась работать с этими функциями. Но на выручку пришел язык groovy: В этом языке нашлись конструкции, которые позволяют обращаться к сторонним ресурсам по HTTP и получать результаты. А в нашем случае на текущем этапе нам надо было всего лишь обратиться к SuiteCRM с ID текущего процесса и назначенным ответственным, чтобы передать в SuiteCRM эту информацию. И вот что в результате получилось: Таким образом скрипт на groove выполнялся ПОСЛЕ того, как ответственный менеджер был выбран, и отправлял эту информацию в SuiteCRM. SuiteCRM, в свою очередь, принимала этот запрос и обрабатывала его. Для приема был добавлен новый entryPoint = camunda В настройках entryPoint: $entry_point_registry['camunda'] = array( 'file' => 'modules/CamundaProcesses/inboundRequests.php', 'auth' => false ); Файл modules/CamundaProcesses/inboundRequests.php: <?php /** * Created by PhpStorm. * User: crmhosting * Date: 06.11.2018 * Time: 19:26 */ include_once "modules/Opportunities/Opportunity.php"; global $db; $json = new JSON(); if(isset($_REQUEST['action'])) { // Пришел запрос с действием print_array('*** inboundRequests - start ***',0,1); print_array($_REQUEST,0,1); // Формируем массив с подробностями $content = []; switch ($_REQUEST['action']) { case 'setAssignedUserInOpportunity': // Указан ответственный пользователь у Заявки if(isset($_REQUEST['user']) AND $_REQUEST['user'] != '') { $content['user'] = $_REQUEST['user']; } break; } $camundaID = isset($_REQUEST['camundaID']) ? $_REQUEST['camundaID'] : ''; $camundaIDArray =explode(":", $camundaID); if(isset($camundaIDArray[2])) $camundaID = $camundaIDArray[2]; // Записываем входящее действие в таблицу print_array('* Записываем входящее действие в таблицу',0,1); $sql = " INSERT INTO `camunda_inbound` SET `action` = '".$_REQUEST['action']."', `camundaID` = '{$camundaID}', `content` = '".base64_encode($json->encode($content))."' "; $db->query($sql, true); } Таким образом мы в этом entryPoint можем принимать из Camunda запросы и подготовили тем самым платформу для реагирования на самые разные запросы. Кстати, в процессе тестирования выявилась интересная особенность: изначально мы думал, что процесс взаимодействия и взаимных вызовов будет строиться следующим образом: Пришли данные из формы -> Стартовали процесс в Camunda -> Получили ID процесса из Camunda -> Сохранили заявку -> Пришел запрос из Camunda с указанием ответственного -> определили заявку -> указали ответственного в SuiteCRM -> Сохранили заявку Но все оказалось немного иначе. Camunda не хотела отдавать ID процесса, пока не пройдется по всем своим блокам. А так как блок с определением ответственного стартует обратный вызов SuiteCRM, то фактическая цепочка получалась такой: Пришли данные из формы -> Стартовали процесс в Camunda -> Пришел запрос из Camunda с указанием ответственного -> пытаемся определить заявку (ее еще нет) -> некуда указывать ответственного -> вызов из Camunda закончился в пустую -> Получили ID процесса из Camunda -> Сохранили заявку То есть из Camunda приходит обратный запрос с ответственным еще до того, как Camunda отдала ID процесса при создании заявки. И так, как ID заявки в SuiteCRM пока нет, то ничего нормально мы не сохранили и ничего не работало. Но выход нашелся в работе через вспомогательную таблицу `camunda_inbound`: в нее мы промежуточно сохраняем информацию когда приходит ответ от Camunda - в каком процессе какой ответственный был указан. Таким образом мы корректно завершаем Hook в заявке, получаем ID процесса и сохраняем его в Заявке. А хуком after_save мы лезем в таблицу `camunda_inbound` и обрабатываем накопившиеся там отбойки от Camunda: public function camundaCheckInboundProcess($bean, $event, $arguments) { include_once "modules/Opportunities/OpportunityCamundaHelper.php"; if(isset($GLOBALS['OpportunityCamundaStartProcess'][$bean->id])) { // Запускаем анализ всех пришедших активностей из Camunda OpportunityCamundaHelper::processAllCamundaInbound(); unset($GLOBALS['OpportunityCamundaStartProcess'][$bean->id]); } } class OpportunityCamundaHelper extends Opportunity { static function processAllCamundaInbound() { global $db; print_array("*** processAllCamundaInbound - start ***",0,1); // Получаем все записи $sql = "SELECT * FROM `camunda_inbound` ORDER BY `id` ASC"; $result = $db->query($sql, true); $json = new JSON(); while ($row = $db->fetchByAssoc($result)) { $content = $json->decode(base64_decode($row['content'])); switch ($row['action']) { case 'setAssignedUserInOpportunity': // Указан ответственный пользователь у Заявки print_array("* Пришел запрос на указание ответственного у Заявки",0,1); if(isset($row['camundaID']) AND $row['camundaID'] != '') { // Пришла информация о процессе // Ищем Заявки с таким ID процесса print_array("* camundaID указано ({$row['camundaID']})",0,1); $sql = "SELECT `id` FROM `opportunities` WHERE `deleted` = 0 AND `camunda_id` = '{$row['camundaID']}'"; print_array('$sql: ' . $sql,0,1); $opportunity_id = $db->getOne($sql, true); if($opportunity_id) { // Если ID Заявки найдено print_array("* ID Заявки найдено: " . $opportunity_id,0,1); $seedOpportunity = new Opportunity(); $seedOpportunity->retrieve($opportunity_id); if(!empty($seedOpportunity->id) AND empty($seedOpportunity->assigned_user_id)) { // Заявка корректно найдена print_array("* Заявка найдена: " . $seedOpportunity->name . ' (ID = '.$seedOpportunity->id.')',0,1); // Далее ищем ответсственного пользователя if(isset($content['user']) AND $content['user'] != '') { // Пользователь указан print_array("* Пользователь указан: " . $content['user'],0,1); $sql = "SELECT `id` FROM `users` WHERE `deleted` = 0 AND `user_name` = '{$content['user']}'"; $user_id = $db->getOne($sql, true); if($user_id) { // Если Пользователь найден print_array("* Пользователь найден: " . $user_id,0,1); $seedOpportunity->assigned_user_id = $user_id; $seedOpportunity->user_assigned_manager_id = $user_id; $seedOpportunity->save(); print_array("** Сохранили: " . $seedOpportunity->assigned_user_name,0,1); } } } } } break; } // Удаляем запись из таблицы $sql = "DELETE FROM `camunda_inbound` WHERE `id` = '{$row['id']}'"; print_array("Удаляем запись из таблицы: " . $sql,0,1); $db->query($sql, true); } } } Вот такое получилось решение. Могу предположить, что это не самое оптимальное решение. Если у кого то есть мысли как можно сделать лучше, или опыт чего то подобного, то буду рад если вы прокомментируете данную статью. Демо-стенд SuiteCRM: http://u1200.crmhosting.ru admin/admin manager1/Manager1 SB1/SB1 Yur1 / Yur1 Возможно эту сборку добавим в список сборок для автоматического разворачивания на нашем сервисе CRMHosting.ru (но это не точно )))) ) Презентация Ну и под конец размещаю видео с презентацией получившегося решения:
  11. Всем привет! Столкнулся с небольшой проблемой: SuiteCRM автоматически при отображении поля типа Text преобразует все URL в ссылки. То есть берет URL и "пихает" его в тег <A>. Классная штука на самом деле, удобно. Но мне надо было отключить её, так как я создал поле "source" => "non-db", в котором хочу сам формировать список ссылок, чтобы в карточке записи был набор ссылок типа такого: Ссылка 1 Ссылка 2 Ссылка 3 Именно так, нормальное название. А не: http://site.ru/page1.html http://site.ru/page2.html http://site.ru/page3.html Ведь удобнее видеть нормальное человеческое описание, кликнуть на него и попасть на желаемую страницу. По реализации: Добавил новое поле в модуль, в котором будет находится текст с списком ссылок: 'different_links' => array ( 'required' => false, 'name' => 'different_links', 'vname' => 'LBL_DIFFERENT_LINKS', 'type' => 'text', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => true, 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'source' => 'non-db', ), Добавил хук after_retrieve в custom/Extension/modules/lm_CRMSystems/Ext/LogicHooks/calculateFields.php, чтобы можно было наполнять поле текстом: if (!isset($hook_array) || !is_array($hook_array)) { $hook_array = array(); } if (!isset($hook_array['after_retrieve']) || !is_array($hook_array['after_retrieve'])) { $hook_array['after_retrieve'] = array(); } $hook_array['after_retrieve'][] = Array(10, 'Ссылка на карточку на сервере', 'custom/modules/lm_CRMSystems/hooks.php', 'lm_CRMSystemsHooks', 'calculateFields'); Добавляем функцию для хука в файле custom/modules/lm_CRMSystems/hooks.php: /** * Получение ссылки на карточку CRM-системы на ее Сервере * @param $bean * @param $event * @param $arguments */ public function calculateFields($bean, $event, $arguments) { global $db; global $sugar_config; // Ссылки $bean->different_links = ""; // Ссылка на карточку на другом сервере switch ($sugar_config['CRMSystems']['systemType']) { case 'base': // Мы находимся на Базовом сервере // Пытаемся сформировать ссылка на карточку на сервере с проектами // Получаем URL сервера if(!empty($bean->lm_crmservers_lm_crmsystemslm_crmservers_ida)) { $sql = "SELECT `url` FROM `lm_crmservers` WHERE `deleted` = 0 AND `id` = '{$bean->lm_crmservers_lm_crmsystemslm_crmservers_ida}'"; $url = $db->getOne($sql, true); $bean->different_links .= '<li><A href="'.$url . '/index.php?module=lm_CRMSystems&action=DetailView&record=' . $bean->id.'" target="_blank">Карта на сервере с проектом</A></li>'; } break; case 'projects': // Мы находимся на Сервере с проектами // Добавляем ссылку на карточку на Базовом сервере // Ищем базовый сервер $sql = "SELECT `url` FROM `lm_crmservers` WHERE `deleted` = 0 AND `type` = 'base'"; $url = $db->getOne($sql, true); $bean->different_links .= '<li><A href="'.$url . '/index.php?module=lm_CRMSystems&action=DetailView&record=' . $bean->id.'" target="_blank">Карта на базовом сервере</A></li>'; break; } if($bean->different_links != '') { $bean->different_links = '<ul>'.$bean->different_links.'</ul>'; } } И вот что у меня получилось: Получается, что я сформировал корректную HTML-ссылку, а SuiteCRM внутри нее нашла URL, и обрамила ее своей ссылкой. Вышло не очень. Полез в функцию-обработчик полей типа Text include/SugarFields/Fields/Text/SugarFieldText.php, и видим там следующее: class SugarFieldText extends SugarFieldBase { function getDetailViewSmarty($parentFieldArray, $vardef, $displayParams, $tabindex) { if(!isset($displayParams['nl2br'])){ $displayParams['nl2br'] = true; } if(!isset($displayParams['htmlescape']) && $vardef['editor'] != "html") { $displayParams['htmlescape'] = true; } if(!isset($displayParams['url2html'])) { $displayParams['url2html'] = true; } return parent::getDetailViewSmarty($parentFieldArray, $vardef, $displayParams, $tabindex); } Тут особо интересный блок: if(!isset($displayParams['url2html'])) { $displayParams['url2html'] = true; } То есть если при отображении поля использовать 'url2html' => false, то в теории оно не должно преобразовывать ссылки. Пробуем: в файле custom/modules/lm_CRMSystems/metadata/detailviewdefs.php описываем поле different_links: 11 => array ( 0 => 'lm_crmservers_lm_crmsystems_name', 1 => array( 'name' => 'different_links', 'label' => 'LBL_DIFFERENT_LINKS', 'displayParams' => array( 'url2html' => false, ), ), ), И вот что получилось после быстрого восстановления: То, что нужно!
  12. Всем привет! Столкнулся только что с ситуацией, когда в карточку Контрагента добавили поле с пользователем (связанная запись из Users), пытаемся в дашлете это поле использовать как фильтр, а оно не работает. Поле добавляли через файлы в /custom/Extensions/modules/Accounts/Ext/Vardefs/ В дашлете это выглядит примерно так: В карточке контрагента это же поле: После того, как в дашлете я выбираю это поле в виде фильтра, SuiteCRM формирует для выборки записей SQL-запрос примерно такого содержания: SELECT accounts.id , accounts.user_id2_c , LTRIM(RTRIM(CONCAT(IFNULL(jt0.first_name,''),' ',IFNULL(jt0.last_name,'')))) fixed_accountant_c , LTRIM(RTRIM(CONCAT(IFNULL(jt1.first_name,''),' ',IFNULL(jt1.last_name,'')))) fixed_accountant_c , jt1.created_by fixed_accountant_c_owner , 'Users' fixed_accountant_c_mod, accounts.assigned_user_id FROM accounts LEFT JOIN users jt0 ON accounts.user_id2_c = jt0.id AND jt0.deleted=0 LEFT JOIN users jt1 ON accounts.user_id2_c=jt1.id AND jt1.deleted=0 AND jt1.deleted=0 where (user_id2_c='d3c33565-3030-cebd-2db4-592308bd456b' ) AND accounts.deleted=0 Как мы видим ((accounts.id IN (''))) - совсем не то, что нам нужно. В результате анализа и поиска места, где это все собирается и как так получается был найден файл /include/Dashlets/DashletGeneric.php и в нем функция buildWhere() с таким участком: switch($widgetDef['type']) {// handle different types case 'date': case 'datetime': case 'datetimecombo': if(is_array($params) && !empty($params)) { if(!empty($params['date'])) $widgetDef['input_name0'] = $params['date']; $filter = 'queryFilter' . $params['type']; } else { $filter = 'queryFilter' . $params; } array_push($returnArray, $widgetClass->$filter($widgetDef, true)); break; case 'assigned_user_name': // This type runs through the SugarWidgetFieldname class, and needs a little extra help to make it through if ( ! isset($widgetDef['column_key']) ) { $widgetDef['column_key'] = $name; } // No break here, we want to run through the default handler case 'relate': if (isset($widgetDef['link']) && $this->seedBean->load_relationship($widgetDef['link'])) { $widgetLink = $widgetDef['link']; $widgetDef['module'] = $this->seedBean->$widgetLink->focus->module_name; $widgetDef['link'] = $this->seedBean->$widgetLink->getRelationshipObject()->name; } // No break - run through the default handler default: $widgetDef['input_name0'] = $params; if(is_array($params) && !empty($params)) { // handle array query array_push($returnArray, $widgetClass->queryFilterone_of($widgetDef, false)); } else { array_push($returnArray, $widgetClass->queryFilterStarts_With($widgetDef, true)); } $widgetDef['input_name0'] = $params; break; } Поле, по которому мы пытаемся в дашлете искать, создано при помощи такого массива: $dictionary['Account']['fields']['fixed_accountant_c'] = array ( 'required' => false, 'source' => 'non-db', 'name' => 'fixed_accountant_c', 'vname' => 'LBL_FIXED_ACCOUNTANT_C', 'type' => 'relate', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => false, 'inline_edit' => true, 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => '255', 'size' => '20', 'id_name' => 'user_id2_c', 'ext2' => 'Users', 'module' => 'Users', 'rname' => 'name', 'quicksearch' => 'enabled', 'studio' => 'visible', ); Судя по всему получается так, что в функции buildWhere() срабатывает case = 'relate'. Но так же выяснил, что НЕ срабатывает блок if (isset($widgetDef['link']) && $this->seedBean->load_relationship($widgetDef['link'])) {} просто по причине отсутствия 'link' в описании нашего поля. Решил добавить 'link', и ВСЕ ПОЛУЧИЛОСЬ!!! За основу для добавления link взял поле assigned_user_id, так как эти поля получились очень похожими: эти поля находятся в таблице `accounts` и ссылаются на таблицу и модуль с Пользователями (`users`) Таким образом в файл, в котором я описал мое поле, я добавил еще следующие блоки: $dictionary['Account']['fields']['fixed_accountant_c_link'] = array ( 'name' => 'fixed_accountant_c_link', 'type' => 'link', 'relationship' => 'fixed_accountant_c', 'vname' => 'LBL_FIXED_ACCOUNTANT_C_LINK', 'link_type' => 'one', 'module' => 'Users', 'bean_name' => 'User', 'source' => 'non-db', 'duplicate_merge' => 'enabled', 'rname' => 'fixed_accountant_c', 'id_name' => 'user_id2_c', 'table' => 'users', ); $dictionary['Account']['relationships']['fixed_accountant_c'] = array ( 'lhs_module' => 'Users', 'lhs_table' => 'users', 'lhs_key' => 'id', 'rhs_module' => 'Accounts', 'rhs_table' => 'accounts', 'rhs_key' => 'user_id2_c', 'relationship_type' => 'one-to-many' ); А в описание нашего поля вставляю link: $dictionary['Account']['fields']['fixed_accountant_c'] = array ( 'required' => false, 'source' => 'non-db', 'name' => 'fixed_accountant_c', 'vname' => 'LBL_FIXED_ACCOUNTANT_C', 'type' => 'relate', 'link'=>'fixed_accountant_c_link' , 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => false, 'inline_edit' => true, 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => '255', 'size' => '20', 'id_name' => 'user_id2_c', 'ext2' => 'Users', 'module' => 'Users', 'rname' => 'name', 'quicksearch' => 'enabled', 'studio' => 'visible', ); После этих манипуляций делаю быстрое восстановление и смотрим что получилось с генерацией SQL-запроса: SELECT accounts.id , accounts.name , accounts.renewal_date_c , accounts.time_zone_c , accounts.tariff_c , accounts.assigned_user_id FROM accounts where ((accounts.id IN ('d924c25c-7ab0-25c8-f5cd-5a58c168620a'))) AND accounts.deleted=0 ORDER BY accounts.date_entered DESC Что уже то, что нам нужно...
  13. Всем привет! Давайте разберем ситуацию, когда нам необходимо выполнить единоразово какую то задачу в CRM-системе, и мы хотим, чтобы эту задачу выполнил планировщик. Подобное может потребоваться, например когда: Пользователь должен запустить "тяжелый" алгоритм, результаты которого не надо сразу получить на экране. Например: синхронизация CRM-системы с какой-либо другой системой (если честно, я этот механизм "подсмотрел" в библиотеки связки Mautic и SuiteCRM). То есть пользователь где то в системе запускает синхронизацию и продолжает далее работать в CRM-системе, а CRM-система в этот момент производит долгую ресурсоёмкую процедуру синхронизации. В теории можно настроить запуск планировщик SuiteCRM на запуск из под root. Таким образом в задачах, выполняемых в планировщике, появится полный доступ к всему серверу. Это может пригодиться для каких то специфических задач (все же обычно запускать crontab из под root не рекомендуется). В этом случае запуск задачи из планировщика позволит сделать что то такое, что выполнить простым запросом не получится (в частности в данный момент при написании статьи мне было необходимо менять chmod у файла с логами php, которые создавались из под root). Нужна некая отсрочка выполнения некой задачи. Наверное можно еще много чего напридумывать, но статья про запуск задач в планировщике, а не про зачем это надо ))) Итак. Общий посыл к тому, что у нас должно происходить: Есть некая задача в планировщике Эту задачу не видно в списке задач, она не предназначена для цикличного воспроизведения, только разовый запуск Мы должны иметь некий механизм, который указывает ЕДИНОРАЗОВО запустить нашу задачу в планировщике Планировщик, используя его встроенные механизмы, видит необходимость запуска задачи и выполняет это действие Теперь давайте перейдем к практике. Давайте начнем с механизма создания задачи в планировщике: require_once('modules/SchedulersJobs/SchedulersJob.php'); require_once("include/SugarQueue/SugarJobQueue.php"); // Создаем задачу в планировщике $job = new SchedulersJob(); $job->name = "postInstall"; $job->target = "class::postInstallCRMHosting"; $job->assigned_user_id = '1'; $job->execute_time =$GLOBALS['db']->convert($GLOBALS['timedate']->getNow()->modify("+10 seconds")->asDb(), 'datetime'); $jq = new SugarJobQueue(); $jq->submitJob($job); Этот блок создаст запись в таблице `job_queue` (да да, именно в той, куда валятся все логи выполняемых задач в планировщике). Мы создаем запись с названием "postInstall" и указываем, что должен выполниться класс postInstallCRMHosting. Время выполнения - через 10 секунд. На самом деле задачи в планировщике выполняются по тикам crontab, который запускается ежеминутно в начале минуты. По этому если надо "прям сейчас", то все равно будет в ближайшую минуту. Но пусть будет 10 секунд. Также, при необходимости, можно указать через час/день/год. Таким образом в ближайшие минуту система попытается запустить выполнение задачи в классе postInstallCRMHosting. Давайте добавим файл с таким классом. Проще всего (и думаю правильнее всего) добавлять задачи в папку /custom/Extension/modules/Schedulers/Ext/ScheduledTasks. Например, файл с моей задачей будет выглядеть примерно так: /custom/Extension/modules/Schedulers/Ext/ScheduledTasks/CRMHosting.postInstall.php <?php /** * Created by PhpStorm. * User: crmhosting * Date: 06.05.2018 * Time: 9:39 */ class postInstallCRMHosting implements RunnableSchedulerJob { public function setJob(SchedulersJob $job) { $this->job = $job; } public function run($job_data) { /************************************ * Назначаем права на файл с логами */ $log_file = "/var/log/php-fpm/www-error.log"; exec("chmod 0777 " . $log_file); /************************************/ return true; } } Таким образом SuiteCRM выполнит наш скрипт единоразово в планировщике. В списке задач планировщика ничего не появится лишнего. Можно подобным образом запускать задачу необходимое количество раз.
  14. Всем привет! Просто небольшая заметка, но может кому пригодиться... Иногда бывает, что в карточке модуля на панели необходимо разместить некую информацию, но Label у этой информации будет лишним. Например: список ссылок, относящихся к тому или иному контрагенту: Как вы видите, здесь имеет место быть отдельная панель, и в этой панели единственная переменная, которая содержит кучу ссылок. В DetailView это выглядит примерно так: 'lbl_editview_panel12' => array ( 0 => array ( 0 => array ( 'name' => 'custom_all_link', 'label' => '', ), ), ), Видите две точки? Это наш пустой Label. Выглядит не красиво, не правда ли? Можно конечно было бы добавить туда какой-нибудь LBL_, но он тут избыточен! Панель и так называется "Ссылки". И внутрь вставлять еще раз "Ссылки" или что то такое уже было бы лишним! Давайте просто уберем вообще блок, в котором находится Label: 'lbl_editview_panel12' => array ( 0 => array ( 0 => array ( 'name' => 'custom_all_link', 'label' => '', 'hideLabel' => true, ), ), ), Теперь наш блок примет вид: Как вы видите Label для нашего поля перестал вообще отображаться!
  15. Всем привет! В последних на текущий момент (апрель 2018 года) версиях SuiteCRM (версии SuiteCRM 7.10.2 и 7.10.3) столкнулись с небольшой проблемой: куда то пропали боковые меню в модулях, доступных админу (которые находятся в админке). Например, когда смотрим на свой профиль: Начал искать что куда делось. Нашел не очень старые версии, в которых все работало. Состав меню определяется в том числе в файле Menu.php, лежащем в модуле. Там есть строки типа: $module_menu = Array(); if ($GLOBALS['current_user']->isAdminForModule('Users') ) { $module_menu = Array( Array("index.php?module=Users&action=EditView&return_module=Users&return_action=DetailView", $mod_strings['LNK_NEW_USER'], "Create"), Array("index.php?module=Users&action=EditView&usertype=group&return_module=Users&return_action=DetailView", $mod_strings['LNK_NEW_GROUP_USER'], "Create_Group_User") ); $module_menu[] = Array("index.php?module=Users&action=ListView&return_module=Users&return_action=DetailView", $mod_strings['LNK_USER_LIST'], "List"); $module_menu[] = Array("index.php?module=Import&action=Step1&import_module=Users&return_module=Users&return_action=index", $mod_strings['LNK_IMPORT_USERS'], "Import", 'Contacts'); } Ищем в /includes/ файлы, где активно используется переменная $module_menu. Внимание привлекла папка /include/MVC/ Сравниваем текущую версию этой папки с версией, где все работало: Смутило использование unset. Оказалось верно, что смутило. В общем убираем этот блок из файла SugarView.php, и вуа-ля:
  16. Стояла задача организовать поиск(фильтрацию на карточке списка) обращений по email связанных c ним Контактов , так же необходимо , что бы искались записи по частичному вхождению адреса, например почта [email protected] должна находиться при запросе: "pup" добавили поле mail_contact_init типа non-db выводим его в карточку расширенного поиска custom/modules/Cases/metadata/searchdefs.php или через студию, добавляем запрос по которому будит производиться поиск записи Обращения (в запросе нужно получить Id записи модуля по необходимым нам условиям) в файле custom/modules/Cases/metadata/SearchFields.php в моем случае это: 'mail_contact_init' => //Наименование поля array ( 'query_type' => 'format', // Тип запроса, важно указать format 'operator' => 'subquery', 'subquery' => 'SELECT cases.id FROM cases INNER JOIN contacts_cases ON cases.id IN (SELECT contacts_cases.case_id FROM contacts_cases WHERE contacts_cases.deleted = 0 AND contacts_cases.contact_id IN (SELECT email_addr_bean_rel.bean_id FROM email_addr_bean_rel WHERE email_addr_bean_rel.deleted = 0 AND email_addr_bean_rel.bean_module = \'Contacts\' AND email_addr_bean_rel.email_address_id IN (SELECT email_addresses.id FROM email_addresses WHERE email_addresses.deleted = 0 AND email_addresses.email_address LIKE "%{0}%" ) ) )', // вместо {0} suitecrm подставляет введенный запрос в данном поле 'db_field' => // Выборка записей основного модуля (обращения) идет по id array ( 0 => 'id', ), 'vname' => 'LBL_MAIL_CONTACT_INIT', ),
  17. Решил по одному проекту сделать сортировку записей в сабпанели. Добавил кнопки, повесил ajax-запросы при нажатии на ссылки. После сортировки надо перезагрузить сабпанель желательно без перезагрузки всей страницы: после успешной смены сортировки через ajax вызываем showSubPanel('themes',null,true); примерно так: function sortThemes(theme_id, order) { console.log('sortThemes - start'); console.log('theme_id = ', theme_id); console.log('order = ', order); var record = $('#formDetailView input[name=record]').val(); console.log('record = ', record); //Pass the properties to the controller function via ajax $.ajax({ type: "GET", url: "index.php?module=AOS_Quotes&action=setOrderThemes&record="+record+"&theme_id="+theme_id+"&sort_order="+order+"&to_pdf=true", dataType: 'json', success: function(data) { // On success generate the tasks for the chart console.log('data = ', data); showSubPanel('themes',null,true); } }); } Панель, которую перезагружаю, соответственно описывается как theme: $layout_defs["AOS_Quotes"]["subpanel_setup"]['themes'] = array ( 'order' => 100, 'module' => 'Themes', 'subpanel_name' => 'default', 'sort_order' => 'asc', 'sort_by' => 'id', 'title_key' => 'LBL_THEMES_SUBPANEL_TITLE', 'get_subpanel_data' => 'themes', 'top_buttons' => array ( 0 => array ( 'widget_class' => 'SubPanelTopButtonQuickCreate', ), 1 => array ( 'widget_class' => 'SubPanelTopSelectButton', 'mode' => 'MultiSelect', ), ), );
  18. Всем привет! Хочу поделиться методом, который позволяет добавлять фильтры в список записей модуля, когда надо найти все записи, у которых есть связи с определенной записью из другого модуля. Сразу оговорюсь, что SuiteCRM позволяет выполнять подобный поиск стандартным способом, когда ищем в связке один-ко-многим. Например, должны найти все Контакты, принадлежащие определенному Контрагенту. Подобные связи можно видеть в настройках шаблона поиска в студии, и настраиваются они без вмешательства программиста путем работы с интерфейсом. Мы же с вами поговорим о ситуациях, когда надо найти все записи модуля, который связан с другим модулем произвольным образом (возможно связью многие-ко-многим, а возможно вообще какими то сложными логическими схемами, главное чтобы эту связь можно было уместить в одном SQL-запросе). Чтобы было понятней давайте рассмотрим пример: Допустим, нам надо в модуль "Документы" в списке записей добавить фильтр, в котором мы сможем найти все документы, у которых за контрагента отвечает определенный пользователь. Таким образом: Мы находимся в модуле Документы Нам надо отфильтровать Документы по пользователю (модуль Users) У каждого Документа есть связь с Контрагентами многие-ко-многим (в карточке Документа сабпанель Контрагенты) В Контрагенте есть связь с Пользователем через поле assigned_user_id Нам надо получить список Документов по фильтру Стандартным способом без программирования мы сможем реализовать подобную задачу только используя модуль "Отчёты". Да. Так тоже можно. Но не удобно. Хочется находиться в модуле Документы, а не "шарится" по другим модулям/отчётам. Далее я тезисно опишу что нам понадобиться сделать, а потом покажу, как это будет выглядеть в файлах. Итак: Хотя механизм формирования фильтров и позволяет в них добавлять разную самописную отсебятину, но наиболее удобным средством, по результатам многочисленных опытов, стало добавление фильтра в виде поля модуля. То есть мы добавляем поле в модуль Documents, которое будет собой представлять как бы ссылку на модуль Users. А чтобы это поле не вносило изменения в структуру таблицы с документами, ему (этому полю) необходимо указать характеристику 'source' => 'non-db'. После выполнения быстрого восстановления с вновь добавленным полем модуля можно работать как с обычным полем: заходим в студию, и в модуле Документы помещаем его в макет поиска туда, куда удобно (имеет смысл работать только с макетом поиска, в других макетах это поле не будет работать, потому что не хранит данные в базе). В настройках поиска вручную задаем SQL-запрос, который должен будет выполниться, если мы введем какое то значение в фильтре в нашем поле. Ну а теперь по конкретике: Создаем файл с описанием добавляемого поля: custom/Extension/modules/Documents/Ext/Vardefs/account_assigned_user.php: <?php $dictionary['Document']['fields']['account_assigned_user'] = array ( 'required' => false, 'source' => 'non-db', 'name' => 'account_assigned_user', 'vname' => 'LBL_ACCOUNT_ASSIGNED_USER', 'type' => 'relate', 'massupdate' => 0, 'no_default' => false, 'comments' => '', 'help' => '', 'importable' => 'true', 'duplicate_merge' => 'disabled', 'duplicate_merge_dom_value' => '0', 'audited' => false, 'inline_edit' => true, 'reportable' => true, 'unified_search' => false, 'merge_filter' => 'disabled', 'len' => '255', 'size' => '20', 'id_name' => 'id', 'ext2' => 'Users', 'module' => 'Users', 'rname' => 'name', 'quicksearch' => 'enabled', 'studio' => 'visible', ); Хотел бы обратить ваше внимание на следующу строку: 'id_name' => 'id', Вообще подобное поле положено создавать в паре с полем, которое будет хранить непосредственно id связанной записи в базе данных. Это когда мы хотим добавить связь один-ко-многим. И в параметре 'id_name' нужно было бы указать название подобного поля. Но в нашем случае в таблице с документами нам не надо ничего хранить. Все необходимые данные мы будем получать из других таблиц. По этому в параметре 'id_name' мы указываем значение 'id' (если не вдаваться сильно в детали, то неуказать ничего мы тоже не можем, потому что этот параметр потом будет учавствовать в SQL-запросе в виде `documents`.`<id_name>`, и если ничего не указать, то будет SQL-ошибка). Создаем файл с локализацией создаваемого поля: custom/Extension/modules/Documents/Ext/Language/ru_RU.account_assigned_user.php <?php $mod_strings['LBL_ACCOUNT_ASSIGNED_USER'] = 'Ответственный за Контрагента'; Выполняем быстрое восстановление. После этого можно идти в Студию и настраивать внешний вид поиска: Переходим в модуль "Документы, и видим, что поле появилось в списке фильтров (верстка кривая, но сейчас не о этом): Нам необходимо немного модернизировать отображение этого поля. Дело в том, что если мы выбор пользователя оставим в текущем виде, то в наш SQL-запрос будет попадать имя пользователя. А это немного не то, что надо. Нам надо, чтобы в SQL-запрос попадал ID выбранного поля. Для этого мы поле переделываем в выпадающий список: открываем файл custom/modules/Documents/metadata/searchdefs.php, находим в нем секцию account_assigned_user, и меняем ее на 'account_assigned_user' => array ( 'type' => 'enum', 'studio' => 'visible', 'label' => 'LBL_ACCOUNT_ASSIGNED_USER', 'id' => 'ID', 'link' => true, 'width' => '10%', 'default' => true, 'name' => 'account_assigned_user', 'function' => array ( 'name' => 'get_user_array', 'params' => array ( 0 => false, ), ), ), После данной манипуляции фильтр по пользователю должен выглядеть как список пользователей (там опять кривая верстка, но мы опять не про нее): Если сейчас выбрать в этом поле пользователя, и нажать кнопку "Найти" в поиске, то мы ничего не получим, потому что еще не настроили механизм поиска нужных нам записей. А для этого переходим к файлу custom/modules/Documents/metadata/SearchFields.php (он как раз должен был создастся после манипуляций в студии), и вставляем в него блок такого содержания: 'account_assigned_user' => array ( 'query_type' => 'format', 'operator' => 'subquery', 'subquery' => ' SELECT `documents`.`id` FROM `documents` INNER JOIN `documents_accounts` ON `documents_accounts`.`document_id` = `documents`.`id` AND `documents_accounts`.`deleted` = 0 INNER JOIN `accounts` ON `accounts`.`id` = `documents_accounts`.`account_id` AND `accounts`.`deleted` = 0 WHERE `documents`.`deleted` = 0 AND `accounts`.`assigned_user_id` = "{0}" ', 'db_field' => array ( 0 => 'documents.id', ), ), Как видно (надеюсь), данный код ищет все id в таблице documents, которые связаны с контрагентом, у которого в assigned_user_id указано выбранное нами значение. А вот такой SQL-запрос соберется в итоге при попытке воспользоваться данным фильтром (указаю юзера с айди = 1 - админ): SELECT documents.id , documents.assigned_user_id , documents.id , documents.document_name , documents.document_revision_id , documents.doc_id , documents.doc_type , documents.doc_url , documents.category_id , documents.subcategory_id , documents.exp_date , jt0.user_name assigned_user_name , jt0.created_by assigned_user_name_owner , 'Users' assigned_user_name_mod, documents.date_entered , LTRIM(RTRIM(CONCAT(IFNULL(jt1.first_name,''),' ',IFNULL(jt1.last_name,'')))) account_assigned_user , documents.created_by FROM documents LEFT JOIN users jt0 ON documents.assigned_user_id=jt0.id AND jt0.deleted=0 AND jt0.deleted=0 LEFT JOIN users jt1 ON documents.id = jt1.id AND jt1.deleted=0 where ((documents.id IN ( SELECT `documents`.`id` FROM `documents` INNER JOIN `documents_accounts` ON `documents_accounts`.`document_id` = `documents`.`id` AND `documents_accounts`.`deleted` = 0 INNER JOIN `accounts` ON `accounts`.`id` = `documents_accounts`.`account_id` AND `accounts`.`deleted` = 0 WHERE `documents`.`deleted` = 0 AND `accounts`.`assigned_user_id` = "1" ))) AND documents.deleted=0 ORDER BY documents.document_name ASC Подобным образом можно заложить любую логику поиска нужной записи. Пользуйтесь )))
  19. Version 1.0.1

    20 downloads

    Описание будет
  20. Доброго времени суток. Только начал знакомиться с замечательным произведением программерского исскусства - SuiteCRM. Приходится работать с уже готовыми системами, опыта мало. Вопрос: Есть список звонков. Есть колонка - продолжительность разговора. У клиента в заголовке колонки указано время продолжительности в секундах. Но в строках эти значения уже в минутах. Список звонков - это стандартный модуль Calls. Есть менее стандартный модуль Asterisk, со своим скриптом asteriskLogger.php. В этом Логгере ведется вычисление продолжительности разговора. Далее совсем мне непонятным способом эти значения продолжительности попадают в журнал звонков и выводятся на экран монитора. Вопрос к опытным специалистам - какова цепочка передачи данных продолжительности звонка в список звонков? Чтобы отследить ошибку и установить верное значение продолжительности уже в секундах, как и положено. Спасибо.
  21. Version 1.0.0

    48 downloads

    Пакетная загрузка файлов в субнанель документы для SuiteCRM/SugarCRM dragndropuploaddocuments - это дополнение к CRM-системе на базе SugarCRM/SuiteCRM, которое позволяет быстро и удобно добавлять файлы в субпанели документы, просто перетягивая их из папки в поле для загрузки. Поддерживает загрузку нескольких файлов одновременно. Вот смотрите, на примере модуля Контрагенты: для установки необходимо : 1 скачать файл дополнения 2 перейти в CRM систему Администрирование -> Загрузчик модулей 3 выбрать файл дополнения 4 После того , как модуль окажется в списке загруженных нажать кнопку установить 5 В появившемся окне ознакомиться с условиями лицензионного соглашения и нажать кнопку переместить переключатель в положение принимаю и нажать кнопку вперед 6 дождитесь окончания установки. Модуль готов к использованию
  22. Может быть не все знают, что SuiteCRM позволяет двумя разными способами отображать кнопки с действиями, которые можно произвести с текущей картой. Это там, где есть кнопки "Править", "Поиск дубликатов", "Просмотреть журнал изменений" и так далее: В целом такая компоновка кнопок удобна, потому что компактна и не перегружает интерфейс кнопками, которые редко используются (часто используемая кнопка "Править" всегда наверху в быстром доступе). Но вот лично мне больше нравится, когда кнопки все расположены рядышком и их можно сразу кликнуть. Чтобы для всех CRM-системы указать именно такой формат отображения кнопок необходимо в config_override.php добавить параметр: $sugar_config['enable_action_menu'] = false; После этого кнопки примут вид:
  23. Version 1.0.0

    14 downloads

    TechnicalSupport - это дополнительный модуль к CRM-системе на базе SugarCRM/SuiteCRM, который позволяет обращаться к службе технической поддержки CRMHosting.ru для согласовывания и последующего выполнения Ваших пожеланий по доработке Вашей CRM-системы. Примерный перечень возможных работ и их стоимость можно посмотреть на сайте.
  24. В процессе разработки столкнулся с проблемой: независимо от того какие значения стоят в полях "Дата начала" и "Дата выполнения" в модуле "Задачи", в календаре такая задача отображается размером в 2 часа. При этом звонки и встречи отображаются нормально. Путём сравнения модулей удалось выяснить, что в звонках и встречах есть поля duration_hours и duration_minutes по которым строится отображение данных в календаре. Я предположил, что необходимо найти место где формируются данные для календаря и высчитывать эти значения там. В файле modules/Calendar/Calendar.php ищем строки "Tasks" => array("showCompleted" => true,"start" => "date_due", "end" => "date_due"), заменяем на "Tasks" => array("showCompleted" => true,"start" => "date_start", "end" => "date_due"), В этом же файле ещё ищем: if (isset($act->sugar_bean->duration_hours)) { $item['duration_hours'] = $act->sugar_bean->duration_hours; $item['duration_minutes'] = $act->sugar_bean->duration_minutes; } и заменяем на $cstm_mod = array('Opportunities', 'Tasks'); //модули в которых необходимо высчитывать duration_hours и duration_minutes if ( in_array($act->sugar_bean->module_dir, $cstm_mod) ) { //для "особенных" модулей считаем if (isset($act->sugar_bean->date_start) && isset($act->sugar_bean->date_due)) { $datetime1 = new DateTime($act->sugar_bean->date_start); //дата начала $datetime2 = new DateTime($act->sugar_bean->date_due); //дата окончания $interval = $datetime1->diff($datetime2); $item['duration_hours'] = $interval->format('%H'); $item['duration_minutes'] = $interval->format('%I'); } } else { //для обычных модулей берём существующие значения if (isset($act->sugar_bean->duration_hours)) { $item['duration_hours'] = $act->sugar_bean->duration_hours; $item['duration_minutes'] = $act->sugar_bean->duration_minutes; } } А так же в файле modules/Calendar/CalendarUtils.php в методе get_time_data комментируем строки: if($bean->object_name == 'Task') $start_field = $end_field = "date_due"; В результате получаем, что задача отображется в календаре начиная со времени начала и длится до времени окончания.
×
×
  • Create New...