fix(medications): release-QA findings on the inventory overhaul

- The legacy GLP-1 ledger reads again: GET and the Coach snapshot fall
  back to ledger-derived pen/dose figures when a medication has no
  inventory items; items always win once they exist. The delta branch
  on the POST is marked deprecated in favour of the inventory endpoints.
- Expired containers count as available nowhere: one shared supply
  summary (ACTIVE/IN_USE with units) feeds the overview row, the
  Bestand summary, the list payload, the GLP-1 card, and the runway
  estimate; expired units surface as a muted suffix instead.
- The inventory endpoints speak one wire dialect: request fields renamed
  to unitsTotal/unitsRemaining to match the responses, OpenAPI regenerated.
- The reorder dialog mirrors the page's grouping: active and inactive
  sections, each sortable within itself, so a saved order can never
  interleave them.
- Polish strings avoid the 2-4 plural trap, sub-dose quantities render
  as below one dose instead of approximately zero, and the table's name
  sort collates by the active locale instead of hardcoded German.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 19:11:53 +02:00
1 parent b765febc97
commit 9dc0ccd03a
28 files changed
+1017 -180

No files matched your search

@@ -32,6 +32,7 @@ function fakeMedication(overrides: Partial<Record<string, unknown>> = {}) {
],
unitsPerDose: 1,
inventoryItems: [],
inventoryEvents: [],
intakeEvents: [],
...overrides,
};
@@ -156,4 +157,47 @@ describe("buildGlp1SnapshotBlock", () => {
weeksOfSupplyApprox: 3,
});
});
// W1 — ledger-only accounts (the pre-item delta writer) keep their
// pen count in the Coach prompt instead of a silent omission.
it("falls back to the legacy ledger when the medication has zero items", async () => {
prismaMock.medication.findMany.mockResolvedValue([
fakeMedication({
dosesPerUnit: 4,
inventoryItems: [],
// +2 purchased, −1 used ⇒ 1 pen ⇒ 4 doses.
inventoryEvents: [{ delta: 2 }, { delta: -1 }],
}),
]);
const out = await buildGlp1SnapshotBlock("user-1");
expect(out?.medications[0].penInventory).toEqual({
pensRemaining: 1,
dosesRemaining: 4,
weeksOfSupplyApprox: 4,
});
});
it("ignores the ledger whenever items exist (items win)", async () => {
prismaMock.medication.findMany.mockResolvedValue([
fakeMedication({
dosesPerUnit: 4,
unitsPerDose: 1,
inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }],
// Stale delta history must not override the per-item truth.
inventoryEvents: [{ delta: 9 }],
}),
]);
const out = await buildGlp1SnapshotBlock("user-1");
expect(out?.medications[0].penInventory).toEqual({
pensRemaining: 1,
dosesRemaining: 4,
weeksOfSupplyApprox: 4,
});
});
it("omits pen inventory when neither items nor ledger rows exist", async () => {
prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]);
const out = await buildGlp1SnapshotBlock("user-1");
expect(out?.medications[0].penInventory).toBeNull();
});
});
+24 -12
View File
@@ -267,8 +267,12 @@ export async function buildGlp1SnapshotBlock(
schedules: true,
doseChanges: { orderBy: { effectiveFrom: "asc" } },
// v1.16.10 — pen inventory reads the per-item entities (the rows
// the intake consumption hook moves), not the legacy event ledger.
// the intake consumption hook moves). The legacy event ledger
// stays as a READ fallback for ledger-only accounts: when a
// medication has zero items, the running-sum numbers surface so
// the Coach still knows the pen count.
inventoryItems: { orderBy: { createdAt: "asc" } },
inventoryEvents: { orderBy: { occurredAt: "asc" } },
intakeEvents: {
where: { takenAt: { not: null } },
orderBy: { takenAt: "desc" },
@@ -371,14 +375,21 @@ export async function buildGlp1SnapshotBlock(
const nextInjection = predictNextInjection(schedule, lastInjection, now);
// Inventory math over the per-item entities (v1.16.10 — the same
// rows the Bestand tab and the consumption hook move; the legacy
// event ledger no longer receives writes). `pensRemaining` counts
// usable containers; `dosesRemaining` pools the units and divides
// by `unitsPerDose` so the Coach can answer "how long until my
// next refill?".
// rows the Bestand tab and the consumption hook move).
// `pensRemaining` counts usable containers; `dosesRemaining` pools
// the units and divides by `unitsPerDose` so the Coach can answer
// "how long until my next refill?". Ledger-only accounts (the
// pre-item delta writer) fall back to the running sum — items win
// whenever any exist.
let dosesRemaining: number | null = null;
let pensRemaining: number | null = null;
let weeksOfSupplyApprox: number | null = null;
const dosesPerWeek =
schedule?.cadence === "weekly"
? 1
: schedule?.cadence === "daily"
? 7
: 1;
if (med.inventoryItems.length > 0) {
const usable = med.inventoryItems.filter(
(item) =>
@@ -393,12 +404,13 @@ export async function buildGlp1SnapshotBlock(
dosesRemaining = Math.floor(
unitsRemaining / Math.max(1, med.unitsPerDose),
);
const dosesPerWeek =
schedule?.cadence === "weekly"
? 1
: schedule?.cadence === "daily"
? 7
: 1;
weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek);
} else if (med.dosesPerUnit && med.inventoryEvents.length > 0) {
// Legacy-ledger fallback — the pre-item contract verbatim: the
// delta sum counts pens, `dosesPerUnit` maps pens to doses.
const pens = med.inventoryEvents.reduce((sum, ev) => sum + ev.delta, 0);
pensRemaining = Math.max(0, pens);
dosesRemaining = pensRemaining * med.dosesPerUnit;
weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek);
}
@@ -0,0 +1,115 @@
/**
* v1.16.10 — shared supply-summary math (`summariseSupply`).
*
* Pins the release-QA disagreement scenario: a single EXPIRED pen with
* units left must read as ZERO available supply on every surface
* (Übersicht supply row, Bestand summary, list payload, GLP-1 endpoint)
* while the expired units stay visible separately. Both detail-page
* surfaces render from this helper, so the predicate cannot drift from
* the list / GLP-1 one (ACTIVE / IN_USE with units only).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
isAvailableSupply,
summariseSupply,
} from "@/lib/medications/inventory/summary";
describe("summariseSupply — expired stock is visible but never available", () => {
it("one expired pen with units ⇒ 0 available, expired units surfaced separately", () => {
const out = summariseSupply(
[{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }],
1,
);
expect(out).toEqual({
unitsRemaining: 0,
unitsTotal: 0,
dosesRemaining: 0,
dosesTotal: 0,
expiredUnits: 4,
});
});
it("pools only ACTIVE / IN_USE containers with units left", () => {
const out = summariseSupply(
[
{ state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 },
{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 },
{ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 },
{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
],
2,
);
// 7 available units / 2 per dose = 3 doses; capacity 8 units = 4 doses.
expect(out).toEqual({
unitsRemaining: 7,
unitsTotal: 8,
dosesRemaining: 3,
dosesTotal: 4,
expiredUnits: 4,
});
});
it("a drained (used-up) container counts nowhere", () => {
const out = summariseSupply(
[{ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }],
1,
);
expect(out).toEqual({
unitsRemaining: 0,
unitsTotal: 0,
dosesRemaining: 0,
dosesTotal: 0,
expiredUnits: 0,
});
});
it("matches the list-route availability predicate per state", () => {
expect(
isAvailableSupply({ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }),
).toBe(true);
expect(
isAvailableSupply({ state: "IN_USE", unitsTotal: 4, unitsRemaining: 1 }),
).toBe(true);
expect(
isAvailableSupply({ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }),
).toBe(false);
expect(
isAvailableSupply({ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }),
).toBe(false);
expect(
isAvailableSupply({ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 0 }),
).toBe(false);
});
});
describe("supply surfaces — both detail-page readouts ride the shared helper (source guard)", () => {
const read = (rel: string) =>
readFileSync(join(process.cwd(), rel), "utf8");
it("the Übersicht supply row sums through summariseSupply and feeds the runway with the available-only figure", () => {
const src = read(
"src/components/medications/detail/MedicationDetailTabs.tsx",
);
expect(src).toContain('from "@/lib/medications/inventory/summary"');
expect(src).toMatch(/summariseSupply\(inventoryItems, perDose\)/);
expect(src).toMatch(
/estimateRunwayDays\(dosesRemaining, medication\.schedules\)/,
);
// No hand-rolled "everything but USED_UP" filter remains.
expect(src).not.toContain('state !== "USED_UP"');
expect(src).toContain("medications.detail.bestand.expiredSuffix");
});
it("the Bestand summary sums through summariseSupply and surfaces the expired suffix", () => {
const src = read(
"src/components/medications/sections/inventory-section.tsx",
);
expect(src).toContain('from "@/lib/medications/inventory/summary"');
expect(src).toMatch(/summariseSupply\(items, perDose\)/);
expect(src).not.toContain('state !== "USED_UP"');
expect(src).toContain("medications.detail.bestand.expiredSuffix");
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* v1.16.10 — shared supply-summary math for every stock readout.
*
* One predicate decides what counts as AVAILABLE supply: ACTIVE or
* IN_USE containers with units left — the same filter the medications
* list route (`stockUnitsRemaining`), the GLP-1 detail endpoint and the
* Coach snapshot run. EXPIRED stock is visible but never available:
* it surfaces separately (`expiredUnits`) so the UI can show a muted
* "expired" suffix without folding it into the headline or the runway
* estimate. USED_UP containers count nowhere.
*
* The Übersicht supply row and the Bestand summary both render from
* this helper, so the surfaces cannot disagree about what "remaining"
* means.
*/
export type SupplyItemState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
export interface SupplyItem {
state: SupplyItemState;
unitsTotal: number;
unitsRemaining: number;
}
export interface SupplySummary {
/** Pooled units across available (ACTIVE / IN_USE, units left) containers. */
unitsRemaining: number;
/** Pooled capacity across the same available containers. */
unitsTotal: number;
/** Dose-derived headline: floor(unitsRemaining / unitsPerDose). */
dosesRemaining: number;
/** Dose-derived capacity: floor(unitsTotal / unitsPerDose). */
dosesTotal: number;
/** Units still sitting in EXPIRED containers — visible, never available. */
expiredUnits: number;
}
/** The list-route / GLP-1 availability predicate, verbatim. */
export function isAvailableSupply(item: SupplyItem): boolean {
return (
(item.state === "ACTIVE" || item.state === "IN_USE") &&
item.unitsRemaining > 0
);
}
export function summariseSupply(
items: readonly SupplyItem[],
unitsPerDose: number,
): SupplySummary {
const perDose = Math.max(1, unitsPerDose);
const available = items.filter(isAvailableSupply);
const unitsRemaining = 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);
return {
unitsRemaining,
unitsTotal,
dosesRemaining: Math.floor(unitsRemaining / perDose),
dosesTotal: Math.floor(unitsTotal / perDose),
expiredUnits,
};
}
+2 -2
View File
@@ -1005,7 +1005,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Register a new supply container",
description:
"Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `dosesTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.",
"Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.",
requestParams: {
path: z.object({ id: z.string() }),
},
@@ -1040,7 +1040,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Mutate a supply container",
description:
"Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`dosesRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.",
"Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.",
requestParams: {
path: z.object({ id: z.string(), itemId: z.string() }),
},
@@ -2,10 +2,11 @@
* v1.16.10 — inventory + units-per-dose validation bounds.
*
* Pins the raised unit caps (100 → 1000 on container capacity and the
* stock correction; `dosesPerUnit` rides along) and the new
* `unitsPerDose` (1–100) and `containerType` fields. The GLP-1 legacy
* ledger delta deliberately KEEPS its ±100 bound — it counts pens, not
* units.
* stock correction; `dosesPerUnit` rides along), the new
* `unitsPerDose` (1–100) and `containerType` fields, and the symmetric
* request field names (`unitsTotal` / `unitsRemaining` — the response
* always carried them). The GLP-1 legacy ledger delta deliberately
* KEEPS its ±100 bound — it counts pens, not units.
*/
import { describe, expect, it } from "vitest";
@@ -30,17 +31,17 @@ const MINIMAL_MEDICATION = {
describe("createInventoryItemSchema — unit cap 1000", () => {
it("accepts 1000 units", () => {
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1000 });
const r = createInventoryItemSchema.safeParse({ unitsTotal: 1000 });
expect(r.success).toBe(true);
});
it("rejects 1001 units", () => {
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1001 });
const r = createInventoryItemSchema.safeParse({ unitsTotal: 1001 });
expect(r.success).toBe(false);
});
it("rejects zero", () => {
const r = createInventoryItemSchema.safeParse({ dosesTotal: 0 });
const r = createInventoryItemSchema.safeParse({ unitsTotal: 0 });
expect(r.success).toBe(false);
});
@@ -54,19 +55,19 @@ describe("createInventoryItemSchema — unit cap 1000", () => {
"OTHER",
]) {
const r = createInventoryItemSchema.safeParse({
dosesTotal: 4,
unitsTotal: 4,
containerType: ct,
});
expect(r.success).toBe(true);
}
const r = createInventoryItemSchema.safeParse({ dosesTotal: 4 });
const r = createInventoryItemSchema.safeParse({ unitsTotal: 4 });
expect(r.success).toBe(true);
expect(r.success && r.data.containerType).toBeUndefined();
});
it("rejects an unknown container type", () => {
const r = createInventoryItemSchema.safeParse({
dosesTotal: 4,
unitsTotal: 4,
containerType: "BAG",
});
expect(r.success).toBe(false);
@@ -75,12 +76,12 @@ describe("createInventoryItemSchema — unit cap 1000", () => {
describe("updateInventoryItemSchema — stock correction cap 1000", () => {
it("accepts 1000", () => {
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1000 });
const r = updateInventoryItemSchema.safeParse({ unitsRemaining: 1000 });
expect(r.success).toBe(true);
});
it("rejects 1001", () => {
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1001 });
const r = updateInventoryItemSchema.safeParse({ unitsRemaining: 1001 });
expect(r.success).toBe(false);
});
});
+21 -8
View File
@@ -842,16 +842,16 @@ export type MedicationContainerTypeValue =
export const createInventoryItemSchema = z
.object({
/** Units the container ships with. v1.16.10 raises the cap from
* 100 to 1000 (large tablet packs); the wire field name stays
* `dosesTotal` — it counts units, mapped to doses via
* 100 to 1000 (large tablet packs) and renames the wire field to
* `unitsTotal` — it counts units, mapped to doses via
* `Medication.unitsPerDose`. */
dosesTotal: z
unitsTotal: z
.number()
.int()
.min(1)
.max(1000)
.describe(
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived readouts divide by the medication's `unitsPerDose`.",
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Dose-derived readouts divide by the medication's `unitsPerDose`.",
),
/** v1.16.10 — container kind. Defaults to OTHER when absent. */
containerType: z
@@ -875,7 +875,7 @@ export const createInventoryItemSchema = z
.meta({
id: "CreateMedicationInventoryItemRequest",
description:
"Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.",
"Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.",
});
export const updateInventoryItemSchema = z
@@ -903,9 +903,11 @@ export const updateInventoryItemSchema = z
* flow). Sets the remaining-unit count directly; the route clamps to
* `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
* a raise out of 0 re-evaluates against the expiry clocks).
* v1.16.10 raises the cap to 1000 alongside the capacity cap.
* v1.16.10 raises the cap to 1000 alongside the capacity cap and
* renames the wire field to `unitsRemaining` (it always counted
* units), matching the response side.
*/
dosesRemaining: z
unitsRemaining: z
.number()
.int()
.min(0)
@@ -972,6 +974,14 @@ export const glp1DoseChangePostSchema = z.object({
note: z.string().max(MAX_NOTE_CHARS).nullable().optional(),
});
/**
* DEPRECATED write path (v1.16.10) — the `inventory.delta` branch feeds
* the legacy `MedicationInventoryEvent` running-sum ledger. The per-item
* endpoints (`POST /api/medications/[id]/inventory`,
* `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; reads
* fall back to the ledger only while a medication has zero inventory
* items. New callers must register containers instead of posting deltas.
*/
export const glp1InventoryPostSchema = z.object({
delta: z
.number()
@@ -979,7 +989,10 @@ export const glp1InventoryPostSchema = z.object({
.finite()
.min(-100)
.max(100)
.refine((n) => n !== 0, { message: "delta must be non-zero" }),
.refine((n) => n !== 0, { message: "delta must be non-zero" })
.describe(
"Deprecated since v1.16.10: pen-count delta on the legacy running-sum ledger. Register containers via the inventory endpoints instead; reads use the ledger only while the medication has no inventory items.",
),
reason: z.string().min(1).max(MAX_REASON_CHARS),
});