Как мигрировать смарт-процессы в Битрикс и не сгореть

от автора

Привет, меня зовут Евгений, я разработчик из Байовэр в компании НЛМК ИТ.

Довелось мне тут столкнуться с разработкой системы опытно-промышленных испытаний на производстве, и если описать это коротко, то в целом большое количество людей разного уровня допуска должны совершить определенные действия в строгой последовательности (или местами асинхронно) для вынесения вердикта относительно качества продукта, и при этом управляться все это должно из одного места (как странно-то прозвучало:) Так как это достаточно инерционный процесс, который может занимать от нескольких месяцев до года, система, которая может рассылать ответственным за текущий шаг уведомления (а в случае простоя, и их руководству), позволяет ускорить прохождение большинства шагов бизнес-процессов (БП).

Приведу пример – требуются лакокрасочные покрытия от стороннего поставщика для работы цеха, но перед заключением контракта на массовые поставки, нужно убедиться, что товар не разбавлен. Ну то есть надлежащего качества:) Заранее прошу прощения за качество юмора, вы привыкнете.

Для этого закупается небольшая опытная партия, она проходит определенные испытания, и если результаты устраивают, процесс масштабируется на опытно-промышленную партию, побольше. Если же и в этом случае все испытания пройдены, то поставщик признается годным и материал одобряется к серийному применению. Собственно процесс испытаний мы и реализовали с помощью смарт-процессов. В данный момент у нас внедрено три категории материалов для испытаний (лакокрасочные материалы, огнеупорные материалы, наконечники медных фурм), они гораздо сложнее примера, который я хочу здесь описать, но его должно быть достаточно для масштабирования под нужды бизнеса.

Вообще смарт-процессы – это довольно гибкий инструмент в битрикс 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *