feat(export): Stockkarte - a colony's complete record as PDF
New report colony-record-card (button "Stockkarte (PDF)" on the colony page): master data, current queen and queen history, every inspection as a table row (strength, eggs, open/capped brood, queen seen, queen cells, gentleness, swarm drive, feed, measures, notes), treatments with batch number and withdrawal period, feedings, varroa counts and harvests. Landscape A4, table header repeated on each page. Whole history by default, one calendar year with ?year=. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
5cf4e87ca3
commit
f263ec79f2
5 files changed
+586
-1
No files matched your search
@@ -0,0 +1,518 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stockkarte: the complete written record of one colony - master data, queen,
|
||||||
|
* every inspection as a table row, treatments, feedings, varroa counts and
|
||||||
|
* harvests. For the folder at the apiary or to hand over with a sold colony.
|
||||||
|
* Whole history by default, or one calendar year.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COLONY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
PRODUCTION_COLONY: 'Wirtschaftsvolk',
|
||||||
|
NUCLEUS_COLONY: 'Ableger',
|
||||||
|
YOUNG_COLONY: 'Jungvolk',
|
||||||
|
CARETAKER_COLONY: 'Pflegevolk',
|
||||||
|
MATING_NUCLEUS: 'Begattungseinheit',
|
||||||
|
RESERVE_COLONY: 'Reservevolk',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLONY_STATUS_LABELS: Record<string, string> = {
|
||||||
|
ACTIVE: 'Aktiv',
|
||||||
|
WEAK: 'Schwach',
|
||||||
|
CONSPICUOUS: 'Auffällig',
|
||||||
|
QUEENLESS: 'Weisellos',
|
||||||
|
DISSOLVED: 'Aufgelöst',
|
||||||
|
SOLD: 'Verkauft',
|
||||||
|
DECEASED: 'Eingegangen',
|
||||||
|
MERGED: 'Vereinigt',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEED_TYPE_LABELS: Record<string, string> = {
|
||||||
|
SUGAR_WATER: 'Zuckerwasser',
|
||||||
|
FEED_SYRUP: 'Futtersirup',
|
||||||
|
FEED_PASTE: 'Futterteig',
|
||||||
|
OWN_HONEY: 'Eigener Honig',
|
||||||
|
OTHER: 'Sonstiges',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VARROA_METHOD_LABELS: Record<string, string> = {
|
||||||
|
NATURAL_MITE_FALL: 'Natürlicher Milbenfall',
|
||||||
|
POWDERED_SUGAR: 'Puderzucker',
|
||||||
|
WASHING_METHOD: 'Auswaschen',
|
||||||
|
DRONE_BROOD_CONTROL: 'Drohnenbrut',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ColonyRecordCard {
|
||||||
|
generatedAt: Date;
|
||||||
|
year: number | null;
|
||||||
|
colony: {
|
||||||
|
code: string;
|
||||||
|
colonyType: string;
|
||||||
|
status: string;
|
||||||
|
hiveType: string | null;
|
||||||
|
originText: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
apiaryName: string;
|
||||||
|
apiaryLocation: string | null;
|
||||||
|
};
|
||||||
|
currentQueen: {
|
||||||
|
code: string;
|
||||||
|
hatchYear: number | null;
|
||||||
|
markingColor: string | null;
|
||||||
|
breed: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
} | null;
|
||||||
|
queenHistory: { code: string; startDate: Date; endDate: Date | null }[];
|
||||||
|
inspections: {
|
||||||
|
date: Date;
|
||||||
|
colonyStrength: number | null;
|
||||||
|
openBroodPresent: boolean | null;
|
||||||
|
cappedBroodPresent: boolean | null;
|
||||||
|
eggsPresent: boolean | null;
|
||||||
|
queenSeen: boolean | null;
|
||||||
|
queenCellsPresent: boolean | null;
|
||||||
|
gentleness: number | null;
|
||||||
|
swarmMood: number | null;
|
||||||
|
feedStatus: string | null;
|
||||||
|
measures: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}[];
|
||||||
|
treatments: {
|
||||||
|
date: Date;
|
||||||
|
endDate: Date | null;
|
||||||
|
product: string;
|
||||||
|
activeIngredient: string | null;
|
||||||
|
amount: string | null;
|
||||||
|
batchNumber: string | null;
|
||||||
|
withdrawalPeriodDays: number | null;
|
||||||
|
}[];
|
||||||
|
feedings: {
|
||||||
|
date: Date;
|
||||||
|
feedType: string;
|
||||||
|
product: string | null;
|
||||||
|
amount: number;
|
||||||
|
unit: string;
|
||||||
|
}[];
|
||||||
|
varroaMeasurements: {
|
||||||
|
date: Date;
|
||||||
|
method: string;
|
||||||
|
miteCount: number | null;
|
||||||
|
calculatedValue: number | null;
|
||||||
|
}[];
|
||||||
|
harvests: {
|
||||||
|
date: Date;
|
||||||
|
honeyType: string | null;
|
||||||
|
netWeight: number | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateFilter(year: number | null) {
|
||||||
|
return year === null
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
gte: new Date(Date.UTC(year, 0, 1)),
|
||||||
|
lt: new Date(Date.UTC(year + 1, 0, 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildColonyRecordCard(
|
||||||
|
prisma: PrismaService,
|
||||||
|
colonyId: string,
|
||||||
|
year: number | null,
|
||||||
|
): Promise<ColonyRecordCard> {
|
||||||
|
const colony = await prisma.colony.findUnique({
|
||||||
|
where: { id: colonyId },
|
||||||
|
include: { apiary: true, currentQueen: true },
|
||||||
|
});
|
||||||
|
if (!colony) {
|
||||||
|
throw new NotFoundException('Colony not found');
|
||||||
|
}
|
||||||
|
const date = dateFilter(year);
|
||||||
|
const [
|
||||||
|
inspections,
|
||||||
|
treatments,
|
||||||
|
feedings,
|
||||||
|
varroa,
|
||||||
|
harvestLinks,
|
||||||
|
queenHistory,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.inspection.findMany({
|
||||||
|
where: { colonyId, date },
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
}),
|
||||||
|
prisma.treatment.findMany({
|
||||||
|
where: { colonyId, date },
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
}),
|
||||||
|
prisma.feeding.findMany({
|
||||||
|
where: { colonyId, date },
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
}),
|
||||||
|
prisma.varroaMeasurement.findMany({
|
||||||
|
where: { colonyId, date },
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
}),
|
||||||
|
prisma.harvestColony.findMany({
|
||||||
|
where: { colonyId, harvest: { date } },
|
||||||
|
include: { harvest: true },
|
||||||
|
orderBy: { harvest: { date: 'asc' } },
|
||||||
|
}),
|
||||||
|
prisma.queenColonyHistory.findMany({
|
||||||
|
where: { colonyId },
|
||||||
|
include: { queen: true },
|
||||||
|
orderBy: { startDate: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const queen = colony.currentQueen;
|
||||||
|
return {
|
||||||
|
generatedAt: new Date(),
|
||||||
|
year,
|
||||||
|
colony: {
|
||||||
|
code: colony.code,
|
||||||
|
colonyType: colony.colonyType,
|
||||||
|
status: colony.status,
|
||||||
|
hiveType: colony.hiveType,
|
||||||
|
originText: colony.originText,
|
||||||
|
createdAt: colony.createdAt,
|
||||||
|
apiaryName: colony.apiary.name,
|
||||||
|
apiaryLocation: colony.apiary.location,
|
||||||
|
},
|
||||||
|
currentQueen: queen
|
||||||
|
? {
|
||||||
|
code: queen.code,
|
||||||
|
hatchYear: queen.hatchYear,
|
||||||
|
markingColor: queen.markingColor,
|
||||||
|
breed: queen.breed,
|
||||||
|
origin: queen.origin,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
queenHistory: queenHistory.map((entry) => ({
|
||||||
|
code: entry.queen.code,
|
||||||
|
startDate: entry.startDate,
|
||||||
|
endDate: entry.endDate,
|
||||||
|
})),
|
||||||
|
inspections: inspections.map((i) => ({
|
||||||
|
date: i.date,
|
||||||
|
colonyStrength: i.colonyStrength,
|
||||||
|
openBroodPresent: i.openBroodPresent,
|
||||||
|
cappedBroodPresent: i.cappedBroodPresent,
|
||||||
|
eggsPresent: i.eggsPresent,
|
||||||
|
queenSeen: i.queenSeen,
|
||||||
|
queenCellsPresent: i.queenCellsPresent,
|
||||||
|
gentleness: i.gentleness,
|
||||||
|
swarmMood: i.swarmMood,
|
||||||
|
feedStatus: i.feedStatus,
|
||||||
|
measures: i.measures,
|
||||||
|
notes: i.notes,
|
||||||
|
})),
|
||||||
|
treatments: treatments.map((t) => ({
|
||||||
|
date: t.date,
|
||||||
|
endDate: t.endDate,
|
||||||
|
product: t.product,
|
||||||
|
activeIngredient: t.activeIngredient,
|
||||||
|
amount: t.amount,
|
||||||
|
batchNumber: t.batchNumber,
|
||||||
|
withdrawalPeriodDays: t.withdrawalPeriodDays,
|
||||||
|
})),
|
||||||
|
feedings: feedings.map((f) => ({
|
||||||
|
date: f.date,
|
||||||
|
feedType: f.feedType,
|
||||||
|
product: f.product,
|
||||||
|
amount: f.amount,
|
||||||
|
unit: f.unit,
|
||||||
|
})),
|
||||||
|
varroaMeasurements: varroa.map((v) => ({
|
||||||
|
date: v.date,
|
||||||
|
method: v.method,
|
||||||
|
miteCount: v.miteCount,
|
||||||
|
calculatedValue: v.calculatedValue,
|
||||||
|
})),
|
||||||
|
harvests: harvestLinks.map((link) => ({
|
||||||
|
date: link.harvest.date,
|
||||||
|
honeyType: link.harvest.honeyType,
|
||||||
|
netWeight: link.harvest.netWeight,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MARGIN = 36;
|
||||||
|
|
||||||
|
function day(date: Date): string {
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Landscape: the inspection table needs the width.
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: 'A4',
|
||||||
|
layout: 'landscape',
|
||||||
|
margin: MARGIN,
|
||||||
|
});
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
|
||||||
|
const { colony } = card;
|
||||||
|
doc
|
||||||
|
.font('Helvetica-Bold')
|
||||||
|
.fontSize(16)
|
||||||
|
.text(`Stockkarte ${colony.code}`, MARGIN, MARGIN);
|
||||||
|
doc
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(9)
|
||||||
|
.text(
|
||||||
|
`${card.year === null ? 'Gesamte Historie' : `Jahr ${card.year}`} · erstellt am ${day(card.generatedAt)}`,
|
||||||
|
);
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
doc
|
||||||
|
.fontSize(9)
|
||||||
|
.text(
|
||||||
|
[
|
||||||
|
`Stand: ${colony.apiaryName}${colony.apiaryLocation ? ` (${colony.apiaryLocation})` : ''}`,
|
||||||
|
`Art: ${COLONY_TYPE_LABELS[colony.colonyType] ?? colony.colonyType}`,
|
||||||
|
`Status: ${COLONY_STATUS_LABELS[colony.status] ?? colony.status}`,
|
||||||
|
`Beute: ${orDash(colony.hiveType)}`,
|
||||||
|
`Herkunft: ${orDash(colony.originText)}`,
|
||||||
|
`Erfasst seit: ${day(colony.createdAt)}`,
|
||||||
|
].join(' · '),
|
||||||
|
);
|
||||||
|
|
||||||
|
section(doc, 'Königin');
|
||||||
|
const queen = card.currentQueen;
|
||||||
|
doc
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(9)
|
||||||
|
.text(
|
||||||
|
queen
|
||||||
|
? `${queen.code} · Schlupfjahr ${orDash(queen.hatchYear)} · Zeichen ${orDash(queen.markingColor)} · Rasse ${orDash(queen.breed)} · Herkunft ${orDash(queen.origin)}`
|
||||||
|
: 'Keine Königin zugeordnet.',
|
||||||
|
MARGIN,
|
||||||
|
);
|
||||||
|
if (card.queenHistory.length > 0) {
|
||||||
|
doc.text(
|
||||||
|
`Verlauf: ${card.queenHistory
|
||||||
|
.map(
|
||||||
|
(q) =>
|
||||||
|
`${q.code} (${day(q.startDate)} – ${q.endDate ? day(q.endDate) : 'heute'})`,
|
||||||
|
)
|
||||||
|
.join('; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
section(doc, 'Durchsichten');
|
||||||
|
drawTable(
|
||||||
|
doc,
|
||||||
|
fillWidth(doc, [
|
||||||
|
{ header: 'Datum', width: 58 },
|
||||||
|
{ header: 'Stärke (Waben)', width: 52 },
|
||||||
|
{ header: 'Stifte', width: 36 },
|
||||||
|
{ header: 'Offene Brut', width: 42 },
|
||||||
|
{ header: 'Verd. Brut', width: 42 },
|
||||||
|
{ header: 'Kö gesehen', width: 44 },
|
||||||
|
{ header: 'Weiselzellen', width: 60 },
|
||||||
|
{ header: 'Sanftmut 1–5', width: 44 },
|
||||||
|
{ header: 'Schwarmtrieb 1–5', width: 66 },
|
||||||
|
{ header: 'Futter', width: 56 },
|
||||||
|
{ header: 'Maßnahmen', width: 126 },
|
||||||
|
{ header: 'Notizen', width: 0 },
|
||||||
|
]),
|
||||||
|
card.inspections.map((i) => [
|
||||||
|
day(i.date),
|
||||||
|
orDash(i.colonyStrength),
|
||||||
|
yesNo(i.eggsPresent),
|
||||||
|
yesNo(i.openBroodPresent),
|
||||||
|
yesNo(i.cappedBroodPresent),
|
||||||
|
yesNo(i.queenSeen),
|
||||||
|
yesNo(i.queenCellsPresent),
|
||||||
|
orDash(i.gentleness),
|
||||||
|
orDash(i.swarmMood),
|
||||||
|
orDash(i.feedStatus),
|
||||||
|
orDash(i.measures),
|
||||||
|
orDash(i.notes),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
section(doc, 'Behandlungen');
|
||||||
|
drawTable(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
{ header: 'Datum', width: 70 },
|
||||||
|
{ header: 'Ende', width: 70 },
|
||||||
|
{ header: 'Mittel', width: 150 },
|
||||||
|
{ header: 'Wirkstoff', width: 120 },
|
||||||
|
{ header: 'Menge', width: 100 },
|
||||||
|
{ header: 'Chargen-Nr.', width: 100 },
|
||||||
|
{ header: 'Wartezeit (Tage)', width: 80 },
|
||||||
|
],
|
||||||
|
card.treatments.map((t) => [
|
||||||
|
day(t.date),
|
||||||
|
t.endDate ? day(t.endDate) : '–',
|
||||||
|
t.product,
|
||||||
|
orDash(t.activeIngredient),
|
||||||
|
orDash(t.amount),
|
||||||
|
orDash(t.batchNumber),
|
||||||
|
orDash(t.withdrawalPeriodDays),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
section(doc, 'Fütterungen');
|
||||||
|
drawTable(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
{ header: 'Datum', width: 70 },
|
||||||
|
{ header: 'Futterart', width: 120 },
|
||||||
|
{ header: 'Produkt', width: 200 },
|
||||||
|
{ header: 'Menge', width: 100 },
|
||||||
|
],
|
||||||
|
card.feedings.map((f) => [
|
||||||
|
day(f.date),
|
||||||
|
FEED_TYPE_LABELS[f.feedType] ?? f.feedType,
|
||||||
|
orDash(f.product),
|
||||||
|
`${f.amount} ${f.unit}`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
section(doc, 'Varroa');
|
||||||
|
drawTable(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
{ header: 'Datum', width: 70 },
|
||||||
|
{ header: 'Methode', width: 150 },
|
||||||
|
{ header: 'Milben gezählt', width: 90 },
|
||||||
|
{ header: 'Berechneter Wert', width: 100 },
|
||||||
|
],
|
||||||
|
card.varroaMeasurements.map((v) => [
|
||||||
|
day(v.date),
|
||||||
|
VARROA_METHOD_LABELS[v.method] ?? v.method,
|
||||||
|
orDash(v.miteCount),
|
||||||
|
v.calculatedValue === null ? '–' : v.calculatedValue.toFixed(1),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
section(doc, 'Ernten');
|
||||||
|
drawTable(
|
||||||
|
doc,
|
||||||
|
[
|
||||||
|
{ header: 'Datum', width: 70 },
|
||||||
|
{ header: 'Sorte', width: 200 },
|
||||||
|
{ header: 'Netto (kg, gesamte Ernte)', width: 140 },
|
||||||
|
],
|
||||||
|
card.harvests.map((h) => [
|
||||||
|
day(h.date),
|
||||||
|
orDash(h.honeyType),
|
||||||
|
orDash(h.netWeight),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
|
import {
|
||||||
|
buildColonyRecordCard,
|
||||||
|
renderColonyRecordCardPdf,
|
||||||
|
} from './colony-record-card';
|
||||||
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 {
|
||||||
@@ -22,6 +26,7 @@ const PDF_REPORTS = [
|
|||||||
'honey-batch-traceability',
|
'honey-batch-traceability',
|
||||||
'colony-annual-report',
|
'colony-annual-report',
|
||||||
'apiary-annual-report',
|
'apiary-annual-report',
|
||||||
|
'colony-record-card',
|
||||||
] as const;
|
] as const;
|
||||||
type PdfReport = (typeof PDF_REPORTS)[number];
|
type PdfReport = (typeof PDF_REPORTS)[number];
|
||||||
|
|
||||||
@@ -188,6 +193,20 @@ export class ExportService {
|
|||||||
);
|
);
|
||||||
return renderApiaryAnnualReportPdf(apiaryReport);
|
return renderApiaryAnnualReportPdf(apiaryReport);
|
||||||
}
|
}
|
||||||
|
case 'colony-record-card': {
|
||||||
|
if (!options.colonyId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Query parameter colonyId is required for this report',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Without a year the record card covers the colony's whole history.
|
||||||
|
const card = await buildColonyRecordCard(
|
||||||
|
this.prisma,
|
||||||
|
options.colonyId,
|
||||||
|
options.year ? resolveYear(options.year) : null,
|
||||||
|
);
|
||||||
|
return renderColonyRecordCardPdf(card);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,33 @@ describe('Export (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the record card (Stockkarte) for the whole history and for one year', async () => {
|
||||||
|
for (const query of [
|
||||||
|
`colonyId=${colonyId}`,
|
||||||
|
`colonyId=${colonyId}&year=2026`,
|
||||||
|
]) {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/export/colony-record-card.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 record card without a colonyId and for an unknown colony', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/export/colony-record-card.pdf')
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.expect(400);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/export/colony-record-card.pdf?colonyId=does-not-exist')
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
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')
|
||||||
|
|||||||
@@ -4,7 +4,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 { fetchColony } from "@/lib/colonies/api";
|
import { fetchColony } from "@/lib/colonies/api";
|
||||||
import { exportColonyAnnualReportPdf } from "@/lib/export/api";
|
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } 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";
|
||||||
@@ -31,6 +31,11 @@ export default function ColonyDetailPage() {
|
|||||||
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
|
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recordCardMutation = useMutation({
|
||||||
|
mutationFn: () => exportColonyRecordCardPdf(params.id, colony?.code ?? "volk"),
|
||||||
|
onError: () => toast.error("Stockkarte 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>;
|
||||||
}
|
}
|
||||||
@@ -45,6 +50,13 @@ export default function ColonyDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={recordCardMutation.isPending}
|
||||||
|
onClick={() => recordCardMutation.mutate()}
|
||||||
|
>
|
||||||
|
{recordCardMutation.isPending ? "Wird erstellt…" : "Stockkarte (PDF)"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={exportReportMutation.isPending}
|
disabled={exportReportMutation.isPending}
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ export async function exportColonyAnnualReportPdf(colonyId: string, year: number
|
|||||||
downloadBlob(blob, filename);
|
downloadBlob(blob, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stockkarte: the colony's whole history as a printable PDF. */
|
||||||
|
export async function exportColonyRecordCardPdf(colonyId: string, code: string): Promise<void> {
|
||||||
|
const { blob, filename } = await apiFetchBlob(
|
||||||
|
`/export/colony-record-card.pdf?colonyId=${colonyId}`,
|
||||||
|
`stockkarte-${code}.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}`,
|
||||||
|
|||||||
Reference in new issue
Block a user