fix(medications): clamp pooled inventory stock at zero

Add a central sanity gate in summariseSupply, the single helper every
supply readout flows through. A corrupt or legacy row with a non-finite
or negative unit count could pool straight into the headline and surface
a nonsensical negative Bestand; the helper now floors the pooled
remaining, total and expired figures at zero and emits a
medication.inventory.underflow wide-event when a raw value would have
gone negative, so the underlying data defect stays observable.

closes #31
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 00:25:25 +02:00
1 parent 6975849bec
commit ea314b40f9
2 files changed
+128 -6

No files matched your search

@@ -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");
+60 -5
View File
@@ -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;
}