medOS ultra

Radiation Therapy Procedure Queue

Reference implementation of the treatment-series pattern for RT: gate rules, screening, MD review, LINAC kanban, QR check-in.

38 min read diagramsUpdated 2026-05-17docs/architecture/radiation-therapy-procedure-queue-integration.md

Status: design — current OrderRadiationTherapyMock writes billing + clinical records correctly, but neither surfaces in the operational procedure queue. This doc lays out the ใบ relationships, the missing wiring, and an implementation roadmap.

Related docs:

This is the reference implementation of the Treatment-Series Screening-Gate Pattern. The pattern applies to chemotherapy, hemodialysis, rehab, IVF, TB DOT, MAT, allergy IT, anticoagulation, ANC, wound care, psych injections, and ~6 more domains. Once RT phases 1–3 stabilize, the engine should be pulled out into packages/medical-kit/src/treatment-series-engine/ and bound to the next 2 domains (chemo + HD) to validate the abstraction.


TL;DR

The RT module needs four documents with a configurable 3-outcome screening gate between them:

  1. orderRequest — billing (SalesOrder, N line items × price) ✅ wired
  2. radiationOncology — clinical RT plan + gateRules (jsonb) configured at planning ✅ wired (gateRules to add)
  3. consultRequestone per fraction-day, drives nurse screening + has screeningPayload, gateOutcome ❌ not created today
  4. procedureRequest — actual LINAC session, has sourceConsultRequestId back-reference ❌ not created today

Each fraction-day, the consultRequest gate produces one of three outcomes:

  • AUTO-PASS (all screening values within gateRules thresholds) → procedureRequest auto-created with status='pending', consultRequest → completed-cleared
  • MD REVIEW (any flag tripped: weight loss >5%, toxicity grade ≥2, milestone fraction, day-of-week review) → consultRequest → awaiting-md-review, doctor queue picks it up
  • DEFER (nurse or doctor decision) → no procedureRequest, reason logged, scheduler notified to insert makeup (no auto-shift of downstream fractions)

What’s wired today: orderRequest (billing) + radiationOncology (clinical plan). What’s missing: per-fraction consultRequests + screening UI + gate evaluation + procedureRequest auto-creation + MD review column + defer-with-rescheduling-flag.


ใบ (Form) Relationships — current state

Three separate documents per RT prescription

encounter (clinic=RT Dept, subClinic=LINAC-1)
    │
    ├─► consultRequest      ← drives nurse worklist (rt-wait-check-in, rt-screening, …)
    │       routing: clinicRef + subClinicRef
    │       NOT what we just fixed
    │
    ├─► orderRequest        ← billing wrapper (SalesOrder)
    │       listitems[]: 25 × { qty: 1, uniprice, orderType: 'radiation-therapy' }
    │       Has NO routing — financial only
    │       What OrderRadiationTherapyMock.submitAll() creates as PRIMARY
    │
    └─► radiationOncology   ← clinical RT plan record
            treatmentType: EBRT | BRACHYTHERAPY | IMRT | RESISTANT_CLONE
            workflowStatus: PRE_TREATMENT → SIMULATION → DELIVERY → COMPLETED
            treatmentDate: course start
            Has its own worklist at /radiation-oncology/worklist (separate from procedure-queue!)
            What OrderRadiationTherapyMock.submitAll() creates as SECONDARY

Schema files (read these first)

Document Schema Service
consultRequest packages/platform-api-schema/src/medication/consultRequest/entity/ConsultRequest.ts services/medication/.../consultRequest/consultRequest.service.ts
orderRequest packages/platform-api-schema/src/medication/orderRequest/entity/OrderRequest.ts services/medication/.../orderRequest/orderRequest.service.ts
radiationOncology packages/platform-api-schema/src/medication/radiationOncology/entity/RadiationOncology.ts services/medication/.../radiationOncology/radiationOncology.service.ts
procedureRequest packages/platform-api-schema/src/medication/procedureRequest/entity/ProcedureRequest.ts services/medication/.../procedureRequest/procedureRequest.service.ts

Current Procedure Queue Tab Behaviour

Route: /procedure/procedure-queue?mainTab=procedure-queue&subTab=appointment-made-patient

Tab Filter Status What user can do
wait-consult-patient (รอทำหัตถการ) status=pending Patient checked in, awaiting procedure View details, Make appointment (→ moves to APPOINTMENT), View history, Cancel
appointment-made-patient (นัดล่วงหน้า) status=appointment Appointment booked for future date View details, View history, Cancel — NO check-in action
all-patient (ทั้งหมด) all statuses All procedure requests View only

Status transitions in code:

PENDING → APPOINTMENT  ← user clicks "ทำนัดหมาย" in wait-consult-patient tab
                          via updateProcedureAppointmentStatusApi
APPOINTMENT → PENDING  ← ❌ NO UI for this. Manual SQL only.
PENDING → ACCEPTED     ← worklist starts procedure
ACCEPTED → COMPLETED   ← worklist finishes procedure

Critical gap: There is no “patient arrived for appointment” check-in action. The flow assumes a separate component (ProcedureStatusTab.tsx in work-list/) handles execution, but nothing transitions APPOINTMENT → PENDING when an RT patient actually arrives for their scheduled fraction.

Filter is hardcoded to dental

web/packages/medical-kit/src/procedure/components/procedure-queue/table-procedure-queue/TableWaitConsultPatient.tsx:92:

sortCategory: 'f44f8146-f59d-410e-8ac9-916e4351ea80',  // hardcoded dental UUID

Radiation procedure categories exist in seed but are unused:

  • 4930609e-b9d5-4ab5-8b83-288194224ddd — ‘งานรังสีวิทยา’
  • ad99ba0e-1c62-4506-8901-578e06e0da26 — ‘ภาควิชารังสีวิทยา’

Seed file: services/medication/src/api/medication/seedDB/procedureCategory/data/procedureCategory.seed.json


What Happens After “นัดล่วงหน้า” (current vs target)

Current (broken for RT)

Day 0:  Doctor prescribes 25 fx → orderRequest + radiationOncology created
        ❌ No procedureRequests → procedure-queue is EMPTY

Day 1:  Patient arrives for fraction 1
        ❌ No row exists in any tab
        ❌ LINAC tech has no list of today's patients
        ❌ Therapist must manually look up the plan

Target (after wiring)

Day 0:  Doctor prescribes 25 fx → submitAll() creates:
          - 1 orderRequest    (billing, 25 line items)
          - 1 radiationOncology (clinical plan)
          - 25 procedureRequests, each with appointmentRef.date = scheduledDate[i]
          - All 25 visible immediately in appointment-made-patient tab
            filtered by category='งานรังสีวิทยา'

Day 1:  Patient arrives, scans QR / staff scans wristband
          → API call: updateProcedureAppointmentStatus(today's id, 'pending')
          → Row jumps from appointment-made-patient → wait-consult-patient tab
          → LINAC therapist sees today's queue, in order, with HN/dose/fx#

During treatment:
          → Therapist marks 'accepted' (in progress)
          → After delivery: marks 'completed'
          → procedure-event-log captures actual start/end times
          → Next day's fraction stays in appointment-made-patient

Design Variations — How to gate the procedure on screening

The naive “create 25 procedureRequests upfront” approach skips the RT nurse’s daily screening step. Every fraction day, the patient should be evaluated before being cleared for that day’s treatment:

  • Vital signs — fever, BP, weight loss (may need to delay fraction)
  • Side effects — skin reaction grade, mucositis, fatigue (may need dose modification)
  • NPO status — for prostate/bladder treatments requiring full/empty bladder
  • Contrast/contrast allergy check — for any imaging-guided sessions
  • Concurrent chemo timing — chemo cycle must align with fraction
  • Lab results review — WBC/platelet thresholds for continuation
  • Mark-up integrity — tattoos/skin marks still visible

This screening step is captured by consultRequest flowing through nurse-radiation-oncology-workflow.json: rt-wait-check-in → rt-screening → rt-triage-to-treatment OR rt-triage-to-doctor.

The cockpitAction rt-triage-to-treatment is the “OK, this patient is cleared for today’s fraction” decision. That’s the natural gate for procedureRequest creation/activation.

Three viable design variations:

Most physiologically correct. One consultRequest per fraction-day, procedureRequest auto-created when nurse clicks “ส่งฉายรังสี / Cleared for treatment”.

Day 0 (RT plan signed):
  ├─ 1 orderRequest        (billing — 25 line items)
  ├─ 1 radiationOncology   (clinical plan — 45 Gy / 25 fx)
  └─ 25 consultRequests    (one per scheduled fraction date)
       └─ status: 'pending', appointmentDate: scheduledDate[i]
       └─ workflowState: 'rt-wait-check-in'

Day N (fraction day):
  Patient arrives → consultRequest moves to 'rt-screening'
  Nurse completes screening (vitals, side effects, etc.)
  Nurse clicks "ส่งฉายรังสี" (rt-triage-to-treatment cockpitAction)
       ↓
  Backend handler fires:
       ├─ consultRequest.workflowState → 'rt-treatment-in-progress'
       └─ CREATE procedureRequest {
             category: 'งานรังสีวิทยา',
             status: 'pending',                    // appears in procedure-queue
             encounter, patient, orderRequest,
             items: [{ name: 'VMAT fx 7/25 — Cervix' }],
             scheduledDate: today
           }
       ↓
  LINAC therapist sees new row in /procedure/procedure-queue?subTab=wait-consult-patient
  Starts → completes → procedure done
       ↓
  Backend handler fires:
       └─ consultRequest.workflowState → 'rt-treatment-done'

Alternate branch (nurse sees problem):
  Nurse clicks "ส่งพบแพทย์" (rt-triage-to-doctor)
       ↓
  consultRequest → 'rt-wait-doctor' (no procedureRequest created)
  Doctor sees patient, decides:
    - Cleared → manually trigger rt-triage-to-treatment → procedureRequest created
    - Skip today → consultRequest → 'rt-finished' with skip reason, regen tomorrow's consult

Pros: Every fraction has explicit screening + clearance gate. Skipped fractions logged cleanly. Matches existing nurse workflow JSON without changes.

Cons: Requires backend handler to auto-create procedureRequest on transition. Two visible queues per patient per day (consult worklist AND procedure queue).

Implementation effort: Medium. Backend handler + 1 frontend tweak to consultRequest creation in submitAll().


Variation B — Pre-Created, Screening-Gated (cleanest UI)

All 25 procedureRequests created upfront AND all 25 consultRequests created upfront, but procedureRequest is BLOCKED from progressing until same-day consultRequest reaches a screening-complete state.

Day 0:
  ├─ orderRequest + radiationOncology (as above)
  ├─ 25 consultRequests    (status: pending, appointmentDate: scheduledDate[i])
  └─ 25 procedureRequests  (status: appointment, blocked_actions: ['start'])

Day N:
  Patient arrives → consultRequest → 'rt-screening' → nurse completes
  Nurse clicks "ส่งฉายรังสี" →
       ├─ consultRequest → 'rt-treatment-in-progress'
       └─ procedureRequest unblocks (blocked_actions cleared)
           AND status → 'pending'
  LINAC sees row, starts treatment...

Pros: Full schedule visible upfront (25 rows in นัดล่วงหน้า from day 0). No backend creation handler needed.

Cons: Needs policy_gates integration on procedureRequest. Pre-created procedureRequests for fractions that get skipped/reschedule = cleanup overhead. Bigger DB footprint.

Implementation effort: Higher. Requires procedure_workflow_config gate rules (see procedure-day-queue.md) per radiation department.


Variation C — Auto-Both (fastest, least safe)

Both consultRequest AND procedureRequest auto-created per fraction-day. Screening becomes informational, not gating.

Day 0:
  ├─ orderRequest + radiationOncology
  ├─ 25 consultRequests (informational)
  └─ 25 procedureRequests (status: appointment, no gating)

Day N:
  Patient arrives → either flow:
    A) Nurse screens via consult worklist → marks completed (informational)
    B) Therapist goes straight to procedure queue → checks in → treats

Pros: Simplest. No gates, no handlers. Both queues populated.

Cons: ⚠️ Safety risk — therapist can start a fraction without nurse screening confirming patient is fit. For RT this is dangerous (skin reactions, weight loss, blood counts can mean today’s dose must be modified or cancelled).

Implementation effort: Lowest. Phase 1 from original plan + parallel consultRequest creation.

Recommendation: AVOID for RT. Useful pattern for low-risk modalities (e.g., pre-scheduled lab draws) where no clearance step exists.


Variation D — Hybrid Configurable (most flexible, future-proof)

Per-modality / per-department policy: department admin chooses gate level. Stored in radiation_oncology_session_policies table.

Policy table:
  modality       | screening_required | allow_skip_after_n_fractions
  ---------------|--------------------|------------------------------
  SRS            | always             | never  (single fraction, max safety)
  SBRT           | always             | never  (5 fractions, hypo)
  EBRT_VMAT      | first_5            | after fraction 5  (standard)
  Brachytherapy  | always             | never  (invasive)

If 'always': follows Variation A (consult-first)
If 'first_N': Variation A for first N fractions, then Variation C for rest
If 'never': pure Variation C

Pros: Realistic — real RT departments do skip screening for stable patients on long courses. Lets the department configure their safety policy.

Cons: Most complex. Needs admin UI for policy configuration.

Implementation effort: Highest. Adds policy table + admin UI + per-fraction policy lookup in handler.


The vanilla Variation A (binary nurse decision: clear / defer) is too coarse. RT teams need a third bucket — MD review — for cases where the nurse sees something needing physician judgment but it’s not an outright defer (CBC nadir, weight loss, mid-treatment imaging week). Combined with per-RT-plan configurable thresholds set by the radiation oncologist at planning time, this gives high throughput on stable courses while preserving safety.

This is the canonical design from here on.


State Machine — consultRequest lifecycle

        ┌─────────┐  (Day 0, RT plan signed)
        │ scheduled │  ← one per fraction-day, fractionNumber + parentRTOrderId set
        └─────┬─────┘
              │ check-in (QR scan or staff)
              ▼
        ┌───────────┐
        │checked-in │  ← appears in nurse screening queue
        └─────┬─────┘
              │ nurse opens, completes screeningPayload
              ▼
       ┌────────────┐
       │ screening  │  ← nurse working, can save partial
       └─────┬──────┘
             │ nurse hits "Submit screening"
             │
             │  evaluateGate(screeningPayload, gateRules) → outcome
             │
       ┌─────┴───────────────────┬──────────────┬──────────┐
       ▼                         ▼              ▼          ▼
 AUTO-PASS                 MD REVIEW         DEFER     (nurse escalate)
       │                         │              │          │
       ▼                         ▼              ▼          ▼
┌──────────────────┐    ┌──────────────────┐  ┌────────┐  → routes to MD REVIEW
│completed-cleared │    │awaiting-md-review│  │deferred│
└────────┬─────────┘    └────────┬─────────┘  └────┬───┘
         │                       │                  │
         │ side-effect:          │ doctor opens     │ side-effect:
         │ create procedureRequest│                  │ flag scheduler
         │ sourceConsultRequestId│                  │ to insert makeup
         │ status='pending'      │                  │ fraction
         ▼                       ▼                  ▼
   (LINAC queue)         ┌─────────────┐    (no procedure today)
                         │   3 options  │
                         │ (1) clear    │ ──► completed-cleared → create proc
                         │ (2) modify   │ ──► completed-cleared-modified
                         │              │     create proc with annotation
                         │ (3) defer    │ ──► deferred → flag scheduler
                         └─────────────┘

Status enum values (new — extend ConsultRequestStatus):

  • scheduled (today’s pending, before check-in)
  • checked-in (patient arrived)
  • screening (nurse working, partial save allowed)
  • awaiting-md-review (flag tripped, in doctor queue)
  • completed-cleared (auto-pass or doctor cleared)
  • completed-cleared-modified (doctor cleared with plan annotation)
  • deferred (no procedure today, flag for reschedule)
  • cancelled (e.g. patient withdrew from course)
  • no-show (didn’t check in)

Gate Rules — set at planning time

Stored as radiationOncology.gateRules (jsonb). Radiation oncologist configures at RT plan creation.

type GateRules = {
  // Numeric thresholds — flag MD review if exceeded
  weightLossPctFromBaseline?: number;     // default 5
  toxicityMaxGrade?: number;              // default 1 (flag if grade ≥ 2)

  // Vital-sign bounds (flag if outside)
  systolicBpMaxMmHg?: number;             // default 180
  systolicBpMinMmHg?: number;             // default 90
  heartRateMaxBpm?: number;               // default 110
  heartRateMinBpm?: number;               // default 50
  spO2MinPct?: number;                    // default 92
  tempMaxC?: number;                      // default 38.0

  // Lab thresholds (if labs included in screening payload)
  absoluteNeutrophilCountMin?: number;    // default 1.0 × 10⁹/L
  plateletCountMin?: number;              // default 100 × 10⁹/L
  hemoglobinMin?: number;                 // default 10 g/dL

  // Schedule-based mandatory MD review
  mandatoryReviewFractions?: number[];    // e.g. [10, 20, 30] for mid-treatment
  mandatoryReviewDayOfWeek?: number[];    // e.g. [1] for every Monday (CBC review)
  mandatoryReviewWeeks?: number[];        // e.g. [3, 5] for week 3 and 5 of course

  // Per-range overrides (doctor pre-clearance)
  preClearedFractions?: number[][];       // e.g. [[1, 5]] = always auto-pass fx 1-5
  mandatoryDeferConditions?: string[];    // e.g. ['radiation-pneumonitis-suspected']

  // Modality-specific
  bladderFillingRequired?: boolean;       // prostate — nurse confirms USG bladder vol
  rectalEmptyingRequired?: boolean;       // prostate — nurse confirms
  fastingHoursRequired?: number;          // upper abdomen
  weighInRequired?: boolean;              // default true for >10 fractions
};

Example RT plan gate config (Cervix EBRT 45Gy/25fx + concurrent cisplatin):

{
  "weightLossPctFromBaseline": 5,
  "toxicityMaxGrade": 1,
  "mandatoryReviewFractions": [5, 10, 15, 20],
  "mandatoryReviewDayOfWeek": [1],
  "absoluteNeutrophilCountMin": 1.5,
  "plateletCountMin": 100,
  "weighInRequired": true,
  "preClearedFractions": []
}

Example SBRT 5fx (every fraction is a clinical event):

{
  "toxicityMaxGrade": 0,
  "mandatoryReviewFractions": [1, 2, 3, 4, 5]
}

Screening Payload Schema

Stored as consultRequest.screeningPayload (jsonb). Nurse fills the form.

type ScreeningPayload = {
  // Vitals (all optional — only fields entered are evaluated)
  weightKg?: number;
  weightDeltaPct?: number;                // auto-computed vs baseline at planning
  bpSystolic?: number;
  bpDiastolic?: number;
  heartRate?: number;
  spO2?: number;
  tempC?: number;
  painScore0to10?: number;

  // CTCAE toxicity grades for tracked organs
  toxicities?: Array<{
    organ: string;                        // e.g. 'skin', 'mucositis', 'fatigue'
    grade: number;                        // 0-5
    notes?: string;
  }>;

  // Site-specific
  bladderVolumeMl?: number;               // for prostate
  rectumEmpty?: boolean;
  fastingHoursReported?: number;

  // Labs (if pulled from LIS/manual)
  anc?: number;
  platelets?: number;
  hemoglobin?: number;

  // Free-text
  nurseNotes?: string;
  symptomChecklist?: string[];            // e.g. ['nausea','dysphagia']

  // Nurse override
  nurseEscalateToMd?: boolean;            // ignore auto-pass, force MD review
  escalationReason?: string;
};

Gate Evaluation Function

Pure function — same input always returns same outcome. Lives in shared util so both frontend (preview) and backend (authoritative) can call it.

type GateOutcome =
  | { outcome: 'auto-pass' }
  | { outcome: 'md-review'; reasons: string[] }
  | { outcome: 'defer'; reasons: string[] };

function evaluateGate(
  payload: ScreeningPayload,
  rules: GateRules,
  context: { fractionNumber: number; courseWeek: number; dayOfWeek: number; baselineWeightKg?: number }
): GateOutcome {
  const reasons: string[] = [];

  // Nurse manual escalation always wins
  if (payload.nurseEscalateToMd) {
    return { outcome: 'md-review', reasons: [payload.escalationReason || 'nurse-escalated'] };
  }

  // Pre-cleared fractions short-circuit to auto-pass
  if (rules.preClearedFractions?.some(([from, to]) => context.fractionNumber >= from && context.fractionNumber <= to)) {
    return { outcome: 'auto-pass' };
  }

  // Mandatory MD review based on schedule
  if (rules.mandatoryReviewFractions?.includes(context.fractionNumber)) reasons.push(`mandatory-review-fx-${context.fractionNumber}`);
  if (rules.mandatoryReviewDayOfWeek?.includes(context.dayOfWeek))      reasons.push(`mandatory-review-dow-${context.dayOfWeek}`);
  if (rules.mandatoryReviewWeeks?.includes(context.courseWeek))         reasons.push(`mandatory-review-wk-${context.courseWeek}`);

  // Threshold checks
  if (rules.weightLossPctFromBaseline != null && payload.weightDeltaPct != null) {
    if (Math.abs(payload.weightDeltaPct) >= rules.weightLossPctFromBaseline) reasons.push(`weight-loss-${payload.weightDeltaPct}pct`);
  }
  if (rules.toxicityMaxGrade != null && payload.toxicities) {
    const max = Math.max(...payload.toxicities.map(t => t.grade));
    if (max > rules.toxicityMaxGrade) reasons.push(`toxicity-grade-${max}`);
  }
  if (rules.systolicBpMaxMmHg != null && payload.bpSystolic != null && payload.bpSystolic > rules.systolicBpMaxMmHg) reasons.push(`bp-high-${payload.bpSystolic}`);
  if (rules.systolicBpMinMmHg != null && payload.bpSystolic != null && payload.bpSystolic < rules.systolicBpMinMmHg) reasons.push(`bp-low-${payload.bpSystolic}`);
  if (rules.spO2MinPct != null && payload.spO2 != null && payload.spO2 < rules.spO2MinPct) reasons.push(`spo2-low-${payload.spO2}`);
  if (rules.tempMaxC != null && payload.tempC != null && payload.tempC > rules.tempMaxC) reasons.push(`fever-${payload.tempC}c`);
  if (rules.absoluteNeutrophilCountMin != null && payload.anc != null && payload.anc < rules.absoluteNeutrophilCountMin) reasons.push(`anc-low-${payload.anc}`);
  if (rules.plateletCountMin != null && payload.platelets != null && payload.platelets < rules.plateletCountMin) reasons.push(`plt-low-${payload.platelets}`);

  // Site-specific gates
  if (rules.bladderFillingRequired && payload.bladderVolumeMl == null) reasons.push('bladder-volume-missing');
  if (rules.rectalEmptyingRequired && payload.rectumEmpty !== true) reasons.push('rectum-not-empty');

  if (reasons.length === 0) return { outcome: 'auto-pass' };
  return { outcome: 'md-review', reasons };
}

Defer is never auto-triggered — it’s always an explicit nurse or doctor decision. This is intentional: the system surfaces problems, humans decide whether to skip.


Defer & Rescheduling Behaviour

When a fraction is deferred:

  1. consultRequest → deferred, deferReason stored, deferredBy user logged
  2. Do NOT auto-shift downstream fractions. Radiation oncologists have opinions about overall treatment time for some tumor sites (e.g. cervix HPV+ wants tight schedule). Auto-shifting silently mutates 13+ appointments.
  3. Surface a task in the RT scheduler’s queue: “Patient X — 1 fraction needs rescheduling. Suggested: insert at end (push end-date +1 day) OR within course (replace next no-treatment day)”
  4. Scheduler manually inserts a makeup consultRequest (new row, status scheduled)
  5. RT plan summary now shows 26 consultRequests for a 25-fraction course (the deferred one + the makeup)
  6. Both deferred and makeup link to same radiationOncologyRef, so cumulative-dose math still adds up correctly

Data Model Changes

Mongo (services/medication/…/schema)

consultRequest — extend existing schema:

// New fields:
fractionNumber?: number;                  // 1-25 etc.
totalFractions?: number;                  // 25 (for display)
parentRTOrderId?: Ref<RadiationOncology>; // back-pointer
gateRules?: GateRules;                    // cached at creation from RT plan (immutability)
screeningPayload?: ScreeningPayload;      // nurse-filled
gateOutcome?: GateOutcome;                // result of evaluateGate
gateEvaluatedAt?: Date;
gateEvaluatedBy?: Ref<User>;
deferReason?: string;
deferredBy?: Ref<User>;
makeupForConsultId?: Ref<ConsultRequest>; // if this is a makeup for a deferred fraction

radiationOncology — extend:

gateRules?: GateRules;          // canonical, applied to all generated consultRequests
baselineWeightKg?: number;      // for weightDeltaPct computation
baselineLabs?: { anc?: number; platelets?: number; hemoglobin?: number };

procedureRequest — extend:

sourceConsultRequestId?: Ref<ConsultRequest>;  // back-reference for audit
modificationAnnotation?: string;               // e.g. "reduce dose 10%", "skip boost field"

Supabase read model

Already has consultation_requests and procedure tables. Add:

-- migration: extend consultation_requests
ALTER TABLE consultation_requests
  ADD COLUMN fraction_number INTEGER,
  ADD COLUMN total_fractions INTEGER,
  ADD COLUMN parent_rt_order_id UUID,
  ADD COLUMN screening_payload JSONB,
  ADD COLUMN gate_rules JSONB,
  ADD COLUMN gate_outcome JSONB,
  ADD COLUMN gate_evaluated_at TIMESTAMPTZ,
  ADD COLUMN defer_reason TEXT,
  ADD COLUMN makeup_for_consult_id UUID;

CREATE INDEX idx_consult_rt_today ON consultation_requests (parent_rt_order_id, scheduled_date)
  WHERE parent_rt_order_id IS NOT NULL;

CREATE INDEX idx_consult_md_review_queue ON consultation_requests (clinic_id, status)
  WHERE status = 'awaiting-md-review';

What We’ve Already Built (anchor against)

Built ✅ File Notes
RT order form with billing PRIMARY web/packages/miniapps/order-radiation-therapy-mock/OrderRadiationTherapyMock.tsx submitAll() creates orderRequest + radiationOncology + Supabase read model
Per-fraction date computation Same file — generateSessionSchedule helper concept Already used in treatmentTracks memo for the calendar
Multi-track timeline calendar Same file — TreatmentCalendar component Shows each RT plan as separate color track with continuous bars
Radiation oncology sessions table infrastructure/medbase/migrations/055_radiation_oncology_sessions.sql Has session-level rows, status enum
RT orders read model infrastructure/medbase/migrations/056_radiation_therapy_orders.sql Already linked to patient/encounter
RT cycle tracker UI web/packages/miniapps/radiation-oncology/radiation-therapy-cycle-tracker/RadiationTherapyCycleTracker.tsx Demo-mode fallback works; live mode reads sessions
Demo mode fallback pattern Same file — demoMode toggle Reusable pattern for sandbox testing
RT nurse worklist JSON web/packages/medical-kit/src/medical-worklist/defaults/nurse-radiation-oncology-workflow.json 7 nodes, 7 manifest transitions already defined
Procedure queue UI web/packages/medical-kit/src/procedure/components/procedure-queue/ 3 tabs, hardcoded to dental category
Patient QR self-service pattern services/public-api/.../patientMenu/ + docs/architecture/patient-qr-self-service-pattern.md Reference for RT QR check-in
Gap ❌ What’s needed
gateRules UI on RT plan form New “Screening Gate Rules” accordion in Treatment Order tab of OrderRadiationTherapyMock
Per-fraction consultRequest generation New 4th step in submitAll() after radiationOncology create
Screening form for nurse New miniapp radiation-screening-form (vitals + toxicity + site-specific)
Gate evaluation util web/packages/medical-kit/src/radiation-oncology/gate/evaluateGate.ts (shared frontend + backend via @medos/shared)
Backend hook on consultRequest workflow transition services/medication/.../consultRequest/consultRequest.service.tsafterUpdateWorkflowState
MD review queue UI New tab in RT department worklist + ability to clear/modify/defer with annotation
Procedure queue radiation filter Phase 3 in implementation plan
Defer-with-reschedule UI New scheduler queue task — “1 fraction needs rescheduling for {patient}”
RT plan QR token mint + public route Phase 5

Implementation Plan (Refined — 3-outcome gate)

Phase 1 — Gate Rules UI + Per-fraction consultRequests

Two pieces in one phase because they’re tightly coupled (the gate rules get cached onto each consultRequest at creation).

Files to modify:

  • web/packages/miniapps/order-radiation-therapy-mock/OrderRadiationTherapyMock.tsx
  • web/src/services/ever-administration/consultRequest.service.ts (ensure new fields accepted)

1a. Add Gate Rules accordion to Treatment Order tab

In the existing form (after the Notes textarea), add a collapsible “Screening Gate Rules / กฎเกณฑ์การคัดกรอง” section:

<Accordion sx={{ mt: 2 }}>
  <AccordionSummary>
    <Typography sx={labelSt}>SCREENING GATE RULES / กฎเกณฑ์การคัดกรอง</Typography>
    <Chip size="small" label={`${gateRuleCount(draft.gateRules)} rules`} sx={{ ml: 1 }} />
  </AccordionSummary>
  <AccordionDetails>
    {/* Quick presets */}
    <Stack direction="row" spacing={1} sx={{ mb: 2 }}>
      <Chip label="Default (Standard EBRT)" onClick={() => applyGatePreset('standard')} />
      <Chip label="SBRT (high vigilance)" onClick={() => applyGatePreset('sbrt')} />
      <Chip label="Brachy (procedural)" onClick={() => applyGatePreset('brachy')} />
      <Chip label="Palliative (relaxed)" onClick={() => applyGatePreset('palliative')} />
    </Stack>

    {/* Numeric thresholds */}
    <Grid container spacing={2}>
      <NumField label="Weight loss flag %" value={draft.gateRules.weightLossPctFromBaseline} onChange={...} default={5} />
      <NumField label="Toxicity max grade" value={draft.gateRules.toxicityMaxGrade} onChange={...} default={1} />
      <NumField label="SpO2 min %" value={draft.gateRules.spO2MinPct} default={92} />
      <NumField label="Temp max °C" value={draft.gateRules.tempMaxC} default={38.0} />
      <NumField label="ANC min ×10⁹/L" value={draft.gateRules.absoluteNeutrophilCountMin} default={1.0} />
      <NumField label="Platelets min ×10⁹/L" value={draft.gateRules.plateletCountMin} default={100} />
    </Grid>

    {/* Mandatory review schedule */}
    <ChipMultiSelect label="Mandatory MD-review fractions"
      options={Array.from({length: draft.fractions}, (_, i) => i+1)}
      value={draft.gateRules.mandatoryReviewFractions}
      placeholder="e.g. 10, 20" />

    <ChipMultiSelect label="Mandatory MD-review day(s) of week"
      options={['Mon','Tue','Wed','Thu','Fri']}
      value={draft.gateRules.mandatoryReviewDayOfWeek} />

    {/* Site-specific gates */}
    <Stack direction="row" spacing={2} sx={{ mt: 2 }}>
      <SwitchField label="Bladder filling required (prostate)" />
      <SwitchField label="Rectal emptying required (prostate)" />
      <SwitchField label="Weigh-in required each visit" defaultChecked />
    </Stack>
  </AccordionDetails>
</Accordion>

Persist draft.gateRules alongside other fields, default per modality:

  • EBRT_VMAT / EBRT_IMRT → “standard”
  • EBRT_SBRT / EBRT_SRS → “sbrt”
  • Brachy → “brachy”

1b. Generate consultRequests in submitAll()

Add fourth step after the existing radiationOncology creation:

// === QUATERNARY: Per-fraction consultRequests with cached gateRules ===
for (const o of draftOrders) {
  const sessionDates = generateSessionSchedule(o.startDate, o.fractions, o.fractionsPerWeek);
  const courseStart = new Date(sessionDates[0]);

  for (let fxIdx = 0; fxIdx < sessionDates.length; fxIdx++) {
    const sessionDate = sessionDates[fxIdx];
    const dt = new Date(sessionDate);
    const weekOfCourse = Math.floor(((dt.getTime() - courseStart.getTime()) / 86400000) / 7) + 1;

    try {
      const consultPayload = {
        encounter: encounterId,
        patient: patientId,
        clinic: rtClinicRef,
        subClinic: rtSubClinicRef,
        requester: userId,
        status: 'scheduled',                  // NEW status — pre-checkin
        workflowState: 'rt-wait-check-in',
        appointmentDate: sessionDate,
        priority: 'routine',
        reason: `RT fx ${fxIdx + 1}/${o.fractions}${o.modalityLabel} ${o.targetSite}`,
        // Per-fraction metadata
        fractionNumber: fxIdx + 1,
        totalFractions: o.fractions,
        parentRTOrderId: o.backendId,
        // CACHED gate rules (immutable per consult — if doc changes plan later, old fractions keep old rules)
        gateRules: o.gateRules,
        metadata: {
          radiationOncologyRef: o.backendId,
          orderRequestRef: o.orderRequestId,
          dosePerFraction: o.dosePerFractionGy,
          cumulativeDose: (fxIdx + 1) * o.dosePerFractionGy,
          modality: o.modality,
          targetSite: o.targetSite,
          weekOfCourse,
        },
      };
      await createConsultRequestApi(consultPayload);
      result.consultRequestCount = (result.consultRequestCount || 0) + 1;
    } catch (err) {
      console.warn(`[RT Order] ConsultRequest fx ${fxIdx + 1} failed:`, err);
    }
  }
}

Result after Phase 1:

  • Submitting an RT order creates N consultRequests with cached gateRules
  • Nurse worklist shows fractions as status=scheduled rows, one per date
  • No procedure queue activity yet (Phase 3 wires that)
  • The doctor sees what gate rules will fire by looking at the RT plan

Phase 2 — Screening form + gate evaluation + auto-create on auto-pass

Files to add:

  • web/packages/miniapps/radiation-screening-form/RadiationScreeningForm.tsx — nurse-facing
  • web/packages/medical-kit/src/radiation-oncology/gate/evaluateGate.ts — pure util (shared with backend)
  • web/packages/medical-kit/src/radiation-oncology/gate/gatePresets.ts — the 4 preset configs

Files to modify:

  • services/medication/src/api/medication/modules/consultRequest/consultRequest.service.tssubmitScreening action
  • services/medication/src/api/medication/modules/radiationOncology/ — new event handler onConsultScreeningCleared

2a. Nurse screening form (new miniapp)

When nurse opens an RT consultRequest from the worklist, render the screening form. Fields are conditionally shown based on the cached gateRules:

┌──────────────────────────────────────────────┐
│ Patient: นาง ABC   HN: 12345                 │
│ RT Plan: Cervix EBRT-VMAT 45Gy/25fx          │
│ Today: Fraction 7/25 — Cumulative 12.6 Gy    │
│                                              │
│ ▼ VITALS (always shown)                      │
│   Weight ___ kg (baseline 58.0)              │
│   BP ___/___  HR ___  SpO2 ___  Temp ___     │
│                                              │
│ ▼ TOXICITY (CTCAE grades)                    │
│   Skin: ○ 0 ○ 1 ○ 2 ○ 3                      │
│   Mucositis: ○ 0 ○ 1 ○ 2 ○ 3                 │
│   Fatigue: ○ 0 ○ 1 ○ 2 ○ 3                   │
│   Diarrhea: ○ 0 ○ 1 ○ 2 ○ 3                  │
│                                              │
│ ▼ SITE-SPECIFIC (shown if rules require)     │
│   Bladder volume ___ mL                      │
│   Rectum empty? ○ Yes ○ No                   │
│                                              │
│ ▼ LABS (if available — auto-pulled from LIS) │
│   ANC ___  Platelets ___  Hb ___             │
│                                              │
│ Nurse notes: ____________________________    │
│                                              │
│ ☐ Request MD review (override auto-pass)    │
│   Reason: ______________________             │
│                                              │
│ ┌─────────────────────────────────────────┐  │
│ │ GATE PREVIEW (live as you type)         │  │
│ │ Outcome: AUTO-PASS ✓                    │  │
│ │ No flags tripped                        │  │
│ └─────────────────────────────────────────┘  │
│                                              │
│ [Save Draft]  [Submit Screening]  [Defer]    │
└──────────────────────────────────────────────┘

Live preview calls evaluateGate(payload, consultRequest.gateRules, context) on each field change — nurse sees outcome before submitting.

On “Submit Screening”:

  1. POST consultRequest.submitScreening(id, payload) to backend
  2. Backend re-runs evaluateGate (authoritative) and stores gateOutcome
  3. Backend triggers next state based on outcome:
    • auto-pass → consultRequest → completed-cleared + creates procedureRequest
    • md-review → consultRequest → awaiting-md-review
    • defer (via “Defer” button only) → consultRequest → deferred + scheduler notification

2b. Backend submitScreening action

// services/medication/src/api/medication/modules/consultRequest/consultRequest.service.ts
async submitScreening(consultId: string, payload: ScreeningPayload, userId: string) {
  const consult = await this.findById(consultId);
  if (!consult.gateRules) throw new Error('No gate rules — not an RT consult');

  const baselineWeightKg = await this.getBaselineWeight(consult.parentRTOrderId);
  if (payload.weightKg != null && baselineWeightKg) {
    payload.weightDeltaPct = ((payload.weightKg - baselineWeightKg) / baselineWeightKg) * 100;
  }

  const context = {
    fractionNumber: consult.fractionNumber!,
    courseWeek: consult.metadata?.weekOfCourse ?? 1,
    dayOfWeek: new Date(consult.appointmentDate!).getDay(),
    baselineWeightKg,
  };

  const outcome = evaluateGate(payload, consult.gateRules, context);

  // Update consultRequest
  await this.update(consultId, {
    screeningPayload: payload,
    gateOutcome: outcome,
    gateEvaluatedAt: new Date(),
    gateEvaluatedBy: userId,
    status: outcome.outcome === 'auto-pass' ? 'completed-cleared'
           : outcome.outcome === 'md-review' ? 'awaiting-md-review'
           : 'deferred',
  });

  // Side-effect: auto-create procedureRequest on auto-pass
  if (outcome.outcome === 'auto-pass') {
    await this.createProcedureRequestFromConsult(consult);
  }

  // Side-effect: notify scheduler on defer
  if (outcome.outcome === 'defer') {
    this.broker.emit('rt.fraction.deferred', {
      consultId, patientId: consult.patient, parentRTOrderId: consult.parentRTOrderId,
      reasons: outcome.reasons,
    });
  }

  return { outcome, nextStatus: consult.status };
}

async createProcedureRequestFromConsult(consult: ConsultRequest, annotation?: string) {
  const rtPlan = await this.radiationOncologyService.findById(consult.parentRTOrderId!);
  const proc = await this.procedureRequestService.create({
    orderRequest: consult.metadata?.orderRequestRef,
    encounterRef: consult.encounter,
    patientRef: consult.patient,
    requester: consult.requester,
    category: RADIATION_PROCEDURE_CATEGORY_UUID,
    status: 'pending',
    priority: consult.priority,
    items: [{
      name: `${rtPlan.modalityLabel} fx ${consult.fractionNumber}/${consult.totalFractions}${rtPlan.targetSite}`,
      orderRequestItem: null,
      remark: `Dose: ${consult.metadata.dosePerFraction} Gy${annotation ? ` · MODIFIED: ${annotation}` : ''}`,
    }],
    inOperatingRoom: false,
    performingDoctorRef: rtPlan.doctor,
    scheduledDate: consult.appointmentDate,
    sourceConsultRequestId: consult._id,
    modificationAnnotation: annotation,
  });
  this.broker.emit('manifest.procedure.created', { procedureRequestId: proc._id, fromConsult: consult._id });
  return proc;
}

Defer is a separate button (not an evaluateGate outcome) — clicking “Defer” prompts for reason, sets status to deferred, emits the scheduler event. The nurse decides; the system doesn’t auto-defer.

Phase 3 — MD Review Queue + Doctor Actions

The new column in the workflow for cases where the gate flagged but didn’t defer. Doctor gets a focused queue with screening data + trend visualization.

Files to add:

  • web/packages/miniapps/rt-md-review-queue/RtMdReviewQueue.tsx — new module
  • web/packages/miniapps/rt-md-review-queue/MdReviewDialog.tsx — the action dialog

Files to modify:

  • web/packages/medical-kit/src/medical-worklist/defaults/nurse-radiation-oncology-workflow.json — add rt-md-review node
  • services/medication/src/api/medication/modules/consultRequest/consultRequest.service.tsmdReview action

3a. MD Review queue UI

┌─────────────────────────────────────────────────────────────────┐
│ RT MD REVIEW QUEUE                          5 awaiting review   │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│ Patient        Fx     Flags                Wait    Action       │
│ ─────────────────────────────────────────────────────────────── │
│ นาง ABC HN12345  7/25  toxicity-grade-2     12 min  [Review]    │
│                       skin                                      │
│ นาย XYZ HN23456  10/30 mandatory-review-fx-10  3 min  [Review]    │
│ นาง DEF HN34567  3/5   anc-low-0.8           28 min  [Review]    │
│                       (SBRT — high vigilance)                   │
│ ...                                                             │
└─────────────────────────────────────────────────────────────────┘

3b. MD Review dialog

When doctor clicks “Review”:

┌──────────────────────────────────────────────────────────────────┐
│ MD Review — นาง ABC — Fraction 7/25 (Cervix VMAT)               │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│ ▼ Today's screening                                              │
│   Weight 55.3 kg (baseline 58.0 → -4.7%, below 5% threshold)    │
│   Vitals: BP 122/78  HR 88  SpO2 96  Temp 37.2                  │
│   Toxicities: Skin grade 2 ⚠️                                    │
│                Mucositis grade 1                                 │
│                Fatigue grade 1                                   │
│   Nurse notes: "Skin reaction in posterior pelvis, asks for     │
│                 moisturizer"                                     │
│                                                                  │
│ ▼ Trend (sparklines)                                             │
│   Weight   ──╲──╲──╲     (steady decline weeks 2-3)             │
│   Skin tox ─────╱╱╱╱     (grade 0→1→2 over fx 4-7)              │
│   Fatigue  ──╱──╲──      (variable)                              │
│                                                                  │
│ ▼ Treatment history                                              │
│   Fx 1-6: all cleared (auto-pass)                                │
│   Today's gate fired: toxicity-grade-2 skin                      │
│                                                                  │
│ ▼ Actions                                                        │
│   ┌────────────────────────────────────────────────────────┐    │
│   │ ○ CLEAR — proceed with planned dose 1.8 Gy             │    │
│   │ ○ MODIFY — annotate change:                            │    │
│   │   [ Reduce dose: ___% ]                                │    │
│   │   [ Skip boost field ]                                 │    │
│   │   [ Add bolus ]                                        │    │
│   │   [ Other: _____________________________________ ]     │    │
│   │ ○ DEFER — reschedule fraction                          │    │
│   │   Reason: [ Skin reaction needs healing ___________ ]  │    │
│   │   Recommend resume: [ in 3 days ▼ ]                    │    │
│   └────────────────────────────────────────────────────────┘    │
│                                                                  │
│   [Cancel]  [Add to nursing care plan]  [Submit decision]       │
└──────────────────────────────────────────────────────────────────┘

3c. Backend mdReview action

async mdReview(consultId: string, decision: {
  outcome: 'clear' | 'modify' | 'defer';
  modificationAnnotation?: string;
  deferReason?: string;
  recommendedResumeDate?: string;
}, doctorId: string) {
  const consult = await this.findById(consultId);
  if (consult.status !== 'awaiting-md-review') throw new Error('Not in MD review');

  if (decision.outcome === 'clear') {
    await this.update(consultId, { status: 'completed-cleared', mdReviewedBy: doctorId, mdReviewedAt: new Date() });
    await this.createProcedureRequestFromConsult(consult);
  } else if (decision.outcome === 'modify') {
    await this.update(consultId, {
      status: 'completed-cleared-modified',
      mdReviewedBy: doctorId, mdReviewedAt: new Date(),
      modificationAnnotation: decision.modificationAnnotation,
    });
    await this.createProcedureRequestFromConsult(consult, decision.modificationAnnotation);
  } else {
    await this.update(consultId, {
      status: 'deferred',
      mdReviewedBy: doctorId, mdReviewedAt: new Date(),
      deferReason: decision.deferReason,
      deferredBy: doctorId,
    });
    this.broker.emit('rt.fraction.deferred', {
      consultId, patientId: consult.patient, parentRTOrderId: consult.parentRTOrderId,
      reasons: [decision.deferReason], recommendedResumeDate: decision.recommendedResumeDate,
    });
  }
}

Phase 4 — Add radiation department filter to procedure queue

Files to modify:

  • web/packages/medical-kit/src/procedure/components/procedure-queue/table-procedure-queue/TableWaitConsultPatient.tsx
  • web/packages/medical-kit/src/procedure/components/procedure-queue/MainTabProcedureQueue.tsx

Replace hardcoded dental category with a tab selector:

const PROCEDURE_DEPARTMENTS = [
  { id: 'dental',    uuid: 'f44f8146-f59d-410e-8ac9-916e4351ea80', label: 'ทันตกรรม / Dental' },
  { id: 'radiation', uuid: '4930609e-b9d5-4ab5-8b83-288194224ddd', label: 'รังสีรักษา / Radiation' },
  { id: 'imaging',   uuid: 'ad99ba0e-1c62-4506-8901-578e06e0da26', label: 'รังสีวินิจฉัย / Imaging' },
];

// In TableWaitConsultPatient.tsx:92
sortCategory: selectedDepartment?.uuid || PROCEDURE_DEPARTMENTS[0].uuid,

Add a category selector chip row above the tabs in MainTabProcedureQueue.tsx. Persist selection in sessionStorage (key procedure-queue-dept).

After Phase 1-4 complete: Submit RT order → consultRequests created with gateRules → nurse screens each visit → outcome routes to auto-pass / md-review / defer → procedureRequest auto-created (with optional modification annotation) → LINAC therapist sees it in /procedure/procedure-queue with รังสีรักษา filter.

Phase 5 — Today’s LINAC kanban + Defer-to-Scheduler queue

For LINAC operations: a patient on day 7 of 25 only appears in the procedure queue after the nurse clears them (or after MD review clears them). Two new views:

5a. Today’s LINAC kanban (operations view)

SELECT * FROM procedure_request
WHERE category = '4930609e-b9d5-4ab5-8b83-288194224ddd'
  AND scheduled_date::date = today
ORDER BY priority, created_at  -- order by clearance time

UI: 4-column kanban (added MD-modified column to flag annotation flow)

┌──────────┬──────────┬──────────────┬───────────┐
│ Cleared  │ Modified │  On LINAC    │ Completed │
│ (pending)│ (mod'd)  │  (accepted)  │(completed)│
│          │          │              │           │
│ Auto-pass│ MD       │ Beam-on now  │ Fraction  │
│ from nurse│ annotated│              │ done      │
└──────────┴──────────┴──────────────┴───────────┘

Modified column shows the annotation prominently (“Reduce dose 10%”, “Skip boost field”) so therapist double-checks.

Right-side rail: “Expected later today” — consultRequests still in rt-wait-check-in / rt-screening / awaiting-md-review, with status badges. LINAC team can plan beam time.

5b. Defer-to-Scheduler queue

When a fraction is deferred (nurse or MD), a task appears in the RT scheduler’s queue:

┌──────────────────────────────────────────────────────────┐
│ FRACTIONS NEEDING RESCHEDULE                  3 pending  │
├──────────────────────────────────────────────────────────┤
│ Patient   Course      Deferred Fx   Reason     Recommend │
│ ──────────────────────────────────────────────────────── │
│ นาง ABC  Cervix 25fx fx 7 (today)  skin tox    +3 days  │
│         [Insert makeup at end] [Insert mid-course]       │
│                                                          │
│ นาย XYZ  Lung 30fx   fx 12 (today) CBC nadir   +5 days  │
│         [Insert makeup at end] [Insert mid-course]       │
└──────────────────────────────────────────────────────────┘

Scheduler clicks an option → creates a new consultRequest with status=scheduled, appointmentDate=newDate, makeupForConsultId=deferredId. Existing future fractions are NOT shifted. The course gets 1 extra row, end-date may extend by 1 day.

Phase 6 — QR-driven check-in (transitions consultRequest, not procedureRequest)

Builds on the existing pattern at patient-qr-self-service-pattern.md.

Key correction from earlier draft: QR check-in operates on the consultRequest, not procedureRequest. Patient scanning means “I’ve arrived at the RT department” → consultRequest moves from rt-wait-check-in to rt-screening. The nurse still has to clear them through screening before the LINAC procedureRequest is created.

Two flows:

A. Staff scans patient wristband (kiosk/handheld at RT department entrance):

Scan: patient HN or encounter QR
  → GET /v2/medication/consultRequests?patientHN={hn}
        &clinic=rt-clinic-id
        &workflowState=rt-wait-check-in
        &appointmentDate=today
  → Auto-trigger transition: workflowState='rt-screening'
  → Show toast: "{name} checked in — fraction {n}/{total} ready for screening"
  → Open consult row dialog with vitals form + cumulative dose summary

B. Patient scans personal QR (issued at RT plan creation):

On RT plan creation:
  mintToken({
    scope: 'rt-self-checkin',
    patientId,
    encounterId,
    consultRequestIds: [25 consultRequest IDs, one per fraction],
    radiationOncologyId,
    ttl: courseEndDate
  })
  → QR encodes: https://clinic.app/rt-checkin/{token}
  → Print on patient card; patient keeps for whole RT course

On arrival day:
  Patient scans → public route /rt-checkin/{token}
  Backend resolves token → finds today's matching consultRequest
  Shows: "Welcome {firstName}! Today is fraction {n}/{total}"
  Patient taps "I've arrived"
  → POST /api/rt-checkin/{token}/arrive
  → Backend verifies scope + finds today's consultRequest
  → Updates consultRequest.workflowState='rt-screening'
  → Sets metadata.arrivedAt=now()
  → Response: "Checked in. Queue position: 3. Approximate wait: 15 min."

New backend module (mirror services/public-api/.../patientMenu/):

  • services/public-api/src/api/publicapi/modules/rtCheckin/
    • RtCheckinTokenService.mint({ patientId, encounterId, consultRequestIds, radiationOncologyId, ttl })
    • RtCheckinTokenService.verify(token) → returns context + today’s matching consultRequest
    • Controller:
      • POST /api/rt-checkin/token (auth-required) — mint
      • GET /api/rt-checkin/:token (public) — get today’s context
      • POST /api/rt-checkin/:token/arrive (public) — transition consult

Frontend public route:

  • web/src/routes/PublicRoutes.tsx — register /rt-checkin/:token above auth guard
  • web/pages/rt-checkin/[token].tsx — minimal mobile-friendly UI

Token scope guard:

  • Scope rt-self-checkin ONLY allows transitioning rt-wait-check-in → rt-screening
  • Cannot trigger clinical decisions (treatment clearance, dose modifications)
  • Cannot view other patients’ data even with stolen token (patientId baked in)
  • Token validates appointmentDate === today server-side

Schema considerations (not blocking — Phase 1 works without)

Should radiationOncology have procedureRequestRefs[]?

Recommended yes — bidirectional traceability:

// RadiationOncology.ts addition
@OneToMany('procedureRequest')
procedureRequestRefs?: Ref<ProcedureRequest, Uuid>[];

// ProcedureRequest.ts addition
@ManyToOne('radiationOncology')
radiationOncologyRef?: Ref<RadiationOncology, Uuid>;

Without this, the linkage is implicit (same orderRequest._id, same encounter._id). Workable but harder to query “show me all fractions for this RT plan”.

Should the procedureRequest carry the fraction number?

Two options:

  1. Embed in items[].name"VMAT fx 7/25 — Cervix" (Phase 1 approach)
  2. Add fractionNumber: number field to ProcedureRequestItem — cleaner for sorting/reporting

Option 1 first; migrate to option 2 if reporting requirements warrant it.


Acceptance Tests

After Phase 1 (Gate Rules + per-fraction consultRequests):

  • [ ] Open RT order form → Gate Rules accordion present, preset chips work
  • [ ] Selecting “SBRT” preset auto-fills mandatoryReviewFractions = [1,2,3,4,5]
  • [ ] Submit an RT order with 25 fractions and custom gate rules
  • [ ] Open RT nurse worklist → see 25 consultRequest rows status=‘scheduled’
  • [ ] Each row carries the cached gateRules (verifiable in row detail dialog)
  • [ ] Earliest date today/tomorrow, latest ~5 weeks out, weekends skipped
  • [ ] Filter by appointmentDate=today → see only today’s expected patients
  • [ ] No procedureRequests exist yet (procedure queue still empty)

After Phase 2 (Screening form + gate evaluation):

  • [ ] Patient checks in → consultRequest moves scheduledchecked-in
  • [ ] Nurse opens row → screening form shows site-specific fields per gateRules
  • [ ] Live gate preview updates as nurse types
  • [ ] Submit with all-normal values → outcome=auto-pass → consultRequest → completed-cleared
  • [ ] procedureRequest auto-created with sourceConsultRequestId set
  • [ ] Submit with toxicity grade 2 → outcome=md-review → consultRequest → awaiting-md-review, NO procedureRequest
  • [ ] Click “Defer” → prompt for reason → consultRequest → deferred, scheduler event emitted
  • [ ] Nurse “Request MD review” checkbox forces md-review regardless of payload

After Phase 3 (MD Review queue):

  • [ ] MD review queue shows all awaiting-md-review consultRequests for clinic
  • [ ] Each row shows trip reasons (e.g. toxicity-grade-2)
  • [ ] Doctor opens review dialog → sees vitals + tox + trend sparklines + history
  • [ ] Doctor clicks CLEAR → consultRequest → completed-cleared, procedureRequest created
  • [ ] Doctor clicks MODIFY + annotation → consultRequest → completed-cleared-modified, procedureRequest created with modificationAnnotation
  • [ ] Doctor clicks DEFER + reason + resume date → consultRequest → deferred, scheduler notified with recommendedResumeDate

After Phase 4 (Procedure queue filter):

  • [ ] Open /procedure/procedure-queue → category chip “รังสีรักษา” appears
  • [ ] Click radiation chip → see ONLY radiation procedureRequests (no dental mixed in)
  • [ ] Selection persists across page reloads (sessionStorage)

After Phase 5 (Today’s LINAC kanban + Defer queue):

  • [ ] Today’s kanban shows 4 columns: Cleared / Modified / On LINAC / Completed
  • [ ] Modified column shows annotation prominently
  • [ ] Right rail “Expected later today” lists consultRequests still in rt-wait-check-in / rt-screening / awaiting-md-review
  • [ ] Click “เริ่มฉาย” on Cleared row → row jumps to “On LINAC”
  • [ ] Click “เสร็จสิ้น” → row jumps to “Completed”, consultRequest auto-transitions to rt-treatment-done
  • [ ] Scheduler queue shows deferred fractions with reschedule action buttons
  • [ ] Clicking reschedule creates new consultRequest with makeupForConsultId set
  • [ ] Downstream fractions NOT shifted (verified by checking row 8+ dates unchanged)

After Phase 6 (QR check-in):

  • [ ] Mint QR token at RT plan creation → 25-fraction scope token issued
  • [ ] Staff wristband scan at entrance → consultRequest moves scheduledchecked-in
  • [ ] Patient phone scan → public page shows “Welcome, fraction 7/25 today” → tap → checked in
  • [ ] Token with scope=rt-self-checkin cannot trigger any other transition (security guard test)
  • [ ] Token cannot check in for a different patient (patientId baked in)
  • [ ] Token cannot check in on a non-appointment date (server-side date check)
  • [ ] Token rejected after course end date (TTL enforced)

What this does NOT cover

  • Daily delivery console (DDC) — separate UI at /radiation-oncology/ddc for tracking actual LINAC beam delivery. Already exists in RtDailyDeliveryConsole. Phase 3+ should link DDC actions to update the procedureRequest.status and procedure_event_log.
  • Brachytherapy HDR sessions — same flow but uses BRACHYTHERAPY treatmentType. Procedure category may need a sub-category (HDR brachy vs EBRT).
  • SBRT/SRS single-fraction — works as fractions=1 (1 procedureRequest, no schedule loop).
  • Re-treatment after cancellation — if a fraction is cancelled, the workflow should generate a make-up appointment automatically. Out of scope for this doc.
  • Insurance pre-auth gating — would slot into policy_gates (see policy-gates.md) to block check-in until insurance approval recorded.

File index

Modify (Phase 1 — Gate Rules UI + consultRequest generation)

  • web/packages/miniapps/order-radiation-therapy-mock/OrderRadiationTherapyMock.tsx — add Gate Rules accordion + per-fraction consultRequest generation in submitAll()
  • web/src/services/ever-administration/consultRequest.service.ts — accept fractionNumber, totalFractions, parentRTOrderId, gateRules fields

Add (Phase 1 — shared gate engine)

  • web/packages/medical-kit/src/radiation-oncology/gate/evaluateGate.ts — pure gate evaluation
  • web/packages/medical-kit/src/radiation-oncology/gate/gatePresets.ts — 4 presets (standard / sbrt / brachy / palliative)
  • web/packages/medical-kit/src/radiation-oncology/gate/types.tsGateRules, ScreeningPayload, GateOutcome types

Add (Phase 2 — Screening form)

  • web/packages/miniapps/radiation-screening-form/RadiationScreeningForm.tsx — nurse-facing miniapp
  • web/packages/miniapps/radiation-screening-form/SitespecificFields.tsx — conditional fields by modality
  • web/packages/miniapps/radiation-screening-form/GatePreview.tsx — live outcome preview

Modify (Phase 2 — backend)

  • services/medication/src/api/medication/modules/consultRequest/consultRequest.service.ts — add submitScreening, createProcedureRequestFromConsult actions
  • services/medication/src/api/medication/modules/consultRequest/entity/ConsultRequest.ts — extend schema with new fields
  • packages/platform-api-schema/src/medication/consultRequest/entity/ConsultRequest.ts — schema sync
  • packages/platform-api-schema/src/medication/procedureRequest/entity/ProcedureRequest.ts — add sourceConsultRequestId, modificationAnnotation
  • packages/platform-api-schema/src/medication/radiationOncology/entity/RadiationOncology.ts — add gateRules, baselineWeightKg, baselineLabs

Add (Phase 3 — MD Review)

  • web/packages/miniapps/rt-md-review-queue/RtMdReviewQueue.tsx — queue view
  • web/packages/miniapps/rt-md-review-queue/MdReviewDialog.tsx — review + decision dialog
  • services/medication/src/api/medication/modules/consultRequest/consultRequest.service.ts — add mdReview action

Modify (Phase 3 — worklist JSON)

  • web/packages/medical-kit/src/medical-worklist/defaults/nurse-radiation-oncology-workflow.json — add rt-md-review node + transitions
  • web/packages/medical-kit/src/medical-worklist/defaults/radiation-oncologist-workflow.json — NEW file for MD-side worklist

Modify (Phase 4 — procedure queue filter)

  • web/packages/medical-kit/src/procedure/components/procedure-queue/table-procedure-queue/TableWaitConsultPatient.tsx — remove hardcoded dental category, accept sortCategory from selector
  • web/packages/medical-kit/src/procedure/components/procedure-queue/MainTabProcedureQueue.tsx — add department selector chip row, persist in sessionStorage

Add (Phase 5 — Today’s kanban + Defer scheduler queue)

  • web/packages/miniapps/rt-linac-today/RtLinacTodayKanban.tsx — 4-column kanban
  • web/packages/miniapps/rt-scheduler-queue/RtSchedulerQueue.tsx — defer/reschedule queue
  • web/src/services/ever-medication/procedureRequest.service.ts — add startProcedureApi, completeProcedureApi

Add (Phase 6 — QR check-in)

  • services/public-api/src/api/publicapi/modules/rtCheckin/ — new module
    • RtCheckinTokenService.ts — mint/verify
    • rtCheckin.controller.ts — REST endpoints
  • web/src/services/rt-checkin.service.ts — frontend client
  • web/src/routes/PublicRoutes.tsx — register /rt-checkin/:token above auth guard
  • web/pages/rt-checkin/[token].tsx — public mobile-friendly check-in page
  • web/packages/miniapps/order-radiation-therapy-mock/OrderRadiationTherapyMock.tsx — add “Print QR card” action after submit

Supabase migrations (Phase 1-2)

  • infrastructure/medbase/migrations/057_consult_request_rt_extensions.sql — extend consultation_requests table with new RT fields + indexes
  • infrastructure/medbase/migrations/058_radiation_md_review_view.sql — convenience view for MD queue

Reference (don’t modify)

  • infrastructure/medbase/migrations/055_radiation_oncology_sessions.sql — sessions table
  • infrastructure/medbase/migrations/056_radiation_therapy_orders.sql — orders read model
  • services/medication/src/api/medication/seedDB/procedureCategory/data/procedureCategory.seed.json — radiation categories seeded here
  • docs/architecture/patient-qr-self-service-pattern.md — reference pattern for Phase 6

Implementation Order (Refined — 6 phases)

# Phase Effort Verifiable in Depends on
1 Phase 1 — Gate Rules UI + consultRequest generation 4-6h Sandbox + nurse worklist nothing (pure frontend + 1 service tweak)
2 Phase 4 — Procedure queue category filter 1h Sandbox /procedure-queue nothing
3 Phase 2 — Screening form + gate evaluation + auto-create 1-2 days Full clinic (backend deploy) Phase 1 data shape confirmed
4 Phase 3 — MD review queue + doctor actions 1-2 days Full clinic Phase 2 done
5 Phase 5 — Today’s kanban + Defer scheduler queue 1 day Full clinic Phases 2-4 done
6 Phase 6 — QR check-in 2-3 days Full clinic + mobile Phase 2 done (consultRequest schema stable)

Start with Phase 1 — biggest immediate visual win, validates the data shape end-to-end. Phases 1 + 4 together unlock the visual story (rows visible in both worklists). Phase 2 is the architectural keystone. Phases 3, 5, 6 are progressively more ambitious feature layers.

Demo readiness: Phases 1 + 2 + 4 are enough for an end-to-end demo (submit RT order → nurse screens → auto-cleared → therapist sees in procedure queue). Phase 3 is needed for the “this catches problems” story. Phases 5 + 6 are polish.


Resolved decisions

Defer policy — DO NOT auto-shift downstream fractions. Surface “1 fraction needs reschedule” task to scheduler queue (Phase 5b). Scheduler manually inserts a makeup row; existing future fractions unchanged. Both deferred and makeup link to same radiationOncologyRef for cumulative-dose math.

Gate granularity — per-RT-plan configurable (jsonb gateRules stored on radiationOncology, cached onto each consultRequest at creation for immutability). Radiation oncologist sets at planning. Nurse can always escalate manually. Doctor can pre-clear fraction ranges.

Screening outcomes — three values: auto-pass / md-review / defer. Defer is always an explicit human decision, never auto-triggered. evaluateGate() only returns auto-pass or md-review; defer is a button.

MD review path — separate awaiting-md-review consultRequest state + dedicated queue UI + 3-option decision (clear / modify / defer with annotation).

Modification annotations — stored on procedureRequest.modificationAnnotation so LINAC therapist sees the doctor’s plan change at beam-on.

Open questions for the team

  1. Single RT visit billing vs per-fraction billing — current implementation creates ONE orderRequest with N line items at plan signing. Should the auto-create hook in Phase 2 also create per-fraction sub-orderRequests for itemized post-treatment billing? Or keep the single billing doc + just attach delivered-fraction events for revenue cycle?

  2. Holiday calendargenerateSessionSchedule skips weekends but not public holidays. Should it consult a clinic_holidays table at consultRequest creation time? Default: skip weekends only; let scheduler manually defer/reschedule when a fraction lands on a holiday.

  3. LINAC machine assignment — does subClinicRef = specific machine (LINAC-1 vs LINAC-2), or is it just “RT room” with the therapist picking machine at beam-on? Affects:

    • Whether different machines have different consultRequest filters in the nurse worklist
    • Whether the kanban can be filtered per-machine for parallel-track LINAC operations
    • Recommend: start with shared room, add per-machine filter as Phase 7
  4. Concurrent course handling — patient with simultaneous prostate EBRT + bone met palliation has TWO RT plans → 2× the consultRequests per day. UI must distinguish so nurse doesn’t accidentally clear one when meaning the other. Recommend distinct color/badge per radiationOncologyRef in worklist rows, and require nurse to confirm which plan they’re screening for.

  5. Trend visualization data source — MD review dialog shows weight curve + toxicity sparklines + fraction history. Source = sum across all consultRequest.screeningPayload for the parent RT plan. Cache as materialized view, or compute on-demand? Performance matters when patient is 20+ fractions deep.

  6. Baseline data capturebaselineWeightKg and baselineLabs go on the RT plan, but who enters them and when? Options:

    • At RT plan creation in OrderRadiationTherapyMock (one extra form section)
    • Auto-pull from most recent encounter at plan creation
    • At fraction 1 screening (first nurse visit captures baseline)
    • Recommend: auto-pull at plan creation with manual override
  7. Acknowledgement system integration — defer events emit rt.fraction.deferred. Should this also create an AcknowledgementRequest (per docs/architecture/acknowledgement-system.md) to the radiation oncologist? Or is that overkill since the scheduler queue already surfaces it?

Ask Anything