maximorum.com

Синхронізація OpenCart з маркетплейсами Prom.ua та Rozetka

D

Коли інтернет-магазин на OpenCart починає продавати на Prom.ua та Rozetka, головною загрозою для прибутку стає десинхронізація залишків. Ситуація типова. Покупець замовляє останню одиницю товару на сайті, а через кілька хвилин той самий товар купують на маркетплейсі. Менеджер змушений телефонувати клієнту зі словами про відсутність позиції на складі. Для магазину це означає не лише втрачений заробіток, а й пряме падіння рейтингу на Prom та штрафні бали від модерації Rozetka, аж до тимчасового відключення магазину від каталогу. Нижче розібрано, як побудувати автоматичний конвеєр синхронізації OpenCart з маркетплейсами, генерувати важкі XML-фіди без перевантаження сервера та оновлювати залишки через API без оверселінгу.

Чому оверселінг руйнує рейтинг магазину

Маркетплейси жорстко контролюють відсоток виконаних замовлень. Для платформи Rozetka скасування замовлення з ініціативи продавця є найбільш критичним показником якості. Якщо частка скасувань через відсутність товару перевищує допустимий поріг, маркетплейс автоматично блокує показ карток магазину або примусово переводить товари в статус неактивних. На Prom.ua високий відсоток успішних замовлень безпосередньо впливає на позицію в каталозі ProSale. Магазин, який регулярно скасовує покупки, втрачає видачу та сплачує дорожче за кожен перехід.

Згідно з дослідженням аналітичного інституту Baymard Institute, середній показник кинутих кошиків сягає 70,22%. Покупець, який зіткнувся зі скасуванням оплаченого замовлення, практично ніколи не повертається на сайт повторно. Неактуальний залишок на вітрині завдає бізнесу прямих збитків у рекламі та знищує повернення клієнтів.

Архітектурні вузькі місця OpenCart при роботі з фідами

Стандартний підхід більшості модулів для OpenCart полягає у формуванні XML-файлу динамічно за прямим посиланням вигляду index.php?route=extension/feed/prom_xml. Поки в каталозі кілька сотень позицій, такий скрипт працює. Коли асортимент перевищує 5 000 або 20 000 товарів, виникають системні помилки:

  • Вичерпання оперативної пам'яті PHP. Завантаження масиву моделей, описів, картинок та атрибутів для тисяч товарів перевищує ліміт пам'яті сервера (memory_limit) та викликає фатальну помилку 500.
  • Таймаут виконання запиту веб-сервера. Робот маркетплейса очікує відповіді не довше 30 чи 60 секунд. Якщо запити до бази даних виконуються довше, Nginx повертає помилку 504 Gateway Timeout. Маркетплейс вважає фід недоступним і не оновлює залишки.
  • Блокування бази даних. Якщо кілька маркетплейсів одночасно звертаються до динамічного фіда, навантаження на MySQL зростає в рази. Звичайні покупці на сайті відчувають повільне завантаження сторінок і кидають кошик.
  • Низька частота оновлення. Роботи Prom та Rozetka завантажують фіди за розкладом приблизно один раз на 2 або 4 години. Якщо товар продали об 11:00, маркетплейс може дізнатися про нульовий залишок лише о 14:00. Протягом цих трьох годин магазин залишається відкритим для повторного продажу вже відсутнього товару.

Генерація важких XML-фідів через фоновий генератор на cron

Єдиний надійний спосіб віддавати фід маркетплейсу полягає у створенні готового статичного файлу на диску за системним розкладом. Скрипт запускається через командний рядок сервера (CLI), обирає товари невеликими порціями (чанками по 500 штук), формує валідний XML та записує результат у стиснений архів або готовий файл на диску. Веб-сервер Nginx віддає готовий файл без участі інтерпретатора PHP за мілісекунди.

Специфікація вивантаження Prom.ua базується на форматі YML (документація Prom API). Для Rozetka фід вимагає точної ієрархії категорій, обов'язкових параметрів виробника, гарантії та артикулу моделі. Скрипт вивантаження на PHP реалізує потоковий запис через клас XMLWriter, що тримає споживання пам'яті на рівні кількох мегабайтів незалежно від розміру каталогу:

<?php
class FastFeedGenerator {
    private $db;
    private $writer;

    public function __construct(PDO $db) {
        $this->db = $db;
        $this->writer = new XMLWriter();
    }

    public function generate(string $outputPath): void {
        $this->writer->openURI($outputPath);
        $this->writer->startDocument('1.0', 'UTF-8');
        $this->writer->setIndent(true);

        $this->writer->startElement('yml_catalog');
        $this->writer->writeAttribute('date', date('Y-m-d H:i'));
        $this->writer->startElement('shop');
        $this->writer->writeElement('name', 'My OpenCart Store');

        $this->writeCategories();
        $this->writeOffers();

        $this->writer->endElement(); // shop
        $this->writer->endElement(); // yml_catalog
        $this->writer->endDocument();
    }

    private function writeOffers(): void {
        $this->writer->startElement('offers');
        $limit = 500;
        $offset = 0;

        while (true) {
            $stmt = $this->db->prepare(
                "SELECT p.product_id, p.model, p.sku, p.quantity, p.price, pd.name, p.status 
                 FROM oc_product p
                 INNER JOIN oc_product_description pd ON (p.product_id = pd.product_id)
                 WHERE pd.language_id = 1 AND p.status = 1
                 ORDER BY p.product_id ASC
                 LIMIT :limit OFFSET :offset"
            );
            $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
            $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
            $stmt->execute();

            $products = $stmt->fetchAll(PDO::FETCH_ASSOC);
            if (empty($products)) {
                break;
            }

            foreach ($products as $p) {
                $isAvailable = ($p['quantity'] > 0) ? 'true' : 'false';
                $this->writer->startElement('offer');
                $this->writer->writeAttribute('id', (string)$p['product_id']);
                $this->writer->writeAttribute('available', $isAvailable);

                $this->writer->writeElement('price', number_format($p['price'], 2, '.', ''));
                $this->writer->writeElement('currencyId', 'UAH');
                $this->writer->writeElement('name', htmlspecialchars($p['name'], ENT_XML1));
                $this->writer->writeElement('vendorCode', $p['model'] ?: $p['sku']);
                $this->writer->writeElement('stock_quantity', (string)$p['quantity']);

                $this->writer->endElement(); // offer
            }

            $offset += $limit;
        }

        $this->writer->endElement(); // offers
    }

    private function writeCategories(): void {
        $this->writer->startElement('categories');
        $stmt = $this->db->query("SELECT category_id, parent_id FROM oc_category WHERE status = 1");
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $this->writer->startElement('category');
            $this->writer->writeAttribute('id', (string)$row['category_id']);
            if ($row['parent_id'] > 0) {
                $this->writer->writeAttribute('parentId', (string)$row['parent_id']);
            }
            $this->writer->text('Category ' . $row['category_id']);
            $this->writer->endElement();
        }
        $this->writer->endElement(); // categories
    }
}

Запуск цього генератора ставиться в crontab сервера кожні 30 або 60 хвилин. Скрипт записує тимчасовий файл feed_temp.xml і після завершення атомарно замінює публічний файл через функцію перейменування. Це захищає маркетплейс від завантаження незавершеного або обірваного файлу.

Миттєве оновлення залишків через API

Навіть щогодинне вивантаження фіда не дає стовідсоткового захисту від оверселінгу при гарячому попиті. Якщо залишається остання одиниця популярного товару, повідомлення про зміну кількості має піти на маркетплейс за лічені секунди після оформлення замовлення.

Сервіс Prom.ua підтримує пряме оновлення характеристик товару через метод POST /products/edit або POST /products/edit_by_external_id. Авторизація виконується за статичним токеном у заголовку Authorization: Bearer <token>. Коли в магазині змінюється статус замовлення, система одразу надсилає точковий запит із новим статусом наявності:

curl -X POST "https://my.prom.ua/api/v1/products/edit" \
  -H "Authorization: Bearer 8a9b7c6d5e4f3a2b1c0d" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "id": 19482103,
      "presence": "not_available",
      "quantity_in_stock": 0
    }
  ]'

Розетка надає повноцінне API для продавців (документація Rozetka Seller API). Авторизація відбувається за логіном кабінету та паролем, закодованим у Base64. Після успішного запиту повертається тимчасовий токен доступу. Оновлення кількості виконується у розділі товарних запасів. Якщо позиція закінчилася на складі сайту, залишок негайно обнуляється на Rozetka.

Захист від стану гонитви в базі даних OpenCart

Багато інсталяцій OpenCart 3 і старших версій історично використовують для таблиці oc_product рушій MyISAM. Цей рушій не підтримує транзакції та блокування окремих рядків. Якщо два покупці одночасно натискають кнопку підтвердження замовлення для товару з залишком 1, звичайний запит перевірки та наступного зменшення прочитає залишок 1 для обох клієнтів. Обидва замовлення отримають підтвердження, а залишок стане мінус один.

Для захисту від такого збою необхідно виконати два кроки:

  1. Перевести таблиці oc_product, oc_product_option_value та oc_order на рушій InnoDB. Це дає повноцінну підтримку транзакцій ACID і блокувань на рівні окремого запису.
  2. Застосовувати атомарне списання кількості без попереднього читання через PHP.

Атомарний SQL-запит оновлення гарантує, що списання відбудеться тільки за умови фізичної наявності товару:

UPDATE oc_product 
SET quantity = quantity - :ordered_qty,
    stock_status_id = IF(quantity - :ordered_qty <= 0, 5, stock_status_id),
    status = IF(quantity - :ordered_qty <= 0 AND subtract = 1, 0, status)
WHERE product_id = :product_id 
  AND quantity >= :ordered_qty;

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

Імпорт замовлень з Prom та Rozetka в єдину панель OpenCart

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

Правильна схема передбачає автоматичне завантаження замовлень з Prom та Rozetka в базу OpenCart за розкладом або за вебхуком. Скрипт воркера виконує послідовні дії:

  1. Запитує нові замовлення з Prom API через метод GET /orders/list?status=received та нові замовлення з Rozetka через GET /orders/search.
  2. Знаходить відповідні товари в каталозі OpenCart за полем model або sku.
  3. Створює нове замовлення в таблицях oc_order та oc_order_product з поміткою джерела замовлення.
  4. Атомарно списує заброньовану кількість зі складу сайту.
  5. Після того, як менеджер в адмінці OpenCart формує накладну Нової Пошти, скрипт відправляє номер ТТН назад на маркетплейс через метод POST /delivery/save_declaration_id для Prom або метод оновлення статусу замовлення для Rozetka.

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

Робочий Python-воркер для перевірки та синхронізації

Нижче наведено приклад коду скрипта, який можна запускати в системній черзі завдань або через cron для періодичної звірки статусів і відправки залишків на Prom.ua:

import json
import logging
import urllib.request as request
import urllib.error as error

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("PromSyncWorker")

class PromStockSync:
    def __init__(self, api_token: str):
        self.api_token = api_token
        self.base_url = "https://my.prom.ua/api/v1"

    def _headers(self) -> dict:
        return {
            "Authorization": f"Bearer {self.api_token}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }

    def update_stock(self, product_updates: list) -> bool:
        url = f"{self.base_url}/products/edit"
        payload = []
        for item in product_updates:
            qty = item["quantity"]
            payload.append({
                "id": item["prom_id"],
                "presence": "available" if qty > 0 else "not_available",
                "quantity_in_stock": max(0, qty)
            })

        data = json.dumps(payload).encode("utf-8")
        req = request.Request(url, data=data, headers=self._headers(), method="POST")

        try:
            with request.urlopen(req, timeout=15) as res:
                response = json.loads(res.read().decode("utf-8"))
                logger.info("Prom sync success: %s", response.get("status"))
                return True
        except error.HTTPError as e:
            logger.error("Prom sync error: %s %s", e.code, e.read().decode("utf-8"))
            return False

    def fetch_new_orders(self) -> list:
        url = f"{self.base_url}/orders/list?status=received"
        req = request.Request(url, headers=self._headers(), method="GET")
        try:
            with request.urlopen(req, timeout=15) as res:
                data = json.loads(res.read().decode("utf-8"))
                return data.get("orders", [])
        except error.HTTPError as e:
            logger.error("Failed to fetch orders: %s", e.code)
            return []

Цей клас легко підключається як мікросервіс, плагін або модуль черги n8n. Якщо вам потрібна кастомна логіка розподілу залишків чи мультискладський облік, ми розробляємо кастомні модулі автоматизації для OpenCart і WooCommerce. Вартість розробки кастомного модуля в нашому прайсі починається від $80, а налаштування складних сценаріїв автоматизації на n8n від $500.

Порівняння підходів до синхронізації

Критерій Динамічний XML за URL Статичний XML на cron Комбінований XML + API дельта
Навантаження на PHP і MySQL Критичне при кожному запиті бота Нульове для веб-сервера Мінімальне (фонові легкі запити)
Швидкість оновлення наявності Від 2 до 6 годин (за розкладом бота) 30-60 хвилин До 5 секунд через API після замовлення
Ризик помилки 504 та обриву фіда Дуже високий при 10 000+ товарів Виключений (Nginx віддає статичний файл) Виключений
Захист від оверселінгу Практично відсутній Частковий Повний захист завдяки резервуванню
Синхронізація вхідних замовлень Відсутня (лише вивантаження каталогу) Відсутня Автоматичний імпорт замовлень в OpenCart

Стратегія створення страхового запасу

Для категорій із високою швидкістю продажів (наприклад, сезонний одяг чи електроніка за акцією) досвідчені продавці застосовують алгоритм страхового залишку (Safety Stock Buffer). Сенс правила простий. На маркетплейс транслюється не весь фізичний залишок, а кількість за вирахуванням однієї чи двох одиниць.

Якщо в базі OpenCart числиться 3 одиниці товару, у фід або запит до API передається значення 2. Якщо на складі залишається 1 одиниця, маркетплейс отримує статус відсутності. Ця остання одиниця залишається доступною для прямого продажу на вашому власному сайті. Такий буфер повністю виключає конфлікт одночасних кошиків. Після надходження нової партії товару від постачальника залишки автоматично поновлюються.

Типові помилки інтеграції

  • Формування фіда безпосередньо в момент звернення маркетплейса. Це призводить до падіння сайту під час пікових навантажень.
  • Використання рушія MyISAM для товарних таблиць. Без підтримки блокування рядків одночасні замовлення призводять до від'ємних залишків.
  • Відсутність контролю унікальності зовнішніх ідентифікаторів. Якщо на Prom артикул не збігається з полем model у OpenCart, зв'язати замовлення без ручного втручання стає неможливо.
  • Збереження цін маркетплейса без урахування комісії платформи. Продаж товару з маркетплейсу вимагає сплати комісії від 5% до 20%. Модуль синхронізації має підтримувати окремі коефіцієнти ціноутворення для кожного каналу збуту.
  • Ігнорування автоматичної фіскалізації. При зростанні потоку замовлень з маркетплейсів обов'язково налаштовуйте автоматичні чеки. Як організувати фіскалізацію оплат без участі касира, читайте в нашій статті про автоматичну інтеграцію Checkbox для магазину. Якщо магазин приймає оплати через monobank, ознайомтеся з посібником про звірку платежів через n8n.

Часті запитання

Чи можна оновлювати лише залишки без повторного вивантаження картинок і описів?

Так. Офіційні API Prom.ua та Rozetka дозволяють передавати запити тільки зі зміною кількості та ціни. Це зменшує обсяг трафіку в сотні разів і прискорює застосування нових даних на вітрині.

Як часто маркетплейси завантажують XML-фід?

Зазвичай маркетплейси опитують посилання раз на 2–4 години. Для більш частого оновлення використовується комбінація статичного фіда та точкових API-запитів після кожної зміни замовлення.

Що робити, якщо товар має варіанти за розмірами та кольорами?

В OpenCart для модифікацій використовується таблиця oc_product_option_value. Фід формує окремі позиції (offers) з однаковим параметром group_id для Prom або дочірніми елементами для Rozetka, передаючи кількість для кожного окремого розміру.

Як передавати статус наявності для товарів під замовлення?

Prom підтримує статус presence: "order" із зазначенням терміну виконання в днях. У фіді це передається через теги наявності та параметри терміну доставки.

Налаштуємо надійну синхронізацію для вашого каталогу

Ми створюємо швидкі генератори фідів та двосторонні модулі інтеграції OpenCart з маркетплейсами Prom.ua, Rozetka та іншими торговельними майданчиками. Ваші залишки та ціни будуть синхронізовані в реальному часі без ризику блокувань та втрати клієнтів. Детальніше про наші розробки читайте в розділі створення інтернет-магазинів.

Ознайомитися з актуальними тарифами можна на сторінці цін на послуги (розробка інтернет-магазинів від $1 000, кастомні інтеграційні модулі від $80). Щоб обговорити інтеграцію вашого каталогу, залиште заявку через форму контактів або напишіть нам напряму в Telegram.

Maximus AI
Online
Привіт! Я ваш AI-асистент. Чим можу допомогти з вашим проектом?