EHRPlus · Production incident audit

Provider identity is out of sync between Odoo and OpenMRS at KAHS

Host kahs (kahs-emr) · audited 26 July 2026 · read-only, nothing was modified

Read-only pass Odoo emr.practitioner — 232 rows OpenMRS — 251 users / 371 providers pgdog in front of ehrplus-odoodb

Per-provider remediation worksheet → · all 232 practitioners with a specific fix each (CSV)

Headline

Wrong-person links
7
Odoo rows pointing at another human's OpenMRS account
Providers unresolved
16 / 119
In-use providers the bridge cannot name
Never synced
23
Active practitioners with no OpenMRS account at all
Duplicate licences
25
NMC numbers shared by 2+ people — these reach insurance claims
Archived but live
6
Archived in Odoo, still able to log in to OpenMRS

How provider information actually flows

Two independent paths write and read provider identity, and they disagree. The write path runs once, at record creation. The read path runs on every printed report.

Write path — Odoo pushes to OpenMRS on create only

Odoo
emr.practitioner
action_synchronize()
create() only
OpenMRS
person · provider · user
SENAITE
lab contact

There is no write() override, so editing a practitioner in Odoo never reaches OpenMRS. Failures in create() are caught and logged, then swallowed — the record saves anyway with a null uuid.

Read path — the bridge asks Odoo to name each provider

OpenMRS DB
encounter_provider ∪ orders.orderer
bridge-api
ProviderProcessor
POST /api/providers/
clinical.py:1152
Report
name · NMC · profession

The whole read path hinges on one line in bahmni_pos/controllers/clinical.py:1166:

Practitioner.search([("provider_uuid", "in", provider_uuids)])

No active_test=False — so archived practitioners silently vanish from reports they signed. No uniqueness guarantee — so one provider uuid can return several different doctors.

Finding 1 — Seven Odoo rows point at the wrong human

Do not run a bulk re-sync

Pressing Synchronize on any of the rows below renames a live OpenMRS account belonging to a different person and overwrites their username — including writing the literal username KAHS onto three of them, which locks those staff out. Any remediation script must skip this set until a human resolves each case.

OpenMRS system_id embeds the Odoo record that created the account (clinical-provider-<odoo_id>-<hash>), which gives an independent provenance trail. Comparing it against the current Odoo row exposes two distinct failure modes.

Odoo id Odoo says OpenMRS account actually is OpenMRS username Cause
7Dr. bipin AgrawalDipendra Jung Shahidipena.jung.shahirow recycled
14Dr. Dirishya BishwakarmaOpendra Kumar Sahopena.kumar.sahrow recycled
19Dr.Suraj P. ranaDr.Kabiraj Poudelkabiraj.poudelrow recycled
39Nabin Kumar MahatSanjiv Sharmasanjiv.sharmarow recycled
52Dr.pujan shresthaAppu Subediappu.subedirow recycled
161Sushma KhadkaDipendra Gharti Mahardipendra.magarcloned from id 149
162Bhagawati RawalDipendra Gharti Mahardipendra.magarcloned from id 149

Mode A — recycled rows (ids 7, 14, 19, 39, 52)

The row was created and synced for person A, then later renamed to person B in Odoo. Because there is no write() sync, OpenMRS kept person A. Two humans now share one row, and the system_id still names the original.

Mode B — cloned identity (ids 149, 161, 162)

uuid, provider_uuid, lab_uid and last_sync_date are declared readonly but not copy=False (bahmni_pos/models/hospital/practitioner.py:81-99). Duplicating a practitioner in the Odoo UI clones the foreign keys, so three Odoo rows — and three SENAITE lab contacts — now resolve to one OpenMRS provider. The two rows have been flip-flopping the account's name since June.

Live evidence. One provider uuid, two different doctors with two different NMC numbers:

$ curl -s -X POST http://localhost:8069/api/providers/ \
    -d '{"providers":["afc895e4-7454-4f13-8feb-775426bc240d"]}'

[{"name":"Dipendra Gharti Mahar","profession":"medical_records_technician",
  "license":"B-14759 Med GM","title":"HA"},
 {"name":"Bhagawati Rawal",     "profession":"healthcare_administrator",
  "license":"B-6322",          "title":"HA"}]

This provider signs live encounters, so any report rendering that visit lists both people as signatories.

Finding 2 — the bridge cannot name 16 of the 119 providers in active use

Provider resolution through POST /api/providers/
All 119 provider uuids that appear in a non-voided encounter_provider row or as an orders.orderer, posted to the live endpoint on kahs.
Resolved to a named practitioner Returns nothing — no name, no NMC
103 resolved 16 unresolved
Clinical volume behind each unresolved provider
Encounters + orders per unresolved provider. The two legacy system accounts (superman, 501,544 records, and LABSYSTEM, 4) are excluded from the scale — they should simply be filtered out of report provider lists.
No Odoo row and no name in OpenMRS Named in Odoo, but archived

The 16 break into three groups:

Group Count Why it fails Fix
Archived in Odoo
Dr Nali Hada — 113 encounters, 245 orders
1 Odoo search() defaults to active=True; the controller never passes active_test=False. Every report she signed loses her name and NMC on reprint. one kwarg
Bahmni-native providers
44-8, 33-1, 34-9, 29-9, …
13 No Odoo row and person_name is NULL in OpenMRS — there is no name to fall back to anywhere. 44-8 alone carries 436 encounters and 607 orders. needs hospital
System accounts
superman, LABSYSTEM
2 Legacy Bahmni service accounts. superman holds 143,422 encounters and 358,122 orders of historical attribution. exclude
Every one of these failures is silent

fetchProviders in bridge-api/src/domains/reports/processors/processor-fetchers.ts:289 wraps the call in try { … } catch { return [] }. If Odoo 500s, or the uuid set is empty (the endpoint answers HTTP 400), the report renders with zero providers and nothing above debug level is logged. A missing doctor and a dead backend look identical.

Finding 3 — data quality on the Odoo side

BucketCountDetail
Never synced, active23 No uuid, no provider, no lab contact. Cannot log in to clinical, cannot be picked as a provider. Splits into 11 duplicates of an already-synced record and 12 genuinely new staff.
Never synced, archived17 Correct end state. No action.
Duplicate usernames13 No unique constraint in Odoo. KAHS/kahs is on 12 rows; arun.upreti on 3. OpenMRS does enforce uniqueness — this is the direct cause of most sync failures.
Username drift8 Odoo and OpenMRS disagree on the login name. OpenMRS is the source of truth here — staff log in with those. e.g. Odoo susmita.pandey vs OpenMRS susmita.pandey10.
Duplicate NMC licences25 Shared by clearly different people, both synced: 20305 = Rajendara Giri and Harihar Adhikari; 19847 = Kushal Joshi and Kamal Raj Joshi; 18466 = Samjhana Shrestha and Samjhana Khatri.
Archived in Odoo, live in OpenMRS6 ids 6, 37, 39, 84, 139, 161. Archiving does not retire anything, and unlink() calls super() before its retire loop — so deletion has never retired an OpenMRS user either.

The licence duplicates are not cosmetic: who_insurance/models/claim/claim.py:3145 puts license_number straight onto the insurance claim as the practitioner's NMC number.

Remediation plan

Phase 0

Guardrails — before touching a single record

~60 lines

This is a prerequisite, not a nice-to-have. Without copy=False and the unique constraint, the Phase 1 data fixes re-corrupt within weeks.

  1. copy=False on uuid, provider_uuid, lab_uid, last_sync_date — stops UI duplication from cloning identities.
  2. _sql_constraints: unique on provider_uuid and uuid (partial, WHERE … IS NOT NULL) plus unique username. The username constraint forces the KAHS×12 problem to surface instead of failing silently.
  3. active_test=False in get_provider_information — archived doctors reappear in reports.
  4. Fix unlink(): iterate before super(), not after.
  5. Fix the selfrecord bug in the SENAITE block (practitioner.py:277-330) — a multi-record sync currently writes the first record's details onto every lab contact.
  6. Add sync_state + sync_error fields, set in the create() except handler. A filterable list of what failed — the lazy alternative to a retry queue.
  7. Log instead of swallow in fetchProviders.

Ships as a normal just deploy + module upgrade. Touches no data.

Phase 1

Machine-decidable data fixes

no human input needed
WhatCountAction
Cloned rows 161, 1622 Clear uuid/provider_uuid/lab_uid, then sync 162 fresh. 161 is archived and superseded by row 232 — leave it. Row 149 keeps the original account untouched.
Active unsynced duplicating a synced record11 Archive ids 13, 17, 22, 23, 40, 48, 71, 104, 111, 138, 143. Confirm each pairing on the worksheet first — several matched on licence only, and the licence field is itself dirty.
Archived in Odoo, live in OpenMRS6 Retire the OpenMRS user and provider. Hold id 39 back — it is in the wrong-person set.
Never synced, archived17 Nothing. Already correct.
Phase 2

Sync the 12 genuinely-new practitioners

no new code

Blocked on two dirty fields, both of which are why they never synced in the first place:

  • Usernames — ids 25, 31 (KAHS) and 54 (kahs) collide. Assign firstname.lastname.
  • Licences — id 41 (Sadikshya) and id 54 (sagar) both claim 35727. One is wrong.

Once filled in, the existing action_synchronize() already does the right thing.

Phase 3

Human verification, then repair

gated on the hospital

Two sets no script can decide:

  • The 5 recycled rows (7, 14, 19, 39, 52). For each: is the Odoo name correct — meaning the account must be split, restoring the row to the original person and creating a new row for the new one — or is the Odoo name simply a typo? Getting this wrong destroys clinical history attribution.
  • The 25 duplicate licence numbers. These are going onto insurance claims today.

Deliverable: one CSV worksheet per set for the records officer, then a scripted apply.

Phase 4

The 13 nameless Bahmni providers

decision required

Either the hospital identifies them and we create Odoo rows that adopt the existing provider_uuid, or we accept them and add a name fallback in ProviderProcessor so reports show something. superman and LABSYSTEM should be excluded from report provider lists either way.

Acceptance test

The same read-only audit used to produce this report doubles as the verification suite:

CheckNowTarget
Wrong-person links (system_id vs Odoo id, and name comparison)70
Provider uuids returning more than one practitioner10
Active practitioners with NULL uuid230
In-use providers resolved through POST /api/providers/103 / 119117 / 119
Duplicate usernames in emr_practitioner130

Target for provider resolution is 117, not 119 — superman and LABSYSTEM are deliberately excluded rather than resolved.

Incidental — ehrplus-odoodb is not actually unhealthy

Odoo connects via HOST=odoodb-dog → pgdog (ehrplus-odoodb-dog) → ehrplus-odoodb, whose Postgres listens on 5433. pgdog is healthy and passing traffic. The container's healthcheck is pg_isready -h localhost -U odoo with no -p 5433, so it probes the wrong port and reports unhealthy forever. Cosmetic, but it is what lights up the fleet health check across the sites running pgdog.