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

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

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

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

Вообще смарт-процессы – это довольно гибкий инструмент в битрикс 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, вы проводите различного рода тестирования, следите за качеством релизов и т.д., то данный материал может быть полезен. Статья получилась нишевая, строго по миграции смарт-процессов (интересно, спросит кто-то в комментариях, зачем вы используете битрикс?), но, надеюсь, кому-то я по итогу помогу. Может в примере нет чего-то из лично ваших нужд, тогда пишите в комментариях или ЛС, что-то постараюсь разобрать подробнее. Всем хорошего дня!

Комментарии (2)


  1. konmitin
    20.02.2025 12:06

    Отлично. То что я искал. Спасибо)) В компанию пришёл новичком как раз на Битрикс, искал много таких материалов, но как вы и сказали их нужно было собирать по крупицам. Потом научился читать ядро и стало вроде полегче))


    1. Amithril Автор
      20.02.2025 12:06

      Я рад, что кто-то это искал и нашел, сам всегда искал подобные материалы и удивлялся, кто их пишет вообще, и вот я один из них)