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
View File
@@ -104,6 +104,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
## 7. Kubernetes
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "holderAddress" TEXT,
ADD COLUMN "holderName" TEXT;
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "apiaryId" TEXT,
ADD COLUMN "prescribingVet" TEXT,
ADD COLUMN "purchaseProof" TEXT,
ADD COLUMN "supplier" TEXT;
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+19 -6
View File
@@ -205,6 +205,7 @@ model Apiary {
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
@@index([status])
}
@@ -615,6 +616,14 @@ model Treatment {
batchNumber String?
notes String?
withdrawalPeriodDays Int?
// Bestandsbuch (Art. 108 VO (EU) 2019/6): where the colony stood on the
// day of the treatment - kept with the treatment because the colony may
// be moved later - and the supplier / purchase proof / prescribing vet.
apiaryId String?
apiary Apiary? @relation(fields: [apiaryId], references: [id])
supplier String?
purchaseProof String?
prescribingVet String?
createdAt DateTime @default(now())
@@index([colonyId, date])
@@ -1102,12 +1111,16 @@ model AutomationRule {
// site-wide social links exists, and a fixed-id upsert is simpler than
// enforcing "at most one row" any other way.
model SiteSettings {
id String @id @default("singleton")
youtubeUrl String?
instagramUrl String?
tiktokUrl String?
websiteUrl String?
updatedAt DateTime @updatedAt
id String @id @default("singleton")
youtubeUrl String?
instagramUrl String?
tiktokUrl String?
websiteUrl String?
// Name and address of the bee keeper, printed on the Bestandsbuch. Never
// part of any public page.
holderName String?
holderAddress String?
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
@@ -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();
});
}
@@ -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,
+13
View File
@@ -184,6 +184,19 @@ describe('Export (e2e)', () => {
.expect(404);
});
it('renders the Bestandsbuch for a year, also when nothing was treated', async () => {
for (const query of ['year=2026', 'year=1999']) {
const response = await request(app.getHttpServer())
.get(`/api/v1/export/treatment-log.pdf?${query}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect(response.headers['content-type']).toContain('application/pdf');
expect(
(response.body as Buffer).subarray(0, 4).toString('latin1'),
).toBe('%PDF');
}
});
it('rejects the apiary annual report without an apiaryId', () => {
return request(app.getHttpServer())
.get('/api/v1/export/apiary-annual-report.pdf')
@@ -5,7 +5,7 @@ import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api";
import { exportApiaryAnnualReportPdf } from "@/lib/export/api";
import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { Button } from "@/components/ui/button";
@@ -26,6 +26,11 @@ export default function ApiaryDetailPage() {
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
});
const treatmentLogMutation = useMutation({
mutationFn: () => exportTreatmentLogPdf(CURRENT_YEAR, params.id),
onError: () => toast.error("Bestandsbuch konnte nicht erstellt werden. Bitte noch einmal versuchen."),
});
if (isLoading) {
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
}
@@ -52,6 +57,13 @@ export default function ApiaryDetailPage() {
>
Neue Ernte
</Button>
<Button
variant="outline"
disabled={treatmentLogMutation.isPending}
onClick={() => treatmentLogMutation.mutate()}
>
{treatmentLogMutation.isPending ? "Wird erstellt…" : `Bestandsbuch ${CURRENT_YEAR}`}
</Button>
<Button
variant="outline"
disabled={exportReportMutation.isPending}
@@ -32,6 +32,9 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
const [method, setMethod] = useState(treatment.method ?? "");
const [reason, setReason] = useState(treatment.reason ?? "");
const [batchNumber, setBatchNumber] = useState(treatment.batchNumber ?? "");
const [supplier, setSupplier] = useState(treatment.supplier ?? "");
const [purchaseProof, setPurchaseProof] = useState(treatment.purchaseProof ?? "");
const [prescribingVet, setPrescribingVet] = useState(treatment.prescribingVet ?? "");
const [notes, setNotes] = useState(treatment.notes ?? "");
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
treatment.withdrawalPeriodDays?.toString() ?? "",
@@ -52,6 +55,9 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
batchNumber: batchNumber || undefined,
notes: notes || undefined,
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
supplier: supplier.trim(),
purchaseProof: purchaseProof.trim(),
prescribingVet: prescribingVet.trim(),
}),
onSuccess: () => {
// After re-filing, both the colony it left and the one it joined
@@ -178,6 +184,42 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
/>
</div>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend>
<p className="text-xs text-muted-foreground">
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108
VO (EU) 2019/6). Belege 5 Jahre aufbewahren.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-supplier">Lieferant (Name, Anschrift)</Label>
<Input
id="treatment-edit-supplier"
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier}
onChange={(event) => setSupplier(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-proof">Beleg für den Erwerb</Label>
<Input
id="treatment-edit-proof"
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-vet">Verschreibender Tierarzt (falls zutreffend)</Label>
<Input
id="treatment-edit-vet"
className="h-11"
value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)}
/>
</div>
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-edit-notes">Notizen</Label>
<Textarea
@@ -26,7 +26,7 @@ const TABS: { value: Tab; label: string }[] = [
{ value: "webhooks", label: "Webhooks" },
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
{ value: "label-templates", label: "Etikettenvorlagen" },
{ value: "social-media", label: "Social Media" },
{ value: "social-media", label: "Stammdaten & Web" },
{ value: "rules", label: "Regeln" },
{ value: "calendar-sync", label: "Kalender-Sync" },
{ value: "api-keys", label: "API-Schlüssel" },
@@ -18,6 +18,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? "");
const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? "");
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
const [holderName, setHolderName] = useState(settings.holderName ?? "");
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
const mutation = useMutation({
mutationFn: updateSiteSettings,
@@ -35,6 +37,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
instagramUrl,
tiktokUrl,
websiteUrl,
holderName,
holderAddress,
});
}
@@ -86,6 +90,28 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
onChange={(event) => setWebsiteUrl(event.target.value)}
/>
</div>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Bienenhalter (für das Bestandsbuch)</legend>
<p className="text-xs text-muted-foreground">
Erscheint nur im Bestandsbuch, nie auf öffentlichen Seiten.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="site-holder-name">Name</Label>
<Input
id="site-holder-name"
value={holderName}
onChange={(event) => setHolderName(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="site-holder-address">Anschrift</Label>
<Input
id="site-holder-address"
value={holderAddress}
onChange={(event) => setHolderAddress(event.target.value)}
/>
</div>
</fieldset>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
@@ -51,6 +51,9 @@ export default function NewTreatmentPage() {
const [batchNumber, setBatchNumber] = useState("");
const [notes, setNotes] = useState("");
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
const [supplier, setSupplier] = useState("");
const [purchaseProof, setPurchaseProof] = useState("");
const [prescribingVet, setPrescribingVet] = useState("");
const [error, setError] = useState<string | null>(null);
// One key per opened form: a double tap or an offline retry of the
@@ -98,6 +101,9 @@ export default function NewTreatmentPage() {
batchNumber: batchNumber || undefined,
notes: notes || undefined,
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
supplier: supplier.trim() || undefined,
purchaseProof: purchaseProof.trim() || undefined,
prescribingVet: prescribingVet.trim() || undefined,
});
}
@@ -229,6 +235,42 @@ export default function NewTreatmentPage() {
geerntet werden.
</p>
</div>
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
<legend className="px-1 text-sm font-medium">Für das Bestandsbuch</legend>
<p className="text-xs text-muted-foreground">
Pflicht bei jedem Tierarzneimittel, auch bei freiverkäuflichen Säuren (Art. 108
VO (EU) 2019/6). Belege 5 Jahre aufbewahren.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-supplier">Lieferant (Name, Anschrift)</Label>
<Input
id="treatment-supplier"
className="h-11"
placeholder="z. B. Imkereibedarf Muster, Hauptstr. 1, 86450 Altenmünster"
value={supplier}
onChange={(event) => setSupplier(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-proof">Beleg für den Erwerb</Label>
<Input
id="treatment-proof"
className="h-11"
placeholder="z. B. Rechnung Nr. 4711 vom 01.08.2026"
value={purchaseProof}
onChange={(event) => setPurchaseProof(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-vet">Verschreibender Tierarzt (falls zutreffend)</Label>
<Input
id="treatment-vet"
className="h-11"
value={prescribingVet}
onChange={(event) => setPrescribingVet(event.target.value)}
/>
</div>
</fieldset>
<div className="flex flex-col gap-2">
<Label htmlFor="treatment-notes">Notizen</Label>
<Textarea
+10
View File
@@ -49,6 +49,16 @@ export async function exportColonyRecordCardPdf(colonyId: string, code: string):
downloadBlob(blob, filename);
}
/** Bestandsbuch (Art. 108 VO (EU) 2019/6): all treatments of a year, one sheet per apiary. */
export async function exportTreatmentLogPdf(year: number, apiaryId?: string): Promise<void> {
const query = apiaryId ? `year=${year}&apiaryId=${apiaryId}` : `year=${year}`;
const { blob, filename } = await apiFetchBlob(
`/export/treatment-log.pdf?${query}`,
`bestandsbuch-${year}.pdf`,
);
downloadBlob(blob, filename);
}
export async function exportApiaryAnnualReportPdf(apiaryId: string, year: number): Promise<void> {
const { blob, filename } = await apiFetchBlob(
`/export/apiary-annual-report.pdf?apiaryId=${apiaryId}&year=${year}`,
@@ -4,6 +4,8 @@ export interface SiteSettings {
instagramUrl: string | null;
tiktokUrl: string | null;
websiteUrl: string | null;
holderName: string | null;
holderAddress: string | null;
updatedAt: string;
}
@@ -12,4 +14,6 @@ export interface UpdateSiteSettingsInput {
instagramUrl?: string;
tiktokUrl?: string;
websiteUrl?: string;
holderName?: string;
holderAddress?: string;
}
@@ -13,6 +13,9 @@ export interface Treatment {
batchNumber: string | null;
notes: string | null;
withdrawalPeriodDays: number | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
createdAt: string;
}
@@ -29,6 +32,9 @@ export interface CreateTreatmentInput {
batchNumber?: string;
notes?: string;
withdrawalPeriodDays?: number;
supplier?: string;
purchaseProof?: string;
prescribingVet?: string;
}
export interface UpdateTreatmentInput {
@@ -45,6 +51,9 @@ export interface UpdateTreatmentInput {
batchNumber?: string;
notes?: string;
withdrawalPeriodDays?: number;
supplier?: string;
purchaseProof?: string;
prescribingVet?: string;
}
export interface WithdrawalStatus {
+22 -5
View File
@@ -27,11 +27,15 @@ Verifikationsstufen: **CR** = durch Codeprüfung beurteilt, **AT** = automatisie
| Wabenhain: Sichtbarkeit | Letzter Lauf erkennbar | Nur direkt nach Klick | Niedrig | „Letzter vollständiger Lauf“ im Panel | CR |
| Start-/Heute-Ansicht | Nächster Schritt klar | Kennzahlen-Kacheln statt Aufgaben | Hoch (UX) | „Als Nächstes“ + „Außerdem heute“, Rückgängig | AT (Logik) + MP |
| Bewegung | Einstellbar, System respektiert | Nur `motion-safe`-Klassen | Mittel | Voll/Dezent/Aus je Gerät | MP |
| Etiketten-Schnittmarken | Anlegekante für Papierschneider | Keine Marken, Zuschnitt nach Augenmaß | Mittel | Haarlinien-Marken im Blattrand an jeder Schnittkante, je Vorlage abschaltbar | AT + MP |
| Folge-Kontrollen | Nachkontrolle nach Maßnahme | Keine | Mittel | Regeln „Nach Behandlung/Fütterung“, standardmäßig aus, Frist „Fällig nach (Tagen)“ | AT |
| Stockkarte je Volk | Vollständige Historie als PDF | Nur Jahresbericht | Mittel | `colony-record-card.pdf`, optional je Jahr | AT + MP |
| Bestandsbuch Tierarzneimittel | Pflichtangaben Art. 108 VO (EU) 2019/6 | Lieferant, Erwerbsbeleg, Tierarzt, Standort am Behandlungstag, Halter fehlten | Hoch (Recht) | Felder ergänzt, Stand je Behandlung gespeichert, PDF je Stand, fehlende Pflichtangaben als „FEHLT“ | AT + MP |
| E2E-Tests | Grün | 23 rot (1 Produktfehler, 3 veraltete Tests) | – | behoben | AT |
## Prüfungen (letzter Lauf)
- Backend: Typecheck, ESLint, 524 Unit-Tests, 185 E2E-Tests (isolierte Postgres 17), `nest build` – grün.
- Backend: Typecheck, ESLint, 539 Unit-Tests, 203 E2E-Tests (isolierte Postgres 17), `nest build` – grün.
- Frontend: Typecheck, ESLint, 44 Tests, `next build` – grün. Design-Detektor (impeccable): 0 Befunde.
- Wabenhain: Typecheck, ESLint (geänderte Dateien), 538 Tests – grün.
- Manuell (Browser, lokale Demo-Daten): Login, Heute-Ansicht Desktop/Mobil, Abhaken in Liste und
@@ -56,8 +60,8 @@ Umgesetzt in Wabenhain `d38f430` (AT: Entscheidungslogik; CR: DB-Ablauf der Serv
## Nächste Schritte
1. ~~Zuständigkeit pro Feld im Sync~~ – erledigt.
2. ~~Eigene, versionierte Sync-Schnittstelle~~ – erledigt: `GET /api/v1/shop-sync` (Vertragsversion 1, Feld-Allowlist, Berechtigung `shop-sync:read`). Bewusst ohne „geändert seit“: kleine Datenmenge, Voll-Abgleich heilt verpasste Läufe selbst. Wabenhain nutzt sie seit `5642f0c`, mit Rückfall auf die alten Endpunkte bei HTTP 404.
3. ~~Nur-Lese-API-Schlüssel~~ – erledigt: Verwaltung → Automatisierung → API-Schlüssel. Nur Hash gespeichert, Klartext einmalig angezeigt, Scope nur `shop-sync:read`, widerrufbar, optional befristet, „zuletzt benutzt“ sichtbar. Wabenhain liest `APICORE_API_KEY` (Passwort-Login bleibt Rückfall).
2. ~~Eigene, versionierte Sync-Schnittstelle~~ – erledigt: `GET /api/v1/shop-sync` (Vertragsversion 1, Feld-Allowlist, Berechtigung `shop-sync:read`). Bewusst ohne „geändert seit“: kleine Datenmenge, Voll-Abgleich heilt verpasste Läufe selbst. Wabenhain nutzt sie seit `5642f0c`; seit `3bc0693` ausschließlich (kein Rückfall auf andere Endpunkte).
3. ~~Nur-Lese-API-Schlüssel~~ – erledigt: Verwaltung → Automatisierung → API-Schlüssel. Nur Hash gespeichert, Klartext einmalig angezeigt, Scope nur `shop-sync:read`, widerrufbar, optional befristet, „zuletzt benutzt“ sichtbar. Wabenhain liest nur noch `APICORE_API_KEY`; der Passwort-Login ist entfernt (`3bc0693`).
4. Ende-zu-Ende-Test beider Systeme gegen Test-Instanzen.
5. Merge/Push.
@@ -65,11 +69,24 @@ Umgesetzt in Wabenhain `d38f430` (AT: Entscheidungslogik; CR: DB-Ablauf der Serv
1. **Wabenhain-Sync gegen echtes ApiCore** nicht Ende zu Ende getestet (keine Zugangsdaten/Testinstanz
verbunden). Mapping, Client und Schutzlogik sind unit-getestet bzw. per Code geprüft.
2. **Service-Konto statt API-Schlüssel**: Wabenhain meldet sich mit E-Mail/Passwort an. Ein
eigener, nur lesender API-Token-Mechanismus in ApiCore wäre sicherer – bewusst nicht ohne Absprache gebaut.
2. ~~Service-Konto statt API-Schlüssel~~ – erledigt: nur noch API-Schlüssel mit Scope `shop-sync:read`.
3. **Status „blockiert“** existiert im Aufgabenmodell nicht. Nicht ergänzt (Schemaänderung + fachliche Definition nötig).
4. **Produktbeschreibung** aus ApiCore wird in Wabenhain übernommen und auf der öffentlichen
Herkunftsseite gezeigt – gilt damit als öffentlich. Falls sie intern gemeint ist: klären.
5. Automatische Vorschläge haben standardmäßig **keinen Termin** (keine erfundene Frist);
je Regel über `config.dueInDays` einstellbar.
6. Übrige Formulare/Seiten wurden nicht einzeln überarbeitet (Fokus: Heute, Aufgaben, Profil).
## Bestandsbuch (Tierarzneimittel)
Grundlage: Art. 108 VO (EU) 2019/6, abgeglichen mit dem Muster-Formblatt des Veterinäramts Kassel
(Stand März 2026) und der FAQ des LGL Bayern. Gilt für jedes Tierarzneimittel, auch freiverkäufliche Säuren;
Aufbewahrung 5 Jahre.
- Export: Stand → „Bestandsbuch {Jahr}“ (`/export/treatment-log.pdf?year=&apiaryId=`), ein Blatt je Stand.
- Gleiche Behandlungen mehrerer Völker (Mittel, Charge, Menge, Zeitraum, Bezug) stehen in einer Zeile.
- Standort am Behandlungstag: seit dieser Version mit der Behandlung gespeichert (`Treatment.apiaryId`).
Für ältere Einträge aus der Umsetz-Historie abgeleitet; ist das unsicher, markiert `*` die Zeile.
- Fehlende Pflichtangaben erscheinen als „FEHLT“ statt leer – das Blatt sieht nie vollständiger aus, als es ist.
- Name und Anschrift des Halters: Verwaltung → Automatisierung → Stammdaten & Web. Erscheinen nur im
Bestandsbuch; die öffentliche Chargen-Seite gibt sie nachweislich nicht aus (Test in `public.service.spec.ts`).