feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
Backend Image / build-and-push (push) Successful in 4m58s
Frontend Image / build-and-push (push) Successful in 7m57s

- 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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 14:48:57 +02:00
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();
});
}