diff --git a/src/lib/medications/inventory/__tests__/summary.test.ts b/src/lib/medications/inventory/__tests__/summary.test.ts index 965c4b417..c0d92b7f7 100644 --- a/src/lib/medications/inventory/__tests__/summary.test.ts +++ b/src/lib/medications/inventory/__tests__/summary.test.ts @@ -10,8 +10,13 @@ */ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { annotate } from "@/lib/logging/context"; import { isAvailableSupply, summariseSupply, @@ -85,6 +90,68 @@ describe("summariseSupply — expired stock is visible but never available", () }); }); +describe("summariseSupply — central negative-stock sanity gate (#31)", () => { + afterEach(() => { + vi.mocked(annotate).mockClear(); + }); + + it("RCA: an available row whose capacity is NaN (corrupt Decimal) would surface a NaN headline pre-fix — now clamped to 0 + annotated", () => { + // The per-row availability predicate gates only on `unitsRemaining > + // 0`, so a row with a real remaining but a corrupt / legacy + // `unitsTotal` (a Decimal that deserialised to NaN) DOES pool in. The + // pre-fix `reduce` then produced a NaN `unitsTotal` / `dosesTotal` + // headline — the nonsensical Bestand the report described. The gate + // floors it to 0 and fires the underflow event. + const out = summariseSupply( + [{ state: "IN_USE", unitsTotal: Number.NaN, unitsRemaining: 3 }], + 1, + ); + expect(out.unitsRemaining).toBe(3); + expect(out.unitsTotal).toBe(0); + expect(out.dosesTotal).toBe(0); + expect(annotate).toHaveBeenCalledWith({ + action: { name: "medication.inventory.underflow" }, + meta: { + raw_units_remaining: 3, + raw_units_total: null, + clamped_units_remaining: 3, + available_count: 1, + }, + }); + }); + + it("sanity gate: a corrupt remaining (NaN) on an EXPIRED-suffix path floors the expired figure too", () => { + // The expired suffix is also pooled; a corrupt EXPIRED row must not + // surface a NaN suffix. EXPIRED never reaches the available pool, so + // no underflow event fires for it — but the expired figure floors. + const out = summariseSupply( + [{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: Number.NaN }], + 1, + ); + expect(out.expiredUnits).toBe(0); + expect(out.unitsRemaining).toBe(0); + expect(annotate).not.toHaveBeenCalled(); + }); + + it("fix: a healthy mixed inventory yields the correct pooled figure and never annotates", () => { + const out = summariseSupply( + [ + { state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 }, + { state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }, + ], + 2, + ); + expect(out).toEqual({ + unitsRemaining: 7, + unitsTotal: 8, + dosesRemaining: 3, + dosesTotal: 4, + expiredUnits: 0, + }); + expect(annotate).not.toHaveBeenCalled(); + }); +}); + describe("supply surfaces — both detail-page readouts ride the shared helper (source guard)", () => { const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); diff --git a/src/lib/medications/inventory/summary.ts b/src/lib/medications/inventory/summary.ts index 18017e33f..a55e60f0f 100644 --- a/src/lib/medications/inventory/summary.ts +++ b/src/lib/medications/inventory/summary.ts @@ -12,8 +12,24 @@ * The Übersicht supply row and the Bestand summary both render from * this helper, so the surfaces cannot disagree about what "remaining" * means. + * + * v1.18.11 (#31) — this is also the single canonical sanity gate for + * the surfaced stock. A self-hoster reported the headline Bestand going + * nonsensically NEGATIVE. The container write paths floor at zero + * structurally (consumption never over-decrements, the stock-correction + * route clamps at `.min(0)` and re-runs the state machine), and the + * legacy running-sum ledger reads are clamped with `Math.max(0, …)` at + * their two call sites — but nothing guaranteed the POOLED figure this + * helper returns could not go negative from a single corrupt / legacy + * row carrying a negative `unitsRemaining` (or `unitsTotal`). Rather + * than leave each surface to re-clamp, the floor lives HERE, at the one + * point every readout flows through, and a raw-negative pool emits a + * `medication.inventory.underflow` wide-event so the underlying bug is + * observable if it ever recurs. */ +import { annotate } from "@/lib/logging/context"; + export type SupplyItemState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; export interface SupplyItem { @@ -51,14 +67,47 @@ export function summariseSupply( // per dose) must stay fractional, else the dose-derived counts halve. const perDose = unitsPerDose > 0 ? unitsPerDose : 1; const available = items.filter(isAvailableSupply); - const unitsRemaining = available.reduce( + const rawUnitsRemaining = available.reduce( (sum, item) => sum + item.unitsRemaining, 0, ); - const unitsTotal = available.reduce((sum, item) => sum + item.unitsTotal, 0); - const expiredUnits = items - .filter((item) => item.state === "EXPIRED") - .reduce((sum, item) => sum + item.unitsRemaining, 0); + const rawUnitsTotal = available.reduce( + (sum, item) => sum + item.unitsTotal, + 0, + ); + const expiredUnits = clampNonNegative( + items + .filter((item) => item.state === "EXPIRED") + .reduce((sum, item) => sum + item.unitsRemaining, 0), + ); + + // v1.18.11 (#31) — central sanity gate. A NaN / negative pool can only + // come from a corrupt or legacy row that slipped past the per-row + // availability predicate; never surface it. Clamp to zero (the dose + // ran out) and emit an underflow wide-event so the data defect stays + // observable — `annotate` is a no-op outside a request context, so the + // pure helper stays safe to call from a server component. + const unitsRemaining = clampNonNegative(rawUnitsRemaining); + const unitsTotal = clampNonNegative(rawUnitsTotal); + if ( + !Number.isFinite(rawUnitsRemaining) || + rawUnitsRemaining < 0 || + !Number.isFinite(rawUnitsTotal) || + rawUnitsTotal < 0 + ) { + annotate({ + action: { name: "medication.inventory.underflow" }, + meta: { + raw_units_remaining: Number.isFinite(rawUnitsRemaining) + ? rawUnitsRemaining + : null, + raw_units_total: Number.isFinite(rawUnitsTotal) ? rawUnitsTotal : null, + clamped_units_remaining: unitsRemaining, + available_count: available.length, + }, + }); + } + return { unitsRemaining, unitsTotal, @@ -67,3 +116,9 @@ export function summariseSupply( expiredUnits, }; } + +/** Floor a pooled figure at zero, treating a non-finite value as zero + * too — a corrupt row must never surface as `NaN` or a negative. */ +function clampNonNegative(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 0; +}