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

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

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація двосторонньої синхронізації каталогу товарів з МійСклад
Складна
~2-4 тижні
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

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

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

Архітектура: джерела істини

Перед розробкою потрібно чітко визначити, яка система головна для кожної сутності:

Сутність Джерело істини Примітка
Товари (назва, опис, ціна) МойСклад Менеджери правлять там
Остатки МойСклад Оновлюються при поступленні/продажу
Зображення товарів Сайт Завантажуються через 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 робочих днів. Час збільшується при нестандартній структурі каталогу (варіації, комплекти, послуги).