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 20 | Mòduls |
|---|---|
| S'instal·len i passen totes les proves | 151 |
| S'instal·len sense cap error, amb proves en vermell | 55 |
| No s'instal·len | 12 |
| Total, de 147 repositoris | 218 |
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 20 | Com s'aprecia |
|---|---|
1. t-esc i t-raw | La dada surt en blanc als informes i al portal |
2. account.payment.state | paid passa a ser «validat, sense conciliar» |
3. is_company | Sense NIF, el contacte es crea com a persona |
| 4. Tipus de canvi | El tipus d'avui s'aplica a partir de demà |
5. b64decode d'un Binary | Bytes corruptes o Incorrect padding |
6. read() d'un Binary | Un diccionari en lloc del base64 |
7. Camps reanomenats a uom_id | getattr cau sempre al valor per defecte |
8. Permisos a ir.access | El mòdul s'instal·la sense cap permís |
| 9. Mètodes reanomenats | La teva sobreescriptura deixa d'executar-se |
10. migrations/20.0.x | El 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.0Arranjament. 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 vFont. 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_idFont. 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/nullmigrations/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 againArranjament. 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
- Passa pel teu codi els grep de cada apartat i revisa a mà el que surti.
- Executa les proves també amb un usuari sense privilegis, no només com a superusuari.
- Renderitza els informes i les pàgines del portal i compara el text amb el de 19.
- Crea dades de prova amb data d'avui, no només d'ahir: tipus de canvi, pagaments, terminis.
- 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
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.

