Skip to main content

Lab (LIMS) — How it works

Overview

Zhenus LIMS is a separate domain plug-in module (com.zhenus.uhp.api.lab) with its own lab PostgreSQL schema. It mirrors the imaging module's boundary: a decoupled Feign plug-in that never imports core or a sibling domain module, and reaches platform engines (tenant, facility, demographic, accesscontrol, clinical, concept, notification, audit) only over Feign. Design: MILESTONE20_PLAN.md.

M20-002 adds the orderable test catalog, panels, and reference ranges. M20-003 adds order intake and accessioning (accession numbers, barcodes, per-specimen tracking ids) via a single LabOrderService#accession path consumed by the LAB OrderTypeProcessor and the REST accession desk. M20-004 adds the specimen lifecycle (collect → receive → aliquot → store / reject) with an append-only custody trail. M20-005 adds result entry, two-step validation (tech → pathologist), reference-range abnormal flagging, and release into clinical observations (Feign); outbound ORU plugs in via LabResultReleaseHook (M12-007B). M20-008 adds panic bounds on reference ranges and raises critical results on release through the shared M10B-009A acknowledgement loop. M20-006 adds control-lot QC runs, write-time z-scores for Levey-Jennings charts, and a vendor-neutral analyzer interface seam.

Data model & ownership

Owned tables live under lab/src/main/resources/db/changelog/lab/. The master changelog is deliberately named lab.db.changelog-master.yaml, not db.changelog-master.yaml — the latter collides with core's on the single-jar classpath.

AreaMigrationPurpose
Schema001CREATE SCHEMA IF NOT EXISTS lab
Catalog002lab_specimen_type, lab_method, lab_test, reference_range, panel, panel_test
Orders003lab_accession_format, lab_order, lab_order_item
Specimens004lab_specimen, lab_custody_event (append-only)
Results005lab_result, lab_result_value
Quality control006qc_run, qc_result (expected mean/SD, observed value, derived z-score)
Panic bounds007reference_range.panic_low_value, reference_range.panic_high_value

Catalog rows are GLOBAL-CAPABLE config: tenant_id from the session, optional facility_id (null = tenant-wide). Visibility is facility ∪ tenant ∪ country via TypeScopeClient / LabScopeResolver. Soft-delete via AuditTrail + @SQLRestriction("voided = false").

A lab test's clinical identity is a concept_id (Long) — never a LOINC string on the row. LOINC arrives through concept_mapping. Units are a UCUM units_term_id (TERM-006), never free text. Specimen type and method are CRUD reference entities (same categorical-field reason as imaging modalities).

Reference ranges are many rows per test (sex + age band in days). Optional panic bounds (panic_low_value / panic_high_value) sit on the same row, wider than the normal band.

Accessioning (M20-003)

  • lab_order is the module's view of a clinical LAB order. clinical_order_id is a logical reference (no cross-module FK). The clinical order stays the source of truth.
  • Accession numbers are unique per tenant (ux_lab_order_accession has no voided = false predicate — a cancelled order never releases its accession).
  • The format/counter is per facility (lab_accession_format) under a pessimistic row lock. A facility with no configured format gets a platform default on first accession (imaging's M10B-003C lesson).
  • lab_order_item lines expand a panel's members or a single catalog test; lines that share a specimen type share one specimen_tracking_id. Barcode payloads are the accession (order label) and the specimen tracking id (specimen label) for the FE to print.
  • LabOrderTypeProcessor (orderTypeCode = LAB) calls the same accession method and returns the lab order id so core stamps clinical_order.fulfiller_reference inside the placement transaction.

Specimen lifecycle (M20-004)

  • Specimens are created on collect from an accession-time specimen_tracking_id.
  • Status moves collect → receive → aliquot → store (validated); reject is allowed from any non-rejected status and keeps the row with a mandatory reason.
  • lab_custody_event is append-only (no void columns, no update API).
  • LabSpecimenService#requireAcceptsResults refuses rejected specimens — the gate M20-005 uses.

Result entry, validation, and release (M20-005)

  • Status is relational: ENTEREDTECH_VALIDATEDPATH_VALIDATEDRELEASED.
  • Release is the gate — clinical observations are created only on release after pathologist validation. Auto-release on entry is refused.
  • Abnormal flagging is driven from reference_range (sex + age band in days); never hardcoded thresholds.
  • Panic bounds on the matched reference range are evaluated only after release (never on entry or validation). LabCriticalValueReleaseHook calls LabCriticalValueService, which raises through CriticalResultClient with source_module = lab and the result id as source_record_id. Lab owns no escalation table or scheduler (M10B-009A in core).
  • Amendments append a new lab_result with replaces_result_id; the prior released row is never overwritten. Releasing an amendment publishes an AMENDED observation with previousVersion.
  • Outbound ORU is not inline: LabResultReleaseNotifier calls LabResultReleaseHook subscribers AFTER_COMMIT (same shape as PatientMovementHook). Panic raise is one such hook; zero other hooks is normal until M12-007B.

Quality control and analyzer seam (M20-006)

  • A qc_run records the test, optional method, control lot/level, expected mean and strictly positive standard deviation. Each qc_result records an observed value and its write-time z_score = (observed - expectedMean) / expectedSd at four decimal places.
  • Manual entry calls QcRunService#recordManual directly and does not resolve an analyzer. A lab with no physical analyzer configured therefore retains the full QC workflow.
  • InstrumentInterface is the deployment adapter SPI: stable providerName(), isConfigured(), and a normalized ingest method. InstrumentInterfaceRouter follows the notification-provider fall-through shape.
  • SimulatedAnalyzer is always configured and is the default. Hl7AstmInstrumentAdapter is only a vendor-neutral hook: it reports unconfigured until an HL7 or ASTM endpoint is set. Vendor dialect parsing belongs in deployment-specific adapters, not the QC service.

Key rules & invariants

A separate module with its own schema, so extraction needs no caller changes

Pointing lab's Feign URLs elsewhere later requires no change to any caller — every cross-module reference is logical (Feign-resolved), never a foreign key.

Every M20 permission family is declared from the skeleton onward

LabModuleDescriptor declares lab.catalog, lab.order, lab.specimen, lab.result, lab.qc and lab.critical (.read/.write). Catalog CRUD uses lab.catalog.read / .write. Accessioning uses lab.order.read / .write; the accession-format admin reuses catalog permissions. Specimen lifecycle uses lab.specimen.read / .write. Result entry / validation / release uses lab.result.read / .write. QC listing and chart data use lab.qc.read; manual/instrument writes use lab.qc.write.

No core import, no sibling-domain import

LabArchitectureBoundaryTest asserts lab never depends on core, imaging, workforce or hl7.

COALESCE uniqueness on nullable facility_id

Unique indexes key (tenant_id, COALESCE(facility_id, nil-uuid), …) WHERE voided = false so two tenant-level rows cannot silently duplicate.

API

Base path ApiV1Paths.LAB (/api/v1/lab):

SurfacePaths
Specimen types / methods/specimen-types, /methods
Tests/tests, /tests/page, /tests/{id}
Reference ranges/tests/{id}/reference-ranges
Panels/panels, /panels/{id}/tests
Orders (accession desk)POST/GET /orders, GET /orders/{id}, GET /orders/by-accession/{accession}
Accession formatGET/PUT /accession-format
SpecimensPOST /specimens/collect, GET /specimens, GET /specimens/{id}, GET …/by-tracking/{id}, GET …/custody, `POST …/receive
ResultsPOST/GET /results, GET /results/{id}, POST …/validate-tech, POST …/validate-path, POST …/release, POST …/amend
Quality controlPOST/GET /qc-runs, GET /qc-runs/{id}, POST /qc-runs/ingest?provider=…

Cross-module read contract (M20-009): exchange.client.lab.LabCatalogClient and LabResultReadClient mirror the catalog and order/result GET surfaces above (listTests, searchTests, getTest, listReferenceRanges, listPanels, searchPanels, getPanel, listPanelMembers, listOrders, getOrder, getOrderByAccession, listResults, getResult). Read-only — portals and BFF layers consume released results over Feign without importing lab. URL: ${lab.service.url:${uhp.platform.url}} (default in lab/uhp-module-defaults.yml and loopback in app/application.yml). ⚠ Scheduled callers still 403 until SEC-018 lands service identity: FeignContextPropagationConfig reads RequestContextHolder, which is empty off-request.

Configuration & feature flags

LAB_APP_PORT (default 8087); LAB_SERVICE_URL in .env.example (Feign loopback for LabCatalogClient / LabResultReadClient).

Analyzer properties are optional: LAB_INSTRUMENT_DEFAULT_PROVIDER defaults to simulated; LAB_INSTRUMENT_HL7_ENDPOINT and LAB_INSTRUMENT_ASTM_ENDPOINT enable the external adapter hook. Leaving both endpoints blank is a supported configuration.

Testing

LabApplicationTest boots against the dedicated test DB and applies the lab Liquibase changelog. Service unit tests cover catalog CRUD and accessioning (unique accession, idempotent clinical-order key, cancel retains accession), QC z-score derivation, manual no-analyzer entry, and simulated instrument ingestion. Exchange LabCatalogContractsTest / LabOrderContractsTest pin concept ids as Long and the OrderTypeProcessor fulfiller-stamp return type. LabClientContractsTest (M20-009) pins the read-only Feign paths and query-parameter names for LabCatalogClient / LabResultReadClient.

Milestone gate (M20-007)

Milestone20LimsIntegrationTest walks the full LIMS journey against uhp_test: catalog → accession → specimen custody → result validate/release → observation publish → panic raise → critical-result acknowledgement. Platform Feign round-trips are stubbed; lab schema persistence is real. See core/MILESTONE20.md.

Imaging shares the critical-result acknowledgement loop this module will consume in M20-008. Workforce is the other domain-module plug-in with the same boundary shape. FE pairs: FE-248 (catalog), FE-249 (accessioning desk).

Patient merge re-point (M37-005)

POST /api/v1/lab/patient-merges/repoint bulk-updates patient_id on lab_order, lab_specimen, and lab_result for the session tenant. Invoked by core after M37-004; returns per-table counts and PARTIAL when rows remain on the loser patient.

Why it is this way

Tests are concept-backed; LOINC never lives on a lab-local column. Analyte identity is concept_id (Long); standard codes arrive through concept_mapping. A per-module analyte table would fork the dictionary silently.

Release is the clinical gate. Observations publish only after pathologist validation; panic evaluation runs after release, never on raw entry — otherwise unvalidated panic noise reaches the chart.

Critical results use the shared platform loop (M10B-009A). Lab raises through CriticalResultClient; there is no lab-local escalation table or scheduler to maintain in parallel.

Instrument adapters mirror NotificationProviderRouter. A simulated provider is always available; real HL7/ASTM endpoints are optional per deployment. Manual QC and manual result entry must work with no analyzer configured.

Traps

Chain of custody is append-only — rejections keep the specimen row with a mandatory reason; results against rejected specimens are refused. Deleting custody events destroys medico-legal trace.

Accession numbers are server-minted — never on input DTOs. Client-supplied accession keys break uniqueness under concurrency and bypass catalog permission checks.

ORU is AFTER_COMMIT via LabResultReleaseHook — do not inline HL7 send inside the release transaction; a failed wire must not roll back a validated result. Since M12-007B the hl7 module's LabOruReleaseHook subscribes here and delivers the ORU^R01 (see the HL7 engine doc); inbound external lab orders arrive as ORM^O01 and enter through the generic order rail, so they accession through the same accession(ClinicalOrderDto) method as every other intake path.

QC z-scores derive at write — store expected mean/SD on the QC run; do not recompute from historical results ad hoc or Levey-Jennings charts drift from persisted facts.

Integration tests stub platform Feign but use real lab schema — a green unit test with mocks only does not substitute for Milestone20LimsIntegrationTest on uhp_test.