Files
HealthLog/src/lib/fhir/lab-qualitative.ts
T
Marc-André Bombeck a5b3715a46 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.
2026-06-20 11:17:43 +02:00

87 lines
3.4 KiB
TypeScript

/**
* v1.18.9 — qualitative lab result → SNOMED CT concept (or honest text-only).
*
* A qualitative serology result ("negativ" / "positiv" / "nicht nachweisbar")
* has no number, so its FHIR Observation carries a `valueCodeableConcept`
* instead of a `valueQuantity`. This module maps the common German / English
* result terms onto a small set of WELL-ESTABLISHED SNOMED CT qualifier-value
* concepts. The raw recorded text ALWAYS rides `.text`; a coded `coding` is
* added only when the term resolves CONFIDENTLY. When in doubt the result stays
* text-only — never a fabricated code — mirroring the conservative stance of
* the `lab-loinc` and `illness-snomed` mappers.
*
* Concepts (SNOMED CT International, qualifier value, REFERENCED not
* redistributed per the licence):
* - 260385009 "Negative" — negativ / negative
* - 10828004 "Positive" — positiv / positive
* - 260415000 "Not detected" — nicht nachweisbar / not detected
* - 260373001 "Detected" — nachweisbar / detected
*
* Borderline / grenzwertig is DELIBERATELY left text-only: the candidate
* "Borderline" qualifier concept could not be confidently verified, and an
* unverified code is worse than an honest `.text`. Any unrecognised term
* likewise stays text-only.
*
* Systems: SNOMED CT — http://snomed.info/sct
*/
import type { FhirCodeableConcept } from "@/lib/fhir/types";
/**
* SNOMED CT system URI. Inlined (rather than imported from `resources.ts`) to
* keep this leaf module free of the heavy `resources.ts` import graph and avoid
* an import cycle — `resources.ts` consumes THIS module.
*/
const SNOMED_SYSTEM = "http://snomed.info/sct";
interface QualitativeConcept {
code: string;
display: string;
}
/**
* Normalised result term → SNOMED qualifier-value concept. The left side is the
* lower-cased, whitespace-collapsed recorded text. Only confidently-known terms
* appear; everything else falls through to text-only.
*/
const QUALITATIVE_SNOMED: Record<string, QualitativeConcept> = {
// Negative-like
negativ: { code: "260385009", display: "Negative" },
negative: { code: "260385009", display: "Negative" },
neg: { code: "260385009", display: "Negative" },
"nicht nachweisbar": { code: "260415000", display: "Not detected" },
"not detected": { code: "260415000", display: "Not detected" },
// Positive-like
positiv: { code: "10828004", display: "Positive" },
positive: { code: "10828004", display: "Positive" },
pos: { code: "10828004", display: "Positive" },
nachweisbar: { code: "260373001", display: "Detected" },
detected: { code: "260373001", display: "Detected" },
};
/** Collapse a recorded qualitative term to a lookup key. */
function normaliseQualitativeKey(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* Build the `valueCodeableConcept` for a qualitative lab result. The raw text
* always rides `.text`; a SNOMED `coding` is added only for a confidently-
* recognised term. Borderline / grenzwertig and any unknown term stay
* text-only.
*/
export function qualitativeValueConcept(
valueText: string,
): FhirCodeableConcept {
const concept = QUALITATIVE_SNOMED[normaliseQualitativeKey(valueText)];
if (!concept) {
return { text: valueText };
}
return {
coding: [
{ system: SNOMED_SYSTEM, code: concept.code, display: concept.display },
],
text: valueText,
};
}