Blood Bank Policy & Lifecycle (Master)
End-to-end blood-bank reference: blood-bag lifecycle/state-machine, infection gate, list-visibility rules, configurability.
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:
blood-bank-dispense-spec.md— §4.3 spec states (S1–S41) and the dispense workflow.blood-infection-register-lock-rules.md— lock-state rules for/blood-bank/blood-infection-register.blood-bank-test-result-entry-3.3.4.md— TTI result-entry spec.- Memory: Blood Bank route ↔ spec §4.3 / EPHIS map — where each spec lane lives.
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.tsis the real counterpart to the standalone “DonorRulesSandbox” — it already encodes permanent-deferral-on-infection, fever >37.5, donation interval, autologous-relevantdonorType, 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 |
quarantine → in_stock |
transitionBagStatus (1113) |
.eq('status','quarantine') |
releasedBy |
in_stock → reserved |
reserveBagForRequest (1001) |
.eq('status','in_stock') (concurrency lock) |
patient + requestId + reservedBy |
reserved → crossmatched |
transitionBagStatus |
.eq('status','reserved') + same-patient guard |
patientId + crossmatchResultId |
crossmatched → issued |
transitionBagStatus |
.eq('status','crossmatched') |
issuedBy |
issued → transfused |
transitionBagStatus |
.eq('status','issued') |
transfusedBy |
emergency in_stock/reserved → issued |
emergencyIssueBag (1184) |
skips XM | justification (non-empty) |
issued → returned/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_resultsbut does not changebag_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)
- 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. - No auto-expiry / no auto-destroy sweep.
expiredand quarantine→destroyedare entirely manual. There is nopg_cronjob. (Seecron-jobs-registryif/when added.) - 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. - No enforced retest policy. A single reactive = disqualified; there’s no built-in “reactive → repeat in duplicate → confirm” state, though
admin-configexposes anautoRetestHIV-style flag. - 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-patient — ValidSubTab (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_FILTERomits the last two tabs, so their count badge reads 0 unless explicitly queried (a known inconsistency worth fixing when this becomes config-driven).
blood-receive — EnumBloodReceiveTabs: 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_FILTERliteral + per-menu-itempermissionsarrays). 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>info → canDonate (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.
7. Recommended phased migration (guardrail-safe)
web/CLAUDE.md forbids bulk refactors and
Writeon existing files. Each phase below is small, additive, and independently shippable.
- P0 (additive, zero behaviour change): create
policy/engine.ts+thresholds.ts+deferrals.tsby lifting the constants currently insidebloodDonorRules.tsclosures into data; re-export sobloodDonorRules.tskeeps working. Add a sandbox target. - P1: move milestone tiers (
awards.ts) and contexts (contexts.ts); wiredonorType(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_gatesrows (release-to-stock, quarantine, discard-witness, emergency-justification); add per-site overrides. - P4:
worklists.ts— driveblood-list-patienttabs/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_bagstable; 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)
- No infected bag in stock. A bag with any reactive marker or failed Q-panel can never reach
in_stockwithout an explicit, authorised release-from-quarantine. - 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. - Concurrency-guarded transitions. Every transition carries a
.eq('status', fromStatus)guard; races fail closed. - Witnessed destruction. A bag reaches
destroyedonly via a discard record carrying method +discarded_by+ witnesses (orrecordDonorScreeningFail). - Append-only audit.
blood_returns,blood_discard_records,blood_inventory_movements,blood_return_audit_logare never mutated retroactively; corrections are new rows (voided). - 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.
- 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.