feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit, bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot, FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export needs veterinary-medicines:read. - Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with watermark, PRINT audit before the PDF, check endpoints; digit rules only from the seeded ComplianceSettings row (fail closed). - Restricted zone (Berlin day): create, update, move, merge and CSV import blocked inside an active restriction; override with reason and compliance:manage, audited, never stored on the colony; split, harvest and dissolve stay free; restriction details only with compliance:read. - Authority notices: settings, herd snapshot, registration/herd change/TSK notices with one open draft per kind under the settings lock. - Label check refinements (geometric reading order, number|unit pairs). - ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c18860c75c
commit
edc193f128
73 files changed
+9470
-171
No files matched your search
@@ -648,4 +648,443 @@ describe('Treatments (e2e)', () => {
|
||||
403,
|
||||
));
|
||||
});
|
||||
|
||||
describe('treatments from a veterinary medicine lot', () => {
|
||||
const suffix = Date.now();
|
||||
let medicineId: string;
|
||||
let otherMedicineId: string;
|
||||
let lotA: string;
|
||||
let lotB: string;
|
||||
let otherMedicineLot: string;
|
||||
let emptyLot: string;
|
||||
let expiredLot: string;
|
||||
let colonyIds: string[];
|
||||
let restrictedRoleId: string;
|
||||
let restrictedUserId: string;
|
||||
let restrictedToken: string;
|
||||
const restrictedEmail = `treatments-lot-e2e-restricted-${suffix}@example.com`;
|
||||
|
||||
const post = (token: string, path: string, body: object, key?: string) => {
|
||||
const call = request(app.getHttpServer())
|
||||
.post(`/api/v1/treatments${path}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
return (key === undefined ? call : call.set('Idempotency-Key', key)).send(
|
||||
body,
|
||||
);
|
||||
};
|
||||
const patch = (id: string, body: object) =>
|
||||
request(app.getHttpServer())
|
||||
.patch(`/api/v1/treatments/${id}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send(body);
|
||||
const movementsOf = async (lotId: string) =>
|
||||
(
|
||||
await prisma.veterinaryMedicineMovement.findMany({
|
||||
where: { lotId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
).map((movement) => movement.quantity);
|
||||
const restOf = async (lotId: string) => {
|
||||
const lot = await prisma.veterinaryMedicineLot.findUniqueOrThrow({
|
||||
where: { id: lotId },
|
||||
});
|
||||
const sum = (await movementsOf(lotId)).reduce((a, b) => a + b, 0);
|
||||
return Math.round((lot.quantity + sum) * 1000) / 1000;
|
||||
};
|
||||
const lotData = (id: string, extra: object = {}) => ({
|
||||
medicineId: id,
|
||||
purchasedOn: new Date('2026-01-10'),
|
||||
quantity: 100,
|
||||
supplier: 'Apotheke Z, Teststr. 1',
|
||||
purchaseProof: 'Rechnung 42',
|
||||
batchNumber: 'CH-LOT',
|
||||
...extra,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const medicine = await prisma.veterinaryMedicine.create({
|
||||
data: { name: `Lot-Mittel ${suffix}`, unit: 'ML' },
|
||||
});
|
||||
medicineId = medicine.id;
|
||||
otherMedicineId = (
|
||||
await prisma.veterinaryMedicine.create({
|
||||
data: { name: `Anderes Mittel ${suffix}`, unit: 'ML' },
|
||||
})
|
||||
).id;
|
||||
lotA = (
|
||||
await prisma.veterinaryMedicineLot.create({ data: lotData(medicineId) })
|
||||
).id;
|
||||
lotB = (
|
||||
await prisma.veterinaryMedicineLot.create({ data: lotData(medicineId) })
|
||||
).id;
|
||||
otherMedicineLot = (
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: lotData(otherMedicineId),
|
||||
})
|
||||
).id;
|
||||
emptyLot = (
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: lotData(medicineId, { quantity: 10 }),
|
||||
})
|
||||
).id;
|
||||
expiredLot = (
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: lotData(medicineId, { expiresOn: new Date('2025-01-31') }),
|
||||
})
|
||||
).id;
|
||||
colonyIds = [];
|
||||
for (const code of ['TL-1', 'TL-2', 'TL-3']) {
|
||||
colonyIds.push(
|
||||
(
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `${code}-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
})
|
||||
).id,
|
||||
);
|
||||
}
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `TREATMENT_ONLY_${suffix}`,
|
||||
name: 'Treatment only',
|
||||
permissions: {
|
||||
create: [{ permission: { connect: { key: 'treatments:create' } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
restrictedRoleId = role.id;
|
||||
restrictedUserId = (
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: restrictedEmail,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: 'Treatment only',
|
||||
role: { connect: { id: role.id } },
|
||||
},
|
||||
})
|
||||
).id;
|
||||
restrictedToken = (await login(app, restrictedEmail, password))
|
||||
.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const treatmentIds = (
|
||||
await prisma.treatment.findMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((treatment) => treatment.id);
|
||||
await prisma.veterinaryMedicineMovement.deleteMany({
|
||||
where: { treatmentId: { in: treatmentIds } },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
aggregateType: 'Treatment',
|
||||
aggregateId: { in: treatmentIds },
|
||||
},
|
||||
});
|
||||
await prisma.treatment.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.veterinaryMedicineLot.deleteMany({
|
||||
where: { medicineId: { in: [medicineId, otherMedicineId] } },
|
||||
});
|
||||
await prisma.veterinaryMedicine.deleteMany({
|
||||
where: { id: { in: [medicineId, otherMedicineId] } },
|
||||
});
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.idempotencyRecord.deleteMany({
|
||||
where: { userId: { in: [beekeeperId, restrictedUserId] } },
|
||||
});
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: restrictedUserId },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId: restrictedUserId } });
|
||||
await prisma.user.deleteMany({ where: { id: restrictedUserId } });
|
||||
await prisma.role.deleteMany({ where: { id: restrictedRoleId } });
|
||||
});
|
||||
|
||||
it('editing dose and lot books only the delta and the movements sum to the new dose', async () => {
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-01',
|
||||
medicineLotId: lotA,
|
||||
doseQuantity: 30,
|
||||
}).expect(201);
|
||||
const body = created.body as {
|
||||
id: string;
|
||||
product: string;
|
||||
medicineId: string;
|
||||
supplier: string | null;
|
||||
batchNumber: string | null;
|
||||
};
|
||||
expect(body).toMatchObject({
|
||||
product: `Lot-Mittel ${suffix}`,
|
||||
medicineId,
|
||||
supplier: null,
|
||||
batchNumber: null,
|
||||
});
|
||||
expect(await movementsOf(lotA)).toEqual([-30]);
|
||||
|
||||
await patch(body.id, { doseQuantity: 20 }).expect(200);
|
||||
expect(await movementsOf(lotA)).toEqual([-30, 10]);
|
||||
expect(await restOf(lotA)).toBe(80);
|
||||
|
||||
await patch(body.id, { notes: 'nur Notiz' }).expect(200);
|
||||
expect(await movementsOf(lotA)).toEqual([-30, 10]);
|
||||
|
||||
await patch(body.id, { medicineLotId: lotB }).expect(200);
|
||||
expect(await movementsOf(lotA)).toEqual([-30, 10, 20]);
|
||||
expect(await movementsOf(lotB)).toEqual([-20]);
|
||||
expect(await restOf(lotA)).toBe(100);
|
||||
expect(await restOf(lotB)).toBe(80);
|
||||
|
||||
await patch(body.id, { doseQuantity: null }).expect(200);
|
||||
expect(await restOf(lotB)).toBe(100);
|
||||
const stored = await prisma.treatment.findUniqueOrThrow({
|
||||
where: { id: body.id },
|
||||
});
|
||||
expect(stored.medicineLotId).toBe(lotB);
|
||||
expect(stored.doseQuantity).toBeNull();
|
||||
|
||||
// Dose again: books -15 on lot B, the counter-bookings stay.
|
||||
await patch(body.id, { doseQuantity: 15 }).expect(200);
|
||||
expect(await restOf(lotB)).toBe(85);
|
||||
});
|
||||
|
||||
it('rejects the invalid combinations with 400 and books nothing', async () => {
|
||||
const before = await movementsOf(lotA);
|
||||
const base = { colonyId: colonyIds[0], date: '2026-08-02' };
|
||||
await post(beekeeperToken, '', {
|
||||
...base,
|
||||
medicineLotId: lotA,
|
||||
supplier: 'Eigener Text',
|
||||
}).expect(400);
|
||||
await post(beekeeperToken, '', {
|
||||
...base,
|
||||
medicineId: medicineId,
|
||||
medicineLotId: otherMedicineLot,
|
||||
}).expect(400);
|
||||
await post(beekeeperToken, '', {
|
||||
...base,
|
||||
medicineLotId: lotA,
|
||||
doseQuantity: 0,
|
||||
}).expect(400);
|
||||
await post(beekeeperToken, '', {
|
||||
...base,
|
||||
medicineId,
|
||||
product: 'Etwas anderes',
|
||||
}).expect(400);
|
||||
await post(beekeeperToken, '', { ...base }).expect(400);
|
||||
expect(await movementsOf(lotA)).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects medicineId null while the treatment has a lot', async () => {
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[1],
|
||||
date: '2026-08-03',
|
||||
medicineLotId: lotA,
|
||||
}).expect(201);
|
||||
await patch((created.body as { id: string }).id, {
|
||||
medicineId: null,
|
||||
}).expect(400);
|
||||
});
|
||||
|
||||
it('a repeated bulk request with the same Idempotency-Key books once', async () => {
|
||||
const key = `treatments-lot-bulk-${suffix}`;
|
||||
const body = {
|
||||
date: '2026-08-05',
|
||||
medicineLotId: lotB,
|
||||
doseQuantity: 5,
|
||||
colonyIds: [colonyIds[1], colonyIds[2]],
|
||||
};
|
||||
const restBefore = await restOf(lotB);
|
||||
|
||||
const first = await post(beekeeperToken, '/bulk', body, key).expect(201);
|
||||
const replay = await post(beekeeperToken, '/bulk', body, key).expect(201);
|
||||
|
||||
expect((replay.body as { bulkId: string }).bulkId).toBe(
|
||||
(first.body as { bulkId: string }).bulkId,
|
||||
);
|
||||
expect(await restOf(lotB)).toBe(restBefore - 10);
|
||||
expect(
|
||||
await prisma.veterinaryMedicineMovement.count({
|
||||
where: {
|
||||
lotId: lotB,
|
||||
treatment: { colonyId: { in: [colonyIds[1], colonyIds[2]] } },
|
||||
},
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('a bulk request with an unknown colony books nothing', async () => {
|
||||
const restBefore = await restOf(lotB);
|
||||
await post(beekeeperToken, '/bulk', {
|
||||
date: '2026-08-06',
|
||||
medicineLotId: lotB,
|
||||
doseQuantity: 5,
|
||||
colonyIds: [colonyIds[1], 'does-not-exist'],
|
||||
}).expect(400);
|
||||
expect(await restOf(lotB)).toBe(restBefore);
|
||||
});
|
||||
|
||||
it('a treatment from an empty lot is stored and the rest turns negative', async () => {
|
||||
await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[2],
|
||||
date: '2026-08-07',
|
||||
medicineLotId: emptyLot,
|
||||
doseQuantity: 12,
|
||||
}).expect(201);
|
||||
|
||||
expect(await restOf(emptyLot)).toBe(-2);
|
||||
});
|
||||
|
||||
it('a treatment after the lot expired is stored', async () => {
|
||||
await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[2],
|
||||
date: '2026-08-08',
|
||||
medicineLotId: expiredLot,
|
||||
doseQuantity: 3,
|
||||
}).expect(201);
|
||||
|
||||
expect(await restOf(expiredLot)).toBe(97);
|
||||
});
|
||||
|
||||
it('a user with treatments:create but without veterinary-medicines:read can book from a lot', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/veterinary-medicine-lots')
|
||||
.set('Authorization', `Bearer ${restrictedToken}`)
|
||||
.expect(403);
|
||||
|
||||
await post(restrictedToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-09',
|
||||
medicineLotId: lotA,
|
||||
doseQuantity: 1,
|
||||
}).expect(201);
|
||||
|
||||
expect((await movementsOf(lotA)).at(-1)).toBe(-1);
|
||||
});
|
||||
|
||||
it('a long edit sequence keeps every lot and the treatment total consistent', async () => {
|
||||
const [lotX, lotY, lotZ] = await Promise.all(
|
||||
[1, 2, 3].map(
|
||||
async () =>
|
||||
(
|
||||
await prisma.veterinaryMedicineLot.create({
|
||||
data: lotData(medicineId),
|
||||
})
|
||||
).id,
|
||||
),
|
||||
);
|
||||
const lots = [lotX, lotY, lotZ];
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-12',
|
||||
medicineLotId: lotX,
|
||||
doseQuantity: 10,
|
||||
}).expect(201);
|
||||
const id = (created.body as { id: string }).id;
|
||||
|
||||
// After every step: the movements per lot sum to -dose on the current
|
||||
// lot and to 0 on all others; over all lots they sum to -dose (0 when
|
||||
// the lot is null).
|
||||
const expectState = async (
|
||||
currentLot: string | null,
|
||||
dose: number | null,
|
||||
) => {
|
||||
const wanted = currentLot !== null && dose !== null ? dose : 0;
|
||||
let total = 0;
|
||||
for (const lot of lots) {
|
||||
const sum = Math.round(
|
||||
(await movementsOf(lot)).reduce((a, b) => a + b, 0) * 1000,
|
||||
);
|
||||
expect(sum / 1000).toBe(lot === currentLot ? 0 - wanted : 0);
|
||||
total += sum;
|
||||
}
|
||||
expect(total / 1000).toBe(0 - wanted);
|
||||
const stored = await prisma.treatment.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
expect(stored.medicineLotId).toBe(currentLot);
|
||||
expect(stored.doseQuantity).toBe(dose);
|
||||
};
|
||||
|
||||
await expectState(lotX, 10);
|
||||
await patch(id, { doseQuantity: 12.5 }).expect(200);
|
||||
await expectState(lotX, 12.5);
|
||||
await patch(id, { medicineLotId: lotY }).expect(200);
|
||||
await expectState(lotY, 12.5);
|
||||
await patch(id, { medicineLotId: lotZ }).expect(200);
|
||||
await expectState(lotZ, 12.5);
|
||||
await patch(id, { medicineLotId: null }).expect(200);
|
||||
await expectState(null, 12.5);
|
||||
await patch(id, { medicineLotId: lotX }).expect(200);
|
||||
await expectState(lotX, 12.5);
|
||||
await patch(id, { doseQuantity: 4 }).expect(200);
|
||||
await expectState(lotX, 4);
|
||||
await patch(id, { doseQuantity: null }).expect(200);
|
||||
await expectState(lotX, null);
|
||||
});
|
||||
|
||||
it('a repeated single POST with the same Idempotency-Key books exactly one movement', async () => {
|
||||
const key = `treatments-lot-single-${suffix}`;
|
||||
const body = {
|
||||
colonyId: colonyIds[1],
|
||||
date: '2026-08-13',
|
||||
medicineLotId: expiredLot,
|
||||
doseQuantity: 1.5,
|
||||
};
|
||||
const restBefore = await restOf(expiredLot);
|
||||
|
||||
const first = await post(beekeeperToken, '', body, key).expect(201);
|
||||
const replay = await post(beekeeperToken, '', body, key).expect(201);
|
||||
|
||||
expect((replay.body as { id: string }).id).toBe(
|
||||
(first.body as { id: string }).id,
|
||||
);
|
||||
expect(await restOf(expiredLot)).toBe(restBefore - 1.5);
|
||||
expect(
|
||||
await prisma.veterinaryMedicineMovement.count({
|
||||
where: { treatmentId: (first.body as { id: string }).id },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('re-filing the treatment to another colony leaves the stock untouched', async () => {
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-10',
|
||||
medicineLotId: lotA,
|
||||
doseQuantity: 2,
|
||||
}).expect(201);
|
||||
const restBefore = await restOf(lotA);
|
||||
|
||||
await patch((created.body as { id: string }).id, {
|
||||
colonyId: colonyIds[1],
|
||||
}).expect(200);
|
||||
|
||||
expect(await restOf(lotA)).toBe(restBefore);
|
||||
});
|
||||
|
||||
it('corrects the apiary of a treatment and answers 404 for an unknown one', async () => {
|
||||
const created = await post(beekeeperToken, '', {
|
||||
colonyId: colonyIds[0],
|
||||
date: '2026-08-11',
|
||||
product: 'Ameisensäure',
|
||||
}).expect(201);
|
||||
const id = (created.body as { id: string }).id;
|
||||
|
||||
await patch(id, { apiaryId: 'does-not-exist' }).expect(404);
|
||||
await patch(id, { apiaryId: null }).expect(400);
|
||||
const fixed = await patch(id, { apiaryId }).expect(200);
|
||||
expect((fixed.body as { apiaryId: string }).apiaryId).toBe(apiaryId);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user