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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -106,4 +106,33 @@ describe('PublicService', () => {
|
||||
tiktok: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('never exposes the holder name or address kept for the Bestandsbuch', async () => {
|
||||
siteSettings.get.mockResolvedValue({
|
||||
youtubeUrl: null,
|
||||
instagramUrl: null,
|
||||
tiktokUrl: null,
|
||||
websiteUrl: null,
|
||||
holderName: 'Nick Muster',
|
||||
holderAddress: 'Musterweg 3, 86450 Altenmünster',
|
||||
});
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
|
||||
lotNumber: 'WH-2026-001',
|
||||
date: new Date('2026-08-20'),
|
||||
bestBeforeDate: new Date('2028-08-20'),
|
||||
jarSize: 500,
|
||||
product: null,
|
||||
honeyBatch: {
|
||||
honeyType: null,
|
||||
waterContent: null,
|
||||
extraction: { date: null, harvests: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.findBatchByLot('WH-2026-001');
|
||||
|
||||
const serialized = JSON.stringify(result);
|
||||
expect(serialized).not.toContain('Nick Muster');
|
||||
expect(serialized).not.toContain('Musterweg');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsUrl, MaxLength, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
// An empty string clears the link (stored as null); anything else must be a
|
||||
// real URL - ValidateIf skips the @IsUrl check only for the empty-string
|
||||
@@ -32,4 +38,18 @@ export class UpdateSiteSettingsDto {
|
||||
@IsUrl({ require_protocol: true })
|
||||
@MaxLength(300)
|
||||
websiteUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Name des Bienenhalters (Bestandsbuch)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
holderName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Anschrift des Bienenhalters (Bestandsbuch)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
holderAddress?: string;
|
||||
}
|
||||
@@ -52,6 +52,14 @@ export class SiteSettingsService {
|
||||
dto.websiteUrl !== undefined
|
||||
? emptyToNull(dto.websiteUrl)
|
||||
: undefined,
|
||||
holderName:
|
||||
dto.holderName !== undefined
|
||||
? emptyToNull(dto.holderName.trim())
|
||||
: undefined,
|
||||
holderAddress:
|
||||
dto.holderAddress !== undefined
|
||||
? emptyToNull(dto.holderAddress.trim())
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTreatmentDto {
|
||||
@@ -55,6 +56,31 @@ export class CreateTreatmentDto {
|
||||
@IsString()
|
||||
batchNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Lieferant (Name, Anschrift), z. B. Apotheke oder Fachhandel',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
supplier?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Beleg für den Erwerb, z. B. "Rechnung Nr. 123 vom 01.08.2026"',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
purchaseProof?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Verschreibender Tierarzt, falls zutreffend',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
prescribingVet?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('TreatmentsService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
colony: { findUnique: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
@@ -31,6 +32,9 @@ describe('TreatmentsService', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
colony: {
|
||||
findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }),
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
@@ -58,9 +62,20 @@ describe('TreatmentsService', () => {
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.treatment.create.mock.calls as [
|
||||
[{ data: { colonyId: string; userId: string; product: string } }],
|
||||
[
|
||||
{
|
||||
data: {
|
||||
colonyId: string;
|
||||
userId: string;
|
||||
product: string;
|
||||
apiaryId: string;
|
||||
};
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(createArg.data.colonyId).toBe('colony-1');
|
||||
// The apiary is kept with the treatment for the Bestandsbuch.
|
||||
expect(createArg.data.apiaryId).toBe('apiary-1');
|
||||
expect(createArg.data.userId).toBe('user-1');
|
||||
expect(createArg.data.product).toBe('Ameisensäure');
|
||||
});
|
||||
|
||||
@@ -83,9 +83,19 @@ export class TreatmentsService {
|
||||
dto: CreateTreatmentDto,
|
||||
actingUserId: string,
|
||||
): Promise<Treatment> {
|
||||
const colony = await this.prisma.colony.findUnique({
|
||||
where: { id: dto.colonyId },
|
||||
select: { apiaryId: true },
|
||||
});
|
||||
if (!colony) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
const treatment = await this.prisma.treatment.create({
|
||||
data: {
|
||||
...dto,
|
||||
// Where the colony stands today is where it was treated - kept on the
|
||||
// treatment for the Bestandsbuch, the colony may be moved later.
|
||||
apiaryId: colony.apiaryId,
|
||||
date: new Date(dto.date),
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
@@ -110,10 +120,19 @@ export class TreatmentsService {
|
||||
actingUserId: string,
|
||||
): Promise<Treatment> {
|
||||
const before = await this.findById(id);
|
||||
// Re-filed to another colony: take that colony's apiary as the location.
|
||||
const movedColony =
|
||||
dto.colonyId !== undefined && dto.colonyId !== before.colonyId
|
||||
? await this.prisma.colony.findUnique({
|
||||
where: { id: dto.colonyId },
|
||||
select: { apiaryId: true },
|
||||
})
|
||||
: null;
|
||||
const treatment = await this.prisma.treatment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
apiaryId: movedColony ? movedColony.apiaryId : undefined,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
|
||||
Reference in new issue
Block a user