Реализация двусторонней синхронизации каталога товаров с МойСклад

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация двусторонней синхронизации каталога товаров с МойСклад
Сложная
~2-4 недели
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Реализация двусторонней синхронизации каталога товаров с МойСклад

МойСклад — российская облачная система управления торговлей. В связке с интернет-магазином синхронизация обычно покрывает три потока данных: товары и остатки (из МС на сайт), заказы (с сайта в МС), статусы и трекинг (из МС обратно на сайт). Двусторонняя синхронизация значительно сложнее односторонней — нужно обрабатывать конфликты, вести маппинг идентификаторов и управлять приоритетами источника истины.

Архитектура: источники истины

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

Сущность Источник истины Примечание
Товары (название, описание, цена) МойСклад Менеджеры правят там
Остатки МойСклад Обновляются при поступлении/продаже
Изображения товаров Сайт Загружаются через CMS
SEO-поля (meta, slug) Сайт Не существуют в МС
Заказы Сайт → МС Создаются на сайте, уходят в МС
Статусы заказов МС → Сайт Менеджер меняет в МС

API МойСклад

МойСклад использует REST API с JSON:API-подобной структурой. Аутентификация — Basic Auth или Bearer-токен.

class MoiSkladClient
{
    private string $baseUrl = 'https://api.moysklad.ru/api/remap/1.2';

    private function headers(): array
    {
        return [
            'Authorization' => 'Bearer ' . config('services.moysklad.token'),
            'Content-Type'  => 'application/json;charset=utf-8',
            'Accept-Encoding' => 'gzip',
        ];
    }

    public function get(string $path, array $params = []): array
    {
        return Http::withHeaders($this->headers())
            ->get("{$this->baseUrl}/{$path}", $params)
            ->throw()
            ->json();
    }

    public function post(string $path, array $data): array
    {
        return Http::withHeaders($this->headers())
            ->post("{$this->baseUrl}/{$path}", $data)
            ->throw()
            ->json();
    }
}

Получение товаров с остатками

class ProductSyncService
{
    public function fetchProducts(int $offset = 0, int $limit = 100): array
    {
        return $this->ms->get('entity/product', [
            'offset'  => $offset,
            'limit'   => $limit,
            'expand'  => 'productFolder,images',
            'filter'  => 'archived=false',
        ]);
    }

    public function fetchStocks(): array
    {
        // Остатки по всем складам
        return $this->ms->get('report/stock/all/current', [
            'stockType'   => 'stock',
            'includeRelated' => false,
        ]);
    }

    public function syncToSite(): void
    {
        $offset = 0;
        $limit  = 100;

        do {
            $response = $this->fetchProducts($offset, $limit);
            $products = $response['rows'];

            foreach ($products as $msProduct) {
                $this->upsertProduct($msProduct);
            }

            $offset += $limit;
        } while ($offset < $response['meta']['size']);

        // Отдельно обновляем остатки
        $stocks = $this->fetchStocks();
        foreach ($stocks as $stock) {
            Product::whereExternalId($stock['assortmentId'])
                ->update(['stock' => max(0, (int)$stock['stock'])]);
        }
    }

    private function upsertProduct(array $msProduct): void
    {
        $msId = $msProduct['id'];

        // Маппинг полей МС → сайт
        $data = [
            'external_id'  => $msId,
            'name'         => $msProduct['name'],
            'article'      => $msProduct['article'] ?? null,
            'price'        => $this->parsePrice($msProduct['salePrices'][0]['value'] ?? 0),
            'description'  => $msProduct['description'] ?? '',
            'ms_updated_at'=> Carbon::parse($msProduct['updated']),
        ];

        $product = Product::updateOrCreate(['external_id' => $msId], $data);

        // SEO и изображения НЕ перезаписываем — они управляются на сайте
    }

    private function parsePrice(int $msPrice): float
    {
        // МС хранит цены в копейках (умноженных на 100)
        return $msPrice / 100;
    }
}

Передача заказов в МойСклад

public function pushOrder(Order $order): string
{
    $positions = [];
    foreach ($order->items as $item) {
        $positions[] = [
            'assortment' => [
                'meta' => [
                    'href' => "{$this->baseUrl}/entity/product/{$item->product->external_id}",
                    'type' => 'product',
                ],
            ],
            'quantity' => $item->quantity,
            'price'    => $item->price * 100, // в копейках
        ];
    }

    $msOrder = $this->ms->post('entity/customerorder', [
        'name'         => "Заказ #{$order->id}",
        'organization' => [
            'meta' => [
                'href' => "{$this->baseUrl}/entity/organization/" . config('services.moysklad.org_id'),
                'type' => 'organization',
            ],
        ],
        'agent' => $this->getOrCreateCounterparty($order->customer),
        'positions' => $positions,
        'description' => "Источник: сайт\nEmail: {$order->customer->email}",
        'attributes'  => [
            [
                'meta' => ['href' => $this->orderIdAttributeHref()],
                'value' => (string)$order->id,
            ],
        ],
    ]);

    $order->update(['ms_order_id' => $msOrder['id']]);
    return $msOrder['id'];
}

Webhook от МойСклад: обновление статусов

МойСклад поддерживает исходящие webhooks на события изменения сущностей:

public function handleMsWebhook(Request $request): Response
{
    $events = $request->json('events', []);

    foreach ($events as $event) {
        if ($event['meta']['type'] === 'customerorder') {
            $msOrderId = basename($event['meta']['href']);
            SyncOrderStatusJob::dispatch($msOrderId);
        }
    }

    return response()->noContent();
}
class SyncOrderStatusJob implements ShouldQueue
{
    public function handle(MoiSkladClient $ms): void
    {
        $msOrder = $ms->get("entity/customerorder/{$this->msOrderId}");

        $order = Order::where('ms_order_id', $this->msOrderId)->first();
        if (!$order) return;

        // Маппинг статусов МС → статусы сайта
        $statusMap = [
            'Новый'      => 'pending',
            'В работе'   => 'processing',
            'Отправлен'  => 'shipped',
            'Доставлен'  => 'completed',
            'Отменён'    => 'cancelled',
        ];

        $msStatus    = $msOrder['state']['name'] ?? '';
        $siteStatus  = $statusMap[$msStatus] ?? null;

        if ($siteStatus && $order->status !== $siteStatus) {
            $order->update(['status' => $siteStatus]);
            $order->customer->notify(new OrderStatusChanged($order));
        }
    }
}

Разрешение конфликтов

При двусторонней синхронизации могут возникнуть конфликты: товар изменён и на сайте, и в МС одновременно. Стратегия зависит от поля:

  • Цена, остатки, артикул — всегда приоритет МС
  • SEO-поля, описание для сайта — только сайт
  • Название — приоритет МС с сохранением истории изменений

Для полей с конкурирующими обновлениями сохраняем ms_updated_at и site_updated_at, при синхронизации берём более свежее значение.

Расписание и частота

// routes/console.php
Schedule::job(new SyncProductsJob)->everyTenMinutes();
Schedule::job(new SyncStocksJob)->everyFiveMinutes();   // остатки чаще
Schedule::job(new SyncOrderStatusesJob)->everyFiveMinutes();

Остатки обновляем чаще — они критичны для отображения «в наличии/нет».

Сроки

Односторонняя синхронизация (МС → сайт, товары+остатки): 2–3 рабочих дня. Полноценная двусторонняя (+ заказы, + статусы, + webhook, + разрешение конфликтов): 6–8 рабочих дней. Время увеличивается при нестандартной структуре каталога (вариации, комплекты, услуги).