Привет, Хабр! Это снова Алиса из сериала про Laravel рядом с Битриксом. В первой части мы аккуратно подселили Laravel к Битриксу. Во второй — растащили события, авторизацию и тяжелую логику по нормальным сервисам, а в третьей — перестали мучить каталог SQL-запросами и отдали поиск OpenSearch.
Теперь добрались до места, где любой e-commerce начинает показывать характер: корзина и расчет заказа. Это каталог может тормозить незаметно. А вот если корзина начинает чудить — это уже чувствует бюджет.
Адаптер корзины: Redis вместо археологии в b_sale_*
Корзина в Битриксе — вещь историческая. Она пережила, кажется, все эпохи веба: iframe-оплаты, jQuery-калькуляторы, XML-обмены с 1С и фронты, написанные временно на Vue. И чем больше проект, тем сильнее это чувствуется.
Пока пользователей немного, еще терпимо. Но стоит нагрузить систему нормальным e-commerce сценарием, начинаются спецэффекты:
пользователь открывает сайт в трех вкладках;
фронт дергает API каждые 200 мс;
скидки пересчитываются каскадом;
количество товара внезапно «откатывается»;
b_sale_basket превращается в место археологических раскопок.
Особенно весело в highload-периоды. Черная пятница быстро показывает, где в системе были компромиссы. Мы решили сделать ровно то, что уже делали с каталогом: вынести горячее чтение и запись в отдельный слой. Корзина переехала в Laravel + Redis. Это намного лучше подходит для объекта, который меняется каждые несколько секунд и живет короткими циклами.
Совместимость при этом никуда не девается, мы не пытаемся отменить Битрикс и переписать sale-модуль. Он по-прежнему остается главным по коммерческим правилам. Laravel хранит и быстро обновляет состояние корзины в Redis, а Битрикс забирает ее, раскладывает в свои структуры, прогоняет через скидки, акции и ограничения, а затем возвращает итоговый расчет.
Позже, когда подключается Pricing API, вычисление цен уходит уже в отдельный сервис. У Битрикса остаются только необходимые проверки и финальная сборка заказа, без постоянных тяжелых пересчетов при каждом изменении корзины.
Модель данных и поток
У корзины всегда есть владелец, авторизованный пользователь или гость. Для гостей создаем долгоживущий cookie cgid с UUID, чтобы корзина не исчезала после первого закрытия вкладки и спокойно переживала возвращение пользователя через день или неделю. Ключ в Redis такой: cart:{owner}. Внутри компактный JSON с ревизией и позициями. Расчетные поля специально пустые, их заполнит Битрикс (до внедрения В2) или наш Pricing API (после).
Скрытый текст
Пример содержимого cart:{user-42}:
{ "owner": "user:42", "revision": 7, "currency": "RUB", "lines": [ { "lineId": "p-SKU123:color=red:size=M", "sku": "SKU123", "qty": 2, "attrs": {"color":"red","size":"M"}, "meta": {"addedAt": 1724820000} } ], "coupons": [], "delivery": {"cityId": 77}, "notes": null }
Чтобы корзина не теряла изменения при параллельных запросах, у каждой версии есть свой номер ревизии. Любое обновление выполняется только поверх актуального состояния.
revision — простая, но очень полезная защита от потерянных обновлений. Каждое изменение корзины происходит только поверх актуальной версии. Если пользователь за это время успел поменять что-то в соседней вкладке или фронт отправил параллельный запрос, старая ревизия уже не совпадает.
Гость, пользователь, две вкладки — и одна корзина
Сначала определяем, чья это корзина. У авторизованного пользователя все просто: владелец — user:{id}. У гостя своего ID еще нет, поэтому выдаем ему долгоживущую куку cgid с UUID и работаем с владельцем guest:{uuid}.
Так корзина не теряется между визитами и не зависит от PHP-сессии. Человек может зайти на сайт, набрать товары, закрыть вкладку, вернуться позже — корзина останется на месте.
Когда гость логинится, у нас появляется две корзины: гостевая и пользовательская. В этот момент их нужно аккуратно слить: объединить позиции, сохранить количество, применить актуальные правила и уже дальше работать с корзиной user:{id}.
<?php namespace App\Cart; use Illuminate\Http\Request; use Illuminate\Support\Str; final class OwnerResolver { public function resolve(Request $r): string { if ($r->user()) { return 'user:' . $r->user()->getAuthIdentifier(); } $gid = (string) $r->cookie('cgid', ''); if ($gid === '') { $gid = (string) Str::uuid(); // cookie установим в ответе контроллера (ниже) app()->instance('cart.set_guest_cookie', $gid); } return 'guest:' . $gid; } }
Redis-корзина без дублей и потерянных обновлений
Корзину храним в Redis обычной JSON-строкой. Для такого сценария это оказалось практичнее, чем раскладывать данные по нескольким Redis-структурам и потом синхронизировать изменения между ключами.
Обновления проходят через WATCH/MULTI и проверку ревизии корзины. Если параллельный запрос успел изменить состояние раньше, Redis не даст тихо перезаписать данные поверх новой версии.
От повторных запросов защищаемся через Idempotency-Key. Если фронт ретрайнул запрос, пользователь обновил страницу или дважды кликнул кнопку, одинаковая комбинация owner + key повторно уже не применяется. Для корзины это важная страховка, ведь операции предсказуемы даже под нагрузкой.
Скрытый текст
<?php namespace App\Cart; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Redis; final class CartRepository { public function get(string $owner): array { $raw = Redis::get($this->key($owner)); if (! $raw) { return [ 'owner' => $owner, 'revision' => 0, 'currency' => 'RUB', 'lines' => [], 'coupons' => [], 'delivery' => null, 'notes' => null, ]; } return \json_decode($raw, true, flags: \JSON_THROW_ON_ERROR); } public function put(string $owner, array $snapshot, int $expectedRevision): array { $key = $this->key($owner); Redis::watch($key); $current = $this->get($owner); if ($current['revision'] !== $expectedRevision) { Redis::unwatch(); throw new \RuntimeException('Revision conflict'); } $snapshot['revision'] = $expectedRevision + 1; Redis::multi(); Redis::setex($key, 60 * 60 * 24 * 14, \json_encode($snapshot, \JSON_UNESCAPED_UNICODE)); $ok = Redis::exec(); if ($ok === null) { throw new \RuntimeException('CAS failed'); } return $snapshot; } public function idempotent(string $owner, string $operationKey, \Closure $callback): array { $cacheKey = 'idem:cart:' . md5($owner . '|' . $operationKey); if ($res = Cache::get($cacheKey)) { return $res; } $res = $callback(); Cache::put($cacheKey, $res, 300); return $res; } private function key(string $owner): string { return 'cart:' . $owner; } }
Контроллер корзины и слияние гостя при логине
Контроллер принимает Idempotency-Key и If-Match с текущей ревизией корзины. Это защищает от повторных запросов и потерянных обновлений между вкладками. Для гостей используем cgid: если приходит гостевой маркер, Laravel выставляет куку с UUID и работает с корзиной как с guest:{uuid}.
Скрытый текст
<?php namespace App\Http\Controllers; use App\Cart\CartRepository; use App\Cart\OwnerResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class CartController { public function __construct( private readonly CartRepository $repo, private readonly OwnerResolver $owners ) {} public function get(Request $r): JsonResponse { $owner = $this->owners->resolve($r); $cart = $this->repo->get($owner); $resp = response()->json(['ok' => true, 'cart' => $cart]) ->withHeaders(['ETag' => (string) $cart['revision']]); if ($gid = app('cart.set_guest_cookie', null)) { $resp->cookie('cgid', (string) $gid, 60 * 24 * 30, '/', '.example.ru', true, true, false, 'Lax'); } return $resp; } public function add(Request $r): JsonResponse { $owner = $this->owners->resolve($r); $data = $r->validate([ 'sku' => ['required', 'string', 'max:64'], 'qty' => ['required', 'numeric', 'min:0.001'], 'attrs' => ['array'], 'idempotency' => ['nullable', 'string', 'max:128'], ]); $opKey = $data['idempotency'] ?? (string) $r->header('Idempotency-Key', ''); $rev = (int) $r->header('If-Match', -1); $res = $this->repo->idempotent($owner, 'add:' . $opKey, function () use ($owner, $data, $rev) { $snap = $this->repo->get($owner); $this->assertRevision($snap, $rev); $lineId = $this->lineId($data['sku'], (array) ($data['attrs'] ?? [])); $lines = $snap['lines']; $found = null; foreach ($lines as $i => $ln) { if ($ln['lineId'] === $lineId) { $found = $i; break; } } if ($found === null) { $lines[] = [ 'lineId' => $lineId, 'sku' => (string) $data['sku'], 'qty' => (float) $data['qty'], 'attrs' => (array) ($data['attrs'] ?? []), 'meta' => ['addedAt' => time()], ]; } else { $lines[$found]['qty'] += (float) $data['qty']; } $snap['lines'] = \array_values($lines); $saved = $this->repo->put($owner, $snap, (int) $snap['revision']); return ['ok' => true, 'cart' => $saved]; }); return response()->json($res)->withHeaders(['ETag' => (string) $res['cart']['revision']]); } public function patchLine(Request $r, string $lineId): JsonResponse { $owner = $this->owners->resolve($r); $data = $r->validate([ 'qty' => ['required', 'numeric', 'min:0'], ]); $rev = (int) $r->header('If-Match', -1); $snap = $this->repo->get($owner); $this->assertRevision($snap, $rev); $lines = $snap['lines']; foreach ($lines as $i => $ln) { if ($ln['lineId'] === $lineId) { if ($data['qty'] <= 0) { unset($lines[$i]); } else { $lines[$i]['qty'] = (float) $data['qty']; } break; } } $snap['lines'] = \array_values($lines); $saved = $this->repo->put($owner, $snap, (int) $snap['revision']); return response()->json(['ok' => true, 'cart' => $saved]) ->withHeaders(['ETag' => (string) $saved['revision']]); } public function clear(Request $r): JsonResponse { $owner = $this->owners->resolve($r); $rev = (int) $r->header('If-Match', -1); $snap = $this->repo->get($owner); $this->assertRevision($snap, $rev); $snap['lines'] = []; $snap['coupons'] = []; $saved = $this->repo->put($owner, $snap, (int) $snap['revision']); return response()->json(['ok' => true, 'cart' => $saved]) ->withHeaders(['ETag' => (string) $saved['revision']]); } public function mergeOnLogin(Request $r): JsonResponse { // вызывается после успешного логина $userOwner = 'user:' . $r->user()->getAuthIdentifier(); $guestOwner = 'guest:' . (string) $r->cookie('cgid', ''); if ($guestOwner === 'guest:' || $guestOwner === $userOwner) { return response()->json(['ok' => true]); } $u = $this->repo->get($userOwner); $g = $this->repo->get($guestOwner); // простая стратегия: сложить qty одинаковых lineId $map = []; foreach (array_merge($u['lines'], $g['lines']) as $ln) { $map[$ln['lineId']] = isset($map[$ln['lineId']]) ? ['...merge...' => true, 'lineId' => $ln['lineId'], 'sku' => $ln['sku'], 'qty' => $map[$ln['lineId']]['qty'] + $ln['qty'], 'attrs' => $ln['attrs'], 'meta' => $ln['meta']] : $ln; } $u['lines'] = \array_values($map); $saved = $this->repo->put($userOwner, $u, (int) $u['revision']); // гостевую корзину очищаем \Illuminate\Support\Facades\Redis::del('cart:' . $guestOwner); return response()->json(['ok' => true, 'cart' => $saved]) ->withHeaders(['ETag' => (string) $saved['revision']]) ->cookie('cgid', '', -1, '/', '.example.ru', true, true, false, 'Lax'); } private function lineId(string $sku, array $attrs): string { ksort($attrs); return 'p-' . $sku . ':' . \http_build_query($attrs, '', '&', \PHP_QUERY_RFC3986); } private function assertRevision(array $snap, int $rev): void { if ($rev < 0) { throw new \InvalidArgumentException('Missing If-Match header'); } if ($snap['revision'] !== $rev) { throw new \RuntimeException('Revision conflict'); } } }
Теперь операции с корзиной укладываются в десятки миллисекунд по TTFB: добавление, изменение количества, удаление позиций происходят быстро и без походов в тяжелые таблицы b_sale_*.
Маршруты:
<?php use App\Http\Controllers\CartController; use Illuminate\Support\Facades\Route; Route::prefix('api/v1/cart')->group(function (): void { Route::get('/', [CartController::class, 'get']); Route::post('/', [CartController::class, 'add']); Route::patch('/{lineId}', [CartController::class, 'patchLine']); Route::delete('/', [CartController::class, 'clear']); Route::post('/merge', [CartController::class, 'mergeOnLogin'])->middleware('bitrix.jwt'); });
Дальше остается следующий этап — материализовать корзину в структуры Битрикса, чтобы применить его правила скидок, доставки и расчета заказа. До перехода на собственный Pricing API Битрикс все еще отвечает за коммерческую логику, но получает уже готовое и консистентное состояние корзины.
Битрикс и материализация корзины для sale-правил
Корзина живет в Redis и читается через Laravel API, но правила скидок и оформления пока остаются в Битриксе. Поэтому перед расчетом Битрикс забирает актуальный срез корзины, проверяет ревизию и временно собирает ее в своем sale-контуре.
b_sale_basket в этой схеме расчетный слой для совместимости. Страницы «Корзина» и «Оформление» читают состояние из API, а Битрикс подключается в момент, когда нужно применить скидки, доставку и собрать заказ.
После подтверждения checkout заказ создается из проверенного среза. Так мы не теряем совместимость с правилами Битрикса, но убираем постоянные записи в b_sale_basket при каждом клике пользователя. Корзина такая же быстрая, а sale-модуль занимается тем, ради чего его и оставили.
Пример безопасного клиента в Битрикс для получения среза и подготовки расчета:
<?php use Bitrix\Main\Web\HttpClient; use Bitrix\Main\Web\Json; use Bitrix\Main\Diag\Helper as DiagHelper; function cart_snapshot(): array { $http = new HttpClient(['socketTimeout' => 2, 'streamTimeout' => 2, 'retries' => 1]); $http->setHeader('X-Request-Id', DiagHelper::getRequestId()); $http->query('GET', 'https://api.example.ru/api/v1/cart/'); $res = Json::decode((string) $http->getResult()); return (array) ($res['cart'] ?? []); }
А теперь материализация корзины для расчета:
Скрытый текст
<?php use Bitrix\Sale\Basket; use Bitrix\Sale\Fuser; use Bitrix\Catalog\ProductTable; function cart_materialize_for_pricing(array $cart): Basket { $fuserId = Fuser::getId(); $siteId = SITE_ID; $basket = Basket::create($siteId); foreach ($cart['lines'] as $ln) { $skuCode = (string) $ln['sku']; $qty = (float) $ln['qty']; // найдем ID товара по SKU (у вас может быть свой маппинг) $productId = findProductIdBySku($skuCode); $item = $basket->createItem('catalog', $productId); $item->setField('QUANTITY', $qty); $item->setField('CURRENCY', $cart['currency'] ?? 'RUB'); $item->setField('LID', $siteId); $item->setField('PRODUCT_PROVIDER_CLASS', \Bitrix\Catalog\Product\CatalogProvider::class); // перенесите нужные атрибуты в PROPS, если правила скидок на них завязаны foreach ((array) ($ln['attrs'] ?? []) as $code => $val) { $item->getPropertyCollection()->setProperty([ 'NAME' => $code, 'CODE' => $code, 'VALUE' => (string) $val, 'SORT' => 100, ]); } } $basket->refreshData(['PRICE', 'COUPONS', 'DISCOUNT']); return $basket; } function findProductIdBySku(string $sku): int { // ваш быстрый маппинг SKU → PRODUCT_ID (например, из витрины svc_catalog_sku) $row = \Bitrix\Main\Application::getConnection()->query(" SELECT product_id FROM svc_catalog_sku WHERE sku = '" . \Bitrix\Main\Application::getConnection()->getSqlHelper()->forSql($sku) . "' LIMIT 1 ")->fetch(); return (int) ($row['product_id'] ?? 0); }
На этом этапе Битрикс применяет все, ради чего его обычно и держат в коммерции: купоны, сегменты, накопительные и составные скидки, ограничения доставки, правила для групп пользователей. На фронт уходит итоговая сумма в том виде, в котором модуль sale умеет считать ее годами.
При этом сама схема уже готова к следующему шагу. Когда появится собственный Pricing API, вызов cart_materialize_for_pricing можно будет заменить на запрос в новый сервис, не переписывая фронт, корзину или секаут-поток. Для клиента это будет тот же API, для Битрикса — чуть меньше работы, а для команды — возможность развивать расчет цен отдельно от монолита.
Поведение под нагрузкой и деградация
Redis в этой схеме — уже часть основного контура. Если он тормозит, тормозит корзина. Поэтому здесь короткие таймауты, аккуратные ретраи и никакой надежды на то, что пройдет само.
Если Redis недоступен, API корзины отвечает 503. Фронт показывает пользователю, что операция временно недоступна, и не пытается собирать корзину из локального состояния. Потому что локально все всегда почти правильно, а потом внезапно оказывается, что в чекаут ушло другое количество товаров или старый купон.
Отдельная история — несколько вкладок. Разработчики e-commerce к таким вещам быстро привыкают: пользователь открыл корзину на ноутбуке, потом на телефоне, потом еще где-то обновил количество товаров. Для разработчиков в e-commerce конфликты ревизий — обычная рабочая ситуация.
Решается это довольно спокойно. Клиент получает свежий срез корзины, повторяет операцию уже поверх новой версии и отправляет запрос еще раз. Idempotency-Key закрывает еще одну очень земную проблему — двойные клики и сетевой дребезг. Пользователь нажал кнопку два раза, браузер повторил запрос, мобильная сеть решила помочь и отправила пакет еще раз. А бэк все это воспринимает как одну операцию и не добавляет товар в корзину трижды.
Что получаем
Корзина работает быстро и предсказуемо для любого фронта: сайта, мобильного приложения, B2B-кабинета или партнерского интерфейса. У всех — один API и одна схема данных.
Ревизии защищают от потерянных изменений между вкладками, Idempotency-Key — от дублей, повторных запросов и сетевого дребезга. Пользователь может обновлять количество товаров с нескольких устройств одновременно, а backend все равно соберет одно корректное состояние.
Битрикс при этом сохраняет свою роль в коммерческой логике. Sale-модуль по-прежнему считает скидки, купоны и правила корзины — до момента, когда расчет цен полностью переедет в отдельный Pricing API.
Для пользователя изменения ощущаются очень просто: товары добавляются сразу, количество обновляется без задержек, checkout не рассыпается от нескольких вкладок, а корзина больше не тянет за собой половину b_sale_*. Для ecommerce-проекта это один из самых заметных апгрейдов после ускорения каталога.
API цен и остатков: быстрые ответы с кэшем и короткими таймаутами
После каталога и корзины обычно всплывают еще два тяжелых участка — цены и остатки. В Битриксе это отдельная вселенная со своими типами цен, группами пользователей, резервами, складами и правилами доступа. Пока запросов мало, все выглядит терпимо. Под нагрузкой запросы в b_catalog_price, b_catalog_store_product и прочие рассчёты модуля sale начинают тянуть вниз весь пользовательский путь — от карточки товара до checkout.
Мы пошли тем же путем, что и с каталогом, чтение вынесли в Laravel API. Источник истины остается в Битриксе, а сервис цен и остатков работает на быстрых срезах в Redis, которые обновляются событиями из Outbox. Схема получается довольно спокойная:
Битрикс изменил цену или остаток → событие ушло в Streams → Laravel обновил snapshot в Redis → фронт получил готовый ответ за миллисекунды, без похода в тяжелые таблицы каталога.
При этом API работает с короткими таймаутами и предсказуемой деградацией. Если сервис цен не успевает ответить или Redis временно недоступен, Битрикс использует последний валидный snapshot. Пользователь увидит слегка устаревший остаток, но checkout продолжит работать. Для ecommerce это почти всегда лучше, чем подвесить страницу на несколько секунд ради идеально свежей цифры.
Как раскладываем данные
С ценами все довольно прямолинейно:
b_catalog_groupхранит типы цен — в snapshot они превращаются в ptype;b_catalog_priceдает сами значения цен и валюты;валюты и курсы можно либо нормализовать в базовую валюту, либо хранить как есть;
права групп пользователей на типы цен маппятся в сегменты.
С остатками — два сценария.
Если включен складской учет, собираем данные из b_catalog_store_product и считаем как остатки по складам, так и общий остаток.
Если складского учета нет, берем QUANTITY и QUANTITY_RESERVED из b_catalog_product и считаем доступный остаток как разницу между ними.
Отдельно держим витрину соответствий для SKU и предложений. В Bitrix это место традиционно умеет удивлять даже опытных разработчиков, поэтому нормальная таблица вида sku → product_id экономит много времени и нервов.
Первичное наполнение витрин
Чтобы система сразу работала на готовых данных, один раз делаем массовую выгрузку и наполняем витрины целиком. Пример простого среза для количественного диапазона по умолчанию и активным ценам:
Скрытый текст
INSERT INTO svc_price_snapshot (product_id, ptype, segment, currency, amount, version, updated_at) SELECT p.PRODUCT_ID AS product_id, g.NAME AS ptype, -- например BASE, WHOLESALE 'default' AS segment, p.CURRENCY AS currency, p.PRICE AS amount, UNIX_TIMESTAMP(NOW()) AS version, NOW() AS updated_at FROM b_catalog_price p JOIN b_catalog_group g ON g.ID = p.CATALOG_GROUP_ID WHERE (p.QUANTITY_FROM IS NULL OR p.QUANTITY_FROM <= 1) AND (p.QUANTITY_TO IS NULL OR p.QUANTITY_TO >= 1) AND p.PRICE IS NOT NULL; # Заполнение по остаткам при включенном складском учете INSERT INTO svc_inventory_snapshot (product_id, total, by_warehouse, version, updated_at) SELECT sp.PRODUCT_ID AS product_id, SUM(GREATEST(sp.AMOUNT, 0)) AS total, JSON_ARRAYAGG(JSON_OBJECT('id', s.XML_ID, 'qty', GREATEST(sp.AMOUNT,0))) AS by_warehouse, UNIX_TIMESTAMP(NOW()) AS version, NOW() AS updated_at FROM b_catalog_store_product sp JOIN b_catalog_store s ON s.ID = sp.STORE_ID GROUP BY sp.PRODUCT_ID; # Заполнение по остаткам при выключенном складском учете INSERT INTO svc_inventory_snapshot (product_id, total, by_warehouse, version, updated_at) SELECT cp.ID AS product_id, GREATEST(cp.QUANTITY - cp.QUANTITY_RESERVED, 0) AS total, JSON_ARRAY() AS by_warehouse, UNIX_TIMESTAMP(NOW()) AS version, NOW() AS updated_at FROM b_catalog_product cp;
Перенос изменений цен через Outbox
Outbox и publisher у нас уже есть, теперь подключаем к ним каталог.
Битрикс шлет события OnPriceAdd, OnPriceUpdate и OnPriceDelete. В обработчиках:
определяем ptype по
CATALOG_GROUP_ID;собираем сегментацию по схеме групп и типов цен;
нормализуем сумму и валюту;
пишем в outbox событие
price.changedс монотонной версией.
Дальше событие уходит в Streams, а Laravel обновляет snapshots и Redis без прямых запросов в каталог во время пользовательского трафика.
Скрытый текст
<?php use Bitrix\Main\Application; use Bitrix\Main\Web\Json; use Project\Bitrix\Outbox\OutboxEventTable; AddEventHandler('catalog', 'OnPriceAdd', static function (int $id, array $fields): void { price_event($fields); }); AddEventHandler('catalog', 'OnPriceUpdate', static function (int $id, array $fields): void { price_event($fields); }); function price_event(array $fields): void { if (! isset($fields['PRODUCT_ID'], $fields['CATALOG_GROUP_ID'])) { return; } $ptype = catalog_group_code((int) $fields['CATALOG_GROUP_ID']); // например BASE, WHOLESALE $segment = 'default'; // или вычислите из групп/ролей $amount = isset($fields['PRICE']) ? (float) $fields['PRICE'] : null; $currency = isset($fields['CURRENCY']) ? (string) $fields['CURRENCY'] : null; // монотонная версия на основе микровремени $version = (int) floor(microtime(true) * 1000); $payload = [ 'product_id' => (int) $fields['PRODUCT_ID'], 'ptype' => $ptype, 'segment' => $segment, 'amount' => $amount, 'currency' => $currency, 'version' => $version, 'updated_at' => date('c'), 'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null, ]; $json = Json::encode($payload, JSON_UNESCAPED_UNICODE); $hash = hash('sha256', 'price.changed|' . $payload['product_id'] . '|' . $ptype . '|' . $segment . '|' . $json); OutboxEventTable::add([ 'EVENT_TYPE' => 'price.changed', 'AGGREGATE_TYPE' => 'product.price', 'AGGREGATE_ID' => $payload['product_id'] . ':' . $ptype . ':' . $segment, 'PAYLOAD_JSON' => $json, 'HASH' => $hash, ]); } function catalog_group_code(int $groupId): string { static $map = null; if ($map === null) { $map = []; $res = \Bitrix\Catalog\GroupTable::getList(['select' => ['ID','NAME','XML_ID']]); while ($row = $res->fetch()) { $map[(int) $row['ID']] = $row['XML_ID'] ?: $row['NAME']; } } return $map[$groupId] ?? 'BASE'; }
С остатками логика зависит от того, включен ли складской учет. Если склады используются — слушаем OnStoreProductAdd, OnStoreProductUpdate и OnStoreProductDelete. Если нет — достаточно OnProductUpdate: читаем QUANTITY и QUANTITY_RESERVED.
Скрытый текст
<?php use Bitrix\Main\Application; use Bitrix\Main\Web\Json; use Project\Bitrix\Outbox\OutboxEventTable; AddEventHandler('catalog', 'OnStoreProductAdd', static function (int $id, array $fields): void { stock_event((int) $fields['PRODUCT_ID']); }); AddEventHandler('catalog', 'OnStoreProductUpdate', static function (int $id, array $fields): void { stock_event((int) $fields['PRODUCT_ID']); }); AddEventHandler('catalog', 'OnProductUpdate', static function (int $id, array $fields): void { stock_event($id); }); function stock_event(int $productId): void { // определим, включен ли складской учет $useStores = \Bitrix\Catalog\Config\State::isUsedInventoryManagement(); if ($useStores) { $by = []; $res = \Bitrix\Catalog\StoreProductTable::getList([ 'filter' => ['=PRODUCT_ID' => $productId], 'select' => ['STORE_ID','AMOUNT'], ]); $total = 0.0; while ($row = $res->fetch()) { $qty = max(0.0, (float) $row['AMOUNT']); $total += $qty; $by[] = ['id' => (string) $row['STORE_ID'], 'qty' => $qty]; } } else { $row = \Bitrix\Catalog\ProductTable::getByPrimary($productId, [ 'select' => ['QUANTITY','QUANTITY_RESERVED'] ])->fetch(); $qty = (float) ($row['QUANTITY'] ?? 0); $resvd = (float) ($row['QUANTITY_RESERVED'] ?? 0); $total = max(0.0, $qty - $resvd); $by = []; } $payload = [ 'product_id' => $productId, 'by_warehouse' => $by, 'version' => (int) floor(microtime(true) * 1000), 'updated_at' => date('c'), 'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? null, ]; $json = Json::encode($payload, JSON_UNESCAPED_UNICODE); $hash = hash('sha256', 'stock.changed|' . $productId . '|' . $json); OutboxEventTable::add([ 'EVENT_TYPE' => 'stock.changed', 'AGGREGATE_TYPE' => 'product.stock', 'AGGREGATE_ID' => (string) $productId, 'PAYLOAD_JSON' => $json, 'HASH' => $hash, ]); }
Модель данных: MySQL для витрин, Redis для быстрых ответов
Здесь делим хранение на два слоя. В MySQL лежат витрины svc_price_* и svc_inventory_* — нормализованные копии данных из Битрикса. Они обновляются из outbox-событий и нужны как «теплый» источник, если в Redis нет нужного значения.
Redis держит быстрые снэпшоты для API. Только то, что нужно прямо сейчас для ответа: цена, валюта, сегмент, общий остаток и остатки по складам.
Ключи простые:
price:{ptype}:{productId}[:seg:{segment}]— цена, TTL 5–10 минут;stock:{productId}— общий остаток и склады, TTL 1–5 минут;miss:price:{ptype}:{productId}— негативный кэш на 60 секунд, чтобы не ходить в MySQL за пустыми значениями.
События price.changed и stock.changed сразу обновляют Redis и параллельно поддерживают витрины в MySQL. Так API отвечает быстро, а систему можно восстановить: если кэш очистился, данные реально поднять из витрин.
Контракты API
Дальше все сводится к нескольким простым эндпоинтам
Получение цен:
GET /api/pricing?ptype=BASE&product_ids[]=1&product_ids[]=2&segment=vip¤cy=RUB
Ответ:
{ prices: [{product_id, amount, currency, ptype, segment, ts}] }
GET /api/inventory?product_ids[]=1&product_ids[]=2&warehouse_ids[]=msk&warehouse_ids[]=spb
Ответ: Массовая оценка строк страницы POST /api/cart/price
Ответ:
{ "lines":[{"sku":"SKU1","qty":2,"segment":"retail"}], "ptype":"BASE","currency":"RUB" }
→ { "lines":[{"sku":"SKU1","qty":2,"unit":1000,"total":2000,"currency":"RUB"}], "ts":... }
Все эндпоинты защищены SSO из шага А1 и отдают ETag/Cache-Control для прокси-кэширования.
Схема витрин (миграции)
Скрытый текст
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('svc_price_snapshot', function (Blueprint $t): void { $t->unsignedBigInteger('product_id'); $t->string('ptype', 32); // тип цены Битрикс $t->string('segment', 32)->default('default'); // группа/сегмент $t->string('currency', 3)->default('RUB'); $t->decimal('amount', 14, 2); $t->unsignedBigInteger('version'); // монотонная версия от излучателя событий $t->timestamp('updated_at'); $t->primary(['product_id','ptype','segment']); $t->index(['ptype','segment']); }); Schema::create('svc_inventory_snapshot', function (Blueprint $t): void { $t->unsignedBigInteger('product_id')->primary(); $t->decimal('total', 14, 3)->default(0); $t->json('by_warehouse'); // [{id, qty}] $t->unsignedBigInteger('version'); $t->timestamp('updated_at'); }); } };
Сервис цен и остатков: чтение из Redis с fallback в MySQL
Скрытый текст
<?php namespace App\Pricing; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; final class PriceStore { public function getMany(array $productIds, string $ptype, string $segment, string $currency): array { $keys = array_map(fn ($id) => $this->key($ptype, (int) $id, $segment), $productIds); // пробуем Redis mget $rows = Redis::mget($keys); $result = []; $missed = []; foreach ($productIds as $i => $pid) { $raw = $rows[$i] ?? null; if ($raw) { $result[(int) $pid] = \json_decode($raw, true, flags: \JSON_THROW_ON_ERROR); } elseif (! Redis::get($this->missKey($ptype, (int) $pid, $segment))) { $missed[] = (int) $pid; } } if ($missed !== []) { // читаем из MySQL-витрины $db = DB::table('svc_price_snapshot') ->whereIn('product_id', $missed) ->where('ptype', $ptype) ->where('segment', $segment) ->select(['product_id','amount','currency','ptype','segment','updated_at']) ->get(); foreach ($db as $row) { $snap = [ 'product_id' => (int) $row->product_id, 'amount' => (float) $row->amount, 'currency' => (string) $row->currency, 'ptype' => (string) $row->ptype, 'segment' => (string) $row->segment, 'ts' => \strtotime((string) $row->updated_at), ]; $result[$snap['product_id']] = $snap; Redis::setex( $this->key($ptype, $snap['product_id'], $segment), 600, \json_encode($snap, \JSON_UNESCAPED_UNICODE) ); } // негативный кэш по пустым $foundIds = array_map(fn ($o) => (int) $o->product_id, $db->all()); foreach (array_diff($missed, $foundIds) as $pid) { Redis::setex($this->missKey($ptype, $pid, $segment), 60, '1'); } } // нормализуем валюту при необходимости (упростим - считаем, что currency совпадает) foreach ($productIds as $pid) { $pid = (int) $pid; if (! isset($result[$pid])) { $result[$pid] = null; } } return $result; } private function key(string $ptype, int $pid, string $segment): string { return "price:{$ptype}:{$pid}:seg:{$segment}"; } private function missKey(string $ptype, int $pid, string $segment): string { return "miss:price:{$ptype}:{$pid}:seg:{$segment}"; } }
Контроллеры API
Скрытый текст
<?php namespace App\Http\Controllers; use App\Inventory\InventoryStore; use App\Pricing\PriceStore; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class PricingController { public function __construct(private readonly PriceStore $store) {} public function batch(Request $r): JsonResponse { $data = $r->validate([ 'ptype' => ['required','string','max:32'], 'product_ids' => ['required','array','min:1','max:200'], 'product_ids.*' => ['integer','min:1'], 'segment' => ['nullable','string','max:32'], 'currency' => ['nullable','string','size:3'], ]); $ptype = $data['ptype']; $segment = $data['segment'] ?? 'default'; $currency = $data['currency'] ?? 'RUB'; $ids = array_values(array_unique($data['product_ids'])); $prices = $this->store->getMany($ids, $ptype, $segment, $currency); return response()->json(['ok' => true, 'prices' => array_values($prices)]); } } <?php namespace App\Http\Controllers; use App\Inventory\InventoryStore; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class InventoryController { public function __construct(private readonly InventoryStore $store) {} public function batch(Request $r): JsonResponse { $data = $r->validate([ 'product_ids' => ['required','array','min:1','max:200'], 'product_ids.*' => ['integer','min:1'], 'warehouse_ids' => ['nullable','array','max:50'], 'warehouse_ids.*' => ['string','max:32'], ]); $items = $this->store->getMany( array_values(array_unique($data['product_ids'])), $data['warehouse_ids'] ?? null ); return response()->json(['ok' => true, 'items' => array_values($items)]); } }
Маршруты:
<?php use App\Http\Controllers\InventoryController; use App\Http\Controllers\PricingController; use Illuminate\Support\Facades\Route; Route::prefix('api/v1')->middleware(['bitrix.jwt'])->group(function (): void { Route::get('/pricing', [PricingController::class, 'batch']); Route::get('/inventory', [InventoryController::class, 'batch']); });
Консьюмеры событий: обновляем витрины и кэш
Общий консьюмер Streams у нас уже есть после настройки Outbox. Теперь добавляем конкретные задачи для цен и остатков.
Скрытый текст
<?php namespace App\Jobs; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; final class RefreshPricing implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct(private readonly string $payloadJson) {} public function handle(): void { $p = \json_decode($this->payloadJson, true, flags: \JSON_THROW_ON_ERROR); // ожидаем: product_id, ptype, segment, amount, currency, version, updated_at $pid = (int) $p['product_id']; $ptype = (string) $p['ptype']; $segment = (string) ($p['segment'] ?? 'default'); // ерсионирование: не откатываемся назад $row = DB::table('svc_price_snapshot') ->select(['version']) ->where('product_id', $pid)->where('ptype', $ptype)->where('segment', $segment) ->first(); if ($row && (int) $row->version >= (int) $p['version']) { return; // старое событие } DB::table('svc_price_snapshot')->upsert([[ 'product_id' => $pid, 'ptype' => $ptype, 'segment' => $segment, 'currency' => (string) $p['currency'], 'amount' => (float) $p['amount'], 'version' => (int) $p['version'], 'updated_at' => (string) $p['updated_at'], ]], ['product_id','ptype','segment'], ['currency','amount','version','updated_at']); // сразу обновим Redis $snap = [ 'product_id' => $pid, 'ptype' => $ptype, 'segment' => $segment, 'currency' => (string) $p['currency'], 'amount' => (float) $p['amount'], 'ts' => \strtotime((string) $p['updated_at']), ]; Redis::setex("price:{$ptype}:{$pid}:seg:{$segment}", 600, \json_encode($snap)); Redis::del("miss:price:{$ptype}:{$pid}:seg:{$segment}"); } }
<?php namespace App\Jobs; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; final class RefreshInventory implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct(private readonly string $payloadJson) {} public function handle(): void { $p = \json_decode($this->payloadJson, true, flags: \JSON_THROW_ON_ERROR); // ожидаем: product_id, by_warehouse: [{id, qty}], version, updated_at $pid = (int) $p['product_id']; $by = (array) ($p['by_warehouse'] ?? []); $sum = array_reduce($by, fn ($c, $w) => $c + (float) ($w['qty'] ?? 0), 0.0); $row = DB::table('svc_inventory_snapshot')->select(['version'])->where('product_id', $pid)->first(); if ($row && (int) $row->version >= (int) $p['version']) { return; } DB::table('svc_inventory_snapshot')->updateOrInsert( ['product_id' => $pid], [ 'total' => $sum, 'by_warehouse' => \json_encode(array_values($by), \JSON_UNESCAPED_UNICODE), 'version' => (int) $p['version'], 'updated_at' => (string) $p['updated_at'], ] ); $snap = [ 'product_id' => $pid, 'total' => $sum, 'by_warehouse' => array_values($by), 'ts' => \strtotime((string) $p['updated_at']), ]; Redis::setex("stock:{$pid}", 300, \json_encode($snap)); } }
Интеграция с Битриксом: один вызов и понятная деградация
В местах, где Битриксу нужны цены или остатки, он делает один запрос в Laravel API. Таймауты держим короткими — 100–250 мс. Если сервис отвечает быстро, Битрикс получает актуальные цены и остатки. Если нет — включается деградация: берем последний локальный кэш на стороне Битрикса или применяем охранные правила.
Например:
товар временно недоступен;
цена показывается «по запросу»;
оформление блокируется до повторной проверки.
Так пользовательский путь управляем даже при сбое внешнего сервиса. Данные могут быть не самыми свежими в эту секунду, зато система не зависает.
Пример клиента для цен:
Скрытый текст
<?php use Bitrix\Main\Web\HttpClient; use Bitrix\Main\Web\Json; function pricing_batch(array $productIds, string $ptype, string $segment = 'default'): array { $http = new HttpClient(['socketTimeout' => 1, 'streamTimeout' => 1, 'retries' => 0]); $url = 'https://api.example.ru/api/v1/pricing?' . http_build_query([ 'ptype' => $ptype, 'segment' => $segment, 'currency' => 'RUB', 'product_ids' => $productIds, ]); if (! $http->query('GET', $url)) { return []; // деградация: верните локальный кэш или ничего } $res = Json::decode((string) $http->getResult()); $map = []; foreach ((array) ($res['prices'] ?? []) as $p) { $map[(int) $p['product_id']] = (float) $p['amount']; } return $map; }
Особые случаи: сегменты, валюты, компоновки цен
В ценах всегда есть нюансы. Особенно в B2B, где один и тот же товар может стоить по-разному для розницы, дилера, VIP-клиента и пользователя из той самой группы, которую завели пять лет назад и никто уже не помнит зачем.
Сегменты приводим к понятному виду: в Битриксе цена часто зависит от группы пользователя, а в Laravel это превращается в segment. Маппинг user → segment должен быть детерминированным и доступным сервису цен. Если сегмент не найден, используем default.
С валютами та же логика: если витрина хранит цены в базовой валюте, а на фронт нужна другая, курсы кладем в Redis и применяем при чтении.
Компоновки вроде «цена от», комплектов и наборов лучше не прятать в условные ветки внутри контроллера. Их стоит оформлять явно: отдельным ptype или дополнительными полями в snapshot, тогда API будет читаемым.
Наблюдаемость и SLO
После выноса цен и остатков в отдельный сервис появляется еще одна важная задача: понимать, где именно система начинает тормозить раньше пользователей.
Для API фиксируем понятные SLO:
P95 /api/pricing — до 120 мс на cache hit и до 300 мс на промахе;
P95 /api/inventory — в тех же пределах.
Смотрим не только на время ответа. В ecommerce гораздо полезнее видеть, что происходит с данными и кэшем под нагрузкой:
долю cache hit;
средний размер batch-запросов;
процент негативного кэша;
распределение таймаутов;
лаг обработки событий price.changed и stock.changed.
Алерты тоже лучше держать ближе к реальным показателям:
доля 5xx выше 0.5% за 5 минут;
лаг событий больше 30 секунд;
резкий рост cache miss;
увеличение времени ответа на cache hit.
Быстрый путь заказов: тяжелое уходит в очереди
Оформление заказа в ecommerce не ограничивается одной записью в базу. Обычно это цепочка синхронных действий: скидки, доставки, платежи, CRM, ERP, письма, SMS, документы. Пока все это выполняется, пользователь смотрит на крутящийся круглешок о загрузке.
Здесь мы разделяем прием заказа и дальнейшую обработку. Laravel принимает POST /api/orders, валидирует запрос, резервирует номер, сохраняет заказ в svc_orders_*, публикует событие order.created и сразу возвращает клиенту order_uuid.
Дальше заказ уходит в фоновые очереди. Воркеры создают «тонкий» заказ в b_sale_*, запускают интеграции, оплаты и синхронизацию статусов. Изменения возвращаются в Битрикс через order.updated.
Для пользователя это выглядит просто: чекаут отвечает быстро, без долгого ожидания между нажатием кнопки и появлением номера заказа.
Минимум, который пишем в Битрикс
Битрикс в этой схеме нужен для админки, менеджеров и старых отчетов. Поэтому при создании заказа сохраняем только базовые данные.
В b_sale_order пишем минимальный набор полей: USER_ID, PRICE, CURRENCY, STATUS_ID, PAYED='N', а в ACCOUNT_NUMBER или XML_ID — внешний order_uuid.
В b_sale_basket — только состав заказа: PRODUCT_ID, QUANTITY, PRICE, CURRENCY.
В b_sale_order_props_value — обязательные поля вроде ФИО, телефона, e-mail и адреса. Этого достаточно, чтобы заказ открывался в админке и менеджер видел данные клиента.
Скидки, доставки, интеграции, оплаты и документы в момент сохранения не считаем. Эти задачи уходят в Laravel-воркеры после события order.created, а изменения возвращаются обратно через order.updated.
Контракты: быстрый прием заказа
POST /api/orders принимает корзину, пользователя, контакты, адрес, выбранные способы оплаты и доставки, а также итоги из Pricing API.
В ответ сервис сразу возвращает 202 Accepted и минимальный набор данных.
Пользователь получает номер заказа сразу, без ожидания оплат, CRM и пересчетов.
Для диагностики и клиентских приложений есть GET /api/orders/{uuid}. Эндпоинт возвращает текущее состояние заказа, ссылки на оплату, трекинг и внешние идентификаторы.
Дальше заказ живет через события:
order.created— заказ принят с минимальным составом;order.materialized— созданb_sale_order_id;order.updated— обновились статусы оплаты, доставки, трекинги или внешние ID.
Laravel и модель данных ордер-сервиса
Скрытый текст
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('svc_orders', function (Blueprint $t): void { $t->uuid('uuid')->primary(); $t->unsignedBigInteger('user_id')->nullable(); $t->string('status', 24)->default('accepted'); // accepted|materialized|processing|completed|failed|canceled $t->string('currency', 3)->default('RUB'); $t->decimal('total', 14, 2); $t->decimal('items_total', 14, 2); $t->decimal('delivery_total', 14, 2)->default(0); $t->json('customer'); // {name,email,phone} $t->json('shipping'); // {address,method_id} $t->json('billing'); // {address} $t->json('lines'); // [{sku,qty,price,attrs}] $t->unsignedBigInteger('bitrix_order_id')->nullable(); $t->string('bitrix_account_number', 32)->nullable(); $t->timestamps(); $t->index(['user_id', 'status']); }); Schema::create('svc_order_events', function (Blueprint $t): void { $t->id(); $t->uuid('order_uuid'); $t->string('type', 32); $t->json('payload'); $t->timestamp('created_at'); $t->index(['order_uuid','type']); }); } };
Прием заказа с идемпотентностью
Скрытый текст
<?php namespace App\Http\Controllers; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; final class OrdersController { public function create(Request $r): JsonResponse { $data = $r->validate([ 'currency' => ['required','string','size:3'], 'items_total' => ['required','numeric','min:0'], 'delivery_total'=> ['required','numeric','min:0'], 'total' => ['required','numeric','min:0'], 'customer' => ['required','array'], 'customer.name' => ['required','string','max:200'], 'customer.email'=> ['required','email'], 'customer.phone'=> ['required','string','max:32'], 'shipping' => ['required','array'], 'shipping.address' => ['required','array'], 'shipping.method_id'=>['required','string','max:64'], 'billing' => ['nullable','array'], 'lines' => ['required','array','min:1','max:500'], 'lines.*.sku' => ['required','string','max:64'], 'lines.*.qty' => ['required','numeric','min:0.001'], 'lines.*.price' => ['required','numeric','min:0'], 'lines.*.attrs' => ['array'], ]); $key = (string) $r->header('Idempotency-Key', ''); if ($key === '') { return response()->json(['ok' => false, 'error' => 'idempotency_required'], 409); } $existing = DB::table('svc_order_events') ->where('type', 'accepted') ->where('payload->idempotency', $key) ->first(); if ($existing) { $uuid = (string) ($existing->order_uuid ?? ''); return response()->json(['ok' => true, 'order_uuid' => $uuid], 202); } $uuid = (string) Str::uuid(); DB::transaction(function () use ($uuid, $data, $key): void { DB::table('svc_orders')->insert([ 'uuid' => $uuid, 'user_id' => auth()->id(), 'status' => 'accepted', 'currency' => $data['currency'], 'items_total' => $data['items_total'], 'delivery_total' => $data['delivery_total'], 'total' => $data['total'], 'customer' => \json_encode($data['customer'], \JSON_UNESCAPED_UNICODE), 'shipping' => \json_encode($data['shipping'], \JSON_UNESCAPED_UNICODE), 'billing' => \json_encode($data['billing'] ?? new \stdClass(), \JSON_UNESCAPED_UNICODE), 'lines' => \json_encode($data['lines'], \JSON_UNESCAPED_UNICODE), 'created_at'=> now(), 'updated_at'=> now(), ]); DB::table('svc_order_events')->insert([ 'order_uuid' => $uuid, 'type' => 'accepted', 'payload' => \json_encode(['idempotency' => $key], \JSON_UNESCAPED_UNICODE), 'created_at' => now(), ]); }); // публикуем событие во внутреннюю очередь \App\Jobs\MaterializeInBitrix::dispatch($uuid); return response()->json([ 'ok' => true, 'order_uuid' => $uuid, 'number_hint'=> \substr($uuid, 0, 8), ], 202); } }
Маршрут:
Route::post('/api/v1/orders', [\App\Http\Controllers\OrdersController::class, 'create'])
->middleware('bitrix.jwt');
Материализуем «тонкий» заказ в Битриксе
Задача воркера — быстро создать минимальный заказ в Битриксе: запись в b_sale_order, состав корзины и связь с внешним order_uuid.
XML_ID или ACCOUNT_NUMBER проставляем равным внешнему UUID, чтобы заказ легко связывался с Laravel-сервисом. Суммы сохраняем такими, какими они пришли из Pricing API. Скидки заново не считаем — иначе быстрый путь снова уйдет в обычный тяжелый чекаут.
Скрытый текст
<?php namespace App\Jobs; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; final class MaterializeInBitrix implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; public int $backoff = 10; public function __construct(private readonly string $orderUuid) {} public function handle(): void { $order = DB::table('svc_orders')->where('uuid', $this->orderUuid)->first(); if (! $order) { return; } $payload = [ 'uuid' => $order->uuid, 'user_id' => $order->user_id, 'currency' => $order->currency, 'totals' => [ 'items' => (float) $order->items_total, 'delivery' => (float) $order->delivery_total, 'total' => (float) $order->total, ], 'customer' => \json_decode((string) $order->customer, true), 'shipping' => \json_decode((string) $order->shipping, true), 'billing' => \json_decode((string) $order->billing, true), 'lines' => \json_decode((string) $order->lines, true), ]; // внутренний эндпоинт Битрикс "быстрое создание" (см. ниже) $res = Http::timeout(2.0)->retry(2, 200) ->withHeaders(['X-Internal-Token' => \env('BITRIX_FAST_TOKEN')]) ->post('https://bitrix.example.ru/bitrix/services/main/ajax.php?action=project:orders.FastCreate.create', $payload); if (! $res->ok()) { throw new \RuntimeException('bitrix fast create failed: ' . $res->status()); } $data = $res->json(); $bitrixId = (int) ($data['order_id'] ?? 0); $accountNo = (string) ($data['account_number'] ?? ''); DB::table('svc_orders')->where('uuid', $this->orderUuid)->update([ 'status' => 'materialized', 'bitrix_order_id' => $bitrixId, 'bitrix_account_number' => $accountNo, 'updated_at' => now(), ]); // публикуем событие для дальнейшей обработки (интеграции, платежи) \App\Jobs\ProcessOrderSaga::dispatch($this->orderUuid); } }
Битрикс и легкий контроллер FastCreate
Для быстрого оформления добавляем отдельный контроллер FastCreate. Он создает минимальный заказ и не тянет тяжелые зависимости checkout-процесса.
Включается все через фича-флаг «быстрый режим», поэтому новую схему можно раскатывать постепенно и так же спокойно откатывать.
Скрытый текст
<?php namespace Project\Orders; use Bitrix\Main\Engine\Controller; use Bitrix\Main\Engine\ActionFilter; use Bitrix\Main\Context; use Bitrix\Sale\Basket; use Bitrix\Sale\Order; final class FastCreateController extends Controller { public function configureActions(): array { return [ 'create' => [ '+prefilters' => [new ActionFilter\HttpMethod(['POST'])], ], ]; } public function createAction(array $uuid, ?int $user_id, string $currency, array $totals, array $customer, array $shipping, array $billing = null, array $lines = []): array { // внутренний токен $token = (string) ($_SERVER['HTTP_X_INTERNAL_TOKEN'] ?? ''); if ($token !== (string) \COption::GetOptionString('project.core', 'FAST_TOKEN')) { $this->addError(new \Bitrix\Main\Error('Forbidden')); return []; } define('PROJECT_FAST_ORDER', true); // наш флаг для "легких" обработчиков $siteId = SITE_ID; $order = Order::create($siteId, $user_id ?: 0); $order->setField('CURRENCY', $currency); $order->setField('XML_ID', (string) $uuid); // внешний uuid $order->setField('ACCOUNT_NUMBER', (string) $uuid); // можно показать в админке $order->setField('STATUS_ID', 'N'); $order->setField('PRICE', (float) $totals['total']); // корзина $basket = Basket::create($siteId); foreach ($lines as $ln) { $productId = self::productIdBySku((string) $ln['sku']); if ($productId <= 0) { continue; } $item = $basket->createItem('catalog', $productId); $item->setFields([ 'QUANTITY' => (float) $ln['qty'], 'PRICE' => (float) $ln['price'], 'CURRENCY' => $currency, ]); } $order->setBasket($basket); // свойства (минимум для менеджера) $props = $order->getPropertyCollection(); self::setProp($props, 'FIO', $customer['name'] ?? ''); self::setProp($props, 'EMAIL', $customer['email'] ?? ''); self::setProp($props, 'PHONE', $customer['phone'] ?? ''); self::setProp($props, 'ADDRESS', self::formatAddress($shipping['address'] ?? [])); // не вызываем тяжелые финализации, просто сохраняем $r = $order->save(); if (! $r->isSuccess()) { $this->addError(new \Bitrix\Main\Error(\implode('; ', $r->getErrorMessages()))); return []; } return [ 'ok' => true, 'order_id' => $order->getId(), 'account_number' => $order->getField('ACCOUNT_NUMBER'), ]; } private static function productIdBySku(string $sku): int { $row = \Bitrix\Main\Application::getConnection()->query(" SELECT product_id FROM svc_catalog_sku WHERE sku = '" . \Bitrix\Main\Application::getConnection()->getSqlHelper()->forSql($sku) . "' LIMIT 1 ")->fetch(); return (int) ($row['product_id'] ?? 0); } private static function setProp(\Bitrix\Sale\PropertyValueCollection $props, string $code, string $value): void { if ($value === '') { return; } if ($p = $props->getItemByOrderPropertyCode($code)) { $p->setValue($value); } } private static function formatAddress(array $addr): string { // простое склеивание для админки return \trim(\implode(', ', \array_filter([ $addr['index'] ?? null, $addr['city'] ?? null, $addr['street'] ?? null, $addr['house'] ?? null, $addr['flat'] ?? null, ]))); } }
Кастомные обработчики внутри проекта тоже должны учитывать флаг PROJECT_FAST_ORDER и в этом режиме не запускать тяжелые интеграции синхронно. Все, что можно вынести из пользовательского запроса, лучше отправлять событием дальше в Laravel-воркеры.
События и саги после материализации
Сразу после MaterializeInBitrix запускается сага обработки заказа:
инициализация или резервирование платежа;
выбор доставки и создание отгрузки;
запись в CRM или ERP;
отправка уведомлений.
Каждый шаг работает как отдельный идемпотентный джоб со своей компенсацией. Если интеграция или внешний сервис временно недоступны, обработка продолжится после ретрая, а заказ не зависнет посередине checkout-процесса.
Скрытый текст
<?php namespace App\Jobs; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; final class ProcessOrderSaga implements ShouldQueue { use Dispatchable, Queueable; public function __construct(private readonly string $orderUuid) {} public function handle(): void { ReservePayment::dispatchSync($this->orderUuid); CreateShipment::dispatchSync($this->orderUuid); PushToCrm::dispatchSync($this->orderUuid); SendNotifications::dispatch($this->orderUuid); // итоги - событие order.updated \App\Events\OrderUpdated::dispatch($this->orderUuid); } }
Публикация событий из Битрикса
Менеджеры все равно продолжают работать с заказами в админке Битрикса: менять статусы, править доставку, обновлять данные клиента. Чтобы синхронизация не расползалась в разные стороны, на OnSaleOrderSaved и связанных событиях публикуем короткие события в Outbox.
Скрытый текст
<?php use Project\Bitrix\Outbox\OutboxEventTable; use Bitrix\Main\Web\Json; AddEventHandler('sale', 'OnSaleOrderSaved', static function (\Bitrix\Main\Event $event): void { /** @var \Bitrix\Sale\Order $order */ $order = $event->getParameter('ENTITY'); $isNew = $event->getParameter('IS_NEW'); $payload = [ 'order_id' => (int) $order->getId(), 'account_number' => (string) $order->getField('ACCOUNT_NUMBER'), 'status' => (string) $order->getField('STATUS_ID'), 'pay_status' => (string) $order->getField('PAYED'), 'updated_at' => date('c'), ]; $json = Json::encode($payload, JSON_UNESCAPED_UNICODE); $hash = hash('sha256', 'order.' . ($isNew ? 'created' : 'updated') . '|' . $payload['order_id'] . '|' . $json); OutboxEventTable::add([ 'EVENT_TYPE' => $isNew ? 'order.created' : 'order.updated', 'AGGREGATE_TYPE' => 'order', 'AGGREGATE_ID' => (string) $payload['order_id'], 'PAYLOAD_JSON' => $json, 'HASH' => $hash, ]); });
Laravel-консьюмер по этим событиям обновляет svc_orders. Если данных из события не хватает, он забирает дополнительные поля через легкий эндпоинт Битрикса, без прямого обхода b_sale_*.
Почему это быстро
Прием заказа — это запись в наши таблицы и публикация события. Без внешних вызовов, тяжелых расчетов и ожидания CRM.
Запись в b_sale_* уходит в отдельное задание. Оно делает только минимальный набор действий и завершается быстро. Все долгое — оплаты, доставки, ERP, уведомления — живет в сагах и очередях, с ретраями, DLQ и нормальными метриками. Пользователь быстро получает номер заказа, менеджер видит его в админке.
Деградация и откат
Если Битрикс временно недоступен, заказ остается в статусе accepted, а MaterializeInBitrix уходит в ретраи. В личном кабинете пользователь видит понятное состояние: заказ принят и обрабатывается.
Если недоступна платежная система, прием заказа все равно продолжается. Пользователь получает номер заказа, а оплату сможет завершить позже по ссылке.
Откат тоже простой: отключаем задачу материализации, и Битрикс перестает получать «зеркало» заказа. Прием заказов, очереди и интеграции продолжают работать в Laravel, а для менеджеров временно показываем собственную карточку из svc_orders.
Что в итоге
Битрикс продолжает жить в своей привычной роли: админка, контент, менеджеры, совместимость со старым контуром.
Laravel забирает все тяжелое и чувствительное к нагрузке: API, очереди, события, кэши, поиск, корзину, цены и checkout.
Каталог отвечает быстро даже с фасетами, фильтрами и большим количеством SKU.
Корзина нормально переживает несколько вкладок, ретраи и мобильный интернет без дублей и потерянных изменений.
Цены и остатки больше не читаются напрямую из
b_catalog_*на каждом пользовательском запросе.Checkout не ждет CRM, ERP, платежки и доставки внутри одного HTTP-запроса.
Интеграции уезжают в очереди и саги, а пользователь сразу получает номер заказа.
Все изменения расходятся событиями через Outbox и Redis Streams, без прямых связей между системами.
У сервисов есть понятная деградация: если что-то временно недоступно, проект не складывается целиком.
Новые части системы можно выкатывать постепенно через фича-флаги и так же спокойно откатывать.
И главное — проект не ощущается как монолит, который страшно трогать перед релизом.
Продолжение следует...
