Manter um módulo Odoo na 19, 18 e 17 em simultâneo não é copiar a pasta para três ramos e mudar o número do manifest. É um ramo de git por versão, um build de instalação real por versão e um inventário de diferenças entre versões que se revê em cada release. Mantemos 105 módulos publicados nas três versões; esta é a lista do que se parte, incluindo as nossas próprias falhas.
Uma ficha, três ramos (19.0, 18.0 e 17.0 com o mesmo nome técnico) está explicado em publicar um módulo na Odoo App Store. Aqui fica o que acontece dentro de cada ramo.
O que muda entre a 17, a 18 e a 19 e não aparece nos guias de novidades
As novidades do Odoo 19 falam de funcionalidade; esta tabela, do que falha na instalação ou, pior, não falha. Está verificada contra o código-fonte das três versões, não contra um changelog.
| O quê | Odoo 17 | Odoo 18 | Odoo 19 | Como falha |
|---|---|---|---|---|
| Restrições SQL | _sql_constraints | _sql_constraints | models.Constraint | Em silêncio: a 19 avisa e continua; a restrição não existe |
| Cron sem limite | numbercall=-1 obrigatório | O campo não existe | O campo não existe | 17: sem ele corre uma vez e desliga-se. 18/19: com ele não instala |
| Grupos do utilizador | groups_id | groups_id | group_ids | Campo inválido na instalação ou na execução |
| Utilizadores do grupo | users | users | user_ids | Igual; rebentou-nos um cron de avisos na 17 e na 18 |
| Categoria do 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; os xpath //tree não encaixam |
O mais perigoso: _sql_constraints
No Odoo 19, _sql_constraints é ignorado: o carregador de modelos escreve «no longer supported» no log e continua. O módulo instala, os testes passam e a restrição de unicidade não está na base de dados; um conector que confia em unique(external_id) para não duplicar encomendas perde essa garantia sem que nada o diga. A forma nova, models.Constraint('unique(...)', 'mensaje'), só existe na 19. O Odoo traz um conversor, odoo/cli/upgrade_code.py --script 18.1-00-sql-constraint, com uma armadilha que aprendemos ao migrar as nossas: devolve código de saída 1 quando alterou ficheiros, ou seja, 1 é sucesso. A única coisa que demonstra que a restrição está viva é um teste que tente o duplicado.
O que nos apanhou a nós: numbercall
No Odoo 17, ir.cron.numbercall vale 1 por omissão: um cron declarado sem esse campo executa-se uma vez e desativa-se sozinho, sem erro nem linha de log. O Odoo 18 eliminou o campo e, a partir daí, declará-lo impede a instalação; por isso o ramo 17 precisa de <field name="numbercall">-1</field> em cada cron e os outros dois não o podem levar. Em setembro de 2026 auditámos os nossos próprios ports para a 17 e encontrámos 14 crons sem esse campo em quatro conectores, dois com demo em curso: ninguém testa uma sincronização periódica à espera da segunda execução. A correção são duas linhas por cron; o custo foi não o ter no inventário.
O que o Odoo 17 valida e o 18 e o 19 deixam passar
O trânsito não vai só para cima. O Odoo 17 exige que um campo usado num modificador de vista (invisible, readonly) esteja nessa mesma vista; o 18 e o 19 não o verificam. Um módulo escrito na 19 e portado para trás falha na 17 com Field 'state' used in modifier 'invisible' (state == 'draft') must be present in view but is missing. Por isso um port não se dá por bom até se instalar na versão mais antiga. Mais casos em um módulo Odoo 19 compila mas não se instala e erros de migração para o Odoo 19.
Estratégia de ramos: um por versão e nada de condicionais
O ramo principal é o da versão mais recente, hoje 19.0. Cada alteração faz-se e testa-se aí e depois leva-se para trás com cherry-pick para 18.0 e 17.0, adaptando o que a tabela obriga a adaptar. O que nunca fazemos é um único código com if version >= 18 lá dentro: sai um módulo com as três APIs ao mesmo tempo, do qual ninguém sabe qual dos três ramos está a testar. Para os conectores novos geramos os ramos 18 e 17 a partir do código da 19 com um script que codifica a tabela acima; não substitui o teste, substitui o copiar e colar.
Duas exceções, e ambas perguntam por uma capacidade, não por um número de versão:
- Deteção de campo ou modelo em tempo de execução. Se o módulo escreve num campo que não existe em todas as versões, pergunta-se ao registo:
'mobile' in partner._fieldsou'hr.expense.sheet' in self.env. Olha para o que existe, não para o que devia existir. - Testes que são saltados quando a capacidade não existe, com um
skipTestcondicionado à presença do campo. O que não vale é passar com 0 de 0: um módulo nosso tinha zero testes nos três ramos; ao escrever-lhe 25 apareceram três falhas reais, uma delas o cron de avisos que usavauser_idsna 17 e na 18.
O teste que vale: instalar a sério nas três
Nem o linter, nem o import, nem uma suite verde na sua máquina demonstram que um módulo se instala: um XML com numbercall é XML válido e um xpath sobre //tree é um xpath válido. A falha só aparece quando o Odoo carrega o módulo contra uma base de dados dessa versão. A nossa regra: uma alteração não existe até ter sido instalada, limpa, num Odoo real de cada versão e, se o módulo já estiver implementado em alguém, também como atualização a partir da versão anterior.
O upgrade importa por uma razão concreta: os grupos de base levam noupdate="1", pelo que um módulo que adiciona o seu grupo de gestor a base.group_system com implied_ids o consegue numa instalação nova e o Odoo ignora-o em silêncio ao atualizar. Vimo-lo numa produção do Odoo.sh: o grupo ficou sem membros e nem o administrador conseguia ler o campo de credenciais que dependia dele. É preciso uma migração em migrations/ além do XML, e um teste que falhe se o grupo ficar vazio.
Usamos dois tipos de bancada. Uma de testes, um Odoo por versão (em local ou em contentores) que instala cada módulo e executa a sua suite contra as três: aí correram as 37 apps da nossa primeira campanha, aí o conector da Mirakl passa 163 testes em cada versão e o da Amazon 786 na 19 e 685 na 18 e na 17. E o Odoo.sh, um build por ramo, para a instalação real. Uma volta de gate são uns dez minutos e vale cada um: os dois módulos que mais caro nos custaram este ano tinham tudo verde em local e não se instalavam (testes verdes e o conector falha em produção).
Falha própria: demos a primeira campanha de 37 apps por terminada com instalação e testes a verde. Quatro dias depois, uma segunda passagem por 47 apps no Odoo 17 e 18 Community, exercitando cada vista com dados, deixou 61 de 94 execuções limpas: 28 falhas de instalação e 4 de exercício, umas onze falhas reais. Xpath sobre //list na 17, âncoras em campos que não existem na 17. Código portado para trás sem o instalar na versão de destino.
O custo real e quando se abandona a 17
Três ramos não triplicam o desenvolvimento, porque a lógica escreve-se uma vez. Triplicam o teste e a publicação: três instalações por alteração, três suites, três fichas para rever, e um inventário que se relê inteiro a cada versão nova, como a 20 (setembro de 2026), que acrescenta linhas à tabela. O Odoo lança uma versão maior por ano e mantém as três últimas; no dia em que publicar a 20, a 17 sai dessa janela e deixa de ser mantida. Até lá o ramo 17 recebe cada correção: quem continua na 17 está a planear uma migração, e vai migrar com o seu módulo ou sem ele.
O que reduz o custo é ter o inventário como ferramenta: um scanner só de leitura que assinala que linha pisa que diferença consoante a versão do manifest. O nosso chegou a marcar 67 módulos por escreverem category_id num grupo, e os 67 estavam certos: essa linha vivia dentro de um <record> de res.groups.privilege, que é exatamente a correção certa da 19. A regra olhava para a linha e não para o registo que a envolve. Uma contagem sem abrir o código não é um dado, e uma regra que assinala a correção certa ensina a ignorar a ferramenta.
Checklist antes de cada push
- Restrições:
models.Constraintna 19,_sql_constraintsna 18 e na 17, e um teste que tenta o duplicado. - Crons:
numbercall=-1em todos os do ramo 17; nenhum na 18 e na 19. - Grupos:
group_ids,user_idseprivilege_id(modelores.groups.privilege) na 19;groups_id,usersecategory_idna 18 e na 17. - Vistas:
<list>na 19 e na 18,<tree>na 17; na 17, todo o campo usado num modificador está na vista. - Instalação limpa nas três, e atualização a partir da versão anterior se já estiver implementado.
- Suite completa a verde nas três, e não vazia: 0 de 0 não é verde.
- Nada de
__pycache__no commit. Umgit add -Adepois dos testes meteu-nos 1078 ficheiros.pycem 35 de 37 repositórios.
É o que fazemos quando um partner de Odoo nos encomenda manter um módulo seu em várias versões, ou quando uma consultora precisa que um desenvolvimento que lhe funciona na 17 chegue à 19 sem perder a 17. O atalho é o que descobre o primeiro comprador da ficha.
Perguntas frequentes
Posso manter um único código com condicionais por versão?
A App Store publica a partir dos ramos 17.0, 18.0 e 19.0, pelo que acaba com três ramos na mesma, só que com as três APIs misturadas em cada um. Um ramo por versão é mais código repetido e muito menos falhas. As únicas condições aceitáveis perguntam por uma capacidade, nunca pelo número de versão.
Porque não basta que compile e os testes passem em local?
Porque as falhas de versão aparecem ao carregar o módulo contra uma base de dados dessa versão, não ao importar o código; e algumas, como _sql_constraints na 19, nem sequer falham: é preciso provar que a restrição existe.
Ligações úteis dentro da FlexigoTech
Tem um módulo numa versão e precisa dele nas três?
Instalamo-lo na 19, 18 e 17, dizemos-lhe o que se parte em cada uma e deixamo-lo com um ramo por versão e o respetivo build a verde. Escreva para comercial@flexigobe.com ou ligue para +34 616 809 504.

