fix(medications): decrement stock on CSV intake import

The CSV intake import recorded taken doses but never consumed inventory,
the only intake seam that skipped stock consumption, so runway and
days-left overstated for any imported history. Import now calls
`consumeForIntake` per freshly created event, matching the web-take,
status-toggle and bulk paths: the consumption stamp keeps it exactly-once,
the existing duplicate-key skip means a re-import never re-creates the row,
and the hook no-ops for as-needed / no-inventory medications.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 10:35:03 +02:00
1 parent 25a7834dec
commit c21338df45
2 files changed
+77 -1

No files matched your search

@@ -24,6 +24,12 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
@@ -44,6 +50,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -154,3 +161,58 @@ describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43
expect(res.status).toBe(422);
});
});
describe("POST /api/medications/[id]/intake/import — inventory consumption", () => {
beforeEach(() => {
// Fresh imports: no pre-existing dedup row, and create echoes an id.
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue(
null as never,
);
let n = 0;
vi.mocked(prisma.medicationIntakeEvent.create).mockImplementation(
(async () => ({ id: `evt-${++n}`, takenAt: new Date() })) as never,
);
});
it("consumes stock once per freshly imported taken dose", async () => {
const res = await POST(
postReq([
{ datum: "2026-01-01", uhrzeit: "07:00:00" },
{ datum: "2026-01-01", uhrzeit: "19:00:00" },
{ datum: "2026-01-02", uhrzeit: "07:00:00" },
]),
ROUTE_CTX,
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { imported: number } };
expect(body.data.imported).toBe(3);
expect(consumeForIntake).toHaveBeenCalledTimes(3);
const call = vi.mocked(consumeForIntake).mock.calls[0]?.[0];
expect(call?.medicationId).toBe("m1");
expect(call?.userId).toBe("user-1");
expect(call?.eventId).toBe("evt-1");
});
it("does not consume for duplicate (already-imported) rows", async () => {
// First entry is a duplicate (existing dedup row), second is fresh.
vi.mocked(prisma.medicationIntakeEvent.findUnique)
.mockResolvedValueOnce({ id: "existing" } as never)
.mockResolvedValueOnce(null as never);
const res = await POST(
postReq([
{ datum: "2026-01-01", uhrzeit: "07:00:00" },
{ datum: "2026-01-01", uhrzeit: "19:00:00" },
]),
ROUTE_CTX,
);
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: { imported: number; skippedDuplicates: number };
};
expect(body.data.imported).toBe(1);
expect(body.data.skippedDuplicates).toBe(1);
// Only the fresh row consumes — the duplicate never re-creates an event,
// so re-import cannot double-decrement.
expect(consumeForIntake).toHaveBeenCalledTimes(1);
});
});
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
recomputeMedicationComplianceForDay,
@@ -121,7 +122,7 @@ export const POST = apiHandler(
continue;
}
await prisma.medicationIntakeEvent.create({
const created = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
@@ -133,6 +134,19 @@ export const POST = apiHandler(
},
});
imported++;
// v1.16.13 — CSV import was the one intake seam that recorded a taken
// dose without decrementing tracked stock, so runway / days-left
// overstated. Consume exactly like the other taken paths: the stamp on
// the freshly-created event makes it exactly-once, the duplicate skip
// above means a re-import never re-creates the row (no double-decrement),
// and `consumeForIntake` no-ops for as-needed / no-inventory medications.
await consumeForIntake({
client: prisma,
userId: user.id,
medicationId: id,
eventId: created.id,
intakeAt: created.takenAt ?? takenAt,
});
touchedDays.add(dayKeyForScheduledFor(takenAt, user.timezone));
}