feat(medications): fractional dosing via decimal units per dose (#316)

A dose can now consume a fraction of a unit — a half, third or quarter
tablet for a split pill. The wizard offers a curated fraction set beside
the whole numbers (the most error-resistant input); units-per-dose and the
per-container counts widen from Int to Decimal, so a 30-tablet pack dosed
at a half reads 29.5 after one dose and the days-left projection follows.
The consumption ledger stamps and refunds the exact fraction, and existing
whole-number doses are untouched. OpenAPI regenerated — the three unit
fields move from integer to number — and the units-per-dose hint is
updated across the six locales.

Also anchors a first-use date relative to now in an inventory route test
that had aged past its 30-day in-use window.
This commit is contained in:
Marc-André Bombeck committed 2026-06-13 16:07:50 +02:00
1 parent 5ce3e81972
commit f4dec8c28f
30 files changed
+510 -136

No files matched your search

+3 -3
View File
@@ -92,15 +92,15 @@ export const GET = apiHandler(
const usable = medication.inventoryItems.filter(
(item) =>
(item.state === "ACTIVE" || item.state === "IN_USE") &&
item.unitsRemaining > 0,
Number(item.unitsRemaining) > 0,
);
const unitsRemaining = usable.reduce(
(sum, item) => sum + item.unitsRemaining,
(sum, item) => sum + Number(item.unitsRemaining),
0,
);
const pensRemaining = usable.length;
const dosesRemaining = Math.floor(
unitsRemaining / Math.max(1, medication.unitsPerDose),
unitsRemaining / (Number(medication.unitsPerDose) || 1),
);
const weeksOfSupply = dosesRemaining;
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
@@ -27,6 +27,10 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/medications/inventory/service", () => ({
computeExpiresAt: vi.fn().mockReturnValue(null),
buildPatchInventoryUpdate: vi.fn().mockReturnValue({}),
// v1.16.12 — the route serialises its Decimal unit columns to numbers
// on the way out; a passthrough keeps these update-logic assertions
// focused on the Prisma call, not the response shape.
serializeInventoryItem: <T,>(item: T) => item,
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
@@ -35,6 +35,7 @@ import {
computeExpiresAt,
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
@@ -83,7 +84,10 @@ export const PATCH = apiHandler(
// commutative — applying them in any order produces the same row.
let nextFirstUseAt = existing.firstUseAt;
let nextState = existing.state;
let nextUnitsRemaining = existing.unitsRemaining;
// v1.16.12 — Decimal column; work in JS numbers locally (the
// arithmetic below stays well within double precision) and let
// Prisma re-widen on write.
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
let nextPrintedExpiry = existing.printedExpiry;
if (markAsFirstUseAt) {
@@ -103,7 +107,7 @@ export const PATCH = apiHandler(
// state-machine re-run below owns every state consequence (0 ⇒
// USED_UP, a raise out of 0 re-evaluates against the expiry
// clocks).
nextUnitsRemaining = Math.min(unitsRemaining, existing.unitsTotal);
nextUnitsRemaining = Math.min(unitsRemaining, Number(existing.unitsTotal));
}
if (markAsUsedUp === true) {
@@ -126,7 +130,7 @@ export const PATCH = apiHandler(
nextState = computeInventoryState(
{
state: nextState,
unitsTotal: existing.unitsTotal,
unitsTotal: Number(existing.unitsTotal),
unitsRemaining: nextUnitsRemaining,
firstUseAt: nextFirstUseAt,
printedExpiry: nextPrintedExpiry,
@@ -166,7 +170,7 @@ export const PATCH = apiHandler(
meta: { medication_id: id, prev: existing.state, next: updated.state },
});
return apiSuccess(updated);
return apiSuccess(serializeInventoryItem(updated));
},
);
@@ -28,7 +28,10 @@ import {
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { createInventoryItemSchema } from "@/lib/validations/medication";
import { buildCreateInventoryInput } from "@/lib/medications/inventory/service";
import {
buildCreateInventoryInput,
serializeInventoryItem,
} from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
type RouteParams = { params: Promise<{ id: string }> };
@@ -54,7 +57,10 @@ export const GET = apiHandler(
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
return apiSuccess({
items: items.map(serializeInventoryItem),
meta: { total: items.length },
});
},
);
@@ -128,6 +134,6 @@ export const POST = apiHandler(
meta: { medication_id: id },
});
return apiSuccess(created, 201);
return apiSuccess(serializeInventoryItem(created), 201);
},
);
+2
View File
@@ -178,6 +178,7 @@ export const GET = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
category,
nextDueAt: display ? display.at.toISOString() : null,
nextDueOverdue: display?.overdue ?? false,
@@ -710,6 +711,7 @@ export const PUT = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
category: normalizedCategory,
});
},
+6 -2
View File
@@ -185,7 +185,7 @@ async function buildMedicationsList(
for (const entry of usableStock) {
usableUnitsByMedId.set(
entry.medicationId,
entry._sum.unitsRemaining ?? 0,
Number(entry._sum.unitsRemaining ?? 0),
);
}
const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId));
@@ -233,9 +233,12 @@ async function buildMedicationsList(
const stockDosesRemaining =
stockUnitsRemaining === null
? null
: Math.floor(stockUnitsRemaining / Math.max(1, m.unitsPerDose));
: Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1));
return {
...m,
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
// the string Prisma would otherwise serialise a Decimal to.
unitsPerDose: Number(m.unitsPerDose),
category: categoryMap[m.id] ?? "OTHER",
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
@@ -489,6 +492,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess(
{
...medication,
unitsPerDose: Number(medication.unitsPerDose),
category: normalizedCategory,
},
201,