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