logistic-node.odoo.com · saas~19.4 · разбор
Что именно не умеет штатная Odoo 19, когда складов становится восемь, и как это оказалось возможно закрыть настройкой, не трогая исходники. Документ объясняет «почему», а не «куда нажать» — за «куда нажать» отвечает прогонный лист.
Есть сеть из восьми складов: два хаба и шесть магазинов. Товар едет с хаба в магазин через транзит. Пока всё идёт по плану, Odoo справляется идеально. Вопрос в том, что происходит, когда план ломается.
Ломается он тремя способами, и все три — обыденные:
Магазин всегда берёт с центрального склада. Сегодня на центральном не хватает — но на резервном лежит. Кто-то должен заметить и переключить.
Одному магазину не хватает, соседнему — девать некуда. Везти от хаба дороже и дольше, чем перекинуть между магазинами. Но никто не знает, у кого излишек.
Склад встал: авария, инвентаризация, карантин. Все, кто с него кормился, должны за минуты переехать на резервный — и так же вернуться, когда узел поднимется.
Это не экзотика и не «хотелки». Это ровно то, ради чего распределённую сеть и строят: чтобы отказ одного плеча не останавливал продажи. И это ровно то место, где штатная Odoo молчит.
Соблазнительно думать, что чего-то не хватает в логистике. Это не так. Физику Odoo закрывает полностью: транзитные локации, двухногие маршруты, приёмки, батчи, сроки — всё собирается штатно и работает. Не хватает не перевозки. Не хватает решения.
В модели stock.warehouse нет признака состояния. Есть только «активен / в архиве», и архивировать склад с ненулевым остатком Odoo запрещает дословно — проверено на стенде.
То есть выключить узел из сети штатно нечем: единственный выключатель заблокирован ровно в той ситуации, ради которой он нужен.
Правило пополнения (orderpoint) хранит route_id — ровно одну ссылку. Один маршрут — один поставщик. Списка приоритетов нет, запасного источника нет, условия «если у первого пусто» нет.
Odoo выполнит правило буквально: закажет с указанного склада, даже если там ноль, и будет ждать.
Система знает остаток каждого склада, но не знает, сколько ему самому надо, а сколько лишнее. Без этого нельзя ответить на вопрос «у кого можно забрать, не навредив».
Значит, латеральная переброска между магазинами невозможна как автоматика — только как ручное решение человека, который держит цифры в голове.
Пока склада три, дырки не видно: кладовщик просто помнит, что и откуда. На восьми узлах и двадцати позициях это уже 160 правил пополнения. Вывод одного хаба из сети означает вручную переставить маршрут у 60 правил шести магазинов, а потом ещё приглушить 20 собственных правил мёртвого склада, чтобы он не заказывал товар сам себе.
Замер на стенде. Ручной failover одного узла — примерно 70–115 действий оператора в шести разных меню, и это при условии, что он ни разу не ошибётся и ничего не забудет. Обратная операция — столько же, плюс нужно откуда-то знать, какой маршрут у каждого правила был до переключения. Этого нигде не записано.
Необратимость здесь хуже трудоёмкости. Переключить можно; вернуть точно как было — уже нет.
Первым побуждением было писать модуль: своя модель сети, свой планировщик, свои стратегии снабжения. Это месяцы. Но если посмотреть на три сбоя внимательно, видно, что все они упираются в одно и то же поле.
Кто решает, откуда приедет товар? Тот, кто записал маршрут в правило
orderpoint.route_id, и записать его туда. Дальше штатный планировщик Odoo сам создаст перемещение, транзит и приёмку — он это умеет безупречно.Как только задача сформулирована так, она перестаёт быть архитектурной. Не нужно подменять снабжение — нужно перенацеливать правило до того, как снабжение отработает. А для этого хватает трёх вещей:
Три поля на складе: состояние, резервный узел, очередь источников. Это те знания о сети, которых в модели не было.
Три поля на правиле: маршрут до failover, маршрут до латерала, метка приглушения. Без памяти любое переключение необратимо.
Серверные действия в шестерёнке: вывести узел, вернуть узел, каскад, найти донора, отменить латерал. Каждое — кнопка, доступная кладовщику.
Почему это вообще получилось без кода. В saas~19.4 не установлены ни Studio, ни модуль автоматизаций. Зато ir.actions.server с питоновским кодом работает штатно — в базе их уже 211 от самой Odoo, — и пользовательские поля x_* создаются через интерфейс. Этого набора хватило ровно впритык.
Без этого раздела дальнейшее читается как фокус. Кнопка «вывести узел» кажется всемогущей — а она намеренно делает мало. Понять, где проходит граница её полномочий, важнее, чем помнить порядок шагов.
Когда в Odoo создают склад, рождается не запись, а целое хозяйство. На стенде у WH-A это:
| Что порождается | Сколько на WH-A | Почему это важно |
|---|---|---|
| Локации | WHA (вид) WHA/Stock (внутренняя) |
На них ссылается каждый остаток и каждая строка каждого перемещения за всю историю склада. |
| Типы операций | WHA/IN/ · WHA/OUT/ WHA/INT/ · WHA/POS/ |
Четыре штуки, у каждого своя нумерация документов. Все приходы, отгрузки, внутренние перемещения и продажи в кассе привязаны к ним. |
| Маршруты снабжения | 7 штук по числу узлов в Resupply From |
Каждая галочка «пополнять отсюда» создаёт маршрут из двух правил: «склад → транзит» и «транзит → получатель». На всей сети это 56 маршрутов. |
Отсюда следует то, что сбивает с толку всех, кто приходит из систем попроще: склад в Odoo нельзя удалить. За ним тянется хвост из локаций, типов операций, последовательностей, маршрутов и всех документов, которые на них ссылаются. Единственное, что с ним можно сделать штатно, — спрятать.
Odoo знает про склад ровно две вещи: active = true и active = false. Активен или в архиве. Ни «временно недоступен», ни «выводится», ни «резерв» — этих понятий в модели нет.
И даже эта единственная дверь заперта. Архивировать склад с ненулевым остатком Odoo отказывается дословно — проверено на стенде. Логика разработчиков понятна: спрятать склад, на котором лежит товар, значит завести неучтённые запасы. Логика верная — но означает она вот что: штатного способа выключить работающий узел не существует вообще. Выключатель заблокирован ровно в той ситуации, ради которой он нужен.
x_node_state, четвёртое — штатный архив Odoo, до которого решение сознательно не доводит. Верхняя дуга — аварийный сценарий: из «доступен» сразу в «выведен», одной кнопкой. Нижняя — возврат, и он единственный, кто знает, куда именно возвращать.Разница между «ограничен» и «выведен» — не в градусе тяжести, а в том, что происходит с уже назначенными правилами:
Узел перестаёт выбираться: каскад его пропустит, латераль не назначит донором, failover не возьмёт резервом. Но ничего не переключается — те, кто уже на нём сидит, так и сидят.
Это режим инвентаризации, переезда, сезонного закрытия: новое не назначаем, начатое доезжает. Ставится вручную, прямо в поле на форме.
Массовое переключение: все, кто кормился от узла, немедленно переезжают на резерв, прежние маршруты уходят в память, собственные правила узла глушатся.
Это реакция на аварию, и потому она одной кнопкой. Ставится действием 933, руками поле лучше не трогать — переключение не произойдёт, а состояние соврёт.
Вот здесь обычно и возникает разрыв ожиданий. Действие 933 управляет будущим спросом, а не текущими обязательствами. Всё, что уже создано, оно принципиально не трогает.
| Объект | Что с ним при выводе узла |
|---|---|
| Правила пополнения потребителей 60 штук на стенде | переключены На резервный узел, прежний маршрут записан в память. |
| Собственные правила узла 20 штук | приглушены Переведены в ручной режим и помечены. Мёртвый склад не заказывает себе товар. |
| Физический остаток на складе | не тронут Как лежало 52 штуки UNV-01, так и лежит. Вывоз — отдельная работа людей, а не кнопки. |
| Резервы под созданные заявки | не сняты Из тех же 52 штук 25 остаются зарезервированными. Свободно по-прежнему 27. |
| Открытые документы 13 незакрытых на WH-A сейчас | не тронуты Доедут по-старому. Отменять или переадресовывать их — решение человека. |
| Локации, типы операций, нумерация | живы Склад продолжает существовать во всех смыслах, кроме одного: он больше не выбирается источником. |
Практическое следствие, к которому стоит подготовить кладовщика. Сразу после переключения у магазина какое-то время идут два потока сразу: поставки, созданные до вывода, приезжают со старого узла, новые — с резервного. Выглядит как дубли, дублями не является.
И сроки поедут. На стенде плечо WH-A → S1 состоит из двух ног: «склад → транзит» с задержкой 0 и «транзит → магазин» с задержкой 1 день. У резервного узла плечо своё. Помните ловушку: lead_days правила показывает только последнюю ногу, а реальная длина цепочки — сумма обеих.
Это другая задача, и решение её сознательно не делает. Аварийный вывод обратим по замыслу; окончательное закрытие — необратимо, и автоматизировать необратимое одной кнопкой не стоит. Порядок такой:
Честно: шаги 02–04 в решении не автоматизированы никак. Это ручная процедура, и её стоит записать в регламент отдельно — она выполняется раз в несколько лет, и к моменту, когда понадобится, её никто не вспомнит.
Ни одно из них не считается автоматически — все заполняет человек или действие. Это осознанно: сеть должна быть описана явно, а не выведена из догадок.
| Поле | Подпись на форме | Что означает и почему без него нельзя |
|---|---|---|
| x_node_state | Состояние узла | Доступен / Ограничен / Выведен. Замена архивированию, которое заблокировано при остатке. Среднее значение не декорация: «ограниченный» узел не выбирается ни донором, ни резервом, но уже назначенные на него правила с места не срываются — это режим планового вывода, когда склад доезжает начатое. |
| x_failover_to | Резервный узел | Кто подхватит нагрузку. Заполнен только у хабов, замкнуто: WH-A ↔ WH-B. У магазинов пуст, и это правильно — магазин никто не подменяет, у него меняется поставщик. |
| x_source_priority | Приоритет источников | Очередь доноров через запятую, например WHA,WHB. Обычный текст, а не связь — сознательное упрощение: порядок важнее ссылочной целостности, а редактировать строку кладовщик может без обучения. У киевских магазинов WHA,WHB, у региональных WHB,WHA. |
| Поле | Где видно | Зачем |
|---|---|---|
| x_route_before_failover | Колонка в списке скрыта по умолчанию |
Маршрут, который стоял до вывода узла. Возврат идёт строго по этой записи, а не по догадке «наверное, был центральный». Без неё round-trip невозможен в принципе. |
| x_route_before_lateral | Колонка в списке | То же для латеральной переброски — отдельным полем. Латераль может висеть одновременно с failover, и одно не должно затирать память другого. |
| x_muted_by_failover | Колонка в списке | Метка «правило переведено в ручной режим потому, что его узел выведен». Без неё возврат не отличил бы приглушённые правила от тех, что оператор сам поставил на ручной — и вернул бы в авто лишнее. |
Почему память — два поля, а не одно. Это тот случай, когда экономия на поле стоит корректности. Представьте: магазин S1 переброшен на донора S6 (латераль), и в этот момент выводится хаб WH-A. Если память одна, второе переключение затрёт первое, и после возврата узла правило останется на S6 навсегда — тихо, без единой ошибки в журнале.
Ниже — не пересказ, а фактическая логика того, что сейчас стоит на стенде. Если что-то в прогоне повело себя иначе, чем здесь написано, расхождение стоит считать дефектом документа, а не вашей ошибкой.
Запускается на выделенных правилах пополнения из шестерёнки. Для каждого правила — по шагам, до первого совпадения:
qty_to_order. Ноль или меньше — правилу ничего не нужно, трогать нечего.route_id.supplier_wh_id. Если у маршрута нет склада-поставщика — это закупка у внешнего вендора (Buy), и подменять её межскладским маршрутом нельзя.x_source_priority склада-получателя разбирается по запятой. Дальше — цикл по кодам в заданном порядке.free_qty в контексте этого склада. Свободный, а не физический: зарезервированное под другие заявки не считается. Меньше потребности — узел отсеивается с числами в отчёте.S1 / UNV-01: WH-A → WHB (нужно 29, свободно 30). Первый подошедший побеждает — дальше очередь не просматривается.Ключевая проверка — шаг 02. Её не было в первой версии, и ночной прогон это немедленно наказал: действие увидело у хабов заполненную очередь источников (она стоит там ради failover) и бодро подменило их закупочный маршрут Buy на межскладской. Хабы перестали закупать у поставщиков и начали заказывать товар друг у друга по кругу.
Урок общий: признак «правило межузловое» надо проверять явно, а не выводить из того, что у склада заполнен каскад.
Ищет донора не среди хабов, а среди равных — соседних магазинов. Вся суть в одной формуле:
# сколько у соседа реально лишнего излишек = free_qty(склад-кандидат) − product_max_qty(его собственное правило)
То есть «лишнее» — это не весь остаток, а только то, что выше его собственного максимума. Магазин, который сам стоит на грани, донором не станет, даже если у него на полке много.
x_source_priority получателя, из кандидатов выбрасывается. Иначе латераль выродилась бы в обычное пополнение с хаба.x_route_before_lateral, правило переводится на донора. Действие 937 разворачивает это обратно.Самое крупное по эффекту: одна кнопка на форме склада вместо семидесяти с лишним ручных правок.
route_id.supplier_wh_id — выводимый склад. На стенде это 60 правил шести магазинов.x_muted_by_failover. Смысл житейский: мёртвый склад не должен заказывать себе товар.x_route_before_failover и запомненный маршрут вёл именно на этот узел. Возвращает маршрут, чистит память, снимает приглушение, ставит состояние «доступен». Ни одной догадки.Проверено на стенде: 60 из 60 правил переключены и 60 из 60 возвращены, распределение маршрутов после round-trip совпало с исходным до единицы — по 20 на маршрутах 33/40/47, 39 на Buy, всего 160. Обе колонки памяти после возврата пусты у всех 160 строк: ни одного осиротевшего значения.
Каскад сам по себе — ручная кнопка. Чтобы он работал без оператора, на него посажен ir.cron: ежедневно в 02:00 собирает правила с ненулевым дефицитом и прогоняет по ним каскад до того, как отработает штатный планировщик. Если дефицитов нет — пишет строку и ничего не трогает.
На время замеров крон снят с расписания намеренно: иначе он двигал бы стенд между прогонами, и сравнивать замеры было бы не с чем.
Настройкой закрыты решения, но не все. Ниже — честный список того, что решение не умеет, с последствиями. Это не список багов: это граница жанра, и проходить её надо с открытыми глазами, а не обнаружить в проде.
| Ограничение | Что это значит на практике | Чем закрывается |
|---|---|---|
| Один донор на правило | Действие выбирает единственный узел. Если нужно 30, а у одного соседа лишних 20 и у другого 15 — оно не сложит 20 + 10, а возьмёт того, у кого больше. Хуже того: выбранный донор может покрыть потребность не полностью — условие «излишек больше нуля» выполнено, а «излишка хватает» не проверяется. | модуль |
| Каскад не резервирует | Самое коварное. Действие смотрит свободный остаток, но ничего не занимает. Если прогнать каскад по десяти правилам подряд, а у WH-A свободно ровно на одно, — все десять честно увидят «хватает» и все десять встанут на WH-A. Дефицит вскроется только когда планировщик создаст перемещения. | модуль |
| Раз в сутки, а не по событию | Пересчёт источника происходит ночью по крону или когда оператор нажал кнопку. Продали последнюю штуку в 10:15 — источник переедет в 02:00 следующих суток. Реакции на событие нет. | модуль |
| Расстояние не учитывается | Очередь источников — статичная строка, которую задал человек. Ни километров, ни стоимости доставки, ни фактического времени плеча в выборе не участвует. Донор с излишком 45 за 500 км победит донора с излишком 40 за углом. | модуль |
| Документы не переезжают | Переключается правило, то есть будущие заявки. Уже созданные заказы и перемещения остаются на прежнем узле. Смена склада на подтверждённом заказе проходит, но отгрузка остаётся на старом складе — проверено. | модуль |
| История не хранится | Результат приходит всплывающим окном и живёт до первого клика. Постоянный след остаётся только в техническом журнале, который не привязан ни к складу, ни к правилу и не виден рядовому пользователю. Ответить «кто и когда вывел узел» через полгода нечем. | модуль |
| Заявки дробятся на документы | Одна потребность правила распадается на несколько документов. Причина структурная: модели procurement.group в saas~19.4 нет вообще, поле group_id убрано и с перемещения, и с документа. Прежним способом слить нечем — на эту роль остаются батчи, но это склейка на исполнении, а не единая заявка. |
батчи |
Про количества — отдельно. Дробление документов легко спутать с переордером, но это разные вещи. Проверено: четыре документа по одному правилу созданы в одну секунду, одним источником, с одним сроком; арифметика сходится точно. Дефект в форме, не в числах.
Девять процессов, по которым шёл разбор. Вердикт у каждого — не мнение, а результат прогона на стенде.
| № | Процесс | Состояние | Вердикт |
|---|---|---|---|
| 1 | Сеть узлов и маршруты | Полная сетка 8×8 через транзитные локации собирается штатно. Бонусом Odoo сама сливает отгрузки на разные магазины в один документ. | штатно |
| 2 | Состояние узла | Понятия в модели не было, архивирование с остатком заблокировано. Закрыто полями на форме склада — отключение узла больше не требует архивирования. | настройка |
| 3 | Пополнение по мин/макс | 160 правил работают. Требуется ручной перевод в авто и осмысленный горизонт. | вручную |
| 4 | Переключение источника у точки | Массовая смена маршрута в списке правил работает — 7 действий на точку. | вручную |
| 5 | Каскад источников | Правило по-прежнему знает один источник; каскад надстроен действием и ночным кроном. При нужде 29 пропустил WH-A с 27 и выбрал WH-B с 30. | настройка |
| 6 | Исполнение переброски | Транзит, приёмка, батчи — штатно, оценки трудоёмкости реалистичны. | вручную |
| 7 | Выбор латерального донора | Маршрут между магазинами настраивается штатно; выбор донора закрыт действием. Нашёл S6 с излишком 45, отсеяв пять кандидатов с нулём. | настройка |
| 8 | Переадресация документов | Смена склада на подтверждённом заказе проходит, но отгрузка и приёмка остаются на старом узле. Правится только кодом в stock. | модуль |
| 9 | Failover узла | Стал кнопкой. 60 правил шести магазинов переключены на резерв и возвращены в исходное состояние; 20 собственных правил узла приглушены. | настройка |
Четыре процесса из девяти перешли из «нужен модуль» в «хватило настройки»
Всё описанное выше стоит на стенде живьём и проверяемо руками. Проверять лучше от лица обычного пользователя: режим разработчика нигде не нужен, все пять действий живут в шестерёнке и отвечают всплывающим окном на экран.
Четырнадцать сценариев с посевом, шагами, ожидаемым результатом и откатом. Отметки сохраняются в браузере, итог выгружается текстом.
Ревизия базы, топология, история спроса, матрица покрытия, перечень созданных объектов и список того, что осталось за границей настройки.
Один нюанс перед прогоном. Сценарии подобраны под точные числа на стенде: например, каскад имеет смысл проверять при потребности ровно 29, потому что у WH-A свободно 27, а у WH-B — 30. Если остатки на базе уже кто-то двигал, это окно схлопнется, и сценарий покажет не то. В прогонном листе есть раздел с исходными числами — сверьтесь с ним до начала.