Июл
27
2021

Как сделать региональные цены и остатки в Битрикс

Показываю как сделать на сайте остатки товаров и цены по городам. Деление по городам вы можете заменить своим условием - делением по юр лицам, например.

Все работы будем вести в папке /local/. Работаю на БУС 20.200.300, редакция Бизнес. Работаю с простыми товарами, с торговыми предложениями, возможно, заработает, а может и надо будет доработать. Но основную логику это не меняет.

Сделаем логику для выбора города

В файл  /local/php_interface/init.php сохраним следующий код. Назначение описал в комментариях в коде.

<?php

// При выборе пользователем города, сохраняем в куки
if (! empty($_GET['usercity']))
{
   setcookie('usercity', $_GET['usercity'], time() + 31536000, '/');
   $_COOKIE['usercity'] = $_GET['usercity'];
}

// Получить выбранный пользователем город
function getCityCode()
{
   return isset($_COOKIE['usercity']) ? $_COOKIE['usercity'] : 'izh';
}

// Получить ID склада выбранного города
function getStoreId()
{
   // Указываем ID складов для городов
   $stores = [
      'izh' => 2, 
      'alnashi' => 3, 
      'sarapul' => 4, 
   ];
   
   return $stores[getCityCode()];
}

// Получить ID типа цены выбранного города
function getPriceId()
{
   // Указываем ID типов цен для городов
   $prices = [
      'izh' => 2, 
      'alnashi' => 4, 
      'sarapul' => 3, 
   ];
   
   return $prices[getCityCode()];
}

Создадим воображаемые города на сайте

У меня это будет 3 населенных пункта: Ижевск, Алнаши, Сарапул.
В используемом шаблоне в шапке добавим выбор города

<? $link = $_SERVER['REDIRECT_URL'] .'?usercity='?>
<a href="<?=$link . 'alnashi'?>" style="<?=(getCityCode() === 'alnashi' ? 'color:red' : '')?>">Алнаши</a>
<a href="<?=$link . 'sarapul'?>" style="<?=(getCityCode() === 'sarapul' ? 'color:red' : '')?>">Сарапул</a>
<a href="<?=$link . 'izh'?>" style="<?=(getCityCode() === 'izh' ? 'color:red' : '')?>">Ижевск</a>

Создадим 3 типа цен и 3 склада.

Настроим Битрикс

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

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

Меняем цены и остатки в каталоге

Чтобы не переделывать весь комплексный компонент каталога и не трогать шаблоны, использовать будем стандартное пространство имен компонентов - bitrix.

Копируем в папку /local/components/bitrix стандартные компоненты catalog.element и catalog.section.

Можно дорабатывать скопированные компоненты, а можно наследовать класс оригинального компонента. Я пойду по второму пути, чтобы в будущем сразу видеть доработки. При моем пути существует риск того, что всё сломается, если оригинальный компонент обновится.

В компоненте catalog.element открываем файл class.php, там видим реализацию класса CatalogElementComponent. Всё удаляем, вписываем в файл следующий контент:

Файл /local/components/bitrix/catalog.element/class.php:
<?php
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/components/bitrix/catalog.element/class.php';

class CatalogElementComponentCustom extends CatalogElementComponent
{
   use CatalogComponentElementsTrait;
}

В компоненте catalog.section проводим аналогичную операцию, только наследуемся от класса раздела каталога

Файл /local/components/bitrix/catalog.section/class.php:
<?
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/components/bitrix/catalog.section/class.php';

class CatalogSectionComponentCustom extends CatalogSectionComponent
{
   use CatalogComponentElementsTrait;
}

В этих файлах мы подключили трейт CatalogComponentElementsTrait - подобие класса, только наследоваться можно от 1 класса в PHP, а трейтов использовать хоть сколько.

Реализуем сам трейт. Создаем файл /local/php_interface/catalogelementstrait.php
В него впишем следующее:

<?php

trait CatalogComponentElementsTrait
{
   /*
    * Указываем по какой цене работать
    */
   public function onPrepareComponentParams($params)
   {
      $params = parent::onPrepareComponentParams($params);
      
      $params['PRICE_CODE'] = [
         getCityCode()
      ];
      
      return $params;
   }
   
   /*
    * Подменяем в каталоге количество товара количеством с конкретного склада
    */
   protected function getIblockElements($elementIterator)
   {
      $iblockElements = parent::getIblockElements($elementIterator);

      if (count($iblockElements))
      {
         foreach ($iblockElements as $id => $element)
         {
            $iblockElements[$id] = $this->fillElementQuantity($element);
         }
         
         $rsStoreProduct = \Bitrix\Catalog\StoreProductTable::getList([
            'filter' => [
               '=PRODUCT_ID' => array_keys($iblockElements),
               'STORE_ID' => getStoreId(),
            ],
         ]);

         while($arStoreProduct = $rsStoreProduct->fetch())
         {
            $productId = $arStoreProduct['PRODUCT_ID'];
            $amount = $arStoreProduct['AMOUNT'];
            
            if (isset($iblockElements[$productId]))
            {
               $iblockElements[$productId] = $this->fillElementQuantity($iblockElements[$productId], $amount);
            }
         }
      }

      return $iblockElements;
   }
   
   /*
    * Заменяем все поля с количеством товара у элемента
    */
   protected function fillElementQuantity($element, $quantity = 0)
   {
      if (isset($element['PRODUCT']['QUANTITY']))
      {
         $element['PRODUCT']['QUANTITY'] = $quantity;
      }
      if (isset($element['PRODUCT']['AVAILABLE']))
      {
         $element['PRODUCT']['AVAILABLE'] = ($quantity > 0 ? 'Y' : 'N');
      }
      if (isset($element['CATALOG_QUANTITY']))
      {
         $element['CATALOG_QUANTITY'] = $quantity;
         $element['~CATALOG_QUANTITY'] = $quantity;
      }
      
      return $element;
   }
   
   /*
    * Если есть сортировка по количеству - меняем на сортировку по количеству на складе
    */
   protected function getSort()
   {
      $oldSort = parent::getSort();
      
      $newSort = [];
      foreach ($oldSort as $k => $v)
      {
         if ($k === 'AVAILABLE')
         {
            $k = 'STORE_AMOUNT_' . getStoreId();
         }
         
         $newSort[$k] = $v;
      }
      
      return $newSort;
   }
   
   /*
    *  Если есть фильтрация по количеству - меняем на фильтрацию по количеству на складе
    */
   protected function getFilter()
   {
      $oldFilter = parent::getFilter();
      
      $newFilter = [];
      foreach ($oldFilter as $k => $v)
      {
         if ($k === 'AVAILABLE')
         {
            $k = '>STORE_AMOUNT_' . getStoreId();
            $v = '0';
         }
         
         $newFilter[$k] = $v;
      }
      
      return $newFilter;
   }

}

Тут мы переопределили показываемые цены пользователю в методе onPrepareComponentParams(), изменили количество товара доступного в методе getIblockElements(), изменили сортировку (если она есть) с сортировки по стандартному полю наличия товара на количество на складе в методе getSort(), изменили фильтр по наличию (если есть) по наличию на складе.

Для простоты просто подключим файл с трейтом CatalogComponentElementsTrait в init.php:

require __DIR__ . '/catalogelementstrait.php';

После этого у пользователя при смене города будут меняться остатки и цены в каталоге. Но корзину надо дорабатывать отдельно.

Меняем цены и остатки в корзине

Первым делом скопируем компонент sale.basket.basket к нам в папку /local/components/bitrix, в нем откроем файл /local/components/bitrix/sale.basket.basket/class.php

Туда сохраним это:

<?
require_once($_SERVER['DOCUMENT_ROOT'].'/bitrix/components/bitrix/sale.basket.basket/class.php');

use Bitrix\Catalog;

class CBitrixBasketComponentCustom extends CBitrixBasketComponent
{
   public function getAvailableQuantity($basketItems)
   {
      if (empty($basketItems) || !is_array($basketItems))
      {
         return [];
      }

      if (!self::includeCatalog())
      {
         return $basketItems;
      }

      $elementIds = [];
      $productMap = [];

      foreach ($basketItems as $key => $item)
      {
         $elementIds[$item['PRODUCT_ID']] = $item['PRODUCT_ID'];

         if (!isset($productMap[$item['PRODUCT_ID']]))
         {
            $productMap[$item['PRODUCT_ID']] = [];
         }

         $productMap[$item['PRODUCT_ID']][] = $key;
      }

      unset($key, $item);

      if (!empty($elementIds))
      {
         sort($elementIds);
         
         /*
          * Устанавливаем параметры товаров
          */
         $productIterator = Catalog\ProductTable::getList([
            'select' => ['ID', 'QUANTITY_TRACE', 'CAN_BUY_ZERO'],
            'filter' => ['@ID' => $elementIds],
         ]);
         while ($product = $productIterator->fetch())
         {
            if (!isset($productMap[$product['ID']]))
               continue;

            $check = ($product['QUANTITY_TRACE'] == 'Y' && $product['CAN_BUY_ZERO'] == 'N' ? 'Y' : 'N');
            foreach ($productMap[$product['ID']] as $key)
            {
               $basketItems[$key]['AVAILABLE_QUANTITY'] = 0;
               $basketItems[$key]['CHECK_MAX_QUANTITY'] = $check;
            }
            
            unset($key, $check);
         }

         /*
          * Устанавливаем максимальное количество для покупки со склада
          */
         $storeIterator = Catalog\StoreProductTable::getList([
            'filter' => [
               '=PRODUCT_ID' => $elementIds,
               'STORE_ID' => getStoreId(),
            ],
         ]);

         while($store = $storeIterator->fetch())
         {
            foreach ($productMap[$store['PRODUCT_ID']] as $key)
            {
               $basketItems[$key]['AVAILABLE_QUANTITY'] = $store['AMOUNT'];
            }
         }

         unset($product, $productIterator);
      }

      return $basketItems;
   }
}

Этим мы изменили получение доступного количества товара, остальной функционал оставили стандартный. Метод getAvailableQuantity в компоненте только визуально ограничивает количество товара, есть еще проверка в модуле Интернет магазин, при изменении заказа.

Снова переходим к файлу /local/php_interface/init.php

Подключим менеджер событий:

$eventManager = Bitrix\Main\EventManager::getInstance();

Установим свой провайдер товаров (это такой посредник между модулями интернет магазина и каталога), через него корзина проверяет количество товара:

$eventManager->addEventHandler('sale', 'OnBeforeBasketAdd', function(&$arFields){
   $arFields['PRODUCT_PROVIDER_CLASS'] = 'CustomCatalogProvider';
});

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

Копируем файл /bitrix/modules/catalog/lib/product/catalogprovider.php в папку /local/php_interface/, в файле заменяем все объявления приватных методов, на защищенные. Пример:

Было:

private function getData(array $products, array $options = array())

Стало:

protected function getData(array $products, array $options = array())

И еще сменим в этом файле имя класса с CatalogProvider на OriginalCatalogProvider.

В той же папке создадим файл /local/php_interface/customcatalogprovider.php.

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

<?php

class CustomCatalogProvider extends Bitrix\Catalog\Product\OriginalCatalogProvider
{
   /*
    * Подменяем в корзине количество доступного товара количеством с конкретного склада
    */
   protected static function getCatalogProducts(array $list, array $select)
   {
      $resultList = parent::getCatalogProducts($list, $select);
      
      $iterator = \Bitrix\Catalog\StoreProductTable::getList([
         'filter' => [
            '=PRODUCT_ID' => array_keys($resultList),
            'STORE_ID' => getStoreId(),
         ],
      ]);
      
      while($row = $iterator->fetch())
      {
         $resultList[$row['PRODUCT_ID']]['QUANTITY'] = $row['AMOUNT'];
      }
      
      return $resultList;
   }
   
}

Оба этих файл подключим в init.php, так же подключив модули каталога и магазина, чтобы классы не ругались:

Bitrix\Main\Loader::includeModule('catalog');
Bitrix\Main\Loader::includeModule('sale');
require __DIR__ . '/catalogprovider.php';
require __DIR__ . '/customcatalogprovider.php';

Установим региональные цены в корзине (используя тот же файл init.php):

$eventManager->addEventHandler('catalog', 'OnGetOptimalPrice', function(
   $productId,
   $quantity = 1,
   $arUserGroups = [],
   $renewal = "N",
   $arPrices = [],
   $siteID = false,
   $arDiscountCoupons = false)
{
   // Через $isLoop убираем рекурсию, так как CCatalogProduct::GetOptimalPrice снова вызовет эту функцию
   static $isLoop = false;
   
   if ($isLoop)
   {
      return true;
   }
   
   $priceIterator = Bitrix\Catalog\PriceTable::getList(array(
      'select' => array('ID', 'CATALOG_GROUP_ID', 'PRICE', 'CURRENCY'),
      'filter' => array(
         '=PRODUCT_ID' => $productId,
         '@CATALOG_GROUP_ID' => getPriceId(),
         array(
            'LOGIC' => 'OR',
            '<=QUANTITY_FROM' => $quantity,
            '=QUANTITY_FROM' => null
         ),
         array(
            'LOGIC' => 'OR',
            '>=QUANTITY_TO' => $quantity,
            '=QUANTITY_TO' => null
         )
      ),
      'order' => array('CATALOG_GROUP_ID' => 'ASC')
   ));
   
   $isLoop = true;
   $prices = CCatalogProduct::GetOptimalPrice($productId, $quantity, $arUserGroups, $renewal, $priceIterator->fetchAll(), $siteID, $arDiscountCoupons);
   $isLoop = false;
   
   return $prices;
});

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

Конечно, хорошо бы было, если бы через событие можно было менять результат метода CCatalogProduct::getAllowedPriceTypes(), но и спасибо на том, что есть.

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

$eventManager->addEventHandler('sale', 'OnSaleOrderBeforeSaved', function($event){
   $order = $event->getParameter("ENTITY");
   $isNew = (empty($oldValues) or empty($oldValues['STATUS_ID']));
   
   $shipmentCollection = $order->getShipmentCollection();
   foreach ($shipmentCollection as $shipment)
   {
      $shipment->setStoreId(getStoreId());
      
      $shipmentItemCollection = $shipment->getShipmentItemCollection();
      foreach ($shipmentItemCollection as $shipmentItem)
      {
         $shipmentItemStoreCollection = $shipmentItem->getShipmentItemStoreCollection();
         
         if (! count($shipmentItemStoreCollection))
         {
            $basketItem = $shipmentItem->getBasketItem();
            $shipmentItemStore = $shipmentItemStoreCollection->createItem($basketItem);
            $shipmentItemStore->setField('BASKET_ID', $shipmentItem->getField('BASKET_ID'));
            $shipmentItemStore->setField('ORDER_DELIVERY_BASKET_ID', $basketItem->getField('ORDER_DELIVERY_BASKET_ID'));
            $shipmentItemStore->setField('STORE_ID', $shipment->getStoreId());
            $shipmentItemStore->setField('QUANTITY', $shipmentItem->getField('QUANTITY'));
         }
      }
   }
   
   $event->addResult(
      new Bitrix\Main\EventResult(
         Bitrix\Main\EventResult::SUCCESS, $order
      )
   );
});

В теории отгрузке достаточно установить склад через $shipment->setStoreId(getStoreId()), но у меня этот вызов ничего не меняет, поэтому установим склад каждому товару вручную.

Итог

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

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

Кеш компонента каталога будет правильно отрабатывать при смене города, так как меняется параметр выбранного типа цен.

Ссылка на получившуюся папку local https://disk.yandex.ru/d/wKYfjTHv-7tlug

Что вам ещё доработать

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

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

Не получится сделать работу сортировки "Отображать не доступные товары в конце". Сортировка получится не просто по наличию, а по количеству. То есть товары с большим остатком будут всегда выше.

Но всё это решаемо 😀

Пожалуйста, оцените на сколько вам понравилась статья!
Голосов: 7 Среднее: 4.9