Tema
Connettore: Odoo (record di un modello, con i campi dichiarati)
Legge i record di un modello di Odoo dentro il grafo: odooRecords è un Source che restituisce tutti i record del modello (o quelli di un dominio), con i soli campi che il nodo dichiara, convertiti in colonne tipizzate. odooConteggi dà i conteggi di Odoo, per confrontarli con le righe caricate. Codice in src/connectors/odoo/, subpath @noeva/etl-core/connectors/odoo; decisioni in ADR-0108.
Il connettore legge e basta: chiama solo search_read, search_count, read, fields_get e read_group, e rifiuta qualunque altro metodo prima di toccare la rete. La chiave può essere quella di un utente che scrive, ma da qui non si scrive.
Parla XML-RPC (/xmlrpc/2/object, execute_kw), l'unica API delle versioni fino alla 18, con l'API key al posto della password. Odoo l'ha deprecata e la toglie con Online 21.1 e Odoo 22; dalla 19 c'è JSON-2, che la config prevede (ODOO_<ID>_PROTOCOL=json2) ma che oggi rifiuta.
| Funzione | Tipo | Cosa fa |
|---|---|---|
odooRecords | Source | I record di un modello, con i campi dichiarati, in colonne |
odooConteggi | Source | Un record per modello con il search_count di Odoo |
loadOdooRegistry | registry | I database configurati, per istanza (ADR-0030) |
loadOdooConfig | config | Un solo database, da variabili con prefisso |
loadOdooInstance | config | Un solo database del registry, senza gli altri |
istanzaOdooPerIlPiano | piano | Il database per il piano, con la sua ricaduta |
istanzaOdooDichiarata | ambiente | Il database come lo vede etl env check |
MANIFEST_ODOO | config | Le variabili di un database, per il piano (ADR-0089) |
valoreOdoo, tipoColonnaOdoo | utilità | La conversione di un valore e il tipo della sua colonna |
schemaRecordOdoo | contratto | Lo schema Zod strict di un record letto con quei campi |
nomiColonneOdoo | utilità | Le colonne di un record, id compreso: quelle di un sink |
dettaglioCredenzialeOdoo | descriptor | La riga credenziale, per un nodo Odoo |
nuovaPortaOdoo | test | Una porta con un altro intervallo, per i test |
CampoOdoo, TipoOdoo, RecordOdoo, … | tipi | Un campo, i tipi letti, un record |
METODI_LETTURA_ODOO, TIPI_ODOO, DEFAULT_* | costanti | I metodi ammessi, i tipi letti, i default del connettore |
Configurazione
loadOdooRegistry(env?) risolve i database: con ODOO_INSTANCES=erp ognuno ha le sue ODOO_<ID>_*, senza lista valgono le variabili base ODOO_* come istanza default.
| Variabile | Default | Note |
|---|---|---|
ODOO_INSTANCES | — | elenco dei database; senza, l'istanza default dalle variabili base |
ODOO_<ID>_URL | — | obbligatoria; l'istanza, solo origine: https://<istanza>.odoo.com |
ODOO_<ID>_DATABASE | — | obbligatoria; il nome del database |
ODOO_<ID>_LOGIN | — | obbligatoria; il login dell'utente della chiave |
ODOO_<ID>_API_KEY | — | obbligatoria, segreta; l'API key, da Preferenze → Sicurezza account |
ODOO_<ID>_PROTOCOL | xmlrpc | xmlrpc; json2 è previsto per Odoo 19 e oggi è un errore di configurazione |
ODOO_<ID>_TIMEOUT_MS | 120000 | tempo massimo di una chiamata, in millisecondi |
Su Odoo.sh il nome del database non è il sottodominio (segue progetto-ramo-build): si legge con POST /web/database/list, che risponde senza autenticazione. Una API key dura al più tre mesi: va ruotata, e una chiave scaduta si presenta come un ConfigError all'autenticazione.
La chiave giusta è quella di un utente di servizio con il solo diritto di lettura sui modelli che servono: Odoo non ha un ruolo di sola lettura predefinito, si crea un gruppo apposta. I gruppi delle app («Vendite: utente») danno anche la scrittura.
URL vuole https; http è ammesso solo verso la macchina locale, per un server di test. Gli errori nominano le variabili, mai i valori.
Source — odooRecords
ts
odooRecords({
config, // loadOdooRegistry().get("erp")
instanceId: "erp",
modello: "account.move",
dominio: [["move_type", "in", ["out_invoice", "out_refund"]]],
campi: [
{ nome: "name", tipo: "char" },
{ nome: "partner_id", tipo: "many2one" },
{ nome: "amount_total_signed", tipo: "monetary" },
{ nome: "invoice_date", tipo: "date" },
],
});| Opzione | Default | Note |
|---|---|---|
config | — | il database |
modello | — | il nome tecnico: sale.order, x_bi_sql_view.portafoglio_ordini_v5 |
campi | — | i campi, con il type di fields_get; id non si dichiara, c'è sempre |
dominio | [] | il dominio Odoo che restringe il modello |
contesto | — | il contesto di ogni chiamata (tz, lang, allowed_company_ids); active_test: false c'è sempre |
perPagina | 500 | righe per search_read, fra 1 e 5000 |
name, instanceId | — | nome del nodo e istanza mostrata nel DAG |
retry, fetchImpl | — | retry di ogni chiamata (default 5 tentativi, attese fino a 60 s); fetch finto nei test |
porta | — | la porta del database; default quella condivisa del processo (vedi sotto) |
Come legge
- Autentica una volta per processo e database (
authenticatesu/xmlrpc/2/common). fields_getdel modello, una volta per run: un campo dichiarato che il database non ha, o che non è stored (un campo calcolato al volo), è unConfigErrorche lo nomina, prima del primo record. Un tipo diverso da quello dichiarato si segnala nel log (odoo-source.tipo-cambiato), e la conversione decide se il valore regge.search_reada pagine in ordine diid, con paginazione keyset: il dominio più["id", ">", <ultimo>],order: "id asc",load: ""(i many2one come soloid, senza nome) econtext: {active_test: false}(anche gli archiviati, come le opportunità perse). Nienteoffset: con le scritture in corso salta o ripete record. Una pagina si chiede solo quando la precedente è stata consumata, e un record fuori ordine ferma la lettura. La lettura finisce a una pagina vuota, non a una corta: su alcuni modelli (mail.activity) Odoo toglie dopo illimiti record che l'utente non può vedere, e una pagina corta ha altri record dopo di sé. Costa una chiamata in più per modello. Qualche modello ridefiniscesearch_readsenza accettareload(in Odoo 18project.tags): al rifiuto la pagina si richiede senza, e così il resto della lettura di quel modello. I many2one arrivano come[id, nome]e la conversione tiene l'id; il log lo segnala (odoo-source.senza-load). Ogni altro errore di Odoo resta un errore.
I campi sono la regola di privacy
Il source chiede a Odoo solo i campi dichiarati (più id) ed emette solo le loro colonne. Un campo nuovo in Odoo non entra finché qualcuno non lo dichiara. schemaRecordOdoo(campi) è il contratto stretto da mettere in un nodo validate: una colonna in più ferma la run.
Tipi e conversione
| Tipo Odoo | Colonna | Valore |
|---|---|---|
integer, many2one, many2one_reference | integer | il numero; di un many2one il solo id |
float, monetary | number | il numero |
boolean | boolean | true / false, mai null |
char, text, selection, reference | string | il testo |
date | date | AAAA-MM-GG |
datetime | datetime | AAAA-MM-GG HH:MM:SS, in UTC senza fuso, come lo salva Odoo |
many2many | ids | l'array degli id |
json, properties | json | il valore com'è (analytic_distribution: {"12,34": 100}) |
Il vuoto di Odoo è false: diventa null, tranne sui boolean. Un valore che non si converte è un ExtractError che nomina modello, campo e id del record, mai il valore. one2many, binary e html non si dichiarano.
Un sink SQL scrive gli array e gli oggetti come li serializza il suo driver: per una colonna jsonb di Postgres un many2many va passato come testo JSON (JSON.stringify), altrimenti pg lo manda come array di Postgres.
Source — odooConteggi
ts
odooConteggi({
config,
instanceId: "erp",
modelli: [
{ modello: "sale.order" },
{ modello: "account.move", dominio: [["move_type", "=", "out_invoice"]] },
],
});Un record per modello, { modello, attese, contato_il }: il search_count con il dominio (e gli archiviati), e l'istante del conteggio in UTC. Serve a una tabella di controllo accanto al carico, dove le righe caricate si confrontano con quelle attese.
Limiti di Odoo: la porta
L'Acceptable Use Policy di Odoo ammette circa una chiamata al secondo, mai in parallelo, e chi la viola rischia la sospensione dell'abbonamento del cliente senza preavviso. Per questo ogni chiamata passa da una porta del database, condivisa da tutti i nodi del processo che parlano con la stessa istanza e lo stesso database: una chiamata alla volta, a distanza di almeno un secondo dall'inizio della precedente. Due source in parallelo non raddoppiano il ritmo, si mettono in fila. Un carico di 90.000 righe a pagine di 500 dura qualche minuto.
Su 429 e 5xx, timeout e rete la chiamata si riprova con backoff (da 5 s, al più 60 s fra un tentativo e l'altro), rispettando Retry-After. Un fault di Odoo non si riprova: diventa un ExtractError con l'ultima riga del messaggio di Odoo e un suggerimento («chiave rifiutata», «nessun diritto di lettura sul modello», «campo inesistente»).
Due run sovrapposte della stessa pipeline sono due processi, con due porte: non lanciarle insieme.
Sicurezza
- La chiave sta solo nella config: mai nei log, negli errori, nel piano o nel descriptor, che dice soltanto se è impostata. Il corpo delle chiamate, che la porta, non entra mai in un errore.
- Una risposta con un
DOCTYPEsi rifiuta prima del parser XML. - La risposta ha un tetto di byte (50 MB) e ogni chiamata un timeout.
Descriptor
kind: "odoo-source", con le righe istanza e database (con le loro variabili), poi modello, dominio (se c'è, troncato), lettura, campi e in fondo la credenziale: il protocollo e se login e chiave sono impostati, o quali mancano.
Quello che non fa (ancora)
- Incrementale: oggi ogni run rilegge tutto. Il passo dopo è un watermark su
write_datecon lookback (le transazioni lunghe committano dopo), e una riconciliazione periodica degliidper le cancellazioni, che in Odoo sono fisiche. - JSON-2: con Odoo 19, sullo stesso connettore.
- Lookup e aggregazioni (
read,read_group): ammessi dalla lista dei metodi, non ancora esposti come nodi.

