Интересные задачи верстки и клиентского программирования

Делаем собственный sale.order.ajax на D7

добавил Шубин Александр 14 Ноябрь, 2017


Штатный bitrix:sale.order.ajax очень сложно кастомизировать. Я думаю никто не будет со мной спорить. Битриксовцы подумали над процедурой заказа, о том как заказ должен выглядеть в идеале, а потом реализовали придуманную процедуру в продукте. Все сделано хорошо, однако если у заказчика представления по процедуре заказа отличаются от штатной реализации, то изменить шаблон компонента bitrix:sale.order.ajax практически нереально. Собственно сами битриксовцы на одной из конференций предложили писать отдельный компонент заказа. В этой статье я покажу один из вариантов по реализации такого компонента. И может быть попутно поясню непонятные моменты по созданию заказа на API. Само API по созданию заказа получилось прям отличное, несмотря на сложности кастомизации sale.order.ajax. Если вдруг разработчики читают эту статью, то спасибо вам, прямо приятно работать 🙂

Вопреки названию блога клиентскую часть мы тут рассматривать практически не будем. По большей части вся статья только про серверный код.

Готовый компонент, сделанный по описанным в статье принципам https://verstaem.com/bitrix/opensource-order/

TL;DR
При каждом вызове компонента, неважно аяксом или на странице создаем объект заказа. По необходимости добавляем новые методы в объекты корзины и заказа. В шаблоне компонента пользуемся объектом заказа напрямую, $arResult скорее всего не пригодится. При ответе аяксом возвращаем json где передаем чистые данные + сразу html нужного куска шаблона.

Компонент

Давайте пройдем все этапы создания компонента, от пустого класса до законченного примера. Собственно вот пустой класс компонента:

<?
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
	die();
}

class customOrderComponent extends CBitrixComponent
{
	function executeComponent()
	{
	}
}

С него мы и начнем.

Еще до прочтения моей статьи рекомендую ознакомиться с информацией по следующим ссылкам:
Работа с заказом в Битрикс D7
Пример создания заказа через API
После прочтения статей по ссылкам, информация ниже будет восприниматься гораздо проще.

Создание виртуального заказа

Ключевое в моей реализации — виртуальный заказ. То есть мы прям с самого начала создаем объект заказа, по мере заполнения данных пользователем объект заказа становится все более заполненным и в итоге, когда пользователь нажмет кнопку «Оформить заказ» мы просто сделаем $this->order->save();

Выделим отдельный метод создания виртуального заказа в нашем пустом компоненте, для начала просто основы:

<?
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
	die();
}

use \Bitrix\Main\Localization\Loc;
use \Bitrix\Main\Loader;

class customOrderComponent extends CBitrixComponent
{
	/**
	 * @var \Bitrix\Sale\Order
	 */
	public $order;

	protected $errors = [];

	function __construct($component = null)
	{
		parent::__construct($component);

		if(!Loader::includeModule('sale')){
			$this->errors[] = 'No sale module';
		};

		if(!Loader::includeModule('catalog')){
			$this->errors[] = 'No catalog module';
		};
	}

	function onPrepareComponentParams($arParams)
	{
		if (isset($arParams['PERSON_TYPE_ID']) && intval($arParams['PERSON_TYPE_ID']) > 0) {
			$arParams['PERSON_TYPE_ID'] = intval($arParams['PERSON_TYPE_ID']);
		} else {
			if (intval($this->request['payer']['person_type_id']) > 0) {
				$arParams['PERSON_TYPE_ID'] = intval($this->request['payer']['person_type_id']);
			} else {
				$arParams['PERSON_TYPE_ID'] = 1;
			}
		}

		return $arParams;
	}

	protected function createVirtualOrder()
	{
		global $USER;

		try {
			$siteId = \Bitrix\Main\Context::getCurrent()->getSite();
			$basketItems = \Bitrix\Sale\Basket::loadItemsForFUser(
				\CSaleBasket::GetBasketUserID(), 
				$siteId
			)
				->getOrderableItems();

			if (count($basketItems) == 0) {
				LocalRedirect(PATH_TO_BASKET);
			}

			$this->order = \Bitrix\Sale\Order::create($siteId, $USER->GetID());
			$this->order->setPersonTypeId($this->arParams['PERSON_TYPE_ID']);
			$this->order->setBasket($basketItems);
		} catch (\Exception $e) {
			$this->errors[] = $e->getMessage();
		}
	}

	function executeComponent()
	{
		$this->createVirtualOrder();
	}

}

Что мы тут сделали? Создали новый объект заказа, определили заказу тип плательщика и поместили в заказ текущую корзину пользователя.

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

Кроме корзины тут есть еще вызов метода по установке типа плательщика, если у вас несколько плательщиков, то плательщика берем из формы заказа, то есть из суперглобальных переменных запроса $_REQUEST или $_POST при обновлении страницы, ну либо из аякса (про аякс дальше). Эти же переменные можно найти в $this->request компонента. Если всего один плательщик, то можно его прямо хардкодом тут прописать.

То есть теперь у нас есть виртуальный заказ. Правда без каких бы ты ни было оплат и доставок. И если мы прям щас даже, вот как есть напишем $this->order->save() в методе executeComponent() в конце, то у нас в адмике появится заказ:
Битрикс, админка. Заказ без службы доставки и службы оплаты

В самом простом случае остается добавить всего три вещи:

  1. Свойства заказа
  2. Отгрузку, или по сути службу доставки. Они вместе добавляются.
  3. Платежную систему

Сейчас я покажу код для этого самого простого случая.

Добавляем свойства заказа

Функционал по добавлению свойств может быть довольно объемным, поэтому давайте его выделим в отдельный метод компонента и вызовем сразу после добавления корзины:

class customOrderComponent extends CBitrixComponent
{
	[...]

	protected function createVirtualOrder()
	{
		global $USER;

		try {
			[...]
			$this->order = \Bitrix\Sale\Order::create($siteId, $USER->GetID());
			$this->order->setPersonTypeId($this->arParams['PERSON_TYPE_ID']);
			$this->order->setBasket($basketItems);

			$this->setOrderProps();

		} catch (\Exception $e) {
			$this->errors[] = $e->getMessage();
		}
	}

	protected function setOrderProps()
	{
		global $USER;
		$arUser = $USER->GetByID(intval($USER->GetID()))
			->Fetch();

		if (is_array($arUser)) {
			$fio = $arUser['LAST_NAME'] . ' ' . $arUser['NAME'] . ' ' . $arUser['SECOND_NAME'];
			$fio = trim($fio);
			$arUser['FIO'] = $fio;
		}

		foreach ($this->order->getPropertyCollection() as $prop) {
			/** @var \Bitrix\Sale\PropertyValue $prop */
			$value = '';

			switch ($prop->getField('CODE')) {
				case 'FIO':
					$value = $this->request['contact']['family'];
					$value .= ' ' . $this->request['contact']['name'];
					$value .= ' ' . $this->request['contact']['second_name'];

					$value = trim($value);
					if (empty($value)) {
						$value = $arUser['FIO'];
					}
					break;

				default:
			}

			if (empty($value)) {
				foreach ($this->request as $key => $val) {
					if (strtolower($key) == strtolower($prop->getField('CODE'))) {
						$value = $val;
					}
				}
			}

			if (empty($value)) {
				$value = $prop->getProperty()['DEFAULT_VALUE'];
			}

			if (!empty($value)) {
				$prop->setValue($value);
			}
		}
	}

	function executeComponent()
	{
		$this->createVirtualOrder();
	}
}

Я тут вырезал все неважное. Считайте что мы просто добавили новый метод, плюс вызов этого метода.

Ключевая характеристика свойства заказа — код свойства. Да можно найти свойство по его ID, но гораздо удобнее делать это по коду. Набор свойств в заказе отличается в зависимости от типа плательщика. Для физ. лица свой набор свойств, для юр. лица свой набор свойств. Но при этом коды свойств могут быть одинаковыми. Если мы заранее продумаем коды свойств, то сможем себе сильно упростить жизнь. Например свойство с кодом PAYER_NAME для физ. лица может быть «ФИО», а для юр. лица «Название компании», но если мы и тому и другому свойству сделаем код «PAYER_NAME», то нам без разницы какой тип плательщика выберет покупатель. У нас в любом случае имя плательщика будет заполняться одинаково. Так же и адресом доставки, и с телефоном и вообще с любым свойством. Не надо создавать свойство телефон с кодом PHYSICAL_PHONE для физ. лица и JURIDICAL_PHONE для юр. лица. Надо и тому и другому сделать одинаковый символьный код PHONE. При этом повторяющихся кодов свойств для одного типа плательщика быть не должно.

Сложности тут будут только с уникальными для типа плательщика свойствами. Например у юр. лица возможно будет свойство «Юридический адрес», а для физ. лица такого свойства может совсем не быть. В этом случае не важно какой код вы присвоите свойству. При наличии таких свойств вам нужно будет обновлять форму заказа в браузере, по переключению между типами плательщика. Если таких свойств нет, то форма заказа в браузере будет одинаковая для всех типов плательщика.

Вернемся к коду. Итого при выполнении компонента у нас создался виртуальный заказ, и теперь смотрим метод setOrderProps() в котором мы заполняем заказу свойства. Мы просто берем все доступные свойства через getPropertyCollection() и перебираем их в цикле. Откуда нам в компонент могут прийти свойства? Ну в большинстве случаев из запроса, очевидно же. Поэтому достаточно проверить $this->request и найти там нужные данные, обращаю внимание на строки 54 — 58. В этих строках мы перебираем все переменные запроса, и сравниваем код свойства с именем переменной запроса без учета регистра. Если переменная в запросе называется так же, как и код свойства, то мы берем значение из этой переменной для сохранения в свойстве.

Те кто уже прочитал код наверняка заметили блок switch case и думают нафига он нужен, если мы подставили все из запроса. Это блок нужен как раз для меньшинства случаев, когда мы не можем взять значение свойства из переменной запроса. Например, для составных свойств. Если в форме заказа ФИО это три отдельных поля, но при этом у нас всего одно свойство «ФИО» в заказе, то мы прям тут хардкодом можем составить значение свойства из переменных запроса, или подставить из переменной пользователя как я продемонстрировал выше.

Если ни в блоке switch case, ни напрямую из запроса ничего не получилось найти, то есть $value все еще пустой, то можно подставить дефолтное значение свойства в заказ. Это показано в строке 62.

Если значение $value заполнилось в конкретной итерации цикла, то сохраняем его. И начинаем следующую итерацию с пустого значения $value.

Если вы не хотите, чтобы служебные свойства заполнялись по данным из браузера, то добавьте проверку $prop->isUtil() и пропускайте служебные свойства в цикле.

По итогу выполнения метода setOrderProps() в компоненте, мы получаем заказ с максимально полно забитыми свойствами.

Добавляем отгрузку (службу доставки)

Новый код в строках 21 — 46

<?
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
	die();
}

use \Bitrix\Main\Localization\Loc;
use \Bitrix\Main\Loader;

class customOrderComponent extends CBitrixComponent
{

	[...]

	protected function createVirtualOrder()
	{
		global $USER;

		try {
			[...]

			/* @var $shipmentCollection \Bitrix\Sale\ShipmentCollection */
			$shipmentCollection = $this->order->getShipmentCollection();

			if (intval($this->request['delivery_id']) > 0) {
				$shipment = $shipmentCollection->createItem(
					Bitrix\Sale\Delivery\Services\Manager::getObjectById(
						intval($this->request['delivery_id'])
					)
				);
			} else {
				$shipment = $shipmentCollection->createItem();
			}

			/** @var $shipmentItemCollection \Bitrix\Sale\ShipmentItemCollection */
			$shipmentItemCollection = $shipment->getShipmentItemCollection();
			$shipment->setField('CURRENCY', $this->order->getCurrency());

			foreach ($this->order->getBasket()->getOrderableItems() as $item) {
				/**
				 * @var $item \Bitrix\Sale\BasketItem
				 * @var $shipmentItem \Bitrix\Sale\ShipmentItem
				 * @var $item \Bitrix\Sale\BasketItem
				 */
				$shipmentItem = $shipmentItemCollection->createItem($item);
				$shipmentItem->setQuantity($item->getQuantity());
			}

		} catch (\Exception $e) {
			$this->errors[] = $e->getMessage();
		}
	}

	protected function setOrderProps()
	{
		[...]
	}

	function executeComponent()
	{
		$this->createVirtualOrder();
	}
}

В D7 переработали систему доставок. У нас теперь не просто служба доставки, а отгрузка. То есть по одному и тому же заказу мы можем несколько посылок покупателю отправить. Каждая такая посылка это отгрузка. И работаем мы теперь не с одной отгрузкой, а с коллекцией отгрузок. Нет чего то в наличии, отгрузили то что есть, отправили, получили трек номер, прописали его в отгрузке. Оставшееся пришло на склад, отправили еще раз, опять получили отдельный трек номер, указали в новой отгрузке и т.д. Как правило все же одна отгрузка на заказ у всех, но частичные отгрузки тоже не редкость.

В нашем виртуальном заказе, сразу при создании ни одной отгрузки нет. Ну точнее есть системная отгрузка, но на нее пока не обращаем внимания. Смотрим переменные запроса, если там пользователь указал ID службы доставки, то мы сразу делаем отгрузку в эту доставку. Если пользователь не указал службу доставки, то делаем новую отгрузку без указания службы доставки.

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

Еще раз посмотрите код. Там есть две переменные $shipmentCollection и $shipmentItemCollection. Это разные вещи, разные коллекции. Первая переменная это коллекция отгрузок заказа. То есть другими словами это количество разных коробок, разных посылок которые мы будем отправлять покупателю. А вторая переменная это предметы внутри коробки.

Обращаю ваше внимание на строку:

$shipment = $shipmentCollection->createItem();

Тут мы берем новую пустую коробку. Если нам на момент создания новой пустой коробки уже известна служба доставки выбранная покупателем, то можно сразу подписать коробку:

$shipment = $shipmentCollection->createItem(
	Bitrix\Sale\Delivery\Services\Manager::getObjectById(
		intval($this->request['delivery_id'])
	)
);

А вот тут мы складываем корзину в эту пустую коробку, заполняем уже отдельную отгрузку:

$shipmentItem = $shipmentItemCollection->createItem($item);
$shipmentItem->setQuantity($item->getQuantity());

Итого. Взяли коллекцию отгрузок заказа, добавили новую отгрузку, и напихали в отгрузку товаров.

Добавляем платежную систему

Новый код в строках 21 — 30.

<?
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
	die();
}

use \Bitrix\Main\Localization\Loc;
use \Bitrix\Main\Loader;

class customOrderComponent extends CBitrixComponent
{

	[...]

	protected function createVirtualOrder()
	{
		global $USER;

		try {
			[...]

			if (intval($this->request['payment_id']) > 0) {
				$paymentCollection = $this->order->getPaymentCollection();
				$payment = $paymentCollection->createItem(
					Bitrix\Sale\PaySystem\Manager::getObjectById(
						intval($this->request['payment_id'])
					)
				);
				$payment->setField("SUM", $this->order->getPrice());
				$payment->setField("CURRENCY", $this->order->getCurrency());
			}

		} catch (\Exception $e) {
			$this->errors[] = $e->getMessage();
		}
	}

	protected function setOrderProps()
	{
		[...]
	}

	function executeComponent()
	{
		$this->createVirtualOrder();
	}
}

То есть мы опять таки из запроса получаем ID выбранной платежной системы и добавляем к заказу. В новом интернет магазине один заказ может содержать несколько оплат. Например покупатель может оформить заказ и сразу оплатить. Если менеджер что-то добавит в заказ, то покупатель по этому же заказу может совершить еще одну оплату. В D7 изменился подход к заказу, у нас не просто платежная система, а коллекция платежных систем. Примерно так же как и с отгрузками выше. И мы добавляем новую оплату в коллекцию оплат. При создании заказа проставляем сумму оплаты и валюту из заказа. То есть на момент сохранения заказа у нас будет всего одна платежная система в коллекции, и к оплате будет полная стоимость заказа с учетом доставки.

Подключение шаблона и сохранение

Собственно наш виртуальный заказ сформирован. Нам остается его только только сохранить. Только сохранять его надо в момент получения данных от пользователя, а не просто при открытии страницы. Пусть у нас в шаблоне есть специальный скрытый инпут, который всегда отправляет значение save=Y. Если страница просто открылась, то никакого save в переменных запроса не будет. Если покупатель отправил форму, то переменная save будет равна Y, и мы выполним функцию сохранение заказа.

Ну плюс еще можете валидацию введенных данных добавить по необходимости, отдельным методом класса.

То есть нам остается написать всего несколько строк в методе executeComponent() и в минимальной комплектации наш компонент готов:

<?
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
	die();
}

use \Bitrix\Main\Localization\Loc;
use \Bitrix\Main\Loader;

class customOrderComponent extends CBitrixComponent
{
	/**
	 * @var \Bitrix\Sale\Order
	 */
	public $order;

	protected $errors = [];

	function __construct($component = null)
	{
		parent::__construct($component);

		if (!Loader::includeModule('sale')) {
			$this->errors[] = 'No sale module';
		};

		if (!Loader::includeModule('catalog')) {
			$this->errors[] = 'No catalog module';
		};
	}

	function onPrepareComponentParams($arParams)
	{
		if (isset($arParams['PERSON_TYPE_ID']) && intval($arParams['PERSON_TYPE_ID']) > 0) {
			$arParams['PERSON_TYPE_ID'] = intval($arParams['PERSON_TYPE_ID']);
		} else {
			if (intval($this->request['payer']['person_type_id']) > 0) {
				$arParams['PERSON_TYPE_ID'] = intval($this->request['payer']['person_type_id']);
			} else {
				$arParams['PERSON_TYPE_ID'] = 1;
			}
		}

		return $arParams;
	}

	protected function createVirtualOrder()
	{
		global $USER;

		try {
			$siteId = \Bitrix\Main\Context::getCurrent()->getSite();
			$basketItems = \Bitrix\Sale\Basket::loadItemsForFUser(
				\CSaleBasket::GetBasketUserID(),
				$siteId
			)
				->getOrderableItems();

			if (count($basketItems) == 0) {
				LocalRedirect(PATH_TO_BASKET);
			}

			$this->order = \Bitrix\Sale\Order::create($siteId, $USER->GetID());
			$this->order->setPersonTypeId($this->arParams['PERSON_TYPE_ID']);
			$this->order->setBasket($basketItems);

			$this->setOrderProps();

			/* @var $shipmentCollection \Bitrix\Sale\ShipmentCollection */
			$shipmentCollection = $this->order->getShipmentCollection();

			if (intval($this->request['delivery_id']) > 0) {
				$shipment = $shipmentCollection->createItem(
					Bitrix\Sale\Delivery\Services\Manager::getObjectById(
						intval($this->request['delivery_id'])
					)
				);
			} else {
				$shipment = $shipmentCollection->createItem();
			}

			/** @var $shipmentItemCollection \Bitrix\Sale\ShipmentItemCollection */
			$shipmentItemCollection = $shipment->getShipmentItemCollection();
			$shipment->setField('CURRENCY', $this->order->getCurrency());

			foreach ($this->order->getBasket()->getOrderableItems() as $item) {
				/**
				 * @var $item \Bitrix\Sale\BasketItem
				 * @var $shipmentItem \Bitrix\Sale\ShipmentItem
				 * @var $item \Bitrix\Sale\BasketItem
				 */
				$shipmentItem = $shipmentItemCollection->createItem($item);
				$shipmentItem->setQuantity($item->getQuantity());
			}


			if (intval($this->request['payment_id']) > 0) {
				$paymentCollection = $this->order->getPaymentCollection();
				$payment = $paymentCollection->createItem(
					Bitrix\Sale\PaySystem\Manager::getObjectById(
						intval($this->request['payment_id'])
					)
				);
				$payment->setField("SUM", $this->order->getPrice());
				$payment->setField("CURRENCY", $this->order->getCurrency());
			}

		} catch (\Exception $e) {
			$this->errors[] = $e->getMessage();
		}
	}

	protected function setOrderProps()
	{
		global $USER;
		$arUser = $USER->GetByID(intval($USER->GetID()))
			->Fetch();

		if (is_array($arUser)) {
			$fio = $arUser['LAST_NAME'] . ' ' . $arUser['NAME'] . ' ' . $arUser['SECOND_NAME'];
			$fio = trim($fio);
			$arUser['FIO'] = $fio;
		}

		foreach ($this->order->getPropertyCollection() as $prop) {
			/** @var \Bitrix\Sale\PropertyValue $prop */
			$value = '';

			switch ($prop->getField('CODE')) {
				case 'FIO':
					$value = $this->request['contact']['family'];
					$value .= ' ' . $this->request['contact']['name'];
					$value .= ' ' . $this->request['contact']['second_name'];

					$value = trim($value);
					if (empty($value)) {
						$value = $arUser['FIO'];
					}
					break;

				default:
			}

			if (empty($value)) {
				foreach ($this->request as $key => $val) {
					if (strtolower($key) == strtolower($prop->getField('CODE'))) {
						$value = $val;
					}
				}
			}

			if (empty($value)) {
				$value = $prop->getProperty()['DEFAULT_VALUE'];
			}

			if (!empty($value)) {
				$prop->setValue($value);
			}
		}
	}

	function executeComponent()
	{
		$this->createVirtualOrder();

		if (isset($this->request['save']) && $this->request['save'] == 'Y') {
			$this->order->save();
		}

		$this->includeComponentTemplate();
	}
}

Теперь пару слов о шаблоне компонента.

Шаблон компонента

Я намеренно не буду останавливаться на шаблоне подробно. Раз вам не подходит штатная реализация процедуры заказа в sale.order.ajax, то значит у вас уже есть и дизайн и верстка для собственного варианта. То есть все должно быть продумано. В этом разделе статьи я обозначу плюсы виртуального заказа, и покажу как его использовать в шаблоне.

И так, у нас есть класс компонента. В данном примере этот класс я назвал customOrderComponent, этот класс наследуется от штатного битриксовского класса CBitrixComponent. Подключение шаблона в классе компонента идет через вызов функции $this->includeComponentTemplate(), далее там идет еще пара вызовов функций и в итоге выполняется CBitrixComponentTemplate->__IncludePHPTemplate(), где банальным include() подключается нужный файл из папки шаблона. По дефолту этим банальным include подключается файл template.php из папки шаблона. Ключевое здесь в том, что до вызова include() метод CBitrixComponentTemplate->__IncludePHPTemplate() определяет некоторые переменные, которые будут доступны в шаблоне компонента. Среди прочих там определяется и переменная $component, в которую помещается класс компонента. То есть внутри файла template.php мы можем пользоваться переменной $component в которой будет наш customOrderComponent.

Вот пустой файл template.php

<? if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
	die();
}
/** @var array $arParams */
/** @var array $arResult */
/** @global CMain $APPLICATION */
/** @global CUser $USER */
/** @global CDatabase $DB */
/** @var CBitrixComponentTemplate $this */
/** @var string $templateName */
/** @var string $templateFile */
/** @var string $templateFolder */
/** @var string $componentPath */
/** @var customOrderComponent $component */

var_dump($component);
?>

В переменной $component хранится customOrderComponent, и прям в шаблоне можно вызывать его методы. А благодаря тому что мы через phpdoc «/** @var customOrderComponent $component */» правильно определили содержимое переменной, в IDE будут красивые подсказки. Естественно никакую логику тут писать категорически нельзя.

В классе customOrderComponent я объявил публичную переменную $this->order, в которой собственно и лежит наш виртуальный заказ. В template.php $this->order превращается в $component->order.

Я понимаю что это некоторое нарушение паттерна MVC компонента. И если вы действительно хотите добиться того, чтобы шаблон прям гарантированно никак не мог повлиять на логику работы заказа, даже теоретическую возможность хотите исключить, то можете сделать переменную $this->order защищенной или приватной, переменная станет не доступна в шаблоне. И в этом случае вы как обычно должны будете cформировать массив $this->arResult и пользоваться именно им в шаблоне. Если вы решите делать именно так, то весь раздел про шаблон вам мысленно придется переделать, например, вместо $component->order->getBasket() вам нужно будет перебирать в цикле $arResult[‘BASKET’], который вам нужно будет заполнить до вызова $this->includeComponentTemplate().

Вот что нам дает прямое использование объекта заказа в шаблоне:

Корзина

Мы можем получить объект корзины и сформировать нужный html


<table>
	<?
	$counter = 1;
	foreach ($component->order->getBasket() as $item):
		/**
		 * @var $item \Local\Sale\BasketItem
		 */
		?>
		<tr class="basket-data__tr">
			<td class="basket-data__td basket-data__td-number">
				<span class="basket-data__number"><?= $counter++ ?></span>
			</td>
			<td class="basket-data__td basket-data__td-img">
				<?
				if (!empty($item->getPicture())): ?>
					<img src="<?= $item->getPictureResized(['width' => 200, 'height' => 200])['SRC'] ?>" class="basket-data__img" alt="">
				<? endif; ?>
			</td>
			<td class="basket-data__td">
				<span class="basket-data__product-title"><?= $item->getField('NAME') ?></span>
			</td>
			<td class="basket-data__td">
				<span class="basket-data__count-products">
					<?= $item->getQuantity() ?>
					<?= $item->getField('MEASURE_NAME') ?>
				</span>
			</td>
			<td class="basket-data__td">
				<span class="basket-data__product-price">
					<?= \SaleFormatCurrency(
							$item->getQuantity() * $item->getPrice(), 
							$item->getCurrency()
					) ?>
				</span>
			</td>
		</tr>
	<? endforeach; ?>
</table>

Работа со свойствами

Еще в шаблоне можно делать всякие интересные штуки со свойствами

Форма


<form action="">
	<? foreach ($component->order->getPropertyCollection() as $prop):
		/** @var \Bitrix\Sale\PropertyValue $prop */
		?>
		<label>
			<?=$prop->getName()?><br>
			<input type="text" name="<?= $prop->getField('CODE') ?>" value="<?= $prop->getValue() ?>">
		</label>
		<br>

	<? endforeach; ?>
</form>

Отображаемое местоположение

В коллекции свойств есть специальные методы для получения особых свойств, типа местоположения:

<?$locationProp = $component->order
	->getPropertyCollection()
	->getDeliveryLocation();

if (is_object($locationProp)):
	?>
	<div class="check__content-row">
		<div class="check__content-label">
			<?= $locationProp->getName() ?>:
		</div>
		<div class="check__content-value">
			<a href="javascript:;" class="check__content-link">
				<?= $locationProp->getViewHtml() ?>
			</a>
		</div>
	</div>
<? endif; ?>

Аналогичные методы есть для получения имени плательщика getPayerName(), получение адреса getAddress(), получение телефона getPhone(). Посмотрите методы объекта PropertyValueCollection, чтобы получить полный список. Какое именно свойство будет возвращено этими спец методами определяется галочками в настройках свойства.

Получение свойства по коду

Можно в нашем классе customOrderComponent написать вспомогательные методы getPropByCode() и getPropDataByCode()

class customOrderComponent extends CBitrixComponent
{
	[...]
 
	/**
	 * @var array
	 */
	public $propMap = [];

	public function getPropByCode($code)
	{
		$result = false;

		$propId = 0;
		if (isset($this->propMap[$code])) {
			$propId = $this->propMap[$code];
		}

		if ($propId > 0) {
			$result = $this->order
				->getPropertyCollection()
				->getItemByOrderPropertyId($propId);
		}

		return $result;
	}

	public function getPropDataByCode($code)
	{
		$result = [];

		$propId = 0;
		if (isset($this->propMap[$code])) {
			$propId = $this->propMap[$code];
		}

		if ($propId > 0) {
			$result = $this->order
				->getPropertyCollection()
				->getItemByOrderPropertyId($propId)
				->getFieldValues();
		}

		return $result;
	}
	
	protected function createVirtualOrder(){}
 
	protected function setOrderProps()
	{
		[...]
 
		foreach ($this->order->getPropertyCollection() as $prop) {
			/** @var \Bitrix\Sale\PropertyValue $prop */
			$this->propMap[$prop->getField('CODE')] = $prop->getPropertyId();
			
			[...]
		}
	}
 
	function executeComponent(){}
}

Получаем объект свойства по коду:

<?$addressProp = $component->getPropByCode('ADDRESS');
if (is_object($addressProp)):
	?>
	<div class="check__content-row">
		<div class="check__content-label">
			<?= $addressProp->getName() ?>:
		</div>
		<div class="check__content-value">
			<a href="javascript:;" class="check__content-link">
				<?= $addressProp->getViewHtml() ?>
			</a>
		</div>
	</div>
<? endif; ?>

Получаем массив полей свойства по коду. В этом случае в шаблоне вывод чуть короче:

<div class="check__content-row">
	<div class="check__content-label">
		<?= $component->getPropDataByCode('PHONE')['NAME'] ?>:
	</div>
	<div class="check__content-value">
		<a href="javascript:;" class="check__content-link">
			<?= $component->getPropDataByCode('PHONE')['VALUE'] ?>
		</a>
	</div>
</div>

Прочие возможности

Стоимость доставки и заказа

Это штатные методы объекта заказа. Стоимость всего заказа:

<?echo \SaleFormatCurrency(
	$component->order->getPrice(), 
	$component->order->getCurrency()
) ?>

Стоимость доставки отдельно:

<?echo \SaleFormatCurrency(
	$component->order->getDeliveryPrice(), 
	$component->order->getCurrency()
) ?>

Сразу форматируем вывод в соответствии с настройками валют.

Выбранная служба доставки

В заказе нет доставок, есть отгрузки. Службу доставки можно взять только из отгрузки:

<?$shipment = false;
/** @var \Bitrix\Sale\Shipment $shipmentItem */
foreach ($component->order->getShipmentCollection() as $shipmentItem) {
	if (!$shipmentItem->isSystem()) {
		$shipment = $shipmentItem;
		break;
	}
}
if ($shipment) :
	?>
	<?= $shipment->getDelivery()->getName() ?>
<? else:?>
	Самовывоз
<? endif; ?>

Выбранная система оплаты

<? foreach ($component->order->getPaymentCollection() as $payment):
	/**
	 * @var \Bitrix\Sale\Payment $payment
	 */
	?>
	<?= $payment->getPaymentSystemName() ?>
<? endforeach; ?>

Итого по шаблону

На текущий момент у вас должно быть понимание того как формируется заказ в битриксе на d7, и зная какие данные ожидает компонент, вы можете правильно сформировать html формы заказа. Вам потребуется всего лишь список свойств, список доставок, и список платежных систем.

Пример получения списка свойств я приводил, вам остается только в зависимости от типа свойства написать switch case, и на каждый вид свойства сформировать нужный html — радио кнопку, текстовый инпут, textarea, select и т.д.

Про получение списка доставок и списка платежных систем в следующем разделе статьи. При наличии переменных со списком доставок / оплат достаточно добавить в форму заказа input типа radio, где значениями будут ID собственно доставок и оплат.

Надеюсь базовые вещи теперь понятны. И форму создания заказа полной перезагрузкой страницы вы сможете написать. Попробуем усложнить функционал.

Добавляем ООП

Внимательный читатель возможно заметил в разделе про шаблон, в корзине я использовал методы $item->getPicture() и $item->getPictureResized(), которых нет в штатной поставке. То есть в штатно полученном товаре корзины добавились какие то нештатные методы.

Давайте рассмотрим откуда берется объект корзины, и вообще и в нашем текущем компоненте в частности. Сам объект корзины создается методом \Bitrix\Sale\Basket::create()

/**
 * @param $siteId
 * @return Basket
 */
public static function create($siteId)
{
	$basket = static::createBasketObject();
	$basket->setSiteId($siteId);

//		if ($fuserId !== null)
//			$basket->setFUserId($fuserId);

	return $basket;
}

Даже если вы вызываете какой то другой метод, например \Bitrix\Sale\Basket::loadItemsForFUser(), по факту для создания корзины все равно будет выполнен именно метод create(). В методе create() нас интересует createBasketObject()

/**
 * @throws Main\NotImplementedException
 * @return Basket
 */
protected static function createBasketObject()
{
	$registry = Registry::getInstance(Registry::REGISTRY_TYPE_ORDER);
	$basketClassName = $registry->getBasketClassName();

	return new $basketClassName;
}

В этом методе битрикс берет singleton класс \Bitrix\Sale\Registry::getInstance(), и получает из него название класса корзины. Не буду цитировать код еще глубже, суть в том у нас есть объект Registry который хранит название классов корзины, товара корзины и т.д. Все эти классы мы можем переопределить методом set() вот так:

$registry = Registry::getInstance(Registry::REGISTRY_TYPE_ORDER);
$registry->set(Registry::ENTITY_BASKET, '\Local\Sale\Basket');
$registry->set(Registry::ENTITY_BASKET_ITEM, '\Local\Sale\BasketItem');

После выполнения этого кода, все последующие корзины будут создаваться из класса ‘\Local\Sale\Basket’, а не из дефолтного ‘\Bitrix\Sale\Basket’. Аналогично вместо ‘\Bitrix\Sale\BasketItem’ битрикс нам всегда будет создавать ‘\Local\Sale\BasketItem’.

В каком месте выполнять эту подмену? Если мы говорим про компонент, то достаточно вставить эти три строки в методе createVirtualOrder(), где нить в самом начале, сразу перед созданием корзины. Если вы хотите сделать подмену вообще по всему сайту, то можете например в прологе, то есть по событию OnBeforeProlog подключить модуль sale, получить инстанс класса Registry и там сделать подмену. Минус глобальной подмены в том, что у вас на каждом хите будет подключаться целый модуль, который не факт что и нужен на этом хите. Тут бы пригодилось какое нить событие типа «onModuleLoaded», чтобы сразу после инициализации модуля выполнять какой нибудь код. Но на текущий момент я такого события не нашел.

Классы ‘\Local\Sale\Basket’ и ‘\Local\Sale\BasketItem’ естественно нужно создать самостоятельно. Сами они ниоткуда не возьмутся. Названия классов могут быть любые, например вместо ‘\Local\Sale\Basket’ вы можете создавать ‘\Foo\Bar\’. Можете положить их в собственный модуль битрикса (например в local.lib) или зарегистрировать автолоад для папки в композере и подключить оттуда. Смотрите сами как вам удобнее. Сейчас пока давайте создадим эти классы и поговорим про возможное их содержимое.

Расширяем корзину

Вот класс корзины:

<?php
namespace Local\Sale;

use Bitrix\Main\Loader;

class Basket extends \Bitrix\Sale\Basket
{
	protected $productsList = [];

	protected function getAllProductsData()
	{
		if(Loader::includeModule('iblock')) {
			$productIDs = [];
			foreach ($this->collection as $item) {
				/**
				 * @var $item \Local\Sale\BasketItem
				 */
				$productIDs[$item->getProductId()] = $item->getProductId();
			}
			sort($productIDs);

			if(!empty($productIDs)) {
				$arFilter = [
					'ID' => $productIDs
				];

				$rsElements = \CIBlockElement::GetList([], $arFilter);

				while ($arElement = $rsElements->Fetch()) {
					$this->productsList[$arElement['ID']] = $arElement;
				}

				foreach ($productIDs as $id) {
					if (!isset($this->productsList[$id])) {
						$this->productsList[$id] = [];
					}
				}
			}
		}
	}

	public function getProductData($productId)
	{
		$data = [];

		if (!isset($this->productsList[$productId])) {
			$this->getAllProductsData();
		}

		if (isset($this->productsList[$productId])) {
			$data = $this->productsList[$productId];
		}

		return $data;
	}
}

Обязательно наследуемся от \Bitrix\Sale\Basket. В качестве примера я тут написал два метода. Первый это защищенный метод getAllProductsData(), который получает данные по элементам инфоблока для всех товаров корзины. Сначала собираются ID всех товаров, а потом одним запросом получается полный список элементов. Список сохраняется в защищенной переменной.

Второй метод публичный, он отдает вовне собранные данные по конкретному товару корзины. Именно он вызывается во втором демонстрационном классе:

<?php
namespace Local\Sale;

class BasketItem extends \Bitrix\Sale\BasketItem
{

	protected $arImage = null;

	public function getPicture()
	{
		/**
		 * @var \Local\Sale\Basket $basket
		 */
		$basket = $this->getCollection();
		$arProduct = $basket->getProductData($this->getProductId());
		if (!empty($arProduct)) {
			$image = new \Local\Lib\Parts\Image([]);
			$image->addImage($arProduct['DETAIL_PICTURE']);
			$image->addImage($arProduct['PREVIEW_PICTURE']);
			$this->arImage = $image->getFirstOriginal();
		}

		if (!is_array($this->arImage)) {
			$this->arImage = [];
		}

		return $this->arImage;
	}


	public function getPictureResized($arSizes)
	{
		if (!is_array($this->arImage)) {
			$this->getPicture();
		}

		$arImage = [];
		if (!empty($this->arImage)) {
			$image = new \Local\Lib\Parts\Image($arSizes);
			$image->addImage($this->arImage);
			$arImage = $image->getFirstResized();
		}
		return $arImage;
	}

}

Обязательно наследуемся от \Bitrix\Sale\BasketItem. В шаблоне нашего sale.order.ajax выше, в цикле перебора товаров корзины я использовал методы именно этого второго класса. А именно getPicture() для определения есть ли у товара картинка вообще. И getPictureResized() чтобы получить путь до картинки товара, ужатой до нужных мне рамок.

В целом как оно все работает. При первом обращении к методу getPicture() в любом товаре корзины, происходит всего один запрос к инфоблоку. Запрос возвращает данные сразу по всем товарам и в дальнейшем, во всех последующих вызовах getPicture(), getPictureResized() и getProductData($productId) берутся уже готовые данные. Аналогично и по картинкам, картинка из БД получается всего один раз, а в дальнейшем берутся уже полученная на текущем хите данные.

Аналогичным образом вы можете прописать любую собственную логику. А потом удобным образом ее использовать в том же шаблоне компонента или в любых других местах по вашему выбору. Возможно вам даже не пригодится $arResult в шаблоне.

Расширяем заказ

Ранее я не показывал как получить список возможных доставок и систем оплат заказа. Давайте расширим объект заказа, и добавим туда два метода для получения доставок и оплат

<?php
namespace Local\Sale;

use Bitrix\Sale\Delivery;
use Bitrix\Sale\Payment;
use Bitrix\Sale\PaySystem;

class Order extends \Bitrix\Sale\Order
{

	public function getAvailableDeliveries()
	{
		$shipment = false;
		/** @var \Bitrix\Sale\Shipment $shipmentItem */
		foreach ($this->getShipmentCollection() as $shipmentItem) {
			if (!$shipmentItem->isSystem()) {
				$shipment = $shipmentItem;
				break;
			}
		}

		$availableDeliveries = [];
		if (!empty($shipment)) {
			$availableDeliveries = Delivery\Services\Manager::getRestrictedObjectsList($shipment);
		}

		return $availableDeliveries;
	}

	public function getAvailablePaySystems()
	{
		$payment = Payment::create($this->getPaymentCollection());
		$payment->setField('SUM', $this->getPrice());
		$payment->setField("CURRENCY", $this->getCurrency());
		$paySystemsList = PaySystem\Manager::getListWithRestrictions($payment);

		//logo
		foreach ($paySystemsList as $key => $paySystem) {
			if (intval($paySystem['LOGOTIP']) > 0) {
				$paySystemsList[$key]['LOGO_PATH'] = \Local\Lib\Helpers\Files::getOriginal(
					$paySystem['LOGOTIP']
				);
			}
		}

		return $paySystemsList;
	}

}

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

$registry = Registry::getInstance(Registry::REGISTRY_TYPE_ORDER);
$registry->set(Registry::ENTITY_ORDER, '\Local\Sale\Order');

И так же как и с корзиной, расширенный класс заказа должен быть наследником \Bitrix\Sale\Order

Рассмотрим два новых метода подробнее.

Метод getAvailableDeliveries() получает первую же не системную отгрузку. Если в заказе есть только системная дефолтная отгрузка, и вы еще не добавляли никаких собственных отгрузок, то метод в текущей реализации не вернет ничего. Эта полученная не системная отгрузка передается в Delivery\Services\Manager::getRestrictedObjectsList() и мы получаем доступных для пользователя систем оплаты. Стоимость доставки естественно не рассчитана. О том как рассчитывать стоимость доставки правильно поговорим в следующем большом блоке статьи про аякс. Однако же в целом механизм предельно простой, можно дополнить наш метод выше примерно так:

$availableDeliveries = [];
if (!empty($shipment)) {
	$availableDeliveries = Delivery\Services\Manager::getRestrictedObjectsList($shipment);

	foreach ($availableDeliveries as $obDelivery) {
		if($obDelivery->isCalculatePriceImmediately()) {
			$shipment->setField('DELIVERY_ID', $obDelivery->getId());
			$calcResult = $obDelivery->calculate();

			if ($calcResult->isSuccess()) {
				echo $calcResult->getPrice();
				echo $calcResult->getPeriodDescription();
			} else {

			}
		}
	}
}

Если пересказать алгоритм обычными словами, то здесь происходит следующее. Мы получили список доступных доставок и перебираем их в цикле. Если доставка не требует запроса ко внешнему сервису (то есть расчет произойдет практически мгновенно), то сразу рассчитываем стоимость вызовом метода $obDelivery->calculate(). И все 🙂 Тут есть свои особенности конечно, возможно в следующем блоке мы их рассмотрим, но в целом расчет доставок действительно настолько прост 🙂

Проверка if($obDelivery->isCalculatePriceImmediately()) вовсе не обязательна. Доставки которые осуществляют расчет запросом ко внешнему сервису работают точно так же, вызываете calculate() и получаете результат, только с задержкой на время запроса.

Метод getAvailablePaySystems() делает примерно то же самое что метод по доставкам выше. Вся разница в том, что возвращается массив, а не список объектов. Метода возвращающего список объектов нет, и честно говоря не знаю появится ли он в будущем. Возможно коллеги из битрикса это прокомментируют 🙂 Ну и плюсом мы в этом методе получаем картинки логотипов сразу.

Итого по ООП

Механизм расширения классов таит в себе гигантский потенциал. Главное не совать туда совсем левые вещи, типа получения погоды на завтра. Да, возможно в шаблоне вам покажется удобным сразу взять и запросить погоду, но к объекту заказа это вообще никак не относится 🙂 Если возникнет желание, воздержитесь)

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


Выберите службу доставки:<br>
<?foreach ($component->order->getAvailableDeliveries() as $obDelivery):?>
	<label>
		<input type="radio" name="delivery_id" value=<?=$obDelivery->getId()?>>
		<?=$obDelivery->getName()?>
	</label>
<? endforeach; ?>

Выберите платежную систему:<br>
<?foreach ($component->order->getAvailablePaySystems() as $arPaySystem):?>
	<label>
		<input type="radio" name="payment_id" value=<?=$arPaySystem['ID']?>>
		<?=$arPaySystem['NAME']?>
	</label>
<? endforeach; ?>

Расширение стандартных объектов это опция, а не обязанность. Никто вас не заставляет получать список доставок именно через расширение объекта заказа. Можете как обычно написать отдельный метод в классе компонента, код там будет примерно такой же. Получаете список доставок, сохраняете полученный список в $this->arResult и потом как обычно пользуетесь $arResult в шаблоне.

Однако же это еще не все. Есть еще пара важных вещей напоследок 🙂

Прикручиваем ajax

Было бы странно писать компонент sale.order.ajax без использования аякса 🙂 А нафига он в общем то нужен в этом компоненте? Так на вскидку я могу назвать несколько причин:

  1. Обновление формы в зависимости от типа плательщика
  2. Получения местоположения
  3. Получение списка доставок и расчет стоимости доставки
  4. Получение систем оплаты

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

Для начала давайте просто научим компонент не отдавать ничего лишнего, чуть дополним метод executeComponent()

<?
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
	die();
}

use \Bitrix\Main\Localization\Loc;
use \Bitrix\Main\Loader;

class customOrderComponent extends CBitrixComponent
{
	/**
	 * @var \Bitrix\Sale\Order
	 */
	public $order;

	protected $errors = [];

	protected $arResponse = [
		'errors' => [],
		'html' => ''
	];

	function __construct($component = null) {}

	function onPrepareComponentParams($arParams)
	{
		[...]

		if (
			isset($arParams['IS_AJAX']) 
			&& ($arParams['IS_AJAX'] == 'Y' || $arParams['IS_AJAX'] == 'N')
		) {
			$arParams['IS_AJAX'] = $arParams['IS_AJAX'] == 'Y';
		} else {
			if (
				isset($this->request['is_ajax']) 
				&& ($this->request['is_ajax'] == 'Y' || $this->request['is_ajax'] == 'N')
			) {
				$arParams['IS_AJAX'] = $this->request['is_ajax'] == 'Y';
			} else {
				$arParams['IS_AJAX'] = false;
			}
		}

		return $arParams;
	}

	protected function createVirtualOrder(){}

	protected function setOrderProps(){}

	function executeComponent()
	{
		global $APPLICATION;

		if ($this->arParams['IS_AJAX']) {
			$APPLICATION->RestartBuffer();
		}

		$this->createVirtualOrder();

		if (isset($this->request['save']) && $this->request['save'] == 'Y') {
			$this->order->save();
		}

		if ($this->arParams['IS_AJAX']) {
			if ($this->getTemplateName() != '') {
				ob_start();
				$this->includeComponentTemplate();
				$this->arResponse['html'] = ob_get_contents();
				ob_end_clean();
			}

			$this->arResponse['errors'] = $this->errors;

			header('Content-Type: application/json');
			echo json_encode($this->arResponse);
			$APPLICATION->FinalActions();
			die();
		} else {
			$this->includeComponentTemplate();
		}
	}
}

В методе onPrepareComponentParams() я добавил проверку переменной $this->request[‘is_ajax’]. Если мы хотим чтобы компонент не отдавал ничего лишнего, то просто добавляем в запросе от браузера is_ajax=Y.

Метод $APPLICATION->RestartBuffer() вы скорее всего знаете. Если аякс запрос вы отправляете прямо к странице в публичной части, и не выделяли никакой отдельный URL в котором нет подключения шаблона сайта, то этим методом мы удаляем весь html в буфере вывода, который успел накопиться до подключения компонента. То есть даже на обычной публичной странице благодаря $APPLICATION->RestartBuffer() и die() мы добиваемся иллюзии отдельного подключения компонента. Как будто бы выше и ниже компонента по коду ничего нет. По факту шаблон все равно выполняется и поэтому я рекомендую сделать действительно отдельную страницу без подключения шаблона, а не создавать иллюзию такого поведения. Чтобы не создавать совершенно ненужную нагрузку от шаблона сайта.

По поводу переменной $this->arResponse. Она нужна с единственной целью — отдавать ответ на аякс запрос в формате json. В принципе вы можете оставить и обычное подключение шаблона, и в принимающем js скрипте просто заменять кусок html пришедший из шаблона. Произошло событие onchange типа плательщика, отправили аякс запрос (с учетом уже введенных данных), в ответе пришел новый вариант формы заказа и вы просто заменяете текущую форму в браузере, пришедшим от сервера html’ем. Благодаря тому что в запросе мы отправляли уже введенные данные, наш виртуальный заказ будет частично заполнен, и форма заказа так же будет частично сформирована. Например свойство местоположение (с кодом LOCATION), если пользователь его уже ввел, и потом переключил тип плательщика уже будет содержать данные.

То есть да, можно и безо всякого json сразу html отправлять в аякс ответе, однако же в json мы параллельно с шаблоном можем передать чистые данные. Все преимущества чистого html остаются, и плюсом вы получаете намного больше свободы. Если вам достаточно просто обновить кусок шаблона, то вместо $(‘form’).html(data) достаточно будет написать $(‘form’).html(data.html).

Итого, мы добавили компоненту дополнительный режим работы. Компонент теперь может отдавать данные в json для аякс запросов, и так же может отдавать обычный html если это просто открытие страницы. Базис для обработки аякс запросов готов. Теперь попробуем добавить новый функционал.

Actions компонента

Продолжаем развивать метод executeComponent()

class customOrderComponent extends CBitrixComponent
{
	/**
	 * @var \Bitrix\Sale\Order
	 */
	public $order;

	protected $errors = [];

	protected $arResponse = [
		'errors' => [],
		'html' => ''
	];

	function __construct($component = null) {}

	function onPrepareComponentParams($arParams) {
		[...]
		
		if (isset($arParams['ACTION']) && strlen($arParams['ACTION']) > 0) {
			$arParams['ACTION'] = strval($arParams['ACTION']);
		} else {
			if (isset($this->request['action']) && strlen($this->request['action']) > 0) {
				$arParams['ACTION'] = strval($this->request['action']);
			} else {
				$arParams['ACTION'] = '';
			}
		}
	}

	protected function createVirtualOrder(){}

	protected function setOrderProps(){}

	
	protected function calcAction()
	{
		$this->setTemplateName('');
		//Рассчитываем стоимость доставки и заполняем данными массив $this->arResponse		
	}
	
	protected function deliveriesAction()
	{
		$this->setTemplateName('delivery');
		//Нифига не делаем, просто подключаем шаблон доставки
	}
	
	protected function saveAction()
	{
		$this->setTemplateName('done');
		//Проверяем что все корректно, все свойства есть, доставка/отгрузка выбрана, платежная система определена
		//И сохраняем заказ в базу если все нормально
		$this->order->save();
		
	}

	function executeComponent()
	{
		global $APPLICATION;

		if ($this->arParams['IS_AJAX']) {
			$APPLICATION->RestartBuffer();
		}

		$this->createVirtualOrder();

		if(!empty($this->arParams['ACTION'])) {
			if (is_callable([$this, $this->arParams['ACTION'] . "Action"])) {
				try {
					call_user_func([$this, $this->arParams['ACTION'] . "Action"]);
				} catch (\Exception $e) {
					$this->errors[] = $e->getMessage();
				}
			}
		}

		if ($this->arParams['IS_AJAX']) {
			if ($this->getTemplateName() != '') {
				ob_start();
				$this->includeComponentTemplate();
				$this->arResponse['html'] = ob_get_contents();
				ob_end_clean();
			}

			$this->arResponse['errors'] = $this->errors;

			header('Content-Type: application/json');
			echo json_encode($this->arResponse);
			$APPLICATION->FinalActions();
			die();
		} else {
			$this->includeComponentTemplate();
		}
	}
}

В $this->arParams у нас добавилась переменная ACTION. То есть вы в аякс запросе прописываете прям отдельно action=save например, а компонент проверяет наличие метода saveAction в классе. Если такой метод есть — вызывает его.

Вы можете называть методы как угодно, извне, клиентским запросом будут доступны только методы с суффиком Action. Если у метода в классе компонента на конце Action, то это значит что его могут вызвать снаружи.

В листинге выше я привел несколько возможных вариантов использования этих экшенов. Вы можете переключить шаблон, чтобы в $this->arResponse[‘html’] получить какой то специфичный под задачу html код. Например вы не хотите перегружать всю форму заказа, пользователь ввел новый адрес, и вы тут же отдельным аякс запросом к компоненту обновляете список возможных доставок для этого адреса. В шаблоне компонента deliveries/template.php вам достаточно будет просто вывести html с доставками, не раздувая основной, дефолтный шаблон компонента. Никаких там if, просто микрокусок html в отдельном шаблоне.

Или например у вас уже форма заказа заполнена, вы отправляете запрос на сохранение заказа. В методе setOrderProps, можно прописать условие if($this->arParams[‘ACTION’] == ‘save’), то проверяем обязательные свойства ($prop->isRequired()) на заполненность, чтоб покупатель ничего не пропустил. В методе createVirtualOrder() можно написать аналогичные проверки на этот экшен, только проверять уже не свойства, а платежную систему и доставку. Если покупатель все заполнил корректно, то сохраняем заказ и выводим ему шаблон done, с текстом «Спасибо дорогой!» 🙂

Помимо изменения шаблона на лету, для аякс ответа можно совсем убрать подключение шаблона, как я сделал в методе calcAction(). В 78 строке стоит условие, мы подключаем только явно определенный шаблон, шаблон без имени подключен не будет. Этот экшен будет использоваться для расчета стоимости доставок, там шаблон нам не пригодится, мы будет возвращать чистые данные. Далее поговорим про calcAction() отдельно.

Поиск местоположений

Я рекомендую пользоваться готовым компонентом bitrix:sale.location.selector.search для поиска. И не рекомендую делать отдельный action в компоненте. Компоненту заказа не важно откуда браузер возьмет код местоположения, главное чтобы значение пришло. Виртуальный заказ для поиска местоположений точно лишний. Если штатный компонент кажется слишком сложным, то можете сделать собственный отдельный компонент, не нужно добавлять функционал поиска в компонент заказа.

Можно сделать собственную форму поиска, но при этом слать запросы штатному компоненту. Для собственной формы потребуется всего три элемента — текстовый инпут для ввода поисковой строки, выпадающий список для отображения найденных местоположений и скрытый инпут для хранения кода местоположения. На клиенте будет обрабатываться всего пара событий — onchange / onkeyup на текстовый инпут. И onclick на выпадающий список. По изменению текстового инпута вам надо будет отправлять аякс запрос подстрокой, и при получении ответа обновлять выпадающий список. По клику на выпадающий список обновляете значение скрытого инпута.

Посмотрите какие данные шлет штатный компонент
Переменные запроса для поиска местоположения
И отправляйте точно такие же переменные запроса. Если формат ответа не устраивает, то можно скопировать компонент и перед отправкой в браузер изменить данные на сервере.

Метод расчета стоимости доставки

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

Здесь есть несколько подходов, рассмотрим их в порядке увеличения сложности:

  1. Можно отобразить список доставок, и у каждой доставки сделать кнопку «Рассчитать». По нажатию кнопки отправлять аякс запрос для получения стоимости. Это самый простой вариант. Но понятно что для современного интернет магазина это уже не сильно подходит.
  2. Вы можете рассчитать стоимость сразу по всем доставкам в одном запросе. Плюс в том что это тоже достаточно просто реализуется. Делаем запрос, и там в цикле для всех доставок вызываем calculate(). Формируем html и возвращаем. Минус остается прежним, расчет всех доставок сразу, практически наверняка займет приличный отрезок времени.
  3. Чуть сложнее, но зато и правильнее будет послать отдельный запрос по каждой доставке. Таким образом у нас сразу, одновременно начинается расчет по всем доставкам. Параллельно, а не последовательно. Так как все запросы мы отправляем независимо друг от друга и асинхронно. Минус тут только в большом обьеме запросов.
  4. Бывают доставки, в которых при расчете возвращается сразу несколько вариантов. Например так делают сервисы агрегаторы доставок. Ты передаешь в сервис габариты, откуда — куда, а в ответ приходит сразу десяток вариантов. Если все эти доставки занесены отдельными профилями или просто отдельными службами, то даже в случае использования общего кеша в сервис уйдет сразу десяток запросов. Для таких случаев правильно отправлять на расчет сразу блок доставок.

Самый правильный вариант — разбить доставки на блоки, а из браузера отправлять запросы на расчет по этим блокам. То есть блок доставок для одного агрегатора будет рассчитываться в одном аякс запросе. Последовательно. При расчете первой доставки из блока уходит запрос во внешний сервис, ждем ответ, пришел ответ, ответ закешировали, рассчитываем вторую доставку из блока, а вот уже вторая доставка берет данные из кеша, и третья, и четвертая.

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

Как все эти расчеты отобразить с пользовательской точки зрения, в шаблоне, смотрите сами. Можно сразу отобразить вообще все доставки, цену рассчитать только у доставок без внешних запросов, а потом пройтись по отображенному списку и блоками отправить запросы на расчет оставшихся доставок. Или еще как вариант — ничего не отображать до момента расчета. На взгляд пользователя будет чуть медленнее, так как результат он не сразу увидит. То есть получить список доставок точно так же, и ничего не отображать, до момента прихода ответов от блоковых запросов. Начинать отображение по мере прихода данных.

Вот например так может выглядеть метод расчета доставок

class customOrderComponent extends CBitrixComponent
{
	[..]

	protected function calcAction()
	{
		$this->setTemplateName('');

		//Собираем ID доставок
		$deliveryIDs = [];
		if (isset($this->request['delivery_id'])) {
			if (is_array($this->request['delivery_id'])) {
				foreach ($this->request['delivery_id'] as $val) {
					if (intval($val) > 0) {
						$deliveryIDs[intval($val)] = intval($val);
					}
				}
			} elseif (intval($this->request['delivery_id']) > 0) {
				$deliveryIDs = [intval($this->request['delivery_id'])];
			} else {
				$deliveryIDs = [];
			}

		}
		//На выходе в любом случае будет массив
		sort($deliveryIDs);

		if (empty($deliveryIDs)) {
			throw new \Exception('Нет доставок для расчета');
		}

		$shipment = false;
		/** @var \Bitrix\Sale\Shipment $shipmentItem */
		foreach ($this->order->getShipmentCollection() as $shipmentItem) {
			if (!$shipmentItem->isSystem()) {
				$shipment = $shipmentItem;
				break;
			}
		}

		if (!$shipment) {
			throw new \Exception('Отгрузка не найдена');
		}

		//Массив с доставками,
		$availableDeliveries = \Bitrix\Sale\Delivery\Services\Manager::getRestrictedObjectsList(
			$shipment
		);

		foreach ($deliveryIDs as $deliveryId) {
			$obDelivery = false;
			if (isset($availableDeliveries[$deliveryId])) {
				//Если переданный из запроса ID доставки доступен покупателю
				$obDelivery = $availableDeliveries[$deliveryId];
			}

			if ($obDelivery) {
				$arDelivery = [
					'id'                => $obDelivery->getId(),
					'name'              => $obDelivery->getName(),
					'logo_path'         => $obDelivery->getLogotipPath(),
					'show'              => false,
					'calculated'        => false,
					'period'            => '',
					'price'             => 0,
					'price_formated'    => '',
				];

				$shipment->setField('DELIVERY_ID', $obDelivery->getId());
				$calcResult = $obDelivery->calculate($shipment);

				if ($calcResult->isSuccess()) {
					$arDelivery['calculated'] = true;
					$arDelivery["price"] = $calcResult->getPrice();
					$arDelivery["price_formated"] = \SaleFormatCurrency(
						$calcResult->getPrice(),
						$this->order->getCurrency()
					);

					if (strlen($calcResult->getPeriodDescription()) > 0) {
						$arDelivery["period_text"] = $calcResult->getPeriodDescription();
					}
				}

				if (floatval($arDelivery['price']) > 0) {
					$arDelivery['show'] = true;
				}

				if (empty($arDelivery["period_text"])) {
					$arDelivery["period_text"] = '...';
				}

				$this->arResponse['deliveries'][$arDelivery['ID']] = $arDelivery;
			} else {
				//В аякс ответе, даже недоступную доставку возвращаем
				$this->arResponse['deliveries'][$deliveryId] = [
					'id'   => $deliveryId,
					'show' => false
				];
			}
		}
	}

	protected function deliveriesAction(){}

	protected function saveAction(){}

	function executeComponent(){}
}

В аякс запросе, в переменной delivery_id можно передать либо один ID доставки, либо массив с ID доставок. Если доставки доступны пользователю, то они рассчитаются и в ответе мы получим json с массивом рассчитанных доставок. Перебираем аякс ответ в цикле $.each(data.deliveries, function(deliveryId, delivery){}) и расставляем данные по нужным местам html кода. Отображаем доставку только в случае show = true. Можете переделать данный метод как вам угодно.

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

Общий итог

В целом у вас уже должно быть понимание как построить собственную процедуру заказа и сделать хороший такой шаг в сторону от штатного варианта. От самых базовых простых форм и до продвинутых, где на каждый чих пользователя уходит пять аякс запросов в разные стороны.

Еще раз кратко суть статьи. В центре всего компонента — объект заказа. Центральный стержень, на который нанизан весь компонент. Примерно как в детской игрушке, где палка такая и на нее кольца разного диаметра одеваются. Объект заказа доступен из любого места компонента. Вам остается понять какая информация для обработки заказа вам нужна, сформировать форму где бы покупатель мог указать эту информацию, а потом все данные формы запихнуть в объект заказа и сохранить его.

Минус. Самый главный минус тут — сознательное нарушение паттерна MVC, то есть у программиста появляется теоретическая возможность изменить заказ в шаблоне, вызвать не предназначенные для шаблона методы. Это недостаток, не буду даже спорить. Однако же на мой взгляд этот недостаток компенсируется заметным сокращением кода по перегону всех данных из объекта заказа в массив $arResult, и в целом большей прозрачностью всей процедуры заказа. Что тут важнее решать безусловно вам 🙂 Я думаю стоит ориентироваться на компетентность команды разработки и в целом на сложность процедуры заказа. Чем ниже уровень разработчиков и чем больше объем кода, тем выше риск неверного использования объекта заказа в шаблоне.

Остались еще некоторые нюансы, на которые мне уже просто не хватает места. И так статья огромная получается. Но все основные аспекты по заказу я осветил достаточно полно. Если у вас остались вопросы, то спрашивайте в комментариях, и лучше прямо тут под статьей. Чтобы все могли ознакомиться с дискуссией.

Если статья вам понравилась и вы что-то новое для себя узнали, то скажите спасибо в комментариях 🙂 Это дает мотивацию писать чаще. Или скиньте ссылку на статью знакомым, тоже хорошо 🙂


добавил Шубин Александр 14 Ноябрь, 2017
Рубрика: bitrix, Уроки


35 комментариев

  • Татьяна:

    Александр, сегодня как раз приступила к интеграции верстки в оформление заказа, и ваша статья оказалась как-никак кстати. Спасибо Вам, все понятно и доступно описано.
    У меня только возникла проблема с расширением стандартных классов новыми методами:
    вместо $basket = static::createBasketObject(); в create() сейчас используется $basket = new static();.
    Объект Registry который хранит название классов не наблюдаю.

    • Рад что понравилось 🙂
      Объект Registry есть только в последних версиях. Ну и еще как вариант вы не тот исходник смотрите.
      Проверьте есть у вас файл bitrix\modules\sale\lib\registry.php или нет. Именно в нем класс регистра лежит.
      Если файл есть, то попробуйте код \Bitrix\Sale\Registry::getInstance() вызвать и там подмену какого нибудь класса сделать. Если ошибок не будет, то значит все работает.

      Можете в $arResult собрать данные, как обычно. Расширение объектов это просто доп. опция, удобнее конечно, но можно и без него

  • я вот как раз сижу думаю, как в таком самописном заказе применять купоны скидок магазина

    • Купоны не в заказе применяются, а в корзине. На момент отображения заказа все уже должно быть рассчитанно с учетом скидок от купонов

      • Виталий Вайти:

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

        • Если нужно в заказе, то ничего не мешает сделать. Для применения купонов есть отдельный класс
          DiscountCouponsManager::init();

          Проверяем купон
          $arData = DiscountCouponsManager::getData($couponCode);

          Если у купона в $arData нормальный статус, то добавляем
          DiscountCouponsManager::add($couponCode);

  • codeblog:

    Большое спасибо за столь подробный рассказ!)
    Использую стандартный компонент order.ajax, но пишу свой шаблон. И, все-равно, Ваша статья помогла структурировать понимание работы стандартного компонента.

  • Илья:

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

    • Для начала советую сделать законченный тестовый вариант и посмотреть что этот вариант хороший получается. Вдруг не понравится собственная реализация 🙂

  • Игорь:

    А если получать заказ через публичный метод getOrder(), а свойство $order сделать приватным, разве не получиться заблокировать изменение заказа в шаблоне?

    • А что будет возвращать этот метод? Тоже объект заказа? Если его как то защитить при передаче в шаблон, то может и получится. Но так на вскидку не вижу как его защитить от сохранения

  • bob:

    Классный разбор. А можно архив с исходниками прикрепить к статье?

  • Иван:

    Добрый день!

    Спасибо вам за полезную статью!

    Вопрос: не могли бы вы выложить архив вашего компонента, чтобы можно было его скачать и более наглядно разобраться в нем.
    Думаю, это многим будет полезно.

    Заранее спасибо!

  • У меня нет готовых к выкладке исходников. Есть работающие компоненты, для живых проектов, но их я не могу выложить, потому что там уже специфичный код проекта. Раз так много просьб про исходники, то решил сделать репозиторий на гитхабе с компонентом. Попробую причесать немного, демку шаблона сделать. Я думаю в течение недели-двух. Пока что ничего не могу предложить 🙂

  • Сергей:

    Добрый день, подскажите реализацию вывода цены вида:
    есть цена 1 и цена 2 при наличии купона (как вывести цену 2, если купон вносится в корзине и там считается).

  • Алексей:

    Нужно очень!) Скинь пожалуйста

  • Bix:

    Согласен очень бы пригодилась ссылка на гите. Будем очень благодарны!

  • Ready:

    Супер! Большое спасибо за статью, очень полезная!

  • Марк:

    Большое спасибо! Очень полезный материал, и главное, доходчиво изложенный!
    Столкнулся с такой штукой: если пользователь не авторизован и накидывает в корзину товары, а затем авторизуется, то корзина пуста. Исправил это, заменив $USER->getId на \Bitrix\Sale\Fuser::getId(). Вдруг кому пригодится.

  • Антон:

    Добрый день.

    Спасибо за отличную статью. Очень был бы полезен репозиторий с исходниками.

    Подскажите пожалуйста, как при данном подходе реализовать оформление заказа для неавторизированного/незарегистрированного пользователя. Этот вопрос в статье никак не затрагивается.

  • Вячеслав:

    Добрый день!
    Спасибо огромное за статью.

    Подскажите, пожалуйста, как добавить в заказ доп. услуги доставки (extra services)? Получилось вывести их на страницу, вроде есть метод setExtraServices, но то ли я его не правильно использую, то ли нужно прописать что-то еще, в заказ добавляется все, кроме этих несчастных услуг.

    • Денис Фролов:

      Все как обычно у битрикса, голова не знает, что делает жопа, жопа не знает, что делает голова.. а в итоге мы получаем вот такие замечательные конструкции:

      if($objOrderDeliveryOptionExtraService = $objDeliveryService->getExtraServices()->getItemByCode(«ORDER_DELIVERY_OPTION»)){
      $objOrderDeliveryOptionExtraService->setValue(«Y»);
      foreach($objDeliveryService->getExtraServices()->getItems() as $extraServiceId => $extraServiceValue){
      $arExtraServices[$extraServiceId] = $extraServiceValue->getValue();
      }
      $shipment->setExtraServices($arExtraServices);
      }

      Передавать в setExtraServices, надо массив вида [extra_service_id] => [value]. И да, Sale\Delivery\ExtraServices\Base — отдавать ID не умеет, так что вот так)

      • Спасибо за комментарий 🙂
        Я доп. услугами не пользовался, а лезть разбираться чтобы ответить все никак не мог собраться)

  • Nikolays93:

    В рекомендуемой вами статье увидел вызов метода doFinalAction
    «`
    $order->doFinalAction(true);
    $result = $order->save();
    $orderId = $order->getId();
    «`
    Для чего он нужен? И можно ли/нужно ли использовать в вашем коде?

    Статья супер! Большое спасибо.

  • Алексей:

    Очень интересная статья, но полностью собрать в компонент так и не получилось =(

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

  • wowkos:

    Ну что с ссылкой на ГИТ?

  • Выложил готовую реализацию компонента описанного в статье https://verstaem.com/bitrix/opensource-order/

  • Михаил:

    Отличная статья! Спасибо за Ваши труды

  • Grigory:

    Отличное изложение. Спасибо за ваш труд.

  • Спасибо, было интересно почитать как разрабатываются свои компоненты.
    Стоит задача, вернее я так решила ее решать: нужно подключить аналитику электронной торговли. В стандартной коробочной версии это уже есть, но для проекта покупался кастомизированный шаблон сайта, где нет аналитики.
    Сейчас занимаюсь переносом настроек аналитики, все лежит в 3-х компонентах
    catalog
    sale.basket.basket
    sale.order.ajax

    И по 3 файла в каждом компоненте
    .parameters.php
    template.php
    lang/ru/.parameters.php

    Каталог и Корзину настроила, застряла с оформлением заказа через sale.order.ajax
    USE_ENHANCED_ECOMMERCE
    DATA_LAYER_NAME
    BRAND_PROPERTY

    добавила код из шаблона .default в файл .parameters.php
    и
    lang/ru/.parameters.php
    объявление параметров в
    template.php

    Но пока в Метрике никак не появляется содержимое заказов. Может вы подскажите, в какую сторону мне еще посмотреть?

    • Нужно суть происходящего понимать. Вся электронная коммерция это запись в js переменную window.dataLayer. И надо смотреть как именно туда запись происходит сейчас. Какими скриптами. В каких случаях срабатывает. Можно по имени переменной поискать.

      • Спасибо, Александр!

        Они полностью переписали sale.order.ajax, и соответственно ничего не передавалось в window.dataLayer. Это уже не мое направление, я все-таки не программист, а seo-специалист

  • Александр:

    Парни, респект и уважуха!
    За такой детальный расклад.

Добавить комментарий для Диана Ашумова Отменить ответ

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