fix(medications): every taken dose moves the stock, in units that match the product

Stock only decremented on the web card's take button — the status
toggle, the phone sync, the Telegram buttons and the token API all
recorded the dose and left the inventory untouched, which is why a
fresh pack kept reading full. Consumption now stamps the intake row
itself: every path that turns a row taken consumes through one module
— open container first, then earliest expiry with auto-open, spilling
into the next pack and flooring at zero — and every path that takes it
back (undo, delete, a take turned skip, a converged duplicate)
refunds exactly what the stamp recorded. Replays and pending sync
echoes can never double-charge, and the nightly convergence pass
refunds the rows it tombstones. A medication can declare units per
dose, so two 2 mg tablets book as one 4 mg dose and stock reads in
doses with the unit count alongside; containers carry a named type
including tablet packs, hold up to a thousand units for inhalers, and
the GLP-1 card reads the same stock as the inventory tab instead of a
separate ledger. The inventory endpoints join the documented API.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 17:42:31 +02:00
1 parent dacdcccfd2
commit 7d3557a3fd
53 files changed
+4118 -641

No files matched your search

@@ -0,0 +1,166 @@
/**
* v1.16.10 — inventory + units-per-dose validation bounds.
*
* Pins the raised unit caps (100 → 1000 on container capacity and the
* stock correction; `dosesPerUnit` rides along) and the new
* `unitsPerDose` (1–100) and `containerType` fields. The GLP-1 legacy
* ledger delta deliberately KEEPS its ±100 bound — it counts pens, not
* units.
*/
import { describe, expect, it } from "vitest";
import {
createInventoryItemSchema,
updateInventoryItemSchema,
createMedicationSchema,
updateMedicationSchema,
glp1InventoryPostSchema,
} from "../medication";
const MINIMAL_MEDICATION = {
name: "Med",
dose: "5 mg",
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
},
],
};
describe("createInventoryItemSchema — unit cap 1000", () => {
it("accepts 1000 units", () => {
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1000 });
expect(r.success).toBe(true);
});
it("rejects 1001 units", () => {
const r = createInventoryItemSchema.safeParse({ dosesTotal: 1001 });
expect(r.success).toBe(false);
});
it("rejects zero", () => {
const r = createInventoryItemSchema.safeParse({ dosesTotal: 0 });
expect(r.success).toBe(false);
});
it("accepts every container type and defaults to absent", () => {
for (const ct of [
"PEN",
"AMPOULE",
"BLISTER",
"INHALER",
"BOTTLE",
"OTHER",
]) {
const r = createInventoryItemSchema.safeParse({
dosesTotal: 4,
containerType: ct,
});
expect(r.success).toBe(true);
}
const r = createInventoryItemSchema.safeParse({ dosesTotal: 4 });
expect(r.success).toBe(true);
expect(r.success && r.data.containerType).toBeUndefined();
});
it("rejects an unknown container type", () => {
const r = createInventoryItemSchema.safeParse({
dosesTotal: 4,
containerType: "BAG",
});
expect(r.success).toBe(false);
});
});
describe("updateInventoryItemSchema — stock correction cap 1000", () => {
it("accepts 1000", () => {
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1000 });
expect(r.success).toBe(true);
});
it("rejects 1001", () => {
const r = updateInventoryItemSchema.safeParse({ dosesRemaining: 1001 });
expect(r.success).toBe(false);
});
});
describe("medication schemas — dosesPerUnit cap 1000 + unitsPerDose 1–100", () => {
it("create accepts dosesPerUnit 1000 and rejects 1001", () => {
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
dosesPerUnit: 1000,
}).success,
).toBe(true);
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
dosesPerUnit: 1001,
}).success,
).toBe(false);
});
it("update accepts dosesPerUnit 1000 and rejects 1001", () => {
expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1000 }).success).toBe(
true,
);
expect(updateMedicationSchema.safeParse({ dosesPerUnit: 1001 }).success).toBe(
false,
);
});
it("create bounds unitsPerDose to 1–100", () => {
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
unitsPerDose: 1,
}).success,
).toBe(true);
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
unitsPerDose: 100,
}).success,
).toBe(true);
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
unitsPerDose: 0,
}).success,
).toBe(false);
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
unitsPerDose: 101,
}).success,
).toBe(false);
expect(
createMedicationSchema.safeParse({
...MINIMAL_MEDICATION,
unitsPerDose: 1.5,
}).success,
).toBe(false);
});
it("update bounds unitsPerDose to 1–100", () => {
expect(updateMedicationSchema.safeParse({ unitsPerDose: 2 }).success).toBe(
true,
);
expect(updateMedicationSchema.safeParse({ unitsPerDose: 0 }).success).toBe(
false,
);
expect(
updateMedicationSchema.safeParse({ unitsPerDose: 101 }).success,
).toBe(false);
});
});
describe("glp1InventoryPostSchema — the legacy pen-ledger delta keeps ±100", () => {
it("accepts ±100 and rejects ±101", () => {
expect(glp1InventoryPostSchema.safeParse({ delta: 100, reason: "purchased" }).success).toBe(true);
expect(glp1InventoryPostSchema.safeParse({ delta: -100, reason: "damaged" }).success).toBe(true);
expect(glp1InventoryPostSchema.safeParse({ delta: 101, reason: "purchased" }).success).toBe(false);
expect(glp1InventoryPostSchema.safeParse({ delta: -101, reason: "damaged" }).success).toBe(false);
});
});
+127 -39
View File
@@ -456,8 +456,23 @@ export const createMedicationSchema = z
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
/** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
/** v1.4.25 W4d — doses per pen/vial for inventory tracking. */
dosesPerUnit: z.number().int().min(1).max(100).optional(),
/** v1.4.25 W4d — doses per pen/vial for inventory tracking.
* v1.16.10 raises the cap to 1000 (large tablet packs). */
dosesPerUnit: z.number().int().min(1).max(1000).optional(),
/**
* v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets
* for a 4 mg dose). Default 1. Dose-level readouts divide the
* unit counts by this factor.
*/
unitsPerDose: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe(
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
),
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
/**
@@ -528,7 +543,17 @@ export const updateMedicationSchema = z
dose: z.string().min(1).max(50).optional(),
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(),
dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(),
/** v1.16.10 — inventory units one dose consumes. Default 1. */
unitsPerDose: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe(
"Inventory units consumed per dose (e.g. 2 tablets of 2 mg for a 4 mg dose). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
),
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
/** v1.8.5 — per-medication injection-site tracking opt-in. */
@@ -799,41 +824,103 @@ export type BulkDeleteIntakeEventsInput = z.infer<
* USED_UP (manual override). EXPIRED is owned by the daily cron and
* the intake hook, so the PATCH schema deliberately omits it.
*/
export const createInventoryItemSchema = z.object({
dosesTotal: z.number().int().min(1).max(100),
printedExpiry: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.nullable()
.optional(),
purchasedAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.nullable()
.optional(),
notes: z.string().max(200).nullable().optional(),
});
/**
* v1.16.10 — container kinds, mirrored from the Prisma
* `MedicationContainerType` enum. Display-level classification only.
*/
export const MEDICATION_CONTAINER_TYPE_VALUES = [
"PEN",
"AMPOULE",
"BLISTER",
"INHALER",
"BOTTLE",
"OTHER",
] as const;
export type MedicationContainerTypeValue =
(typeof MEDICATION_CONTAINER_TYPE_VALUES)[number];
export const updateInventoryItemSchema = z.object({
markAsFirstUseAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
markAsUsedUp: z.boolean().optional(),
printedExpiry: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.nullable()
.optional(),
/**
* v1.16.1 — stock correction (the Bestand tab's adjust / withdraw
* flow). Sets the remaining-dose count directly; the route clamps to
* `dosesTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
* a raise out of 0 re-evaluates against the expiry clocks).
*/
dosesRemaining: z.number().int().min(0).max(100).optional(),
notes: z.string().max(200).nullable().optional(),
});
export const createInventoryItemSchema = z
.object({
/** Units the container ships with. v1.16.10 raises the cap from
* 100 to 1000 (large tablet packs); the wire field name stays
* `dosesTotal` — it counts units, mapped to doses via
* `Medication.unitsPerDose`. */
dosesTotal: z
.number()
.int()
.min(1)
.max(1000)
.describe(
"Units the container ships with (tablets / ampoules / puffs; 1–1000). Stored as `unitsTotal`; dose-derived readouts divide by the medication's `unitsPerDose`.",
),
/** v1.16.10 — container kind. Defaults to OTHER when absent. */
containerType: z
.enum(MEDICATION_CONTAINER_TYPE_VALUES)
.optional()
.describe(
"Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.",
),
printedExpiry: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.nullable()
.optional(),
purchasedAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.nullable()
.optional(),
notes: z.string().max(200).nullable().optional(),
})
.meta({
id: "CreateMedicationInventoryItemRequest",
description:
"Register a new supply container (pen / blister pack / bottle). `dosesTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.",
});
export const updateInventoryItemSchema = z
.object({
markAsFirstUseAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional()
.describe(
"Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.",
),
markAsUsedUp: z
.boolean()
.optional()
.describe(
"Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).",
),
printedExpiry: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.nullable()
.optional(),
/**
* v1.16.1 — stock correction (the Bestand tab's adjust / withdraw
* flow). Sets the remaining-unit count directly; the route clamps to
* `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP,
* a raise out of 0 re-evaluates against the expiry clocks).
* v1.16.10 raises the cap to 1000 alongside the capacity cap.
*/
dosesRemaining: z
.number()
.int()
.min(0)
.max(1000)
.optional()
.describe(
"Absolute remaining-unit correction (0–1000). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).",
),
notes: z.string().max(200).nullable().optional(),
})
.meta({
id: "UpdateMedicationInventoryItemRequest",
description:
"Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative.",
});
export type CreateInventoryItemInput = z.infer<
typeof createInventoryItemSchema
@@ -859,8 +946,9 @@ export type UpdateInventoryItemInput = z.infer<
* - `effectiveFrom` is constrained to a ±5-year window around now —
* a paper-record back-fill or a planned future step both fit, but
* "1970" / "9999" do not.
* - `delta` is a non-zero finite integer in [−100, 100] (the existing
* inventory route caps `dosesTotal` at 100 doses per pen).
* - `delta` is a non-zero finite integer in [−100, 100] — the legacy
* ledger counts pens, and ±100 pens per correction stays plenty
* (deliberately NOT raised with the v1.16.10 per-item unit cap).
* - `reason` is a bounded string (the route logs it; raw blob bad).
*/
const MAX_DOSE_MG = 100;