diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index 348f9a0..25c31a5 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -205,6 +205,63 @@ export const BEREICHE = Object.freeze({ }) +const atom = (id, typ, hoehe, extra = {}) => ({ id, typ, hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra }) + +/** Alle Bausteine der AB in Zeichenreihenfolge. messe(text, stil, breite) liefert die Texthöhe in pt. */ +export function baueAtome(daten, messe) { + const b = daten.beschriftung + const B = INHALT_BREITE + const atome = [] + const absatz = (id, text, extra = {}, stil = STIL.text) => atome.push(atom(id, 'absatz', messe(text, stil, B), { text, stil: stil === STIL.klein ? 'klein' : 'text', abstandDavor: 6, ...extra })) + const ueberschrift = (id, text) => atome.push(atom(id, 'ueberschrift', messe(text, STIL.ueberschrift, B) + 5, { text, abstandDavor: 14, haengtAnNaechstem: true })) + + // Briefkopf (nur Seite 1): Logo, Absender, Anschrift, Angaben, Titel + const infoZeilen = [[b.nummer, daten.nummer], [b.datum, daten.datum], ...(daten.kundennummer ? [[b.kundennummer, daten.kundennummer]] : [])] + const adresseHoehe = messe(daten.empfaenger.join('\n'), STIL.adresse, MASSE.adresseBreite) + const blockEnde = MASSE.adresseY + Math.max(adresseHoehe, infoZeilen.length * MASSE.infoZeile) + const titelY = blockEnde + 22 + const projektY = titelY + STIL.titel.groesse * 1.2 + 6 + const projektHoehe = messe(`${b.projekt}: ${daten.projekt}`, STIL.projekt, B) + atome.push(atom('briefkopf', 'briefkopf', projektY + projektHoehe + 4, { haengtAnNaechstem: true, masse: { titelY, projektY, projektHoehe, infoZeilen } })) + + absatz('einleitung', daten.einleitung, { abstandDavor: 8 }) + const l = daten.leistung + ueberschrift('leistung', l.ueberschrift) + if (l.art === 'festpreis') { + const kopf = atom('tabellenkopf', 'tabellenkopf', MASSE.kopfHoehe, { haengtAnNaechstem: true, spalten: b.spalten }) + atome.push(kopf) + l.positionen.forEach((p, i) => { + const text = messe(p.beschreibung, STIL.text, SPALTEN[1] - 2 * ZELLEN_RAND) + const eineZeile = messe('X', STIL.text, SPALTEN[1]) + atome.push(atom(`zeile-${i + 1}`, 'zeile', Math.max(text, eineZeile) + 2 * ZELLEN_RAND, { zelle: p, kopf, haengtAnNaechstem: i === l.positionen.length - 1 })) + }) + atome.push(atom('summe', 'summe', MASSE.summeHoehe, { label: l.gesamt.label, betrag: l.gesamt.betrag, haengtAnNaechstem: true })) + } else { + atome.push(atom('aufwand', 'absatz', messe(l.aufwandText, STIL.text, B - 2 * MASSE.kastenRand) + 2 * MASSE.kastenRand, { text: l.aufwandText, stil: 'text', kasten: true, haengtAnNaechstem: true })) + } + absatz('kleinunternehmer', daten.kleinunternehmer, { abstandDavor: 8 }, STIL.klein) + for (const a of daten.abschnitte) { + ueberschrift(`${a.id}-kopf`, a.ueberschrift) + absatz(a.id, a.text, { abstandDavor: 0 }) + } + absatz('agb', daten.agb, { abstandDavor: 12 }) + if (daten.ersetzt) absatz('ersetzt', daten.ersetzt, { abstandDavor: 8 }) + absatz('gruss', daten.gruss, { abstandDavor: 14 }) + + if (daten.widerruf) { + const { belehrung, formular } = daten.widerruf + atome.push(atom('widerruf-titel', 'ueberschrift', messe(belehrung.titel, STIL.titel, B) + 8, { text: belehrung.titel, stil: 'titel', umbruchDavor: true, haengtAnNaechstem: true })) + belehrung.abschnitte.forEach((a, i) => { + ueberschrift(`widerruf-${i + 1}-kopf`, a.ueberschrift) + a.absaetze.forEach((t, k) => absatz(`widerruf-${i + 1}-${k + 1}`, t, { abstandDavor: k === 0 ? 0 : 5 })) + }) + atome.push(atom('formular-titel', 'ueberschrift', messe(formular.titel, STIL.ueberschrift, B) + 5, { text: formular.titel, abstandDavor: 20, haengtAnNaechstem: true })) + absatz('formular-einleitung', formular.einleitung, { abstandDavor: 0, haengtAnNaechstem: true }, STIL.klein) + formular.zeilen.forEach((t, i) => absatz(`formular-${i + 1}`, t, { abstandDavor: 8, haengtAnNaechstem: i < formular.zeilen.length - 1 })) + } + return atome +} + /** Teilt Bausteine auf Seiten auf (Ketten bleiben zusammen, Tabellenkopf wiederholt sich). */ export function paginiere(atome, bereiche = BEREICHE) { const seiten = [] diff --git a/test/auftraegePdfLayout.test.js b/test/auftraegePdfLayout.test.js index cd9cfee..70c32d9 100644 --- a/test/auftraegePdfLayout.test.js +++ b/test/auftraegePdfLayout.test.js @@ -74,3 +74,71 @@ test('paginiere: Kette größer als eine Seite wird zwischen Bausteinen getrennt assert.deepEqual(lage(paginiere(kette, SEITEN)), [['a@0'], ['b@20'], ['c@20']]) assert.deepEqual(lage(paginiere([baustein('a', 50), baustein('riesig', 500), baustein('b', 50)], SEITEN)), [['a@0'], ['riesig@20'], ['b@20']]) }) + +// Messfunktion ohne pdfkit: 10 pt je angefangene 60 Zeichen +const messe = (text) => 10 * Math.max(1, Math.ceil(String(text).length / 60)) +const bausteine = (extra = {}, optionen = {}) => baueAtome(auftragPdfDaten(beispielAuftrag(extra), { settings: beispielSettings, ...optionen }), messe) +const ids = (atome) => atome.map((a) => a.id) + +test('baueAtome Festpreis: Reihenfolge, Zeilenhöhen und Zusammenhalt der Tabelle', () => { + const atome = bausteine() + assert.deepEqual(ids(atome), [ + 'briefkopf', 'einleitung', 'leistung', 'tabellenkopf', 'zeile-1', 'zeile-2', 'summe', 'kleinunternehmer', + 'zahlung-kopf', 'zahlung', 'termine-kopf', 'termine', 'mitwirkung-kopf', 'mitwirkung', 'agb', 'gruss', + ]) + const nach = (id) => atome.find((a) => a.id === id) + assert.equal(nach('briefkopf').typ, 'briefkopf') + assert.ok(nach('briefkopf').hoehe > 150) + assert.equal(nach('briefkopf').masse.infoZeilen.length, 3) + assert.equal(nach('zeile-1').hoehe, 10 + 12, 'eine Zeile Text plus 2 × 6 pt Rand') + assert.deepEqual([nach('leistung'), nach('tabellenkopf'), nach('zeile-1'), nach('zeile-2'), nach('summe'), nach('kleinunternehmer')].map((a) => a.haengtAnNaechstem), [true, true, false, true, true, false]) + assert.equal(nach('zeile-1').kopf, nach('tabellenkopf')) + assert.equal(nach('zeile-2').kopf, nach('tabellenkopf')) + assert.equal(nach('summe').label, 'Gesamtpreis') + assert.ok(atome.every((a) => a.hoehe > 0 && Number.isFinite(a.hoehe))) +}) + +test('baueAtome Aufwand, ersetzt und Privatperson: nur die passenden Bausteine', () => { + const aufwand = bausteine({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }) + assert.ok(ids(aufwand).includes('aufwand') && !ids(aufwand).includes('tabellenkopf') && !ids(aufwand).includes('summe')) + assert.equal(aufwand.find((a) => a.id === 'aufwand').kasten, true) + const mitErsetzt = bausteine({}, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }) + assert.ok(ids(mitErsetzt).indexOf('ersetzt') > ids(mitErsetzt).indexOf('agb') && ids(mitErsetzt).indexOf('ersetzt') < ids(mitErsetzt).indexOf('gruss')) + assert.equal(ids(bausteine()).some((id) => /^(widerruf|formular)/.test(id)), false) + const privat = bausteine({ kunde: { ...beispielAuftrag().kunde, kundenart: 'privat' } }) + const titel = privat.find((a) => a.id === 'widerruf-titel') + assert.equal(titel.umbruchDavor, true) + assert.ok(ids(privat).indexOf('formular-titel') > ids(privat).indexOf('widerruf-titel')) + assert.equal(privat.find((a) => a.id === 'formular-titel').haengtAnNaechstem, true) + assert.equal(privat.at(-1).haengtAnNaechstem, false) +}) + +test('20 Positionen mit je 200 Zeichen ohne Leerzeichen: alle Zeilen da, mehrere Seiten, Tabellenkopf auf jeder Folgeseite', () => { + const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: 'X'.repeat(200), menge: i + 1, einzelpreisCents: 1000 })) + const summe = positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0) + const seiten = paginiere(bausteine({ positionen, summeCents: summe }), BEREICHE) + assert.ok(seiten.length >= 2) + const zeilen = seiten.flatMap((s) => s.posten.filter((p) => p.atom.typ === 'zeile').map((p) => p.atom.id)) + assert.deepEqual(zeilen, Array.from({ length: 20 }, (_, i) => `zeile-${i + 1}`)) + for (const seite of seiten.slice(1)) { + const ersteZeile = seite.posten.findIndex((p) => p.atom.typ === 'zeile') + if (ersteZeile >= 0) assert.equal(seite.posten[0].atom.typ, 'tabellenkopf', `Seite ${seite.nummer} beginnt mit dem Tabellenkopf`) + } + const letzte = seiten.at(-1).posten.map((p) => p.atom.id) + assert.ok(letzte.indexOf('summe') > letzte.indexOf('zeile-20'), 'Summe steht bei der letzten Zeile') + for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten + 1e-9) +}) + +test('lange Widerrufsbelehrung: beginnt auf einer neuen Seite, das Formular bleibt zusammen', () => { + const absatz = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(12) + const d = auftragPdfDaten(beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, kundenart: 'privat' } }), { settings: beispielSettings }) + d.widerruf = { ...d.widerruf, belehrung: { titel: 'Widerrufsbelehrung', abschnitte: Array.from({ length: 6 }, (_, i) => ({ ueberschrift: `Abschnitt ${i + 1}`, absaetze: [absatz, absatz] })) } } + const seiten = paginiere(baueAtome(d, messe), BEREICHE) + const seiteVon = (id) => seiten.find((s) => s.posten.some((p) => p.atom.id === id)).nummer + assert.ok(seiten.length >= 4) + assert.ok(seiteVon('widerruf-titel') >= 2) + assert.equal(seiten[seiteVon('widerruf-titel') - 1].posten.find((p) => !p.wiederholt).atom.id, 'widerruf-titel', 'Belehrung beginnt oben auf einer neuen Seite') + const formular = seiten.flatMap((s) => s.posten.filter((p) => p.atom.id.startsWith('formular')).map(() => s.nummer)) + assert.ok(formular.length >= 2 && new Set(formular).size === 1, 'alle Zeilen des Formulars stehen auf derselben Seite') + for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten + 1e-9) +})