Skip to content

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.

FunzioneTipoCosa fa
odooRecordsSourceI record di un modello, con i campi dichiarati, in colonne
odooConteggiSourceUn record per modello con il search_count di Odoo
loadOdooRegistryregistryI database configurati, per istanza (ADR-0030)
loadOdooConfigconfigUn solo database, da variabili con prefisso
loadOdooInstanceconfigUn solo database del registry, senza gli altri
istanzaOdooPerIlPianopianoIl database per il piano, con la sua ricaduta
istanzaOdooDichiarataambienteIl database come lo vede etl env check
MANIFEST_ODOOconfigLe variabili di un database, per il piano (ADR-0089)
valoreOdoo, tipoColonnaOdooutilitàLa conversione di un valore e il tipo della sua colonna
schemaRecordOdoocontrattoLo schema Zod strict di un record letto con quei campi
nomiColonneOdooutilitàLe colonne di un record, id compreso: quelle di un sink
dettaglioCredenzialeOdoodescriptorLa riga credenziale, per un nodo Odoo
nuovaPortaOdootestUna porta con un altro intervallo, per i test
CampoOdoo, TipoOdoo, RecordOdoo, …tipiUn campo, i tipi letti, un record
METODI_LETTURA_ODOO, TIPI_ODOO, DEFAULT_*costantiI 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.

VariabileDefaultNote
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>_PROTOCOLxmlrpcxmlrpc; json2 è previsto per Odoo 19 e oggi è un errore di configurazione
ODOO_<ID>_TIMEOUT_MS120000tempo 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" },
  ],
});
OpzioneDefaultNote
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
perPagina500righe 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 ​

  1. Autentica una volta per processo e database (authenticate su /xmlrpc/2/common).
  2. fields_get del modello, una volta per run: un campo dichiarato che il database non ha, o che non è stored (un campo calcolato al volo), è un ConfigError che 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.
  3. search_read a pagine in ordine di id, con paginazione keyset: il dominio più ["id", ">", <ultimo>], order: "id asc", load: "" (i many2one come solo id, senza nome) e context: {active_test: false} (anche gli archiviati, come le opportunità perse). Niente offset: 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 il limit i 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 ridefinisce search_read senza accettare load (in Odoo 18 project.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 OdooColonnaValore
integer, many2one, many2one_referenceintegeril numero; di un many2one il solo id
float, monetarynumberil numero
booleanbooleantrue / false, mai null
char, text, selection, referencestringil testo
datedateAAAA-MM-GG
datetimedatetimeAAAA-MM-GG HH:MM:SS, in UTC senza fuso, come lo salva Odoo
many2manyidsl'array degli id
json, propertiesjsonil 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 DOCTYPE si 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_date con lookback (le transazioni lunghe committano dopo), e una riconciliazione periodica degli id per 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.

Noeva è un marchio registrato di 4D S.R.L.