Compounding Room (End-to-End)
End-to-end compounding-room system after porting the legacy mix workflow.
Status: active build (updated 2026-06-09 after porting the legacy mix workflow).
Route: /pharmacy/compounding-room → CompoundingRoomPage → PharmacyWorkList 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:true→compoundingType='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-blocking — updateMedicationRequestStatus 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_compoundingenum members for the compounding branch. The system of record usesreviewBeforeMix/mixdrug/reviewAfterMixand keepsAUDIT+reviewingahead 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:
- 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. - Allergy screening — ingredients matched against
mixdrug.allergyInfo(byingredientId/name) render a red “แพ้ / Allergy” row + a banner. - Sign-off — the stage’s approver(s) selected from the user list.
- 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 medications —
PatientMedicationHistory(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
medicationStatusLabelinto 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:
updateMedicationRequestStatusfiresSTOCK_RESERVEon AUDIT andACCEPT_ORDER(decrement) on COMPLETED whenisStockAdjustmentis 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
StockBalancerows, 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 |
updateDrugCheck → medicationRequests/: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:
DialogNewEditOrder2026compounding selector +OrderRequestItem.compoundingType(entity + create/update DTOs) +orderRequest.servicepropagation tocreateMedicationRequest(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.mixdrugApproverswrite.
10. Invariants
- Normal IPD pharmacy (
/pharmacy/ipd) is byte-identical — all compounding routing is gated behind theCOMPOUNDING_ROOMcase /isInjection/compoundingType. MedicationRequest.statusis the single source of truth; both surfaces read it.compoundingTypeabsent ⇒ standard branch ⇒ today’s behavior (additive/optional).- The order-creation critical path (
orderRequest.service) is not modified — routing is derived increateMedicationRequest. - The transition guard is advisory (annotate, don’t block).
- Stock is only ever changed through
StockTransaction(never a direct balance write). - The mix double-check chain records a named user per stage (no anonymous compounding).