MediRec docs Index Appointment lifecycle Gap analysis Migration plan Clinic questions

Planning draft — for review

Odoo MediRec implementation and migration plan

How each gap gets built and how 12,427 patients, 15,488 consultations and 3,167 files eventually move across. Strategy is dual-run with repeatable sync, which drives most of what follows.

Correction · 2026-08-02

W1 was scoped as though the CIE-10 catalog had to be built from Odoo. It didn't: two importers and both source catalogs shipped under MED-93, and the dev database already holds 82,477 codes. W1 drops from 10–14 h to 6–9 h and changes character entirely — from "build an importer" to "wire it into the seed path and reconcile against Odoo before the consultation import".

Revision · 2026-08-02

Q4, Q5, Q5a and Q6 answered. Odoo is decommissioned after cutover; all 3,167 attachments carry over and the X-rays are ordinary images, so W2 needs no imaging viewer; the read-only lock is dropped in favour of a procedural freeze plus an importer-side conflict guard. Q2 is the only remaining blocker — Q1 and Q3 are open but gate nothing before Phase 4.

260–375 Engineering hours, excluding discovery and review cycles Rough total
12 Workstreams, plus foundations and cutover Scope
1 Question still blocking — Q2, the patient categories Blocking
3 New dependencies needing approval Not before Phase 3

What "dual-run" commits us to

The clinic keeps working in Odoo while MediRec is built. That one decision drives most of the architecture below.

  • Stable external keysAn odoo_id column on each target table — unique, nullable, since MediRec-native rows have none. Kept permanently after cutover; it is the only way to trace a record to its origin or re-run a repair.
  • Idempotent importersEvery run is updateOrCreate keyed on odoo_id, never insert. Running it twice must be a no-op.
  • Incremental by defaultOdoo stamps write_date on every row. We keep a per-model watermark and pull only what changed. Full re-sync stays available behind a flag.
  • Explicit ownershipWhile both systems are live, one owns each record. See below — this is the decision most likely to cause data loss if we get it wrong.
  • Cutover is a repeat, not an eventThe final sync is the same command we'll have run dozens of times, with a shorter delta. That is the entire point of the strategy.

Non-goal: two-way sync. Nothing MediRec does flows back into Odoo. Odoo is upstream until cutover, then it is frozen.

Source access is undecided — so we abstract it

Nothing below assumes a transport. All extraction goes through one interface.

App\Services\Odoo\OdooSource        (interface)
  ├─ RpcOdooSource        — JSON-RPC via /web/dataset/call_kw
  ├─ PostgresOdooSource   — direct read against a dump or replica
  └─ ArrayOdooSource      — fixtures, used by the test suite (no Odoo, no PHI)
MethodPurpose
fetch(model, fields, since, offset, limit)Paged record read, watermark-aware
count(model, since)Progress and reconciliation
fetchBinary(attachmentId)Raw file bytes — may return null on sources that can't serve them

Recommendation — push for a Postgres dump

The argument is weaker than it was. This section originally rested on the attachment binaries being impractical to pull over RPC. MED-111 has since established that Erik already extracted them (~5,100 files, ~14 GB), so the files are no longer a transport problem for us.

What still argues for the dump is the records: 12,427 patients, 15,488 consultations and 3,077 lab rows, all pulled repeatedly under dual-run, from an account that cannot read ir.model and may be subject to record rules we can't see. fetchBinary() stays in the interface for completeness, but the attachment path now most likely reads from the Laravel Cloud copy rather than from Odoo at all.

This is blocked but not blocking. Phase 0 and most feature work proceed without it. The first thing that genuinely needs the decision is the patient import, which is late in the sequence.

Ownership during dual-run — resolved

Q6 answered: no code-level lock. The clinic will be asked to stop using Odoo at cutover, and that is considered sufficient.

The read-only proposal is dropped

That removes is_odoo_managed from Phase 0 and the locked-state UI from every screen that would have rendered it.

Why this holds up

The lock was guarding against a problem that barely arises, given the actual shape of the transition:

  • During the buildThe clinic has to keep using Odoo — MediRec has no lab module and no attachment support yet, so there is no clinical work for staff to do in it. The records MediRec holds are imported copies nobody is editing.
  • MediRec-native featuresScheduling, patient flow and the public request form have no Odoo counterpart, so no sync ever writes over them.
  • At cutoverOdoo use stops and the final delta sync runs. After that there is no upstream at all.

The window in which a MediRec edit could be silently overwritten is therefore narrow, and it closes completely at the freeze.

Residual risk, and a cheaper guard than the lock

"Ask them to stop" is an instruction, not an enforcement. Two things worth doing anyway, neither of which is the lock:

1

Disable Odoo logins at the freeze, rather than only announcing it

A revoked login is a guarantee; a memo is a hope. Costs nothing, and belongs in the runbook at step 3.

2

Make the importer refuse to clobber newer local edits

Before overwriting a row, compare its MediRec updated_at against the timestamp of its last import. If the local copy is newer, skip it and list it in the run summary.

Conflict detection without a conflict UI — roughly 3–4 h on top of the Importer base class. It converts the failure mode from "silent data loss" into "a line in the import report", and since the whole strategy depends on running the sync repeatedly, it is worth having whatever the policy says.

Net effect on the estimate: close to a wash — around 6–9 h saved on the lock and its UI, 3–4 h added for the guard.

Phase 0 — foundations

No user-visible features. Everything else depends on this.

IDWorkEst (h)
P0.1odoo_id columns + unique indexes across target tables (no lock flag — see above)4–6
P0.2import_runs table (source, model, timings, watermark, counts, status) + model4–6
P0.3OdooSource interface + RpcOdooSource + ArrayOdooSource10–14
P0.4Importer base class — batching, watermarks, dry-run, failure isolation, newer-local-edit guard11–16
P0.5medirec:import artisan command with --since, --dry-run, --full, --limit4–6
P0.6Pest harness — fixture-driven importer tests against ArrayOdooSource6–8

PHI constraint on the importer

No patient data in logs, per CLAUDE.md. Import output is counts and IDs only — never field values, never names. Failures record the odoo_id and the exception class, not the payload. Test fixtures are Faker-generated and shaped like Odoo responses; nothing is ever copied from the live instance.

Workstreams

Each includes schema, Livewire UI, capability-method gating, EN + ES keys in both language files, and Pest coverage. Estimates assume that baseline.

W1

CIE-10 catalog

G36–9 h

Corrected 2026-08-02. An earlier draft scoped this as "build the catalog importer, sourced from Odoo, 10–14 h". That work already shipped under MED-93 (Doctor Visit v1.1, READY FOR REVIEW). Two idempotent importers and both source catalogs are in the repo, and the dev database already holds 82,477 rows — 12,687 Spanish, 74,719 English.

W1a · Wire into the seed path — 2–3 h. DatabaseSeeder doesn't call either importer, so migrate:fresh --seed leaves icd_codes empty. Fresh environments and CI get no catalog, which makes diagnosis-picker tests vacuous rather than failing. Add a seeder that runs both commands, and mention them in CLAUDE.md — right now the only documentation is in ingest/*/README.md.

W1b · Reconcile against Odoo — 3–5 h. The one with real risk. Ours is the Mexican SSA/DGE catalog (12,687 terminal codes); Odoo's medical.pathology holds 12,423. Historical consultations reference Odoo's codes, so any code in Odoo but not in ours yields a migrated diagnosis pointing at nothing. Similar counts are not evidence of matching sets. Diff them, then decide per gap: add, map, or accept a null with a recorded reason. Must happen before the consultation import.

W1c · Search performance — 1–2 h, only if needed. 82k rows with LIKE %term% across two description columns will be slow on SQLite. Measure before optimising.

No longer true of this workstream: it is not a useful proving ground for the OdooSource pipeline, because these importers read local files and never touch Odoo. Phase 0 gets its first real exercise at the physicians import instead.

W2

Document attachments

G2MED-11120–28 h

Rescoped 2026-08-02 against MED-111. This workstream was written from my own read of ir.attachment (3,167 rows) and assumed we'd pull the files onto the local private disk. Both assumptions were wrong: Erik has already extracted the documents — ~5,100 files, ~14 GB — and the target is Laravel Cloud, decided 2026-07-21. My 3,167 was a floor, not a total; the read-only account may have been subject to record rules, so reconcile against Erik's export rather than my query.

Scope confirmed by Q5: scanned medical visit forms, lab results, X-rays and other documents. All of them carry over, with no exclusion by type, and they must be available inside MediRec — Odoo is being decommissioned (Q4), so there is no read-only fallback.

MED-111 adds requirements this workstream didn't have: encrypted transfer, access limited to project participants, documented storage location and readership, a verification method against Odoo's counts, and confirmation that Erik's local copy is deleted or encrypted once the authoritative copy is on Laravel Cloud. The ticket is explicit that this is real patient data and needs a human at every step.

Polymorphic attachments table with disk, path, original filename, MIME type, size, checksum, uploader, odoo_id, and soft deletes.

Storage is already correct by default — the local disk roots at storage_path('app/private'), so files are not web-reachable. Serve through a controller that authorizes per record; never a public URL or storage:link.

Add attachment_audit_logs following the existing four-table pattern. Viewing a PHI document is an access event and should be recorded, not just uploads and deletes.

MIME allowlist must cover PDF and the scan formats (JPEG, PNG, TIFF). The size cap has to accommodate radiographs, the largest files in the set — don't inherit a default 2 MB limit and discover it during the import. Malware scanning is out of scope; flag it as a deployment concern rather than pretending it's handled.

Q5a answered — the X-rays are ordinary images, not DICOM. No imaging viewer is needed and the estimate stands: browser-native rendering covers the whole attachment set, so this remains a file feature rather than becoming a medical imaging one. The MIME allowlist above is sufficient as written.

W3

Lab results module

G136–48 h

The largest build — five tables: lab_test_units (106), lab_test_types (44), lab_test_parameters, lab_results (1,585) and lab_result_lines (1,492).

Result lines carry numeric and text values (Odoo has both), unit, lower and upper limits, an is_out_of_range flag computed on save, remark and sequence. Out-of-range values must be visually unmistakable — that is the clinical point of storing limits at all.

Add canManageLabResults() / canViewLabResults() to UserRole; laboratory_technician already exists with nothing behind it. New patient-detail tab, plus recent results surfaced on consultation forms.

PDF export deferred to W3b (8–12 h) — needs a new dependency.

W4

Epidemiological reporting

G412–16 h + 8–16 h

notifiable_conditions seeded with the 91 MINSAL entries, plus a pivot to consultations and a multi-select on all five forms.

Split deliberately. Capture is unambiguous and can ship now; the return format and cadence depend on Q3. Build the data model first so we capture from day one and can generate any format retroactively.

W5

Reporting and dashboards

G54 h + 20–30 h

Currently a placeholder page. Do not scope this from the gap analysis — scope it from watching someone use Odoo for twenty minutes.

Interim safe bets, all of which exist as Odoo pivot views today: consultations by type and period, consultations by provider, patient demographics, lab volume and turnaround.

W6

Consultation-type catalog

G614–20 h

New consultation_types lookup with name ES/EN, mapped AppointmentType, active flag and odoo_id.

Keep AppointmentType as-is. It correctly drives scheduling and provider eligibility. Add consultation_type_id for the finer clinical catalog — two concepts, two columns. Collapsing them would break eligibleProviderRoles().

A hand-curated mapping file resolves the 71 dirty Odoo values onto a clean set. Needs clinic sign-off, not a developer guess.

W7

Prenatal and obstetric follow-up

G716–24 h

patient_pregnancies plus prenatal control visits linked to consultations. Scope from the real CONTROL PRENATAL records rather than Odoo's obstetric models, which are all empty and therefore tell us nothing about actual practice.

W8

Ultrasound

G84 h discovery

Broken on both sides: ~149 consultations, two orphaned report templates, and the medical.ultrasound model no longer exists. Needs a conversation before it needs a schema.

W9

Patient category

G93–4 h + 4–8 h

Split this. Add a category column and preserve the Odoo a/b/c/d value on import immediately — losing it would be irreversible. Reconciling it against calculateConsultationFeeCode() waits for Q2.

The column must land before the first patient import. Everything else about this gap can wait.

W10

Insurance status on consultations

G104–6 h

Add to consultations, matching the existing insurance_status on exam reports.

W11

Deferred

G11 · G12 · G13

Not scheduled. G12 and G13 are effectively unused in Odoo; G11 needs a relevance check first.

Sequencing

Four rules drive the order; everything else is parallelisable.

  1. Phase 0
    P0.1 external IDsP0.2 run trackingP0.3 source P0.4 importer baseP0.5 CLIP0.6 test harness

    Foundations. Nothing ships to users; everything else depends on it.

  2. Phase 1
    W1 CIE-10 wiring + reconcileW9a category column

    W1b must land before the consultation import or migrated diagnoses reference codes we don't hold. W9a must land before any patient import or the category values are lost permanently.

  3. Phase 2
    W2 attachmentsW6 consultation types

    Parallel. W2 comes before the consultation import so 3,167 files land on first pass rather than needing a reconciliation run.

  4. Phase 3
    W3 lab moduleW4a epi capture

    Parallel. The two biggest clinical builds.

  5. Phase 4
    W5 reportingW7 prenatalW10 insuranceW3b lab PDF

    W5 gated on discovery, not on engineering.

  6. Phase 5
    Entity importsReconciliationCutover rehearsal

    See below.

Import and cutover

Each entity is a separate importer, developed and rehearsed independently.

OrderEntitySourceVolumeEst (h)
ICD codesalready loaded from local catalogs (MED-93)82,477n/a
2Lab catalogstest types, units150in W3
3Physicians → usersmedical.physician346–8
4Patientsmedical.patient12,42710–14
5Consultationsconsulta.medica15,48816–24
6Lab results + linesmedical.lab3,07710–14
7AttachmentsLaravel Cloud copy (MED-111)~5,100 (~14 GB)12–18

Known mapping problems

  • No family_abroad_sends_help in Odoo. Null for every migrated patient, silently forcing the lower fee tier. Must be resolved with Q2 before patients import — not after.
  • Sex unset on 44% of patients. Nullable so it imports, but every sex-dependent form path needs a null branch. Audit those before import, not during.
  • 8,590 patients from a 2023 bulk import. Profile for duplicates and test records; decide whether they migrate at all.
  • One Odoo table covers five specialties. The consultation importer routes on tipo_consulta_id, and the 613 consultations with no type set need an explicit rule.
  • Physician → user mapping needs a per-person role decision. It cannot be derived: Odoo has one group for everyone, and MediRec has eleven roles.

Cutover runbook 12–16 h to write and rehearse

  1. Rehearse the full sequence against a staging copy. Repeat until clean — this is the deliverable, not a formality.
  2. Reconciliation report: row counts per entity, per year, both sides. Any variance is a blocker.
  3. Freeze Odoo — announced, scheduled, and logins actually disabled, not merely requested.
  4. Final delta sync. Same command, short delta.
  5. Verify reconciliation, and check the newer-local-edit guard skipped nothing.Any skipped row is a real conflict and needs resolving by hand before proceeding.
  6. Confirm all 3,167 attachments are present and openable, not just row-counted.A file that imported as a zero-byte record reconciles fine and is still lost.
  7. Odoo to read-only for a defined grace period, then decommissioned.Do not decommission until the clinic has signed off on a full month of MediRec.

New dependencies needing approval

CLAUDE.md is explicit that packages aren't added casually. Three will be needed — none before Phase 3.

NeedWorkstreamNote
PDF generationW3bNothing in composer.json does this today
ChartingW5package.json has no charting library; server-rendered SVG is a real alternative worth weighing first
Postgres driver / dump toolingImportOnly if we go the dump route rather than RPC

Blocked on the clinic

Q4, Q5, Q5a and Q6 came back on 2026-08-02. Q2 is the only remaining blocker.

#QuestionBlocksStatus
Q1What do staff actually open in Odoo daily?W5 scopeOpen
Q2What do categories a/b/c/d mean — do they set what a patient pays?W9b, patient importOpen · urgent
Q3Is the MINSAL return a legal obligation, and on what cadence?W4 return formatOpen
Q4Does Odoo stay for anything after cutover?Runbook step 7Answered Goes away entirely
Q5Do attachments migrate, or stay in Odoo?W2 scope, import #7Answered All of them carry over
Q5aWhat format are the X-rays — DICOM or flat images?W2 scopeAnswered Ordinary images; no viewer needed
Q6Is read-only-until-cutover an acceptable ownership policy?W2 and everything afterAnswered No lock; procedural freeze

Q2 is the only item left that can cost real rework. It can invalidate a patient import that otherwise looks completely successful. Q1 and Q3 remain open but gate nothing before Phase 4.

Reconciliation against the existing MED backlog

Checked 2026-08-02 against all 111 MED issues. This plan was written without consulting Jira, and roughly half of it is already ticketed.

Creating W1–W11 as new epics would have duplicated two existing epics and around sixteen existing tickets.

Already ticketed — extend these, don't create new epics

WorkstreamExisting JiraNote
W3 Lab moduleEpic MED-25 + MED-26…339 tickets covering ordering, entry, history, models, UI
W5 ReportingEpic MED-69 + MED-70…757 tickets: patient statistics, visit reports, report builder
W2 AttachmentsMED-111Already scoped, and differently — see W2
Phase 0 source accessMED-104This document and the gap analysis are its deliverable
W1 CIE-10MED-93Shipped

Genuinely new — no ticket exists

W4 epidemiologicalW6 consultation types W7 prenatalW8 ultrasound W9 patient categoryW10 insurance Phase 0 foundationsCutover runbook

Two conflicts worth resolving before any ticket work

MED-51

Billing and Payments — an epic with 9 tickets

Conflict

MED-52…59 cover invoice generation, payment tracking and financial reporting. This document says billing is out of scope because Odoo holds zero invoices, zero payments and zero sales orders. Both can't be right — either the backlog is aspirational post-migration scope, or the clinic bills somewhere neither system can see.

Worth asking before it gets planned as parity work. It is nine tickets of potentially dead scope.

MED-34

Medication and Prescription Management — an epic with 8 tickets

Tension

MED-35…42 include a medication database, prescription PDF export and medication history. This document rates prescriptions as an area where MediRec is ahead of Odoo — true of the data model, but measured against Odoo's HTML blob, not against MED-34's intended scope. Not a contradiction so much as two different yardsticks, but "MediRec ahead" shouldn't be read as "nothing to do here".

MED-110 asks for something this plan does not provide

MED-110 — Draft MediRec MVP scope: Odoo parity + bilingual UI + ICD-10-ES. Erik proposed shipping an MVP at Odoo parity and deferring per-specialty build-out until after the switch; Mantas approved MVP-first on 2026-07-21, with all in-progress improvements and bug fixes in scope.

This plan is not that document. It is a full-scope migration programme at 260–375 h with no MVP cut, no explicit deferral list, and no go-live criteria. MED-110's acceptance — a scope doc agreed by Mantas, Erik and Teri, with deferred items captured as follow-up tickets — is not met by anything written here.

Useful context from MED-110 that should shape the cut: ~40% of patients return, and returners drive ~80% of visits. That argues for prioritising the returning-patient path over first-visit completeness.

Recommendation: derive an MVP cut as a separate short document rather than retrofitting MVP framing into a 400-line programme plan. The material is all here; the selection isn't.

Rough totals

Engineering hours for someone fluent in this codebase. Bars show the low–high range.

Phase 0 foundations39–56
W1 CIE-10 wiring6–9
W2 attachments20–28
W3 lab module + PDF44–60
W4 epidemiological20–32
W5 reporting24–34
W6 consultation types14–20
W7 prenatal16–24
W9 patient category7–12
W10 insurance4–6
Import entities 3–754–78
Cutover + rehearsal12–16
0 h20406080 h
Total260–375

Excludes W8 (ultrasound, undiscovered) and W11 (deferred). Assumes existing conventions hold — bilingual keys in both files, capability-method gating, Pest coverage per workstream, Pint on specific paths. Does not include clinic discovery time, review cycles, or cutover-day coordination.

Suggested Jira shape

  • Epic per workstream (W1–W10), plus one for Phase 0 and one for import/cutover.
  • Story points = estimated hours, per house convention. est-15 / est-30 / est-45 labels for anything under an hour.
  • Q1–Q6 as blocker-linked tasks on the epics they gate, so the dependency is visible on the board rather than living only in this document.