Більшість запитів на інтеграцію Odoo приходять одним реченням про труби: підключити Odoo до маркетплейсу, до складу, до банку. Труби — легша половина. Дві системи вже не згодні між собою, що таке товар, що входить у ціну і коли замовлення стає справжнім, і конектор або залагоджує ці суперечки, або щоночі переносить їх у вашу базу.
Далі — порядок, у якому я йду, і те, що мені однаково довелося доробляти потім.
Перше питання не технічне: чиє яке поле
Перш ніж обрано транспорт, має існувати одна таблиця. Ліворуч — кожен шматок даних, що перетинає кордон: товар, залишок, ціна, замовлення, клієнт, рахунок. Згори — дві колонки: хто має право це змінювати і хто лише отримує.
Залишки — найчесніший приклад. Якщо кількості може міняти маркетплейс і Odoo теж, у вас не інтеграція, а перегони, і програє той, хто записав другим. Тож таблиця каже: кількості належать Odoo. Маркетплейсу їх повідомляють, а все, що він там собі думає, перезаписується наступним прогоном. Цей один рядок вартий більше за будь-який обсяг логіки злиття, а домовитися про нього — десять хвилин.
Кожне поле, де відповідь «обидва», повертається пізніше. Перше, що я перевіряю, — email клієнта, бо його найімовірніше правлять з обох боків: раз у вебшопі, коли клієнт виправляє одруківку, раз в Odoo, коли хтось відповів на дзвінок. Тихо виграє той прогін, який стався наступним, і ніхто про це не дізнається, доки рахунки не поїдуть на стару адресу.
Транспорт — це вибір, і в Odoo 19 він змінився
Odoo вміє розмовляти кількома способами, і неправильно обраний коштує дешево сьогодні й дорого на четвертий місяць.
XML-RPC і JSON-RPC — вбудоване API Odoo, якому не потрібно ані рядка коду на боці Odoo. Добре для скрипта, який живе десь окремо і ганяє помірну кількість записів. Погано як хребет навантаженої синхронізації: кожен execute_kw несе облікові дані й автентифікується заново, тож цикл по десяти тисячах записів — це десять тисяч логінів.
Дві речі варто знати, перш ніж на цьому будувати. Обліковими даними має бути API-ключ із власних налаштувань користувача, а не пароль — із увімкненою двофакторною пароль не спрацює взагалі. І в Odoo 19 старі точки входу оголошено застарілими: у вихідниках сказано прямо — /xmlrpc, /xmlrpc/2 і /jsonrpc доживають, на зміну їм ідуть POST /json/2/<model>/<method> і bearer-токен. Сьогодні вони ще відповідають. Якщо ви пишете конектор під 19-ту зараз — пишіть під нову; якщо вам дістався написаний під /xmlrpc/2, це рядок у наступній оцінці оновлення, а не несподіванка посеред нього.
Контролер у власному модулі Odoo — те, що потрібно, коли інша система штовхає дані, а вам треба їх перевірити, перетворити і відповісти чимось осмисленим. Він вимагає трьох рішень, яких не відкласти: auth='user' означає справжнього користувача і справжню сесію, auth='public' працює з дуже маленькими правами користувача сайту, а auth='none' означає, що автентифікацію ви робите самі в перших п'яти рядках методу. Точці входу, куди постить чужий сервер, потрібен ще csrf=False — і це рівно той момент, коли ви взяли на себе відповідальність перевіряти, хто дзвонить. У 19-й тип маршруту для JSON тепер jsonrpc; type='json' ще працює і пише попередження про застарілість.
Вебхуки. Вбудований приймач у Odoo є з 17-ї: правило автоматизації з тригером «за вебхуком» дає вам URL із секретом усередині й кладе тіло запиту в змінну. Справді корисно для рідкісного хука. Зверніть увагу, чого воно не робить: перевірки підпису там немає ніде, тож модель безпеки — «ніхто ніколи не вставляв цей URL у тікет підтримки», а на день, коли хтось таки вставив, є кнопка «оновити секрет». Усе, що рухає гроші чи залишки, я все одно веду через власний контролер, де підпис перевіряють до розбору тіла. Публічна точка входу, яка пише все, що їй надіслали, — це чужий доступ на запис до вашої бази.
Файли через SFTP — це досі те, як працює багато постачальників, і нічого поганого в цьому немає. CSV, який щоранку о шостій лягає в теку, простіший для розуміння, ніж API без чейнджлогу і без сторінки статусу. Файлам потрібні тека приймання, архів того, що прийшло, і записане правило для рядка, який не розбереться.
Більшість того, що я будую, використовує два з цих способів: push для замовлень, бо вони термінові, і нічний pull, який звіряє підсумки і скаржиться, коли вони не збігаються.
Вважайте, що кожне повідомлення приходить двічі
Мережі повторюють, черги повторюють, і люди тиснуть кнопку ще раз, бо перший клік «нічого не зробив». Питання ніколи не стояло, чи прийде повідомлення двічі.
Відповідь — зовнішній ключ, збережений на боці Odoo: ідентифікатор замовлення з маркетплейсу — на замовленні, ідентифікатор рядка постачальника — на рядку, і унікальне обмеження за ним. Тоді друга доставка оновлює запис, а не створює його близнюка.
Дві деталі, які вже коштували людям продакшен-тижня. Перша: робіть ключ у розрізі компанії, якщо в базі їх більше однієї — дві компанії, що купують в одного постачальника, цілком законно побачать однаковий зовнішній ідентифікатор. Друга: подивіться в лог, коли обмеження встановлюється вперше. Якщо в таблиці вже лежать дублікати, Postgres відмовиться його створювати, Odoo запише попередження і піде далі — модуль стає зеленим, а гарантії, яку ви думали, що купили, немає.
Без цього ключа нічого не падає. Просто склад друкує два комплекти документів на одне замовлення, і першим про це дізнається той, хто стоїть там, тримаючи обидва.
Вихідний напрямок має ту саму проблему в дзеркалі, і чистої відповіді в неї немає: ваша транзакція в базі не може вмістити чужий HTTP-виклик. Зафіксуєте прапорець до виклику — збій лишить запис позначеним як надісланий, хоч його не надсилали. Зафіксуєте після — таймаут змусить надіслати ще раз. Тож запис позначають «у дорозі» до виклику і «надіслано» після, приймальний бік роблять ідемпотентним за моїм референсом, щоб надіслати двічі було нудно, а прогін за розкладом підбирає все, що висить «у дорозі» вже годину, — бо ззовні падіння має вигляд саме такий.
Таблиця відповідностей — і є справжній проєкт
Транспорт забирає день. Мапінг забирає проєкт.
Одиниці виміру, які є з одного боку і яких немає з іншого. Податки, включені в одній системі й додані в іншій. Маркетплейс, для якого варіант — це товар, а комплект — це варіант. Округлення, яке в Odoo не є властивістю поля: точність цін береться із записів decimal.precision, а округлення валюти — із самої валюти, тож два підсумки, що на вигляд рівні, різняться на копійку, і рядок тихо не знаходить пари. Гроші порівнюють через float_compare, ніколи через ==.
Штрихкоди — те, на що я натикаюся найчастіше. Один бік зберігає 13-значний EAN, інший губить провідний нуль, бо файл дорогою відкрили в табличному редакторі, і половина каталогу не знаходить відповідності, тоді як обидві системи рапортують про успіх.
Нічого складного тут немає. Просто довго, і саме тут інтеграція Odoo або вголос проговорює, що вона робить із крайніми випадками, або тихо втрачає два відсотки рядків. Ці два відсотки хтось знаходить у березні, у бухгалтерії.
Відповідності живуть як дані, а не як код: модель із правилом доступу, яку редагують зі спискового вигляду люди клієнта. Я не хочу бути вузьким місцем для «цей постачальник називає палету PAL, а той — PLT».
Що буває, коли інший бік лежить
I wrote in the piece on how modules get built that "retries twice, then parks the order and emails me" is an architecture. This is what it costs to actually have one.
Прогони йдуть через чергу завдань — на практиці це queue_job від OCA, бо в ядрі Odoo нічого подібного немає. Виклик стає записом завдання з власним каналом, схемою повторів зі зростаючими паузами і межею, після якої він зупиняється й чекає на людину, замість гатити по сервісу, якому явно погано. Йому потрібні рядок у конфігурації сервера і воркер, на якому крутитися, — це варто знати до того, як хтось пообіцяє таке на дешевому шаредному хостингу.
Робота за розкладом виконується як ir.cron, і тут мушу виправити те, що сам казав необережно: власне блокування крону не потрібне. Odoo саме блокує рядок, тож завдання не перетнеться з власним попереднім прогоном. Справжня пастка — годинник. Крон-воркера вбивають по ліміту часу, тож довгий імпорт не падає — його ріже навпіл, і ви дізнаєтеся про це так: учорашній прогін зупинився на постачальнику F. Довга робота належить черзі, а крон лише її запускає. І на сервері з воркерами завданням за розкладом взагалі потрібен налаштований крон-потік; без нього нічого не працює і ніхто не скаржиться — дуже тихий спосіб для інтеграції бути мертвою одразу після народження.
Одне правило, якого я не згинаю: інтеграція ніколи не має падати мовчки. Зупинене завдання, якого ніхто не бачить, гірше за падіння, бо бізнес далі ухвалює рішення за числами, які три дні тому перестали рухатися.
Обсяг змінює конструкцію, а не строк
Двісті записів пробачають усе. На ста тисячах той самий код зустрічає таймаут запиту, ліміт пам'яті й рейт-лімітер на тому боці, зазвичай саме в такому порядку, і жодного з них не було видно у зразковому файлі, який надіслав клієнт.
Писати пакетами: create() приймає список, і один виклик із тисячею словників — зовсім інший звір, ніж тисяча викликів. На масовому імпорті я ще вимикаю механіку, зроблену для людей — трекінг, чатер, сповіщення, — бо інакше більша частина прогону піде на написання повідомлень, яких ніхто не прочитає.
Дельта-синхронізація — це запит на те, що змінилося з минулого прогону, і write_date тут фільтр очевидний і трохи зрадливий: він рухається щоразу, коли перераховується збережене обчислюване поле, тож один масовий перерахунок робить «зміненим» увесь каталог. І про видалення він не каже нічого. Я тримаю на конекторі власну позначку часу останньої синхронізації, а видалення обробляю явно, бо жодна з двох систем не повідомить добровільно, що рядок перестав існувати.
Scale multiplies in ways the design has to know about in advance. A catalogue of a hundred and ten thousand products will not fit a full nightly pull into any sensible window. An instance serving forty storefronts is forty price lists and forty sets of stock in one run.
Перший день — окремий проєкт
Нічого з написаного вище не описує запуску. Перш ніж нічна синхронізація взагалі почне мати значення, хтось переносить історію: відкриті замовлення, поточні залишки, клієнтів, які існують в обох системах під трохи різними іменами.
Це окремий одноразовий імпорт зі власним звітом зіставлення — скільки рядків зійшлося, скільки створено, скільки лишили людині подивитися, — і він двічі проганяється на копії, перш ніж наблизитися до продакшену. Далі дві системи зазвичай тиждень працюють паралельно зі щоденним звіренням підсумків. Цей тиждень — найдешевша нагода, яку ви взагалі матимете, дізнатися, що мапінг був неправильний.
Як це тестують, коли з того боку живий постачальник
Мало в кого з партнерів є пісочниця, а стейджингова база — це копія продакшену з реальними доступами всередині. Перша ж репетиція на недоторканій копії підтвердить реальні замовлення реальному постачальнику і напише реальним клієнтам.
Тож стейджинг отримує звичну нейтралізацію — вихідна пошта вимкнена, завдання за розкладом вимкнені, — а понад те доступи конектора міняють на тестові або направляють у файл на диску. Там, де тестової точки входу немає взагалі, я записую тиждень реальних відповідей і потім їх відтворюю. Налаштовувати це довше, і це єдиний спосіб протестувати ті збої, які не попросиш партнера відтворити на замовлення.
Доступи і спокуса sudo
API-ключі живуть у власному конфігураційному записі модуля або в системних параметрах, ніколи у вихідниках. Обидва варіанти кращі за репозиторій, і жоден з них не сховище секретів: системні параметри читаються через sudo, тож будь-який код у базі може дістати значення, а саме значення їде в кожному бекапі — включно з копією на стейджингу і тією, що лежить у когось на ноутбуці. Що справді прибирає ключ з екранів — це обмеження поля групою налаштувань, яке викидає його з подання, а не просто ховає. Де хостинг дозволяє, найбезпечніше місце — конфігураційний файл сервера, бо він не їде разом із дампом.
А тепер те, що я найпильніше шукаю в будь-якому коді, включно зі своїм: sudo(). Він скидає правила доступу і record rules разом, це найшвидший спосіб змусити інтеграцію «працювати», а в публічному контролері це те, як точка входу, зроблена для одного постачальника, починає відповідати кожному, хто вгадав URL. Коли публічній точці входу справді треба торкнутися однієї моделі, чесна форма — окремий користувач рівно з тими правами і with_user().
Хто платить, коли їхній API змінюється
Інтеграцію не купують один раз, бо в іншого боку теж є голос. Маркетплейси перейменовують поля, банки міняють автентифікацію, а сам Odoo виводить транспорти з обігу — застарілі RPC-точки вище саме про це, і приходять вони з роками попередження для всіх, хто читає release notes.
Тож це фіксують письмово: які поламки мої й лагоджу я за гарантією, які оплачувані, бо зрушив інший бік, і приблизно скільки коштує підняття версії з будь-якого боку. Тоді перша ламка зміна — це запланований робочий день, а не сварка.
Три питання перед тим, як замовляти
Яка система володіє кожним полем і що стається, коли його міняють обидві?
Що робить інтеграція, коли інший бік шість годин повертає помилки?
Як я завтра вранці перевірю, що нічний прогін пройшов нормально?
Ви не оцінюєте відповіді, ви слухаєте, чи вони взагалі є. Перша має дати таблицю, а не речення. У другій десь має бути черга. У третій не має бути мене — якщо єдиний спосіб дізнатися, чи спрацював учорашній імпорт, це написати розробнику, робота не завершена; це має бути пункт меню, який офіс-менеджерка відкриває разом із кавою.
If you have two systems that should be talking and are not, write to me. Bring the field list, even a rough one, and we will spend the first half hour on who owns what.