Битрикс sale order ajax вывод ошибок

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

В файле template.php блоки заказа отмечены комментариями:

BASKET ITEMS BLOCK - корзина заказа - таблица товаров
REGION BLOCK - выбор типа плательщика и ввод местоположения - города покупателя
DELIVERY BLOCK - выбор службы доставки
PAY SYSTEMS BLOCK - выбор способа оплаты
BUYER PROPS BLOCK - форма с полями для ввода данных покупателя
ORDER SAVE BLOCK - итог и кнопка подтверждения заказа

Чтобы все блоки были активны, а не только первый комментируем вторую строчку в данном блоке

initFirstSection: function()
{
	var firstSection = this.orderBlockNode.querySelector('.bx-soa-section.bx-active');
	//BX.addClass(firstSection, 'bx-selected');
	this.activeSectionId = firstSection.id;
},

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

/*if (this.result.DELIVERY.length > 0)
{
	BX.addClass(this.deliveryBlockNode, 'bx-active');
	this.deliveryBlockNode.removeAttribute('style');
}
else
{
	BX.removeClass(this.deliveryBlockNode, 'bx-active');
	this.deliveryBlockNode.style.display = 'none';
}*/

Убираем сокрытие блоков при авторизации (при выключенной в настройках опции «регистрировать вместе с оформлением заказа»).

if (this.result.SHOW_AUTH && section.id != this.authBlockNode.id && section.id != this.basketBlockNode.id)
	section.style.display = 'none';
else if (section.id != this.pickUpBlockNode.id)
	section.style.display = '';

Открыть все блоки и убрать лишнее

Чтобы раскрыть все скрытые блоки можно воспользоваться следующими методами (лично использовал на версиях до 20):

Ищем строку var active = section.id == this.activeSectionId и меняем ее на

var active = true

Далее отключаем реагирование на клик по заголовку блока

BX.unbindAll(titleNode);

if (this.result.SHOW_AUTH)
{
	BX.bind(titleNode, 'click', BX.delegate(function(){
		this.animateScrollTo(this.authBlockNode);
		this.addAnimationEffect(this.authBlockNode, 'bx-step-good');
	}, this));
}
else
{
	BX.bind(titleNode, 'click', BX.proxy(this.showByClick, this));
	editButton = titleNode.querySelector('.bx-soa-editstep');
	editButton && BX.bind(editButton, 'click', BX.proxy(this.showByClick, this));
}

Чтобы всегда были открыты Регион и Пользователь

if (this.activeSectionId !== this.regionBlockNode.id)
	this.editFadeRegionContent(this.regionBlockNode.querySelector('.bx-soa-section-content'));

if (this.activeSectionId != this.propsBlockNode.id)
	this.editFadePropsContent(this.propsBlockNode.querySelector('.bx-soa-section-content'));

Чтобы убрать кнопки Далее/Назад

node.appendChild(
	BX.create('DIV', {
		props: {className: 'row bx-soa-more'},
		children: [
			BX.create('DIV', {
				props: {className: 'bx-soa-more-btn col-xs-12'},
				children: buttons
			})
		]
	})
);

Чтобы убрать ссылки «изменить» у всех блоков в editOrder (~2222 стр.)

var editSteps = this.orderBlockNode.querySelectorAll('.bx-soa-editstep'), i;
 for (i in editSteps) {
	if (editSteps.hasOwnProperty(i)) {
	   BX.remove(editSteps[i]);
	}
 }

Чтобы скрыть уведомление о том, что данный заполнены автоматически добавляем в конец файла style.css

.alert.alert-warning{display:none;}

Определение местоположения пользователя в автоматическом режиме

use BitrixMainEventManager; 
$eventManager = EventManager::getInstance();
$eventManager->addEventHandler("sale", "OnSaleComponentOrderProperties", Array("Example", "OnSaleComponentOrderProperties"));

class Example
{
   /**
   * У меня по условию задачи известны ID и NAME местоположения
   */
   static $curCityId = XX;  // числовое значение идентификатора местоположения
   static $curCityName = 'Название города';
   
   /**
   * ID свойств заказа   
   */
   const PROP_LOCATION = 6; 
   const PROP_ZIP = 4; 
   const PROP_LOCATION_NAME = 5;


   static function OnSaleComponentOrderProperties(&$arFields)
   {
      $rsLocaction = CSaleLocation::GetLocationZIP(self::$curCityId); 
      $arLocation = $rsLocaction->Fetch(); 
      $arFields['ORDER_PROP'][self::PROP_ZIP] = $arLocation['ZIP'];
      $arFields['ORDER_PROP'][self::PROP_LOCATION_NAME] = self::$curCityName;
      $arFields['ORDER_PROP'][self::PROP_LOCATION] = CSaleLocation::getLocationCODEbyID(self::$curCityId);
   }
}

А вот такая модификация позволяет определить местоположение только по названию города:

class Example {
   static function OnSaleComponentOrderProperties(&$arFields)
   {
      static $curCityName = 'Название города';
      const PROP_LOCATION = 6;  // - Идентификатор свойства с местоположением
      static function OnSaleComponentOrderProperties(&$arFields)
      {
         $res = BitrixSaleLocationLocationTable::getList(array(
         'filter' => array('=NAME.NAME' => self::$curCityName, '=NAME.LANGUAGE_ID' => LANGUAGE_ID),
         'select' => array('CODE' => 'CODE', 'NAME_RU' => 'NAME.NAME', 'TYPE_CODE' => 'TYPE.CODE') //'*', 
         ));
         while($item = $res->fetch())
         {            
            $code = $item["CODE"];
         }
         $arFields['ORDER_PROP'][self::PROP_LOCATION] = $code;
      }
   }
}

Скрыть какое-то свойство

Если необходимо скрыть какое-то свойство, например, свойство индекс — задать значение по умолчанию и не показывать пользователям это поле, то можно внести корректировку в JS. В функции getPropertyRowNode после switch (propertyType) добавляем скрытие данного свойства:

if(property.getId()==6){// идентификатор скрываемого свойства
   var addressInput=propsItemNode.querySelector('textarea');
   propsItemNode.style='display:none;';
   addressInput.value='нужное значение';
}

Скрываем сообщение «Вы заказывали в нашем интернет-магазине, поэтому мы заполнили все данные автоматически»

Идем в функцию checkNotifications на ~750 стр и находим код

informer.appendChild(
	BX.create('DIV', {
		props: {className: 'row'},
		children: [
			BX.create('DIV', {
				props: {className: 'col-xs-12'},
				style: {position: 'relative', paddingLeft: '48px'},
				children: [
					BX.create('DIV', {props: {className: 'icon-' + className}}),
					BX.create('DIV', {html: text})
				]
			})
		]
	})
);
BX.addClass(informer, 'alert alert-' + className);

И обрамляем данный код доп. проверкой

if(this.params.MESS_SUCCESS_PRELOAD_TEXT.indexOf('Вы заказывали в нашем инте') === false) {
	//Код выше ставим сюда
}

Скрываем сообщение «Выберите свой город в списке. Если вы не нашли свой город, выберите «другое местоположение», а город впишите в поле «Город»

Идем в функцию getDeliveryLocationInput и комментируем код:

/*
if (location && location[0])
{
	node.appendChild(
		BX.create('DIV', {
			props: {className: 'bx-soa-reference'},
			html: this.params.MESS_REGION_REFERENCE
		})
	);
}
*/

Или с помощью стилей скрываем класс bx-soa-reference

#bx-soa-region .bx_soa_location .bx-soa-reference{display: none}

Исключить из показа нулевой цены за доставку

В функции getDeliveryPriceNodes: function(delivery) в блоке «else» заменяем. Вместо:

priceNodesArray = [delivery.PRICE_FORMATED];

пишем:

if(delivery.PRICE>0) priceNodesArray = [delivery.PRICE_FORMATED];

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

Дальше нужно скрыть нули в списке служб доставки. Для этого в функции createDeliveryItem: function(item) делаем строгую проверку на ноль. Вместо:

if (item.PRICE >= 0 || typeof item.DELIVERY_DISCOUNT_PRICE !== 'undefined')

пишем:

if (item.PRICE > 0 || typeof item.DELIVERY_DISCOUNT_PRICE !== 'undefined')

А также вместо:

else if (deliveryCached && (deliveryCached.PRICE >= 0 || typeof deliveryCached.DELIVERY_DISCOUNT_PRICE !== 'undefined'))

пишем:

else if (deliveryCached && (deliveryCached.PRICE > 0 || typeof deliveryCached.DELIVERY_DISCOUNT_PRICE !== 'undefined'))

И последним нужно скрыть нулевую доставку из итоговых сумм. Для этого в функции editTotalBlock: function() также ставим строгую проверку на ноль. Вместо

if (parseFloat(total.DELIVERY_PRICE) >= 0 && this.result.DELIVERY.length)

пишем:

if (parseFloat(total.DELIVERY_PRICE) > 0 && this.result.DELIVERY.length)

В результате нулевая доставка не будет показана пользователю.

Убираем поле «Адрес доставки» из вывода блока «Пользователь»

Описанные у моих коллег способы в моем случае не сработали. Решил пойти некрасивым, но действенным способом

Идем в функцию editPropsItemsи находим код:

if (
	this.deliveryLocationInfo.loc == property.getId()
	|| this.deliveryLocationInfo.zip == property.getId()
	|| this.deliveryLocationInfo.city == property.getId()
)

Меняем его на:

if (
	this.deliveryLocationInfo.loc == property.getId()
	|| this.deliveryLocationInfo.zip == property.getId()
	|| this.deliveryLocationInfo.city == property.getId()
	|| property.getName()=='Адрес доставки (улица, дом, квартира)' //где property.getName() приравниваем к названию поля адреса в вашей системе
)

В базовом стандартном шаблоне поле индекс достаточно легко перенести. Давайте перенесем поле Индекс из блока местоположений в блок пользовательских свойств

Находим функцию getDeliveryLocationInput (~4451 стр.) и комментируем код this.getZipLocationInput(node);

Идем в функцию editPropsItems (~6728 стр.) и перед propsNode.appendChild(propsItemsContainer) добавляем код this.getZipLocationInput(propsItemsContainer);.

По идее этого должно хватить. Но если все равно не получается, то перейдите по адресу /bitrix/admin/sale_order_props.php?lang=ru и в поле Индекс переведите его а группу свойств Личные данные

Вывод поля «Адрес доставки» в блоке «Доставка»

Идем в функцию editDeliveryInfo и в самый конец добавляем код:

var deliveryItemsContainer = BX.create('DIV', {props: {className: 'col-sm-12 bx-soa-delivery'}}),
	group, property, groupIterator = this.propertyCollection.getGroupIterator(), propsIterator;

if (!deliveryItemsContainer)
	deliveryItemsContainer = this.propsBlockNode.querySelector('.col-sm-12.bx-soa-delivery');

while (group = groupIterator())
{
	propsIterator =  group.getIterator();
	while (property = propsIterator())
	{
		if (property.getName()=='Адрес доставки (улица, дом, квартира)') { //Если свойство совпадает с названием поля адреса в вашей системе

			this.getPropertyRowNode(property, deliveryItemsContainer, false); //вставляем свойство в подготовленный контейнер
			deliveryNode.appendChild(deliveryItemsContainer); //контейнер вместе со свойством в нём добавляем в конце блока с описанием (deliveryInfoContainer)

		}
	}
}

Переносим поле «Адрес доставки» в отдельный новый(!) блок

Данная необходимость возникает, например, в шаблонах Аспро, где поле «Адрес доставки» в блоке с выбором доставок выглядит чужеродно и некрасиво

В шаблоне template.php компонента sale.order.ajax добавляем новый блок в наиболее удобное нам место

<!--	ADRESS BLOCK	-->
<div id="bx-soa-adress-dostavki" data-visited="false" class="bx-soa-section bx-active">
	<div class="bx-soa-section-title-container">
		<h2 class="bx-soa-section-title col-sm-9">
			<span class="bx-soa-section-title-count"></span>Данные для доставки
		</h2>
	</div>
	<div class="bx-soa-section-content container-fluid"></div>
</div>

Идем в файл order_ajax.js и в функции init на ~77 стр добавляем вызов вновь добавленного блока

this.orderAdresBlockNode = BX('bx-soa-adress-dostavki');

Скрываем вывод поля «Адрес доставки» как показано выше

if (
	this.deliveryLocationInfo.loc == property.getId()
	|| this.deliveryLocationInfo.zip == property.getId()
	|| this.deliveryLocationInfo.city == property.getId()
	|| property.getName()=='Адрес доставки' //где property.getName() приравниваем к названию поля адреса в вашей системе
)

Реализуем вывод нового поля в дополнительном блоке. В стандартном случае придется учесть, что поле «Адрес доставки» генерируется отдельно для каждого из типов плательщиков, имеет разный id свойства, а сам наш блок надо скрывать, если выбраны оплаты и доставки при которых поле «Адрес доставки» не выводится. Поэтому код будет таким.

Идем в функцию editDeliveryInfo (любую аналогичную или свою) и после строки deliveryNode.appendChild(deliveryInfoContainer) или в конце функции добавляем:

var deliveryItemsContainer = BX.create('DIV', {props: {className: 'col-sm-12 bx-soa-delivery'}}),
	group, property, groupIterator = this.propertyCollection.getGroupIterator(), propsIterator;

if (!deliveryItemsContainer)
	deliveryItemsContainer = this.propsBlockNode.querySelector('.col-sm-12.bx-soa-delivery');

while (group = groupIterator())
{
	propsIterator =  group.getIterator();
	while (property = propsIterator())
	{

		var personType = this.getSelectedPersonType();

		this.orderAdresBlockNode.querySelector('.bx-soa-section-content').innerHTML = '';

		if (property.getName()=='Адрес доставки' && personType.ID === '1' && property.getId() == '7') {
			this.getPropertyRowNode(property, deliveryItemsContainer, false); //вставляем свойство в подготовленный контейнер
			this.orderAdresBlockNode.querySelector('.bx-soa-section-content').appendChild(deliveryItemsContainer); 

			this.orderAdresBlockNode.classList.remove('hidden');
		}else if (property.getName()=='Адрес доставки' && personType.ID === '2' && property.getId() == '19') { 
			this.getPropertyRowNode(property, deliveryItemsContainer, false); //вставляем свойство в подготовленный контейнер
			this.orderAdresBlockNode.querySelector('.bx-soa-section-content').appendChild(deliveryItemsContainer);

			this.orderAdresBlockNode.classList.remove('hidden');
		}else{
			this.orderAdresBlockNode.classList.add('hidden');
		}

	}
}

Код не самый красивый и правильный, но работает

Переносим поле «Комментарии к заказу» в конец формы

Идем в функцию editActivePropsBlock и комментируем строчку:

this.editPropsComment(propsNode);

Для вывода поля ищем в шаблоне функцию последнего блока. В моем случае это вывод состава заказа. Ищем функцию editBasketItems и в самый конец дописываем:

this.editPropsComment(basketItemsNode);

Переносим поле «Местоположения» в блок пользовательских свойств

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

if (
	this.deliveryLocationInfo.loc == property.getId()
	|| this.deliveryLocationInfo.zip == property.getId()
	|| this.deliveryLocationInfo.city == property.getId()
)
	continue;

Запрет Битриксу выбирать доставку по умолчанию

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

if (isset($arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY'])
 && $arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY']!='') {
    $arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY'] = '';}

В данном случае блок с доставками всегда будет открыт, и пользователь сразу обратит внимание на необходимость заполнения полей. Но! Если пользователь в кабинете удаляет профиль, поле с местоположением будет у него незаполнено, и после его заполнения блок с доставками автоматически закроется без возможности его отредактировать (пропадет кнопка «Изменить»). Это очень трудно пофиксить, чтобы не посыпалось всё остальное, поэтому мы приняли решение убрать возможность редактирования профилей в кабинете пользователя (делается снятием галочки в настройках компонента личного кабинета)

На данный момент у меня всё. Конечно, этот код был написан для конкретного проекта и с определенными допущениями. Но надеюсь, что данная заметка оказалась вам полезной и наведет вас на путь истинный при решении вашей задачи. Ибо документации по методам класса OrderAjaxComponent нет и не будет. Если Вам есть что добавить или поправить — буду рада комментариям.

order_ajax_ext.js

Создаём файл order_ajax_ext.js в папке с шаблоном компонента sale.order.ajax (там же, где лежит файл order_ajax.js) с содержимым:

(function () {
    'use strict'; 
 
    var initParent = BX.Sale.OrderAjaxComponent.init,
        getBlockFooterParent = BX.Sale.OrderAjaxComponent.getBlockFooter,
        editOrderParent = BX.Sale.OrderAjaxComponent.editOrder
        ;
 
    BX.namespace('BX.Sale.OrderAjaxComponentExt');    
 
    BX.Sale.OrderAjaxComponentExt = BX.Sale.OrderAjaxComponent;
 
	//Пример перехвата стандартной функции
    BX.Sale.OrderAjaxComponentExt.init = function (parameters) {
        initParent.apply(this, arguments);
 
        var editSteps = this.orderBlockNode.querySelectorAll('.bx-soa-editstep'), i;
        for (i in editSteps) {
            if (editSteps.hasOwnProperty(i)) {
                BX.remove(editSteps[i]);
            }
        }
 
    }; 
})();

В отдельных переменных определяем функции-методы родительского BX.Sale.OrderAjaxComponent, чтобы их можно было вызвать в дочерних функциях и не получить ошибку Maximum call stack size exceeded.

Копируем ссылку с BX.Sale.OrderAjaxComponent в BX.Sale.OrderAjaxComponentExt.

В методе BX.Sale.OrderAjaxComponentExt.init вызываем родительский init, следом прибиваем ссылки «изменить» у всех блоков. Они нам не нужны.

В методе BX.Sale.OrderAjaxComponentExt.getBlockFooter прибиваем кнопки «Назад» и «Вперед» у блоков. Они нам тоже не понадобятся — все блоки у нас развёрнуты.

В методе BX.Sale.OrderAjaxComponentExt.editOrder ненужным блокам-секциям добавляем css-класс bx-soa-section-hide. По нему мы и будем скрывать ненужные блоки. А так же в этом методе раскрываем только нужные нам блоки: «Покупатель» и «Товары в заказе».

Метод BX.Sale.OrderAjaxComponentExt.initFirstSection оставляем просто пустым. Если этого не сделать, то у анонимов при попытке оформления будет вываливаться эксепшен, по поводу отсутствия необходимых обязательных полей.

Идем дальше.

В файле template.php нашего шаблона нового оформления добавляем подключение нашего скрипта order_ajax_ext.js

После строчки:

$this->addExternalJs($templateFolder.'/order_ajax.js');

добавляем:

$this->addExternalJs($templateFolder.'/order_ajax_ext.js');

А так же в файле template.php меняем все вызовы BX.Sale.OrderAjaxComponent на BX.Sale.OrderAjaxComponentExt

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

.bx-soa-section-hide {
    display: none;
}

showValidationResult: function(inputs, errors) — функция в которой полям с ошибкой добавляется класс hasError, который помечает ошибкой(в стандартном варианте добавляет обводку красным).

showErrorTooltip: function(tooltipId, targetNode, text) — функция в которой добавляются тултипы для полей с ошибкой.

showError: function(node, msg, border) — функция в которой выводятся ошибки в «групповой контейнер»

refreshOrder: function(result) — функция в которой происходит разбор ошибок, которые приходят от сервера. Там есть ветка result.error

Первые 3 функции отвечают за валидацию на форме без перезагрузки, а четвёртая за обработку результатов от сервера.

Выполнение кода после перезагрузки страницы

Бывает, что нужно регулярно выполнить код после перезагрузки страницы (изменения опций заказа). Например, требуется перерисовать селект. Это просто. Откройте файл order_ajax.js и в самый конец допишите:

(function ($) {
	$(function() {
		BX.addCustomEvent('onAjaxSuccess', function(){
			$(function() {
				$('select').ikSelect({
					autoWidth:false,
					ddFullWidth:false
				});
			});
		});

		$('select').ikSelect({
			autoWidth:false,
			ddFullWidth:false
		});

		window.onresize = function() {
			$('select').ikSelect('redraw');
		}
	});
})(jQuery);

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

top.$

Для стилизации селектов, которые по умолчанию выглядят ужасно лучше всего использовать замечательную библиотеку ikSelect

Программная смена города в sale.order.ajax на javascript

Часто, по техническому заданию, требуется программная смена города на javascript с ajax перезагрузкой страницы и перерасчетом формы. Покажу как сделать это.

Будем считать, что у вас уже есть code локации из местоположений Битрикс. Тогда в части кода где необходимо произвести смену локации пишем:

//Данный код будет работать не только внутри sale.order.ajax, но и во внешних скриптах, поскольку мы обращается к его функциям через пространство имен BX.Sale.OrderAjaxComponent
var code = '0000000001';//Ваш код локации. В данном случае это Белоруссия

$('.dropdown-field').val(code);//Обновляем код в скрытом инпуте
//$('.bx-ui-sls-fake').val(code);

BX.Sale.OrderAjaxComponent.params.newCity=code; //Записываем в параметры компонента необходимый нам город
BX.Sale.OrderAjaxComponent.sendRequest();//Обновляем форму

Здесь мы заполнили скрытый input нужным нам кодом и записали его в переменную, которой воспользуемся при перестроении формы. Идем в функцию prepareLocations (~1557 стр.).Находим код:

temporaryLocations.push({

И выше него пишем:

//Делаем двойную проверку.
if(typeof this.params!='undefined'){//В первом случае на то, что параметры вообще установлены, поскольку код выполняется первый раз до их инициализации
	if(typeof this.params.newCity!='undefined'){//Вторая проверка - установлена ли наша переменная
		locations[i].lastValue = this.params.newCity;//Если переменная установлена, то подставляем ее в локацию
		delete this.params.newCity;//Обнуляем нашу переменную
	}
}

В итоге у меня получилось так:

for (k in output)
{
	if (output.hasOwnProperty(k))
	{
		//Делаем двойную проверку.
		if(typeof this.params!='undefined'){//В первом случае на то, что параметры вообще установлены, поскольку код выполняется первый раз до их инициализации
			if(typeof this.params.newCity!='undefined'){//Вторая проверка - установлена ли наша переменная
				locations[i].lastValue = this.params.newCity;//Если переменная установлена, то подставляем ее в локацию
				delete this.params.newCity;//Обнуляем нашу переменную
			}
		}


		temporaryLocations.push({
			output: BX.processHTML(output[k], false),
			showAlt: locations[i].showAlt,
			lastValue: locations[i].lastValue,
			coordinates: locations[i].coordinates || false
		});
	}
}

Расчет стоимости доставки для всех служб доставки

Будем полагать, что компонент sale.order.ajax вынесен у вас в отдельную папку

Тогда идем в файл /local/components/YOUR_NAMESPACE/sale.order.ajax/class.php и находим функцию protected function calculateDeliveries(Order $order) (~4289 стр)

Находим условие if ((int)$shipment->getDeliveryId() === $deliveryId) и в области else сразу после кода:

$mustBeCalculated = $this->arParams['DELIVERY_NO_AJAX'] === 'Y'
						|| ($this->arParams['DELIVERY_NO_AJAX'] === 'H' && $deliveryObj->isCalculatePriceImmediately());

пишем:

$mustBeCalculated = true;
$calcResult = $deliveryObj->calculate($shipment);
$calcOrder = $order;

Теперь после обращения к серверу в наш order_ajax.js приходят службы доставки с рассчитанными стоимостями. Остается их только обработать и вывести.

В скрипте находим функциюcreateDeliveryItem: function(item) и работаем с параметром item.PRICE или item.PRICE_FORMATED и выводим его куда нужно.

Получение стоимости доставки для продукта после применения скидок, правил корзины и …

/**
 * Получение стоимости доставки для продукта после применения скидок, правил корзины и ...
 *
 * @param string|int $bitrixProductId Id битриксового продукта
 * @param string     $siteId          Id битриксового сайта, например "s1"
 * @param string|int $userId          Id битриксового пользователя
 * @param string|int $personTypeId    Id битриксового "Тип плательщика" /bitrix/admin/sale_person_type.php?lang=ru
 * @param string|int $deliveryId      Id битриксового "Службы доставки" /bitrix/admin/sale_delivery_service_list.php?lang=ru&filter_group=0
 * @param string|int $paySystemId     Id битриксового "Платежные системы" /bitrix/admin/sale_pay_system.php?lang=ru
 * @param array      $userCityId      Id битриксового города ("куда доставлять")
 *
 * @return null|float null - не удалось получить; float - стоимость (может быть 0 (после применения скидок на доставку))
 *
 * @throws BitrixMainArgumentException
 * @throws BitrixMainArgumentNullException
 * @throws BitrixMainArgumentOutOfRangeException
 * @throws BitrixMainArgumentTypeException
 * @throws BitrixMainLoaderException
 * @throws BitrixMainNotImplementedException
 * @throws BitrixMainNotSupportedException
 * @throws BitrixMainObjectException
 * @throws BitrixMainObjectNotFoundException
 * @throws BitrixMainSystemException
 */
function getDeliveryPriceForProduct($bitrixProductId, $siteId, $userId, $personTypeId, $deliveryId, $paySystemId, $userCityId)
{
    $result = null;

    BitrixMainLoader::includeModule('catalog');
    BitrixMainLoader::includeModule('sale');

    $products = array(
        array(
            'PRODUCT_ID' => $bitrixProductId,
            'QUANTITY'   => 1,
            // 'NAME'       => 'Товар 1', 
            // 'PRICE' => 500,
            // 'CURRENCY' => 'RUB',
        ),
    );
    /** @var BitrixSaleBasket $basket */
    $basket = BitrixSaleBasket::create($siteId);
    foreach ($products as $product) {
        $item = $basket->createItem("catalog", $product["PRODUCT_ID"]);
        unset($product["PRODUCT_ID"]);
        $item->setFields($product);
    }

    /** @var BitrixSaleOrder $order */
    $order = BitrixSaleOrder::create($siteId, $userId);
    $order->setPersonTypeId($personTypeId);
    $order->setBasket($basket);

    /** @var BitrixSalePropertyValueCollection $orderProperties */
    $orderProperties = $order->getPropertyCollection();
    /** @var BitrixSalePropertyValue $orderDeliveryLocation */
    $orderDeliveryLocation = $orderProperties->getDeliveryLocation();
    $orderDeliveryLocation->setValue($userCityId); // В какой город "доставляем" (куда доставлять).

    /** @var BitrixSaleShipmentCollection $shipmentCollection */
    $shipmentCollection = $order->getShipmentCollection();

    $delivery = BitrixSaleDeliveryServicesManager::getObjectById($deliveryId);
    /** @var BitrixSaleShipment $shipment */
    $shipment = $shipmentCollection->createItem($delivery);

    /** @var BitrixSaleShipmentItemCollection $shipmentItemCollection */
    $shipmentItemCollection = $shipment->getShipmentItemCollection();
    /** @var BitrixSaleBasketItem $basketItem */
    foreach ($basket as $basketItem) {
        $item = $shipmentItemCollection->createItem($basketItem);
        $item->setQuantity($basketItem->getQuantity());
    }

    /** @var BitrixSalePaymentCollection $paymentCollection */
    $paymentCollection = $order->getPaymentCollection();
    /** @var BitrixSalePayment $payment */
    $payment = $paymentCollection->createItem(
        BitrixSalePaySystemManager::getObjectById($paySystemId)
    );
    $payment->setField("SUM", $order->getPrice());
    $payment->setField("CURRENCY", $order->getCurrency());

    // $result = $order->save(); // НЕ сохраняем заказ в битриксе - нам нужны только применённые "скидки" и "правила корзины" на заказ.
    // if (!$result->isSuccess()) {
    //     //$result->getErrors();
    // }

    $deliveryPrice = $order->getDeliveryPrice();
    if ($deliveryPrice === '') {
        $deliveryPrice = null;
    }
    $result = $deliveryPrice;

    return $result;
}

// Использование
$deliveryPriceForProductCourier = getDeliveryPriceForProduct(
    $bitrixProductId,
    SITE_ID,
    $USER->GetID(),
    '1', // Юридическое лицо  /bitrix/admin/sale_person_type.php?lang=ru
    '1386', // Доставка курьером до дома (в случае наличия "профиля" - указываем его id)  /bitrix/admin/sale_delivery_service_edit.php?lang=ru
    '37', // Наличными или картой при получении  /bitrix/admin/sale_pay_system.php?lang=ru
    $userCity['ID'] // Город пользователя
);

Делаем бесплатную доставку по России с условием

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

В моем случае в свойствах заказа есть отдельное поле «Страна», поэтому задача упрощается и весь код можно свести к паре проверок

<?php
use BitrixMainEventManager;
use BitrixMainEvent;
use BitrixMainEventResult;

// Получить цену товаров при создании корзины
EventManager::getInstance()->addEventHandler(
    'sale',
    'OnSaleComponentOrderCreated',
    'OnSaleComponentOrderCreated'
);

function OnSaleComponentOrderCreated($order, &$arUserResult, $request, &$arParams, &$arResult, &$arDeliveryServiceAll, &$arPaySystemServiceAll)
{
	//Записываем стоимость товаров
    $_SESSION['ORDER_BASKET_PRICE'] = $order->getBasket()->getPrice();
	
	//Записываем код текущей страны из свойства заказа с id = 24
    $_SESSION['ORDER_LOCATION'] = $arUserResult['ORDER_PROP']['24'];
}

//Изменяем стоимость доставки во время каждого расчета стоимости доставки
EventManager::getInstance()->addEventHandler(
    'sale',
    'onSaleDeliveryServiceCalculate',
    'onSaleDeliveryServiceCalculate'
);

function onSaleDeliveryServiceCalculate(BitrixMainEvent $event)
{

    $baseResult = $event->getParameter('RESULT');
    $shipment = $event->getParameter('SHIPMENT');

    //Если установлена цена, страна Россия и служба доставки не EMS
    if(isset($_SESSION['ORDER_BASKET_PRICE'])
        && $_SESSION['ORDER_LOCATION']=='0000073738'
        && $event->getParameter('DELIVERY_ID') != 45
        && $event->getParameter('DELIVERY_ID') != 31
        && $event->getParameter('DELIVERY_ID') != 33){

		//Смотрим, если настройки доставки мы уже в текущем сеансе получали, то пропускаем блок
        if(!$_SESSION['DELIVERY_PRICE_WHILE_RUSSIA']){
            //Получаем настройки стоимости товара при котором доставка должна быть бесплатной
            $arSelect = Array('ID','PROPERTY_VAL');
            $arFilter = Array(
                '=IBLOCK_ID' => 27, //Настройки
                '=IBLOCK_SECTION_ID' => 86, //Настройки бесплатной доставки по России
                '=ACTIVE' => 'Y',
                '=ID' => array(1776), //Доставка по России бесплатна при стоимости цены больше
            );

            $res = CIBlockElement::GetList(Array(), $arFilter, false, Array(), $arSelect);

            //Ищем настройки бесплатной доставки
            while($ob = $res->GetNextElement())
            {
                $arFields = $ob->GetFields();

                $_SESSION['DELIVERY_PRICE_WHILE_RUSSIA'] = (int)$arFields['PROPERTY_VAL_VALUE'];
            }
        }

        $basketPrice = $_SESSION['ORDER_BASKET_PRICE'];

		//Если цена товаров больше значения из настроек
        if($basketPrice > $_SESSION['DELIVERY_PRICE_WHILE_RUSSIA']) {
            //делаем доставку бесплатной
            $baseResult->setDeliveryPrice(0);
        }
    }

    //Пересохраняем результат
    $event->addResult(
        new BitrixMainEventResult(
            BitrixMainEventResult::SUCCESS, array('RESULT' => $baseResult)
        )
    );
}


/*
// Так же обработчик можно вызвать старым способом
AddEventHandler("sale", "onSaleDeliveryServiceCalculate", "onSaleDeliveryServiceCalculate");
function onSaleDeliveryServiceCalculate($result, $shipment, $deliveryID){
    // Проверка id службы доставки
    //17 - Почта России
    //20 - Доставка курьером
    //21 - Пункт выдачи СДЭК
    //24 - Пункт выдачи Boxberry
    //33 - EMS Почта России
	
    if($deliveryID == 20 ){
        if(isset($_SESSION['ORDER_BASKET_PRICE']) )
        {
            $basketPrice = $_SESSION['ORDER_BASKET_PRICE'];

            if($basketPrice > 1000){
                // Записываем новое значение цены на доставку
                $shipment->setBasePriceDelivery(0, true);
            }
        }
    }
	
    if($deliveryID == 31 || $deliveryID == 33){
        $shipment->setBasePriceDelivery(2500, true);
    }
}*/

Не забываем, что код нужно разместить в init.php

Если вы знаете более изящные способы, то буду рад если вы ими поделитесь.

Полезные переменные order_ajax.js

this.result.TOTAL //Данные о текущей цене и стоимости корзины

Массив соответствия международных двухбуквенных кодов стран

const C_COUNTRIES = [
"RU" => 1,  //Россия
"AZ" => 2,  //Азербайджан
"AM" => 3,  //Армения
"BY" => 4,  //Беларусь
"GE" => 5,  //Грузия
"KZ" => 6,  //Казахстан
"KG" => 7,  //Киргизия
"LV" => 8,  //Латвия
"LT" => 9,  //Литва
"MD" => 10,  //Молдавия
"TJ" => 11,  //Таджикистан
"TM" => 12,  //Туркменистан
"UZ" => 13,  //Узбекистан
"UA" => 14,  //Украина
"EE" => 15,  //Эстония
"AU" => 16,  //Австралия
"AT" => 17,  //Австрия
"AL" => 18,  //Албания
"DZ" => 19,  //Алжир
"AO" => 20,  //Ангола
"AE" => 21,  //Арабские Эмираты
"AR" => 22,  //Аргентина
"AW" => 23,  //Аруба
"AF" => 24,  //Афганистан
"BS" => 25,  //Багамские острова
"BD" => 26,  //Бангладеш
"BB" => 27,  //Барбадос
"BE" => 28,  //Бельгия
"BJ" => 29,  //Бенин
"BM" => 30,  //Бермудские острова
"BG" => 31,  //Болгария
"BO" => 32,  //Боливия
"BA" => 33,  //Босния и Герцеговина
"BR" => 34,  //Бразилия
"BN" => 35,  //Бруней
"GB" => 36,  //Великобритания
"HU" => 37,  //Венгрия
"VE" => 38,  //Венесуэлла
"VN" => 39,  //Вьетнам
"HT" => 40,  //Гаити
"GM" => 41,  //Гамбия
"HN" => 42,  //Гондурас
"GP" => 43,  //Гваделупа
"GT" => 44,  //Гватемала
"GN" => 45,  //Гвинея
"DE" => 46,  //Германия
"GI" => 47,  //Гибралтар
"NL" => 48,  //Нидерланды
"HK" => 49,  //Гонконг
"GD" => 50,  //Гренада
"GL" => 51,  //Гренландия
"GR" => 52,  //Греция
"GU" => 53,  //Гуана
"DK" => 54,  //Дания
"DO" => 55,  //Доминиканская Республика
"EG" => 56,  //Египет
"CD" => 57,  //Демократическая республика Конго
"ZM" => 58,  //Замбия
"ZW" => 59,  //Зимбабве
"IL" => 60,  //Израиль
"IN" => 61,  //Индия
"ID" => 62,  //Индонезия
"JO" => 63,  //Иордания
"IQ" => 64,  //Ирак
"IR" => 65,  //Иран
"IE" => 66,  //Ирландия
"IS" => 67,  //Исландия
"ES" => 68,  //Испания
"IT" => 69,  //Италия
"YE" => 70,  //Йемен
"KY" => 71,  //Каймановы острова
"CM" => 72,  //Камерун
"CA" => 73,  //Канада
"KE" => 74,  //Кения
"CY" => 75,  //Кипр
"CN" => 76,  //Китай
"CO" => 77,  //Колумбия
"KH" => 78,  //Камбоджа
"CG" => 79,  //Конго
"KR" => 80,  //Корея (Южная)
"CR" => 81,  //Коста Рика
"CU" => 82,  //Куба
"KW" => 83,  //Кувейт
"LR" => 84,  //Либерия
"LI" => 85,  //Лихтенштейн
"LU" => 86,  //Люксембург
"MR" => 87,  //Мавритания
"MG" => 88,  //Мадагаскар
"MK" => 89,  //Македония
"MY" => 90,  //Малайзия
"ML" => 91,  //Мали
"MT" => 92,  //Мальта
"MX" => 93,  //Мексика
"MZ" => 94,  //Мозамбик
"MC" => 95,  //Монако
"MN" => 96,  //Монголия
"MA" => 97,  //Морокко
"NA" => 98,  //Намибия
"NP" => 99,  //Непал
"NG" => 100,  //Нигерия
"NI" => 102,  //Никарагуа
"NZ" => 103,  //Новая Зеландия
"NO" => 104,  //Норвегия
"PK" => 105,  //Пакистан
"PA" => 106,  //Панама
"PG" => 107,  //Папуа Новая Гвинея
"PY" => 108,  //Парагвай
"PE" => 109,  //Перу
"PL" => 110,  //Польша
"PT" => 111,  //Португалия
"PR" => 112,  //Пуэрто Рико
"RO" => 113,  //Румыния
"SA" => 114,  //Саудовская Аравия
"SN" => 115,  //Сенегал
"SG" => 116,  //Сингапур
"SY" => 117,  //Сирия
"SK" => 118,  //Словакия
"SI" => 119,  //Словения
"SO" => 120,  //Сомали
"SD" => 121,  //Судан
"US" => 122,  //США
"TW" => 123,  //Тайвань
"TH" => 124,  //Таиланд
"TT" => 125,  //Тринидад и Тобаго
"TN" => 126,  //Тунис
"TR" => 127,  //Турция
"UG" => 128,  //Уганда
"UY" => 129,  //Уругвай
"PH" => 130,  //Филиппины
"FI" => 131,  //Финляндия
"FR" => 132,  //Франция
"TD" => 133,  //Чад
"CZ" => 134,  //Чехия
"CL" => 135,  //Чили
"CH" => 136,  //Швейцария
"SE" => 137,  //Швеция
"LK" => 138,  //Шри-Ланка
"EC" => 139,  //Эквадор
"ET" => 140,  //Эфиопия
"ZA" => 141,  //ЮАР
"RS" => 142,  //Сербия
"JM" => 143,  //Ямайка
"JP" => 144,  //Япония
"BH" => 145,  //Бахрейн
"AD" => 146,  //Андорра
"BZ" => 147,  //Белиз
"BT" => 148,  //Бутан
"BW" => 149,  //Ботсвана
"BF" => 150,  //Буркина Фасо
"BI" => 151,  //Бурунди
"CF" => 152,  //Центральноафриканская Республика
"KM" => 153,  //Коморос
"CI" => 154,  //Кот-Д`ивуар
"DJ" => 155,  //Джибути
"TL" => 156,  //Восточный Тимор
"SV" => 157,  //Эль Сальвадор
"GQ" => 158,  //Экваториальная Гвинея
"ER" => 159,  //Эритрея
"FJ" => 160,  //Фижи
"GA" => 161,  //Габон
"GH" => 162,  //Гана
"GW" => 163,  //Гвинея-Биссау
"KP" => 164,  //Корея (Северная)
"LB" => 165,  //Ливан
"LS" => 166,  //Лесото
"LY" => 167,  //Ливия
"MV" => 168,  //Мальдивы
"MH" => 169,  //Маршалские острова
"NE" => 170,  //Нигер
"OM" => 171,  //Оман
"QA" => 172,  //Катар
"RW" => 173,  //Руанда
"WS" => 174,  //Самоа
"SC" => 175,  //Сейшеллы
"SL" => 176,  //Сьерра-Леоне
"SR" => 177,  //Суринам
"SZ" => 178,  //Свазиленд
"TZ" => 179,  //Танзания
"EH" => 180,  //Западная Сахара
"HR" => 181,  //Хорватия
"AI" => 182,  //Ангилья
"AQ" => 183,  //Антарктида
"AG" => 184,  //Антигуа и Барбуда
"BV" => 185,  //Остров Буве
"IO" => 186,  //Британские территории в Индийском Океане
"VG" => 187,  //Британские Виргинские острова
"MM" => 188,  //Мьянма
"CV" => 189,  //Кабо-Верде
"CX" => 190,  //Остров Рождества
"CC" => 191,  //Кокосовые острова
"CK" => 192,  //Острова Кука
"DM" => 193,  //Доминика
"FK" => 194,  //Фолклендские острова
"FO" => 195,  //Фарерские острова
"GF" => 196,  //Гвиана
"PF" => 197,  //Французская Полинезия
"TF" => 198,  //Южные Французские территории
"HM" => 199,  //Острова Херд и Макдоналд
"KI" => 200,  //Кирибати
"LA" => 201,  //Лаос
"MO" => 202,  //Макао
"MW" => 203,  //Малави
"MQ" => 204,  //Мартиника
"MU" => 205,  //Маврикий
"YT" => 206,  //Майотта
"FM" => 207,  //Микронезия
"MS" => 208,  //Монтсеррат
"NR" => 209,  //Науру
//  210  Антильские острова - они же Карибы, относятся к нескольким государствам, каое имеется ввиду в Битриксе - хз
"NC" => 211,  //Новая Каледония
"NU" => 212,  //Ниуэ
"NF" => 213,  //Остров Норфолк
"PW" => 214,  //Палау
"PS" => 215,  //Палестина
"PN" => 216,  //Остров Питкэрн
"RE" => 217,  //Реюньон
"SH" => 218,  //Остров Св.Елены
"KN" => 219,  //Острова Сент-Киттс и Невис
"LC" => 220,  //Санта-Лючия
"PM" => 221,  //Острова Сен-Пьер и Микелон
"VC" => 222,  //Сент-Винсент и Гренадины
"SM" => 223,  //Сан-Марино
"SB" => 224,  //Соломоновы острова
"LK" => 225,  //Южная Георгия и Южные Сандвичевы острова
"SJ" => 226,  //Острова Шпицберген и Ян-Майен
"TG" => 227,  //Того
"TK" => 228,  //Токелау
"TO" => 229,  //Тонга
"TC" => 230,  //Острова Тёркс и Кайкос
"TV" => 231,  //Тувалу
"VI" => 232,  //Американские Виргинские острова
"VU" => 233,  //Вануату
"VA" => 234,  //Ватикан
"WF" => 235,  //Острова Уоллис и Футуна
"ME" => 236,  //Черногория    
];

Альтернативные (кастомные) sale.order.ajax

Интересный кастомный sale.order.ajax предложил alorian в компоненте Opensource Bitrix Order

Ошибка в Битрикс при оформлении заказа window.__jsonp_ymaps_map is not a function

Скорей всего вы используете несколько служб доставки, таких как СДЭК или Boxberry. Зайдите в настройки этих служб и в одной из них отключите использование Яндекс карт. Для работы будет достаточно одного подключения API Яндекс

Баг при автозаполнении телефона. После любого действия начальная цифра (7) дублируется, стирая актуальную цифру телефона

С проблемой столкнулся на одном из шаблонов Аспро Оптимус. Решил обходным путем.

Идем в функцию alterProperty и отключаем штатный механизм маскирования закомментировав данные строки:

//if (settings.IS_PHONE == 'Y')
//textNode.setAttribute('autocomplete', 'tel');

Подключил библиотеку Inputmask и добавил в конец файла order_ajax.js следующие строки, где #soa-property-3 это идентификатор нашего свойства с телефоном

$(function() {
$('#soa-property-3').inputmask({"mask": "7 (999) 999-99-99"});
BX.addCustomEvent('onAjaxSuccess', function(){
$('#soa-property-3').inputmask({"mask": "7 (999) 999-99-99"});
});
});

На реализацию этого функционала ушло порядка 30 часов рабочего времени (плюс время на самообразование).
За это время было отправлено 18 коммитов, написано 371 строк кода и осуществлено несколько попыток виртуального суицида :)

Основная задача

Создать группу свойств «Параметры доставки», которая будет зависеть от выбора типа доставки. Для курьера это «Адрес доставки», для «Транспортной компании» это выбор ТК из выпадающего списка, для доставки «Другая транспортная компания» — тоже текстовое поле (как и адрес доставки). Все эти поля являются обязательными, и отображаться должны не в блоке «Пользователь», а в блоке с доставками.

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

Кастомизация нового шаблона sale.order.ajax осуществляется полностью средставми JS. Для этого лучше не вносить изменения в сам файл order_ajax.js, а создать новый order_ajax_ext.js, как это советует Олег.

Теперь нам нужно будет переопределить часть методов. Я постараюсь прокомментировать подробно, насколько смогу.

Первая задача — перенести поле «Адрес доставки» из блока «Пользователь» в блок «Доставка».

Это можно сделать через событие OnSaleComponentOrderJsDataHandler. В этом событии мы можем поймать тот массив, который летит в ajax-обработчик для дальнейшего отображения и что-то поменять в нем.

function OnSaleComponentOrderJsDataHandler(&$arResult, &$arParams) { $groupParamsId = 5; //ID группы свойств с параметрами доставки foreach ($arResult['JS_DATA']['ORDER_PROP']['properties'] as $key => $prop) { if ($prop['PROPS_GROUP_ID']==$groupParamsId) { $arResult['JS_DATA']['DELIVERY_PROPS']['properties'][] = $arResult['JS_DATA']['ORDER_PROP']['properties'][$key]; unset($arResult['JS_DATA']['ORDER_PROP']['properties'][$key]); } } foreach ($arResult['JS_DATA']['ORDER_PROP']['groups'] as $key => $group) { if ($group['ID']==$groupParamsId) { $arResult['JS_DATA']['DELIVERY_PROPS']['groups'][] = $arResult['JS_DATA']['ORDER_PROP']['groups'][$key]; unset($arResult['JS_DATA']['ORDER_PROP']['groups'][$key]); } } }

В данном случае мы создали дополнительный ключ массива JS_DATA для свойств, которые касаются именно доставки. Теперь они не будут появляться в блоке «Пользователь», но и в блоке с доставкой тоже не появятся — у нас всё еще впереди.

Задача 1.1 — вывести поля «Адрес доставки», убранные из блока «Пользователь», в блоке «Доставка».

Чтобы обращаться к нашим «новым» свойствам, создаем экземпляр «коллекции» и добавляем его в наш объект. Для этого наследуем метод initOptions, но весь его текст нам не нужен, поэтому вызовем родительский метод и добавим нужную нам строку в конце:

var initOptionsParent = BX.Sale.OrderAjaxComponent.initOptions;
BX.Sale.OrderAjaxComponentExt.initOptions = function() {
initOptionsParent.apply(this, arguments);
this.propertyDeliveryCollection = new BX.Sale.PropertyCollection(BX.merge({publicMode: true}, this.result.DELIVERY_PROPS));
};

Наследуем метод editDeliveryInfo:

    BX.Sale.OrderAjaxComponentExt.editDeliveryInfo = function(deliveryNode) {
editDeliveryInfoParent.apply(this, arguments); //вызываем родителя
var deliveryInfoContainer = deliveryNode.querySelector('.bx-soa-pp-company-desc'); //находим блок с описанием службы доставки
var group, property, groupIterator = this.propertyDeliveryCollection.getGroupIterator(), propsIterator, htmlAddress;
//используем коллекцию, инициализированную в предыдущем методе
var deliveryItemsContainer = BX.create('DIV', {props: {className: 'col-sm-12 bx-soa-delivery'}}); //создаем контейнер для будущего поля
while (group = groupIterator())
{
propsIterator =  group.getIterator();
while (property = propsIterator())
{
if (property.getGroupId()==5) { //если это свойство является параметром доставки
this.getPropertyRowNode(property, deliveryItemsContainer, false); //вставляем свойство в подготовленный контейнер
deliveryInfoContainer.appendChild(deliveryItemsContainer); //контейнер вместе со свойством в нём добавляем в конце блока с описанием (deliveryInfoContainer)
}
}
}
};

Определять, какая именно служба доставки выбрана, не нужно. Если у вас правильно настроены зависимости для свойств заказа (например, что «Адрес доставки» выводится только при доставке курьером, а «Выбор транспортной компании» только при доставке ТК), то поля будут отображаться только там, где нужно.

Задача 2 — добиться того, чтобы ошибки о незаполненных полях выводились в тех блоках, в которых нужно.

Если поля, которые мы переместили в блок с доставками, являются необязательными, то на этом можно и закончить. Но обычно наша задача не только вывести форму для оформления заказа, но и максимально помочь пользователю, например, не забыть заполнить важное поле «Адрес доставки».

Основное, что мы можем для этого сделать — это унаследовать метод initValidation:

    BX.Sale.OrderAjaxComponentExt.initValidation = function() {
if (!this.result.ORDER_PROP || !this.result.ORDER_PROP.properties)
return;
var properties = this.result.ORDER_PROP.properties, 
            deliveryProps = this.result.DELIVERY_PROPS.properties,
obj = {}, deliveryObj = {}, i;
for (i in properties)
{
if (properties.hasOwnProperty(i))
obj[properties[i].ID] = properties[i];
}
for (i in deliveryProps)
{
if (deliveryProps.hasOwnProperty(i))
deliveryObj[deliveryProps[i].ID] = deliveryProps[i];
}
this.validation.properties = obj;
this.validation.deliveryProperties = deliveryObj;
};

Помимо массива ORDER_PROP у нас появился массив DELIVERY_PROPS, который мы должны показать компоненту при инициализации валидации. Записываем его в отдельное свойство нашего объекта — this.validation.deliveryProperties.

Теперь эти свойство надо где-то применить. Создаем свою функцию isValidDeliveryBlock: она будет создана по образу и подобию функции isValidPropertiesBlock (думаю, по их названиям понятно, для чего они предназначены).

BX.Sale.OrderAjaxComponentExt.isValidDeliveryBlock = function(excludeLocation) { if (!this.options.propertyValidation) return []; var props = this.orderBlockNode.querySelectorAll('.bx-soa-customer-field[data-property-id-row]'), propsErrors = [], id, propContainer, arProperty, data, i; for (i = 0; i < props.length; i++) { id = props[i].getAttribute('data-property-id-row'); if (!!excludeLocation && this.locations[id]) continue; propContainer = props[i].querySelector('.soa-property-container'); if (propContainer) { arProperty = this.validation.deliveryProperties[id]; data = this.getValidationData(arProperty, propContainer); propsErrors = propsErrors.concat(this.isValidProperty(data, true)); } } return propsErrors; };

Эта функция возвращает массив с ошибками, касающимися полей доставки, используя для валидации список полей из нашего js-массива this.validation.deliveryProperties.

Теперь будем использовать её в методе editFadeDeliveryContent. Этот метод отвечает за содержимое блока «Доставка» в закрытом виде. В этом состоянии он должен выводить красный блок с описанием ошибки, при нажатии на который блок будет раскрываться. За отображение такого блока отвечает метод showError. В него мы отправляем this.deliveryBlockNode (блок, в котором нужно показывать ошибку) и validDeliveryErrors (переменная, которая получила ошибки с помощью нашего метода isValidDeliveryBlock).

    BX.Sale.OrderAjaxComponentExt.editFadeDeliveryContent = function(node) {
editFadeDeliveryContentParent.apply(this, arguments);
if (this.initialized.delivery) { //проверяем, была ли инициализирована доставка
var validDeliveryErrors = this.isValidDeliveryBlock(); //вызываем наш метод
if (validDeliveryErrors.length && BX.hasClass(BX.findParent(node),'bx-selected') == true) {
this.showError(this.deliveryBlockNode, validDeliveryErrors);
} else { //если ошибок нет и всё в порядке
node.querySelector('.alert.alert-danger').style.display = 'none';
var section = BX.findParent(node.querySelector('.alert.alert-danger'), {className: 'bx-soa-section'});
node.setAttribute('data-visited', 'true');
BX.removeClass(section, 'bx-step-error'); //убираем иконку, что есть ошибка в этом шаге
BX.addClass(section, 'bx-step-completed'); //выставляем, что блок валиден и готов
}
}
};

Задача 2.1 — скорректировать вывод ошибок при отправке формы.

Все эти проверки касаются ситуации, когда проверки происходят «на лету». Но еще есть ситуация, когда мы нажимаем кнопку «Подтвердить заказ» и все поля снова проверяются не только на стороне клиента, но и на стороне сервера. В стандартном оформлении заказа, если какое-то обязательное поле не заполнено, js даже не отправляет запроса к серверу, а показывает ошибку сразу. В нашей ситуации, если мы заполнили все поля, кроме поля «Адрес доставки», запрос всё-таки на сервер уйдет, а сервер уже покажет нам ошибку. Но ошибку он покажет не в том блоке, который нам нужен, а опять в блоке «Пользователь», потому в методе saveOrder, который выполняется при нажатии кнопки оформления заказа, нам тоже нужно переопределить место показа для блока ошибки.

Лирическое отступление. Определить, какая именно ошибка показывается, очень просто. Языковые сообщения здесь почему-то не унифицированы, и когда происходит проверка на стороне клиента, текст ошибки звучит как «Поле «Адрес доставки» обязательно для заполнения».
Если же мы получили ошибку от сервера, ошибка выводится без слова «поле», название поля без кавычек. Примерно так: «Адрес доставки обязательно для заполнения».
Может, конечно, это когда-то пофиксят, но сейчас у меня стоит последняя версия модуля sale (даже бета), и там всё так.

Но не суть. Метод saveOrder будем переопределять целиком, так как там изменения будут в середине кода внутри условия else.
Этот метод получает объект result, внутри которого есть order.ERROR.PROPERTY, куда он и складывает по умолчанию все ошибки, связанные с пользовательскими свойствами. К сожалению, мне не удалось (в адекватные сроки) найти место, где формируется объект result, который туда попадает. Поэтому пришлось просто перенести ошибку из свойства PROPERTY в свойство DELIVERY. В данном случае я сделала допущение, что ошибки типа PROPERTY обычно валидируются в форме «на лету» и мне не удалось ни разу воспроизвести ситуацию, когда в метод saveOrder попадали ошибки, кроме ошибки, связанной с незаполненным адресом доставки.

    BX.Sale.OrderAjaxComponentExt.saveOrder = function(result) {
var res = BX.parseJSON(result), redirected = false;
if (res && res.order)
{
result = res.order;
this.result.SHOW_AUTH = result.SHOW_AUTH;
this.result.AUTH = result.AUTH;
if (this.result.SHOW_AUTH)
{
this.editAuthBlock();
this.showAuthBlock();
this.animateScrollTo(this.authBlockNode);
}
else
{
if (result.REDIRECT_URL && result.REDIRECT_URL.length)
{
if (this.params.USE_ENHANCED_ECOMMERCE === 'Y')
{
this.setAnalyticsDataLayer('purchase', result.ID);
}
redirected = true;
document.location.href = result.REDIRECT_URL;
}
if (result.ERROR.hasOwnProperty('PROPERTY')) {
result.ERROR['DELIVERY'] = result.ERROR.PROPERTY;
delete result.ERROR.PROPERTY;
}
this.showErrors(result.ERROR, true, true);
}
}
if (!redirected)
{
this.endLoader();
this.disallowOrderSave();
}
};

Задача 3 — запретить битриксу выбирать доставку по умолчанию.

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

if (isset($arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY'])
 && $arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY']!='') {
 $arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY'] = '';}

В данном случае блок с доставками всегда будет открыт, и пользователь сразу обратит внимание на необходимость заполнения полей. Но! Если пользователь в кабинете удаляет профиль, поле с местоположением будет у него незаполнено, и после его заполнения блок с доставками автоматически закроется без возможности его отредактировать (пропадет кнопка «Изменить»). Это очень трудно пофиксить, чтобы не посыпалось всё остальное, поэтому мы приняли решение убрать возможность редактирования профилей в кабинете пользователя (делается снятием галочки в настройках компонента личного кабинета).

На данный момент у меня всё. Конечно, этот код был написан для конкретного проекта и с определенными допущениями. Но надеюсь, что данная заметка оказалась вам полезной и наведет вас на путь истинный при решении вашей задачи. Ибо документации по методам класса OrderAjaxComponent нет и не будет.
Если Вам есть что добавить или поправить — буду рада комментариям.

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

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

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

Содержимое статьи

  • 1 Компонент
    • 1.1 Создание виртуального заказа
    • 1.2 Добавляем свойства заказа
    • 1.3 Добавляем отгрузку (службу доставки)
    • 1.4 Добавляем платежную систему
    • 1.5 Подключение шаблона и сохранение
  • 2 Шаблон компонента
    • 2.1 Корзина
    • 2.2 Работа со свойствами
      • 2.2.1 Форма
      • 2.2.2 Отображаемое местоположение
      • 2.2.3 Получение свойства по коду
    • 2.3 Прочие возможности
      • 2.3.1 Стоимость доставки и заказа
      • 2.3.2 Выбранная служба доставки
      • 2.3.3 Выбранная система оплаты
    • 2.4 Итого по шаблону
  • 3 Добавляем ООП
    • 3.1 Расширяем корзину
    • 3.2 Расширяем заказ
    • 3.3 Итого по ООП
  • 4 Прикручиваем ajax
    • 4.1 Actions компонента
    • 4.2 Поиск местоположений
    • 4.3 Метод расчета стоимости доставки
  • 5 Общий итог

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 BitrixMainLocalizationLoc;
use BitrixMainLoader;
class customOrderComponent extends CBitrixComponent
{
/**
* @var BitrixSaleOrder
*/
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 = BitrixMainContext::getCurrent()->getSite();
$basketItems = BitrixSaleBasket::loadItemsForFUser(
CSaleBasket::GetBasketUserID(), 
$siteId
)
->getOrderableItems();
if (count($basketItems) == 0) {
LocalRedirect(PATH_TO_BASKET);
}
$this->order = BitrixSaleOrder::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 = BitrixSaleOrder::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 BitrixSalePropertyValue $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 BitrixMainLocalizationLoc;
use BitrixMainLoader;
class customOrderComponent extends CBitrixComponent
{
[...]
protected function createVirtualOrder()
{
global $USER;
try {
[...]
/* @var $shipmentCollection BitrixSaleShipmentCollection */
$shipmentCollection = $this->order->getShipmentCollection();
if (intval($this->request['delivery_id']) > 0) {
$shipment = $shipmentCollection->createItem(
BitrixSaleDeliveryServicesManager::getObjectById(
intval($this->request['delivery_id'])
)
);
} else {
$shipment = $shipmentCollection->createItem();
}
/** @var $shipmentItemCollection BitrixSaleShipmentItemCollection */
$shipmentItemCollection = $shipment->getShipmentItemCollection();
$shipment->setField('CURRENCY', $this->order->getCurrency());
foreach ($this->order->getBasket()->getOrderableItems() as $item) {
/**
* @var $item BitrixSaleBasketItem
* @var $shipmentItem BitrixSaleShipmentItem
* @var $item BitrixSaleBasketItem
*/
$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(
BitrixSaleDeliveryServicesManager::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 BitrixMainLocalizationLoc;
use BitrixMainLoader;
class customOrderComponent extends CBitrixComponent
{
[...]
protected function createVirtualOrder()
{
global $USER;
try {
[...]
if (intval($this->request['payment_id']) > 0) {
$paymentCollection = $this->order->getPaymentCollection();
$payment = $paymentCollection->createItem(
BitrixSalePaySystemManager::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 BitrixMainLocalizationLoc;
use BitrixMainLoader;
class customOrderComponent extends CBitrixComponent
{
/**
* @var BitrixSaleOrder
*/
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 = BitrixMainContext::getCurrent()->getSite();
$basketItems = BitrixSaleBasket::loadItemsForFUser(
CSaleBasket::GetBasketUserID(),
$siteId
)
->getOrderableItems();
if (count($basketItems) == 0) {
LocalRedirect(PATH_TO_BASKET);
}
$this->order = BitrixSaleOrder::create($siteId, $USER->GetID());
$this->order->setPersonTypeId($this->arParams['PERSON_TYPE_ID']);
$this->order->setBasket($basketItems);
$this->setOrderProps();
/* @var $shipmentCollection BitrixSaleShipmentCollection */
$shipmentCollection = $this->order->getShipmentCollection();
if (intval($this->request['delivery_id']) > 0) {
$shipment = $shipmentCollection->createItem(
BitrixSaleDeliveryServicesManager::getObjectById(
intval($this->request['delivery_id'])
)
);
} else {
$shipment = $shipmentCollection->createItem();
}
/** @var $shipmentItemCollection BitrixSaleShipmentItemCollection */
$shipmentItemCollection = $shipment->getShipmentItemCollection();
$shipment->setField('CURRENCY', $this->order->getCurrency());
foreach ($this->order->getBasket()->getOrderableItems() as $item) {
/**
* @var $item BitrixSaleBasketItem
* @var $shipmentItem BitrixSaleShipmentItem
* @var $item BitrixSaleBasketItem
*/
$shipmentItem = $shipmentItemCollection->createItem($item);
$shipmentItem->setQuantity($item->getQuantity());
}
if (intval($this->request['payment_id']) > 0) {
$paymentCollection = $this->order->getPaymentCollection();
$payment = $paymentCollection->createItem(
BitrixSalePaySystemManager::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 BitrixSalePropertyValue $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 LocalSaleBasketItem
*/
?>
<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 BitrixSalePropertyValue $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 BitrixSalePropertyValue $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 BitrixSaleShipment $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 BitrixSalePayment $payment
*/
?>
<?= $payment->getPaymentSystemName() ?>
<? endforeach; ?>

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

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

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

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

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

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

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

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

/**
* @param $siteId
* @return Basket
*/
public static function create($siteId)
{
$basket = static::createBasketObject();
$basket->setSiteId($siteId);
//		if ($fuserId !== null)
//			$basket->setFUserId($fuserId);
return $basket;
}

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

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

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

$registry = Registry::getInstance(Registry::REGISTRY_TYPE_ORDER);
$registry->set(Registry::ENTITY_BASKET, 'LocalSaleBasket');
$registry->set(Registry::ENTITY_BASKET_ITEM, 'LocalSaleBasketItem');

После выполнения этого кода, все последующие корзины будут создаваться из класса ‘LocalSaleBasket’, а не из дефолтного ‘BitrixSaleBasket’. Аналогично вместо ‘BitrixSaleBasketItem’ битрикс нам всегда будет создавать ‘LocalSaleBasketItem’.

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

Классы ‘LocalSaleBasket’ и ‘LocalSaleBasketItem’ естественно нужно создать самостоятельно. Сами они ниоткуда не возьмутся. Названия классов могут быть любые, например вместо ‘LocalSaleBasket’ вы можете создавать ‘FooBar’. Можете положить их в собственный модуль битрикса (например в local.lib) или зарегистрировать автолоад для папки в композере и подключить оттуда. Смотрите сами как вам удобнее. Сейчас пока давайте создадим эти классы и поговорим про возможное их содержимое.

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

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

<?php
namespace LocalSale;
use BitrixMainLoader;
class Basket extends BitrixSaleBasket
{
protected $productsList = [];
protected function getAllProductsData()
{
if(Loader::includeModule('iblock')) {
$productIDs = [];
foreach ($this->collection as $item) {
/**
* @var $item LocalSaleBasketItem
*/
$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;
}
}

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

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

<?php
namespace LocalSale;
class BasketItem extends BitrixSaleBasketItem
{
protected $arImage = null;
public function getPicture()
{
/**
* @var LocalSaleBasket $basket
*/
$basket = $this->getCollection();
$arProduct = $basket->getProductData($this->getProductId());
if (!empty($arProduct)) {
$image = new LocalLibPartsImage([]);
$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 LocalLibPartsImage($arSizes);
$image->addImage($this->arImage);
$arImage = $image->getFirstResized();
}
return $arImage;
}
}

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

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

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

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

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

<?php
namespace LocalSale;
use BitrixSaleDelivery;
use BitrixSalePayment;
use BitrixSalePaySystem;
class Order extends BitrixSaleOrder
{
public function getAvailableDeliveries()
{
$shipment = false;
/** @var BitrixSaleShipment $shipmentItem */
foreach ($this->getShipmentCollection() as $shipmentItem) {
if (!$shipmentItem->isSystem()) {
$shipment = $shipmentItem;
break;
}
}
$availableDeliveries = [];
if (!empty($shipment)) {
$availableDeliveries = DeliveryServicesManager::getRestrictedObjectsList($shipment);
}
return $availableDeliveries;
}
public function getAvailablePaySystems()
{
$payment = Payment::create($this->getPaymentCollection());
$payment->setField('SUM', $this->getPrice());
$payment->setField("CURRENCY", $this->getCurrency());
$paySystemsList = PaySystemManager::getListWithRestrictions($payment);
//logo
foreach ($paySystemsList as $key => $paySystem) {
if (intval($paySystem['LOGOTIP']) > 0) {
$paySystemsList[$key]['LOGO_PATH'] = LocalLibHelpersFiles::getOriginal(
$paySystem['LOGOTIP']
);
}
}
return $paySystemsList;
}
}

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

$registry = Registry::getInstance(Registry::REGISTRY_TYPE_ORDER);
$registry->set(Registry::ENTITY_ORDER, 'LocalSaleOrder');

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

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

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

$availableDeliveries = [];
if (!empty($shipment)) {
$availableDeliveries = DeliveryServicesManager::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 BitrixMainLocalizationLoc;
use BitrixMainLoader;
class customOrderComponent extends CBitrixComponent
{
/**
* @var BitrixSaleOrder
*/
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 BitrixSaleOrder
*/
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 BitrixSaleShipment $shipmentItem */
foreach ($this->order->getShipmentCollection() as $shipmentItem) {
if (!$shipmentItem->isSystem()) {
$shipment = $shipmentItem;
break;
}
}
if (!$shipment) {
throw new Exception('Отгрузка не найдена');
}
//Массив с доставками,
$availableDeliveries = BitrixSaleDeliveryServicesManager::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, и в целом большей прозрачностью всей процедуры заказа. Что тут важнее решать безусловно вам 🙂 Я думаю стоит ориентироваться на компетентность команды разработки и в целом на сложность процедуры заказа. Чем ниже уровень разработчиков и чем больше объем кода, тем выше риск неверного использования объекта заказа в шаблоне.

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

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

Итак в файле order_ajax.js проходят все проверки.
Подробнее по нужным функциям:

showValidationResult: function(inputs, errors) — Функция в которой полям с ошибкой добавляется класс hasError, который помечает ошибкой(в стандартном варианте добавляет обводку красным).

showErrorTooltip: function(tooltipId, targetNode, text) — Функция в которой добавляются тултипы для полей с ошибкой.

showError: function(node, msg, border) — Функция в которой выводятся ошибки в «групповой контейнер»

refreshOrder: function(result) — Функция в которой происходит разбор ошибок, которые приходят от сервера. Там есть ветка result.error

Первые 3 функции отвечают за валидацию на форме без перезагрузки, а четвёртая за обработку результатов от сервера.

Для Вашей задачи нужно переписать функцию showError. В эту функцию передаётся параметр msg — массив и в нём хранятся все ошибки, которые есть на форме.

Итак в файле order_ajax.js проходят все проверки.
Подробнее по нужным функциям:

showValidationResult: function(inputs, errors) — Функция в которой полям с ошибкой добавляется класс hasError, который помечает ошибкой(в стандартном варианте добавляет обводку красным).

showErrorTooltip: function(tooltipId, targetNode, text) — Функция в которой добавляются тултипы для полей с ошибкой.

showError: function(node, msg, border) — Функция в которой выводятся ошибки в «групповой контейнер»

refreshOrder: function(result) — Функция в которой происходит разбор ошибок, которые приходят от сервера. Там есть ветка result.error

Первые 3 функции отвечают за валидацию на форме без перезагрузки, а четвёртая за обработку результатов от сервера.

Для Вашей задачи нужно переписать функцию showError. В эту функцию передаётся параметр msg — массив и в нём хранятся все ошибки, которые есть на форме.

В стандартном компоненте sale.order.ajax ошибки выводятся в блок того раздела который пользователь заполняет, например если покупатель не заполнил свои данные то ошибки появятся в блоке Покупатель joxi.ru/KAg7BEJhgLyDxA . Как я понял основная часть логики работы компонента вынесена в JS в файл order_ajax.js

В моем случаи все блоки: Регион доставки, Покупатель и тд — это отдельные вкладки, joxi.ru/MAj7BRehvPNN0r и joxi.ru/ZrJOkEjH1jx71m . Когда покупатель переходит к последнему этапу и нажимает кнопку Оформить заказ, в том случаи если он не заполнил сданные на первом этапе joxi.ru/MAj7BRehvPNN0r появляются ошибки но выводятся они на складке Покупатель, то есть чтобы их увидеть надо вернуться назад, это не удобно. Я хочу сделать специальный блок выше вкладок и в него буду выводится ошибки, чтобы они не были привязаны к вкладкам. Но проблема в том что вывод ошибок жестко привязан к разделам формы, которые у меня отображаются в виде вкладок.

Как можно сделать так, чтобы ошибки выводились в произвольный блок ? Как можно переопределить вывод ошибок через JS используя JS библиотеку Битрикс?

На реализацию этого функционала ушло порядка 30 часов рабочего времени (плюс время на самообразование).
За это время было отправлено 18 коммитов, написано 371 строк кода и осуществлено несколько попыток виртуального суицида :)

Основная задача

Создать группу свойств «Параметры доставки», которая будет зависеть от выбора типа доставки. Для курьера это «Адрес доставки», для «Транспортной компании» это выбор ТК из выпадающего списка, для доставки «Другая транспортная компания» — тоже текстовое поле (как и адрес доставки). Все эти поля являются обязательными, и отображаться должны не в блоке «Пользователь», а в блоке с доставками.

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

Кастомизация нового шаблона sale.order.ajax осуществляется полностью средставми JS. Для этого лучше не вносить изменения в сам файл order_ajax.js, а создать новый order_ajax_ext.js, как это советует Олег.

Теперь нам нужно будет переопределить часть методов. Я постараюсь прокомментировать подробно, насколько смогу.

Первая задача — перенести поле «Адрес доставки» из блока «Пользователь» в блок «Доставка».

Это можно сделать через событие OnSaleComponentOrderJsDataHandler. В этом событии мы можем поймать тот массив, который летит в ajax-обработчик для дальнейшего отображения и что-то поменять в нем.

function OnSaleComponentOrderJsDataHandler(&$arResult, &$arParams) {
    $groupParamsId = 5; //ID группы свойств с параметрами доставки
    foreach ($arResult['JS_DATA']['ORDER_PROP']['properties'] as $key => $prop) {
        if ($prop['PROPS_GROUP_ID']==$groupParamsId) {
            $arResult['JS_DATA']['DELIVERY_PROPS']['properties'][] = $arResult['JS_DATA']['ORDER_PROP']['properties'][$key];
            unset($arResult['JS_DATA']['ORDER_PROP']['properties'][$key]);
        }
    }
    foreach ($arResult['JS_DATA']['ORDER_PROP']['groups'] as $key => $group) {
        if ($group['ID']==$groupParamsId) {
            $arResult['JS_DATA']['DELIVERY_PROPS']['groups'][] = $arResult['JS_DATA']['ORDER_PROP']['groups'][$key];
            unset($arResult['JS_DATA']['ORDER_PROP']['groups'][$key]);
        }
    }
}

В данном случае мы создали дополнительный ключ массива JS_DATA для свойств, которые касаются именно доставки. Теперь они не будут появляться в блоке «Пользователь», но и в блоке с доставкой тоже не появятся — у нас всё еще впереди.

Задача 1.1 — вывести поля «Адрес доставки», убранные из блока «Пользователь», в блоке «Доставка».

Чтобы обращаться к нашим «новым» свойствам, создаем экземпляр «коллекции» и добавляем его в наш объект. Для этого наследуем метод initOptions, но весь его текст нам не нужен, поэтому вызовем родительский метод и добавим нужную нам строку в конце:

var initOptionsParent = BX.Sale.OrderAjaxComponent.initOptions;
BX.Sale.OrderAjaxComponentExt.initOptions = function() {
        initOptionsParent.apply(this, arguments);
        this.propertyDeliveryCollection = new BX.Sale.PropertyCollection(BX.merge({publicMode: true}, this.result.DELIVERY_PROPS));
    };

Наследуем метод editDeliveryInfo:

    BX.Sale.OrderAjaxComponentExt.editDeliveryInfo = function(deliveryNode) {
        editDeliveryInfoParent.apply(this, arguments); //вызываем родителя
        var deliveryInfoContainer = deliveryNode.querySelector('.bx-soa-pp-company-desc'); //находим блок с описанием службы доставки
        var group, property, groupIterator = this.propertyDeliveryCollection.getGroupIterator(), propsIterator, htmlAddress;
//используем коллекцию, инициализированную в предыдущем методе
        var deliveryItemsContainer = BX.create('DIV', {props: {className: 'col-sm-12 bx-soa-delivery'}}); //создаем контейнер для будущего поля
        while (group = groupIterator())
        {
            propsIterator =  group.getIterator();
            while (property = propsIterator())
            {
                if (property.getGroupId()==5) { //если это свойство является параметром доставки
                    this.getPropertyRowNode(property, deliveryItemsContainer, false); //вставляем свойство в подготовленный контейнер
                    deliveryInfoContainer.appendChild(deliveryItemsContainer); //контейнер вместе со свойством в нём добавляем в конце блока с описанием (deliveryInfoContainer)

                }
            }
        }
    };

Определять, какая именно служба доставки выбрана, не нужно. Если у вас правильно настроены зависимости для свойств заказа (например, что «Адрес доставки» выводится только при доставке курьером, а «Выбор транспортной компании» только при доставке ТК), то поля будут отображаться только там, где нужно.

Задача 2 — добиться того, чтобы ошибки о незаполненных полях выводились в тех блоках, в которых нужно.

Если поля, которые мы переместили в блок с доставками, являются необязательными, то на этом можно и закончить. Но обычно наша задача не только вывести форму для оформления заказа, но и максимально помочь пользователю, например, не забыть заполнить важное поле «Адрес доставки».

Основное, что мы можем для этого сделать — это унаследовать метод initValidation:

    BX.Sale.OrderAjaxComponentExt.initValidation = function() {
        if (!this.result.ORDER_PROP || !this.result.ORDER_PROP.properties)
            return;

        var properties = this.result.ORDER_PROP.properties, 
            deliveryProps = this.result.DELIVERY_PROPS.properties,
            obj = {}, deliveryObj = {}, i;


        for (i in properties)
        {
            if (properties.hasOwnProperty(i))
                obj[properties[i].ID] = properties[i];
        }
        for (i in deliveryProps)
        {
            if (deliveryProps.hasOwnProperty(i))
                deliveryObj[deliveryProps[i].ID] = deliveryProps[i];
        }

        this.validation.properties = obj;
        this.validation.deliveryProperties = deliveryObj;
    };

Помимо массива ORDER_PROP у нас появился массив DELIVERY_PROPS, который мы должны показать компоненту при инициализации валидации. Записываем его в отдельное свойство нашего объекта — this.validation.deliveryProperties.

Теперь эти свойство надо где-то применить. Создаем свою функцию isValidDeliveryBlock: она будет создана по образу и подобию функции isValidPropertiesBlock (думаю, по их названиям понятно, для чего они предназначены).

BX.Sale.OrderAjaxComponentExt.isValidDeliveryBlock = function(excludeLocation) {
        if (!this.options.propertyValidation)
            return [];

        var props = this.orderBlockNode.querySelectorAll('.bx-soa-customer-field[data-property-id-row]'),
            propsErrors = [],
            id, propContainer, arProperty, data, i;
        for (i = 0; i < props.length; i++)
        {
            id = props[i].getAttribute('data-property-id-row');

            if (!!excludeLocation && this.locations[id])
                continue;

            propContainer = props[i].querySelector('.soa-property-container');
            if (propContainer)
            {
                arProperty = this.validation.deliveryProperties[id];
                data = this.getValidationData(arProperty, propContainer);
                propsErrors = propsErrors.concat(this.isValidProperty(data, true));
            }
        }
        return propsErrors;
    };

Эта функция возвращает массив с ошибками, касающимися полей доставки, используя для валидации список полей из нашего js-массива this.validation.deliveryProperties.

Теперь будем использовать её в методе editFadeDeliveryContent. Этот метод отвечает за содержимое блока «Доставка» в закрытом виде. В этом состоянии он должен выводить красный блок с описанием ошибки, при нажатии на который блок будет раскрываться. За отображение такого блока отвечает метод showError. В него мы отправляем this.deliveryBlockNode (блок, в котором нужно показывать ошибку) и validDeliveryErrors (переменная, которая получила ошибки с помощью нашего метода isValidDeliveryBlock).

    BX.Sale.OrderAjaxComponentExt.editFadeDeliveryContent = function(node) {
        editFadeDeliveryContentParent.apply(this, arguments);
        if (this.initialized.delivery) { //проверяем, была ли инициализирована доставка
            var validDeliveryErrors = this.isValidDeliveryBlock(); //вызываем наш метод
            if (validDeliveryErrors.length && BX.hasClass(BX.findParent(node),'bx-selected') == true) {
                this.showError(this.deliveryBlockNode, validDeliveryErrors);
            } else { //если ошибок нет и всё в порядке
                node.querySelector('.alert.alert-danger').style.display = 'none';

                var section = BX.findParent(node.querySelector('.alert.alert-danger'), {className: 'bx-soa-section'});

                node.setAttribute('data-visited', 'true');
                BX.removeClass(section, 'bx-step-error'); //убираем иконку, что есть ошибка в этом шаге
                BX.addClass(section, 'bx-step-completed'); //выставляем, что блок валиден и готов
            }
        }
    };

Задача 2.1 — скорректировать вывод ошибок при отправке формы.

Все эти проверки касаются ситуации, когда проверки происходят «на лету». Но еще есть ситуация, когда мы нажимаем кнопку «Подтвердить заказ» и все поля снова проверяются не только на стороне клиента, но и на стороне сервера. В стандартном оформлении заказа, если какое-то обязательное поле не заполнено, js даже не отправляет запроса к серверу, а показывает ошибку сразу. В нашей ситуации, если мы заполнили все поля, кроме поля «Адрес доставки», запрос всё-таки на сервер уйдет, а сервер уже покажет нам ошибку. Но ошибку он покажет не в том блоке, который нам нужен, а опять в блоке «Пользователь», потому в методе saveOrder, который выполняется при нажатии кнопки оформления заказа, нам тоже нужно переопределить место показа для блока ошибки.

Лирическое отступление. Определить, какая именно ошибка показывается, очень просто. Языковые сообщения здесь почему-то не унифицированы, и когда происходит проверка на стороне клиента, текст ошибки звучит как «Поле «Адрес доставки» обязательно для заполнения».
Если же мы получили ошибку от сервера, ошибка выводится без слова «поле», название поля без кавычек. Примерно так: «Адрес доставки обязательно для заполнения».
Может, конечно, это когда-то пофиксят, но сейчас у меня стоит последняя версия модуля sale (даже бета), и там всё так.

Но не суть. Метод saveOrder будем переопределять целиком, так как там изменения будут в середине кода внутри условия else.
Этот метод получает объект result, внутри которого есть order.ERROR.PROPERTY, куда он и складывает по умолчанию все ошибки, связанные с пользовательскими свойствами. К сожалению, мне не удалось (в адекватные сроки) найти место, где формируется объект result, который туда попадает. Поэтому пришлось просто перенести ошибку из свойства PROPERTY в свойство DELIVERY. В данном случае я сделала допущение, что ошибки типа PROPERTY обычно валидируются в форме «на лету» и мне не удалось ни разу воспроизвести ситуацию, когда в метод saveOrder попадали ошибки, кроме ошибки, связанной с незаполненным адресом доставки.

    BX.Sale.OrderAjaxComponentExt.saveOrder = function(result) {
        var res = BX.parseJSON(result), redirected = false;
        if (res && res.order)
        {
            result = res.order;
            this.result.SHOW_AUTH = result.SHOW_AUTH;
            this.result.AUTH = result.AUTH;

            if (this.result.SHOW_AUTH)
            {
                this.editAuthBlock();
                this.showAuthBlock();
                this.animateScrollTo(this.authBlockNode);
            }
            else
            {
                if (result.REDIRECT_URL && result.REDIRECT_URL.length)
                {
                    if (this.params.USE_ENHANCED_ECOMMERCE === 'Y')
                    {
                        this.setAnalyticsDataLayer('purchase', result.ID);
                    }

                    redirected = true;
                    document.location.href = result.REDIRECT_URL;
                }
                if (result.ERROR.hasOwnProperty('PROPERTY')) {
                    result.ERROR['DELIVERY'] = result.ERROR.PROPERTY;
                    delete result.ERROR.PROPERTY;
                }
                this.showErrors(result.ERROR, true, true);
            }
        }

        if (!redirected)
        {
            this.endLoader();
            this.disallowOrderSave();
        }
    };

Задача 3 — запретить битриксу выбирать доставку по умолчанию.

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

if (isset($arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY'])
 && $arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY']!='') {
    $arResult['JS_DATA']['LAST_ORDER_DATA']['DELIVERY'] = '';}

В данном случае блок с доставками всегда будет открыт, и пользователь сразу обратит внимание на необходимость заполнения полей. Но! Если пользователь в кабинете удаляет профиль, поле с местоположением будет у него незаполнено, и после его заполнения блок с доставками автоматически закроется без возможности его отредактировать (пропадет кнопка «Изменить»). Это очень трудно пофиксить, чтобы не посыпалось всё остальное, поэтому мы приняли решение убрать возможность редактирования профилей в кабинете пользователя (делается снятием галочки в настройках компонента личного кабинета).

На данный момент у меня всё. Конечно, этот код был написан для конкретного проекта и с определенными допущениями. Но надеюсь, что данная заметка оказалась вам полезной и наведет вас на путь истинный при решении вашей задачи. Ибо документации по методам класса OrderAjaxComponent нет и не будет.
Если Вам есть что добавить или поправить — буду рада комментариям.

  • Битрикс 24 ошибка подключения к серверу ошибка передачи данных
  • Битрикс 24 ошибка импорта pd2 ответ сервера не опознан
  • Битрикс 24 ошибка авторизации почты
  • Битрикс 24 отправка почтовых уведомлений функция работает неправильно требуется устранить ошибки
  • Бит четности позволяет обнаружить сколько ошибок