feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
- Authority notice PDFs: pure model plus renderer, TSK count in A4 landscape, FEHLT for missing particulars, stored totals with correction note; GET /authority-notices/:id/pdf with compliance:read. - Compliance notifications in category COMPLIANCE: herd change and TSK reference day, at most one unread per topic and user (advisory lock); daily rule TskReferenceDayReached with Berlin day and catch-up; handlers in API and worker. - Readiness core: live evaluation of seven pure rules, fingerprinted acknowledgements that reopen when revoked or stale, subject masking, read-only GET paths; READINESS_RULES injectable; kept out of the worker. - e2e hygiene: no unscoped deletes, seeded settings rows saved/restored. - ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
edc193f128
commit
b912d29166
54 files changed
+5300
-14
No files matched your search
@@ -0,0 +1,275 @@
|
||||
import type { AuthorityNotice } from '../../../generated/prisma';
|
||||
import type { HerdApiarySnapshot, HerdSnapshot } from '../herd-snapshot';
|
||||
import { buildNoticePdfModel } from './authority-notice-model';
|
||||
|
||||
function apiary(patch: Partial<HerdApiarySnapshot> = {}): HerdApiarySnapshot {
|
||||
return {
|
||||
apiaryId: 'a1',
|
||||
name: 'Heide',
|
||||
address: 'Heideweg 1, 34117 Kassel',
|
||||
location: 'Kassel',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
livingColonies: 3,
|
||||
byType: { PRODUCTION_COLONY: 2, NUCLEUS_COLONY: 1 },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(apiaries: HerdApiarySnapshot[]): HerdSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
takenAt: '2027-01-02T10:00:00.000Z',
|
||||
apiaries,
|
||||
totalLiving: apiaries.reduce((sum, a) => sum + a.livingColonies, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function notice(
|
||||
patch: Partial<AuthorityNotice> = {},
|
||||
apiaries: HerdApiarySnapshot[] = [apiary()],
|
||||
): AuthorityNotice {
|
||||
const snap = snapshot(apiaries);
|
||||
return {
|
||||
id: 'n1',
|
||||
kind: 'REGISTRATION',
|
||||
status: 'DRAFT',
|
||||
year: null,
|
||||
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
|
||||
countedAt: new Date('2027-01-02T10:00:00.000Z'),
|
||||
snapshot: snap as never,
|
||||
totalColonies: snap.totalLiving,
|
||||
sentOn: null,
|
||||
sentVia: null,
|
||||
reference: null,
|
||||
notes: null,
|
||||
openKey: 'REGISTRATION',
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date('2027-01-03T09:00:00.000Z'),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
const SITE = {
|
||||
holderName: 'Imkerei Muster',
|
||||
holderAddress: 'Weg 2, 34117 Kassel',
|
||||
};
|
||||
const SETTINGS = {
|
||||
veterinaryOfficeName: 'Veterinäramt Kassel' as string | null,
|
||||
veterinaryOfficeAddress: 'Amtsstr. 1, 34117 Kassel' as string | null,
|
||||
registrationNumber: 'DE-123' as string | null,
|
||||
tskMemberNumber: 'TSK-9' as string | null,
|
||||
};
|
||||
const NOW = new Date('2027-01-05T12:00:00.000Z');
|
||||
|
||||
function build(
|
||||
n: AuthorityNotice,
|
||||
baseline: AuthorityNotice | null = null,
|
||||
overrides: {
|
||||
site?: { holderName: string | null; holderAddress: string | null };
|
||||
settings?: typeof SETTINGS;
|
||||
} = {},
|
||||
) {
|
||||
return buildNoticePdfModel({
|
||||
notice: n,
|
||||
baseline,
|
||||
site: overrides.site ?? SITE,
|
||||
settings: overrides.settings ?? SETTINGS,
|
||||
now: NOW,
|
||||
});
|
||||
}
|
||||
|
||||
describe('buildNoticePdfModel', () => {
|
||||
it('keeps an empty veterinary office and an empty holder as null so the renderer prints FEHLT', () => {
|
||||
const model = build(notice(), null, {
|
||||
site: { holderName: null, holderAddress: ' ' },
|
||||
settings: {
|
||||
...SETTINGS,
|
||||
veterinaryOfficeName: '',
|
||||
veterinaryOfficeAddress: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.holder).toBeNull();
|
||||
expect(model.office).toEqual({ name: null, address: null });
|
||||
});
|
||||
|
||||
it('the first notice has the heading "Anzeige der Bienenhaltung (§ 1a BienSeuchV)" and no "vorher" column', () => {
|
||||
const model = build(notice());
|
||||
|
||||
expect(model.title).toBe('Anzeige der Bienenhaltung (§ 1a BienSeuchV)');
|
||||
expect(model.columns).toEqual(['Stand', 'Lage', 'Völker']);
|
||||
expect(model.rows[0].previous).toBeNull();
|
||||
expect(model.totals).toEqual({ current: 3, previous: null });
|
||||
expect(model.changes).toEqual([]);
|
||||
expect(model.referenceLine).toBe('Stand der Völker am 01.01.2027');
|
||||
});
|
||||
|
||||
it('the change notice shows the previous count per apiary and the totals', () => {
|
||||
const baseline = notice(
|
||||
{ kind: 'REGISTRATION', status: 'SENT', sentOn: new Date('2026-06-01') },
|
||||
[apiary({ livingColonies: 2 })],
|
||||
);
|
||||
const model = build(
|
||||
notice({ kind: 'HERD_CHANGE', openKey: 'HERD_CHANGE' }),
|
||||
baseline,
|
||||
);
|
||||
|
||||
expect(model.title).toBe(
|
||||
'Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)',
|
||||
);
|
||||
expect(model.columns).toEqual(['Stand', 'Lage', 'Völker', 'vorher']);
|
||||
expect(model.rows[0]).toMatchObject({
|
||||
name: 'Heide',
|
||||
cells: ['3'],
|
||||
previous: 2,
|
||||
});
|
||||
expect(model.totals).toEqual({ current: 3, previous: 2 });
|
||||
});
|
||||
|
||||
it('lists new and abandoned apiaries under "Änderungen seit der Anzeige vom {sentOn}"', () => {
|
||||
const baseline = notice(
|
||||
{ kind: 'REGISTRATION', status: 'SENT', sentOn: new Date('2026-06-01') },
|
||||
[apiary(), apiary({ apiaryId: 'a2', name: 'Alt', livingColonies: 1 })],
|
||||
);
|
||||
const model = build(
|
||||
notice({ kind: 'HERD_CHANGE' }, [
|
||||
apiary(),
|
||||
apiary({ apiaryId: 'a3', name: 'Neu', livingColonies: 1 }),
|
||||
]),
|
||||
baseline,
|
||||
);
|
||||
|
||||
expect(model.changes[0]).toBe(
|
||||
'Änderungen seit der Anzeige vom 01.06.2026:',
|
||||
);
|
||||
expect(model.changes).toContain('Stand „Neu“ neu');
|
||||
expect(model.changes).toContain('Stand „Alt“ aufgegeben');
|
||||
});
|
||||
|
||||
it('prints coordinates with five decimals only when there is no address', () => {
|
||||
const model = build(
|
||||
notice({}, [
|
||||
apiary({ apiaryId: 'a1', name: 'Mit', address: 'Weg 1' }),
|
||||
apiary({
|
||||
apiaryId: 'a2',
|
||||
name: 'Ohne',
|
||||
address: null,
|
||||
latitude: 51.3127,
|
||||
longitude: 9.4797,
|
||||
}),
|
||||
apiary({
|
||||
apiaryId: 'a3',
|
||||
name: 'Nichts',
|
||||
address: null,
|
||||
location: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(model.rows.map((r) => r.location)).toEqual([
|
||||
'Weg 1',
|
||||
'51.31270, 9.47970',
|
||||
'FEHLT',
|
||||
]);
|
||||
});
|
||||
|
||||
it('the TSK model has one column per counted colony type and a sum', () => {
|
||||
const model = build(
|
||||
notice({ kind: 'TSK_REPORT', year: 2027, openKey: 'TSK_REPORT:2027' }),
|
||||
);
|
||||
|
||||
expect(model.title).toBe(
|
||||
'Zählnachweis Bienenvölker zum Stichtag 01.01.2027',
|
||||
);
|
||||
expect(model.columns).toEqual([
|
||||
'Stand',
|
||||
'Lage',
|
||||
'Wirtschaftsvolk',
|
||||
'Ableger',
|
||||
'Summe',
|
||||
]);
|
||||
expect(model.rows[0].cells).toEqual(['2', '1', '3']);
|
||||
expect(model.tskMemberNumber).toBe('TSK-9');
|
||||
expect(model.totals).toEqual({ current: 3, previous: null });
|
||||
});
|
||||
|
||||
it('the TSK model says "Gezählt am {countedAt}, nicht am Stichtag. Bitte prüfen." when counted after the reference day', () => {
|
||||
const late = build(notice({ kind: 'TSK_REPORT', year: 2027 }));
|
||||
const onTime = build(
|
||||
notice({
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2027,
|
||||
countedAt: new Date('2026-12-31T23:30:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(late.countedNote).toBe(
|
||||
'Gezählt am 02.01.2027, nicht am Stichtag. Bitte prüfen.',
|
||||
);
|
||||
expect(onTime.countedNote).toBe('Gezählt am 01.01.2027');
|
||||
});
|
||||
|
||||
it('the TSK footer says "Ersetzt nicht die Meldung an die Tierseuchenkasse."', () => {
|
||||
const model = build(notice({ kind: 'TSK_REPORT', year: 2027 }));
|
||||
|
||||
expect(model.footer).toContain(
|
||||
'Ersetzt nicht die Meldung an die Tierseuchenkasse.',
|
||||
);
|
||||
expect(model.footer).toContain(
|
||||
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
|
||||
);
|
||||
});
|
||||
|
||||
it('the registration footer has no TSK sentence and the TSK number stays out', () => {
|
||||
const model = build(notice());
|
||||
|
||||
expect(model.footer).toEqual([
|
||||
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
|
||||
]);
|
||||
expect(model.tskMemberNumber).toBeNull();
|
||||
expect(model.countedNote).toBeNull();
|
||||
});
|
||||
|
||||
it('derives the place from the last holder address line and dates the letter in Berlin time', () => {
|
||||
const model = build(notice());
|
||||
|
||||
expect(model.place).toBe('Kassel');
|
||||
expect(model.dateLine).toBe('Kassel, 05.01.2027');
|
||||
});
|
||||
|
||||
it('prints FEHLT for the address when only the holder name is entered', () => {
|
||||
const model = build(notice(), null, {
|
||||
site: { holderName: 'Imkerei Muster', holderAddress: null },
|
||||
});
|
||||
|
||||
expect(model.holder).toBe('Imkerei Muster, FEHLT');
|
||||
expect(model.place).toBeNull();
|
||||
expect(model.dateLine).toBe('05.01.2027');
|
||||
});
|
||||
|
||||
it('uses the stored total on both sides and notes a corrected total', () => {
|
||||
const baseline = notice(
|
||||
{
|
||||
kind: 'REGISTRATION',
|
||||
status: 'SENT',
|
||||
sentOn: new Date('2026-06-01'),
|
||||
totalColonies: 5,
|
||||
},
|
||||
[apiary({ livingColonies: 2 })],
|
||||
);
|
||||
const model = build(
|
||||
notice({ kind: 'HERD_CHANGE', totalColonies: 7 }),
|
||||
baseline,
|
||||
);
|
||||
|
||||
expect(model.totals).toEqual({ current: 7, previous: 5 });
|
||||
expect(model.totalNote).toBe('Gesamtzahl berichtigt (Summe der Stände: 3)');
|
||||
expect(model.changes).toContain('Zahl der Völker: 5 auf 7');
|
||||
});
|
||||
|
||||
it('has no total note while the total matches the rows', () => {
|
||||
expect(build(notice()).totalNote).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import type {
|
||||
AuthorityNotice,
|
||||
AuthorityNoticeKind,
|
||||
ColonyType,
|
||||
ComplianceSettings,
|
||||
SiteSettings,
|
||||
} from '../../../generated/prisma';
|
||||
import { berlinDayOf } from '../../common/dates';
|
||||
import { formatDayDe } from '../../common/format-day';
|
||||
import type { HerdApiarySnapshot, HerdSnapshot } from '../herd-snapshot';
|
||||
|
||||
/** Printed in place of a mandatory particular that is not entered yet. */
|
||||
export const MISSING = 'FEHLT';
|
||||
|
||||
export interface NoticePdfModel {
|
||||
kind: AuthorityNoticeKind;
|
||||
title: string;
|
||||
holder: string | null;
|
||||
registrationNumber: string | null;
|
||||
tskMemberNumber: string | null;
|
||||
office: { name: string | null; address: string | null };
|
||||
place: string | null;
|
||||
dateLine: string;
|
||||
referenceLine: string;
|
||||
columns: string[];
|
||||
rows: {
|
||||
name: string;
|
||||
location: string;
|
||||
cells: string[];
|
||||
previous: number | null;
|
||||
}[];
|
||||
totals: { current: number; previous: number | null };
|
||||
changes: string[];
|
||||
/** Set when the stored total was corrected and differs from the rows. */
|
||||
totalNote: string | null;
|
||||
countedNote: string | null;
|
||||
footer: string[];
|
||||
}
|
||||
|
||||
export interface NoticePdfInput {
|
||||
notice: AuthorityNotice;
|
||||
baseline: AuthorityNotice | null;
|
||||
site: Pick<SiteSettings, 'holderName' | 'holderAddress'>;
|
||||
settings: Pick<
|
||||
ComplianceSettings,
|
||||
| 'veterinaryOfficeName'
|
||||
| 'veterinaryOfficeAddress'
|
||||
| 'registrationNumber'
|
||||
| 'tskMemberNumber'
|
||||
>;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
const TITLE_REGISTRATION = 'Anzeige der Bienenhaltung (§ 1a BienSeuchV)';
|
||||
const TITLE_HERD_CHANGE = 'Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)';
|
||||
const TSK_DISCLAIMER = 'Ersetzt nicht die Meldung an die Tierseuchenkasse.';
|
||||
const COORDINATE_DECIMALS = 5;
|
||||
|
||||
/** In display order; the TSK columns follow it. */
|
||||
const COLONY_TYPES: readonly { type: ColonyType; label: string }[] = [
|
||||
{ type: 'PRODUCTION_COLONY', label: 'Wirtschaftsvolk' },
|
||||
{ type: 'NUCLEUS_COLONY', label: 'Ableger' },
|
||||
{ type: 'YOUNG_COLONY', label: 'Jungvolk' },
|
||||
{ type: 'CARETAKER_COLONY', label: 'Pflegevolk' },
|
||||
{ type: 'MATING_NUCLEUS', label: 'Begattungs-einheit' },
|
||||
{ type: 'RESERVE_COLONY', label: 'Reservevolk' },
|
||||
];
|
||||
|
||||
function clean(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** A stored day (midnight UTC) as dd.mm.yyyy. */
|
||||
function storedDay(date: Date): string {
|
||||
return formatDayDe(date.toISOString().slice(0, 10), true);
|
||||
}
|
||||
|
||||
/** An instant as the dd.mm.yyyy of its Berlin calendar day. */
|
||||
function berlinDay(instant: Date): string {
|
||||
return formatDayDe(berlinDayOf(instant), true);
|
||||
}
|
||||
|
||||
/** The town of a "Street 1, 34117 Town" address, when it has a postcode. */
|
||||
function placeOf(address: string | null): string | null {
|
||||
const match = address ? /\b\d{5}\s+([^,\n]+?)\s*$/.exec(address) : null;
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function locationOf(apiary: HerdApiarySnapshot): string {
|
||||
const address = clean(apiary.address);
|
||||
if (address) {
|
||||
return address;
|
||||
}
|
||||
if (apiary.latitude !== null && apiary.longitude !== null) {
|
||||
return `${apiary.latitude.toFixed(COORDINATE_DECIMALS)}, ${apiary.longitude.toFixed(COORDINATE_DECIMALS)}`;
|
||||
}
|
||||
return clean(apiary.location) ?? MISSING;
|
||||
}
|
||||
|
||||
function sameSite(a: HerdApiarySnapshot, b: HerdApiarySnapshot): boolean {
|
||||
return (
|
||||
a.address === b.address &&
|
||||
a.location === b.location &&
|
||||
a.latitude === b.latitude &&
|
||||
a.longitude === b.longitude
|
||||
);
|
||||
}
|
||||
|
||||
function titleOf(notice: AuthorityNotice, hasBaseline: boolean): string {
|
||||
if (notice.kind === 'TSK_REPORT') {
|
||||
return `Zählnachweis Bienenvölker zum Stichtag ${storedDay(notice.referenceDate)}`;
|
||||
}
|
||||
return notice.kind === 'HERD_CHANGE' && hasBaseline
|
||||
? TITLE_HERD_CHANGE
|
||||
: TITLE_REGISTRATION;
|
||||
}
|
||||
|
||||
function changeLines(
|
||||
baseline: AuthorityNotice,
|
||||
before: HerdSnapshot,
|
||||
current: HerdSnapshot,
|
||||
currentTotal: number,
|
||||
): string[] {
|
||||
const old = new Map(before.apiaries.map((a) => [a.apiaryId, a]));
|
||||
const now = new Map(current.apiaries.map((a) => [a.apiaryId, a]));
|
||||
const lines = [
|
||||
`Änderungen seit der Anzeige vom ${baseline.sentOn ? storedDay(baseline.sentOn) : '–'}:`,
|
||||
];
|
||||
for (const apiary of current.apiaries) {
|
||||
const previous = old.get(apiary.apiaryId);
|
||||
if (!previous) {
|
||||
lines.push(`Stand „${apiary.name}“ neu`);
|
||||
} else if (!sameSite(previous, apiary)) {
|
||||
lines.push(`Stand „${apiary.name}“: Lage geändert`);
|
||||
}
|
||||
}
|
||||
for (const apiary of before.apiaries) {
|
||||
if (!now.has(apiary.apiaryId)) {
|
||||
lines.push(`Stand „${apiary.name}“ aufgegeben`);
|
||||
}
|
||||
}
|
||||
if (baseline.totalColonies !== currentTotal) {
|
||||
lines.push(
|
||||
`Zahl der Völker: ${baseline.totalColonies} auf ${currentTotal}`,
|
||||
);
|
||||
}
|
||||
return lines.length === 1 ? [...lines, 'keine'] : lines;
|
||||
}
|
||||
|
||||
function tskTypes(snapshot: HerdSnapshot) {
|
||||
return COLONY_TYPES.filter(({ type }) =>
|
||||
snapshot.apiaries.some((apiary) => (apiary.byType[type] ?? 0) > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function footerOf(notice: AuthorityNotice): string[] {
|
||||
const lines = [
|
||||
`Stand der Daten ${berlinDay(notice.updatedAt)} · erstellt mit ApiCore`,
|
||||
];
|
||||
return notice.kind === 'TSK_REPORT' ? [...lines, TSK_DISCLAIMER] : lines;
|
||||
}
|
||||
|
||||
function countedNoteOf(notice: AuthorityNotice): string | null {
|
||||
if (notice.kind !== 'TSK_REPORT') {
|
||||
return null;
|
||||
}
|
||||
const counted = `Gezählt am ${berlinDay(notice.countedAt)}`;
|
||||
const countedDay = berlinDayOf(notice.countedAt);
|
||||
const referenceDay = notice.referenceDate.toISOString().slice(0, 10);
|
||||
return countedDay > referenceDay
|
||||
? `${counted}, nicht am Stichtag. Bitte prüfen.`
|
||||
: counted;
|
||||
}
|
||||
|
||||
/** Pure: everything the notice PDF prints, without any drawing. */
|
||||
export function buildNoticePdfModel(input: NoticePdfInput): NoticePdfModel {
|
||||
const { notice, baseline, site, settings, now } = input;
|
||||
const current = notice.snapshot as unknown as HerdSnapshot;
|
||||
const isTsk = notice.kind === 'TSK_REPORT';
|
||||
const compare = notice.kind === 'HERD_CHANGE' && baseline !== null;
|
||||
const before = compare
|
||||
? (baseline.snapshot as unknown as HerdSnapshot)
|
||||
: null;
|
||||
const previousOf = (apiaryId: string): number | null =>
|
||||
before
|
||||
? (before.apiaries.find((a) => a.apiaryId === apiaryId)?.livingColonies ??
|
||||
0)
|
||||
: null;
|
||||
|
||||
const types = isTsk ? tskTypes(current) : [];
|
||||
const columns = isTsk
|
||||
? ['Stand', 'Lage', ...types.map((t) => t.label), 'Summe']
|
||||
: ['Stand', 'Lage', 'Völker', ...(compare ? ['vorher'] : [])];
|
||||
const rows = current.apiaries.map((apiary) => ({
|
||||
name: apiary.name,
|
||||
location: locationOf(apiary),
|
||||
cells: isTsk
|
||||
? [
|
||||
...types.map((t) => String(apiary.byType[t.type] ?? 0)),
|
||||
String(apiary.livingColonies),
|
||||
]
|
||||
: [String(apiary.livingColonies)],
|
||||
previous: previousOf(apiary.apiaryId),
|
||||
}));
|
||||
|
||||
const holderName = clean(site.holderName);
|
||||
const holderAddress = clean(site.holderAddress);
|
||||
// One of the two missing is a gap worth printing; both missing is null.
|
||||
const holder =
|
||||
holderName || holderAddress
|
||||
? `${holderName ?? MISSING}, ${holderAddress ?? MISSING}`
|
||||
: '';
|
||||
const place = placeOf(clean(site.holderAddress));
|
||||
const today = berlinDay(now);
|
||||
|
||||
return {
|
||||
kind: notice.kind,
|
||||
title: titleOf(notice, baseline !== null),
|
||||
holder: holder || null,
|
||||
registrationNumber: clean(settings.registrationNumber),
|
||||
tskMemberNumber: isTsk ? clean(settings.tskMemberNumber) : null,
|
||||
office: {
|
||||
name: clean(settings.veterinaryOfficeName),
|
||||
address: clean(settings.veterinaryOfficeAddress),
|
||||
},
|
||||
place,
|
||||
dateLine: place ? `${place}, ${today}` : today,
|
||||
referenceLine: `Stand der Völker am ${storedDay(notice.referenceDate)}`,
|
||||
columns,
|
||||
rows,
|
||||
totals: {
|
||||
current: notice.totalColonies,
|
||||
previous: compare ? baseline.totalColonies : null,
|
||||
},
|
||||
totalNote:
|
||||
notice.totalColonies === current.totalLiving
|
||||
? null
|
||||
: `Gesamtzahl berichtigt (Summe der Stände: ${current.totalLiving})`,
|
||||
changes:
|
||||
compare && before
|
||||
? changeLines(baseline, before, current, notice.totalColonies)
|
||||
: [],
|
||||
countedNote: countedNoteOf(notice),
|
||||
footer: footerOf(notice),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { PDF_MARGIN } from '../../export/pdf-table';
|
||||
import type { NoticePdfModel } from './authority-notice-model';
|
||||
import {
|
||||
noticeHeaderLines,
|
||||
renderAuthorityNoticePdf,
|
||||
} from './authority-notice-pdf';
|
||||
|
||||
function model(patch: Partial<NoticePdfModel> = {}): NoticePdfModel {
|
||||
return {
|
||||
kind: 'REGISTRATION',
|
||||
title: 'Anzeige der Bienenhaltung (§ 1a BienSeuchV)',
|
||||
holder: 'Imkerei Muster, Weg 2, 34117 Kassel',
|
||||
registrationNumber: 'DE-123',
|
||||
tskMemberNumber: null,
|
||||
office: { name: 'Veterinäramt Kassel', address: 'Amtsstr. 1' },
|
||||
place: 'Kassel',
|
||||
dateLine: 'Kassel, 05.01.2027',
|
||||
referenceLine: 'Stand der Völker am 01.01.2027',
|
||||
columns: ['Stand', 'Lage', 'Völker'],
|
||||
rows: [{ name: 'Heide', location: 'Weg 1', cells: ['3'], previous: null }],
|
||||
totals: { current: 3, previous: null },
|
||||
changes: [],
|
||||
totalNote: null,
|
||||
countedNote: null,
|
||||
footer: ['Stand der Daten 03.01.2027 · erstellt mit ApiCore'],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('noticeHeaderLines', () => {
|
||||
it('prints FEHLT for an empty veterinary office and an empty holder', () => {
|
||||
const lines = noticeHeaderLines(
|
||||
model({ holder: null, office: { name: null, address: null } }),
|
||||
);
|
||||
|
||||
expect(lines.holder).toBe('FEHLT');
|
||||
expect(lines.office).toEqual(['FEHLT']);
|
||||
});
|
||||
|
||||
it('prints the registration number or a dash, and FEHLT for a missing TSK number', () => {
|
||||
expect(
|
||||
noticeHeaderLines(model({ registrationNumber: null })).identifiers,
|
||||
).toEqual(['Registriernummer: –']);
|
||||
expect(
|
||||
noticeHeaderLines(model({ kind: 'TSK_REPORT', tskMemberNumber: null }))
|
||||
.identifiers,
|
||||
).toContain('TSK-Mitgliedsnummer: FEHLT');
|
||||
});
|
||||
|
||||
it('leaves out the recipient block for the TSK report', () => {
|
||||
expect(noticeHeaderLines(model({ kind: 'TSK_REPORT' })).office).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderAuthorityNoticePdf', () => {
|
||||
it.each([
|
||||
['REGISTRATION', model()],
|
||||
[
|
||||
'HERD_CHANGE',
|
||||
model({
|
||||
kind: 'HERD_CHANGE',
|
||||
columns: ['Stand', 'Lage', 'Völker', 'vorher'],
|
||||
rows: [{ name: 'Heide', location: 'Weg 1', cells: ['3'], previous: 2 }],
|
||||
totals: { current: 3, previous: 2 },
|
||||
changes: [
|
||||
'Änderungen seit der Anzeige vom 01.06.2026:',
|
||||
'Stand „Neu“ neu',
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
'TSK_REPORT',
|
||||
model({
|
||||
kind: 'TSK_REPORT',
|
||||
tskMemberNumber: 'TSK-9',
|
||||
columns: ['Stand', 'Lage', 'Wirtschaftsvolk', 'Summe'],
|
||||
rows: [
|
||||
{
|
||||
name: 'Heide',
|
||||
location: 'Weg 1',
|
||||
cells: ['3', '3'],
|
||||
previous: null,
|
||||
},
|
||||
],
|
||||
countedNote: 'Gezählt am 02.01.2027, nicht am Stichtag. Bitte prüfen.',
|
||||
footer: [
|
||||
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
|
||||
'Ersetzt nicht die Meldung an die Tierseuchenkasse.',
|
||||
],
|
||||
}),
|
||||
],
|
||||
])('renders a PDF buffer that starts with %PDF for %s', async (_kind, m) => {
|
||||
const buffer = await renderAuthorityNoticePdf(m);
|
||||
|
||||
expect(buffer.subarray(0, 4).toString('latin1')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders a notice without apiaries', async () => {
|
||||
const buffer = await renderAuthorityNoticePdf(
|
||||
model({ rows: [], totals: { current: 0, previous: null } }),
|
||||
);
|
||||
|
||||
expect(buffer.subarray(0, 4).toString('latin1')).toBe('%PDF');
|
||||
});
|
||||
});
|
||||
|
||||
function pageCount(buffer: Buffer): number {
|
||||
return (buffer.toString('latin1').match(/\/Type \/Page(?!s)/g) ?? []).length;
|
||||
}
|
||||
|
||||
function manyRows(count: number): NoticePdfModel['rows'] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
name: `Bienenstand Müßiggang-Süd ${index + 1} am Fließgewässer`,
|
||||
location:
|
||||
'Äußere Gartenallee 12, Gemarkung Großalmerode, 37247 Großalmerode',
|
||||
cells: ['8'],
|
||||
previous: null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('notice PDF layout', () => {
|
||||
let restoreText = (): void => undefined;
|
||||
afterEach(() => restoreText());
|
||||
|
||||
it('keeps 15 apiaries with long addresses on one portrait page, signature included', async () => {
|
||||
const buffer = await renderAuthorityNoticePdf(
|
||||
model({ rows: manyRows(15), totals: { current: 120, previous: null } }),
|
||||
);
|
||||
|
||||
expect(pageCount(buffer)).toBe(1);
|
||||
});
|
||||
|
||||
it('prints the notes and the footer at the left margin after the table', async () => {
|
||||
const calls: { text: string; x: number }[] = [];
|
||||
type TextFn = (this: { x: number }, ...args: unknown[]) => unknown;
|
||||
const proto = PDFDocument.prototype as { text: TextFn };
|
||||
const original = proto.text;
|
||||
proto.text = function (this: { x: number }, ...args: unknown[]) {
|
||||
calls.push({
|
||||
text: String(args[0]),
|
||||
x: typeof args[1] === 'number' ? args[1] : this.x,
|
||||
});
|
||||
return original.apply(this, args);
|
||||
};
|
||||
restoreText = () => {
|
||||
proto.text = original;
|
||||
};
|
||||
|
||||
await renderAuthorityNoticePdf(
|
||||
model({
|
||||
rows: manyRows(3),
|
||||
totalNote: 'Gesamtzahl berichtigt (Summe der Stände: 24)',
|
||||
changes: [
|
||||
'Änderungen seit der Anzeige vom 01.06.2026:',
|
||||
'Stand „A“ neu',
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
for (const prefix of [
|
||||
'Gesamtzahl berichtigt',
|
||||
'Änderungen seit',
|
||||
'– Stand',
|
||||
'Stand der Daten',
|
||||
]) {
|
||||
const call = calls.find((c) => c.text.startsWith(prefix));
|
||||
expect(call?.x).toBe(PDF_MARGIN);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the TSK report landscape with room for the location column', async () => {
|
||||
const buffer = await renderAuthorityNoticePdf(
|
||||
model({
|
||||
kind: 'TSK_REPORT',
|
||||
columns: [
|
||||
'Stand',
|
||||
'Lage',
|
||||
'Wirtschaftsvolk',
|
||||
'Begattungs-einheit',
|
||||
'Summe',
|
||||
],
|
||||
rows: manyRows(15).map((row) => ({ ...row, cells: ['5', '3', '8'] })),
|
||||
totals: { current: 120, previous: null },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(buffer.toString('latin1')).toContain('/MediaBox [0 0 841.89');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import {
|
||||
PDF_MARGIN,
|
||||
PdfColumn,
|
||||
drawTable,
|
||||
orDash,
|
||||
} from '../../export/pdf-table';
|
||||
import { MISSING, NoticePdfModel } from './authority-notice-model';
|
||||
|
||||
const NAME_WIDTH = 110;
|
||||
const MIN_NUMBER_WIDTH = 40;
|
||||
const NUMBER_PADDING = 8;
|
||||
const SIGNATURE_LINE_WIDTH = 200;
|
||||
|
||||
export interface NoticeHeaderLines {
|
||||
holder: string;
|
||||
identifiers: string[];
|
||||
/** The addressee block; empty for the TSK report (another recipient). */
|
||||
office: string[];
|
||||
}
|
||||
|
||||
/** Pure: the header texts, with FEHLT for every mandatory gap. */
|
||||
export function noticeHeaderLines(model: NoticePdfModel): NoticeHeaderLines {
|
||||
const isTsk = model.kind === 'TSK_REPORT';
|
||||
const identifiers = [`Registriernummer: ${orDash(model.registrationNumber)}`];
|
||||
if (isTsk) {
|
||||
identifiers.push(
|
||||
`TSK-Mitgliedsnummer: ${model.tskMemberNumber ?? MISSING}`,
|
||||
);
|
||||
}
|
||||
const office = isTsk
|
||||
? []
|
||||
: [
|
||||
model.office.name ?? MISSING,
|
||||
...(model.office.address ? [model.office.address] : []),
|
||||
];
|
||||
return { holder: model.holder ?? MISSING, identifiers, office };
|
||||
}
|
||||
|
||||
/** Room for the widest word of a header (it may break at a hyphen). */
|
||||
function numberWidth(doc: PDFKit.PDFDocument, header: string): number {
|
||||
doc.font('Helvetica-Bold').fontSize(8);
|
||||
const widest = Math.max(
|
||||
...header.split('-').map((part) => doc.widthOfString(part)),
|
||||
);
|
||||
return Math.max(MIN_NUMBER_WIDTH, Math.ceil(widest) + NUMBER_PADDING);
|
||||
}
|
||||
|
||||
function tableColumns(
|
||||
doc: PDFKit.PDFDocument,
|
||||
model: NoticePdfModel,
|
||||
): PdfColumn[] {
|
||||
const numeric = model.columns.slice(2).map((header) => ({
|
||||
header,
|
||||
width: numberWidth(doc, header),
|
||||
align: 'right' as const,
|
||||
}));
|
||||
const used = numeric.reduce((sum, column) => sum + column.width, 0);
|
||||
return [
|
||||
{ header: model.columns[0], width: NAME_WIDTH },
|
||||
{
|
||||
header: model.columns[1],
|
||||
width: doc.page.width - 2 * PDF_MARGIN - NAME_WIDTH - used,
|
||||
},
|
||||
...numeric,
|
||||
];
|
||||
}
|
||||
|
||||
/** Sums of the numeric columns, so the TSK table totals each colony type. */
|
||||
function totalsRow(model: NoticePdfModel): string[] {
|
||||
const isTsk = model.kind === 'TSK_REPORT';
|
||||
if (isTsk) {
|
||||
const typeCount = model.columns.length - 3;
|
||||
const sums = Array.from({ length: typeCount }, (_, index) =>
|
||||
model.rows.reduce((sum, row) => sum + Number(row.cells[index]), 0),
|
||||
);
|
||||
return ['Summe', '', ...sums.map(String), String(model.totals.current)];
|
||||
}
|
||||
return [
|
||||
'Summe',
|
||||
'',
|
||||
String(model.totals.current),
|
||||
...(model.totals.previous === null ? [] : [String(model.totals.previous)]),
|
||||
];
|
||||
}
|
||||
|
||||
function bodyRows(model: NoticePdfModel): string[][] {
|
||||
const rows = model.rows.map((row) => [
|
||||
row.name,
|
||||
row.location,
|
||||
...row.cells,
|
||||
...(row.previous === null || model.kind === 'TSK_REPORT'
|
||||
? []
|
||||
: [String(row.previous)]),
|
||||
]);
|
||||
return rows.length === 0 ? rows : [...rows, totalsRow(model)];
|
||||
}
|
||||
|
||||
function drawHeader(doc: PDFKit.PDFDocument, model: NoticePdfModel): void {
|
||||
const header = noticeHeaderLines(model);
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(10)
|
||||
.text(header.holder, PDF_MARGIN, PDF_MARGIN);
|
||||
doc.font('Helvetica').fontSize(9);
|
||||
for (const line of header.identifiers) writeLine(doc, line);
|
||||
if (header.office.length > 0) {
|
||||
doc.moveDown(1);
|
||||
doc.text(`An ${header.office[0]}`);
|
||||
for (const line of header.office.slice(1)) doc.text(` ${line}`);
|
||||
}
|
||||
doc.moveDown(0.6);
|
||||
doc.text(model.dateLine, { align: 'right' });
|
||||
doc.moveDown(0.6);
|
||||
doc.font('Helvetica-Bold').fontSize(13).text(model.title);
|
||||
doc.font('Helvetica').fontSize(9).text(model.referenceLine);
|
||||
doc.moveDown(0.6);
|
||||
}
|
||||
|
||||
/** A paragraph at the left margin; the table leaves x at its last column. */
|
||||
function writeLine(doc: PDFKit.PDFDocument, text: string): void {
|
||||
doc.text(text, PDF_MARGIN, doc.y, {
|
||||
width: doc.page.width - 2 * PDF_MARGIN,
|
||||
});
|
||||
}
|
||||
|
||||
function drawNotes(doc: PDFKit.PDFDocument, model: NoticePdfModel): void {
|
||||
doc.font('Helvetica').fontSize(9);
|
||||
if (model.totalNote) {
|
||||
writeLine(doc, model.totalNote);
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
if (model.changes.length > 0) {
|
||||
doc.font('Helvetica-Bold');
|
||||
writeLine(doc, model.changes[0]);
|
||||
doc.font('Helvetica');
|
||||
for (const line of model.changes.slice(1)) writeLine(doc, `– ${line}`);
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
if (model.countedNote) {
|
||||
writeLine(doc, model.countedNote);
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
function drawSignatureAndFooter(
|
||||
doc: PDFKit.PDFDocument,
|
||||
model: NoticePdfModel,
|
||||
): void {
|
||||
if (doc.y > doc.page.height - PDF_MARGIN - 110) doc.addPage();
|
||||
doc.moveDown(3);
|
||||
const y = doc.y;
|
||||
doc
|
||||
.moveTo(PDF_MARGIN, y)
|
||||
.lineTo(PDF_MARGIN + SIGNATURE_LINE_WIDTH, y)
|
||||
.lineWidth(0.5)
|
||||
.strokeColor('#000000')
|
||||
.stroke();
|
||||
doc
|
||||
.font('Helvetica')
|
||||
.fontSize(8)
|
||||
.text('Unterschrift', PDF_MARGIN, y + 3);
|
||||
doc.moveDown(1.5);
|
||||
doc.fillColor('#555555');
|
||||
for (const line of model.footer) writeLine(doc, line);
|
||||
doc.fillColor('#000000');
|
||||
}
|
||||
|
||||
export function renderAuthorityNoticePdf(
|
||||
model: NoticePdfModel,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Seven colony-type columns need the width of a landscape page.
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: model.kind === 'TSK_REPORT' ? 'landscape' : 'portrait',
|
||||
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);
|
||||
|
||||
drawHeader(doc, model);
|
||||
drawTable(doc, tableColumns(doc, model), bodyRows(model), {
|
||||
boldLastRow: true,
|
||||
});
|
||||
doc.x = PDF_MARGIN;
|
||||
drawNotes(doc, model);
|
||||
drawSignatureAndFooter(doc, model);
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user