Большинство запросов на интеграцию Odoo приходит одной фразой про трубы: соединить Odoo с маркетплейсом, со складом, с банком. Трубы — простая половина. Две системы уже расходятся в том, что такое товар, что входит в цену и когда заказ становится настоящим, и коннектор либо разрешает эти споры, либо каждую ночь пересылает их в вашу базу.
Дальше — порядок, в котором я иду, и то, что всё равно приходилось чинить потом.
Первый вопрос не технический: чьё какое поле
До того как выбран транспорт, должна появиться одна таблица. Слева — все данные, которые пересекают границу: товар, остаток, цена, заказ, клиент, счёт. Сверху два столбца: кто имеет право это менять и кто просто получает.
Остатки — честный пример. Если количество может менять маркетплейс и может Odoo, у вас не интеграция, у вас гонка, и проигрывает в ней тот, кто записал вторым. Поэтому в таблице написано: остатками владеет Odoo. Маркетплейсу их сообщают, а всё, что он думает о них знать, перезаписывается на следующем прогоне. Эта строчка стоит дороже любой логики слияния, а договориться о ней — десять минут.
Каждое поле, где ответ «оба», возвращается позже. Первым я проверяю почту клиента: её правят с обеих сторон чаще всего — один раз в интернет-магазине, когда клиент исправляет опечатку, второй раз в Odoo, когда кто-то взял трубку. Молча выигрывает тот прогон, который случился следующим, и никто об этом не узнаёт, пока счета не уйдут на старый адрес.
Транспорт — это выбор, и в Odoo 19 он изменился
Odoo умеет разговаривать несколькими способами, и неправильный выбор стоит дёшево сегодня и дорого — на четвёртый месяц.
XML-RPC и JSON-RPC встроены и не требуют кода на стороне Odoo. Хороши для скрипта, который живёт снаружи и перекладывает умеренное число записей. Плохи как хребет нагруженной синхронизации: каждый execute_kw несёт с собой учётные данные и заново авторизуется, поэтому цикл по десяти тысячам записей — это десять тысяч входов в систему.
Две вещи, которые стоит знать до того, как на этом строить. Учётными данными должен быть API-ключ из личных настроек пользователя, а не пароль: при включённой двухфакторной аутентификации пароль не сработает вообще. И в Odoo 19 старые адреса объявлены устаревшими — в исходниках сказано прямо, что /xmlrpc, /xmlrpc/2 и /jsonrpc уходят, а на смену идут POST /json/2/<model>/<method> и bearer-токен. Сегодня они ещё отвечают. Если вы пишете коннектор под 19 сейчас — пишите под новый API Odoo; если вам достался написанный под /xmlrpc/2, это строчка в оценке следующего обновления, а не сюрприз посреди него.
Контроллер в собственном модуле нужен, когда вторая система толкает данные, а их надо проверить, преобразовать и ответить чем-то осмысленным. Он требует трёх решений, которые нельзя отложить: auth='user' — это настоящий пользователь и настоящая сессия, auth='public' работает с очень маленькими правами пользователя сайта, а auth='none' означает, что аутентификацию вы делаете сами в первых пяти строках метода. Точке входа, куда постит чужой сервер, нужен ещё csrf=False — и это ровно тот момент, когда вы взяли на себя проверку того, кто звонит. В 19 тип маршрута для JSON теперь jsonrpc; type='json' ещё работает и пишет предупреждение об устаревании.
Вебхуки. Встроенный приёмник есть в Odoo с 17-й версии: правило автоматизации с триггером «по вебхуку» даёт адрес с секретом внутри и кладёт тело запроса в переменную. Для редких событий — правда удобно. Стоит понимать, чего он не делает: проверки подписи там нет нигде, поэтому модель безопасности звучит как «никто никогда не вставлял этот адрес в тикет поддержки», а на день, когда кто-то всё-таки вставил, есть кнопка перевыпуска. Всё, что двигает деньги или остатки, я по-прежнему веду через свой контроллер, где подпись проверяется до разбора тела. Публичная точка входа, которая пишет всё, что получила, — это чужой доступ на запись в вашу базу.
Файлы по SFTP — всё ещё то, как работает множество поставщиков, и ничего плохого в этом нет. CSV, который падает каждое утро в шесть, понятнее, чем API без списка изменений и без страницы статуса. Файлам нужны папка приёма, архив принятого и записанное правило для строки, которая не разберётся.
Большая часть того, что я строю, использует два способа сразу: push для заказов, потому что они срочные, и ночной pull, который сверяет итоги и ругается, если они разошлись.
Считайте, что каждое сообщение придёт дважды
Сети повторяют, очереди повторяют, а люди жмут кнопку ещё раз, потому что первый клик «ничего не сделал». Вопрос никогда не стоял так — придёт ли сообщение дважды.
Ответ — внешний ключ, сохранённый на стороне Odoo: идентификатор заказа маркетплейса на заказе, идентификатор строки поставщика на строке, и уникальное ограничение за ним. Тогда вторая доставка обновляет запись, а не создаёт её близнеца.
Две детали, каждая из которых уже стоила кому-то недели на боевом сервере. Первая: делайте ключ уникальным в пределах компании, если компаний в базе больше одной, — две компании, закупающиеся у одного поставщика, законно увидят один и тот же внешний идентификатор. Вторая: посмотрите в лог в тот момент, когда ограничение ставится впервые. Если в таблице уже лежат дубликаты, Postgres отказывается его создавать, Odoo пишет предупреждение и идёт дальше — модуль поднимается зелёным, а гарантии, за которую вы платили, нет.
Без этого ключа ничего не падает. Просто склад печатает два листа комплектации на один заказ, и первым об этом узнаёт тот, кто стоит и держит оба.
У исходящего направления та же проблема в зеркале, и чистого ответа у неё нет: ваша транзакция в базе не может содержать в себе чужой HTTP-вызов. Зафиксируете флаг до вызова — при сбое останется запись, помеченная как отправленная, хотя она никуда не ушла. Зафиксируете после — таймаут заставит отправить ещё раз. Поэтому запись помечается «в пути» до вызова и «отправлено» после, принимающая сторона делается идемпотентной по моей ссылке, чтобы отправить дважды было скучно, а задание по расписанию подбирает всё, что висит «в пути» уже час, — потому что снаружи именно так выглядит падение.
Таблица соответствий — вот где на самом деле проект
Транспорт занимает день. Соответствия занимают проект.
Единицы измерения, которые есть на одной стороне и которых нет на другой. Налоги, включённые в цену в одной системе и добавляемые в другой. Маркетплейс, для которого вариант — это товар, а комплект — вариант. Округление, которое в Odoo не свойство поля: точность цены берётся из записей decimal.precision, а округление валюты — из самой валюты, поэтому две одинаковые на вид суммы расходятся на копейку и строка молча не сопоставляется. Деньги сравнивают через float_compare, никогда через ==.
Штрихкоды — то, на что я натыкаюсь чаще всего. Одна сторона хранит тринадцатизначный 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. Долгая работа живёт в очереди, а крон только запускает её. И на сервере с воркерами заданиям по расписанию нужен вообще настроенный крон-поток; без него ничего не работает и никто не жалуется — очень тихий способ для интеграции Odoo умереть ещё до старта.
Одно правило, которое я не гну: интеграция не имеет права падать молча. Остановившееся задание, которого никто не видит, хуже падения, потому что бизнес продолжает принимать решения по цифрам, которые перестали двигаться три дня назад.
Объём меняет конструкцию, а не сроки
Двести записей прощают всё. На ста тысячах тот же код встречается с таймаутом запроса, лимитом памяти и ограничителем частоты на той стороне — обычно именно в таком порядке, и ни одного из них не было видно в примере файла, который прислал клиент.
Писать надо пакетами: 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(). Он снимает права доступа и правила записи разом, это самый быстрый способ заставить интеграцию «заработать», а в публичном контроллере это то, как точка входа, сделанная для одного поставщика, начинает отвечать любому, кто угадал адрес. Когда публичной точке входа действительно надо потрогать одну модель, честная форма — отдельный пользователь ровно с этими правами и with_user().
Кто платит, когда у них меняется API
Интеграция Odoo не покупается один раз, потому что у второй стороны есть право голоса. Маркетплейсы переименовывают поля, банки меняют аутентификацию, а сама Odoo выводит транспорты из обращения — устаревшие RPC-адреса выше ровно эта история, приходящая с многолетним запасом для всех, кто читает заметки к релизам.
Поэтому это записывают: какие поломки чиню я по гарантии, какие оплачиваются, потому что сдвинулась вторая сторона, и примерно во что обходится подъём версии с любой из сторон. Тогда первое ломающее изменение — это запланированный вечер, а не спор.
Три вопроса до того, как заказывать интеграцию
Какая система владеет каждым полем и что происходит, когда его меняют обе?
Что делает интеграция, если вторая сторона шесть часов возвращает ошибки?
Как мне завтра утром проверить, что ночной прогон прошёл нормально?
Вы не оцениваете ответы, вы слушаете, есть ли они вообще. На первый должна появиться таблица, а не фраза. Во втором где-то должна быть очередь. В третьем не должно быть меня: если единственный способ узнать, сработал ли ночной импорт, — написать разработчику, работа не закончена. Это должен быть пункт меню, который офис-менеджер открывает с утренним кофе.
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.