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.
|
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.
|
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.
|
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
|
## 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[]
|
colonies Colony[]
|
||||||
colonyMoves ColonyApiaryHistory[]
|
colonyMoves ColonyApiaryHistory[]
|
||||||
harvests Harvest[]
|
harvests Harvest[]
|
||||||
|
treatments Treatment[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -615,6 +616,14 @@ model Treatment {
|
|||||||
batchNumber String?
|
batchNumber String?
|
||||||
notes String?
|
notes String?
|
||||||
withdrawalPeriodDays Int?
|
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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([colonyId, date])
|
@@index([colonyId, date])
|
||||||
@@ -1102,12 +1111,16 @@ model AutomationRule {
|
|||||||
// site-wide social links exists, and a fixed-id upsert is simpler than
|
// site-wide social links exists, and a fixed-id upsert is simpler than
|
||||||
// enforcing "at most one row" any other way.
|
// enforcing "at most one row" any other way.
|
||||||
model SiteSettings {
|
model SiteSettings {
|
||||||
id String @id @default("singleton")
|
id String @id @default("singleton")
|
||||||
youtubeUrl String?
|
youtubeUrl String?
|
||||||
instagramUrl String?
|
instagramUrl String?
|
||||||
tiktokUrl String?
|
tiktokUrl String?
|
||||||
websiteUrl String?
|
websiteUrl String?
|
||||||
updatedAt DateTime @updatedAt
|
// 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 { NotFoundException } from '@nestjs/common';
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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,
|
* Stockkarte: the complete written record of one colony - master data, queen,
|
||||||
@@ -242,109 +250,14 @@ export async function buildColonyRecordCard(
|
|||||||
// Rendering
|
// Rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const MARGIN = 36;
|
const MARGIN = PDF_MARGIN;
|
||||||
|
const day = formatDay;
|
||||||
function day(date: Date): string {
|
const section = drawSection;
|
||||||
return date.toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function yesNo(value: boolean | null): string {
|
function yesNo(value: boolean | null): string {
|
||||||
return value === null ? '–' : value ? 'ja' : 'nein';
|
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(
|
export function renderColonyRecordCardPdf(
|
||||||
card: ColonyRecordCard,
|
card: ColonyRecordCard,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildColonyRecordCard,
|
buildColonyRecordCard,
|
||||||
renderColonyRecordCardPdf,
|
renderColonyRecordCardPdf,
|
||||||
} from './colony-record-card';
|
} from './colony-record-card';
|
||||||
|
import { buildTreatmentLog, renderTreatmentLogPdf } from './treatment-log';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
|
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +28,7 @@ const PDF_REPORTS = [
|
|||||||
'colony-annual-report',
|
'colony-annual-report',
|
||||||
'apiary-annual-report',
|
'apiary-annual-report',
|
||||||
'colony-record-card',
|
'colony-record-card',
|
||||||
|
'treatment-log',
|
||||||
] as const;
|
] as const;
|
||||||
type PdfReport = (typeof PDF_REPORTS)[number];
|
type PdfReport = (typeof PDF_REPORTS)[number];
|
||||||
|
|
||||||
@@ -207,6 +209,14 @@ export class ExportService {
|
|||||||
);
|
);
|
||||||
return renderColonyRecordCardPdf(card);
|
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,
|
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 { 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
|
// 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
|
// real URL - ValidateIf skips the @IsUrl check only for the empty-string
|
||||||
@@ -32,4 +38,18 @@ export class UpdateSiteSettingsDto {
|
|||||||
@IsUrl({ require_protocol: true })
|
@IsUrl({ require_protocol: true })
|
||||||
@MaxLength(300)
|
@MaxLength(300)
|
||||||
websiteUrl?: string;
|
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
|
dto.websiteUrl !== undefined
|
||||||
? emptyToNull(dto.websiteUrl)
|
? emptyToNull(dto.websiteUrl)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
holderName:
|
||||||
|
dto.holderName !== undefined
|
||||||
|
? emptyToNull(dto.holderName.trim())
|
||||||
|
: undefined,
|
||||||
|
holderAddress:
|
||||||
|
dto.holderAddress !== undefined
|
||||||
|
? emptyToNull(dto.holderAddress.trim())
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateTreatmentDto {
|
export class CreateTreatmentDto {
|
||||||
@@ -55,6 +56,31 @@ export class CreateTreatmentDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
batchNumber?: string;
|
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()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe('TreatmentsService', () => {
|
|||||||
create: jest.Mock;
|
create: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
|
colony: { findUnique: jest.Mock };
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ describe('TreatmentsService', () => {
|
|||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
colony: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||||
@@ -58,9 +62,20 @@ describe('TreatmentsService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [[createArg]] = prisma.treatment.create.mock.calls as [
|
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');
|
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.userId).toBe('user-1');
|
||||||
expect(createArg.data.product).toBe('Ameisensäure');
|
expect(createArg.data.product).toBe('Ameisensäure');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,9 +83,19 @@ export class TreatmentsService {
|
|||||||
dto: CreateTreatmentDto,
|
dto: CreateTreatmentDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Treatment> {
|
): 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({
|
const treatment = await this.prisma.treatment.create({
|
||||||
data: {
|
data: {
|
||||||
...dto,
|
...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),
|
date: new Date(dto.date),
|
||||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||||
@@ -110,10 +120,19 @@ export class TreatmentsService {
|
|||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Treatment> {
|
): Promise<Treatment> {
|
||||||
const before = await this.findById(id);
|
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({
|
const treatment = await this.prisma.treatment.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...dto,
|
...dto,
|
||||||
|
apiaryId: movedColony ? movedColony.apiaryId : undefined,
|
||||||
date: dto.date ? new Date(dto.date) : undefined,
|
date: dto.date ? new Date(dto.date) : undefined,
|
||||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||||
|
|||||||
@@ -184,6 +184,19 @@ describe('Export (e2e)', () => {
|
|||||||
.expect(404);
|
.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', () => {
|
it('rejects the apiary annual report without an apiaryId', () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get('/api/v1/export/apiary-annual-report.pdf')
|
.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 { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchApiary } from "@/lib/apiaries/api";
|
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 { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -26,6 +26,11 @@ export default function ApiaryDetailPage() {
|
|||||||
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
|
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) {
|
if (isLoading) {
|
||||||
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
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
|
Neue Ernte
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={treatmentLogMutation.isPending}
|
||||||
|
onClick={() => treatmentLogMutation.mutate()}
|
||||||
|
>
|
||||||
|
{treatmentLogMutation.isPending ? "Wird erstellt…" : `Bestandsbuch ${CURRENT_YEAR}`}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={exportReportMutation.isPending}
|
disabled={exportReportMutation.isPending}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
|||||||
const [method, setMethod] = useState(treatment.method ?? "");
|
const [method, setMethod] = useState(treatment.method ?? "");
|
||||||
const [reason, setReason] = useState(treatment.reason ?? "");
|
const [reason, setReason] = useState(treatment.reason ?? "");
|
||||||
const [batchNumber, setBatchNumber] = useState(treatment.batchNumber ?? "");
|
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 [notes, setNotes] = useState(treatment.notes ?? "");
|
||||||
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
|
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState(
|
||||||
treatment.withdrawalPeriodDays?.toString() ?? "",
|
treatment.withdrawalPeriodDays?.toString() ?? "",
|
||||||
@@ -52,6 +55,9 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
|||||||
batchNumber: batchNumber || undefined,
|
batchNumber: batchNumber || undefined,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
|
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : undefined,
|
||||||
|
supplier: supplier.trim(),
|
||||||
|
purchaseProof: purchaseProof.trim(),
|
||||||
|
prescribingVet: prescribingVet.trim(),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// After re-filing, both the colony it left and the one it joined
|
// 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)}
|
onChange={(event) => setWithdrawalPeriodDays(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="treatment-edit-notes">Notizen</Label>
|
<Label htmlFor="treatment-edit-notes">Notizen</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const TABS: { value: Tab; label: string }[] = [
|
|||||||
{ value: "webhooks", label: "Webhooks" },
|
{ value: "webhooks", label: "Webhooks" },
|
||||||
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
|
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
|
||||||
{ value: "label-templates", label: "Etikettenvorlagen" },
|
{ value: "label-templates", label: "Etikettenvorlagen" },
|
||||||
{ value: "social-media", label: "Social Media" },
|
{ value: "social-media", label: "Stammdaten & Web" },
|
||||||
{ value: "rules", label: "Regeln" },
|
{ value: "rules", label: "Regeln" },
|
||||||
{ value: "calendar-sync", label: "Kalender-Sync" },
|
{ value: "calendar-sync", label: "Kalender-Sync" },
|
||||||
{ value: "api-keys", label: "API-Schlüssel" },
|
{ value: "api-keys", label: "API-Schlüssel" },
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
|||||||
const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? "");
|
const [instagramUrl, setInstagramUrl] = useState(settings.instagramUrl ?? "");
|
||||||
const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? "");
|
const [tiktokUrl, setTiktokUrl] = useState(settings.tiktokUrl ?? "");
|
||||||
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
|
const [websiteUrl, setWebsiteUrl] = useState(settings.websiteUrl ?? "");
|
||||||
|
const [holderName, setHolderName] = useState(settings.holderName ?? "");
|
||||||
|
const [holderAddress, setHolderAddress] = useState(settings.holderAddress ?? "");
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: updateSiteSettings,
|
mutationFn: updateSiteSettings,
|
||||||
@@ -35,6 +37,8 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
|||||||
instagramUrl,
|
instagramUrl,
|
||||||
tiktokUrl,
|
tiktokUrl,
|
||||||
websiteUrl,
|
websiteUrl,
|
||||||
|
holderName,
|
||||||
|
holderAddress,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +90,28 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
|||||||
onChange={(event) => setWebsiteUrl(event.target.value)}
|
onChange={(event) => setWebsiteUrl(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export default function NewTreatmentPage() {
|
|||||||
const [batchNumber, setBatchNumber] = useState("");
|
const [batchNumber, setBatchNumber] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
|
const [withdrawalPeriodDays, setWithdrawalPeriodDays] = useState("");
|
||||||
|
const [supplier, setSupplier] = useState("");
|
||||||
|
const [purchaseProof, setPurchaseProof] = useState("");
|
||||||
|
const [prescribingVet, setPrescribingVet] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// One key per opened form: a double tap or an offline retry of the
|
// 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,
|
batchNumber: batchNumber || undefined,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
withdrawalPeriodDays: withdrawalPeriodDays ? Number(withdrawalPeriodDays) : 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.
|
geerntet werden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="treatment-notes">Notizen</Label>
|
<Label htmlFor="treatment-notes">Notizen</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ export async function exportColonyRecordCardPdf(colonyId: string, code: string):
|
|||||||
downloadBlob(blob, filename);
|
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> {
|
export async function exportApiaryAnnualReportPdf(apiaryId: string, year: number): Promise<void> {
|
||||||
const { blob, filename } = await apiFetchBlob(
|
const { blob, filename } = await apiFetchBlob(
|
||||||
`/export/apiary-annual-report.pdf?apiaryId=${apiaryId}&year=${year}`,
|
`/export/apiary-annual-report.pdf?apiaryId=${apiaryId}&year=${year}`,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface SiteSettings {
|
|||||||
instagramUrl: string | null;
|
instagramUrl: string | null;
|
||||||
tiktokUrl: string | null;
|
tiktokUrl: string | null;
|
||||||
websiteUrl: string | null;
|
websiteUrl: string | null;
|
||||||
|
holderName: string | null;
|
||||||
|
holderAddress: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,4 +14,6 @@ export interface UpdateSiteSettingsInput {
|
|||||||
instagramUrl?: string;
|
instagramUrl?: string;
|
||||||
tiktokUrl?: string;
|
tiktokUrl?: string;
|
||||||
websiteUrl?: string;
|
websiteUrl?: string;
|
||||||
|
holderName?: string;
|
||||||
|
holderAddress?: string;
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,9 @@ export interface Treatment {
|
|||||||
batchNumber: string | null;
|
batchNumber: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
withdrawalPeriodDays: number | null;
|
withdrawalPeriodDays: number | null;
|
||||||
|
supplier: string | null;
|
||||||
|
purchaseProof: string | null;
|
||||||
|
prescribingVet: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +32,9 @@ export interface CreateTreatmentInput {
|
|||||||
batchNumber?: string;
|
batchNumber?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
withdrawalPeriodDays?: number;
|
withdrawalPeriodDays?: number;
|
||||||
|
supplier?: string;
|
||||||
|
purchaseProof?: string;
|
||||||
|
prescribingVet?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTreatmentInput {
|
export interface UpdateTreatmentInput {
|
||||||
@@ -45,6 +51,9 @@ export interface UpdateTreatmentInput {
|
|||||||
batchNumber?: string;
|
batchNumber?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
withdrawalPeriodDays?: number;
|
withdrawalPeriodDays?: number;
|
||||||
|
supplier?: string;
|
||||||
|
purchaseProof?: string;
|
||||||
|
prescribingVet?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WithdrawalStatus {
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| E2E-Tests | Grün | 23 rot (1 Produktfehler, 3 veraltete Tests) | – | behoben | AT |
|
||||||
|
|
||||||
## Prüfungen (letzter Lauf)
|
## 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.
|
- Frontend: Typecheck, ESLint, 44 Tests, `next build` – grün. Design-Detektor (impeccable): 0 Befunde.
|
||||||
- Wabenhain: Typecheck, ESLint (geänderte Dateien), 538 Tests – grün.
|
- Wabenhain: Typecheck, ESLint (geänderte Dateien), 538 Tests – grün.
|
||||||
- Manuell (Browser, lokale Demo-Daten): Login, Heute-Ansicht Desktop/Mobil, Abhaken in Liste und
|
- 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
|
## Nächste Schritte
|
||||||
|
|
||||||
1. ~~Zuständigkeit pro Feld im Sync~~ – erledigt.
|
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.
|
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 `APICORE_API_KEY` (Passwort-Login bleibt Rückfall).
|
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.
|
4. Ende-zu-Ende-Test beider Systeme gegen Test-Instanzen.
|
||||||
5. Merge/Push.
|
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
|
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.
|
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
|
2. ~~Service-Konto statt API-Schlüssel~~ – erledigt: nur noch API-Schlüssel mit Scope `shop-sync:read`.
|
||||||
eigener, nur lesender API-Token-Mechanismus in ApiCore wäre sicherer – bewusst nicht ohne Absprache gebaut.
|
|
||||||
3. **Status „blockiert“** existiert im Aufgabenmodell nicht. Nicht ergänzt (Schemaänderung + fachliche Definition nötig).
|
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
|
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.
|
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);
|
5. Automatische Vorschläge haben standardmäßig **keinen Termin** (keine erfundene Frist);
|
||||||
je Regel über `config.dueInDays` einstellbar.
|
je Regel über `config.dueInDays` einstellbar.
|
||||||
6. Übrige Formulare/Seiten wurden nicht einzeln überarbeitet (Fokus: Heute, Aufgaben, Profil).
|
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