fix(medications): make inventory Bestand summary server-authoritative

The medication detail clients computed the supply headline in the browser
via summariseSupply, risking web/iOS drift and pulling the shared math
into the client bundle. The GET /api/medications/[id]/inventory route now
computes the canonical SupplySummary server-side through summariseSupply
(the single source of truth) and ships it in the DTO; the Bestand and
Übersicht surfaces render the ready figures. Adds the MedicationSupplySummary
schema to the inventory list response and regenerates the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:19:41 +02:00
1 parent 7540d0b508
commit 2386d1fe78
10 files changed
+292 -60

No files matched your search

@@ -7,12 +7,19 @@
* suffix — matching the list payload and the GLP-1 endpoint instead of
* counting expired stock as supply.
*
* v1.19.0 (iOS#25) — the headline is now server-authoritative: the GET
* response carries a `summary` computed server-side via `summariseSupply`,
* and the component RENDERS it (no client-side derivation). The seed
* builds that summary with the same canonical helper the server uses, so
* these scenarios assert the component renders the server DTO faithfully.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { summariseSupply } from "@/lib/medications/inventory/summary";
const useQueryMock = vi.fn();
@@ -35,9 +42,27 @@ function render(node: React.ReactNode): string {
);
}
function seedItems(items: unknown[]) {
function seedItems(
items: Array<{
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
unitsTotal: number;
unitsRemaining: number;
[key: string]: unknown;
}>,
unitsPerDose = 1,
) {
// The server computes the canonical summary; reproduce it here with the
// one source of truth so the seed mirrors the real DTO the route ships.
const summary = summariseSupply(
items.map((i) => ({
state: i.state,
unitsTotal: i.unitsTotal,
unitsRemaining: i.unitsRemaining,
})),
unitsPerDose,
);
useQueryMock.mockReturnValue({
data: { items, meta: { total: items.length } },
data: { items, summary, meta: { total: items.length } },
isLoading: false,
});
}
@@ -72,7 +72,7 @@ import { SideEffectsSection } from "@/components/medications/side-effects-sectio
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TitrationTimeline } from "@/components/medications/titration-timeline";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { summariseSupply } from "@/lib/medications/inventory/summary";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { useQuery } from "@tanstack/react-query";
@@ -335,9 +335,8 @@ export function MedicationDetailTabs({
return await apiGet<{
items: Array<{
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
unitsTotal: number;
unitsRemaining: number;
}>;
summary?: SupplySummary;
} | null>(`/api/medications/${id}/inventory`);
} catch {
return null;
@@ -348,26 +347,34 @@ export function MedicationDetailTabs({
});
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
// v1.16.10 — items count UNITS; the supply readout and the runway are
// dose-derived (floor over the pooled units — consumption spills
// across containers, so the pool is the honest dose count). The raw
// unit tally renders as secondary text when a dose spans > 1 unit.
// Availability rides the shared summary helper (ACTIVE / IN_USE with
// units only — the list / GLP-1 semantic); expired stock surfaces as
// a separate muted suffix and never feeds the runway.
// v1.16.12 — guard at > 0, NOT ≥ 1, so a fractional unitsPerDose (½
// tablet per dose) stays fractional instead of halving the dose counts.
const perDose =
medication.unitsPerDose && medication.unitsPerDose > 0
? medication.unitsPerDose
: 1;
// v1.19.0 (iOS#25) — the supply headline is server-authoritative: the
// GET response carries a `summary` computed server-side through the
// shared `summariseSupply` helper (ACTIVE / IN_USE with units only —
// the list / GLP-1 semantic; EXPIRED surfaced separately, never
// available; dose-derived counts floored over the pooled units). The
// Übersicht status row renders these ready figures, so web and iOS read
// identical numbers from the same DTO. The runway estimate is a UI
// projection over the canonical `dosesRemaining`.
const {
unitsRemaining,
unitsTotal,
dosesRemaining,
dosesTotal,
expiredUnits,
} = summariseSupply(inventoryItems, perDose);
} = inventory?.summary ?? {
unitsRemaining: 0,
unitsTotal: 0,
dosesRemaining: 0,
dosesTotal: 0,
expiredUnits: 0,
};
// v1.16.12 — fractional unitsPerDose (½ tablet per dose) drives the
// "show the raw unit tally" toggle below; the canonical figures above
// already account for it server-side.
const perDose =
medication.unitsPerDose && medication.unitsPerDose > 0
? medication.unitsPerDose
: 1;
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
return (
@@ -76,7 +76,7 @@ import {
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { summariseSupply } from "@/lib/medications/inventory/summary";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
/**
* Invalidate every read key whose payload reflects a medication's supply
@@ -127,6 +127,11 @@ interface InventoryItem {
interface InventoryResponse {
items: InventoryItem[];
// v1.19.0 (iOS#25) — server-authoritative supply summary. The headline
// figures are computed server-side via the shared `summariseSupply`
// helper and shipped ready; the client renders them rather than
// re-deriving in the browser, so web and iOS agree on the Bestand.
summary?: SupplySummary;
meta?: { total: number };
}
@@ -232,28 +237,19 @@ export function InventorySection({
}
const items = Array.isArray(data?.items) ? data.items : [];
// Available supply: ACTIVE / IN_USE containers with units left — the
// shared summary helper keeps this row, the Übersicht row, the list
// payload and the GLP-1 endpoint on one predicate. Expired stock is
// never available; it renders as a separate muted suffix.
// Dose-derived headline (floor — a partial dose is not a dose).
// v1.18.3 (iOS#31) — an unknown-units row (null) contributes nothing to
// the available-supply math; it shows "—" in its own row but cannot pad
// the pooled headline. Coalesce null → 0 only for the summary inputs.
const {
unitsRemaining: remainingUnits,
unitsTotal: totalUnits,
dosesRemaining: remaining,
dosesTotal: total,
expiredUnits,
} = summariseSupply(
items.map((item) => ({
state: item.state,
unitsTotal: item.unitsTotal ?? 0,
unitsRemaining: item.unitsRemaining ?? 0,
})),
perDose,
);
// v1.19.0 (iOS#25) — the supply headline is server-authoritative: the
// GET response carries a `summary` computed server-side through the
// shared `summariseSupply` helper (ACTIVE / IN_USE with units left;
// EXPIRED surfaced separately, never available; dose-derived counts
// floored). The client renders these ready figures so web and iOS show
// identical numbers. The per-item rows below still divide by `perDose`
// locally, which is presentation only — the canonical pool is the DTO.
const summary = data?.summary;
const remainingUnits = summary?.unitsRemaining ?? 0;
const totalUnits = summary?.unitsTotal ?? 0;
const remaining = summary?.dosesRemaining ?? 0;
const total = summary?.dosesTotal ?? 0;
const expiredUnits = summary?.expiredUnits ?? 0;
const dialogs = (
<>