From 118be44847901ea46f11d682087cc0697b6067a9 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:06:11 +0000 Subject: [PATCH] Buchungskalender: PDF-Writer und Rechnungs-PDF Co-Authored-By: Claude Opus 5.5 --- server/services/unterkunft/pdf.js | 169 ++++++++++++++++++++++ server/services/unterkunft/rechnungPdf.js | 134 +++++++++++++++++ test/unterkunftPdf.test.js | 110 ++++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 server/services/unterkunft/pdf.js create mode 100644 server/services/unterkunft/rechnungPdf.js create mode 100644 test/unterkunftPdf.test.js diff --git a/server/services/unterkunft/pdf.js b/server/services/unterkunft/pdf.js new file mode 100644 index 0000000..b0e0b60 --- /dev/null +++ b/server/services/unterkunft/pdf.js @@ -0,0 +1,169 @@ +// Minimaler PDF-Writer (Buchungskalender N2): Helvetica und Helvetica-Bold, WinAnsiEncoding, keine Abhaengigkeit. +// Koordinaten: Punkt, Ursprung oben links (y waechst nach unten). Ausgabe ist deterministisch. + +// Helvetica-AFM-Breiten (1/1000 em) fuer die WinAnsi-Codes 32..255 (0 = nicht definiert, wird als '?' geschrieben). +const W_NORMAL = [ + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, + 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, +] +const W_FETT = [ + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, + 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, + 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, +] + +// CP1252-Zeichen oberhalb von U+007F im Bereich 0x80..0x9F. +const CP1252 = new Map(Object.entries({ + '€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a, + '‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97, + '˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f, +})) + +/** Ein Zeichen als WinAnsi-Code; Unbekanntes wird '?'. */ +function code(ch) { + if (ch === '−') return 0x2d + const c = ch.codePointAt(0) + if (c === 0x0a || c === 0x0d || c === 0x09) return 0x20 + if (c >= 32 && c < 127) return c + if (c >= 160 && c <= 255) return c + return CP1252.get(ch) ?? 0x3f +} + +const bytes = (str) => Array.from(String(str ?? ''), code) +const zahl = (n) => String(Math.round(n * 100) / 100) + +export class PdfDoc { + constructor({ breite = 595.28, hoehe = 841.89 } = {}) { + this.b = breite + this.h = hoehe + this.seiten = [] + this.aktuell = null + this.seite() + } + + /** Neue Seite beginnen (und zur aktuellen machen). */ + seite() { + this.seiten.push([]) + this.aktuell = this.seiten.length - 1 + return this.aktuell + } + + /** Auf eine bestehende Seite zurueckschalten (z. B. fuer Fusszeilen). */ + aufSeite(n) { + if (!(n >= 0 && n < this.seiten.length)) throw new RangeError('Seite existiert nicht') + this.aktuell = n + } + + get seitenzahl() { + return this.seiten.length + } + + breite(str, groesse = 10, fett = false) { + const w = fett ? W_FETT : W_NORMAL + let sum = 0 + for (const c of bytes(str)) sum += w[c - 32] || w[0x3f - 32] + return (sum * groesse) / 1000 + } + + /** Text; y = Grundlinie von oben, x = linker Rand (bei rechts: rechter Rand). */ + text(x, y, str, { groesse = 10, fett = false, rechts = false } = {}) { + const bs = bytes(str) + if (bs.length === 0) return + const px = rechts ? x - this.breite(str, groesse, fett) : x + let s = '' + for (const c of bs) s += c === 0x5c || c === 0x28 || c === 0x29 ? `\\${String.fromCharCode(c)}` : String.fromCharCode(c) + this.seiten[this.aktuell].push(`BT /${fett ? 'F2' : 'F1'} ${zahl(groesse)} Tf ${zahl(px)} ${zahl(this.h - y)} Td (${s}) Tj ET`) + } + + linie(x1, y1, x2, y2, { dicke = 0.5 } = {}) { + this.seiten[this.aktuell].push(`${zahl(dicke)} w ${zahl(x1)} ${zahl(this.h - y1)} m ${zahl(x2)} ${zahl(this.h - y2)} l S`) + } + + /** Bricht an Leerzeichen um; ueberlange Woerter werden hart getrennt. */ + umbrechen(str, maxBreite, groesse = 10, fett = false) { + const zeilen = [] + for (const absatz of String(str ?? '').split(/\r?\n/)) { + let zeile = '' + for (let wort of absatz.split(/ +/).filter(Boolean)) { + const probe = zeile ? `${zeile} ${wort}` : wort + if (this.breite(probe, groesse, fett) <= maxBreite) { + zeile = probe + continue + } + if (zeile) zeilen.push(zeile) + zeile = '' + while (this.breite(wort, groesse, fett) > maxBreite && wort.length > 1) { + let n = wort.length - 1 + while (n > 1 && this.breite(wort.slice(0, n), groesse, fett) > maxBreite) n-- + zeilen.push(wort.slice(0, n)) + wort = wort.slice(n) + } + zeile = wort + } + zeilen.push(zeile) + } + return zeilen + } + + toBuffer() { + const teile = [] + const offs = [] + let len = 0 + const add = (buf) => { + const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf, 'latin1') + teile.push(b) + len += b.length + } + const obj = (nr, inhalt) => { + offs[nr] = len + add(`${nr} 0 obj\n`) + add(inhalt) + add('\nendobj\n') + } + const n = this.seiten.length + add('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n') + obj(1, '<< /Type /Catalog /Pages 2 0 R >>') + const kids = this.seiten.map((_, i) => `${5 + i * 2} 0 R`).join(' ') + obj(2, `<< /Type /Pages /Kids [${kids}] /Count ${n} >>`) + obj(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>') + obj(4, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>') + this.seiten.forEach((ops, i) => { + const pn = 5 + i * 2 + obj(pn, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${zahl(this.b)} ${zahl(this.h)}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${pn + 1} 0 R >>`) + const strom = Buffer.from(ops.join('\n') + '\n', 'latin1') + offs[pn + 1] = len + add(`${pn + 1} 0 obj\n<< /Length ${strom.length} >>\nstream\n`) + add(strom) + add('endstream\nendobj\n') + }) + const total = 5 + n * 2 + const xref = len + add(`xref\n0 ${total}\n0000000000 65535 f \n`) + for (let i = 1; i < total; i++) add(`${String(offs[i]).padStart(10, '0')} 00000 n \n`) + add(`trailer\n<< /Size ${total} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`) + return Buffer.concat(teile) + } +} diff --git a/server/services/unterkunft/rechnungPdf.js b/server/services/unterkunft/rechnungPdf.js new file mode 100644 index 0000000..9050fd2 --- /dev/null +++ b/server/services/unterkunft/rechnungPdf.js @@ -0,0 +1,134 @@ +// Rechnungs-PDF aus dem unveraenderlichen Schnappschuss ukRechnungen.inhalt (N2). Deterministisch, A4. +import { PdfDoc } from './pdf.js' +import { formatEuro } from './preis.js' +import { formatTag } from './datum.js' + +const L = 56 +const R = 595.28 - 56 +const UNTEN = 745 // Inhalt endet hier, darunter Fusszeile +const tag = (t) => (/^\d{4}-\d{2}-\d{2}$/.test(String(t ?? '')) ? formatTag(t) : String(t ?? '')) +const nz = (s) => String(s ?? '').trim() +const ortZeile = (o) => [nz(o?.plz), nz(o?.ort)].filter(Boolean).join(' ') + +export function rechnungPdf(inhalt, { art = 'rechnung', nummer = '', datum = '' } = {}) { + const i = inhalt || {} + const a = i.aussteller || {} + const e = i.empfaenger || {} + const storno = art === 'storno' + const klein = (i.steuerart || i.summen?.steuerart) === 'kleinunternehmer' + const doc = new PdfDoc() + let y + + // Absenderzeile (klein) + const absender = [nz(a.firma), nz(a.strasse), ortZeile(a)].filter(Boolean).join(' · ') + doc.text(L, 60, absender, { groesse: 7.5 }) + doc.linie(L, 64, L + 250, 64, { dicke: 0.3 }) + + // Empfaenger + const adr = [nz(e.firma), nz(e.name), nz(e.strasse), ortZeile(e), nz(e.land)].filter(Boolean) + adr.forEach((z, k) => doc.text(L, 82 + k * 13, z, { groesse: 10 })) + + // Rechts: Nummer, Datum, Leistungszeitraum + const kopf = [ + [storno ? 'Stornonummer' : 'Rechnungsnummer', nz(nummer)], + ['Datum', tag(datum)], + ['Leistungszeitraum', `${tag(i.von)} – ${tag(i.bis)}`], + ] + if (storno && i.bezugNummer) kopf.splice(1, 0, ['Zu Rechnung', nz(i.bezugNummer)]) + kopf.forEach(([k, v], n) => { + doc.text(350, 82 + n * 13, k, { groesse: 9 }) + doc.text(R, 82 + n * 13, v, { groesse: 9, fett: true, rechts: true }) + }) + + // Titel + y = 190 + doc.text(L, y, storno ? `Stornorechnung zu ${nz(i.bezugNummer)}` : 'Rechnung', { groesse: 16, fett: true }) + y += 22 + const leistung = `Beherbergung ${nz(i.unterkunft?.name)}, ${tag(i.von)}–${tag(i.bis)} (${i.naechte} ${i.naechte === 1 ? 'Nacht' : 'Nächte'}, ${i.personen} ${i.personen === 1 ? 'Person' : 'Personen'})` + for (const z of doc.umbrechen(leistung, R - L, 10)) { + doc.text(L, y, z, { groesse: 10 }) + y += 13 + } + y += 10 + + // Posten-Tabelle + const cMenge = 318 + const cUst = 362 + const cEinzel = 450 + const cBetrag = R + const textBreite = (klein ? cMenge - 30 : cUst - 40) - L - 20 + const kopfzeile = () => { + doc.text(L, y, 'Leistung', { groesse: 9, fett: true }) + doc.text(cMenge, y, 'Menge', { groesse: 9, fett: true, rechts: true }) + if (!klein) doc.text(cUst, y, 'USt', { groesse: 9, fett: true, rechts: true }) + doc.text(cEinzel, y, 'Einzelpreis', { groesse: 9, fett: true, rechts: true }) + doc.text(cBetrag, y, 'Betrag', { groesse: 9, fett: true, rechts: true }) + doc.linie(L, y + 4, R, y + 4) + y += 18 + } + kopfzeile() + for (const p of i.posten || []) { + const zeilen = doc.umbrechen(nz(p.text), textBreite, 10) + if (y + zeilen.length * 12 > UNTEN) { + doc.seite() + y = 70 + kopfzeile() + } + const betrag = Math.round(p.menge * p.einzelCent) + zeilen.forEach((z, k) => doc.text(L, y + k * 12, z, { groesse: 10 })) + doc.text(cMenge, y, String(p.menge), { groesse: 10, rechts: true }) + if (!klein) doc.text(cUst, y, `${p.ustProzent} %`, { groesse: 10, rechts: true }) + doc.text(cEinzel, y, formatEuro(p.einzelCent), { groesse: 10, rechts: true }) + doc.text(cBetrag, y, formatEuro(betrag), { groesse: 10, rechts: true }) + y += zeilen.length * 12 + 4 + } + doc.linie(L, y - 2, R, y - 2) + y += 12 + + // Summen (zusammenhaengend auf einer Seite) + const s = i.summen || {} + const zeilenSumme = [] + if (klein) { + zeilenSumme.push(['Gesamtbetrag', formatEuro(s.bruttoCent ?? 0), true]) + } else { + for (const u of s.ust || []) { + zeilenSumme.push([`Nettobetrag ${u.satz} %`, formatEuro(u.nettoCent), false]) + zeilenSumme.push([`Enthaltene USt ${u.satz} %`, formatEuro(u.steuerCent), false]) + } + zeilenSumme.push(['Gesamtbetrag (brutto)', formatEuro(s.bruttoCent ?? 0), true]) + } + const hinweis = klein ? doc.umbrechen('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', R - L, 9) : [] + const zahlText = i.zahlart === 'online' ? 'Zahlart: Online-Zahlung' : i.zahlart === 'vorort' ? 'Zahlart: Zahlung vor Ort' : '' + const noetig = zeilenSumme.length * 14 + hinweis.length * 12 + 40 + if (y + noetig > UNTEN) { + doc.seite() + y = 70 + } + for (const [k, v, fett] of zeilenSumme) { + doc.text(cEinzel, y, k, { groesse: 10, fett, rechts: true }) + doc.text(cBetrag, y, v, { groesse: 10, fett, rechts: true }) + y += 14 + } + y += 8 + for (const z of hinweis) { + doc.text(L, y, z, { groesse: 9 }) + y += 12 + } + if (zahlText) { + y += 4 + doc.text(L, y, zahlText, { groesse: 9 }) + } + + // Fusszeile auf jeder Seite + const fuss1 = [nz(a.firma), nz(a.inhaber) && `Inh. ${nz(a.inhaber)}`, nz(a.strasse), ortZeile(a)].filter(Boolean).join(' · ') + const fuss2 = [nz(a.steuernummer) && `Steuernummer ${nz(a.steuernummer)}`, nz(a.ustId) && `USt-IdNr. ${nz(a.ustId)}`, nz(a.email), nz(a.telefon)].filter(Boolean).join(' · ') + const fuss3 = [nz(a.bank), nz(a.iban) && `IBAN ${nz(a.iban)}`, nz(a.bic) && `BIC ${nz(a.bic)}`].filter(Boolean).join(' · ') + const n = doc.seitenzahl + for (let p = 0; p < n; p++) { + doc.aufSeite(p) + doc.linie(L, 790, R, 790, { dicke: 0.3 }) + ;[fuss1, fuss2, fuss3].filter(Boolean).forEach((z, k) => doc.text(L, 802 + k * 10, z, { groesse: 7.5 })) + if (n > 1) doc.text(R, 826, `Seite ${p + 1} von ${n}`, { groesse: 7.5, rechts: true }) + } + return doc.toBuffer() +} diff --git a/test/unterkunftPdf.test.js b/test/unterkunftPdf.test.js new file mode 100644 index 0000000..07330ad --- /dev/null +++ b/test/unterkunftPdf.test.js @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { PdfDoc } from '../server/services/unterkunft/pdf.js' +import { rechnungPdf } from '../server/services/unterkunft/rechnungPdf.js' + +// Buchungskalender Task 19: PDF-Writer und Rechnungs-PDF +const latin = (b) => b.toString('latin1') + +function inhalt(extra = {}) { + return { + aussteller: { firma: 'Haus Seeblick', inhaber: 'Anna Muster', strasse: 'Seestr. 1', plz: '88662', ort: 'Ueberlingen', email: 'a@x.de', telefon: '0123', steuernummer: '12/345/67890', ustId: '', iban: 'DE02 1203 0000 0000 2020 51', bic: 'BYLADEM1001', bank: 'DKB' }, + empfaenger: { name: 'Max Gast', firma: '', strasse: 'Weg 2', plz: '10115', ort: 'Berlin', land: 'Deutschland' }, + unterkunft: { name: 'Ferienwohnung Süd' }, + von: '2026-11-12', bis: '2026-11-15', naechte: 3, personen: 2, + posten: [{ text: 'Übernachtung (Frühstück), 3 Nächte', menge: 3, einzelCent: 9000, ustProzent: 7 }, { text: 'Endreinigung', menge: 1, einzelCent: 5000, ustProzent: 19 }], + summen: { bruttoCent: 32000, nettoCent: 29000, ust: [{ satz: 7, nettoCent: 25234, steuerCent: 1766 }, { satz: 19, nettoCent: 4202, steuerCent: 798 }], steuerart: 'regel' }, + steuerart: 'regel', zahlart: 'vorort', bezugNummer: '', anfrageNummer: 'A-1', ...extra, + } +} +const kleinInhalt = () => inhalt({ + steuerart: 'kleinunternehmer', + posten: [{ text: 'Übernachtung, 3 Nächte', menge: 3, einzelCent: 9000, ustProzent: 0 }], + summen: { bruttoCent: 27000, nettoCent: 27000, ust: [], steuerart: 'kleinunternehmer' }, +}) + +test('PdfDoc: Struktur, xref, Kopf und Ende', () => { + const d = new PdfDoc() + d.text(50, 50, 'Hallo') + const b = d.toBuffer() + const s = latin(b) + assert.ok(s.startsWith('%PDF-1.4')) + assert.ok(s.endsWith('%%EOF\n')) + const start = Number(/startxref\n(\d+)\n/.exec(s)[1]) + assert.equal(s.slice(start, start + 4), 'xref') + const eintraege = s.slice(start).split('\n').filter((z) => /^\d{10} 00000 n $/.test(z)) + assert.ok(eintraege.length >= 6) + eintraege.forEach((z, k) => { + const off = Number(z.slice(0, 10)) + assert.ok(s.slice(off).startsWith(`${k + 1} 0 obj`), `Objekt ${k + 1}`) + }) +}) + +test('PdfDoc: CP1252, Euro, Escaping, Minus', () => { + const d = new PdfDoc() + d.text(10, 10, 'Übernachtung ß 5 € −3 (a) \\ ☃') + const s = latin(d.toBuffer()) + assert.ok(s.includes('\xdcbernachtung \xdf 5 \x80 -3 \\(a\\) \\\\ ?')) + assert.ok(s.includes('/Encoding /WinAnsiEncoding')) +}) + +test('PdfDoc: Breiten und Umbruch', () => { + const d = new PdfDoc() + assert.equal(Math.round(d.breite('Hello', 10) * 100), 2278) + assert.ok(d.breite('Hello', 10, true) > d.breite('Hello', 10)) + const z = d.umbrechen('eins zwei drei vier fünf sechs', 60, 10) + assert.ok(z.length > 1) + z.forEach((x) => assert.ok(d.breite(x, 10) <= 60)) + assert.ok(d.umbrechen('Donaudampfschifffahrtsgesellschaft', 50, 10).length > 1) + assert.ok(d.breite('x', 10) > 0 && d.breite('€', 10) > 0) +}) + +test('Rechnung: Regelbesteuerung', () => { + const s = latin(rechnungPdf(inhalt(), { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' })) + assert.ok(s.includes('RE-2026-0001')) + assert.ok(s.includes('Enthaltene USt 7 %')) + assert.ok(s.includes('Nettobetrag 19 %')) + assert.ok(s.includes('12.11.2026')) + assert.ok(s.includes('Beherbergung Ferienwohnung S\xfcd')) + assert.ok(s.includes('Zahlung vor Ort')) + assert.ok(!s.includes('Gem\xe4\xdf \xa7 19 UStG')) + assert.ok(s.includes('Steuernummer 12/345/67890')) + assert.ok(s.includes('IBAN')) +}) + +test('Rechnung: Kleinunternehmer und Online-Zahlung', () => { + const s = latin(rechnungPdf({ ...kleinInhalt(), zahlart: 'online' }, { art: 'rechnung', nummer: 'RE-2026-0002', datum: '2026-11-01' })) + assert.ok(s.includes('Gem\xe4\xdf \xa7 19 UStG wird keine Umsatzsteuer berechnet.')) + assert.ok(s.includes('Online-Zahlung')) + assert.ok(!s.includes('Enthaltene USt')) +}) + +test('Storno: Titel mit Bezug, negative Betraege', () => { + const neg = kleinInhalt() + neg.posten = neg.posten.map((p) => ({ ...p, einzelCent: -p.einzelCent })) + neg.summen = { ...neg.summen, bruttoCent: -27000, nettoCent: -27000 } + neg.bezugNummer = 'RE-2026-0002' + const s = latin(rechnungPdf(neg, { art: 'storno', nummer: 'RE-2026-0003', datum: '2026-11-02' })) + assert.ok(s.includes('Stornorechnung zu RE-2026-0002')) + assert.ok(s.includes('-270,00 \x80')) +}) + +test('Viele Posten: Seitenumbruch', () => { + const posten = Array.from({ length: 40 }, (_, k) => ({ text: `Posten ${k + 1}`, menge: 1, einzelCent: 1000, ustProzent: 0 })) + const s = latin(rechnungPdf({ ...kleinInhalt(), posten }, { art: 'rechnung', nummer: 'RE-2026-0004', datum: '2026-11-01' })) + assert.ok(s.includes('/Count 2')) + assert.ok(s.includes('Seite 2 von 2')) +}) + +test('Deterministisch', () => { + const o = { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' } + assert.ok(rechnungPdf(inhalt(), o).equals(rechnungPdf(inhalt(), o))) +}) + +test('Beispiel-PDF schreiben (nur wenn Zielordner existiert)', async () => { + const { mkdirSync, writeFileSync } = await import('node:fs') + const dir = '/root/reports/buchungskalender-2026-10-07' + if (process.env.UK_BEISPIEL_PDF !== '1') return + mkdirSync(dir, { recursive: true }) + writeFileSync(`${dir}/beispiel-rechnung.pdf`, rechnungPdf(inhalt(), { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' })) +})