fix(medications): clear the pending clock deadlines the repair would have missed

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 18:00:08 +02:00
1 parent 48b8071a67
commit f226a907cb
2 files changed
+68 -14

No files matched your search

@@ -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')
@@ -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, {