Привет, меня зовут Евгений, я разработчик из Байовэр в компании НЛМК ИТ.
Довелось мне тут столкнуться с разработкой системы опытно-промышленных испытаний на производстве, и если описать это коротко, то в целом большое количество людей разного уровня допуска должны совершить определенные действия в строгой последовательности (или местами асинхронно) для вынесения вердикта относительно качества продукта, и при этом управляться все это должно из одного места (как странно-то прозвучало:) Так как это достаточно инерционный процесс, который может занимать от нескольких месяцев до года, система, которая может рассылать ответственным за текущий шаг уведомления (а в случае простоя, и их руководству), позволяет ускорить прохождение большинства шагов бизнес-процессов (БП).
Приведу пример – требуются лакокрасочные покрытия от стороннего поставщика для работы цеха, но перед заключением контракта на массовые поставки, нужно убедиться, что товар не разбавлен. Ну то есть надлежащего качества:) Заранее прошу прощения за качество юмора, вы привыкнете.
Для этого закупается небольшая опытная партия, она проходит определенные испытания, и если результаты устраивают, процесс масштабируется на опытно-промышленную партию, побольше. Если же и в этом случае все испытания пройдены, то поставщик признается годным и материал одобряется к серийному применению. Собственно процесс испытаний мы и реализовали с помощью смарт-процессов. В данный момент у нас внедрено три категории материалов для испытаний (лакокрасочные материалы, огнеупорные материалы, наконечники медных фурм), они гораздо сложнее примера, который я хочу здесь описать, но его должно быть достаточно для масштабирования под нужды бизнеса.
Вообще смарт-процессы – это довольно гибкий инструмент в битрикс 24. Они чем-то похожи на шаблонные БП битрикса, типа сделок и лидов, но при этом не ограничены никакими рамками – все поля настраиваются свободно, схема взаимодействия собирается как конструктор. Описание их можно легко найти на просторах бескрайнего (как и примеры работы с ними через визуальный интерфейс), я же хотел больше сосредоточиться на примерах кода, которые в свое время искал с миру по нитке(Отдельная благодарность Реналю Сунагаттулину, Даниилу Кузнецову и Александру Костикову — во многом это их код будет представлен). В идеале я просто выложу здесь несколько миграций, прогнав которые на чистой коробке можно пощупать новый БП со всех сторон и начать работать.
На проекте испытаний мы используем модуль миграций sprint, и именно его мы используем для переноса БП между ландшафтами. Сразу скажу, кода будет много, входите только если взяли с собой достаточно терпения и бутерброды, поехали.
Создание нового БП
Каждый БП представляет собой динамический тип – запись в таблице b_crm_dynamic_type. У этой записи при этом будет произвольный entity_type_id – пусть будет 145, и карточки этого БП соответственно будут храниться в таблице b_crm_dynamic_items_145. Также это число 145 будет фигурировать в пути к элементу БП, например /crm/type/145/details/14/ — карточка с ид 14, принадлежащая 145 БП. Это я к тому, что если мы захотим завести пункт меню для конкретных процессов, нужно будет учитывать, как строится url.
Естественно, для того чтобы начать какую-то работу, нужен предварительный план, так что накидаем приблизительно, что мы будем делать в нашем тестовом примере.
Пусть у нас будет небольшой цех испытаний вольфрамовых наконечников для стрел. В простейшем БП мы пройдем 3 стадии – загрузка отчета об испытаниях и согласование документов, заключение начальника цеха и финальное заключение о целесообразности сотрудничества. Схема будет выглядеть примерно вот так:

После того, как мы накидали план, можно приступать к созданию нового типа БП. Сделать это можно вот так:
Создание БП
<?php namespace Sprint\Migration; use Bitrix\Crm\Model\Dynamic\TypeTable; use Bitrix\Main\Application; use Bitrix\Main\Composite\Page; use Bitrix\Main\Data\Connection as DataConnection; use Bitrix\Main\Data\ManagedCache; use Bitrix\Main\DB\Connection as DBConnection; use Bitrix\Main\Error; use Bitrix\Main\Loader; use Bitrix\Main\Engine\Resolver; use Bitrix\Main\ArgumentException; use Bitrix\Main\DB\SqlQueryException; use Bitrix\Main\LoaderException; use Bitrix\Main\NotSupportedException; use Bitrix\Main\ObjectNotFoundException; use Bitrix\Main\ObjectPropertyException; use Bitrix\Main\SystemException; use Bitrix\Crm\Service\Factory; use Bitrix\Crm\Service\Container; use Bitrix\Crm\StatusTable; use Bitrix\UI\Form\EntityEditorConfigScope; use Bitrix\UI\Form\EntityEditorConfiguration; use CCrmOwnerType; use CStackCacheManager; use Exception; use Throwable; /** * Создаем новую сущность смарт-процессов 'Вольфрамовые Наконечники', задаем список стадий согласно плану, * цвета можно брать произвольные, это влияет только на внешний вид вкладок со стадиями */ class CreateBP_20241115103045 extends Version { protected $description = "Миграция на смарт-процесс 'Вольфрамовые Наконечники'"; protected $moduleVersion = "4.3.1"; protected ?HelperManager $helper; protected ?EntityEditorConfiguration $editorConf; protected DataConnection|DBConnection|null $connection; protected ?int $entityId = null; protected ?int $entityTypeId = null; protected const SMART_PROCESS_TUNGSTEN_TIPS = [ 'CODE' => 'TUNGSTEN_TIPS', 'TITLE' => 'Вольфрамовые наконечники', ]; protected const SMART_PROCESS_CONFIG = [ 'isCategoriesEnabled' => true, 'isStagesEnabled' => true, 'isBeginCloseDatesEnabled' => false, 'isClientEnabled' => true, 'isUseInUserfieldEnabled' => true, 'isLinkWithProductsEnabled' => false, 'isMycompanyEnabled' => true, 'isDocumentsEnabled' => true, 'isSourceEnabled' => false, 'isObserversEnabled' => true, 'isRecyclebinEnabled' => true, 'isAutomationEnabled' => true, 'isBizProcEnabled' => true, 'isSetOpenPermissions' => true, 'isPaymentsEnabled' => false, 'isCountersEnabled' => false, 'linkedUserFields' => [ 'CALENDAR_EVENT|UF_CRM_CAL_EVENT' => 'true', 'TASKS_TASK|UF_CRM_TASK' => 'true', 'TASKS_TASK_TEMPLATE|UF_CRM_TASK' => 'true' ], 'customSections' => false, 'customSectionId' => 0, ]; protected const CATEGORIES = [ 'DEFAULT' => [ 'NAME' => 'Проверка качества', 'STAGES' => [ 'REPORT_UPLOAD' => [ 'NAME' => 'Загрузка Отчёта', 'SORT' => 10, 'COLOR' => '#91BAFF' ], 'DIVISION_APPROVAL' => [ 'NAME' => 'Согласование Цеха', 'SORT' => 30, 'COLOR' => '#FFCC82' ], 'FINAL_CONCLUSION' => [ 'NAME' => 'Финальное заключение', 'SORT' => 40, 'COLOR' => '#BBD6FF', ], 'CLOSED' => [ 'NAME' => 'Отклонено', 'SORT' => 50, 'COLOR' => '#FFA0A0', 'SEMANTICS' => 'F', ], 'APPROVED' => [ 'NAME' => 'Согласовано', 'SORT' => 60, 'COLOR' => '#A7C8FE', 'SEMANTICS' => 'S', ], ] ], ]; protected const EDITOR_COMMON_SCOPE = [ 'scope' => EntityEditorConfigScope::COMMON, 'forAllUsers' => 'Y', 'delete' => 'Y', ]; /** * @throws LoaderException */ public function __construct() { Loader::includeModule('crm'); $this->helper = $this->getHelperManager(); $this->connection = Application::getConnection(); $this->editorConf = new EntityEditorConfiguration('crm.entity.editor'); } /** * @return bool * @throws SqlQueryException */ public function up(): bool { $result = true; try { $this->connection->startTransaction(); [$entityId, $entityTypeId] = $this->upSmartProcess(); if (!$entityId || !$entityTypeId) { throw new Exception('Smart process was not created'); } $this->entityTypeId = $entityTypeId; $this->entityId = $entityId; $this->createCategories(); $this->connection->commitTransaction(); } catch (Throwable $e) { $this->connection->rollbackTransaction(); $this->outError($e->getMessage()); $result = false; } $this->clearCache(); return $result; } /** * @return bool * @throws SqlQueryException */ public function down(): bool { $result = true; try { $this->connection->startTransaction(); $arEntity = $this->getEntityByCode(self::SMART_PROCESS_TUNGSTEN_TIPS['CODE']); if (empty($arEntity['ID'])) { throw new Exception('Smart process was not found'); } $this->entityId = $arEntity['ID']; $this->entityTypeId = $arEntity['ENTITY_TYPE_ID']; $this->downSmartProcess(); $this->connection->commitTransaction(); } catch (Throwable $e) { $this->connection->rollbackTransaction(); $this->outError($e->getMessage()); $result = false; } $this->clearCache(); return $result; } /** * @return TypeTable|string * @throws ObjectNotFoundException */ protected function getEntityClass(): TypeTable|string { return Container::getInstance()->getDynamicTypeDataClass(); } /** * @param int $entityTypeId * @return Factory * @throws Exception */ protected function getFactory(int $entityTypeId): Factory { $factory = Container::getInstance()->getFactory($entityTypeId); if (!$factory) { throw new Exception('Can\t resolve factory'); } return $factory; } /** * @param string $code * @return array * @throws ArgumentException * @throws SystemException * @throws ObjectPropertyException */ protected function getEntityByCode(string $code): array { $entityClass = $this->getEntityClass(); $query = $entityClass::getList([ 'select' => ['ID', 'ENTITY_TYPE_ID'], 'filter' => ['CODE' => $code] ]); return $query->fetch() ?: []; } /** * Сбор конфигурации для нового смарт-процесса * @return array * @throws Exception */ protected function prepareConfig(): array { /** @var object $entityType */ $entityType = $this->getEntityClass()::createObject(); if (!$newEntityTypeId = $entityType->getEntityTypeId()) { throw new Exception('Can\t create new entity type'); } return array_merge( [ 'code' => self::SMART_PROCESS_TUNGSTEN_TIPS['CODE'], 'title' => self::SMART_PROCESS_TUNGSTEN_TIPS['TITLE'], 'entityTypeId' => $newEntityTypeId ], [ 'relations' => [ 'parent' => false, 'child' => [ [ 'entityTypeId' => CCrmOwnerType::Contact, 'isChildrenListEnabled' => false ], [ 'entityTypeId' => CCrmOwnerType::Company, 'isChildrenListEnabled' => false ] ] ] ], self::SMART_PROCESS_CONFIG, ); } /** * @param int|null $categoryId * @return void * @throws Exception */ protected function deleteCategoryStages(int $categoryId = null): void { $factory = $this->getFactory($this->entityTypeId); if (!$factory->isStagesEnabled()) { throw new Exception('Stages not enabled'); } if (!$categoryId) { $defaultCategory = $factory->getDefaultCategory(); $categoryId = $defaultCategory->getId(); } $stages = $factory->getStages($categoryId); foreach ($stages as $stage) { $stage->delete(); } } /** * @return void * @throws NotSupportedException * @throws Exception */ protected function createCategories(): void { $factory = $this->getFactory($this->entityTypeId); $defaultCategory = $factory->getDefaultCategory(); foreach (self::CATEGORIES as $categoryCode => $arCategory) { if ($categoryCode == 'DEFAULT') { $categoryId = $defaultCategory->getId(); $defaultCategory->setName($arCategory['NAME']); $defaultCategory->save(); } else { $newCategory = $factory->createCategory([ 'NAME' => $arCategory['NAME'], 'CODE' => $categoryCode, 'IS_SYSTEM' => 'N', 'IS_DEFAULT' => 'N', 'ENTITY_TYPE_ID' => $this->entityTypeId ]); $newCategory->save(); $categoryId = $newCategory->getId(); } if (!$categoryId) { continue; } $this->deleteCategoryStages($categoryId); foreach ($arCategory['STAGES'] as $statusCode => $arStage) { $this->addStage($categoryId, $statusCode, $arStage); } } $factory->clearCategoriesCache(); } /** * @param int $categoryId * @param string $code * @param array $arStage * @return int|null * @throws Exception */ protected function addStage(int $categoryId, string $code, array $arStage): ?int { $entityId = sprintf('DYNAMIC_%s_STAGE_%s', $this->entityTypeId, $categoryId); $statusId = sprintf('DT%s_%s:%s', $this->entityTypeId, $categoryId, $code); $arFields = [ 'COLOR' => $arStage['COLOR'] ?? '#1111AA', 'NAME_INIT' => $arStage['SYSTEM'] === 'Y' ? $arStage['NAME'] : '', 'NAME' => $arStage['NAME'], 'SORT' => $arStage['SORT'], 'ENTITY_ID' => $entityId, 'CATEGORY_ID' => $categoryId, 'STATUS_ID' => $statusId, 'SEMANTICS' => $arStage['SEMANTICS'] ?? null, ]; if (!empty($arStage['SYSTEM'])) { $arFields['SYSTEM'] = $arStage['SYSTEM']; } $result = StatusTable::add($arFields); return $result->isSuccess(); } /** * @return array * @throws Exception */ protected function upSmartProcess(): array { $arResult = []; $arEntity = $this->getEntityByCode(self::SMART_PROCESS_TUNGSTEN_TIPS['CODE']); if (!empty($arEntity['ID'])) { throw new Exception('Smart process already created'); } //Используем стандартный bitrix контроллер для управления смарт процессами [$controller, $action] = Resolver::getControllerAndAction('bitrix', 'crm', 'controller.type.add'); if ($controller && $action) { //Для выключения ActionFilter $controller->setScope($controller::SCOPE_CLI); //Создание смарт-процесса $arConfig = $this->prepareConfig(); $newType = $controller->run($action, [['fields' => $arConfig]]); $errorCollection = $controller->getErrors(); if (is_array($errorCollection) && !empty($errorCollection)) { $errorMessage = 'Error during creating smart process'; if ($errorCollection[0] instanceof Error) { $errorMessage = $errorCollection[0]->getMessage(); } throw new Exception($errorMessage); } $this->outInfo('Smart process %s successfully created', $newType['type']['id']); $arResult = [$newType['type']['id'], $newType['type']['entityTypeId']]; } return $arResult; } /** * @return void * @throws Exception */ protected function downSmartProcess(): void { $factory = $this->getFactory($this->entityTypeId); if ($factory->getItemsCount() > 0) { throw new Exception('Can\t delete smart process with items'); } [$controller, $action] = Resolver::getControllerAndAction('bitrix', 'crm', 'controller.type.delete'); if ($controller && $action) { $controller->setScope($controller::SCOPE_CLI); //Удаляем smart процесс $controller->run($action, [['id' => $this->entityId]]); $errorCollection = $controller->getErrors(); if (is_array($errorCollection) && !empty($errorCollection)) { $errorMessage = 'Error during deleting smart process'; if ($errorCollection[0] instanceof Error) { $errorMessage = $errorCollection[0]->getMessage(); } throw new Exception($errorMessage); } } $this->outInfo('Smart process successfully deleted'); } /** * @return void */ protected function clearCache(): void { BXClearCache(true); (new ManagedCache())->cleanAll(); (new CStackCacheManager())->CleanAll(); Page::getInstance()->deleteAll(); } }
Файлы миграции я буду приводить в чуть измененном виде, чем используем мы, т.к. мне пришлось выпиливать зависимости на классы, которых в коробке не существует, чтобы каждую из миграций можно было бы копировать целиком и запускать. Как вы скоро увидите, краткость была принесена в жертву самым жестоким образом, вслед за братом, однако я постараюсь изложить полный процесс создания сущности смарт-процесса с помощью последовательных миграций.
Как можно увидеть, мы сделали БП, в котором 5 стадий. В последних двух есть ключ ‘SEMANTICS’ – он отвечает за успешное или не успешное завершение БП, сам факт попадания в эту стадию означает завершение текущей воронки БП. Наличие хотя бы одной стадии с ключом ‘SEMANTICS’ => ‘S’ необходимо для работы карточки БП (иначе будет ошибка в карточке).
По итогу у нас появилась новая сущность – Вольфрамовые наконечники, можем посмотреть на нее вот тут (/crm/type/), но чтобы работать с этим, нужно еще наполнить карточку полями.

Создание пользовательских полей
Количество этих полей зависит только от требований бизнеса, в примере их будет несколько, а в реальных БП их, как правило, десятки. Допустим у нас будет 4 таких поля – ответственный по цеху, дирекция, отчет по качеству продукции и заключение. Таким образом будут покрыты поля разных типов: привязка к пользователю, файл и строка. Следите за руками:
Создание пользовательских полей
<?php namespace Sprint\Migration; use Bitrix\Main\Application; use Bitrix\Main\Composite\Page; use Bitrix\Main\Data\Connection as DataConnection; use Bitrix\Main\Data\ManagedCache; use Bitrix\Main\DB\Connection as DBConnection; use Bitrix\Main\Loader; use Bitrix\Main\ArgumentException; use Bitrix\Main\DB\SqlQueryException; use Bitrix\Main\LoaderException; use Bitrix\Main\ObjectPropertyException; use Bitrix\Main\SystemException; use Bitrix\Crm\Service\Factory; use Bitrix\Crm\Service\Container; use Bitrix\UI\Form\EntityEditorConfigScope; use Bitrix\UI\Form\EntityEditorConfiguration; use CStackCacheManager; use Exception; use Sprint\Migration\Exceptions\HelperException; use Sprint\Migration\Exceptions\MigrationException; use Throwable; /** * Создаем 4 пользовательских поля с привязкой к Вольфрамовым наконечникам */ class CreateBPFields_20241125103343 extends Version { protected $description = "Создание пользовательских полей Вольфрамовых наконечников"; protected $moduleVersion = "4.3.1"; protected ?HelperManager $helper; protected ?EntityEditorConfiguration $editorConf; protected DataConnection|DBConnection|null $connection; protected ?int $entityId = null; protected ?int $entityTypeId = null; protected const SMART_PROCESS_TUNGSTEN_TIPS = [ 'CODE' => 'TUNGSTEN_TIPS', 'TITLE' => 'Вольфрамовые наконечники' ]; protected const EDITOR_COMMON_SCOPE = [ 'scope' => EntityEditorConfigScope::COMMON, 'forAllUsers' => 'Y', 'delete' => 'Y', ]; protected const USER_FIELDS_FOR_ADD = [ [ 'FIELD_NAME' => 'UF_CRM_DIVISION_RESPONSIBLE', 'USER_TYPE_ID' => 'employee', 'XML_ID' => 'UF_CRM_DIVISION_RESPONSIBLE', 'SORT' => '100', 'MULTIPLE' => 'N', 'MANDATORY' => 'Y', 'SHOW_FILTER' => 'E', 'SHOW_IN_LIST' => 'Y', 'EDIT_IN_LIST' => 'Y', 'IS_SEARCHABLE' => 'Y', 'SETTINGS' => ['DEFAULT_VALUE' => ''], 'EDIT_FORM_LABEL' => [ 'en' => 'Division responsible', 'ru' => 'Ответственный Цеха', ], 'LIST_COLUMN_LABEL' => [ 'en' => 'Division responsible', 'ru' => 'Ответственный Цеха', ], 'LIST_FILTER_LABEL' => [ 'en' => 'Division responsible', 'ru' => 'Ответственный Цеха', ], 'ERROR_MESSAGE' => ['en' => '', 'ru' => ''], 'HELP_MESSAGE' => ['en' => '', 'ru' => ''], ], [ 'FIELD_NAME' => 'UF_CRM_HEAD_MANAGER', 'USER_TYPE_ID' => 'employee', 'XML_ID' => 'UF_CRM_HEAD_MANAGER', 'SORT' => '100', 'MULTIPLE' => 'N', 'MANDATORY' => 'Y', 'SHOW_FILTER' => 'E', 'SHOW_IN_LIST' => 'Y', 'EDIT_IN_LIST' => 'Y', 'IS_SEARCHABLE' => 'Y', 'SETTINGS' => ['DEFAULT_VALUE' => ''], 'EDIT_FORM_LABEL' => [ 'en' => 'Head manager', 'ru' => 'Дирекция', ], 'LIST_COLUMN_LABEL' => [ 'en' => 'Head manager', 'ru' => 'Дирекция', ], 'LIST_FILTER_LABEL' => [ 'en' => 'Head manager', 'ru' => 'Дирекция', ], 'ERROR_MESSAGE' => ['en' => '', 'ru' => ''], 'HELP_MESSAGE' => ['en' => '', 'ru' => ''], ], [ 'FIELD_NAME' => 'UF_CRM_QUALITY_REPORT', 'USER_TYPE_ID' => 'file', 'XML_ID' => 'UF_CRM_QUALITY_REPORT', 'SORT' => '100', 'MULTIPLE' => 'Y', 'MANDATORY' => 'N', 'SHOW_FILTER' => 'N', 'SHOW_IN_LIST' => 'Y', 'EDIT_IN_LIST' => 'Y', 'IS_SEARCHABLE' => 'Y', 'SETTINGS' => ['DISPLAY' => 'LIST'], 'EDIT_FORM_LABEL' => [ 'en' => 'Quality Report', 'ru' => 'Отчет о качестве', ], 'LIST_COLUMN_LABEL' => [ 'en' => 'Quality Report', 'ru' => 'Отчет о качестве', ], 'LIST_FILTER_LABEL' => [ 'en' => 'Quality Report', 'ru' => 'Отчет о качестве', ], 'ERROR_MESSAGE' => ['en' => '', 'ru' => ''], 'HELP_MESSAGE' => ['en' => '', 'ru' => ''], ], [ 'FIELD_NAME' => 'UF_CRM_FINAL_CONCLUSION', 'USER_TYPE_ID' => 'string', 'XML_ID' => 'UF_CRM_FINAL_CONCLUSION', 'SORT' => '100', 'MULTIPLE' => 'N', 'MANDATORY' => 'N', 'SHOW_FILTER' => 'S', 'SHOW_IN_LIST' => 'Y', 'EDIT_IN_LIST' => 'Y', 'IS_SEARCHABLE' => 'Y', 'SETTINGS' => [ 'SIZE' => 20, 'ROWS' => 1, 'REGEXP' => '', 'MIN_LENGTH' => 0, 'MAX_LENGTH' => 0, 'DEFAULT_VALUE' => '', ], 'EDIT_FORM_LABEL' => [ 'en' => 'Final conclusion', 'ru' => 'Окончательное заключение', ], 'LIST_COLUMN_LABEL' => [ 'en' => 'Final conclusion', 'ru' => 'Окончательное заключение', ], 'LIST_FILTER_LABEL' => [ 'en' => 'Final conclusion', 'ru' => 'Окончательное заключение', ], 'ERROR_MESSAGE' => ['en' => '', 'ru' => ''], 'HELP_MESSAGE' => ['en' => '', 'ru' => ''], ] ]; /** * @throws LoaderException */ public function __construct() { Loader::includeModule('crm'); $this->helper = $this->getHelperManager(); $arEntity = $this->getEntityByCode(self::SMART_PROCESS_TUNGSTEN_TIPS['CODE']); $this->entityId = $arEntity['ID']; $this->entityTypeId = $arEntity['ENTITY_TYPE_ID']; $this->connection = Application::getConnection(); $this->editorConf = new EntityEditorConfiguration('crm.entity.editor'); } /** * @return bool * @throws SqlQueryException */ public function up(): bool { $result = true; try { $this->connection->startTransaction(); $this->updateUserFields(); $this->connection->commitTransaction(); } catch (Throwable $e) { $this->connection->rollbackTransaction(); $this->outError($e->getMessage()); $result = false; } $this->clearCache(); return $result; } /** * @return bool * @throws SqlQueryException */ public function down(): bool { $result = true; try { $this->connection->startTransaction(); $arEntity = $this->getEntityByCode(self::SMART_PROCESS_TUNGSTEN_TIPS['CODE']); if (empty($arEntity['ID'])) { throw new Exception('Smart process was not found'); } $this->entityId = $arEntity['ID']; $this->entityTypeId = $arEntity['ENTITY_TYPE_ID']; $this->updateUserFields(false); $this->connection->commitTransaction(); } catch (Throwable $e) { $this->connection->rollbackTransaction(); $this->outError($e->getMessage()); $result = false; } $this->clearCache(); return $result; } /** * @param int $entityTypeId * @return Factory * @throws Exception */ protected function getFactory(int $entityTypeId): Factory { $factory = Container::getInstance()->getFactory($entityTypeId); if (!$factory) { throw new Exception('Can\t resolve factory'); } return $factory; } /** * @param string $code * @return array * @throws ArgumentException * @throws SystemException * @throws ObjectPropertyException */ protected function getEntityByCode(string $code): array { $entityClass = Container::getInstance()->getDynamicTypeDataClass(); $query = $entityClass::getList([ 'select' => ['ID', 'ENTITY_TYPE_ID'], 'filter' => ['CODE' => $code] ]); return $query->fetch() ?: []; } /** * @param bool $up * @return void * @throws HelperException * @throws MigrationException */ protected function updateUserFields(bool $up = true): void { if (empty($this->entityId)) { throw new Exception('Entity ID not defined, UF fields update was not proceeded'); } foreach (static::USER_FIELDS_FOR_ADD as $field) { $field['ENTITY_ID'] = 'CRM_' . $this->entityId; if ($up) { if (!$this->helper->UserTypeEntity()->saveUserTypeEntity($field)) { throw new Exception('Can\t save smart process userfield'); } } else { $this->helper->UserTypeEntity()->deleteUserTypeEntityIfExists( $field['ENTITY_ID'], $field['FIELD_NAME'] ); } } } /** * @return void */ protected function clearCache(): void { BXClearCache(true); (new ManagedCache())->cleanAll(); (new CStackCacheManager())->CleanAll(); Page::getInstance()->deleteAll(); } }
На самом деле у любой карточки есть и поля по умолчанию, идентификатор, название и ответственный, ну и еще несколько служебных. Прогнав вторую миграцию, к нашей карточке будут привязаны новые поля, но чтобы корректно вывести их, нужно еще прописать конфигурацию карточки. Сами поля можно посмотреть вот здесь: /bitrix/admin/userfield_admin.php?lang=ru

Конфигурация вывода полей в карточке
По умолчанию в карточке не будут выводиться созданные пользовательские поля, т.к. они скрыты. Для того, чтобы их отобразить, а также для более удобного разграничения полей в карточке, запускаем миграцию конфига.
Конфигурация вывода в карточке
<?php namespace Sprint\Migration; use Bitrix\Crm\Service\Container; use Bitrix\Main\Application; use Bitrix\Main\ArgumentException; use Bitrix\Main\Composite\Page; use Bitrix\Main\Data\Connection as DataConnection; use Bitrix\Main\Data\ManagedCache; use Bitrix\Main\DB\Connection as DBConnection; use Bitrix\Main\Loader; use Bitrix\Main\DB\SqlQueryException; use Bitrix\Main\LoaderException; use Bitrix\Crm\Service\Factory; use Bitrix\Main\ObjectPropertyException; use Bitrix\Main\SystemException; use Bitrix\UI\Form\EntityEditorConfigScope; use Bitrix\UI\Form\EntityEditorConfiguration; use CBPWorkflowTemplateLoader; use CStackCacheManager; use CUserOptions; use Exception; use Throwable; /** * Выводим поля карточки, разбив их по секциям */ class ConfigBPFields_20241115121315 extends Version { protected $description = 'Конфигурация полей вольфрамовых наконечников'; protected $moduleVersion = "4.3.1"; protected ?HelperManager $helper; protected DataConnection|DBConnection|null $connection; protected ?CBPWorkflowTemplateLoader $bpLoader; protected ?Factory $factory; protected int $entityId; protected ?int $entityTypeId = null; protected ?EntityEditorConfiguration $editorConf; protected array $editorParams = [ 'scope' => EntityEditorConfigScope::COMMON, 'forAllUsers' => 'Y', 'delete' => 'Y', 'options' => [ 'client_layout' => '4', 'client_visible_fields' => '' ] ]; protected const EDITOR_COMMON_SCOPE = [ 'scope' => EntityEditorConfigScope::COMMON, 'forAllUsers' => 'Y', 'delete' => 'Y', ]; protected const EDITOR_DETAILS_CONFIG_OPTS = [ 'client_layout' => '4', 'client_visible_fields' => '' ]; protected const SMART_PROCESS_TUNGSTEN_TIPS = [ 'CODE' => 'TUNGSTEN_TIPS', 'TITLE' => 'Вольфрамовые наконечники' ]; protected const EDITOR_CONFIG = [ [ 'name' => 'default_column', 'type' => 'column', 'elements' => [ [ 'name' => 'main', 'title' => 'Общая информация', 'type' => 'section', 'elements' => [ ['name' => 'TITLE', 'optionFlags' => 1], ['name' => 'ASSIGNED_BY_ID', 'optionFlags' => 1], ] ], [ 'name' => 'responsible', 'title' => 'Согласующие', 'type' => 'section', 'elements' => [ ['name' => 'UF_CRM_DIVISION', 'optionFlags' => 1], ['name' => 'UF_CRM_DIVISION_RESPONSIBLE', 'optionFlags' => 1], ] ], [ 'name' => 'research', 'title' => 'Проведение испытания', 'type' => 'section', 'elements' => [ ['name' => 'UF_CRM_QUALITY_REPORT', 'optionFlags' => 1], ['name' => 'UF_CRM_FINAL_CONCLUSION', 'optionFlags' => 1], ] ], ] ] ]; /** * @throws LoaderException */ public function __construct() { Loader::includeModule('crm'); Loader::includeModule('bizproc'); $this->helper = $this->getHelperManager(); $this->connection = Application::getConnection(); $arEntity = $this->getEntityByCode(self::SMART_PROCESS_TUNGSTEN_TIPS['CODE']); $this->entityId = $arEntity['ID']; $this->entityTypeId = $arEntity['ENTITY_TYPE_ID']; $this->editorConf = new EntityEditorConfiguration('crm.entity.editor'); } /** * @return bool * @throws SqlQueryException */ public function up(): bool { return $this->run(); } /** * @return bool * @throws SqlQueryException */ public function down(): bool { return $this->run(false); } /** * @param bool $up * @return bool * @throws SqlQueryException */ protected function run(bool $up = true): bool { $result = true; try { $this->connection->startTransaction(); $this->upEditorConfig(); $this->connection->commitTransaction(); } catch (Throwable $e) { $this->connection->rollbackTransaction(); $this->outError($e->getMessage()); $result = false; } $this->clearCache(); return $result; } /** * @param string $code * @return array * @throws ArgumentException * @throws SystemException * @throws ObjectPropertyException */ protected function getEntityByCode(string $code): array { $entityClass = Container::getInstance()->getDynamicTypeDataClass(); $query = $entityClass::getList([ 'select' => ['ID', 'ENTITY_TYPE_ID'], 'filter' => ['CODE' => $code] ]); return $query->fetch() ?: []; } /** * @return void * @throws Exception */ protected function upEditorConfig(): void { if (empty($this->entityTypeId)) { throw new Exception('Entity Type ID not defined, editor config was not apply'); } $factory = $this->getFactory($this->entityTypeId); $categories = $factory->getCategories(); foreach ($categories as $category) { $editorConfigId = sprintf('DYNAMIC_%s_details_C%s', $this->entityTypeId, $category->getId()); $this->editorConf->set($editorConfigId, static::EDITOR_CONFIG, static::EDITOR_COMMON_SCOPE); $editorConfigId = sprintf('dynamic_%s_details_c%s', $this->entityTypeId, $category->getId()); CUserOptions::SetOption('crm.entity.editor', $editorConfigId . '_opts', static::EDITOR_DETAILS_CONFIG_OPTS, true); CUserOptions::SetOption('crm.entity.editor', $editorConfigId . '_common_opts', static::EDITOR_DETAILS_CONFIG_OPTS, true); } } /** * @param int $entityTypeId * @return Factory * @throws Exception */ protected function getFactory(int $entityTypeId): Factory { $factory = Container::getInstance()->getFactory($entityTypeId); if (!$factory) { throw new Exception('Can\t resolve factory'); } return $factory; } /** * @return void */ protected function clearCache(): void { BXClearCache(true); (new ManagedCache())->cleanAll(); (new CStackCacheManager())->CleanAll(); Page::getInstance()->deleteAll(); } }
Те 7%, которые просмотрели файл с кодом до конца (я оптимист), наверное заметили, что некоторые функции повторяются из миграции в миграцию, и за это полиция PSR меня когда-нибудь расстреляет без предупреждения, но это единственный доступный способ привести цельный и работающий код, не прилагая никаких архивов с зависимыми классами (лично я бы не стал их качать). Идея в том, чтобы просто скопировать, вставить и запустить, и потом увидеть результат.
Создание шаблона БП
Теперь наша карточка готова, пришло время сделать шаблоны БП. Эту часть сделать в коде полностью невозможно, так что накидаем примитивный БП средствами системы, а в миграцию добавим созданный шаблон с помощью экспорта. Наш БП будет состоять из одной воронки, но их может быть несколько.
Создадим шаблон на странице конфигурации БП (/crm/configs/bp/) согласно плану, который мы набросали ранее:

Шаблон максимально простой, установить его через миграцию можно вот так (внимательнее с путями, по умолчанию скрипт будет искать его тут: local/php_interface/migrations/bizproc):
Ссылка на файл шаблона
Создание шаблона
<?php namespace Sprint\Migration; use Bitrix\Crm\Service\Container; use Bitrix\Main\Application; use Bitrix\Main\Composite\Page; use Bitrix\Main\Data\Connection as DataConnection; use Bitrix\Main\Data\ManagedCache; use Bitrix\Main\DB\Connection as DBConnection; use Bitrix\Main\Loader; use Bitrix\Main\ArgumentException; use Bitrix\Main\DB\SqlQueryException; use Bitrix\Main\LoaderException; use Bitrix\Main\ObjectPropertyException; use Bitrix\Main\SystemException; use CBPInvalidOperationException; use CBPWorkflowTemplateLoader; use CStackCacheManager; use Throwable; /** * Импорт шаблона в систему, при необходимости скорректируйте путь к файлу шаблона */ class UpBpTemplate_20241115193845 extends Version { protected $description = 'Импорт шаблона бизнес-процесса Вольфрамовых наконечников'; protected $moduleVersion = "4.3.1"; protected DataConnection|DBConnection|null $connection; protected ?CBPWorkflowTemplateLoader $bpLoader; protected const BP_BASE_PATH = '/local/php_interface/migrations/bizproc'; protected const PROCESS_FILENAME = 'bpExample.bpt'; protected const PROCESS_OPTIONS = [ 'name' => 'Испытание наконечников', 'autostart' => 1, 'description' => 'TIPS_RESEARCH', 'code' => 'TIPS_RESEARCH' ]; protected array $documentType = [ 'module' => 'crm', 'entity' => 'Bitrix\Crm\Integration\BizProc\Document\Dynamic', 'type' => 'DYNAMIC_', ]; protected const SMART_PROCESS_TUNGSTEN_TIPS = [ 'CODE' => 'TUNGSTEN_TIPS', 'TITLE' => 'Вольфрамовые наконечники' ]; /** * @throws LoaderException */ public function __construct() { Loader::includeModule('crm'); Loader::includeModule('bizproc'); $this->bpLoader = CBPWorkflowTemplateLoader::getLoader(); $this->connection = Application::getConnection(); $arEntity = $this->getEntityByCode(self::SMART_PROCESS_TUNGSTEN_TIPS['CODE']); $this->documentType['type'] = 'DYNAMIC_' . $arEntity['ENTITY_TYPE_ID']; } /** * @return bool * @throws SqlQueryException */ public function up(): bool { return $this->run(); } /** * @return bool * @throws SqlQueryException */ public function down(): bool { return $this->run(false); } /** * @param bool $up * @return bool * @throws SqlQueryException */ protected function run(bool $up = true): bool { $result = true; try { $this->connection->startTransaction(); $this->updateBP($up); $this->connection->commitTransaction(); } catch (Throwable $e) { $this->connection->rollbackTransaction(); $this->outError($e->getMessage()); $result = false; } $this->clearCache(); return $result; } /** * @param bool $up * @return void * @throws ArgumentException * @throws CBPInvalidOperationException */ public function updateBP(bool $up = true): void { $id = $this->getProcessIdByCode('TIPS_RESEARCH') ?? 0; if ($up && $this->importProcess($id, $this->getProcessContent())) { $this->outInfo('BP template successfully installed'); } else { $id ? $this->bpLoader->deleteTemplate($id) : $this->outInfo('BP template not found'); } } /** * @param string $code * @return int|null */ public function getProcessIdByCode(string $code): ?int { $bpId = $this->bpLoader->GetTemplatesList( ['ID' => 'DESC'], ['DESCRIPTION' => $code], false, false, ['ID'] )->fetch(); return $bpId['ID'] ?? null; } /** * @return string */ public function getProcessContent(): string { $path = Application::getDocumentRoot() . static::BP_BASE_PATH . '/' . static::PROCESS_FILENAME; $f = fopen($path, 'rb'); $datum = fread($f, filesize($path)); fclose($f); return $datum; } /** * @param int|null $id * @param string $content * @return int|false * @throws ArgumentException */ public function importProcess(?int $id, string $content): int|false { return $this->bpLoader::importTemplate( $id ?? 0, [$this->documentType['module'], $this->documentType['entity'], $this->documentType['type']], static::PROCESS_OPTIONS['autostart'], // 0 - не запускать / 1 - при добавлении / 2 - при изменении static::PROCESS_OPTIONS['name'], static::PROCESS_OPTIONS['description'], $content ); } /** * @param string $code * @return array * @throws ArgumentException * @throws SystemException * @throws ObjectPropertyException */ protected function getEntityByCode(string $code): array { $entityClass = Container::getInstance()->getDynamicTypeDataClass(); $query = $entityClass::getList([ 'select' => ['ID', 'ENTITY_TYPE_ID'], 'filter' => ['CODE' => $code] ]); return $query->fetch() ?: []; } /** * @return void */ protected function clearCache(): void { BXClearCache(true); (new ManagedCache())->cleanAll(); (new CStackCacheManager())->CleanAll(); Page::getInstance()->deleteAll(); } }
Создание ролей БП
Осталось только создать роли и группы пользователей для ответственных. В реальных БП типов ответственных может быть гораздо больше, но для примера нам хватит и трех: Ответственный менеджер, Ответственный цеха и Дирекция. Первый рулит процессом, поэтому у него больше прав, у двух других ролей ставим чтение. Накатываем миграцию по ролям:
Создание и привязка ролей
<?php namespace Sprint\Migration; use Bitrix\Crm\Service\Container; use Bitrix\Iblock\SectionTable; use Bitrix\Main\Application; use Bitrix\Main\ArgumentException; use Bitrix\Main\Data\Connection; use Bitrix\Main\GroupTable; use Bitrix\Main\Loader; use Bitrix\Crm\RolePermissionTable; use Bitrix\Crm\RoleTable; use Bitrix\Crm\Security\Role\Model\RoleRelationTable; use Bitrix\Main\ORM\Query\Filter\ConditionTree; use Bitrix\Main\ObjectPropertyException; use Bitrix\Main\SystemException; use Exception; use Sprint\Migration\Exceptions\HelperException; use Throwable; /** * Создание трех новых групп пользователей, трех ролей crm и привязка ролей к группам. Выставление прав на * взаимодействие с документами смарт-процесса Вольфрамовые наконечники */ class RolesCreate_202411177150344 extends Version { protected $description = "Миграция ролей доступа для вольфрамовых наконечников"; protected $moduleVersion = "4.3.1"; protected ?HelperManager $helper; const USER_GROUPS = [ [ 'STRING_ID' => 'RESPONSIBLE_MANAGER', 'NAME' => 'Ответственный менеджер', ], [ 'STRING_ID' => 'HEAD_DIVISION', 'NAME' => 'Цех', ], [ 'STRING_ID' => 'HEAD_MANAGER', 'NAME' => 'Дирекция', ], ]; const CRM_ROLES = [ 'TUNGSTEN_TIPS' => [ [ 'NAME' => 'Ответственный менеджер', 'CODE' => 'RESPONSIBLE_MANAGER', 'PERMISSIONS' => [ 'TUNGSTEN_TIPS' => [ 'DEFAULT' => [ 'ALL' => [ 'READ' => [ 'FIELD' => '-', 'FIELD_VALUE' => NULL, 'ATTR' => 'X', ], 'ADD' => [ 'FIELD' => '-', 'FIELD_VALUE' => NULL, 'ATTR' => 'A', ], 'WRITE' => [ 'FIELD' => '-', 'FIELD_VALUE' => NULL, 'ATTR' => 'A', ], 'DELETE' => [ 'FIELD' => '-', 'FIELD_VALUE' => NULL, 'ATTR' => 'A', ], ], ], ] ], 'RELATIONS' => [ 'RESPONSIBLE_MANAGER' ] ], [ 'NAME' => 'Ответственный цеха', 'CODE' => 'HEAD_DIVISION', 'RELATIONS' => [ 'HEAD_DIVISION', ], ], [ 'NAME' => 'Дирекция', 'CODE' => 'HEAD_MANAGER', 'RELATIONS' => [ 'HEAD_MANAGER', ], ], ], ]; const CRM_DEFAULT_PERMISSIONS = [ 'TUNGSTEN_TIPS' => [ 'DEFAULT' => [ 'ALL' => [ 'READ' => [ 'FIELD' => '-', 'FIELD_VALUE' => NULL, 'ATTR' => 'X', ], ], ], ], ]; const CRM_DEFAULT_PERMISSION_RIGHTS = [ 'FIELD' => '-', 'FIELD_VALUE' => NULL, 'ATTR' => '', ]; protected const CACHE_PATH = '/crm/user_permission_roles/'; private \Bitrix\Main\DB\Connection|Connection $connection; public function __construct() { Loader::includeModule('crm'); $this->helper = $this->getHelperManager(); $this->connection = Application::getConnection(); } public function up() { $result = true; try { $this->connection->startTransaction(); $this->upUserGroups(); $this->upCrmPermissions(); $this->connection->commitTransaction(); } catch (Throwable $e) { $this->connection->rollbackTransaction(); $this->outError($e->getMessage()); $result = false; } $this->clearCache(); return $result; } public function down() { $result = true; try { $this->connection->startTransaction(); $this->downCrmPermissions(); $this->downUserGroups(); $this->connection->commitTransaction(); } catch (Throwable $e) { $this->connection->rollbackTransaction(); $this->outError($e->getMessage()); $result = false; } $this->clearCache(); return $result; } /** * @throws ArgumentException * @throws ObjectPropertyException * @throws SystemException * @throws Exception */ protected function upCrmPermissions(): void { foreach (static::CRM_ROLES as $code => $dynamicTypeRoles) { $relations = []; $typeId = $this->getEntityByCode($code)['ENTITY_TYPE_ID']; foreach ($dynamicTypeRoles as $role) { $roleId = $this->addCrmRoleIfNotExists($role); $this->clearCrmRolePermissions($roleId, $typeId); $rolePermissions = $role['PERMISSIONS'] ?? static::CRM_DEFAULT_PERMISSIONS; $this->insertCrmRolePermissions( $roleId, $this->createPermissions($rolePermissions, $code, $typeId) ); foreach ($role['RELATIONS'] as $userGroup) { $relations[] = [ 'ROLE' => $role['NAME'], 'GROUP' => $userGroup, ]; } } if (!empty($relations)) { $this->updateRoleRelations($relations); } } } /** * @return void * @throws ArgumentException * @throws ObjectPropertyException * @throws SystemException */ protected function downCrmPermissions(): void { foreach (static::CRM_ROLES as $code => $dynamicTypeRoles) { $typeId = $this->getEntityByCode($code)['ENTITY_TYPE_ID']; foreach ($dynamicTypeRoles as $role) { if($role['RELATIONS']) { $sections = $this->getSectionsByCode($role['RELATIONS']); $roleRelations = $this->getRoleRelationsByCategories(array_values($sections)); if (!empty($sections)) { foreach ($roleRelations as $roleId => $relationId) { $this->clearCrmRolePermissions($roleId, $typeId); } } } } } } /** * @param array $categoryIds * @return array * @throws ArgumentException * @throws ObjectPropertyException * @throws SystemException */ protected function getRoleRelationsByCategories(array $categoryIds): array { $categoriesRelations = []; foreach ($categoryIds as $categoryId) { $categoriesRelations[] = 'DR' . $categoryId; } $filter = new ConditionTree(); $filter->whereIn('RELATION', $categoriesRelations); $query = RoleRelationTable::getList([ 'filter' => $filter ]); return array_column($query->fetchAll(), 'ID', 'ROLE_ID'); } /** * @return array * @throws ArgumentException * @throws ObjectPropertyException * @throws SystemException */ protected function getRoleList(): array { $roles = RoleTable::getList(); return array_column($roles->fetchAll(), 'ID', 'NAME'); } /** * @param array $sectionCodes * @return array * @throws ArgumentException * @throws ObjectPropertyException * @throws SystemException */ protected function getSectionsByCode(array $sectionCodes): array { $sections = SectionTable::getList([ 'filter' => ['CODE' => $sectionCodes], 'select' => ['ID', 'CODE'] ])->fetchAll(); return array_column($sections, 'ID', 'CODE'); } /** * @param array $role * @return int * @throws ArgumentException * @throws ObjectPropertyException * @throws SystemException */ protected function addCrmRoleIfNotExists(array $role): int { $currentRole = RoleTable::getList([ 'filter' => ['NAME' => $role['NAME']] ])->fetch(); if (!$currentRole) { $result = RoleTable::add([ 'NAME' => $role['NAME'], 'CODE' => $role['CODE'] ?? null ]); if (!$result->isSuccess()) { throw new Exception('CANT ADD CRM ROLE'); } $currentRoleId = $result->getId(); } else { $currentRoleId = $currentRole['ID']; } return $currentRoleId; } /** * @param array $permissions * @param string $currentEntityCode * @param int $typeId * @return array */ protected function createPermissions(array $permissions, string $currentEntityCode, int $typeId): array { $rolePermissions = []; foreach ($permissions as $entityCode => $perms) { if ($entityCode === $currentEntityCode) { $rolePermissions = array_merge( $rolePermissions, $this->createTypePermissions($perms, $typeId) ); continue; } foreach ($perms as $permType => $perm) { $rolePermissions[] = [ 'ENTITY' => $entityCode, 'FIELD' => $perm['FIELD'] ?? static::CRM_DEFAULT_PERMISSION_RIGHTS['FIELD'], 'FIELD_VALUE' => $perm['FIELD_VALUE'] ?? static::CRM_DEFAULT_PERMISSION_RIGHTS['FIELD_VALUE'], 'PERM_TYPE' => $permType, 'ATTR' => $perm['ATTR'] ?? static::CRM_DEFAULT_PERMISSION_RIGHTS['ATTR'], ]; } } return $rolePermissions; } /** * @param array $permissions * @param int $typeId * @return array */ protected function createTypePermissions(array $permissions, int $typeId): array { $typePermissions = []; $typeCategories = []; $categories = Container::getInstance()->getFactory($typeId)->getCategories(); foreach ($categories as $category) { $code = $category->getIsDefault() ? 'DEFAULT' : $category->getCode(); $typeCategories[$code] = $category->getId(); } foreach ($permissions as $categoryCode => $perms) { $categoryId = $typeCategories[$categoryCode] ?? null; if (!$categoryId) { continue; } $entityCode = sprintf('DYNAMIC_%s_C%s', $typeId, $categoryId); foreach ($perms['ALL'] as $permType => $perm) { $typePermissions[] = [ 'ENTITY' => $entityCode, 'FIELD' => $perm['FIELD'] ?? static::CRM_DEFAULT_PERMISSION_RIGHTS['FIELD'], 'FIELD_VALUE' => $perm['FIELD_VALUE'] ?? static::CRM_DEFAULT_PERMISSION_RIGHTS['FIELD_VALUE'], 'PERM_TYPE' => $permType, 'ATTR' => $perm['ATTR'] ?? static::CRM_DEFAULT_PERMISSION_RIGHTS['ATTR'], ]; } foreach ($perms['STAGES'] as $stageCode => $stages) { $fieldValue = sprintf('DT%s_%s:%s', $typeId, $categoryId, $stageCode); foreach ($stages as $permType => $perm) { $typePermissions[] = [ 'ENTITY' => $entityCode, 'FIELD' => 'STAGE_ID', 'FIELD_VALUE' => $fieldValue, 'PERM_TYPE' => $permType, 'ATTR' => $perm['ATTR'] ?? static::CRM_DEFAULT_PERMISSION_RIGHTS['ATTR'], ]; } } } return $typePermissions; } protected function insertCrmRolePermissions(int $roleId, array $permissions): void { foreach ($permissions as $permission) { $permission['ROLE_ID'] = $roleId; $result = RolePermissionTable::add($permission); if (!$result->isSuccess()) { throw new Exception('CANT ADD CRM ROLE PERMISSION'); } } } /** * @param int $roleId * @return void * @throws ArgumentException * @throws ObjectPropertyException * @throws SystemException * @throws Exception */ protected function clearCrmRolePermissions(int $roleId, int $typeId): void { $currentPermissions = RolePermissionTable::getList([ 'filter' => [ '=ROLE_ID' => $roleId, '%ENTITY' => "DYNAMIC_$typeId" ], ])->fetchAll(); foreach ($currentPermissions as $currentPermission) { $result = RolePermissionTable::delete($currentPermission['ID']); if (!$result->isSuccess()) { throw new Exception('CANT DELETE CRM ROLE PERMISSION'); } } } /** * @return void * @throws HelperException */ protected function upUserGroups(): void { foreach (static::USER_GROUPS as $userGroup) { $this->helper->UserGroup()->addGroupIfNotExists($userGroup['STRING_ID'], $userGroup); } } /** * @return void */ protected function downUserGroups(): void { foreach (static::USER_GROUPS as $userGroup) { $this->helper->UserGroup()->deleteGroup($userGroup['STRING_ID']); } } protected function clearCache() { Application::getInstance()->getCache()->cleanDir(static::CACHE_PATH); RolePermissionTable::getEntity()->cleanCache(); BXClearCache(true); } /** * @param array $relations * @param bool $up * @return void * @throws Exception */ protected function updateRoleRelations(array $relations, bool $up = true): void { $groups = $this->getGroupsByCode(array_column($relations, 'GROUP')); $roleRelations = $this->getRoleRelationsByGroups(array_values($groups)); if (!empty($groups)) { if ($up) { $roles = $this->getRoleList(); foreach ($relations as $relation) { $roleId = $roles[$relation['ROLE']] ?? null; if (empty($roleRelations[$roleId])) { RoleRelationTable::add(['ROLE_ID' => $roleId, 'RELATION' => 'G' . $groups[$relation['GROUP']]]); } } } else { foreach ($roleRelations as $roleId => $relationId) { $this->deleteCrmRoleRelation($relationId); $this->deleteCrmRole($roleId); } } } else { $this->outInfo('GROUPS NOT FOUND'); } } /** * @param array $groupCodes * @return array * @throws Exception */ protected function getGroupsByCode(array $groupCodes): array { $query = GroupTable::getList(['filter' => (new ConditionTree())->whereIn('STRING_ID', $groupCodes)]); return array_column($query->fetchAll(), 'ID', 'STRING_ID'); } /** * @param int $relationId * @return void * @throws Exception */ protected function deleteCrmRoleRelation(int $relationId): void { if (!RoleRelationTable::delete($relationId)->isSuccess()) { throw new Exception('CANT DELETE ROLE RELATION'); } } /** * @param int $roleId * @return void * @throws Exception */ protected function deleteCrmRole(int $roleId): void { if (!RoleTable::delete($roleId)->isSuccess()) { throw new Exception('CANT DELETE ROLE'); } } /** * @param array $groupIds * @return array * @throws Exception */ protected function getRoleRelationsByGroups(array $groupIds): array { $groupRelations = array_map(fn($groupId) => 'G' . $groupId, $groupIds); $query = RoleRelationTable::getList(['filter' => (new ConditionTree())->whereIn('RELATION', $groupRelations)]); return array_column($query->fetchAll(), 'ID', 'ROLE_ID'); } /** * @param string $code * @return array * @throws ArgumentException * @throws SystemException * @throws ObjectPropertyException */ protected function getEntityByCode(string $code): array { $entityClass = Container::getInstance()->getDynamicTypeDataClass(); $query = $entityClass::getList([ 'select' => ['ID', 'ENTITY_TYPE_ID'], 'filter' => ['CODE' => $code] ]); return $query->fetch() ?: []; } }
После этого видим на странице ролей (/crm/configs/perms/) созданные группы и роли:

По итогу, открывает карточки всегда ответственный менеджер, другие роли могут только просматривать карточки, а также выполнять задачи, которые будут в их зоне ответственности. Делегирование здесь работает штатно, на каждом шаге БП его можно настроить, или вообще запретить.
Итог
Вот вроде бы и все. Конечно, все это легче настраивать с помощью визуального интерфейса crm (если вы только что увидели смарт-процессы, то лучше начать именно с этого), но если у вас есть различные ландшафты, например, dev/test/prod, вы проводите различного рода тестирования, следите за качеством релизов и т.д., то данный материал может быть полезен. Статья получилась нишевая, строго по миграции смарт-процессов (интересно, спросит кто-то в комментариях, зачем вы используете битрикс?), но, надеюсь, кому-то я по итогу помогу. Может в примере нет чего-то из лично ваших нужд, тогда пишите в комментариях или ЛС, что-то постараюсь разобрать подробнее. Всем хорошего дня!
ссылка на оригинал статьи https://habr.com/ru/articles/865252/
Добавить комментарий