Кастомизация: изменение логики

Подход к реализации смарт-процессов сильно отличается от подхода используемого ранее: вместо простых и понятных разработчику событий и методов по изменению предлагается новый подход с использованием операций и действий. Эта статья в каком-то роде переосмысление стандартной документации по кастомизации crm, которая не точно раскрывает подходы.

Мы постараемся сделать все то же самое, но двигаясь поэтапно с подробными пояснениями. Для улучшения восприятия мы сделали ряд предположений:

  1. ENTITY_TYPE_ID (идентификатор сущности смарт-процесса) известен заранее и для него существует некоторая константа SUPER_ENTITY_TYPE_ID. В реальном проекте у вас скорее всего будет другой механизм для получения такого идентификатора, но здесь мы будем использовать именно его
  2. В оригинальной статье используется динамическое создание класса (new class(...) extends), но мы не будем использовать этот подход. Вместо этого мы будем разносить классы по разным файлам согласно нашей структуре кода и namespace - \Fusion\SomeSmartProcess.
  3. Мы предполагаем что работаем на “чистой” коробке, т.е. никаких модулей и кода модифицирующих сервисы не было.

Открывающие php-теги в указанных фрагментах отсутствуют. Их нужно дописывать руками.

Шаг 1. Фабрика

Любая работа будь то операция создания или получение списка элементов со смарт-процессами или новым API начинается с получения фабрики этого типа. Если вы не знакомы с паттернами проектирования, то рекомендую сначала почитать про фабрики, а потом вернуться к изучению данного материала.

Подмена контейнера crm

Как вы знаете из главы Процессы все взаимодействие со смарт-процессами осуществляется через контейнер (\Bitrix\Crm\Service\Container), получить который можно следующим кодом:

use \Bitrix\Crm\Service;

/**
 * @var Container
 */
$container = Service\Container::getInstance();

Однако, внутри себя getInstance() метод представляет не что иное, как обращение к DI\ServiceLocator (подробнее в документации) и если мы заглянем внутрь этого метода, то увидим не что иное, как получение crm.service.container сервиса:

public static function getInstance(): Container
{
    return ServiceLocator::getInstance()->get('crm.service.container');
}

Таким образом, воспользовавшись возможностями DI\ServiceLocator мы можем подменить возвращаемый результат на своего наследника.

Так как контейнер является общим объектом для CRM и для нашей сущности, выносить его подмену в наше пространство имен не является корректным шагом.

Почему это не корректный шаг? В будущем возможно появление нового смарт-процесса со своей логикой работы и добавлять подмену фабрики для него в пространстве имен другой механике - плохая затея.

Так как контейнер общая часть для всех модулей CRM, целесообразно будет выделить его в соответсвующее пространство имен. Пусть это будет Fusion\Crm\Container.

Создадим файл с нашим новым контейнером: /local/php_interface/classes/Fusion/Crm/Container.php:


namespace Fusion\Crm;

use \Bitrix\Main,
	\Bitrix\Crm\Service;

Main\Loader::requireModule('crm');

class Container extends Service\Container
{

}

Не буду пояснять что означает каждая строчка в данном файле, остановлюсь лишь на одном моменте: на 7 строчке мы добавили код

Main\Loader::requireModule('crm');

Мы сделали это потому, что разработчик может явно обратиться к нашему коду и мы должны быть уверены, что класс-родитель из модуля CRM подключен.

Теперь если мы попытаемся что-то сделать в CRM… ничего не произойдет. Мы создали класс-наследник, но он ничего не делает и нигде не участвует. Даже если мы впишем ему какие-то методы модуль CRM не будет его использовать.

Следующий наш шаг - подмена сервиса. Для этого, согласно нашей структуре файлов в секцию смарт-процессов добавим следующий код:

$serviceLocator->addInstanceLazy('crm.service.container', [
	'className' => '\\Fusion\\Crm\\Container',
]);

Почему мы добавляем LazyInstance вместо Instance? Потому что не на каждом хите мы подключаем и используем контейнеры CRM. Инициализация нашего класса произойдет исключительно по требованию.

Теперь, мы можем открыть php-консоль в административном интерфейсе и выполнить код:

\Bitrix\Main\Loader::IncludeModule('crm');

if ( \Bitrix\Crm\Service\Container::getInstance() instanceof \Fusion\Crm\Container ) 
{
	echo "It works!";
}
else
{
	echo "Something wrong!";
}

Мы увидим вывод It works!.

Если что-то пошло не так - проверьте еще раз. Возможно вы что-то упустили. Уверены, что все сделано правильно? Свяжитесь с нами через github issue - мы исследуем вашу ситуацию.

Теперь когда мы подменили контейнер, самое время подменить фабрику. Для начала создадим класс отвечающий за саму фабрику /local/php_interface/classes/Fusion/SomeSmartProcess/Factory.php с содержимым:


namespace Fusion\SomeSmartProcess;

use \Bitrix\Main,
	\Bitrix\Crm,
	\Bitrix\Crm\Service\Factory\Dynamic
	;

Main\Loader::requireModule('crm');

class Factory extends Dynamic
{

}

Теперь изменим контейнер чтобы он возвращал нашу фабрику.

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

Нижеследующий код ПРЕКРАТИТ работу CRM. Не выполнять на production в рабочие часы!

Для начала необходимо перекрыть код самого метода, для этого добавим класс контейнера в файл Container.php следующий код:

public function getFactory(int $entityTypeId): ?Service\Factory
{
	die("Overridden");
	return parent::getFactory($entityTypeId);
}

Теперь когда мы выполнили нижеследующий код в консоли:

\Bitrix\Main\Loader::IncludeModule('crm');

\Bitrix\Crm\Service\Container::getInstance()->getFactory( SUPER_ENTITY_TYPE_ID );

Мы увидим на экране надпись Overridden.

Если мы удалим die-функцию то CRM продолжит функционировать в обычном режиме, но с наследуемыми классами. Реализовывать нужно по принципу “нашел - подменяем, нет - пропускаем”, т.е. изменения должны влиять только на наш код. В перекрытом методе мы должны сделать следующие действия

  1. Проверить что подменяем наш сервис (не наш подменять не нужно)
  2. Проверить на наличие объекта (вдруг мы уже подменили и это повторное обращение?)
  3. Создать объект фабрики, запомнить его и вернуть

Полный текст метода getFactory с комментариями:

public function getFactory(int $entityTypeId): ?Service\Factory
{
	// Если наш тип - подменяем
	if ( $entityTypeId == SUPER_ENTITY_TYPE_ID )
	{
		// Сгенерируем название сервиса ->
		$identifier = static::getIdentifierByClassName(static::$dynamicFactoriesClassName, [$entityTypeId]);
		// ... и проверим - вдруг уже есть объект класса?
		if ( Main\DI\ServiceLocator::getInstance()->has($identifier) )
		{
			return Main\DI\ServiceLocator::getInstance()->get($identifier);
		}

		// Объекта нет. Получим 'объект смарт-процесса'
		$type = $this->getTypeByEntityTypeId($entityTypeId);
		if ( !$type )
		{
			// Не получилось, смарт-процесс удален
			return null;
		}

		// Создадим фабрику, запомним ее 
		$factory = new \Fusion\SomeSmartProcess\Factory($type);
		Main\DI\ServiceLocator::getInstance()->addInstance(
			$identifier,
			$factory
		);
		// Вернем подмененную фабрику
		return $factory;
	}
	// Если тип не наш - передаем в родительский метод
	return parent::getFactory($entityTypeId);
}

Теперь, мы можем открыть php-консоль в административном интерфейсе и выполнить код:

use \Bitrix\Main\Loader;
use \Bitrix\Crm\Service\Container;

Loader::IncludeModule('crm');

$factory = Container::getInstance()->getFactory( SUPER_ENTITY_TYPE_ID );

if ( $factory instanceof \Fusion\SomeSmartProcess\Factory ) 
{
	echo "It works!";
}
else
{
	echo "Something wrong!";
}

Мы увидим вывод It works!.

Отлично! Мы подменили фабрику через подмену контейнера. Шаг выполнен.

Подмена фабрики без подмены контейнера

Пытливый читатель может заметить, что при разработке модулей подход с подменой контейнера может не сработать. Если внимательно приглядеться к методу getFactory можно заметить что мы вызываем из контейнера (который по факту является сервисом возвращаемым из DI\ServiceLocator) тот же DI\ServiceLocator для поиска существующих контейнеров. А значит мы можем на любом событии (например OnPageStart) до момента его первого вызова подменить его сервис.

Для этого нам нужно получить подменяемое имя сервиса через метод \Bitrix\Crm\Service\Container::getIdentifierByClassName и реализовать аналогичный lazy-сервис подмены.

Указанный способ - является способом “со звездочкой” поэтому в данном материале она пока не рассматривается.

Шаг 2. Readonly-поле

Теперь, когда каким-либо способом мы подменили фабрику у нас появилась возможность изменять бизнес-логику работы элемента.

Например, есть пользовательское поле с кодом UF_CRM_150_STRING, которое должно быть доступно только для чтения (оно будет меняться только через API).

В файле /local/php_interface/classes/Fusion/SomeSmartProcess/Factory.php необходимо изменить метод getUserFieldsInfo, который согласно документации возвращает описание пользовательских полей, следующим образом:

public function getUserFieldsInfo(): array
{
	$fields = parent::getUserFieldsInfo();
	$fields['UF_CRM_150_STRING']['ATTRIBUTES'][] = \CCrmFieldInfoAttr::Immutable;

	return $fields;
}

Примечание: поскольку ранее мы изменяли этот метод, в нашем случае подразумевается что мы просто добавим этот метод в класс нашей фабрики Factory

Добавление атрибута \CCrmFieldInfoAttr::Immutable не позволяет изменять это поле через интерфейс пользователем.

По аналогии можно было бы добавить атрибуты:

  • \CCrmFieldInfoAttr::NotDisplayed - скроет поле из детальной карточки.
  • \CCrmFieldInfoAttr::Required - сделает поле обязательным независимо от настроек.

Шаг 3. Подмена операции удаления

Обычные бизнес-требования могут подразумевать различное поведение элементов в системе в зависимости от выполняемых действий над элементом. В старом ядре подобный механизм основывался на событийной модели. При работе со смарт-процессами подобные влияниям можно осуществить через действия - аналог событий.

Напомню, что создание, изменение и удаление элементов происходят через операции, а действия - дополнительные обработчики которые выполняются до или после самой операции.

Звучи сложно, давайте разбираться на конкретном примере. Предположим мы хотим записывать удаление элементов для нашего смарт-процесса. Мы можем сделать это двумя путями:

  1. Подменить операцию удаления, подобно тому как мы делали это с фабрикой.
  2. Использовать механизм действий, чтобы дополнить стандартную операцию.

Мы выберем второй вариант, потому что подменять целые части системы для такой простой операции слишком затратно. Дополнить свое действие будет гораздо эффективнее.

Из документации мы знаем, что любое действие является реализацией абстрактного класса \Bitrix\Crm\Service\Operation\Action. Создадим свое действие-логгер: для этого создадим класс, который будет реализовывать это действие и добавим его к операции удаления. Назовем его обработчик действия: \Fusion\SomeSmartProcess\Operation\Action\Log.

Почему мы делаем такую большую вложенность? Вы правы, такая большая вложенность в данном случае избыточна, но она открывает в будущем неограниченную возможность для кастомизации фабрики. Вы можете не ограничиваться стандартными операциями и добавить свои операции. Например Operation\Sync для синхронизации с внешней системой. И конечно же у вас может быть не одно действие для различных операций.

Создадим файл /local/php_interface/classes/Fusion/SomeSmartProcess/Operation/Action/Log.php:


namespace Fusion\SomeSmartProcess\Operation\Action;

use \Bitrix\Main,
	\Bitrix\Crm\Item,
	\Bitrix\Crm\Service\Operation
	;

Main\Loader::requireModule('crm');

class Log extends Operation\Action
{
	public function process(Item $item): Main\Result
	{
		\AddMessage2Log(Main\Web\Json::encode([
			'id'           => $item->getId(),
			'entityTypeId' => $item->getEntityTypeId(),
		]));

		return new Main\Result();
	}
}

Теперь когда мы реализовали действие, необходимо добавить его к операции удаления. Для этого нам нужно расширить метод getDeleteOperation в нашей подмененной фабрике:

public function getDeleteOperation(Crm\Item $item, Crm\Service\Context $context = null): Crm\Service\Operation\Delete
{
	$operation = parent::getDeleteOperation($item, $context);

	return $operation->addAction(
		Crm\Service\Operation::ACTION_AFTER_SAVE,
		new Operation\Action\Log()
	);
}

Шаг 4. Подмена операции редактирования

Запретить менять стадию определенному пользователю - чуть более сложный кейс. Допустим, мы хотим запретить переносить элемент со стадии D150_3:PREPARATION на стадию D150_3:CLIENT пользователю с идентификатором 222.

Для этого создадим действие ChangeStageRestriction


namespace Fusion\SomeSmartProcess\Operation\Action;

use \Bitrix\Main,
	\Bitrix\Crm\Item,
	\Bitrix\Crm\Service\Container,
	\Bitrix\Crm\Service\Operation
	;

Main\Loader::requireModule('crm');

class ChangeStageRestriction extends Operation\Action
{
	public function process(Item $item): Main\Result
	{
		$result = new Main\Result();

		// ID пользователя на чей хит выпало выполнение действия
		$userId = Container::getInstance()
			->getContext()
			->getUserId();

		/**
		 * Если нужно получить ID сотрудника, который передан в действие,
		 * то следует воспользоваться конструкцией
		 * $this->getContext()->getUserId()
		 */

		if (
			// Если действие выполняет пользователь ID:222
			$userId === 222
			// Если стадия меняется 
			&& $item->isChangedStageId()
			// Если стадия на которую мы переходим 'D150_3:CLIENT'
			&& $item->getStageId() === 'D150_3:CLIENT'
			// Если предыдущая стадия 'D150_3:PREPARATIO'
			&& $item->remindActual(Item::FIELD_NAME_STAGE_ID) === 'D150_3:PREPARATION'
		) {
			$result->addError(
				new Main\Error('Change stage is prohibited')
			);
		}

		return $result;
	}
}

Теперь когда мы реализовали действие, необходимо добавить его к операции изменения. Для этого нам нужно расширить метод getUpdateOperation в нашей подмененной фабрике:

public function getUpdateOperation(Crm\Item $item, Crm\Service\Context $context = null): Crm\Service\Operation\Update
{
	$operation = parent::getUpdateOperation($item, $context);

	return $operation->addAction(
		Crm\Service\Operation::ACTION_BEFORE_SAVE,
		new Operation\Action\ChangeStageRestriction()
	);
}