惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Властелин корзин: две вкладки и один Redis (часть 4)
AliceInCodeL · 2026-05-20 · via Все публикации подряд на Хабре

Привет, Хабр! Это снова Алиса из сериала про 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&currency=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.

Что в итоге

  1. Битрикс продолжает жить в своей привычной роли: админка, контент, менеджеры, совместимость со старым контуром.

  2. Laravel забирает все тяжелое и чувствительное к нагрузке: API, очереди, события, кэши, поиск, корзину, цены и checkout.

  3. Каталог отвечает быстро даже с фасетами, фильтрами и большим количеством SKU.

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

  5. Цены и остатки больше не читаются напрямую из b_catalog_* на каждом пользовательском запросе.

  6. Checkout не ждет CRM, ERP, платежки и доставки внутри одного HTTP-запроса.

  7. Интеграции уезжают в очереди и саги, а пользователь сразу получает номер заказа.

  8. Все изменения расходятся событиями через Outbox и Redis Streams, без прямых связей между системами.

  9. У сервисов есть понятная деградация: если что-то временно недоступно, проект не складывается целиком.

  10. Новые части системы можно выкатывать постепенно через фича-флаги и так же спокойно откатывать.

  11. И главное — проект не ощущается как монолит, который страшно трогать перед релизом.

Продолжение следует...