feat(export): Bestandsbuch für Tierarzneimittel nach Art. 108 VO (EU) 2019/6

- Treatment records supplier, purchase proof and prescribing vet, and
  keeps the apiary the colony stood at on the treatment day.
- Holder name and address in site settings (never in public output;
  regression test in public.service.spec).
- treatment-log.pdf: one sheet per apiary, identical treatments grouped,
  missing mandatory fields printed as FEHLT, uncertain locations marked.
- Shared PDF table helpers extracted from the record card.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 11:10:07 +02:00
1 parent f263ec79f2
commit 0d23963f07
24 files changed
+941 -113

No files matched your search

@@ -1,6 +1,14 @@
import { NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import {
PDF_MARGIN,
drawSection,
drawTable,
fillWidth,
formatDay,
orDash,
} from './pdf-table';
/**
* Stockkarte: the complete written record of one colony - master data, queen,
@@ -242,109 +250,14 @@ export async function buildColonyRecordCard(
// Rendering
// ---------------------------------------------------------------------------
const MARGIN = 36;
function day(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
}
const MARGIN = PDF_MARGIN;
const day = formatDay;
const section = drawSection;
function yesNo(value: boolean | null): string {
return value === null ? '–' : value ? 'ja' : 'nein';
}
function orDash(value: string | number | null | undefined): string {
return value === null || value === undefined || value === ''
? '–'
: String(value);
}
interface Column {
header: string;
width: number;
}
/** Draws a simple table, repeating the header on every new page. */
function drawTable(
doc: PDFKit.PDFDocument,
columns: Column[],
rows: string[][],
): void {
const left = MARGIN;
const bottomLimit = doc.page.height - MARGIN - 20;
const drawRow = (cells: string[], bold: boolean) => {
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
const heights = cells.map((cell, index) =>
doc.heightOfString(cell, { width: columns[index].width - 4 }),
);
const rowHeight = Math.max(...heights, 10) + 4;
if (doc.y + rowHeight > bottomLimit) {
doc.addPage();
if (!bold) {
drawRow(
columns.map((c) => c.header),
true,
);
doc.font('Helvetica').fontSize(8);
}
}
const top = doc.y;
let x = left;
cells.forEach((cell, index) => {
doc.text(cell, x + 2, top + 2, { width: columns[index].width - 4 });
x += columns[index].width;
});
doc
.moveTo(left, top + rowHeight)
.lineTo(x, top + rowHeight)
.lineWidth(0.3)
.strokeColor('#999999')
.stroke();
doc.y = top + rowHeight;
};
drawRow(
columns.map((c) => c.header),
true,
);
if (rows.length === 0) {
doc
.font('Helvetica-Oblique')
.fontSize(8)
.text('Keine Einträge.', left + 2, doc.y + 2);
doc.moveDown(0.5);
return;
}
for (const row of rows) drawRow(row, false);
doc.moveDown(0.5);
}
function section(doc: PDFKit.PDFDocument, title: string): void {
if (doc.y > doc.page.height - MARGIN - 80) doc.addPage();
doc.moveDown(0.6);
doc
.font('Helvetica-Bold')
.fontSize(11)
.fillColor('#000000')
.text(title, MARGIN);
doc.moveDown(0.2);
}
/** Widens the last column to fill the page. */
function fillWidth(doc: PDFKit.PDFDocument, columns: Column[]): Column[] {
const fixed = columns.slice(0, -1).reduce((sum, c) => sum + c.width, 0);
const last = columns[columns.length - 1];
return [
...columns.slice(0, -1),
{ ...last, width: doc.page.width - 2 * MARGIN - fixed },
];
}
export function renderColonyRecordCardPdf(
card: ColonyRecordCard,
): Promise<Buffer> {
@@ -4,6 +4,7 @@ import {
buildColonyRecordCard,
renderColonyRecordCardPdf,
} from './colony-record-card';
import { buildTreatmentLog, renderTreatmentLogPdf } from './treatment-log';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
import {
@@ -27,6 +28,7 @@ const PDF_REPORTS = [
'colony-annual-report',
'apiary-annual-report',
'colony-record-card',
'treatment-log',
] as const;
type PdfReport = (typeof PDF_REPORTS)[number];
@@ -207,6 +209,14 @@ export class ExportService {
);
return renderColonyRecordCardPdf(card);
}
case 'treatment-log': {
const log = await buildTreatmentLog(
this.prisma,
resolveYear(options.year),
options.apiaryId,
);
return renderTreatmentLogPdf(log);
}
}
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Small pdfkit helpers shared by the printable records (Stockkarte,
* Bestandsbuch): dates, empty values and a table that repeats its header on
* every page.
*/
export const PDF_MARGIN = 36;
export interface PdfColumn {
header: string;
width: number;
}
export function formatDay(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
}
export function orDash(value: string | number | null | undefined): string {
return value === null || value === undefined || value === ''
? '–'
: String(value);
}
/** Widens the last column so the table fills the page width. */
export function fillWidth(
doc: PDFKit.PDFDocument,
columns: PdfColumn[],
): PdfColumn[] {
const fixed = columns.slice(0, -1).reduce((sum, c) => sum + c.width, 0);
const last = columns[columns.length - 1];
return [
...columns.slice(0, -1),
{ ...last, width: doc.page.width - 2 * PDF_MARGIN - fixed },
];
}
export function drawSection(doc: PDFKit.PDFDocument, title: string): void {
if (doc.y > doc.page.height - PDF_MARGIN - 80) doc.addPage();
doc.moveDown(0.6);
doc
.font('Helvetica-Bold')
.fontSize(11)
.fillColor('#000000')
.text(title, PDF_MARGIN);
doc.moveDown(0.2);
}
/** Draws a simple table, repeating the header on every new page. */
export function drawTable(
doc: PDFKit.PDFDocument,
columns: PdfColumn[],
rows: string[][],
): void {
const left = PDF_MARGIN;
const bottomLimit = doc.page.height - PDF_MARGIN - 20;
const drawRow = (cells: string[], bold: boolean) => {
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
const heights = cells.map((cell, index) =>
doc.heightOfString(cell, { width: columns[index].width - 4 }),
);
const rowHeight = Math.max(...heights, 10) + 4;
if (doc.y + rowHeight > bottomLimit) {
doc.addPage();
if (!bold) {
drawRow(
columns.map((c) => c.header),
true,
);
doc.font('Helvetica').fontSize(8);
}
}
const top = doc.y;
let x = left;
cells.forEach((cell, index) => {
doc.text(cell, x + 2, top + 2, { width: columns[index].width - 4 });
x += columns[index].width;
});
doc
.moveTo(left, top + rowHeight)
.lineTo(x, top + rowHeight)
.lineWidth(0.3)
.strokeColor('#999999')
.stroke();
doc.y = top + rowHeight;
};
drawRow(
columns.map((c) => c.header),
true,
);
if (rows.length === 0) {
doc
.font('Helvetica-Oblique')
.fontSize(8)
.text('Keine Einträge.', left + 2, doc.y + 2);
doc.moveDown(0.5);
return;
}
for (const row of rows) drawRow(row, false);
doc.moveDown(0.5);
}
@@ -0,0 +1,126 @@
import {
apiaryOnDate,
groupTreatmentLog,
TreatmentLogSource,
} from './treatment-log';
const d = (value: string) => new Date(`${value}T00:00:00.000Z`);
function source(overrides: Partial<TreatmentLogSource>): TreatmentLogSource {
return {
id: 't',
colonyId: 'c1',
colonyCode: 'V-01',
colonyApiaryId: 'today',
apiaryId: null,
date: d('2026-08-01'),
startDate: null,
endDate: null,
product: 'Oxalsäure',
amount: '30 ml',
batchNumber: 'B1',
supplier: 'Imkereibedarf X',
purchaseProof: 'Rechnung 1',
prescribingVet: null,
withdrawalPeriodDays: 0,
administeredBy: 'Imker',
...overrides,
};
}
describe('apiaryOnDate', () => {
it('uses the apiary stored with the treatment', () => {
expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
apiaryId: 'stored',
uncertain: false,
});
});
it('uses the last move on or before the treatment day', () => {
const moves = [
{ colonyId: 'c1', apiaryId: 'summer', movedAt: d('2026-05-01') },
{ colonyId: 'c1', apiaryId: 'winter', movedAt: d('2026-10-01') },
];
expect(apiaryOnDate(source({}), moves)).toEqual({
apiaryId: 'summer',
uncertain: false,
});
});
it('flags the location when the colony was only moved after the treatment', () => {
const moves = [
{ colonyId: 'c1', apiaryId: 'today', movedAt: d('2026-09-01') },
];
expect(apiaryOnDate(source({}), moves)).toEqual({
apiaryId: 'today',
uncertain: true,
});
});
it('trusts today when the colony was never moved', () => {
expect(apiaryOnDate(source({}), [])).toEqual({
apiaryId: 'today',
uncertain: false,
});
});
});
describe('groupTreatmentLog', () => {
it('puts identical treatments of several colonies into one row, per apiary', () => {
const sheets = groupTreatmentLog(
[
source({ id: '1', colonyId: 'c1', colonyCode: 'V-02', apiaryId: 'a' }),
source({ id: '2', colonyId: 'c2', colonyCode: 'V-01', apiaryId: 'a' }),
source({ id: '3', colonyId: 'c3', colonyCode: 'V-09', apiaryId: 'b' }),
source({
id: '4',
colonyId: 'c4',
colonyCode: 'V-03',
apiaryId: 'a',
amount: '50 ml',
}),
],
[],
);
const sheetA = sheets.find((sheet) => sheet.apiaryId === 'a');
expect(sheetA?.rows).toHaveLength(2);
expect(sheetA?.rows[0].colonyCodes).toEqual(['V-01', 'V-02']);
expect(sheets.find((sheet) => sheet.apiaryId === 'b')?.rows).toHaveLength(
1,
);
});
it('computes the treatment duration in days, inclusive', () => {
const [sheet] = groupTreatmentLog(
[
source({
apiaryId: 'a',
startDate: d('2026-08-01'),
endDate: d('2026-08-14'),
}),
],
[],
);
expect(sheet.rows[0].durationDays).toBe(14);
});
it('keeps missing mandatory fields empty so the PDF can flag them', () => {
const [sheet] = groupTreatmentLog(
[
source({
apiaryId: 'a',
supplier: null,
purchaseProof: null,
withdrawalPeriodDays: null,
}),
],
[],
);
expect(sheet.rows[0]).toMatchObject({
supplier: null,
purchaseProof: null,
withdrawalPeriodDays: null,
});
});
});
+353
View File
@@ -0,0 +1,353 @@
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import {
PDF_MARGIN,
drawTable,
fillWidth,
formatDay,
orDash,
} from './pdf-table';
/**
* Bestandsbuch: record of every veterinary medicine applied to bees, as
* required for food-producing animals by Art. 108 Regulation (EU) 2019/6 -
* for every product, including over-the-counter formic or oxalic acid. The
* columns follow the model sheet of the German veterinary authorities
* (Veterinäramt Kassel, "Buchführung über die Anwendung von TAM bei
* Bienen", March 2026): one sheet per apiary; colonies, product, supplier
* and purchase proof, date and duration, amount, withdrawal period (also
* when 0), administering person. Keep 5 years, as well as purchase proofs.
*
* Missing mandatory information is printed as "FEHLT" so it can be added
* before an inspection, instead of silently leaving a gap.
*/
export const MISSING = 'FEHLT';
export interface TreatmentLogSource {
id: string;
colonyId: string;
colonyCode: string;
colonyApiaryId: string;
/** Apiary recorded with the treatment (set since this report exists). */
apiaryId: string | null;
date: Date;
startDate: Date | null;
endDate: Date | null;
product: string;
amount: string | null;
batchNumber: string | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
withdrawalPeriodDays: number | null;
administeredBy: string;
}
export interface ColonyMove {
colonyId: string;
apiaryId: string;
movedAt: Date;
}
export interface TreatmentLogRow {
colonyCodes: string[];
product: string;
batchNumber: string | null;
supplier: string | null;
prescribingVet: string | null;
purchaseProof: string | null;
date: Date;
endDate: Date | null;
durationDays: number;
amount: string | null;
withdrawalPeriodDays: number | null;
administeredBy: string;
/** Apiary derived from today's assignment, not known for that date. */
locationUncertain: boolean;
}
export interface TreatmentLogSheet {
apiaryId: string;
rows: TreatmentLogRow[];
}
/**
* Where the colony stood on the day of the treatment: the apiary stored with
* the treatment, else the last recorded move on or before that day. Before
* the first recorded move the original apiary is not known - then today's
* apiary is used and the row is flagged.
*/
export function apiaryOnDate(
source: TreatmentLogSource,
moves: ColonyMove[],
): { apiaryId: string; uncertain: boolean } {
if (source.apiaryId) return { apiaryId: source.apiaryId, uncertain: false };
const colonyMoves = moves
.filter((move) => move.colonyId === source.colonyId)
.sort((a, b) => a.movedAt.getTime() - b.movedAt.getTime());
const before = colonyMoves.filter(
(move) => move.movedAt.getTime() <= source.date.getTime(),
);
if (before.length > 0) {
return { apiaryId: before[before.length - 1].apiaryId, uncertain: false };
}
return { apiaryId: source.colonyApiaryId, uncertain: colonyMoves.length > 0 };
}
function durationDays(start: Date, end: Date | null): number {
if (!end) return 1;
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
return Math.max(days, 1);
}
/**
* One sheet per apiary; treatments that are identical except for the colony
* (same day, product, batch, amount, supplier, person) become one row listing
* all colonies - as in the paper form.
*/
export function groupTreatmentLog(
sources: TreatmentLogSource[],
moves: ColonyMove[],
): TreatmentLogSheet[] {
const sheets = new Map<string, Map<string, TreatmentLogRow>>();
const sorted = [...sources].sort(
(a, b) =>
a.date.getTime() - b.date.getTime() ||
a.colonyCode.localeCompare(b.colonyCode, 'de', { numeric: true }),
);
for (const source of sorted) {
const { apiaryId, uncertain } = apiaryOnDate(source, moves);
const start = source.startDate ?? source.date;
const key = [
source.date.toISOString(),
source.endDate?.toISOString() ?? '',
source.product,
source.batchNumber ?? '',
source.amount ?? '',
source.supplier ?? '',
source.purchaseProof ?? '',
source.prescribingVet ?? '',
source.withdrawalPeriodDays ?? '',
source.administeredBy,
uncertain,
].join('|');
const rows = sheets.get(apiaryId) ?? new Map<string, TreatmentLogRow>();
const existing = rows.get(key);
if (existing) {
existing.colonyCodes.push(source.colonyCode);
} else {
rows.set(key, {
colonyCodes: [source.colonyCode],
product: source.product,
batchNumber: source.batchNumber,
supplier: source.supplier,
prescribingVet: source.prescribingVet,
purchaseProof: source.purchaseProof,
date: source.date,
endDate: source.endDate,
durationDays: durationDays(start, source.endDate),
amount: source.amount,
withdrawalPeriodDays: source.withdrawalPeriodDays,
administeredBy: source.administeredBy,
locationUncertain: uncertain,
});
}
sheets.set(apiaryId, rows);
}
return [...sheets.entries()].map(([apiaryId, rows]) => ({
apiaryId,
rows: [...rows.values()],
}));
}
// ---------------------------------------------------------------------------
// Loading and rendering
// ---------------------------------------------------------------------------
export interface TreatmentLog {
year: number;
generatedAt: Date;
holderName: string | null;
holderAddress: string | null;
sheets: (TreatmentLogSheet & {
apiaryName: string;
apiaryLocation: string | null;
})[];
}
export async function buildTreatmentLog(
prisma: PrismaService,
year: number,
apiaryId?: string,
): Promise<TreatmentLog> {
const treatments = await prisma.treatment.findMany({
where: {
date: {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
},
},
include: {
colony: { select: { code: true, apiaryId: true } },
user: { select: { name: true } },
},
});
const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
const [moves, settings, apiaries] = await Promise.all([
prisma.colonyApiaryHistory.findMany({
where: { colonyId: { in: colonyIds } },
select: { colonyId: true, apiaryId: true, movedAt: true },
}),
prisma.siteSettings.findUnique({ where: { id: 'singleton' } }),
prisma.apiary.findMany({
select: { id: true, name: true, location: true, address: true },
}),
]);
const sheets = groupTreatmentLog(
treatments.map((t) => ({
id: t.id,
colonyId: t.colonyId,
colonyCode: t.colony.code,
colonyApiaryId: t.colony.apiaryId,
apiaryId: t.apiaryId,
date: t.date,
startDate: t.startDate,
endDate: t.endDate,
product: t.product,
amount: t.amount,
batchNumber: t.batchNumber,
supplier: t.supplier,
purchaseProof: t.purchaseProof,
prescribingVet: t.prescribingVet,
withdrawalPeriodDays: t.withdrawalPeriodDays,
administeredBy: t.user.name,
})),
moves,
).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
const apiaryById = new Map(apiaries.map((a) => [a.id, a]));
return {
year,
generatedAt: new Date(),
holderName: settings?.holderName ?? null,
holderAddress: settings?.holderAddress ?? null,
sheets: sheets
.map((sheet) => {
const apiary = apiaryById.get(sheet.apiaryId);
return {
...sheet,
apiaryName: apiary?.name ?? 'Unbekannter Stand',
apiaryLocation: apiary?.address ?? apiary?.location ?? null,
};
})
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de')),
};
}
function orMissing(value: string | number | null | undefined): string {
return value === null || value === undefined || value === ''
? MISSING
: String(value);
}
function renderSheet(
doc: PDFKit.PDFDocument,
log: TreatmentLog,
sheet: TreatmentLog['sheets'][number] | null,
): void {
doc
.font('Helvetica-Bold')
.fontSize(14)
.text(
'Buchführung (Bestandsbuch) über die Anwendung von Tierarzneimitteln bei Bienen',
PDF_MARGIN,
PDF_MARGIN,
);
doc.font('Helvetica').fontSize(9);
doc.text(
`Jahr ${log.year} · erstellt am ${formatDay(log.generatedAt)} · Art. 108 VO (EU) 2019/6`,
);
doc.moveDown(0.4);
doc.text(
`Name und Anschrift des Bienenhalters: ${
log.holderName || log.holderAddress
? [log.holderName, log.holderAddress].filter(Boolean).join(', ')
: `${MISSING} (in ApiCore unter Verwaltung → Automatisierung → Stammdaten & Web eintragen)`
}`,
);
if (sheet) {
doc.text(
`Standort der Bienen: ${sheet.apiaryName}${sheet.apiaryLocation ? `, ${sheet.apiaryLocation}` : ''}`,
);
}
doc.moveDown(0.5);
if (!sheet) {
doc.text(`Im Jahr ${log.year} wurden keine Behandlungen erfasst.`);
return;
}
const uncertain = sheet.rows.some((row) => row.locationUncertain);
drawTable(
doc,
fillWidth(doc, [
{ header: 'Anzahl und Nummern der Völker', width: 110 },
{ header: 'Arzneimittel (Handelsname), Ch.-B.', width: 120 },
{ header: 'a) Lieferant (Name, Anschrift), ggf. Tierarzt', width: 150 },
{ header: 'b) Beleg für den Erwerb', width: 110 },
{ header: 'Datum der Verabreichung, Dauer', width: 90 },
{ header: 'Verabreichte Menge', width: 70 },
{ header: 'Wartezeit (Tage)', width: 50 },
{ header: 'Anwendende Person', width: 0 },
]),
sheet.rows.map((row) => [
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
`${orMissing(row.supplier)}${row.prescribingVet ? `; Tierarzt: ${row.prescribingVet}` : ''}`,
orMissing(row.purchaseProof),
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
orMissing(row.amount),
orMissing(row.withdrawalPeriodDays),
orDash(row.administeredBy),
]),
);
doc.font('Helvetica').fontSize(7);
if (uncertain) {
doc.text(
'* Standort aus der heutigen Zuordnung ermittelt: das Volk wurde später umgesetzt, der Stand am Behandlungstag ist nicht erfasst. Bitte prüfen.',
PDF_MARGIN,
);
}
doc.text(
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung nachtragen. Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
PDF_MARGIN,
);
}
export function renderTreatmentLogPdf(log: TreatmentLog): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: PDF_MARGIN,
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
if (log.sheets.length === 0) {
renderSheet(doc, log, null);
}
log.sheets.forEach((sheet, index) => {
if (index > 0) doc.addPage();
renderSheet(doc, log, sheet);
});
doc.end();
});
}