diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index e922491e7..22f7f6f96 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -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 { diff --git a/src/lib/medications/inventory/__tests__/summary.test.ts b/src/lib/medications/inventory/__tests__/summary.test.ts index c0d92b7f7..edf6bcfb4 100644 --- a/src/lib/medications/inventory/__tests__/summary.test.ts +++ b/src/lib/medications/inventory/__tests__/summary.test.ts @@ -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(); }); }); diff --git a/src/lib/medications/inventory/summary.ts b/src/lib/medications/inventory/summary.ts index a55e60f0f..d1d5c6f21 100644 --- a/src/lib/medications/inventory/summary.ts +++ b/src/lib/medications/inventory/summary.ts @@ -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 {