Региональные цены и остатки по складам в Битрикс
Задача
Нужно, чтобы на витрине и в корзине пользователь видел цену и доступное количество в зависимости от выбранного города (или другого признака — филиала, юрлица). Ниже — схема на БУС с модулем торгового каталога и «Интернет-магазин»: состояние региона храним в cookie, к типам цен и складам привязываемся через свои справочники в PHP.
Состояние региона в init.php
Параметром в query-string задаём код «хаба», сохраняем его в cookie на год и используем во всех последующих расчётах. Идентификаторы складов и типов цен подставьте свои из административки.
<?php
if (!empty($_GET['rgn_hub'])) {
setcookie('rgn_hub', $_GET['rgn_hub'], time() + 31536000, '/');
$_COOKIE['rgn_hub'] = $_GET['rgn_hub'];
}
function rgnActiveHubCode(): string {
return isset($_COOKIE['rgn_hub']) ? (string)$_COOKIE['rgn_hub'] : 'msk';
}
function rgnWarehouseId(): int {
$warehouseByHub = ['msk' => 12, 'spb' => 18, 'nsk' => 26];
$code = rgnActiveHubCode();
return isset($warehouseByHub[$code]) ? (int)$warehouseByHub[$code] : (int)reset($warehouseByHub);
}
function rgnPriceCatalogGroupId(): int {
$priceByHub = ['msk' => 5, 'spb' => 7, 'nsk' => 9];
$code = rgnActiveHubCode();
return isset($priceByHub[$code]) ? (int)$priceByHub[$code] : (int)reset($priceByHub);
}Переключатель в шаблоне
В шапке соберите ссылки на текущий URL с разными значениями rgn_hub. Для подсветки активного пункта используйте классы верстки шаблона, без инлайновых стилей в PHP.
Пример формирования суффикса запроса: <code><?= htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8') . '?rgn_hub=spb' ?></code> — при необходимости объединяйте с уже существующими GET-параметрами через разбор и сбор массива параметров.
Склады и типы цен до включения логики
Создайте нужное число складов и типов цен. Распределите остатки по складам во вкладке торгового каталога до перехода на многокладовый режим, если планируете потом вести количество только через обмен или API: после активации складской учёта ручное редактирование общего количества в карточке ограничивается правилами продукта.
Суммарный доступный остаток должен быть не меньше суммы по складам — иначе при отгрузках возможны коллизии. Резервирование логично переносить на этап отгрузки, если по бизнес-процессу нельзя резервировать дробно по региональным ячейкам.
Каталог: копии компонентов и трейт
В /local/components/bitrix/ размещают копии catalog.element и catalog.section. Классы можно оформить минимальными наследниками стандартных и подключить один трейт, чтобы не дублировать логику между списком и карточкой.
Файл с трейтом, например /local/php_interface/rgn_catalog_trait.php, подключите из init.php директивой require __DIR__ . '/rgn_catalog_trait.php'; до вывода каталога.
<?php
trait RgnCatalogElementsTrait
{
public function onPrepareComponentParams($params)
{
$params = parent::onPrepareComponentParams($params);
$params['PRICE_CODE'] = [rgnActiveHubCode()];
return $params;
}
protected function getIblockElements($elementIterator)
{
$rows = parent::getIblockElements($elementIterator);
if (!$rows) {
return $rows;
}
foreach ($rows as $pk => $row) {
$rows[$pk] = $this->rgnPatchElementQuantity($row);
}
$warehouseId = rgnWarehouseId();
$res = \Bitrix\Catalog\StoreProductTable::getList([
'filter' => [
'=PRODUCT_ID' => array_keys($rows),
'STORE_ID' => $warehouseId,
],
]);
while ($sp = $res->fetch()) {
$pid = (int)$sp['PRODUCT_ID'];
if (isset($rows[$pid])) {
$rows[$pid] = $this->rgnPatchElementQuantity($rows[$pid], (float)$sp['AMOUNT']);
}
}
return $rows;
}
protected function rgnPatchElementQuantity($row, $qty = 0.0)
{
if (isset($row['PRODUCT']['QUANTITY'])) {
$row['PRODUCT']['QUANTITY'] = $qty;
}
if (isset($row['PRODUCT']['AVAILABLE'])) {
$row['PRODUCT']['AVAILABLE'] = ($qty > 0 ? 'Y' : 'N');
}
if (isset($row['CATALOG_QUANTITY'])) {
$row['CATALOG_QUANTITY'] = $qty;
$row['~CATALOG_QUANTITY'] = $qty;
}
return $row;
}
protected function getSort()
{
$merged = [];
foreach (parent::getSort() as $field => $direction) {
if ($field === 'AVAILABLE') {
$field = 'STORE_AMOUNT_' . rgnWarehouseId();
}
$merged[$field] = $direction;
}
return $merged;
}
protected function getFilter()
{
$merged = [];
foreach (parent::getFilter() as $field => $value) {
if ($field === 'AVAILABLE') {
$field = '>STORE_AMOUNT_' . rgnWarehouseId();
$value = '0';
}
$merged[$field] = $value;
}
return $merged;
}
}В catalog.element/class.php и catalog.section/class.php остаётся подключить штатный класс компонента и объявить наследника с use RgnCatalogElementsTrait; — имена классов должны совпасть с теми, что указаны в параметрах компонентов на сайте.
Корзина: лимит по складу
Копируете sale.basket.basket в /local/components/bitrix/ и переопределяете метод расчёта доступного количества: сначала обнуляете расчёт по умолчанию для позиций, затем подставляете AMOUNT из StoreProductTable для выбранного склада.
<?php
require_once $_SERVER['DOCUMENT_ROOT'].'/bitrix/components/bitrix/sale.basket.basket/class.php';
use Bitrix\Catalog;
class RgnBasketViewComponent extends CBitrixBasketComponent
{
public function getAvailableQuantity($basketItems)
{
if (empty($basketItems) || !is_array($basketItems)) {
return [];
}
if (!self::includeCatalog()) {
return $basketItems;
}
$productIds = [];
$keysByProduct = [];
foreach ($basketItems as $basketKey => $line) {
$pid = (int)$line['PRODUCT_ID'];
$productIds[$pid] = $pid;
if (!isset($keysByProduct[$pid])) {
$keysByProduct[$pid] = [];
}
$keysByProduct[$pid][] = $basketKey;
}
sort($productIds);
$traceIter = Catalog\ProductTable::getList([
'select' => ['ID', 'QUANTITY_TRACE', 'CAN_BUY_ZERO'],
'filter' => ['@ID' => array_values($productIds)],
]);
while ($prod = $traceIter->fetch()) {
if (!isset($keysByProduct[$prod['ID']])) {
continue;
}
$needCap = (
$prod['QUANTITY_TRACE'] === 'Y' && $prod['CAN_BUY_ZERO'] === 'N'
? 'Y'
: 'N'
);
foreach ($keysByProduct[$prod['ID']] as $bk) {
$basketItems[$bk]['AVAILABLE_QUANTITY'] = 0;
$basketItems[$bk]['CHECK_MAX_QUANTITY'] = $needCap;
}
}
$wh = Catalog\StoreProductTable::getList([
'filter' => [
'=PRODUCT_ID' => array_values($productIds),
'STORE_ID' => rgnWarehouseId(),
],
]);
while ($row = $wh->fetch()) {
foreach ($keysByProduct[$row['PRODUCT_ID']] as $bk) {
$basketItems[$bk]['AVAILABLE_QUANTITY'] = $row['AMOUNT'];
}
}
return $basketItems;
}
}Провайдер каталога и события
Чтобы фактическое ограничение количества в заказе совпало с витриной, через обработчик OnBeforeBasketAdd можно подключить свой класс-провайдер. Если штатный CatalogProvider не даёт расширить нужный метод из-за области видимости, копия файла провайдера из модуля в /local/php_interface/ с переименованным классом и заменой private на protected у используемых методов — рабочий, хотя и хрупкий при обновлениях, обходной путь. Наследник переопределяет выборку товарных данных и подставляет остаток с выбранного склада.
Региональную цену в корзине подключают обработчиком OnGetOptimalPrice: статический флаг защитит от рекурсивного входа при повторном вызове GetOptimalPrice.
Для отгрузки по нужному складу обрабатывайте OnSaleOrderBeforeSaved: задаёте склад отгрузке и при необходимости создаёте записи по строкам, если штатный setStoreId не заполняет связи так, как ожидает ваша версия модуля.
<?php
Bitrix\Main\Loader::includeModule('catalog');
Bitrix\Main\Loader::includeModule('sale');
$ev = Bitrix\Main\EventManager::getInstance();
$ev->addEventHandler('sale', 'OnBeforeBasketAdd', function (&$fields) {
$fields['PRODUCT_PROVIDER_CLASS'] = 'RgnCatalogProductProvider';
});
class RgnCatalogProductProvider extends Bitrix\Catalog\Product\OriginalCatalogProvider
{
protected static function getCatalogProducts(array $list, array $select)
{
$byId = parent::getCatalogProducts($list, $select);
$q = \Bitrix\Catalog\StoreProductTable::getList([
'filter' => [
'=PRODUCT_ID' => array_keys($byId),
'STORE_ID' => rgnWarehouseId(),
],
]);
while ($row = $q->fetch()) {
$byId[$row['PRODUCT_ID']]['QUANTITY'] = $row['AMOUNT'];
}
return $byId;
}
}
$ev->addEventHandler('catalog', 'OnGetOptimalPrice', function (
$productId,
$quantity = 1,
$siteUserGroups = [],
$renewal = 'N',
$alreadyLoadedPrices = [],
$siteId = false,
$couponsList = false
) {
static $inOptimalGuard = false;
if ($inOptimalGuard) {
return true;
}
$inOptimalGuard = true;
$subset = \Bitrix\Catalog\PriceTable::getList([
'select' => ['ID', 'CATALOG_GROUP_ID', 'PRICE', 'CURRENCY'],
'filter' => [
'=PRODUCT_ID' => $productId,
'@CATALOG_GROUP_ID' => rgnPriceCatalogGroupId(),
[
'LOGIC' => 'OR',
'<=QUANTITY_FROM' => $quantity,
'=QUANTITY_FROM' => null,
],
[
'LOGIC' => 'OR',
'>=QUANTITY_TO' => $quantity,
'=QUANTITY_TO' => null,
],
],
'order' => ['CATALOG_GROUP_ID' => 'ASC'],
]);
$pricesPayload = [];
while ($p = $subset->fetch()) {
$pricesPayload[] = $p;
}
$opt = CCatalogProduct::GetOptimalPrice(
$productId,
$quantity,
$siteUserGroups,
$renewal,
$pricesPayload,
$siteId,
$couponsList
);
$inOptimalGuard = false;
return $opt;
});
$ev->addEventHandler('sale', 'OnSaleOrderBeforeSaved', function ($event) {
/** @var \Bitrix\Sale\Order $order */
$order = $event->getParameter('ENTITY');
foreach ($order->getShipmentCollection() as $shipment) {
if ($shipment->isSystem()) {
continue;
}
$shipment->setStoreId(rgnWarehouseId());
foreach ($shipment->getShipmentItemCollection() as $shipmentItem) {
$stores = $shipmentItem->getShipmentItemStoreCollection();
if (count($stores)) {
continue;
}
$basketRow = $shipmentItem->getBasketItem();
$lnk = $stores->createItem($basketRow);
$lnk->setField('BASKET_ID', $shipmentItem->getField('BASKET_ID'));
$lnk->setField(
'ORDER_DELIVERY_BASKET_ID',
$basketRow->getField('ORDER_DELIVERY_BASKET_ID')
);
$lnk->setField('STORE_ID', $shipment->getStoreId());
$lnk->setField('QUANTITY', $shipmentItem->getField('QUANTITY'));
}
}
$event->addResult(
new Bitrix\Main\EventResult(Bitrix\Main\EventResult::SUCCESS, $order)
);
});Композитный кеш и параметры компонента
При смене региона у пользователя меняется набор параметров типа цены, поэтому кеш страниц каталога естественно разделяется по cookie и строке запроса. Опцию «ставить недоступные позиции в конец» нужно трактовать как упорядочивание по полю склада — фактический порядок будет по числовому количеству, а не бинарному «есть/нет».
Ограничения подхода
- Пока cookie региона совпадает с выбором клиента у терминала в публичной части, ручное редактирование отгрузок из админки может конфликтовать с тем же браузером — учитывайте отдельные политики для бэкенда.
- Списание по складу выполняется на этапе отгрузки; при необходимости автоматизируйте переход статусов.
- Наследование от стандартных компонентов и патч файла провайдера может потребовать повторной проверки после крупных обновлений БУС.
Не хотите копаться сами?
Починю за 1-3 дня. Без предоплаты — оплата по результату.
15+ лет опыта с 1С-Битрикс · Без предоплаты · 7 дней гарантии