Mantener un módulo de Odoo en 19, 18 y 17 a la vez no es copiar la carpeta a tres ramas y cambiar el número del manifest. Es una rama de git por versión, un build de instalación real por versión y un inventario de diferencias entre versiones que se revisa en cada release. Nosotros mantenemos 117 módulos publicados en las tres versiones; esta es la lista de lo que se rompe, incluidos nuestros propios fallos.
Una ficha, tres ramas (19.0, 18.0 y 17.0 con el mismo nombre técnico) está explicado en publicar un módulo en la Odoo App Store. Aquí va lo que pasa dentro de cada rama.
Lo que cambia entre 17, 18 y 19 y no sale en las guías de novedades
Las novedades de Odoo 19 hablan de funcionalidad; esta tabla, de lo que falla al instalar o, peor, no falla. Está comprobada contra el código fuente de las tres versiones, no contra un changelog.
| Qué | Odoo 17 | Odoo 18 | Odoo 19 | Cómo falla |
|---|---|---|---|---|
| Restricciones SQL | _sql_constraints | _sql_constraints | models.Constraint | En silencio: 19 avisa y sigue; la restricción no existe |
| Cron sin límite | numbercall=-1 obligatorio | No existe el campo | No existe el campo | 17: sin él corre una vez y se apaga. 18/19: con él no instala |
| Grupos del usuario | groups_id | groups_id | group_ids | Campo inválido al instalar o ejecutar |
| Usuarios del grupo | users | users | user_ids | Igual; nos reventó un cron de avisos en 17 y 18 |
| Categoría del grupo | category_id | category_id | privilege_id → res.groups.privilege | Invalid field 'category_id' in 'res.groups' |
| Vista de lista | <tree> | <list> | <list> | Tipo de vista inválido; los xpath //tree no casan |
El más peligroso: _sql_constraints
En Odoo 19 _sql_constraints se ignora: el cargador de modelos escribe «no longer supported» en el log y sigue. El módulo instala, los tests pasan y la restricción de unicidad no está en la base de datos; un conector que confía en unique(external_id) para no duplicar pedidos pierde esa garantía sin que nada lo diga. La forma nueva, models.Constraint('unique(...)', 'mensaje'), solo existe en 19. Odoo trae un conversor, odoo/cli/upgrade_code.py --script 18.1-00-sql-constraint, con una trampa que aprendimos migrando las nuestras: devuelve código de salida 1 cuando ha cambiado ficheros, o sea, 1 es éxito. Lo único que demuestra que la restricción está viva es un test que intente el duplicado.
El que nos pilló a nosotros: numbercall
En Odoo 17, ir.cron.numbercall vale 1 por defecto: un cron declarado sin ese campo se ejecuta una vez y se desactiva solo, sin error ni línea de log. Odoo 18 eliminó el campo y desde ahí declararlo impide instalar, así que la rama 17 necesita <field name="numbercall">-1</field> en cada cron y las otras dos no pueden llevarlo. En septiembre de 2026 auditamos nuestros propios ports a 17 y encontramos 14 crones sin ese campo en cuatro conectores, dos con demo en marcha: nadie prueba una sincronización periódica esperando a la segunda ejecución. La corrección son dos líneas por cron; el coste fue no tenerlo en el inventario.
Lo que Odoo 17 valida y 18 y 19 dejan pasar
El tráfico no va solo hacia arriba. Odoo 17 exige que un campo usado en un modificador de vista (invisible, readonly) esté en esa misma vista; 18 y 19 no lo comprueban. Un módulo escrito en 19 y portado hacia atrás falla en 17 con Field 'state' used in modifier 'invisible' (state == 'draft') must be present in view but is missing. Por eso un port no se da por bueno hasta que se instala en la versión más antigua. Más casos en un módulo de Odoo 19 compila pero no se instala y errores de migración a Odoo 19.
Estrategia de ramas: una por versión y nada de condicionales
La rama principal es la de la versión más nueva, hoy 19.0. Cada cambio se hace y se prueba ahí y después se lleva hacia atrás con cherry-pick a 18.0 y 17.0, adaptando lo que la tabla obliga a adaptar. Lo que no hacemos nunca es un solo código con if version >= 18 dentro: sale un módulo con las tres APIs a la vez, del que nadie sabe cuál de las tres ramas está probando. Para los conectores nuevos generamos las ramas 18 y 17 desde el fuente de 19 con un script que codifica la tabla de arriba; no sustituye a la prueba, sustituye al copiar y pegar.
Dos excepciones, y las dos preguntan por una capacidad, no por un número de versión:
- Detección de campo o modelo en tiempo de ejecución. Si el módulo escribe en un campo que no existe en todas las versiones, se pregunta al registro:
'mobile' in partner._fieldso'hr.expense.sheet' in self.env. Mira lo que hay, no lo que debería haber. - Tests que se saltan cuando la capacidad no está, con un
skipTestcondicionado a la presencia del campo. Lo que no vale es pasar con 0 de 0: un módulo nuestro tenía cero tests en las tres ramas; al escribirle 25 salieron tres fallos reales, uno el cron de avisos que usabauser_idsen 17 y 18.
La prueba que vale: instalar de verdad en las tres
Ni el linter, ni el import, ni una suite verde en tu máquina demuestran que un módulo se instala: un XML con numbercall es XML válido y un xpath sobre //tree es un xpath válido. El fallo solo aparece cuando Odoo carga el módulo contra una base de datos de esa versión. Nuestra regla: un cambio no existe hasta que se ha instalado, limpio, en un Odoo real de cada versión y, si el módulo ya está desplegado en alguien, también como actualización desde la versión anterior.
El upgrade importa por una razón concreta: los grupos de base llevan noupdate="1", así que un módulo que añade su grupo de gestor a base.group_system con implied_ids lo consigue en una instalación nueva y Odoo lo ignora en silencio al actualizar. Lo vimos en una producción de Odoo.sh: el grupo quedó sin miembros y ni el administrador podía leer el campo de credenciales que dependía de él. Hace falta una migración en migrations/ además del XML, y un test que falle si el grupo queda vacío.
Usamos dos clases de banco. Uno de pruebas, un Odoo por versión —en local o en contenedores— que instala cada módulo y ejecuta su suite contra las tres: ahí corrieron las 37 apps de nuestra primera campaña, ahí el conector de Mirakl pasa 163 tests en cada versión y el de Amazon 786 en 19 y 685 en 18 y en 17. Y Odoo.sh, un build por rama, para la instalación real. Una vuelta de gate son unos diez minutos y merece cada uno: los dos módulos que más caro nos han costado este año tenían todo verde en local y no se instalaban (tests verdes y el conector falla en producción).
Fallo propio: la primera campaña de 37 apps la dimos por terminada con instalación y tests en verde. Cuatro días después, una segunda pasada sobre 47 apps en Odoo 17 y 18 Community, ejerciendo cada vista con datos, dejó 61 de 94 corridas limpias: 28 fallos de instalación y 4 de ejercicio, unos once fallos reales. Xpath sobre //list en 17, anclajes sobre campos que no existen en 17. Código portado hacia atrás sin instalarlo en la versión de destino.
El coste real y cuándo se abandona la 17
Tres ramas no triplican el desarrollo, porque la lógica se escribe una vez. Triplican la prueba y la publicación: tres instalaciones por cambio, tres suites, tres fichas que revisar, y un inventario que releer entero cuando salga la 20, que añadirá filas a la tabla. Odoo saca una versión mayor al año y mantiene las tres últimas; el día que publique la 20, la 17 sale de esa ventana y se deja de mantener. Hasta entonces la rama 17 recibe cada corrección: quien sigue en 17 está planificando una migración, y migrará con tu módulo o sin él.
Lo que sí reduce el coste es tener el inventario como herramienta: un escáner de solo lectura que señala qué línea pisa qué diferencia según la versión del manifest. El nuestro llegó a marcar 67 módulos por escribir category_id en un grupo, y los 67 estaban bien: esa línea vivía dentro de un <record> de res.groups.privilege, que es exactamente el arreglo correcto de 19. La regla miraba la línea y no el registro que la envuelve. Un recuento sin abrir el código no es un dato, y una regla que señala el arreglo correcto enseña a ignorar la herramienta.
Checklist antes de cada push
- Restricciones:
models.Constrainten 19,_sql_constraintsen 18 y 17, y un test que intenta el duplicado. - Crones:
numbercall=-1en todos los de la rama 17; ninguno en 18 y 19. - Grupos:
group_ids,user_idsyprivilege_id(modelores.groups.privilege) en 19;groups_id,usersycategory_iden 18 y 17. - Vistas:
<list>en 19 y 18,<tree>en 17; en 17, todo campo usado en un modificador está en la vista. - Instalación limpia en las tres, y actualización desde la versión anterior si ya está desplegado.
- Suite completa en verde en las tres, y no vacía: 0 de 0 no es verde.
- Nada de
__pycache__en el commit. Ungit add -Atras los tests nos metió 1.078 ficheros.pycen 35 de 37 repositorios.
Es lo que hacemos cuando un partner de Odoo nos encarga mantener un módulo suyo en varias versiones, o cuando una consultoría necesita que un desarrollo que le funciona en 17 llegue a 19 sin perder la 17. El atajo es lo que descubre el primer comprador de la ficha.
Preguntas frecuentes
¿Puedo mantener un solo código con condicionales por versión?
El App Store publica desde ramas 17.0, 18.0 y 19.0, así que acabas con tres ramas igualmente, solo que con las tres APIs mezcladas en cada una. Una rama por versión es más código repetido y muchos menos fallos. Las únicas condiciones aceptables preguntan por una capacidad, nunca por el número de versión.
¿Por qué no basta con que compile y los tests pasen en local?
Porque los fallos de versión aparecen al cargar el módulo contra una base de datos de esa versión, no al importar el código; y algunos, como _sql_constraints en 19, ni siquiera fallan: hay que probar que la restricción existe.
Enlaces útiles dentro de FlexigoTech
¿Tienes un módulo en una versión y lo necesitas en las tres?
Lo instalamos en 19, 18 y 17, te decimos qué se rompe en cada una y lo dejamos con una rama por versión y su build en verde. Escribe a comercial@flexigobe.com o llama al +34 616 809 504.

