feat(labs): convert a reading into its marker's unit when the pair is known

Slice 2 of phase 3 of #1095. A reading that stated a unit other than its
marker's was refused. When the marker's analyte is known and the registry has the
pair, the three write paths now convert it instead: POST /api/labs, the OCR
commit and the document commit.

The converted value is stored at full precision in the marker's unit, with what
the report printed in the existing source_value and source_unit columns. The
report's own reference window is in the printed unit, so it is converted with the
value; otherwise the verdict would compare a converted value with an unconverted
window. A pair the registry does not have is still refused as before (the 422
now says why), as is a marker whose analyte is unknown.

The marker's key is filled the first time a reading needs it. The same scan
committed again is still a duplicate: a converted row holds the number in the
marker's unit, so the duplicate check also matches on what the report printed.
Editing the value of a converted reading clears the conversion record, because
the number is then the person's own.

The lab result DTO and the OpenAPI schemas (labs and the OCR commit) carry
converted, sourceValue and sourceUnit.

Refs #1095
This commit is contained in:
BernhardBuckel committed 2026-10-11 20:28:00 +02:00
1 parent 08c69d4a5a
commit caa7a937fd
18 files changed
+1011 -105

No files matched your search

+37 -12
View File
@@ -28,7 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
import {
isUnreadableRange,
parseReferenceRange,
@@ -128,22 +128,45 @@ async function commitObservation(
referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
// minted marker adopts the document's unit, so this only bites when the
// stated unit disagrees with the catalog's authoritative one (e.g. a
// document states "6.1 mmol/L" against a marker stored in mg/dL). Writing
// the stated number under the catalog's unit would corrupt the reading AND
// break the "transcribe verbatim" contract — so reject the fact and let the
// user reconcile on the review screen, exactly like the `unitRequired` gate.
// A unit that differs from an EXISTING marker's is converted when the
// marker's analyte is known and the registry has the pair (a document states
// "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document
// stated beside the stored number. Otherwise it fails closed: writing the
// stated number under the catalog's unit would corrupt the reading AND break
// the "transcribe verbatim" contract — so reject the fact and let the user
// reconcile on the review screen, exactly like the `unitRequired` gate. A
// freshly minted marker adopts the document's unit, so it cannot disagree.
// Spelling is compared through the shared normaliser, the same one the
// manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`.
let storedValue: number | null = isQualitative
? null
: (data.value as number);
let storedSourceLow = isQualitative ? null : sourceLow;
let storedSourceHigh = isQualitative ? null : sourceHigh;
let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null;
if (!isQualitative) {
if (!sameLabUnit(data.unit as string, biomarker.unit)) {
const reading = await readingInMarkerUnit(prisma, {
marker: biomarker,
value: data.value as number,
unit: data.unit as string,
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
});
if (!reading.ok) {
throw new FactCommitError(
"observation.unitMismatch",
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
);
}
storedValue = reading.value;
storedSourceLow = reading.sourceReferenceLow;
storedSourceHigh = reading.sourceReferenceHigh;
if (reading.converted) {
convertedFrom = {
sourceValue: reading.sourceValue,
sourceUnit: reading.sourceUnit,
};
}
}
const created = await prisma.labResult.create({
data: {
@@ -151,15 +174,17 @@ async function commitObservation(
biomarkerId: biomarker.id,
panel: biomarker.panel,
analyte: biomarker.name,
value: isQualitative ? null : (data.value as number),
value: storedValue,
valueText: isQualitative ? data.valueText : null,
unit: biomarker.unit,
sourceValue: convertedFrom?.sourceValue ?? null,
sourceUnit: convertedFrom?.sourceUnit ?? null,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
// The report's own window for THIS reading. A qualitative result has no
// window to record.
sourceReferenceLow: isQualitative ? null : sourceLow,
sourceReferenceHigh: isQualitative ? null : sourceHigh,
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
sourceReferenceText: isQualitative ? null : sourceText,
takenAt: statedDateOrNow(data.effectiveDate),
source: "DOCUMENT",