From c21338df45d6379aeffe78041fa875bcbbd72adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:35:03 +0200 Subject: [PATCH] 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. --- .../intake/import/__tests__/route.test.ts | 62 +++++++++++++++++++ .../medications/[id]/intake/import/route.ts | 16 ++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index b8199b8ab..780a4aa49 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 96e618490..be0ab8373 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -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)); }