Yandex Metrika
sanches.free 19 просмотров

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

Задача

Нужно, чтобы на витрине и в корзине пользователь видел цену и доступное количество в зависимости от выбранного города (или другого признака — филиала, юрлица). Ниже — схема на БУС с модулем торгового каталога и «Интернет-магазин»: состояние региона храним в 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 дней гарантии