fix(labs): refuse a reading whose unit differs from its marker's

A numeric lab reading was stored with its value as received and the marker's
unit stamped on it, so 5.6 mmol/L against a marker kept in mg/dL became
5.6 mg/dL and read as far below range. Both the manual/API write and the
scan commit did this; nothing compared the two units.

- POST /api/labs returns 422 labs.unit.mismatch, with the marker's unit and
  the reading's unit in meta, and writes nothing. The unit is compared only
  when one was sent: on the biomarkerId path it may be omitted, and the
  marker's unit is then the stated one. A qualitative reading has no unit.
- The scan commit skips such a row with reason unit_mismatch, so the outcome
  reads partial or failed instead of a clean save, and the toast names the
  analytes concerned rather than saying "duplicates".
- The extract response carries the matched marker's unit. The review screen
  shows a calm badge on a row in another unit, starts it unticked like a
  duplicate, and treats it as a blocking error on the unit field if it is
  ticked anyway.
- A small lab-specific normaliser forgives spelling only: spacing, the micro
  prefix, the case of the litre, and a unit printed in capitals for a short
  list where capitals cannot mean another unit. Case is otherwise significant,
  so mIU/L and MIU/L, or g/L and G/L, are never merged.

The document-commit path already refuses a mismatch and is left as it is.
Readings stored wrongly before this cannot be identified, because the
original unit was never kept; they are not touched.

Adds EN and DE strings; the other locales are left to the maintainers. The
OpenAPI spec and the error-code catalogue carry the new code.

Fixes #1094
This commit is contained in:
BernhardBuckel committed 2026-10-03 15:44:51 +02:00
1 parent 556bd6d6cd
commit d3916036c2
21 files changed
+854 -35

No files matched your search

+38 -15
View File
@@ -14063,7 +14063,16 @@ paths:
"401": *a1
"403": *a10
"409": *a7
"422": *a4
"422":
description: Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue
under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one
its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent;
with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven
(`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a11
/api/labs/{id}:
get:
@@ -14264,9 +14273,9 @@ paths:
- Labs
summary: Commit confirmed Lab-OCR rows
description: 'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped
biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped.
Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body
field.'
biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading, or states a unit
other than that of its marker, is skipped (`skipped[].reason`). Idempotent (Idempotency-Key). Audits as
`labs.ocr.commit`. `userId` is narrowed from the session, never a body field.'
parameters:
- *a6
requestBody:
@@ -31757,15 +31766,16 @@ components:
labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge,
labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid,
labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected,
labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile —
managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
managed_profile.guardian.required, managed_profile.not_found; measurement — measurement.batch.invalid,
measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid,
measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large,
measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source;
medication — medication.intake.import.invalid_format; medications —
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch,
labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
labs.update.qualitativeExpected, labs.update.referenceRangeInvalid,
labs.update.sourceReferenceRangeInvalid; managed_profile — managed_profile.guardian.duplicate,
managed_profile.guardian.managed_invitee, managed_profile.guardian.required, managed_profile.not_found;
measurement — measurement.batch.invalid, measurement.batch.source_not_permitted,
measurement.batch.too_large, measurement.bulk-delete.invalid, measurement.create.invalid,
measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp,
measurement.restore.invalid, measurement.update.server_owned_source; medication —
medication.intake.import.invalid_format; medications —
medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid,
medications.intake.bulk.too_large, medications.intake.force_slot.invalid,
medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed,
@@ -63372,6 +63382,13 @@ components:
enum:
- new
- existing
markerUnit:
anyOf:
- type: string
- type: "null"
description: "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this
row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason:
unit_mismatch`, and the review screen marks it before Save."
duplicateOf:
anyOf:
- type: string
@@ -63387,6 +63404,7 @@ components:
- takenAt
- confidence
- biomarkerMatch
- markerUnit
- duplicateOf
additionalProperties: false
description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side:
@@ -63427,7 +63445,11 @@ components:
type: string
reason:
type: string
const: duplicate
enum:
- duplicate
- unit_mismatch
description: "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other
than the one its marker is tracked in, so it was not written."
required:
- analyte
- reason
@@ -63447,7 +63469,8 @@ components:
- skipped
- outcome
additionalProperties: false
description: 'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.'
description: 'The rows written (`source: "OCR"`), those skipped (commit-time duplicates, or a unit that differs from
that of the marker), and the resolved outcome.'
OcrCommittedLabResult:
type: object
properties: