🧓 SCRIPT COMPLET — Agent IA d’entretien gĂ©riatrique

1- contexte

👉 Bot IA gĂ©riatrique → n8n → Odoo

Elle est structurée, lisible comme une fiche projet, compréhensible par un métier (pharmacien / chef de projet / développeur), et directement exploitable.

🧓 Synergia Senior

Documentation fonctionnelle – Flow “Entretien gĂ©riatrique IA → Odoo”

🎯 Objectif mĂ©tier

Automatiser l’enregistrement structurĂ© d’un entretien gĂ©riatrique rĂ©alisĂ© par un agent IA vers Odoo, afin de :

  • centraliser les Ă©valuations des patients seniors,
  • tracer les scores de fragilitĂ©,
  • dĂ©clencher des alertes,
  • alimenter un dossier patient unifiĂ©.

L’agent IA ne pose pas de diagnostic mĂ©dical mais agit comme un outil de prĂ©-alerte et d’orientation.

đŸ§± Vue d’ensemble du systĂšme

Acteurs

  • Patient / Aidant : rĂ©pond aux questions via voix ou texte.
  • Bot IA gĂ©riatrique : conduit l’entretien, calcule les scores.
  • n8n : orchestre les Ă©changes techniques.
  • Odoo Online : devient la base de donnĂ©es patient.

🔄 SchĂ©ma global (logique mĂ©tier)

Patient / Aidant | | (voix / texte) v đŸ€– Agent IA | | JSON structurĂ© v ⚙ Webhook n8n | +--> 🔍 Recherche patient (Odoo) | +--> 📝 CrĂ©ation entretien gĂ©riatrique (Odoo) | +--> 🔄 Mise Ă  jour fiche patient (score & niveau) | +--> 🚹 Alerte si risque Ă©levĂ© (email, future extension : SMS / Slack)

🧓 SCRIPT COMPLET — Agent IA d’entretien gĂ©riatrique

Objectif : évaluer autonomie, risques et besoins

Durée cible : 12 à 18 minutes

Mode : voix ou texte

Output : score global + alertes + orientation

🧭 BLOC 0 — Introduction & consentement

Agent :

Bonjour, je suis votre assistant de suivi bien-ĂȘtre. Je vais vous poser quelques questions pour mieux comprendre comment vous allez au quotidien.

Vous pouvez rĂ©pondre librement, il n’y a pas de bonnes ou de mauvaises rĂ©ponses.

Êtes-vous d’accord pour commencer ?

✅ Si OUI → BLOC 1

❌ Si NON → Fin avec message de respect



đŸ‘€ BLOC 1 — Profil gĂ©nĂ©ral

  1. Quel est votre Ăąge ?
  2. Vivez-vous seul(e) ou avec quelqu’un ?
  3. Avez-vous une personne qui vous aide réguliÚrement ?

🧠 DĂ©tection : isolement / aidance




🏃 BLOC 2 — MobilitĂ© & chutes

  1. Marchez-vous sans aide, avec une canne, un dĂ©ambulateur, ou ĂȘtes-vous souvent aidĂ©(e) ?
  2. Avez-vous chuté ces derniers mois ?
    • Si oui : combien de fois ?
    • Avez-vous eu des blessures ?
  3. Avez-vous peur de tomber ?

⚠ DĂ©tection : risque de chute




🏠 BLOC 3 — Autonomie quotidienne

  1. Avez-vous des difficultés pour :
    • vous laver ?
    • vous habiller ?
    • prĂ©parer vos repas ?
    • faire vos courses ?
    • prendre vos mĂ©dicaments ?

🎯 Échelle ADL / IADL simplifiĂ©e





🧠 BLOC 4 — MĂ©moire & cognition

  1. Vous arrive-t-il d’oublier des rendez-vous ?
  2. Avez-vous du mal Ă  retrouver vos mots ?
  3. Vous trompez-vous parfois de jour ou de moment de la journée ?

🧠 Suspicion trouble cognitif





❀ BLOC 5 — SantĂ© gĂ©nĂ©rale

  1. Avez-vous des douleurs fréquentes ?
  2. Êtes-vous suivi(e) pour une maladie chronique ?
  3. Êtes-vous souvent fatiguĂ©(e) ?

🎯 FragilitĂ© mĂ©dicale





💊 BLOC 6 — MĂ©dicaments

  1. Prenez-vous des médicaments tous les jours ?
  2. Vous arrive-t-il d’en oublier ?
  3. Les prenez-vous seul(e) ou avec aide ?

⚠ Risque de non-observance





đŸ„— BLOC 7 — Nutrition & hydratation

  1. Avez-vous perdu du poids récemment ?
  2. Mangez-vous moins qu’avant ?
  3. Buvez-vous suffisamment ?

đŸœïž Risque nutritionnel





😔 BLOC 8 — Moral & solitude

  1. Vous sentez-vous souvent seul(e) ?
  2. Avez-vous le moral en ce moment ?
  3. Vous arrive-t-il de perdre l’envie de faire des choses que vous aimiez ?

🧠 DĂ©pistage dĂ©pression





🏡 BLOC 9 — Environnement de vie

  1. Votre logement vous semble-t-il sûr ?
  • tapis ?
  • escaliers ?
  • Ă©clairage ?
  1. Avez-vous des barres d’appui dans la salle de bain ?

đŸšïž Risque environnemental





đŸ‘Ș BLOC 10 — Aidants

  1. La personne qui vous aide semble-t-elle fatiguée ?
  2. Y a-t-il quelqu’un en cas d’urgence ?

đŸ‘„ Charge aidant




✅ BLOC 11 — Clîture bienveillante

Agent :

Merci pour vos réponses.

Elles vont permettre de mieux comprendre vos besoins et de voir comment améliorer votre confort et votre sécurité.

⚠ Cadre lĂ©gal intĂ©grĂ©

L’agent affiche automatiquement :

Ce service ne remplace pas un professionnel de santé.

En cas d’urgence, contactez les services mĂ©dicaux.

📊 OUTPUT STRUCTURÉ (format JSON recommandĂ©)

📊 OUTPUT STRUCTURÉ (format JSON recommandĂ©)

{ "profil": { "age": 82, "habitat": "seul", "aidant": true }, "scores": { "autonomie": 55, "cognitif": 40, "chute": 80, "nutrition": 60, "moral": 65 }, "fragilite_globale": 68, "niveau_risque": "élevé", "alertes": ["chute", "isolement", "oubli traitement"], "recommandations": [ "aménagement salle de bain", "visite infirmiÚre", "bilan gériatrique", "appel de suivi hebdomadaire" ] }

 1ïžâƒŁ OĂč inscrire ça dans Odoo ?

Recycler l'eau pour la réutiliser au lieu d'utiliser des réserves d'eau douce permet d'économiser de l'eau.

 .

.

 .

 .

B) le moteur de scoring

Je te fais un moteur de scoring complet, directement exploitable dans un backend (Nest, Node, etc.) mais expliqué en français.

1ïžâƒŁ Principe gĂ©nĂ©ral

  • Chaque domaine → un score de risque de 0 Ă  100
    • 0 = aucun risque dĂ©tectĂ©
    • 100 = risque maximal sur ce domaine
  • Puis un score global de fragilitĂ© = moyenne pondĂ©rĂ©e des domaines.
  • Des seuils transforment les scores en niveaux : faible / modĂ©rĂ© / Ă©levĂ©.

2ïžâƒŁ Codage standard des rĂ©ponses

Pour simplifier, tu peux normaliser les réponses en codes :

A. Questions de type « difficulté »

Options proposĂ©es Ă  l’utilisateur :

  • Aucune difficultĂ©
  • Petite difficultĂ©
  • Grosse difficultĂ©
  • Impossible sans aide

Codage interne (risque) :

  • Aucune difficultĂ© → 0
  • Petite difficultĂ© → 1
  • Grosse difficultĂ© → 2
  • Impossible sans aide → 3

👉 Score de l’item = code / 3 * 100.

B. Questions Oui / Non

  • Oui = prĂ©sence de symptĂŽme / problĂšme → 1
  • Non = absence → 0

👉 Score de l’item = code * 100 (ou pondĂ©rĂ©).


3ïžâƒŁ ModĂšle de donnĂ©es (input)

Exemple de structure JSON aprĂšs entretien :

{ "profil": { "age": 82, "habitat": "seul", "hasAidant": true }, "mobilite": { "aideMarche": "canne", // "aucune" | "canne" | "deambulateur" | "fauteuil" | "aide_humaine" "chutes12m": 2, // nombre de chutes "blessureChute": true, // oui / non "peurDeTomber": true // oui / non }, "autonomie": { "toilette": "grosse_difficulte", // codage texte → 0..3 "habillage": "petite_difficulte", "repas": "aucune_difficulte", "courses": "impossible", "medicaments": "grosse_difficulte" }, "cognitif": { "oublisRendezVous": true, "trousDeMemoire": true, "desorientation": false }, "sante": { "douleursFrequentes": true, "maladieChronique": true, "fatigueFrequente": true }, "medicaments": { "nbQuotidien": 5, "oublis": "souvent", // "jamais" | "rarement" | "souvent" "aidePourPrise": true }, "nutrition": { "pertePoids": "oui", // "non" | "oui_leger" | "oui_marque" "appetit": "diminue", // "normal" | "diminue" | "tres_diminue" "hydratationFaible": true }, "moral": { "seSentSeul": true, "moralBas": true, "perteInterets": true }, "environnement": { "tapisRisque": true, "escaliersDangereux": true, "eclairageMauvais": false, "barresAppuiSalleDeBain": false }, "aidant": { "aidantPresent": true, "aidantFatigue": true, "aidantDeborde": true } }

4ïžâƒŁ Calcul des scores par domaine

4.1. 🏃 Risque de chute (score_chute)

base = 0 // Nombre de chutes si chutes12m == 0 → base += 0 si chutes12m == 1 → base += 60 si chutes12m >= 2 → base += 80 // Blessures si blessureChute == true → base += 20 // Peur de tomber si peurDeTomber == true → base += 20 score_chute = min(base, 100)

4.2. 🏠 Autonomie (score_autonomie)

Items : toilette, habillage, repas, courses, médicaments (5 items).

  1. Convertir chaque réponse en code numérique :
    • aucune_difficulte → 0
    • petite_difficulte → 1
    • grosse_difficulte → 2
    • impossible → 3
  2. Score autonomie :

codes = [toilette, habillage, repas, courses, medicaments] // 0..3 moyenne_code = moyenne(codes) // entre 0 et 3 score_autonomie = (moyenne_code / 3) * 100

4.3. 🧠 Cognitif (score_cognitif)

Items : oublis, trous de mémoire, désorientation (3 questions oui/non).

nb_positifs = (oublisRendezVous ? 1 : 0) + (trousDeMemoire ? 1 : 0) + (desorientation ? 1 : 0) score_cognitif = (nb_positifs / 3) * 100

4.4. ❀ SantĂ© gĂ©nĂ©rale (score_sante)

Items : douleurs fréquentes, maladie chronique, fatigue fréquente.

nb_positifs = (douleursFrequentes ? 1 : 0) + (maladieChronique ? 1 : 0) + (fatigueFrequente ? 1 : 0) score_sante = (nb_positifs / 3) * 100

4.5. 💊 MĂ©dicaments (score_medicaments)

  • PolymĂ©dication : plus il y a de mĂ©dicaments, plus le risque augmente.
  • Oublis : jamais / rarement / souvent.
  • Besoin d’aide.

// polymĂ©dication si nbQuotidien <= 3 → poly = 20 si 4 <= nbQuotidien <= 5 → poly = 40 si nbQuotidien >= 6 → poly = 60 // oublis si oublis == "jamais" → oublisScore = 0 si oublis == "rarement" → oublisScore = 30 si oublis == "souvent" → oublisScore = 70 // aide aideScore = aidePourPrise ? 40 : 0 score_medicaments = min(poly + oublisScore + aideScore, 100)

4.6. đŸ„— Nutrition (score_nutrition)

// perte de poids si pertePoids == "non" → perteScore = 0 si pertePoids == "oui_leger" → perteScore = 40 si pertePoids == "oui_marque" → perteScore = 70 // appĂ©tit si appetit == "normal" → appetitScore = 0 si appetit == "diminue" → appetitScore = 30 si appetit == "tres_diminue" → appetitScore = 60 // hydratation hydratationScore = hydratationFaible ? 40 : 0 score_nutrition = min(perteScore + appetitScore + hydratationScore, 100)

4.7. 😔 Moral / psycho-social (score_moral)

Items : se sent seul, moral bas, perte d’intĂ©rĂȘt.

nb_positifs = (seSentSeul ? 1 : 0) + (moralBas ? 1 : 0) + (perteInterets ? 1 : 0) score_moral = (nb_positifs / 3) * 100

4.8. 🏡 Environnement (score_environnement)

Items : tapis, escaliers, Ă©clairage, barres d’appui.

nb_risques = (tapisRisque ? 1 : 0) + (escaliersDangereux ? 1 : 0) + (eclairageMauvais ? 1 : 0) abs_barres = (barresAppuiSalleDeBain ? 0 : 1) nb_total = nb_risques + abs_barres // 0..4 score_environnement = (nb_total / 4) * 100

4.9. đŸ‘Ș Aidant (score_aidant)

Si pas d’aidant → score_aidant = 0 (ou null).

si !aidantPresent: score_aidant = 0 sinon: nb_positifs = (aidantFatigue ? 1 : 0) + (aidantDeborde ? 1 : 0) score_aidant = (nb_positifs / 2) * 100

5ïžâƒŁ FragilitĂ© globale

On peut utiliser une moyenne pondérée en donnant davantage de poids aux domaines critiques (chute, autonomie, cognition, médicaments) :

poids = { chute: 2, autonomie: 2, cognitif: 2, sante: 1, medicaments: 2, nutrition: 1, moral: 1, environnement: 1, aidant: 1 } somme_poids = 13 fragilite_globale = (score_chute * 2 + score_autonomie * 2 + score_cognitif * 2 + score_sante * 1 + score_medicaments * 2 + score_nutrition * 1 + score_moral * 1 + score_environnement* 1 + score_aidant * 1) / somme_poids

6ïžâƒŁ Niveaux de risque & rĂšgles d’alerte

A. Niveaux (pour chaque domaine et global)

  • 0–33 → Risque faible
  • 34–66 → Risque modĂ©rĂ©
  • 67–100 → Risque Ă©levĂ©

fonction niveau(score): si score < 34 → "faible" sinon si score < 67 → "modĂ©rĂ©" sinon → "Ă©levĂ©"

B. DĂ©clenchement d’alertes

Exemples de rĂšgles simples :

  • Si score_chute >= 67 → Ajouter alerte "risque_chute"
  • Si score_autonomie >= 67 → "perte_autonomie"
  • Si score_cognitif >= 67 → "trouble_cognitif_suspect"
  • Si score_moral >= 67 → "risque_depression"
  • Si score_nutrition >= 67 → "risque_denutrition"
  • Si fragilite_globale >= 67 → "fragilite_globale_elevee"

7ïžâƒŁ Exemple de rĂ©sultat final (output)

{ "scores": { "chute": 85, "autonomie": 70, "cognitif": 66, "sante": 67, "medicaments": 75, "nutrition": 60, "moral": 80, "environnement": 50, "aidant": 50 }, "niveaux": { "chute": "élevé", "autonomie": "élevé", "cognitif": "modéré", "sante": "élevé", "medicaments": "élevé", "nutrition": "modéré", "moral": "élevé", "environnement": "modéré", "aidant": "modéré" }, "fragilite_globale": 72, "niveau_fragilite": "élevé", "alertes": [ "risque_chute", "perte_autonomie", "trouble_cognitif_suspect", "risque_depression", "fragilite_globale_elevee" ] }


Une seule page “dashboard patient” Ă©ditable via Odoo Studio, qui agrĂšge :

1ïžâƒŁ Concept : une “Fiche Synergia Senior” sur res.partner

c’est tout Ă  fait possible de faire ça dans Odoo – et proprement – avec une seule page “dashboard patient” Ă©ditable via Odoo Studio, qui agrĂšge :

  • 🟩 Les infos aidant / famille venant de la WebApp n°1
  • 🟹 Les infos senior venant du bot d’entretien gĂ©riatrique
  • đŸŸ„ Une synthĂšse visuelle (scores, risques, alertes) tout en haut


On garde le patient comme res.partner, et on ajoute un onglet dédié :

Contact → Synergia Senior

Cette page se découpe en 3 blocs :

  1. Bandeau synthĂšse (en haut)
    • identitĂ© + badges + scores clĂ©s
  2. Colonne gauche : “Aidant & Famille (WebApp #1)”
    • qui sont les enfants / aidants ?
    • coordonnĂ©es, disponibilitĂ©, niveau de charge
    • remarques de la WebApp aidant
  3. Colonne droite : “Senior & GĂ©riatrie (Bot)”
    • dernier score de fragilitĂ© + niveau de risque
    • mini dĂ©tail des scores par domaine
    • liste des derniers entretiens (smart button / sous-liste)

2ïžâƒŁ Layout proposĂ© (vue formulaire Odoo)

đŸ§© Onglet “Synergia Senior” (sur la fiche Contact)

BANDEROLE HAUT (pleine largeur, 1 ligne)

  • name (nom du patient)
  • x_external_ref (ID patient Synergia)
  • x_last_risk_level (badge couleur : vert / orange / rouge)
  • x_last_fragility_score (KPI numĂ©rique)
  • x_dependency_level (sĂ©lection : autonome / fragile / dĂ©pendant)

LIGNE SUIVANTE : 2 colonnes

🟩 Colonne gauche – “Aidant & Famille (WebApp #1)”

 Bloc A – Aidant principal

 

  • x_has_caregiver (boolĂ©en : Aidant identifiĂ© ?)
  • x_main_caregiver_id (Many2one → res.partner)
  • x_caregiver_relation (sĂ©lection : enfant, conjoint, voisin, autre)
  • x_caregiver_phone (Char, tĂ©lĂ©phone direct si diffĂ©rent)
  • x_caregiver_email (Char, email direct si diffĂ©rent)

 Bloc B – Enfants & proches


  • x_children_json (Text) – JSON brut des enfants provenant de la WebApp#1
    • Exemple :

      [ {"nom":"Paul","lien":"fils","tel":"+33..."}, {"nom":"Marie","lien":"fille","tel":"+33..."} ]

  • x_children_notes (Text multi-ligne) – rĂ©sumĂ© lisible (gĂ©nĂ©rĂ© cĂŽtĂ© WebApp ou IA)


 Bloc C – Ressenti de l’aidant (WebApp #1)

 

  • x_caregiver_burnout (bool : “Aidant en difficultĂ© ?”)
  • x_caregiver_burnout_level (sĂ©lection : faible / modĂ©rĂ© / Ă©levĂ©)
  • x_caregiver_comments (Text) – ce que l’aidant a exprimĂ© dans la WebApp
  • x_support_needs (Text) – besoins signalĂ©s (soutien psychologique, aide Ă  domicile
)

🟹 Colonne droite – “Senior & GĂ©riatrie (Bot IA)”



 Bloc D – SynthĂšse derniĂšre Ă©valuation

 

x_last_ai_interview (Datetime) – date du dernier entretien

x_last_fragility_score (Integer) – 0–100

x_last_risk_level (Selection : faible / modéré / élevé)

x_mobility_level (Selection : marche autonome, canne, déambulateur, fauteuil
)

x_known_cognitive_issue (Boolean) – “Troubles cognitifs connus”

 Bloc E – Scores dĂ©taillĂ©s (lecture seule, dernier entretien)

 (champs remontĂ©s depuis le dernier x_synergia_geriatrie_entretien liĂ©)

  • x_score_autonomie
  • x_score_cognitif
  • x_score_chute
  • x_score_nutrition
  • x_score_moral

Option UI : afficher ces 5 champs dans une seule “box” horizontale, façon petits KPIs.

 Bloc F – Alertes & recommandations


  • x_last_alertes_label (Text) – ex : “Risque de chute ; Isolement ; Oubli traitement”
  • x_last_reco_text (Text) – ex : les 3–4 reco principales

Bloc G – Historique des entretiens

 

  • Smart button “Entretiens gĂ©riatriques (N)” qui ouvre la liste x_synergia_geriatrie_entretien filtrĂ©e sur ce patient.
  • En dessous Ă©ventuellement un widget “One2many” montrant les 3 derniers (date / risque / score global).

3ïžâƒŁ Champs principaux Ă  prĂ©voir (rĂ©sumĂ©)

Tu as déjà la plupart, mais pour ce dashboard unifié aidant + senior, les plus importants :

Sur res.partner (patient)

  • x_external_ref (Char) – ID patient Synergia
  • x_contact_type (Selection) – on met patient
  • x_has_caregiver (Bool)
  • x_main_caregiver_id (Many2one → res.partner)
  • x_caregiver_relation (Selection)
  • x_children_json (Text)
  • x_children_notes (Text)
  • x_caregiver_burnout (Bool)
  • x_caregiver_burnout_level (Selection)
  • x_caregiver_comments (Text)
  • x_support_needs (Text)
  • x_last_ai_interview (Datetime)
  • x_last_fragility_score (Integer)
  • x_last_risk_level (Selection)
  • x_mobility_level (Selection)
  • x_known_cognitive_issue (Bool)
  • x_score_autonomie (Integer)
  • x_score_cognitif (Integer)
  • x_score_chute (Integer)
  • x_score_nutrition (Integer)
  • x_score_moral (Integer)
  • x_last_alertes_label (Text)
  • x_last_reco_text (Text)

Ces champs sont alimentés cÎté n8n à chaque nouvel entretien ou chaque mise à jour de WebApp#1. via le bot



4ïžâƒŁ Construction dans Odoo Studio (step-by-step rapide)

  1. Ouvrir Contacts → Studio.
  2. Sur la vue formulaire :
    • Ajouter un onglet “Synergia Senior”.
  3. Dans cet onglet :
    • Ajouter un groupe horizontal pour le bandeau haut (name + id + risque + score).
    • Ajouter un groupement en 2 colonnes.
    • Colonne gauche : blocs Aidant / Famille / Ressenti (champs WebApp#1).
    • Colonne droite : blocs Scores + Alertes + Historique.
  4. CrĂ©er un smart button “Entretiens gĂ©riatriques” liĂ© au modĂšle x_synergia_geriatrie_entretien.

Tu obtiens ainsi une vue unique, lisible par un pharmacien ou un infirmier, qui voit en un clin d’Ɠil :

  • ce que l’aidant dit (WebApp1)
  • ce que le senior dit (bot IA gĂ©riatrique)
  • un score global + la couleur du risque.

5ïžâƒŁ Image HD du dashboard – comment t’en servir

mapping exact n8n → champs de ce dashboard (table de correspondance),

Les grandes histoires ont une personnalitĂ©. Envisagez de raconter une belle histoire qui donne de la personnalitĂ©. Écrire une histoire avec de la personnalitĂ© pour des clients potentiels aidera Ă  Ă©tablir un lien relationnel. Cela se traduit par de petites spĂ©cificitĂ©s comme le choix des mots ou des phrases. Écrivez de votre point de vue, pas de l'expĂ©rience de quelqu'un d'autre.

Les grandes histoires sont pour tout le monde, mĂȘme lorsqu'elles ne sont Ă©crites que pour une seule personne. Si vous essayez d'Ă©crire en pensant Ă  un public large et gĂ©nĂ©ral, votre histoire sonnera fausse et manquera d'Ă©motion. Personne ne sera intĂ©ressĂ©. Ecrire pour une personne en particulier signifie que si c'est authentique pour l'un, c'est authentique pour le reste.

JSON “spec” de ce dashboard Ă  donner Ă  un designer / dev front pour une version WebApp indĂ©pendante d’Odoo.

1ïžâƒŁ ModĂšle de donnĂ©es – patient.schema.json

{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "SynergiaSeniorPatient", "type": "object", "properties": { "patient": { "type": "object", "properties": { "id": { "type": "string" }, "externalRef": { "type": "string" }, "fullName": { "type": "string" }, "birthDate": { "type": "string", "format": "date" }, "age": { "type": "integer" }, "gender": { "type": "string", "enum": ["homme", "femme", "autre", "inconnu"] }, "dependencyLevel": { "type": "string", "enum": ["autonome", "fragile", "dependant", "inconnu"] }, "preferredChannel": { "type": "string", "enum": ["voix", "sms", "email", "whatsapp", "app", "inconnu"] }, "language": { "type": "string" } }, "required": ["id", "fullName"] }, "caregiverBlock": { "type": "object", "properties": { "hasCaregiver": { "type": "boolean" }, "mainCaregiver": { "type": "object", "properties": { "id": { "type": "string" }, "fullName": { "type": "string" }, "relation": { "type": "string", "enum": ["enfant", "conjoint", "frere_soeur", "proche", "voisin", "autre", "inconnu"] }, "phone": { "type": "string" }, "email": { "type": "string" } } }, "relatives": { "type": "array", "items": { "type": "object", "properties": { "fullName": { "type": "string" }, "relation": { "type": "string" }, "phone": { "type": "string" }, "email": { "type": "string" } } } }, "caregiverDistress": { "type": "object", "properties": { "isInDifficulty": { "type": "boolean" }, "level": { "type": "string", "enum": ["faible", "moderee", "elevee", "inconnu"] }, "freeText": { "type": "string" }, "supportNeeds": { "type": "string" } } } } }, "geriatricsBlock": { "type": "object", "properties": { "lastInterviewAt": { "type": "string", "format": "date-time" }, "globalFragilityScore": { "type": "integer", "minimum": 0, "maximum": 100 }, "globalRiskLevel": { "type": "string", "enum": ["faible", "modere", "eleve", "inconnu"] }, "mobilityLevel": { "type": "string", "enum": ["autonome", "canne", "deambulateur", "fauteuil", "alitE", "inconnu"] }, "cognitiveIssuesKnown": { "type": "boolean" }, "detailedScores": { "type": "object", "properties": { "autonomy": { "type": "integer", "minimum": 0, "maximum": 100 }, "cognitive": { "type": "integer", "minimum": 0, "maximum": 100 }, "falls": { "type": "integer", "minimum": 0, "maximum": 100 }, "nutrition": { "type": "integer", "minimum": 0, "maximum": 100 }, "mood": { "type": "integer", "minimum": 0, "maximum": 100 } } }, "alerts": { "type": "array", "items": { "type": "string" } }, "alertsLabel": { "type": "string" }, "recommendations": { "type": "array", "items": { "type": "string" } }, "interviewsSummary": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "date": { "type": "string", "format": "date-time" }, "globalScore": { "type": "integer" }, "riskLevel": { "type": "string", "enum": ["faible", "modere", "eleve", "inconnu"] } } } } } } }, "required": ["patient", "geriatricsBlock"] }


2ïžâƒŁ SpĂ©cification UI – patientDashboard.ui.json

Ici on décrit la page : sections, composants, bindings avec le modÚle de données.

{ "id": "patient-dashboard", "title": "Synergia Senior – Fiche patient", "layout": { "type": "page", "sections": [ { "id": "header", "type": "row", "style": { "padding": 24, "borderBottom": true }, "columns": [ { "width": "2fr", "components": [ { "type": "text", "variant": "title", "bind": "patient.fullName" }, { "type": "text", "variant": "subtitle", "text": "ID Synergia :", "inline": true }, { "type": "badge", "variant": "neutral", "bind": "patient.externalRef", "inline": true } ] }, { "width": "1fr", "align": "right", "components": [ { "type": "pill", "label": "Risque", "bind": "geriatricsBlock.globalRiskLevel", "colorMap": { "faible": "green", "modere": "orange", "eleve": "red", "inconnu": "gray" } }, { "type": "kpi", "label": "Score fragilitĂ©", "bind": "geriatricsBlock.globalFragilityScore" }, { "type": "tag", "label": "DĂ©pendance", "bind": "patient.dependencyLevel" } ] } ] }, { "id": "main-two-columns", "type": "row", "style": { "padding": 24, "gap": 24 }, "columns": [ { "id": "col-caregiver", "width": "1fr", "title": "Aidant & Famille (WebApp #1)", "components": [ { "type": "card", "title": "Aidant principal", "components": [ { "type": "checkbox", "label": "Aidant identifiĂ©", "bind": "caregiverBlock.hasCaregiver", "readOnly": true }, { "type": "keyValue", "items": [ { "label": "Nom", "bind": "caregiverBlock.mainCaregiver.fullName" }, { "label": "Lien", "bind": "caregiverBlock.mainCaregiver.relation" }, { "label": "TĂ©lĂ©phone", "bind": "caregiverBlock.mainCaregiver.phone" }, { "label": "E-mail", "bind": "caregiverBlock.mainCaregiver.email" } ] } ] }, { "type": "card", "title": "Enfants & proches", "components": [ { "type": "table", "bind": "caregiverBlock.relatives", "columns": [ { "header": "Nom", "field": "fullName" }, { "header": "Lien", "field": "relation" }, { "header": "TĂ©lĂ©phone", "field": "phone" }, { "header": "E-mail", "field": "email" } ] } ] }, { "type": "card", "title": "Ressenti de l’aidant", "components": [ { "type": "checkbox", "label": "Aidant en difficultĂ© ?", "bind": "caregiverBlock.caregiverDistress.isInDifficulty", "readOnly": true }, { "type": "keyValue", "items": [ { "label": "Niveau de charge", "bind": "caregiverBlock.caregiverDistress.level" } ] }, { "type": "textarea", "label": "Paroles de l’aidant", "bind": "caregiverBlock.caregiverDistress.freeText", "readOnly": true, "rows": 3 }, { "type": "textarea", "label": "Besoins exprimĂ©s", "bind": "caregiverBlock.caregiverDistress.supportNeeds", "readOnly": true, "rows": 2 } ] } ] }, { "id": "col-geriatrics", "width": "1fr", "title": "Senior & GĂ©riatrie (Bot IA)", "components": [ { "type": "card", "title": "SynthĂšse derniĂšre Ă©valuation", "components": [ { "type": "keyValue", "items": [ { "label": "Date", "bind": "geriatricsBlock.lastInterviewAt", "format": "date" }, { "label": "Score fragilitĂ©", "bind": "geriatricsBlock.globalFragilityScore" }, { "label": "Niveau de risque", "bind": "geriatricsBlock.globalRiskLevel" }, { "label": "MobilitĂ©", "bind": "geriatricsBlock.mobilityLevel" }, { "label": "Troubles cognitifs connus", "type": "booleanText", "bind": "geriatricsBlock.cognitiveIssuesKnown" } ] } ] }, { "type": "card", "title": "Scores dĂ©taillĂ©s", "components": [ { "type": "scoreStrip", "scores": [ { "label": "Autonomie", "bind": "geriatricsBlock.detailedScores.autonomy" }, { "label": "Cognitif", "bind": "geriatricsBlock.detailedScores.cognitive" }, { "label": "Chute", "bind": "geriatricsBlock.detailedScores.falls" }, { "label": "Nutrition", "bind": "geriatricsBlock.detailedScores.nutrition" }, { "label": "Moral", "bind": "geriatricsBlock.detailedScores.mood" } ] } ] }, { "type": "card", "title": "Alertes & recommandations", "components": [ { "type": "tagList", "bind": "geriatricsBlock.alerts" }, { "type": "list", "label": "Recommandations", "bind": "geriatricsBlock.recommendations" } ] }, { "type": "card", "title": "Entretiens gĂ©riatriques", "components": [ { "type": "table", "bind": "geriatricsBlock.interviewsSummary", "columns": [ { "header": "Date", "field": "date", "format": "date" }, { "header": "Score", "field": "globalScore" }, { "header": "Risque", "field": "riskLevel" } ], "actions": [ { "type": "link", "label": "Voir le dĂ©tail", "route": "/geriatrie/entretien/:id", "paramField": "id" } ] } ] } ] } ] } ] } }

Comment l’utiliser

  • Designer : s’appuie sur patientDashboard.ui.json pour savoir
    • quelles sections,
    • quels types de composants,
    • quelles infos affichĂ©es oĂč.
  • Dev front :
    • mappe les type vers ses composants React/Vue (ex. card, scoreStrip, tagList
),
    • connecte les bind au JSON renvoyĂ© par l’API (patient.schema.json).

Si tu veux, je peux maintenant te faire la version TypeScript des types (Patient, CaregiverBlock, GeriatricsBlock) ou un exemple de composant React <PatientDashboard /> qui consomme exactement ce JSON.


> exemple de composant React <PatientDashboard /> qui consomme exactement ce JSON.

Voici un exemple complet de composant React + TypeScript <PatientDashboard /> qui consomme exactement le JSON qu’on a dĂ©fini (patient, caregiverBlock, geriatricsBlock).

Je pars sur :

  • ✅ Props typĂ©es (interfaces TS cohĂ©rentes avec ton schema)
  • ✅ Mise en page 2 colonnes avec un bandeau haut
  • ✅ Classes type Tailwind (faciles Ă  adapter / remplacer)

Tu peux coller ça dans un projet React/Next et adapter le style.

1ïžâƒŁ Types TypeScript pour les donnĂ©es

// types.ts export type RiskLevel = "faible" | "modere" | "eleve" | "inconnu"; export interface PatientInfo { id: string; externalRef?: string; fullName: string; birthDate?: string; age?: number; gender?: "homme" | "femme" | "autre" | "inconnu"; dependencyLevel?: "autonome" | "fragile" | "dependant" | "inconnu"; preferredChannel?: "voix" | "sms" | "email" | "whatsapp" | "app" | "inconnu"; language?: string; } export interface CaregiverPerson { id?: string; fullName?: string; relation?: string; phone?: string; email?: string; } export interface CaregiverDistress { isInDifficulty?: boolean; level?: "faible" | "moderee" | "elevee" | "inconnu"; freeText?: string; supportNeeds?: string; } export interface CaregiverBlock { hasCaregiver?: boolean; mainCaregiver?: CaregiverPerson; relatives?: CaregiverPerson[]; caregiverDistress?: CaregiverDistress; } export interface GeriatricScores { autonomy?: number; cognitive?: number; falls?: number; nutrition?: number; mood?: number; } export interface InterviewSummary { id: string; date: string; globalScore?: number; riskLevel?: RiskLevel; } export interface GeriatricsBlock { lastInterviewAt?: string; globalFragilityScore?: number; globalRiskLevel?: RiskLevel; mobilityLevel?: | "autonome" | "canne" | "deambulateur" | "fauteuil" | "alitE" | "inconnu"; cognitiveIssuesKnown?: boolean; detailedScores?: GeriatricScores; alerts?: string[]; alertsLabel?: string; recommendations?: string[]; interviewsSummary?: InterviewSummary[]; } export interface PatientDashboardData { patient: PatientInfo; caregiverBlock?: CaregiverBlock; geriatricsBlock: GeriatricsBlock; }

2ïžâƒŁ Composant <PatientDashboard />

// PatientDashboard.tsx import React from "react"; import { PatientDashboardData, RiskLevel, CaregiverPerson, GeriatricScores, } from "./types"; interface PatientDashboardProps { data: PatientDashboardData; } const riskLabelMap: Record<RiskLevel, string> = { faible: "Risque faible", modere: "Risque modĂ©rĂ©", eleve: "Risque Ă©levĂ©", inconnu: "Risque inconnu", }; const riskColorMap: Record<RiskLevel, string> = { faible: "bg-green-100 text-green-800", modere: "bg-orange-100 text-orange-800", eleve: "bg-red-100 text-red-800", inconnu: "bg-gray-100 text-gray-800", }; const dependencyLabelMap: Record<string, string> = { autonome: "Autonome", fragile: "Fragile", dependant: "DĂ©pendant", inconnu: "Inconnu", }; function formatDate(dateStr?: string) { if (!dateStr) return "—"; const d = new Date(dateStr); if (isNaN(d.getTime())) return dateStr; return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric", }); } function booleanToText(v?: boolean) { if (v === true) return "Oui"; if (v === false) return "Non"; return "—"; } // Mini composant KPI const KPI: React.FC<{ label: string; value?: number | string }> = ({ label, value, }) => ( <div className="flex flex-col items-end ml-4"> <span className="text-xs text-gray-500">{label}</span> <span className="text-xl font-semibold text-gray-900"> {value !== undefined && value !== null && value !== "" ? value : "—"} </span> </div> ); // Badge risque const RiskBadge: React.FC<{ level?: RiskLevel }> = ({ level = "inconnu" }) => ( <span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${riskColorMap[level]}`} > {riskLabelMap[level]} </span> ); // Card gĂ©nĂ©rique const Card: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children, }) => ( <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-4"> <h3 className="text-sm font-semibold text-gray-700 mb-3">{title}</h3> {children} </div> ); // Liste clĂ©/valeur const KeyValueList: React.FC<{ items: { label: string; value?: string | number | null }[]; }> = ({ items }) => ( <dl className="space-y-1 text-sm"> {items.map((item, idx) => ( <div key={idx} className="flex justify-between"> <dt className="text-gray-500">{item.label}</dt> <dd className="text-gray-900 font-medium ml-4 text-right"> {item.value !== undefined && item.value !== null && item.value !== "" ? item.value : "—"} </dd> </div> ))} </dl> ); // Table enfants / proches const RelativesTable: React.FC<{ relatives?: CaregiverPerson[] }> = ({ relatives, }) => { if (!relatives || relatives.length === 0) { return <p className="text-sm text-gray-400">Aucun proche dĂ©clarĂ©.</p>; } return ( <div className="overflow-hidden rounded-lg border border-gray-100"> <table className="min-w-full divide-y divide-gray-100 text-sm"> <thead className="bg-gray-50"> <tr> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Nom </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Lien </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> TĂ©lĂ©phone </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> E-mail </th> </tr> </thead> <tbody className="bg-white divide-y divide-gray-50"> {relatives.map((r, idx) => ( <tr key={idx}> <td className="px-3 py-2 text-gray-900">{r.fullName || "—"}</td> <td className="px-3 py-2 text-gray-700">{r.relation || "—"}</td> <td className="px-3 py-2 text-gray-700">{r.phone || "—"}</td> <td className="px-3 py-2 text-gray-700">{r.email || "—"}</td> </tr> ))} </tbody> </table> </div> ); }; // Bande de scores const ScoresStrip: React.FC<{ scores?: GeriatricScores }> = ({ scores }) => { const items: { key: keyof GeriatricScores; label: string }[] = [ { key: "autonomy", label: "Autonomie" }, { key: "cognitive", label: "Cognitif" }, { key: "falls", label: "Chute" }, { key: "nutrition", label: "Nutrition" }, { key: "mood", label: "Moral" }, ]; return ( <div className="grid grid-cols-2 md:grid-cols-5 gap-3"> {items.map(({ key, label }) => { const value = scores?.[key]; return ( <div key={key} className="flex flex-col items-center rounded-lg border border-gray-100 bg-gray-50 px-3 py-2" > <span className="text-xs text-gray-500 mb-1">{label}</span> <span className="text-lg font-semibold text-gray-900"> {typeof value === "number" ? `${value}` : "—"} </span> </div> ); })} </div> ); }; export const PatientDashboard: React.FC<PatientDashboardProps> = ({ data }) => { const { patient, caregiverBlock, geriatricsBlock } = data; return ( <div className="w-full max-w-6xl mx-auto p-6 bg-gray-50 min-h-screen"> {/* HEADER */} <header className="flex flex-col md:flex-row md:items-center md:justify-between border-b border-gray-200 pb-4 mb-6"> <div className="mb-3 md:mb-0"> <h1 className="text-2xl font-semibold text-gray-900"> {patient.fullName} </h1> <div className="flex items-center flex-wrap gap-2 mt-1"> {patient.externalRef && ( <span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700"> ID Synergia&nbsp;: {patient.externalRef} </span> )} {patient.age !== undefined && ( <span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700"> {patient.age} ans </span> )} {patient.dependencyLevel && ( <span className="inline-flex items-center rounded-full bg-purple-50 px-2.5 py-1 text-xs font-medium text-purple-700"> {dependencyLabelMap[patient.dependencyLevel] ?? patient.dependencyLevel} </span> )} </div> </div> <div className="flex flex-col items-end"> <RiskBadge level={geriatricsBlock.globalRiskLevel || "inconnu"} /> <div className="flex mt-2"> <KPI label="Score fragilitĂ©" value={ geriatricsBlock.globalFragilityScore !== undefined ? geriatricsBlock.globalFragilityScore : "—" } /> </div> </div> </header> {/* MAIN 2 COLS */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {/* COL GAUCHE : Aidant & famille */} <section> <h2 className="text-sm font-semibold text-gray-600 mb-3"> Aidant & Famille (WebApp #1) </h2> <Card title="Aidant principal"> <div className="flex items-center justify-between mb-2"> <span className="text-sm text-gray-700"> Aidant identifiĂ© :{" "} <span className="font-medium"> {booleanToText(caregiverBlock?.hasCaregiver)} </span> </span> </div> <KeyValueList items={[ { label: "Nom", value: caregiverBlock?.mainCaregiver?.fullName || "—", }, { label: "Lien", value: caregiverBlock?.mainCaregiver?.relation || "—", }, { label: "TĂ©lĂ©phone", value: caregiverBlock?.mainCaregiver?.phone || "—", }, { label: "E-mail", value: caregiverBlock?.mainCaregiver?.email || "—", }, ]} /> </Card> <Card title="Enfants & proches"> <RelativesTable relatives={caregiverBlock?.relatives} /> </Card> <Card title="Ressenti de l’aidant"> <div className="mb-2"> <span className="text-sm text-gray-700"> Aidant en difficultĂ© :{" "} <span className="font-medium"> {booleanToText( caregiverBlock?.caregiverDistress?.isInDifficulty )} </span> </span> </div> <KeyValueList items={[ { label: "Niveau de charge", value: caregiverBlock?.caregiverDistress?.level?.toString() ?? "—", }, ]} /> <div className="mt-3"> <label className="block text-xs font-medium text-gray-500 mb-1"> Paroles de l’aidant </label> <p className="text-sm text-gray-800 whitespace-pre-line bg-gray-50 rounded-md p-2"> {caregiverBlock?.caregiverDistress?.freeText || "—"} </p> </div> <div className="mt-3"> <label className="block text-xs font-medium text-gray-500 mb-1"> Besoins exprimĂ©s </label> <p className="text-sm text-gray-800 whitespace-pre-line bg-gray-50 rounded-md p-2"> {caregiverBlock?.caregiverDistress?.supportNeeds || "—"} </p> </div> </Card> </section> {/* COL DROITE : Senior & gĂ©riatrie */} <section> <h2 className="text-sm font-semibold text-gray-600 mb-3"> Senior & GĂ©riatrie (Bot IA) </h2> <Card title="SynthĂšse derniĂšre Ă©valuation"> <KeyValueList items={[ { label: "Date", value: formatDate(geriatricsBlock.lastInterviewAt), }, { label: "Score fragilitĂ©", value: geriatricsBlock.globalFragilityScore !== undefined ? `${geriatricsBlock.globalFragilityScore}` : "—", }, { label: "Niveau de risque", value: riskLabelMap[ geriatricsBlock.globalRiskLevel || "inconnu" ], }, { label: "MobilitĂ©", value: geriatricsBlock.mobilityLevel || "—", }, { label: "Troubles cognitifs connus", value: booleanToText( geriatricsBlock.cognitiveIssuesKnown ), }, ]} /> </Card> <Card title="Scores dĂ©taillĂ©s"> <ScoresStrip scores={geriatricsBlock.detailedScores} /> </Card> <Card title="Alertes & recommandations"> <div className="mb-3"> <label className="block text-xs font-medium text-gray-500 mb-1"> Alertes </label> <div className="flex flex-wrap gap-2"> {(geriatricsBlock.alerts || []).length > 0 ? ( geriatricsBlock.alerts!.map((a, idx) => ( <span key={idx} className="inline-flex items-center rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700" > {a} </span> )) ) : ( <span className="text-sm text-gray-400"> Aucune alerte active. </span> )} </div> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1"> Recommandations </label> {(geriatricsBlock.recommendations || []).length > 0 ? ( <ul className="list-disc list-inside text-sm text-gray-800 space-y-1"> {geriatricsBlock.recommendations!.map((r, idx) => ( <li key={idx}>{r}</li> ))} </ul> ) : ( <p className="text-sm text-gray-400">Aucune recommandation.</p> )} </div> </Card> <Card title="Entretiens gĂ©riatriques"> {geriatricsBlock.interviewsSummary && geriatricsBlock.interviewsSummary.length > 0 ? ( <div className="overflow-hidden rounded-lg border border-gray-100"> <table className="min-w-full divide-y divide-gray-100 text-sm"> <thead className="bg-gray-50"> <tr> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Date </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Score </th> <th className="px-3 py-2 text-left text-xs font-semibold text-gray-500"> Risque </th> </tr> </thead> <tbody className="bg-white divide-y divide-gray-50"> {geriatricsBlock.interviewsSummary.map((itw) => ( <tr key={itw.id}> <td className="px-3 py-2 text-gray-900"> {formatDate(itw.date)} </td> <td className="px-3 py-2 text-gray-700"> {itw.globalScore ?? "—"} </td> <td className="px-3 py-2 text-gray-700"> {riskLabelMap[itw.riskLevel || "inconnu"]} </td> </tr> ))} </tbody> </table> </div> ) : ( <p className="text-sm text-gray-400"> Aucun entretien gĂ©riatrique enregistrĂ©. </p> )} </Card> </section> </div> </div> ); };

3ïžâƒŁ Exemple d’appel du composant

// App.tsx import React from "react"; import { PatientDashboard } from "./PatientDashboard"; import { PatientDashboardData } from "./types"; const mockData: PatientDashboardData = { patient: { id: "123", externalRef: "PAT-000123", fullName: "Jeanne Dupont", age: 82, dependencyLevel: "fragile", }, caregiverBlock: { hasCaregiver: true, mainCaregiver: { fullName: "Paul Dupont", relation: "fils", phone: "+33 6 12 34 56 78", email: "paul.dupont@example.com", }, relatives: [ { fullName: "Marie Dupont", relation: "fille", phone: "+33 6 98 76 54 32", email: "marie.dupont@example.com", }, ], caregiverDistress: { isInDifficulty: true, level: "elevee", freeText: "Je me sens dĂ©passĂ©, j’ai du mal Ă  concilier travail et accompagnement.", supportNeeds: "Besoin d’aide Ă  domicile et de rĂ©pit.", }, }, geriatricsBlock: { lastInterviewAt: "2025-11-27T14:25:00Z", globalFragilityScore: 68, globalRiskLevel: "eleve", mobilityLevel: "canne", cognitiveIssuesKnown: true, detailedScores: { autonomy: 55, cognitive: 40, falls: 80, nutrition: 60, mood: 65, }, alerts: ["chute", "isolement", "oubli traitement"], recommendations: [ "AmĂ©nagement salle de bain", "Visite infirmiĂšre hebdomadaire", "Bilan gĂ©riatrique spĂ©cialisĂ©", ], interviewsSummary: [ { id: "itw-1", date: "2025-10-10T10:00:00Z", globalScore: 62, riskLevel: "modere", }, { id: "itw-2", date: "2025-08-01T09:30:00Z", globalScore: 55, riskLevel: "modere", }, ], }, }; export const App: React.FC = () => { return <PatientDashboard data={mockData} />; };

Si tu veux ensuite, je peux :

  • te faire la version strictement alignĂ©e avec Odoo (noms de champs x_
 en interne + adaptateur),
  • ou un schĂ©ma “API backend” pour que ton orchestrateur (n8n / Nest) alimente exactement ce JSON pour le front.

version strictement alignée avec Odoo (noms de champs x_
 en interne + adaptateur),

Parfait, on va coller Ă  Odoo Ă  100 % :

  • noms de champs x_
 tels qu’on les a dĂ©finis,
  • un type TypeScript pour la rĂ©ponse search_read Odoo,
  • un adaptateur qui transforme ça en PatientDashboardData dĂ©jĂ  utilisĂ© par <PatientDashboard />.

1ïžâƒŁ Interfaces cĂŽtĂ© Odoo (JSON-RPC)

// odoo-types.ts /** Représentation d'un Many2one Odoo : [id, display_name] */ export type OdooMany2One = [number, string] | false | null; /** Enregistrement res.partner tel que renvoyé par search_read */ export interface OdooPartnerRecord { id: number; name: string; // Identité / profil x_external_ref?: string; birthdate_date?: string; // "YYYY-MM-DD" x_gender?: "homme" | "femme" | "autre" | "inconnu"; x_dependency_level?: "autonome" | "fragile" | "dependant" | "inconnu"; x_preferred_channel?: | "voix" | "sms" | "email" | "whatsapp" | "app" | "inconnu"; x_language?: string; // Aidant / famille (WebApp #1) x_has_caregiver?: boolean; x_main_caregiver_id?: OdooMany2One; // -> res.partner x_caregiver_relation?: string; x_children_json?: string; // JSON texte x_caregiver_burnout?: boolean; x_caregiver_burnout_level?: "faible" | "moderee" | "elevee" | "inconnu"; x_caregiver_comments?: string; x_support_needs?: string; // Dernier état gériatrique (Bot IA) x_last_ai_interview?: string; // datetime ISO x_last_fragility_score?: number; x_last_risk_level?: "faible" | "modere" | "eleve" | "inconnu"; x_mobility_level?: | "autonome" | "canne" | "deambulateur" | "fauteuil" | "alitE" | "inconnu"; x_known_cognitive_issue?: boolean; x_score_autonomie?: number; x_score_cognitif?: number; x_score_chute?: number; x_score_nutrition?: number; x_score_moral?: number; x_last_alertes_label?: string; x_last_reco_text?: string; } /** Enregistrement x_synergia_geriatrie_entretien pour l'historique */ export interface OdooGeriatrieEntretien { id: number; x_date_entretien?: string; // datetime x_fragilite_globale?: number; x_niveau_risque?: "faible" | "modere" | "eleve" | "inconnu"; }

2ïžâƒŁ Types front (dĂ©jĂ  utilisĂ©s par ton dashboard)

On reprend les types du <PatientDashboard /> (résumé) :

// front-types.ts export type RiskLevel = "faible" | "modere" | "eleve" | "inconnu"; export interface PatientInfo { id: string; externalRef?: string; fullName: string; birthDate?: string; age?: number; gender?: "homme" | "femme" | "autre" | "inconnu"; dependencyLevel?: "autonome" | "fragile" | "dependant" | "inconnu"; preferredChannel?: "voix" | "sms" | "email" | "whatsapp" | "app" | "inconnu"; language?: string; } export interface CaregiverPerson { id?: string; fullName?: string; relation?: string; phone?: string; email?: string; } export interface CaregiverDistress { isInDifficulty?: boolean; level?: "faible" | "moderee" | "elevee" | "inconnu"; freeText?: string; supportNeeds?: string; } export interface CaregiverBlock { hasCaregiver?: boolean; mainCaregiver?: CaregiverPerson; relatives?: CaregiverPerson[]; caregiverDistress?: CaregiverDistress; } export interface GeriatricScores { autonomy?: number; cognitive?: number; falls?: number; nutrition?: number; mood?: number; } export interface InterviewSummary { id: string; date: string; globalScore?: number; riskLevel?: RiskLevel; } export interface GeriatricsBlock { lastInterviewAt?: string; globalFragilityScore?: number; globalRiskLevel?: RiskLevel; mobilityLevel?: | "autonome" | "canne" | "deambulateur" | "fauteuil" | "alitE" | "inconnu"; cognitiveIssuesKnown?: boolean; detailedScores?: GeriatricScores; alerts?: string[]; alertsLabel?: string; recommendations?: string[]; interviewsSummary?: InterviewSummary[]; } export interface PatientDashboardData { patient: PatientInfo; caregiverBlock?: CaregiverBlock; geriatricsBlock: GeriatricsBlock; }

3ïžâƒŁ Adaptateur Odoo → Dashboard

Ce module fait le “pont” entre Odoo JSON-RPC et le front.

Tu peux l’utiliser dans ton backend (Nest) ou directement cĂŽtĂ© front si tu appelles l’API Odoo depuis un proxy.

// odoo-adapter.ts import { OdooPartnerRecord, OdooGeriatrieEntretien, OdooMany2One, } from "./odoo-types"; import { PatientDashboardData, CaregiverPerson, RiskLevel, } from "./front-types"; function unwrapMany2One(m2o?: OdooMany2One): { id?: string; name?: string } { if (!m2o || m2o === false) return {}; const [id, name] = m2o; return { id: String(id), name }; } function computeAge(birthdate?: string): number | undefined { if (!birthdate) return undefined; const d = new Date(birthdate); if (isNaN(d.getTime())) return undefined; const now = new Date(); let age = now.getFullYear() - d.getFullYear(); const m = now.getMonth() - d.getMonth(); if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--; return age; } export function mapOdooToDashboard( partner: OdooPartnerRecord, entretiens: OdooGeriatrieEntretien[] ): PatientDashboardData { // 1) Patient const patient = { id: String(partner.id), externalRef: partner.x_external_ref, fullName: partner.name, birthDate: partner.birthdate_date, age: computeAge(partner.birthdate_date), gender: partner.x_gender, dependencyLevel: partner.x_dependency_level, preferredChannel: partner.x_preferred_channel, language: partner.x_language, }; // 2) Aidant & famille const mainCaregiverRaw = unwrapMany2One(partner.x_main_caregiver_id); const mainCaregiver: CaregiverPerson | undefined = mainCaregiverRaw.id || mainCaregiverRaw.name ? { id: mainCaregiverRaw.id, fullName: mainCaregiverRaw.name, relation: partner.x_caregiver_relation, // téléphone / email : à rajouter si tu les exposes dans search_read } : undefined; // Enfants / proches : stockés en JSON texte dans x_children_json let relatives: CaregiverPerson[] | undefined; if (partner.x_children_json) { try { const parsed = JSON.parse(partner.x_children_json) as any[]; relatives = parsed.map((child) => ({ fullName: child.nom ?? child.fullName, relation: child.lien ?? child.relation, phone: child.tel ?? child.phone, email: child.email, })); } catch (e) { console.warn("x_children_json invalide pour partner", partner.id, e); } } const caregiverBlock = { hasCaregiver: partner.x_has_caregiver, mainCaregiver, relatives, caregiverDistress: { isInDifficulty: partner.x_caregiver_burnout, level: partner.x_caregiver_burnout_level, freeText: partner.x_caregiver_comments, supportNeeds: partner.x_support_needs, }, }; // 3) Bloc gériatrie const geriatricsBlock = { lastInterviewAt: partner.x_last_ai_interview, globalFragilityScore: partner.x_last_fragility_score, globalRiskLevel: partner.x_last_risk_level as RiskLevel | undefined, mobilityLevel: partner.x_mobility_level, cognitiveIssuesKnown: partner.x_known_cognitive_issue, detailedScores: { autonomy: partner.x_score_autonomie, cognitive: partner.x_score_cognitif, falls: partner.x_score_chute, nutrition: partner.x_score_nutrition, mood: partner.x_score_moral, }, alerts: partner.x_last_alertes_label ? partner.x_last_alertes_label .split(";") .map((s) => s.trim()) .filter(Boolean) : undefined, alertsLabel: partner.x_last_alertes_label, recommendations: partner.x_last_reco_text ? partner.x_last_reco_text .split("\n") .map((s) => s.trim()) .filter(Boolean) : undefined, interviewsSummary: entretiens.map((e) => ({ id: String(e.id), date: e.x_date_entretien || "", globalScore: e.x_fragilite_globale, riskLevel: e.x_niveau_risque as RiskLevel | undefined, })), }; return { patient, caregiverBlock, geriatricsBlock, }; }

4ïžâƒŁ Exemple d’usage end-to-end

Dans ton backend (NestJS ou autre) :

// Exemple pseudo-service async function getPatientDashboardFromOdoo(partnerId: number) { // 1) search_read sur res.partner avec tous les champs x_
 nĂ©cessaires const [partner] = (await odooCall<OdooPartnerRecord[]>("res.partner", "search_read", [ [["id", "=", partnerId]], [ "name", "birthdate_date", "x_external_ref", "x_gender", "x_dependency_level", "x_preferred_channel", "x_language", "x_has_caregiver", "x_main_caregiver_id", "x_caregiver_relation", "x_children_json", "x_caregiver_burnout", "x_caregiver_burnout_level", "x_caregiver_comments", "x_support_needs", "x_last_ai_interview", "x_last_fragility_score", "x_last_risk_level", "x_mobility_level", "x_known_cognitive_issue", "x_score_autonomie", "x_score_cognitif", "x_score_chute", "x_score_nutrition", "x_score_moral", "x_last_alertes_label", "x_last_reco_text" ] ])) || []; // 2) search_read sur x_synergia_geriatrie_entretien pour l’historique const entretiens = await odooCall<OdooGeriatrieEntretien[]>( "x_synergia_geriatrie_entretien", "search_read", [ [["x_patient_id", "=", partnerId]], ["id", "x_date_entretien", "x_fragilite_globale", "x_niveau_risque"], ] ); // 3) Adapter vers PatientDashboardData return mapOdooToDashboard(partner, entretiens); }

Ensuite, cÎté front :

// front const data: PatientDashboardData = await fetch("/api/patient/123").then(r => r.json()); return <PatientDashboard data={data} />;

Avec ça, tu as vraiment :

  • 🔗 un contrat strictement alignĂ© Odoo (tous les x_
)
  • 🔁 un adaptateur unique Ă  maintenir si on ajoute des champs
  • 🎹 un front React totalement indĂ©pendant d’Odoo, qui consomme un JSON propre.

Si tu veux, je peux te faire la version NestJS complÚte du odooCall() (service générique JSON-RPC /web/dataset/call_kw), ou une spec OpenAPI pour ton endpoint /api/patient/:id.


schĂ©ma “API backend” pour que ton orchestrateur (n8n / Nest) alimente exactement ce JSON pour le front.

Parfait, on va faire une vraie spec d’API backend pour servir ton PatientDashboardData au front, en restant agnostique : que ce soit Nest qui parle à Odoo, ou n8n qui orchestre derriùre, le front ne voit qu’un joli JSON propre.

Je te donne :

  1. đŸ—ïž Architecture logique
  2. 🌐 Endpoints proposĂ©s
  3. 📩 SchĂ©ma JSON de la rĂ©ponse (strict)
  4. đŸ§Ș Exemple de payload complet
  5. 📄 Extrait OpenAPI 3.0

1ïžâƒŁ Architecture logique

  • Frontend (React / Next)
    → appelle une API REST simple, ex. GET /api/patient-dashboard/:id
  • Backend API (NestJS ou autre)
    → implĂ©mente /api/patient-dashboard
    → appelle Odoo (et Ă©ventuellement n8n) via JSON-RPC / HTTP
    → utilise l’adaptateur mapOdooToDashboard(...) qu’on a dĂ©fini
    → renvoie le JSON propre au front
  • Odoo / n8n
    • Odoo : source de vĂ©ritĂ© (res.partner + x_synergia_geriatrie_entretien)
    • n8n : pour les flows â€œĂ©criture” (entretien IA, alertes, etc.).
      L’API de dashboard, elle, peut taper directement Odoo.

2ïžâƒŁ Endpoints proposĂ©s (backend)

Je te propose un mini-API trĂšs simple :

MéthodeURLDescription
GET/api/patient-dashboard/:idCharger la fiche d’un patient par id Odoo
GET/api/patient-dashboard/by-external/:refCharger la fiche par x_external_ref
GET/api/patient-dashboard/:id/interviews(optionnel) Liste brute des entretiens

Pour le front, un seul endpoint suffit vraiment : GET /api/patient-dashboard/:id.

3ïžâƒŁ SchĂ©ma JSON de la rĂ©ponse

C’est exactement notre PatientDashboardData :

{ "patient": { "id": "123", "externalRef": "PAT-000123", "fullName": "Jeanne Dupont", "birthDate": "1943-05-12", "age": 82, "gender": "femme", "dependencyLevel": "fragile", "preferredChannel": "voix", "language": "fr" }, "caregiverBlock": { "hasCaregiver": true, "mainCaregiver": { "id": "456", "fullName": "Paul Dupont", "relation": "fils", "phone": "+33 6 12 34 56 78", "email": "paul.dupont@example.com" }, "relatives": [ { "fullName": "Marie Dupont", "relation": "fille", "phone": "+33 6 98 76 54 32", "email": "marie.dupont@example.com" } ], "caregiverDistress": { "isInDifficulty": true, "level": "elevee", "freeText": "Je me sens dĂ©passé ", "supportNeeds": "Besoin d’aide Ă  domicile et de rĂ©pit." } }, "geriatricsBlock": { "lastInterviewAt": "2025-11-27T14:25:00Z", "globalFragilityScore": 68, "globalRiskLevel": "eleve", "mobilityLevel": "canne", "cognitiveIssuesKnown": true, "detailedScores": { "autonomy": 55, "cognitive": 40, "falls": 80, "nutrition": 60, "mood": 65 }, "alerts": ["chute", "isolement", "oubli traitement"], "alertsLabel": "chute ; isolement ; oubli traitement", "recommendations": [ "AmĂ©nagement salle de bain", "Visite infirmiĂšre hebdomadaire", "Bilan gĂ©riatrique spĂ©cialisĂ©" ], "interviewsSummary": [ { "id": "itw-1", "date": "2025-10-10T10:00:00Z", "globalScore": 62, "riskLevel": "modere" }, { "id": "itw-2", "date": "2025-08-01T09:30:00Z", "globalScore": 55, "riskLevel": "modere" } ] } }

Erreur standard (simple)

En cas d’erreur (patient introuvable, problùme Odoo, etc.) :

{ "error": { "code": "PATIENT_NOT_FOUND", "message": "Aucun patient trouvé avec cet identifiant." } }

4ïžâƒŁ SpĂ©cification “API backend” (logique)

4.1. Endpoint principal

GET /api/patient-dashboard/:id

  • Path params :
    • id : string — identifiant interne Odoo (res.partner.id)
  • RĂ©ponse 200 :
    Objet PatientDashboardData (voir JSON ci-dessus).
  • RĂ©ponse 404 :

{ "error": { "code": "PATIENT_NOT_FOUND", "message": "Aucun patient trouvé." } }

4.2. Endpoint alternatif par externalRef

GET /api/patient-dashboard/by-external/:ref

  • Path params :
    • ref : string — x_external_ref du patient
  • Comportement :
    • Le backend fait un search_read Odoo sur res.partner avec x_external_ref = :ref
    • S’il trouve un partner, il appelle la mĂȘme logique que pour /api/patient-dashboard/:id
    • Sinon, 404.

4.3. (Optionnel) Endpoint “interviews only”

GET /api/patient-dashboard/:id/interviews

  • Retourne juste :

{ "patientId": "123", "interviews": [ { "id": "itw-1", "date": "2025-10-10T10:00:00Z", "globalScore": 62, "riskLevel": "modere" } ] }

5ïžâƒŁ Extrait OpenAPI 3.0 (Ă  coller dans Notion / YAML)

openapi: 3.0.3 info: title: Synergia Senior – Patient Dashboard API version: "1.0.0" paths: /api/patient-dashboard/{id}: get: summary: RĂ©cupĂ©rer le dashboard patient par ID Odoo parameters: - in: path name: id required: true schema: type: string description: Identifiant interne Odoo (res.partner.id) responses: "200": description: Dashboard patient content: application/json: schema: $ref: "#/components/schemas/PatientDashboardData" "404": description: Patient introuvable content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" /api/patient-dashboard/by-external/{ref}: get: summary: RĂ©cupĂ©rer le dashboard patient par identifiant externe Synergia parameters: - in: path name: ref required: true schema: type: string description: Identifiant externe (x_external_ref) responses: "200": description: Dashboard patient content: application/json: schema: $ref: "#/components/schemas/PatientDashboardData" "404": description: Patient introuvable content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" components: schemas: PatientDashboardData: type: object properties: patient: type: object properties: id: { type: string } externalRef: { type: string } fullName: { type: string } birthDate: { type: string, format: date } age: { type: integer } gender: type: string enum: [homme, femme, autre, inconnu] dependencyLevel: type: string enum: [autonome, fragile, dependant, inconnu] preferredChannel: type: string enum: [voix, sms, email, whatsapp, app, inconnu] language: { type: string } required: [id, fullName] caregiverBlock: type: object nullable: true properties: hasCaregiver: { type: boolean } mainCaregiver: type: object nullable: true properties: id: { type: string } fullName: { type: string } relation: { type: string } phone: { type: string } email: { type: string } relatives: type: array items: type: object properties: fullName: { type: string } relation: { type: string } phone: { type: string } email: { type: string } caregiverDistress: type: object nullable: true properties: isInDifficulty: { type: boolean } level: type: string enum: [faible, moderee, elevee, inconnu] freeText: { type: string } supportNeeds: { type: string } geriatricsBlock: type: object properties: lastInterviewAt: { type: string, format: date-time } globalFragilityScore: { type: integer, minimum: 0, maximum: 100 } globalRiskLevel: type: string enum: [faible, modere, eleve, inconnu] mobilityLevel: type: string enum: [autonome, canne, deambulateur, fauteuil, alitE, inconnu] cognitiveIssuesKnown: { type: boolean } detailedScores: type: object properties: autonomy: { type: integer, minimum: 0, maximum: 100 } cognitive: { type: integer, minimum: 0, maximum: 100 } falls: { type: integer, minimum: 0, maximum: 100 } nutrition: { type: integer, minimum: 0, maximum: 100 } mood: { type: integer, minimum: 0, maximum: 100 } alerts: type: array items: { type: string } alertsLabel: { type: string } recommendations: type: array items: { type: string } interviewsSummary: type: array items: type: object properties: id: { type: string } date: { type: string, format: date-time } globalScore: { type: integer } riskLevel: type: string enum: [faible, modere, eleve, inconnu] ErrorResponse: type: object properties: error: type: object properties: code: { type: string } message: { type: string } required: [error]

Comment ton orchestrateur s’y branche

  • Nest :
    • Controller /api/patient-dashboard
    • Service qui appelle Odoo JSON-RPC, puis mapOdooToDashboard(...)
    • Renvoie PatientDashboardData
  • n8n (si tu veux l’utiliser comme façade) :
    • Webhook /api/patient-dashboard/:id
    • Nodes HTTP → Odoo + Function → mapOdooToDashboard (version JS)
    • RĂ©ponse JSON au front

controller NestJS complet (avec DTO) qui implĂ©mente exactement ce schĂ©ma, ou le flow n8n “API façade” qui sert ce JSON au front

On va partir sur NestJS complet 👍

Je te donne :

  • les DTO (rĂ©ponse + erreur)
  • le controller avec 2 endpoints
  • un service PatientDashboardService
  • un client Odoo gĂ©nĂ©rique pour call_kw
  • l’intĂ©gration de mapOdooToDashboard(...) qu’on a dĂ©jĂ  dĂ©fini

Tu peux quasi copié-coller dans un projet Nest.

1ïžâƒŁ DTO & types (src/patient-dashboard/dto/*.ts)

a) DTO de rĂ©ponse – patient-dashboard-response.dto.ts

// src/patient-dashboard/dto/patient-dashboard-response.dto.ts import { ApiProperty } from '@nestjs/swagger'; export type RiskLevel = 'faible' | 'modere' | 'eleve' | 'inconnu'; export class PatientInfoDto { @ApiProperty() id: string; @ApiProperty({ required: false }) externalRef?: string; @ApiProperty() fullName: string; @ApiProperty({ required: false, format: 'date' }) birthDate?: string; @ApiProperty({ required: false }) age?: number; @ApiProperty({ required: false, enum: ['homme', 'femme', 'autre', 'inconnu'], }) gender?: 'homme' | 'femme' | 'autre' | 'inconnu'; @ApiProperty({ required: false, enum: ['autonome', 'fragile', 'dependant', 'inconnu'], }) dependencyLevel?: 'autonome' | 'fragile' | 'dependant' | 'inconnu'; @ApiProperty({ required: false, enum: ['voix', 'sms', 'email', 'whatsapp', 'app', 'inconnu'], }) preferredChannel?: 'voix' | 'sms' | 'email' | 'whatsapp' | 'app' | 'inconnu'; @ApiProperty({ required: false }) language?: string; } export class CaregiverPersonDto { @ApiProperty({ required: false }) id?: string; @ApiProperty({ required: false }) fullName?: string; @ApiProperty({ required: false }) relation?: string; @ApiProperty({ required: false }) phone?: string; @ApiProperty({ required: false }) email?: string; } export class CaregiverDistressDto { @ApiProperty({ required: false }) isInDifficulty?: boolean; @ApiProperty({ required: false, enum: ['faible', 'moderee', 'elevee', 'inconnu'], }) level?: 'faible' | 'moderee' | 'elevee' | 'inconnu'; @ApiProperty({ required: false }) freeText?: string; @ApiProperty({ required: false }) supportNeeds?: string; } export class CaregiverBlockDto { @ApiProperty({ required: false }) hasCaregiver?: boolean; @ApiProperty({ type: () => CaregiverPersonDto, required: false }) mainCaregiver?: CaregiverPersonDto; @ApiProperty({ type: () => [CaregiverPersonDto], required: false }) relatives?: CaregiverPersonDto[]; @ApiProperty({ type: () => CaregiverDistressDto, required: false }) caregiverDistress?: CaregiverDistressDto; } export class GeriatricScoresDto { @ApiProperty({ required: false }) autonomy?: number; @ApiProperty({ required: false }) cognitive?: number; @ApiProperty({ required: false }) falls?: number; @ApiProperty({ required: false }) nutrition?: number; @ApiProperty({ required: false }) mood?: number; } export class InterviewSummaryDto { @ApiProperty() id: string; @ApiProperty({ format: 'date-time' }) date: string; @ApiProperty({ required: false }) globalScore?: number; @ApiProperty({ required: false, enum: ['faible', 'modere', 'eleve', 'inconnu'], }) riskLevel?: RiskLevel; } export class GeriatricsBlockDto { @ApiProperty({ required: false, format: 'date-time' }) lastInterviewAt?: string; @ApiProperty({ required: false, minimum: 0, maximum: 100 }) globalFragilityScore?: number; @ApiProperty({ required: false, enum: ['faible', 'modere', 'eleve', 'inconnu'], }) globalRiskLevel?: RiskLevel; @ApiProperty({ required: false, enum: ['autonome', 'canne', 'deambulateur', 'fauteuil', 'alitE', 'inconnu'], }) mobilityLevel?: | 'autonome' | 'canne' | 'deambulateur' | 'fauteuil' | 'alitE' | 'inconnu'; @ApiProperty({ required: false }) cognitiveIssuesKnown?: boolean; @ApiProperty({ type: () => GeriatricScoresDto, required: false }) detailedScores?: GeriatricScoresDto; @ApiProperty({ type: () => [String], required: false }) alerts?: string[]; @ApiProperty({ required: false }) alertsLabel?: string; @ApiProperty({ type: () => [String], required: false }) recommendations?: string[]; @ApiProperty({ type: () => [InterviewSummaryDto], required: false }) interviewsSummary?: InterviewSummaryDto[]; } export class PatientDashboardDataDto { @ApiProperty({ type: () => PatientInfoDto }) patient: PatientInfoDto; @ApiProperty({ type: () => CaregiverBlockDto, required: false }) caregiverBlock?: CaregiverBlockDto; @ApiProperty({ type: () => GeriatricsBlockDto }) geriatricsBlock: GeriatricsBlockDto; } export class ErrorResponseDto { @ApiProperty({ example: { code: 'PATIENT_NOT_FOUND', message: 'Aucun patient trouvé.' }, }) error: { code: string; message: string; }; }

2ïžâƒŁ Client Odoo gĂ©nĂ©rique – odoo.client.ts

// src/odoo/odoo.client.ts import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import axios, { AxiosInstance } from 'axios'; interface OdooJsonRpcParams { model: string; method: string; args: any[]; kwargs?: Record<string, any>; } interface OdooJsonRpcRequest { jsonrpc: '2.0'; method: 'call'; params: OdooJsonRpcParams; } interface OdooJsonRpcResponse<T> { jsonrpc: '2.0'; id?: any; result?: T; error?: { code: number; message: string; data?: any; }; } @Injectable() export class OdooClient { private readonly http: AxiosInstance; constructor() { this.http = axios.create({ baseURL: process.env.ODOO_BASE_URL, // ex: https://xxx.odoo.com headers: { 'Content-Type': 'application/json', }, }); // si tu utilises une API key / auth basique : const apiKey = process.env.ODOO_API_KEY; const db = process.env.ODOO_DB; const login = process.env.ODOO_LOGIN; if (apiKey && db && login) { // Auth Odoo Online typique via header custom ou Basic + context this.http.defaults.auth = { username: `${login}`, password: apiKey, }; } } async call<T>(params: OdooJsonRpcParams): Promise<T> { const payload: OdooJsonRpcRequest = { jsonrpc: '2.0', method: 'call', params, }; try { const res = await this.http.post<OdooJsonRpcResponse<T>>( '/web/dataset/call_kw', payload, ); if (res.data.error) { throw new HttpException( { error: { code: 'ODOO_ERROR', message: res.data.error.message, data: res.data.error.data, }, }, HttpStatus.BAD_GATEWAY, ); } return res.data.result as T; } catch (e: any) { throw new HttpException( { error: { code: 'ODOO_CALL_FAILED', message: e.message ?? 'Erreur lors de l’appel Odoo', }, }, HttpStatus.BAD_GATEWAY, ); } } }

3ïžâƒŁ Types Odoo & adaptateur – (reprend ce qu’on a dĂ©jĂ )

Fichier types Odoo + mapper (je condense, tu peux reprendre la version longue qu’on a Ă©crite) :

// src/patient-dashboard/odoo-types.ts export type OdooMany2One = [number, string] | false | null; export interface OdooPartnerRecord { id: number; name: string; x_external_ref?: string; birthdate_date?: string; x_gender?: 'homme' | 'femme' | 'autre' | 'inconnu'; x_dependency_level?: 'autonome' | 'fragile' | 'dependant' | 'inconnu'; x_preferred_channel?: | 'voix' | 'sms' | 'email' | 'whatsapp' | 'app' | 'inconnu'; x_language?: string; x_has_caregiver?: boolean; x_main_caregiver_id?: OdooMany2One; x_caregiver_relation?: string; x_children_json?: string; x_caregiver_burnout?: boolean; x_caregiver_burnout_level?: 'faible' | 'moderee' | 'elevee' | 'inconnu'; x_caregiver_comments?: string; x_support_needs?: string; x_last_ai_interview?: string; x_last_fragility_score?: number; x_last_risk_level?: 'faible' | 'modere' | 'eleve' | 'inconnu'; x_mobility_level?: | 'autonome' | 'canne' | 'deambulateur' | 'fauteuil' | 'alitE' | 'inconnu'; x_known_cognitive_issue?: boolean; x_score_autonomie?: number; x_score_cognitif?: number; x_score_chute?: number; x_score_nutrition?: number; x_score_moral?: number; x_last_alertes_label?: string; x_last_reco_text?: string; } export interface OdooGeriatrieEntretien { id: number; x_date_entretien?: string; x_fragilite_globale?: number; x_niveau_risque?: 'faible' | 'modere' | 'eleve' | 'inconnu'; }

// src/patient-dashboard/odoo-to-dashboard.mapper.ts import { OdooPartnerRecord, OdooGeriatrieEntretien, OdooMany2One, } from './odoo-types'; import { PatientDashboardDataDto, RiskLevel, } from './dto/patient-dashboard-response.dto'; function unwrapMany2One(m2o?: OdooMany2One): { id?: string; name?: string } { if (!m2o || m2o === false) return {}; const [id, name] = m2o; return { id: String(id), name }; } function computeAge(birthdate?: string): number | undefined { if (!birthdate) return undefined; const d = new Date(birthdate); if (isNaN(d.getTime())) return undefined; const now = new Date(); let age = now.getFullYear() - d.getFullYear(); const m = now.getMonth() - d.getMonth(); if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--; return age; } export function mapOdooToDashboard( partner: OdooPartnerRecord, entretiens: OdooGeriatrieEntretien[], ): PatientDashboardDataDto { const patient: PatientDashboardDataDto['patient'] = { id: String(partner.id), externalRef: partner.x_external_ref, fullName: partner.name, birthDate: partner.birthdate_date, age: computeAge(partner.birthdate_date), gender: partner.x_gender, dependencyLevel: partner.x_dependency_level, preferredChannel: partner.x_preferred_channel, language: partner.x_language, }; const mainCaregiverRaw = unwrapMany2One(partner.x_main_caregiver_id); const mainCaregiver = mainCaregiverRaw.id || mainCaregiverRaw.name ? { id: mainCaregiverRaw.id, fullName: mainCaregiverRaw.name, relation: partner.x_caregiver_relation, } : undefined; let relatives; if (partner.x_children_json) { try { const parsed = JSON.parse(partner.x_children_json) as any[]; relatives = parsed.map((child) => ({ fullName: child.nom ?? child.fullName, relation: child.lien ?? child.relation, phone: child.tel ?? child.phone, email: child.email, })); } catch (e) { console.warn('x_children_json invalide pour partner', partner.id, e); } } const caregiverBlock: PatientDashboardDataDto['caregiverBlock'] = { hasCaregiver: partner.x_has_caregiver, mainCaregiver, relatives, caregiverDistress: { isInDifficulty: partner.x_caregiver_burnout, level: partner.x_caregiver_burnout_level, freeText: partner.x_caregiver_comments, supportNeeds: partner.x_support_needs, }, }; const geriatricsBlock: PatientDashboardDataDto['geriatricsBlock'] = { lastInterviewAt: partner.x_last_ai_interview, globalFragilityScore: partner.x_last_fragility_score, globalRiskLevel: partner.x_last_risk_level as RiskLevel | undefined, mobilityLevel: partner.x_mobility_level, cognitiveIssuesKnown: partner.x_known_cognitive_issue, detailedScores: { autonomy: partner.x_score_autonomie, cognitive: partner.x_score_cognitif, falls: partner.x_score_chute, nutrition: partner.x_score_nutrition, mood: partner.x_score_moral, }, alerts: partner.x_last_alertes_label ? partner.x_last_alertes_label .split(';') .map((s) => s.trim()) .filter(Boolean) : undefined, alertsLabel: partner.x_last_alertes_label, recommendations: partner.x_last_reco_text ? partner.x_last_reco_text .split('\n') .map((s) => s.trim()) .filter(Boolean) : undefined, interviewsSummary: entretiens.map((e) => ({ id: String(e.id), date: e.x_date_entretien || '', globalScore: e.x_fragilite_globale, riskLevel: e.x_niveau_risque as RiskLevel | undefined, })), }; return { patient, caregiverBlock, geriatricsBlock, }; }

4ïžâƒŁ Service – patient-dashboard.service.ts

// src/patient-dashboard/patient-dashboard.service.ts import { Injectable, NotFoundException, } from '@nestjs/common'; import { OdooClient } from '../odoo/odoo.client'; import { OdooPartnerRecord, OdooGeriatrieEntretien, } from './odoo-types'; import { mapOdooToDashboard } from './odoo-to-dashboard.mapper'; import { PatientDashboardDataDto } from './dto/patient-dashboard-response.dto'; @Injectable() export class PatientDashboardService { constructor(private readonly odoo: OdooClient) {} async getById(partnerId: number): Promise<PatientDashboardDataDto> { const partners = await this.odoo.call<OdooPartnerRecord[]>({ model: 'res.partner', method: 'search_read', args: [ [['id', '=', partnerId]], [ 'name', 'birthdate_date', 'x_external_ref', 'x_gender', 'x_dependency_level', 'x_preferred_channel', 'x_language', 'x_has_caregiver', 'x_main_caregiver_id', 'x_caregiver_relation', 'x_children_json', 'x_caregiver_burnout', 'x_caregiver_burnout_level', 'x_caregiver_comments', 'x_support_needs', 'x_last_ai_interview', 'x_last_fragility_score', 'x_last_risk_level', 'x_mobility_level', 'x_known_cognitive_issue', 'x_score_autonomie', 'x_score_cognitif', 'x_score_chute', 'x_score_nutrition', 'x_score_moral', 'x_last_alertes_label', 'x_last_reco_text', ], ], }); if (!partners || partners.length === 0) { throw new NotFoundException({ error: { code: 'PATIENT_NOT_FOUND', message: 'Aucun patient trouvé.', }, }); } const partner = partners[0]; const entretiens = await this.odoo.call<OdooGeriatrieEntretien[]>({ model: 'x_synergia_geriatrie_entretien', method: 'search_read', args: [ [['x_patient_id', '=', partnerId]], ['id', 'x_date_entretien', 'x_fragilite_globale', 'x_niveau_risque'], ], kwargs: { limit: 20, order: 'x_date_entretien desc', }, }); return mapOdooToDashboard(partner, entretiens || []); } async getByExternalRef(externalRef: string): Promise<PatientDashboardDataDto> { const partners = await this.odoo.call<OdooPartnerRecord[]>({ model: 'res.partner', method: 'search_read', args: [ [['x_external_ref', '=', externalRef]], ['id'], // on refait un getById derriÚre pour ne pas dupliquer la logique ], kwargs: { limit: 1 }, }); if (!partners || partners.length === 0) { throw new NotFoundException({ error: { code: 'PATIENT_NOT_FOUND', message: 'Aucun patient trouvé pour cet identifiant externe.', }, }); } return this.getById(partners[0].id); } }

5ïžâƒŁ Controller NestJS – patient-dashboard.controller.ts

// src/patient-dashboard/patient-dashboard.controller.ts import { Controller, Get, Param, ParseIntPipe, } from '@nestjs/common'; import { ApiOkResponse, ApiNotFoundResponse, ApiTags } from '@nestjs/swagger'; import { PatientDashboardService } from './patient-dashboard.service'; import { PatientDashboardDataDto, ErrorResponseDto, } from './dto/patient-dashboard-response.dto'; @ApiTags('Patient Dashboard') @Controller('api/patient-dashboard') export class PatientDashboardController { constructor( private readonly patientDashboardService: PatientDashboardService, ) {} @Get(':id') @ApiOkResponse({ type: PatientDashboardDataDto }) @ApiNotFoundResponse({ type: ErrorResponseDto }) async getById( @Param('id', ParseIntPipe) id: number, ): Promise<PatientDashboardDataDto> { return this.patientDashboardService.getById(id); } @Get('by-external/:ref') @ApiOkResponse({ type: PatientDashboardDataDto }) @ApiNotFoundResponse({ type: ErrorResponseDto }) async getByExternalRef( @Param('ref') ref: string, ): Promise<PatientDashboardDataDto> { return this.patientDashboardService.getByExternalRef(ref); } }

6ïžâƒŁ Module – patient-dashboard.module.ts

// src/patient-dashboard/patient-dashboard.module.ts import { Module } from '@nestjs/common'; import { PatientDashboardController } from './patient-dashboard.controller'; import { PatientDashboardService } from './patient-dashboard.service'; import { OdooClient } from '../odoo/odoo.client'; @Module({ controllers: [PatientDashboardController], providers: [PatientDashboardService, OdooClient], }) export class PatientDashboardModule {}

Avec ça tu as :

  • 🔐 une façade backend propre,
  • 🔗 totalement alignĂ©e sur Odoo (x_
),
  • 🎹 qui sert exactement le JSON attendu par ton <PatientDashboard />.

Les grandes histoires ont une personnalitĂ©. Envisagez de raconter une belle histoire qui donne de la personnalitĂ©. Écrire une histoire avec de la personnalitĂ© pour des clients potentiels aidera Ă  Ă©tablir un lien relationnel. Cela se traduit par de petites spĂ©cificitĂ©s comme le choix des mots ou des phrases. Écrivez de votre point de vue, pas de l'expĂ©rience de quelqu'un d'autre.

Les grandes histoires sont pour tout le monde, mĂȘme lorsqu'elles ne sont Ă©crites que pour une seule personne. Si vous essayez d'Ă©crire en pensant Ă  un public large et gĂ©nĂ©ral, votre histoire sonnera fausse et manquera d'Ă©motion. Personne ne sera intĂ©ressĂ©. Ecrire pour une personne en particulier signifie que si c'est authentique pour l'un, c'est authentique pour le reste.