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>
1646 lines
111 KiB
Markdown
1646 lines
111 KiB
Markdown
# 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) oder `String.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 Flag `u`.
|
||
- **pdfkit:** `import PDFDocument from 'pdfkit'` nur in `pdf.js`. Seitenränder von pdfkit auf 0 (`margins: { top: 0, bottom: 0, left: 0, right: 0 }`); die Geometrie steht allein im Abschnitt „Layout“ von `pdf.js`. Jeder Text wird mit `width` gemessen und gezeichnet (ohne `width` nimmt 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/` (mit `Figtree-OFL.txt` daneben) und `public/webklar-logo.png`; alles wird über `ladeSchriften()` 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äuft `bereinigeTexte` über die Daten. Die Uhr kommt als Parameter `jetzt` (Vorgabe `new Date()`), nie als `Date.now()` im Funktionskörper.
|
||
- **Wortlaute** nur aus `texte.js` und `auftragSicht.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`, `kleinunternehmer` und der Satz „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“, den `AB_TEXTE.termine` bei Privatpersonen liefert).
|
||
- **Keine neuen Abhängigkeiten** (kein devDependency, kein PDF-Werkzeug in Tests; der Textleser der Tests nutzt nur `node:zlib`). `package.json` bleibt unberührt.
|
||
- **Dateien:** nur die der Tabelle unten; je Task ein Commit mit einzeln genannten Dateien; kein Push.
|
||
|
||
## Review Focus
|
||
|
||
1. **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 …`).
|
||
2. **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 …`).
|
||
3. **Texte aus `texte.js` mit 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 …`).
|
||
4. **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: …`).
|
||
5. **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` (Branch `auftraege/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 in `node_modules` und in `dependencies`.
|
||
- Produces: den Arbeitsplatz für alle folgenden Tasks.
|
||
|
||
- [ ] **Step 1: Worktree und Symlink anlegen**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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; `…Font` sind 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`):
|
||
|
||
```js
|
||
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.js` schreiben**
|
||
|
||
Dateien holen (`npm pack` lädt nur das Paket in den Scratchpad, kein `npm install`):
|
||
|
||
```bash
|
||
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:
|
||
|
||
```text
|
||
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'` und `const fontkit = fontkitModul.default ?? fontkitModul` (die ESM-Fassung von fontkit 2.0.4 hat nur benannte Exporte, `create` inklusive).
|
||
- Pfade relativ zur Datei: `new URL('../../assets/fonts/', import.meta.url)` und `new URL('../../../public/webklar-logo.png', import.meta.url)`.
|
||
- `ladeSchriften()` liest beim ersten Aufruf beide TTFs und das Logo als Buffer, erzeugt mit `fontkit.create(buffer)` die beiden Schriften und merkt sich das eingefrorene Ergebnis in einer Modulvariablen.
|
||
- `hatGlyph(codePoint)` fragt `hasGlyphForCodePoint` an 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**
|
||
|
||
```bash
|
||
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 durch `bereinigeText`; Zahlen, `null`, Wahrheitswerte bleiben; liefert neue Objekte, ändert die Eingabe nicht).
|
||
|
||
**Ersatzregel** (Review Focus 1 und 2), in dieser Reihenfolge:
|
||
|
||
1. `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.
|
||
2. Je Codepunkt: `\n` oder `hatGlyphFn(codePoint)` → unverändert.
|
||
3. Sonst steht der Codepunkt in `ERSATZ` → dessen Ersatz.
|
||
4. Sonst Leerzeichen-Art (`\p{Zs}`) → ein Leerzeichen.
|
||
5. Sonst kombinierendes Zeichen (`\p{M}`) → entfällt.
|
||
6. Sonst erster Codepunkt von `zeichen.normalize('NFD')`, falls er sich vom Zeichen unterscheidet und ein Glyph hat (ğ → g, Å → A) → dieser Buchstabe.
|
||
7. Sonst `?`.
|
||
|
||
```js
|
||
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:
|
||
|
||
```js
|
||
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: `bereinigeText` und `bereinigeTexte` in Abschnitt 1 von `pdf.js` ergä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**
|
||
|
||
```bash
|
||
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ächst `PDF_BESCHRIFTUNG` und `auftragPdfDaten`)
|
||
- Create: `test/auftraegePdfHilfe.js` (Beispiel-Auftrag und `alleTexte`; 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), `jetzt` und `ersetzt = { nummer, datum /* 'YYYY-MM-DD' */ }` stehen seit der Planrunde in V4 (Vertragsfragen 1 und 2: entschieden). Die Funktion ist rein und wirft nur `AuftragError`.
|
||
|
||
`PDF_BESCHRIFTUNG` (eingefroren, in `pdf.js` exportiert):
|
||
|
||
```js
|
||
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:
|
||
|
||
```js
|
||
{
|
||
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`, und `Steuernummer <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.land` nur, 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] })` – `weg` ist der **Anzeigetext** („telefonisch“), nicht der Schlüssel (Vertragsfrage 4).
|
||
- `leistung` Festpreis: `positionen` aus `auftrag.positionen` (`nr` ab `'1'`, `beschreibung` getrimmt, `menge` als Text, `einzelpreis` und `summe = menge × einzelpreisCents` über `euroDe`), `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):
|
||
|
||
```js
|
||
/** 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):
|
||
|
||
```js
|
||
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.js` schreiben (Felder bis `kleinunternehmer`)**
|
||
|
||
Ü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**
|
||
|
||
```bash
|
||
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: `auftragPdfDaten` aus Task 4; `bereinigeTexte` aus Task 3 (nur im Wächter-Test).
|
||
- Produces: die restlichen Felder von `AuftragPdfDaten` (Form in Task 4).
|
||
|
||
Regeln:
|
||
|
||
- `abschnitte` in dieser Reihenfolge: `zahlung` (`ueberschrift` „Zahlung“, `text: zahlungsplanText(auftrag)`), `termine` (nur wenn `AB_TEXTE.termine({ start, fertig, privat })` einen nicht leeren Text liefert; `start` und `fertig` sind `formatDe(startAm)` bzw. `formatDe(fertigBis)` oder `null`, wenn leer; **`privat` ist `kunde.kundenart === 'privat'`** (V7): bei Privatpersonen liefert `termine` immer Text, auch ohne Daten, nämlich mit dem Satz „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“; bei Unternehmen ohne Daten `null`, dann entfällt der Abschnitt; Überschrift „Termine“), `mitwirkung` (Überschrift „Mitwirkung und Abnahme“, `text: AB_TEXTE.mitwirkung`). Die Überschriften stehen in `PDF_BESCHRIFTUNG`.
|
||
- `agb`: `AB_TEXTE.agb({ fassung: formatDe(auftrag.agbFassung || AGB_FASSUNG) })`. `gruss`: `AB_TEXTE.gruss`.
|
||
- `ersetzt`: Option `ersetzt = { nummer, datum }` → `AB_TEXTE.ersetzt({ nummer, datum: formatDe(datum) })`, sonst `null`.
|
||
- `widerruf`: nur bei `kunde.kundenart === 'privat'` das Paar `{ belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR }` (unverändert, V7), sonst `null`. 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.bankDetailsText` an 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):
|
||
|
||
```js
|
||
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 `auftragPdfDaten` ergänzen**
|
||
|
||
Regeln wie oben.
|
||
|
||
- [ ] **Step 4: Lauf, erwartet PASS**
|
||
|
||
Run: derselbe wie in Step 2. Erwartet: alle vierzehn Tests bestehen.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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`/`auftragPdfDaten` erst ab Task 7).
|
||
- Produces (`pdf.js`): exportiert `SEITE`, `INHALT_BREITE`, `SPALTEN`, `BEREICHE` (Werte unten, alle in pt) und `paginiere(atome: Baustein[], bereiche = BEREICHE) → Seite[]`; modulintern (nicht exportiert) `FARBEN`, `ZELLEN_RAND`, `STIL`, `MASSE`.
|
||
|
||
```js
|
||
// 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):
|
||
|
||
```js
|
||
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):
|
||
|
||
```js
|
||
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):
|
||
|
||
```js
|
||
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.js` schreiben**
|
||
|
||
`// ===== 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**
|
||
|
||
```bash
|
||
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. `messe` liefert die Texthöhe in pt (in `erzeugeAuftragPdf` über `doc.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:
|
||
|
||
```js
|
||
// 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: `baueAtome` in Abschnitt 3 von `pdf.js` ergä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**
|
||
|
||
```bash
|
||
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, `seitenZahl` und `leseSeiten` anhä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 je `text()`-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`:
|
||
|
||
```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:
|
||
|
||
```js
|
||
/** 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**
|
||
|
||
```bash
|
||
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`, `BEREICHE` und die Maße (Tasks 6 und 7), Testhilfe `leseSeiten`, `seitenZahl`, `alleTexte`, `beispielAuftrag` (Tasks 4 und 8).
|
||
- Produces (`pdf.js`, Abschnitt 4, modulintern): `setzeStil(doc, stil) → doc` (setzt Schrift `Figtree` bzw. `Figtree-SemiBold` nach `stil.schrift`, Größe und Füllfarbe) und `zeichneAuftrag(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>`. Wirft `AuftragError` (409) wie `auftragPdfDaten`; 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'`):
|
||
|
||
```js
|
||
/** 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):
|
||
|
||
```js
|
||
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.js` schreiben**
|
||
|
||
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 aus `ladeSchriften()`; 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, `heightOfString` misst es richtig.
|
||
- `doc.text` bricht `\n` in Absätzen selbst; Texte mit Zeilenumbruch (Anschrift, Fußzeilen, Gruß) brauchen nur einen Aufruf.
|
||
- Das Dokument ist ein `Readable`: `data`-Ereignisse einsammeln, `end` abwarten, erst nach dem Zeichnen `doc.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.CreationDate` fest setzen (`gesendetAm` bzw. `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**
|
||
|
||
```bash
|
||
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); L0 `getAuftrag(id) → Auftrag|null` aus `auftragStore.js` (liest fehlende Felder tolerant, Übersicht V4 und L0-Plan); `startAppwriteMock` und `schemaKeys('auftraege')` aus `test/support`.
|
||
- Produces: `erzeugeAuftragPdf(auftrag, { settings, vorschau = false, jetzt = new Date(), ersetzt } = {})` mit dem Schlüssel `ersetzt` (seit der Planrunde in V4): `{ nummer, datum /* 'YYYY-MM-DD' */ }` (diese AB wird ersetzt), `null` (kein Hinweis, kein Lesen) oder weggelassen (hat `auftrag.ersetztId` einen Wert, liest die Funktion die alte AB über `getAuftrag` und nimmt `nummer` und den Berliner Tag von `gesendetAm`). Findet sie die alte AB nicht (oder ohne Nummer oder Sendedatum), wirft sie `AuftragError(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):
|
||
|
||
```js
|
||
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:
|
||
|
||
```js
|
||
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`):
|
||
|
||
```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 `ersetzt` umsetzen**
|
||
|
||
`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**
|
||
|
||
```bash
|
||
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)**
|
||
|
||
```bash
|
||
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.js` exportiert `auftragPdfDaten(auftrag, { settings, vorschau = false })` (reine Funktion) und `erzeugeAuftragPdf(auftrag, { settings, vorschau = false }) → Promise<Buffer>` (V4); `jetzt` und `ersetzt` stehen 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/` mit `Figtree-OFL.txt` und `QUELLE.txt`; Logo `public/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 in `PDF_BESCHRIFTUNG`.
|
||
- [ ] `AB_TEXTE.termine({ start, fertig, privat })` bekommt `privat = (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`, Datum `TT.MM.JJJJ` nach Berliner Tag, kein Chrome, Tests nur mit Nachbauten, keine IP-Adressen, Commits klein mit Schlusszeile, kein Push, kein `git 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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```js
|
||
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')
|
||
```
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
1. **Der „ersetzt“-Hinweis braucht Nummer und Datum der alten AB:** entschieden (siehe Übersicht, Nachträge). V4 führt `ersetzt = { nummer, datum }` als Option von `auftragPdfDaten` und `erzeugeAuftragPdf`; ohne sie liest L4 die alte AB über `getAuftrag(auftrag.ersetztId)`. Umsetzung: Task 5 und Task 10.
|
||
2. **Optionaler Schlüssel `jetzt`:** entschieden (siehe Übersicht, Nachträge), steht in V4. Er bestimmt das Datum der AB, wenn `gesendetAm` fehlt (Vorschau), und das `CreationDate` der PDF-Metadaten. L3 übergibt beim Senden `nummer`, `gesendetAm` und `agbFassung` im Objekt, `jetzt` greift dort nicht. Ist `agbFassung` leer, gilt `AGB_FASSUNG` (wie bei `agbUrl` in V3).
|
||
3. **Die 409-Klartexte von L4:** entschieden (siehe Übersicht, Nachträge). L3 reicht `AuftragError` aus `erzeugeAuftragPdf` unverändert durch (andere Fehler werden 500), und V5 nennt für `GET …/auftraege/:id/pdf` 404 und 409 „Entwurf unvollständig“. L4 wirft die Meldungen wie in Task 4 beschrieben.
|
||
4. **Parameter der Textfunktionen:** entschieden (siehe Übersicht, Nachträge; V7 führt `privat` in `AB_TEXTE.termine`): `weg` ist das deutsche Wort aus `AUFTRAG_WEGE`, `schaetzung` ein Stundentext, Beträge sind `euroDe`-Texte, und `AB_TEXTE.termine({ start, fertig, privat })` bekommt `privat` von L4. Bei Privatpersonen liefert `termine` immer Text (mit dem Satz „Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.“), bei Unternehmen `null`, wenn beide Daten fehlen. Tests: Task 4, Task 5 (`Termine: Privatpersonen …`) und Task 9 (`Privatperson: …`).
|
||
5. **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.js` nach jeder Textänderung laufen lassen. Im PDF selbst ersetzt `bereinigeText` ein fehlendes Zeichen still (der Wortlaut ist dann nicht wie freigegeben).
|
||
6. **`WIDERRUFSFORMULAR.zeilen`:** keine Entscheidung nötig, bleibt als Hinweis an L7. L4 druckt jede Zeile als eigenen Absatz unverändert (ein führendes „–“ muss in `texte.js` stehen), ohne Ausfüllfelder oder Linien.
|
||
7. **Leere Steuernummer:** entschieden (siehe Übersicht, Nachträge). Die Zeile entfällt im PDF (kein Platzhalter); W2 zeigt im AB-Dialog einen Hinweis.
|
||
8. **`fontkit`:** entschieden (siehe Übersicht, Nachträge). L0 trägt `fontkit` als direkte Abhängigkeit in `package.json` ein (Version wie die pdfkit-Abhängigkeit); L4 importiert es direkt. Task 1 prüft den Eintrag.
|
||
9. **Schrift- und Logo-Pfade der Spec:** entschieden (siehe Übersicht, Nachträge). Die Spec nennt jetzt „statische Figtree-Schnitte (OFL) unter `server/assets/fonts/`, Logo `public/webklar-logo.png`“; dieser Plan setzt genau das um.
|
||
10. **Testhilfe und Symlink:** entschieden (siehe Übersicht, Nachträge). Testhilfen ohne `.test.js` gehören dem anlegenden Strang, `test/auftraegePdfHilfe.js` also L4. L0 trägt `node_modules` ohne Schrägstrich in `.gitignore` ein, damit erscheint der Symlink der Lane-Worktrees nicht mehr in `git status` (Task 1 und Task 11 erwarten eine leere Ausgabe).
|
||
11. **Schriftquelle und Lizenz:** entschieden (siehe Übersicht, Nachträge): statische Figtree-TTFs aus `@expo-google-fonts/figtree` (OFL) unter `server/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 und `QUELLE.txt` dann gemeinsam anpassen.
|
||
12. **Test-Nachbau:** bestätigt durch den L0-Plan, keine Entscheidung nötig: Task 10 braucht `schemaKeys('auftraege')` (`test/support/schema.js`) und `getAuftrag` (`auftragStore.js`); Task 1 prüft sie.
|