Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-06-auftraege-12-integration-kontrolle.md
T
ClaudeandClaude Opus 5.5 9d82fc579f Umsetzungspläne Aufträge und Rechnungen: Übersicht und 12 Strang-Pläne
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>
2026-10-06 12:49:15 +00:00

301 KiB
Raw Blame History

Integration und Kontrolle – Umsetzungsplan (Strang Z)

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: Die sieben Portal-Stränge (L1–L7) und die zwei WOMS-Stränge (W1, W2) werden zu je einem lauffähigen, geprüften Stand auf feature/auftraege zusammengeführt, durch einen Durchlauf-Test über alle neuen Endpunkte abgesichert, von fünf Prüfern kontrolliert und mit exakten Befehlen und Reihenfolge für den Livegang übergeben. Nichts wird gepusht.

Architecture: Z1 (Portal) und Z2 (WOMS) arbeiten in den Basis-Worktrees dev-auftraege der beiden Repos und dürfen parallel laufen; beide hängen ihre Ergebnisse an ein gemeinsames Protokoll (z-protokoll.md). Z3 liest den fertigen Gesamt-Diff mit fünf Linsen (sonnet), lässt jeden Befund von einem zweiten Agenten (haiku) gegenprüfen und korrigiert nur bestätigte Critical/Important (sonnet, eine Datei-Gruppe je Agent); danach laufen betroffene Tests, Durchlauf und volle Suite erneut. Z4 schreibt die Übergabe und die Memory-Notiz.

Tech Stack: git 2.55 (Worktrees, merge --no-ff, merge-tree), node:test mit den Nachbauten aus test/support/ (startAppwriteMock, startFakeStripe, mailSink, startTestApp) und echtem pdfkit, Node 24 auf dem Host und Node 20 (Docker-Image node:20) für die Auftrags-Tests, WOMS: React 18/Vite 5 mit npm test (tests/unit/*.test.mjs) und dem Prüfwerkzeug des Redesigns (Playwright-Chromium, Ordner $T/werkzeug), flock und systemd-run für die Speichergrenzen.

Spec: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md

Übersicht/Verträge: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (verbindlich: Ausführungsmodell, Dateizuständigkeit, „Nachträge nach der Planrunde“, V1–V8, Review Focus, „Integration, Kontrolle, Übergabe“, Livegang). Die Nachträge entscheiden, wo ein Strang-Plan abweicht. Offene Widersprüche und Lücken stehen am Ende unter „Vertragsfragen“; was die Planrunde entschieden hat, ist dort als „entschieden (siehe Übersicht, Nachträge)“ markiert.

Reihenfolge: Z1 und Z2 dürfen parallel laufen, sobald ihre Stränge gemeldet sind (Z1: L1–L7, Z2: W1 und W2). Z3 beginnt erst, wenn Z1 und Z2 grün sind. Z4 folgt auf Z3.

Abkürzungen (jede Shell-Zeile steht für sich: die Variablen werden in jedem Befehlsblock neu gesetzt):

Name Pfad
P /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege (Portal, Branch feature/auftraege)
Wo /home/webklar/apps/ticket.webklar.com/dev-auftraege (WOMS, Branch feature/auftraege)
L /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad (Scratchpad; Sperre $L/auftraege-schwer.lock)
R /root/reports/auftraege-rechnungen-2026-10-06 (Berichte, Bilder, Übergabe)
T /root/reports/ticketsystem-design-2026-10-04 (Prüfwerkzeug des Redesigns in $T/werkzeug, Sperre $T/sperren/schwer.lock)

Global Constraints

Alle Punkte der Übersicht gelten (Sprache, Cent, Berliner Tage, Token, keine IP-Adressen, Schalter, Tests nur mit Nachbauten, Ressourcen, Git, Sicherheit der Umgebung, „Vorhandene Tests bleiben grün“), ebenso ihr Abschnitt „Nachträge nach der Planrunde“ (Zuständigkeiten, Sperren, K31 ohne Team-BCC, Link mit #t=, Merge-Reihenfolge); wo ein Strang-Plan davon abweicht, gilt die Übersicht. Für Strang Z kommt hinzu:

  • Volle Suite nur hier und nur hinter der Sperre: Portal flock $L/auftraege-schwer.lock timeout 3600 nice -n 10 env NODE_OPTIONS=--max-old-space-size=1536 npm test, WOMS … NODE_OPTIONS=--max-old-space-size=1024 … npm test. Vorher free -m: steht „available“ unter 1500 MB, warten. Die Skripte des Redesign-Werkzeugs (build.sh, check.sh, shots.sh, run-browser.sh) nehmen ihre eigene Sperre $T/sperren/schwer.lock. Reihenfolge immer erst auftraege-schwer.lock, dann deren Sperre (wie in W0).
  • Kein Push, nie --force, nie git add -A oder git add .: Dateien einzeln. git remote -v, git config remote.origin.url und Push-URLs enthalten ein Zugangstoken: nie ausgeben und nie in Berichte oder Protokolle kopieren.
  • Code ändert Strang Z nur (a) als kleinen Integrationsfix, der einen Widerspruch zwischen Strängen oder zu einem bestehenden Test auflöst, und (b) als Korrektur eines bestätigten Befunds in Z3. Alles andere wird gelesen und geprüft. Ein Fix über etwa 30 Zeilen oder mit Wirkung auf einen Vertrag (V1–V8) geht als Rückfrage an den Orchestrator, nicht als Commit.
  • Der Vertrag entscheidet. Schlägt der Durchlauf-Test an einer Stelle an, die V1–V8 oder die Spec festlegen, ist der Code falsch. Legt der Vertrag nichts fest, ist die Annahme des Tests falsch: Test anpassen, im Test den Kommentar // Annahme (nicht im Vertrag): … setzen und die Entscheidung im Protokoll vermerken.
  • Modelle: Prüfer und Korrektoren sonnet, Gegenprüfung haiku; kein opus, kein fable (Memory „feedback-billige-modelle“). Schlusszeile jedes Commits wie in der Übersicht (zweites -m): Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Sicherheitsblock für jeden Agentenauftrag (Memory „feedback-destruktive-skripte“, steht wörtlich in jedem Auftrag unten): nie rm -r, rsync --delete, git clean, git reset --hard, git worktree remove --force; kein Skript mit einem Zielordner aufrufen oder mit Pfaden wie .. ausprobieren; Hilfsdateien nur unter $L oder $R, nie nach / schreiben; keine Zugangsdaten, .env-Inhalte oder Tokens ausgeben; keine Live-Daten lesen oder schreiben; grep ist hier eine Funktion, die Dateien still auslässt: rg oder command grep -a benutzen; Node-Läufe immer mit --max-old-space-size und timeout; nie pkill -f.
  • Protokoll: Jeder Z-Agent hängt Befehle, Ergebnisse (Zahlen, SHAs) und Entscheidungen an $R/z-protokoll.md an (nur anhängen, >>). Die Übergabe (Z4.1) entsteht aus diesem Protokoll.
  • Ablage: Berichte, Bilder und Befunde unter $R/ (bilder/<strang>/, kontrolle/, pruefmaterial/), flüchtige Läufe im Scratchpad $L.

Review Focus

Die fünf Fälle der Übersicht. Jeder hat einen Durchlauf-Test in test/auftraegeAblauf.test.js (Task Z1.3) und einen Prüfer in Z3, der den Code dazu liest; die Tests der Stränge (L2, L5, L3, L4) decken sie zusätzlich im Einzelnen ab.

# Fall Erwartung Durchlauf-Test Prüfer
1 Gleichzeitige Aufwandsrechnungen mit denselben Arbeitsblättern (Doppelklick, zwei Admins) Die zweite scheitert mit 409 „Arbeitsblatt bereits abgerechnet“ und hinterlässt keinen Entwurf bei Stripe D3 (Wettlauf über HTTP mit verschiedenem Vermerk und Zahlungsziel, damit der 30-Sekunden-Schutz gegen gleiche Inhalte nicht schon greift) 1 (Sperren, auch verschachtelte: die Schlüssel sind entschieden, Prüfer 1 prüft die Einhaltung)
2 Alte Token nach Erinnerung, neuer Fassung, Storno oder Widerruf Der alte Link bestätigt nie; die Seite nennt den Grund (404 nach Erinnerung oder Ablauf, 410 ersetzt/storniert/widerrufen) D6 (Erinnerung, Ablauf, ersetzt, storniert), D6b (Bestätigen gegen Storno), D8 (widerrufen), D13 (Zähler zählt auch Fehlversuche) 2
3 Schlussrechnung neben stornierten oder noch nicht gesendeten Rechnungen Stornos zählen nicht; ein offener Entwurf zur AB blockiert mit 409 D4 (Entwurf blockiert, Minuszeile), D5 (stornierte Anzahlung zählt nicht) 1
4 Unvollständige Kundendaten beim AB-Senden (keine Kundenart, keine Anschrift, keine E-Mail, Interessent) Klare Meldung vor Nummernvergabe und PDF, keine Nummer verbraucht D9, D10 (Ablage kaputt), D15 (Nummern lückenlos) 2 und 5
5 Umlaute, €, Minuszeichen und lange Texte im PDF Lesbar, kein Glyphenausfall, Seitenumbruch mit Kopf der Tabelle D4 (echtes pdfkit mit ä ö ü ß € im Titel und in Positionen), D8 (Privatperson hat mehr Seiten); Text und Optik an den Beispiel-PDFs aus Z3.1 3 und 4

Dateien

Datei Aktion Task
$L/zustaendigkeit-pruefen.mjs neu (Scratchpad, nicht im Repo) Z1.1
$L/zustaendigkeit-woms.mjs neu (Scratchpad, nicht im Repo) Z2.1
test/auftraegeAblauf.test.js (Portal) neu Z1.3
test/kundenansichtGuard.test.js (Portal) ändern: zwei Einträge in MOUNTS Z1.4
test/financeSettings.test.js (Portal) ändern, falls steuernummer die festen DEFAULTS bricht Z1.4
Portal: Integrationsfixes nur wenn Z1.2 bis Z1.6 sie verlangen Z1.2–Z1.6
$T/werkzeug-auftraege/ (Worktree des Prüfwerkzeugs, Zweig strang/auftraege): backend.mjs, endpunkte.md, fixtures/auftraege.mjs, views.mjs, gates.mjs ändern/neu Z2.3
scripts/setup-legal.mjs, tests/setup-legal.test.mjs (WOMS) ändern, bedingt Z2.4
$L/pruefmaterial.mjs, $L/gruppen.mjs, $L/befunde-zusammenfuehren.mjs, $L/befunde-auswerten.mjs, $L/gruppen-pruefen.mjs neu (Scratchpad, nicht im Repo) Z3.1, Z3.3, Z3.4
Dateien der Gruppen G1 bis G6 (Tabelle in Z3.4) und je eine neue Testdatei auftraegeKorrektur<G> nur für bestätigte Befunde Z3.4
server/services/finance/stripeFinance.js, test/financeStripeWrite.test.js, scripts/stripe-kunden-fuss.mjs, test/stripeKundenFuss.test.js (Portal) ändern bzw. neu, nur auf Weisung Z4.3
$R/z-protokoll.md, $R/uebergabe.md, $R/kontrolle/*, $R/pruefmaterial/*, $R/bilder/* neu Z1–Z4
/root/.claude/projects/-root/memory/auftraege-rechnungen.md (Absatz anhängen), MEMORY.md (vorhandene Indexzeile ergänzen) ändern Z4.2

Z1 – Portal-Integration (Worktree $P)

Task Z1.1: Bestandsaufnahme und Zuständigkeitsprüfung

Files:

  • Create: $L/zustaendigkeit-pruefen.mjs (Scratchpad), $R/z-protokoll.md (anhängen)
  • Keine Änderung im Repo.

Interfaces:

  • Consumes: Branches auftraege/l1-projektkonto, auftraege/l2-rechnungen, auftraege/l3-ab-kern, auftraege/l4-ab-pdf, auftraege/l5-bestaetigung, auftraege/l6-kundenportal, auftraege/l7-rechtstexte und feature/auftraege (Stand nach L0); die Tabelle „Dateizuständigkeit“ der Übersicht.

  • Produces: die Gewissheit, dass jede Lane nur eigene Dateien geändert hat und keine zwei Lanes dieselbe Datei; im Protokoll die Ausgangs-SHAs (ORIGIN_MAIN, L0_TIP, je Lane der Tip).

  • Schritt 1: Lage und Speicher

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
free -m | sed -n 2p
cd $P && git status --short && git log --oneline -3 && git rev-parse --short origin/main feature/auftraege
for n in 1 2 3 4 5 6 7; do d=$P-l$n; printf 'Lane %s: ' $n; git -C $d status --short | rg -v '^\?\? node_modules$' | head -3; git -C $d branch --show-current; done

Erwartet: git status im Basis-Worktree leer; je Lane nur der Branch-Name (ein unversionierter node_modules-Symlink wird ausgeblendet; ein .superpowers/-Ordner mit Strang-Protokollen ist in Ordnung, alles andere (geänderte oder neue Dateien) bedeutet: die Lane ist nicht fertig, Orchestrator fragen).

  • Schritt 2: Branch-Tips prüfen und festhalten
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
for b in l1-projektkonto l2-rechnungen l3-ab-kern l4-ab-pdf l5-bestaetigung l6-kundenportal l7-rechtstexte; do
  printf '%-30s' "auftraege/$b"; git -C $P rev-parse --short "auftraege/$b" 2>/dev/null || echo FEHLT
  git -C $P merge-base --is-ancestor feature/auftraege "auftraege/$b" && echo '  enthält feature/auftraege' || echo '  HINWEIS: feature/auftraege ist weiter (L0 nach dem Start der Lane geändert): Merge bringt es mit'
done

Erwartet: sieben Tips, kein FEHLT. Die SHAs gehören ins Protokoll (Schritt 8).

  • Schritt 3: Prüfskript anlegen ($L/zustaendigkeit-pruefen.mjs, nur lesend: ruft git diff auf)
// Meldet Dateien, die eine Lane laut Dateizuständigkeit der Übersicht nicht ändern darf, und Überschneidungen zwischen Lanes.
import { execFileSync } from 'node:child_process'

const REPO = '/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege'
const S = 'server/services/auftraege/'
// Zuständigkeit laut Tabelle „Dateizuständigkeit“ und Abschnitt „Nachträge nach der Planrunde“ der Übersicht (die Nachträge gelten, wo ein Strang-Plan abweicht).
// L0 liegt schon auf feature/auftraege und ist nicht Gegenstand dieser Prüfung; ihm gehören auch .gitignore (Eintrag node_modules ohne Schrägstrich)
// und die Abhängigkeit fontkit in package.json: ändert eine Lane .gitignore, package.json oder package-lock.json, meldet das Skript eine FREMDE DATEI.
const LANES = {
  'auftraege/l1-projektkonto': [S + 'projektkonto.js', 'server/routes/admin/projektkonten.js', 'server/routes/team/arbeitsblaetter.js'],
  // L2: zusätzlich der erwartete Schlüsselsatz von mapInvoice in test/financeStripeRead.test.js
  'auftraege/l2-rechnungen': [S + 'positionen.js', S + 'rechnungsbau.js', 'server/services/finance/stripeFinance.js', 'server/routes/admin/finanzen.js', 'test/support/fakeStripe.js', 'test/financeStripeRead.test.js'],
  'auftraege/l3-ab-kern': [S + 'nummern.js', S + 'auftraege.js', 'server/routes/admin/auftraege.js'],
  'auftraege/l4-ab-pdf': [S + 'pdf.js', /^server\/assets\/fonts\//],
  // L5: zusätzlich die Blöcke „projekt“ in K04 und T09 (Widerruf von Projektaufträgen)
  'auftraege/l5-bestaetigung': [S + 'token.js', S + 'bestaetigung.js', S + 'mails.js', /^server\/mail-templates\/(K31|K32|T22|K04|T09) /, 'server/routes/auftrag.js', 'server/routes/auftraegeKunde.js', 'server/routes/abo.js', 'test/mailTemplates.test.js'],
  // L6: zusätzlich vier Module in public/ und je eine Zeile in test/customerInvoices.test.js und test/publicForms.test.js;
  // die Testhilfe test/auftraegeKundenportalDaten.js und das UI-Skript test/ui/auftraege-kundenportal.ui.mjs sind eigene neue Testdateien (NEUE_TESTS)
  'auftraege/l6-kundenportal': [/^public\/auftrag\.(html|js|css)$/, /^public\/auftrag-(ansicht|dom|portal|widerruf)\.js$/, 'public/dashboard.html', 'public/app.js', 'public/widerrufen.html', 'server/services/customerInvoices.js', 'test/customerInvoices.test.js', 'test/publicForms.test.js'],
  // L7: zusätzlich die AGB-Quelle, der Normnachweis und die Stand-Zeile in test/legalTexts.test.js
  'auftraege/l7-rechtstexte': [S + 'texte.js', S + 'konstanten.js', /^public\/rechtliches\//, 'docs/rechtliches/agb.md', 'docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt', 'scripts/build-legal-html.mjs', 'scripts/build-legal-pdfs.sh', 'public/datenschutz.html', /^docs\/WEBklar-Datenschutzerklaerung\.pdf(\.sha256)?$/, 'test/legalTexts.test.js'],
}
// jede Lane darf eigene neue Testdateien, Testhilfen ohne .test.js (test/helpers/auftraege….js, test/auftraege…Hilfe.js) und Prüfskripte mit dem Namensraum auftraege… anlegen: sie gehören dem anlegenden Strang (Übersicht, Nachträge)
const NEUE_TESTS = /^test\/(auftraege[A-Za-z0-9]*\.(test\.)?js|ui\/auftraege-[a-z-]+\.ui\.mjs|helpers\/auftraege[A-Za-z]*\.js)$/
const passt = (muster, datei) => (muster instanceof RegExp ? muster.test(datei) : muster === datei)
const git = (...args) => execFileSync('git', ['-C', REPO, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })

let meldungen = 0
const geaendert = {}
for (const [zweig, erlaubt] of Object.entries(LANES)) {
  geaendert[zweig] = new Set()
  for (const zeile of git('diff', '--no-renames', '--name-status', `feature/auftraege...${zweig}`).split('\n').filter(Boolean)) {
    const [art, datei] = zeile.split('\t')
    geaendert[zweig].add(datei)
    if (art === 'A' && NEUE_TESTS.test(datei)) continue
    if (erlaubt.some((muster) => passt(muster, datei))) continue
    console.log(`FREMDE DATEI    ${zweig}: ${art} ${datei}`)
    meldungen += 1
  }
}
const namen = Object.keys(geaendert)
for (let i = 0; i < namen.length; i += 1) {
  for (let j = i + 1; j < namen.length; j += 1) {
    for (const datei of geaendert[namen[i]]) {
      if (!geaendert[namen[j]].has(datei)) continue
      console.log(`ÜBERSCHNEIDUNG  ${namen[i]} und ${namen[j]}: ${datei}`)
      meldungen += 1
    }
  }
}
// Sonderregeln: L7 darf in konstanten.js nur die Zeile AGB_FASSUNG ändern und in texte.js keine Exportnamen
const konstanten = git('diff', '-U0', 'feature/auftraege...auftraege/l7-rechtstexte', '--', S + 'konstanten.js').split('\n').filter((z) => /^[+-][^+-]/.test(z))
if (konstanten.some((z) => !z.includes('AGB_FASSUNG'))) {
  console.log('L7 ändert konstanten.js außerhalb von AGB_FASSUNG:\n' + konstanten.join('\n'))
  meldungen += 1
}
const exportnamen = (ref) => {
  try { return (git('show', `${ref}:${S}texte.js`).match(/^export (?:const|function|class) \w+/gm) || []).sort().join('|') } catch { return '' }
}
if (exportnamen('feature/auftraege') !== exportnamen('auftraege/l7-rechtstexte')) {
  console.log('L7 ändert die Exportnamen von texte.js')
  meldungen += 1
}
console.log(meldungen ? `${meldungen} Meldung(en)` : 'Zuständigkeit eingehalten')
process.exit(meldungen ? 1 : 0)
  • Schritt 4: Prüfung laufen lassen

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 120 node --max-old-space-size=256 /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/zustaendigkeit-pruefen.mjs

Erwartet: Zuständigkeit eingehalten, Exit 0. Jede Meldung entscheidet der Z1-Agent so: FREMDE DATEI in docs/ oder in einer reinen Testdatei ist ein Hinweis fürs Protokoll; eine fremde Datei unter server/ oder public/ oder eine ÜBERSCHNEIDUNG ist ein Konfliktkandidat: im Protokoll festhalten und in Z1.2 beim Merge dieser Lane besonders prüfen (Konfliktregel dort). Wer die Datei „darf“, steht in der Tabelle der Übersicht; im Zweifel Orchestrator fragen.

  • Schritt 5: Hygiene der Lane-Branches
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
for b in l1-projektkonto l2-rechnungen l3-ab-kern l4-ab-pdf l5-bestaetigung l6-kundenportal l7-rechtstexte; do
  printf '%-22s' "$b"; git -C $P ls-tree -r --name-only "auftraege/$b" | rg '^(node_modules|upload-data|\.env)|\.(key|pem)$' || echo 'ok (keine Fremdkörper)'
done

Erwartet: siebenmal ok …. Ein Treffer heißt: Symlink node_modules, Zugangsdaten oder Upload-Daten wurden versehentlich committed: STOPP, Orchestrator melden (nicht mergen).

  • Schritt 6: Mail-Kennungen und Setup-Skript
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
git -C $P fetch origin
git -C $P ls-tree --name-only origin/main:server/mail-templates | rg '^(K3[1-9]|T2[2-9]) ' || echo 'K31, K32, T22 sind auf origin/main frei'
git -C $P diff --name-status feature/auftraege...auftraege/l5-bestaetigung -- server/mail-templates
git -C $P show feature/auftraege:scripts/auftraege-setup.mjs | rg -n "projektRechnungen|auftraege" | head

Erwartet: erste Zeile „frei“, die Vorlagen-Liste zeigt genau drei A-Zeilen (K31 …, K32 …, T22 …) und genau zwei M-Zeilen (K04 Eingang Widerruf.html und T09 Widerruf eingegangen.html: nur der neue Block projekt, Übersicht V6), sonst keine geänderte alte Vorlage. Das Setup-Skript legt beide Collections an (projektRechnungen und auftraege; L1 liest bei ausgeschaltetem Schalter die auftraege-Collection nicht, die Rechnungsarten ohne AB brauchen aber projektRechnungen immer). Ist die Kennung K31, K32 oder T22 auf origin/main vergeben (andere Sitzungen bauen parallel), STOPP: Orchestrator fragt, wer umbenennt (nächste freie Nummer, Pins in test/mailTemplates.test.js).

  • Schritt 7: Abschlussberichte der Stränge sammeln

Die Abschlussberichte von L1–L7 (Testzahlen, Abweichungen, Vertragsfragen) liegen beim Orchestrator. Die Zahlen und alle Abweichungen kommen in das Protokoll; Abweichungen, die einen Vertrag berühren, fragt der Z1-Agent vor dem Merge nach.

  • Schritt 8: Protokoll anlegen
R=/root/reports/auftraege-rechnungen-2026-10-06
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
{
  echo "# Protokoll der Z-Phase (Aufträge und Rechnungen)"
  echo
  echo "## Z1.1 Bestandsaufnahme $(date -u +%Y-%m-%dT%H:%MZ)"
  echo "- ORIGIN_MAIN=$(git -C $P rev-parse --short origin/main) L0_TIP=$(git -C $P rev-parse --short feature/auftraege)"
  for b in l1-projektkonto l2-rechnungen l3-ab-kern l4-ab-pdf l5-bestaetigung l6-kundenportal l7-rechtstexte; do echo "- auftraege/$b=$(git -C $P rev-parse --short auftraege/$b)"; done
  echo "- Zuständigkeitsprüfung: <Ausgabe von Schritt 4 eintragen>"
} >> $R/z-protokoll.md

Kein Commit (nichts im Repo geändert).


Task Z1.2: Lane-Merges in der Reihenfolge der Übersicht (L1, L2, L3, L4, L7, L5, L6) mit den Tests des Strangs

Files:

  • Modify: Branch feature/auftraege (Merge-Commits), Dateien nur bei einem Integrationsfix.

Interfaces:

  • Consumes: die sieben Lane-Branches (Z1.1), die Dateizuständigkeit der Übersicht.
  • Produces: feature/auftraege mit allen sieben Strängen, je ein Merge-Commit je Lane (--no-ff) in der Reihenfolge L1, L2, L3, L4, L7 (ohne seinen Datenschutz-Commit), L5, L6 (entschieden, Übersicht „Integration“), nach jedem Merge grüne Strang-Tests.

Konfliktregel. Die Tabelle „Dateizuständigkeit“ der Übersicht entscheidet:

  • Konflikt in einer Datei, die der einmündenden Lane gehört: deren Fassung gilt (git checkout --theirs -- <datei>).

  • Konflikt in einer Datei, die L0 oder einer bereits gemergten Lane gehört (z. B. package.json, server/config.js, server/index.js, test/support/*, konstanten.js): die Fassung auf feature/auftraege gilt (git checkout --ours -- <datei>); die Änderung der einmündenden Lane wird im Protokoll als Verstoß gegen die Zuständigkeit festgehalten und von Hand nachgezogen, wenn sie fachlich nötig ist (kleiner Integrationsfix).

  • Echter Logikkonflikt (beide Seiten haben dieselbe Funktion oder denselben Absatz inhaltlich geändert, und beide Änderungen werden gebraucht): nicht raten. git merge --abort, Datei, beide Fassungen und die Frage an den Orchestrator melden.

  • Nach jeder Auflösung die Datei einzeln stagen (git add -- <datei>), nie -A; Merge mit git commit --no-edit abschließen. Bleibt etwas unklar: git merge --abort ist jederzeit erlaubt.

  • Schritt 1: Ausgangslage

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && git status --short && git branch --show-current && free -m | sed -n 2p

Erwartet: leer, feature/auftraege, „available“ mindestens 1500 MB.

  • Schritt 2: Verfahren je Lane (hier am Beispiel L1; für jede weitere Lane Branch und Testliste austauschen)
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P
git merge --no-ff -m "Merge auftraege/l1-projektkonto: Projektkonto (Strang L1)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" auftraege/l1-projektkonto
git status --short | rg '^(UU|AA|DU|UD|AU|UA) ' || echo "keine Konflikte"
# neue und geänderte Testdateien dieses Merges (Nettowirkung der Lane auf feature/auftraege)
FILES=$(git diff --name-only --diff-filter=AM HEAD^1 HEAD -- 'test/*.test.js' | tr '\n' ' '); echo "$FILES"
free -m | sed -n 2p
timeout 900 nice -n 10 node --max-old-space-size=1024 --test $FILES

Erwartet: Merge-Commit angelegt (git log --oneline -1), „keine Konflikte“, # fail 0 und # pass ungleich 0. Dazu je Lane die bestehenden Tests, die ihre Dateien berühren (unten); ihre Ausgabe # fail 0. Schlagen Tests nach dem Merge an, die in der Lane grün waren, ist es ein Integrationseffekt (L0-Stand nach dem Lane-Start geändert, oder Wechselwirkung mit einer früher gemergten Lane): Ursache im Protokoll festhalten, kleinste Korrektur als eigener Commit Integration: <was> in der Datei des zuständigen Strangs (Konfliktregel), Tests wiederholen. Kein Weitermachen mit einer roten Lane.

  • Schritt 3: Die sieben Merges in dieser Reihenfolge (entschieden, Übersicht „Integration“: L1, L2, L3, L4, L7 ohne seinen letzten Commit, L5, L6)
Lane Branch Zusätzlich zu den neuen Testdateien (bestehend, einzeln laufen lassen)
1. L1 Projektkonto auftraege/l1-projektkonto test/financeStripeRead.test.js
2. L2 Rechnungen auftraege/l2-rechnungen test/financeStripeWrite.test.js test/financeStripeRead.test.js test/financeRoutesBank.test.js test/financeAssign.test.js test/financeOverview.test.js test/financeSettings.test.js test/customerInvoices.test.js
3. L3 AB-Kern auftraege/l3-ab-kern test/financeSettings.test.js
4. L4 AB-PDF auftraege/l4-ab-pdf (keine)
5. L7 Rechtstexte auftraege/l7-rechtstexte~1 (ohne den letzten Commit) test/legalTexts.test.js test/legalDocs.test.js test/buildLegalHtml.test.js und alle Testdateien, die datenschutz.html oder agb lesen: command grep -a -l "datenschutz.html|Datenschutzerklaerung.pdf|AGB_FASSUNG" test/*.test.js
6. L5 Bestätigung und Mails auftraege/l5-bestaetigung test/mailTemplates.test.js test/publicForms.test.js test/aboRoutes.test.js test/kundenansichtGuard.test.js
7. L6 Kundenportal auftraege/l6-kundenportal test/customerInvoices.test.js test/publicForms.test.js test/kundenansicht.test.js und alle Testdateien, die public/app.js, public/dashboard.html oder public/widerrufen.html lesen: command grep -a -l "public/app.js|dashboard.html|widerrufen.html" test/*.test.js
  • 1. L1 gemergt, Strang-Tests grün
  • 2. L2 gemergt, Strang-Tests grün (danach test/auftraegeProjektkonto*.test.js erneut: L2 ändert stripeFinance.js, das L1 nur liest)
  • 3. L3 gemergt, Strang-Tests grün
  • 4. L4 gemergt, Strang-Tests grün (die PDF-Tests von L4 laufen jetzt gegen das echte pdfkit von L0; Beispiel-PDF in der Lane-Ausgabe gegenlesen: # pass gleich dem Lane-Bericht)
  • 5. L7 gemergt (ohne den Datenschutz-Commit, siehe unten), Strang-Tests grün (die PDF-Prüfsummen *.sha256 gehören zu den Dateien aus L7; test/legalTexts.test.js und test/buildLegalHtml.test.js prüfen sie). Danach alle bisherigen Auftrags-Tests auf einmal: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraege*.test.js 2>&1 | tail -15. L7 ersetzt die vorläufigen Wortlaute aus L0; pinnt ein Test von L0 einen Wortlaut, bricht er jetzt. Erwartet # fail 0; ein roter L0-Test mit festem Wortlaut wird angepasst (Integrationsfix, nur die Erwartung, nie der Wortlaut)
  • 6. L5 gemergt, Strang-Tests grün (test/mailTemplates.test.js pinnt die Vorlagenliste: K31, K32, T22 müssen darin stehen, T22 in der Liste der Team-Fußzeilen; K04 und T09 tragen den Block projekt; die AGB-Datei aus L7 liegt schon im Stand, K31 hängt sie an)
  • 7. L6 gemergt, Strang-Tests grün

Reihenfolge und Datenschutz-Commit (beides entschieden, Übersicht „Nachträge“ und „Integration“)

  1. L7 steht vor L5. K31 hängt public/rechtliches/agb-<AGB_FASSUNG>.pdf an; die Datei kommt mit L7. So finden die Tests von L5 sie im Stand, und der Durchlauf in Z1.3 scheitert nicht am Mailanhang.
  2. Der Datenschutz-Commit bleibt draußen. Er ist der letzte Commit von auftraege/l7-rechtstexte und wird erst beim AB-Livegang nach der Freigabe gemergt (Übergabe, Abschnitt D): public/datenschutz.html und das PDF sind mit dem ersten Push von main sofort öffentlich, und der neue Abschnitt 9a beschreibt eine Verarbeitung, die bei AUFTRAEGE_ENABLED=off nicht läuft, in einem Wortlaut, den der Nutzer noch nicht freigegeben hat. Gemergt wird deshalb auftraege/l7-rechtstexte~1:
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P
git log --oneline feature/auftraege..auftraege/l7-rechtstexte | head -12
DS=$(git log --format=%H -n1 --grep='^Datenschutz: Abschnitt 9a' auftraege/l7-rechtstexte); echo "Datenschutz-Commit: $(git rev-parse --short $DS)  Spitze der Lane: $(git rev-parse --short auftraege/l7-rechtstexte)"
test "$DS" = "$(git rev-parse auftraege/l7-rechtstexte)" && echo "der Datenschutz-Commit ist die Spitze: ~1 merge" || echo "WEITERE COMMITS DANACH: Orchestrator fragen"
git merge --no-ff -m "Merge auftraege/l7-rechtstexte (ohne Datenschutz-Commit): Rechtstexte (Strang L7)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" auftraege/l7-rechtstexte~1

Der Datenschutz-Commit (Abschnitt 9a, Widerrufsabschnitt, Stand, PDF und die Pins dazu in test/auftraegeRechtstexte.test.js und test/legalTexts.test.js) bleibt auf auftraege/l7-rechtstexte und wird nach der Rechtsrunde mit git merge --no-ff auftraege/l7-rechtstexte nachgeholt (Übergabe, Abschnitt D; die Branches der Lanes bleiben dafür erhalten). Verweise der Seiten auf den Abschnitt 9a zeigen bis dahin auf die Seite ohne diesen Anker. Gibt es nach dem Datenschutz-Commit noch Commits der Lane, die Datenschutz-Dateien berühren, das dem Orchestrator melden statt zu raten.

  • Schritt 4: Nach dem letzten Merge die gemeinsame Lage prüfen
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && git status --short && git log --oneline --first-parent origin/main..HEAD | head -30
node -e "const p=require('./package.json'),l=require('./package-lock.json');const v=l.packages['node_modules/pdfkit']?.version;const f=Object.keys(l.packages).filter((k)=>k.endsWith('node_modules/fontkit'));console.log('pdfkit',p.dependencies.pdfkit,'gelockt',v,'| fontkit',p.dependencies.fontkit,'gelockt',f.length);process.exit(p.dependencies.pdfkit&&v&&p.dependencies.fontkit&&f.length?0:1)"
rg -n '^node_modules$' .gitignore
ls server/assets/fonts public/rechtliches | rg -i 'figtree|agb-' 

Erwartet: git status leer, sieben Merge-Commits plus die Strang-Commits in der First-Parent-Liste, pdfkit und fontkit stehen in dependencies (nicht devDependencies: der Produktionscontainer installiert mit npm install --omit=dev) und im Lock, .gitignore hat node_modules ohne Schrägstrich (L0, Übersicht „Nachträge“); die Figtree-Schnitte samt Lizenzdatei und public/rechtliches/agb-<AGB_FASSUNG>.pdf samt .sha256 sind da (die AGB-Datei hängt in K31 an: fehlt sie, scheitert der Durchlauf in Z1.3 am Mailanhang). Danach noch einmal alle Auftrags-Tests: timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraege*.test.js 2>&1 | tail -15, erwartet # fail 0.

  • Schritt 5: Protokoll
R=/root/reports/auftraege-rechnungen-2026-10-06; P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
{ echo; echo "## Z1.2 Merges $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- feature/auftraege = $(git -C $P rev-parse --short HEAD) (7 Merges)"; echo "- Konflikte und Integrationsfixes: <eintragen oder 'keine'>"; } >> $R/z-protokoll.md

Die Merge-Commits sind der Commit dieses Tasks; Integrationsfixes wurden einzeln committed.

Task Z1.3: Durchlauf-Test test/auftraegeAblauf.test.js

Files:

  • Create: test/auftraegeAblauf.test.js

Interfaces:

  • Consumes (Verträge der Übersicht nach der Planrunde, alle über HTTP): V5 komplett (Projektkonten, Aufträge samt POST …/auftraege/:id/projekt, Rechnungen mit art, Vorschau, Team-Abzeichen, öffentliche Token-Routen, Kundenrouten, Widerruf order: 'projekt' mit optionaler abNummer, Einstellungen mit steuernummer), V6 (K31 mit Link auftrag.html#t=<Token>, ohne BCC, dazu die Teamkopie ohne Link; K32 mit FRUEHBEGINN_TEXT; T22 mit Präfix „[Aufträge]“), V2 AGB_FASSUNG und SCHALTER_AUS_MELDUNG, V4 formatNummer, hashToken; die Nachbauten aus test/support/; echtes pdfkit.

  • Produces: den Beleg, dass die sieben Stränge zusammen funktionieren. Szenarien D0 bis D17 und D6b (siehe Review-Focus-Tabelle für D3, D4, D5, D6, D6b, D8, D9, D10, D13); D17 (AB ohne Projekt, später zuordnen) steht vor D15 und D16, die am Ende alle Nummern und Token prüfen.

  • Annahmen, die V5 offen lässt, und wie der Test damit umgeht: Erfolgsstatus 200 oder 201 (erfolg()), Router-Module mit export default, Fehlerstatus bei zu früher Anzahlung und Überfakturierung 400 oder 409, unbekanntes oder nicht abrechenbares Arbeitsblatt 400, 404 oder 409, fremdes oder unbekanntes Projekt bei POST …/auftraege/:id/projekt 400, 404 oder 409.

  • Schritt 1: Testdatei anlegen (vollständig)

// Durchlauf über alle neuen Endpunkte von „Aufträge und Rechnungen“ (Strang Z, Task Z1.3).
// Echter Server-Code (Router, Dienste, pdfkit); Nachbauten für Appwrite, Stripe und SMTP, nichts verlässt den Rechner.
// Aufruf: cd <Worktree> && free -m && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblauf.test.js
// Jeder Test hat eigene Kunden und Projekte; die AB-Nummern laufen über alle Tests (D15 prüft sie am Ende).
// Der Vertrag entscheidet (Übersicht V1–V8, Spec). Was der Vertrag offen lässt, steht als „Annahme“ im Kommentar.
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { inspect } from 'node:util'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js'

// --- Umgebung ------------------------------------------------------------------------------------------------
// Ablage der AB-PDFs in einem Wegwerf-Ordner und Schalter an (Vorgabe wäre off): config.js liest beides beim ersten Import.
const ABLAGE = fs.mkdtempSync(path.join(os.tmpdir(), 'auftraege-ablauf-'))
process.env.AUFTRAEGE_UPLOAD_DIR = ABLAGE
process.env.AUFTRAEGE_ENABLED = 'on'

// Alles, was dieser Prozess ins Protokoll schreibt, wird gesammelt: D16 prüft, dass kein Token und keine Client-Adresse darin steht.
const PROTOKOLL = []
for (const stufe of ['log', 'info', 'warn', 'error']) {
  const original = console[stufe].bind(console)
  console[stufe] = (...teile) => {
    PROTOKOLL.push(teile.map((teil) => (typeof teil === 'string' ? teil : inspect(teil, { depth: 5 }))).join(' '))
    original(...teile)
  }
}

const felder = (liste) => liste.map((attribut) => attribut.key)
const datumsfelder = (liste) => liste.filter((attribut) => attribut.type === 'datetime').map((attribut) => attribut.key)
const aw = await startAppwriteMock({
  schema: {
    auftraege: felder(AUFTRAEGE_COLLECTIONS.auftraege.attributes),
    projektRechnungen: felder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes),
    financeSettings: [...new Set([...schemaKeys('financeSettings'), ...felder(AUFTRAEGE_ZUSATZ_ATTRIBUTE.financeSettings)])],
  },
  datetimes: {
    auftraege: datumsfelder(AUFTRAEGE_COLLECTIONS.auftraege.attributes),
    projektRechnungen: datumsfelder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes),
  },
  defaultLimit: 25, // wie Appwrite: ohne limit() kommen nur 25 Dokumente zurück (die Termine-Tests prüfen es ebenso)
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })

const { config } = await import('../server/config.js')
const { startTestApp, send, sessionCookie } = await import('./support/testApp.js')
const { kundenansichtCookie } = await import('./support/fixtures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { customerStripeInvoice } = await import('../server/services/customerInvoices.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { addDays, berlinDate, formatDe } = await import('../server/services/finance/dates.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { AGB_FASSUNG, SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js')
const { formatNummer } = await import('../server/services/auftraege/nummern.js')
const { hashToken } = await import('../server/services/auftraege/token.js')
const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
const { default: adminProjektkontenRoutes } = await import('../server/routes/admin/projektkonten.js')
const { default: adminAuftraegeRoutes } = await import('../server/routes/admin/auftraege.js')
const { default: adminFinanzenRoutes } = await import('../server/routes/admin/finanzen.js')
const { default: publicAuftragRoutes } = await import('../server/routes/auftrag.js')
const { default: kundeAuftraegeRoutes } = await import('../server/routes/auftraegeKunde.js')
const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')

// Eingebunden wie in server/index.js (V5 „Einbindung“): Wächter der Kundenansicht zuerst, spezifische Admin-Pfade vor dem Finanzen-Router
const app = await startTestApp({
  '/': kundenansichtGuard,
  '/api/admin/finanzen/projektkonten': adminProjektkontenRoutes,
  '/api/admin/finanzen/auftraege': adminAuftraegeRoutes,
  '/api/admin/finanzen': adminFinanzenRoutes,
  '/api/auftrag': publicAuftragRoutes,
  '/api/auftraege': kundeAuftraegeRoutes,
  '/api/abo': aboRoutes,
  '/api/team': teamRoutes,
})
const mails = mailSink()
setMailTransportForTests(mails)
after(async () => {
  setMailTransportForTests(null)
  await Promise.all([app.close(), aw.close(), st.close()])
  // nur der selbst angelegte Wegwerf-Ordner, nie ein Pfad von außen
  if (ABLAGE.startsWith(`${os.tmpdir()}${path.sep}auftraege-ablauf-`)) fs.rmSync(ABLAGE, { recursive: true, force: true })
})
beforeEach(() => {
  resetInvoiceCreateGuardForTests() // 30-Sekunden-Schutz gegen gleiche Rechnungen
  invalidateFinanceCache() // Stripe-Zwischenspeicher (60 s)
  resetPublicRateLimit() // Widerrufsseite: 10 Erklärungen je Stunde und Adresse
})

// --- Hilfen --------------------------------------------------------------------------------------------------
const ADMIN = { Authorization: 'Bearer jwt-admin' }
const STAFF = { Authorization: 'Bearer jwt-staff' } // Mitarbeiter ohne Admin-Label
const KV = 'a'.repeat(32) // Kennung der Kundenansicht (Kopfzeile X-Kundenansicht)
const UNBEKANNT = 'Dieser Link ist ungültig oder abgelaufen.'
const HEUTE = berlinDate()
const JAHR = Number(HEUTE.slice(0, 4))
const AB = () => config.collections.auftraege
const PR = () => config.collections.projektRechnungen
const ip = (n) => ({ 'x-forwarded-for': `203.0.113.${n}` }) // je Test eine eigene Client-Adresse (Ratenbegrenzung je Adresse)
const sha256 = (daten) => crypto.createHash('sha256').update(daten).digest('hex')
const pause = (ms) => new Promise((fertig) => setTimeout(fertig, ms))
const kundeCookie = (id) => sessionCookie({ customerId: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example` })
const admin = (methode, pfad, body, headers = {}) => send(methode, `/api/admin/finanzen${pfad}`, { body, headers: { ...ADMIN, ...headers } })

function erwarte(res, status, was, meldung) {
  assert.equal(res.status, status, `${was}: erwartet ${status}, bekam ${res.status} ${JSON.stringify(res.body)}`)
  if (meldung instanceof RegExp) assert.match(String(res.body?.error ?? ''), meldung, `${was}: Meldung`)
  else if (typeof meldung === 'string') assert.equal(res.body?.error, meldung, `${was}: Meldung`)
  return res.body
}
// Annahme (nicht im Vertrag): Erfolg heißt 200 oder 201
function erfolg(res, was) {
  assert.ok(res.status === 200 || res.status === 201, `${was}: erwartet 200 oder 201, bekam ${res.status} ${JSON.stringify(res.body)}`)
  return res.body
}

async function holePdf(pfad, { headers = {}, cookie } = {}) {
  const res = await fetch(app.url + pfad, { headers: { ...(cookie ? { cookie } : {}), ...headers } })
  return { status: res.status, typ: res.headers.get('content-type') || '', bytes: Buffer.from(await res.arrayBuffer()) }
}
const istPdf = (bytes) => bytes.subarray(0, 5).toString('latin1') === '%PDF-' && bytes.length > 1500 && bytes.toString('latin1', Math.max(0, bytes.length - 64)).includes('%%EOF')
const seiten = (bytes) => (bytes.toString('latin1').match(/\/Type \/Page(?![s\w])/g) || []).length
const pdfDateien = () => (fs.existsSync(ABLAGE) ? fs.readdirSync(ABLAGE, { recursive: true }).filter((name) => String(name).endsWith('.pdf')) : [])

const adressen = (feld) => (feld || []).map((eintrag) => eintrag.address)
const findeMail = (vorher, muster) => mails.sent.slice(vorher).find((mail) => muster.test(mail.subject))
async function warteBis(pruefung, { ms = 5000, was = 'Bedingung' } = {}) {
  const ende = Date.now() + ms
  for (;;) {
    const wert = await pruefung()
    if (wert) return wert
    if (Date.now() > ende) assert.fail(`Zeitüberschreitung beim Warten auf: ${was}`)
    await pause(25)
  }
}
const GESEHEN = new Set() // alle Token aus Mails und Fehlversuchen: D16 sucht sie in Dokumenten und im Protokoll
const TOKEN_IM_LINK = /\/auftrag\.html#t=([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/ // V6: K31 verlinkt auftrag.html#t=<Token> (Fragment: landet in keinem Server- oder Proxy-Protokoll)
function tokenAus(mail) {
  const treffer = String(mail.html).match(TOKEN_IM_LINK) || String(mail.text).match(TOKEN_IM_LINK)
  assert.ok(treffer, `kein Bestätigungslink in der Mail "${mail.subject}"`)
  GESEHEN.add(treffer[1])
  return treffer[1]
}

// V6: K31 geht OHNE BCC an den Kunden (die Mail trägt den Bestätigungslink); das Team bekommt eine eigene Kopie ohne Link (Block teamkopie),
// auch bei Erinnerungen. k31Mails trennt beide.
const kundenAdresse = (kundenId) => `${kundenId}@kunde.example`
function k31Mails(vorher, kundenId) {
  const alle = mails.sent.slice(vorher).filter((mail) => /Auftragsbestätigung AB-\d{4}-\d{4}/.test(mail.subject))
  const anKunde = alle.filter((mail) => adressen(mail.to).includes(kundenAdresse(kundenId)))
  return { anKunde, teamkopien: alle.filter((mail) => !anKunde.includes(mail)) }
}
function pruefeTeamkopie(kundenMail, token, teamkopien, was) {
  assert.deepEqual(adressen(kundenMail.bcc), [], `${was}: K31 an den Kunden hat keine BCC-Kopie (V6)`)
  assert.ok(teamkopien.length >= 1, `${was}: das Team bekommt eine Kopie der K31`)
  const empfaenger = teamkopien.flatMap((mail) => adressen(mail.to))
  for (const adresse of config.chatNotify.emails) assert.ok(empfaenger.includes(adresse), `${was}: Teamkopie an ${adresse}`)
  for (const kopie of teamkopien) {
    const inhalt = `${kopie.html}\n${kopie.text}`
    assert.equal(inhalt.includes(token), false, `${was}: die Teamkopie enthält den Token`)
    assert.equal(/auftrag\.html[?#]/.test(inhalt), false, `${was}: die Teamkopie enthält einen Bestätigungslink`)
    assert.match(kopie.text, /Kopie für das Team/, `${was}: Hinweis „Kopie für das Team“ statt des Knopfes`)
  }
}

const stripeAufrufe = (ab, methode, pfad) => st.calls.slice(ab).filter((aufruf) => aufruf.method === methode && (pfad instanceof RegExp ? pfad.test(aufruf.path) : aufruf.path === pfad))
const zeilen = (rechnungId) => st.invoices.get(rechnungId).lines.data
function bezahlen(rechnungId) {
  const roh = st.invoices.get(rechnungId)
  assert.ok(roh, `Rechnung ${rechnungId} fehlt im Stripe-Nachbau`)
  st.invoices.set(rechnungId, {
    ...roh, status: 'paid', paid: true, amount_paid: roh.total, amount_remaining: 0,
    status_transitions: { ...roh.status_transitions, paid_at: Math.floor(Date.now() / 1000) },
  })
  invalidateFinanceCache()
}
async function rechnung(body, was) {
  return erfolg(await admin('POST', '/rechnungen', { daysUntilDue: 14, senden: false, ...body }), was).rechnung
}
async function konto(kundenId, projektId) {
  const body = erwarte(await admin('GET', `/projektkonten?kunde=${kundenId}`), 200, `Projektkonten von ${kundenId}`)
  const eintrag = body.konten.find((k) => k.projektId === projektId)
  assert.ok(eintrag, `Projekt ${projektId} fehlt in den Projektkonten von ${kundenId}`)
  return { body, konto: eintrag }
}

const AB_BASIS = {
  titel: 'Neue Website', art: 'festpreis', projektId: '',
  positionen: [{ beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 }],
  stundensatzCents: null, schaetzungMinuten: null,
  zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14,
  auftragVom: addDays(HEUTE, -1), auftragWeg: 'telefon', startAm: addDays(HEUTE, 7), fertigBis: addDays(HEUTE, 40), zeitquelle: [],
}
async function entwurf(kundenId, ueberschreibe = {}) {
  const auftrag = erfolg(await admin('POST', '/auftraege', { ...AB_BASIS, kundenId, ...ueberschreibe }), `AB-Entwurf für ${kundenId}`).auftrag
  assert.equal(auftrag.status, 'entwurf')
  assert.equal(auftrag.nummer, '', 'Entwürfe haben keine Nummer')
  return auftrag
}
const lies = async (id) => erwarte(await admin('GET', `/auftraege/${id}`), 200, `AB ${id} lesen`).auftrag
async function sende(id, was = 'AB senden') {
  const vorher = mails.sent.length
  const body = erwarte(await admin('POST', `/auftraege/${id}/senden`), 200, was)
  assert.equal(body.mailFehler, false, `${was}: Mail nicht zugestellt`)
  const { anKunde, teamkopien } = k31Mails(vorher, body.auftrag.kundenId)
  assert.equal(anKunde.length, 1, `${was}: genau eine K31 an den Kunden (${mails.sent.slice(vorher).map((m) => `${m.subject} an ${adressen(m.to)}`).join(' | ')})`)
  const token = tokenAus(anKunde[0])
  pruefeTeamkopie(anKunde[0], token, teamkopien, was)
  return { auftrag: body.auftrag, mail: anKunde[0], token }
}
const ansicht = (token, n) => send('GET', `/api/auftrag/${token}`, { headers: ip(n) })
const bestaetigen = (token, n, body = { bestaetigt: true, fruehbeginn: false }, extra = {}) =>
  send('POST', `/api/auftrag/${token}/bestaetigen`, { body, headers: { ...ip(n), ...(extra.headers || {}) }, cookie: extra.cookie })
const vergebeneNummern = () => [...aw.docs(AB()).values()].filter((d) => d.nummer).map((d) => d.nummer).sort()

// --- Testdaten -----------------------------------------------------------------------------------------------
const ADRESSE = { street: 'Hauptstraße 1', postalCode: '10115', city: 'Berlin', country: 'Deutschland' }
const kundendoc = (id, ueberschreibe = {}) => ({
  $id: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example`, code: `K-${id}`,
  customerStatus: 'active', status: 'active', portalAccessEnabled: true, kundenart: 'unternehmen', ...ADRESSE, ...ueberschreibe,
})
const KUNDEN = {
  kfirma: kundendoc('kfirma', { companyName: 'Beispiel GmbH', name: 'Anna Beispiel' }),
  kprivat: kundendoc('kprivat', { kundenart: 'privat', name: 'Petra Privat' }),
  kfremd: kundendoc('kfremd', { companyName: 'Fremd AG', name: 'Frank Fremd' }),
  kluecke: kundendoc('kluecke', { kundenart: '', name: 'Lena Lücke' }),
}
for (const doc of Object.values(KUNDEN)) aw.seed('customers', doc)
const setzeKunde = (id, patch) => aw.seed('customers', { ...KUNDEN[id], ...patch })

function legeProjektAn(id, kundenId, ticketId, woid, name) {
  aw.seed('workorders', { $id: ticketId, woid, customerId: kundenId, topic: name, status: 'Open', type: 'Webpage' })
  aw.seed('websiteProjects', { $id: id, customerId: kundenId, ticketId, projectName: name, projectType: 'website', status: 'active' })
}
legeProjektAn('pfest', 'kfirma', 'tkfest', '100201', 'Website Beispiel')
legeProjektAn('paufw', 'kfirma', 'tkaufw', '100202', 'Online-Shop Beispiel')
legeProjektAn('pab', 'kfirma', 'tkab', '100203', 'Relaunch Größe & Maße')
legeProjektAn('pstorno', 'kfirma', 'tkstorno', '100204', 'Landingpage Beispiel')
legeProjektAn('pzuordnen', 'kfirma', 'tkzuordnen', '100205', 'Projekt zum Zuordnen')
legeProjektAn('pfremd', 'kfremd', 'tkfremd', '100299', 'Fremdes Projekt')
const blatt = (id, ticketId, ueberschreibe) => ({
  $id: id, workorderId: ticketId, woid: ticketId === 'tkaufw' ? '100202' : '100299', serviceType: 'Remote', employeeName: 'Justin Klein',
  totalTime: 60, startDate: '06.10.2026', endDate: '06.10.2026', details: 'Arbeit', isComment: false, countsToPlan: false, ...ueberschreibe,
})
const ANDREJ = [{ employeeId: 'andrej', name: 'Andrej Stevanovski', task: 'Farbkonzept' }]
// Zeit des Projekts „Online-Shop“: 06.10. = 60 + 75 min, 07.10. = 50 → 60 min, 08.10. = 40 + 30 Unterstützung → 75 min; zusammen 270 min = 4,5 Std.
aw.seed('worksheets', blatt('wsA', 'tkaufw', { totalTime: 60, details: 'Startseite' }))
aw.seed('worksheets', blatt('wsB', 'tkaufw', { totalTime: 75, details: 'Kontaktformular' }))
aw.seed('worksheets', blatt('wsC', 'tkaufw', { totalTime: 50, startDate: '07.10.2026', endDate: '07.10.2026', details: 'Impressum und Datenschutz' }))
aw.seed('worksheets', blatt('wsD', 'tkaufw', { totalTime: 40, startDate: '08.10.2026', endDate: '08.10.2026', details: 'Farbkonzept', supporters: JSON.stringify(ANDREJ) }))
// nie abrechenbar: Abo-Zeit, Kommentar, 0 Minuten; dazu ein Blatt eines anderen Kunden
aw.seed('worksheets', blatt('wsAbo', 'tkaufw', { totalTime: 90, countsToPlan: true, details: 'Wartung im Abo' }))
aw.seed('worksheets', blatt('wsKomm', 'tkaufw', { totalTime: 30, isComment: true, details: 'Interner Hinweis' }))
aw.seed('worksheets', blatt('wsNull', 'tkaufw', { totalTime: 0, details: 'Telefonat ohne Zeit' }))
aw.seed('worksheets', blatt('wsFremd', 'tkfremd', { totalTime: 60, details: 'Arbeit beim anderen Kunden' }))

// --- D0: Einbindung, Seiten und Zugriffsschutz ---------------------------------------------------------------
test('D0 Einbindung: server/index.js hängt die neuen Router ein (Admin-Pfade vor dem Finanzen-Router), das Team-Gerüst hängt hinter requireStaffJwt', () => {
  const index = fs.readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
  const stelle = (pfad, was) => {
    const treffer = new RegExp(`app\\.use\\(\\s*['"]${pfad}['"]\\s*,\\s*\\w+\\s*\\)`).exec(index)
    assert.ok(treffer, `server/index.js: ${was} (${pfad}) fehlt`)
    return treffer.index
  }
  const finanzen = stelle('/api/admin/finanzen', 'Finanzen-Router')
  assert.ok(stelle('/api/admin/finanzen/projektkonten', 'Projektkonten') < finanzen, 'Projektkonten vor dem Finanzen-Router')
  assert.ok(stelle('/api/admin/finanzen/auftraege', 'Aufträge (Admin)') < finanzen, 'Aufträge vor dem Finanzen-Router')
  stelle('/api/auftrag', 'öffentliche Token-Routen')
  stelle('/api/auftraege', 'Kundenrouten')
  const team = fs.readFileSync(new URL('../server/routes/team/index.js', import.meta.url), 'utf8')
  const jwt = team.indexOf('router.use(requireStaffJwt)')
  assert.ok(jwt >= 0 && team.indexOf("router.use('/arbeitsblaetter'") > jwt, "Team: router.use('/arbeitsblaetter', …) steht hinter requireStaffJwt")
})

test('D0 Seiten: die Bestätigungsseite ist noindex, die Widerrufsseite kennt den Projektauftrag', () => {
  const auftrag = fs.readFileSync(new URL('../public/auftrag.html', import.meta.url), 'utf8')
  assert.match(auftrag, /<meta[^>]*(?:name=["']robots["'][^>]*noindex|noindex[^>]*name=["']robots["'])/i, 'auftrag.html trägt noindex')
  const widerruf = fs.readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8')
  assert.match(widerruf, /value=["']projekt["']/, 'Vertragsart mit order=projekt')
  assert.match(widerruf, /Projektauftrag/, 'Beschriftung „Projektauftrag (Auftragsbestätigung)“')
})

test('D0 Zugriff: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403, Kunden-Cookie ersetzt kein JWT, unbekannte Token einheitlich 404', async () => {
  const adminRouten = [
    ['GET', '/projektkonten?kunde=kfirma'], ['GET', '/projektkonten/pfest'], ['GET', '/auftraege?kunde=kfirma'], ['POST', '/auftraege'],
    ['GET', '/auftraege/x'], ['PATCH', '/auftraege/x'], ['DELETE', '/auftraege/x'], ['GET', '/auftraege/x/pdf'], ['POST', '/auftraege/x/senden'],
    ['POST', '/auftraege/x/erinnern'], ['POST', '/auftraege/x/neue-fassung'], ['POST', '/auftraege/x/stornieren'], ['POST', '/auftraege/x/projekt'], ['POST', '/rechnungen/vorschau'],
  ]
  for (const [methode, pfad] of adminRouten) {
    const body = methode === 'GET' || methode === 'DELETE' ? undefined : {}
    const was = `${methode} ${pfad}`
    erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body }), 401, `${was} ohne JWT`)
    erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, cookie: kundeCookie('kfirma') }), 401, `${was} mit Kunden-Cookie`)
    erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, headers: STAFF }), 403, `${was} als Mitarbeiter ohne Admin-Label`)
  }
  const abgerechnet = '/api/team/arbeitsblaetter/abgerechnet?ticketId=tkaufw'
  erwarte(await send('GET', abgerechnet), 401, 'Team-Route ohne JWT')
  erwarte(await send('GET', abgerechnet, { cookie: kundeCookie('kfirma') }), 401, 'Team-Route mit Kunden-Cookie')
  erwarte(await send('GET', abgerechnet, { headers: { Authorization: 'Bearer jwt-kunde' } }), 403, 'Team-Route mit Kundenkonto ohne Label')
  assert.deepEqual(Object.keys(erwarte(await send('GET', abgerechnet, { headers: STAFF }), 200, 'Team-Route als Mitarbeiter')), ['abgerechnet'])
  for (const [methode, pfad] of [['GET', '/api/auftraege'], ['GET', '/api/auftraege/x/pdf'], ['POST', '/api/auftraege/x/bestaetigen']]) {
    erwarte(await send(methode, pfad, { body: methode === 'POST' ? { bestaetigt: true } : undefined }), 401, `${methode} ${pfad} ohne Sitzung`)
  }
  for (const token of ['x', 'kurz', 'A'.repeat(43), 'A'.repeat(44), `${'A'.repeat(42)}%2F`]) {
    erwarte(await ansicht(token, 1), 404, `Ansicht mit Token ${token}`, UNBEKANNT)
    erwarte(await send('GET', `/api/auftrag/${token}/pdf`, { headers: ip(1) }), 404, `PDF mit Token ${token}`, UNBEKANNT)
    erwarte(await bestaetigen(token, 2, { bestaetigt: true }), 404, `Bestätigen mit Token ${token}`, UNBEKANNT)
  }
})

// --- D1: Einstellungen ---------------------------------------------------------------------------------------
test('D1 Einstellungen: die Steuernummer wird geprüft und gespeichert (sie steht später auf dem AB-PDF)', async () => {
  assert.equal(erfolg(await admin('GET', '/einstellungen'), 'Einstellungen lesen').einstellungen.steuernummer, '')
  // V4: erlaubt sind Buchstaben, Ziffern, Leerzeichen, / - und . (auch eine USt-IdNr. „DE…“), höchstens 40 Zeichen
  for (const falsch of ['12/345/67890<script>', '1'.repeat(41), '143/123/45678; DROP']) {
    erwarte(await admin('PATCH', '/einstellungen', { steuernummer: falsch }), 400, `Steuernummer ${falsch.slice(0, 20)}`, /steuernummer/i)
  }
  assert.equal(erfolg(await admin('PATCH', '/einstellungen', { steuernummer: 'DE 123 456 789' }), 'USt-IdNr. mit Buchstaben').einstellungen.steuernummer, 'DE 123 456 789')
  assert.equal(erfolg(await admin('PATCH', '/einstellungen', { steuernummer: '' }), 'leer löscht die Steuernummer').einstellungen.steuernummer, '')
  const gespeichert = erfolg(
    await admin('PATCH', '/einstellungen', { steuernummer: ' 143/123/45678 ', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00 · BIC TESTDEFF' }),
    'Steuernummer speichern'
  ).einstellungen
  assert.equal(gespeichert.steuernummer, '143/123/45678', 'getrimmt')
  assert.equal(aw.docs(config.collections.financeSettings).get('main').steuernummer, '143/123/45678')
})

// --- D2: Festbetrag, Projektkonto, Rechnung ohne Projekt, Stripe-Ausfall -------------------------------------
test('D2 Festbetrag: Entwurf mit Zusatzfeld Projekt und A4, Senden, Projektkonto, Zahlung, Rechnung ohne Projekt, Stripe-Ausfall', async () => {
  const k0 = await konto('kfirma', 'pfest')
  assert.equal(k0.body.auftraegeAktiv, true)
  assert.equal(k0.body.stripeOk, true)
  assert.deepEqual([k0.body.kunde.id, k0.body.kunde.kundenart], ['kfirma', 'unternehmen'])
  assert.deepEqual(k0.konto.vereinbart, { festpreisCents: null, aufwand: null })
  assert.deepEqual([k0.konto.berechnetCents, k0.konto.offenCents], [0, null])
  assert.deepEqual(k0.konto.tickets, [{ id: 'tkfest', woid: '100201' }])

  const vorher = st.calls.length
  const entw = await rechnung({
    customerId: 'kfirma', projektId: 'pfest', art: 'festbetrag',
    items: [{ description: 'Erstellung der Website, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }],
  }, 'Festbetrag als Entwurf')
  assert.deepEqual([entw.status, entw.art, entw.projektId, entw.projektName, entw.totalCents], ['entwurf', 'festbetrag', 'pfest', 'Website Beispiel', 50000])
  const anlage = stripeAufrufe(vorher, 'POST', '/v1/invoices')[0]
  assert.ok(anlage, 'Rechnung bei Stripe angelegt')
  assert.deepEqual([anlage.body['custom_fields[0][name]'], anlage.body['custom_fields[0][value]']], ['Projekt', 'Website Beispiel'])
  assert.equal(anlage.body['custom_fields[1][name]'], undefined, 'ohne AB und Leistungszeitraum nur ein Zusatzfeld')
  assert.equal(anlage.body['rendering[pdf][page_size]'], 'a4')
  assert.deepEqual(
    [anlage.body['metadata[projektId]'], anlage.body['metadata[art]'], anlage.body['metadata[webklarCustomerId]'], anlage.body['metadata[quelle]']],
    ['pfest', 'festbetrag', 'kfirma', 'woms']
  )
  assert.ok(anlage.body.footer.includes('IBAN DE00'), 'Rechnungsfuß mit Bankverbindung bleibt')
  const pr = aw.docs(PR()).get(entw.id)
  assert.ok(pr, 'projektRechnungen-Dokument mit der Stripe-Kennung als ID')
  assert.deepEqual([pr.kundenId, pr.projektId, pr.art, pr.betragCents, pr.aktiv], ['kfirma', 'pfest', 'festbetrag', 50000, true])
  assert.deepEqual(JSON.parse(pr.arbeitsblattIdsJson || '[]'), [])
  assert.equal((await konto('kfirma', 'pfest')).konto.berechnetCents, 0, 'ein Entwurf zählt nicht als berechnet')

  const gesendet = erfolg(await admin('POST', `/rechnungen/${entw.id}/senden`), 'Festbetrag senden').rechnung
  assert.equal(gesendet.status, 'offen')
  assert.ok(gesendet.number)
  assert.equal(aw.docs(PR()).get(entw.id).rechnungsnummer, gesendet.number, 'Finalisieren schreibt die Nummer ans Projektdokument')
  const k1 = await konto('kfirma', 'pfest')
  assert.deepEqual([k1.konto.berechnetCents, k1.konto.bezahltCents, k1.konto.offenCents], [50000, 0, null])
  const zeile = erfolg(await admin('GET', '/rechnungen?kunde=kfirma'), 'Rechnungsliste').rechnungen.find((r) => r.id === gesendet.id)
  assert.deepEqual([zeile.projektId, zeile.projektName, zeile.art], ['pfest', 'Website Beispiel', 'festbetrag'])
  assert.equal(customerStripeInvoice(st.invoices.get(gesendet.id)).projekt, 'Website Beispiel', 'das Kundenportal zeigt das Projekt')
  bezahlen(gesendet.id)
  assert.equal((await konto('kfirma', 'pfest')).konto.bezahltCents, 50000)

  // Rechnung ohne Projekt wie im bisherigen Dialog (kein art, kein projektId): bleibt möglich, jetzt auch in A4
  const vorFrei = st.calls.length
  const frei = await rechnung({ customerId: 'kfirma', items: [{ description: 'Beratung', quantity: 2, unitAmountCents: 6000 }], senden: true }, 'Rechnung ohne Projekt')
  assert.equal(frei.art, 'frei')
  assert.ok(!frei.projektId)
  const freiAnlage = stripeAufrufe(vorFrei, 'POST', '/v1/invoices')[0]
  assert.equal(freiAnlage.body['rendering[pdf][page_size]'], 'a4', 'A4 auch im bisherigen Dialog')
  assert.equal(freiAnlage.body['custom_fields[0][name]'], undefined, 'ohne Projekt keine Zusatzfelder')
  const kFrei = (await konto('kfirma', 'pfest')).body
  assert.equal(kFrei.ohneProjekt.berechnetCents, 12000)
  assert.ok(kFrei.ohneProjekt.anzahl >= 1)

  // Stripe fällt aus: Projektkonto antwortet trotzdem, markiert den Stripe-Teil
  invalidateFinanceCache()
  st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'Nachbau: Stripe gestört' }) // 4xx wiederholt stripe-node nicht
  const ausfall = await konto('kfirma', 'pfest')
  assert.equal(ausfall.body.stripeOk, false)
  assert.deepEqual([ausfall.konto.berechnetCents, ausfall.konto.bezahltCents, ausfall.konto.offenCents], [0, 0, null])
  assert.equal(ausfall.konto.zeit.satzCents, 10000, 'Zeit und Vereinbartes laufen ohne Stripe weiter')
  invalidateFinanceCache()
  assert.equal((await konto('kfirma', 'pfest')).body.stripeOk, true)
})

// --- D3: Aufwand ---------------------------------------------------------------------------------------------
test('D3 Aufwand: Vorschau, Wettlauf um dieselben Arbeitsblätter, Abzeichen, Senden, Storno und Löschen geben frei, Aufwands-AB setzt den Satz', async () => {
  const ALLE = ['wsA', 'wsB', 'wsC', 'wsD']
  const aufwand = (ids, extra = {}) => ({ customerId: 'kfirma', projektId: 'paufw', art: 'aufwand', arbeitsblattIds: ids, ...extra })
  const abzeichen = async () => erfolg(await send('GET', '/api/team/arbeitsblaetter/abgerechnet?ticketId=tkaufw', { headers: STAFF }), 'Abzeichen').abgerechnet

  // Ausgangslage: vier abrechenbare Blätter (Abo-Zeit, Kommentar und 0 Minuten zählen nie), je Blatt auf 15 Minuten aufgerundet, Unterstützung eingerechnet
  const k0 = await konto('kfirma', 'paufw')
  assert.deepEqual(k0.konto.zeit, { minuten: 270, aboMinuten: 90, offeneMinuten: 270, satzCents: 10000, wertCents: 45000, offenerWertCents: 45000 })
  const detail = erwarte(await admin('GET', '/projektkonten/paufw'), 200, 'Projektkonto-Detail')
  assert.deepEqual(detail.offeneArbeitsblaetter.map((b) => b.id).sort(), ALLE)
  for (const b of detail.offeneArbeitsblaetter) {
    assert.equal(b.minuten % 15, 0, `${b.id}: auf 15 Minuten aufgerundet`)
    assert.match(b.datum, /^\d{4}-\d{2}-\d{2}$/)
  }
  assert.equal(detail.offeneArbeitsblaetter.find((b) => b.id === 'wsD').minuten, 75, 'mit Unterstützung: 40 + 30 = 70, aufgerundet 75')

  // Vorschau: eine Zeile je Tag, Zusatzfelder Projekt und Leistungszeitraum; sie schreibt nichts
  const stripeVorher = st.calls.length
  const rechnungenVorher = st.invoices.size
  const vorschau = erwarte(await admin('POST', '/rechnungen/vorschau', { customerId: 'kfirma', projektId: 'paufw', art: 'aufwand', arbeitsblattIds: ALLE }), 200, 'Aufwand-Vorschau')
  assert.deepEqual(vorschau.positionen.map((p) => p.betragCents), [22500, 10000, 12500])
  assert.equal(vorschau.summeCents, 45000)
  assert.match(vorschau.positionen[0].beschreibung, /^06\.10\.2026: /)
  assert.ok(vorschau.positionen[0].beschreibung.includes('Startseite') && vorschau.positionen[0].beschreibung.includes('Kontaktformular'))
  assert.match(vorschau.positionen[0].beschreibung, / – 2,25 Std\. à 100,00\s€$/)
  assert.match(vorschau.leistungszeitraum, /^06\.10\.[–-]08\.10\.2026$/)
  assert.deepEqual(vorschau.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
  assert.equal(vorschau.customFields[0].value, 'Online-Shop Beispiel')
  assert.equal(st.invoices.size, rechnungenVorher, 'die Vorschau legt nichts bei Stripe an')
  assert.deepEqual(stripeAufrufe(stripeVorher, 'POST', /./), [], 'die Vorschau schreibt nichts zu Stripe')

  // Wettlauf (Review Focus 1): zwei Admins rechnen dieselben Blätter ab. Vermerk und Zahlungsziel unterscheiden sich, damit der
  // 30-Sekunden-Schutz gegen gleiche Inhalte nicht schon greift; verhindern muss es die Sperre mit der Prüfung der Arbeitsblätter.
  const lauf = (nr) => admin('POST', '/rechnungen', aufwand(ALLE, { memo: `Wettlauf ${nr}`, daysUntilDue: 13 + nr, senden: false, idempotencyKey: `ablauf-aufwand-${nr}` }))
  st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 }) // die erste Anlage bei Stripe dauert, damit beide Aufrufe sicher gleichzeitig prüfen
  const [r1, r2] = await Promise.all([lauf(1), lauf(2)])
  assert.deepEqual([r1.status, r2.status].sort(), [200, 409], `genau eine gewinnt: ${JSON.stringify([r1.body, r2.body])}`)
  const gewinner = (r1.status === 200 ? r1 : r2).body.rechnung
  const verlierer = r1.status === 200 ? r2 : r1
  assert.match(verlierer.body.error, /abgerechnet/i, 'die zweite meldet „Arbeitsblatt bereits abgerechnet“')
  assert.deepEqual([gewinner.art, gewinner.totalCents], ['aufwand', 45000])
  assert.equal([...st.invoices.values()].filter((r) => r.metadata?.projektId === 'paufw').length, 1, 'der Verlierer hinterlässt keinen Entwurf bei Stripe')
  const aktive = [...aw.docs(PR()).values()].filter((d) => d.projektId === 'paufw' && d.aktiv === true)
  assert.equal(aktive.length, 1)
  assert.deepEqual(JSON.parse(aktive[0].arbeitsblattIdsJson).sort(), ALLE)
  assert.deepEqual(zeilen(gewinner.id).map((z) => z.amount), [22500, 10000, 12500])
  const anlage = stripeAufrufe(stripeVorher, 'POST', '/v1/invoices').find((c) => c.body['metadata[projektId]'] === 'paufw')
  assert.equal(anlage.body['custom_fields[1][name]'], 'Leistungszeitraum')
  assert.match(anlage.body['custom_fields[1][value]'], /^06\.10\.[–-]08\.10\.2026$/)

  // Abzeichen „abgerechnet“ (Team, ohne Beträge): im Entwurf „Entwurf“, nach dem Senden die Rechnungsnummer
  assert.deepEqual(await abzeichen(), { wsA: 'Entwurf', wsB: 'Entwurf', wsC: 'Entwurf', wsD: 'Entwurf' })
  erwarte(await send('GET', '/api/team/arbeitsblaetter/abgerechnet', { headers: STAFF }), 400, 'Abzeichen ohne ticketId')
  const gesendet = erfolg(await admin('POST', `/rechnungen/${gewinner.id}/senden`), 'Aufwandsrechnung senden').rechnung
  assert.equal(gesendet.status, 'offen')
  assert.deepEqual(await abzeichen(), { wsA: gesendet.number, wsB: gesendet.number, wsC: gesendet.number, wsD: gesendet.number })
  const berechnet = await konto('kfirma', 'paufw')
  assert.equal(berechnet.konto.berechnetCents, 45000)
  assert.deepEqual([berechnet.konto.zeit.offeneMinuten, berechnet.konto.zeit.offenerWertCents], [0, 0])
  assert.deepEqual(erwarte(await admin('GET', '/projektkonten/paufw'), 200, 'Detail nach dem Senden').offeneArbeitsblaetter, [])

  // Storno gibt die Blätter frei, der Betrag zählt nicht mehr
  assert.equal(erfolg(await admin('POST', `/rechnungen/${gewinner.id}/stornieren`), 'Aufwandsrechnung stornieren').rechnung.status, 'storniert')
  assert.equal(aw.docs(PR()).get(gewinner.id).aktiv, false)
  assert.deepEqual(await abzeichen(), {})
  const frei = await konto('kfirma', 'paufw')
  assert.deepEqual([frei.konto.berechnetCents, frei.konto.zeit.offeneMinuten], [0, 270])

  // Entwurf löschen gibt ebenfalls frei
  const teil = await rechnung(aufwand(['wsC']), 'Entwurf mit einem Blatt')
  assert.deepEqual(Object.keys(await abzeichen()), ['wsC'])
  erfolg(await admin('DELETE', `/rechnungen/${teil.id}`), 'Entwurf löschen')
  assert.equal(aw.docs(PR()).get(teil.id)?.aktiv, false)
  assert.deepEqual(await abzeichen(), {})

  // Nie abrechenbar: Abo-Zeit, Kommentar, 0 Minuten, Blatt eines anderen Kunden, unbekanntes Blatt, leere Auswahl
  const vorAblehnung = st.invoices.size
  for (const [ids, was] of [[['wsAbo'], 'Abo-Zeit'], [['wsKomm'], 'Kommentar'], [['wsNull'], '0 Minuten'], [['wsFremd'], 'Blatt eines anderen Kunden'], [['gibtEsNicht'], 'unbekanntes Blatt'], [[], 'leere Auswahl']]) {
    const antwort = await admin('POST', '/rechnungen', aufwand(ids))
    assert.ok([400, 404, 409].includes(antwort.status), `${was}: erwartet 400, 404 oder 409, bekam ${antwort.status} ${JSON.stringify(antwort.body)}`)
  }
  assert.equal(st.invoices.size, vorAblehnung, 'abgelehnte Aufwandsrechnungen hinterlassen nichts bei Stripe')

  // Aufwands-AB: der Satz der jüngsten gültigen Aufwands-AB (auch unbestätigt) gilt, das Projektkonto zeigt „nach Aufwand“
  const aufwandsAb = await entwurf('kfirma', {
    projektId: 'paufw', titel: 'Betreuung Online-Shop', art: 'aufwand', positionen: [], stundensatzCents: 12000, schaetzungMinuten: 600,
    zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zeitquelle: ['tkaufw'],
  })
  assert.equal(aufwandsAb.summeCents, null)
  const gesendeteAb = (await sende(aufwandsAb.id, 'Aufwands-AB senden')).auftrag
  const mitAb = await konto('kfirma', 'paufw')
  assert.deepEqual(mitAb.konto.vereinbart, { festpreisCents: null, aufwand: { stundensatzCents: 12000, schaetzungMinuten: 600 } })
  assert.deepEqual([mitAb.konto.zeit.satzCents, mitAb.konto.zeit.wertCents, mitAb.konto.zeit.offenerWertCents], [12000, 54000, 54000])
  // Annahme (Spec Abschnitt 4: „Noch offen = Wert der noch nicht abgerechneten Zeit“; V4: offenCents ist null ohne Festpreis, der Wert steht in zeit.offenerWertCents)
  assert.equal(mitAb.konto.offenCents, null)
  const ohneId = erwarte(await admin('POST', '/rechnungen/vorschau', { customerId: 'kfirma', projektId: 'paufw', art: 'aufwand', arbeitsblattIds: ALLE }), 200, 'Vorschau, Satz aus der AB')
  assert.deepEqual(ohneId.positionen.map((p) => p.betragCents), [27000, 12000, 15000])
  assert.match(ohneId.positionen[0].beschreibung, /à 120,00\s€$/)
  const mitId = erwarte(await admin('POST', '/rechnungen/vorschau', { customerId: 'kfirma', projektId: 'paufw', art: 'aufwand', arbeitsblattIds: ALLE, auftragId: aufwandsAb.id }), 200, 'Vorschau mit auftragId')
  assert.deepEqual(mitId.customFields.map((f) => f.name), ['Projekt', 'Auftrag', 'Leistungszeitraum'])
  assert.equal(mitId.customFields[1].value, `${gesendeteAb.nummer} vom ${formatDe(berlinDate(new Date(gesendeteAb.gesendetAm)))}`)
})

// --- D4: AB bis Schlussrechnung ------------------------------------------------------------------------------
test('D4 AB bis Schlussrechnung: Vorschau-PDF, Senden mit Mail und Ablage, Bestätigen, Anzahlung, Schlussrechnung mit Minuszeile, Storno, neue Fassung', async () => {
  const stripeVorher = st.calls.length
  const dateienVorher = pdfDateien().length

  // 1) Entwurf mit Umlauten, € und Gedankenstrich (Review Focus 5, echtes pdfkit); das Vorschau-PDF wird nicht gespeichert
  const e = await entwurf('kfirma', {
    projektId: 'pab', zeitquelle: ['tkab'], titel: 'Relaunch: Größe, Äpfel & Öl – ß, € (5 € Aufpreis)',
    positionen: [
      { beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 },
      { beschreibung: 'Programmierung der Seiten (Größe, Äpfel, Öl, ß, € – ohne Abzug)', menge: 2, einzelpreisCents: 15000 },
    ],
    zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
  })
  assert.deepEqual([e.summeCents, e.pdfVorhanden], [80000, false])
  for (const intern of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(intern in e, false, `AuftragTeam enthält kein ${intern}`)
  const vorschau = await holePdf(`/api/admin/finanzen/auftraege/${e.id}/pdf`, { headers: ADMIN })
  assert.equal(vorschau.status, 200)
  assert.match(vorschau.typ, /application\/pdf/)
  assert.ok(istPdf(vorschau.bytes), 'die Vorschau ist ein gültiges PDF')
  assert.equal(pdfDateien().length, dateienVorher, 'die Vorschau legt keine Datei ab')
  const zuFrueh = await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: e.id, art: 'anzahlung', anzahlung: { prozent: 50 } })
  assert.ok([400, 409].includes(zuFrueh.status), `Anzahlung zu einem Entwurf: ${zuFrueh.status} ${JSON.stringify(zuFrueh.body)}`)
  assert.equal(stripeAufrufe(stripeVorher, 'POST', '/v1/invoices').length, 0, 'zu einem Entwurf entsteht keine Rechnung')

  // 2) Senden: Nummer, PDF in der Ablage mit Prüfsumme, Mail mit beiden PDFs und Link, Token nur als Hash
  const s = await sende(e.id)
  const a = s.auftrag
  assert.deepEqual([a.status, a.jahr, a.nummer], ['versendet', JAHR, formatNummer(JAHR, a.laufnummer)])
  assert.deepEqual([a.mailStatus, a.gesendetAn, a.agbFassung, a.pdfVorhanden], ['gesendet', 'kfirma@kunde.example', AGB_FASSUNG, true])
  assert.ok(a.texteFassung)
  assert.deepEqual(adressen(s.mail.to), ['kfirma@kunde.example'])
  assert.deepEqual(adressen(s.mail.bcc), [], 'V6: K31 geht ohne BCC an den Kunden; die Teamkopie ohne Link prüft sende()')
  assert.ok(s.mail.subject.startsWith(`Ihre Auftragsbestätigung ${a.nummer}`), s.mail.subject)
  const abAnhang = s.mail.attachments.find((x) => x.filename === `${a.nummer}.pdf`)
  const agbAnhang = s.mail.attachments.find((x) => x.filename === `WEBklar-AGB-${AGB_FASSUNG}.pdf`)
  assert.ok(abAnhang && agbAnhang, `AB-PDF und AGB-PDF hängen an (${s.mail.attachments.map((x) => x.filename).join(', ')})`)
  const abBytes = Buffer.from(abAnhang.content, 'base64')
  assert.ok(istPdf(abBytes) && istPdf(Buffer.from(agbAnhang.content, 'base64')), 'beide Anhänge sind gültige PDFs')
  const doc = aw.docs(AB()).get(a.id)
  assert.match(doc.pdfDatei, /^kfirma\/[A-Za-z0-9_-]+\.pdf$/)
  const datei = path.join(ABLAGE, doc.pdfDatei)
  assert.equal(sha256(fs.readFileSync(datei)), doc.pdfSha256, 'die Prüfsumme am AB passt zur Datei')
  assert.equal(doc.pdfSha256, sha256(abBytes), 'gemailtes und abgelegtes PDF sind dasselbe')
  assert.equal(fs.statSync(datei).mode & 0o777, 0o600)
  assert.equal(fs.statSync(path.dirname(datei)).mode & 0o777, 0o700)
  assert.equal(doc.tokenHash, hashToken(s.token), 'gespeichert wird nur der Hash')
  assert.match(doc.tokenHash, /^[0-9a-f]{64}$/)
  assert.equal(JSON.stringify(doc).includes(s.token), false, 'der Token steht nirgends im Dokument')
  const tage = (new Date(doc.tokenGueltigBis).getTime() - Date.now()) / 86_400_000
  assert.ok(tage > 59 && tage <= 60.01, `Token 60 Tage gültig, war ${tage}`)
  assert.equal(sha256((await holePdf(`/api/admin/finanzen/auftraege/${a.id}/pdf`, { headers: ADMIN })).bytes), doc.pdfSha256, 'nach dem Senden liefert der Admin die gespeicherte Datei')
  erwarte(await admin('PATCH', `/auftraege/${a.id}`, { titel: 'Nachträglich geändert' }), 409, 'gesendete AB ändern')
  erwarte(await admin('DELETE', `/auftraege/${a.id}`), 409, 'gesendete AB löschen')

  // 3) Öffentliche Ansicht: nur die Whitelist, keine Interna
  const sicht = erwarte(await ansicht(s.token, 11), 200, 'Ansicht per Link').auftrag
  assert.deepEqual([sicht.nummer, sicht.status, sicht.summeCents, sicht.kundenart], [a.nummer, 'versendet', 80000, 'unternehmen'])
  assert.deepEqual(sicht.positionen[0], { beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000, summeCents: 50000 })
  assert.equal(sicht.positionen[1].summeCents, 30000)
  assert.equal(sicht.agbUrl, `/rechtliches/agb-${AGB_FASSUNG}.pdf`)
  assert.equal(sicht.texte.fruehbeginn, null, 'Unternehmen: keine Frühbeginn-Erklärung')
  assert.ok(sicht.texte.bestaetigung)
  assert.equal(sicht.texte.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', 'V3: der Kleinunternehmer-Satz kommt aus texte.js')
  const roh = JSON.stringify(sicht)
  for (const intern of ['kfirma@kunde.example', '"kfirma"', 'tokenHash', 'pdfDatei', 'pdfSha256', 'gesendetAn', 'kundeJson']) assert.equal(roh.includes(intern), false, `öffentliche Ansicht enthält ${intern}`)
  const linkPdf = await holePdf(`/api/auftrag/${s.token}/pdf`, { headers: ip(11) })
  assert.deepEqual([linkPdf.status, sha256(linkPdf.bytes)], [200, doc.pdfSha256])

  // 4) Bestätigen: ohne Häkchen nichts; per Link; Protokoll am AB ohne IP; K32 an den Kunden, T22 ans Team; zweites Mal folgenlos
  erwarte(await bestaetigen(s.token, 11, {}), 400, 'Bestätigen ohne Angabe')
  erwarte(await bestaetigen(s.token, 11, { bestaetigt: false }), 400, 'Bestätigen mit abgewähltem Häkchen')
  assert.equal((await lies(a.id)).status, 'versendet')
  const vorBest = mails.sent.length
  const best = erwarte(await bestaetigen(s.token, 11), 200, 'Bestätigen per Link').auftrag
  assert.equal(best.status, 'bestaetigt')
  assert.ok(best.bestaetigtAm)
  const bestDoc = aw.docs(AB()).get(a.id)
  assert.deepEqual([bestDoc.bestaetigtUeber, bestDoc.bestaetigtPdfSha256, Boolean(bestDoc.fruehbeginn)], ['link', bestDoc.pdfSha256, false])
  assert.ok(bestDoc.texteFassung)
  assert.equal(JSON.stringify(bestDoc).includes('203.0.113.'), false, 'keine Client-Adresse am AB')
  const k32 = await warteBis(() => findeMail(vorBest, new RegExp(`Bestätigung zu ${a.nummer}`)), { was: 'K32' })
  assert.deepEqual(adressen(k32.to), ['kfirma@kunde.example'])
  const t22 = await warteBis(() => findeMail(vorBest, new RegExp(`Auftrag ${a.nummer} bestätigt`)), { was: 'T22' })
  assert.match(t22.subject, /^\[Aufträge\] Auftrag AB-\d{4}-\d{4} bestätigt: /, 'V6: Betreff von T22 mit dem Präfix [Aufträge]')
  for (const adresse of config.chatNotify.emails) assert.ok(adressen(t22.to).includes(adresse), `T22 an ${adresse}`)
  for (const mail of [k32, t22]) for (const token of GESEHEN) assert.equal(`${mail.html}${mail.text}`.includes(token), false, `${mail.subject}: kein Token in der Mail`)
  assert.ok(`${t22.html}${t22.text}`.includes('/customers/kfirma?tab=invoices'), 'T22 verlinkt den Reiter im Ticketsystem')
  const nachBest = mails.sent.length
  const zweite = erwarte(await bestaetigen(s.token, 11), 200, 'zweite Bestätigung').auftrag
  assert.equal(new Date(zweite.bestaetigtAm).getTime(), new Date(best.bestaetigtAm).getTime(), 'die erste Bestätigung zählt')
  await pause(150)
  assert.equal(mails.sent.length, nachBest, 'die zweite Bestätigung verschickt nichts')

  // 5) Anzahlung 50 % aus dem Zahlungsplan, Zusatzfeld „Auftrag“, nie mehr als der Rest
  const anz = await rechnung({ customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'anzahlung', anzahlung: { prozent: 50 }, senden: true, idempotencyKey: 'ablauf-anzahlung-1' }, 'Anzahlung')
  assert.deepEqual([anz.art, anz.totalCents, anz.status, anz.auftragNummer], ['anzahlung', 40000, 'offen', a.nummer])
  assert.match(zeilen(anz.id)[0].description, /Anzahlung/)
  assert.ok(zeilen(anz.id)[0].description.includes(a.nummer))
  const anzAnlage = stripeAufrufe(stripeVorher, 'POST', '/v1/invoices').find((c) => c.body['metadata[art]'] === 'anzahlung')
  assert.deepEqual([anzAnlage.body['custom_fields[0][name]'], anzAnlage.body['custom_fields[1][name]']], ['Projekt', 'Auftrag'])
  assert.equal(anzAnlage.body['custom_fields[1][value]'], `${a.nummer} vom ${formatDe(berlinDate(new Date(a.gesendetAm)))}`)
  const zuViel = await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'anzahlung', anzahlung: { betragCents: 50000 } })
  assert.ok([400, 409].includes(zuViel.status), `Anzahlung über den Rest: ${zuViel.status} ${JSON.stringify(zuViel.body)}`)
  bezahlen(anz.id)
  const kA = await konto('kfirma', 'pab')
  assert.deepEqual([kA.konto.vereinbart.festpreisCents, kA.konto.berechnetCents, kA.konto.bezahltCents, kA.konto.offenCents], [80000, 40000, 40000, 40000])

  // 6) Review Focus 3: ein offener Entwurf zur AB blockiert die Schlussrechnung; Minuszeilen baut nur der Server
  const teil = await rechnung({ customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'anzahlung', anzahlung: { betragCents: 10000, bezeichnung: 'Teilrechnung' } }, 'Teilrechnung als Entwurf')
  erwarte(await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'schluss', senden: true }), 409, 'Schlussrechnung neben offenem Entwurf', /Entwurf/)
  erfolg(await admin('DELETE', `/rechnungen/${teil.id}`), 'Entwurf löschen')
  erwarte(await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'schluss', items: [{ description: 'Abzug', quantity: 1, unitAmountCents: -100 }] }), 400, 'Minuszeile vom Client')

  // 7) Schlussrechnung mit Nachtrag und Minuszeile; die AB ist danach „abgerechnet“, „noch offen“ nie unter 0
  const schluss = await rechnung({
    customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'schluss', senden: true, idempotencyKey: 'ablauf-schluss-1',
    items: [{ description: 'Zusätzliche Unterseite (Nachtrag)', quantity: 1, unitAmountCents: 10000 }],
  }, 'Schlussrechnung')
  assert.deepEqual([schluss.art, schluss.totalCents, schluss.status], ['schluss', 50000, 'offen'])
  const z = zeilen(schluss.id)
  assert.deepEqual(z.map((x) => x.amount), [50000, 30000, 10000, -40000], 'AB-Positionen, Nachtrag, dann die Minuszeile')
  assert.deepEqual([z[0].quantity, z[1].quantity], [1, 2])
  assert.match(z.at(-1).description, /abzüglich Anzahlung/)
  assert.ok(z.at(-1).description.includes(anz.number), 'die Minuszeile nennt die Rechnung der Anzahlung')
  assert.equal((await lies(a.id)).status, 'abgerechnet')
  const kS = await konto('kfirma', 'pab')
  assert.deepEqual([kS.konto.vereinbart.festpreisCents, kS.konto.berechnetCents, kS.konto.offenCents], [80000, 90000, 0])
  const nochmal = await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: a.id, art: 'schluss' })
  assert.ok([400, 409].includes(nochmal.status), `zweite Schlussrechnung: ${nochmal.status} ${JSON.stringify(nochmal.body)}`)

  // 8) Storno der Schlussrechnung setzt die AB zurück (auf „bestätigt“, weil bestaetigtAm gesetzt ist)
  assert.equal(erfolg(await admin('POST', `/rechnungen/${schluss.id}/stornieren`), 'Schlussrechnung stornieren').rechnung.status, 'storniert')
  assert.equal((await lies(a.id)).status, 'bestaetigt')
  assert.equal(aw.docs(PR()).get(schluss.id).aktiv, false)
  const kZ = await konto('kfirma', 'pab')
  assert.deepEqual([kZ.konto.berechnetCents, kZ.konto.offenCents], [40000, 40000])

  // 9) Neue Fassung mit anderem Preis: die Anzahlung wandert mit (projektRechnungen und Projektkonto folgen)
  const neu = erfolg(await admin('POST', `/auftraege/${a.id}/neue-fassung`), 'neue Fassung').auftrag
  assert.deepEqual([neu.status, neu.ersetztId, neu.nummer], ['entwurf', a.id, ''])
  const geaendert = erfolg(await admin('PATCH', `/auftraege/${neu.id}`, {
    positionen: [
      { beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 },
      { beschreibung: 'Programmierung der Seiten (Größe, Äpfel, Öl, ß, € – ohne Abzug)', menge: 2, einzelpreisCents: 17500 },
    ],
  }), 'Entwurf der neuen Fassung ändern').auftrag
  assert.equal(geaendert.summeCents, 85000)
  assert.equal((await lies(a.id)).status, 'bestaetigt', 'bis zum Senden der neuen gilt die alte AB')
  await sende(neu.id, 'neue Fassung senden')
  const alt = await lies(a.id)
  assert.deepEqual([alt.status, alt.ersetztDurchId], ['ersetzt', neu.id])
  assert.equal(aw.docs(PR()).get(anz.id).auftragId, neu.id, 'die Anzahlung gehört jetzt zur neuen AB')
  // Die Stripe-Metadaten (auftragId, auftragNummer) der alten Rechnung bleiben historisch (entschieden in der Planrunde, Vertragsfrage 3 dieser Datei, L3):
  // maßgeblich ist projektRechnungen.auftragId; die Metadaten werden deshalb nicht geprüft.
  const kN = await konto('kfirma', 'pab')
  assert.deepEqual([kN.konto.vereinbart.festpreisCents, kN.konto.berechnetCents, kN.konto.offenCents], [85000, 40000, 45000], 'die ersetzte AB zählt nicht mehr als vereinbart')
  const schlussNeu = await rechnung({ customerId: 'kfirma', projektId: 'pab', auftragId: neu.id, art: 'schluss', senden: true, idempotencyKey: 'ablauf-schluss-2' }, 'Schlussrechnung zur neuen AB')
  assert.equal(schlussNeu.totalCents, 45000)
  assert.deepEqual(zeilen(schlussNeu.id).map((x) => x.amount), [50000, 35000, -40000])
  assert.equal((await lies(neu.id)).status, 'abgerechnet')
})

// --- D5: Storno einer Anzahlung ------------------------------------------------------------------------------
test('D5 Schlussrechnung neben stornierter Anzahlung: ein Storno zählt nicht, die Schlussrechnung hat keine Minuszeile', async () => {
  const e = await entwurf('kfirma', {
    projektId: 'pstorno', zeitquelle: ['tkstorno'], titel: 'Landingpage',
    positionen: [{ beschreibung: 'Landingpage', menge: 1, einzelpreisCents: 30000 }], zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
  })
  const { auftrag } = await sende(e.id)
  const anz = await rechnung({ customerId: 'kfirma', projektId: 'pstorno', auftragId: auftrag.id, art: 'anzahlung', anzahlung: { prozent: 50 }, senden: true }, 'Anzahlung')
  assert.equal(anz.totalCents, 15000)
  assert.equal((await konto('kfirma', 'pstorno')).konto.berechnetCents, 15000)
  assert.equal(erfolg(await admin('POST', `/rechnungen/${anz.id}/stornieren`), 'Anzahlung stornieren').rechnung.status, 'storniert')
  const k = await konto('kfirma', 'pstorno')
  assert.deepEqual([k.konto.berechnetCents, k.konto.offenCents], [0, 30000], 'die stornierte Anzahlung zählt nicht')
  const schluss = await rechnung({ customerId: 'kfirma', projektId: 'pstorno', auftragId: auftrag.id, art: 'schluss' }, 'Schlussrechnung ohne Abzug')
  assert.equal(schluss.totalCents, 30000)
  assert.deepEqual(zeilen(schluss.id).map((x) => x.amount), [30000], 'keine Minuszeile für eine stornierte Rechnung')
  erfolg(await admin('DELETE', `/rechnungen/${schluss.id}`), 'Entwurf löschen')
})

// --- D6: alte Links ------------------------------------------------------------------------------------------
test('D6 Alte Links: Erinnerung, Ablauf, neue Fassung und Storno machen den alten Link unbrauchbar, die Seite nennt den Grund', async () => {
  const e = await entwurf('kfirma', { titel: 'Pflege und Wartung der Seite', positionen: [{ beschreibung: 'Einrichtung', menge: 1, einzelpreisCents: 30000 }] })
  const s1 = await sende(e.id)
  assert.equal(erwarte(await ansicht(s1.token, 21), 200, 'erster Link').auftrag.status, 'versendet')

  // Erinnerung: neuer Link, der alte gilt nicht mehr
  const vorErinnerung = mails.sent.length
  const erinnert = erfolg(await admin('POST', `/auftraege/${e.id}/erinnern`), 'Erinnerung senden')
  assert.equal(erinnert.mailFehler, false)
  const erinnerung = k31Mails(vorErinnerung, 'kfirma')
  assert.equal(erinnerung.anKunde.length, 1, 'die Erinnerung verschickt K31 erneut an den Kunden')
  const mail2 = erinnerung.anKunde[0]
  const token2 = tokenAus(mail2)
  pruefeTeamkopie(mail2, token2, erinnerung.teamkopien, 'Erinnerung') // V6: die Teamkopie gibt es auch bei Erinnerungen
  // Annahme (nicht im Vertrag): das Wort „Erinnerung“ steht nur im Block erinnerung der Vorlage K31
  assert.match(mail2.text, /Erinnerung/, 'K31 als Erinnerung trägt den Erinnerungs-Hinweis')
  assert.notEqual(token2, s1.token)
  erwarte(await ansicht(s1.token, 21), 404, 'alter Link nach Erinnerung', UNBEKANNT)
  erwarte(await bestaetigen(s1.token, 21), 404, 'Bestätigen mit altem Link', UNBEKANNT)
  assert.equal((await lies(e.id)).status, 'versendet', 'mit dem alten Link wurde nichts bestätigt')
  erwarte(await ansicht(token2, 21), 200, 'neuer Link')

  // Ablauf (60 Tage): das Datum im Dokument kippen und wieder zurück
  const frisch = aw.docs(AB()).get(e.id)
  aw.seed(AB(), { ...frisch, tokenGueltigBis: new Date(Date.now() - 1000).toISOString() })
  erwarte(await ansicht(token2, 21), 404, 'abgelaufener Link', UNBEKANNT)
  erwarte(await bestaetigen(token2, 21), 404, 'Bestätigen mit abgelaufenem Link', UNBEKANNT)
  aw.seed(AB(), frisch)
  erwarte(await ansicht(token2, 21), 200, 'der Link gilt wieder, sobald das Datum stimmt')

  // Neue Fassung: bis zum Senden gilt die alte AB, danach 410 mit dem Grund
  const neu = erfolg(await admin('POST', `/auftraege/${e.id}/neue-fassung`), 'neue Fassung').auftrag
  erfolg(await admin('PATCH', `/auftraege/${neu.id}`, { positionen: [{ beschreibung: 'Einrichtung', menge: 1, einzelpreisCents: 35000 }] }), 'Entwurf ändern')
  erwarte(await ansicht(token2, 21), 200, 'die alte AB gilt bis zum Senden der neuen')
  const s2 = await sende(neu.id, 'neue Fassung senden')
  erwarte(await ansicht(token2, 21), 410, 'Link der ersetzten AB', 'Diese Auftragsbestätigung wurde ersetzt.')
  erwarte(await bestaetigen(token2, 21), 410, 'Bestätigen über die ersetzte AB', /ersetzt/)
  assert.equal(aw.docs(AB()).get(e.id).bestaetigtAm ?? null, null, 'die ersetzte AB wurde nie bestätigt')
  erwarte(await admin('POST', `/auftraege/${e.id}/erinnern`), 409, 'Erinnerung zu einer ersetzten AB')

  // Storno (von Hand, ohne Mail)
  const vorStorno = mails.sent.length
  const storniert = erfolg(await admin('POST', `/auftraege/${neu.id}/stornieren`), 'Storno').auftrag
  assert.equal(storniert.status, 'storniert')
  assert.ok(storniert.storniertAm)
  assert.equal(mails.sent.length, vorStorno, 'ein Storno schickt keine Mail')
  erwarte(await ansicht(s2.token, 21), 410, 'Link nach Storno', 'Diese Auftragsbestätigung wurde storniert.')
  erwarte(await bestaetigen(s2.token, 21), 410, 'Bestätigen nach Storno', /storniert/)
  erwarte(await admin('POST', `/auftraege/${neu.id}/stornieren`), 409, 'zweiter Storno')
  erwarte(await admin('POST', `/auftraege/${neu.id}/senden`), 409, 'Senden nach Storno')
})

// --- D6b: Bestätigen gegen Storno ----------------------------------------------------------------------------
test('D6b Bestätigen und Storno gleichzeitig: eine stornierte AB lebt nie wieder auf', async () => {
  for (let runde = 1; runde <= 5; runde += 1) {
    const s = await sende((await entwurf('kfirma', { titel: `Rennen ${runde}` })).id, `AB Rennen ${runde}`)
    const [best, storno] = await Promise.all([bestaetigen(s.token, 23), admin('POST', `/auftraege/${s.auftrag.id}/stornieren`)])
    assert.ok([200, 410].includes(best.status), `Runde ${runde}: Bestätigen ${best.status} ${JSON.stringify(best.body)}`)
    assert.ok([200, 409].includes(storno.status), `Runde ${runde}: Storno ${storno.status} ${JSON.stringify(storno.body)}`)
    const danach = await lies(s.auftrag.id)
    // Ein Storno nach der Bestätigung ist erlaubt (Stand „storniert“, bestaetigtAm bleibt als Verlauf). Verboten ist der umgekehrte Fall:
    // die Bestätigung überschreibt den Storno („bestätigt“ mit gesetztem storniertAm). L3 und L5 müssen dieselbe Sperre je AB benutzen (sperreAuftrag).
    assert.equal(danach.status === 'bestaetigt' && Boolean(danach.storniertAm), false, `Runde ${runde}: die stornierte AB lebte wieder auf (${danach.status})`)
    if (storno.status === 200) assert.equal(danach.status, 'storniert', `Runde ${runde}: der Storno gelang, Stand ${danach.status}`)
  }
})

// --- D7: Mail scheitert --------------------------------------------------------------------------------------
test('D7 Mail scheitert: die AB bleibt versendet, mailStatus fehler, die Erinnerung sendet erneut', async () => {
  const e = await entwurf('kfirma', { titel: 'Mail-Ausfall' })
  setMailTransportForTests({ sendMail: async () => { throw new Error('Nachbau: SMTP nicht erreichbar') } })
  let versendet
  try {
    const body = erwarte(await admin('POST', `/auftraege/${e.id}/senden`), 200, 'Senden bei Mail-Ausfall')
    assert.equal(body.mailFehler, true)
    versendet = body.auftrag
  } finally {
    setMailTransportForTests(mails)
  }
  assert.deepEqual([versendet.status, versendet.mailStatus, versendet.pdfVorhanden], ['versendet', 'fehler', true])
  assert.match(versendet.nummer, /^AB-\d{4}-\d{4}$/, 'die Nummer ist vergeben')
  assert.ok(String(aw.docs(AB()).get(e.id).mailFehler).length <= 500)
  const vor = mails.sent.length
  const neu = erfolg(await admin('POST', `/auftraege/${e.id}/erinnern`), 'Erneut senden')
  assert.deepEqual([neu.mailFehler, neu.auftrag.mailStatus], [false, 'gesendet'])
  const mail = k31Mails(vor, 'kfirma').anKunde[0]
  assert.ok(mail, 'die Mail ging jetzt raus')
  // L3 (Übersicht, Nachträge): nach einem Mail-Fehler ist „Erneut senden“ eine normale K31, keine Erinnerung; Annahme (nicht im Vertrag): „Erinnerung“ steht nur im Erinnerungs-Block
  assert.doesNotMatch(mail.text, /Erinnerung/, 'normale K31 ohne Erinnerungs-Hinweis')
  erwarte(await ansicht(tokenAus(mail), 22), 200, 'Link der neuen Mail')
})

// --- D8: Privatperson ----------------------------------------------------------------------------------------
test('D8 Privatperson: zusätzliche PDF-Seiten, Frühbeginn nur auf Verlangen, Bestätigen im Portal, Kundenansicht gesperrt, Widerruf', async () => {
  const position = [{ beschreibung: 'Erstellung der Website', menge: 1, einzelpreisCents: 90000 }]
  const privat = (titel) => entwurf('kprivat', { titel, positionen: position })
  const pdfSeiten = ({ mail, auftrag }) => seiten(Buffer.from(mail.attachments.find((x) => x.filename === `${auftrag.nummer}.pdf`).content, 'base64'))

  // 1) Die AB an eine Privatperson hat zusätzliche Seiten (Widerrufsbelehrung, Muster-Widerrufsformular)
  const firma = await sende((await entwurf('kfirma', { titel: 'Website für die Firma', positionen: position })).id, 'AB Unternehmen')
  const ohneFH = await sende((await privat('Website Privat ohne Frühbeginn')).id, 'AB Privatperson')
  assert.ok(pdfSeiten(ohneFH) > pdfSeiten(firma), 'Privatperson: zusätzliche Seiten')
  assert.deepEqual(adressen(ohneFH.mail.to), ['kprivat@kunde.example'])

  // 2) Öffentliche Ansicht: Erklärung zum Frühbeginn vorhanden, frühester Beginn = Tag nach dem Fristende
  const frist = addDays(withdrawalDeadline(new Date(ohneFH.auftrag.gesendetAm)).lastDay, 1)
  const sicht = erwarte(await ansicht(ohneFH.token, 31), 200, 'Ansicht Privatperson').auftrag
  assert.equal(sicht.kundenart, 'privat')
  assert.ok(typeof sicht.texte.fruehbeginn === 'string' && sicht.texte.fruehbeginn.length > 20, 'Text der Frühbeginn-Erklärung')
  assert.equal(sicht.fruehesterBeginn, frist)

  // 3) Ohne Frühbeginn: der Wartetag steht in K32, T22 und im Ticketsystem
  const vor3 = mails.sent.length
  const b1 = erwarte(await bestaetigen(ohneFH.token, 31, { bestaetigt: true, fruehbeginn: false }), 200, 'Bestätigen ohne Frühbeginn').auftrag
  assert.deepEqual([b1.fruehbeginn, b1.fruehesterBeginn], [false, frist])
  const k32 = await warteBis(() => findeMail(vor3, new RegExp(`Bestätigung zu ${ohneFH.auftrag.nummer}`)), { was: 'K32 Privatperson' })
  const t22 = await warteBis(() => findeMail(vor3, new RegExp(`Auftrag ${ohneFH.auftrag.nummer} bestätigt`)), { was: 'T22 Privatperson' })
  assert.match(t22.subject, /^\[Aufträge\] /, 'V6: Präfix [Aufträge]')
  for (const mail of [k32, t22]) assert.ok(`${mail.html}${mail.text}`.includes(formatDe(frist)), `${mail.subject}: nennt den frühesten Beginn ${formatDe(frist)}`)
  const team = await lies(ohneFH.auftrag.id)
  assert.deepEqual([team.fruehbeginn, team.fruehesterBeginn], [false, frist])
  assert.ok(aw.docs(AB()).get(ohneFH.auftrag.id).fruehbeginnFassung, 'L5: die angezeigte Fassung der Erklärung wird bei Privatpersonen auch ohne Haken gespeichert')

  // 4) Mit Frühbeginn: Erklärung samt Fassung gespeichert, kein Wartetag; ein Unternehmen kann keinen Frühbeginn erklären
  const mitFH = await sende((await privat('Website Privat mit Frühbeginn')).id, 'zweite AB Privatperson')
  const vor4 = mails.sent.length
  const b2 = erwarte(await bestaetigen(mitFH.token, 31, { bestaetigt: true, fruehbeginn: true }), 200, 'Bestätigen mit Frühbeginn').auftrag
  assert.deepEqual([b2.fruehbeginn, b2.fruehesterBeginn], [true, null])
  const fhDoc = aw.docs(AB()).get(mitFH.auftrag.id)
  assert.equal(fhDoc.fruehbeginn, true)
  assert.ok(fhDoc.fruehbeginnFassung)
  const k32b = await warteBis(() => findeMail(vor4, new RegExp(`Bestätigung zu ${mitFH.auftrag.nummer}`)), { was: 'K32 mit Frühbeginn' })
  assert.equal(`${k32b.html}${k32b.text}`.includes(formatDe(frist)), false, 'mit Frühbeginn gibt es keinen Wartetag')
  // V6: K32 gibt FRUEHBEGINN_TEXT wörtlich wieder (kein Satz „Ihr Widerrufsrecht bleibt unberührt“); Annahme (nicht im Vertrag): bis auf Leerraum unverändert
  const ohneLeerraum = (text) => String(text).replace(/\s+/g, ' ').trim()
  assert.ok(ohneLeerraum(k32b.text).includes(ohneLeerraum(FRUEHBEGINN_TEXT)), 'K32 enthält FRUEHBEGINN_TEXT')
  assert.doesNotMatch(k32b.text, /Widerrufsrecht bleibt[^.]*unberührt/i)
  // Annahme (V4: „fruehbeginn nur bei privat“): bei einem Unternehmen wird der Wert ignoriert, nicht abgelehnt
  const firmaBest = erwarte(await bestaetigen(firma.token, 31, { bestaetigt: true, fruehbeginn: true }), 200, 'Unternehmen bestätigt').auftrag
  assert.equal(firmaBest.fruehbeginn, false)
  assert.equal(Boolean(aw.docs(AB()).get(firma.auftrag.id).fruehbeginn), false)
  assert.equal(aw.docs(AB()).get(firma.auftrag.id).fruehbeginnFassung || '', '', 'L5: bei Unternehmen bleibt die Fassung leer')

  // 5) Portal-Weg: eigene Liste ohne Entwürfe, andere Kunden sehen nichts, Bestätigen mit Sitzung, Kundenansicht gesperrt
  const portal = await sende((await privat('Website Privat im Portal')).id, 'dritte AB Privatperson')
  const offenerEntwurf = await privat('Entwurf bleibt unsichtbar')
  const meine = kundeCookie('kprivat')
  const fremde = kundeCookie('kfremd')
  const liste = erfolg(await send('GET', '/api/auftraege', { cookie: meine }), 'Portal-Liste')
  assert.equal(liste.aktiv, true)
  const eintrag = liste.auftraege.find((x) => x.id === portal.auftrag.id)
  assert.deepEqual([eintrag.status, eintrag.kannBestaetigen], ['versendet', true])
  assert.equal(liste.auftraege.some((x) => x.id === offenerEntwurf.id || x.status === 'entwurf'), false, 'keine Entwürfe im Portal')
  assert.equal(erfolg(await send('GET', '/api/auftraege', { cookie: fremde }), 'Liste eines anderen Kunden').auftraege.length, 0)
  erwarte(await send('GET', `/api/auftraege/${portal.auftrag.id}/pdf`, { cookie: fremde }), 404, 'fremdes PDF')
  erwarte(await send('POST', `/api/auftraege/${portal.auftrag.id}/bestaetigen`, { cookie: fremde, body: { bestaetigt: true, fruehbeginn: false } }), 404, 'fremde AB bestätigen')
  erwarte(await send('GET', `/api/auftraege/${offenerEntwurf.id}/pdf`, { cookie: meine }), 404, 'ein Entwurf liefert kein PDF')
  const eigenesPdf = await holePdf(`/api/auftraege/${portal.auftrag.id}/pdf`, { cookie: meine })
  assert.deepEqual([eigenesPdf.status, sha256(eigenesPdf.bytes)], [200, aw.docs(AB()).get(portal.auftrag.id).pdfSha256])
  const kv = { cookie: kundenansichtCookie({ customerId: 'kprivat' }), headers: { 'x-kundenansicht': KV } }
  const gesperrt = await send('POST', `/api/auftraege/${portal.auftrag.id}/bestaetigen`, { ...kv, body: { bestaetigt: true, fruehbeginn: false } })
  assert.deepEqual([gesperrt.status, gesperrt.body.code], [403, 'view_blocked'])
  const gesperrtLink = await bestaetigen(portal.token, 32, { bestaetigt: true, fruehbeginn: false }, kv)
  assert.deepEqual([gesperrtLink.status, gesperrtLink.body.code], [403, 'view_blocked'], 'auch der Link-Weg mit dem Cookie der Kundenansicht')
  const ansichtListe = erwarte(await send('GET', '/api/auftraege', kv), 200, 'Lesen bleibt in der Kundenansicht erlaubt')
  assert.equal(ansichtListe.aktiv, true)
  assert.equal(ansichtListe.auftraege.find((x) => x.id === portal.auftrag.id).kannBestaetigen, false, 'V3: in der Kundenansicht ist Bestätigen nicht möglich')
  assert.equal((await lies(portal.auftrag.id)).status, 'versendet', 'in der Kundenansicht wurde nichts bestätigt')
  const imPortal = erwarte(await send('POST', `/api/auftraege/${portal.auftrag.id}/bestaetigen`, { cookie: meine, body: { bestaetigt: true, fruehbeginn: true } }), 200, 'Bestätigen im Portal').auftrag
  assert.equal(imPortal.status, 'bestaetigt')
  assert.equal(aw.docs(AB()).get(portal.auftrag.id).bestaetigtUeber, 'portal')
  const danach = erfolg(await send('GET', '/api/auftraege', { cookie: meine }), 'Liste danach').auftraege.find((x) => x.id === portal.auftrag.id)
  assert.equal(danach.kannBestaetigen, false)

  // 6) Widerruf eines Projektauftrags: nur mit passender Nummer und E-Mail, nur Privatperson; der alte Link ist danach tot
  const widerruf = (body) => send('POST', '/api/abo/public/withdraw', { body: { name: 'Petra Privat', order: 'projekt', ...body }, headers: ip(33) })
  erfolg(await widerruf({ email: 'kprivat@kunde.example', abNummer: 'AB-1999-0001' }), 'falsche Nummer')
  erfolg(await widerruf({ email: 'andere@kunde.example', abNummer: mitFH.auftrag.nummer }), 'falsche Adresse')
  const vorFirma = mails.sent.length
  erfolg(await widerruf({ email: 'kfirma@kunde.example', abNummer: firma.auftrag.nummer }), 'Unternehmen')
  const k04Firma = await warteBis(() => findeMail(vorFirma, /Eingangsbestätigung Ihres Widerrufs/), { was: 'K04 für ein Unternehmen' })
  assert.match(k04Firma.text, /kein gesetzliches Widerrufsrecht/i, 'V6: K04-Block projekt nennt bei Unternehmen den Hinweis')
  // V5: die AB-Nummer ist optional; ohne sie (oder mit einer ungültigen) gibt es nur die Mails mit dem Vermerk „ungeprüft“, nie eine Statusänderung
  const vorOhne = mails.sent.length
  erfolg(await widerruf({ email: 'kprivat@kunde.example' }), 'ohne AB-Nummer')
  const t09Ohne = await warteBis(() => findeMail(vorOhne, /WIDERRUF eingegangen/), { was: 'T09 ohne AB-Nummer' })
  assert.match(`${t09Ohne.text}`, /ungeprüft/i, 'Vermerk „ungeprüft“')
  assert.equal((await lies(mitFH.auftrag.id)).status, 'bestaetigt')
  assert.equal((await lies(firma.auftrag.id)).status, 'bestaetigt', 'ein Unternehmen widerruft nicht')
  const vor6 = mails.sent.length
  erfolg(await widerruf({ email: 'KPrivat@Kunde.Example', abNummer: mitFH.auftrag.nummer }), 'richtiger Widerruf (E-Mail in anderer Schreibweise)')
  const widerrufen = await lies(mitFH.auftrag.id)
  assert.equal(widerrufen.status, 'widerrufen')
  assert.ok(widerrufen.widerrufenAm)
  const k04 = await warteBis(() => findeMail(vor6, /Eingangsbestätigung Ihres Widerrufs/), { was: 'K04 an die Privatperson' })
  const t09 = await warteBis(() => findeMail(vor6, /WIDERRUF eingegangen/), { was: 'T09 ans Team' })
  for (const mail of [k04, t09]) assert.ok(`${mail.text}`.includes('Projektauftrag') && `${mail.text}`.includes(mitFH.auftrag.nummer), `V6: Block projekt in ${mail.subject} nennt Projektauftrag und AB-Nummer`)
  erwarte(await ansicht(mitFH.token, 33), 410, 'Link nach Widerruf', 'Diese Auftragsbestätigung wurde widerrufen.')
  erwarte(await bestaetigen(mitFH.token, 33), 410, 'Bestätigen nach Widerruf', /widerrufen/)
})

// --- D9: unvollständige Kundendaten --------------------------------------------------------------------------
test('D9 Unvollständige Kundendaten: klare Meldung vor Nummer, PDF und Mail, keine Nummer verbraucht', async () => {
  const nummernVorher = vergebeneNummern()
  const dateienVorher = pdfDateien().length
  const mailsVorher = mails.sent.length
  const e = await entwurf('kluecke', { titel: 'AB für einen unvollständigen Kunden' })
  const faelle = [
    ['Kundenart fehlt', {}, 400],
    ['E-Mail-Adresse fehlt', { kundenart: 'unternehmen', email: '' }, 400],
    ['Anschrift unvollständig', { kundenart: 'unternehmen', street: '' }, 409],
    ['Interessent statt Kunde', { kundenart: 'unternehmen', customerStatus: 'lead' }, 400],
  ]
  for (const [was, patch, status] of faelle) {
    setzeKunde('kluecke', patch)
    const antwort = await admin('POST', `/auftraege/${e.id}/senden`)
    assert.equal(antwort.status, status, `${was}: erwartet ${status}, bekam ${antwort.status} ${JSON.stringify(antwort.body)}`)
    assert.ok(typeof antwort.body?.error === 'string' && antwort.body.error.length > 5, `${was}: verständliche Meldung`)
    if (status === 409) assert.match(antwort.body.error, /Adresse des Kunden unvollständig/)
  }
  assert.deepEqual(vergebeneNummern(), nummernVorher, 'keine Nummer verbraucht')
  assert.equal(pdfDateien().length, dateienVorher, 'kein PDF abgelegt')
  assert.equal(mails.sent.length, mailsVorher, 'keine Mail')
  assert.equal((await lies(e.id)).status, 'entwurf')
  // Der Dialog fragt die Kundenart und speichert sie am Kunden (nur wenn sie dort noch leer ist)
  setzeKunde('kluecke', {})
  erfolg(await admin('PATCH', `/auftraege/${e.id}`, { kundenart: 'privat' }), 'Kundenart über den Entwurf setzen')
  assert.equal(aw.docs('customers').get('kluecke').kundenart, 'privat')
  assert.equal((await sende(e.id, 'Senden mit vollständigen Daten')).auftrag.status, 'versendet')
  assert.equal(vergebeneNummern().length, nummernVorher.length + 1)
})

// --- D10: Ablage kaputt --------------------------------------------------------------------------------------
test('D10 Ablage nicht beschreibbar: Senden scheitert mit 500, die AB bleibt Entwurf, keine Nummer verbraucht', async (t) => {
  const e = await entwurf('kfirma', { titel: 'Ablage kaputt' })
  const nummernVorher = vergebeneNummern()
  const blockade = path.join(ABLAGE, 'keine-ordner-sondern-datei')
  fs.writeFileSync(blockade, 'x')
  const ordner = config.auftraege.uploadDir
  // Annahme (V1): ablage.js liest config.auftraege.uploadDir bei jedem Aufruf. Unter einer Datei lässt sich kein Ordner anlegen, auch nicht als root.
  config.auftraege.uploadDir = path.join(blockade, 'auftraege')
  try {
    const antwort = await admin('POST', `/auftraege/${e.id}/senden`)
    if (antwort.status === 200) return t.skip('ablage.js liest das Verzeichnis nur beim Laden: Fehlerpfad im Einzeltest von L3/L4 belegen und im Protokoll vermerken')
    assert.equal(antwort.status, 500, JSON.stringify(antwort.body))
    assert.equal(/ENOTDIR|EEXIST|auftraege-ablauf/.test(String(antwort.body?.error)), false, 'keine Pfade oder Systemfehler an der Oberfläche')
  } finally {
    config.auftraege.uploadDir = ordner
  }
  assert.equal((await lies(e.id)).status, 'entwurf')
  assert.deepEqual(vergebeneNummern(), nummernVorher, 'keine Nummer verbraucht')
  assert.equal((await sende(e.id, 'Senden nach dem Fehler')).auftrag.status, 'versendet')
})

// --- D11: Nummernvergabe -------------------------------------------------------------------------------------
test('D11 Nummernvergabe: parallele Sendungen bekommen fortlaufende Nummern, dasselbe zweimal senden gelingt nur einmal, über 25 ABs bleibt alles vollständig', async () => {
  const entwuerfe = await Promise.all([1, 2, 3, 4].map((n) => entwurf('kfirma', { titel: `Parallel ${n}` })))
  const vorher = mails.sent.length
  const antworten = await Promise.all(entwuerfe.map((x) => admin('POST', `/auftraege/${x.id}/senden`)))
  antworten.forEach((antwort, i) => erwarte(antwort, 200, `parallel senden ${i + 1}`))
  const nummern = antworten.map((antwort) => antwort.body.auftrag.laufnummer).sort((a, b) => a - b)
  assert.equal(new Set(nummern).size, 4, 'verschiedene Nummern')
  assert.deepEqual(nummern, [nummern[0], nummern[0] + 1, nummern[0] + 2, nummern[0] + 3], 'fortlaufend')
  assert.equal(k31Mails(vorher, 'kfirma').anKunde.length, 4, 'je AB genau eine K31 an den Kunden')

  const einzeln = await entwurf('kfirma', { titel: 'Doppelt gesendet' })
  const vorher2 = mails.sent.length
  const [x, y] = await Promise.all([admin('POST', `/auftraege/${einzeln.id}/senden`), admin('POST', `/auftraege/${einzeln.id}/senden`)])
  assert.deepEqual([x.status, y.status].sort(), [200, 409], JSON.stringify([x.body, y.body]))
  assert.equal(k31Mails(vorher2, 'kfirma').anKunde.length, 1, 'nur eine K31 an den Kunden')

  // Mehr als 25 Dokumente des Kunden: Appwrite liefert ohne limit() nur 25, die Nummer folgt trotzdem dem Maximum und die Liste ist vollständig
  const kfirmaDokumente = () => [...aw.docs(AB()).values()].filter((d) => d.kundenId === 'kfirma')
  while (kfirmaDokumente().length < 27) await sende((await entwurf('kfirma', { titel: `Auffüllen ${kfirmaDokumente().length}` })).id, 'Auffüllen')
  const hoechste = Math.max(...[...aw.docs(AB()).values()].map((d) => d.laufnummer || 0))
  const naechste = await sende((await entwurf('kfirma', { titel: 'Nummer nach über 25 ABs' })).id, 'Nummer nach über 25 ABs')
  assert.equal(naechste.auftrag.laufnummer, hoechste + 1)
  const alle = erwarte(await admin('GET', '/auftraege?kunde=kfirma'), 200, 'Liste des Kunden').auftraege
  assert.equal(alle.length, kfirmaDokumente().length, 'die Liste ist vollständig')
  assert.ok(alle.length > 25)
})

// --- D12: Schalter -------------------------------------------------------------------------------------------
test('D12 Schalter: off blendet alle AB-Funktionen aus (404/400), Projektkonto und Rechnungsarten ohne AB laufen weiter, pilot wirkt je Kunden-E-Mail', async () => {
  const gesendet = await sende((await entwurf('kfirma', { titel: 'Für den Schalter-Test' })).id, 'AB senden')
  const entwurfsAb = await entwurf('kfirma', { titel: 'Bleibt Entwurf' })
  // eine bestätigte AB einer Privatperson: der Widerruf läuft auch bei ausgeschaltetem Schalter (Verbraucherrecht)
  const widerrufsAb = await sende((await entwurf('kprivat', { titel: 'Widerruf bei ausgeschaltetem Schalter' })).id, 'AB Privatperson')
  erwarte(await bestaetigen(widerrufsAb.token, 52), 200, 'Bestätigen vor dem Ausschalten')
  const vorher = { mode: config.auftraege.mode, pilotEmails: [...config.auftraege.pilotEmails] }
  try {
    config.auftraege.mode = 'off'
    erwarte(await admin('GET', '/auftraege?kunde=kfirma'), 404, 'Liste', SCHALTER_AUS_MELDUNG)
    erwarte(await admin('POST', '/auftraege', { ...AB_BASIS, kundenId: 'kfirma' }), 404, 'anlegen', SCHALTER_AUS_MELDUNG)
    erwarte(await admin('POST', `/auftraege/${entwurfsAb.id}/senden`), 404, 'senden', SCHALTER_AUS_MELDUNG)
    erwarte(await admin('POST', `/auftraege/${entwurfsAb.id}/projekt`, { projektId: 'pzuordnen' }), 404, 'Projekt zuordnen', SCHALTER_AUS_MELDUNG)
    for (const art of ['anzahlung', 'schluss']) {
      erwarte(await admin('POST', '/rechnungen', { customerId: 'kfirma', projektId: 'pab', auftragId: gesendet.auftrag.id, art, anzahlung: { prozent: 50 } }), 400, `Rechnungsart ${art}`, SCHALTER_AUS_MELDUNG)
    }
    // V5: der Widerruf eines Projektauftrags gilt unabhängig vom Schalter (Verbraucherrecht)
    erfolg(await send('POST', '/api/abo/public/withdraw', { body: { name: 'Petra Privat', email: 'kprivat@kunde.example', order: 'projekt', abNummer: widerrufsAb.auftrag.nummer }, headers: ip(52) }), 'Widerruf bei Schalter aus')
    assert.equal(erwarte(await admin('GET', '/projektkonten?kunde=kfirma'), 200, 'Projektkonto bei Schalter aus').auftraegeAktiv, false)
    const festbetrag = await rechnung({ customerId: 'kfirma', projektId: 'pfest', art: 'festbetrag', items: [{ description: 'Kleinigkeit laut Absprache', quantity: 1, unitAmountCents: 12345 }] }, 'Festbetrag bei Schalter aus')
    assert.equal(festbetrag.status, 'entwurf')
    erfolg(await admin('DELETE', `/rechnungen/${festbetrag.id}`), 'Entwurf löschen')
    erwarte(await ansicht(gesendet.token, 51), 404, 'Link bei Schalter aus', UNBEKANNT)
    erwarte(await bestaetigen(gesendet.token, 51), 404, 'Bestätigen bei Schalter aus', UNBEKANNT)
    assert.deepEqual(erfolg(await send('GET', '/api/auftraege', { cookie: kundeCookie('kfirma') }), 'Kundenliste bei Schalter aus'), { aktiv: false, auftraege: [] })
    erwarte(await send('POST', `/api/auftraege/${gesendet.auftrag.id}/bestaetigen`, { cookie: kundeCookie('kfirma'), body: { bestaetigt: true, fruehbeginn: false } }), 404, 'Portal-Bestätigen bei Schalter aus')

    config.auftraege.mode = 'on'
    assert.equal((await lies(gesendet.auftrag.id)).status, 'versendet', 'der Schalter ändert keinen Stand')
    assert.equal((await lies(widerrufsAb.auftrag.id)).status, 'widerrufen', 'der Widerruf wirkte auch bei ausgeschaltetem Schalter')
    erwarte(await ansicht(gesendet.token, 51), 200, 'der Link gilt wieder, sobald der Schalter an ist')

    config.auftraege.mode = 'pilot'
    config.auftraege.pilotEmails = ['kfirma@kunde.example']
    erfolg(await admin('GET', '/auftraege?kunde=kfirma'), 'Pilot: Kunde steht auf der Liste')
    erwarte(await admin('GET', '/auftraege?kunde=kprivat'), 404, 'Pilot: anderer Kunde', SCHALTER_AUS_MELDUNG)
    assert.equal(erwarte(await admin('GET', '/projektkonten?kunde=kprivat'), 200, 'Projektkonto im Pilot').auftraegeAktiv, false)
    config.auftraege.pilotEmails = ['*']
    erfolg(await admin('GET', '/auftraege?kunde=kprivat'), 'Pilot mit *: alle Kunden')
  } finally {
    config.auftraege.mode = vorher.mode
    config.auftraege.pilotEmails = vorher.pilotEmails
  }
})

// --- D13: Ratenbegrenzung ------------------------------------------------------------------------------------
test('D13 Ratenbegrenzung: auch Fehlversuche mit erfundenem Token zählen, je Adresse getrennt', async () => {
  const erfunden = 'Z'.repeat(43)
  GESEHEN.add(erfunden)
  for (let i = 1; i <= 10; i += 1) erwarte(await bestaetigen(erfunden, 250), 404, `Versuch ${i}`, UNBEKANNT)
  erwarte(await bestaetigen(erfunden, 250), 429, 'elfter Versuch', 'Zu viele Anfragen. Bitte später erneut versuchen.')
  erwarte(await bestaetigen(erfunden, 251), 404, 'eine andere Adresse ist nicht betroffen', UNBEKANNT)
})

// --- D14: Dateiablage ----------------------------------------------------------------------------------------
test('D14 Dateiablage: manipulierte Pfade und Kennungen liefern nie fremde Dateien', async () => {
  const echt = [...aw.docs(AB()).values()].find((d) => d.pdfDatei && d.kundenId === 'kfirma' && ['versendet', 'bestaetigt'].includes(d.status))
  assert.ok(echt, 'Ausgangs-AB mit PDF vorhanden')
  const sitzung = kundeCookie('kfirma')
  try {
    for (const pfad of ['../../../../etc/passwd', '/etc/passwd', 'kfirma/../../x.pdf', 'kfirma/a.pdf\u0000.png', '']) {
      aw.seed(AB(), { ...echt, $id: 'manip', nummer: '', pdfDatei: pfad })
      const durchAdmin = await holePdf('/api/admin/finanzen/auftraege/manip/pdf', { headers: ADMIN })
      const durchKunde = await holePdf('/api/auftraege/manip/pdf', { cookie: sitzung })
      for (const antwort of [durchAdmin, durchKunde]) {
        assert.equal(antwort.status, 404, `Pfad ${JSON.stringify(pfad)}`)
        assert.equal(antwort.bytes.toString('latin1').includes('root:'), false)
      }
    }
    for (const kennung of ['..%2f..%2fetc%2fpasswd', '%00', 'a'.repeat(300)]) {
      assert.equal((await holePdf(`/api/auftraege/${kennung}/pdf`, { cookie: sitzung })).status, 404, `Kennung ${kennung.slice(0, 20)}`)
      assert.equal((await holePdf(`/api/admin/finanzen/auftraege/${kennung}/pdf`, { headers: ADMIN })).status, 404, `Admin-Kennung ${kennung.slice(0, 20)}`)
    }
  } finally {
    await deleteDocument(AB(), 'manip').catch(() => {})
  }
})

// --- D17: AB ohne Projekt, später dem Projekt zuordnen ---------------------------------------------------------
// Spec Abschnitt 1: „Projekt (oder freier Titel, wenn es noch kein Projekt gibt; später zuordenbar)“. V5: POST …/auftraege/:id/projekt.
// Der Test steht vor D15 und D16, weil diese am Ende alle Nummern und Token prüfen.
test('D17 AB ohne Projekt: nach dem Senden dem Projekt zuordnen; das Projektkonto zeigt sie unter „Vereinbart“, ein zweiter Aufruf ist 409', async () => {
  const e = await entwurf('kfirma', { titel: 'Beratung ohne Projekt', projektId: '', zeitquelle: [], positionen: [{ beschreibung: 'Beratung und Konzept', menge: 1, einzelpreisCents: 70000 }] })
  assert.equal(e.projektId, '')
  const s = await sende(e.id, 'AB ohne Projekt senden')
  const vorher = aw.docs(AB()).get(s.auftrag.id)
  assert.equal(vorher.projektId || '', '')
  const pdfVorher = await holePdf(`/api/admin/finanzen/auftraege/${s.auftrag.id}/pdf`, { headers: ADMIN })
  assert.equal(pdfVorher.status, 200)

  // vor der Zuordnung zählt die AB in keinem Projektkonto
  const k0 = await konto('kfirma', 'pzuordnen')
  assert.deepEqual([k0.konto.vereinbart.festpreisCents, k0.konto.offenCents], [null, null])

  // Fehler: ohne projektId 400, unbekannte AB 404; ein unbekanntes oder fremdes Projekt wird abgelehnt (Annahme (nicht im Vertrag): 400, 404 oder 409); nichts ändert sich
  erwarte(await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, {}), 400, 'ohne projektId')
  erwarte(await admin('POST', '/auftraege/gibtsnicht/projekt', { projektId: 'pzuordnen' }), 404, 'unbekannte AB')
  for (const projektId of ['pfremd', 'gibtsnicht']) {
    const antwort = await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, { projektId })
    assert.ok([400, 404, 409].includes(antwort.status), `Projekt ${projektId}: ${antwort.status} ${JSON.stringify(antwort.body)}`)
  }
  assert.equal(aw.docs(AB()).get(s.auftrag.id).projektId || '', '', 'abgelehnte Zuordnungen ändern nichts')

  // Zuordnung: projektId gesetzt, leere Zeitquelle = Ticket des Projekts; Nummer, Status, Sendezeit und das gespeicherte PDF bleiben
  const zugeordnet = erfolg(await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, { projektId: 'pzuordnen' }), 'Projekt zuordnen').auftrag
  assert.deepEqual([zugeordnet.projektId, zugeordnet.zeitquelle, zugeordnet.status, zugeordnet.nummer], ['pzuordnen', ['tkzuordnen'], 'versendet', s.auftrag.nummer])
  const nachher = aw.docs(AB()).get(s.auftrag.id)
  assert.deepEqual([nachher.pdfDatei, nachher.pdfSha256, nachher.gesendetAm, nachher.tokenHash], [vorher.pdfDatei, vorher.pdfSha256, vorher.gesendetAm, vorher.tokenHash], 'PDF, Sendezeit und Link bleiben')
  const pdfNachher = await holePdf(`/api/admin/finanzen/auftraege/${s.auftrag.id}/pdf`, { headers: ADMIN })
  assert.equal(sha256(pdfNachher.bytes), sha256(pdfVorher.bytes), 'das PDF bleibt unverändert')
  erwarte(await ansicht(s.token, 61), 200, 'der Link gilt weiter')

  // Projektkonto: die AB steht jetzt unter „Vereinbart“, das Detail nennt sie
  const k1 = await konto('kfirma', 'pzuordnen')
  assert.deepEqual([k1.konto.vereinbart.festpreisCents, k1.konto.berechnetCents, k1.konto.offenCents], [70000, 0, 70000])
  assert.deepEqual(k1.konto.zeitquelle, ['tkzuordnen'])
  const detail = erwarte(await admin('GET', '/projektkonten/pzuordnen'), 200, 'Detail des Projektkontos')
  assert.ok(detail.auftraege.some((x) => x.id === s.auftrag.id), 'das Detail nennt die AB')

  // zweiter Aufruf: 409 (hat schon ein Projekt), auch mit einem anderen Projekt; der Stand bleibt
  erwarte(await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, { projektId: 'pzuordnen' }), 409, 'zweite Zuordnung')
  erwarte(await admin('POST', `/auftraege/${s.auftrag.id}/projekt`, { projektId: 'pab' }), 409, 'Zuordnung zu einem anderen Projekt')
  assert.equal(aw.docs(AB()).get(s.auftrag.id).projektId, 'pzuordnen')

  // eine stornierte AB lässt sich nicht mehr zuordnen
  const t = await sende((await entwurf('kfirma', { titel: 'Beratung ohne Projekt, storniert', projektId: '', zeitquelle: [] })).id, 'zweite AB ohne Projekt')
  erfolg(await admin('POST', `/auftraege/${t.auftrag.id}/stornieren`), 'stornieren')
  erwarte(await admin('POST', `/auftraege/${t.auftrag.id}/projekt`, { projektId: 'pzuordnen' }), 409, 'Zuordnung einer stornierten AB')
  assert.equal(aw.docs(AB()).get(t.auftrag.id).projektId || '', '', 'die stornierte AB bleibt ohne Projekt')
  assert.equal((await konto('kfirma', 'pzuordnen')).konto.vereinbart.festpreisCents, 70000, 'das Projektkonto zählt nur die zugeordnete AB')
})

// --- D15 und D16: Gesamtbild am Ende -------------------------------------------------------------------------
test('D15 Nummern: AB-JJJJ-NNNN, je Jahr fortlaufend und ohne Lücke', () => {
  const dokumente = [...aw.docs(AB()).values()].filter((d) => d.nummer)
  assert.ok(dokumente.length >= 28, `genug ABs im Test: ${dokumente.length}`)
  const lauf = dokumente.map((d) => d.laufnummer).sort((a, b) => a - b)
  assert.deepEqual(lauf, lauf.map((_, i) => i + 1), 'lückenlos und ohne Doppelte (fehlgeschlagene Sendungen verbrauchen nichts)')
  for (const d of dokumente) {
    assert.equal(d.jahr, JAHR)
    assert.equal(d.nummer, formatNummer(d.jahr, d.laufnummer))
    assert.match(d.nummer, /^AB-\d{4}-\d{4}$/)
  }
})

test('D16 Datenschutz: kein Token und keine Client-Adresse in Dokumenten und im Protokoll, kein Feld für Adresse oder Browser', () => {
  assert.ok(GESEHEN.size >= 10, `Token wurden gesammelt: ${GESEHEN.size}`)
  const dokumente = JSON.stringify([...aw.docs(AB()).values()])
  const protokoll = PROTOKOLL.join('\n')
  for (const token of GESEHEN) {
    assert.equal(dokumente.includes(token), false, 'Token im Dokument')
    assert.equal(protokoll.includes(token), false, 'Token im Protokoll')
  }
  assert.equal(protokoll.includes('203.0.113.'), false, 'Client-Adresse im Protokoll')
  // V6: Mails ans Team (Teamkopie der K31, T22, T09 …) enthalten nie einen Bestätigungs-Token und keinen Bestätigungslink
  const teamMails = mails.sent.filter((mail) => adressen(mail.to).some((adresse) => config.chatNotify.emails.includes(adresse)))
  assert.ok(teamMails.length >= 10, `Mails ans Team gesammelt: ${teamMails.length}`)
  for (const mail of teamMails) {
    const inhalt = `${mail.html}\n${mail.text}`
    for (const token of GESEHEN) assert.equal(inhalt.includes(token), false, `Token in der Team-Mail "${mail.subject}"`)
    assert.equal(/auftrag\.html[?#]t=/.test(inhalt), false, `Bestätigungslink in der Team-Mail "${mail.subject}"`)
  }
  const schluessel = AUFTRAEGE_COLLECTIONS.auftraege.attributes.map((a) => a.key)
  for (const verboten of ['ip', 'ipAdresse', 'clientIp', 'ipHash', 'userAgent', 'consentIp', 'consentUserAgent']) assert.equal(schluessel.includes(verboten), false, `Feld ${verboten}`)
})
  • Schritt 2: Syntax prüfen, dann zum ersten Mal laufen lassen
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && node --check test/auftraegeAblauf.test.js && echo "Syntax ok"
free -m | sed -n 2p
timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblauf.test.js 2>&1 | tail -60

Erwartet am Ende: # tests 21, # fail 0 (D0 dreimal, dazu D1 bis D17 und D6b je einmal; D10 darf mit # skipped 1 enden, siehe die Annahme im Test). Der erste Lauf scheitert voraussichtlich an Einzelheiten, die V5 offen lässt (Namen der Router-Exporte, genaue Fehlermeldungen, Statuscodes). Das ist gewollt: jede Abweichung wird nach der Regel „Der Vertrag entscheidet“ behandelt.

  • Schritt 3: Rote Stellen einordnen und beheben

Je roter Prüfung (Meldung nennt Test und Schritt):

  1. In V1–V8 und der Spec nachlesen, was gilt.
  2. Vertrag verletzt → Code ist falsch. Behebung im Strang-Code der Datei als eigener kleiner Commit Integration: <was> (Datei-Besitzer laut Tabelle der Übersicht); der Durchlauf-Test ist der fehlschlagende Test (Beleg im Protokoll). Mehr als etwa 30 Zeilen oder Vertragsänderung → Orchestrator fragen, Test an dieser Stelle mit t.todo oder Kommentar markieren und weitermachen.
  3. Vertrag lässt es offen → Annahme des Tests lockern oder anpassen, Kommentar // Annahme (nicht im Vertrag): …, Protokolleintrag.
  4. Test falsch (Tippfehler, Rechenfehler): berichtigen, Rechnung im Kommentar belegen.
  5. Nach jeder Änderung node --test test/auftraegeAblauf.test.js erneut und die Tests der betroffenen Lane aus Z1.2.

Bekannte Stellen (nach der Planrunde entschieden, siehe Vertragsfragen 2, 6 und 7): V1 legt die Kennung der Projektrechnungen auf /^in_\w{1,33}$/ fest (der Stripe-Nachbau vergibt in_test_<n>). Hat ein Strang noch die enge Fassung /^in_[A-Za-z0-9]{1,33}$/, scheitert schon die Anlage der ersten Rechnung: Integrationsfix in projektRechnungenStore.js. Schlägt test/financeSettings.test.js an steuernummer an, siehe Z1.4.

  • Schritt 4: Commit
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && git add test/auftraegeAblauf.test.js && git commit -m "Aufträge: Durchlauf-Test über alle Endpunkte (Festbetrag, Aufwand, AB bis Schlussrechnung, Privatperson, Kundenansicht, Schalter)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Integrationsfixes aus Schritt 3 stehen in eigenen Commits davor.


Task Z1.4: Bestehende Tests nachziehen, Kollisionen prüfen

Files:

  • Modify: test/kundenansichtGuard.test.js (Liste MOUNTS, ein neuer Test), test/financeSettings.test.js (nur wenn steuernummer die festen DEFAULTS bricht)

Interfaces:

  • Consumes: server/routes/auftraegeKunde.js und server/routes/auftrag.js (beide export default), Spec Abschnitt 2 „Kundenansicht: Bestätigen ist gesperrt“, KUNDENANSICHT_ALLOWED (unverändert, 22 Einträge).

  • Produces: Der Wächter-Test sperrt die neuen Schreib-Routen nachweislich (die Schleife „jede Schreib-Route ausserhalb der Erlaubnisliste → 403“ erfasst sie); die Erlaubnisliste bleibt wie sie ist.

  • Schritt 1: Wächter-Test lesen und Ausgangslage prüfen

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 300 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtGuard.test.js 2>&1 | tail -8

Erwartet: # fail 0. (Der Test zählt KUNDENANSICHT_ALLOWED.length auf 22; hat L5 die Liste angefasst, ist er rot: das ist ein Befund gegen L5, nicht gegen den Test.)

  • Schritt 2: MOUNTS erweitern (in test/kundenansichtGuard.test.js, direkt hinter der Zeile ['/api/zeiten', zeitenRouter],)
  ['/api/auftraege', (await import('../server/routes/auftraegeKunde.js')).default],
  ['/api/auftrag', (await import('../server/routes/auftrag.js')).default],

Dazu ein neuer Test hinter dem Test „Behalten der Öffnungszeiten …“:

// Auftragsbestätigung bestätigen ist eine Erklärung im Namen des Kunden (Spec Abschnitt 2): Portal-Weg und Link-Weg stehen nicht auf der
// Erlaubnisliste; der Wächter sperrt sie vor dem Router (auch der Link-Weg, wenn der Browser das Cookie einer Kundenansicht trägt).
test('Auftragsbestätigung bestätigen ist in der Kundenansicht gesperrt (Portal-Weg und Link-Weg)', async () => {
  const routes = writeRoutes(MOUNTS)
  for (const p of ['/api/auftraege/x/bestaetigen', '/api/auftrag/x/bestaetigen']) {
    assert.ok(routes.some(({ method, path: r }) => method === 'POST' && r === p), `Route ${p} gefunden`)
    assert.ok(!allowed('POST', p), p)
    const res = await call('POST', p, { ...kv(), body: { bestaetigt: true, fruehbeginn: false } })
    assert.equal(res.status, 403, p)
    assert.equal(res.body.code, 'view_blocked', p)
  }
})
  • Schritt 3: Wächter-Test laufen lassen

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 300 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtGuard.test.js 2>&1 | tail -8

Erwartet: # fail 0, ein Test mehr als vorher.

  • Schritt 4: Feste Einstellungs-Erwartung nachziehen (Vertragsfrage 6)

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 300 nice -n 10 node --max-old-space-size=1024 --test test/financeSettings.test.js test/financeOverview.test.js test/financeSetup.test.js 2>&1 | tail -15

Erwartet bei grün: nichts zu tun. Ist test/financeSettings.test.js rot, weil DEFAULTS (Zeile const DEFAULTS = { minBufferCents: 0, … bankDetailsText: '' }) das neue Feld nicht kennt, ergänze dort steuernummer: '' (der Test prüft Object.keys(einstellungen) gegen Object.keys(DEFAULTS)). Andere Gründe für Rot sind Befunde gegen L0.

  • Schritt 5: Kollisionen prüfen
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P
echo "Mail-Kennungen doppelt:"; ls server/mail-templates | cut -c1-3 | sort | uniq -d
echo "Anker doppelt in datenschutz.html:"; rg -o 'id="[^"]+"' public/datenschutz.html | sort | uniq -d
echo "Überschriften der Datenschutzerklärung (Nummernfolge prüfen, neuer Abschnitt ohne Doppelvergabe):"; rg -n '<h[23][^>]*>' public/datenschutz.html | rg -n '§|[0-9]+[a-z]?\.' | tail -25

Erwartet: beide „doppelt“-Listen leer (eine doppelte Kennung K31 etc. lässt loadTemplate die falsche Datei lesen); der neue Abschnitt „Auftragsbestätigungen“ von L7 heißt 9a und kommt nur einmal vor (6e ist Öffnungszeiten, 6d Webshop). Doppelvergabe: L7 fragen, Orchestrator informieren. Gilt der Datenschutz-Commit nach der Regel aus Z1.2 als zurückgestellt, ist 9a hier nicht vorhanden: dann entfällt die Prüfung der Anker bis zur Rechtsrunde, und die Verweise aus L5/L6 auf „Abschnitt 9a“ werden bis dahin nicht gezählt.

  • Schritt 6: Zusagen der Übersicht an L4 und L5 prüfen (nur lesen: nach der Planrunde stehen sie im Vertrag, V4 L4, V6 und V7; die Strang-Pläne setzen sie um, Z baut hier nichts)
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P
echo "--- 1. Satz zum Beginn nach Ablauf der Widerrufsfrist im PDF (AB_TEXTE.termine mit privat):"; rg -n "termine\(" server/services/auftraege/pdf.js
echo "--- 2. Wortlaut der Frühbeginn-Erklärung in K32 (Platzhalter fruehbeginn_text):"; rg -n "fruehbeginn_text" server/mail-templates server/services/auftraege | head -5 || echo "kein Platzhalter fruehbeginn_text"
echo "--- 3. K31 ohne BCC ans Team, Block teamkopie:"; rg -n "teamkopie|bccTeam" server/mail-templates server/services/auftraege | head -8
echo "--- 4. Schlüssel der Kundenart Privatperson (L3 speichert, pdf.js und mails.js vergleichen):"; rg -n "'privat'" server/services/auftraege/*.js | head -12

Erwartet:

  1. pdf.js ruft AB_TEXTE.termine({ start, fertig, privat }) auf, privat aus der Kundenart der AB-Momentaufnahme (V4 L4, V7).
  2. K32 und der Mail-Dienst kennen fruehbeginn_text; der Block fruehbeginn gibt FRUEHBEGINN_TEXT wörtlich wieder (V6).
  3. K31 hat den Block teamkopie, der Versand an den Kunden läuft mit bccTeam: false, die Teamkopie geht an config.chatNotify.emails (V6).
  4. Der Schlüssel für „Privatperson“ ist in L3, L4, L5 und L7 derselbe (privat, Gegenstück unternehmen; V1, V3).

Fehlt etwas, ist es ein Befund gegen den Strang und kein Integrationsfix von Z: im Protokoll festhalten und dem Orchestrator melden (ein Branch, dessen Plan vor der Planrunde ausgeführt wurde, hat die Zusage noch nicht). Der Durchlauf-Test zeigt die Wirkung: D4 und D8 (Teamkopie ohne Link, K32 mit dem Text, T22 mit Präfix, mehr Seiten für die Privatperson).

  • Schritt 7: Betroffene Tests laufen lassen

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtGuard.test.js test/financeSettings.test.js test/mailTemplates.test.js test/legalTexts.test.js test/auftraegeAblauf.test.js 2>&1 | tail -12

Erwartet: # fail 0.

  • Schritt 8: Commit
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && git status --short
git add test/kundenansichtGuard.test.js && git commit -m "Tests: Wächter der Kundenansicht sperrt auch die Routen der Auftragsbestätigung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git add test/financeSettings.test.js && git commit -m "Tests: Einstellungen kennen das Feld steuernummer" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"   # nur, wenn Schritt 4 sie geändert hat

Task Z1.5: Volle Suite, Node-20-Lauf, UI-Stichprobe

Files: keine Änderung; Ergebnisse ins Protokoll.

Interfaces:

  • Consumes: den Stand nach Z1.4.

  • Produces: die Zahlen für Übergabe und Memory: Testzahl Host (Node 24), Testzahl Node 20, UI-Stichprobe; Gewissheit, dass nichts Bestehendes gebrochen ist.

  • Schritt 1: Volle Suite (Host, hinter der Sperre)

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
cd $P && free -m | sed -n 2p
flock $L/auftraege-schwer.lock timeout 3600 nice -n 10 env NODE_OPTIONS=--max-old-space-size=1536 npm test > $L/auftraege-z1-suite.log 2>&1; echo "exit $?"
rg "^(ℹ|#) (tests|pass|fail|skipped) " $L/auftraege-z1-suite.log

Erwartet: exit 0, fail 0. Bekannt wacklig ist test/previewDevRouter.test.js; schlägt nur er an, einzeln wiederholen (timeout 300 node --max-old-space-size=1024 --test test/previewDevRouter.test.js), grün zählt. Jede andere rote Datei:

  • rg "^not ok|✖" $L/auftraege-z1-suite.log | head -40 nennt sie.

  • Ursache klären: durch unsere Änderung (Integrationsfix, Regeln aus Z1.2) oder schon auf origin/main rot? Prüfung gegen die Basis in einem abgetrennten Wegwerf-Worktree: git -C $P worktree add --detach $L/auftraege-basis origin/main && ln -s $P/node_modules $L/auftraege-basis/node_modules && (cd $L/auftraege-basis && timeout 300 node --max-old-space-size=1024 --test test/<datei>); danach zuerst rm $L/auftraege-basis/node_modules (nur den Symlink), dann git -C $P worktree remove $L/auftraege-basis. Rot schon auf der Basis: im Protokoll vermerken, nicht hier beheben.

  • Schritt 2: Node 20 (Produktionsstand) für alle neuen und geänderten Testdateien

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
cd $P && free -m | sed -n 2p
FILES=$(git diff --name-only --diff-filter=AM origin/main...HEAD -- 'test/*.test.js' | tr '\n' ' '); echo "$FILES" | wc -w
flock $L/auftraege-schwer.lock docker run --rm --network none -m 1500m --memory-swap 1500m --cpus 2 -v $P:/app:ro -w /app node:20 sh -c "node --version && timeout 1500 node --max-old-space-size=1024 --test $FILES" 2>&1 | tail -25

Erwartet: v20.…, # fail 0. Image node:20 (Debian, glibc, Node 20.20) statt node:20-alpine: der Host-node_modules enthält glibc-Binärdateien (sharp), die unter Alpine (musl) nicht laden; der Produktionscontainer (node:20-alpine, 20.19.5) installiert beim Start eigene. Der Unterschied der Nebenversion ist für diese Tests ohne Belang. --network none gibt dem Container nur Loopback: die Tests sprechen nur mit ihren Nachbauten auf 127.0.0.1. Die Ausgabe # pass und die Dateizahl ins Protokoll.

  • Schritt 3: UI-Prüfung der Kundenseiten (L6) auf dem Gesamtstand

L6 hinterlegt test/ui/auftraege-kundenportal.ui.mjs (Playwright, Handy 390 px und Desktop 1440 px, 21 Bildschirmfotos; nicht Teil von npm test) und bittet Z1, es nach dem Zusammenführen noch einmal laufen zu lassen.

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
R=/root/reports/auftraege-rechnungen-2026-10-06
cd $P && ls test/ui/auftraege-kundenportal.ui.mjs && mkdir -p $R/bilder/l6 && free -m | sed -n 2p
flock $L/auftraege-schwer.lock env SHOTS=$R/bilder/l6 timeout 900 nice -n 10 node --max-old-space-size=1024 test/ui/auftraege-kundenportal.ui.mjs 2>&1 | tail -30; echo "exit ${PIPESTATUS[0]}"
ls $R/bilder/l6 | wc -l

Erwartet: exit 0, 21 Dateien in $R/bilder/l6/ (Namen <Ansicht>-<handy|desktop>.png). Die Bilder sind die Eingabe für Prüfer 4 (Z3). Fehlt das Skript oder scheitert eine Prüfung der Seiten, ist das ein Befund gegen L6 (nicht hier korrigieren, wenn mehr als eine Kleinigkeit: Orchestrator melden).

  • Schritt 4: Protokoll
R=/root/reports/auftraege-rechnungen-2026-10-06; P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
{ echo; echo "## Z1.5 Läufe $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- HEAD $(git -C $P rev-parse --short HEAD)"; echo "- Host (Node $(node --version)): <tests/pass/fail aus dem Log>"; echo "- Node 20: <Dateien, pass, fail>"; echo "- UI-Stichprobe: <Ergebnis oder 'kein Skript'>"; } >> $R/z-protokoll.md

Kein Commit.


Task Z1.6: Stand von origin/main einarbeiten, Lane-Worktrees aufräumen

Files:

  • Modify: Branch feature/auftraege (ggf. Merge-Commit)

Interfaces:

  • Consumes: origin/main (andere Sitzungen pushen laufend: Webshop, Öffnungszeiten, Relaunch).

  • Produces: feature/auftraege, auf dem origin/main ein Vorfahre ist (Voraussetzung für den Fast-Forward-Push der Übergabe), nach dem Aufräumen nur noch die Basis-Worktrees.

  • Schritt 1: Abstand feststellen

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && git fetch origin
git log --oneline feature/auftraege..origin/main | head -20
git merge-base --is-ancestor origin/main feature/auftraege && echo "origin/main ist Vorfahre: nichts einzuarbeiten" || echo "origin/main ist weiter"
  • Schritt 2: Wenn nötig, einarbeiten (Merge, nicht Rebase: der Zweig enthält sieben Merge-Commits; ein Rebase würde sie auflösen und alle SHAs aus den Berichten ungültig machen)
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && git status --short   # leer
git merge --no-ff -m "Merge origin/main in feature/auftraege" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" origin/main

Erwartete Konfliktstellen und Regel (beide Seiten behalten, nichts verwerfen): server/index.js (Importe und app.use-Zeilen), server/config.js, public/app.js, test/mailTemplates.test.js (Vorlagenliste und Fußzeilenliste), test/kundenansichtGuard.test.js (MOUNTS, die Zahl KUNDENANSICHT_ALLOWED.length), public/datenschutz.html samt docs/WEBklar-Datenschutzerklaerung.pdf und .sha256. Bei der Datenschutzerklärung zuerst die HTML-Datei auflösen, dann das PDF neu drucken: cd $P && free -m | sed -n 2p && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock env FORCE=1 nice -n 10 scripts/build-legal-pdfs.sh datenschutz; die Prüfsumme prüft test/legalTexts.test.js. Logikkonflikte: Orchestrator fragen (Regel aus Z1.2).

  • Schritt 3: Nach dem Merge alles Betroffene wiederholen

Z1.4 Schritt 5 (Kollisionen: neue Mail-Kennungen und Datenschutz-Abschnitte von main können sich mit unseren überschneiden), dann Z1.5 Schritt 1 und 2 vollständig, denn main kann Dateien geändert haben, die der Durchlauf berührt. Ohne neue Commits auf main entfällt dieser Schritt.

  • Schritt 4: Lane-Worktrees aufräumen (erst nach grünem Schritt 3; die Branches bleiben)
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
R=/root/reports/auftraege-rechnungen-2026-10-06
for n in 1 2 3 4 5 6 7; do
  d=$P-l$n
  [ -d "$d" ] || { echo "Lane $n: Worktree schon weg"; continue; }
  # Strang-Protokolle (unversioniert) vor dem Entfernen in die Berichte verschieben: sie sind das Gedächtnis des Strangs
  if [ -d "$d/.superpowers" ] && [ ! -e "$R/sdd-archiv/l$n" ]; then mkdir -p "$R/sdd-archiv" && mv "$d/.superpowers" "$R/sdd-archiv/l$n"; fi
  # nur der Symlink, und nur wenn er genau auf die Basis zeigt
  if [ -L "$d/node_modules" ] && [ "$(readlink "$d/node_modules")" = "../dev-auftraege/node_modules" ]; then rm "$d/node_modules"; fi
  git -C "$P" worktree remove "$d" && echo "Lane $n: Worktree entfernt (Branch bleibt)" || { echo "Lane $n: nicht entfernt, Rest:"; git -C "$d" status --short; }
done
git -C $P worktree list | rg 'dev-auftraege'
git -C $P branch --list 'auftraege/*'

Erwartet: sieben Meldungen „entfernt“, in der Worktree-Liste nur dev-auftraege, sieben Branches auftraege/l1-… bis auftraege/l7-…. Kein --force. Bleibt eine Lane mit Resten stehen (status zeigt Dateien), die Dateien ansehen, entscheiden (sichern oder Orchestrator fragen) und nichts löschen, was nicht committed oder gesichert ist.

  • Schritt 5: Protokoll und Abschluss von Z1
R=/root/reports/auftraege-rechnungen-2026-10-06; P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
{ echo; echo "## Z1 abgeschlossen $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- PORTAL_HEAD=$(git -C $P rev-parse --short HEAD) ORIGIN_MAIN=$(git -C $P rev-parse --short origin/main)"; echo "- Basis-Merge: <ja/nein>; Lane-Worktrees entfernt: <ja/nein>"; } >> $R/z-protokoll.md

Z2 – WOMS-Integration (Worktree $Wo)

Task Z2.1: Bestandsaufnahme und Merge von W1 und W2

Files:

  • Modify: Branch feature/auftraege im WOMS-Repo (zwei Merge-Commits), Dateien nur bei einem Integrationsfix.

Interfaces:

  • Consumes: Branches auftraege/w1-rechnungen und auftraege/w2-auftraege (Worktrees $Wo-w1, $Wo-w2), der Stand von W0 auf feature/auftraege (Container AuftraegeUndRechnungen, vier Platzhalter, API-Schicht); Dateizuständigkeit WOMS laut Übersicht, Abschnitt „Nachträge nach der Planrunde“:

    • W0: src/lib/financeApi.js, src/lib/financeRequest.js (Blob), src/lib/teamApi.js, src/lib/auftraegeEndpoints.js, src/lib/auftraegeAnsicht.js, src/lib/customerTabs.js (samt Test), der Container und die vier Startdateien unter src/components/auftraege/, in CustomerDetailPage.jsx nur die Reiterzeile, CustomerInvoices.jsx (gelöscht).
    • W1: ProjektkontoListe.jsx, RechnungDialog.jsx, projektkonto.css, rechnung-dialog.css, src/lib/projektkontoView.js, src/lib/rechnungDialogView.js (und weitere eigene Libs), InvoiceTable.jsx, WorksheetList.jsx, WorksheetTimesModal.jsx, InvoiceCreateModal.jsx (gelöscht).
    • W2: AuftragListe.jsx, AuftragDialog.jsx, auftrag.css, src/lib/auftragView.js, src/lib/auftragPdf.js, src/lib/kundenFelder.js, in CustomerDetailPage.jsx das Feld Kundenart (Übersicht), SettingsCard.jsx.
  • Produces: feature/auftraege (WOMS) mit W0, W1 und W2; die Redesign-Basis B (Tag) für die Diffs von Z3 und die Übergabe.

  • Schritt 1: Lage, Branches, Basis B

Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo && git status --short && git branch --show-current && git log --oneline -3 && free -m | sed -n 2p
for b in w1-rechnungen w2-auftraege; do printf '%-28s' "auftraege/$b"; git rev-parse --short "auftraege/$b" 2>/dev/null || echo FEHLT; done
for n in 1 2; do printf 'Lane w%s: ' $n; git -C $Wo-w$n status --short | rg -v '^\?\? node_modules$' | head -3; echo; done
for t in design-p10 design-p9 design-p8 design-p7 design-p6 design-p5 design-p4 design-p3; do git merge-base --is-ancestor "$t" feature/auftraege 2>/dev/null && { echo "B=$t ($(git rev-parse --short $t))"; break; }; done

Erwartet: git status leer (node_modules ist ignoriert), beide Branches da, je Lane nur der Symlink (kein weiterer Rest), eine Zeile B=design-p…. B ist der höchste Redesign-Tag unterhalb von feature/auftraege (W0 nannte ihn in seiner Abschlussmeldung; weichen beide ab, gilt W0). Fehlt etwas: Orchestrator fragen.

  • Schritt 2: Überschneidungen und fremde Dateien
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo
echo "== von beiden Lanes geändert (Konfliktkandidaten):"
comm -12 <(git diff --name-only feature/auftraege...auftraege/w1-rechnungen | sort) <(git diff --name-only feature/auftraege...auftraege/w2-auftraege | sort)
echo "== W1:"; git diff --no-renames --name-status feature/auftraege...auftraege/w1-rechnungen
echo "== W2:"; git diff --no-renames --name-status feature/auftraege...auftraege/w2-auftraege

Erwartet: die Überschneidungsliste ist leer (CustomerDetailPage.jsx ändert W0 im Reiter invoices, W2 in OverviewTab: W0 ist schon in der Basis, also kein Treffer). Die beiden Dateilisten gegen die Zuständigkeit oben lesen: eine Datei, die laut Liste der anderen Lane oder W0 gehört, ist ein Konfliktkandidat; Zusätze, die die Pläne selbst melden (W1: rechnungAnfrage.js, rechnungDialogApi.js, Dialog-Bausteine; W2: AuftragSchritte.jsx, AuftragVorschau.jsx, UebersichtLink.jsx, eigene Tests) sind in Ordnung, ebenso src/lib/auftragPdf.js und src/lib/kundenFelder.js (W2, laut Nachträgen). Ein Fund wird im Protokoll festgehalten.

Dieselbe Prüfung als Skript, mit den Zuständigkeiten aus den „Nachträgen“ ($L/zustaendigkeit-woms.mjs, nur lesend):

// Meldet Dateien, die W1 oder W2 laut Übersicht (Nachträge) nicht ändern dürfen, und Überschneidungen beider Lanes.
import { execFileSync } from 'node:child_process'

const REPO = '/home/webklar/apps/ticket.webklar.com/dev-auftraege'
const A = 'src/components/auftraege/'
// W0 liegt schon auf feature/auftraege (Basis) und ist nicht Gegenstand der Prüfung: ändert eine Lane eine Datei von W0
// (financeApi.js, financeRequest.js, teamApi.js, auftraegeEndpoints.js, auftraegeAnsicht.js, customerTabs.js, den Container), meldet das Skript sie als FREMDE DATEI.
const LANES = {
  'auftraege/w1-rechnungen': [
    A + 'ProjektkontoListe.jsx', A + 'RechnungDialog.jsx', A + 'projektkonto.css', A + 'rechnung-dialog.css',
    /^src\/components\/auftraege\/(RechnungPositionen|RechnungVorschau|AufwandAuswahl|KontoZusammenfassung)\.jsx$/,
    /^src\/lib\/(projektkontoView|rechnungDialogView|rechnungAnfrage|rechnungDialogState|rechnungDialogApi)\.js$/,
    'src/hooks/useRechnungDaten.js', 'src/hooks/useAbgerechnet.js',
    'src/components/finance/InvoiceTable.jsx', 'src/components/finance/InvoiceCreateModal.jsx', 'src/components/WorksheetList.jsx', 'src/components/WorksheetTimesModal.jsx',
  ],
  'auftraege/w2-auftraege': [
    A + 'AuftragListe.jsx', A + 'AuftragDialog.jsx', A + 'auftrag.css', A + 'AuftragSchritte.jsx', A + 'AuftragVorschau.jsx', A + 'UebersichtLink.jsx',
    /^src\/lib\/(auftragView|auftragPdf|kundenFelder)\.js$/, 'src/pages/CustomerDetailPage.jsx', 'src/components/finance/SettingsCard.jsx',
  ],
}
const NEUE_TESTS = /^tests\/(unit\/)?[A-Za-z0-9-]+\.test\.mjs$/ // neue Testdateien gehören dem anlegenden Strang; ein geänderter vorhandener Test wird gemeldet
const passt = (muster, datei) => (muster instanceof RegExp ? muster.test(datei) : muster === datei)
const git = (...args) => execFileSync('git', ['-C', REPO, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })

let meldungen = 0
const geaendert = {}
for (const [zweig, erlaubt] of Object.entries(LANES)) {
  geaendert[zweig] = new Set()
  for (const zeile of git('diff', '--no-renames', '--name-status', `feature/auftraege...${zweig}`).split('\n').filter(Boolean)) {
    const [art, datei] = zeile.split('\t')
    geaendert[zweig].add(datei)
    if (art === 'A' && NEUE_TESTS.test(datei)) continue
    if (erlaubt.some((muster) => passt(muster, datei))) continue
    console.log(`FREMDE DATEI    ${zweig}: ${art} ${datei}`)
    meldungen += 1
  }
}
for (const datei of geaendert['auftraege/w1-rechnungen']) {
  if (!geaendert['auftraege/w2-auftraege'].has(datei)) continue
  console.log(`ÜBERSCHNEIDUNG  W1 und W2: ${datei}`)
  meldungen += 1
}
console.log(meldungen ? `${meldungen} Meldung(en)` : 'Zuständigkeit eingehalten')
process.exit(meldungen ? 1 : 0)

Lauf: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && timeout 120 node --max-old-space-size=256 /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/zustaendigkeit-woms.mjs. Erwartet: Zuständigkeit eingehalten. Meldungen werden wie in Z1.1 Schritt 4 behandelt (FREMDE DATEI in einer reinen Testdatei ist ein Hinweis fürs Protokoll, eine fremde Datei unter src/ oder eine ÜBERSCHNEIDUNG ein Konfliktkandidat für den Merge).

  • Schritt 3: Merge W1, dann W2 (Konfliktregel wie in Z1.2: gehört die Datei der einmündenden Lane, gilt deren Fassung (--theirs); gehört sie W0 oder der früher gemergten Lane, die Fassung auf feature/auftraege (--ours) und die fachlich nötige Änderung von Hand nachziehen; Logikkonflikte: abbrechen und fragen)
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo
git merge --no-ff -m "Merge auftraege/w1-rechnungen: Rechnungs-UI (Strang W1)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" auftraege/w1-rechnungen
git status --short | rg '^(UU|AA|DU|UD|AU|UA) ' || echo "keine Konflikte"
FILES=$(git diff --name-only --diff-filter=AM HEAD^1 HEAD -- 'tests/*.test.mjs' 'tests/unit/*.test.mjs' | tr '\n' ' '); echo "$FILES"
free -m | sed -n 2p
NODE_OPTIONS=--max-old-space-size=1024 timeout 900 nice -n 10 node --test $FILES 2>&1 | tail -12

Dasselbe für auftraege/w2-auftraege (Nachricht „Merge auftraege/w2-auftraege: Auftrags-UI (Strang W2)“). Erwartet je Lane: Merge-Commit, „keine Konflikte“, # fail 0. Nach W2 zusätzlich die bestehenden Tests, die W1 und W2 berühren: NODE_OPTIONS=--max-old-space-size=1024 timeout 900 nice -n 10 node --test tests/unit/customerTabs.test.mjs tests/unit/financeView.test.mjs tests/unit/financeFormat.test.mjs tests/unit/financeSettings.test.mjs tests/unit/worksheetView.test.mjs tests/unit/worksheetFlow.test.mjs tests/setup-legal.test.mjs, ebenfalls # fail 0.

  • W1 gemergt, Strang-Tests grün

  • W2 gemergt, Strang-Tests grün

  • Schritt 4: Platzhalter ersetzt, Container reicht alle Props durch

Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo
for f in ProjektkontoListe RechnungDialog AuftragListe AuftragDialog; do printf '%-20s' $f; wc -l < src/components/auftraege/$f.jsx; done
rg -n "AuftragListe|AuftragDialog|konten=|onShowOverview" src/components/auftraege/AuftraegeUndRechnungen.jsx
test ! -e src/components/finance/CustomerInvoices.jsx && echo "CustomerInvoices.jsx ist weg"

Erwartet: die vier Dateien sind keine Platzhalter mehr (jeweils deutlich mehr als 10 Zeilen); im Container bekommt AuftragListe die Props konten und onShowOverview und AuftragDialog die Prop onShowOverview (V8 nach der Planrunde: W0 reicht sie durch; W2 braucht sie für den Projektnamen in der Liste, für „Zur Übersicht“ und für die Aktion „Projekt zuordnen“). Fehlt das, ist es ein Integrationsfix: im Container konten={ansicht.konten} bzw. onShowOverview={onShowOverview} an den beiden Stellen ergänzen (Namen der Variablen im Container nachlesen), danach tests/unit/auftraegeAnsicht.test.mjs und Build (Z2.2). Commit Integration: Container reicht konten und onShowOverview an die Auftrags-Komponenten durch.

  • Schritt 5: V8-Vertragsliste abhaken (nur lesen)
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo
for n in getProjektkonten getProjektkonto previewInvoice createInvoice listAuftraege getAuftrag createAuftrag patchAuftrag deleteAuftrag sendAuftrag erinnereAuftrag neueFassungAuftrag storniereAuftrag ordneAuftragProjektZu getAuftragPdf; do rg -q "\b$n\b" src/lib/financeApi.js && echo "ok $n" || echo "FEHLT $n"; done
rg -n "getAbgerechnet" src/lib/teamApi.js src/components/WorksheetList.jsx | head -3
rg -n "label: 'Aufträge und Rechnungen'" src/lib/customerTabs.js
rg -n "RechnungDialog" src/components/finance/InvoiceTable.jsx src/pages/FinancePage.jsx | head -4
test ! -e src/components/finance/InvoiceCreateModal.jsx && echo "InvoiceCreateModal ist weg"

Erwartet: 15-mal ok (V8 nennt ordneAuftragProjektZu(id, projektId) für „Projekt zuordnen“), getAbgerechnet in Team-API und Arbeitsblatt-Liste, die Beschriftung, der neue Dialog in InvoiceTable.jsx (W1 Vertragsfrage 1: FinancePage.jsx braucht keine Änderung), der alte Dialog gelöscht.

  • Schritt 6: Protokoll
R=/root/reports/auftraege-rechnungen-2026-10-06; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
{ echo; echo "## Z2.1 Merges $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- WOMS feature/auftraege = $(git -C $Wo rev-parse --short HEAD); Basis B = <Tag aus Schritt 1>"; echo "- Konflikte und Integrationsfixes: <eintragen oder 'keine'>"; } >> $R/z-protokoll.md

Die Merge-Commits sind der Commit dieses Tasks; Integrationsfixes stehen in eigenen Commits.


Task Z2.2: Tests, Hygiene und Build des WOMS-Gesamtstands

Files: keine Änderung außer Integrationsfixes.

Interfaces:

  • Consumes: den Stand nach Z2.1.

  • Produces: Testzahl und Build-Ergebnis für Übergabe und Memory; die Gewissheit, dass die neuen Ansichten die Regeln des Redesigns einhalten (keine Icon-Importe, keine Farbliterale, keine KOMPAT-Klassen).

  • Schritt 1: Volle Suite (hinter der Sperre)

Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
cd $Wo && free -m | sed -n 2p
flock $L/auftraege-schwer.lock env NODE_OPTIONS=--max-old-space-size=1024 timeout 1800 nice -n 10 npm test > $L/auftraege-z2-test.log 2>&1; echo "exit $?"
rg "^(ℹ|#) (tests|pass|fail) " $L/auftraege-z2-test.log

Erwartet: exit 0, fail 0; pass ist die Summe aus N0 (Ausgangszahl aus W0) plus den Zahlen der drei Lane-Berichte (W0: N0 + 21, W1 meldet 61 eigene, W2 nennt seine). Weicht die Zahl ab, fehlt eine Testdatei oder wurde eine doppelt gezählt: git diff --name-only --diff-filter=A $B..HEAD -- tests mit den Berichten vergleichen.

  • Schritt 2: Regeln des Redesigns für die neuen und geänderten Dateien
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
T=/root/reports/ticketsystem-design-2026-10-04
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
cd $Wo
B=<B aus Z2.1>
FILES=$(git diff --name-only --diff-filter=AM $B..HEAD -- src | tr '\n' ' ')
rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|console\.log" $FILES || echo "Hygiene: keine Treffer"
timeout 300 node $T/werkzeug/farben.mjs $Wo; echo "farben exit $?"
timeout 300 node $T/werkzeug/css-klassen.mjs $Wo > $L/auftraege-z2-klassen.json; echo "klassen exit $?"
node -e "const j=JSON.parse(require('fs').readFileSync('$L/auftraege-z2-klassen.json','utf8')); console.log('benutzt, aber nicht definiert:', JSON.stringify(j.undefinedUsed||[]))"

Erwartet: Hygiene: keine Treffer (Treffer in Dateien, die nicht aus den Auftrags-Strängen stammen, sind ältere Funde: nur melden, nicht beheben), farben exit 0, klassen exit 0, undefinedUsed ohne Namen aus den neuen Komponenten (pk-…, auftrag-…, rechnung-…). Ein neuer undefinierter Klassenname ist ein Fund (CSS-Datei des Strangs ergänzen oder den Namen im JSX korrigieren: kleiner Integrationsfix).

  • Schritt 3: Build (Speichergrenze, Ausgabe nur ins Scratchpad)
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
cd $Wo && free -m | sed -n 2p
test ! -e $L/auftraege-z2-dist || test -f $L/auftraege-z2-dist/index.html   # nur ein früherer eigener Build darf dort liegen
flock $L/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 nice -n 10 node node_modules/vite/bin/vite.js build --outDir $L/auftraege-z2-dist --emptyOutDir > $L/auftraege-z2-build.log 2>&1; echo "exit $?"
tail -8 $L/auftraege-z2-build.log

Erwartet: exit 0, die letzten Zeilen nennen die Bündel und „built in …“, keine Meldung über fehlende Exporte oder Importe. Die Bündelgröße (index-….js gzip) gehört ins Protokoll.

  • Schritt 4: Protokoll
R=/root/reports/auftraege-rechnungen-2026-10-06; L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
{ echo; echo "## Z2.2 Läufe $(date -u +%Y-%m-%dT%H:%MZ)"; rg "^(ℹ|#) (tests|pass|fail) " $L/auftraege-z2-test.log | sed 's/^/- /'; echo "- Build: <exit, Bündelgröße>; Hygiene: <Ergebnis>; farben/css-klassen: <Ergebnis>"; } >> $R/z-protokoll.md

Kein Commit (außer Integrationsfixes aus Schritt 2).


Task Z2.3: Bildschirmfotos aller neuen Ansichten (Desktop 1440 px, Handy 390 px)

Files:

  • Create (nur Scratchpad und Berichte, nicht im Repo): $L/auftraege-z2-w1/, $L/auftraege-z2-w2/, $L/auftraege-z2-gesamt/ (Prüfläufer), $R/pruefmaterial/harness-w1/, $R/pruefmaterial/harness-w2/, $R/pruefmaterial/harness-gesamt/ (Sicherung der Läufer), $R/bilder/z2/ (Ergebnis)

Interfaces:

  • Consumes: die Prüfläufer der Lanes, die das Redesign-Werkzeug nur importieren (createBackend, loadFixtures, launchChromium, runChecks aus $T/werkzeug) und die neuen Portal-Endpunkte selbst nachbilden: W1 unter $L/auftraege-w1/ (lauf.sh, shots.mjs, fixtures-w1.mjs, szenarien.mjs; 22 Szenarien in zwei Breiten), W2 unter $L/auftraege-w2/ (shots-w2.mjs). Das Werkzeug selbst kennt die Endpunkte nicht (W0 Vertragsfrage 10) und bleibt unverändert (Regel des Redesigns: nie an werkzeug/ oder dev-design rühren).
  • Produces: Bilder <Breite>x<Höhe>/<Ansicht>.png (1440x900 und 390x844) für alle neuen Ansichten und Zustände unter $R/bilder/z2/{w1,w2,gesamt}/ und eine Liste der Ansichten für Prüfer 4 ($R/bilder/z2/ansichten.md).

Soll-Abdeckung (jede Zeile braucht mindestens ein Bild in beiden Breiten; Ansichten, die die Lanes schon liefern, werden übernommen, fehlende im Läufer „gesamt“ ergänzt):

Bereich Ansichten und Zustände
Kundenreiter „Aufträge und Rechnungen“ gesamt (Projekte, Aufträge in allen Status entwurf, versendet, bestaetigt, abgerechnet, storniert, ersetzt, widerrufen, Rechnungsliste mit Spalten Projekt und Art); leer; Schalter aus (kein Aufträge-Abschnitt, kein Knopf „Auftragsbestätigung“); Stripe-Ausfall (Hinweis, Beträge mit „–“); Projektkonten-Abruf scheitert (Szenario fehler); Anschrift fehlt (Adresswarnung); lange Namen und Umlaute
Rechnungsdialog Festbetrag; Aufwand (Arbeitsblätter vorausgewählt und abwählbar, Leistungszeitraum, Zeilentexte); Anzahlung; Schlussrechnung (Minuszeile, Nachtrag); Ohne Projekt; Art gesperrt ohne AB (Satz „Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.“); 409 „Arbeitsblatt bereits abgerechnet“; Rückfrage vor dem Senden; Doppelklick-Sperre; Kundenwahl auf der Finanzen-Seite
AB-Dialog und -Liste Kundenart-Frage (Schritt 1, nur bei leerer Kundenart); Festpreis mit Zahlungsplan; Aufwand mit Schätzung; Termine und Zeitquelle; Vorschau; Rückfrage vor dem Senden; Privatperson (Hinweis auf Belehrung und Frühbeginn); Listenaktionen je Status (Erinnerung senden, „Erneut senden“ bei mailStatus fehler, Neue Fassung, Stornieren mit Rückfrage, PDF, Entwurf bearbeiten und löschen, „Projekt zuordnen“ bei ABs ohne Projekt); nicht blockierende Hinweise im AB-Dialog (fehlende Steuernummer im letzten Schritt; Weg „vor Ort“ bei einer Privatperson: „zusätzlich ausdrucken und übergeben“, V8)
Finanzen Rechnungstabelle mit Spalten Projekt und Art; Einstellungen mit Steuernummer (gültig, ungültig)
Ticketseite Abzeichen „abgerechnet (Rechnungsnummer)“ und „abgerechnet (Entwurf)“ in der Arbeitsblattliste; Warnung beim Ändern der Zeit eines abgerechneten Blatts („Dieses Arbeitsblatt ist abgerechnet (…). Eine Änderung ändert die Rechnung nicht.“)
Kundenübersicht Feld „Kundenart“ (leer, Unternehmen, Privatperson)
  • Schritt 1: Läufer der Lanes sichern und auf den Gesamtstand umstellen
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
R=/root/reports/auftraege-rechnungen-2026-10-06
ls $L/auftraege-w1 $L/auftraege-w2
mkdir -p $R/pruefmaterial
for n in w1 w2; do
  test -d $L/auftraege-$n || { echo "Läufer von $n fehlt (Scratchpad nach einem Neustart leer?): Orchestrator fragen, nicht neu erfinden"; continue; }
  mkdir -p $R/pruefmaterial/harness-$n $L/auftraege-z2-$n
  # nur die Skripte und Daten, nicht Build (dist), Bilder (shots) und Syntaxprüfung
  (cd $L/auftraege-$n && ls | rg -v '^(dist|shots|syntax)$' | while read -r f; do cp -a "$f" $R/pruefmaterial/harness-$n/; cp -a "$f" $L/auftraege-z2-$n/; done)
done
rg -n "dev-auftraege-w[12]|auftraege-w[12]" $L/auftraege-z2-w1 $L/auftraege-z2-w2

Die Trefferliste zeigt alle Stellen, an denen die Kopien auf den Worktree und das Ausgabeverzeichnis der Lane zeigen (W1: WT= und SP= in lauf.sh, W2: Konstanten in shots-w2.mjs). Nur diese Stellen in den Kopien auf $Wo bzw. $L/auftraege-z2-w1|w2 umstellen (sed -i auf genau diese Zeilen); die Originale in $L/auftraege-w1|w2 und die Sicherung in $R bleiben unverändert.

  • Schritt 2: Läufer von W1 und W2 gegen den Gesamtstand laufen lassen

W1s lauf.sh sperrt sich selbst (erst auftraege-schwer.lock, dann die Sperre des Redesigns, wenn deren Ordner existiert): nicht zusätzlich in flock einhüllen, sonst wartet er auf sich selbst. Der Läufer von W2 sperrt nicht selbst; er läuft hinter flock.

L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
free -m | sed -n 2p
$L/auftraege-z2-w1/lauf.sh 2>&1 | tail -50; echo "exit ${PIPESTATUS[0]}"
free -m | sed -n 2p
FIXTURE_TODAY=2026-10-05 flock $L/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 node $L/auftraege-z2-w2/shots-w2.mjs 2>&1 | tail -40; echo "exit ${PIPESTATUS[0]}"

Erwartet: beide exit 0; W1 meldet je Szenario und Breite ok … (44 Zeilen), W2 beendet ohne geworfene Zusicherung, overflow-x und bad-text leer, keine Konsolenfehler außer den bewusst erzeugten 409. Exit 3 von lauf.sh heißt zu wenig freier Speicher: später wiederholen. Eine Prüfung, die mit dem Gesamtstand rot wird, in der Lane aber grün war, ist ein Integrationsfehler (z. B. der Container reicht eine Prop nicht durch, W2 und W0 überschreiben sich): im Protokoll festhalten, kleinste Korrektur als Integrationsfix, Läufe wiederholen. Den Läufer nie abschwächen, um grün zu werden.

  • Schritt 3: Abdeckung gegen die Soll-Tabelle prüfen
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
for n in w1 w2; do echo "== $n"; ls $L/auftraege-z2-$n/shots/1440x900 | sed 's/\.png$//' | tr '\n' ' '; echo; done

Jede Zeile der Soll-Tabelle einer Ansicht aus den beiden Listen zuordnen. Was keiner Ansicht zugeordnet ist, wird im dritten Läufer $L/auftraege-z2-gesamt/ ergänzt (Schritt 4), vor allem die zusammengesetzten Ansichten des ganzen Reiters mit Projektliste und Auftragsliste und Rechnungsliste (die Läufer der Lanes zeigen den Container nur mit dem Platzhalter der jeweils anderen Lane) und die Zustände Schalter aus, Stripe-Ausfall, Projektkonten-Abruf scheitert, Anschrift fehlt.

  • Schritt 4: Läufer „gesamt“ für die fehlenden und die zusammengesetzten Ansichten

Aufbau aus den Kopien (nichts Neues erfinden): $L/auftraege-z2-gesamt/ bekommt eine Kopie von $L/auftraege-z2-w2/shots-w2.mjs als shots-gesamt.mjs (er ist der Läufer mit Kundenreiter und Dialogen), dazu die Kopie von fixtures-w1.mjs aus dem Läufer von W1 und die Antworten von W2 (beantworte(zustand, methode, url, body)): beide Antwortfunktionen hintereinander schalten (erst W1s, dann W2s, sonst fällt die Anfrage an backend.handle des Werkzeugs durch). Szenarien (Id: Zustand): reiter-gesamt (Kunde c2 des Werkzeugs, W1-Konten mit drei Projekten plus „Ohne Projekt“, W2-Aufträge in allen sieben Status, Rechnungen mit Projekt und Art), reiter-leer, reiter-schalter-aus (auftraegeAktiv: false), reiter-stripe-aus (stripeOk: false), reiter-projektkonten-fehler (Antwort 500 auf GET …/projektkonten), reiter-ohne-anschrift (Kunde ohne Straße). Jede Ansicht in beiden Breiten, danach runChecks(page): overflow-x und bad-text müssen leer sein. Aufruf wie in Schritt 2 (flock außen, systemd-run, FIXTURE_TODAY=2026-10-05). Läufer und Daten wie in Schritt 1 sichern (ohne dist und shots): mkdir -p $R/pruefmaterial/harness-gesamt && (cd $L/auftraege-z2-gesamt && ls | rg -v '^(dist|shots)$' | while read -r f; do cp -a "$f" $R/pruefmaterial/harness-gesamt/; done).

  • Schritt 5: Alle Bilder selbst ansehen

Mit dem Read-Werkzeug jede Ansicht der Soll-Tabelle bei 1440 und bei 390 px öffnen (mindestens: reiter-gesamt, je ein Dialog je Rechnungsart, die vier Schritte des AB-Dialogs, die Auftragsliste, Finanzen-Tabelle, Einstellungen mit Steuernummer, Abzeichen). Prüfen: nichts schneidet ab, kein waagerechter Überlauf, Knöpfe erreichbar, Texte wörtlich wie in der Spec (Abschnitt 1: „Auftragsbestätigung“, „Rechnung“, „Erinnerung senden“, „Neue Fassung“, „Stornieren“, „Als Entwurf speichern“, „Senden“), Beträge im Format 1.234,56 €, Datum TT.MM.JJJJ. Funde: kleine Korrekturen in der Datei des zuständigen Strangs als Integrationsfix (eigener Commit, danach Schritt 2 wiederholen); größere Funde und Gestaltungsfragen kommen in die Liste für Prüfer 4 (Schritt 6).

  • Schritt 6: Bilder und Ansichtenliste ablegen
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
R=/root/reports/auftraege-rechnungen-2026-10-06
for n in w1 w2 gesamt; do
  d=$L/auftraege-z2-$n/shots
  [ -d "$d" ] || { echo "keine Bilder von $n"; continue; }
  mkdir -p $R/bilder/z2/$n && cp -a $d/. $R/bilder/z2/$n/
done
find $R/bilder/z2 -name '*.png' | wc -l
{ echo "# Ansichten von Z2 (Stand $(date -u +%Y-%m-%dT%H:%MZ))"; for n in w1 w2 gesamt; do echo; echo "## $n"; ls $R/bilder/z2/$n/1440x900 2>/dev/null | sed 's/\.png$//;s/^/- /'; done; echo; echo "## Nicht abgedeckt aus der Soll-Tabelle"; echo "- <Zeilen eintragen oder 'keine'>"; } > $R/bilder/z2/ansichten.md

cp -a überschreibt gleichnamige Dateien und löscht nichts. Erwartet: je Ansicht zwei Bilder (zwei Breiten), ansichten.md nennt jede Ansicht und jede Lücke. Z3.1 sammelt zusätzlich die Bilder von L6 ($R/bilder/l6/) und L4.

Kein Commit (nur Berichte); Integrationsfixes aus Schritt 2 und 5 stehen in eigenen Commits.


Task Z2.4: Karten für „Rechtliches“ im Ticketsystem (bedingt)

Files:

  • Modify: scripts/setup-legal.mjs (Liste DOCUMENTS), tests/setup-legal.test.mjs (Pins)

Interfaces:

  • Consumes: das Freigabe-Dokument von L7 (/root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md, Abschnitt 8 „Nach der Freigabe“, Punkt 5) mit den Rechtstexten, die in WOMS „Rechtliches“ hochgeladen werden (Spec Abschnitt 8): Widerrufsbelehrung und Muster-Widerrufsformular für Projektaufträge (neu), Datenschutzerklärung (Karte datenschutz besteht), AGB (Karte agb besteht).
  • Produces: für jedes Dokument ohne Karte eine Karte in DOCUMENTS, damit der Upload nach der Rechtsrunde (Z4.1, Schritt „Rechtsrunde“) eine Karte findet. Standardfall: genau eine neue Karte widerrufsbelehrung-projekt (so nennt sie Abschnitt 9 des Freigabe-Dokuments von L7). Keine Strang-Datei legt sie an (Vertragsfrage 9 dieser Datei).

Bedingung: nur ausführen, wenn L7 fertig ist und sein Freigabe-Dokument neue Dokumentenarten nennt, für die es noch keine Karte gibt. Nennt es keine, entfällt der Task (ein Satz im Protokoll).

  • Schritt 1: Bedarf feststellen
R=/root/reports/auftraege-rechnungen-2026-10-06; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
test -f $R/rechtsrunde.md && rg -n -i "karte|rechtliches|documentKey|schl[uü]ssel" $R/rechtsrunde.md | head -20 || echo "rechtsrunde.md fehlt"
cd $Wo && rg -n "key:" scripts/setup-legal.mjs | tail -6

Erwartet: eine Liste der Karten (Schlüssel, Titel, Dateien), die L7 braucht. Der Abschnitt 9 des Freigabe-Dokuments gilt (entschieden, Übersicht „Nachträge“, L7: Karten laut Freigabe-Dokument, eine Karte ohne Datei entfällt); L7 nennt dort genau eine neue Karte: Schlüssel widerrufsbelehrung-projekt (26 Zeichen; Grenze der WOMS-Dokument-ID 36, nur [a-z0-9-]), Titel „Widerrufsbelehrung und Muster-Widerrufsformular (Projektaufträge)“ (Muster der Karte widerrufsbelehrung-funktionen), liveUrl https://project.webklar.com/rechtliches/widerrufsbelehrung-projektauftrag-<TEXTE-Fassung>.html mit der Fassung aus server/services/auftraege/texte.js des Portal-Stands (L7 liefert nur versionierte Adressen, keine Alias-Seite: L7 Vertragsfrage 11). Fehlt das Dokument oder der Abschnitt, gelten diese Werte; der Schlüssel kommt dann ins Protokoll, denn die Übergabe (Abschnitt D5) braucht ihn. Die Textbausteine der AB brauchen keine eigene Karte: sie stehen in der AB-PDF und werden bei der Rechtsrunde mit der Belehrung zusammen freigegeben. Karten für AGB und Datenschutz gibt es schon.

  • Schritt 2: Pins im Test nachziehen (Test zuerst)

tests/setup-legal.test.mjs pinnt (Stand origin/test vom 06.10.2026) DOCUMENTS.length auf 12 und DOCUMENTS.at(-1) auf die Karte leistungsbeschreibung-zeiten; die Pins mit rg -n "DOCUMENTS.length|at\(-1\)" tests/setup-legal.test.mjs suchen, denn nach den Redesign-Paketen kann die Zahl abweichen. Beides auf die neue Lage ändern (neue Länge, neue letzte Karte, die vorhandenen Karten bleiben unverändert davor), dazu je neue Karte ein Pin wie bei den Webshop-Karten (Schlüssel, Titel, liveUrl, sort = Position). Lauf: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 300 nice -n 10 node --test tests/setup-legal.test.mjs. Erwartet: FAIL (die Karten fehlen noch).

  • Schritt 3: Karten ergänzen

In scripts/setup-legal.mjs hinter der letzten vorhandenen Karte je Karte { key, title, liveUrl } (die Nummer sort setzt die Zuordnung i + 1 am Ende der Liste), mit einem Kommentar „Projektaufträge: Adresse mit Fassungsdatum, nur beim Anlegen geschrieben; bei neuer Fassung die Karte im Ticketsystem anpassen“. Das Skript legt nur fehlende Karten an (getDocument 404 → createDocument) und ändert vorhandene nie; eine neue Fassung ändert die liveUrl der Karte also nicht von selbst (Vertragsfrage 9). Lauf wie in Schritt 2: # fail 0; zusätzlich node scripts/setup-legal.mjs --dry-run | tail -5 (liest nur, braucht keinen Schlüssel): die neuen Karten stehen darin.

  • Schritt 4: Commit
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo && git add scripts/setup-legal.mjs tests/setup-legal.test.mjs && git commit -m "Rechtliches: Karte für die Widerrufsbelehrung der Projektaufträge" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Die Karte entsteht erst mit dem WOMS-Push und dem Lauf von setup-legal.mjs in Appwrite (Übergabe, Schritt „Rechtsrunde“).


Task Z2.5: Stand von origin/test, Aufräumen der Lane-Worktrees

Files:

  • Modify: Branch feature/auftraege (WOMS), ggf. Merge-Commit

Interfaces:

  • Consumes: origin/test (Redesign-Pakete und andere Sitzungen schieben nach).

  • Produces: den Befund, ob origin/test ein Vorfahre von feature/auftraege ist (Voraussetzung des Fast-Forward-Pushs), und ein aufgeräumtes WOMS-Repo.

  • Schritt 1: Abstand zu origin/test

Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo && git fetch origin
B=<B aus Z2.1>
git merge-base --is-ancestor $B origin/test && echo "Redesign bis $B ist auf origin/test" || echo "Redesign bis $B ist NOCH NICHT auf origin/test (Push des Nutzers steht aus: p1, p2, … nie --force)"
git merge-base --is-ancestor origin/test feature/auftraege && echo "origin/test ist Vorfahre: Fast-Forward-Push möglich" || echo "origin/test ist weiter oder anders"
git log --oneline feature/auftraege..origin/test | head -15

Das Ergebnis gehört ins Protokoll und in die Übergabe. Ist origin/test weiter (andere Sitzungen), entsteht ein Merge-Commit Merge origin/test in feature/auftraege (Merge, nicht Rebase, wegen der Merge-Commits der Stränge). Dann Z2.2 vollständig wiederholen (Tests, Build) und die Läufer aus Z2.3 einmal laufen lassen. Ist das Redesign noch nicht auf origin/test, bleibt der Branch unverändert: er enthält die Redesign-Pakete bis B, und der Nutzer pusht erst diese (Übergabe, Schritt „WOMS“).

  • Schritt 2: Lane-Worktrees aufräumen (nach grünem Schritt 1; die Branches bleiben)
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
R=/root/reports/auftraege-rechnungen-2026-10-06
for n in 1 2; do
  d=$Wo-w$n
  [ -d "$d" ] || { echo "Lane w$n: Worktree schon weg"; continue; }
  if [ -d "$d/.superpowers" ] && [ ! -e "$R/sdd-archiv/w$n" ]; then mkdir -p "$R/sdd-archiv" && mv "$d/.superpowers" "$R/sdd-archiv/w$n"; fi
  # nur ein Symlink und nur, wenn er auf die Basis zeigt; ein echtes node_modules ist ignoriert und blockiert die Entfernung nicht
  if [ -L "$d/node_modules" ] && [ "$(readlink "$d/node_modules")" = "../dev-auftraege/node_modules" ]; then rm "$d/node_modules"; fi
  git -C "$Wo" worktree remove "$d" && echo "Lane w$n: Worktree entfernt (Branch bleibt)" || { echo "Lane w$n: nicht entfernt, Rest:"; git -C "$d" status --short; }
done
git -C $Wo worktree list | rg 'dev-auftraege'
git -C $Wo branch --list 'auftraege/*'

Erwartet: beide Lanes entfernt, nur dev-auftraege in der Liste, Branches auftraege/w1-rechnungen und auftraege/w2-auftraege bleiben. Kein --force.

  • Schritt 3: Protokoll und Abschluss von Z2
R=/root/reports/auftraege-rechnungen-2026-10-06; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
{ echo; echo "## Z2 abgeschlossen $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- WOMS_HEAD=$(git -C $Wo rev-parse --short HEAD) B=<Tag> ORIGIN_TEST=$(git -C $Wo rev-parse --short origin/test)"; echo "- Redesign auf origin/test: <ja/nein>; Fast-Forward möglich: <ja/nein>; Bilder: $R/bilder/z2/"; } >> $R/z-protokoll.md

Z3 – Kontrolle (Portal und WOMS)

Z3 beginnt erst, wenn Z1 und Z2 grün sind: $R/z-protokoll.md enthält die Zeilen ## Z1 abgeschlossen und ## Z2 abgeschlossen. Z3.1 bis Z3.3 ändern keinen Code. Z3.4 ändert Code nur für bestätigte Befunde. Kein Agent in Z3 pusht, legt Worktrees an oder startet die volle Suite (das tut nur Z3.5 selbst).

Task Z3.1: Prüfmaterial erzeugen

Files: keine Änderung im Repo. Neu außerhalb: $L/pruefmaterial.mjs (Scratchpad), $R/pruefmaterial/…, $R/kontrolle/ (leer angelegt).

Interfaces:

  • Consumes: Z1 und Z2 grün; Testhilfen test/auftraegePdfHilfe.js (beispielAuftrag(extra), beispielSettings, leseSeiten(pdf) → string[][]) und test/auftraegeBestaetigungHilfe.js (abDokument(extra), kunde(kundenart, extra), TOKEN); erzeugeAuftragPdf(auftrag, { settings, vorschau, jetzt, ersetzt }) → Promise<Buffer> aus server/services/auftraege/pdf.js; sendeAuftragMail(auftrag, { pdf, token, erinnerung }) und sendeBestaetigtMails(auftrag) aus server/services/auftraege/mails.js (V4; sie verschicken K31 samt Teamkopie bzw. K32 und T22 so, wie der Server es tut), die Mail-Senke mailSink() (test/support/mailSink.js) und setMailTransportForTests(senke) aus server/services/mailer.js.

  • Produces in $R/pruefmaterial/: stand.md (SHAs und Diff-Basen), portal-dateien.txt, portal-diffstat.txt, woms-dateien.txt, woms-diffstat.txt, bekannte-vertragsfragen.md, spec-zeilen.md (Spec mit Zeilennummern), pdf/<fall>.pdf, pdf/<fall>.seiten.txt, pdf/<fall>-<n>.png, pdf/faelle.md, pdf/pdffonts.txt, mails/<fall>-<n>.html|.txt (jede wirklich verschickte Mail, auch die Teamkopie der K31), mails/liste.md (An, BCC, Betreff je Mail), bilder.md (alle Bilder für Prüfer 4).

  • Schritt 1: Voraussetzungen prüfen, Stand festhalten

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
R=/root/reports/auftraege-rechnungen-2026-10-06
mkdir -p $R/pruefmaterial/pdf $R/pruefmaterial/mails $R/kontrolle
rg -c '^## Z1 abgeschlossen|^## Z2 abgeschlossen' $R/z-protokoll.md
git -C $P status --short | head -5; git -C $Wo status --short | head -5
BASIS_P=$(git -C $P merge-base origin/main feature/auftraege)
B=<B aus der Zeile „## Z2 abgeschlossen“ im Protokoll>
{ echo "# Stand der Kontrolle $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- Portal feature/auftraege = $(git -C $P rev-parse --short HEAD), Diff-Basis origin/main = $(git -C $P rev-parse --short $BASIS_P)"; echo "- WOMS feature/auftraege = $(git -C $Wo rev-parse --short HEAD), Diff-Basis (Redesign) B = $B"; } > $R/pruefmaterial/stand.md
cat $R/pruefmaterial/stand.md

Erwartet: 2 (beide Abschlusszeilen), beide git status ohne Ausgabe, stand.md mit zwei SHAs. Anders: Z1 oder Z2 sind nicht fertig, Z3 nicht starten.

  • Schritt 2: Datei- und Diff-Listen
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
R=/root/reports/auftraege-rechnungen-2026-10-06
BASIS_P=$(git -C $P merge-base origin/main feature/auftraege); B=<B>
git -C $P diff --name-status $BASIS_P feature/auftraege > $R/pruefmaterial/portal-dateien.txt
git -C $P diff --stat=160,100 $BASIS_P feature/auftraege | tail -150 > $R/pruefmaterial/portal-diffstat.txt
git -C $Wo diff --name-status $B feature/auftraege > $R/pruefmaterial/woms-dateien.txt
git -C $Wo diff --stat=160,100 $B feature/auftraege | tail -80 > $R/pruefmaterial/woms-diffstat.txt
wc -l $R/pruefmaterial/portal-dateien.txt $R/pruefmaterial/woms-dateien.txt
rg -n '^D' $R/pruefmaterial/portal-dateien.txt $R/pruefmaterial/woms-dateien.txt

Erwartet: etwa 90 bis 150 Zeilen im Portal, etwa 20 bis 60 im WOMS (ohne das Redesign davor); keine mit D (gelöschte Dateien wären ein Befund für Prüfer 2: Bestehendes darf nicht verschwinden).

  • Schritt 3: Bekannte Vertragsfragen und nummerierte Spec
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; R=/root/reports/auftraege-rechnungen-2026-10-06
D=$P/docs/superpowers/plans
for f in $D/2026-10-06-auftraege-0[1-9]-*.md $D/2026-10-06-auftraege-1[0-2]-*.md; do
  printf '\n# %s\n' "$(basename "$f")"; awk '/^## Vertragsfragen/{p=1;next} /^## /{p=0} p' "$f"
done > $R/pruefmaterial/bekannte-vertragsfragen.md
for f in $D/2026-10-06-auftraege-0[1-9]-*.md $D/2026-10-06-auftraege-1[0-2]-*.md; do printf '%-50s %s Zeilen\n' "$(basename "$f")" "$(awk '/^## Vertragsfragen/{p=1;next} /^## /{p=0} p' "$f" | wc -l)"; done
cat -n $P/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md > $R/pruefmaterial/spec-zeilen.md

Erwartet: zwölf Zeilen (Plan-Dateien 01 bis 12), jede mit mindestens zehn Zeilen (Stand beim Schreiben dieses Plans: 10 bis 32). Steht bei einer Datei 0, nennt sie den Abschnitt anders: kurz nachsehen (rg -n '^#+ .*[Vv]ertragsfrage' <Datei>) und die Überschrift im awk-Muster anpassen.

  • Schritt 4: Skript $L/pruefmaterial.mjs anlegen (Scratchpad, nicht ins Repo; Namen der Helfer und Builder stehen in den Interfaces oben: stimmt einer nicht mehr, im Helfer nachlesen mit rg -n "^export" test/auftraegePdfHilfe.js test/auftraegeBestaetigungHilfe.js server/services/auftraege/mails.js)
// Prüfmaterial für Z3: Beispiel-PDFs der Auftragsbestätigung und die verschickten Mails (K31 samt Teamkopie, K32, T22).
// Liest den Portal-Stand, schreibt nur unter OUT. Kein Netz (Port 1), keine Zugangsdaten.
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

const P = '/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege'
const OUT = '/root/reports/auftraege-rechnungen-2026-10-06/pruefmaterial'
if (!OUT.startsWith('/root/reports/auftraege-rechnungen-2026-10-06/')) throw new Error('Zielpfad liegt außerhalb des Berichtsordners')
const PDF_DIR = join(OUT, 'pdf')
const MAIL_DIR = join(OUT, 'mails')
mkdirSync(PDF_DIR, { recursive: true })
mkdirSync(MAIL_DIR, { recursive: true })

const { useTestEnv } = await import(`${P}/test/support/testEnv.js`)
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
const { beispielAuftrag, beispielSettings, leseSeiten } = await import(`${P}/test/auftraegePdfHilfe.js`)
const { TOKEN, abDokument, kunde } = await import(`${P}/test/auftraegeBestaetigungHilfe.js`)
const { erzeugeAuftragPdf } = await import(`${P}/server/services/auftraege/pdf.js`)
const { sendeAuftragMail, sendeBestaetigtMails } = await import(`${P}/server/services/auftraege/mails.js`)
const { mailSink } = await import(`${P}/test/support/mailSink.js`)
const { setMailTransportForTests } = await import(`${P}/server/services/mailer.js`)

// --- PDFs: jede Variante, die ein Prüfer sehen muss ---
const JETZT = new Date('2026-10-06T10:00:00.000Z')
const basis = beispielAuftrag()
const privat = { ...basis.kunde, firma: '', name: 'Jörg Müßig-Åström', kundenart: 'privat' }
const lang = Array.from({ length: 20 }, (_, i) => ({
  beschreibung: i % 3 === 0 ? `Position ${i + 1}: ${'X'.repeat(170)}` : `Position ${i + 1}: Beschreibung mit Umlauten äöüß, € und „Anführungszeichen“ – Gedankenstrich`,
  menge: i + 1,
  einzelpreisCents: 1000 + i,
}))
const summe = (pos) => pos.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
const faelle = {
  festpreis: ['Festpreis, Unternehmen, 50 % Anzahlung', basis, {}],
  'festpreis-privat': ['Festpreis, Privatperson: Belehrung und Muster-Widerrufsformular', beispielAuftrag({ kunde: privat }), {}],
  'festpreis-20-positionen': ['20 Positionen, lange Wörter, Umlaute, Seitenumbruch', beispielAuftrag({ positionen: lang, summeCents: summe(lang), zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }), {}],
  aufwand: ['Nach Aufwand, monatlich, Schätzung 10 Stunden', beispielAuftrag(AUFWAND), {}],
  'ohne-steuernummer': ['Einstellung Steuernummer leer', basis, { settings: { ...beispielSettings, steuernummer: '' } }],
  ersetzt: ['Neue Fassung: Hinweis auf die ersetzte AB', beispielAuftrag({ ersetztId: 'alt1' }), { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }],
  vorschau: ['Vorschau eines Entwurfs (ENTWURF, ohne Nummer)', beispielAuftrag({ nummer: '', gesendetAm: null }), { vorschau: true }],
  'frei-zahlungsplan': ['Zahlungsplan „Frei“ mit eigenem Text', beispielAuftrag({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in drei Raten: 30 %, 30 %, 40 %, jeweils nach Meilenstein.' } }), {}],
}
const faelleMd = ['# Beispiel-PDFs', '']
for (const [name, [beschreibung, auftrag, extra]] of Object.entries(faelle)) {
  faelleMd.push(`- ${name}: ${beschreibung}`)
  try {
    const pdf = await erzeugeAuftragPdf(auftrag, { settings: beispielSettings, jetzt: JETZT, ersetzt: null, ...extra })
    writeFileSync(join(PDF_DIR, `${name}.pdf`), pdf)
    const seiten = leseSeiten(pdf)
    writeFileSync(join(PDF_DIR, `${name}.seiten.txt`), `${seiten.map((zeilen, i) => `--- Seite ${i + 1} von ${seiten.length} ---\n${zeilen.join('\n')}`).join('\n')}\n`)
    console.log(`${name}: ${seiten.length} Seiten, ${pdf.length} Bytes`)
  } catch (e) {
    faelleMd.push(`  FEHLER beim Erzeugen: ${e.status || ''} ${e.message}`)
    console.log(`${name}: FEHLER ${e.status || ''} ${e.message}`)
  }
}
writeFileSync(join(PDF_DIR, 'faelle.md'), `${faelleMd.join('\n')}\n`)

// --- Mails: die echten Versandfunktionen (V4) in eine Mail-Senke; nichts verlässt den Rechner ---
const senke = mailSink()
setMailTransportForTests(senke)
const ATTRAPPE = Buffer.from('%PDF-1.4\nBeispiel\n%%EOF\n')
const BEST = (extra = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...extra })
const firma = kunde('unternehmen')
const aufwandFirma = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: firma }
const szenarien = {
  'k31-festpreis-privat': () => sendeAuftragMail(abDokument(), { pdf: ATTRAPPE, token: TOKEN }),
  'k31-festpreis-unternehmen': () => sendeAuftragMail(abDokument({ kunde: firma }), { pdf: ATTRAPPE, token: TOKEN }),
  'k31-aufwand-erinnerung': () => sendeAuftragMail(abDokument(aufwandFirma), { pdf: ATTRAPPE, token: TOKEN, erinnerung: true }),
  'k32-t22-privat-fruehbeginn': () => sendeBestaetigtMails(BEST({ fruehbeginn: true })),
  'k32-t22-privat-ohne-fruehbeginn': () => sendeBestaetigtMails(BEST()),
  'k32-t22-unternehmen': () => sendeBestaetigtMails(BEST({ kunde: firma })),
}
const adr = (feld) => (feld || []).map((eintrag) => eintrag.address).join(', ') || '–'
const liste = ['# Verschickte Mails (aus der Mail-Senke)', '']
for (const [name, lauf] of Object.entries(szenarien)) {
  const vorher = senke.sent.length
  try {
    await lauf()
  } catch (e) {
    liste.push(`- ${name}: FEHLER ${e.message}`)
    console.log(`${name}: FEHLER ${e.message}`)
    continue
  }
  senke.sent.slice(vorher).forEach((mail, i) => {
    const datei = `${name}-${i + 1}`
    writeFileSync(join(MAIL_DIR, `${datei}.html`), mail.html)
    writeFileSync(join(MAIL_DIR, `${datei}.txt`), `An: ${adr(mail.to)}\nBCC: ${adr(mail.bcc)}\nBetreff: ${mail.subject}\nAnhänge: ${(mail.attachments || []).map((a) => a.filename).join(', ') || '–'}\n\n${mail.text}\n`)
    liste.push(`- ${datei}: An ${adr(mail.to)} | BCC ${adr(mail.bcc)} | ${mail.subject}`)
  })
  console.log(`${name}: ${senke.sent.length - vorher} Mail(s)`)
}
writeFileSync(join(MAIL_DIR, 'liste.md'), `${liste.join('\n')}\n`)
console.log('Speicher: Heap', Math.round(process.memoryUsage().heapUsed / 1e6), 'MB, RSS', Math.round(process.memoryUsage().rss / 1e6), 'MB')
  • Schritt 5: Skript laufen lassen
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
cd $P && free -m | sed -n 2p
flock $L/auftraege-schwer.lock timeout 300 nice -n 10 node --max-old-space-size=512 $L/pruefmaterial.mjs

Erwartet: acht Zeilen <fall>: <n> Seiten, <n> Bytes (festpreis 2 bis 3 Seiten, privat vier oder mehr, 20-positionen drei bis fünf, vorschau mit „Entwurf“), dann sechs Zeilen <fall>: 2 Mail(s) (K31 an den Kunden und Teamkopie; K32 und T22), Heap unter 150 MB. Eine Zeile FEHLER … ist kein Abbruch: sie ist selbst Prüfmaterial (z. B. verweigert ohne-steuernummer das PDF mit 409, oder die AGB-Datei fehlt). mails/liste.md zeigt je Mail Empfänger und BCC: die K31 an den Kunden hat kein BCC, die Teamkopie geht an CHAT_NOTIFY_EMAILS (Übersicht V6); weicht das ab, ist es ein Befund für Prüfer 2, hier nicht beheben.

  • Schritt 6: Seitenbilder, Schriften, Bilderliste
R=/root/reports/auftraege-rechnungen-2026-10-06; L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
flock $L/auftraege-schwer.lock timeout 300 docker run --rm --network none -m 700m --user 0:0 -v $R/pruefmaterial/pdf:/pdf --entrypoint sh \
  ghcr.io/paperless-ngx/paperless-ngx:2.20.15 -c 'cd /pdf && for f in *.pdf; do b=${f%.pdf}; echo "$b: $(pdfinfo "$f" | grep -E "^(Pages|Page size)" | tr "\n" " ")"; pdftoppm -png -r 70 "$f" "$b"; done; pdffonts festpreis.pdf > pdffonts.txt; cat pdffonts.txt'
{ echo "# Bilder für die Prüfung (Pfade relativ zu $R)"; for d in bilder/l6 bilder/z2 pruefmaterial/pdf; do echo; echo "## $d"; find $R/$d -name '*.png' 2>/dev/null | sed "s#^$R/##" | sort; done; } > $R/pruefmaterial/bilder.md
rg -c '\.png$' $R/pruefmaterial/bilder.md

Erwartet: je PDF Pages: n Page size: 595.28 x 841.89 pts (A4), pdffonts nennt nur Figtree (kein Helvetica), die Bilderliste mehr als 40 PNG-Namen (21 aus L6, WOMS-Bilder aus Z2.3, Seiten der Beispiel-PDFs). Fehlt das Image (Unable to find image), bleiben die PDFs und die .seiten.txt; in bilder.md den Hinweis „PDF-Bilder fehlen“ ergänzen, Prüfer 4 sieht dann nur die Bildschirmfotos.

  • Schritt 7: Protokoll
R=/root/reports/auftraege-rechnungen-2026-10-06
{ echo; echo "## Z3.1 Prüfmaterial $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- $(wc -l < $R/pruefmaterial/portal-dateien.txt) Portal-Dateien, $(wc -l < $R/pruefmaterial/woms-dateien.txt) WOMS-Dateien im Diff; $(ls $R/pruefmaterial/pdf/*.pdf | wc -l) Beispiel-PDFs, $(ls $R/pruefmaterial/mails/*.txt | wc -l) Mails aus der Mail-Senke (mails/liste.md)"; } >> $R/z-protokoll.md

Kein Commit.


Task Z3.2: Fünf Prüfer parallel (sonnet)

Files: keine Änderung. Jeder Prüfer schreibt $R/kontrolle/befunde-<n>-<name>.json und $R/kontrolle/protokoll-<n>-<name>.md; Prüfer 5 zusätzlich $R/kontrolle/abdeckung.json.

Interfaces:

  • Consumes: das Prüfmaterial aus Z3.1, beide Worktrees (nur lesen), Spec, Übersicht, Protokoll.
  • Produces: fünf JSON-Arrays mit Elementen { schwere: "Critical" | "Important" | "Minor", datei, zeile, befund, beleg, vorschlag } (Format und Regeln im Kopf unten), fünf Protokolle, die Abdeckungsliste.

Die Prüfer sind general-purpose-Agenten mit model: sonnet (Memory „feedback-billige-modelle“: kein opus, kein fable). Jeder Auftrag besteht aus dem Kopf (Schritt 1, wörtlich) und genau einem Linsenblock (Schritte 2 bis 6). Die Platzhalter <P_SHA>, <W_SHA>, <BASIS_P>, <B> setzt Z aus $R/pruefmaterial/stand.md ein, <n> und <name> stehen im Linsenblock.

  • Schritt 1: Kopf (steht vor jedem Linsenblock)
Du bist Prüfer <n> von 5 in der Schlusskontrolle des Vorhabens „Aufträge und Rechnungen“ (WEBklar-Kundenportal und Ticketsystem WOMS). Fünf Prüfer lesen denselben fertigen Stand, jeder mit einer anderen Linse; deine Linse steht unten. Du änderst NICHTS.

DARFST:
- Dateien lesen (cat, sed -n, rg, `command grep -a`, find, ls), `git show`, `git log`, `git diff` (nur lesend), PNG-Bilder mit dem Lesewerkzeug ansehen.
- Kleine Rechenproben an reinen Funktionen: `cd <Worktree> && timeout 60 node --max-old-space-size=256 --input-type=module -e '…'` (kein Server, kein Netz, kein Appwrite, kein Stripe, keine Mails).
- Genau die zwei Dateien schreiben, die unter AUSGABE stehen.
DARFST NICHT:
- Code, Tests oder andere Repo-Dateien ändern; committen; Worktrees anlegen; npm, Tests oder Builds starten (ihre Ergebnisse stehen im Protokoll).
- Zugangsdaten, .env-Inhalte, Token oder Live-Daten lesen oder ausgeben. `git remote -v` und `git config remote.origin.url` enthalten ein Zugangstoken: nie aufrufen.
- `rm -r`, `rsync --delete`, `git clean`, `git reset --hard`, `git worktree remove --force`, `pkill -f`. Keine Datei außerhalb von /root/reports/auftraege-rechnungen-2026-10-06/kontrolle/ schreiben.
- `grep` benutzen: das ist hier eine Funktion, die Dateien still auslässt. Nimm `rg` oder `command grep -a`.

STAND:
- Portal: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege, Zweig feature/auftraege, HEAD <P_SHA>. Diff-Basis <BASIS_P>: `git diff <BASIS_P> feature/auftraege -- <Pfad>`.
- WOMS: /home/webklar/apps/ticket.webklar.com/dev-auftraege, Zweig feature/auftraege, HEAD <W_SHA>. Diff-Basis <B>; das Redesign davor ist nicht Gegenstand der Prüfung.
- Der Datenschutz-Commit von Strang L7 liegt bewusst NICHT im Portal-Zweig (entschieden: er wird erst beim AB-Livegang nach der Freigabe gemergt): `git -C <Portal> show auftraege/l7-rechtstexte:public/datenschutz.html` zeigt den Stand mit dem neuen Abschnitt 9a.
- Prüfmaterial: /root/reports/auftraege-rechnungen-2026-10-06/pruefmaterial/ (stand.md, portal-dateien.txt, woms-dateien.txt, *-diffstat.txt, bekannte-vertragsfragen.md, spec-zeilen.md, pdf/ mit Beispiel-PDFs, ihrem Text (*.seiten.txt) und PNG je Seite, mails/ mit den verschickten Mails (K31 an den Kunden samt Teamkopie, K32, T22) und liste.md, bilder.md). Bildschirmfotos: …/bilder/l6 (Kundenseiten) und …/bilder/z2 (WOMS). Was die Integration getan, entschieden und angenommen hat: …/z-protokoll.md.

LIES ZUERST, in dieser Reihenfolge:
1. Die Spezifikation docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md im Portal-Worktree.
2. Die Übersicht docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (Verträge V1–V8, Global Constraints, Review Focus).
3. pruefmaterial/bekannte-vertragsfragen.md: bekannt und entschieden, nicht noch einmal melden, außer das Zusammenspiel ist falsch.
4. z-protokoll.md.
5. Die Plan-Dateien der Stränge (docs/superpowers/plans/2026-10-06-auftraege-01 bis 11-*.md) nur dort, wo dein Auftrag es verlangt.
Spezifikation und Verträge gelten; die Plan-Dateien sagen, was die Stränge vorhatten. Arbeitsweise: zuerst die Dateiliste, dann gezielt lesen; große Dateien abschnittsweise (`sed -n 'a,bp'`); Diffs statt ganzer Dateien, wo das reicht.

SCHWEREGRADE:
- Critical: Geld wird falsch, doppelt oder gar nicht berechnet; eine Pflichtangabe einer Rechnung fehlt; ein Fremder kann eine Auftragsbestätigung bestätigen, lesen oder ändern; personenbezogene Daten gelangen nach außen; ein Rechtstext ist für den Fall, für den er gedacht ist, falsch oder unwirksam; Daten gehen verloren oder werden beschädigt; der Server startet nicht oder eine bestehende Funktion bricht.
- Important: ein realistischer Fall liefert ein falsches oder irreführendes Ergebnis; ein Spec-Punkt fehlt oder ist anders umgesetzt; zwei Texte widersprechen sich; eine Oberfläche ist im Hauptfall nicht bedienbar (auch nicht mit der Tastatur oder auf dem Handy) oder sagt dem Nutzer etwas Falsches. Es gibt einen Umweg.
- Minor: seltener Fall, Schönheit, Wortwahl, Nachbesserung ohne Folgen für Geld, Recht oder Sicherheit.
Im Zweifel eine Stufe niedriger und den Zweifel im Befund nennen. Reine Rechtsfragen (Wortlaut, Normzitat) sind höchstens Minor mit dem Anfang „Rechtfrage:“ im Befund, es sei denn, zwei Texte des Systems widersprechen sich oder eine Pflichtangabe der Spezifikation fehlt (dann gilt die Schwere des Falls).

REGELN:
1. Jeder Befund hat einen Beleg: Datei:Zeile mit zitierter Stelle, Ausgabe einer Probe oder zwei sich widersprechende Stellen. Ohne Beleg kein Befund.
2. Ein Befund ist ein Problem. Dasselbe Problem an mehreren Stellen ist ein Befund; die Stellen stehen im Beleg.
3. Keine Befunde zu Dingen, die die Spezifikation unter „Nicht im Umfang“ ausschließt oder die in bekannte-vertragsfragen.md entschieden sind. Keine Wünsche nach neuen Funktionen.
4. Höchstens 60 Befunde, nach Schwere sortiert; bei mehr die gewichtigsten, und in der Antwort nennen, wie viele du weggelassen hast.
5. Ist deine Linse in Ordnung, ist die leere Liste [] ein gutes Ergebnis; das Protokoll belegt dann, wo du hingesehen hast.
6. `vorschlag`: was in welcher Datei zu ändern ist, ein bis drei Sätze, ohne den Code zu schreiben. Ändert der Vorschlag einen Vertrag (V1–V8: Namen, Signaturen, Datenformen, Endpunkte, Mailplatzhalter), beginnt er mit „Vertragsänderung:“; ändert er den Wortlaut eines Rechtstextes, beginnt er mit „Wortlaut:“.

AUSGABE (zuerst `mkdir -p /root/reports/auftraege-rechnungen-2026-10-06/kontrolle`):
1. …/kontrolle/befunde-<n>-<name>.json: ein JSON-Array. Jedes Element hat genau diese Schlüssel: "schwere" ("Critical" | "Important" | "Minor"), "datei" (Pfad relativ zum Worktree mit Vorsatz "portal:" oder "woms:"; bei einem Bild "bild:<Dateiname>"), "zeile" (Zahl oder null), "befund" (ein bis drei Sätze: was ist falsch, und was passiert dann), "beleg" (Zitat, Probenausgabe oder Gegenüberstellung), "vorschlag". Danach prüfen: node -e 'const a=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(a.length,"Befunde")' <Datei>
2. …/kontrolle/protokoll-<n>-<name>.md: je Prüfpunkt deiner Linse eine Zeile „<Nummer> · ok | Befund | nicht prüfbar · <Stelle oder Grund>“.
ANTWORT: höchstens zehn Zeilen: Anzahl Befunde je Schwere, die zwei gewichtigsten in je einem Satz, die beiden Dateipfade. Kein weiterer Text.
  • Schritt 2: Linsenblock 1 – Geld und Rechenlogik (<n> = 1, <name> = geld)
LINSE 1 – GELD UND RECHENLOGIK. Frage: Kann ein Kunde zu viel, zu wenig, doppelt oder falsch berechnet werden, oder zeigt das System dem Admin falsche Zahlen?

Zu lesen (Portal): server/services/auftraege/{positionen,rechnungsbau,projektkonto,arbeitsblaetter,projektRechnungenStore,auftragSicht,nummern,auftraege,konstanten}.js; server/services/finance/stripeFinance.js (Diff gegen die Basis), server/services/finance/settings.js; server/routes/admin/{finanzen,projektkonten,auftraege}.js; server/routes/team/arbeitsblaetter.js; server/services/keyedLock.js und server/services/worksheetMinutes.js. WOMS: src/lib/{rechnungDialogView,projektkontoView,auftragView}.js, src/lib/financeApi.js, src/components/auftraege/*. Die Tests (test/auftraege*.test.js) zeigen, was die Autoren abgesichert haben; such die Fälle, die fehlen.

Prüfpunkte (im Protokoll so nummerieren):
1.1 Cent: jeder Betrag eine sichere Ganzzahl; nirgends Gleitkomma auf Geldwerten (Multiplikation oder Division mit Dezimalen, toFixed, parseFloat auf Beträgen); Rundung genau dort, wo die Übersicht sie festlegt (Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung); Prozent außerhalb 1–99 oder mit Nachkomma; Anzahlung 0 oder über dem Rest; Grenzen der Übersicht (Menge 1–999, Einzelpreis bis 10.000.000, Summe bis 99.999.999, höchstens 20 Zeilen einschließlich Minuszeilen; eine Schlussrechnung darf bis 40 Zeilen haben: AB-Positionen höchstens 20, Zusatzpositionen höchstens 10, Abzüge höchstens 10).
1.2 Aufwand: je Arbeitsblatt auf 15 Minuten aufgerundet (vor dem Zusammenfassen, nicht danach); Unterstützung wie worksheetMinutes; Abo-Zeit (countsToPlan === true), Kommentare und 0 Minuten werden nie berechnet; „Menge 1, Betrag = Stunden × Satz“: die Rundung auf ganze Cent je Tag (Math.round(Minuten × Satz / 60); so entschieden, siehe bekannte Vertragsfragen von L2) ist an Sätzen wie 9999, 8333 und 10001 und an 15, 30, 45, 60 Minuten zu prüfen: bleibt die Summe der Zeilen gleich der Rechnungssumme und dem Zeitwert im Projektkonto (L1 rechnet je Tag genauso), und stimmt der Text „2,25 Std. à 100,00 €“ mit dem Betrag überein? Berliner Tage der Arbeitsblätter (Datum TT.MM.JJJJ, Zeitzone um Mitternacht), Tageszeilen sortiert, Zeilentext höchstens 200 Zeichen, zeilentexte ersetzt nur den Text.
1.3 Zusammenfassung zu Wochen bei mehr als 20 Zeilen: ISO-Kalenderwoche am Jahreswechsel (Woche 53 oder 1, Jahr der Woche ungleich Kalenderjahr), Schlüssel KW-<Jahr>-<KW>, die Schleife endet immer, zusätzliche Positionen und Minuszeilen zählen mit, die Summe ist vor und nach dem Zusammenfassen gleich.
1.4 Sperren und Wettläufe (die Schlüssel sind entschieden, V2: je Sperre genau ein zusammengesetzter Schlüssel `SPERRE_NUMMER`, `sperreRechnung(kundenId)`, `sperreAuftrag(id)`; du prüfst nur die Einhaltung): benutzen alle Stellen mit withLocks diese Hilfen und keine eigenen Listen oder Zeichenfolgen? Gilt die Reihenfolge der Übersicht: wer beide braucht, nimmt erst sperreRechnung und darin sperreAuftrag, innerhalb von sperreAuftrag nie sperreRechnung, SPERRE_NUMMER vor sperreAuftrag? Sperren sind nicht wiederbetretbar: ruft Code unter einer Sperre eine Funktion auf, die dieselbe Sperre nimmt (Verklemmung)? Gehen die Mails (K31, K32, T22) erst nach Freigabe der Sperre raus? Gilt die Sperre auch für Anzahlung, Schlussrechnung, Finalisieren, Stornieren, Löschen? Lesen-Prüfen-Schreiben ohne Sperre irgendwo (Prüfung der Arbeitsblätter gegen projektRechnungen; vergibNummer nur unter SPERRE_NUMMER)?
1.5 Projektkonto (Spec Abschnitt 4): Vereinbart nur aus Festpreis-ABs mit Status versendet, bestätigt, abgerechnet (nicht ersetzt, storniert, widerrufen, Entwurf); Berechnet nur aus finalisierten, aktiven Projektrechnungen (Entwürfe und stornierte nicht, uneinbringlich ja); bezahlt = amount_paid; Noch offen nie unter 0; ohne AB „–“; „Ohne Projekt“; Zeit erfasst ohne Abo-Zeit; Stripe nicht erreichbar: Beträge 0 und stripeOk false, nie still falsche Zahlen. Weicht die Rechenregel von der Spezifikation ab (etwa nur Rechnungen mit auftragId einer Festpreis-AB), ist das ein Befund, wenn eine falsche Zahl entstehen kann.
1.6 Anzahlung und Schlussrechnung: Anzahlung höchstens AB-Summe minus bereits Berechnetes (nur aktive, nicht stornierte), mindestens 1 Cent; Schlussrechnung = alle AB-Positionen plus Nachträge, je früherer finalisierter, nicht stornierter Rechnung der AB eine Minuszeile (negativer Betrag, Menge 1), Summe größer 0, sonst 409 „Nichts mehr offen“; ein offener Entwurf zur AB blockiert. Wird nach dem Anlegen eines Schlussrechnungs-Entwurfs eine Anzahlung storniert oder erst dann gesendet: stimmt der Entwurf noch, und prüft das Senden des Entwurfs erneut? Status der AB: abgerechnet nach finalisierter Schlussrechnung, Rückgang beim Storno auf den richtigen Vorstatus (bestaetigt, wenn bestaetigtAm gesetzt). Neue Fassung nach einer Anzahlung: wandern die Rechnungen mit (umhaengen), und zählt die Minuszeile die Anzahlung der alten AB?
1.7 Buchführungsfälle: dieselben Arbeitsblätter in zwei Entwürfen (ein Entwurf zählt als abgerechnet); Entwurf löschen und Storno geben frei; Schutz gegen Doppelklick (gleicher idempotencyKey mit anderem Inhalt, 30-Sekunden-Schutz); Adresssperre über 250 €; Summen 0 oder negativ; Stripe-Antwort fehlt nach dem Anlegen (Dokument in projektRechnungen ohne Rechnung oder umgekehrt): bleibt ein Arbeitsblatt dann dauerhaft gesperrt?
1.8 Stripe-Parameter: Minuszeile über amount (negativ) statt unit_amount_decimal; custom_fields höchstens 4, Name höchstens 40, Wert höchstens 140 Zeichen (Projektname gekürzt); rendering.pdf.page_size 'a4' bei ALLEN Rechnungen (auch Art frei); Metadaten vollständig und im erlaubten Umfang (Stripe: höchstens 50 Schlüssel, Wert höchstens 500 Zeichen); Währung EUR.
1.9 Anzeige und Eingabe: Beträge überall über euroDe (WOMS euro); die Eingaben „1.234,56“ und „1234,5“ in den Dialogen werden ohne Gleitkomma in Cent umgerechnet; die Summe im Dialog ist die Summe, die der Server später rechnet (der Vorschau-Endpunkt als einzige Quelle?); das Abzeichen „abgerechnet“ zeigt die Rechnungsnummer oder „Entwurf“.
  • Schritt 3: Linsenblock 2 – Sicherheit und Datenschutz (<n> = 2, <name> = sicherheit)
LINSE 2 – SICHERHEIT UND DATENSCHUTZ. Frage: Kann ein Fremder etwas sehen oder tun, was nur der Kunde oder das Team darf, und gelangen personenbezogene Daten dorthin, wo sie nicht hingehören?

Zu lesen (Portal): server/index.js (Reihenfolge der app.use, Header, trust proxy, static), server/routes/{auftrag,auftraegeKunde,abo}.js, server/routes/admin/{auftraege,projektkonten,finanzen}.js, server/routes/team/{index,arbeitsblaetter}.js, server/services/auftraege/{token,bestaetigung,ablage,auftraege,mails,schalter,auftragSicht}.js, server/services/kundenansicht.js samt Wächter-Test, server/services/keyedLock.js, scripts/auftraege-setup.mjs, public/{auftrag.html,dashboard.html,app.js,widerrufen.html}, public/auftrag*.js, server/mail-templates/{K31,K32,T22}*. WOMS: src/lib/financeApi.js und src/components/auftraege/* (Blob-Adressen, dangerouslySetInnerHTML).

Prüfpunkte:
2.1 Token: 32 Zufallsbytes aus crypto.randomBytes, base64url (43 Zeichen); nur der SHA-256 wird gespeichert (nirgends der Klartext: Dokumente, Protokolle, Fehlermeldungen, mailFehler, Adressen in Logs); Format wird vor jeder Datenbankabfrage geprüft; Ablauf (60 Tage ab dem letzten Versand) bei GET, PDF und POST; Erinnerung, neue Fassung, Storno und Widerruf machen den alten Token unbrauchbar. Entschieden (V6): K31 an den Kunden geht OHNE BCC; das Team bekommt eine eigene Kopie (Block teamkopie, an CHAT_NOTIFY_EMAILS, auch bei Erinnerungen) **ohne Link, ohne Knopf und ohne Token**; K32 und T22 enthalten ebenfalls keinen Token. Prüfe das an den Beispielmails (pruefmaterial/mails/liste.md, die .txt und .html jeder Mail) und im Code von mails.js: kein Weg, auf dem der Token ans Team oder in ein Protokoll gelangt.
2.2 Wettläufe mit Sicherheitsfolge: Bestätigen gegen Storno, Neue Fassung, Widerruf und Erinnerung gleichzeitig: liegt auf allen Pfaden, die status, tokenHash oder bestaetigtAm schreiben, dieselbe Sperre? Eine zweite Bestätigung ändert nichts; K32 und T22 gehen genau einmal raus.
2.3 Öffentliche Routen: Ratenbegrenzung (GET 120/h, POST 10/h je Adresse, im Arbeitsspeicher; Fehlversuche zählen; der Speicher wächst nicht unbegrenzt: alte Einträge werden aufgeräumt); req.ip hinter dem Proxy (trust proxy): ein frei gesetzter X-Forwarded-For des Angreifers umgeht die Begrenzung nicht; keine Adresse in Dokumenten oder Protokollen (Spec: keine IP-Adresse); unbekannte, abgelaufene und (bei ausgeschaltetem Schalter) gesperrte Token antworten gleich (404), damit niemand etwas daraus ableiten kann; 410 mit Grund nur für bekannte, gültig geformte Token (so ist der Vertrag).
2.4 Antwort-Header der öffentlichen Seite und Routen: Cache-Control no-store, noindex (Header und meta), Referrer-Policy. Entschieden (V6): der Token steht im **Fragment** (`auftrag.html#t=<Token>`; Rückfall `?t=` nur beim Lesen): es wird nie an einen Server gesendet, die Seite ruft die API mit `credentials: 'omit'` auf; der Token im Pfad der API-Aufrufe (V5) steht in Proxy-Protokollen, das ist bekannt und hingenommen. Befund nur, wenn der Token an Dritte gelangt (ein Link mit `?t=` statt `#t=` in einer Mail, fremde Skripte, Schriften oder Bilder auf der Seite, Referer), oder wenn die Content-Security-Policy der App von den neuen Seiten nicht eingehalten wird (keine Inline-Skripte, wenn die Richtlinie sie verbietet).
2.5 Kundenrouten: requireSession plus Eigentumsprüfung (auftrag.kundenId gleich Sitzungskunde) bei Liste, PDF und Bestätigen; fremde ID gibt 404; Entwürfe nie sichtbar; die Kundenansicht (ein Mitarbeiter schaut als Kunde) kann nicht bestätigen (der Wächter sperrt, KUNDENANSICHT_ALLOWED ist unverändert), auch nicht über den Link-Weg mit dem Cookie einer Kundenansicht.
2.6 Teamrouten: alle neuen Admin-Router hinter requireAppwriteStaff (Admin-Label, zweiter Faktor), eingehängt vor dem Finanzen-Router; /api/team/arbeitsblaetter/abgerechnet hinter requireStaffJwt und ohne Beträge; ein Kunden-Konto darf nichts davon (Mitarbeiter-Zugang per Label).
2.7 Eingabe: POST und PATCH einer AB nehmen nur erlaubte Felder (kein Mass-Assignment auf status, nummer, tokenHash, pdfDatei, pdfSha256, bestaetigt*, kundeJson); kundenId, projektId und zeitquelle gehören zum selben Kunden (Arbeitsblätter fremder Kunden dürfen nicht auf einer Rechnung landen); arbeitsblattIds gehören zur Zeitquelle des Projekts; Längen und Typen werden geprüft.
2.8 Ablage: lesePdf nur über die Whitelist ^[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}\.pdf$ und nie mit einem Pfad aus Nutzereingaben ohne Prüfung; Verzeichnis 0700, Datei 0600, kein Überschreiben; upload-data/ ist über keinen Router und kein express.static auslieferbar; wird beim Ausliefern oder Bestätigen die Prüfsumme der Datei gegen pdfSha256 geprüft (Spec: unveränderlich nach dem Senden)?
2.9 Ausgabe: Seiten und Dialoge setzen Serverdaten nur mit textContent oder React-Escaping (kein innerHTML mit Titel, Positionen, Name); renderMail escaped; Betreff und Empfänger ohne Zeilenumbrüche (Header-Injection) bei Name, Titel, E-Mail; das PDF bereinigt Steuerzeichen und stürzt bei langen Wörtern nicht ab.
2.10 Datenschutz: was wird gespeichert und wohin (Dokument, Datei, Mail an den Kunden, Teamkopie ohne Link, Stripe-Metadaten: nur Kennungen und Projektname, keine Anschrift)? Protokollzeilen (console.*) ohne Token, Adresse, E-Mail, Namen; mailFehler ohne Zugangsdaten und gekürzt; die Antwort auf den Widerruf verrät nicht, ob eine AB existiert; Aufbewahrung 6 und 8 Jahre gegen den fehlenden Löschlauf (bekannt, Vertragsfrage 8: nur melden, wenn weitere Daten ohne jede Frist liegen).
2.11 Schalter und Pilot: AUFTRAEGE_ENABLED=off wirkt serverseitig auf jeder Route (V5: Admin-AB-Routen 404, Rechnungsarten anzahlung und schluss 400, Token- und Kundenrouten wie unbekannt, Liste { aktiv: false, auftraege: [] }); pilot vergleicht die E-Mail klein geschrieben, * heißt alle; eine Route ohne Schalterprüfung (Neue Fassung, Erinnern, PDF, Vorschau) ist ein Befund.
2.12 Betrieb: scripts/auftraege-setup.mjs ist idempotent, löscht nichts und gibt keine Schlüssel aus; pdfkit steht in dependencies (nicht devDependencies) und in package-lock.json; die Schriftdateien haben eine Lizenzdatei; keine weiteren neuen Abhängigkeiten; .gitignore deckt upload-data/.
  • Schritt 4: Linsenblock 3 – Recht und Texte (<n> = 3, <name> = recht)
LINSE 3 – RECHT UND TEXTE. Du bist kein Anwalt und gibst keine Rechtsberatung. Du prüfst, ob Texte und Abläufe in sich stimmig sind, der Spezifikation entsprechen und den Gesetzen, soweit du sie kennst (Rechtslage 2026), und du markierst Zweifel für die Rechtsrunde.

Zu lesen: server/services/auftraege/{texte,pdf,auftragSicht,mails,bestaetigung}.js; die Beispiele in pruefmaterial/pdf/*.seiten.txt (Text jedes Beispiel-PDFs, faelle.md beschreibt sie) und pruefmaterial/mails/*.txt; public/auftrag.html und public/auftrag*.js (die Seitentexte stehen in auftrag-ansicht.js: TEXTE_GEMEINSAM, TEXTE_SIE, TEXTE_DU, zustandAusAntwort, fehlerMeldung, bestaetigtZeilen); den Abschnitt „Aufträge“ in public/dashboard.html und public/app.js; public/widerrufen.html; public/rechtliches/* (Widerrufsbelehrung Projektauftrag, AGB-Kopie samt Prüfsumme); docs/rechtliches/*; die Datenschutzerklärung mit Abschnitt 9a auf dem Zweig auftraege/l7-rechtstexte (siehe STAND); das Freigabe-Dokument /root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md; die Texte der Rechnungen (Zusatzfelder in rechnungsbau.js, Fuß in server/config.js und stripeFinance.js, Positionstexte in positionen.js).

Prüfpunkte:
3.1 Rechnung (§ 34a UStDV): Name und Anschrift beider Seiten, Steuernummer (steht sie auf dem Stripe-PDF? Der Plan setzt die Hinterlegung im Stripe-Dashboard voraus: ist das in der Übergabe und im WOMS-Dialog nicht zu übersehen), Ausstellungsdatum, Art und Umfang der Leistung (Positionstexte verständlich; bei Aufwand mit Datum, Stunden, Satz), Entgelt in einer Summe und der Hinweis auf § 19 UStG; Minuszeilen als Abzug erkennbar; die Rechnung ohne Projekt (frei) wie bisher.
3.2 Auftragsbestätigung (Spec Abschnitt 2, Beispiel-PDFs): Briefkopf, Steuernummer, Empfänger, Titel, Nummer, Datum, Einleitung mit Datum und Weg, Leistungsumfang, Preis oder Aufwandsregel (100 € pro Stunde in 15-Minuten-Schritten, halbe Arbeitszeit der unterstützenden Person nach AGB Nr. 9, unverbindliche Schätzung, Rücksprache bei Überschreitung), § 19-Satz, Zahlungsplan mit Beträgen, Termine, Mitwirkung und Abnahme (AGB Nr. 7 und 8), AGB-Satz mit Fassung, Fuß mit Bankverbindung; bei Privatpersonen Widerrufsbelehrung und Muster-Widerrufsformular als eigene Seiten; bei Unternehmen keine Belehrung. Prüfe jedes Beispiel auf Rechenfehler (Summen, Prozent, Rest), Rechtschreibung, Sie-Form, Umlaute und Zeichen.
3.3 Widerruf (Privatperson): Belehrung und Muster-Formular im Aufbau der gesetzlichen Muster (Art. 246a § 1 Abs. 2, Anlagen 1 und 2 EGBGB; Dienstleistungsvertrag: Frist 14 Tage ab Vertragsschluss, Wertersatz für die bis zum Widerruf erbrachte Leistung, wenn auf Verlangen früher begonnen wurde); Widerrufsadresse, Telefon und E-Mail richtig und gleich mit Impressum und ABSENDER in texte.js; die Frist, die das System rechnet (withdrawalDeadline ab gesendetAm, „Arbeitsbeginn frühestens am …“), passt zu dem, was Belehrung, PDF, Seite und Mails sagen; Zeitpunkt der Belehrung (mit der AB, auf einem dauerhaften Datenträger) gegen Zeitpunkt des Vertragsschlusses (mündlicher Auftrag vor der AB): trägt der Text beides?
3.4 Frühbeginn: Wortlaut der Erklärung (FRUEHBEGINN_TEXT), Haken freiwillig und nicht vorbelegt, Speicherung von Fassung und Zeitpunkt; ohne Haken kein Arbeitsbeginn vor Fristende (Hinweis in WOMS und im PDF); Wertersatz-Hinweis im Teammail T22; die Wahl der Normen gilt als entschieden (Übersicht, Nachträge, L7: § 356 Abs. 5 Nr. 2 und § 357a Abs. 2 BGB); Zweifel an ihrer Passung zum Dienstleistungsvertrag nur als Minor „Rechtfrage:“ für die Rechtsrunde. Außerhalb von Geschäftsräumen geschlossene Verträge (Weg „vor Ort“, § 312f Abs. 1 BGB: Bestätigung auf Papier oder, mit Zustimmung, auf einem anderen dauerhaften Datenträger; das Verlangen des Verbrauchers auf einem dauerhaften Datenträger): deckt der Ablauf das, oder ist es als Rechtfrage vermerkt (der AB-Dialog zeigt dafür einen Hinweis, V8)? Entschieden (V6): K32 gibt FRUEHBEGINN_TEXT wörtlich wieder und enthält den Satz „Ihr Widerrufsrecht bleibt unberührt“ nicht: stimmt der Mailtext mit texte.js überein, und passt er zum Haken auf der Seite?
3.5 Konsistenz über alle Orte: derselbe Satz und dieselbe Zahl in PDF, Bestätigungsseite, K31, K32, T22, Portal-Abschnitt, WOMS-Dialog und Datenschutz: 14 Tage Widerruf, 60 Tage Gültigkeit des Links, Beginn nach Fristende, Firmenname und Anschrift, Telefonnummer, Datumsformat, „Fassung vom …“, Bezeichnung der Dokumente (Auftragsbestätigung, AB, Auftrag), „ohne Umsatzsteuer“. Fassungsdaten TEXTE_FASSUNG, FRUEHBEGINN_FASSUNG, AGB_FASSUNG gegen Dateinamen und Prüfsummen; ist die AGB-Kopie inhaltlich gleich den AGB auf webklar.com/agb?
3.6 Datenschutzerklärung (Abschnitt 9a und die Ergänzungen): beschreibt sie genau, was das System verarbeitet (Momentaufnahme der Kundendaten, Auftragsdaten, PDF und Prüfsumme, Token als Hash, Zeitpunkt, Weg, Frühbeginn, Widerruf, Mailversand an den Kunden, Kopie ohne Link an das Team, Stripe für Rechnungen), Zwecke, Rechtsgrundlagen, Empfänger, Speicherdauer (6 und 8 Jahre) und „keine IP-Adresse“ (stimmt das für den Anwendungsserver; die Zugriffsprotokolle des Reverse-Proxys regelt ein anderer Abschnitt: wird nichts Falsches zugesagt)? „Danach löschen wir sie“ ohne Löschlauf ist bekannt (Vertragsfrage 8): nicht erneut melden.
3.7 Kleinunternehmer und Preisangaben: überall der Hinweis nach § 19 UStG, nirgends „inkl. MwSt.“ oder ein Steuersatz; Stundensatz als Endpreis.
3.8 Verbraucherinformationen vor der Bestätigung (Art. 246a § 1 EGBGB, soweit für Dienstleistungen einschlägig): wesentliche Eigenschaften, Identität und Anschrift des Unternehmers, Gesamtpreis, Zahlungsbedingungen, Widerrufsrecht, Hinweis auf die Allgemeinen Geschäftsbedingungen, Hinweis zur Verbraucherschlichtung (§ 36 VSBG): stehen sie in der AB oder in den beigelegten AGB? Fehlt klar etwas, was die Spezifikation nennt, ist es Important; sonst Minor mit „Rechtfrage:“.
3.9 Sprache: Sie-Form in Mails, PDF und auf der öffentlichen Bestätigungsseite; im angemeldeten Kundenportal (Dashboard, Abschnitt „Aufträge“) die Du-Form wie im übrigen Portal (entschieden, Übersicht Global Constraints); keine englischen Wörter in Kundentexten, Rechtschreibung und Zeichensetzung, einheitliche typografische Anführungszeichen; WOMS-Texte wie im Bestand. Entschieden und kein Befund: § 649 BGB ist die richtige Norm für den Überschreitungssatz (L7 belegt sie).
  • Schritt 5: Linsenblock 4 – Oberfläche (<n> = 4, <name> = oberflaeche)
LINSE 4 – OBERFLÄCHE. Frage: Kann ein Mensch die Seiten, Dialoge und PDFs im Hauptfall bedienen und verstehen, auf dem Handy und am Rechner, mit der Maus und mit der Tastatur?

Zu sehen: ALLE Bilder aus …/bilder/l6 (Kundenseiten: Bestätigungsseite in ihren Zuständen, Abschnitt „Aufträge“ im Portal, Widerrufsseite; je Handy 390 px und Desktop 1440 px), …/bilder/z2 (WOMS: Projektkonto, Rechnungsdialog, Auftragsliste, Auftragsdialog, Kundenart, Steuernummer, Abzeichen am Arbeitsblatt, Desktop und Handy; ansichten.md nennt je Bild die Ansicht und den Soll-Zustand) und …/pruefmaterial/pdf/*.png (Seiten der Beispiel-PDFs). Bilder mit dem Lesewerkzeug ansehen: es zeigt PNG-Dateien. Dazu lesen: public/auftrag.html, public/auftrag*.js, public/auftrag.css, den Abschnitt „Aufträge“ in public/dashboard.html und public/app.js, public/widerrufen.html; WOMS: src/components/auftraege/*.jsx und *.css, src/lib/*View.js.

Prüfpunkte:
4.1 Zustände vollständig und sichtbar. Bestätigungsseite: laden, Fehler, Link ungültig oder abgelaufen, ersetzt, storniert, widerrufen, bereits bestätigt mit „Bestätigt am …“, Unternehmen, Privatperson mit Frühbeginn-Haken, Knopf erst nach dem Häkchen bedienbar, Bestätigung läuft und ist gelaufen, Fehler beim Senden. Portal-Abschnitt: leer, mit Aufträgen, Status, PDF, Bestätigen. WOMS: Projektkonto leer, mit Daten, Stripe nicht erreichbar, „Ohne Projekt“; AB-Liste leer; Dialoge mit Fehlern; die Zustände Entwurf, versendet, bestätigt, abgerechnet, ersetzt, storniert, widerrufen; Mailstatus „nicht zugestellt“ mit „Erneut senden“; bei ausgeschaltetem Schalter kein AB-Bereich.
4.2 Beschriftungen laut Spec Abschnitt 1 und 2: Reiter „Aufträge und Rechnungen“, Knöpfe „Auftragsbestätigung“, „Rechnung“, „Als Entwurf speichern“, „Senden“ (mit Rückfrage), Listenaktionen (PDF, Erinnerung senden, Neue Fassung, Stornieren, Entwurf bearbeiten und löschen), der Hinweis „Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.“, die Spalten Vereinbart, Berechnet, Noch offen, Zeit erfasst samt Unterzeilen (bezahlt, Wert, über Abo), die Warnung am abgerechneten Arbeitsblatt.
4.3 Handy (390 px): kein waagerechtes Scrollen der Seite, Tabellen mit eigenem Scrollen oder umgebrochen, Dialoge passen auf den Bildschirm, Knöpfe mindestens 44 px hoch, Text mindestens 14 px, nichts abgeschnitten. Desktop (1440 px): sinnvolle Breite, keine überdehnten Zeilen.
4.4 Tastatur und Hilfstechnik: jedes Eingabefeld hat eine Beschriftung (label oder aria-label); Fehler und Status mit role="alert" oder aria-live; Dialoge fangen den Fokus ein und schließen mit Escape; sichtbarer Fokusrahmen; sinnvolle Tab-Reihenfolge; Knöpfe sind button-Elemente; Zustände nicht nur durch Farbe; Kontrast von Text und Pillen (grobe Prüfung am Bild).
4.5 Gestaltung: WOMS nach den Regeln des weißen Redesigns (nur Variablen für Farben und Abstände, Komponenten aus src/components/ui, keine eigenen Schatten oder Verläufe); Kundenseiten im Stil der vorhandenen Portalseiten (Dark Mode, wenn die Seiten ihn sonst unterstützen).
4.6 Texte der Oberfläche: Meldungen sagen, was zu tun ist (nicht „Fehler 409“); deutsche Beträge „1.234,56 €“ und Datum „TT.MM.JJJJ“ überall; Einzahl und Mehrzahl („1 Position“); keine abgeschnittenen Titel ohne Hinweistext; Sie-Form auf der öffentlichen Bestätigungsseite und der Widerrufsseite, Du-Form im angemeldeten Portal (Dashboard, Abschnitt „Aufträge“).
4.7 PDFs (Bilder): Briefkopf mit Logo, Absender und Steuernummer; Empfängerblock; Tabelle mit Kopfzeile auf jeder Folgeseite, Summe direkt unter der letzten Position, nichts über dem Rand, keine leeren Kästchen statt Zeichen (ä ö ü ß € – „ “), „Seite x von n“ im Fuß; Privatperson: Belehrung beginnt oben auf einer neuen Seite; Vorschau: „ENTWURF“ gut erkennbar, Text trotzdem lesbar; Zahlen rechtsbündig.
  • Schritt 6: Linsenblock 5 – Spec-Abdeckung (<n> = 5, <name> = spec)
LINSE 5 – SPEC-ABDECKUNG. Frage: Steht jede Zeile der Spezifikation, die etwas verlangt, im Code und in einem Test, ist bewusst ausgelassen oder ein Schritt für den Nutzer, und wo ist eine Lücke?

Arbeit: Geh die Spezifikation Zeile für Zeile durch (pruefmaterial/spec-zeilen.md hat die Zeilennummern). Jede Zeile oder jeder Aufzählungspunkt, der eine Anforderung, ein Verhalten, eine Zahl, einen Text, einen Endpunkt, ein Feld oder eine Regel enthält, bekommt einen Eintrag in /root/reports/auftraege-rechnungen-2026-10-06/kontrolle/abdeckung.json: ein JSON-Array mit Elementen { "zeile": <Zahl>, "text": "<die ersten 80 Zeichen>", "status": "umgesetzt" | "ausgelassen" | "nutzerschritt" | "luecke", "beleg": "<Text>" }.
- umgesetzt: beleg = Datei:Zeile des Codes und Name des Tests.
- ausgelassen: beleg = wo die Spezifikation oder eine Vertragsfrage es ausschließt („Nicht im Umfang“, Vertragsfrage mit Nummer).
- nutzerschritt: etwa Steuernummer im Stripe-Dashboard, Rechtsrunde, Schalter.
- luecke: nichts davon.
Überschriften, Begründungen, Recherche, Quellen und Beispiele ohne Pflichtcharakter bekommen keinen Eintrag. Abdecken musst du: Ziel und Erfolg; die Rahmenbedingungen, soweit sie etwas Prüfbares verlangen; Entscheidungen 1 bis 6; Abschnitt 1 (Oberfläche, beide Dialoge, Kundenart, Finanzen-Seite, Arbeitsblätter); Abschnitt 2 (Nummer und Status, Inhalt des PDFs, Erzeugung und Ablage, Versand, Bestätigung, Widerruf); Abschnitt 3 (Arten, Zusatzfelder, abgerechnete Arbeitsblätter, Steuernummer, Kundenportal); Abschnitt 4 (Projektkonto); 5 (Daten); 6 (Schnittstellen: jeder Endpunkt mit Methode, Pfad und Schutz; V5 nennt zusätzlich `POST …/auftraege/:id/projekt`, `POST …/rechnungen/vorschau` und `GET /api/team/arbeitsblaetter/abgerechnet`, die die Spezifikation nur teilweise beschreibt: prüfe sie gegen V5); 7 (Mails); 8 (Rechtsrunde); 9 (Fehlerverhalten: für jede Zeile ein Test?); 10 (Tests); 11 (Pläne und Livegang); „Nicht im Umfang“ (wurde etwas davon gebaut?).
Befunde gibt es nur für luecke und für umgesetzt-Einträge, bei denen der Beleg zeigt, dass der Code von der Spezifikation abweicht. Schwere: Important für sichtbares Verhalten; Critical, wenn ein Erfolgskriterium aus „Ziel“ oder eine gesetzliche Anforderung betroffen ist; Minor für Kleinigkeiten. Außerdem Widersprüche zwischen Spezifikation und Übersicht (V1–V8), die nicht in bekannte-vertragsfragen.md stehen: Befund Minor mit dem Anfang „Vertragsfrage:“. Das Protokoll (protokoll-5-spec.md) zählt: Zeilen gesamt und je Status.
  • Schritt 7: Prüfer starten und Ausgaben prüfen

Z startet die fünf Agenten in einer einzigen Nachricht (parallel; subagent_type: general-purpose, model: sonnet, je Auftrag Kopf plus ein Linsenblock) und wartet, bis alle gemeldet haben. Danach:

R=/root/reports/auftraege-rechnungen-2026-10-06
for f in $R/kontrolle/befunde-[1-5]-*.json; do
  node -e 'const a=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); const ok=a.every((b)=>["Critical","Important","Minor"].includes(b.schwere)&&b.datei&&b.befund&&b.beleg&&b.vorschlag); console.log(process.argv[1].split("/").pop(), a.length, ok?"ok":"FORMFEHLER")' "$f"
done
ls $R/kontrolle/protokoll-[1-5]-*.md $R/kontrolle/abdeckung.json | wc -l
node -e 'const a=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); const n={}; for (const e of a) n[e.status]=(n[e.status]||0)+1; console.log(a.length, JSON.stringify(n))' $R/kontrolle/abdeckung.json

Erwartet: fünf Zeilen … ok (eine Zahl 0 ist erlaubt), 6 Dateien, die Abdeckung mit einer Zählung je Status (luecke darf vorkommen). Fehlt eine Datei oder ist ein JSON ungültig: dieselbe Linse noch einmal starten (derselbe Auftrag plus der Satz „Deine Ausgabedatei fehlte oder war kein gültiges JSON“), höchstens einmal; danach den Ausfall im Protokoll festhalten und ohne diese Linse weitermachen.

  • Schritt 8: Protokoll
R=/root/reports/auftraege-rechnungen-2026-10-06
{ echo; echo "## Z3.2 Prüfer $(date -u +%Y-%m-%dT%H:%MZ)"; for f in $R/kontrolle/befunde-[1-5]-*.json; do node -e 'const a=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); const n={Critical:0,Important:0,Minor:0}; for (const e of a) n[e.schwere]++; console.log("- "+process.argv[1].split("/").pop()+": Critical "+n.Critical+", Important "+n.Important+", Minor "+n.Minor)' "$f"; done; } >> $R/z-protokoll.md

Kein Commit.


Task Z3.3: Befunde zusammenführen und gegenprüfen (haiku)

Files: keine Änderung im Repo. Neu im Scratchpad: $L/gruppen.mjs, $L/befunde-zusammenfuehren.mjs, $L/befunde-auswerten.mjs, $L/gruppen-pruefen.mjs. Neu unter $R/kontrolle/: befunde-alle.json, gegenpruefung/<ID>.json, gegenpruefung/minor-<n>.json, entscheidungen.json (nur bei Bedarf), korrekturliste.json, offen-fuer-nutzer.md, minors.md.

Interfaces:

  • Consumes: die fünf Befundlisten aus Z3.2.

  • Produces: befunde-alle.json, ein Array von { id, linse, linsen, schwere, datei, zeile, befund, beleg, vorschlag, gruppe } (id = P<Linse>-<nn>, gruppe = G1 bis G6 oder G?); je Critical/Important-Befund gegenpruefung/<ID>.json = { id, urteil: "echt" | "falsch" | "unklar", schwere_meinung: "gleich" | "niedriger" | "hoeher", begruendung, beleg }; je Linse gegenpruefung/minor-<n>.json = Array von { id, urteil, begruendung }; korrekturliste.json = { G1: [Befund mit Gegenprüfung], … } (nur bestätigte Critical/Important ohne Vertrags- oder Wortlautänderung); offen-fuer-nutzer.md (alles, was nicht automatisch korrigiert wird); minors.md (die Kleinigkeiten je Linse, für den Nutzer).

  • Schritt 1: Gruppen und Zusammenführung als Skripte anlegen und laufen lassen

Dateigruppen der Korrektur (eine Quelle, von allen Skripten gelesen). $L/gruppen.mjs:

// Dateigruppen der Korrektur (Z3.4). Die Muster laufen gegen "portal:<Pfad>" bzw. "woms:<Pfad>".
export const GRUPPEN = [
  ['G6', /^woms:/],
  ['G5', /^portal:(public\/(auftrag(-[a-z]+)?\.(html|js|css)|dashboard\.html|app\.js|widerrufen\.html)|server\/services\/customerInvoices\.js|test\/ui\/auftraege-kundenportal\.ui\.mjs)/],
  ['G4', /^portal:(server\/services\/auftraege\/(pdf|texte)\.js|server\/assets\/|public\/rechtliches\/|public\/datenschutz\.html|docs\/|scripts\/build-legal)/],
  ['G3', /^portal:(server\/services\/auftraege\/(token|bestaetigung|mails)\.js|server\/routes\/(auftrag|auftraegeKunde|abo)\.js|server\/mail-templates\/)/],
  ['G2', /^portal:(server\/services\/auftraege\/(auftraege|nummern|auftragStore|auftragSicht|ablage|schalter|fehler|konstanten|schema)\.js|server\/routes\/admin\/auftraege\.js|server\/(config|index)\.js|server\/routes\/team\/index\.js|scripts\/auftraege-setup\.mjs)/],
  ['G1', /^portal:(server\/services\/auftraege\/(positionen|rechnungsbau|projektkonto|arbeitsblaetter|projektRechnungenStore)\.js|server\/services\/finance\/|server\/routes\/admin\/(finanzen|projektkonten)\.js|server\/routes\/team\/arbeitsblaetter\.js|test\/support\/fakeStripe\.js)/],
]
export const gruppeVon = (datei) => GRUPPEN.find(([, re]) => re.test(datei))?.[0] ?? 'G?'
// je Gruppe die eine neue Testdatei, die der Korrektor anlegen darf
export const EIGENER_TEST = {
  G1: /^portal:test\/auftraegeKorrekturG1\.test\.js$/, G2: /^portal:test\/auftraegeKorrekturG2\.test\.js$/,
  G3: /^portal:test\/auftraegeKorrekturG3\.test\.js$/, G4: /^portal:test\/auftraegeKorrekturG4\.test\.js$/,
  G5: /^portal:test\/auftraegeKorrekturG5\.test\.js$/, G6: /^woms:tests\/unit\/auftraegeKorrekturG6\.test\.mjs$/,
}

$L/befunde-zusammenfuehren.mjs:

// Führt die Befundlisten der fünf Prüfer zusammen: doppelte Befunde zusammenlegen, IDs und Gruppen vergeben.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { gruppeVon } from './gruppen.mjs'

const K = '/root/reports/auftraege-rechnungen-2026-10-06/kontrolle'
const LINSEN = { 1: 'geld', 2: 'sicherheit', 3: 'recht', 4: 'oberflaeche', 5: 'spec' }
const RANG = { Critical: 0, Important: 1, Minor: 2 }

const alle = []
for (const [n, name] of Object.entries(LINSEN)) {
  try {
    for (const b of JSON.parse(readFileSync(join(K, `befunde-${n}-${name}.json`), 'utf8'))) alle.push({ ...b, linse: Number(n) })
  } catch (e) {
    console.log(`Linse ${n} (${name}): keine lesbare Datei (${e.code || e.message})`)
  }
}
// gleiche Stelle und gleicher Anfang des Befundtextes = derselbe Befund; die höhere Schwere gewinnt
const gesehen = new Map()
for (const b of alle) {
  const schluessel = `${b.datei}:${b.zeile ?? ''}:${String(b.befund).slice(0, 60).toLowerCase()}`
  const vorhanden = gesehen.get(schluessel)
  if (!vorhanden) { gesehen.set(schluessel, { ...b, linsen: [b.linse] }); continue }
  vorhanden.linsen.push(b.linse)
  if (RANG[b.schwere] < RANG[vorhanden.schwere]) vorhanden.schwere = b.schwere
}
const zaehler = {}
const ergebnis = [...gesehen.values()]
  .sort((a, b) => RANG[a.schwere] - RANG[b.schwere] || a.linse - b.linse)
  .map((b) => {
    zaehler[b.linse] = (zaehler[b.linse] || 0) + 1
    return { id: `P${b.linse}-${String(zaehler[b.linse]).padStart(2, '0')}`, ...b, gruppe: gruppeVon(b.datei) }
  })
mkdirSync(join(K, 'gegenpruefung'), { recursive: true })
writeFileSync(join(K, 'befunde-alle.json'), JSON.stringify(ergebnis, null, 2))
const tabelle = {}
for (const b of ergebnis) tabelle[`Linse ${b.linse}`] = { ...tabelle[`Linse ${b.linse}`], [b.schwere]: (tabelle[`Linse ${b.linse}`]?.[b.schwere] || 0) + 1 }
console.log(`${ergebnis.length} Befunde (vorher ${alle.length}), ohne Gruppe: ${ergebnis.filter((b) => b.gruppe === 'G?').length}`)
console.table(tabelle)
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
cd $L && timeout 60 node --max-old-space-size=256 befunde-zusammenfuehren.mjs

Erwartet: die Gesamtzahl, eine Tabelle je Linse und Schwere, die Zahl der Befunde ohne Gruppe. Das Skript liest nur und schreibt befunde-alle.json.

  • Schritt 2: Befunde ohne Gruppe zuordnen

R=/root/reports/auftraege-rechnungen-2026-10-06; rg -n '"gruppe": "G\?"' -B8 $R/kontrolle/befunde-alle.json zeigt sie. Für jeden die Datei ansehen und das Feld gruppe von Hand auf G1 bis G6 setzen (Gruppen: G1 Geld und Rechnungen, G2 AB-Kern, G3 Bestätigung, Mails und Widerruf, G4 PDF und Texte, G5 Kundenseiten, G6 WOMS; die Tabelle steht in Z3.4). Bilder (bild:…) gehören zur Gruppe der Ansicht (Kundenseiten G5, WOMS G6, PDF G4). Gehört ein Befund in keine Gruppe (zum Beispiel package.json), wird er nicht automatisch korrigiert: gruppe auf "offen" setzen, er landet in offen-fuer-nutzer.md.

  • Schritt 3: Gegenprüfung jedes Critical- und Important-Befunds (haiku, je Befund ein Agent)

Z startet für jeden Befund mit schwere Critical oder Important einen Agenten (subagent_type: general-purpose, model: haiku), höchstens acht gleichzeitig, in Wellen. Der Auftrag (die Werte in spitzen Klammern setzt Z aus befunde-alle.json und stand.md ein; <BEFUND_JSON> ist der Befund ohne das Feld gruppe):

Du prüfst als unabhängiger zweiter Prüfer GENAU EINEN Befund einer Schlusskontrolle von „Aufträge und Rechnungen“. Du änderst nichts: keine Datei im Repo, kein Commit, kein npm, keine Tests, keine Builds, keine Worktrees; keine Zugangsdaten, Token oder Remote-URLs lesen oder ausgeben (`git remote -v` nie aufrufen); `rg` oder `command grep -a` statt `grep`; kein `rm -r`, `git clean`, `git reset --hard`, `pkill -f`. Du schreibst genau eine Datei (unten).

Befund:
<BEFUND_JSON>

Stand: Portal /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege (HEAD <P_SHA>), WOMS /home/webklar/apps/ticket.webklar.com/dev-auftraege (HEAD <W_SHA>). Spezifikation: docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md, Verträge: docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (beide im Portal-Worktree; nur lesen, wenn der Befund sich darauf beruft).

Aufgabe:
1. Lies die genannte Stelle (40 Zeilen davor und danach) und den Code, der sie aufruft oder von ihr aufgerufen wird.
2. Prüfe, ob stimmt, was der Befund behauptet: Steht das Zitat im Beleg wirklich dort? Folgt aus dem Code das behauptete Verhalten (bei Zweifel eine kleine Probe: cd <Worktree> && timeout 60 node --max-old-space-size=256 --input-type=module -e '…' an einer reinen Funktion; kein Server, kein Netz)? Verhindert eine andere Stelle das Problem (Sperre, Prüfung, Test)?
3. Urteil: "echt" (belegt, die Folge stimmt), "falsch" (der Code tut es nicht, oder es ist anderswo abgesichert: nenne die Stelle) oder "unklar" (nicht entscheidbar: nenne, was fehlt). Eine reine Rechtsfrage („Rechtfrage:“) beurteilst du nur an Tatsachen (steht der Text da, widersprechen sich zwei Texte), nicht an einer Rechtsansicht: sonst "unklar" mit dem Hinweis „Rechtsrunde“.
4. Schwere: "gleich", "niedriger" oder "hoeher" als im Befund, mit einem Satz Begründung.

Ausgabe: genau eine Datei /root/reports/auftraege-rechnungen-2026-10-06/kontrolle/gegenpruefung/<ID>.json mit dem Inhalt { "id": "<ID>", "urteil": "echt" | "falsch" | "unklar", "schwere_meinung": "gleich" | "niedriger" | "hoeher", "begruendung": "höchstens vier Sätze", "beleg": "Datei:Zeile oder Probenausgabe" }. Antwort: eine Zeile „<ID> <urteil>“.
  • Schritt 4: Gegenprüfung der Minor-Befunde (haiku, ein Agent je Linse)

Je Linse mit Minor-Befunden ein Agent (höchstens fünf gleichzeitig), derselbe Sicherheitsabsatz wie in Schritt 3. Auftrag:

Du prüfst die Befunde der Schwere Minor einer Linse (<name>) einer Schlusskontrolle von „Aufträge und Rechnungen“ kurz auf Plausibilität. Du änderst nichts (Sicherheitsabsatz wie oben). Stand: Portal …/dev-auftraege (HEAD <P_SHA>), WOMS …/dev-auftraege (HEAD <W_SHA>).
Befunde (JSON-Array, ohne das Feld gruppe):
<MINOR_JSON>
Für jeden Befund: die Stelle lesen (20 Zeilen davor und danach) und urteilen: "echt" (die Stelle ist so, wie beschrieben), "falsch" (nicht so, oder anderswo abgesichert) oder "unklar", mit einem Satz Begründung. Rechtsfragen („Rechtfrage:“) bleiben "unklar".
Ausgabe: genau eine Datei /root/reports/auftraege-rechnungen-2026-10-06/kontrolle/gegenpruefung/minor-<n>.json: ein Array von { "id", "urteil", "begruendung" }. Antwort: eine Zeile mit den Zahlen je Urteil.
  • Schritt 5: Auswerten, unklar entscheiden

$L/befunde-auswerten.mjs:

// Wertet die Gegenprüfung aus: Korrekturliste je Gruppe, Offenes für den Nutzer, Minor-Liste.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

const K = '/root/reports/auftraege-rechnungen-2026-10-06/kontrolle'
const alle = JSON.parse(readFileSync(join(K, 'befunde-alle.json'), 'utf8'))
const lies = (pfad, vorgabe) => (existsSync(pfad) ? JSON.parse(readFileSync(pfad, 'utf8')) : vorgabe)
// Z schreibt hier von Hand hinein, was es bei „unklar“ entschieden hat: { "P2-03": "echt" | "falsch" | "frage" }
const entscheidungen = lies(join(K, 'entscheidungen.json'), {})
const minorUrteile = {}
for (const n of [1, 2, 3, 4, 5]) for (const u of lies(join(K, 'gegenpruefung', `minor-${n}.json`), [])) minorUrteile[u.id] = u

const korrektur = {}
const frage = []
const verworfen = []
const minors = []
for (const b of alle) {
  if (b.schwere === 'Minor') {
    const u = minorUrteile[b.id]
    if (u?.urteil === 'falsch') verworfen.push(b.id)
    else minors.push({ ...b, urteil: u?.urteil ?? 'ungeprüft' })
    continue
  }
  const g = lies(join(K, 'gegenpruefung', `${b.id}.json`), null)
  let urteil = g?.urteil ?? 'fehlt'
  if (urteil === 'unklar' || urteil === 'fehlt') urteil = entscheidungen[b.id] ?? urteil
  if (urteil === 'falsch') { verworfen.push(b.id); continue }
  const vertrag = /^(Vertragsänderung|Wortlaut):/.test(b.vorschlag)
  if (urteil === 'echt' && !vertrag && /^G[1-6]$/.test(b.gruppe)) { (korrektur[b.gruppe] ||= []).push({ ...b, gegenpruefung: g }); continue }
  frage.push({ ...b, urteil, grund: vertrag ? 'Vertrag oder Wortlaut: Entscheidung nötig' : !/^G[1-6]$/.test(b.gruppe) ? 'keiner Dateigruppe zugeordnet' : urteil === 'echt' ? '' : 'Gegenprüfung unklar oder fehlt' })
}
const zeile = (b) => `- [${b.id}] ${b.schwere}: ${b.befund} (${b.datei}${b.zeile ? `:${b.zeile}` : ''})`
writeFileSync(join(K, 'korrekturliste.json'), JSON.stringify(korrektur, null, 2))
writeFileSync(join(K, 'offen-fuer-nutzer.md'), `# Offen für dich (nicht automatisch korrigiert)\n\n${frage.map((b) => `${zeile(b)}\n  Vorschlag: ${b.vorschlag}\n  Grund: ${b.grund}`).join('\n') || 'nichts'}\n`)
const titel = { 1: 'Geld und Rechenlogik', 2: 'Sicherheit und Datenschutz', 3: 'Recht und Texte', 4: 'Oberfläche', 5: 'Spec-Abdeckung' }
writeFileSync(join(K, 'minors.md'), `# Kleinigkeiten (Minor) aus der Kontrolle\n\n${Object.entries(titel).map(([n, t]) => `## ${t}\n${minors.filter((b) => b.linse === Number(n)).map(zeile).join('\n') || '- keine'}`).join('\n\n')}\n`)
console.log('zu korrigieren:', JSON.stringify(Object.fromEntries(Object.entries(korrektur).map(([g, l]) => [g, l.length]))), '| offen für den Nutzer:', frage.length, '| verworfen:', verworfen.length, '| Minor:', minors.length)
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad; R=/root/reports/auftraege-rechnungen-2026-10-06
rg -l '"urteil": "unklar"' $R/kontrolle/gegenpruefung | head -20
cd $L && timeout 60 node --max-old-space-size=256 befunde-auswerten.mjs

Zu jedem unklar (und jedem fehlenden Urteil) entscheidet Z selbst nach Lektüre der Stelle (nicht länger als etwa zehn Minuten je Befund) und trägt es in $R/kontrolle/entscheidungen.json ein ("echt": wird korrigiert; "falsch": verworfen; "frage": für den Nutzer, wird nicht korrigiert); danach das Skript noch einmal laufen lassen. Ein Befund, den Z nicht entscheiden kann, wird nie geraten: "frage". Befunde mit dem Anfang „Rechtfrage:“ im Befundtext gehen immer in die Rechtsrunde (Übergabe), nie in die Korrektur.

Erwartet am Ende: eine Zeile mit der Zahl der Korrekturen je Gruppe, der offenen Punkte, der verworfenen und der Minor-Befunde; korrekturliste.json, offen-fuer-nutzer.md und minors.md existieren.

  • Schritt 6: Protokoll
R=/root/reports/auftraege-rechnungen-2026-10-06
{ echo; echo "## Z3.3 Gegenprüfung $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- $(node -e 'const a=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); console.log(a.length+" Befunde, davon "+a.filter(b=>b.schwere==="Critical").length+" Critical, "+a.filter(b=>b.schwere==="Important").length+" Important, "+a.filter(b=>b.schwere==="Minor").length+" Minor")' $R/kontrolle/befunde-alle.json)"; echo "- Korrekturliste je Gruppe: $(node -e 'const k=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); console.log(JSON.stringify(Object.fromEntries(Object.entries(k).map(([g,l])=>[g,l.length]))))' $R/kontrolle/korrekturliste.json); Entscheidungen von Z: $(test -f $R/kontrolle/entscheidungen.json && node -e 'console.log(Object.keys(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))).length)' $R/kontrolle/entscheidungen.json || echo 0)"; } >> $R/z-protokoll.md

Kein Commit.


Task Z3.4: Bestätigte Befunde korrigieren (sonnet, eine Dateigruppe je Agent)

Files: nur die Dateien der Gruppen mit bestätigten Befunden (Tabelle unten) und je Gruppe höchstens eine neue Testdatei.

Interfaces:

  • Consumes: $R/kontrolle/korrekturliste.json (Befunde mit Beleg, Vorschlag und Gegenprüfung je Gruppe).
  • Produces: je behobenem Befund ein Commit Korrektur <ID>: <was> mit einem Test, der vorher rot war; den Bericht des Korrektors (je Befund erledigt <SHA> <Testname>, offen <Grund> oder nicht reproduzierbar); $R/kontrolle/korrekturen.md.
Gruppe Worktree Dateien, die der Korrektor ändern darf Neue Testdatei
G1 Geld und Rechnungen Portal server/services/auftraege/{positionen,rechnungsbau,projektkonto,arbeitsblaetter,projektRechnungenStore}.js, server/services/finance/**, server/routes/admin/{finanzen,projektkonten}.js, server/routes/team/arbeitsblaetter.js, test/support/fakeStripe.js test/auftraegeKorrekturG1.test.js
G2 AB-Kern Portal server/services/auftraege/{auftraege,nummern,auftragStore,auftragSicht,ablage,schalter,fehler,konstanten,schema}.js, server/routes/admin/auftraege.js, server/routes/team/index.js, server/config.js, server/index.js, scripts/auftraege-setup.mjs test/auftraegeKorrekturG2.test.js
G3 Bestätigung, Mails, Widerruf Portal server/services/auftraege/{token,bestaetigung,mails}.js, server/routes/{auftrag,auftraegeKunde,abo}.js, server/mail-templates/{K31,K32,T22,K04,T09}* test/auftraegeKorrekturG3.test.js
G4 PDF und Texte Portal server/services/auftraege/{pdf,texte}.js (in texte.js nur Struktur und Formate, nie ein Wortlaut), server/assets/**, public/rechtliches/**, docs/**, scripts/build-legal-* (nur formale Fehler) test/auftraegeKorrekturG4.test.js
G5 Kundenseiten Portal public/{auftrag.html,auftrag.css,dashboard.html,app.js,widerrufen.html}, public/auftrag*.js (auftrag.js, auftrag-ansicht.js, auftrag-dom.js, auftrag-portal.js, auftrag-widerruf.js), server/services/customerInvoices.js, test/ui/auftraege-kundenportal.ui.mjs test/auftraegeKorrekturG5.test.js
G6 WOMS WOMS alle Dateien aus $R/pruefmaterial/woms-dateien.txt (src/**, tests/**, scripts/setup-legal.mjs) tests/unit/auftraegeKorrekturG6.test.mjs

Die Gruppen laufen nacheinander (G1 bis G6, nur solche mit Befunden), nie gleichzeitig: sie teilen sich einen Arbeitsbaum, und ein Test sähe sonst die halbfertigen Änderungen des Nachbarn.

  • Schritt 1: Ausgangsstand merken
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege; R=/root/reports/auftraege-rechnungen-2026-10-06
{ echo "PORTAL_VOR_KORREKTUR=$(git -C $P rev-parse HEAD)"; echo "WOMS_VOR_KORREKTUR=$(git -C $Wo rev-parse HEAD)"; } > $R/kontrolle/vor-korrektur.txt
cat $R/kontrolle/vor-korrektur.txt
node -e 'const k=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); for (const [g,l] of Object.entries(k)) console.log(g, l.map(b=>b.id+":"+b.schwere[0]).join(" "))' $R/kontrolle/korrekturliste.json

Ist die Korrekturliste leer, entfällt der Rest dieses Tasks (ein Satz im Protokoll: „keine bestätigten Befunde“).

  • Schritt 2: Pro Gruppe mit Befunden einen Korrektor starten und seinen Bericht prüfen

Hilfsskript $L/gruppen-pruefen.mjs (meldet geänderte Dateien, die nicht zur Gruppe gehören):

// Aufruf: node gruppen-pruefen.mjs <G1..G6> <Worktree> <SHA vor der Gruppe>
import { execFileSync } from 'node:child_process'
import { EIGENER_TEST, gruppeVon } from './gruppen.mjs'

const [gruppe, worktree, vorher] = process.argv.slice(2)
const vorsatz = gruppe === 'G6' ? 'woms:' : 'portal:'
const dateien = execFileSync('git', ['-C', worktree, 'diff', '--name-only', `${vorher}..HEAD`], { encoding: 'utf8' }).split('\n').filter(Boolean)
const fremd = dateien.filter((d) => gruppeVon(vorsatz + d) !== gruppe && !EIGENER_TEST[gruppe].test(vorsatz + d))
console.log(`${dateien.length} Dateien geändert, davon fremd: ${fremd.length}`)
for (const d of fremd) console.log(`  FREMD ${d}`)
process.exit(fremd.length ? 1 : 0)

Auftrag an den Korrektor (subagent_type: general-purpose, model: sonnet; Platzhalter setzt Z ein):

Du korrigierst bestätigte Befunde der Schlusskontrolle in der Dateigruppe <Gx – Name> von „Aufträge und Rechnungen“. Du arbeitest in genau einem Worktree: <Portal: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege | WOMS: /home/webklar/apps/ticket.webklar.com/dev-auftraege>, Zweig feature/auftraege. Es arbeitet immer nur ein Korrektor zur Zeit.

SICHERHEIT: nie `rm -r`, `rsync --delete`, `git clean`, `git reset --hard`, `git worktree remove --force`, `pkill -f`; keine Zugangsdaten, .env-Inhalte, Token oder Remote-URLs lesen oder ausgeben (`git remote -v` nie aufrufen); keine Live-Daten; nichts außerhalb des Worktrees und des Scratchpads /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad schreiben; `rg` oder `command grep -a` statt `grep`; Node immer mit `timeout` und `--max-old-space-size`.

Lies zuerst die Spezifikation docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md, die Übersicht docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (Verträge V1–V8, Global Constraints) und deine Befunde (unten).

Deine Dateien (nur diese darfst du ändern): <Dateiliste der Gruppe aus der Tabelle>. Neue Testdatei (genau diese): <Pfad aus der Tabelle>.
Befunde (JSON, nach Schwere sortiert):
<Gruppe aus korrekturliste.json>

Vorgehen je Befund, einzeln, erst Critical, dann Important:
1. Beleg selbst nachvollziehen (Stelle lesen, bei Bedarf Probe). Stimmt er nicht (mehr): „nicht reproduzierbar“ in den Bericht, weiter zum nächsten.
2. Einen Test schreiben, der den Fehler zeigt, und ihn rot laufen sehen: cd <Worktree> && timeout 300 nice -n 10 node --max-old-space-size=1024 --test <Testdatei> (WOMS: node --test <Testdatei>).
3. Die kleinste Korrektur im Code: keine Umbauten, keine Umbenennungen, nichts nebenbei aufräumen.
4. Der neue Test ist grün; danach die Tests der betroffenen Dateien (`rg -l <Dateiname> test` bzw. tests, dazu test/auftraege*.test.js, wenn Dateien unter server/services/auftraege geändert sind): alle `# fail 0`.
5. Commit nur mit deinen Dateien einzeln (`git add <Datei> …`, nie `-A` oder `.`): Nachricht `Korrektur <ID>: <was>`, zweites -m `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
VERBOTEN: Verträge V1–V8 ändern (Namen, Signaturen, Datenformen, Endpunkte, Mailplatzhalter); den Wortlaut eines Rechtstextes ändern; Dateien außerhalb deiner Gruppe ändern; pushen; --force; die volle Suite (npm test) oder einen Build starten; Worktrees anlegen. Braucht ein Befund etwas Verbotenes, änderst du nichts und meldest ihn als `offen` mit dem Grund.
BERICHT (Antwort, höchstens 20 Zeilen): je Befund-ID eine Zeile `erledigt <SHA> <Testname>` | `offen <Grund>` | `nicht reproduzierbar`; am Ende die Ausgabe von `git status --short` (muss leer sein).

Nach jedem Korrektor, bevor die nächste Gruppe startet:

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad; R=/root/reports/auftraege-rechnungen-2026-10-06
VOR=<SHA vor dieser Gruppe: Portal aus vor-korrektur.txt, bei G2 bis G5 die HEAD-SHA nach der vorigen Gruppe>
git -C $P status --short | head -5
git -C $P log --oneline $VOR..HEAD
node $L/gruppen-pruefen.mjs <Gx> $P $VOR; echo "gruppen-pruefen exit $?"

Erwartet: git status leer; je Befund mit erledigt mindestens ein Commit mit Korrektur <ID>; gruppen-pruefen exit 0. Zeigt das Skript FREMD-Dateien: ansehen; eine angepasste Erwartung in einem bestehenden Test ist erlaubt (Grund im Bericht), eine geänderte Datei einer anderen Gruppe nicht: rückgängig machen mit git revert <SHA> (neuer Commit, kein Reset) und den Befund als offen führen. Den Bericht jedes Korrektors hängt Z an $R/kontrolle/korrekturen.md an (>>).

  • Schritt 3: Abschluss
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege; R=/root/reports/auftraege-rechnungen-2026-10-06
. <(sed 's/^/export /' $R/kontrolle/vor-korrektur.txt)
{ echo; echo "## Z3.4 Korrekturen $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- Portal: $(git -C $P log --oneline $PORTAL_VOR_KORREKTUR..HEAD | wc -l) Commits, HEAD $(git -C $P rev-parse --short HEAD)"; echo "- WOMS: $(git -C $Wo log --oneline $WOMS_VOR_KORREKTUR..HEAD | wc -l) Commits, HEAD $(git -C $Wo rev-parse --short HEAD)"; echo "- Offene Befunde aus den Berichten stehen in kontrolle/korrekturen.md; sie in kontrolle/offen-fuer-nutzer.md übernehmen"; } >> $R/z-protokoll.md

Alle Befunde mit offen oder nicht reproduzierbar aus den Berichten ergänzt Z mit der Zeile - [<ID>] <Schwere>: <Befund> … Grund: <aus dem Bericht> in $R/kontrolle/offen-fuer-nutzer.md (>>). Die Commits sind der Ertrag dieses Tasks; es gibt hier nichts weiter zu committen.


Task Z3.5: Nachlauf: Tests, Durchlauf, volle Suite, Nachprüfung, Ergebnis

Files: keine Änderung. Neu: $R/kontrolle/nachpruefung/<ID>.json, $R/kontrolle/ergebnis.md.

Interfaces:

  • Consumes: den Stand nach Z3.4 (oder den unveränderten Stand, wenn es keine Korrekturen gab; dann entfallen die Schritte 2 und 3, und die Läufe aus Z1.5 und Z2.2 gelten weiter).

  • Produces: die endgültigen Testzahlen, die Bestätigung, dass jeder korrigierte Critical-Befund behoben ist, und ergebnis.md mit den Zahlen für Übergabe und Memory.

  • Schritt 1: Betroffene Tests und Durchlauf (Portal)

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && free -m | sed -n 2p
timeout 1800 nice -n 10 node --max-old-space-size=1024 --test test/auftraege*.test.js test/kundenansichtGuard.test.js test/financeSettings.test.js test/mailTemplates.test.js test/legalTexts.test.js 2>&1 | rg "^# (tests|pass|fail|skipped) "
timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblauf.test.js 2>&1 | rg "^# (tests|pass|fail|skipped) "

Erwartet: # fail 0 in beiden Läufen; im zweiten # tests 21 (die Korrekturen ändern die Zahl der Durchlauf-Tests nicht; ein neuer Test eines Korrektors steht in seiner Datei auftraegeKorrektur…). Ein roter Test ist ein neuer Befund gegen die Korrektur: derselbe Korrektor (Auftrag wie in Z3.4, nur für diesen Test) noch einmal, höchstens zweimal; danach offen.

  • Schritt 2: Volle Suite und Node 20 (nur wenn Z3.4 Portal-Commits hat)

Wie Z1.5 Schritt 1 und 2 (volle Suite hinter der Sperre mit NODE_OPTIONS=--max-old-space-size=1536, danach die neuen und geänderten Testdateien unter node:20 mit --network none); die Zahlen mit denen aus Z1.5 vergleichen: mehr Tests (die neuen), fail 0. Haben die Korrekturen die Kundenseiten (G5) verändert, zusätzlich das UI-Skript wie in Z1.5 Schritt 3 laufen lassen (Bilder nach $R/bilder/l6-nach-kontrolle, damit die Bilder aus Z1.5 erhalten bleiben).

  • Schritt 3: WOMS (nur wenn Z3.4 WOMS-Commits hat)

Wie Z2.2 Schritt 1 und 3 (npm test hinter der Sperre, vite build nach $L/auftraege-z2-dist); haben die Korrekturen sichtbares Verhalten geändert (Komponenten, CSS), zusätzlich die Läufer aus Z2.3 Schritt 2 und 3 einmal wiederholen (Bilder nach $R/bilder/z2-nach-kontrolle). Erwartet: fail 0, Build exit 0, die Prüfungen der Läufer grün.

  • Schritt 4: Nachprüfung der korrigierten Critical-Befunde (haiku, je Befund ein Agent)

Für jeden Befund der Schwere Critical, der in Z3.4 als erledigt gemeldet wurde, ein Agent (subagent_type: general-purpose, model: haiku; Sicherheitsabsatz wie in Z3.3 Schritt 3, nur lesen). Auftrag:

Du prüfst, ob eine Korrektur den Befund wirklich behebt. Befund: <BEFUND_JSON>. Korrektur: Commit <SHA> im Worktree <Pfad> (`git show <SHA>` zeigt sie), Test <Name> in <Datei>.
1. Lies den Commit und den Test. Prüft der Test genau den Fehler aus dem Befund (und nicht nur etwas Ähnliches)? 2. Lies die Stelle im aktuellen Stand: ist die Ursache behoben, oder ist nur ein Symptom verdeckt (zum Beispiel nur im Test-Nachbau, nur für einen Pfad der mehreren)? 3. Hat die Änderung einen anderen Aufrufer oder Vertrag (V1–V8) gebrochen oder einen neuen Fehler gleicher Art eingeführt?
Ausgabe: genau eine Datei /root/reports/auftraege-rechnungen-2026-10-06/kontrolle/nachpruefung/<ID>.json mit { "id": "<ID>", "behoben": "ja" | "teilweise" | "nein", "neue_probleme": "<Text oder leer>", "begruendung": "höchstens drei Sätze" }. Antwort: eine Zeile „<ID> <behoben>“.

Erwartet: jedes behoben: "ja" ohne neue Probleme. teilweise oder nein: der Befund geht zurück an den Korrektor (einmal), danach offen-fuer-nutzer.md.

  • Schritt 5: Ergebnis festhalten
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege; R=/root/reports/auftraege-rechnungen-2026-10-06
node -e '
const k=process.argv[1]; const fs=require("fs");
const alle=JSON.parse(fs.readFileSync(k+"/befunde-alle.json","utf8"));
const z=(f)=>alle.filter(f).length;
console.log("# Ergebnis der Kontrolle");
console.log("");
console.log("Gemeldet: "+z(b=>b.schwere==="Critical")+" Critical, "+z(b=>b.schwere==="Important")+" Important, "+z(b=>b.schwere==="Minor")+" Minor (nach dem Zusammenlegen von Doppelten).");
const kor=JSON.parse(fs.readFileSync(k+"/korrekturliste.json","utf8"));
console.log("Zur Korrektur bestätigt: "+Object.values(kor).reduce((s,l)=>s+l.length,0)+" (je Gruppe: "+JSON.stringify(Object.fromEntries(Object.entries(kor).map(([g,l])=>[g,l.length])))+").");
' $R/kontrolle > $R/kontrolle/ergebnis.md
{ echo "Stand danach: Portal $(git -C $P rev-parse --short HEAD), WOMS $(git -C $Wo rev-parse --short HEAD)."; echo "Offen für den Nutzer: $(rg -c '^- \[P' $R/kontrolle/offen-fuer-nutzer.md || echo 0) Punkte (kontrolle/offen-fuer-nutzer.md). Kleinigkeiten: $(rg -c '^- \[P' $R/kontrolle/minors.md || echo 0) (kontrolle/minors.md)."; echo "Zahlen der Läufe: <Portal Host, Node 20, Durchlauf, WOMS Suite und Build aus den Schritten 1 bis 3>."; } >> $R/kontrolle/ergebnis.md
{ echo; echo "## Z3 abgeschlossen $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- PORTAL_HEAD=$(git -C $P rev-parse --short HEAD) WOMS_HEAD=$(git -C $Wo rev-parse --short HEAD); Ergebnis: $R/kontrolle/ergebnis.md"; } >> $R/z-protokoll.md
cat $R/kontrolle/ergebnis.md

Die Zeile „Zahlen der Läufe“ füllt Z von Hand mit den Ergebnissen der Schritte 1 bis 3 aus. Kein Commit: die Berichte liegen außerhalb der Repos.


Z4 – Übergabe und Memory

Task Z4.1: Übergabe-Dokument uebergabe.md

Files:

  • Create: /root/reports/auftraege-rechnungen-2026-10-06/uebergabe.md (außerhalb der Repos)

Interfaces:

  • Consumes: $R/z-protokoll.md (SHAs, Zahlen, Entscheidungen von Z1 bis Z3), $R/kontrolle/ergebnis.md, offen-fuer-nutzer.md, minors.md, das Freigabe-Dokument von L7 ($R/rechtsrunde.md), die Vertragsfragen am Ende dieser Datei.
  • Produces: das eine Dokument, mit dem der Nutzer (oder eine spätere Sitzung) den Livegang Schritt für Schritt ausführt: Stand, Reihenfolge mit exakten Befehlen, Pilot, Schalter on, Rechtsrunde, Rückweg, Betriebsregeln, offene Punkte. Jeder Befehlsblock ist ohne Vorwissen kopierbar (Variablen stehen in jedem Block neu, kein Block ist eingerückt).

Die Übergabe folgt dem Muster der Notiz „Öffnungszeiten live“ (docs/superpowers/plans/2026-10-04-oeffnungszeiten-live-livegang.md: Reihenfolge, Pilot, Schalter, Rechtsrunde) und den Livegang-Mustern der Memory-Notizen: Schema vor dem Push, Einstellungen mit Sicherung, Push mit Ziel-Ref, up -d --no-deps statt restart.

  • Schritt 1: Werte einsammeln (nur lesen)
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
R=/root/reports/auftraege-rechnungen-2026-10-06
cd $P && git fetch origin -q
echo "PORTAL_SHA=$(git rev-parse --short HEAD) COMMITS_VOR_MAIN=$(git rev-list --count origin/main..feature/auftraege) MAIN_SHA=$(git rev-parse --short origin/main)"
git merge-base --is-ancestor origin/main feature/auftraege && echo "VORFAHRE=ja" || echo "VORFAHRE=nein"
cd $Wo && git fetch origin -q
B=<B aus „## Z2 abgeschlossen“ im Protokoll>
echo "WOMS_SHA=$(git rev-parse --short HEAD) TEST_SHA=$(git rev-parse --short origin/test)"
git merge-base --is-ancestor $B origin/test && echo "REDESIGN_AUF_TEST=ja" || echo "REDESIGN_AUF_TEST=nein"
git merge-base --is-ancestor origin/test feature/auftraege && echo "FAST_FORWARD_WOMS=ja" || echo "FAST_FORWARD_WOMS=nein"
rg -n '^## Z' $R/z-protokoll.md | head -40
cat $R/kontrolle/ergebnis.md
git -C $P branch --list 'auftraege/*'

Erwartet: alle Werte, die im Gerüst unten als «…» stehen. VORFAHRE=nein oder FAST_FORWARD_WOMS=nein heißt: erst Z1.6 bzw. Z2.5 (Stand von origin/main und origin/test einarbeiten, neu testen), dann hier weiter.

  • Schritt 2: Die Befehle der Übergabe gegen den heutigen Stand prüfen (nur lesen) und den Ausgangszustand der Schnittstellen festhalten
R=/root/reports/auftraege-rechnungen-2026-10-06
C=/home/webklar/apps/docker-compose.yml
test "$(rg -c 'SHOP_PILOT_EMAILS=\$\{SHOP_PILOT_EMAILS:-\}' $C)" = 1 && echo "compose: Marker genau einmal"
rg -c 'AUFTRAEGE_' $C /home/webklar/apps/.env || echo "keine AUFTRAEGE_-Zeilen (erwartet)"
docker compose -f $C config -q && echo "compose gültig"
docker network inspect webklar-network -f 'Netz {{.Name}}' && docker image inspect node:20-alpine -f 'Image {{.Id}}' | cut -c1-24
docker inspect -f '{{.State.Status}} seit {{.State.StartedAt}}' webklar-kundenbereich-server
git -C /home/webklar/apps/kundenbereich.webklar.com/build check-ignore -v upload-data/auftraege/x/y.pdf
ls -ld /home/webklar/apps/kundenbereich.webklar.com/build/upload-data
for u in "/api/admin/finanzen/projektkonten?kunde=x" /api/admin/finanzen/auftraege "/api/team/arbeitsblaetter/abgerechnet?ticketId=x" /api/auftraege /api/auftrag/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA; do
  printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "https://project.webklar.com$u")" "$u"; done > $R/vorher-curl.txt
cat $R/vorher-curl.txt

Erwartet: compose: Marker genau einmal, keine AUFTRAEGE_-Zeilen, compose gültig, Netz und Image sind da, der Container läuft, upload-data/ steht in der .gitignore des Deploy-Checkouts (das git clean -fd im Deploy löscht ignorierte Dateien nicht: die PDFs der Auftragsbestätigungen überleben jeden Deploy), vorher-curl.txt zeigt den heutigen Stand (die neuen Pfade sind noch unbekannt, antworten also nicht mit 401). Weicht etwas ab (zum Beispiel ein zweiter Marker), die Befehle im Gerüst anpassen. Der Token im Beispiel hat genau 43 Zeichen (Format eines echten Tokens, damit die Route ihn bis zur Suche annimmt).

  • Schritt 3: uebergabe.md schreiben (Gerüst; jede «…» mit dem Wert aus Schritt 1 und dem Protokoll ersetzen, jeden Satz, der nicht stimmt, streichen; die Befehle bleiben wörtlich)
# Aufträge und Rechnungen – Übergabe und Livegang

Stand «Datum UTC», für Kenso. Grundlage: die Spezifikation (`/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md`), die Übersicht und die Strang-Pläne `2026-10-06-auftraege-00` bis `-12` im Ordner `docs/superpowers/plans/` desselben Worktrees, das Protokoll der Integration (`/root/reports/auftraege-rechnungen-2026-10-06/z-protokoll.md`) und die Kontrolle (`kontrolle/`). **Nichts davon ist gepusht.** Keine Rechtsberatung.

## Stand

| Repo | Worktree / Zweig | Stand | Basis und Abstand |
|---|---|---|---|
| Portal | `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege`, `feature/auftraege` | `«PORTAL_SHA»` («N» Commits vor `origin/main`) | `origin/main` `«MAIN_SHA»`, Vorfahre: «ja/nein» |
| WOMS | `/home/webklar/apps/ticket.webklar.com/dev-auftraege`, `feature/auftraege` | `«WOMS_SHA»` | Redesign bis `«B»`; `origin/test` `«TEST_SHA»`, Redesign auf `test`: «ja/nein», Fast-Forward: «ja/nein» |

Läufe (Protokoll): Portal `npm test` «tests/pass/fail» (Node 24), Node 20 «Dateien/pass/fail», Durchlauf-Test 21/21, UI-Stichprobe «Bilder»; WOMS `npm test` «pass/fail» und `vite build` «ok»; Beispiel-PDFs «Seiten, Schriften».
Kontrolle: «C» Critical, «I» Important, «M» Minor gemeldet; «K» bestätigt und korrigiert; «O» offen für dich (Abschnitt G); die Kleinigkeiten stehen in `kontrolle/minors.md`.

## Was sich ändert

- **Mit dem Portal-Push** (der AB-Teil bleibt aus, `AUFTRAEGE_ENABLED` hat die Vorgabe `off`): Projektkonto je Projekt (zunächst nur die Schnittstelle), Rechnungsarten **Festbetrag, Nach Aufwand, Frei**, alle Rechnungen im Format A4 mit den Zusatzfeldern Projekt, Auftrag und Leistungszeitraum, die Schnittstelle für das Abzeichen „abgerechnet“, das Projekt in der Rechnungsliste der Kunden, die Einstellung `steuernummer`. Neu in Appwrite: die Collections `auftraege` und `projektRechnungen` und die Felder `customers.kundenart` und `financeSettings.steuernummer`.
- **Mit dem WOMS-Push:** der Reiter „Aufträge und Rechnungen“ im neuen Design (Projektkonto, neuer Rechnungsdialog), die Dialoge zur Auftragsbestätigung (sichtbar nur, wenn der Schalter für den Kunden an ist), Kundenart, Steuernummer, Abzeichen am Arbeitsblatt.
- **Erst mit `AUFTRAEGE_ENABLED=pilot` oder `on`:** Auftragsbestätigungen (AB), die Rechnungsarten Anzahlung und Schlussrechnung, die Bestätigungsseite `auftrag.html`, der Abschnitt „Aufträge“ im Portal, der Widerruf von Projektaufträgen, die Mails K31, K32 und T22.
- **Noch nicht im Portal-Zweig:** der Datenschutz-Commit von L7 (Abschnitt 9a der Datenschutzerklärung samt PDF). Er ist der letzte Commit des Strangs und wird erst beim AB-Livegang nach deiner Freigabe gemergt und gepusht (Abschnitt D3), damit die öffentliche Datenschutzerklärung keine Verarbeitung beschreibt, die noch nicht läuft.

## Entscheidungen für dich (die Vorgabe gilt, wenn du nichts sagst)

«Aus den Vertragsfragen am Ende des Plans `2026-10-06-auftraege-12-integration-kontrolle.md`: **offen für dich** die Nummern 8 (Löschlauf für Aufträge) und 12 („Noch offen“ bei Aufwand) mit ihrem Vorschlag in je ein bis zwei Sätzen; **zur Kenntnis (in der Übersicht entschieden)** kurz erklärt die Nummern 3 (Stripe-Metadaten wandern bei „Neue Fassung“ nicht mit), 10 und 11 (K31 ohne Team-BCC, Teamkopie ohne Link, Link mit `#t=`), 14 (Steuernummer im Rechnungsfuß nur auf deine Weisung), 16 (Datenschutz-Abschnitt kommt erst beim AB-Livegang) und 17 (K32 mit dem Wortlaut der Erklärung); dazu aus `pruefmaterial/bekannte-vertragsfragen.md` die Entscheidungen E1 bis E12 und die Kanzlei-Fragen im Freigabe-Dokument von L7 und die Punkte aus `kontrolle/offen-fuer-nutzer.md`, die eine Entscheidung brauchen.»

## A. Reihenfolge am Livegang-Tag (Rechnungsteil)

### A0. Du: Steuernummer in Stripe

Jederzeit, fünf Minuten, unabhängig vom Bau. Stripe-Dashboard → Einstellungen → Billing → Rechnungen → „Steuerinformationen für Rechnungen“: die Steuernummer der WEBklar GbR eintragen und als Standard setzen. Sie steht dann auf allen Stripe-Rechnungen, auch auf Abo-Rechnungen (§ 34a UStDV; der bisherige Rechnungsfuß enthält sie nicht). Die Nummer für das PDF der Auftragsbestätigung trägst du nach dem WOMS-Push in WOMS → Finanzen → Einstellungen → „Steuernummer“ ein (A9).

### A1. Ich: Vorprüfung (nur lesen)

```bash
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && git fetch origin -q
git merge-base --is-ancestor origin/main feature/auftraege && echo "OK: origin/main ist Vorfahre, der Push ist ein Fast-Forward" || echo "origin/main ist weiter: erst einarbeiten (git merge origin/main), neu testen, SHAs hier nachtragen"
git log --oneline origin/main..feature/auftraege | wc -l
git status --short | head -3
```

Ist `origin/main` weiter, arbeite ich es mit `git merge origin/main` ein (Merge, nicht Rebase: die Commit-Nummern in den Protokollen bleiben gültig), wiederhole `npm test` und die Auftrags-Tests und trage die neuen SHAs hier ein.

### A2. Ich: Ausgangslage sichern

```bash
R=/root/reports/auftraege-rechnungen-2026-10-06
git -C /home/webklar/apps/kundenbereich.webklar.com/build rev-parse HEAD > $R/rueckweg-portal-sha.txt
cat $R/rueckweg-portal-sha.txt
```

Das ist der Stand der Produktion vor dem Push, die Grundlage des Rückwegs (Abschnitt E).

### A3. Ich: Schema anlegen, vor dem Push

Das Skript braucht den neuen Code, läuft also aus dem Worktree in einem Wegwerf-Container (kein `docker compose run`: der Container bekäme die Traefik-Labels des Portals). Die Env-Datei enthält Zugangsdaten: mit `umask 077` anlegen, danach löschen. Erst `--check` (beim ersten Lauf Exit 1, erwartet: es fehlt ja alles), dann ohne, dann **noch einmal `--check`** (muss grün sein; die Index-Reihenfolge der Schema-Skripte hat am 30.09. schon einmal überrascht). Das Skript ist idempotent, `--check` schreibt nichts, es legt nur an und löscht nichts. Blockiert der Auto-Modus den Zugriff auf den Schlüssel, führst du die Zeilen selbst aus.

```bash
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
(umask 077; docker inspect webklar-kundenbereich-server -f '{{range .Config.Env}}{{println .}}{{end}}' > /root/auftraege-env.tmp)
docker run --rm --env-file /root/auftraege-env.tmp --network webklar-network -v $P:/app:ro -w /app node:20-alpine node scripts/auftraege-setup.mjs --check; echo "exit $?"
docker run --rm --env-file /root/auftraege-env.tmp --network webklar-network -v $P:/app:ro -w /app node:20-alpine node scripts/auftraege-setup.mjs
docker run --rm --env-file /root/auftraege-env.tmp --network webklar-network -v $P:/app:ro -w /app node:20-alpine node scripts/auftraege-setup.mjs --check; echo "exit $?"
rm -f /root/auftraege-env.tmp
```

Erwartet: der erste Lauf nennt `- auftraege FEHLT`, `- projektRechnungen FEHLT` und die Felder (Exit 1), der zweite legt alles an, der dritte endet mit Exit 0. Vor dem Push kennt der laufende (alte) Code die neuen Collections nicht: harmlos. Das Skript legt `projektRechnungen` in jedem Fall an (der Rechnungsteil braucht sie auch bei ausgeschaltetem Schalter), und der WOMS-Push setzt das Feld `customers.kundenart` voraus: dieser Schritt darf nicht entfallen.

**Ausweichweg** (so sieht es auch die Übersicht vor): ging A3 vor dem Push nicht, läuft das Skript **sofort nach dem Deploy** im neuen Container:

```bash
docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs --check; echo "exit $?"
docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs
docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs --check; echo "exit $?"
```

Erwartet wie oben (1, dann Anlegen, dann 0). Bis dahin scheitern die neuen Projektkonto-Aufrufe und die Rechnungsarten Festbetrag und Nach Aufwand (die Collection fehlt); die alte Rechnung ohne Art (`frei`) läuft. Blockiert der Auto-Modus den Zugriff auf den Schlüssel, führst du diese Zeilen selbst aus: der Container kennt den Schlüssel aus seiner Umgebung.

### A4. Du: Portal pushen

Zu einer ruhigen Zeit: der Container startet danach neu, `apk add` und `npm install` dauern ein bis zwei Minuten. Die Ziel-Ref steht immer dabei: der Webhook deployt **jeden** gepushten Zweig unter seinem Namen (`DEPLOY_BRANCH` kommt aus `payload.ref`), `feature/auftraege` darf nie unter seinem eigenen Namen gepusht werden.

```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && git push origin feature/auftraege:main
```

### A5. Ich: Prüfungen nach dem Deploy

```bash
R=/root/reports/auftraege-rechnungen-2026-10-06
docker inspect -f 'Container gestartet {{.State.StartedAt}}' webklar-kundenbereich-server
git -C /home/webklar/apps/kundenbereich.webklar.com/build rev-parse --short HEAD
docker logs --since 10m webklar-kundenbereich-server 2>&1 | command grep -a -i -E "unknown attribute|cannot find module|error" | head
docker exec -w /app webklar-kundenbereich-server node -e "import('pdfkit').then(() => console.log('pdfkit ok'))"
docker exec -w /app webklar-kundenbereich-server node scripts/auftraege-setup.mjs --check; echo "exit $?"
docker exec webklar-kundenbereich-server sh -c 'echo "AUFTRAEGE_ENABLED=${AUFTRAEGE_ENABLED:-<nicht gesetzt = off>}"'
for u in "/api/admin/finanzen/projektkonten?kunde=x" /api/admin/finanzen/auftraege "/api/team/arbeitsblaetter/abgerechnet?ticketId=x" /api/auftraege /api/auftrag/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA; do
  printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "https://project.webklar.com$u")" "$u"; done > $R/nachher-curl.txt
diff $R/vorher-curl.txt $R/nachher-curl.txt
curl -s -D - -o /dev/null https://project.webklar.com/api/auftrag/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | rg -i '^(HTTP|cache-control|x-robots-tag|referrer-policy)'
curl -s https://project.webklar.com/auftrag.html | rg -c -i noindex
curl -sI https://project.webklar.com/rechtliches/agb-2026-09-30.pdf | head -1
```

Erwartet: der Container wurde nach dem Push gestartet, `build` steht auf der gepushten SHA, im Log weder `unknown attribute` noch `cannot find module` (Treffer von `error` einzeln ansehen: ältere Zeilen anderer Funktionen sind möglich), `pdfkit ok`, `--check` Exit 0, `AUFTRAEGE_ENABLED` nicht gesetzt (gleich `off`). Die Admin-, Team- und Kundenpfade antworten **401** (vorher 404), der Token-Pfad **404** mit `{"error":"Dieser Link ist ungültig oder abgelaufen."}` und `Cache-Control: no-store`; `auftrag.html` enthält `noindex` (Zahl größer 0), die AGB-Kopie antwortet `200`. Ist `agb-2026-09-30.pdf` 404, liegt Strang L7 nicht im Stand: dann hängt die K31-Mail später ohne AGB an, **vor** dem Pilot klären.

### A6. Ich: Probe des Rechnungsdialogs

Bis zum WOMS-Push gilt der alte Dialog „Rechnung schreiben“. Beim Kunden „test“ (kenso.gri@gmail.com) einen Entwurf mit einer freien Position schreiben, **nicht senden**. Erwartet: kein Fehler. Im Stripe-Dashboard die Rechnung öffnen und das PDF ansehen: Seitenformat A4, § 19-Hinweis und Bankverbindung im Fuß wie bisher, die Steuernummer steht auf dem PDF (A0) mit einer passenden Beschriftung. Den Entwurf über den Löschen-Knopf in WOMS löschen (nie im Stripe-Dashboard). Ist die Beschriftung der Steuernummer unpassend oder fehlt sie: sag Bescheid, dann baue ich den Fallback (Steuernummer in den Rechnungsfuß, Plan Z4.3).

### A7. Du: WOMS pushen, erst wenn das Redesign auf `test` ist

Nach dem Portal: sonst antwortet das Portal auf die neuen Aufrufe mit 404. Die Prüfung dazu, die letzte Zeile ist dein Push (nur nach zwei erfolgreichen Prüfungen):

```bash
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo && git fetch origin -q
git merge-base --is-ancestor «B» origin/test && echo "Redesign ist auf test" || echo "Redesign noch nicht auf test: erst die Pushs der Redesign-Pakete (Reihenfolge p1, p2, …, nie --force), dann weiter"
git merge-base --is-ancestor origin/test feature/auftraege && echo "Fast-Forward möglich" || echo "origin/test ist weiter: ich arbeite es ein (git merge origin/test), teste neu, melde die neue SHA"
git push origin feature/auftraege:test
```

Der WOMS-Webhook deployt nur `test`. Das Feld `customers.kundenart` muss vorher angelegt sein (A3). Ohne WOMS-Push bleibt der Rechnungsteil des Portals mit dem alten Dialog voll nutzbar (alte Aufrufe ohne `art` gelten als Art `frei`).

### A8. Ich: WOMS prüfen

Kunde „test“ → Reiter „Aufträge und Rechnungen“ lädt ohne Fehler, die Zeile des Projekts „Test-Website“ zeigt Vereinbart „keine AB“; Finanzen → „Rechnung schreiben“ öffnet den neuen Dialog mit Kundenwahl; am Arbeitsblatt eines Tickets mit Rechnung das Abzeichen; Finanzen → Einstellungen zeigt das Feld „Steuernummer“; Browser-Konsole ohne Fehler (Desktop und Handy). Dann einen Entwurf mit dem neuen Dialog (Art Festbetrag, Projekt „Test-Website“) schreiben und wie in A6 prüfen: A4, Zusatzfelder „Projekt“ und „Leistungszeitraum“ (ohne AB kein Feld „Auftrag“); Entwurf löschen. Die Bilder in `/root/reports/auftraege-rechnungen-2026-10-06/bilder/z2/` mit der Live-Ansicht vergleichen.

### A9. Ich und du: Rechtliches-Karte, Steuernummer, Kundenart

Hat Z2.4 eine Karte angelegt (Commit `Rechtliches: Karte für die Widerrufsbelehrung der Projektaufträge` im WOMS-Zweig):

```bash
Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
cd $Wo && node scripts/setup-legal.mjs --dry-run | tail -4
(umask 077; docker inspect webklar-kundenbereich-server -f '{{range .Config.Env}}{{println .}}{{end}}' | command grep -a -E '^APPWRITE_(ENDPOINT|PROJECT_ID|DATABASE_ID|API_KEY)=' > /root/auftraege-env.tmp)
docker run --rm --env-file /root/auftraege-env.tmp --network webklar-network -v $Wo:/app:ro -w /app node:20-alpine node scripts/setup-legal.mjs
rm -f /root/auftraege-env.tmp
```

Das Skript legt nur **fehlende** Karten an und ändert nie eine vorhandene. Erwartet: `Rechtstext widerrufsbelehrung-projekt angelegt` (Schlüssel wie in Z2.4 festgelegt). **Du:** in WOMS → Finanzen → Einstellungen die **Steuernummer** eintragen (sie steht später im Briefkopf der Auftragsbestätigung) und bei Privatkunden im Reiter „Übersicht“ die **Kundenart** „Privatperson“ setzen (leer heißt unbekannt: der AB-Dialog fragt dann vorher danach).

## B. Pilot-Probelauf mit `AUFTRAEGE_ENABLED=pilot` (Kunde „test“)

Nur Kunden, deren E-Mail in `AUFTRAEGE_PILOT_EMAILS` steht, sehen den AB-Teil. Der Pilot setzt den WOMS-Push voraus (die AB-Dialoge gibt es nur dort). Die Mails gehen wirklich raus: K31 und K32 an kenso.gri@gmail.com (K31 ohne BCC), dazu im Team-Postfach (`CHAT_NOTIFY_EMAILS`) eine Kopie der K31 ohne Link und T22: sag Andrej und Justin vorher Bescheid.

### B1. Ich: Schalter einrichten

Sicherungen, dann `.env` und compose; die `.env` enthält Zugangsdaten und wird nie ausgegeben.

```bash
T=$(date +%Y-%m-%d)
cp -p /home/webklar/apps/docker-compose.yml /home/webklar/apps/docker-compose.yml.bak-$T-auftraege
(umask 077; cp -p /home/webklar/apps/.env /home/webklar/apps/.env.bak-$T-auftraege)
python3 - <<'PY'
c = '/home/webklar/apps/docker-compose.yml'
alt = "      - SHOP_PILOT_EMAILS=${SHOP_PILOT_EMAILS:-}\n"
neu = alt + "      # Auftraege und Rechnungen (2026-10-06): AB-Teil, off | pilot | on\n      - AUFTRAEGE_ENABLED=${AUFTRAEGE_ENABLED:-off}\n      - AUFTRAEGE_PILOT_EMAILS=${AUFTRAEGE_PILOT_EMAILS:-}\n"
s = open(c, encoding='utf-8').read()
assert s.count(alt) == 1 and 'AUFTRAEGE_ENABLED' not in s, 'compose: Marker nicht eindeutig oder schon gesetzt'
open(c, 'w', encoding='utf-8').write(s.replace(alt, neu))
e = '/home/webklar/apps/.env'
s = open(e, encoding='utf-8').read()
assert 'AUFTRAEGE_ENABLED' not in s, '.env: schon gesetzt'
s += ('' if s.endswith('\n') else '\n') + '# Auftraege und Rechnungen (2026-10-06): AB-Teil, off | pilot | on\nAUFTRAEGE_ENABLED=pilot\nAUFTRAEGE_PILOT_EMAILS=kenso.gri@gmail.com\n'
open(e, 'w', encoding='utf-8').write(s)
print('ok')
PY
docker compose -f /home/webklar/apps/docker-compose.yml config -q && echo "compose gültig"
docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server
docker exec webklar-kundenbereich-server sh -c 'echo "$AUFTRAEGE_ENABLED $AUFTRAEGE_PILOT_EMAILS"'
```

Ein bloßes `docker restart` liest neue Umgebungswerte nicht: es muss `up -d --no-deps` sein (`--no-deps` fasst nur diesen Dienst an; den Container `preview-portal` nie neu anlegen). Erwartet: `ok`, `compose gültig`, zuletzt `pilot kenso.gri@gmail.com`.

### B2. Du und ich: Ablauf mit dem Testkunden „test“

Kunden-ID `6a53655c4973f99891ff`, Projekt „Test-Website“; die Beträge sind klein gewählt.

1. Kundenart „Privatperson“, Anschrift vollständig (sonst 409 beim Senden: so gewollt).
2. AB-Dialog: Projekt „Test-Website“, Festpreis, eine Position „Probe“ zu 2,00 €, Zahlungsplan „Anzahlung 50 %“, Zahlungsziel 14 Tage, Datum und Weg. **Vorschau** öffnen: Wasserzeichen „ENTWURF“, Texte, Seiten der Widerrufsbelehrung und des Muster-Widerrufsformulars (Privatperson).
3. **Senden** (mit Rückfrage): K31 kommt an, Anhänge AB-PDF und AGB-PDF, ohne BCC; Nummer, Steuernummer, Summe und Zahlungsplan im PDF stimmen; WOMS zeigt Status „versendet“ und Mailstatus „gesendet“. Im Team-Postfach liegt die Kopie der K31 ohne Link und ohne Knopf („Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.“).
4. Den Link aus der Mail (`…/auftrag.html#t=…`) in einem privaten Fenster und auf dem Handy öffnen: Zusammenfassung, Links auf beide PDFs, Häkchen, Frühbeginn-Haken (freiwillig). **Ohne** Frühbeginn bestätigen: „Bestätigt am …“; K32 und T22 kommen; WOMS zeigt „bestätigt“ und „Arbeitsbeginn frühestens am …“ (Tag nach Ablauf der Widerrufsfrist). Den Link noch einmal öffnen: unverändert „Bestätigt am …“, keine zweite Mail.
5. Eine zweite Probe-AB senden und **Erinnerung senden**: der alte Link zeigt „Dieser Link ist ungültig oder abgelaufen.“, der neue funktioniert.
6. Rechnungsdialog: Art **Anzahlung** (1,00 €) als **Entwurf**: im Stripe-Dashboard A4, Zusatzfelder „Projekt“ und „Auftrag“ (AB-Nummer), Steuernummer; Entwurf in WOMS löschen. Art **Nach Aufwand** für ein Test-Ticket mit Arbeitsblättern: eine Zeile je Tag, Abo-Zeit fehlt; ein zweiter Entwurf mit denselben Arbeitsblättern wird mit „Arbeitsblatt bereits abgerechnet“ abgelehnt; Entwurf in WOMS löschen: die Arbeitsblätter sind wieder frei.
7. **Optional, mit echtem Geld** (einige Cent Gebühr, die Nummern sind danach verbraucht): die Anzahlung (1,00 €) senden und per Karte bezahlen, dann die Schlussrechnung als Entwurf ansehen: eine Minuszeile „abzüglich Anzahlung, Rechnung … vom …“, Summe 1,00 €; senden und bezahlen: die AB steht auf „abgerechnet“; die Schlussrechnung in WOMS stornieren: die AB geht auf „bestätigt“ zurück.
8. **Widerruf:** mit einer eigens gesendeten Probe-AB (Privatperson) die Seite `widerrufen.html` öffnen, Vertragsart „Projektauftrag (Auftragsbestätigung)“, AB-Nummer und E-Mail eingeben: Eingangsbestätigung an dich (K04), Team-Mail (T09), WOMS zeigt „widerrufen“.
9. **Kundenansicht:** in WOMS „Als Kunde ansehen“ öffnen, im Portal den Abschnitt „Aufträge“: der Knopf „Auftrag bestätigen“ ist gesperrt.
10. **Aufräumen:** offene Probe-ABs in WOMS **stornieren** (Status `storniert`); Probe-Rechnungen, die finalisiert wurden, in WOMS stornieren; nichts im Stripe-Dashboard voiden oder löschen. Die Probe-ABs und ihre Nummern bleiben als Belege bestehen: die erste echte AB trägt die nächste freie Nummer.

### B3. Ich: Log prüfen

```bash
docker logs --since 60m webklar-kundenbereich-server 2>&1 | command grep -a -i -E "auftr|unknown attribute|error" | head -20
```

## C. Schalter `on` (letzter Schritt)

Erst wenn Abschnitt B ohne offene Fehler ist **und** Abschnitt D vollständig erledigt (Datenschutzerklärung live, Rechtstexte freigegeben und hochgeladen, Steuernummer in WOMS eingetragen):

```bash
T=$(date +%Y-%m-%d)
(umask 077; cp -p /home/webklar/apps/.env /home/webklar/apps/.env.bak-$T-auftraege-on)
python3 - <<'PY'
e = '/home/webklar/apps/.env'
s = open(e, encoding='utf-8').read()
assert s.count('AUFTRAEGE_ENABLED=pilot\n') == 1
open(e, 'w', encoding='utf-8').write(s.replace('AUFTRAEGE_ENABLED=pilot\n', 'AUFTRAEGE_ENABLED=on\n'))
print('ok')
PY
docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server
docker exec webklar-kundenbereich-server sh -c 'echo "$AUFTRAEGE_ENABLED"'
```

Erwartet: `ok`, dann `on`. Danach den Log einen Tag im Blick behalten und die erste echte AB mit einem Kunden gemeinsam prüfen.

## D. Rechtsrunde (Voraussetzung für `on`)

### D1. Du: freigeben

Das Freigabe-Dokument `/root/reports/auftraege-rechnungen-2026-10-06/rechtsrunde.md` (Wortlaute, Entscheidungen E1 bis E12, Fragen an die Kanzlei) lesen und freigeben oder mit neuem Wortlaut zurückgeben. Dazu die Rechtsfragen aus der Kontrolle: «Zeilen mit „Rechtfrage:“ aus `kontrolle/minors.md` hier einfügen».

### D2. Ich: Änderungen einarbeiten

Im Zweig `auftraege/l7-rechtstexte`. Ändert sich ein Wortlaut, setze ich `TEXTE_FASSUNG` und `FRUEHBEGINN_FASSUNG` (`server/services/auftraege/texte.js`) auf den Tag der Freigabe, erzeuge die Seiten neu (`node scripts/build-legal-html.mjs auftraege`) und drucke die PDFs (`scripts/build-legal-pdfs.sh auftraege`; Chrome, hinter der Sperre `auftraege-schwer.lock`, nicht neben anderen schweren Läufen). Die Dateien der früheren Fassung bleiben liegen, sobald sie ausgeliefert wurden.

### D3. Ich: Datenschutz-Commit nachholen

Er wurde zurückgehalten, weil `public/datenschutz.html` und das PDF mit dem ersten Push sofort öffentlich wären, der Abschnitt 9a aber eine Verarbeitung beschreibt, die bei `AUFTRAEGE_ENABLED=off` noch nicht läuft. Befehl: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege merge --no-ff auftraege/l7-rechtstexte`. Dann den **Stand** der Datenschutzerklärung auf den Tag der Veröffentlichung setzen (eine Zeile in `public/datenschutz.html`, eine in `test/legalTexts.test.js`; beim Zusammenführen mit `feature/oeffnungszeiten` ändern beide Zweige dieselbe Zeile: den späteren Tag nehmen), das PDF neu drucken (`scripts/build-legal-pdfs.sh datenschutz`, nicht `all`), die Prüfsumme nachziehen und `test/legalTexts.test.js`, `test/auftraegeRechtstexte.test.js` und `test/auftraege*.test.js` laufen lassen.

### D4. Du: zweiter Portal-Push

Prüfung und Befehl wie A1 und A4 (erst `git merge-base --is-ancestor origin/main feature/auftraege`, dann `git push origin feature/auftraege:main`). Warten, bis der Deploy durch ist (`build` steht auf der gepushten SHA).

### D5. Du und ich: Uploads in WOMS → Rechtliches

Nur mit deinem kurzlebigen Schlüssel (Memory „Rechtstexte selbst hochladen“). **Du:** in der Appwrite-Konsole (Projekt des Ticketsystems) einen Schlüssel „Rechtliches-Upload“ mit den Bereichen `files.read`, `files.write`, `documents.read`, `documents.write` anlegen und den Wert in die Datei `/root/.appwrite-rechtliches.key` legen (`chmod 600`). **Ich:**

```bash
R=/root/reports/auftraege-rechnungen-2026-10-06
cp /root/reports/webshop-2026-10-05/rechtliches-hochladen.mjs $R/rechtliches-hochladen-auftraege.mjs
```

In der Kopie `COMMIT` auf die gepushte SHA setzen und `FASSUNGEN` ersetzen durch drei Einträge: `widerrufsbelehrung-projekt` (der Schlüssel der Karte aus A9; Dateien `public/rechtliches/widerrufsbelehrung-projektauftrag-«FASSUNG».pdf` und `.html`, `validFrom` = Tag der Fassung), `datenschutz` (Dateien `docs/WEBklar-Datenschutzerklaerung.pdf` und `public/datenschutz.html`, `validFrom` = Tag der Veröffentlichung, `changeNote` „Stand «TT.MM.JJJJ»: Abschnitt 9a Auftragsbestätigungen, Widerruf von Projektaufträgen“) und `agb` (nur, wenn `--probe` zeigt, dass es für die Fassung 2026-09-30 noch keine Version gibt: Dateien `public/rechtliches/agb-2026-09-30.pdf` und `.html`). Danach:

```bash
R=/root/reports/auftraege-rechnungen-2026-10-06
node $R/rechtliches-hochladen-auftraege.mjs --probe
node $R/rechtliches-hochladen-auftraege.mjs
shred -u /root/.appwrite-rechtliches.key
```

`--probe` liest nur und prüft die Rechte des Schlüssels; das Skript überspringt Fassungen, die schon hochgeladen sind, und setzt die bisherige gültige Fassung auf „abgelöst“. **Du:** den Schlüssel danach in der Appwrite-Konsole löschen.

### D6. Danach

Abschnitt C (`on`).

## E. Rückweg

### E1. Nur der AB-Teil macht Ärger

`AUFTRAEGE_ENABLED=off`: in `/home/webklar/apps/.env` die Zeile `AUFTRAEGE_ENABLED=…` auf `off` setzen (dasselbe `python3`-Muster wie in Abschnitt C, mit `off` statt `on`), dann `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server`. Die AB-Routen antworten 404, der Rechnungsteil (Projektkonto, Festbetrag, Aufwand, A4) läuft weiter. Achtung: Bei `off` gelten bereits versendete Links nicht mehr (die Token-Seiten antworten 404, das Bestätigen ist gesperrt); der Widerruf einer Privatperson wird weiter angenommen. Soll ein versendeter Link weiter gelten, schalte auf `pilot` mit deiner Adresse statt auf `off`.

### E2. Das Ganze zurück (Rechnungsteil eingeschlossen)

Die Änderungen des Pushs als neuer Commit umkehren, **nie** `--force`:

```bash
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; R=/root/reports/auftraege-rechnungen-2026-10-06
ALT=$(cat $R/rueckweg-portal-sha.txt)
NEU=«PUSH_SHA»
git -C $P fetch origin -q
git -C $P worktree add ../dev-auftraege-rueckweg -b rueckweg/auftraege origin/main
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-rueckweg
git diff --binary $ALT $NEU | git apply -R --index --3way
git status --short | head
git commit -m "Rücknahme: Aufträge und Rechnungen (Änderungen $ALT bis $NEU rückgängig)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```

Danach `npm test` laufen lassen, und **du** pushst `git push origin rueckweg/auftraege:main` (Fast-Forward auf `origin/main`). Haben andere Commits dieselben Dateien geändert, meldet `git apply` Konflikte: dann von Hand auflösen, nicht erzwingen.

### E3. Schema, Daten, WOMS

Das Schema ist rein additiv (neue Collections und Felder), es gibt nichts zurückzubauen. Stripe-Rechnungen, die der neue Code angelegt hat, bleiben gültig (Metadaten und A4 schaden dem alten Stand nicht). WOMS: bei `off` blendet die Oberfläche die AB-Bereiche aus (`auftraegeAktiv` ist falsch), ein Rückweg des WOMS-Standes ist nicht nötig; ist er doch nötig, dasselbe Verfahren mit `origin/test` statt `origin/main` und dem Ziel `test` (der WOMS-Zweig enthält die Redesign-Pakete).

## F. Betriebsregeln

- Rechnungen und Entwürfe, die aus WOMS entstanden sind, nur **in WOMS** stornieren oder löschen, nie im Stripe-Dashboard: sonst bleibt das Dokument in `projektRechnungen` aktiv, und die Arbeitsblätter bleiben als abgerechnet gesperrt.
- Eine gesendete AB ändert man nie: „Neue Fassung“ (die alte wird beim Senden der neuen ersetzt, ihr Link ungültig).
- Die PDFs der Auftragsbestätigungen liegen in `/home/webklar/apps/kundenbereich.webklar.com/build/upload-data/auftraege/<Kunde>/<AB>.pdf` (außerhalb von Git, überlebt jeden Deploy, in der Serversicherung): nie von Hand ändern, verschieben oder löschen.
- AB-Nummern werden beim Senden vergeben und nie zurückgegeben; eine Nummer, die in einer Probe verbraucht wurde, bleibt verbraucht.
- Eine neue AGB-Fassung: Datei `agb-<Datum>.pdf` samt Prüfsumme in `public/rechtliches/`, `AGB_FASSUNG` in `konstanten.js`, neue Version in WOMS → Rechtliches; bereits versendete ABs behalten ihre Fassung.
- Die Aufbewahrungsfristen (AB 6 oder 8 Jahre) laufen frühestens Ende 2032 ab; einen Löschlauf gibt es noch nicht (Aufgabe für den Finanzen-Strang).

## G. Offen

- **Aus der Kontrolle:** «Inhalt von `kontrolle/offen-fuer-nutzer.md` hier einfügen (je Punkt: Befund, Vorschlag, Grund)». Die Kleinigkeiten stehen in `/root/reports/auftraege-rechnungen-2026-10-06/kontrolle/minors.md`.
- **Vertragsfragen:** die Liste am Ende von `plans/2026-10-06-auftraege-12-integration-kontrolle.md`; die Stellen mit Entscheidungsbedarf stehen oben unter „Entscheidungen für dich“.
- **Nach dem Livegang (Aufgaben, nicht blockierend):** Löschlauf für Aufträge nach der Aufbewahrungsfrist; Alias-Adressen für die Rechtsseiten ohne Fassungsdatum (die Karte in WOMS verlinkt die Fassung vom «Datum»; bei einer neuen Fassung die Karte anpassen); Abweichung zwischen Stripe-Dashboard und Projektkonto (L1 Vertragsfrage 5); Spezifikation Abschnitt 2 anpassen (Stripe-Metadaten wandern bei „Neue Fassung“ nicht mit, Vertragsfrage 3 des Plans Z).
  • Schritt 4: Gegenlesen
R=/root/reports/auftraege-rechnungen-2026-10-06; L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
rg -n '«' $R/uebergabe.md || echo "keine offenen Platzhalter"
awk '/^```bash$/{f=1;next} /^```$/{f=0;print "# ----"} f' $R/uebergabe.md > $L/uebergabe-befehle.sh
bash -n $L/uebergabe-befehle.sh && echo "Syntax der Befehle ok"
rg -n 'git push' $R/uebergabe.md | rg -v ':main|:test' || echo "jeder Push nennt seine Ziel-Ref"
rg -n -i 'api_key=|secret|bearer' $R/uebergabe.md | head

Erwartet: keine offenen Platzhalter, Syntax der Befehle ok, jeder git push mit Ziel-Ref (:main oder :test; Erklärtexte, die git push ohne Ziel nennen, sind in Ordnung, wenn sie es verbieten); die letzte Suche darf nur Erklärtexte treffen, nie einen Wert. Findet bash -n einen Fehler, ist es ein Fehler im Gerüst: beheben und neu prüfen.

  • Schritt 5: Protokoll
R=/root/reports/auftraege-rechnungen-2026-10-06
{ echo; echo "## Z4.1 Übergabe $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- $R/uebergabe.md ($(wc -l < $R/uebergabe.md) Zeilen)"; } >> $R/z-protokoll.md

Kein Commit.


Task Z4.2: Memory-Notiz fortschreiben

Files:

  • Modify: /root/.claude/projects/-root/memory/auftraege-rechnungen.md (Absatz anhängen, modified setzen)
  • Modify: /root/.claude/projects/-root/memory/MEMORY.md (die vorhandene Indexzeile der Notiz um einen Satz ergänzen)

Interfaces:

  • Consumes: die Zahlen und SHAs aus z-protokoll.md, kontrolle/ergebnis.md.
  • Produces: eine Notiz, mit der eine spätere Sitzung ohne Vorwissen weiterarbeiten kann: Stand, was als Nächstes kommt, was nie getan werden darf.

Die Notiz und ihre Indexzeile bestehen schon (vom Orchestrator angelegt; die Zeile beginnt mit - [Aufträge + Rechnungen](auftraege-rechnungen.md)). Es wird angehängt, nichts gelöscht, nichts überschrieben. Jede «…» im Python-Text wird vor dem Lauf durch den Wert aus dem Protokoll ersetzt.

  • Schritt 1: Absatz an die Notiz anhängen
M=/root/.claude/projects/-root/memory/auftraege-rechnungen.md
python3 - <<'PY'
import datetime, re
p = '/root/.claude/projects/-root/memory/auftraege-rechnungen.md'
s = open(p, encoding='utf-8').read()
assert len(re.findall(r'\n\s+modified: ', s)) == 1, 'Kopf: modified nicht eindeutig'
jetzt = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')
s = re.sub(r'(\n\s+modified: )[^\n]*', lambda m: m.group(1) + jetzt, s, count=1)
s = s.rstrip('\n') + '\n\n' + '''2026-10-06 «HH:MM» UTC Plan + Integration + Kontrolle FERTIG, NICHTS gepusht: Portal feature/auftraege = «PORTAL_SHA» («N» Commits vor origin/main «MAIN_SHA»), WOMS feature/auftraege = «WOMS_SHA» (Redesign bis «B»). Läufe: Portal npm test «Zahlen», Node 20 «Zahlen», Durchlauf-Test test/auftraegeAblauf.test.js 21/21, WOMS «Zahlen» + vite build ok. Kontrolle (5 Linsen sonnet, Gegenprüfung haiku): «C»/«I»/«M» Befunde, «K» korrigiert, «O» offen für den Nutzer. Übergabe mit exakten Befehlen: /root/reports/auftraege-rechnungen-2026-10-06/uebergabe.md; Protokoll z-protokoll.md, Kontrolle kontrolle/, Bilder bilder/.
Reihenfolge Livegang: (0) Nutzer: Steuernummer im Stripe-Dashboard; Schema vor dem Push per Wegwerf-Container (scripts/auftraege-setup.mjs, --check dreimal); Nutzer pusht Portal `git push origin feature/auftraege:main` (nie unter dem eigenen Zweignamen: der Webhook deployt jeden Zweig); WOMS erst nach dem Portal und nach dem Redesign (`feature/auftraege:test`); Pilot AUFTRAEGE_ENABLED=pilot mit kenso.gri@gmail.com (Kunde „test“) per compose-Zeilen + .env + `up -d --no-deps`; Rechtsrunde (rechtsrunde.md; Datenschutz-Commit von L7 bewusst zurückgehalten, wird nach der Freigabe nachgeholt, zweiter Push) und Uploads in WOMS Rechtliches mit kurzlebigem Schlüssel; dann `on`. Der AB-Teil ist hinter AUFTRAEGE_ENABLED=off (Vorgabe), Projektkonto und Rechnungsarten Festbetrag/Aufwand/Frei laufen sofort mit dem Push.
Fakten: Mail-Kennungen K31/K32/T22 vergeben (nächste freie K33/T23/G16); AB-PDFs unter build/upload-data/auftraege (gitignored, überlebt Deploys); Rechnungen aus WOMS nur in WOMS stornieren/löschen, nie im Stripe-Dashboard; offene Vertragsfragen stehen am Ende von plans/2026-10-06-auftraege-12-integration-kontrolle.md (u. a. kein Löschlauf für Aufträge); in der Übersicht entschieden: K31 ohne Team-BCC mit Teamkopie ohne Link, Link mit #t=, Stripe-Metadaten wandern bei „Neue Fassung“ nicht mit, Datenschutz-Commit erst beim AB-Livegang. Rückweg: AUFTRAEGE_ENABLED=off oder Gesamtdiff als neuer Commit umkehren (nie --force).
'''
open(p, 'w', encoding='utf-8').write(s)
print('ok')
PY
rg -n '«' $M || echo "keine offenen Platzhalter"

Erwartet: ok, keine offenen Platzhalter.

  • Schritt 2: Indexzeile ergänzen
cp -p /root/.claude/projects/-root/memory/MEMORY.md /root/.claude/projects/-root/memory/MEMORY.md.bak
python3 - <<'PY'
p = '/root/.claude/projects/-root/memory/MEMORY.md'
s = open(p, encoding='utf-8').read()
anfang = '- [Aufträge + Rechnungen](auftraege-rechnungen.md)'
zeilen = [z for z in s.split('\n') if z.startswith(anfang)]
assert len(zeilen) == 1, len(zeilen)
alt = zeilen[0]
assert 'Übergabe' not in alt, 'schon ergänzt'
neu = alt + '; 2026-10-06 Plan + Integration + Kontrolle fertig, Portal «PORTAL_SHA»/WOMS «WOMS_SHA» auf feature/auftraege, NICHTS gepusht, Übergabe /root/reports/auftraege-rechnungen-2026-10-06/uebergabe.md; Mail-Kennungen K31/K32/T22 vergeben (frei K33/T23/G16); AB-Teil hinter AUFTRAEGE_ENABLED=off'
open(p, 'w', encoding='utf-8').write(s.replace(alt, neu, 1))
print('ok')
PY
diff /root/.claude/projects/-root/memory/MEMORY.md.bak /root/.claude/projects/-root/memory/MEMORY.md | wc -l
rg -c '«' /root/.claude/projects/-root/memory/MEMORY.md || echo "keine offenen Platzhalter"
rm -f /root/.claude/projects/-root/memory/MEMORY.md.bak

Erwartet: ok, der diff meldet genau 4 Zeilen (eine alte, eine neue, zwei Rahmenzeilen), keine offenen Platzhalter. Keine andere Zeile von MEMORY.md ändert sich. Kein Commit (die Memory-Dateien liegen außerhalb der Repos).


Task Z4.3: Steuernummer im Rechnungsfuß (Fallback, nur auf Weisung)

Files:

  • Modify: server/services/finance/stripeFinance.js (invoiceFooter, Aufrufstelle in der Rechnungsanlage), test/financeStripeWrite.test.js (der Test invoiceFooter und die Fuß-Pins der Anlage)
  • Create: scripts/stripe-kunden-fuss.mjs, test/stripeKundenFuss.test.js

Interfaces:

  • Consumes: getSettings() aus server/services/finance/settings.js (liefert bankDetailsText und seit L0 steuernummer), invoiceFooter(bankDetailsText = '') (heute stripeFinance.js, Reihenfolge [config.stripe.invoiceFooter, Bank, PAYMENT_REFERENCE_NOTE]), config.stripe.invoiceFooter (server/config.js), stripeBilling.js (Abo-Rechnungen und Stripe-Kunden: invoice_settings.footer).
  • Produces: invoiceFooter(bankDetailsText = '', steuernummer = '') → string mit der Zeile Steuernummer: <Wert> zwischen dem Kleinunternehmer-Hinweis und der Bankverbindung, wenn steuernummer nicht leer ist; ohne zweites Argument bleibt die Rückgabe byte-gleich der bisherigen. Das Skript scripts/stripe-kunden-fuss.mjs [--anwenden], das den Fuß bestehender Stripe-Kunden (invoice_settings.footer) um dieselbe Zeile ergänzt.

Bedingung: nur ausführen, wenn der Nutzer nach A6 der Übergabe (Prüfung des Stripe-PDFs) sagt, die Beschriftung der Steuernummer sei unpassend oder fehle (Spec Abschnitt 3 „Steuernummer“). Sonst entfällt der Task (ein Satz im Protokoll). Er ändert Rechnungen für alle Kunden: also erst nach dem Livegang des Rechnungsteils.

  • Schritt 1: Test zuerst

In test/financeStripeWrite.test.js zwei Tests ergänzen: invoiceFooter('IBAN DE00 0000 0000 0000 0000 00', '12/345/67890') ist ${config.stripe.invoiceFooter}\nSteuernummer: 12/345/67890\nIBAN DE00 0000 0000 0000 0000 00\n${PAYMENT_NOTE}; die Rechnungsanlage mit gesetzter steuernummer in den Einstellungen schickt diesen Fuß an Stripe (der Fake-Stripe zeigt create.body.footer). Die vorhandenen Pins (invoiceFooter(), invoiceFooter(''), invoiceFooter(' \n ') und die Fuß-Erwartungen der Anlage ohne Steuernummer) bleiben unverändert grün: die Abo-Rechnungen und alle Rechnungen ohne Steuernummer ändern sich nicht.

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 300 nice -n 10 node --max-old-space-size=1024 --test test/financeStripeWrite.test.js Expected: FAIL (Steuernummer fehlt im Fuß).

  • Schritt 2: invoiceFooter(bankDetailsText, steuernummer) ändern

In stripeFinance.js: zweiter optionaler Parameter, die Zeile nur bei nicht leerem, getrimmtem Wert; an der Stelle der Rechnungsanlage, an der getSettings() gelesen und footer: invoiceFooter(bankDetailsText) übergeben wird, die Steuernummer aus denselben Einstellungen mitgeben. Lauf wie Schritt 1: # fail 0; danach test/finance*.test.js und test/auftraege*.test.js.

  • Schritt 3: Skript für bestehende Stripe-Kunden (Test zuerst: test/stripeKundenFuss.test.js mit dem Fake-Stripe prüft, dass ohne --anwenden nichts geschrieben wird, dass invoice_settings.footer die Zeile genau einmal bekommt und dass ein zweiter Lauf nichts mehr ändert)

scripts/stripe-kunden-fuss.mjs: liest die Steuernummer aus getSettings(), listet die Stripe-Kunden seitenweise (höchstens 100 je Aufruf), ergänzt in invoice_settings.footer die Zeile Steuernummer: … hinter dem Kleinunternehmer-Hinweis, wenn sie fehlt (idempotent), und schreibt nur mit --anwenden (ohne: Trockenlauf mit Zählung). Der Fehler eines Kunden bricht den Lauf nicht ab, er wird gezählt. Der Lauf gegen die Produktion braucht den Stripe-Schlüssel und die Zustimmung des Nutzers (erst ohne --anwenden, Zahl nennen, dann Freigabe).

  • Schritt 4: Commit
P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege
cd $P && git status --short
git add server/services/finance/stripeFinance.js scripts/stripe-kunden-fuss.mjs test/stripeKundenFuss.test.js test/financeStripeWrite.test.js
git commit -m "Rechnungen: Steuernummer im Rechnungsfuß (Fallback), Skript für bestehende Stripe-Kunden" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Das ist ein weiterer Portal-Push (Übergabe A1 und A4) und danach der Lauf des Skripts gegen die Produktion mit Freigabe.


Task Z4.4: Selbstprüfung von Strang Z und Abschlussmeldung

Files: keine Änderung; letzter Eintrag im Protokoll.

Interfaces:

  • Consumes: alles aus Z1 bis Z4.

  • Produces: die Gewissheit, dass nichts fehlt und nichts gepusht wurde, und die Abschlussmeldung an den Orchestrator.

  • Schritt 1: Prüfliste abarbeiten

P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege; Wo=/home/webklar/apps/ticket.webklar.com/dev-auftraege
R=/root/reports/auftraege-rechnungen-2026-10-06
echo "Portal: $(git -C $P rev-parse --short HEAD), Merges seit origin/main: $(git -C $P log --merges --oneline origin/main..feature/auftraege | wc -l), status: $(git -C $P status --short | wc -l) Zeilen"
echo "WOMS:   $(git -C $Wo rev-parse --short HEAD), Merges seit Basis: $(git -C $Wo log --merges --oneline origin/test..feature/auftraege | wc -l), status: $(git -C $Wo status --short | wc -l) Zeilen"
echo "Zweige der Stränge im Portal:"; git -C $P branch --list 'auftraege/*' | wc -l
echo "auf dem Remote vorhanden (muss 0 sein):"; { git -C $P branch -r --list 'origin/feature/auftraege' 'origin/auftraege/*'; git -C $Wo branch -r --list 'origin/feature/auftraege' 'origin/auftraege/*'; } | wc -l
ls -l $R/z-protokoll.md $R/uebergabe.md $R/kontrolle/ergebnis.md $R/kontrolle/minors.md $R/kontrolle/offen-fuer-nutzer.md $R/pruefmaterial/stand.md
ls $R/kontrolle/befunde-[1-5]-*.json | wc -l; ls $R/kontrolle/gegenpruefung | wc -l; ls $R/bilder/l6 | wc -l; ls $R/bilder/z2 | wc -l
rg -c '^## Z' $R/z-protokoll.md
rg -n '«' $R/uebergabe.md /root/.claude/projects/-root/memory/auftraege-rechnungen.md || echo "keine offenen Platzhalter"

Erwartet: Portal mindestens 7 Merges und status 0 Zeilen, WOMS mindestens 2 Merges und 0 Zeilen, die Zweige der Stränge sind noch da (Z1.6 und Z2.5 entfernen nur die Worktrees), auf dem Remote vorhanden: 0 (nichts wurde gepusht), alle Dateien vorhanden, fünf Befundlisten, je Critical/Important-Befund eine Gegenprüfung, Bilder in beiden Ordnern, Protokolleinträge für Z1.1 bis Z4.1, keine offenen Platzhalter.

  • Schritt 2: Vertragsliste der Übersicht abhaken (im Protokoll, ein Satz je Zeile)

  • V1 bis V8: Namen, Signaturen, Datenformen und Endpunkte, wie sie die Übersicht festlegt, sind im Gesamtstand vorhanden; jede Abweichung, die Z gefunden hat, steht im Protokoll (Integrationsfix mit SHA oder Vertragsfrage mit Nummer).

  • Dateizuständigkeit: der Prüfer zustaendigkeit-pruefen.mjs (Z1.1) und die Gruppenprüfung (Z3.4) haben keine unerklärten Fremdänderungen gezeigt.

  • Review Focus 1 bis 5: je ein grüner Durchlauf-Test (D3, D6/D6b/D8/D13, D4/D5, D9/D10/D15, D4/D8) und ein Prüfer.

  • Spec-Abdeckung: kontrolle/abdeckung.json hat keine luecke, die nicht im Befund, in der Korrektur oder in offen-fuer-nutzer.md steht.

  • Nichts gepusht, nie --force, kein git add -A, keine Token oder Remote-URLs in Berichten.

  • Schritt 3: Abschlussmeldung und Protokoll

R=/root/reports/auftraege-rechnungen-2026-10-06
{ echo; echo "## Z4 abgeschlossen $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- Übergabe: $R/uebergabe.md; Memory: auftraege-rechnungen.md aktualisiert; nichts gepusht"; } >> $R/z-protokoll.md

Die Meldung an den Orchestrator (kurz): Pfad der Übergabe, Stand beider Zweige (SHAs), Testzahlen, Zahl der Befunde je Schwere und wie viele korrigiert wurden, was für den Nutzer offen ist (kontrolle/offen-fuer-nutzer.md), die offenen Vertragsfragen (Nummern aus dem nächsten Abschnitt), und der Hinweis, dass der Nutzer zwei Dinge selbst tut: die Steuernummer in Stripe und die Pushs.


Vertragsfragen

Lücken und Widersprüche in Spezifikation, Übersicht und Strang-Plänen, auf die dieser Plan gestoßen ist. Die Planrunde hat vieles davon in der Übersicht entschieden (Abschnitt „Nachträge nach der Planrunde“, V1 bis V8, „Integration“); diese Punkte sind mit entschieden (siehe Übersicht, Nachträge) markiert und gelten nicht mehr als offen: sie stehen hier zur Nachvollziehbarkeit, und Z führt sie nur noch als Prüfpunkt. Offen sind die übrigen; keiner verhindert den Start von Z. Jeder Punkt nennt, was dieser Plan tut, und den Vorschlag. Die Nummern werden im Plan, in den Prüferaufträgen und in der Übergabe zitiert (Z3 reicht sie über bekannte-vertragsfragen.md an die Prüfer weiter: entschiedene Punkte melden sie nicht erneut).

  1. Zweignamen der Stränge. Entschieden (siehe Übersicht, Tabelle der Stränge): auftraege/l1-projektkonto bis auftraege/l7-rechtstexte, auftraege/w1-rechnungen, auftraege/w2-auftraege. Die Aufgabenstellung nannte sie als auftraege/lN. Plan: Z1.1 und Z2.1 lesen die Namen mit git branch --list 'auftraege/*'.
  2. Rechnungs-ID-Format. Entschieden (siehe Übersicht, Nachträge, V1): /^in_\w{1,33}$/, weil der Stripe-Nachbau in_test_<n> vergibt. Plan: hat ein Strang noch die enge Fassung /^in_[A-Za-z0-9]{1,33}$/, ist es ein Integrationsfix in Z1.3 Schritt 3.
  3. Stripe-Metadaten bei „Neue Fassung“. Entschieden (siehe Übersicht, Nachträge, L3): sie wandern nicht mit, maßgeblich ist projektRechnungen.auftragId (umhaengen); Spec Abschnitt 2 verlangt es anders. Plan: Test D4 prüft nur das Dokument, nicht die Metadaten. Offen bleibt nur die Spezifikation: die Worte „und Stripe-Metadaten“ in Abschnitt 2 streichen.
  4. Reihenfolge von Schema und Push. Entschieden (siehe Übersicht, Nachträge, „Livegang“): das Setup-Skript läuft vor dem Push (Wegwerf-Container mit dem Worktree) oder als Ausweichweg sofort danach per docker exec; es legt projektRechnungen in jedem Fall an, und der WOMS-Push setzt customers.kundenart voraus. Plan: Übergabe A3 mit dem Ausweichweg.
  5. Node-20-Lauf. Offen: die Übersicht („Integration“) nennt node:20-alpine mit --network host -m 1200m. Der Host-node_modules enthält glibc-Binärdateien (sharp), die unter Alpine nicht laden; die Tests brauchen kein Netz außer Loopback. Plan: node:20 (Debian) mit --network none -m 1500m (Z1.5), wie in der Notiz „Öffnungszeiten live“. Wer der Übersicht folgen will, nimmt node:20-alpine; scheitert der Lauf am Laden von sharp, ist es dieses Problem. Vorschlag: Übersicht anpassen.
  6. Testdateien ohne Besitzer. Teilweise entschieden (siehe Übersicht, Nachträge): test/financeStripeRead.test.js gehört L2, test/customerInvoices.test.js und test/publicForms.test.js (je eine Zeile) L6, test/legalTexts.test.js (Stand-Zeile) L7, Testhilfen ohne .test.js dem anlegenden Strang. Offen: test/kundenansichtGuard.test.js (die neuen Routen in MOUNTS: Z1.4) und test/financeSettings.test.js (DEFAULTS mit steuernummer: L0 zieht es nach, Z1.4 prüft). Vorschlag: beide in die Tabelle der Übersicht aufnehmen.
  7. V5 lässt Einzelheiten offen. Teilweise entschieden (siehe Übersicht, Nachträge): der Widerruf gilt unabhängig vom Schalter, die AB-Nummer ist optional (V5), kannBestaetigen ist in V3 definiert. Offen: Namen der Router-Exporte (die Pläne nehmen export default an), genaue Fehlermeldungen und einzelne Statuscodes (etwa bei einem fremden oder unbekannten Projekt in POST …/projekt). Plan: der Durchlauf-Test pinnt nur, was V1 bis V8 und die Spezifikation festlegen; alles andere trägt den Kommentar // Annahme (nicht im Vertrag): …. Vorschlag: V5 um diese Zeilen ergänzen.
  8. Aufbewahrungsfristen ohne Löschlauf. Offen: die Spezifikation nennt 6 und 8 Jahre, die Datenschutzerklärung (L7) sagt „Danach löschen wir sie“; kein Plan enthält einen Löschlauf (die erste Frist läuft frühestens Ende 2032 ab). Plan: nur melden, nichts bauen. Vorschlag: nach dem Livegang eine Aufgabe „Löschlauf Aufträge“ im Finanzen-Strang anlegen; bis dahin den Satz im Text lassen.
  9. Karten in WOMS → Rechtliches. Entschieden (siehe Übersicht, Nachträge, L7): Karten laut Freigabe-Dokument, eine Karte ohne Datei entfällt. scripts/setup-legal.mjs gehört keinem Strang; Plan: Z2.4 legt die eine Karte widerrufsbelehrung-projekt an, die L7 im Freigabe-Dokument nennt (AGB und Datenschutz haben Karten). Hinweise: die liveUrl trägt die Fassung im Namen (L7 liefert keine Alias-Seite), setup-legal.mjs ändert vorhandene Karten nie (bei einer neuen Fassung wird die Karte von Hand angepasst), und die sort-Nummer ist die Position in der Liste (hängt feature/oeffnungszeiten seine Karte ebenfalls hinten an, teilen sich zwei Karten eine Nummer, harmlos).
  10. Die Kopie ans Team und der Bestätigungslink. Entschieden (siehe Übersicht, Nachträge, V6 und L5): K31 geht ohne BCC an den Kunden; das Team bekommt eine eigene Kopie (Block teamkopie, an CHAT_NOTIFY_EMAILS, auch bei Erinnerungen) ohne Link und ohne Token. Plan: der Durchlauf-Test prüft es bei jedem Senden (sende()) und am Ende für alle Team-Mails (D16), Prüfer 2 prüft den Code.
  11. Token in der Adresse. Entschieden (siehe Übersicht, Nachträge, V6 und L6): K31 verlinkt auftrag.html#t=<Token> (Fragment, landet in keinem Server- oder Proxy-Protokoll), die Seite liest #t= mit Rückfall ?t=. Der Token im Pfad der API-Aufrufe (V5) steht weiter in Proxy-Protokollen: hingenommen. Plan: der Durchlauf-Test liest den Token aus auftrag.html#t=.
  12. „Noch offen“ bei Aufwand. Offen: Spezifikation Abschnitt 4: „Wert der noch nicht abgerechneten Zeit“; V4: offenCents ist bei Aufwand null, der Wert steht in zeit.offenerWertCents. Plan: Prüfer 5 prüft die Anzeige. Vorschlag: Formulierung der Spezifikation an V4 anpassen.
  13. Trenner im Zeilentext der Aufwandsrechnung. Entschieden (siehe Übersicht, Nachträge, L2): die Texte eines Tages werden mit „; “ verbunden; das Beispiel der Spezifikation („Startseite, Kontaktformular“) ist ein einzelnes Arbeitsblatt mit Komma im Text. Plan: Test D3 folgt V4.
  14. Fallback „Steuernummer im Rechnungsfuß“. Entschieden (siehe Übersicht, Nachträge, Koordination): nur auf Weisung des Nutzers, nach der Prüfung des Stripe-PDFs (Übergabe A6). Plan: Z4.3, bedingt.
  15. Sperrschlüssel. Entschieden (siehe Übersicht, Nachträge, V2): je Sperre genau ein zusammengesetzter Schlüssel (sperreRechnung(kundenId), sperreAuftrag(id), SPERRE_NUMMER), Reihenfolge erst sperreRechnung, darin sperreAuftrag, innerhalb von sperreAuftrag nie sperreRechnung, Mails nach der Freigabe der Sperre. Plan: Prüfer 1 prüft nur die Einhaltung (Punkt 1.4).
  16. Datenschutz-Commit von L7, AGB-Datei, Merge-Reihenfolge. Entschieden (siehe Übersicht, Nachträge, L7 und „Integration“): Reihenfolge L1, L2, L3, L4, L7 ohne seinen letzten Commit (auftraege/l7-rechtstexte~1), L5, L6; der Datenschutz-Commit kommt erst beim AB-Livegang nach der Freigabe (zweiter Push, Übergabe D3); L7 steht vor L5, damit K31 die AGB-Datei findet. Offen bleibt nur ein Merge-Hinweis: die Stand-Zeile der Datenschutzerklärung ändern L7 und feature/oeffnungszeiten beide (L7 Vertragsfrage 9): beim Zusammenführen den späteren Tag nehmen.
  17. Zusagen von L7 an L4 und L5. Entschieden (siehe Übersicht, Nachträge, V4 und V6): L4 reicht privat an AB_TEXTE.termine durch, K32 trägt den Platzhalter fruehbeginn_text und gibt FRUEHBEGINN_TEXT wörtlich wieder. Plan: Z baut dafür nichts; Z1.4 Schritt 6 prüft es nur, D8 zeigt die Wirkung.
  18. Co-Authored-By. Offen: die Übersicht legt Claude Opus 5.5 fest, ausgeführt wird mit sonnet. Plan: Z übernimmt die Zeile der Übersicht, damit die Historie einheitlich bleibt. Vorschlag: Übersicht anpassen oder lassen.
  19. Ablage der Prüfbilder und Harnische. Offen: L6 legt seine Bilder unter $R/bilder/l6 ab, die Harnische von W1 und W2 liegen im Scratchpad (flüchtig). Plan: Z2.3 erzeugt die WOMS-Bilder neu unter $R/bilder/z2. Vorschlag: alle Stränge schreiben nach $R.
  20. Gegenprüfung je Befund. Offen: die Übersicht sagt „Jeder Befund wird von einem zweiten Agenten (haiku) gegengeprüft.“ Plan: Critical und Important einzeln, Minor gebündelt je Prüfer (Kosten; Memory „feedback-billige-modelle“). Vorschlag: so lassen, oder auf Wunsch jeden Minor einzeln.
  21. Einarbeiten von origin/main und origin/test. Offen: W0 (Vertragsfrage 9) spricht von Rebase. Z1.6 und Z2.5 nehmen einen Merge, weil beide Zweige die Merge-Commits der Stränge tragen und die SHAs in allen Berichten gültig bleiben sollen. Vorschlag: so lassen.
  22. Form der Antwort für die Abzeichen. Entschieden (siehe Übersicht, V5): { abgerechnet: { <id>: '<Rechnungsnummer>' | 'Entwurf' } }; Spezifikation Abschnitt 6 nennt { worksheetId: Rechnungsnummer }. Plan: Test D0 prüft V5. Vorschlag: Spezifikation angleichen.