Exemple de workflow n8n (export JSON) avec les nœuds de base (Webhook → Function → 3×Odoo RPC → Return), prêt à importer
oici un workflow n8n prêt à importer (JSON export) qui :
- reçoit le payload via Webhook (POST /maintien-domicile-intake),
- normalise/valide les champs clés,
- upsert l’aidant et le senior (res.partner),
- crée le dossier x_care_case (avec M2M services),
- crée l’évaluation x_care_assessment,
- renvoie une réponse JSON propre.
Hypothèses : vous avez créé dans Odoo les modèles/champs selon notre spec (x_care_case, x_care_assessment, x_care_service, champs x_* sur res.partner).
Utilise le nœud Odoo natif de n8n (choisir vos Credentials Odoo après import).
Si votre n8n ne dispose pas du nœud Odoo, je peux vous fournir une variante HTTP JSON-RPC.
▶️ Import direct (n8n > Workflows > Import from file/clipboard)
{ "name": "MaintienDomicile Intake → Odoo", "nodes": [ { "parameters": { "path": "maintien-domicile-intake", "options": { "responseData": "={{$json}}", "responseHeaders": { "entries": [ { "name": "Content-Type", "value": "application/json" } ] } } }, "id": "Webhook_Entry", "name": "Webhook (POST /maintien-domicile-intake)", "type": "n8n-nodes-base.webhook", "typeVersion": 1, "position": [ -820, -80 ], "webhookId": "maintien-domicile-intake", "notesInFlow": true, "notes": "Reçoit le JSON de la webapp. Méthode: POST. Content-Type: application/json." }, { "parameters": { "functionCode": "const p = $json;\n// Validation minimale + normalisation\nfunction req(v, name){ if(v===undefined||v===null||v==='') throw new Error(`Missing ${name}`); return v; }\nconst out = {\n traceId: $headers[\"x-trace-id\"] || $json.meta?.traceId || $json.traceId || $json.meta?.trace_id || '',\n declarante: {\n role: req(p.declarante?.role,'declarante.role'),\n prenom: req(p.declarante?.prenom,'declarante.prenom'),\n nom: req(p.declarante?.nom,'declarante.nom'),\n email: p.declarante?.email || '',\n phone: p.declarante?.phone || '',\n pref_channel: p.declarante?.pref_channel || 'tel'\n },\n senior: {\n prenom: req(p.senior?.prenom,'senior.prenom'),\n nom: req(p.senior?.nom,'senior.nom'),\n age: p.senior?.age ?? 80,\n ville: p.senior?.ville || '',\n vit_accompagne: p.senior?.vit_accompagne || 'seul'\n },\n autonomie_risques: {\n dependence_level: req(p.autonomie_risques?.dependence_level,'autonomie_risques.dependence_level'),\n risk_fall: !!p.autonomie_risques?.risk_fall,\n risk_wandering: !!p.autonomie_risques?.risk_wandering,\n cognitive_issues: !!p.autonomie_risques?.cognitive_issues,\n main_diagnosis: p.autonomie_risques?.main_diagnosis || ''\n },\n habitat_iot: {\n home_adapted: p.habitat_iot?.home_adapted || 'non',\n has_teleassistance: !!p.habitat_iot?.has_teleassistance,\n has_internet: p.habitat_iot?.has_internet || 'unknown',\n iot_setup: Array.isArray(p.habitat_iot?.iot_setup) ? p.habitat_iot.iot_setup : []\n },\n aides_besoins: {\n current_services: Array.isArray(p.aides_besoins?.current_services) ? p.aides_besoins.current_services : [],\n desired_services: Array.isArray(p.aides_besoins?.desired_services) ? p.aides_besoins.desired_services : [],\n medication_support: p.aides_besoins?.medication_support || 'autonome'\n },\n orga_budget: {\n contact_frequency: p.orga_budget?.contact_frequency || 'hebdo',\n distance_caregiver_km: Number(p.orga_budget?.distance_caregiver_km || 0),\n budget_band: p.orga_budget?.budget_band || 'nspp',\n preference_cc: p.orga_budget?.preference_cc || 'mixte_humain_ia',\n followup_mode: p.orga_budget?.followup_mode || 'mensuel',\n urgency_level: p.orga_budget?.urgency_level || 'information'\n },\n consent: {\n rgpd: !!p.consent?.rgpd,\n note: p.consent?.note || '',\n timestamp_iso: p.consent?.timestamp_iso || new Date().toISOString()\n },\n meta: {\n channel: p.meta?.channel || 'web',\n lang: p.meta?.lang || 'fr'\n }\n};\nreturn [{ json: out }];" }, "id": "Fn_Normalize", "name": "Function (Normalize payload)", "type": "n8n-nodes-base.function", "typeVersion": 2, "position": [ -580, -80 ], "notesInFlow": true, "notes": "Nettoie/valide le minimum & prépare la structure pour Odoo." }, { "parameters": { "operation": "search", "resource": "record", "model": "res.partner", "options": { "domainList": { "domain": [ [ "email", "=", "={{$json.declarante.email}}" ] ] }, "limit": 1 } }, "id": "Odoo_Search_Caregiver", "name": "Odoo: Search Aidant (by email)", "type": "n8n-nodes-base.odoo", "typeVersion": 1, "position": [ -320, -200 ], "credentials": { "odooApi": { "id": "__SET_ME__", "name": "Odoo Credentials" } }, "notesInFlow": true, "notes": "Si email vide, la recherche renverra 0 résultat; la création se fera ensuite." }, { "parameters": { "operation": "create", "resource": "record", "model": "res.partner", "values": { "name": "={{$json.declarante.prenom + ' ' + $json.declarante.nom}}", "email": "={{$json.declarante.email}}", "phone": "={{$json.declarante.phone}}", "x_is_caregiver": true, "x_pref_channel": "={{$json.declarante.pref_channel}}" } }, "id": "Odoo_Create_Caregiver", "name": "Odoo: Create Aidant (res.partner)", "type": "n8n-nodes-base.odoo", "typeVersion": 1, "position": [ -80, -200 ], "credentials": { "odooApi": { "id": "__SET_ME__", "name": "Odoo Credentials" } }, "notesInFlow": true, "notes": "Crée l’aidant si absent." }, { "parameters": { "conditions": { "number": [ { "value1": "={{$json[\"Odoo: Search Aidant (by email)\"]?.data?.length || 0}}", "operation": "smaller" } ] } }, "id": "IF_Create_Caregiver", "name": "IF (Aidant not found?)", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ -200, -200 ], "notesInFlow": true, "notes": "Si aucun partner trouvé → créer." }, { "parameters": { "operation": "search", "resource": "record", "model": "res.partner", "options": { "domainList": { "domain": [ "AND", [ "name", "=", "={{$json.senior.prenom + ' ' + $json.senior.nom}}" ], [ "city", "=", "={{$json.senior.ville}}" ] ] }, "limit": 1 } }, "id": "Odoo_Search_Senior", "name": "Odoo: Search Senior (name+city)", "type": "n8n-nodes-base.odoo", "typeVersion": 1, "position": [ -320, 80 ], "credentials": { "odooApi": { "id": "__SET_ME__", "name": "Odoo Credentials" } } }, { "parameters": { "operation": "create", "resource": "record", "model": "res.partner", "values": { "name": "={{$json.senior.prenom + ' ' + $json.senior.nom}}", "city": "={{$json.senior.ville}}", "x_is_senior": true } }, "id": "Odoo_Create_Senior", "name": "Odoo: Create Senior (res.partner)", "type": "n8n-nodes-base.odoo", "typeVersion": 1, "position": [ -80, 80 ], "credentials": { "odooApi": { "id": "__SET_ME__", "name": "Odoo Credentials" } } }, { "parameters": { "conditions": { "number": [ { "value1": "={{$json[\"Odoo: Search Senior (name+city)\"]?.data?.length || 0}}", "operation": "smaller" } ] } }, "id": "IF_Create_Senior", "name": "IF (Senior not found?)", "type": "n8n-nodes-base.if", "typeVersion": 2, "position": [ -200, 80 ] }, { "parameters": { "operation": "search", "resource": "record", "model": "x_care_service", "options": { "domainList": { "domain": [ "OR", [ "name", "in", "={{$json.aides_besoins.current_services}}" ], [ "name", "in", "={{$json.aides_besoins.desired_services}}" ] ] }, "fieldsToRetrieve": "name", "limit": 100 } }, "id": "Odoo_Read_Services", "name": "Odoo: Read Services (names→ids)", "type": "n8n-nodes-base.odoo", "typeVersion": 1, "position": [ 160, -60 ], "credentials": { "odooApi": { "id": "__SET_ME__", "name": "Odoo Credentials" } }, "notesInFlow": true, "notes": "Récupère les IDs des services référencés par leur nom." }, { "parameters": { "functionCode": "const caregiver = $items(\"Odoo: Search Aidant (by email)\", 0, 0).json?.data?.[0] || $items(\"Odoo: Create Aidant (res.partner)\", 0, 0).json?.data;\nconst senior = $items(\"Odoo: Search Senior (name+city)\", 0, 0).json?.data?.[0] || $items(\"Odoo: Create Senior (res.partner)\", 0, 0).json?.data;\nconst services = $items(\"Odoo: Read Services (names→ids)\", 0, 0).json?.data || [];\nconst serviceIds = services.map((r:any)=>r.id);\n\n// Commandes M2M acceptées par Odoo Studio pour write/create : [(6,0,[ids])]\nconst m2m = (ids:number[])=> ids.length ? [[6,0,ids]] : false;\n\nreturn [{ json: {\n caregiver_id: caregiver?.id || null,\n senior_id: senior?.id || null,\n current_service_ids: serviceIds, // sera appliqué à current & desired (même référentiel)\n desired_service_ids: serviceIds\n}}];" }, "id": "Fn_PickIDs", "name": "Function (Collect IDs)", "type": "n8n-nodes-base.function", "typeVersion": 2, "position": [ 380, -60 ], "notesInFlow": true, "notes": "Assemble les IDs (aidant, senior, services)." }, { "parameters": { "operation": "create", "resource": "record", "model": "x_care_case", "values": { "x_senior_id": "={{$json.senior_id}}", "x_main_caregiver_id": "={{$json.caregiver_id}}", "x_has_internet": "={{$json(\"..\",\"Fn_Normalize\").habitat_iot.has_internet}}", "x_dependence_level": "={{$json(\"..\",\"Fn_Normalize\").autonomie_risques.dependence_level}}", "x_risk_fall": "={{$json(\"..\",\"Fn_Normalize\").autonomie_risques.risk_fall}}", "x_risk_wandering": "={{$json(\"..\",\"Fn_Normalize\").autonomie_risques.risk_wandering}}", "x_cognitive_issues": "={{$json(\"..\",\"Fn_Normalize\").autonomie_risques.cognitive_issues}}", "x_main_diagnosis": "={{$json(\"..\",\"Fn_Normalize\").autonomie_risques.main_diagnosis}}", "x_home_adapted": "={{$json(\"..\",\"Fn_Normalize\").habitat_iot.home_adapted}}", "x_has_teleassistance": "={{$json(\"..\",\"Fn_Normalize\").habitat_iot.has_teleassistance}}", "x_medication_support": "={{$json(\"..\",\"Fn_Normalize\").aides_besoins.medication_support}}", "x_contact_frequency": "={{$json(\"..\",\"Fn_Normalize\").orga_budget.contact_frequency}}", "x_distance_caregiver_km": "={{$json(\"..\",\"Fn_Normalize\").orga_budget.distance_caregiver_km}}", "x_budget_band": "={{$json(\"..\",\"Fn_Normalize\").orga_budget.budget_band}}", "x_preference_cc": "={{$json(\"..\",\"Fn_Normalize\").orga_budget.preference_cc}}", "x_followup_mode": "={{$json(\"..\",\"Fn_Normalize\").orga_budget.followup_mode}}", "x_urgency_level": "={{$json(\"..\",\"Fn_Normalize\").orga_budget.urgency_level}}", "x_consent_rgpd": "={{$json(\"..\",\"Fn_Normalize\").consent.rgpd}}", "x_consent_note": "={{$json(\"..\",\"Fn_Normalize\").consent.note + ' | ' + $json(\"..\",\"Fn_Normalize\").consent.timestamp_iso + ' | source:web'}}", "x_current_services_ids": "={{$json.current_service_ids.length ? [[6,0,$json.current_service_ids]] : undefined}}", "x_desired_services_ids": "={{$json.desired_service_ids.length ? [[6,0,$json.desired_service_ids]] : undefined}}", "x_status": "en_cours" } }, "id": "Odoo_Create_Case", "name": "Odoo: Create Case (x_care_case)", "type": "n8n-nodes-base.odoo", "typeVersion": 1, "position": [ 640, -60 ], "credentials": { "odooApi": { "id": "__SET_ME__", "name": "Odoo Credentials" } }, "notesInFlow": true, "notes": "Déclenche les automatisations Studio (risque, SLA, etc.)." }, { "parameters": { "operation": "create", "resource": "record", "model": "x_care_assessment", "values": { "case_id": "={{$json(\"..\",\"Odoo: Create Case (x_care_case)\").data.id}}", "date": "={{$now}}", "x_channel": "={{$json(\"..\",\"Fn_Normalize\").meta.channel}}", "x_notes": "={{'Auto-évaluation via webapp. Dépendance: ' + $json(\"..\",\"Fn_Normalize\").autonomie_risques.dependence_level + ', Risques: chutes=' + $json(\"..\",\"Fn_Normalize\").autonomie_risques.risk_fall + ', fugue=' + $json(\"..\",\"Fn_Normalize\").autonomie_risques.risk_wandering + ', cognition=' + $json(\"..\",\"Fn_Normalize\").autonomie_risques.cognitive_issues}}" } }, "id": "Odoo_Create_Assessment", "name": "Odoo: Create Assessment (x_care_assessment)", "type": "n8n-nodes-base.odoo", "typeVersion": 1, "position": [ 900, -60 ], "credentials": { "odooApi": { "id": "__SET_ME__", "name": "Odoo Credentials" } } }, { "parameters": { "responseBody": "={{({ ok:true, case_id: $json(\"..\",\"Odoo: Create Case (x_care_case)\").data.id, assessment_id: $json(\"..\",\"Odoo: Create Assessment (x_care_assessment)\").data.id, traceId: $json(\"..\",\"Fn_Normalize\").traceId })}}", "responseCode": 200, "options": {} }, "id": "Respond_OK", "name": "Respond (200 OK)", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1, "position": [ 1120, -60 ], "notesInFlow": true, "notes": "Renvoie les IDs utiles au front ou à l’API proxy." } ], "connections": { "Webhook (POST /maintien-domicile-intake)": { "main": [ [ { "node": "Function (Normalize payload)", "type": "main", "index": 0 } ] ] }, "Function (Normalize payload)": { "main": [ [ { "node": "Odoo: Search Aidant (by email)", "type": "main", "index": 0 } ], [ { "node": "Odoo: Search Senior (name+city)", "type": "main", "index": 0 } ] ] }, "Odoo: Search Aidant (by email)": { "main": [ [ { "node": "IF (Aidant not found?)", "type": "main", "index": 0 } ] ] }, "IF (Aidant not found?)": { "main": [ [ { "node": "Odoo: Create Aidant (res.partner)", "type": "main", "index": 0 } ], [ { "node": "Odoo: Read Services (names→ids)", "type": "main", "index": 0 } ] ] }, "Odoo: Create Aidant (res.partner)": { "main": [ [ { "node": "Odoo: Read Services (names→ids)", "type": "main", "index": 0 } ] ] }, "Odoo: Search Senior (name+city)": { "main": [ [ { "node": "IF (Senior not found?)", "type": "main", "index": 0 } ] ] }, "IF (Senior not found?)": { "main": [ [ { "node": "Odoo: Create Senior (res.partner)", "type": "main", "index": 0 } ], [ { "node": "Odoo: Read Services (names→ids)", "type": "main", "index": 0 } ] ] }, "Odoo: Create Senior (res.partner)": { "main": [ [ { "node": "Odoo: Read Services (names→ids)", "type": "main", "index": 0 } ] ] }, "Odoo: Read Services (names→ids)": { "main": [ [ { "node": "Function (Collect IDs)", "type": "main", "index": 0 } ] ] }, "Function (Collect IDs)": { "main": [ [ { "node": "Odoo: Create Case (x_care_case)", "type": "main", "index": 0 } ] ] }, "Odoo: Create Case (x_care_case)": { "main": [ [ { "node": "Odoo: Create Assessment (x_care_assessment)", "type": "main", "index": 0 } ] ] }, "Odoo: Create Assessment (x_care_assessment)": { "main": [ [ { "node": "Respond (200 OK)", "type": "main", "index": 0 } ] ] } }, "pinData": {}, "staticData": null, "meta": { "templateCredsSetup": [ { "name": "Odoo Credentials", "type": "odooApi", "required": [ "url", "db", "username", "password" ] } ] } }
Configuration après import
- Webhook
- Méthode: POST.
- URL affichée par n8n (copiez-la dans votre BACKEND_INTAKE_URL).
- Credentials Odoo
- Renseignez url, db, username, password.
- Testez la connexion.
- Modèles Odoo
-
Confirmez que les modèles/champs existent :
- x_care_case (et champs x_*), x_care_assessment, x_care_service, res.partner.x_is_caregiver, res.partner.x_is_senior, etc.
- Services (référentiel)
- Le nœud “Read Services” mappe par name. Assurez-vous que les names (ex: “Aide à la toilette”, “Télésurveillance 24/7”, etc.) correspondent à ceux envoyés depuis la webapp.
- Sinon, remplacez la logique par un mapping fixe (ex: “aide_vie” → “Aide à la toilette”).
- Sécurité
- Ajoutez un token partagé (ex: vérifiez X-Trace-Id non vide ou ajoutez un header X-Intake-Token) via un nœud IF en début de flow.
- Optionnel: IP allowlist via reverse-proxy.
Variante “HTTP JSON-RPC” (si pas de nœud Odoo)
Je peux vous fournir une seconde version du workflow qui appelle Odoo via HTTP Request (/web/session/authenticate, /jsonrpc object.execute_kw) + Cookie Jar. Dites-moi si vous la souhaitez et je la colle aussi.
voici la variante n8n “HTTP JSON-RPC” (aucun nœud Odoo requis).
Elle s’authentifie via /jsonrpc → common.authenticate, puis enchaîne des appels execute_kw pour upsert aidant & senior, lire les services, créer le dossier x_care_case et l’évaluation x_care_assessment, puis renvoie un JSON propre.
⚙️ Pré-requis : définissez 4 variables d’environnement n8n (Settings → Environment variables) :
ODOO_URL, ODOO_DB, ODOO_LOGIN, ODOO_PASSWORD.
▶️ Workflow à importer (copier/coller)
{ "name": "MaintienDomicile Intake → Odoo (HTTP JSON-RPC)", "nodes": [ { "parameters": { "path": "maintien-domicile-intake", "options": { "responseData": "={{$json}}", "responseHeaders": { "entries": [ { "name": "Content-Type", "value": "application/json" } ] } } }, "id": "Webhook_Entry", "name": "Webhook (POST /maintien-domicile-intake)", "type": "n8n-nodes-base.webhook", "typeVersion": 1, "position": [-1060, -60], "webhookId": "maintien-domicile-intake", "notes": "Reçoit le JSON de la webapp (voir /api/submit)." }, { "parameters": { "functionCode": "const p = $json;\nfunction req(v, name){ if(v===undefined||v===null||v==='') throw new Error(`Missing ${name}`); return v; }\nconst out = {\n traceId: $headers[\"x-trace-id\"] || p.meta?.traceId || p.traceId || '',\n declarante: {\n role: req(p.declarante?.role,'declarante.role'),\n prenom: req(p.declarante?.prenom,'declarante.prenom'),\n nom: req(p.declarante?.nom,'declarante.nom'),\n email: p.declarante?.email || '',\n phone: p.declarante?.phone || '',\n pref_channel: p.declarante?.pref_channel || 'tel'\n },\n senior: {\n prenom: req(p.senior?.prenom,'senior.prenom'),\n nom: req(p.senior?.nom,'senior.nom'),\n age: p.senior?.age ?? 80,\n ville: p.senior?.ville || '',\n vit_accompagne: p.senior?.vit_accompagne || 'seul'\n },\n autonomie_risques: {\n dependence_level: req(p.autonomie_risques?.dependence_level,'autonomie_risques.dependence_level'),\n risk_fall: !!p.autonomie_risques?.risk_fall,\n risk_wandering: !!p.autonomie_risques?.risk_wandering,\n cognitive_issues: !!p.autonomie_risques?.cognitive_issues,\n main_diagnosis: p.autonomie_risques?.main_diagnosis || ''\n },\n habitat_iot: {\n home_adapted: p.habitat_iot?.home_adapted || 'non',\n has_teleassistance: !!p.habitat_iot?.has_teleassistance,\n has_internet: p.habitat_iot?.has_internet || 'unknown',\n iot_setup: Array.isArray(p.habitat_iot?.iot_setup) ? p.habitat_iot.iot_setup : []\n },\n aides_besoins: {\n current_services: Array.isArray(p.aides_besoins?.current_services) ? p.aides_besoins.current_services : [],\n desired_services: Array.isArray(p.aides_besoins?.desired_services) ? p.aides_besoins.desired_services : [],\n medication_support: p.aides_besoins?.medication_support || 'autonome'\n },\n orga_budget: {\n contact_frequency: p.orga_budget?.contact_frequency || 'hebdo',\n distance_caregiver_km: Number(p.orga_budget?.distance_caregiver_km || 0),\n budget_band: p.orga_budget?.budget_band || 'nspp',\n preference_cc: p.orga_budget?.preference_cc || 'mixte_humain_ia',\n followup_mode: p.orga_budget?.followup_mode || 'mensuel',\n urgency_level: p.orga_budget?.urgency_level || 'information'\n },\n consent: {\n rgpd: !!p.consent?.rgpd,\n note: p.consent?.note || '',\n timestamp_iso: p.consent?.timestamp_iso || new Date().toISOString()\n },\n meta: { channel: p.meta?.channel || 'web', lang: p.meta?.lang || 'fr' }\n};\nreturn [{ json: { normalized: out } }];" }, "id": "Fn_Normalize", "name": "Function (Normalize payload)", "type": "n8n-nodes-base.function", "typeVersion": 2, "position": [-820, -60], "notes": "Nettoie/valide le minimum & prépare la structure." }, { "parameters": { "keepOnlySet": true, "values": { "string": [ { "name": "odoo_url", "value": "={{$env.ODOO_URL}}"}, { "name": "odoo_db", "value": "={{$env.ODOO_DB}}"}, { "name": "odoo_login", "value": "={{$env.ODOO_LOGIN}}"}, { "name": "odoo_password", "value": "={{$env.ODOO_PASSWORD}}"} ] }, "options": {} }, "id": "Set_Creds", "name": "Set (Env → JSON)", "type": "n8n-nodes-base.set", "typeVersion": 2, "position": [-620, -60], "notes": "Récupère les creds Odoo depuis les env n8n." }, { "parameters": { "functionCode": "const { normalized } = $json;\nconst { odoo_url, odoo_db, odoo_login, odoo_password } = $json;\nif(!odoo_url||!odoo_db||!odoo_login||!odoo_password) throw new Error('Env ODOO_* manquants');\n\nasync function rpc(service, method, args){\n const body = { jsonrpc: '2.0', method: 'call', params: { service, method, args } };\n return await this.helpers.httpRequest({\n method: 'POST',\n url: odoo_url.replace(/\\/$/, '') + '/jsonrpc',\n json: true,\n body,\n timeout: 10000\n });\n}\n\nasync function execute_kw(uid, model, method, args=[], kwargs={}){\n const payload = [ odoo_db, uid, odoo_password, model, method, args, kwargs ];\n const res = await rpc('object','execute_kw', payload);\n if(res.error) throw new Error(res.error.message || JSON.stringify(res.error));\n return res.result;\n}\n\n// 1) Auth → uid\nconst auth = await rpc('common','authenticate',[ odoo_db, odoo_login, odoo_password, {} ]);\nif(auth.error || !auth.result) throw new Error('Odoo authenticate failed');\nconst uid = auth.result;\n\n// 2) Upsert Aidant (res.partner by email if provided, else by name)\nlet caregiverId = null;\nif(normalized.declarante.email){\n const found = await execute_kw(uid,'res.partner','search_read',[[[\"email\",\"=\", normalized.declarante.email]]],{fields:[\"id\"], limit:1});\n if(found.length){ caregiverId = found[0].id; }\n}\nif(!caregiverId){\n const foundByName = await execute_kw(uid,'res.partner','search_read',[[[\"name\",\"=\", normalized.declarante.prenom + ' ' + normalized.declarante.nom]]],{fields:[\"id\"], limit:1});\n if(foundByName.length){ caregiverId = foundByName[0].id; }\n}\nif(!caregiverId){\n caregiverId = await execute_kw(uid,'res.partner','create',[{\n name: normalized.declarante.prenom + ' ' + normalized.declarante.nom,\n email: normalized.declarante.email || undefined,\n phone: normalized.declarante.phone || undefined,\n x_is_caregiver: true,\n x_pref_channel: normalized.declarante.pref_channel\n }]);\n}\n\n// 3) Upsert Senior (res.partner by name+city)\nconst seniorName = normalized.senior.prenom + ' ' + normalized.senior.nom;\nlet seniorId = null;\nconst sFound = await execute_kw(uid,'res.partner','search_read',[[\"&\",[\"name\",\"=\", seniorName],[\"city\",\"=\", normalized.senior.ville]]],{fields:[\"id\"], limit:1});\nif(sFound.length){ seniorId = sFound[0].id; }\nif(!seniorId){\n seniorId = await execute_kw(uid,'res.partner','create',[{\n name: seniorName,\n city: normalized.senior.ville,\n x_is_senior: true\n }]);\n}\n\n// 4) Read Services (names in union current/desired)\nconst names = Array.from(new Set([ ...(normalized.aides_besoins.current_services||[]), ...(normalized.aides_besoins.desired_services||[]) ]));\nlet serviceIds = [];\nif(names.length){\n const svc = await execute_kw(uid,'x_care_service','search_read',[[[\"name\",\"in\", names]]],{fields:[\"id\",\"name\"], limit:200});\n serviceIds = svc.map(r=>r.id);\n}\n\n// helper for m2m set\nconst m2m = (ids)=> ids && ids.length ? [[6,0,ids]] : undefined;\n\n// 5) Create x_care_case\nconst caseVals = {\n x_senior_id: seniorId,\n x_main_caregiver_id: caregiverId,\n x_has_internet: normalized.habitat_iot.has_internet,\n x_dependence_level: normalized.autonomie_risques.dependence_level,\n x_risk_fall: normalized.autonomie_risques.risk_fall,\n x_risk_wandering: normalized.autonomie_risques.risk_wandering,\n x_cognitive_issues: normalized.autonomie_risques.cognitive_issues,\n x_main_diagnosis: normalized.autonomie_risques.main_diagnosis,\n x_home_adapted: normalized.habitat_iot.home_adapted,\n x_has_teleassistance: normalized.habitat_iot.has_teleassistance,\n x_medication_support: normalized.aides_besoins.medication_support,\n x_contact_frequency: normalized.orga_budget.contact_frequency,\n x_distance_caregiver_km: normalized.orga_budget.distance_caregiver_km,\n x_budget_band: normalized.orga_budget.budget_band,\n x_preference_cc: normalized.orga_budget.preference_cc,\n x_followup_mode: normalized.orga_budget.followup_mode,\n x_urgency_level: normalized.orga_budget.urgency_level,\n x_consent_rgpd: normalized.consent.rgpd,\n x_consent_note: `${normalized.consent.note} | ${normalized.consent.timestamp_iso} | source:${normalized.meta.channel}`,\n x_current_services_ids: m2m(serviceIds),\n x_desired_services_ids: m2m(serviceIds),\n x_status: 'en_cours'\n};\nconst caseId = await execute_kw(uid,'x_care_case','create',[caseVals]);\n\n// 6) Create x_care_assessment\nconst assessmentId = await execute_kw(uid,'x_care_assessment','create',[{\n case_id: caseId,\n date: new Date().toISOString().slice(0,10),\n x_channel: normalized.meta.channel,\n x_notes: `Auto via webapp. Dependence=${normalized.autonomie_risques.dependence_level}, chutes=${normalized.autonomie_risques.risk_fall}, fugue=${normalized.autonomie_risques.risk_wandering}, cognition=${normalized.autonomie_risques.cognitive_issues}`\n}]);\n\nreturn [{ json: { ok:true, case_id: caseId, assessment_id: assessmentId, caregiver_id: caregiverId, senior_id: seniorId, traceId: normalized.traceId } }];" }, "id": "Fn_Odoo_JSONRPC", "name": "Function (Odoo JSON-RPC upsert)", "type": "n8n-nodes-base.function", "typeVersion": 2, "position": [-380, -60], "notes": "Effectue tous les appels JSON-RPC (/jsonrpc) avec execute_kw." }, { "parameters": { "responseBody": "={{$json}}", "responseCode": 200, "options": {} }, "id": "Respond_OK", "name": "Respond (200 OK)", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1, "position": [-140, -60], "notes": "Renvoie ok, IDs & traceId." } ], "connections": { "Webhook (POST /maintien-domicile-intake)": { "main": [[{ "node": "Function (Normalize payload)", "type": "main", "index": 0 }]] }, "Function (Normalize payload)": { "main": [[{ "node": "Set (Env → JSON)", "type": "main", "index": 0 }]] }, "Set (Env → JSON)": { "main": [[{ "node": "Function (Odoo JSON-RPC upsert)", "type": "main", "index": 0 }]] }, "Function (Odoo JSON-RPC upsert)": { "main": [[{ "node": "Respond (200 OK)", "type": "main", "index": 0 }]] } }, "meta": { "templateCredsSetup": [ { "name": "Env ODOO_URL", "type": "environmentVariable", "required": ["ODOO_URL"] }, { "name": "Env ODOO_DB", "type": "environmentVariable", "required": ["ODOO_DB"] }, { "name": "Env ODOO_LOGIN", "type": "environmentVariable", "required": ["ODOO_LOGIN"] }, { "name": "Env ODOO_PASSWORD", "type": "environmentVariable", "required": ["ODOO_PASSWORD"] } ] } }
⚠️ Notes importantes
- Idempotence : res.partner est recherché par email (sinon par name) et senior par name+city. Adaptez les critères si besoin (ajoutez téléphone).
- M2M : on applique la même liste d’IDs pour current et desired (répertoire commun). Si vous voulez les distinguer, séparez la lecture par deux requêtes name in current et name in desired.
- Sécurité : vous pouvez ajouter un contrôle de jeton partagé en amont (nœud IF sur header X-Intake-Token).
- Erreurs : les exceptions dans le Function node renvoient un 500 au Webhook ; si vous voulez un message custom, encapsulez try/catch et retournez { ok:false, error }.
🔎 C’est quoi l’idempotence ici ?
C’est le fait que ré-envoyer la même demande (même formulaire / même traceId) ne crée pas de doublon : on obtient le même résultat (mêmes enregistrements) au lieu de recréer un aidant, un senior, un dossier, etc.
Comme Odoo Online/Studio ne permet pas d’ajouter de vraies contraintes SQL uniques, il faut l’assurer côté intégration (n8n) + garder une trace côté Odoo.