feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
- Readiness: all 18 checks (medicine lots, treatment log gaps, labels, harvest withdrawal), medicine subjects masked without veterinary-medicines:read, report PDF (counts only), summary cached 60 s, 409 codes READINESS_*. - Frontend: /compliance page with acknowledge/revoke, dashboard line and nav; veterinary medicine catalogue, lots, movement book with inventory and disposal; label designer check panel, print card with gap list and proof print, food business master data, product legal name. - Service worker: cacheability is path-based (the backend sends no-store on every answer); compliance, medicine, restriction, notice and label check/print paths and restricted document kinds are never cached. - ARCHITECTURE.md §2, §12.10–12.11, Annahmen 86–90. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
b912d29166
commit
b4746a2b17
149 files changed
+11869
-320
No files matched your search
@@ -5,7 +5,11 @@ import { Test } from '@nestjs/testing';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { berlinToday, dayToDate } from '../src/common/dates';
|
||||
import { addDays, berlinToday, dayToDate } from '../src/common/dates';
|
||||
import {
|
||||
buildTreatmentLog,
|
||||
treatmentLogGaps,
|
||||
} from '../src/export/treatment-log';
|
||||
import type {
|
||||
ReadinessItem,
|
||||
ReadinessReport,
|
||||
@@ -20,6 +24,7 @@ import { configureApp } from '../src/app.config';
|
||||
import { THROTTLER_NAMESPACE } from '../src/common/throttler/prisma-throttler.storage';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { login } from './utils/api';
|
||||
import { pdfText } from './utils/pdf-text';
|
||||
|
||||
const READINESS = '/api/v1/compliance/readiness';
|
||||
const ACKNOWLEDGEMENTS = '/api/v1/compliance/acknowledgements';
|
||||
@@ -428,6 +433,10 @@ describe('Compliance readiness (e2e)', () => {
|
||||
});
|
||||
|
||||
expect(stale.status).toBe(409);
|
||||
expect(stale.body).toMatchObject({
|
||||
code: 'READINESS_FINGERPRINT_STALE',
|
||||
message: 'Der Punkt hat sich geändert. Bitte neu laden.',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const createdItem = created.body as ReadinessItem;
|
||||
expect(createdItem.state).toBe('ACKNOWLEDGED');
|
||||
@@ -458,7 +467,12 @@ describe('Compliance readiness (e2e)', () => {
|
||||
.set(as('beekeeper'));
|
||||
expect((await revoke()).status).toBe(201);
|
||||
expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN');
|
||||
expect((await revoke()).status).toBe(409);
|
||||
const again = await revoke();
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.body).toMatchObject({
|
||||
code: 'READINESS_ACK_REVOKED',
|
||||
message: 'Die Quittung ist schon zurückgenommen.',
|
||||
});
|
||||
const row = await prisma.complianceAcknowledgement.findUniqueOrThrow({
|
||||
where: { id: acknowledgementId },
|
||||
});
|
||||
@@ -466,6 +480,21 @@ describe('Compliance readiness (e2e)', () => {
|
||||
expect(row.revokedByUserId).not.toBeNull();
|
||||
});
|
||||
|
||||
it('acknowledging a subject that is not open answers 409 READINESS_ITEM_NOT_OPEN', async () => {
|
||||
const response = await acknowledge({
|
||||
itemKey: SOFT_KEY,
|
||||
subjectId: 'no-such-subject',
|
||||
fingerprint: 'a'.repeat(64),
|
||||
note: 'Geprüft und in Ordnung.',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body).toMatchObject({
|
||||
code: 'READINESS_ITEM_NOT_OPEN',
|
||||
message: 'Der Punkt hat sich geändert. Bitte neu laden.',
|
||||
});
|
||||
});
|
||||
|
||||
it('revoking an unknown acknowledgement is a 404', async () => {
|
||||
const response = await request(server())
|
||||
.post(`${ACKNOWLEDGEMENTS}/does-not-exist/revoke`)
|
||||
@@ -474,4 +503,457 @@ describe('Compliance readiness (e2e)', () => {
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('medicine, label and harvest areas', () => {
|
||||
const area = Date.now();
|
||||
const today = berlinToday();
|
||||
const created = {
|
||||
treatments: [] as string[],
|
||||
lots: [] as string[],
|
||||
harvests: [] as string[],
|
||||
};
|
||||
let colonyId: string;
|
||||
let medicineId: string;
|
||||
let lotOk: string;
|
||||
let lotSmall: string;
|
||||
let lotOld: string;
|
||||
let treatmentOk: string;
|
||||
let treatmentSmall: string;
|
||||
let treatmentOld: string;
|
||||
let harvestInside: string;
|
||||
let harvestAfter: string;
|
||||
let productId: string;
|
||||
let templateId: string;
|
||||
let userId: string;
|
||||
let extractionId: string;
|
||||
let honeyBatchId: string;
|
||||
let bottlingId: string;
|
||||
let medicineName: string;
|
||||
|
||||
const textElement = (id: string, y: number, key: string, size = 9) => ({
|
||||
id,
|
||||
type: 'text',
|
||||
x: 2,
|
||||
y,
|
||||
width: 80,
|
||||
height: 8,
|
||||
text: '',
|
||||
placeholderKey: key,
|
||||
fontSize: size,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
});
|
||||
|
||||
const itemsOf = (body: ReadinessReport, key: string) =>
|
||||
body.items.filter((item) => item.key === key);
|
||||
const openSubjectIds = (body: ReadinessReport, key: string) =>
|
||||
itemsOf(body, key)
|
||||
.filter((item) => item.state === 'OPEN')
|
||||
.flatMap((item) => [
|
||||
item.subjectId,
|
||||
...item.subjects.map((subject) => subject.id),
|
||||
]);
|
||||
|
||||
async function postTreatment(body: Record<string, unknown>) {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/treatments')
|
||||
.set(as('beekeeper'))
|
||||
.send({ colonyId, date: today, ...body });
|
||||
expect(response.status).toBe(201);
|
||||
const id = (response.body as { id: string }).id;
|
||||
created.treatments.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function makeLot(patch: Record<string, unknown>) {
|
||||
const lot = await prisma.veterinaryMedicineLot.create({
|
||||
data: {
|
||||
medicineId,
|
||||
purchasedOn: dayToDate('2026-01-10'),
|
||||
quantity: 100,
|
||||
supplier: 'Apotheke E2E',
|
||||
purchaseProof: 'Beleg E2E',
|
||||
expiresOn: dayToDate('2099-01-01'),
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
created.lots.push(lot.id);
|
||||
return lot.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const owner = await prisma.user.findFirstOrThrow({
|
||||
where: { email: `readiness-e2e-beekeeper-${suffix}@example.com` },
|
||||
});
|
||||
userId = owner.id;
|
||||
colonyId = (
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `RA-${area}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
medicineName = `Readiness-Mittel ${area}`;
|
||||
medicineId = (
|
||||
await prisma.veterinaryMedicine.create({
|
||||
data: { name: medicineName, unit: 'ML', withdrawalPeriodDays: 0 },
|
||||
})
|
||||
).id;
|
||||
lotOk = await makeLot({});
|
||||
lotSmall = await makeLot({ quantity: 10 });
|
||||
lotOld = await makeLot({
|
||||
quantity: 5,
|
||||
expiresOn: dayToDate('2020-01-01'),
|
||||
});
|
||||
treatmentOk = await postTreatment({
|
||||
medicineLotId: lotOk,
|
||||
doseQuantity: 30,
|
||||
withdrawalPeriodDays: 0,
|
||||
});
|
||||
treatmentSmall = await postTreatment({
|
||||
medicineLotId: lotSmall,
|
||||
doseQuantity: 30,
|
||||
withdrawalPeriodDays: 0,
|
||||
});
|
||||
treatmentOld = await postTreatment({
|
||||
medicineLotId: lotOld,
|
||||
doseQuantity: 1,
|
||||
withdrawalPeriodDays: 0,
|
||||
});
|
||||
await postTreatment({
|
||||
product: `Wartezeit-Mittel ${area}`,
|
||||
amount: '10 ml',
|
||||
supplier: 'Apotheke E2E',
|
||||
purchaseProof: 'Beleg E2E',
|
||||
withdrawalPeriodDays: 10,
|
||||
});
|
||||
harvestInside = (
|
||||
await prisma.harvest.create({
|
||||
data: { apiaryId, date: dayToDate(today), userId },
|
||||
})
|
||||
).id;
|
||||
harvestAfter = (
|
||||
await prisma.harvest.create({
|
||||
data: { apiaryId, date: dayToDate(addDays(today, 20)), userId },
|
||||
})
|
||||
).id;
|
||||
created.harvests.push(harvestInside, harvestAfter);
|
||||
for (const harvestId of created.harvests) {
|
||||
await prisma.harvestColony.create({ data: { harvestId, colonyId } });
|
||||
}
|
||||
// A complete label: its own bottling (far in the future, so it is the
|
||||
// newest one with stock) and a product with a legal name.
|
||||
productId = (
|
||||
await prisma.product.create({
|
||||
data: { name: `Readiness-Glas ${area}`, legalName: 'Blütenhonig' },
|
||||
})
|
||||
).id;
|
||||
extractionId = (
|
||||
await prisma.extraction.create({ data: { date: new Date(), userId } })
|
||||
).id;
|
||||
honeyBatchId = (
|
||||
await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `RA-${area}`,
|
||||
extractionId,
|
||||
amount: 25,
|
||||
initialAmount: 25,
|
||||
},
|
||||
})
|
||||
).id;
|
||||
bottlingId = (
|
||||
await prisma.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId,
|
||||
productId,
|
||||
date: new Date('2099-06-01T00:00:00.000Z'),
|
||||
jarSize: 500,
|
||||
jarCount: 2,
|
||||
totalAmount: 1,
|
||||
bestBeforeDate: new Date('2101-06-01T00:00:00.000Z'),
|
||||
lotNumber: `RA-L-${area}`,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
).id;
|
||||
templateId = (
|
||||
await prisma.labelTemplate.create({
|
||||
data: {
|
||||
name: `Readiness-Vorlage ${area}`,
|
||||
elements: [
|
||||
textElement('legal', 1, 'legalName'),
|
||||
textElement('net', 10, 'jarSize', 16),
|
||||
textElement('mhd', 20, 'bestBeforeLine'),
|
||||
textElement('lot', 29, 'lotLine'),
|
||||
textElement('origin', 38, 'originCountry'),
|
||||
textElement('firm', 47, 'foodBusinessName'),
|
||||
textElement('address', 56, 'foodBusinessAddress'),
|
||||
],
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
},
|
||||
})
|
||||
).id;
|
||||
await setSite({
|
||||
holderName: 'Imker E2E',
|
||||
holderAddress: 'Weg 1, 12345 Ort',
|
||||
foodBusinessName: 'Imkerei E2E',
|
||||
foodBusinessAddress: 'Weg 2, 12345 Ort',
|
||||
});
|
||||
await prisma.complianceSettings.upsert({
|
||||
where: { id: 'singleton' },
|
||||
update: {},
|
||||
create: { id: 'singleton' },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.veterinaryMedicineMovement.deleteMany({
|
||||
where: { lotId: { in: created.lots } },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
aggregateType: 'Treatment',
|
||||
aggregateId: { in: created.treatments },
|
||||
},
|
||||
});
|
||||
await prisma.treatment.deleteMany({
|
||||
where: { id: { in: created.treatments } },
|
||||
});
|
||||
await prisma.veterinaryMedicineLot.deleteMany({
|
||||
where: { id: { in: created.lots } },
|
||||
});
|
||||
await prisma.veterinaryMedicine.deleteMany({
|
||||
where: { id: medicineId },
|
||||
});
|
||||
await prisma.harvestColony.deleteMany({
|
||||
where: { harvestId: { in: created.harvests } },
|
||||
});
|
||||
await prisma.harvest.deleteMany({
|
||||
where: { id: { in: created.harvests } },
|
||||
});
|
||||
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingId } });
|
||||
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
|
||||
await prisma.extraction.deleteMany({ where: { id: extractionId } });
|
||||
await prisma.labelTemplate.deleteMany({ where: { id: templateId } });
|
||||
await prisma.product.deleteMany({ where: { id: productId } });
|
||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||
});
|
||||
|
||||
it('complete data in every area leaves no open item for the subjects of this test and no open item of the Stammdaten rules', async () => {
|
||||
await setVetOffice('Amt E2E');
|
||||
const body = await report();
|
||||
const keys = [
|
||||
'treatment-log-gaps',
|
||||
'treatment-location-uncertain',
|
||||
'lot-gaps',
|
||||
'lot-prescription',
|
||||
'lot-expired-stock',
|
||||
'treatment-expired-lot',
|
||||
'label-template-gaps',
|
||||
'label-template-form',
|
||||
'medicine-withdrawal-missing',
|
||||
];
|
||||
const ownIds = [
|
||||
treatmentOk,
|
||||
treatmentSmall,
|
||||
lotOk,
|
||||
lotSmall,
|
||||
medicineId,
|
||||
templateId,
|
||||
harvestAfter,
|
||||
];
|
||||
|
||||
for (const key of ['holder-data', 'food-business-data', 'vet-office']) {
|
||||
expect(itemOf(body, key).state).toBe('DONE');
|
||||
}
|
||||
for (const key of keys) {
|
||||
const open = openSubjectIds(body, key);
|
||||
for (const id of ownIds) {
|
||||
expect({ key, id, open: open.includes(id) }).toEqual({
|
||||
key,
|
||||
id,
|
||||
open: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('a treatment from a complete lot gives a Bestandsbuch row without FEHLT and no treatment-log-gaps item', async () => {
|
||||
const year = Number(today.slice(0, 4));
|
||||
const log = await buildTreatmentLog(prisma, year, apiaryId);
|
||||
const rows = log.sheets
|
||||
.flatMap((sheet) => sheet.rows)
|
||||
.filter((row) => row.lotId === lotOk);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(treatmentLogGaps(rows[0])).toEqual([]);
|
||||
const open = openSubjectIds(await report(), 'treatment-log-gaps');
|
||||
expect(open).not.toContain(treatmentOk);
|
||||
expect(open).not.toContain(lotOk);
|
||||
});
|
||||
|
||||
it('the gap names of an incomplete treatment equal the gaps the Bestandsbuch row reports', async () => {
|
||||
// Dated in the first year of the lookback: the item lists at most 50
|
||||
// subjects and the shared database holds other treatments.
|
||||
const year = Number(today.slice(0, 4)) - 5;
|
||||
const id = await postTreatment({
|
||||
date: `${year}-01-01`,
|
||||
product: `Luecken-Mittel ${area}`,
|
||||
withdrawalPeriodDays: 0,
|
||||
});
|
||||
|
||||
const item = itemOf(await report(), 'treatment-log-gaps', String(year));
|
||||
const subject = item.subjects.find((entry) => entry.id === id);
|
||||
const log = await buildTreatmentLog(prisma, year, apiaryId);
|
||||
const row = log.sheets
|
||||
.flatMap((sheet) => sheet.rows)
|
||||
.find((entry) => entry.product === `Luecken-Mittel ${area}`)!;
|
||||
const names = {
|
||||
SUPPLIER: 'Lieferant',
|
||||
PROOF: 'Beleg',
|
||||
AMOUNT: 'Menge',
|
||||
WITHDRAWAL: 'Wartezeit',
|
||||
VET: 'Tierarzt',
|
||||
};
|
||||
|
||||
expect(item.state).toBe('OPEN');
|
||||
expect(subject).toMatchObject({ type: 'TREATMENT', parentId: colonyId });
|
||||
expect(subject!.missing).toEqual(
|
||||
treatmentLogGaps(row).map((code) => names[code]),
|
||||
);
|
||||
expect(JSON.stringify(item)).not.toContain('Luecken-Mittel');
|
||||
});
|
||||
|
||||
it('a negative rest after a treatment from a too small lot opens lot-negative', async () => {
|
||||
const body = await report();
|
||||
|
||||
expect(itemOf(body, 'lot-negative').state).toBe('OPEN');
|
||||
expect(openSubjectIds(body, 'lot-negative')).toContain(lotSmall);
|
||||
expect(openSubjectIds(body, 'lot-negative')).not.toContain(lotOk);
|
||||
});
|
||||
|
||||
it('lot-expired-stock is acknowledgeable per lot, a booking that changes the rest reopens it', async () => {
|
||||
const before = itemOf(await report(), 'lot-expired-stock', lotOld);
|
||||
expect(before).toMatchObject({ state: 'OPEN', acknowledgeable: true });
|
||||
expect(openSubjectIds(await report(), 'treatment-expired-lot')).toContain(
|
||||
treatmentOld,
|
||||
);
|
||||
|
||||
const response = await acknowledge({
|
||||
itemKey: 'lot-expired-stock',
|
||||
subjectId: lotOld,
|
||||
fingerprint: before.fingerprint,
|
||||
note: 'Wird bei der nächsten Abgabe entsorgt.',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
acknowledgementIds.push(
|
||||
(response.body as ReadinessItem).acknowledgement!.id,
|
||||
);
|
||||
expect(itemOf(await report(), 'lot-expired-stock', lotOld).state).toBe(
|
||||
'ACKNOWLEDGED',
|
||||
);
|
||||
|
||||
const disposal = await request(server())
|
||||
.post(`/api/v1/veterinary-medicine-lots/${lotOld}/movements`)
|
||||
.set(as('beekeeper'))
|
||||
.send({ type: 'DISPOSAL', quantity: 1, reason: 'Teil entsorgt' });
|
||||
expect(disposal.status).toBe(201);
|
||||
|
||||
expect(itemOf(await report(), 'lot-expired-stock', lotOld).state).toBe(
|
||||
'OPEN',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the lot subjects without veterinary-medicines:read but keeps the count', async () => {
|
||||
const masked = itemOf(await report('complianceonly'), 'lot-negative');
|
||||
const full = itemOf(await report('beekeeper'), 'lot-negative');
|
||||
|
||||
expect(masked.state).toBe('OPEN');
|
||||
expect(masked.subjects).toEqual([]);
|
||||
expect(masked.more).toBeGreaterThanOrEqual(1);
|
||||
expect(JSON.stringify(masked)).not.toContain(medicineName);
|
||||
expect(full.subjects.map((subject) => subject.id)).toContain(lotSmall);
|
||||
});
|
||||
|
||||
it('opens harvest-withdrawal only for the harvest inside the window and acknowledges it', async () => {
|
||||
const body = await report();
|
||||
const open = openSubjectIds(body, 'harvest-withdrawal');
|
||||
|
||||
expect(open).toContain(harvestInside);
|
||||
expect(open).not.toContain(harvestAfter);
|
||||
const item = itemOf(body, 'harvest-withdrawal', harvestInside);
|
||||
expect(JSON.stringify(item)).not.toContain('Wartezeit-Mittel');
|
||||
const response = await acknowledge({
|
||||
itemKey: 'harvest-withdrawal',
|
||||
subjectId: harvestInside,
|
||||
fingerprint: item.fingerprint,
|
||||
note: 'Honig bleibt bis zum Ende der Wartezeit zurück.',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
acknowledgementIds.push(
|
||||
(response.body as ReadinessItem).acknowledgement!.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('the label rules judge a template against the newest bottling with stock', async () => {
|
||||
expect(
|
||||
openSubjectIds(await report(), 'label-template-gaps'),
|
||||
).not.toContain(templateId);
|
||||
await prisma.labelTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: { elements: [textElement('legal', 1, 'legalName')] },
|
||||
});
|
||||
|
||||
expect(openSubjectIds(await report(), 'label-template-gaps')).toContain(
|
||||
templateId,
|
||||
);
|
||||
});
|
||||
|
||||
const fetchPdf = (user: string) =>
|
||||
request(server())
|
||||
.get(`${READINESS}/report.pdf`)
|
||||
.set(as(user))
|
||||
.buffer(true)
|
||||
.parse((res, callback) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => callback(null, Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
it('GET /compliance/readiness/report.pdf answers application/pdf and no-store', async () => {
|
||||
const response = await fetchPdf('beekeeper');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('application/pdf');
|
||||
expect(response.headers['cache-control']).toBe('no-store');
|
||||
expect(response.headers['content-disposition']).toContain(
|
||||
'pruefbericht.pdf',
|
||||
);
|
||||
const text = pdfText(response.body as Buffer);
|
||||
expect(text).toContain('Prüfbericht');
|
||||
expect(text).not.toContain(medicineName);
|
||||
expect((await fetchPdf('apiclient')).status).toBe(403);
|
||||
});
|
||||
|
||||
it('the report PDF of a user with compliance:read but without veterinary-medicines:read names no medicine', async () => {
|
||||
const response = await fetchPdf('complianceonly');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const text = pdfText(response.body as Buffer);
|
||||
expect(text).toContain('Prüfbericht');
|
||||
for (const name of [
|
||||
medicineName,
|
||||
`Wartezeit-Mittel ${area}`,
|
||||
`Luecken-Mittel ${area}`,
|
||||
]) {
|
||||
expect(text).not.toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user