Привет, меня зовут Евгений, я разработчик из Байовэр в компании НЛМК ИТ.
Довелось мне тут столкнуться с разработкой системы опытно-промышленных испытаний на производстве, и если описать это коротко, то в целом большое количество людей разного уровня допуска должны совершить определенные действия в строгой последовательности (или местами асинхронно) для вынесения вердикта относительно качества продукта, и при этом управляться все это должно из одного места (как странно-то прозвучало:) Так как это достаточно инерционный процесс, который может занимать от нескольких месяцев до года, система, которая может рассылать ответственным за текущий шаг уведомления (а в случае простоя, и их руководству), позволяет ускорить прохождение большинства шагов бизнес-процессов (БП).
Приведу пример – требуются лакокрасочные покрытия от стороннего поставщика для работы цеха, но перед заключением контракта на массовые поставки, нужно убедиться, что товар не разбавлен. Ну то есть надлежащего качества:) Заранее прошу прощения за качество юмора, вы привыкнете.
Для этого закупается небольшая опытная партия, она проходит определенные испытания, и если результаты устраивают, процесс масштабируется на опытно-промышленную партию, побольше. Если же и в этом случае все испытания пройдены, то поставщик признается годным и материал одобряется к серийному применению. Собственно процесс испытаний мы и реализовали с помощью смарт-процессов. В данный момент у нас внедрено три категории материалов для испытаний (лакокрасочные материалы, огнеупорные материалы, наконечники медных фурм), они гораздо сложнее примера, который я хочу здесь описать, но его должно быть достаточно для масштабирования под нужды бизнеса.
Вообще смарт-процессы – это довольно гибкий инструмент в битрикс 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, вы проводите различного рода тестирования, следите за качеством релизов и т.д., то данный материал может быть полезен. Статья получилась нишевая, строго по миграции смарт-процессов (интересно, спросит кто-то в комментариях, зачем вы используете битрикс?), но, надеюсь, кому-то я по итогу помогу. Может в примере нет чего-то из лично ваших нужд, тогда пишите в комментариях или ЛС, что-то постараюсь разобрать подробнее. Всем хорошего дня!
konmitin
Отлично. То что я искал. Спасибо)) В компанию пришёл новичком как раз на Битрикс, искал много таких материалов, но как вы и сказали их нужно было собирать по крупицам. Потом научился читать ядро и стало вроде полегче))
Amithril Автор
Я рад, что кто-то это искал и нашел, сам всегда искал подобные материалы и удивлялся, кто их пишет вообще, и вот я один из них)