Salta al contingut principal
Odoo 20 · Guia tècnica

Què trenca Odoo 20 sense avisar: deu canvis que no donen cap error

El mòdul s'instal·la, les proves passen i el resultat ja no és el mateix. Deu canvis d'Odoo 20 que no llancen cap excepció, amb el grep que els troba, l'arranjament i la línia del codi d'Odoo on és cadascun.

Codi font d'Odoo 20 comparat amb el d'Odoo 19 per trobar els canvis que no donen error

Odoo 20 trenca moltes coses amb un error clar: un import que ja no existeix, un camp reanomenat, un mòdul que no s'instal·la. Aquests surten a la primera arrencada i s'arreglen. Aquest article va dels altres: deu canvis que no llancen cap excepció. El mòdul s'instal·la, les seves proves passen i el resultat ja no és el mateix.

Mètode. Cada punt està llegit al codi d'odoo/odoo, branca 20.0 (commit 2fec203, 2 de novembre de 2026), i comparat amb la branca 19.0 (commit 4d2d4b5, del mateix dia). Els enllaços de cada apartat porten a aquestes línies exactes. Set dels deu els vam executar el 6 de novembre amb el mateix guió en dues bases d'usar i llençar, una d'Odoo 19 i una altra d'Odoo 20 (Community, mòduls base i account); la seva sortida va a cada apartat.

El que vam veure en portar el nostre catàleg

Mantenim un catàleg de connectors i mòduls per a Odoo 17, 18, 19 i 20. Per mesurar què es trenca en pujar a 20, el vam portar sencer de manera automàtica sobre còpies (els guions de migració que porta Odoo a odoo/upgrade_code més els nostres) i vam provar cada mòdul instal·lant-lo en un Odoo 20 real i executant-ne totes les proves. L'últim rastreig complet va acabar l'1 de novembre de 2026, amb el nostre codi del 31 d'octubre:

Resultat del port automàtic a 20Mòduls
S'instal·len i passen totes les proves151
S'instal·len sense cap error, amb proves en vermell55
No s'instal·len12
Total, de 147 repositoris218

Els 12 que no s'instal·len són la part fàcil: el log diu què falta. Els 55 del mig es van instal·lar nets, i sense proves que recorrin el camí de debò haurien semblat llestos. Al log, aquestes proves cauen per excepcions (KeyError, AttributeError, TypeError, ValueError) o per assercions que no es compleixen. Els deu canvis d'aquest article no llancen cap excepció: només els veu una prova que miri el resultat, com les que vam escriure a propòsit (un usuari sense privilegis, un tipus de canvi amb data d'avui, un informe comparat amb el de 19), o una sonda en un Odoo 20 real.

Són dades del port automàtic sobre còpies, no de allò publicat: un mòdul nostre surt a 20.0 després de passar totes les seves proves en un Odoo 20 real.

Els deu, d'un cop d'ull

Canvi a Odoo 20Com s'aprecia
1. t-esc i t-rawLa dada surt en blanc als informes i al portal
2. account.payment.statepaid passa a ser «validat, sense conciliar»
3. is_companySense NIF, el contacte es crea com a persona
4. Tipus de canviEl tipus d'avui s'aplica a partir de demà
5. b64decode d'un BinaryBytes corruptes o Incorrect padding
6. read() d'un BinaryUn diccionari en lloc del base64
7. Camps reanomenats a uom_idgetattr cau sempre al valor per defecte
8. Permisos a ir.accessEl mòdul s'instal·la sense cap permís
9. Mètodes reanomenatsLa teva sobreescriptura deixa d'executar-se
10. migrations/20.0.xEl guió de migració corre dues vegades

1. t-esc i t-raw surten en blanc

Què passa. El motor QWeb de 20 ja no compila t-esc ni t-raw. A 19 encara es compilaven com t-out; a 20 l'element es pinta buit i el log només deixa un WARNING: Unknown directives or unused attributes: {'t-esc'}.

Com s'aprecia. El PDF de la factura o la pàgina del portal es generen bé, però la dada no hi és. A la sonda, la mateixa plantilla dona <p>HOLA</p> a 19 i <p></p> a 20. El guió d'Odoo per passar a OWL 3 (owl3-migration.py) sí que canvia t-esc per t-out, però només als fitxers que pengen de /static/: les plantilles d'informes i del portal es queden com estaven.

grep -rnE 't-(esc|raw)=' --include='*.xml' .
<!-- Odoo 17-19: prints the name. Odoo 20: empty, only a WARNING in the log -->
<span t-esc="o.partner_id.name"/>

<!-- Odoo 17, 18, 19 and 20 -->
<span t-out="o.partner_id.name"/>

Arranjament. t-out, que existeix a 17, 18, 19 i 20. El que abans anava amb t-raw ha d'arribar com Markup; si no, t-out l'escapa.

Font. 20.0: ir_qweb.py:1927 (l'avís) i owl3-migration.py:547-554 (el guió d'OWL 3 només mira /static/). 19.0: ir_qweb.py:2370-2374 (t-esc i t-raw encara es compilen).

2. Un pagament paid ja no està conciliat

Què passa. El camp state de account.payment canvia de valors. A 19: draft, in_process, paid, canceled, rejected. A 20: draft, paid, reconciled, canceled, rejected. El paid de 20 és l'antic in_process (validat, sense conciliar) i l'antic paid passa a dir-se reconciled. A les factures, payment_state conserva els seus valors de 19 i n'afegeix un, todo.

Com s'aprecia. Un filtre per 'paid' segueix funcionant i retorna una altra cosa: pagaments validats que encara no estan conciliats. Un per 'in_process' tampoc dona error a 20: a la sonda retorna 0 registres sense cap excepció.

grep -rnE "'(in_process|paid)'" --include='*.py' --include='*.xml' . | grep -i payment
# Odoo 19: reconciled payments. Odoo 20: validated payments, NOT reconciled yet
payments = env['account.payment'].search([('state', '=', 'paid')])

# Odoo 18, 19 and 20
states = dict(env['account.payment']._fields['state'].selection)
done = 'reconciled' if 'reconciled' in states else 'paid'
payments = env['account.payment'].search([('state', '=', done)])

Arranjament. Revisa a mà els resultats del grep que siguin de pagaments: el mateix 'paid' a payment_state d'una factura segueix significant el mateix.

Font. 19.0: account_payment.py:36-43. 20.0: account_payment.py:37-44.

3. is_company ho decideix el NIF

Què passa. A 20, res.partner.is_company és un camp calculat i desat que depèn de has_vat i de commercial_partner_id: un contacte és empresa si és la seva pròpia entitat comercial i té NIF (no buit, ni «/», «na» o «NA»). A 19 era un booleà normal.

Com s'aprecia. Un create() amb is_company: True i sense NIF crea una persona, sense error. A la sonda, sense NIF, 19 dona True i 20 dona False; amb NIF, True a les dues. Un write() posterior sí que el deixa a True, fins que Odoo el recalculi perquè canviï el NIF o l'entitat comercial. A un connector de marketplace que crea clients empresa sense NIF li surten persones, i si després busca el contacte per is_company per reutilitzar-lo, no el troba i en crea un altre a cada comanda. Ens va passar en portar un dels nostres.

grep -rn "is_company" --include='*.py' --include='*.xml' .
# Odoo 19: a company. Odoo 20: a person (no VAT number), no error
env['res.partner'].create({'name': 'ACME SL', 'is_company': True})

# Odoo 20: pass the VAT number when you have it,
# and do not look existing contacts up by is_company
env['res.partner'].create({'name': 'ACME SL', 'is_company': True, 'vat': vat})

Arranjament. Passa el NIF quan el tinguis, no busquis contactes per is_company i, si l'escrius, comprova abans si el camp és calculat: Partner._fields['is_company'].compute.

Font. 20.0: res_partner.py:371 (el camp), res_partner.py:942-945 (has_vat) i res_partner.py:950-951 (de què depèn). 19.0: res_partner.py:277 (booleà normal).

4. El tipus de canvi d'avui s'aplica demà

Què passa. res.currency._get_rates busca l'últim tipus amb data anterior o igual a la de l'operació a 19 ('<=') i estrictament anterior a 20 ('<'). A 20, un tipus amb data d'avui no compta per a les operacions d'avui.

Com s'aprecia. A la sonda, la lliura té 1,10 ahir i 1,20 avui; convertir 100 dòlars a lliures amb data d'avui dona 120 a 19 i 110 a 20. Sense error. Un procés que carrega el tipus del dia al matí i factura aquell mateix dia comptabilitza amb el d'ahir, i una prova que crea un tipus amb data d'avui i espera veure'l aplicat falla sense que res hagi canviat al teu mòdul.

grep -rn "res.currency.rate" --include='*.py' .
# rate dated today: used today on Odoo 19, from tomorrow on Odoo 20
env['res.currency.rate'].create({'currency_id': gbp.id, 'name': today, 'rate': 1.20})
usd._convert(100, gbp, company, today)   # Odoo 19: 120.0 / Odoo 20: 110.0

Arranjament. Al codi no hi ha cap opció per tornar al comportament de 19. A les proves, crea el tipus amb data d'ahir. Si la teva integració necessita el tipus exacte del dia, aplica'l de manera explícita en lloc de passar per _convert, o desa'l amb la data que li correspon segons la regla de 20.

Font. 19.0: res_currency.py:126-129. 20.0: res_currency.py:185-187.

5. b64decode(registro.campo) retorna escombraries

Què passa. A 19, llegir un camp Binary retornava el contingut en base64. A 20 retorna un BinaryValue, que exposa els bytes en cru amb el protocol de buffer de Python. base64.b64decode() accepta qualsevol buffer: llegeix aquests bytes crus com si fossin base64 i, sense validate=True, llença el que no és de l'alfabet.

Com s'aprecia. A la sonda, amb un camp Binary normal: amb un text de 12 bytes, 20 retorna 9 bytes diferents i cap error; amb un PNG, binascii.Error: Incorrect padding. A 19 els dos surten idèntics a l'original. Segons el contingut, una imatge o un PDF que envies a un altre sistema arriba corromput o la crida rebenta.

grep -rn "b64decode(" --include='*.py' .    # then check which ones read a record's field
# Odoo 19: the original bytes. Odoo 20: garbage or binascii.Error
data = base64.b64decode(record.file_data)

# Odoo 17, 18, 19 and 20
value = record.file_data
data = value.content if hasattr(value, 'content') else base64.b64decode(value)

Font. 20.0: binary.py:9 (class BinaryValue(Buffer)), binary.py:49 (__buffer__) i fields_binary.py:128-129 (el que retorna el camp en llegir-lo).

6. read() d'un Binary retorna un diccionari

Què passa. A 20, read() d'un camp Binary retorna {'content': <base64>, 'size': N}, més filename si en té. A 19 retornava directament el base64.

Com s'aprecia. A la sonda, read() retorna bytes a 19 i un diccionari amb content i size a 20. Si aquest valor acaba en un str() o en una plantilla de text que va a un altre sistema, s'envia el text del diccionari i a Odoo no falla res.

grep -rnE "\.(read|search_read)\(" --include='*.py' .    # then check which ones read Binary fields
# Odoo 19: base64. Odoo 20: {'content': ..., 'size': ...}
b64 = record.read(['file_data'])[0]['file_data']

# Odoo 17, 18, 19 and 20
v = record.read(['file_data'])[0]['file_data']
b64 = v['content'] if isinstance(v, dict) else v

Font. 20.0: fields_binary.py:134-146.

7. Camps reanomenats que es llegeixen amb getattr

Què passa. A 20, stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id i mrp.bom.line.product_uom_id passen a dir-se uom_id. Fer servir el nom vell de manera directa dona error (en un create(), ValueError: Invalid field). El problema és el codi pensat per a diverses versions que el llegeix amb un valor per defecte: a 20, getattr(bom, 'product_uom_id', False) retorna sempre el valor per defecte.

Com s'aprecia. Quantitats en la unitat equivocada: un kit en dotzenes, o una línia en quilos contra un component en grams. Ens va passar en un connector de marketplace en desglossar kits: a 20 queia sempre a la unitat del producte.

grep -rnE "(getattr\(|\.get\()[^)]*'product_uom(_id)?'" --include='*.py' .
# Odoo 20: always falls back to the product's unit
uom = getattr(bom, 'product_uom_id', False) or product.uom_id

# Odoo 17, 18, 19 and 20
fname = 'uom_id' if 'uom_id' in bom._fields else 'product_uom_id'
uom = bom[fname] or product.uom_id

Font. 19.0: mrp_bom.py:46, stock_move.py:67 i purchase_order_line.py:43. 20.0: mrp_bom.py:47, stock_move.py:68 i purchase_order_line.py:49; el ValueError de create(), models.py:4151-4153.

8. Un mòdul sense permisos s'instal·la sense avisar

Què passa. Odoo 20 substitueix ir.model.access i ir.rule per un model nou, ir.access, amb el seu fitxer security/ir.access.csv. El guió d'Odoo que converteix els permisos (odoo/upgrade_code/19.4-00-ir-access.py) treu el CSV vell del manifest i, per afegir el nou, busca una línia que comenci per 'data': [. Si al teu manifest 'data' és a mitja línia, escriu ERROR <módulo>: Cannot add security/ir.access.csv to manifest, please add manually i continua amb la resta.

Com s'aprecia. El mòdul s'instal·la sense cap permís. A 19, un model sense permisos donava un avís en instal·lar («The models [...] have no access rules»); a 20 aquest avís ja no existeix. Les proves, que corren com a superusuari, passen. L'usuari normal rep «No group currently allows this operation». Ens va passar amb mòduls ja portats: ho va caçar una prova amb un usuari sense privilegis, i des d'aleshores la nostra cadena de port s'atura si un ir.access.csv no es carrega.

grep -L "ir.access.csv" */__manifest__.py     # manifests that do not load it
grep -n "Cannot add" upgrade.log              # the script's own complaint
# the script cannot add ir.access.csv here ('data' in the middle of a line)
'depends': ['base'], 'data': ['views/views.xml'],

# here it can
'depends': ['base'],
'data': ['security/ir.access.csv', 'views/views.xml'],

Arranjament. Afegeix 'security/ir.access.csv' a data a mà i escriu una prova que creï i llegeixi un registre de cada model amb un usuari normal (new_test_user i with_user). És l'única prova que ho veu.

Font. 20.0: 19.4-00-ir-access.py:415-434 (on busca 'data' i l'ERROR) i ir_access.py:342 (el missatge a l'usuari). 19.0: loading.py:250-257 (l'avís que a 20 ja no hi és).

9. Sobreescriure un mètode que Odoo 20 ja no crida

Què passa. Python no avisa si sobreescrius un mètode que la classe base ja no té: el teu mètode existeix, però ningú el crida. A 20, el framework de pagaments substitueix payment.provider._get_compatible_providers per _find_available_providers, amb una altra signatura (el que va després de amount passa a ser només per nom).

Com s'aprecia. Si sobreescrivies _get_compatible_providers per amagar un proveïdor de pagament per país o per import, a 20 el proveïdor torna a sortir en pagar. A la sonda, el model de 19 té _get_compatible_providers i no _find_available_providers; el de 20, al revés.

grep -rhoE "def _[a-z0-9_]+" models/ | sort -u                  # private methods you override
grep -rn "def _get_compatible_providers" odoo-20.0/addons/      # no output = Odoo 20 no longer has it
# Odoo 19
def _get_compatible_providers(self, company_id, partner_id, amount, **kwargs):

# Odoo 20
def _find_available_providers(self, company_id, partner_id, amount, **kwargs):

Font. 19.0: payment_provider.py:555, cridat des de portal.py:113. 20.0: payment_provider.py:671.

10. Les migracions copiades a 20.0.x es repeteixen

Què passa. No és nou de 20, però surt just en portar. Odoo executa una carpeta migrations/<versión completa> si la versió instal·lada és menor que la de la carpeta i la nova és igual o major. Si en portar copies migrations/19.0.1.3.0 com migrations/20.0.1.3.0, en pujar a 20 una base amb el mòdul a 19.0.1.3.1, aquest guió torna a córrer sobre dades que ja va transformar. Les carpetes sense sèrie (migrations/1.3.0) comparen només la part del mòdul i no es repeteixen.

Com s'aprecia. El -u acaba bé i les dades queden transformades dues vegades: hores desplaçades una altra vegada, línies copiades dues vegades.

ls -d */migrations/20.0.* 2>/dev/null
migrations/20.0.1.3.0/post-migrate.py   # runs again when a 19.0.1.3.x database moves to 20
migrations/1.3.0/post-migrate.py        # compares only 1.3.0: does not run again

Arranjament. Carpetes sense sèrie, guions idempotents (que mirin l'estat abans de tocar res) i una prova que executi la migració dues vegades.

Font. 20.0: migration.py:197-210 (línies 205 a 208 per a les carpetes sense sèrie, 210 per a les de versió completa).

I un que ja no és silenciós: datas als adjunts

Al setembre, ir.attachment.create({'datas': ...}) creava a 20 un adjunt buit sense cap error (en un connector de signatura, el PDF signat s'hauria desat amb zero bytes). Des del commit c06a072 del 31 d'octubre («[FIX] base: remove fallback for datas») rebenta amb AssertionError: _check_contents should handle that (ir_attachment.py:502). L'arranjament és el mateix i val a 17, 18, 19 i 20: 'raw' amb els bytes sense codificar. La branca 20.0 segueix rebent canvis cada setmana, així que el que vas provar al setembre convé tornar-ho a provar amb el codi d'avui.

Com caçar-los abans que els teus usuaris

  1. Passa pel teu codi els grep de cada apartat i revisa a mà el que surti.
  2. Executa les proves també amb un usuari sense privilegis, no només com a superusuari.
  3. Renderitza els informes i les pàgines del portal i compara el text amb el de 19.
  4. Crea dades de prova amb data d'avui, no només d'ahir: tipus de canvi, pagaments, terminis.
  5. Puja una base de 19 a 20 amb dades i executa la migració dues vegades.

Preguntes freqüents

Els guions de migració d'Odoo (upgrade_code) arreglen aquests canvis?

No. Cap dels guions de odoo/upgrade_code de la branca 20.0 resol aquests deu: el de permisos (19.4-00-ir-access) és justament el que pot deixar un mòdul sense, i el d'OWL 3 canvia t-esc per t-out només als fitxers de /static/.

Per què les proves no els veuen?

Perquè corren com a superusuari, amb dades que no passen per aquests camins i sense mirar el que es genera. Un informe amb un camp en blanc no llança res i 'paid' segueix sent un valor vàlid. Calen proves que mirin el resultat: el text de l'informe, l'import convertit, el que veu un usuari normal.

Es pot deixar el codi llest a 19 abans de portar?

En diversos casos, sí: t-out en lloc de t-esc i 'raw' als adjunts ja funcionen a 17, 18 i 19, i llegir els camps reanomenats a través de _fields deixa el mateix codi llest per a 20.

Enllaços útils dins de FlexigoTech

Odoo 20: què canvia per als teus connectorsEl que desapareix i no s'instal·laMigrar a Odoo 20: checklistPer a empreses amb integracions activesEls nostres connectors ja a Odoo 20La llista, amb enllaç a cada fitxa

Connectors per a Odoo 17, 18, 19 i 20

FlexigoTech fa connectors i mòduls per a Odoo 17, 18, 19 i 20, i els prova així a cada versió abans de publicar-los.

Veure el catàleg