medOS ultra

Blood Bank Policy & Lifecycle (Master)

End-to-end blood-bank reference: blood-bag lifecycle/state-machine, infection gate, list-visibility rules, configurability.

14 min read diagramsUpdated 2026-05-30docs/architecture/blood-bank-policy-and-lifecycle-master.md

Scope. End-to-end reference for the blood-bank subsystem in web/: the blood-bag lifecycle/state-machine, the infection gate (can an infected bag enter คลัง?), “when does a list show up where” (route map + worklist tab filters), and a catalog of every hardcoded rule / policy gate with a proposed configuration layer.

Read alongside:

Verification. The gate logic, state machine, and donor-rules engine cited here were read directly (file:line confirmed). Inventory/return/discard table shapes and bag-type catalog values were mapped by exploration agents against migrations and bagTypes.ts; treat those line numbers as “confirmed file, spot-checked value.” Where a number is clinically standard (35/42/365-day shelf lives) it is doubly trustworthy.


0. The two-sentence answer to the headline question

No — an infected/disqualified bag cannot enter inventory (คลัง). Two independent gates enforce this: a UI navigation gate (handleSaveAndReceive refuses to open the receive page while any visible bag is infected) and a data-layer gate (cascadeInfectionDecisionToBag will only flip a bag to in_stock from ready/separated/received — never from quarantine). The infected bag is instead tagged quarantine (กักกัน) by an explicit officer action, and from there can only be released by an authorized transitionBagStatus({toStatus:'in_stock', releasedBy}) action or destroyed (ทำลายทิ้ง) via a witnessed discard record.


1. Component map — the four you named + neighbours

Component (your name) Real path Role in lifecycle
blood-intake web/src/containers/blood-bank/blood-intake/BloodIntakeSpreadsheet.tsx Bulk bag entry/scan; assigns bag #, bag-type, volume, expiry; captures 6 TTI markers; computes a pass/pending/fail verdict; writes new bags as received.
blood-infection-register web/src/containers/blood-bank/blood-infection-register/BloodInfectionRegisterModule.tsx Per-bag NAT/SERO/Antibody screening worklist; owns the infection gate; explicit รับเข้าคลัง / กักกัน buttons.
blood-list-patient web/packages/diagnostics-kit/src/blood-bank-list-patient/ (route /blood-bank/blood-list-patient) The canonical EPHIS Officer worklist — 8 sub-tabs sliced by BloodRequestStatus.
separate-components-new web/packages/diagnostics-kit/src/blood-bank/components/separate-components-new/ Splits a whole-blood bag into components (PRC/LPRC/FFP/LPPC/SDP/CRYO); parent → separated, children → received.

The rules already live in one place (embryonic policy layer): web/packages/diagnostics-kit/src/blood-bank/rules/

  • bloodDonorRules.ts — donor eligibility engine (block/warn/info).
  • bagTypes.ts — bag-type SKU catalog + component split + shelf-life defaults.

Note for the donor-rules sandbox effort: bloodDonorRules.ts is the real counterpart to the standalone “DonorRulesSandbox” — it already encodes permanent-deferral-on-infection, fever >37.5, donation interval, autologous-relevant donorType, and the 7/16/24/50/100 milestone (เข็มเชิดชูเกียรติ). Future donor-rule work belongs here, not in a parallel module.


2. The blood-bag lifecycle (state machine)

2.1 Status enum (source of truth)

blood_separation_entries.status — CHECK constraint set by migration 20260424_blood_bag_state_expansion.sql:

received · separated · ready · quarantine · in_stock ·
reserved · crossmatched · issued · transfused ·
dispensed(deprecated alias of issued) ·
returned · expired · infectOrImmuno · damaged · destroyed

2.2 Happy path + branches

                          ┌──────────────── DISPOSAL ────────────────┐
                          │                                          │
 received ──▶ separated ──▶ ready ──▶ in_stock ──▶ reserved ──▶ crossmatched ──▶ issued ──▶ transfused
   │  (intake)  (split)        ▲   release  │  reserve      │ XM ok          │ issue       │ ward
   │                           │  (gate)    │               │                │             │
   │                           │            │               ▼                ▼             ▼
   └─ TTI screening ──▶ quarantine ◀────────┘          (cancel→in_stock)   ┌─ returned ──▶ in_stock / quarantine / destroyed
        (infected)         │  │                                            │  (ward return, cascade trigger)
                           │  └─▶ in_stock  (authorized release, releasedBy)
                           ▼
                        destroyed (ทำลายทิ้ง — witnessed discard)            expired (shelf-life; currently no auto-sweep)

2.3 Transition functions (all in web/src/services/medbase/bloodBank.medbase.ts)

Transition Function From-guard Required metadata
screening → quarantine / in_stock cascadeInfectionDecisionToBag (962) release: ready/separated/received; quarantine: +in_stock decision only; fires only on explicit button, never auto-save
quarantinein_stock transitionBagStatus (1113) .eq('status','quarantine') releasedBy
in_stockreserved reserveBagForRequest (1001) .eq('status','in_stock') (concurrency lock) patient + requestId + reservedBy
reservedcrossmatched transitionBagStatus .eq('status','reserved') + same-patient guard patientId + crossmatchResultId
crossmatchedissued transitionBagStatus .eq('status','crossmatched') issuedBy
issuedtransfused transitionBagStatus .eq('status','issued') transfusedBy
emergency in_stock/reservedissued emergencyIssueBag (1184) skips XM justification (non-empty)
issuedreturned/destroyed/in_stock blood_returns INSERT + cascade trigger trigger maps return-status → bag-status reason + temp/appearance checks
donor-fail → destroyed recordDonorScreeningFail (1319) fail category + optional witnessed discard

Key invariant: every straight-line transition uses a .eq('status', fromStatus) concurrency guard, so an illegal/racing move updates zero rows and surfaces as an error rather than corrupting state.


3. THE INFECTION GATE — deep dive

The whole question — “can a bag go into คลัง if infected?” — resolves to four predicates and two enforcement points in BloodInfectionRegisterModule.tsx.

3.1 The predicates (lines 147–174)

isDisqualified(row)   = nat_qualified===false || sero_qualified===false || antibody_qualified===false
                        || hiv/hbv/hcv/syphilis/nat_result === 'positive'
isAllQualified(row)   = nat_qualified && sero_qualified && antibody_qualified
isPending(row)        = !isAllQualified && !isDisqualified
isInfectedRow(row)    = isDisqualified(row) || row.bag_status === 'quarantine'
isReadyForInventory   = bag_status==='in_stock' || (isAllQualified && !isDisqualified)

So a bag is “infected” if any of the three Q-panels is explicitly failed or any of the five serology/NAT markers is positive.

3.2 Gate #1 — UI navigation block (lines 2211–2238)

The “ไปคลัง / รับเข้าคลัง” button calls handleSaveAndReceive, which hard-refuses if any visible row is infected:

const infectedRowsInView = filteredRows.filter(isInfectedRow);
if (infectedRowsInView.length > 0) {
  enqueueSnackbar(`มี N ถุงที่ติดเชื้อ/ไม่ผ่าน — ต้องจัดการ (Quarantine) ก่อน จึงจะรับเข้าคลังได้`, { variant: 'error' });
  return;            // ⛔ never navigates to /blood-bank/blood-receive
}

3.3 Gate #2 — data-layer allowList (cascadeInfectionDecisionToBag, lines 962–981)

Even if Gate #1 were bypassed, the only “release to stock” path from this screen sets status='in_stock' only from ['ready','separated','received']. quarantine is deliberately not in the release allowList — a quarantined bag cannot be silently promoted. Its docstring is explicit: “Called ONLY from explicit user actions … Never fires from an auto-save.”

3.4 The tagging + disposal path

TTI reactive  ──(officer clicks กักกัน)──▶  status = 'quarantine'   (tab: SCREENING_REJECTED = "คัดกรองไม่ผ่าน / กักกัน")
                                               │
                  ┌────────────────────────────┴───────────────────────────┐
        (authorized release)                                    (ทำลายทิ้ง — destroy)
   transitionBagStatus(in_stock, releasedBy)            blood_discard_records insert:
   — for false-reactives after retest                   { discard_reason, discard_method ∈
                                                           autoclave|incineration|chemical,
                                                           discarded_by, witness_1, witness_2 }
                                                         → blood_separation_entries.status='destroyed'

Donor-side equivalent: recordDonorScreeningFail flips the bag to destroyed, sets permanent_deferral on the donor, and optionally writes the witnessed discard row.

3.5 What is deliberately decoupled (not a bug)

  • Editing a Q/D toggle and Save writes blood_lis_results but does not change bag_status. Status only moves on the explicit รับเข้าคลัง / กักกัน buttons. This separation of “record result” from “decide disposition” is intentional and audit-friendly.

3.6 Gaps / hardening candidates (for the doc’s policy section)

  1. Gate #1 is per-view. It filters filteredRows (current tab/search), not the whole dataset. An infected bag hidden by a filter wouldn’t block the bulk action. Consider a dataset-wide guard.
  2. No auto-expiry / no auto-destroy sweep. expired and quarantine→destroyed are entirely manual. There is no pg_cron job. (See cron-jobs-registry if/when added.)
  3. Result vocabulary is hardcoded (positive/negative/pending/not_done, the 5 markers). Countries with different mandatory panels (e.g. add HTLV, malaria Ab, Chagas) require code edits today.
  4. No enforced retest policy. A single reactive = disqualified; there’s no built-in “reactive → repeat in duplicate → confirm” state, though admin-config exposes an autoRetestHIV-style flag.
  5. Release-from-quarantine has no required reason/second-signature at the data layer (only releasedBy).

4. “When does a list show up where?”

Two levels: the route map (which surface) and the tab filter (which rows inside a worklist).

4.1 Route-level — which surface is a list

web/src/routes/BloodBankRoutes.tsx registers ~50 routes. The list/worklist surfaces (vs forms/stats/print):

Route Surface Shows
/blood-bank/blood-list-patient (+/urgent) Officer worklist patient blood requests, 8 status tabs (§4.2)
/blood-bank/blood-receive Inventory hub (คลัง) 5 tabs incl. BLOOD_INVENTORY = “ข้อมูลเลือดคงคลัง”
/blood-bank/blood-bank-list Bag registry all bags
/blood-bank/blood-infection-register Screening worklist per-bag NAT/SERO/Ab + the gate
/blood-bank/inventory-sources-new Stock by source Red-Cross/in-house × blood group
/blood-bank/blood-donor-list, /outstation-blood-donor-list Donor worklists donors / off-site collection
/blood-bank/blood-discard, /blood-return, /blood-return-request Disposal/return registers discarded / returned bags

4.2 Worklist-level — the tab → status filter (the literal “list shows up here” rule)

blood-list-patientValidSubTab (interface.ts:7–17) sliced by BloodRequestStatus via TAB_STATUS_FILTER (PatientWorkList.tsx:28–38):

Tab Thai Row appears when status ∈
INCOMING_REQUEST รายการขอเลือดใหม่ inactive, pending
STICKER_PRINTED_AND_BLOOD_SAMPLE_COLLECTED พิมพ์สติกเกอร์ + รับตัวอย่าง pending, sticker_printed_…
NOT_CROSSMATCHED_YET ยังไม่ Crossmatch not_crossmatched_yet
CROSSMATCHED crossmatched แล้ว crossmatched
PARTIAL_BLOOD_RECEIVED รับเลือดบางส่วน partial_blood_received
BLOOD_FULLY_RECEIVED รับเลือดครบ blood_fully_received
SPECIMEN_RECEIVER_ASSIGNED ผู้รับสิ่งส่งตรวจ specimen_receiver_assigned
SCREENING_REJECTED คัดกรองไม่ผ่าน / กักกัน screening_rejected

TAB_STATUS_FILTER omits the last two tabs, so their count badge reads 0 unless explicitly queried (a known inconsistency worth fixing when this becomes config-driven).

blood-receiveEnumBloodReceiveTabs: BLOOD_RECEIVE (rับเข้า), BLOOD_INVENTORY (status='in_stock'), BLOOD_RECHECK, BLOOD_LOCATIONS, BLOOD_TRANSFUSION.

blood-infection-register — status filter chips: all / disqualified (isDisqualified) / pending / ready (isReadyForInventory).

All of the above tab sets, filters, sort orders, and per-tab row actions are hardcoded (enums + switch + TAB_STATUS_FILTER literal + per-menu-item permissions arrays). None is admin-configurable today.


5. Policy & rules abstraction catalog

Everything hardcoded in the blood-bank, grouped by the recurring shape it takes. The shape tells you how to externalise it.

5.1 The recurring shapes

# Shape Examples in code Generalised config form
S1 Numeric threshold (min/max, unit, keyed by a dimension, varying by context) Hb 12.5♀/13♂, age 17/60/70, weight 50, BP 180/100 & 90/50, pulse 50–100, temp 37.5, interval 90d/14d (bloodDonorRules.ts) { key, min?, max?, unit, by?:'sex', byContext?:{whole,plasma,platelet,autologous} }
S2 Coded list → disposition TTI markers → block; questionnaire keys → block/warn; discard reasons one catalog { id, label, disposition, deferDays?, appliesTo? }
S3 Outcome model (severity → verdict fold) block>warn>infocanDonate (summarizeAlerts); pass/pending/fail (intake); Q/D → ready/infected ordered severities + fold rule + presentation per outcome
S4 Context applicability donorType==='plasma' gate; whole vs apheresis intervals rule carries appliesTo: context[]; engine filters
S5 Tiered bracket table milestone 7/16/24/50/100 → เข็มเชิดชูเกียรติ; (future) storage-age, urgency par-levels bracket(value, tiers)
S6 Enumerations blood groups, component keys, bag types, donation types, return reasons, discard methods shared enums / Supabase master tables
S7 Status + presentation metadata STATUS_COLOR, STATUS_LABEL_TH, severity colors, component chip colors one registry → also the i18n + dark-mode seam
S8 State machine / transition table transitionBagStatus, cascadeInfectionDecisionToBag allowLists declarative {from, to, requires[], guard} rows
S9 Worklist tab → status filter TAB_STATUS_FILTER, EnumBloodReceiveTabs, infection chips { tab, label, statuses[], actions[], roles[] } rows
S10 Relational matrix (mostly net-new) ABO/Rh compatibility, ECM eligibility (patient_ecm_eligibility view) lookup table

5.2 The honest “what’s hardcoded vs already-config” table

Rule / gate Where (file) Today Target home Admin surface
Donor vitals/lab/interval thresholds rules/bloodDonorRules.ts hardcoded in rule closures policy/thresholds (S1) /blood-bank/admin-config
Questionnaire deferral rules rules/bloodDonorRules.ts (Q_* codes) hardcoded policy/deferrals catalog (S2) admin-config
Donor verdict fold (block→canDonate) bloodDonorRules.ts summarizeAlerts hardcoded policy/outcome (S3) code (stable)
TTI marker set + result vocab BloodInfectionRegisterModule.tsx 147–155 hardcoded 5 markers policy/panels (S2/S6) admin-config (per-country panel)
Infection gate predicates BloodInfectionRegisterModule.tsx 147–174, 2211–2238 hardcoded keep logic in code; expose gate thresholds/flags admin-config flags
Bag-type SKU + shelf-life/volume/split rules/bagTypes.ts hardcoded array + useBloodBagCatalog Supabase merge finish moving to collection_bags table (S6) admin master-data editor
Component storage temp per blood_storage_locations.temperature_range already config (per location) admin location editor
Component prep-time SLA none not enforced policy/products (S1) admin-config
Bag status enum + transitions bloodBank.medbase.ts + migration CHECK hardcoded policy/lifecycle (S8) — change with care code (regulated)
Worklist tabs/filters/actions interface.ts, PatientWorkList.tsx, Blood.template.tsx hardcoded policy/worklists (S9) worklist-editor
Bag-number prefixes (201/101/301/901) bloodBank.medbase.ts 380–385 hardcoded policy/numbering (S6) admin-config (site-specific)
Discard methods / return reasons migrations + bloodBank.medbase.ts hardcoded policy/disposal (S2/S6) admin-config
Milestone award tiers bloodDonorRules.ts FREQUENT_DONOR_MILESTONE hardcoded [7,16,24,50,100] policy/awards (S5) admin-config
ABO/Rh compatibility (implicit / per-XM) net-new policy/compatibility (S10) code (regulated)

5.3 Connect to the repo’s existing policy-gate engine

The repo already ships a generic, DB-backed policy_gates rule engine (docs/architecture/policy-gates.md, admin UI /admin/policy-gates) with the same JSONB scope / predicate / action / priority shape used by cds_rules and facility_billing_rule. The blood-bank gates (release-to-stock, quarantine, discard-requires-witness, emergency-issue-justification) are prime candidates to be expressed as policy_gates rows rather than hardcoded ifs — giving per-site/per-country tuning with zero code change, consistent with how billing and CDS already work. The clinical eligibility/screening logic is better served by a dedicated typed engine (below), because it needs ranked multi-alert output, not a single allow/deny.


6. Proposed policy/ configuration layer

Grow the existing rules/ folder into a single source of truth. Additive — new files; existing modules repoint to it in a later, reviewed phase.

web/packages/diagnostics-kit/src/blood-bank/policy/
  engine.ts        # evaluate(rules, subject, ctx) · fold(results, model) · bracket(value, tiers)
                   #   (generalise the proven evaluateBloodDonorRules pattern)
  thresholds.ts    # S1 — donor vitals/labs/intervals; component prep-time; (storage handled per-location)
  deferrals.ts     # S2 — unified catalog: questionnaire + conditions + TTI markers → disposition
  panels.ts        # S2/S6 — TTI panel: markers, method (sero/NAT), window-period, mandatory-by-country
  products.ts      # S6 — re-export/derive from bagTypes.ts; per-component temp/shelf-life/prep-limit
  awards.ts        # S5 — milestone tiers (move FREQUENT_DONOR_MILESTONE here)
  contexts.ts      # S4 — donationType (whole/plasma/platelet/autologous) + applicability
  lifecycle.ts     # S8 — declarative status graph (mirrors transitionBagStatus; doc-as-data)
  worklists.ts     # S9 — tab→status→actions→roles rows (feeds blood-list-patient + blood-receive)
  compatibility.ts # S10 — ABO/Rh matrix; ECM eligibility constants
  presentation.ts  # S7 — STATUS_COLOR/LABEL_TH, severity colors (i18n seam)
  enums.ts         # S6
  index.ts

Hydration strategy (matches useBloodBagCatalog precedent): each config object ships a static default and can be overlaid from Supabase/admin at runtime. Because today’s rule closures already read their constants at call-time, swapping a static threshold map for a hydrated one is a drop-in — no rule body rewrites.

Keep in code (don’t over-abstract): the engine, the evaluate-bodies (only their parameters are config), the gate enforcement points (predicates can read config; the block stays in code), per-screen layout/animation.


web/CLAUDE.md forbids bulk refactors and Write on existing files. Each phase below is small, additive, and independently shippable.

  • P0 (additive, zero behaviour change): create policy/engine.ts + thresholds.ts + deferrals.ts by lifting the constants currently inside bloodDonorRules.ts closures into data; re-export so bloodDonorRules.ts keeps working. Add a sandbox target.
  • P1: move milestone tiers (awards.ts) and contexts (contexts.ts); wire donorType (whole/plasma/platelet/autologous) as a first-class context.
  • P2: panels.ts — make the TTI marker set + result vocab data-driven; the infection-register reads markers from config (closes gap §3.6.3) while the gate logic stays in code.
  • P3: express the four blood-bank gates as policy_gates rows (release-to-stock, quarantine, discard-witness, emergency-justification); add per-site overrides.
  • P4: worklists.ts — drive blood-list-patient tabs/filters from config via the worklist-editor; fix the count-badge omission for the last two tabs.
  • P5: admin master-data — finish bagTypes.ts → collection_bags table; surface thresholds/panels/awards in /blood-bank/admin-config.

Each phase repoints one consumer at a time, verified in /sandbox and pnpm e2e:sandbox.


8. Invariants (must always hold)

  1. No infected bag in stock. A bag with any reactive marker or failed Q-panel can never reach in_stock without an explicit, authorised release-from-quarantine.
  2. Explicit-action-only status change. Bag status moves on user actions with audit (releasedBy/issuedBy/etc.), never as a side-effect of editing a result.
  3. Concurrency-guarded transitions. Every transition carries a .eq('status', fromStatus) guard; races fail closed.
  4. Witnessed destruction. A bag reaches destroyed only via a discard record carrying method + discarded_by + witnesses (or recordDonorScreeningFail).
  5. Append-only audit. blood_returns, blood_discard_records, blood_inventory_movements, blood_return_audit_log are never mutated retroactively; corrections are new rows (voided).
  6. Config can tune thresholds, not weaken gates. Externalising a number (Hb, interval, panel) is allowed; the enforcement (gate blocks, witness requirement, concurrency guard) stays in code.
  7. One donor-rules engine. Donor eligibility lives in bloodDonorRules.ts/policy/, never re-implemented per screen.

Maintainers: when you add a bag status, a TTI marker, a worklist tab, or a gate — update §2.1, §3, §4.2, and the catalog in §5.2 so this stays the single source of truth.

Ask Anything