fix(medications): keep the inventory supply helper free of server-only logging

summariseSupply is rendered by the medication detail client surfaces, so the
v1.18.11 underflow wide-event it called pulled node:async_hooks into the client
chunk and failed the production build. Split the observability out: the helper
stays a pure clamp, a new pure detectSupplyUnderflow reports a would-be negative
pool, and the low-stock job emits the medication.inventory.underflow event at
its request-scoped seam where annotate can actually fire.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 09:53:27 +02:00
1 parent 3e578836cc
commit 7540d0b508
3 files changed
+100 -68

No files matched your search

+17
View File
@@ -64,6 +64,7 @@ import {
type RunwaySchedule,
} from "@/components/medications/detail/supply-runway";
import {
detectSupplyUnderflow,
summariseSupply,
type SupplyItem,
} from "@/lib/medications/inventory/summary";
@@ -118,6 +119,22 @@ export function evaluateMedicationRunway(
schedules: RunwaySchedule[],
): LowStockEvaluation {
const supply = summariseSupply(items, unitsPerDose);
// v1.18.11 (#31) — server-side observability for the central stock clamp.
// `summariseSupply` floors a corrupt/legacy negative pool to zero; this is
// the request-scoped seam that records the underflow so the data defect
// stays visible if it ever recurs.
const underflow = detectSupplyUnderflow(items);
if (underflow) {
annotate({
action: { name: "medication.inventory.underflow" },
meta: {
raw_units_remaining: underflow.rawUnitsRemaining,
raw_units_total: underflow.rawUnitsTotal,
clamped_units_remaining: underflow.clampedUnitsRemaining,
available_count: underflow.availableCount,
},
});
}
const perDay = estimateDailyDoseCount(schedules);
if (perDay <= 0) {
return {
@@ -10,14 +10,10 @@
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { annotate } from "@/lib/logging/context";
import {
detectSupplyUnderflow,
isAvailableSupply,
summariseSupply,
} from "@/lib/medications/inventory/summary";
@@ -90,57 +86,49 @@ 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", () => {
describe("summariseSupply / detectSupplyUnderflow — central negative-stock sanity gate (#31)", () => {
it("RCA: an available row whose capacity is NaN (corrupt Decimal) surfaces a NaN headline pre-fix — now clamped to 0, underflow detected", () => {
// 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,
);
// headline — the nonsensical Bestand the report described. The clamp
// floors it to 0 and the pure detector flags the defect for the
// server-side caller to record.
const items = [
{ state: "IN_USE", unitsTotal: Number.NaN, unitsRemaining: 3 },
] as const;
const out = summariseSupply(items, 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,
},
expect(detectSupplyUnderflow(items)).toEqual({
rawUnitsRemaining: 3,
rawUnitsTotal: null,
clampedUnitsRemaining: 3,
availableCount: 1,
});
});
it("sanity gate: a corrupt remaining (NaN) on an EXPIRED-suffix path floors the expired figure too", () => {
it("sanity gate: a corrupt remaining (NaN) on an EXPIRED-suffix path floors the expired figure, no underflow", () => {
// 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,
);
// the detector returns null for it — but the expired figure floors.
const items = [
{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: Number.NaN },
] as const;
const out = summariseSupply(items, 1);
expect(out.expiredUnits).toBe(0);
expect(out.unitsRemaining).toBe(0);
expect(annotate).not.toHaveBeenCalled();
expect(detectSupplyUnderflow(items)).toBeNull();
});
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,
);
it("fix: a healthy mixed inventory yields the correct pooled figure and no underflow", () => {
const items = [
{ state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 },
{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 },
] as const;
const out = summariseSupply(items, 2);
expect(out).toEqual({
unitsRemaining: 7,
unitsTotal: 8,
@@ -148,7 +136,7 @@ describe("summariseSupply — central negative-stock sanity gate (#31)", () => {
dosesTotal: 4,
expiredUnits: 0,
});
expect(annotate).not.toHaveBeenCalled();
expect(detectSupplyUnderflow(items)).toBeNull();
});
});
+53 -26
View File
@@ -23,13 +23,13 @@
* 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.
* point every readout flows through. The matching `medication.inventory.underflow`
* wide-event is emitted by the server-side caller (`evaluateMedicationRunway`
* in the low-stock job) via the pure `detectSupplyUnderflow` predicate below —
* this module imports no request-scoped logging so it stays safe to bundle into
* the client detail surfaces that also render from it.
*/
import { annotate } from "@/lib/logging/context";
export type SupplyItemState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
export interface SupplyItem {
@@ -51,6 +51,15 @@ export interface SupplySummary {
expiredUnits: number;
}
/** Raw figures behind a clamped pool, captured when the available pool would
* otherwise have surfaced a non-finite or negative Bestand. */
export interface SupplyUnderflow {
rawUnitsRemaining: number | null;
rawUnitsTotal: number | null;
clampedUnitsRemaining: number;
availableCount: number;
}
/** The list-route / GLP-1 availability predicate, verbatim. */
export function isAvailableSupply(item: SupplyItem): boolean {
return (
@@ -84,29 +93,11 @@ export function summariseSupply(
// 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.
// ran out). The matching underflow wide-event is emitted by the
// server-side caller through `detectSupplyUnderflow` — this helper stays
// pure so it can bundle into the client detail surfaces.
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,
@@ -117,6 +108,42 @@ export function summariseSupply(
};
}
/**
* Pure underflow detector for the available pool. Returns the raw figures
* (and the clamped result) when the available containers would have summed
* to a non-finite or negative Bestand, else `null`. No request-scoped logging
* import, so it is safe to bundle anywhere; a server caller with a request
* context turns a non-null result into the `medication.inventory.underflow`
* wide-event.
*/
export function detectSupplyUnderflow(
items: readonly SupplyItem[],
): SupplyUnderflow | null {
const available = items.filter(isAvailableSupply);
const rawUnitsRemaining = available.reduce(
(sum, item) => sum + item.unitsRemaining,
0,
);
const rawUnitsTotal = available.reduce(
(sum, item) => sum + item.unitsTotal,
0,
);
const underflowed =
!Number.isFinite(rawUnitsRemaining) ||
rawUnitsRemaining < 0 ||
!Number.isFinite(rawUnitsTotal) ||
rawUnitsTotal < 0;
if (!underflowed) return null;
return {
rawUnitsRemaining: Number.isFinite(rawUnitsRemaining)
? rawUnitsRemaining
: null,
rawUnitsTotal: Number.isFinite(rawUnitsTotal) ? rawUnitsTotal : null,
clampedUnitsRemaining: clampNonNegative(rawUnitsRemaining),
availableCount: available.length,
};
}
/** 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 {