From f226a907cbe8c3937f7ea4d5a8af6353f74c6612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 10:50:52 +0200 Subject: [PATCH] fix(medications): clear the pending clock deadlines the repair would have missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass of migration 0276 restored the rows the in-use clock had already flipped, but every clock-less container still IN_USE carried the same clock-derived `expires_at`, most of them with the deadline still ahead. The daily expire scan would have worked through them over the following weeks and reproduced the whole defect one row at a time. A clock-less container's `expires_at` is now set from its printed expiry — usually nothing, which is how it stays out of that scan. Pens and ampoules keep theirs; their clock is real. --- .../migration.sql | 33 ++++++++++--- ...ntory-expiry-container-type-repair.test.ts | 49 ++++++++++++++++--- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql b/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql index 9424627b3..bc63cc098 100644 --- a/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql +++ b/prisma/migrations/0276_inventory_expiry_container_type_repair/migration.sql @@ -1,5 +1,6 @@ -- Restore supply containers that the in-use clock wrote off although they --- never carried one. +-- never carried one, and drop the deadlines that would have written off the +-- rest of them over the following weeks. -- -- Until this release the state machine started a 30-day post-opening clock -- for every container as soon as `first_use_at` was set, including the @@ -8,8 +9,26 @@ -- the row went EXPIRED, its units dropped out of the available pool, and the -- supply card read "0 of 0 doses" beside a count of expired units. -- --- The rows to restore are the ones that can ONLY have reached EXPIRED through --- that clock: +-- Two things have to be undone. The rows already flipped need their state +-- back, and every clock-less container still carries a persisted `expires_at` +-- derived from that clock — including the ones whose deadline has not landed +-- yet. Leaving those would let the daily expire scan repeat the whole defect +-- on its own schedule. + +-- (1) A clock-less container's only legitimate deadline is its printed +-- expiry, so that is what `expires_at` becomes. For the common row that is +-- NULL, which is precisely how it stays out of the daily expire scan. PEN and +-- AMPOULE are left alone: their post-opening clock is real, so the deadline +-- they carry is earned. +UPDATE "medication_inventory_items" +SET + "expires_at" = "printed_expiry", + "updated_at" = NOW() +WHERE "container_type" NOT IN ('PEN', 'AMPOULE') + AND "expires_at" IS DISTINCT FROM "printed_expiry"; + +-- (2) The rows that already flipped. They are the ones that can ONLY have +-- reached EXPIRED through that clock: -- * `container_type` is one of the kinds that has no post-opening clock -- (everything except PEN and AMPOULE — see IN_USE_CLOCK_CONTAINER_TYPES -- in src/lib/medications/inventory/state-machine.ts), @@ -26,17 +45,15 @@ -- * any row whose printed expiry has genuinely lapsed, whatever its kind; -- * rows with no units left (their state is a USED_UP question, not this -- one); --- * every other column. No date is invented and no printed expiry is --- fabricated: a row that recorded no expiry keeps recording none, which --- is why `expires_at` is set from `printed_expiry` rather than computed. +-- * the state of every row that is not EXPIRED, and every other column. +-- No date is invented and no printed expiry is fabricated: a row that +-- recorded no expiry keeps recording none. UPDATE "medication_inventory_items" SET "state" = CASE WHEN "first_use_at" IS NOT NULL THEN 'IN_USE'::"medication_inventory_state" ELSE 'ACTIVE'::"medication_inventory_state" END, - -- The only deadline a clock-less container can carry is its printed one. - "expires_at" = "printed_expiry", "updated_at" = NOW() WHERE "state" = 'EXPIRED' AND "container_type" NOT IN ('PEN', 'AMPOULE') diff --git a/tests/integration/inventory-expiry-container-type-repair.test.ts b/tests/integration/inventory-expiry-container-type-repair.test.ts index 216703aee..2ee6868c9 100644 --- a/tests/integration/inventory-expiry-container-type-repair.test.ts +++ b/tests/integration/inventory-expiry-container-type-repair.test.ts @@ -224,25 +224,62 @@ describe("0276 inventory expiry container-type repair — integration", () => { }); it.each(["ACTIVE", "IN_USE", "USED_UP"] as const)( - "leaves a %s row untouched", + "leaves the state of a %s row alone", async (state) => { const prisma = getPrismaClient(); - const expiresAt = new Date(Date.now() + 10 * DAY_MS); const id = await seedItem(prisma, medicationId, { containerType: "BLISTER", state, unitsRemaining: state === "USED_UP" ? 0 : 54, - expiresAt, + expiresAt: new Date(Date.now() + 10 * DAY_MS), }); await prisma.$executeRawUnsafe(REPAIR_SQL); - const row = await readItem(prisma, id); - expect(row.state).toBe(state); - expect(row.expiresAt).toEqual(expiresAt); + expect((await readItem(prisma, id)).state).toBe(state); }, ); + it("clears the pending clock deadline on a still-IN_USE blister", async () => { + // The rows the flip has not reached yet. Their `expires_at` is the + // old firstUseAt + 30 days, still in the future, and the daily + // expire scan would have written them off on schedule. + const prisma = getPrismaClient(); + const openedAt = new Date(Date.now() - 10 * DAY_MS); + const id = await seedItem(prisma, medicationId, { + containerType: "BLISTER", + state: "IN_USE", + firstUseAt: openedAt, + printedExpiry: null, + expiresAt: new Date(openedAt.getTime() + 30 * DAY_MS), + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + const row = await readItem(prisma, id); + expect(row.state).toBe("IN_USE"); + expect(row.expiresAt).toBeNull(); + }); + + it("leaves a pen's pending clock deadline in place", async () => { + const prisma = getPrismaClient(); + const openedAt = new Date(Date.now() - 10 * DAY_MS); + const expiresAt = new Date(openedAt.getTime() + 30 * DAY_MS); + const id = await seedItem(prisma, medicationId, { + containerType: "PEN", + state: "IN_USE", + firstUseAt: openedAt, + printedExpiry: null, + expiresAt, + }); + + await prisma.$executeRawUnsafe(REPAIR_SQL); + + const row = await readItem(prisma, id); + expect(row.state).toBe("IN_USE"); + expect(row.expiresAt).toEqual(expiresAt); + }); + it("is idempotent — a second application changes nothing", async () => { const prisma = getPrismaClient(); const id = await seedItem(prisma, medicationId, {