---
name: lis-reference-ranges
description: How LIS reference ranges work (per-result SNAPSHOT vs live), why ranges "don't show", the data-loss bug that wiped ranges/panel-members on empty save (FIXED), and the inline per-result range-override feature with audit.
updated: 2026-06-25
---

# LIS Reference Ranges — model, the data-loss bug, and the override feature

## The model (important)
- A result's range is a **persisted SNAPSHOT** on `lab_results.normal_min/normal_max/critical_low/critical_high` (+ `abnormal_flag`), taken at **first numeric entry** (`LabResultService::calculateAbnormalFlag` → `updateQuietly`, refreshes ONLY when BOTH min+max are null). The Worklist/Validation grid + the printed report read this **snapshot** (FE `validation-worklist` `getRangeDisplay`/`formatRangeFromRow`; `result-report.blade.php:66-90`).
- The Worklist ALSO computes a **live** `reference_range_text` (`WorklistContextService::matchedReferenceRange`, machine-agnostic + all-ranges fallback) but the FE shows the numeric snapshot first.
- Catalog ranges live in `lab_investigation_normal_ranges` — **FK column is `investigation_id`** (NOT `lab_investigation_id`); has `machine_id`/`external_lab_id`/`gender`/`age_from..to(+unit)`. NO `deleted_at` (hard delete). Matching precedence: external-lab → machine → global; gender (`= OR NULL`); age in **whole years** (age units IGNORED — a months/days range won't match). `reference_range_type` is NOT consulted.
- **Why a range "doesn't show":** snapshot null/stale — test had no matching range at entry; range ADDED AFTER entry (snapshot not refreshed); machine/gender/age/age-unit mismatch; or the test simply has **0 ranges** (the COAG-PT case, a catalog gap). Full study: `public_html/lis-reference-range-study.html`.

## ✅ فحص Vitamin D staging↔المدينة (2026-07-01، طلب المالك «فيه فرق في الرنجات بالتقرير»)
قارنت `selmadina_stg` ضد `elmadina_db` سطر-بسطر: **Vitamin D (25-OH)** (id 3616, code `VIT_D`) **متطابق 100%** في الاتنين — unit ng/mL، decimals 1، range واحدة 30–100 (عمر 1–120)، وكل الـ10 نتائج snapshot-ها 30–100 بلا أي override (الفرق الوحيد `updated_at`: staging اتلمس 07-01 بنفس القيم). **يعني مفيش فرق staging/live في التحليل ده.**
🔴 **لكن اكتشفت الالتباس المحتمل:** فيه تحليل Vitamin D **تاني مكرر** — id 3615 code `VITD2` («Vitamin D» بدون 25-OH) — **وحدة الـ range بتاعته `pg/mL` بينما وحدة التحليل `ng/mL`** (في الاتنين بنفس الغلط). لو التقرير اللي المالك شافه كان على `VITD2` هيبان «مختلف». **⏳ OPEN:** يا نصلّح وحدة range الـ`VITD2` → ng/mL يا نشيل التحليل المكرر؛ ولو المالك عنده تقرير معيّن فيه فرق → هات رقم الـ LR وأتتبّع الـ snapshot بتاعه.

## 🔴 DATA-LOSS bug — FIXED (2026-06-25, BE `59694585d`)
Saving a test/panel **silently deleted ALL its ranges or panel members** (permanent — no soft-delete/audit) when the payload carried an **empty array**: `update()` did `if ($normalRanges !== null) delete()+recreate` and `sync($panelMembers)` — `[] !== null` so `[]` wiped everything; same in `syncNormalRanges()` and `syncPanelMembers()` (`{sections:[],ungrouped:[]}` → `sync([])`). Proven: COAG-PT had a range (result snapshot `11.5-13.5`) that vanished from the catalog; ~11 range-less tests + 3 member-less panels per DB.
**Fix:** empty/absent array = **no change** (existing kept); wiping needs an explicit `clear_normal_ranges` / `clear_panel_members` flag; `syncNormalRanges`/`syncPanelMembers` 422 on empty-without-clear; delete-recreate wrapped in a transaction. 9 tests (`LabInvestigationRangeGuardTest`). Owner: **no recovery of lost data** (client re-entered) — fix-only. ⚠️ FE follow-up: wire the `clear_*` flags for the intentional "remove all" workflow.

## ⭐ Feature — inline per-result range override + audit (2026-06-25, BE `435dd9a68` + FE `f7e642f`)
Click a test's range in Worklist/Validation → dialog (normal min/max + critical low/high + **required reason**) → `POST /lis/results/{id}/range` (`LabResultController::overrideRange`, gated by `lis.results.validate`). Stores the range on the result snapshot (prints on report + recomputes flag), stamps `is_range_overridden`/`range_overridden_by`/`_at`/`reason` (migration `2026_06_25_230200`), and writes a `lab_result_audit_logs` row (`action=range_overridden`, before/after) shown in the result lifecycle. **Per-result override** (not the catalog); catalog edit (`PUT /lis/investigations/{id}/normal-ranges`) stays "future results only". 3 tests (`LabResultRangeOverrideTest`). All on `hazemdev` → reaches elmadina with the next release.

### v2 extensions (2026-06-26, BE `6087da905` + FE `6e1284c`/`de35b8f`)
- **TEXT ranges**: new `lab_results.reference_range_text` (migration `2026_06_25_233000`) + `overrideRange` accepts `reference_range_text` (text-only override allowed). Lets you override qualitative/age-banded tests (e.g. VITB12 whose catalog range is a `text_normal` blob, single row, null numeric — the numeric dialog couldn't represent it). Dialog prefills from the effective range (`row.referenceRangeText`) so text tests don't open blank.
- **Override wins on PRINT**: `LisPrintReportService.formatRangeFromRow` + portal `lis-report-section-builder.resolveRange` got a step-0 — an `is_range_overridden` result's `reference_range_text` (else its overridden numeric) beats the catalog `text_normal`. `calculateAbnormalFlag` now never re-resolves an overridden result (guard on `is_range_overridden`) so the override isn't clobbered. `WorklistContextService` mirrors this in the live worklist range.
- **Clickable post-validation + in dept-worklist**: `canEditRange` no longer requires `isEditable` — override stays clickable after validate/approve/release and on panel members (the user couldn't click PT inside COAG because it was past `validated`). Gate = `resultId > 0` (excludes synthetic pending placeholders; dept also excludes `isPanel` parent). Feature ported into **dept-worklist** (result entry), not just validation. The BE has NO status guard — any company result is overridable, permission-gated only.
- **CRASH fix**: `confirmRange` `toNum` coerces with `String(s)` before `.trim()` — `<input type=number>` ngModel yields a number, so `(num).trim()` threw `TypeError`.
- ⚠️ **Two pending migrations** (`2026_06_25_230200` audit + `2026_06_25_233000` text) auto-run on client update (in `config/moonstack.php`). On **moonui-dev** they must be run manually (`php artisan migrate --path=...`) — the safety classifier blocks me from `migrate` on the shared non-binlogged `moonui_dev_be`; SAVE 500s with "Unknown column reference_range_text" until run.

## Open / next
- Display robustness (P4, not done): make the snapshot auto-refresh / FE fall back to the live `reference_range_text` when the snapshot is empty so a range added AFTER entry shows without a manual override; honor age units in matching.
- Portal print exposure (optional): `pp-print.service.ts` / `client-report-print.service.ts` + portal API mapping `reference_range_text`/`is_range_overridden` so the override also shows on patient/B2B portal reports (staff print + shared section-builder already do).
