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

@@ -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),
});