Skip to Content

Один каталог — п'ять товарних фідів

Задача: віддати той самий каталог товарів у Google Merchant, Facebook, Rozetka, Prom.ua і Епіцентр. У кожного — свій XML, свій набір обов'язкових тегів, свої вимоги до картинок. П'ять окремих генераторів писати нема сенсу: вибір товарів, ціна, залишок, картинка — усюди однакові. Розглянемо, як це зроблено в kw_marketplace і п'яти надбудовах над ним: kw_marketplace_google_xml, ..._facebook_xml, ..._epicentrk_xml, ..._rozetka_xml, ..._promua_xml.

Важливо! На 18.0 з цих п'яти реально встановлюються три — kw_marketplace_epicentrk_xml, kw_marketplace_rozetka_xml, kw_marketplace_promua_xml. У kw_marketplace_google_xml і kw_marketplace_facebook_xml в маніфесті стоїть 'installable': False: на 18.0 ці два поки не встановлюються, порт не завершено. Нижче код обох модулів лишається прикладом того, як влаштований спільний шар, а не діючою інтеграцією.

Спільне ядро

Базовий модуль не знає нічого про конкретний майданчик. Йому досить чотирьох моделей: kw.mp.merchant (підключення магазину до майданчика), kw.mp.field (яке поле Odoo йде в який тег), kw.mp.offer (товар × продавець) і kw.mp.offer.value (значення одного поля для одного товару). Мапінг — не код, а дані:

algorithm = fields.Selection(
    default='field', required=True,
    selection=[('name', 'Name'), ('url', 'URL'), ('field', 'Field'),
               ('category_name', 'Feed category name'),
               ('category_ref', 'Feed category ID'),
               ('product_image_url', 'Product image URL'),
               ('feed_offer_ref', 'Feed offer ID'),
               ('stock_quantity', 'Stock quantity')])

Значення поля рахується тим самим прийомом, яким побудований увесь модуль, — виклик методу за іменем:

def _compute_value(self):
    for obj in self:
        if obj.is_manual:
            obj.value = obj.manual_value
        elif obj.mp_field_id:
            obj.value = getattr(
                obj, f'get_value_by_{obj.mp_field_id.algorithm}')()
        else:
            obj.value = False

Мерчант отримує свою копію шаблонів фіда й офера ще при створенні — правка під одним клієнтом не чіпає інших.

Де закінчується спільний код

Той самий трюк вирішує, куди піде рендеринг категорій. Ключ — kw.marketplace.name (google, facebook, epicentrk, rozetka, prom):

def get_qcontext(self):
    self.ensure_one()
    name = f'get_qcontext_{self.marketplace_id.name}'
    if hasattr(self, name):
        return getattr(self, name)()
    return {'categories': [...]}

kw_marketplace_rozetka_xml просто додає свій метод, без класів і реєстрів:

def get_qcontext_rozetka(self):
    return {
        'categories': [{
            'id': x.res_id, 'ref': x.mp_category_id.ref,
            'name': x.res_name,
        } for x in self.env['kw.mp.category.mapping'].sudo().search([
            ('merchant_id', '=', self.id)])]}

Неправильно — одна Python-гілка if marketplace == ... на всі XML-структури. Правильно — окремий модуль на кожен майданчик з тим самим суфіксом методу скрізь. Пастка: модуль kw_marketplace_promua_xml, а код у kw.marketplace.nameprom, не promua.

Ну і ось тут проходить межа. Google хоче Atom з простором імен Google Shopping:

<entry>
    <g:id t-raw="offer_ref"/>
    <g:price t-raw="price + ' ' + currencyId"/>
    <g:brand t-raw="brand"/>
</entry>

Rozetka й Prom — не Atom, а старий yml_catalog з DOCTYPE на shops.dtd:

<offer t-att-id="offer_ref">
    <stock_quantity t-esc="stock_quantity or 0"/>
    <price t-esc="price"/>
    <categoryId t-esc="categoryId"/>
</offer>

У Prom шаблоні лишились обидва теги: categoryId — мертвий залишок від спільного з Rozetka шаблону, якого нема в marketplace_fields.json для Prom, тому він рендериться порожнім без помилки; а реальний тег категорії — живий portal_category_id (алгоритм category_ref), який справді мапиться і потрапляє в JSON.

Мапінг полів і донастройка вручну

marketplace_fields.json кожного модуля — не повний список тегів шаблону, а стартовий набір. Офер-шаблон Facebook звертається до google_product_category:

<g:google_product_category t-raw="google_product_category"/>

— якого в JSON немає. Розраховано на ручне донастроювання: kw.mp.field додається рядком у списку на формі мерчанта. Такі поля виникають із бізнес-рішень, які json заздалегідь не вгадає.

Обов'язковість поля теж не однакова для всіх. description обов'язковий у Google, Facebook і Епіцентрі, а в Rozetka й Prom позначений is_required: false:

@api.depends('value', 'is_required')
def _compute_is_correct(self):
    for obj in self:
        obj.is_correct = (not obj.is_required) or obj.value

Якщо хоч одне обов'язкове поле товару порожнє — увесь офер стає is_correct = False і зникає з фіда цілим рядком, не порожнім тегом. На картці мерчанта є лічильник incorrect_offer_count, тож дізнатися про це можна без логів.

Варіанти, категорії, картинки

kw.mp.offer.product_id — це product.product, тобто варіант, а не шаблон товару. Ціна й залишок Odoo вже рахує на рівні варіанта — досить обмеження в базі:

_sql_constraints = [(
    'kw_mp_offer_merchant_product_uniq',
    'unique(merchant_id, product_id)',
    'Product must be unique for merchant.',
)]

Категорія майданчика мапиться не на product.category Odoo, а на довільне поле, яке вкаже адміністратор (category_field_id, домен — будь-який many2one на product.product). Головне зображення береться за адресою /web/image/product.product/<id>/image_1024; додаткові — з product_template_image_ids, і кожен майданчик сам ріже галерею: Епіцентр бере перші 14 картинок, Facebook — 20.

Кешування

Генерувати весь каталог на кожен запит до фіда — дорого, тому важку частину рахують заздалегідь, у два кроки. Зміна залишку чи відстежуваного поля товару одразу дописує product_id у файлову чергу kw_marketplace_offer_queue. Далі один крон (5 хвилин) розбирає чергу і переводить офер у state='created', другий — рендерить XML-шматок кожного офера в prepared_xml:

<field name="code">model.search([('state', '=', 'created')], limit=500).action_fill_in_fields()</field>
<field name='interval_number'>5</field>
<field name='interval_type'>minutes</field>

Тобто гірший випадок затримки — не 5 хвилин, а близько 10: спершу чергу мусить розібрати перший крон, потім другий — дорендерити prepared_xml. А сам HTTP-запит просто клеїть готові шматки:

for offer in offer_ids:
    opened_file.write(str.encode(offer.prepared_xml or ''))

Кеш є, але тільки на рівні даних. HTTP-відповідь збирається заново на кожен виклик, без Cache-Control і без ETag.

Доступ

Тут я не буду ввічливим. Контролер фіда:

@http.route([
    '/kw_marketplace_feed/<feed_code>.xml',
    '/kw_marketplace_feed/<feed_code>.xlsx'
], auth='public', website=False, methods=['GET', 'POST'])

auth='public' — без токена, без сесії. А feed_code — звичайний текстовий рядок без значення за замовчуванням:

code = obj.feed_code or obj.id

Не заповнили feed_code руками — у URL піде числовий ID мерчанта: /kw_marketplace_feed/1.xml, /kw_marketplace_feed/2.xml.

Важливо! Це не автентифікація, а обфускація рядком. Довгий випадковий feed_code на кожен майданчик — мінімум, який варто зробити руками, бо сам модуль цього не робить.

Де ідея одного генератора закінчується

Prom.ua історично приймає ще й XLSX замість XML. На мерчанті є перемикач is_xlsx_used, і ось тут спільний QWeb-конвеєр просто вимикається:

def write_data_to_file(self, opened_file):
    self.ensure_one()
    if self.is_xlsx_used:
        return self._write_prom_xlsx(opened_file)
    return super().write_data_to_file(opened_file)

_write_prom_xlsx збирає книгу через xlsxwriter — свої аркуші, свої колонки, змінна кількість колонок під характеристики товару. Рядок таблиці й вузол XML — різні структури даних: тут правильніше перехопити виклик на рівні write_data_to_file, ніж гнути спільний конвеєр далі.

Один каталог — п'ять товарних фідів
KitWorks, Volodymyr Karabanov 18 серпня 2026 р.
Поділитися цією публікацією
Архів