feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s

- 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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 14:18:41 +02:00
1 parent c18860c75c
commit edc193f128
73 files changed
+9470 -171

No files matched your search

+235
View File
@@ -2,8 +2,13 @@ import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import {
buildTreatmentLog,
treatmentLogGaps,
} from '../src/export/treatment-log';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { pdfText } from './utils/pdf-text';
import { login } from './utils/api';
describe('Export (e2e)', () => {
@@ -214,4 +219,234 @@ describe('Export (e2e)', () => {
'%PDF',
);
});
describe('Bestandsbuch from the catalogue and a lot', () => {
const suffix = Date.now();
const year = 2031;
let medicineId: string;
let lotId: string;
let treatmentColonyId: string;
let treatmentApiaryId: string;
let treatmentId: string;
beforeAll(async () => {
treatmentApiaryId = (
await prisma.apiary.create({ data: { name: `Bestandsbuch ${suffix}` } })
).id;
treatmentColonyId = (
await prisma.colony.create({
data: {
code: `BB-${suffix}`,
apiaryId: treatmentApiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
medicineId = (
await prisma.veterinaryMedicine.create({
data: {
name: `Bestand-Mittel ${suffix}`,
unit: 'ML',
prescriptionOnly: true,
},
})
).id;
lotId = (
await prisma.veterinaryMedicineLot.create({
data: {
medicineId,
purchasedOn: new Date(`${year}-01-10`),
quantity: 100,
batchNumber: 'CH-4711',
supplier: 'Apotheke Bestand, Hauptstr. 9',
purchaseProof: 'Rechnung B-77',
prescribingVet: 'Dr. Vet Bestand',
},
})
).id;
});
afterAll(async () => {
await prisma.veterinaryMedicineMovement.deleteMany({
where: { lotId },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Treatment', aggregateId: treatmentId },
});
await prisma.treatment.deleteMany({
where: { colonyId: treatmentColonyId },
});
await prisma.veterinaryMedicineLot.deleteMany({ where: { id: lotId } });
await prisma.veterinaryMedicine.deleteMany({
where: { id: medicineId },
});
await prisma.colony.deleteMany({ where: { id: treatmentColonyId } });
await prisma.apiary.deleteMany({ where: { id: treatmentApiaryId } });
});
it('a complete catalogue entry and lot give a Bestandsbuch PDF without FEHLT and treatmentLogGaps empty for every row', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/treatments')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
colonyId: treatmentColonyId,
date: `${year}-08-01`,
medicineLotId: lotId,
doseQuantity: 30,
withdrawalPeriodDays: 0,
})
.expect(201);
treatmentId = (created.body as { id: string }).id;
const log = await buildTreatmentLog(prisma, year, treatmentApiaryId);
const rows = log.sheets.flatMap((sheet) => sheet.rows);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
supplier: 'Apotheke Bestand, Hauptstr. 9',
purchaseProof: 'Rechnung B-77',
batchNumber: 'CH-4711',
prescribingVet: 'Dr. Vet Bestand',
amount: '30 ml',
prescriptionOnly: true,
lotId,
});
for (const row of rows) {
expect(treatmentLogGaps(row)).toEqual([]);
}
const response = await request(app.getHttpServer())
.get(
`/api/v1/export/treatment-log.pdf?year=${year}&apiaryId=${treatmentApiaryId}`,
)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
// Wrapped cells come out as several lines; a soft hyphen marks the
// wrap point.
const lines = pdfText(response.body as Buffer)
.replaceAll('­', '')
.split('\n');
const text = lines.join(' ').replace(/ {2,}/g, ' ');
expect(text).toContain('Rechnung B-77');
expect(text).toContain('Apotheke Bestand');
expect(text).toContain('Tierarzt: Dr. Vet Bestand');
expect(text).toContain('30 ml');
// Only the footnote and the holder line (settings of the test DB) may
// name FEHLT, no table cell does.
const cells = lines
.filter(
(line) =>
!line.startsWith('"FEHLT" = ') &&
!line.startsWith('Name und Anschrift des Bienenhalters'),
)
.join('\n');
expect(cells).not.toContain('FEHLT');
});
it('prints FEHLT for a lot without supplier, proof and vet, matching treatmentLogGaps', async () => {
const bareYear = year + 1;
const bareLot = await prisma.veterinaryMedicineLot.create({
data: {
medicineId,
purchasedOn: new Date(`${bareYear}-01-10`),
quantity: 50,
},
});
const created = await request(app.getHttpServer())
.post('/api/v1/treatments')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
colonyId: treatmentColonyId,
date: `${bareYear}-08-01`,
medicineLotId: bareLot.id,
doseQuantity: 5,
withdrawalPeriodDays: 0,
})
.expect(201);
const bareTreatmentId = (created.body as { id: string }).id;
try {
const log = await buildTreatmentLog(
prisma,
bareYear,
treatmentApiaryId,
);
const rows = log.sheets.flatMap((sheet) => sheet.rows);
expect(rows).toHaveLength(1);
expect(treatmentLogGaps(rows[0])).toEqual(['SUPPLIER', 'PROOF', 'VET']);
const response = await request(app.getHttpServer())
.get(
`/api/v1/export/treatment-log.pdf?year=${bareYear}&apiaryId=${treatmentApiaryId}`,
)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const lines = pdfText(response.body as Buffer)
.replaceAll('­', '')
.split('\n')
.filter(
(line) =>
!line.startsWith('"FEHLT" = ') &&
!line.startsWith('Name und Anschrift des Bienenhalters'),
);
const text = lines.join(' ').replace(/ {2,}/g, ' ');
// Supplier, vet and proof: exactly three gaps, no more.
expect(text.split('FEHLT').length - 1).toBe(3);
expect(text).toContain('Tierarzt: FEHLT');
expect(text).toContain('5 ml');
} finally {
await prisma.veterinaryMedicineMovement.deleteMany({
where: { lotId: bareLot.id },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Treatment', aggregateId: bareTreatmentId },
});
await prisma.treatment.deleteMany({ where: { id: bareTreatmentId } });
await prisma.veterinaryMedicineLot.deleteMany({
where: { id: bareLot.id },
});
}
});
it('shows the Bestandsbuch only with veterinary-medicines:read in addition to export:read', async () => {
const role = await prisma.role.create({
data: {
key: `EXPORT_ONLY_${suffix}`,
name: 'Export only',
permissions: {
create: [
{ permission: { connect: { key: 'export:read' } } },
{ permission: { connect: { key: 'colonies:read' } } },
],
},
},
});
const email = `export-e2e-exportonly-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Export only',
role: { connect: { id: role.id } },
},
});
try {
const token = (await login(app, email, password)).accessToken;
await request(app.getHttpServer())
.get(`/api/v1/export/treatment-log.pdf?year=${year}`)
.set('Authorization', `Bearer ${token}`)
.expect(403);
// Other exports are unchanged.
await request(app.getHttpServer())
.get(
`/api/v1/export/colony-record-card.pdf?colonyId=${treatmentColonyId}`,
)
.set('Authorization', `Bearer ${token}`)
.expect(200);
} finally {
await prisma.refreshToken.deleteMany({ where: { userId: user.id } });
await prisma.user.deleteMany({ where: { id: user.id } });
await prisma.role.deleteMany({ where: { id: role.id } });
}
});
});
});