medOS ultra

Compounding Room (End-to-End)

End-to-end compounding-room system after porting the legacy mix workflow.

8 min read diagramsUpdated 2026-06-09docs/architecture/compounding-room-e2e-system.md

Status: active build (updated 2026-06-09 after porting the legacy mix workflow). Route: /pharmacy/compounding-roomCompoundingRoomPagePharmacyWorkList type="compounding-room". Companions: compounding-room-uat-visual-map.md (19 TOR → visual points), compounding-room-admixture-system.md (configurable-pillars plan).

This is the source-of-truth reference for how a drug order becomes a compounding-room work item, the one central state machine that branches into the sterile-compounding (mix) flow, how that state is mirrored to the doctor and the back office, and the UX that surfaces it. It reconciles the legacy web-legacy implementation (the system of record we ported from) with the new app.


0. Concept

The compounding room prepares medicines that can’t be dispensed off-the-shelf — they must be mixed / compounded under pharmacist control:

Category (compoundingType) TH What it is
iv_admixture IV Admixture drug(s) added to an IV base solution
chemo เคมีบำบัด cytotoxic chemotherapy admixtures (hazardous, USP <800>)
tpn สารอาหารทางหลอดเลือด total parenteral nutrition
compounded ตำรับโรงพยาบาล hospital-formula extemporaneous compounds (MixDrug)

The global standard (USP <797>/<800>, ASHP) rests on a verify → mix → verify double-check chain with named accountability at each step (who checked before, who mixed, who assisted, who checked after) plus ingredient verification and allergy screening. That chain is exactly the legacy workflow we ported (§3) and is the heart of the compounding room.


1. Linkage — how an Order relates to the compounding room

There is no separate “compounding order” entity. A compounding-room item is a MedicationRequest born from an ordinary drug order; the pipeline branches on a routing tag.

Doctor order entry (DialogNewEditOrder2026 / DialogOrder)
        │  POST /v2/medication/orderRequests
        ▼
OrderRequest ──(1..N)──▶ OrderRequestItem        (product, qty, injection[], sigMethod, mixdrug, mixdrugApprovers)
        │  orderRequest.service.createOrderRequest()
        │    • groups items: injection-only vs not, routine vs urgent
        │    • createMedicationRequest(...) — injection group gets isInjection:true
        ▼
MedicationRequest                                (status = the shared state; .compounding subdoc; .compoundingType)
   • back-refs: .orderRequest  +  items[].orderRequestItem      ← the order↔work-item link
   • compoundingType derived: MixDrug→'compounded' | injectable→'iv_admixture' | else undefined
        ▼
/pharmacy/compounding-room  (dynamicTableData COMPOUNDING_ROOM → listIPD, scoped isInjection=true)

Identifier model: one OrderRequest fans out into 1…N MedicationRequests; each MedicationRequest references the order (orderRequest) and each line references its orderRequestItem. The MixDrug recipe + approver chain live on orderRequestItem (mixdrug.ingredients, mixdrug.allergyInfo, mixdrugApprovers).

What tags an order for the room (automatic today):

  • line carries injection[]isInjection:truecompoundingType='iv_admixture'
  • product is a MixDrug (getMixDrugFromProductId) → compoundingType='compounded'
  • else → standard pharmacy

Order-time tagging (Phase 3c, shipped): DialogNewEditOrder2026 now has a “ห้องผสมยา / Compounding” selector (None | IV Admixture | Chemo | TPN | Hospital formula) in the injection block. It sets item.compoundingType (top-level, via updateItemFields) → DialogOrder.onSubmit pushes it → orderRequest.service persists it on OrderRequestItem.compoundingType AND passes the group’s first-tagged value to createMedicationRequest (top-level compoundingType), which takes precedence over the MixDrug/isInjection derivation. So a doctor can now explicitly route chemo/TPN (which are injectable but shouldn’t all read as iv_admixture) to the right branch at order time.


2. Central branching state machine

One enum (MedicationRequestStatus) is the single source of truth. It branches by compoundingType.

STANDARD (oral / non-compounded):

pending ─▶ AUDIT ─▶ reviewing ─▶ accepted ─▶ completed
(Ordered)(Screened)(Prepared) (Verified)  (Dispensed)

COMPOUNDING — faithful to legacy MedicationAction.getNextStatus (8 states; the three mix sub-states are inserted between Prepared and Verified):

pending ─▶ AUDIT ─▶ reviewing ─▶ reviewBeforeMix ─▶ mixdrug ─▶ reviewAfterMix ─▶ accepted ─▶ completed
(Ordered)(Screened)(Prepared) (Check before mix)  (Mixing)  (Check after mix) (Verified)  (Dispensed)

Defined once in platform-api-schema/.../MedicationRequestWorkflow.ts: COMPOUNDING_BRANCH_ORDER, COMPOUNDING_MIX_STATES, MEDICATION_STATUS_LABELS (bilingual), and helpers branchForCompoundingType, nextStatuses, nextForwardStatus, isOnBranchTransition, statusLabel. The frontend mirrors this in web/.../compounding/medicationStatusLabels.ts (web doesn’t import the schema — keep in sync).

Transition guard: advisory and non-blockingupdateMedicationRequestStatus annotates the audit log [off-branch] when a jump leaves the branch, preserving the legacy “change to any status with a reason” power. Strict enforcement is opt-in later.

Correction (2026-06-09): an earlier draft used the audit_before_compounding/compounding/ audit_after_compounding enum members for the compounding branch. The system of record uses reviewBeforeMix/mixdrug/reviewAfterMix and keeps AUDIT+reviewing ahead of the mix steps — the branch order is now corrected to match.


3. The mix step (ported sterile-compounding workflow)

Ported + revamped from web-legacy/.../screen-prescription/{MedicationAction,DialogCheckIngredients}.tsx into CompoundingMixPanel (web/.../compounding/components/CompoundingMixPanel.tsx).

Three mix stages, each captures a named sign-off (orderRequestItem.mixdrugApprovers):

Status Stage Sign-off captured
reviewBeforeMix ตรวจสอบก่อนผสม (Check before mix) auditBeforeCompounding — checker before mix
mixdrug ผสมยา (Mix) compoundingUser (mixer) + compoundingAssistant
reviewAfterMix ตรวจสอบหลังผสมยา (Check after mix) auditAfterCompounding — checker after mix

Each stage:

  1. Ingredient verification — checkbox per ingredient (from mixdrug.ingredients, falling back to the saved admixture formula base+additives). Confirm is gated until all ingredients are checked.
  2. Allergy screening — ingredients matched against mixdrug.allergyInfo (by ingredientId/name) render a red “แพ้ / Allergy” row + a banner.
  3. Sign-off — the stage’s approver(s) selected from the user list.
  4. Advance — Confirm persists the approver chain + advances the status via nextForwardStatus.

The panel is props-driven (sandbox-safe); the worklist wires the data + the persist/advance calls (handleConfirmMix): approver chain → MedicationRequest.compounding.mixApprovers, status → updateMedicationRequestStatus.


4. UX / UI

Worklist (/pharmacy/compounding-room):

  • Category filter row: ทั้งหมด / เคมีบำบัด / TPN / ตำรับ รพ. / IV Admixture.
  • Status sub-tabs (Ordered → Screened → Prepared → Verified → Dispensed) + Ward + Priority + Search.
  • Row actions: คัดกรอง (screen), สูตรผสม (recipe modal), Verify, จ่ายยา, พิมพ์ฉลากยา, เปลี่ยนสถานะ (+reason), Hold.

Recipe modal (สูตรผสมยา) — the compounding workspace, opened from the row:

  • Header chip strip (mirroring): branch (ห้องผสมยา/Compounding) + category + live status (bilingual) — the same medication state the doctor’s order view reflects.
  • Sticky patient header: HN·AN·name·age·sex + editable wt/ht driving a live BSA (Mosteller/Du Bois)
    • diagnosis + coverage (TOR 3, 4).
  • Tab 1 · สูตรผสม / Recipe — formula editor (base + additives), live dose/volume/concentration calc, glassware viz, safety-alert banner (6 classes), Save formula (TOR 6, 10, 11, 14).
  • Tab 2 · ผสมยา / Mixing — the §3 mix step (stepper: before → mix → after; ingredient check; allergy; approver chain; advance) (TOR 7, 10).
  • Tab 3 · ประวัติยา / Past medicationsPatientMedicationHistory (list + compare-visits) (TOR 8).

Verify modal: bill-closed payment gate + out-of-formulary badge (TOR 18).


5. Mirroring — one state, two surfaces

MedicationRequest.status is the single source. Status changes emit rx.prescribed/verified/dispensed → Supabase hospital_events → orchestrator → department_queues → both UIs subscribe. Because both read the same status, mirroring is automatic; the bilingual MEDICATION_STATUS_LABELS (incl. the mix sub-states) render the same branch step on the worklist chip and on the doctor’s OrderSystem.

Pending: import medicationStatusLabel into the doctor-side OrderActivity so the order view shows “กำลังผสมยา / Mixing” etc. (the label map is ready; this is the integration point).


6. Stock (TOR 13/14)

Model exists: StockBalance (item × location × batch → qty, WAC), StockTransaction (stock-reserve | accept-order | stock-issue | …), StockItem, Store.

  • Decrement on dispense already exists: updateMedicationRequestStatus fires STOCK_RESERVE on AUDIT and ACCEPT_ORDER (decrement) on COMPLETED when isStockAdjustment is set.
  • “How much left” read is ready via foundation.stockItems/getByProductId (productId[] + location) → stockBalances/listStockBalanceByItemLocation.
  • Blocked on data: the demo’s drug products mostly have no StockBalance rows, so on-hand renders empty and decrement could throw — needs stock seeding (good-receive) before it’s demonstrable.
  • Actual-prepared-volume decrement needs recipe-ingredient→product linkage (Phase 3 recipe master).

7. Legacy → new port mapping

Legacy (web-legacy) New app Notes
pages/compounding-room/index.tsx (usingPage='compounding-room') PharmacyWorkList type="compounding-room" (usingPage='pharmacy') new flattened to the pharmacy tabs; routing now via isInjection/compoundingType
MedicationAction.getNextStatus (8-state) MedicationRequestWorkflow + getNextCompoundingStatus/nextForwardStatus the canonical sequence, ported
DialogCheckIngredients (ingredient check + 4 approvers + allergy) CompoundingMixPanel (Mixing tab) revamped to a stepper; same data contract
orderRequestItem.mixdrug{.ingredients,.allergyInfo}, .mixdrugApprovers same fields (already on the entity) backend already supports it
updateDrugCheckmedicationRequests/:id/medicationRequestItem same endpoint unchanged

Revamped (not pixel-faithful): the mix step is a single tabbed stepper inside the recipe dialog (instead of click-Save-3-times on a separate screen), and the approver chain currently persists to MedicationRequest.compounding.mixApprovers (reliable) — wiring the canonical orderRequestItem.mixdrugApprovers write is a small refinement.


8. Data model — fields

MedicationRequest (Mongo): status (the machine), compoundingType (branch selector — new), compounding (subdoc: formula, finalVolumeMl, baseVolumeMl, totalAdditiveVolumeMl, durationHr, infusionRateMlPerHr, mixApprovers, savedBy/At — new), statusReason/statusReasonCode (edit reason — new), isInjection, isHAD, items[] (each → orderRequestItem).

OrderRequestItem: mixdrug (→ MixDrug{ name, quantity, unitPrice, mainDrug, supplementaryDrugs, ingredients[] }), mixdrugApprovers, injection[], sigMethod, compoundingType (to add — Phase 3b).

MedicationRequestStatusAuditLog: every transition (old_data/new_data/reason incl. [off-branch]

  • edit reason, 180-day TTL) — the audit spine for TOR 19.

9. Phase tracker

  • Phase 1 — routing spine ✅ deployed + live-tested (compoundingType, isInjection scoping).
  • Phase 2 — central workflow + advisory guard ✅ deployed + live-tested.
  • Phase 3a — mirroring labels ✅ shipped to release.
  • Phase 3b — mix workflow port ✅ corrected 8-state machine + CompoundingMixPanel (ingredient check + allergy + double-check approver chain) as the recipe-dialog Mixing tab.
  • Phase 3c — ordering-side control ✅ this change: DialogNewEditOrder2026 compounding selector + OrderRequestItem.compoundingType (entity + create/update DTOs) + orderRequest.service propagation to createMedicationRequest (group-level, prescriber value overrides derivation).
  • Pending: category sub-filter (chemo/tpn/compounded in the back-office worklist query); stock seeding → on-hand badge wiring + safe decrement; doctor-side OrderActivity label integration; canonical orderRequestItem.mixdrugApprovers write.

10. Invariants

  1. Normal IPD pharmacy (/pharmacy/ipd) is byte-identical — all compounding routing is gated behind the COMPOUNDING_ROOM case / isInjection / compoundingType.
  2. MedicationRequest.status is the single source of truth; both surfaces read it.
  3. compoundingType absent ⇒ standard branch ⇒ today’s behavior (additive/optional).
  4. The order-creation critical path (orderRequest.service) is not modified — routing is derived in createMedicationRequest.
  5. The transition guard is advisory (annotate, don’t block).
  6. Stock is only ever changed through StockTransaction (never a direct balance write).
  7. The mix double-check chain records a named user per stage (no anonymous compounding).
Ask Anything