mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user