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
@@ -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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user