mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
08c69d4a5a
commit
caa7a937fd
18 files changed
+1011
-105
No files matched your search
@@ -192,7 +192,15 @@ export const PUT = apiHandler(
|
||||
const data: Record<string, unknown> = {};
|
||||
if (d.panel !== undefined) data.panel = d.panel;
|
||||
if (d.analyte !== undefined) data.analyte = d.analyte;
|
||||
if (d.value !== undefined) data.value = d.value;
|
||||
if (d.value !== undefined) {
|
||||
data.value = d.value;
|
||||
// A number the person typed is no longer the conversion of what the
|
||||
// report printed, so the "converted from" record would now be wrong.
|
||||
if (existing.sourceValue !== null || existing.sourceUnit !== null) {
|
||||
data.sourceValue = null;
|
||||
data.sourceUnit = null;
|
||||
}
|
||||
}
|
||||
// v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a
|
||||
// qualitative row (or `valueText` on a numeric row) crosses the row's type
|
||||
// and is rejected below. An edit of the matching field updates in place.
|
||||
|
||||
@@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
||||
import { serialiseLabResult } from "@/lib/labs/serialise";
|
||||
@@ -75,7 +76,13 @@ async function isDuplicate(
|
||||
analyte: { equals: row.analyte.trim(), mode: "insensitive" },
|
||||
takenAt: labReadingDaySearchRange(day),
|
||||
},
|
||||
select: { value: true, valueText: true, takenAt: true },
|
||||
select: {
|
||||
value: true,
|
||||
valueText: true,
|
||||
sourceValue: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day);
|
||||
@@ -83,6 +90,19 @@ async function isDuplicate(
|
||||
if (row.value !== undefined && c.value !== null && c.value === row.value) {
|
||||
return true;
|
||||
}
|
||||
// A reading stored converted holds the value in the marker's unit, so the
|
||||
// same scan committed again no longer equals it. What the report printed is
|
||||
// kept beside it: the same number in the same unit is the same reading.
|
||||
if (
|
||||
row.value !== undefined &&
|
||||
row.unit !== undefined &&
|
||||
c.sourceValue !== null &&
|
||||
c.sourceValue === row.value &&
|
||||
c.sourceUnit !== null &&
|
||||
sameLabUnit(c.sourceUnit, row.unit)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
row.valueText !== undefined &&
|
||||
c.valueText !== null &&
|
||||
@@ -179,19 +199,40 @@ async function commitOcrRows(request: NextRequest) {
|
||||
panel: row.panel ?? null,
|
||||
});
|
||||
|
||||
// The marker's unit is the one the reading is stored under, so a row that
|
||||
// states another unit cannot be written: the number would be kept under a
|
||||
// unit it is not in. The review screen marks such a row before Save; this
|
||||
// is the backstop for a client that did not, and for a marker whose unit
|
||||
// changed between the scan and the commit. A freshly minted marker adopted
|
||||
// this row's unit, so it cannot disagree.
|
||||
if (
|
||||
!isQualitative &&
|
||||
row.unit !== undefined &&
|
||||
!sameLabUnit(row.unit, biomarker.unit)
|
||||
) {
|
||||
skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" });
|
||||
continue;
|
||||
// The marker's unit is the one the reading is stored under. A row that
|
||||
// states another unit is converted when the marker's analyte is known and
|
||||
// the registry has the pair, with what the report printed kept beside it;
|
||||
// otherwise it cannot be written: the number would be kept under a unit it
|
||||
// is not in. The review screen marks such a row before Save; this is the
|
||||
// backstop for a client that did not, and for a marker whose unit changed
|
||||
// between the scan and the commit. A freshly minted marker adopted this
|
||||
// row's unit, so it cannot disagree.
|
||||
let storedValue = row.value ?? null;
|
||||
let storedSourceLow = isQualitative ? null : sourceLow;
|
||||
let storedSourceHigh = isQualitative ? null : sourceHigh;
|
||||
let convertedFrom: { sourceValue: number; sourceUnit: string } | null =
|
||||
null;
|
||||
if (!isQualitative && row.value !== undefined) {
|
||||
const reading = await readingInMarkerUnit(prisma, {
|
||||
marker: biomarker,
|
||||
value: row.value,
|
||||
unit: row.unit,
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
});
|
||||
if (!reading.ok) {
|
||||
skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" });
|
||||
continue;
|
||||
}
|
||||
storedValue = reading.value;
|
||||
storedSourceLow = reading.sourceReferenceLow;
|
||||
storedSourceHigh = reading.sourceReferenceHigh;
|
||||
if (reading.converted) {
|
||||
convertedFrom = {
|
||||
sourceValue: reading.sourceValue,
|
||||
sourceUnit: reading.sourceUnit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field — never spread the parsed row. The row stamps the resolved
|
||||
@@ -202,13 +243,15 @@ async function commitOcrRows(request: NextRequest) {
|
||||
biomarkerId: biomarker.id,
|
||||
panel: biomarker.panel,
|
||||
analyte: biomarker.name,
|
||||
value: row.value ?? null,
|
||||
value: storedValue,
|
||||
valueText: row.valueText ?? null,
|
||||
unit: biomarker.unit,
|
||||
sourceValue: convertedFrom?.sourceValue ?? null,
|
||||
sourceUnit: convertedFrom?.sourceUnit ?? null,
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
sourceReferenceLow: isQualitative ? null : sourceLow,
|
||||
sourceReferenceHigh: isQualitative ? null : sourceHigh,
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
sourceReferenceText: isQualitative ? null : sourceText,
|
||||
takenAt: row.takenAt,
|
||||
source: "OCR",
|
||||
|
||||
+57
-27
@@ -18,7 +18,7 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import {
|
||||
type ResolvedBiomarker,
|
||||
serialiseLabResult,
|
||||
@@ -232,6 +232,7 @@ async function postLabResult(request: NextRequest) {
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
},
|
||||
});
|
||||
if (!found) {
|
||||
@@ -253,30 +254,56 @@ async function postLabResult(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// The marker's unit is the one every reading is stored under, so a reading
|
||||
// that states another unit cannot be written: the number would be kept and
|
||||
// the label swapped, and nothing downstream could tell. Compared only when a
|
||||
// unit was actually sent: on the `biomarkerId` path it may be omitted, and
|
||||
// then the marker's unit is the stated one. A freshly minted marker adopted
|
||||
// the reading's own unit, so it cannot disagree with it.
|
||||
if (
|
||||
!isQualitative &&
|
||||
unit !== undefined &&
|
||||
!sameLabUnit(unit, biomarker.unit)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "labs.create.unit-mismatch" },
|
||||
meta: { biomarkerId: biomarker.id },
|
||||
// The marker's unit is the one every reading is stored under. A reading that
|
||||
// states another unit is converted when the marker's analyte is known and the
|
||||
// registry has the pair (the value, and the report's own window with it, with
|
||||
// what the report printed kept beside it); otherwise it cannot be written:
|
||||
// the number would be kept and the label swapped, and nothing downstream
|
||||
// could tell. Compared only when a unit was actually sent: on the
|
||||
// `biomarkerId` path it may be omitted, and then the marker's unit is the
|
||||
// stated one. A freshly minted marker adopted the reading's own unit, so it
|
||||
// cannot disagree with it.
|
||||
let storedValue = value ?? null;
|
||||
let storedSourceLow = isQualitative ? null : (sourceReferenceLow ?? null);
|
||||
let storedSourceHigh = isQualitative ? null : (sourceReferenceHigh ?? null);
|
||||
let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null;
|
||||
if (!isQualitative && value !== undefined) {
|
||||
const reading = await readingInMarkerUnit(prisma, {
|
||||
marker: biomarker,
|
||||
value,
|
||||
unit,
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
});
|
||||
return apiError(
|
||||
`The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`,
|
||||
422,
|
||||
{
|
||||
errorCode: "labs.unit.mismatch",
|
||||
markerUnit: biomarker.unit,
|
||||
readingUnit: unit,
|
||||
},
|
||||
);
|
||||
if (!reading.ok) {
|
||||
annotate({
|
||||
action: { name: "labs.create.unit-mismatch" },
|
||||
meta: { biomarkerId: biomarker.id, reason: reading.reason },
|
||||
});
|
||||
return apiError(
|
||||
`The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`,
|
||||
422,
|
||||
{
|
||||
errorCode: "labs.unit.mismatch",
|
||||
markerUnit: biomarker.unit,
|
||||
readingUnit: unit,
|
||||
reason: reading.reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
storedValue = reading.value;
|
||||
storedSourceLow = reading.sourceReferenceLow;
|
||||
storedSourceHigh = reading.sourceReferenceHigh;
|
||||
if (reading.converted) {
|
||||
convertedFrom = {
|
||||
sourceValue: reading.sourceValue,
|
||||
sourceUnit: reading.sourceUnit,
|
||||
};
|
||||
annotate({
|
||||
action: { name: "labs.create.unit-converted" },
|
||||
meta: { biomarkerId: biomarker.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field assignment — never spread `parsed.data`. The row stamps the
|
||||
@@ -290,17 +317,20 @@ async function postLabResult(request: NextRequest) {
|
||||
panel: biomarker.panel,
|
||||
analyte: biomarker.name,
|
||||
// Exactly one of value / valueText is set (the Zod XOR refine).
|
||||
value: value ?? null,
|
||||
value: storedValue,
|
||||
valueText: valueText ?? null,
|
||||
unit: biomarker.unit,
|
||||
// What the report printed, when the value above is a conversion of it.
|
||||
sourceValue: convertedFrom?.sourceValue ?? null,
|
||||
sourceUnit: convertedFrom?.sourceUnit ?? null,
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
// The window this reading's own report printed, kept beside the catalog
|
||||
// stamp rather than folded into it: it governs THIS reading's verdict,
|
||||
// and the catalog band stays the net for every reading without one. A
|
||||
// qualitative reading has no window to record.
|
||||
sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null),
|
||||
sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null),
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null),
|
||||
takenAt,
|
||||
// v1.25 (iOS #36) — provenance: the on-device-OCR path posts
|
||||
|
||||
Reference in new issue
Block a user