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.
Planning draft — for review
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.
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".
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.
The clinic keeps working in Odoo while MediRec is built. That one decision drives most of the architecture below.
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.updateOrCreate keyed on odoo_id, never insert. Running it twice must be a no-op.write_date on every row. We keep a per-model watermark and pull only what changed. Full re-sync stays available behind a flag.Non-goal: two-way sync. Nothing MediRec does flows back into Odoo. Odoo is upstream until cutover, then it is frozen.
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)
| Method | Purpose |
|---|---|
| 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 |
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.
Q6 answered: no code-level lock. The clinic will be asked to stop using Odoo at cutover, and that is considered sufficient.
That removes is_odoo_managed from Phase 0 and the locked-state UI from every screen that would have rendered it.
The lock was guarding against a problem that barely arises, given the actual shape of the transition:
The window in which a MediRec edit could be silently overwritten is therefore narrow, and it closes completely at the freeze.
"Ask them to stop" is an instruction, not an enforcement. Two things worth doing anyway, neither of which is the lock:
A revoked login is a guarantee; a memo is a hope. Costs nothing, and belongs in the runbook at step 3.
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.
No user-visible features. Everything else depends on this.
| ID | Work | Est (h) |
|---|---|---|
| P0.1 | odoo_id columns + unique indexes across target tables (no lock flag — see above) | 4–6 |
| P0.2 | import_runs table (source, model, timings, watermark, counts, status) + model | 4–6 |
| P0.3 | OdooSource interface + RpcOdooSource + ArrayOdooSource | 10–14 |
| P0.4 | Importer base class — batching, watermarks, dry-run, failure isolation, newer-local-edit guard | 11–16 |
| P0.5 | medirec:import artisan command with --since, --dry-run, --full, --limit | 4–6 |
| P0.6 | Pest harness — fixture-driven importer tests against ArrayOdooSource | 6–8 |
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.
Each includes schema, Livewire UI, capability-method gating, EN + ES keys in both language files, and Pest coverage. Estimates assume that baseline.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Add to consultations, matching the existing insurance_status on exam reports.
Not scheduled. G12 and G13 are effectively unused in Odoo; G11 needs a relevance check first.
Four rules drive the order; everything else is parallelisable.
Foundations. Nothing ships to users; everything else depends on it.
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.
Parallel. W2 comes before the consultation import so 3,167 files land on first pass rather than needing a reconciliation run.
Parallel. The two biggest clinical builds.
W5 gated on discovery, not on engineering.
See below.
Each entity is a separate importer, developed and rehearsed independently.
| Order | Entity | Source | Volume | Est (h) |
|---|---|---|---|---|
| — | ICD codes | already loaded from local catalogs (MED-93) | 82,477 | n/a |
| 2 | Lab catalogs | test types, units | 150 | in W3 |
| 3 | Physicians → users | medical.physician | 34 | 6–8 |
| 4 | Patients | medical.patient | 12,427 | 10–14 |
| 5 | Consultations | consulta.medica | 15,488 | 16–24 |
| 6 | Lab results + lines | medical.lab | 3,077 | 10–14 |
| 7 | Attachments | Laravel Cloud copy (MED-111) | ~5,100 (~14 GB) | 12–18 |
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.tipo_consulta_id, and the 613 consultations with no type set need an explicit rule.CLAUDE.md is explicit that packages aren't added casually. Three will be needed — none before Phase 3.
| Need | Workstream | Note |
|---|---|---|
| PDF generation | W3b | Nothing in composer.json does this today |
| Charting | W5 | package.json has no charting library; server-rendered SVG is a real alternative worth weighing first |
| Postgres driver / dump tooling | Import | Only if we go the dump route rather than RPC |
Q4, Q5, Q5a and Q6 came back on 2026-08-02. Q2 is the only remaining blocker.
| # | Question | Blocks | Status |
|---|---|---|---|
| Q1 | What do staff actually open in Odoo daily? | W5 scope | Open |
| Q2 | What do categories a/b/c/d mean — do they set what a patient pays? | W9b, patient import | Open · urgent |
| Q3 | Is the MINSAL return a legal obligation, and on what cadence? | W4 return format | Open |
| Q4 | Does Odoo stay for anything after cutover? | Runbook step 7 | Answered Goes away entirely |
| Q5 | Do attachments migrate, or stay in Odoo? | W2 scope, import #7 | Answered All of them carry over |
| Q5a | What format are the X-rays — DICOM or flat images? | W2 scope | Answered Ordinary images; no viewer needed |
| Q6 | Is read-only-until-cutover an acceptable ownership policy? | W2 and everything after | Answered 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.
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.
| Workstream | Existing Jira | Note |
|---|---|---|
| W3 Lab module | Epic MED-25 + MED-26…33 | 9 tickets covering ordering, entry, history, models, UI |
| W5 Reporting | Epic MED-69 + MED-70…75 | 7 tickets: patient statistics, visit reports, report builder |
| W2 Attachments | MED-111 | Already scoped, and differently — see W2 |
| Phase 0 source access | MED-104 | This document and the gap analysis are its deliverable |
| W1 CIE-10 | MED-93 | Shipped |
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-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 — 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.
Engineering hours for someone fluent in this codebase. Bars show the low–high range.
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.
est-15 / est-30 / est-45 labels for anything under an hour.