Parallele Stränge L0–L7 (Portal), W0–W2 (WOMS), Z (Integration und Kontrolle); Vertragsfragen der Planrunde entschieden (Abschnitt „Nachträge nach der Planrunde“ in der Übersicht). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 KiB
AB-PDF – Umsetzungsplan (Strang L4)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Das PDF der Auftragsbestätigung (A4, Figtree, WEBklar-Logo, Briefkopf mit Steuernummer, Positionstabelle mit Seitenumbruch und Kopf auf jeder Folgeseite, Zahlungsplan, bei Privatpersonen Widerrufsbelehrung und Muster-Widerrufsformular, Fuß mit Bankverbindung und „Seite X von Y“, bei Vorschau „Entwurf“) als reine Datenfunktion auftragPdfDaten und Zeichnung erzeugeAuftragPdf mit pdfkit – ohne Chrome und ohne neue Abhängigkeiten.
Architecture: Alles Produktive steht in einer Datei, server/services/auftraege/pdf.js (so legt es die Dateitabelle der Übersicht fest), in fünf Abschnitten mit Kommentarüberschrift: 1 Schriften (Figtree-Dateien und Logo einmal je Prozess laden, Zeichenabdeckung prüfen, Zeichen ohne Glyph ersetzen), 2 Daten (auftragPdfDaten: alle Texte und Zahlen fertig formatiert), 3 Layout (rein, ohne pdfkit: Maße, Bausteine mit Höhen, paginiere), 4 Zeichnen (pdfkit), 5 Ablauf (erzeugeAuftragPdf). Gemessen und gezeichnet wird mit denselben Stilen; die Seitenzahl steht vor dem Zeichnen fest, deshalb braucht es weder bufferPages noch einen zweiten Durchlauf.
Tech Stack: Node 20.19 (Container) und 24 (Host), ES-Module, pdfkit 0.20.x (Node ≥ 20, von L0 installiert), fontkit 2.0.4 (direkte Abhängigkeit, von L0 in package.json eingetragen; hasGlyphForCodePoint), Figtree 2.002 in den statischen Schnitten Regular und SemiBold (SIL OFL 1.1) aus dem npm-Paket @expo-google-fonts/figtree@0.4.1, Tests mit node:test.
Spec: docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md (Abschnitt 2 „Inhalt des PDFs“ und „Erzeugung und Ablage“)
Übersicht/Verträge: docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (V3 Datenformen, V4 „L4 – AB-PDF“, V7 Texte, Review Focus 5 und der Abschnitt „Nachträge nach der Planrunde“; im Zweifel gilt die Übersicht). Die „Vertragsfragen“ am Ende tragen ihren Entscheidungsstand.
Worktree: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4, Branch auftraege/l4-ab-pdf (aus feature/auftraege nach L0).
Referenzstand (nur zum Nachschlagen): /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/pdfprobe/ enthält eine lauffähige Vorstufe von pdf.js und aller Tests dieses Plans (mit Ersatzdateien für die L0-Module unter server/services/auftraege/) sowie gerenderte Beispielseiten in out/. Maßgeblich sind die Tests in diesem Plan, nicht der Referenzstand; nichts daraus gehört ins Repo.
Global Constraints
Alle Punkte der Übersicht gelten. Zusätzlich für diesen Strang:
- Sonderzeichen im Quelltext nur als
\u{…}(mit Klammern) oderString.fromCodePoint(…)schreiben, nie als vierstelliges\uXXXX: die Werkzeugschicht macht daraus beim Schreiben das Zeichen selbst (unsichtbare NBSP, U+2028 in Tests und Regexen). Sichtbare Zeichen (ä, ß, €, „ “, –) dürfen wörtlich im Quelltext stehen. Regexe mit\u{…}brauchen das Flagu. - pdfkit:
import PDFDocument from 'pdfkit'nur inpdf.js. Seitenränder von pdfkit auf 0 (margins: { top: 0, bottom: 0, left: 0, right: 0 }); die Geometrie steht allein im Abschnitt „Layout“ vonpdf.js. Jeder Text wird mitwidthgemessen und gezeichnet (ohnewidthnimmt pdfkit die Seitenbreite; mit Rand 0 fügt pdfkit auch nie selbst eine Seite ein). - Schriften und Logo: nur Figtree Regular und SemiBold aus
server/assets/fonts/(mitFigtree-OFL.txtdaneben) undpublic/webklar-logo.png; alles wird überladeSchriften()einmal je Prozess gelesen. Kein Helvetica im PDF. - Alles Gedruckte kommt aus
AuftragPdfDaten(auch Beschriftungen und „Seite x von y“). Vor Messen und Zeichnen läuftbereinigeTexteüber die Daten. Die Uhr kommt als Parameterjetzt(Vorgabenew Date()), nie alsDate.now()im Funktionskörper. - Wortlaute nur aus
texte.jsundauftragSicht.js(L7 ändert sie später); Tests vergleichen mit den Rückgabewerten dieser Funktionen, nie mit eigenen Wortlauten. Ausnahmen sind die in V7 festgelegten Werte (ABSENDER,kleinunternehmerund der Satz „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“, denAB_TEXTE.terminebei Privatpersonen liefert). - Keine neuen Abhängigkeiten (kein devDependency, kein PDF-Werkzeug in Tests; der Textleser der Tests nutzt nur
node:zlib).package.jsonbleibt unberührt. - Dateien: nur die der Tabelle unten; je Task ein Commit mit einzeln genannten Dateien; kein Push.
Review Focus
- Umlaute, ß, €, Minuszeichen und lange Texte (Review Focus 5 der Übersicht: ä ö ü ß €, „−“ U+2212, 200 Zeichen ohne Leerzeichen, 20 Positionen über eine Seite): lesbar, kein Glyphenausfall, Seitenumbruch mit Tabellenkopf – Tests in Task 3, Task 7 und Task 9 (
Umlaute …,20 Positionen …). - Zeichen ohne Glyph in Kundendaten (kyrillische oder japanische Namen, Emoji, schmale Leerzeichen und Zero-Width-Zeichen aus Copy-and-paste): nie ein leeres Kästchen, sondern die Ersatzregel (Leerzeichen,
?, entfernt) – Tests in Task 3 und Task 9 (Zeichen ohne Glyph …). - Texte aus
texte.jsmit Zeichen jenseits von Figtree oder sehr langer Belehrung (L7 formuliert später): ein Wächter-Test schlägt an, statt dass der freigegebene Wortlaut still verändert wird; eine lange Belehrung läuft über mehrere Seiten, das Formular bleibt zusammen – Tests in Task 5 (Wächter …) und Task 7 (lange Widerrufsbelehrung …). - Unvollständiger Entwurf und leere Angaben (Vorschau mit fehlendem Datum oder fehlenden Positionen, keine Steuernummer, keine Kundennummer, keine Termine, keine Bankverbindung, Land Deutschland): kein „undefined“ und kein Absturz im Zeichnen; fehlende Pflichtangaben ergeben 409 mit Klartext, leere optionale Angaben lassen ihre Zeile weg; bei Privatpersonen steht der Abschnitt „Termine“ auch ohne Daten da (Satz zum Beginn nach Ablauf der Widerrufsfrist), bei Unternehmen ohne Daten nicht – Tests in Task 4 (
Fehlende Pflichtangaben …,Anschrift …), Task 5 (Fuß …,Termine: Privatpersonen …) und Task 9 (Privatperson: …). - Seitenumbruch an der Kante: Überschrift nie allein am Seitenende, Summe nie ohne die letzte Position, Tabellenkopf auf jeder Folgeseite, zu hohe Bausteine und Ketten führen nicht zur Endlosschleife – Tests in Task 6.
Dateien dieses Strangs
| Datei | Zweck |
|---|---|
server/services/auftraege/pdf.js |
alles Produktive; Exporte ladeSchriften, hatGlyph, bereinigeText, bereinigeTexte, PDF_BESCHRIFTUNG, auftragPdfDaten, SEITE, INHALT_BREITE, SPALTEN, BEREICHE, baueAtome, paginiere, erzeugeAuftragPdf |
server/assets/fonts/ |
Figtree-Regular.ttf, Figtree-SemiBold.ttf, Figtree-OFL.txt, QUELLE.txt |
test/auftraegePdfSchrift.test.js, test/auftraegePdfDaten.test.js, test/auftraegePdfLayout.test.js, test/auftraegePdfLeser.test.js, test/auftraegePdf.test.js, test/auftraegePdfErsetzt.test.js |
Tests |
test/auftraegePdfHilfe.js |
Testhilfe (Beispiel-Auftrag, Textleser); endet nicht auf .test.js, npm test startet sie nicht; Testhilfen gehören laut Übersicht (Nachträge) dem anlegenden Strang, wie auftraegeRechnungenHilfe.js und auftraegeBestaetigungHilfe.js der Nachbarstränge |
Damit bleibt dieser Strang bei den Dateien der Übersicht (pdf.js, server/assets/fonts/*, eigene Testdateien); QUELLE.txt liegt im Schriftordner, zur Testhilfe siehe Vertragsfrage 10 (entschieden).
Task 1: Worktree einrichten und Voraussetzungen prüfen
Files:
- Create: Worktree
/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4(Branchauftraege/l4-ab-pdf)
Interfaces:
-
Consumes (L0, müssen vorhanden sein):
konstanten.js(AUFTRAG_ARTEN,AUFTRAG_WEGE,ZAHLUNGSPLAN_ARTEN,AGB_FASSUNG),fehler.js(AuftragError),texte.js(ABSENDER,AB_TEXTE,WIDERRUFSBELEHRUNG_PROJEKT,WIDERRUFSFORMULAR),auftragSicht.js(zahlungsplanText(auftrag),aufwandText(auftrag)),auftragStore.js(getAuftrag(id)),finance/format.js(euroDe),finance/dates.js(berlinDate,formatDe); pdfkit und fontkit innode_modulesund independencies. -
Produces: den Arbeitsplatz für alle folgenden Tasks.
-
Step 1: Worktree und Symlink anlegen
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l4 -b auftraege/l4-ab-pdf feature/auftraege
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && ln -s ../dev-auftraege/node_modules node_modules
git status --short
Erwartet: keine Ausgabe. L0 hat node_modules (ohne Schrägstrich) in .gitignore eingetragen (Übersicht, Nachträge), damit ist auch der Symlink ignoriert. Erscheint doch ?? node_modules, fehlt der Eintrag: dem Orchestrator melden und weiterarbeiten (Dateien ohnehin einzeln mit git add, nie git add -A).
- Step 2: L0-Module und ihre Exporte prüfen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && node --input-type=module -e "
const soll = {
'./server/services/auftraege/konstanten.js': ['AUFTRAG_ARTEN', 'AUFTRAG_WEGE', 'ZAHLUNGSPLAN_ARTEN', 'AGB_FASSUNG'],
'./server/services/auftraege/fehler.js': ['AuftragError'],
'./server/services/auftraege/texte.js': ['ABSENDER', 'AB_TEXTE', 'WIDERRUFSBELEHRUNG_PROJEKT', 'WIDERRUFSFORMULAR'],
'./server/services/auftraege/auftragSicht.js': ['zahlungsplanText', 'aufwandText'],
'./server/services/auftraege/auftragStore.js': ['getAuftrag'],
}
for (const [datei, namen] of Object.entries(soll)) {
const modul = await import(datei)
const fehlt = namen.filter((n) => !(n in modul))
console.log(datei, fehlt.length ? 'FEHLT: ' + fehlt.join(', ') : 'ok')
}
"
Erwartet: fünfmal ok. Fehlt etwas, den Strang anhalten und dem Orchestrator melden (kein Nachbau in L4). Außerdem dürfen server/services/auftraege/pdf.js und server/assets/fonts/ noch nicht existieren (ls meldet beides als fehlend); gibt es sie schon, heißt „Create“ in den Tasks 2 und 4 „Modify“ – dann dem Orchestrator melden.
- Step 3: pdfkit und fontkit prüfen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && node --input-type=module -e "
import PDFDocument from 'pdfkit'
import * as fontkit from 'fontkit'
import { readFileSync } from 'node:fs'
const paket = JSON.parse(readFileSync('package.json', 'utf8'))
console.log(typeof PDFDocument, typeof (fontkit.default ?? fontkit).create, JSON.parse(readFileSync('node_modules/pdfkit/package.json', 'utf8')).version, paket.dependencies.pdfkit, paket.dependencies.fontkit)
"
Erwartet: function function 0.20.… und danach die Einträge von pdfkit und fontkit aus dependencies (beide da: L0 trägt fontkit seit der Planrunde als direkte Abhängigkeit ein, Vertragsfrage 8). Fehlt der Eintrag oder schlägt der Import fehl, dem Orchestrator melden.
- Step 4: Schema-Nachbau für Task 10 prüfen
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && command grep -a -n "auftraege" test/support/schema.js
Erwartet: mindestens ein Treffer (L0 hat schemaKeys('auftraege') eingerichtet). Ohne Treffer nur den Test in Task 10 (auftraegePdfErsetzt.test.js) zurückstellen und melden.
Kein Commit in diesem Task.
Task 2: Schriften und Logo laden, Glyphen prüfen
Files:
- Create:
server/assets/fonts/Figtree-Regular.ttf,server/assets/fonts/Figtree-SemiBold.ttf,server/assets/fonts/Figtree-OFL.txt,server/assets/fonts/QUELLE.txt - Create:
server/services/auftraege/pdf.js(zunächst nur Abschnitt 1 „Schriften“) - Test:
test/auftraegePdfSchrift.test.js
Interfaces:
- Consumes: nichts aus anderen Tasks.
- Produces (
pdf.js):ladeSchriften() → { regular: Buffer, semibold: Buffer, logo: Buffer, regularFont, semiboldFont }(eingefrorenes Objekt, bei jedem Aufruf dasselbe;…Fontsind fontkit-Schriften);hatGlyph(codePoint: number) → boolean(Glyph in Regular und SemiBold).
Quelle der Schriften: fontkit liest zwar WOFF2 (mit den Dateien aus public/fonts geprüft), aber diese Dateien sind variabel (Standardgewicht „Light“) und in latin und latin-ext zerlegt; die woff2-Dateien von @fontsource/figtree sind es ebenfalls (zwei Dateien je Gewicht, ein Zeichen aus dem zweiten Teil bräuchte einen Schriftwechsel mitten im Text). @expo-google-fonts/figtree@0.4.1 liefert dagegen je Gewicht eine vollständige statische TTF (Latin und Latin Extended in einer Datei, 392 Codepunkte, je rund 40 KB).
- Step 1: Failing Test schreiben
test/auftraegePdfSchrift.test.js (Task 3 hängt weitere Tests an; die Importzeile nennt die Namen von Task 3 schon, sie sind bis dahin undefined):
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync, readdirSync } from 'node:fs'
import { useTestEnv } from './support/testEnv.js'
const FONTS = new URL('../server/assets/fonts/', import.meta.url)
const sha256 = (datei) => createHash('sha256').update(readFileSync(new URL(datei, FONTS))).digest('hex')
const cp = (...codes) => String.fromCodePoint(...codes)
const NBSP = cp(0xa0)
test('Schriftdateien: Figtree Regular und SemiBold samt OFL-Lizenz und Quellenangabe liegen unverändert vor', () => {
assert.deepEqual(readdirSync(FONTS).sort(), ['Figtree-OFL.txt', 'Figtree-Regular.ttf', 'Figtree-SemiBold.ttf', 'QUELLE.txt'])
assert.equal(sha256('Figtree-Regular.ttf'), '4e2e9876c4a908c2cbe410ceb648e4724464a84492bd49cb2d6e7b0d3f769a66')
assert.equal(sha256('Figtree-SemiBold.ttf'), '1b76aae525b51758fc0c9c5096f33d90831657d3ad2fe3c34fb7e666d76f8960')
assert.equal(sha256('Figtree-OFL.txt'), '140d37233e7f3ce7313798befa9600893bcceaf41a55fa0fa5ad52f7f657a268')
assert.match(readFileSync(new URL('Figtree-OFL.txt', FONTS), 'utf8'), /^Copyright 2022 The Figtree Project Authors/)
assert.match(readFileSync(new URL('QUELLE.txt', FONTS), 'utf8'), /@expo-google-fonts\/figtree@0\.4\.1/)
})
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { ladeSchriften, hatGlyph, bereinigeText, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
test('ladeSchriften: Dateien werden einmal je Prozess gelesen, jeder Aufruf liefert dasselbe Objekt', () => {
const schriften = ladeSchriften()
assert.equal(ladeSchriften(), schriften)
assert.equal(schriften.regularFont.postscriptName, 'Figtree-Regular')
assert.equal(schriften.semiboldFont.postscriptName, 'Figtree-SemiBold')
assert.equal(schriften.regular.length, 40140)
assert.equal(schriften.logo.subarray(1, 4).toString('latin1'), 'PNG')
})
test('hatGlyph: deutsche Buchstaben, €, Minuszeichen, geschütztes Leerzeichen und typografische Zeichen sind in beiden Schnitten da', () => {
const noetig = [
...'äöüÄÖÜ߀§%/&()@:;,.!?+*#"\'0123456789 ',
NBSP, cp(0x2212), cp(0x2013), cp(0x2014), cp(0x201e), cp(0x201c), cp(0x201d), cp(0x2018), cp(0x2019), cp(0x2026), cp(0x2022), cp(0xb7), cp(0xd7),
'ł', 'ğ', 'Ş', 'ő', 'ř', 'ı',
]
for (const zeichen of noetig) assert.ok(hatGlyph(zeichen.codePointAt(0)), `Glyph fehlt: U+${zeichen.codePointAt(0).toString(16)}`)
for (const code of [0x202f, 0x2009, 0x200b, 0x416, 0x1f600, 0x3042]) assert.equal(hatGlyph(code), false, `U+${code.toString(16)} sollte fehlen`)
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdfSchrift.test.js
Erwartet: FAIL („Cannot find module …/pdf.js“; das Schriftverzeichnis fehlt).
- Step 3: Schriften beschaffen und Abschnitt „Schriften“ in
pdf.jsschreiben
Dateien holen (npm pack lädt nur das Paket in den Scratchpad, kein npm install):
S=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-fonts
mkdir -p $S && cd $S && timeout 120 npm pack @expo-google-fonts/figtree@0.4.1
tar -xzf expo-google-fonts-figtree-0.4.1.tgz package/400Regular/Figtree_400Regular.ttf package/600SemiBold/Figtree_600SemiBold.ttf package/LICENSE_FONT
W=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4/server/assets/fonts
mkdir -p $W
cp package/400Regular/Figtree_400Regular.ttf $W/Figtree-Regular.ttf
cp package/600SemiBold/Figtree_600SemiBold.ttf $W/Figtree-SemiBold.ttf
cp package/LICENSE_FONT $W/Figtree-OFL.txt
sha256sum $W/*
Erwartet: 4e2e9876…9a66 für Regular, 1b76aae5…8960 für SemiBold, 140d3723…a268 für Figtree-OFL.txt (volle Werte stehen im Test). Dazu server/assets/fonts/QUELLE.txt mit genau diesem Inhalt:
Figtree, statische TrueType-Schnitte Regular (400) und SemiBold (600) für das PDF der Auftragsbestätigung.
Lizenz: SIL Open Font License 1.1, Text in Figtree-OFL.txt.
Quelle: npm-Paket @expo-google-fonts/figtree@0.4.1 (400Regular/Figtree_400Regular.ttf, 600SemiBold/Figtree_600SemiBold.ttf, LICENSE_FONT als Figtree-OFL.txt), Stand 2026-10-06.
SHA-256 Figtree-Regular.ttf: 4e2e9876c4a908c2cbe410ceb648e4724464a84492bd49cb2d6e7b0d3f769a66
SHA-256 Figtree-SemiBold.ttf: 1b76aae525b51758fc0c9c5096f33d90831657d3ad2fe3c34fb7e666d76f8960
server/services/auftraege/pdf.js (neu; Kopfkommentar „PDF der Auftragsbestätigung …“ mit den fünf Abschnitten, dann // ===== 1. Schriften, Logo, Zeichenabdeckung =====) mit den Exporten oben:
-
import * as fontkitModul from 'fontkit'undconst fontkit = fontkitModul.default ?? fontkitModul(die ESM-Fassung von fontkit 2.0.4 hat nur benannte Exporte,createinklusive). -
Pfade relativ zur Datei:
new URL('../../assets/fonts/', import.meta.url)undnew URL('../../../public/webklar-logo.png', import.meta.url). -
ladeSchriften()liest beim ersten Aufruf beide TTFs und das Logo als Buffer, erzeugt mitfontkit.create(buffer)die beiden Schriften und merkt sich das eingefrorene Ergebnis in einer Modulvariablen. -
hatGlyph(codePoint)fragthasGlyphForCodePointan beiden fontkit-Schriften ab. -
Step 4: Lauf, erwartet PASS
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdfSchrift.test.js
Erwartet: die drei Tests dieses Tasks bestehen; die Tests von Task 3 gibt es noch nicht.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add server/assets/fonts/Figtree-Regular.ttf server/assets/fonts/Figtree-SemiBold.ttf server/assets/fonts/Figtree-OFL.txt server/assets/fonts/QUELLE.txt server/services/auftraege/pdf.js test/auftraegePdfSchrift.test.js
git commit -m "AB-PDF: Figtree Regular und SemiBold (OFL) mit Schriftlader und Glyphenprüfung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 3: Zeichen ohne Glyph ersetzen
Files:
- Modify:
server/services/auftraege/pdf.js(Abschnitt 1) - Test:
test/auftraegePdfSchrift.test.js
Interfaces:
- Consumes:
hatGlyph(codePoint)aus Task 2. - Produces:
bereinigeText(text: unknown, hatGlyphFn = hatGlyph) → string;bereinigeTexte<T>(wert: T, hatGlyphFn = hatGlyph) → T'(jede Zeichenkette in beliebig verschachtelten Objekten und Listen durchbereinigeText; Zahlen,null, Wahrheitswerte bleiben; liefert neue Objekte, ändert die Eingabe nicht).
Ersatzregel (Review Focus 1 und 2), in dieser Reihenfolge:
String(text ?? ''),normalize('NFC');\r\n,\r, U+2028 und U+2029 werden\n; Tab wird Leerzeichen; entfernt werden Steuerzeichen außer\n(U+0000–U+0008, U+000B–U+001F, U+007F–U+009F), U+00AD, U+200B–U+200D, U+2060, U+FE00–U+FE0F und U+FEFF.- Je Codepunkt:
\noderhatGlyphFn(codePoint)→ unverändert. - Sonst steht der Codepunkt in
ERSATZ→ dessen Ersatz. - Sonst Leerzeichen-Art (
\p{Zs}) → ein Leerzeichen. - Sonst kombinierendes Zeichen (
\p{M}) → entfällt. - Sonst erster Codepunkt von
zeichen.normalize('NFD'), falls er sich vom Zeichen unterscheidet und ein Glyph hat (ğ → g, Å → A) → dieser Buchstabe. - Sonst
?.
const ERSATZ = new Map([
[0x2010, '-'], [0x2011, '-'], [0x2012, '-'], [0x2013, '-'], [0x2014, '-'], [0x2015, '-'], [0x2212, '-'],
[0x2018, "'"], [0x2019, "'"], [0x201a, "'"], [0x2032, "'"],
[0x201c, '"'], [0x201d, '"'], [0x201e, '"'], [0x201f, '"'], [0x2033, '"'],
[0x2026, '...'], [0x20ac, 'EUR'], [0x2022, '-'], [0x00b7, '-'], [0x00d7, 'x'], [0x2192, '->'],
])
// Steuerzeichen (außer \n), weiche Trennung, Zero-Width-Zeichen, Variantenselektoren, BOM
const ENTFERNEN = /[\u{0}-\u{8}\u{B}-\u{1F}\u{7F}-\u{9F}\u{AD}\u{200B}-\u{200D}\u{2060}\u{FE00}-\u{FE0F}\u{FEFF}]/gu
- Step 1: Failing Tests anhängen
An test/auftraegePdfSchrift.test.js anhängen:
test('bereinigeText: vorhandene Zeichen bleiben unverändert, getrennte Akzente werden zusammengesetzt', () => {
const text = `Müller & Söhne – „Größe“ 1.234,56${NBSP}€ ${cp(0x2212)} 20,00${NBSP}€ ÄÖÜ äöü ß … Łukasz Yıldız Çelik`
assert.equal(bereinigeText(text), text)
assert.equal(bereinigeText('a' + cp(0x301)), 'á'.normalize('NFC'))
assert.equal(bereinigeText(null), '')
assert.equal(bereinigeText(undefined), '')
assert.equal(bereinigeText(42), '42')
})
test('bereinigeText: Steuerzeichen, Zero-Width, weiche Trennung und BOM entfallen; Zeilenenden werden Zeilenumbrüche, Tab wird Leerzeichen', () => {
const roh = `a${cp(0x200b)}b${cp(0xad)}c${cp(0xfeff)}d${cp(0)}e\tf\r\ng\rh${cp(0x2028)}i${cp(0x200d, 0xfe0f)}j`
assert.equal(bereinigeText(roh), 'abcde f\ng\nh\nij')
})
test('bereinigeText mit Figtree: schmale Leerzeichen werden Leerzeichen, kyrillische und japanische Zeichen sowie Emoji je ein Fragezeichen', () => {
assert.equal(bereinigeText(`a${cp(0x202f)}b${cp(0x2009)}c`), 'a b c')
assert.equal(bereinigeText(`${cp(0x416)}${cp(0x3042)}${cp(0x1f600)}`), '???')
})
test('bereinigeText: Ersatzregeln bei fehlendem Glyph (Schrift ohne Zeichen über ASCII)', () => {
const nurAscii = (code) => code < 0x80
const fall = (eingabe) => bereinigeText(eingabe, nurAscii)
assert.equal(fall('9,00' + NBSP + '€'), '9,00 EUR')
assert.equal(fall(`${cp(0x201e)}x${cp(0x201c)} ${cp(0x201a)}y${cp(0x2019)}`), `"x" 'y'`)
assert.equal(fall(`1 ${cp(0x2013)} 2 ${cp(0x2212)} 3 ${cp(0x2014)} 4`), '1 - 2 - 3 - 4')
assert.equal(fall(`wait${cp(0x2026)} 3${cp(0xd7)}4 a${cp(0x2192)}b`), 'wait... 3x4 a->b')
assert.equal(fall('Müller Çelik Ångström'), 'Muller Celik Angstrom')
assert.equal(fall('Łukasz Ğ'), '?ukasz G')
assert.equal(fall('e' + cp(0x301) + cp(0x301)), 'e')
assert.equal(fall(cp(0x301)), '')
})
test('bereinigeTexte: bearbeitet jede Zeichenkette in verschachtelten Objekten und Listen, andere Werte bleiben', () => {
const roh = { a: ['x' + cp(0x200b) + 'y', { b: 'ż' + cp(0x1f600) }], n: 5, f: null, t: true }
assert.deepEqual(bereinigeTexte(roh), { a: ['xy', { b: 'ż?' }], n: 5, f: null, t: true })
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdfSchrift.test.js
Erwartet: die fünf neuen Tests scheitern („bereinigeText is not a function“), die drei alten bestehen.
- Step 3:
bereinigeTextundbereinigeTextein Abschnitt 1 vonpdf.jsergänzen
Beides mit den Signaturen und der Regel oben; die Konstanten ERSATZ (Map Codepunkt → Ersatz) und ENTFERNEN (Regex mit Flag u) wie im Block oben.
- Step 4: Lauf, erwartet PASS
Run: derselbe wie in Step 2. Erwartet: alle acht Tests bestehen.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add server/services/auftraege/pdf.js test/auftraegePdfSchrift.test.js
git commit -m "AB-PDF: Zeichen ohne Glyph werden ersetzt statt als Kästchen gedruckt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: auftragPdfDaten – Kopf, Anschrift, Leistung, Prüfung der Pflichtangaben
Files:
- Modify:
server/services/auftraege/pdf.js(Importe oben ergänzen, Abschnitt 2 „Daten“ anhängen: zunächstPDF_BESCHRIFTUNGundauftragPdfDaten) - Create:
test/auftraegePdfHilfe.js(Beispiel-Auftrag undalleTexte; Task 8 ergänzt den Textleser) - Test:
test/auftraegePdfDaten.test.js
Interfaces:
- Consumes:
AuftragError(status, message)(fehler.js);AUFTRAG_ARTEN,AUFTRAG_WEGE,ZAHLUNGSPLAN_ARTEN,AGB_FASSUNG(konstanten.js);ABSENDER,AB_TEXTE(texte.js, V7);aufwandText(auftrag) → string|null,zahlungsplanText(auftrag) → string(auftragSicht.js, V3);euroDe(cents);berlinDate(date),formatDe(ymd). - Produces (
pdf.js):PDF_BESCHRIFTUNG(Beschriftungen, siehe Block);auftragPdfDaten(auftrag: Auftrag, { settings = {}, vorschau = false, jetzt = new Date(), ersetzt = null } = {}) → AuftragPdfDaten.settings = { steuernummer, bankDetailsText }(V4),jetztundersetzt = { nummer, datum /* 'YYYY-MM-DD' */ }stehen seit der Planrunde in V4 (Vertragsfragen 1 und 2: entschieden). Die Funktion ist rein und wirft nurAuftragError.
PDF_BESCHRIFTUNG (eingefroren, in pdf.js exportiert):
export const PDF_BESCHRIFTUNG = Object.freeze({
nummer: 'Nummer', datum: 'Datum', kundennummer: 'Kundennummer', projekt: 'Projekt',
leistung: 'Leistungsumfang', zahlung: 'Zahlung', termine: 'Termine', mitwirkung: 'Mitwirkung und Abnahme',
spalten: Object.freeze(['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe']), gesamt: 'Gesamtpreis',
steuernummer: 'Steuernummer', telefon: 'Telefon', bank: 'Bankverbindung', kontakt: 'Kontakt', seite: 'Seite', von: 'von',
entwurf: 'ENTWURF', entwurfHinweis: 'Entwurf – Vorschau, noch keine gültige Auftragsbestätigung',
})
Form von AuftragPdfDaten (alles Zeichenketten, fertig formatiert). Task 4 liefert die Felder bis kleinunternehmer, Task 5 den Rest:
{
vorschau: boolean,
beschriftung: PDF_BESCHRIFTUNG,
titel: AB_TEXTE.titel,
nummer: 'AB-2026-0007', // auftrag.nummer, sonst 'Entwurf'
datum: '06.10.2026', // Berliner Tag von gesendetAm, sonst von jetzt
kundennummer: 'K-1042', // kunde.kundennummer oder ''
projekt: 'Neue Website …', // auftrag.titel, getrimmt
absender: { zeilen: string[], rueckzeile: string },
empfaenger: string[],
einleitung: string,
leistung: {
art: 'festpreis' | 'aufwand', ueberschrift: 'Leistungsumfang',
positionen: [{ nr: '1', beschreibung, menge: '2', einzelpreis: '100,00 €', summe: '200,00 €' }], // Aufwand: []
gesamt: { label: 'Gesamtpreis', betrag: '800,00 €' } | null, // Aufwand: null
aufwandText: string | null, // nur Aufwand
},
kleinunternehmer: AB_TEXTE.kleinunternehmer,
// Task 5:
abschnitte: [{ id: 'zahlung' | 'termine' | 'mitwirkung', ueberschrift, text }],
agb: string, ersetzt: string | null, gruss: string,
widerruf: { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR } | null,
fuss: { bank: string[], kontakt: string[] },
}
Regeln für die Felder von Task 4:
nummer:auftrag.nummer || 'Entwurf'.datum:formatDe(berlinDate(new Date(auftrag.gesendetAm || jetzt))).projekt:auftrag.titel.trim().absender.zeilen:ABSENDER.name,strasse,plzOrt,Telefon <telefon>,email,web, undSteuernummer <settings.steuernummer>nur, wenn die (getrimmte) Steuernummer nicht leer ist.rueckzeile:<name> · <strasse> · <plzOrt>(mit Mittelpunkt U+00B7).empfaenger:kunde.firma,kunde.name,kunde.strasse,<plz> <ort>(leere Teile entfallen),kunde.landnur, wenn es nicht Deutschland, DE oder Germany ist (ohne Beachtung der Groß-/Kleinschreibung); jede Zeile getrimmt, leere und doppelte Zeilen entfallen (Firma gleich Name erscheint einmal).einleitung:AB_TEXTE.einleitung({ datum: formatDe(auftrag.auftragVom), weg: AUFTRAG_WEGE[auftrag.auftragWeg] })–wegist der Anzeigetext („telefonisch“), nicht der Schlüssel (Vertragsfrage 4).leistungFestpreis:positionenausauftrag.positionen(nrab'1',beschreibunggetrimmt,mengeals Text,einzelpreisundsumme = menge × einzelpreisCentsübereuroDe),gesamt.betrag = euroDe(auftrag.summeCents). Aufwand:positionen: [],gesamt: null,aufwandText: aufwandText(auftrag).- Prüfung vor allem anderen (
AuftragError(409, …)), in dieser Reihenfolge, je Bedingung genau diese Meldung:
| Bedingung | Meldung |
|---|---|
auftrag.kunde fehlt oder ist kein Objekt |
Entwurf unvollständig: Kundendaten fehlen. |
ohne vorschau: nummer leer |
Entwurf unvollständig: Nummer fehlt. |
titel nach trim() leer |
Entwurf unvollständig: Titel fehlt. |
auftragVom nicht JJJJ-MM-TT |
Entwurf unvollständig: Datum der Absprache fehlt. |
auftragWeg nicht in AUFTRAG_WEGE |
Entwurf unvollständig: Weg der Absprache fehlt. |
art nicht in AUFTRAG_ARTEN |
Entwurf unvollständig: Art des Auftrags fehlt. |
zahlungsplan.art nicht in ZAHLUNGSPLAN_ARTEN |
Entwurf unvollständig: Zahlungsplan fehlt. |
| Festpreis ohne Positionen | Entwurf unvollständig: Positionen fehlen. |
Festpreis: Σ menge × einzelpreisCents ≠ summeCents |
Die Summe der Positionen stimmt nicht mit der Auftragssumme überein. |
Aufwand: stundensatzCents keine positive sichere Ganzzahl |
Entwurf unvollständig: Stundensatz fehlt. |
- Step 1: Testhilfe und Failing Tests schreiben
test/auftraegePdfHilfe.js (neu; Task 8 ergänzt am Anfang import { inflateSync } from 'node:zlib' und hängt seitenZahl und leseSeiten an):
/** Beispiel-Auftrag (V3-Form) für die PDF-Tests; `extra` überschreibt einzelne Felder. */
export function beispielAuftrag(extra = {}) {
return {
id: 'ab1', nummer: 'AB-2026-0007', jahr: 2026, laufnummer: 7, status: 'versendet', kundenId: 'k1',
kunde: {
name: 'Anna Müller', firma: 'Müller Bäckerei GmbH', strasse: 'Hauptstraße 12', plz: '67722', ort: 'Winnweiler',
land: 'Deutschland', email: 'anna@example.test', kundenart: 'unternehmen', kundennummer: 'K-1042',
},
projektId: 'p1', titel: 'Neue Website Müller Bäckerei', zeitquelle: ['t1'], art: 'festpreis',
positionen: [
{ beschreibung: 'Gestaltung und Umsetzung der Website (5 Seiten)', menge: 1, einzelpreisCents: 60000 },
{ beschreibung: 'Kontaktformular mit E-Mail-Versand', menge: 2, einzelpreisCents: 10000 },
],
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-10-12', fertigBis: '2026-11-15',
ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', pdfDatei: '', pdfSha256: '',
gesendetAm: '2026-10-06T08:30:00.000Z', gesendetAn: 'anna@example.test', mailStatus: '', mailFehler: '',
tokenHash: '', tokenGueltigBis: null, bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '',
fruehbeginn: false, fruehbeginnFassung: '', storniertAm: null, widerrufenAm: null,
erstelltVon: 'kenso', erstelltAm: '2026-10-06T08:00:00.000Z', geaendertAm: '2026-10-06T08:00:00.000Z',
...extra,
}
}
export const beispielSettings = { steuernummer: '12/345/67890', bankDetailsText: 'Finom Bank\nIBAN DE12 3456 7890 1234 5678 90\nBIC FNOMDEB2' }
/** Alle Zeichenketten in verschachtelten Objekten und Listen (flach gesammelt); Werte unter den Schlüsseln id und art sind keine Texte. */
export function alleTexte(wert, aus = []) {
if (typeof wert === 'string') aus.push(wert)
else if (Array.isArray(wert)) wert.forEach((e) => alleTexte(e, aus))
else if (wert && typeof wert === 'object') {
for (const [schluessel, e] of Object.entries(wert)) if (schluessel !== 'id' && schluessel !== 'art') alleTexte(e, aus)
}
return aus
}
test/auftraegePdfDaten.test.js (die Importe nennen schon die Namen aus Task 5; Task 5 hängt Tests an):
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { beispielAuftrag, beispielSettings } from './auftraegePdfHilfe.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { auftragPdfDaten, PDF_BESCHRIFTUNG, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
const { AB_TEXTE, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } = await import('../server/services/auftraege/texte.js')
const { zahlungsplanText, aufwandText } = await import('../server/services/auftraege/auftragSicht.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { euroDe } = await import('../server/services/finance/format.js')
const JETZT = new Date('2026-10-06T10:00:00.000Z')
const NBSP = String.fromCodePoint(0xa0)
const FRUEHBEGINN_SATZ = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.' // V7: AB_TEXTE.termine bei Privatpersonen
const daten = (extra = {}, optionen = {}) => auftragPdfDaten(beispielAuftrag(extra), { settings: beispielSettings, jetzt: JETZT, ...optionen })
const aufwandAuftrag = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
test('euroDe liefert Beträge mit geschütztem Leerzeichen vor dem €-Zeichen (Grundlage aller Betragsvergleiche hier)', () => {
assert.equal(euroDe(60000), `600,00${NBSP}€`)
})
test('Festpreis: Kopf, Anschrift, Angaben und Positionen sind fertig formatiert', () => {
const d = daten()
assert.equal(d.vorschau, false)
assert.equal(d.titel, AB_TEXTE.titel)
assert.equal(d.nummer, 'AB-2026-0007')
assert.equal(d.datum, '06.10.2026')
assert.equal(d.kundennummer, 'K-1042')
assert.equal(d.projekt, 'Neue Website Müller Bäckerei')
assert.deepEqual(d.absender.zeilen, [
'WEBklar GbR', 'Am Schwimmbad 10', '67722 Winnweiler', 'Telefon 0176 23726355', 'support@webklar.com', 'webklar.com', 'Steuernummer 12/345/67890',
])
assert.equal(d.absender.rueckzeile, 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler')
assert.deepEqual(d.empfaenger, ['Müller Bäckerei GmbH', 'Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
assert.equal(d.einleitung, AB_TEXTE.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }))
assert.ok(d.einleitung.includes('05.10.2026') && d.einleitung.includes('telefonisch'))
assert.equal(d.leistung.art, 'festpreis')
assert.equal(d.leistung.ueberschrift, 'Leistungsumfang')
assert.deepEqual(d.leistung.positionen, [
{ nr: '1', beschreibung: 'Gestaltung und Umsetzung der Website (5 Seiten)', menge: '1', einzelpreis: euroDe(60000), summe: euroDe(60000) },
{ nr: '2', beschreibung: 'Kontaktformular mit E-Mail-Versand', menge: '2', einzelpreis: euroDe(10000), summe: euroDe(20000) },
])
assert.deepEqual(d.leistung.gesamt, { label: 'Gesamtpreis', betrag: euroDe(80000) })
assert.equal(d.leistung.aufwandText, null)
assert.equal(d.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
assert.deepEqual(PDF_BESCHRIFTUNG.spalten, ['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe'])
})
test('Datum der AB ist der Berliner Tag von gesendetAm, ohne gesendetAm der heutige Berliner Tag', () => {
assert.equal(daten({ gesendetAm: '2026-10-06T22:30:00.000Z' }).datum, '07.10.2026')
assert.equal(daten({ gesendetAm: '2026-10-06T21:30:00+00:00' }).datum, '06.10.2026')
assert.equal(daten({ gesendetAm: null }, { jetzt: new Date('2026-12-31T23:30:00.000Z') }).datum, '01.01.2027')
})
test('Aufwand: Aufwandsregel statt Positionen, keine Gesamtsumme', () => {
const auftrag = beispielAuftrag(aufwandAuftrag)
const d = auftragPdfDaten(auftrag, { settings: beispielSettings, jetzt: JETZT })
assert.equal(d.leistung.art, 'aufwand')
assert.deepEqual(d.leistung.positionen, [])
assert.equal(d.leistung.gesamt, null)
assert.equal(d.leistung.aufwandText, aufwandText(auftrag))
assert.ok(d.leistung.aufwandText.includes(euroDe(10000)))
assert.equal(d.kleinunternehmer, AB_TEXTE.kleinunternehmer)
})
test('Vorschau: Nummer „Entwurf“, heutiges Datum, Kennzeichnung; ohne Vorschau braucht die AB eine Nummer', () => {
const entwurf = { nummer: '', gesendetAm: null }
const d = daten(entwurf, { vorschau: true })
assert.equal(d.vorschau, true)
assert.equal(d.nummer, 'Entwurf')
assert.equal(d.datum, '06.10.2026')
assert.equal(d.beschriftung.entwurf, 'ENTWURF')
assert.throws(() => daten(entwurf), (e) => e instanceof AuftragError && e.status === 409 && /Nummer/.test(e.message))
})
test('Anschrift: Firma und Name nur einmal, Land nur außerhalb Deutschlands, leere Teile entfallen', () => {
const kunde = (extra) => ({ ...beispielAuftrag().kunde, ...extra })
assert.deepEqual(daten({ kunde: kunde({ firma: 'Anna Müller' }) }).empfaenger, ['Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
assert.deepEqual(daten({ kunde: kunde({ land: 'Österreich', plz: '1010', ort: 'Wien' }) }).empfaenger, ['Müller Bäckerei GmbH', 'Anna Müller', 'Hauptstraße 12', '1010 Wien', 'Österreich'])
assert.deepEqual(daten({ kunde: kunde({ firma: '', land: '', strasse: '' }) }).empfaenger, ['Anna Müller', '67722 Winnweiler'])
})
test('Fehlende Pflichtangaben und falsche Summe: AuftragError 409 mit Klartext statt kaputtem PDF', () => {
const faelle = [
[{ kunde: null }, /Kundendaten/],
[{ titel: ' ' }, /Titel/],
[{ auftragVom: '' }, /Datum der Absprache/],
[{ auftragWeg: 'brieftaube' }, /Weg der Absprache/],
[{ art: 'pauschal' }, /Art des Auftrags/],
[{ zahlungsplan: null }, /Zahlungsplan/],
[{ positionen: [], summeCents: 0 }, /Positionen/],
[{ summeCents: 79999 }, /Summe der Positionen/],
[{ ...aufwandAuftrag, stundensatzCents: null }, /Stundensatz/],
]
for (const [extra, muster] of faelle) {
assert.throws(() => daten(extra, { vorschau: true }), (e) => e instanceof AuftragError && e.status === 409 && muster.test(e.message), JSON.stringify(extra))
}
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdfDaten.test.js
Erwartet: FAIL („auftragPdfDaten is not a function“ bzw. „Cannot read properties of undefined“ für PDF_BESCHRIFTUNG).
- Step 3: Abschnitt „Daten“ in
pdf.jsschreiben (Felder biskleinunternehmer)
Über dem Abschnitt „Schriften“ die Importe ergänzen: euroDe (../finance/format.js), berlinDate, formatDe (../finance/dates.js), AuftragError (./fehler.js), AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, ZAHLUNGSPLAN_ARTEN (./konstanten.js), AB_TEXTE, ABSENDER, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR (./texte.js), aufwandText, zahlungsplanText (./auftragSicht.js). Dann // ===== 2. Daten ===== mit Signatur, Form und Regeln wie oben. Die Prüfung steht in einer eigenen Funktion pruefe(auftrag, vorschau), die Anschrift in empfaengerZeilen(kunde).
- Step 4: Lauf, erwartet PASS
Run: derselbe wie in Step 2. Erwartet: alle sieben Tests bestehen.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add server/services/auftraege/pdf.js test/auftraegePdfHilfe.js test/auftraegePdfDaten.test.js
git commit -m "AB-PDF: auftragPdfDaten mit Kopf, Anschrift, Positionen und Prüfung der Pflichtangaben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: auftragPdfDaten – Zahlung, Termine, AGB, Privatperson, Fuß, Vorschau, ersetzt
Files:
- Modify:
server/services/auftraege/pdf.js - Test:
test/auftraegePdfDaten.test.js
Interfaces:
- Consumes:
auftragPdfDatenaus Task 4;bereinigeTexteaus Task 3 (nur im Wächter-Test). - Produces: die restlichen Felder von
AuftragPdfDaten(Form in Task 4).
Regeln:
-
abschnittein dieser Reihenfolge:zahlung(ueberschrift„Zahlung“,text: zahlungsplanText(auftrag)),termine(nur wennAB_TEXTE.termine({ start, fertig, privat })einen nicht leeren Text liefert;startundfertigsindformatDe(startAm)bzw.formatDe(fertigBis)odernull, wenn leer;privatistkunde.kundenart === 'privat'(V7): bei Privatpersonen lieferttermineimmer Text, auch ohne Daten, nämlich mit dem Satz „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“; bei Unternehmen ohne Datennull, dann entfällt der Abschnitt; Überschrift „Termine“),mitwirkung(Überschrift „Mitwirkung und Abnahme“,text: AB_TEXTE.mitwirkung). Die Überschriften stehen inPDF_BESCHRIFTUNG. -
agb:AB_TEXTE.agb({ fassung: formatDe(auftrag.agbFassung || AGB_FASSUNG) }).gruss:AB_TEXTE.gruss. -
ersetzt: Optionersetzt = { nummer, datum }→AB_TEXTE.ersetzt({ nummer, datum: formatDe(datum) }), sonstnull. -
widerruf: nur beikunde.kundenart === 'privat'das Paar{ belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR }(unverändert, V7), sonstnull. Die Frühbeginn-Erklärung selbst (das Verlangen des Kunden) gehört nicht ins PDF, sie steht auf der Bestätigungsseite; im PDF steht nur der Satz zum Beginn nach Ablauf der Widerrufsfrist im Abschnitt „Termine“ (siehe oben). -
fuss.bank:settings.bankDetailsTextan Zeilenumbrüchen geteilt, Zeilen getrimmt, leere entfallen (ohne Text:[]).fuss.kontakt:['<name> · vertreten durch <vertretenDurch>', 'Telefon <telefon> · <email> · <web>']. -
Step 1: Failing Tests anhängen
An test/auftraegePdfDaten.test.js anhängen (der Wächter-Test besteht sofort; er schlägt erst an, wenn L7 später ein Zeichen außerhalb von Figtree einführt):
test('Zahlungsplan Anzahlung nennt Anzahlung und Rest mit Beträgen und das Zahlungsziel', () => {
const auftrag = beispielAuftrag()
const text = daten().abschnitte.find((a) => a.id === 'zahlung').text
assert.equal(text, zahlungsplanText(auftrag))
assert.equal(text.split(euroDe(40000)).length - 1, 2)
assert.ok(text.includes('50') && text.includes('14'))
const nachAufwand = beispielAuftrag(aufwandAuftrag)
assert.equal(auftragPdfDaten(nachAufwand, { settings: beispielSettings, jetzt: JETZT }).abschnitte[0].text, zahlungsplanText(nachAufwand))
const dreissig = beispielAuftrag({ zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 30 })
const text30 = auftragPdfDaten(dreissig, { settings: beispielSettings, jetzt: JETZT }).abschnitte[0].text
assert.ok(text30.includes(euroDe(24000)) && text30.includes(euroDe(56000)) && text30.includes('30'))
})
test('Abschnitte: Zahlung, Termine (nur mit Datum), Mitwirkung und Abnahme; AGB-Satz mit Fassung', () => {
const d = daten()
assert.deepEqual(d.abschnitte.map((a) => [a.id, a.ueberschrift]), [['zahlung', 'Zahlung'], ['termine', 'Termine'], ['mitwirkung', 'Mitwirkung und Abnahme']])
assert.equal(d.abschnitte[1].text, AB_TEXTE.termine({ start: '12.10.2026', fertig: '15.11.2026', privat: false }))
assert.equal(d.abschnitte[2].text, AB_TEXTE.mitwirkung)
assert.equal(d.agb, AB_TEXTE.agb({ fassung: '30.09.2026' }))
assert.equal(daten({ startAm: '', fertigBis: '' }).abschnitte.some((a) => a.id === 'termine'), false)
assert.equal(daten({ agbFassung: '' }).agb, AB_TEXTE.agb({ fassung: '30.09.2026' }), 'ohne agbFassung gilt AGB_FASSUNG')
assert.equal(daten().gruss, AB_TEXTE.gruss)
})
test('Termine: Privatpersonen bekommen den Satz zum Beginn nach Ablauf der Widerrufsfrist, auch ohne Daten; Unternehmen nicht', () => {
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
const termine = (d) => d.abschnitte.find((a) => a.id === 'termine')?.text
const mitDaten = termine(daten(privat))
assert.equal(mitDaten, AB_TEXTE.termine({ start: '12.10.2026', fertig: '15.11.2026', privat: true }))
assert.ok(mitDaten.includes(FRUEHBEGINN_SATZ) && mitDaten.includes('12.10.2026') && mitDaten.includes('15.11.2026'))
const ohneDaten = termine(daten({ ...privat, startAm: '', fertigBis: '' }))
assert.equal(ohneDaten, AB_TEXTE.termine({ start: null, fertig: null, privat: true }))
assert.ok(ohneDaten.includes(FRUEHBEGINN_SATZ), 'der Abschnitt „Termine“ steht auch ohne Start- und Fertigdatum')
assert.equal(termine(daten()).includes(FRUEHBEGINN_SATZ), false)
assert.equal(termine(daten({ startAm: '', fertigBis: '' })), undefined, 'Unternehmen ohne Daten: kein Abschnitt')
})
test('Privatperson: Widerrufsbelehrung und Muster-Widerrufsformular gehören dazu, bei Unternehmen nicht', () => {
assert.equal(daten().widerruf, null)
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
const d = daten(privat)
assert.deepEqual(d.widerruf, { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR })
assert.deepEqual(d.empfaenger, ['Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
})
test('Ersetzte AB: Hinweis mit Nummer und Datum nur, wenn ersetzt übergeben wird', () => {
assert.equal(daten().ersetzt, null)
assert.equal(daten({}, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }).ersetzt, AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))
})
test('Fuß: Bankverbindung zeilenweise, Kontakt; leere Angaben lassen ihre Zeilen weg', () => {
const d = daten()
assert.deepEqual(d.fuss.bank, ['Finom Bank', 'IBAN DE12 3456 7890 1234 5678 90', 'BIC FNOMDEB2'])
assert.deepEqual(d.fuss.kontakt, ['WEBklar GbR · vertreten durch Kenso Grimm und Justin Klein', 'Telefon 0176 23726355 · support@webklar.com · webklar.com'])
const leer = auftragPdfDaten(beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, kundennummer: '' } }), { settings: {}, jetzt: JETZT })
assert.deepEqual(leer.fuss.bank, [])
assert.equal(leer.kundennummer, '')
assert.equal(leer.absender.zeilen.at(-1), 'webklar.com', 'ohne Steuernummer entfällt die Zeile')
assert.equal(auftragPdfDaten(beispielAuftrag(), { jetzt: JETZT }).fuss.bank.length, 0, 'ohne settings')
})
test('Wächter: alle Texte, die ins PDF kommen, haben ein Glyph in Figtree (die Ersetzung greift dort nie ein)', () => {
const kunde = beispielAuftrag().kunde
const faelle = [
daten({ kunde: { ...kunde, kundenart: 'privat' } }, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }),
daten({ kunde: { ...kunde, kundenart: 'privat' }, startAm: '', fertigBis: '' }),
daten({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }),
daten({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in drei Raten.' } }),
daten(aufwandAuftrag),
daten({ ...aufwandAuftrag, schaetzungMinuten: null }),
]
for (const d of faelle) assert.deepEqual(bereinigeTexte(d), d)
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdfDaten.test.js
Erwartet: die Tests zu Zahlungsplan, Abschnitten, Termine, Privatperson, ersetzt und Fuß scheitern (Felder fehlen); der Wächter-Test besteht schon jetzt (er hält nur fest, dass die Texte lückenlos Glyphen haben); die sieben Tests aus Task 4 bestehen.
- Step 3: Restliche Felder in
auftragPdfDatenergänzen
Regeln wie oben.
- Step 4: Lauf, erwartet PASS
Run: derselbe wie in Step 2. Erwartet: alle vierzehn Tests bestehen.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add server/services/auftraege/pdf.js test/auftraegePdfDaten.test.js
git commit -m "AB-PDF: Zahlungsplan, Termine, AGB-Satz, Privatperson-Seiten, Fuß und Vorschau in den PDF-Daten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 6: Seitenaufteilung paginiere (rein, ohne pdfkit)
Files:
- Modify:
server/services/auftraege/pdf.js(Abschnitt 3 „Layout“ anhängen: Maße, Farben, Stile,paginiere) - Test:
test/auftraegePdfLayout.test.js
Interfaces:
- Consumes: nichts (Test nutzt
beispielAuftrag/auftragPdfDatenerst ab Task 7). - Produces (
pdf.js): exportiertSEITE,INHALT_BREITE,SPALTEN,BEREICHE(Werte unten, alle in pt) undpaginiere(atome: Baustein[], bereiche = BEREICHE) → Seite[]; modulintern (nicht exportiert)FARBEN,ZELLEN_RAND,STIL,MASSE.
// Baustein: kleinste Einheit, wird nie geteilt (Höhe in pt)
{ id: string, typ: string, hoehe: number, abstandDavor: number, haengtAnNaechstem: boolean, umbruchDavor: boolean, kopf: Baustein | null }
// Seite
{ nummer: number /* ab 1 */, unten: number, posten: [{ atom: Baustein, y: number /* pt von der Oberkante */, wiederholt: boolean }] }
Maße und Stile (verbindliche Werte; pdfkit-Ränder bleiben 0, die Ränder stehen hier). Das Papier ist A4; unten bleiben 96 pt für den Fuß, Folgeseiten beginnen 40 pt tiefer als Seite 1 (Platz für den Kopf der Folgeseiten):
export const SEITE = Object.freeze({ breite: 595.28, hoehe: 841.89, links: 56.7, rechts: 56.7, oben: 48, unten: 96 })
export const INHALT_BREITE = SEITE.breite - SEITE.links - SEITE.rechts // 481.88
const FARBEN = Object.freeze({ tinte: '#111111', gedaempft: '#6b6964', linie: '#e8e6e0', linieDunkel: '#c9c6bd', flaeche: '#f7f6f3', entwurf: '#b42318' })
export const SPALTEN = Object.freeze([28, 237.88, 44, 86, 86]) // Pos., Beschreibung, Menge, Einzelpreis, Summe
const ZELLEN_RAND = 6
const STIL = Object.freeze({
titel: { schrift: 'semibold', groesse: 22, lineGap: 0, farbe: FARBEN.tinte },
projekt: { schrift: 'regular', groesse: 11, lineGap: 2, farbe: FARBEN.tinte },
adresse: { schrift: 'regular', groesse: 10, lineGap: 2.5, farbe: FARBEN.tinte },
ueberschrift: { schrift: 'semibold', groesse: 11, lineGap: 0, farbe: FARBEN.tinte },
text: { schrift: 'regular', groesse: 9.5, lineGap: 2.5, farbe: FARBEN.tinte },
klein: { schrift: 'regular', groesse: 8.5, lineGap: 2, farbe: FARBEN.gedaempft },
tabellenkopf: { schrift: 'semibold', groesse: 8.5, lineGap: 0, farbe: FARBEN.gedaempft },
summe: { schrift: 'semibold', groesse: 10.5, lineGap: 0, farbe: FARBEN.tinte },
fussLabel: { schrift: 'semibold', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
fuss: { schrift: 'regular', groesse: 7.5, lineGap: 1.5, farbe: FARBEN.gedaempft },
})
const MASSE = Object.freeze({
kopfHoehe: 22, summeHoehe: 30, kastenRand: 8, adresseBreite: 250, infoX: 340, infoZeile: 15,
rueckY: 80, adresseY: 94, // Rücksendezeile und Anschriftfeld relativ zur Oberkante des Briefkopfs (DIN 5008, Form B)
fussHoehe: 82, folgeKopf: 40,
})
const SICHERHEIT = 1
export const BEREICHE = Object.freeze({
erste: { oben: SEITE.oben, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
folge: { oben: SEITE.oben + MASSE.folgeKopf, unten: SEITE.hoehe - SEITE.unten - SICHERHEIT },
})
paginiere setzt voraus: Bausteine mit haengtAnNaechstem bilden mit ihrem Nachfolger eine Kette; eine Kette steht ganz auf einer Seite, wenn sie auf eine leere Folgeseite passt, sonst wird sie zwischen Bausteinen getrennt (kein Endlosschleifen-Risiko: ein zu hoher Baustein steht allein ganz oben). umbruchDavor beginnt eine neue Seite, aber nie eine leere. abstandDavor entfällt am Seitenanfang. Beginnt eine Folgeseite mit einem Baustein, der einen kopf hat (Tabellenzeile), steht dieser Kopf als wiederholter Posten (wiederholt: true) davor. Ohne Bausteine gibt es eine leere Seite. Der Algorithmus (bestimmt weder Signatur noch Test eindeutig):
export function paginiere(atome, bereiche = BEREICHE) {
const seiten = []
const folgeHoehe = bereiche.folge.unten - bereiche.folge.oben
let seite
let y
const neueSeite = () => {
const bereich = seiten.length === 0 ? bereiche.erste : bereiche.folge
seite = { nummer: seiten.length + 1, posten: [], unten: bereich.unten }
seiten.push(seite)
y = bereich.oben
}
const frisch = () => seite.posten.every((p) => p.wiederholt)
const abstand = (a) => (frisch() ? 0 : a.abstandDavor)
neueSeite()
for (let i = 0; i < atome.length; ) {
let j = i
while (j < atome.length - 1 && atome[j].haengtAnNaechstem) j += 1
const kette = atome.slice(i, j + 1)
i = j + 1
if (kette[0].umbruchDavor && !frisch()) neueSeite()
const bedarf = kette.reduce((summe, a, k) => summe + (k === 0 ? 0 : a.abstandDavor) + a.hoehe, 0)
const kopfHoehe = kette[0].kopf ? kette[0].kopf.hoehe : 0
if (!frisch() && y + abstand(kette[0]) + bedarf > seite.unten && bedarf + kopfHoehe <= folgeHoehe) neueSeite()
for (const a of kette) {
if (!frisch() && y + abstand(a) + a.hoehe > seite.unten) neueSeite()
if (seite.nummer > 1 && a.kopf && seite.posten.length === 0) {
seite.posten.push({ atom: a.kopf, y, wiederholt: true })
y += a.kopf.hoehe
}
y += abstand(a)
seite.posten.push({ atom: a, y, wiederholt: false })
y += a.hoehe
}
}
return seiten
}
- Step 1: Failing Tests schreiben
test/auftraegePdfLayout.test.js (die Importe nennen baueAtome und die Testhilfen schon, Task 7 hängt Tests an):
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { beispielAuftrag, beispielSettings } from './auftraegePdfHilfe.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { auftragPdfDaten, paginiere, baueAtome, BEREICHE, SEITE, SPALTEN, INHALT_BREITE } = await import('../server/services/auftraege/pdf.js')
// Kleine Seiten: erste Seite 0–200 pt, Folgeseiten 20–200 pt
const SEITEN = { erste: { oben: 0, unten: 200 }, folge: { oben: 20, unten: 200 } }
const baustein = (id, hoehe, extra = {}) => ({ id, typ: 'absatz', hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra })
const lage = (seiten) => seiten.map((s) => s.posten.map((p) => `${p.atom.id}${p.wiederholt ? '*' : ''}@${p.y}`))
test('Maße: A4, Inhaltsbreite und Spaltenbreiten passen zusammen', () => {
assert.equal(SEITE.breite, 595.28)
assert.equal(SEITE.hoehe, 841.89)
assert.ok(Math.abs(INHALT_BREITE - 481.88) < 1e-9)
assert.ok(Math.abs(SPALTEN.reduce((a, b) => a + b, 0) - INHALT_BREITE) < 1e-9)
assert.ok(BEREICHE.erste.oben < BEREICHE.folge.oben && BEREICHE.erste.unten === BEREICHE.folge.unten)
assert.ok(BEREICHE.erste.unten < SEITE.hoehe - 80, 'Platz für den Fuß bleibt frei')
})
test('paginiere: alles passt auf eine Seite, Abstände nur zwischen Bausteinen', () => {
const seiten = paginiere([baustein('a', 30, { abstandDavor: 9 }), baustein('b', 20, { abstandDavor: 8 }), baustein('c', 10, { abstandDavor: 8 })], SEITEN)
assert.deepEqual(lage(seiten), [['a@0', 'b@38', 'c@66']])
assert.deepEqual(paginiere([], SEITEN).map((s) => s.posten), [[]], 'auch ohne Inhalt gibt es eine Seite')
})
test('paginiere: was nicht mehr passt, beginnt die nächste Seite ohne Abstand davor', () => {
const seiten = paginiere([baustein('a', 170), baustein('b', 40, { abstandDavor: 5 }), baustein('c', 40, { abstandDavor: 5 })], SEITEN)
assert.deepEqual(lage(seiten), [['a@0'], ['b@20', 'c@65']])
assert.deepEqual(seiten.map((s) => s.nummer), [1, 2])
})
test('paginiere: Überschrift (haengtAnNaechstem) bleibt bei ihrem Absatz', () => {
const seiten = paginiere([baustein('a', 150), baustein('kopf', 15, { abstandDavor: 5, haengtAnNaechstem: true }), baustein('text', 40)], SEITEN)
assert.deepEqual(lage(seiten), [['a@0'], ['kopf@20', 'text@35']])
})
test('paginiere: umbruchDavor beginnt eine neue Seite, aber nie eine leere', () => {
assert.deepEqual(lage(paginiere([baustein('a', 30), baustein('b', 30, { umbruchDavor: true })], SEITEN)), [['a@0'], ['b@20']])
assert.deepEqual(lage(paginiere([baustein('a', 30, { umbruchDavor: true })], SEITEN)), [['a@0']])
assert.deepEqual(lage(paginiere([baustein('a', 200), baustein('b', 30, { umbruchDavor: true })], SEITEN)), [['a@0'], ['b@20']])
})
const tabelle = (zeilen, extra = {}) => {
const kopf = baustein('kopf', 20, { typ: 'tabellenkopf', haengtAnNaechstem: true })
const rows = Array.from({ length: zeilen }, (_, i) => baustein(`z${i + 1}`, 30, { typ: 'zeile', kopf, haengtAnNaechstem: i === zeilen - 1 && extra.mitSumme }))
return [kopf, ...rows, ...(extra.mitSumme ? [baustein('summe', 30, { haengtAnNaechstem: true }), baustein('hinweis', 10)] : [])]
}
test('paginiere: lange Tabelle läuft über mehrere Seiten, der Kopf wiederholt sich, keine Zeile wird geteilt', () => {
const seiten = paginiere(tabelle(20), SEITEN)
assert.deepEqual(lage(seiten).map((s) => s.length), [7, 6, 6, 5], 'Seite 1: Kopf + 6 Zeilen; Folgeseiten: wiederholter Kopf + je 5 Zeilen, Rest auf der letzten')
assert.equal(seiten.length, 4)
for (const seite of seiten.slice(1)) {
assert.equal(seite.posten[0].atom.id, 'kopf')
assert.equal(seite.posten[0].wiederholt, true)
assert.equal(seite.posten[0].y, 20)
}
const zeilen = seiten.flatMap((s) => s.posten.filter((p) => !p.wiederholt).map((p) => p.atom.id))
assert.deepEqual(zeilen, ['kopf', ...Array.from({ length: 20 }, (_, i) => `z${i + 1}`)])
for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten, `${p.atom.id} ragt über die Seite`)
})
test('paginiere: letzte Zeile, Summe und Hinweis bleiben zusammen, auch wenn die Zeile allein noch gepasst hätte', () => {
// Seite 1: Kopf (20) + 6 Zeilen (180) = 200 – Zeile 6 ist die letzte und hängt an Summe und Hinweis
const seiten = paginiere(tabelle(6, { mitSumme: true }), SEITEN)
assert.deepEqual(lage(seiten), [['kopf@0', 'z1@20', 'z2@50', 'z3@80', 'z4@110', 'z5@140'], ['kopf*@20', 'z6@40', 'summe@70', 'hinweis@100']])
})
test('paginiere: Kette größer als eine Seite wird zwischen Bausteinen getrennt, zu hoher Baustein steht allein', () => {
const kette = [baustein('a', 120, { haengtAnNaechstem: true }), baustein('b', 120, { haengtAnNaechstem: true }), baustein('c', 120)]
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']])
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdfLayout.test.js
Erwartet: FAIL („Cannot read properties of undefined“ bzw. „paginiere is not a function“).
- Step 3: Abschnitt „Layout“ in
pdf.jsschreiben
// ===== 3. Layout (rein, ohne pdfkit): Maße, Bausteine, Seitenaufteilung ===== mit den Konstanten und paginiere wie oben (Werte und Algorithmus sind verbindlich).
- Step 4: Lauf, erwartet PASS
Run: derselbe wie in Step 2. Erwartet: alle acht Tests bestehen.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add server/services/auftraege/pdf.js test/auftraegePdfLayout.test.js
git commit -m "AB-PDF: Maße und Seitenaufteilung mit wiederholtem Tabellenkopf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 7: Bausteine baueAtome
Files:
- Modify:
server/services/auftraege/pdf.js(Abschnitt 3) - Test:
test/auftraegePdfLayout.test.js
Interfaces:
- Consumes:
AuftragPdfDaten(Tasks 4 und 5),STIL,MASSE,SPALTEN,ZELLEN_RAND,INHALT_BREITE(Task 6). - Produces:
baueAtome(daten: AuftragPdfDaten, messe: (text: string, stil: object, breite: number) => number) → Baustein[]in Zeichenreihenfolge.messeliefert die Texthöhe in pt (inerzeugeAuftragPdfüberdoc.heightOfString, im Test eine Attrappe). Die Funktion ist rein.
Bausteine (typ und Nutzlast sind der Vertrag mit dem Zeichnen in Abschnitt 4; Höhe, Abstand und Kette wie angegeben):
id |
typ und Nutzlast |
Höhe | abstandDavor |
haengtAnNaechstem |
|---|---|---|---|---|
briefkopf |
briefkopf, masse: { titelY, projektY, projektHoehe, infoZeilen } |
projektY + projektHoehe + 4 |
0 | ja |
einleitung |
absatz, text, stil: 'text' |
messe(text, STIL.text, INHALT_BREITE) |
8 | nein |
leistung |
ueberschrift, text |
messe(text, STIL.ueberschrift, INHALT_BREITE) + 5 |
14 | ja |
tabellenkopf (Festpreis) |
tabellenkopf, spalten: beschriftung.spalten |
MASSE.kopfHoehe |
0 | ja |
zeile-1 … zeile-n |
zeile, zelle (die Position), kopf (der Baustein tabellenkopf) |
max(messe(beschreibung, STIL.text, SPALTEN[1] − 2 × ZELLEN_RAND), messe('X', STIL.text, SPALTEN[1])) + 2 × ZELLEN_RAND |
0 | nur die letzte Zeile |
summe (Festpreis) |
summe, label, betrag |
MASSE.summeHoehe |
0 | ja |
aufwand (statt Tabelle) |
absatz, text: aufwandText, stil: 'text', kasten: true |
messe(text, STIL.text, INHALT_BREITE − 2 × MASSE.kastenRand) + 2 × MASSE.kastenRand |
0 | ja |
kleinunternehmer |
absatz, stil: 'klein' |
messe(text, STIL.klein, INHALT_BREITE) |
8 | nein |
je Abschnitt <id>-kopf und <id> |
ueberschrift (wie leistung) und absatz (stil: 'text') |
wie leistung bzw. einleitung |
14 bzw. 0 | Kopf ja, Text nein |
agb |
absatz, stil: 'text' |
Textblock | 12 | nein |
ersetzt (nur wenn Text) |
absatz, stil: 'text' |
Textblock | 8 | nein |
gruss |
absatz, stil: 'text' |
Textblock | 14 | nein |
widerruf-titel (nur Privatperson) |
ueberschrift, stil: 'titel' |
messe(titel, STIL.titel, INHALT_BREITE) + 8 |
0 | ja; umbruchDavor: true |
widerruf-<i>-kopf, widerruf-<i>-<k> |
ueberschrift bzw. absatz (stil: 'text') |
wie oben | 14 bzw. 0 (erster Absatz) / 5 (weitere) | Kopf ja |
formular-titel |
ueberschrift (Stil STIL.ueberschrift) |
messe(…) + 5 |
20 | ja |
formular-einleitung |
absatz, stil: 'klein' |
Textblock | 0 | ja |
formular-<i> je Zeile |
absatz, stil: 'text' |
Textblock | 8 | ja, außer bei der letzten Zeile |
briefkopf.masse: infoZeilen = [[Nummer, nummer], [Datum, datum]] plus [Kundennummer, kundennummer] nur bei nicht leerer Kundennummer (Beschriftungen aus daten.beschriftung); adresseHoehe = messe(empfaenger.join('\n'), STIL.adresse, MASSE.adresseBreite); blockEnde = MASSE.adresseY + max(adresseHoehe, infoZeilen.length × MASSE.infoZeile); titelY = blockEnde + 22; projektY = titelY + STIL.titel.groesse × 1.2 + 6; projektHoehe = messe('<Projekt-Beschriftung>: <projekt>', STIL.projekt, INHALT_BREITE). Jeder Baustein hat zusätzlich die Standardfelder aus Task 6 (abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, wo nichts anderes steht). Die Reihenfolge ist die der Tabelle von oben nach unten; das Formular steht hinter der Belehrung. Die ganze Kette des Formulars (formular-titel bis zur letzten Zeile) bleibt dadurch zusammen.
- Step 1: Failing Tests anhängen
An test/auftraegePdfLayout.test.js anhängen:
// 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)
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdfLayout.test.js
Erwartet: die vier neuen Tests scheitern („baueAtome is not a function“), die acht aus Task 6 bestehen.
- Step 3:
baueAtomein Abschnitt 3 vonpdf.jsergänzen (exportiert)
Signatur, Tabelle und masse-Regeln wie oben.
- Step 4: Lauf, erwartet PASS
Run: derselbe wie in Step 2. Erwartet: alle zwölf Tests bestehen.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add server/services/auftraege/pdf.js test/auftraegePdfLayout.test.js
git commit -m "AB-PDF: Bausteine der Auftragsbestätigung mit Höhen und Zusammenhalt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 8: Textleser für PDF-Tests
Files:
- Modify:
test/auftraegePdfHilfe.js(Import ergänzen,seitenZahlundleseSeitenanhängen) - Test:
test/auftraegePdfLeser.test.js
Interfaces:
- Consumes:
ladeSchriften()(Task 2; der Test baut mit pdfkit direkt ein kleines PDF, unabhängig vom AB-Code). - Produces (Testhilfe):
seitenZahl(pdf: Buffer) → number(zählt/Type /Page, nicht/Type /Pages);leseSeiten(pdf: Buffer) → string[][](je Seite die Textzeilen in Zeichenreihenfolge, eine Zeile jetext()-Aufruf bzw. je umbrochene Zeile).
Warum ein eigener Leser: auf dem Host gibt es kein pdftotext, neue Abhängigkeiten sind verboten. pdfkit schreibt eingebettete Schriften als Glyphnummern der Teilmenge ([<0001 0002> -20 <0003>] TJ zwischen BT und ET); die Rückübersetzung steht in der ToUnicode-Tabelle der Schrift (bfrange-Einträge mit je einem Eintrag pro Glyphnummer). Der Leser sammelt alle Objekte (Ströme mit zlib.inflateSync, wenn /FlateDecode), baut je Schrift die Tabelle, ordnet die Schriftnamen der Seitenressourcen (/F1) den Schriftobjekten zu und liest je Seite den Inhaltsstrom. Er kennt nur, was pdfkit schreibt.
- Step 1: Failing Test schreiben
test/auftraegePdfLeser.test.js:
import { test } from 'node:test'
import assert from 'node:assert/strict'
import PDFDocument from 'pdfkit'
import { useTestEnv } from './support/testEnv.js'
import { leseSeiten, seitenZahl } from './auftraegePdfHilfe.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { ladeSchriften } = await import('../server/services/auftraege/pdf.js')
// Prüft die Testhilfe selbst an einem kleinen, direkt mit pdfkit gebauten PDF (unabhängig vom AB-Code)
function kleinesPdf(komprimieren) {
const { regular, semibold } = ladeSchriften()
const doc = new PDFDocument({ size: 'A4', compress: komprimieren, info: { CreationDate: new Date('2026-10-06T10:00:00.000Z') } })
doc.registerFont('Figtree', regular)
doc.registerFont('Figtree-SemiBold', semibold)
const teile = []
doc.on('data', (teil) => teile.push(teil))
const fertig = new Promise((resolve) => doc.on('end', () => resolve(Buffer.concat(teile))))
doc.font('Figtree').fontSize(12).text(`Müller & Söhne – ß € ${String.fromCodePoint(0x2212)}5`, 50, 50, { lineBreak: false })
doc.font('Figtree-SemiBold').fontSize(12).text('Zweite Zeile „fett“', 50, 80, { lineBreak: false })
doc.addPage().font('Figtree').fontSize(12).text('Seite zwei', 50, 50, { lineBreak: false })
doc.end()
return fertig
}
test('leseSeiten und seitenZahl: Text je Seite aus einem pdfkit-PDF, komprimiert und unkomprimiert', async () => {
for (const komprimieren of [true, false]) {
const pdf = await kleinesPdf(komprimieren)
assert.equal(seitenZahl(pdf), 2)
assert.deepEqual(leseSeiten(pdf), [[`Müller & Söhne – ß € ${String.fromCodePoint(0x2212)}5`, 'Zweite Zeile „fett“'], ['Seite zwei']])
}
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdfLeser.test.js
Erwartet: FAIL (leseSeiten und seitenZahl werden von der Testhilfe nicht exportiert).
- Step 3: Testhilfe ergänzen
In test/auftraegePdfHilfe.js ganz oben import { inflateSync } from 'node:zlib' einfügen und unten anhängen:
/** Anzahl Seiten eines PDFs (Seitenobjekte, nicht der Seitenbaum). */
export const seitenZahl = (pdf) => (pdf.toString('latin1').match(/\/Type \/Page(?![s\w])/g) || []).length
/**
* Text aus einem mit pdfkit erzeugten PDF (eingebettete Schriften, Identity-H, ToUnicode), je Seite eine Liste
* von Textzeilen in Zeichenreihenfolge. Nur für Tests: kennt genau das, was pdfkit schreibt.
*/
export function leseSeiten(pdf) {
const s = pdf.toString('latin1')
const objekte = new Map()
const re = /(\d+) 0 obj\n/g
let m
while ((m = re.exec(s))) {
const start = m.index + m[0].length
const ende = s.indexOf('\nendobj', start)
const koerper = s.slice(start, ende)
const strom = koerper.indexOf('\nstream\n')
let dict = koerper
let daten = null
if (strom >= 0) {
dict = koerper.slice(0, strom)
const laenge = Number(/\/Length (\d+)/.exec(dict)[1])
daten = pdf.subarray(start + strom + 8, start + strom + 8 + laenge)
if (/\/Filter \/FlateDecode/.test(dict)) daten = inflateSync(daten)
}
objekte.set(Number(m[1]), { dict, daten })
re.lastIndex = ende
}
const tabellen = new Map()
for (const [id, o] of objekte) {
const tu = /\/ToUnicode (\d+) 0 R/.exec(o.dict)
if (!/\/Subtype \/Type0/.test(o.dict) || !tu) continue
const tabelle = []
for (const r of objekte.get(Number(tu[1])).daten.toString('latin1').matchAll(/<([0-9a-f]{4})> <([0-9a-f]{4})> \[([^\]]*)\]/g)) {
let i = parseInt(r[1], 16)
for (const e of r[3].matchAll(/<([0-9a-f ]+)>/g)) tabelle[i++] = e[1].split(' ').map((h) => String.fromCharCode(parseInt(h, 16))).join('')
}
tabellen.set(id, tabelle)
}
const schriftNachName = new Map()
for (const o of objekte.values()) {
if (!/\/Font <</.test(o.dict)) continue
for (const f of o.dict.matchAll(/\/(F\d+) (\d+) 0 R/g)) schriftNachName.set(f[1], tabellen.get(Number(f[2])))
}
const seiten = []
for (const o of objekte.values()) {
if (!/\/Type \/Page(?![s\w])/.test(o.dict)) continue
const inhalt = objekte.get(Number(/\/Contents (\d+) 0 R/.exec(o.dict)[1])).daten.toString('latin1')
const zeilen = []
for (const bt of inhalt.matchAll(/BT\n([\s\S]*?)\nET/g)) {
const tabelle = schriftNachName.get(/\/(F\d+) [\d.]+ Tf/.exec(bt[1])?.[1])
let text = ''
for (const tj of bt[1].matchAll(/\[([^\]]*)\] TJ/g)) {
for (const h of tj[1].matchAll(/<([0-9a-f]+)>/g)) {
for (let i = 0; i < h[1].length; i += 4) text += tabelle[parseInt(h[1].slice(i, i + 4), 16)] ?? '\u{FFFD}'
}
}
zeilen.push(text)
}
seiten.push(zeilen)
}
return seiten
}
- Step 4: Lauf, erwartet PASS
Run: derselbe wie in Step 2. Erwartet: der eine Test besteht (komprimiert und unkomprimiert).
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add test/auftraegePdfHilfe.js test/auftraegePdfLeser.test.js
git commit -m "AB-PDF: Textleser für PDF-Tests (ToUnicode, nur Node-Bordmittel)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 9: PDF zeichnen – erzeugeAuftragPdf
Files:
- Modify:
server/services/auftraege/pdf.js(Importe oben ergänzen; Abschnitt 4 „Zeichnen“ und Abschnitt 5 „Ablauf“ anhängen) - Test:
test/auftraegePdf.test.js
Interfaces:
- Consumes:
auftragPdfDaten(Tasks 4 und 5),bereinigeTexte,ladeSchriften(Tasks 2 und 3),baueAtome,paginiere,BEREICHEund die Maße (Tasks 6 und 7), TesthilfeleseSeiten,seitenZahl,alleTexte,beispielAuftrag(Tasks 4 und 8). - Produces (
pdf.js, Abschnitt 4, modulintern):setzeStil(doc, stil) → doc(setzt SchriftFigtreebzw.Figtree-SemiBoldnachstil.schrift, Größe und Füllfarbe) undzeichneAuftrag(doc, daten: AuftragPdfDaten, seiten: Seite[], assets: ladeSchriften()) → void. - Produces (
pdf.js, Abschnitt 5, exportiert):erzeugeAuftragPdf(auftrag: Auftrag, { settings, vorschau = false, jetzt = new Date() } = {}) → Promise<Buffer>. WirftAuftragError(409) wieauftragPdfDaten; PDF-Fehler anderer Art werden unverändert weitergereicht.
Ablauf von erzeugeAuftragPdf (Algorithmus, den Signatur und Tests nicht bestimmen; neuer Import am Kopf von pdf.js: import PDFDocument from 'pdfkit'):
/** Das AB-PDF (A4) als Buffer. */
export async function erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date() } = {}) {
const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt }))
const assets = ladeSchriften()
const doc = new PDFDocument({
size: 'A4', margins: { top: 0, bottom: 0, left: 0, right: 0 }, lang: 'de-DE', displayTitle: true,
info: {
Title: `${daten.titel} ${daten.nummer}`, Author: 'WEBklar GbR', Subject: daten.projekt, Producer: 'WEBklar Kundenportal',
CreationDate: new Date(auftrag.gesendetAm || jetzt),
},
})
doc.registerFont('Figtree', assets.regular)
doc.registerFont('Figtree-SemiBold', assets.semibold)
const teile = []
doc.on('data', (teil) => teile.push(teil))
const fertig = new Promise((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(teile)))
doc.on('error', reject)
})
fertig.catch(() => {}) // scheitert das Zeichnen zuerst, soll das verworfene Versprechen nichts auslösen
try {
const messe = (text, stil, breite) => {
setzeStil(doc, stil)
return doc.heightOfString(text, { width: breite, lineGap: stil.lineGap })
}
const seiten = paginiere(baueAtome(daten, messe), BEREICHE)
zeichneAuftrag(doc, daten, seiten, assets)
doc.end()
} catch (fehler) {
doc.destroy()
throw fehler
}
return fertig
}
Zeichenregeln für Abschnitt 4 (// ===== 4. Zeichnen mit pdfkit =====; pt; links = SEITE.links, rechts = SEITE.breite − SEITE.rechts; „schreiben“ heißt setzeStil(doc, stil) und dann doc.text(text, x, y, { lineGap: stil.lineGap, …optionen }); „Linie“ heißt eine gerade Linie mit save/restore, Farbe und Breite wie genannt, Vorgabe FARBEN.linie und 0,5 pt; SPALTEN_X[i] = links plus die Breiten der Spalten davor). Jede Seite zeichnet in dieser Reihenfolge: Folgeseitenkopf (ab Seite 2), die Posten der Seite in y-Reihenfolge, Fuß. Seiten ab der zweiten legt doc.addPage() an (ohne Optionen, es gelten die Ränder 0 des Dokuments).
| Baustein | Zeichnen |
|---|---|
briefkopf (y = Oberkante) |
Logo assets.logo 34 pt breit bei (links, y). Wortmarke „WEBklar“ (festes Wort) SemiBold 20 pt, Tinte, characterSpacing: -0.4, lineBreak: false, bei (links + 43, y + 6). Absenderblock rechtsbündig in 190 pt Breite, rechter Rand = rechts: erste Zeile von absender.zeilen SemiBold 8,5 pt Tinte bei y, die übrigen mit \n verbunden in STIL.klein bei y + 13. Rücksendezeile absender.rueckzeile in STIL.klein mit 7 pt, underline: true, lineBreak: false bei (links, y + MASSE.rueckY). Anschrift empfaenger.join('\n') in STIL.adresse bei (links, y + MASSE.adresseY), Breite MASSE.adresseBreite. Angaben masse.infoZeilen: Zeile i bei yi = y + MASSE.adresseY + i × MASSE.infoZeile: Beschriftung STIL.klein bei (MASSE.infoX, yi + 1), Breite 90, lineBreak: false; Wert STIL.text mit schrift: 'semibold', rechtsbündig in [MASSE.infoX + 92, rechts] bei yi. Titel daten.titel in STIL.titel bei (links, y + masse.titelY); Projektzeile <beschriftung.projekt>: <projekt> in STIL.projekt bei (links, y + masse.projektY), beide Breite INHALT_BREITE. |
ueberschrift |
Text bei (links, y), Breite INHALT_BREITE, Stil STIL.titel wenn stil === 'titel', sonst STIL.ueberschrift. |
absatz |
STIL[atom.stil], bei (links, y), Breite INHALT_BREITE. Mit kasten: vorher ein abgerundetes Rechteck (Radius 4, Linie 0,5 pt, Füllung FARBEN.flaeche, Rand FARBEN.linie) über (links, y, INHALT_BREITE, atom.hoehe); der Text steht dann bei (links + MASSE.kastenRand, y + MASSE.kastenRand) in INHALT_BREITE − 2 × MASSE.kastenRand. |
tabellenkopf |
Fläche FARBEN.flaeche über (links, y, INHALT_BREITE, atom.hoehe); atom.spalten[i] in STIL.tabellenkopf bei y + 7, lineBreak: false: Spalte 0 bei SPALTEN_X[0] (Breite SPALTEN[0] − ZELLEN_RAND), Spalten ab 1 bei SPALTEN_X[i] + ZELLEN_RAND (Breite SPALTEN[i] − 2 × ZELLEN_RAND), Spalten 2 bis 4 rechtsbündig; unten Linie FARBEN.linieDunkel, 0,75 pt. |
zeile |
oben = y + ZELLEN_RAND, alles in STIL.text: zelle.nr bei (SPALTEN_X[0], oben), Breite SPALTEN[0] − ZELLEN_RAND; beschreibung bei (SPALTEN_X[1] + ZELLEN_RAND, oben), Breite SPALTEN[1] − 2 × ZELLEN_RAND; menge, einzelpreis, summe bei SPALTEN_X[2..4], Breite SPALTEN[i] − ZELLEN_RAND, rechtsbündig; unten Linie bei y + atom.hoehe. |
summe |
Linie FARBEN.tinte, 0,75 pt bei y; label rechtsbündig in [links, SPALTEN_X[4] − ZELLEN_RAND] und betrag rechtsbündig in Spalte 4 (Breite SPALTEN[4] − ZELLEN_RAND), beide STIL.summe bei y + 9, lineBreak: false. |
Folgeseitenkopf (bei y = SEITE.oben) |
Logo 16 pt breit bei (links, y); „WEBklar“ SemiBold 11 pt, characterSpacing: -0.2, bei (links + 22, y + 2); <titel> <nummer> in STIL.klein, rechtsbündig in 250 pt Breite bis rechts, bei y + 4; Linie bei y + 24. |
Fuß (bei y = SEITE.hoehe − MASSE.fussHoehe) |
Linie über die Inhaltsbreite bei y. Spalte „Bankverbindung“ (nur wenn fuss.bank nicht leer) bei links, Breite 175, Spalte „Kontakt“ bei links + 190, Breite 225: Beschriftung STIL.fussLabel bei y + 8 (lineBreak: false), Zeilen mit \n in STIL.fuss bei y + 19 mit height: 46 und ellipsis: true. „Seite i von n“ (<seite> <i> <von> <n> aus beschriftung) in STIL.fussLabel, rechtsbündig in 70 pt Breite bis rechts, bei y + 8, lineBreak: false. |
- Step 1: Failing Tests schreiben
test/auftraegePdf.test.js (Task 10 hängt zwei Tests an):
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { alleTexte, beispielAuftrag, beispielSettings, leseSeiten, seitenZahl } from './auftraegePdfHilfe.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { auftragPdfDaten, erzeugeAuftragPdf, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
const { AB_TEXTE, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } = await import('../server/services/auftraege/texte.js')
const { aufwandText } = await import('../server/services/auftraege/auftragSicht.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { euroDe } = await import('../server/services/finance/format.js')
const JETZT = new Date('2026-10-06T10:00:00.000Z')
const cp = (...codes) => String.fromCodePoint(...codes)
const FRUEHBEGINN_SATZ = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.' // V7: AB_TEXTE.termine bei Privatpersonen
const optionen = (extra = {}) => ({ settings: beispielSettings, jetzt: JETZT, ...extra })
const ohneLeerraum = (text) => text.replace(/\s+/g, '')
const gesamtText = (pdf) => leseSeiten(pdf).flat().join('\n')
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
const aufwand = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
/** Alles, was in den Daten steht und nicht nur Beschriftung oder Schlüssel ist, muss im PDF-Text vorkommen. */
function pruefeAlleTexteImPdf(pdf, auftrag, extra = {}) {
const daten = bereinigeTexte(auftragPdfDaten(auftrag, optionen(extra)))
const imPdf = ohneLeerraum(gesamtText(pdf))
const erwartet = alleTexte({ ...daten, beschriftung: null })
assert.ok(erwartet.length > 20)
for (const text of erwartet) assert.ok(imPdf.includes(ohneLeerraum(text)), `fehlt im PDF: ${text.slice(0, 70)}`)
}
test('Festpreis-AB: gültiges A4-PDF, jede Angabe aus den Daten steht im Text', async () => {
const auftrag = beispielAuftrag()
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
assert.ok(Buffer.isBuffer(pdf))
assert.equal(pdf.subarray(0, 5).toString('latin1'), '%PDF-')
assert.equal(pdf.subarray(-6).toString('latin1'), '%%EOF\n')
assert.match(pdf.toString('latin1'), /\/MediaBox \[0 0 595\.28 841\.89\]/)
const n = seitenZahl(pdf)
assert.ok(n >= 1)
assert.equal(leseSeiten(pdf).length, n)
pruefeAlleTexteImPdf(pdf, auftrag)
const text = gesamtText(pdf)
for (const erwartet of ['AB-2026-0007', '06.10.2026', 'K-1042', 'Steuernummer 12/345/67890', euroDe(60000), euroDe(80000), `Seite 1 von ${n}`]) {
assert.ok(text.includes(erwartet), erwartet)
}
assert.equal(text.includes('ENTWURF'), false)
assert.equal(text.includes('\u{FFFD}'), false)
})
test('Aufwand-AB: Aufwandsregel statt Tabelle', async () => {
const auftrag = beispielAuftrag(aufwand)
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
pruefeAlleTexteImPdf(pdf, auftrag)
const text = gesamtText(pdf)
assert.ok(ohneLeerraum(text).includes(ohneLeerraum(aufwandText(auftrag))))
assert.equal(text.includes('Einzelpreis'), false)
assert.equal(text.includes('Gesamtpreis'), false)
})
test('Privatperson: Widerrufsbelehrung und Muster-Widerrufsformular auf eigenen Seiten nach der AB', async () => {
const auftrag = beispielAuftrag(privat)
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
const seiten = leseSeiten(pdf)
const texte = seiten.map((zeilen) => zeilen.join('\n'))
const index = seiten.findIndex((zeilen) => zeilen.includes(WIDERRUFSBELEHRUNG_PROJEKT.titel))
assert.ok(index >= 1, 'die Belehrung steht nicht auf Seite 1')
assert.equal(seiten[index][2], WIDERRUFSBELEHRUNG_PROJEKT.titel, 'sie beginnt oben, direkt unter dem Seitenkopf (WEBklar, Titel und Nummer)')
assert.ok(texte.slice(index).join('\n').includes(WIDERRUFSFORMULAR.titel))
assert.ok(ohneLeerraum(texte.join('')).includes(ohneLeerraum(FRUEHBEGINN_SATZ)), 'Satz zum Beginn nach Ablauf der Widerrufsfrist')
pruefeAlleTexteImPdf(pdf, auftrag)
texte.forEach((text, i) => assert.ok(text.includes(`Seite ${i + 1} von ${texte.length}`)))
const unternehmen = gesamtText(await erzeugeAuftragPdf(beispielAuftrag(), optionen()))
assert.equal(unternehmen.includes(WIDERRUFSFORMULAR.titel), false)
assert.equal(ohneLeerraum(unternehmen).includes(ohneLeerraum(FRUEHBEGINN_SATZ)), false, 'bei Unternehmen steht der Satz nicht im PDF')
})
test('Umlaute, ß, €, Minuszeichen, Gedankenstrich und Anführungszeichen kommen lesbar im PDF an', async () => {
const name = `Jörg Müßig${cp(0x2212)}Åström`
const titel = `Übergabe „Größe“ ${cp(0x2013)} 3${cp(0xa0)}€ ${cp(0x2212)}5${cp(0xa0)}€`
const auftrag = beispielAuftrag({ titel, kunde: { ...beispielAuftrag().kunde, name, firma: '' } })
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
const text = gesamtText(pdf)
assert.ok(text.includes(name), name)
assert.ok(ohneLeerraum(text).includes(ohneLeerraum(titel)), titel)
assert.equal(text.includes('\u{FFFD}'), false)
})
test('Zeichen ohne Glyph werden ersetzt statt als Kästchen gedruckt', async () => {
const auftrag = beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, firma: '', name: `${cp(0x411, 0x43e, 0x440)} Łukasz`, strasse: `Hauptstraße${cp(0x202f)}12` } })
const text = gesamtText(await erzeugeAuftragPdf(auftrag, optionen()))
assert.ok(text.includes('??? Łukasz'))
assert.ok(text.includes('Hauptstraße 12'))
assert.equal(text.includes('\u{FFFD}'), false)
})
test('20 Positionen mit 200 Zeichen ohne Leerzeichen: mehrere Seiten, Tabellenkopf auf jeder Seite mit Positionen, nichts geht verloren', async () => {
const baustein = 'ÄÖÜäöü߀' + cp(0x2212)
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: `P${i + 1}${baustein.repeat(30)}`.slice(0, 200), menge: i + 1, einzelpreisCents: 1000 + i }))
const summeCents = positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
const auftrag = beispielAuftrag({ positionen, summeCents, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
const n = seitenZahl(pdf)
const seiten = leseSeiten(pdf).map((zeilen) => zeilen.join('\n'))
assert.ok(n >= 2, `Seiten: ${n}`)
assert.equal(seiten.length, n)
const alles = ohneLeerraum(seiten.join('\n'))
for (const p of positionen) assert.ok(alles.includes(ohneLeerraum(p.beschreibung)), p.beschreibung.slice(0, 12))
assert.ok(alles.includes(ohneLeerraum(euroDe(summeCents))), 'Gesamtpreis')
seiten.forEach((text, i) => {
if (/P\d+/.test(text)) assert.ok(text.includes('Beschreibung') && text.includes('Einzelpreis'), `Seite ${i + 1} ohne Tabellenkopf`)
assert.ok(text.includes(`Seite ${i + 1} von ${n}`))
assert.equal(text.includes('\u{FFFD}'), false)
})
assert.ok(seiten.find((text) => text.includes('P20')).includes('Gesamtpreis'), 'Summe steht auf der Seite der letzten Position')
})
test('mehrere PDFs gleichzeitig: Schriften sind geteilt, nichts vermischt sich', async () => {
const nummern = [1, 2, 3, 4, 5].map((i) => `AB-2026-000${i}`)
const pdfs = await Promise.all(nummern.map((nummer) => erzeugeAuftragPdf(beispielAuftrag({ nummer }), optionen())))
pdfs.forEach((pdf, i) => {
const text = gesamtText(pdf)
assert.ok(text.includes(nummern[i]))
assert.equal(nummern.filter((n) => text.includes(n)).length, 1)
})
})
test('unvollständige AB: AuftragError 409 statt halbem PDF', async () => {
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ titel: '' }), optionen()), (e) => e instanceof AuftragError && e.status === 409)
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ kunde: null }), optionen({ vorschau: true })), (e) => e instanceof AuftragError && e.status === 409)
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdf.test.js
Erwartet: alle acht Tests scheitern („erzeugeAuftragPdf is not a function“).
- Step 3: Abschnitte „Zeichnen“ und „Ablauf“ in
pdf.jsschreiben
Abschnitt 4 nach den Zeichenregeln (Reihenfolge und Maße wie in der Tabelle), Abschnitt 5 (// ===== 5. Ablauf =====) wie im Block oben. Hinweise zu pdfkit 0.20:
-
doc.registerFont(name, buffer)nimmt den Buffer ausladeSchriften(); pdfkit parst ihn je Dokument (rund 40 KB, vernachlässigbar), gelesen wird er nur einmal je Prozess. -
Wörter, die breiter als die Spalte sind (200 Zeichen ohne Leerzeichen), bricht pdfkits Zeilenumbruch zeichenweise um; das braucht keinen Sonderfall,
heightOfStringmisst es richtig. -
doc.textbricht\nin Absätzen selbst; Texte mit Zeilenumbruch (Anschrift, Fußzeilen, Gruß) brauchen nur einen Aufruf. -
Das Dokument ist ein
Readable:data-Ereignisse einsammeln,endabwarten, erst nach dem Zeichnendoc.end(). Scheitert das Zeichnen,doc.destroy()und den Fehler weiterwerfen. -
doc.image(assets.logo, x, y, { width })nimmt den PNG-Buffer (RGBA mit Transparenz); es gibt keinen Bilder-Cache je Prozess, das Logo hat 5 KB. -
info.CreationDatefest setzen (gesendetAmbzw.jetzt), damit das PDF zu seinem Inhalt passt und nicht zur Uhrzeit des Zeichnens. -
Step 4: Lauf, erwartet PASS
Run: derselbe wie in Step 2. Erwartet: alle acht Tests bestehen.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add server/services/auftraege/pdf.js test/auftraegePdf.test.js
git commit -m "AB-PDF: erzeugeAuftragPdf zeichnet Briefkopf, Tabelle über mehrere Seiten, Belehrung und Fuß mit pdfkit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 10: Vorschau-Kennzeichnung und ersetzte AB
Files:
- Modify:
server/services/auftraege/pdf.js(Abschnitte 4 und 5) - Test:
test/auftraegePdf.test.js,test/auftraegePdfErsetzt.test.js
Interfaces:
- Consumes:
erzeugeAuftragPdf(Task 9); L0getAuftrag(id) → Auftrag|nullausauftragStore.js(liest fehlende Felder tolerant, Übersicht V4 und L0-Plan);startAppwriteMockundschemaKeys('auftraege')austest/support. - Produces:
erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date(), ersetzt } = {})mit dem Schlüsselersetzt(seit der Planrunde in V4):{ nummer, datum /* 'YYYY-MM-DD' */ }(diese AB wird ersetzt),null(kein Hinweis, kein Lesen) oder weggelassen (hatauftrag.ersetztIdeinen Wert, liest die Funktion die alte AB übergetAuftragund nimmtnummerund den Berliner Tag vongesendetAm). Findet sie die alte AB nicht (oder ohne Nummer oder Sendedatum), wirft sieAuftragError(409, 'Die ersetzte Auftragsbestätigung wurde nicht gefunden.'): ein Hinweis, der sich auf eine unbekannte AB bezieht, darf nicht still entfallen. Hintergrund: Vertragsfrage 1 (entschieden).
Vorschau-Kennzeichnung (Abschnitt 4, vor allem anderen auf jeder Seite, wenn daten.vorschau): Wasserzeichen beschriftung.entwurf („ENTWURF“) in SemiBold 110 pt, Farbe FARBEN.entwurf, Deckkraft 0,07 (fillOpacity), um −35° um die Seitenmitte gedreht (rotate(-35, { origin })), um die Seitenmitte zentriert (x = mitteX − widthOfString/2, y = mitteY − 110 × 1,2 / 2), lineBreak: false, in save()/restore(); danach der Kopfvermerk beschriftung.entwurfHinweis in SemiBold 8 pt FARBEN.entwurf, mittig in INHALT_BREITE bei (links, 22), lineBreak: false. Die Daten (nummer: 'Entwurf', heutiges Datum) sind schon aus Task 4/5 da.
Änderung in pdf.js (der Rest von erzeugeAuftragPdf bleibt):
async function ladeErsetzte(id) {
const { getAuftrag } = await import('./auftragStore.js')
const alt = await getAuftrag(id)
if (!alt?.nummer || !alt.gesendetAm) throw new AuftragError(409, 'Die ersetzte Auftragsbestätigung wurde nicht gefunden.')
return { nummer: alt.nummer, datum: berlinDate(new Date(alt.gesendetAm)) }
}
/** Das AB-PDF (A4) als Buffer. */
export async function erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date(), ersetzt } = {}) {
const alt = ersetzt !== undefined ? ersetzt : auftrag.ersetztId ? await ladeErsetzte(auftrag.ersetztId) : null
const daten = bereinigeTexte(auftragPdfDaten(auftrag, { settings, vorschau, jetzt, ersetzt: alt }))
// … der Rest wie in Task 9: Schriften laden, Dokument anlegen, messen, paginieren, zeichnen, beenden
- Step 1: Failing Tests schreiben
An test/auftraegePdf.test.js anhängen:
test('Vorschau: ENTWURF und Hinweis auf jeder Seite, Nummer „Entwurf“', async () => {
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ nummer: '', gesendetAm: null }), optionen({ vorschau: true }))
const seiten = leseSeiten(pdf)
assert.ok(seiten.length >= 1)
for (const zeilen of seiten) {
const text = zeilen.join('\n')
assert.ok(text.includes('ENTWURF'))
assert.ok(text.includes('Entwurf – Vorschau, noch keine gültige Auftragsbestätigung'))
}
assert.ok(gesamtText(pdf).includes('Entwurf'))
})
test('ersetzte AB: Hinweis mit Nummer und Datum der alten AB im PDF', async () => {
const pdf = await erzeugeAuftragPdf(beispielAuftrag(), optionen({ ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }))
assert.ok(ohneLeerraum(gesamtText(pdf)).includes(ohneLeerraum(AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))))
})
Neue Datei test/auftraegePdfErsetzt.test.js (der Appwrite-Nachbau mit dem Schema aus L0 liefert die alte AB; die Umgebung steht vor dem ersten Import von config.js):
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { beispielAuftrag, beispielSettings, leseSeiten } from './auftraegePdfHilfe.js'
// Die alte AB liegt im Appwrite-Nachbau (Schema aus L0); erzeugeAuftragPdf liest sie über getAuftrag aus auftragStore.js
const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') } })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { erzeugeAuftragPdf } = await import('../server/services/auftraege/pdf.js')
const { AB_TEXTE } = await import('../server/services/auftraege/texte.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const optionen = { settings: beispielSettings, jetzt: new Date('2026-10-06T10:00:00.000Z') }
const ohneLeerraum = (text) => text.replace(/\s+/g, '')
const text = (pdf) => ohneLeerraum(leseSeiten(pdf).flat().join(''))
const hinweis = ohneLeerraum(AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))
aw.seed('auftraege', { $id: 'alt1', kundenId: 'k1', status: 'versendet', nummer: 'AB-2026-0003', gesendetAm: '2026-10-01T09:00:00.000+00:00' })
test('ersetztId ohne Option: die alte AB wird gelesen, ihre Nummer und ihr Berliner Datum stehen im PDF', async () => {
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'alt1' }), optionen)
assert.ok(text(pdf).includes(hinweis))
})
test('ersetztId verweist auf eine unbekannte AB: AuftragError 409 statt PDF ohne den Hinweis', async () => {
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'gibtsnicht' }), optionen), (e) => e instanceof AuftragError && e.status === 409)
})
test('ersetzt: null unterdrückt Hinweis und Lesen, auch bei gesetztem ersetztId', async () => {
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'gibtsnicht' }), { ...optionen, ersetzt: null })
assert.equal(text(pdf).includes(hinweis), false)
})
- Step 2: Lauf, erwartet FAIL
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegePdf.test.js test/auftraegePdfErsetzt.test.js
Erwartet: Vorschau …, ersetzte AB … und die ersten beiden Tests in auftraegePdfErsetzt.test.js scheitern (der dritte besteht schon, er sichert nur, dass null nichts liest); die acht Tests aus Task 9 bestehen.
- Step 3: Wasserzeichen und
ersetztumsetzen
zeichneAuftrag ruft vor allem anderen auf jeder Seite die Vorschau-Kennzeichnung auf, wenn daten.vorschau. erzeugeAuftragPdf löst ersetzt wie im Block oben auf; ladeErsetzte importiert ./auftragStore.js dynamisch (so bleibt pdf.js ohne Appwrite ladbar, und L3 braucht für Tests nur Attrappen).
- Step 4: Lauf, erwartet PASS
Run: derselbe wie in Step 2. Erwartet: zehn Tests in auftraegePdf.test.js und drei in auftraegePdfErsetzt.test.js bestehen.
- Step 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git add server/services/auftraege/pdf.js test/auftraegePdf.test.js test/auftraegePdfErsetzt.test.js
git commit -m "AB-PDF: Entwurfs-Kennzeichnung für die Vorschau und Hinweis auf die ersetzte AB
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 11: Selbstprüfung
Files:
- keine neuen; nur Prüfen, Korrigieren (eigene Dateien) und ein Commit, falls etwas zu korrigieren war.
Interfaces:
-
Consumes: alle Ergebnisse der Tasks 2 bis 10.
-
Produces: grüne eigene Tests, abgehakte Vertragsliste, Beispiel-PDFs für die Kontrolle in Strang Z.
-
Step 1: Eigene Tests (Host, Node 24)
Vorher free -m (unter 1500 MB „available“ warten).
Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegePdf*.test.js
Erwartet: # fail 0; 48 Tests in sechs Dateien (8 + 14 + 12 + 1 + 10 + 3).
- Step 2: Dieselben Tests unter Node 20 (Version des Containers)
free -m | head -2
flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock \
timeout 600 docker run --rm --network none -m 900m \
-v /home/webklar/apps/kundenbereich.webklar.com:/w:ro -w /w/dev-auftraege-l4 \
node:20-alpine node --max-old-space-size=512 --test --test-concurrency=1 test/auftraegePdf*.test.js 2>&1 | tail -15
Erwartet: # fail 0. (Der Symlink node_modules zeigt relativ auf ../dev-auftraege/node_modules und löst im Container über den eingehängten Elternordner auf.)
-
Step 3: Vertragsliste abhaken
-
pdf.jsexportiertauftragPdfDaten(auftrag, { settings, vorschau = false })(reine Funktion) underzeugeAuftragPdf(auftrag, { settings, vorschau = false }) → Promise<Buffer>(V4);jetztundersetztstehen seit der Planrunde in V4. -
settings = { steuernummer, bankDetailsText }: Steuernummer im Briefkopf (Zeile entfällt, wenn leer), Bankverbindung im Fuß. -
Schrift: nur Figtree Regular und SemiBold, statisch, in
server/assets/fonts/mitFigtree-OFL.txtundQUELLE.txt; Logopublic/webklar-logo.png; beides einmal je Prozess geladen. -
Inhalt laut Spec Abschnitt 2 „Inhalt des PDFs“: Briefkopf (Logo, WEBklar GbR, Anschrift, Kontakt, Steuernummer), Empfänger, Titel/Nummer/Datum/Projekt/Kundennummer, Einleitung, Positionen mit Menge/Einzelpreis/Summe und Gesamtpreis oder Aufwandsregel, § 19-Hinweis, Zahlungsplan mit Beträgen, Termine, Mitwirkung und Abnahme, AGB-Satz mit Fassung, ersetzt-Hinweis, bei Privatperson Widerrufsbelehrung und Muster-Widerrufsformular auf neuer Seite, Fuß mit Bankverbindung, Kontakt und „Seite X von Y“, bei Vorschau Wasserzeichen und Kopfvermerk „Entwurf“.
-
Namen aus V7 verwendet:
ABSENDER,AB_TEXTE.{titel, einleitung, kleinunternehmer, termine, mitwirkung, agb, ersetzt, gruss},WIDERRUFSBELEHRUNG_PROJEKT,WIDERRUFSFORMULAR; aus V3:zahlungsplanText,aufwandText; kein eigener Wortlaut außer den Beschriftungen inPDF_BESCHRIFTUNG. -
AB_TEXTE.termine({ start, fertig, privat })bekommtprivat = (kunde.kundenart === 'privat'): bei Privatpersonen steht der Abschnitt „Termine“ mit dem Satz „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“ im PDF (auch ohne Daten), bei Unternehmen nicht (Tests in Task 5 und Task 9). -
Global Constraints der Übersicht: Beträge über
euroDe, DatumTT.MM.JJJJnach Berliner Tag, kein Chrome, Tests nur mit Nachbauten, keine IP-Adressen, Commits klein mit Schlusszeile, kein Push, keingit add -A. -
Review Focus 1 bis 5 durch grüne Tests belegt (Task 3, 4, 5, 6, 7, 9).
-
Die Vertragsfragen am Ende dieser Datei tragen den Entscheidungsstand der Übersicht (Abschnitt „Nachträge nach der Planrunde“); nichts davon ist noch offen.
-
Step 4: Nur eigene Dateien geändert
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git diff --name-only feature/auftraege...HEAD | command grep -a -v -E '^(server/services/auftraege/pdf\.js|server/assets/fonts/(Figtree-Regular\.ttf|Figtree-SemiBold\.ttf|Figtree-OFL\.txt|QUELLE\.txt)|test/auftraegePdf[A-Za-z]*\.(test\.)?js)$'
Erwartet: keine Ausgabe.
- Step 5: Beispiel-PDFs schreiben und ansehen
Skript /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-pdf/erzeuge.mjs (nur Scratchpad, nicht ins Repo):
import { mkdirSync, writeFileSync } from 'node:fs'
const WT = '/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4'
const OUT = new URL('./', import.meta.url)
const { beispielAuftrag, beispielSettings } = await import(`${WT}/test/auftraegePdfHilfe.js`)
const { erzeugeAuftragPdf } = await import(`${WT}/server/services/auftraege/pdf.js`)
const kunde = beispielAuftrag().kunde
const positionen = Array.from({ length: 20 }, (_, i) => ({
beschreibung: `Position ${i + 1}: ${i % 3 === 0 ? 'X'.repeat(190) : 'Ausführliche Beschreibung der Leistung mit Umlauten äöüß und Zeichen wie € – „Test“. '.repeat(2)}`,
menge: i + 1,
einzelpreisCents: 1000 + i,
}))
const aufwand = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
const faelle = {
festpreis: [beispielAuftrag(), {}],
'festpreis-20-positionen': [beispielAuftrag({ positionen, summeCents: positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0), zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }), {}],
aufwand: [beispielAuftrag(aufwand), {}],
privat: [beispielAuftrag({ kunde: { ...kunde, firma: '', name: 'Jörg Müßig-Åström', kundenart: 'privat' } }), {}],
vorschau: [beispielAuftrag({ nummer: '', gesendetAm: null }), { vorschau: true }],
}
mkdirSync(OUT, { recursive: true })
for (const [name, [auftrag, optionen]] of Object.entries(faelle)) {
const start = Date.now()
const pdf = await erzeugeAuftragPdf(auftrag, { settings: beispielSettings, ersetzt: null, ...optionen })
writeFileSync(new URL(`${name}.pdf`, OUT), pdf)
console.log(name, pdf.length, 'Bytes', Date.now() - start, 'ms')
}
console.log('Speicher: Heap', Math.round(process.memoryUsage().heapUsed / 1e6), 'MB, RSS', Math.round(process.memoryUsage().rss / 1e6), 'MB')
S=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-pdf
mkdir -p $S && cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4 && node --max-old-space-size=256 $S/erzeuge.mjs
free -m | head -2
flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock \
timeout 300 docker run --rm --network none -m 700m --user 0:0 -v $S:/pdf --entrypoint sh \
ghcr.io/paperless-ngx/paperless-ngx:2.20.15 -c 'cd /pdf && for f in *.pdf; do b=${f%.pdf}; pdfinfo "$f" | grep -E "Pages|Page size"; pdftoppm -png -r 70 "$f" "$b"; pdftotext -layout "$f" "$b.txt"; done; pdffonts festpreis.pdf'
ls $S
Die Ausgabe des Skripts zeigt je PDF die Dauer und am Ende den Speicher: erwartet weniger als 1000 ms je Datei und Heap unter 100 MB (Referenzstand: 45 bis 115 ms, 27 MB); die Erzeugung darf den Server nicht belasten. Das Paperless-Image enthält pdftoppm, pdftotext, pdfinfo und pdffonts (Poppler); fehlt es, bleiben es die PDFs (Hinweis in der Übergabe). pdffonts darf nur Figtree-Regular und Figtree-SemiBold zeigen (eingebettet, Teilmenge, Unicode-Tabelle: yes yes yes), kein Helvetica. Die PNGs (festpreis-1.png, festpreis-20-positionen-1.png bis -4.png, aufwand-1.png, privat-1.png, -2.png, vorschau-1.png) mit dem Lesewerkzeug ansehen und prüfen: Briefkopf mit Logo, Absender und Steuernummer rechts; Anschrift unter der Rücksendezeile; Tabelle mit Kopf auf jeder Folgeseite; Summe bei der letzten Position; Fuß mit „Seite i von n“; Privatperson: Belehrung beginnt oben auf einer neuen Seite; Vorschau: diagonales „ENTWURF“ und roter Kopfvermerk; kein Text über den Rand, keine leeren Kästchen. Fällt etwas auf: in den eigenen Dateien korrigieren (Test dazu) und committen.
- Step 6: Abschluss
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l4
git status --short
git log --oneline feature/auftraege..HEAD
Erwartet: git status zeigt nichts (der Symlink node_modules ist über .gitignore ignoriert; steht dort ?? node_modules, fehlt L0s Eintrag: dem Orchestrator melden); git log zeigt die Commits der Tasks 2 bis 10 (und ggf. Korrekturen aus Step 5). Übergabe an Strang Z: Pfad der Beispiel-PDFs (…/scratchpad/auftraege-pdf/), die Vertragsfragen unten, Commit-Stand.
Vertragsfragen
Alle Punkte sind in der Planrunde entschieden oder bestätigt; maßgeblich ist die Übersicht (Abschnitt „Nachträge nach der Planrunde“, V4, V5, V7). Dieser Plan ist darauf angepasst. Stand je Punkt:
- Der „ersetzt“-Hinweis braucht Nummer und Datum der alten AB: entschieden (siehe Übersicht, Nachträge). V4 führt
ersetzt = { nummer, datum }als Option vonauftragPdfDatenunderzeugeAuftragPdf; ohne sie liest L4 die alte AB übergetAuftrag(auftrag.ersetztId). Umsetzung: Task 5 und Task 10. - Optionaler Schlüssel
jetzt: entschieden (siehe Übersicht, Nachträge), steht in V4. Er bestimmt das Datum der AB, wenngesendetAmfehlt (Vorschau), und dasCreationDateder PDF-Metadaten. L3 übergibt beim Sendennummer,gesendetAmundagbFassungim Objekt,jetztgreift dort nicht. IstagbFassungleer, giltAGB_FASSUNG(wie beiagbUrlin V3). - Die 409-Klartexte von L4: entschieden (siehe Übersicht, Nachträge). L3 reicht
AuftragErrorauserzeugeAuftragPdfunverändert durch (andere Fehler werden 500), und V5 nennt fürGET …/auftraege/:id/pdf404 und 409 „Entwurf unvollständig“. L4 wirft die Meldungen wie in Task 4 beschrieben. - Parameter der Textfunktionen: entschieden (siehe Übersicht, Nachträge; V7 führt
privatinAB_TEXTE.termine):wegist das deutsche Wort ausAUFTRAG_WEGE,schaetzungein Stundentext, Beträge sindeuroDe-Texte, undAB_TEXTE.termine({ start, fertig, privat })bekommtprivatvon L4. Bei Privatpersonen lieferttermineimmer Text (mit dem Satz „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“), bei Unternehmennull, wenn beide Daten fehlen. Tests: Task 4, Task 5 (Termine: Privatpersonen …) und Task 9 (Privatperson: …). - Zeichenvorrat der Texte (L7): keine Entscheidung nötig, bleibt als Hinweis an L7. Es zählen nur Zeichen, die Figtree Regular und SemiBold abdecken (392 Codepunkte: Basic Latin, Latin-1, Latin Extended-A und Teile von B, „ “ ‚ ‘ – — … € − • → u. a.). Nicht enthalten sind unter anderem U+202F und U+2009 (schmale Leerzeichen), Häkchen, Emoji und kyrillische Schrift. Der Wächter-Test in Task 5 schlägt dann an; L7 soll
node --test test/auftraegePdfDaten.test.jsnach jeder Textänderung laufen lassen. Im PDF selbst ersetztbereinigeTextein fehlendes Zeichen still (der Wortlaut ist dann nicht wie freigegeben). WIDERRUFSFORMULAR.zeilen: keine Entscheidung nötig, bleibt als Hinweis an L7. L4 druckt jede Zeile als eigenen Absatz unverändert (ein führendes „–“ muss intexte.jsstehen), ohne Ausfüllfelder oder Linien.- Leere Steuernummer: entschieden (siehe Übersicht, Nachträge). Die Zeile entfällt im PDF (kein Platzhalter); W2 zeigt im AB-Dialog einen Hinweis.
fontkit: entschieden (siehe Übersicht, Nachträge). L0 trägtfontkitals direkte Abhängigkeit inpackage.jsonein (Version wie die pdfkit-Abhängigkeit); L4 importiert es direkt. Task 1 prüft den Eintrag.- Schrift- und Logo-Pfade der Spec: entschieden (siehe Übersicht, Nachträge). Die Spec nennt jetzt „statische Figtree-Schnitte (OFL) unter
server/assets/fonts/, Logopublic/webklar-logo.png“; dieser Plan setzt genau das um. - Testhilfe und Symlink: entschieden (siehe Übersicht, Nachträge). Testhilfen ohne
.test.jsgehören dem anlegenden Strang,test/auftraegePdfHilfe.jsalso L4. L0 trägtnode_modulesohne Schrägstrich in.gitignoreein, damit erscheint der Symlink der Lane-Worktrees nicht mehr ingit status(Task 1 und Task 11 erwarten eine leere Ausgabe). - Schriftquelle und Lizenz: entschieden (siehe Übersicht, Nachträge): statische Figtree-TTFs aus
@expo-google-fonts/figtree(OFL) unterserver/assets/fonts/. Das Paket ist als „MIT AND OFL-1.1“ lizenziert (MIT für das Paket, OFL für die Schriften); mitgeliefert wird nur der OFL-Text der Schrift (Figtree-OFL.txt). Bei einer neueren Figtree-Fassung ändern sich Prüfsummen und Glyphenmenge; Test undQUELLE.txtdann gemeinsam anpassen. - Test-Nachbau: bestätigt durch den L0-Plan, keine Entscheidung nötig: Task 10 braucht
schemaKeys('auftraege')(test/support/schema.js) undgetAuftrag(auftragStore.js); Task 1 prüft sie.