mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
3e578836cc
commit
7540d0b508
3 files changed
+100
-68
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user