feat(labs): record qualitative lab results

Add first-class qualitative lab findings (positive / negative / borderline
serology) alongside the existing numeric path. A LabResult is now EITHER a
numeric reading (value + unit, optional reference bounds) OR a qualitative one
(valueText, e.g. "negativ"), never both, never neither.

- schema: make `value` nullable, add `value_text`; migration 0181 is additive
  (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched.
- validation: numeric-XOR-qualitative refine on create + update; unit/bounds
  apply to the numeric path only; valueText capped at 120 chars. The single
  resource PUT rejects a cross-type edit.
- serialise: carry valueText through both DTOs; a qualitative row reports the
  neutral `unknown` range verdict (no number to classify).
- FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier
  value for confidently-known terms (Negative 260385009, Positive 10828004,
  Not detected 260415000, Detected 260373001), text-only for borderline /
  grenzwertig and any unrecognised term. The raw text always rides .text.
- UI: a numeric/qualitative toggle in the lab form; list, history, detail,
  chart, sparkline, and the doctor-report PDF all render qualitative readings
  as their text and never plot or divide on a null value.
- i18n: new labs.form keys across all six locales.
- openapi: response carries a nullable value + valueText; regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 11:17:43 +02:00
1 parent e631396af3
commit a5b3715a46
30 files changed
+819 -123

No files matched your search

+30 -7
View File
@@ -6081,6 +6081,10 @@ paths:
maxLength: 120
value:
type: number
valueText:
type: string
minLength: 1
maxLength: 120
unit:
type: string
minLength: 1
@@ -6100,7 +6104,6 @@ paths:
- type: string
const: ""
required:
- value
- takenAt
responses:
"201":
@@ -6174,6 +6177,10 @@ paths:
maxLength: 120
value:
type: number
valueText:
type: string
minLength: 1
maxLength: 120
unit:
type: string
minLength: 1
@@ -18838,7 +18845,13 @@ components:
analyte:
type: string
value:
type: number
anyOf:
- type: number
- type: "null"
valueText:
anyOf:
- type: string
- type: "null"
unit:
type: string
referenceLow:
@@ -18867,6 +18880,7 @@ components:
- panel
- analyte
- value
- valueText
- unit
- referenceLow
- referenceHigh
@@ -18877,10 +18891,12 @@ components:
- createdAt
- updatedAt
additionalProperties: false
description: A stored lab result. `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows);
when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker —
the client renders them and never recomputes the range. The encrypted note is never echoed in list rows;
`hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
description: A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is
non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links
the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` /
`referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never
recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the
single-resource GET returns the decrypted `note`.
LabReferenceRangeStatus:
type: string
enum:
@@ -18941,7 +18957,13 @@ components:
analyte:
type: string
value:
type: number
anyOf:
- type: number
- type: "null"
valueText:
anyOf:
- type: string
- type: "null"
unit:
type: string
referenceLow:
@@ -18972,6 +18994,7 @@ components:
- panel
- analyte
- value
- valueText
- unit
- referenceLow
- referenceHigh