Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-06-auftraege-04-ab-kern.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

188 KiB
Raw Blame History

AB-Kern – Umsetzungsplan (Strang L3)

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: Eine Auftragsbestätigung (AB) lässt sich im Portal-Server als Entwurf anlegen, prüfen und ändern, mit lückenloser Nummer AB-JJJJ-NNNN senden (PDF abgelegt, Bestätigungs-Token, Mail), erinnern, als neue Fassung ersetzen, stornieren und nachträglich einem Projekt zuordnen – über die Admin-Routen unter /api/admin/finanzen/auftraege.

Architecture: nummern.js vergibt die Nummer (größte Laufnummer des Berliner Jahres plus 1; der Aufrufer hält SPERRE_NUMMER). auftraege.js enthält die reine Eingabeprüfung (pruefeEingabe) und die Abläufe (Entwurf, Vorschau, Senden, Erinnern, Neue Fassung, Storno, Projekt zuordnen); es benutzt nur die L0-Speicher (auftragStore, projektRechnungenStore, ablage, auftragSicht) und reicht PDF, Token und Mail über deps hinein, die beim Aufruf per dynamischem Import aus L4/L5 geladen werden (Tests übergeben Attrappen). routes/admin/auftraege.js (Gerüst aus L0) ist nur dünne HTTP-Schicht: Kunde laden, Schalter prüfen, Dienst aufrufen, Fehler über sendeFehler.

Tech Stack: Node 20/24 (ES-Module), Express 4, Appwrite-REST über server/services/appwriteAdmin.js und die L0-Speicher, withLocks (server/services/keyedLock.js), Tests mit node --test und den Nachbauten aus test/support/ (startAppwriteMock, useTestEnv, startTestApp).

Spec: docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md – Abschnitt 1 (Dialog-Felder), 2 (Nummer, Status, Unveränderlichkeit, Versand, Erinnerung), 6 (Schnittstellen), 9 (Fehlerverhalten), 10 (Tests).

Übersicht/Verträge: docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (V1–V5 verbindlich: Namen, Signaturen, Datenformen, Endpunkte; maßgeblich ist der Stand mit dem Abschnitt „Nachträge nach der Planrunde“: Sperren als zusammengesetzte Einzel-Schlüssel in V2, ablage.entfernePdf, ordneProjektZu samt Route POST …/:id/projekt in V5). Spec, Übersicht und diese Datei liegen im Basis-Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/…; der Lane-Worktree enthält sie nur, wenn sie dort committed sind – von dort lesen.

Worktree/Branch: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3, Branch auftraege/l3-ab-kern (aus feature/auftraege nach L0). Alle Befehle mit absolutem Pfad.


Entscheidungen dieses Strangs

  1. pruefeEingabe ist synchron und rein (V4 lässt es offen): Form, Grenzen, Vorgaben. Alles, was Appwrite braucht (Kunde laden, Projekt und Tickets dem Kunden zuordnen, Titel aus dem Projektnamen, customers.kundenart schreiben), macht legeEntwurfAn/aendereEntwurf. Nur Felder der Whitelist werden übernommen; status, nummer, tokenHash & Co. aus einer Eingabe landen nie im Dokument. teilweise: true prüft nur die übergebenen Felder; ein PATCH führt die bereinigten Felder mit dem gespeicherten Entwurf zusammen und prüft das Ganze noch einmal im Voll-Modus (so fängt er auch Querbezüge wie „Anzahlung bei Aufwand“).
  2. Vorgaben des Servers (die Dialoge zeigen dieselben Werte): Stundensatz 100 € (STUNDENSATZ_STANDARD_CENTS), Anzahlung 50 %, Zahlungsziel 14 Tage, Zeitquelle = Ticket des Projekts (nur wenn keine angegeben ist), Titel = Projektname (gekürzt auf 200 Zeichen). Schätzung 0, '', null bedeuten „keine Schätzung“.
  3. Sperren (V2: zusammengesetzte Einzel-Schlüssel, withLocks sperrt jedes Listenelement für sich): Jede Änderung an einem bestehenden AB-Dokument läuft unter sperreAuftrag(id) (['auftraege-ab:<id>'] aus konstanten.js, dieselbe Sperre, unter der L5 bestätigt und widerruft und L2 den Status ändert): aendereEntwurf, loescheEntwurf, neueFassung, storniereAuftrag, der Token-Tausch in erinnereAuftrag, ordneProjektZu, im Senden das Ersetzen der alten AB (verschachtelt, Schlüssel der alten AB) und sendeAuftrag für den eigenen Entwurf. sendeAuftrag nimmt zusätzlich SPERRE_NUMMER (nur dafür ist sie da: Nummernvergabe), beide Schlüssel in einem Aufruf: withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], …). So kann ein Entwurf nicht mitten im Senden geändert, gelöscht oder einem Projekt zugeordnet werden, zwei Klicks auf „Senden“ ergeben eine Nummer, und ein gleichzeitiges Bestätigen oder Widerrufen (L5) geht nicht verloren. L3 benutzt nie sperreRechnung (Regel der Übersicht: innerhalb von sperreAuftrag nie sperreRechnung). Die Prüfungen vor der Nummer (Status, Eingabe, Kunde) laufen dabei in der Sperre (V4 nennt sie davor): nur so sieht der zweite von zwei gleichzeitigen Aufrufen den Status des ersten; sie liegen weiterhin vor jeder Nummer und jedem PDF. Die Mail geht immer nach der Sperre (ein hängender SMTP-Server darf weder die Nummernvergabe noch ein Bestätigen blockieren).
  4. „Keine Nummer verbraucht“ gilt automatisch: die Nummer ist größte gespeicherte Laufnummer plus 1 und erst mit dem Speichern der AB vergeben; jede Prüfung und jeder Fehler davor lässt keine Spur. vergibNummer überspringt zusätzlich eine Nummer, die es schon gibt (findAuftragByNummer), damit eine abgeschnittene Liste nie zu Doppelnummern führt (V1 legt keinen eindeutigen Index an: Entwürfe teilen sich nummer = '', siehe Vertragsfragen 4).
  5. Ersetzen der alten AB zuerst, dann Speichern der neuen (V4 lässt die Reihenfolge offen): Alte AB ersetzt + ersetztDurchId und umhaengen(alt, neu) laufen vor dem Speichern der neuen AB (unter sperreAuftrag(alt.id), verschachtelt in den Schlüsseln des Entwurfs) und sind wiederholbar. Scheitert das Speichern, bleibt der Entwurf ein Entwurf, und ein zweiter Versuch führt alles zu Ende. Umgekehrt (neue AB zuerst) bliebe nach einem Fehler die alte AB gültig, und die Anzahlung der alten AB würde in der Schlussrechnung der neuen fehlen – das wäre doppeltes Berechnen.
  6. PDF-Datei und Fehler: ablage.speicherePdf überschreibt nie. Scheitert nach dem Ablegen noch etwas, entfernt sendeAuftrag die eben geschriebene Datei mit ablage.entfernePdf (Übersicht, Nachträge: L0 liefert sie, L3 nutzt genau diesen Namen) – aber nur, wenn ein erneutes Lesen bestätigt, dass die AB noch ein Entwurf ist (ein unklarer Appwrite-Fehler darf nie die Datei einer tatsächlich gesendeten AB löschen). Eine Datei, die vor dem Senden schon dalag (Absturzrest), wird nie angefasst; dann scheitert das Senden mit der PDF-Meldung.
  7. Mail-Fehler: Die AB bleibt versendet, mailStatus: 'fehler', mailFehler = fester Text (E-Mail konnte nicht gesendet werden. plus Fehlercode in Klammern, wenn der Fehler einen kurzen code trägt, z. B. (EAUTH)); Meldungen von SMTP und Bibliotheken gelangen nie ins Dokument. Wiederholung über erinnereAuftrag („Erneut senden“, V5 hat nur diesen Endpunkt): nach einem Mail-Fehler ohne Erinnerungs-Hinweis (erinnerung: false), sonst mit. Der neue Token wird vor dem Versand gespeichert (der alte gilt ab dann nicht mehr), die Empfängeradresse gesendetAn wird auf die aktuelle Kunden-E-Mail gesetzt (die häufigste Ursache eines Mail-Fehlers ist eine falsche Adresse, die der Admin gerade korrigiert hat). Die Team-Kopie der K31 (ohne Link, Block teamkopie) baut L5 selbst in sendeAuftragMail; L3 ruft immer nur sendeAuftragMail(gespeichert, { pdf, token, erinnerung }) mit dem gespeicherten Dokument auf.
  8. Token bleiben auffindbar: storniereAuftrag und das Ersetzen lassen tokenHash unverändert, damit L5 für den alten Link „wurde storniert/ersetzt“ (410) statt „ungültig“ (404) antworten kann.
  9. Prüfsumme beim Ausliefern: ladeGespeichertesPdf liest die Datei über ablage.lesePdf und vergleicht SHA-256 mit pdfSha256 (Spec: „unveränderlich mit Prüfsumme“); bei Abweichung 500 – es wird nie eine veränderte Datei verschickt oder angezeigt.
  10. von (E-Mail des Admins) wird bei legeEntwurfAn und neueFassung als erstelltVon gespeichert; bei sendeAuftrag angenommen, aber nicht gespeichert (V1 hat kein Feld gesendetVon, die Team-BCC der Mail belegt den Versand).
  11. Zusätze ohne Vertragsänderung: optionaler Parameter jetzt bei sendeAuftrag/erinnereAuftrag (Zeit für Tests, Vorgabe new Date()); zusätzliche Exporte istDokumentId, ladeAuftrag, ladeKunde, ladeGespeichertesPdf, setzeDepsFuerTests aus auftraege.js.
  12. Fehler der PDF-Funktion: L4 wirft bei unvollständigen Entwürfen AuftragError(409, 'Entwurf unvollständig: …') (L4-Plan, Vertragsfrage 8). Einen AuftragError aus der PDF-Funktion reicht L3 unverändert durch (Status und Text, bei Vorschau und Senden), jeder andere Fehler und ein leeres Ergebnis werden zu 500 Das PDF konnte nicht erzeugt werden. Der Entwurf bleibt in beiden Fällen ein Entwurf ohne Nummer.
  13. Projekt nachträglich zuordnen (Nachtrag der Planrunde, Übersicht V5 „L3“): ordneProjektZu(id, projektId) setzt projektId und, wenn zeitquelle leer ist, zeitquelle auf das Ticket des Projekts (ticketsDesProjekts, ohne Leereinträge); sonst ändert es an der AB nichts. Titel, Nummer, Status, Kunden-Schnappschuss, PDF-Datei samt Prüfsumme, Token und Versandfelder bleiben, es wird kein PDF erzeugt und keine Mail gesendet (das abgelegte PDF ist die Vereinbarung und bleibt unverändert). Die Projektrechnungen der AB werden mitgenommen: jede Projektrechnung dieser AB (listProjektRechnungen({ auftragId: id, nurAktiv: false }), auch stornierte) mit leerer projektId bekommt die neue (updateProjektRechnung(invoiceId, { projektId })); eine mit schon gesetzter, auch anderer projektId bleibt unverändert, Stripe-Metadaten werden nicht angefasst. Das geschieht zuerst und die AB wird zuletzt gespeichert: ein Fehler dazwischen lässt die AB ohne Projekt, die Zuordnung bleibt wiederholbar (wie in Entscheidung 5). Erlaubt für Entwurf, versendet, bestaetigt und abgerechnet; 409 bei storniert, ersetzt, widerrufen und bei jeder AB, die schon ein Projekt hat (auch bei demselben Projekt: eine zweite Zuordnung ist ein Fehler, keine stille Wiederholung); das Projekt muss dem Kunden der AB gehören, sonst 400 (auch bei einem unbekannten Projekt). Reihenfolge der Prüfungen: Form der Kennung (400) → AB laden (404) → Status (409) → schon ein Projekt (409) → Projekt und Kunde (400); der Stand der AB gilt vor dem Projekt, damit eine zweite Zuordnung immer 409 ergibt. Alles nach der Prüfung der Kennung läuft unter sperreAuftrag(id), nie unter sperreRechnung; die Route prüft zusätzlich den Schalter wie die anderen Routen. Damit darf W2 „Projekt zuordnen“ unabhängig von gebuchten Rechnungen anbieten (Vertragsfrage 16).

Global Constraints

Alle Punkte der Übersicht gelten. Zusätzlich für L3:

  • Eigene Dateien: genau server/services/auftraege/nummern.js, server/services/auftraege/auftraege.js, server/routes/admin/auftraege.js (Gerüst aus L0 füllen) sowie test/auftraegeAbVertrag.test.js, test/auftraegeNummern.test.js, test/auftraegeAbEingabe.test.js, test/auftraegeEntwurf.test.js, test/auftraegeAbVorschau.test.js, test/auftraegeSenden.test.js, test/auftraegeAbFolge.test.js, test/auftraegeRoutesAb.test.js und die Testhilfe test/helpers/auftraegeAb.js. Keine Änderung an server/config.js, server/index.js, konstanten.js, test/support/* und keiner anderen L0-Datei.
  • L0-Abweichungen: Verhält sich ein L0-Baustein anders, als V1–V4 sagen, wird er nicht in L0-Dateien repariert; die Abweichung kommt in den Abschlussbericht. Test nur anpassen, wenn die Übersicht eindeutig etwas anderes festlegt.
  • Speicherzugriff: auftraege und projektRechnungen nur über auftragStore.js und projektRechnungenStore.js; Kunden, Projekte und Tickets (customers, websiteProjects, workorders) über appwriteAdmin.js. PDFs nur über ablage.js (speicherePdf, lesePdf, entfernePdf), kein eigener Dateizugriff; Funktionen von ablage.js immer mit await aufrufen (synchron oder asynchron). Keine Stripe-Aufrufe in L3.
  • Kennungen prüfen: Jede Kennung aus einer Eingabe (Kunde, Auftrag, Projekt, Ticket) erfüllt istDokumentId (/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/), bevor sie in einen Appwrite-Pfad oder eine Abfrage geht.
  • Fehler: AuftragError(status, Meldung) aus fehler.js, Meldungen wörtlich aus der Tabelle unten; Routen antworten nur über sendeFehler. Meldungen von Appwrite, SMTP und der PDF-Erzeugung gelangen nie an die Seite und nie in mailFehler; ins Protokoll nur über logFehler (Art, Status, Code – nie die Meldung).
  • Sperren (V2): Schlüssel nur aus konstanten.js, nie selbst gebaut: sperreAuftrag(id) (['auftraege-ab:<id>']) für jede Änderung an einem bestehenden AB-Dokument (dieselbe Sperre wie bei L2 und L5) und SPERRE_NUMMER (['auftraege-nummer']) nur im Senden, für die Nummernvergabe. Nie sperreRechnung (Regel der Übersicht: innerhalb von sperreAuftrag nie sperreRechnung). Aufruf als withLocks(sperreAuftrag(id), …), im Senden als ein Aufruf mit beiden Schlüsseln withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], …). withLocks ist nicht wiederholbar: eine gesperrte Funktion ruft nie eine andere auf, die denselben Schlüssel nimmt (Selbst-Deadlock); die einzige Verschachtelung ist im Senden einer neuen Fassung der Schlüssel der alten AB (sperreAuftrag(ersetztId), nur bei ersetztId !== id). Mails (K31) gehen immer erst nach der Sperre.
  • Texte wörtlich: normale Leerzeichen, Anführungszeichen „…“ wie in der Tabelle, Beträge als Literale (nicht über euroDe, das ein geschütztes Leerzeichen setzt).
  • Testbefehl (Ressourcen laut Übersicht): cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/<datei>; vorher free -m („available“ ≥ 1500 MB). In Shell-Befehlen command grep -a statt grep. Tests nehmen die Zeit über den Parameter jetzt, nie über die Systemuhr; erwartete Fehlerprotokolle werden mit mock.method(console, 'error', () => {}) stumm geschaltet.

Meldungstexte (wörtlich)

Eingabeprüfung (pruefeEingabe, immer AuftragError(400, …); N = Nummer der Position ab 1):

  • Ungültige Eingabe.
  • Art: Bitte Festpreis oder Nach Aufwand wählen.
  • Titel: Höchstens 200 Zeichen.
  • Projekt: Ungültige Projekt-Kennung.
  • Kundenart: Bitte Unternehmen oder Privatperson wählen.
  • Eine Auftragsbestätigung braucht 1 bis 20 Positionen.
  • Position N: Die Beschreibung braucht 1 bis 200 Zeichen.
  • Position N: Die Menge muss eine ganze Zahl von 1 bis 999 sein.
  • Position N: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.
  • Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).
  • Stundensatz: Der Stundensatz muss zwischen 0,01 € und 100.000,00 € liegen.
  • Schätzung: Die Schätzung muss ein Vielfaches von 15 Minuten sein (15 bis 99.960).
  • Zahlungsplan: Bitte eine Zahlungsart wählen.
  • Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.
  • Zahlungsplan: Der freie Text braucht 1 bis 500 Zeichen.
  • Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.
  • Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.
  • Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.
  • Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).
  • Weg: Bitte den Weg der Absprache wählen.
  • Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.
  • Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.
  • Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.
  • Zeitquelle: Höchstens 10 Tickets.
  • Zeitquelle: Ungültige Ticket-Kennung.

Dienste (Status in Klammern):

  • (404) Auftragsbestätigung nicht gefunden. · (404) Kunde nicht gefunden.
  • (400) Kunde: Bitte einen Kunden angeben. · (400) Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.
  • (400) Projekt: Das Projekt gehört nicht zu diesem Kunden. · (400) Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden. · (400) Titel: Bitte einen Titel angeben oder ein Projekt wählen.
  • (409) Nur ein Entwurf lässt sich ändern. · Nur ein Entwurf lässt sich löschen. · Nur ein Entwurf lässt sich senden. · Eine Vorschau gibt es nur für Entwürfe.
  • Senden, in dieser Prüfreihenfolge: (400) Auftragsbestätigungen nur für feste Kunden. → (400) Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson). → (400) Für den Versand fehlt die E-Mail-Adresse des Kunden. → (409) Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.
  • (409) Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.
  • (500) Das PDF konnte nicht erzeugt werden. · (500) Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein. · (404) PDF nicht gefunden.; ein AuftragError der PDF-Funktion (L4, z. B. (409) Entwurf unvollständig: …) wird unverändert durchgereicht.
  • (409) Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen. · Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge. · Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung. · Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.
  • Projekt zuordnen (ordneProjektZu): (400) Projekt: Bitte ein Projekt angeben. · (400) Projekt: Ungültige Projekt-Kennung. · (400) Projekt: Das Projekt gehört nicht zu diesem Kunden. (wie beim Anlegen) · (409) Diese Auftragsbestätigung gehört schon zu einem Projekt. · (409) Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.
  • Route: (400) Kunde fehlt. (GET-Liste ohne ?kunde=); Schalter aus: (404) SCHALTER_AUS_MELDUNG aus konstanten.js.
  • mailFehler im Dokument: E-Mail konnte nicht gesendet werden. oder E-Mail konnte nicht gesendet werden (CODE).

Review Focus

  1. Unvollständige Kundendaten beim Senden (Interessent, keine Kundenart, keine E-Mail, Straße, PLZ oder Ort fehlt): klare Meldung vor Nummer und PDF, keine Nummer verbraucht, nichts abgelegt; die Prüfreihenfolge steht fest. Test: Task 6 »Senden: unvollständige Kundendaten …«.
  2. Doppelklick und zwei Admins: dieselbe AB zweimal gleichzeitig senden ergibt eine Nummer, eine Mail, der zweite Aufruf 409; zwei Entwürfe gleichzeitig bekommen verschiedene Nummern; Ändern und Löschen während des Sendens warten und scheitern danach mit 409; während die Mail unterwegs ist, ist die Sperre schon frei; Storno, Erinnerung, Projekt zuordnen und das Ersetzen der alten AB warten auf die Sperre sperreAuftrag(id) (auftraege-ab:<id>, V2), unter der L5 bestätigt, und prüfen danach den neuen Stand (ein gleichzeitiges Bestätigen oder Widerrufen geht nicht verloren); zwei gleichzeitige Zuordnungen ergeben genau eine; eine Fassung, deren ersetztId auf sie selbst zeigt (beschädigte Daten), scheitert mit 409, statt an der eigenen Sperre zu hängen. Tests: Task 7 »Senden: dieselbe AB zweimal gleichzeitig …«, »… zwei Entwürfe gleichzeitig …«, »Senden hält die Sperre …«, »… während die Mail unterwegs ist …«; Task 8 »erinnereAuftrag wartet auf die Sperre …«; Task 9 »Senden der neuen Fassung wartet …«, »Senden: zeigt die ersetztId … auf ihn selbst …«; Task 10 »storniereAuftrag wartet …«; Task 11 »ordneProjektZu wartet …«, »… zwei Aufrufe gleichzeitig …«.
  3. Fehler mitten im Senden: PDF leer oder wirft, Zieldatei existiert schon, Speichern der AB scheitert, Mail scheitert: der Entwurf bleibt ein Entwurf ohne Nummer und ohne Dateirest (ein zweiter Versuch gelingt mit AB-…-0001); bei Mail-Fehler bleibt die AB versendet, mailStatus: 'fehler', der Text verrät nichts Internes. Auch das Ersetzen der alten AB und das Zuordnen eines Projekts (Projektrechnungen zuerst, AB zuletzt) sind wiederholbar. Tests: Task 7 »Senden: scheitert die PDF-Erzeugung …«, »… eine schon vorhandene Zieldatei …«, »… das Speichern …«, »… nur die Mail …«; Task 9 »… scheitert das Speichern erst nach dem Ersetzen …«; Task 11 »ordneProjektZu: scheitert das Umhängen …«.
  4. Fremde und geschützte Eingaben: Projekt oder Ticket eines anderen Kunden (auch beim nachträglichen Zuordnen eines Projekts), kundenId-Wechsel im PATCH, status/nummer/tokenHash im Body, Grenzwerte (Summe genau 999.999,99 € ja, ein Cent mehr nein; 21 Positionen; Schätzung 10 Minuten; 30.02.), Kennungen mit ../; das nachträgliche Zuordnen ändert nur projektId und eine leere Zeitquelle der AB sowie die leere projektId ihrer Projektrechnungen (Nummer, PDF, Prüfsumme, Token und Stripe-Daten bleiben) und gelingt nur einmal (409 bei vorhandenem Projekt und bei stornierten, ersetzten oder widerrufenen ABs). Tests: Task 3 »Ablehnungen …«, »Unbekannte und geschützte Felder …«; Task 4 »fremde Bezüge …«, »status, nummer, Token …«, »aendereEntwurf: Kunde lässt sich nicht wechseln …«; Task 11 »ordneProjektZu: eine versendete AB …«, »… hängt auch die Projektrechnungen … um …«, »… Projekt eines anderen Kunden …«, »… eine AB mit Projekt …«.
  5. Jahreswechsel und Zählung: 31.12. 23:00 UTC ist in Berlin schon der 01.01. → AB-2027-0001; Lücken, Entwürfe, andere Jahre und mehr als 25 ABs (Seitengröße von Appwrite) stören die Zählung nicht. Tests: Task 2 (alle), Task 6 »Senden: Nummern zählen fortlaufend …«.

Tasks

Task 1: Worktree einrichten, L0-Vertrag prüfen, Testhilfe anlegen

Files:

  • Create: test/helpers/auftraegeAb.js
  • Test: test/auftraegeAbVertrag.test.js

Interfaces:

  • Consumes: Stand von feature/auftraege nach L0 – Module laut V1–V4 (schema, konstanten, fehler, schalter, auftragStore, projektRechnungenStore, auftragSicht, ablage, texte), Gerüst server/routes/admin/auftraege.js, config.auftraege, settings.js mit steuernummer.
  • Produces (für alle folgenden Tasks): test/helpers/auftraegeAb.js mit AUFTRAEGE_SCHEMA, AUFTRAEGE_DATETIMES, Konstanten (KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, LEAD_KUNDE, FREMD, VON), MELDUNG, Fixtures (kundeDoc, projektDoc, ticketDoc, festpreisEingabe, aufwandEingabe, entwurfsDaten), baueDeps, Hilfen (sha256, warte, tor, wartebis, ergebnis, abgelehnt, wirftFehler, dateienIn, leereVerzeichnis, legeUploadVerzeichnisAn, raeumeUploadVerzeichnisAuf) und starteUmgebung() → { aw, config, uploadDir, zuruecksetzen(), schliesse() }.

Dieser Task prüft nur L0 und legt die Testhilfe an: es gibt keinen roten Lauf. Ein Fehlschlag des Vertragstests ist ein Befund über L0 (Abschlussbericht), kein Anlass, L0-Dateien zu ändern.

  • Schritt 1: Speicher prüfen
free -m

Erwartet: Spalte „available“ mindestens 1500 MB, sonst warten.

  • Schritt 2: Worktree anlegen
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l3 -b auftraege/l3-ab-kern feature/auftraege
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
ln -s ../dev-auftraege/node_modules node_modules
git branch --show-current
ls node_modules/express/package.json
git log --oneline -5

Erwartet: auftraege/l3-ab-kern, der Pfad zu express/package.json und oben in der Liste die L0-Commits (nicht nur e00b4ae). Fehlt node_modules im Basis-Worktree oder sind keine L0-Commits da, ist L0 nicht fertig: anhalten und melden.

  • Schritt 3: L0-Dateien prüfen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
for f in schema konstanten schalter auftragStore projektRechnungenStore auftragSicht arbeitsblaetter ablage fehler texte; do
  test -f server/services/auftraege/$f.js && echo "ok    $f" || echo "FEHLT $f"
done
test -f server/routes/admin/auftraege.js && echo "ok    Gerüst auftraege" || echo "FEHLT Gerüst auftraege"
command grep -a -c "requireAppwriteStaff" server/routes/admin/auftraege.js
command grep -a -n "auftraege" server/config.js | head -5

Erwartet: nur ok-Zeilen, mindestens 1 Treffer für requireAppwriteStaff im Gerüst und Treffer für auftraege in config.js.

  • Schritt 4: Testhilfe test/helpers/auftraegeAb.js anlegen (vollständig):
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { mock } from 'node:test'
import { startAppwriteMock } from '../support/appwriteMock.js'
import { useTestEnv } from '../support/testEnv.js'
import { AUFTRAEGE_COLLECTIONS } from '../../server/services/auftraege/schema.js'

// Testhilfen des Strangs L3 (AB-Kern). Kein statischer Import von server/config.js: starteUmgebung() setzt zuerst die
// Umgebung, erst danach darf config.js geladen werden.

const NAMEN = Object.keys(AUFTRAEGE_COLLECTIONS)
const attribute = (name) => AUFTRAEGE_COLLECTIONS[name].attributes
/** Attribut-Schlüssel je AB-Collection für startAppwriteMock({ schema }). */
export const AUFTRAEGE_SCHEMA = Object.fromEntries(NAMEN.map((name) => [name, attribute(name).map((a) => a.key)]))
/** Datumsfelder je AB-Collection für startAppwriteMock({ datetimes }): der Nachbau schreibt sie wie Appwrite ("+00:00"). */
export const AUFTRAEGE_DATETIMES = Object.fromEntries(
  NAMEN.map((name) => [name, attribute(name).filter((a) => a.type === 'datetime').map((a) => a.key)]),
)

export const KUNDE_ID = 'c_kunde01'
export const PROJEKT_ID = 'p_projekt01'
export const TICKET_ID = 't_ticket01'
export const ZWEITES_TICKET = 't_zwei02'
export const LEAD_KUNDE = 'c_lead01'
export const FREMD = { kunde: 'c_fremd01', projekt: 'p_fremd01', ticket: 't_fremd01' }
export const VON = 'kenso@webklar.com'

/** Feste Meldungen (wörtlich wie in der Tabelle „Meldungstexte“ des Plans). */
export const MELDUNG = {
  auftragFehlt: 'Auftragsbestätigung nicht gefunden.',
  kundeFehlt: 'Kunde nicht gefunden.',
  positionen: 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.',
  nurEntwurfAendern: 'Nur ein Entwurf lässt sich ändern.',
  nurEntwurfLoeschen: 'Nur ein Entwurf lässt sich löschen.',
  nurEntwurfSenden: 'Nur ein Entwurf lässt sich senden.',
  keinFesterKunde: 'Auftragsbestätigungen nur für feste Kunden.',
  keineKundenart: 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).',
  keineMail: 'Für den Versand fehlt die E-Mail-Adresse des Kunden.',
  adresse: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.',
  pdf: 'Das PDF konnte nicht erzeugt werden.',
  pruefsumme: 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.',
  ersetzteUngueltig: 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.',
  keineErinnerung: 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.',
  keineNeueFassung: 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.',
  schonEntwurf: 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.',
  keinStorno: 'Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.',
  projektFehlt: 'Projekt: Bitte ein Projekt angeben.',
  projektUngueltig: 'Projekt: Ungültige Projekt-Kennung.',
  projektFremd: 'Projekt: Das Projekt gehört nicht zu diesem Kunden.',
  schonProjekt: 'Diese Auftragsbestätigung gehört schon zu einem Projekt.',
  keinProjekt: 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.',
}

/** Kundendokument wie in Appwrite: Unternehmen mit vollständiger Anschrift und E-Mail. */
export const kundeDoc = (patch = {}) => ({
  $id: KUNDE_ID,
  name: 'Anna Beispiel',
  companyName: 'Beispiel GmbH',
  email: 'anna@example.invalid',
  street: 'Hauptstraße 1',
  postalCode: '67722',
  city: 'Winnweiler',
  country: 'DE',
  code: 'K-1001',
  customerStatus: 'active',
  kundenart: 'unternehmen',
  ...patch,
})
export const projektDoc = (patch = {}) => ({ $id: PROJEKT_ID, customerId: KUNDE_ID, projectName: 'Website Beispiel', ticketId: TICKET_ID, ...patch })
export const ticketDoc = (patch = {}) => ({ $id: TICKET_ID, customerId: KUNDE_ID, woid: '100200', topic: 'Website Beispiel', ...patch })

/** Gültige POST-Eingabe: Festpreis, Summe 800,00 € (600 + 2 × 100), Anzahlung 50 % = 400,00 €. */
export const festpreisEingabe = (patch = {}) => ({
  kundenId: KUNDE_ID,
  projektId: PROJEKT_ID,
  titel: '',
  art: 'festpreis',
  positionen: [
    { beschreibung: 'Webdesign Startseite', menge: 1, einzelpreisCents: 60000 },
    { beschreibung: 'Kontaktformular', menge: 2, einzelpreisCents: 10000 },
  ],
  zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
  zahlungszielTage: 14,
  auftragVom: '2026-10-05',
  auftragWeg: 'telefon',
  startAm: '',
  fertigBis: '',
  zeitquelle: [],
  ...patch,
})

/** Gültige POST-Eingabe: nach Aufwand zu 100 €/Std., Schätzung 10 Stunden. */
export const aufwandEingabe = (patch = {}) => ({
  kundenId: KUNDE_ID,
  projektId: PROJEKT_ID,
  titel: '',
  art: 'aufwand',
  stundensatzCents: 10000,
  schaetzungMinuten: 600,
  zahlungsplan: { art: 'monatlich', prozent: null, text: '' },
  zahlungszielTage: 14,
  auftragVom: '2026-10-05',
  auftragWeg: 'email',
  startAm: '',
  fertigBis: '',
  zeitquelle: [],
  ...patch,
})

/** Genau die Felder, die legeEntwurfAn an createAuftrag übergibt (bereinigt, ohne kundenart). */
export const entwurfsDaten = (patch = {}) => ({
  kundenId: KUNDE_ID,
  projektId: PROJEKT_ID,
  titel: 'Website Beispiel',
  zeitquelle: [TICKET_ID],
  art: 'festpreis',
  positionen: festpreisEingabe().positionen,
  stundensatzCents: null,
  schaetzungMinuten: null,
  summeCents: 80000,
  zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
  zahlungszielTage: 14,
  auftragVom: '2026-10-05',
  auftragWeg: 'telefon',
  startAm: '',
  fertigBis: '',
  erstelltVon: VON,
  ...patch,
})

export const sha256 = (daten) => createHash('sha256').update(daten).digest('hex')
export const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/** Offene Zusage, die der Test später einlöst: { promise, freigeben }. */
export function tor() {
  let freigeben
  const promise = new Promise((resolve) => {
    freigeben = resolve
  })
  return { promise, freigeben }
}
/** Wartet (höchstens eine Sekunde), bis bedingung() wahr ist. */
export async function wartebis(bedingung) {
  for (let i = 0; i < 200; i += 1) {
    if (bedingung()) return
    await warte(5)
  }
  throw new Error('Bedingung nicht eingetreten')
}
/** Fängt eine Zusage ab, damit sie nie unbehandelt scheitert: { wert } oder { fehler }. */
export const ergebnis = (promise) => promise.then((wert) => ({ wert }), (fehler) => ({ fehler }))

const pruefeFehler = (status, message, name) => (err) => {
  assert.equal(err.status, status, `${name} Status ${err.status}: ${err.message}`)
  if (message !== undefined) assert.equal(err.message, message, name)
  return true
}
/** Die Zusage scheitert mit diesem Status und (wenn angegeben) dieser Meldung. */
export const abgelehnt = (promise, status, message, name = '') => assert.rejects(promise, pruefeFehler(status, message, name))
/** Der Aufruf wirft sofort mit diesem Status und (wenn angegeben) dieser Meldung. */
export const wirftFehler = (aufruf, status, message, name = '') => assert.throws(aufruf, pruefeFehler(status, message, name))

let tokenZaehler = 0
/**
 * Attrappen für deps (kein pdfkit, kein SMTP); calls zeichnet jeden Aufruf auf. Optionen: pdf (feste Antwort, sonst
 * "%PDF-1.4 <nummer>"), pdfFehler, pdfWarten (Promise, auf die die PDF-Erzeugung wartet), mailFehler, mailWarten.
 */
export function baueDeps({ pdf = null, pdfFehler = null, pdfWarten = null, mailFehler = null, mailWarten = null } = {}) {
  const calls = { pdf: [], token: [], mail: [] }
  const deps = {
    async erzeugeAuftragPdf(auftrag, optionen) {
      calls.pdf.push({ auftrag: structuredClone(auftrag), optionen })
      if (pdfWarten) await pdfWarten
      if (pdfFehler) throw pdfFehler
      return pdf ?? Buffer.from(`%PDF-1.4 ${auftrag.nummer}`)
    },
    neuerToken(jetzt = new Date()) {
      tokenZaehler += 1
      const token = `t${tokenZaehler}`.padEnd(43, 'x')
      calls.token.push(token)
      return { token, hash: sha256(token), gueltigBis: new Date(jetzt.getTime() + 60 * 86_400_000) }
    },
    async sendeAuftragMail(auftrag, optionen) {
      calls.mail.push({ auftrag: structuredClone(auftrag), pdf: optionen.pdf, token: optionen.token, erinnerung: optionen.erinnerung ?? false })
      if (mailWarten) await mailWarten
      if (mailFehler) throw mailFehler
    },
  }
  return { deps, calls }
}

const PRAEFIX = 'auftraege-l3-'
function pruefeTestVerzeichnis(dir) {
  if (path.dirname(dir) !== os.tmpdir() || !path.basename(dir).startsWith(PRAEFIX)) throw new Error(`Kein Testverzeichnis des Strangs L3: ${dir}`)
}
export const legeUploadVerzeichnisAn = () => fs.mkdtempSync(path.join(os.tmpdir(), PRAEFIX))
export function raeumeUploadVerzeichnisAuf(dir) {
  pruefeTestVerzeichnis(dir)
  fs.rmSync(dir, { recursive: true, force: true })
}
export function leereVerzeichnis(dir) {
  pruefeTestVerzeichnis(dir)
  for (const name of fs.readdirSync(dir)) fs.rmSync(path.join(dir, name), { recursive: true, force: true })
}
/** Alle Dateien unterhalb von dir, relativ und sortiert (leere Verzeichnisse zählen nicht). */
export function dateienIn(dir) {
  const gefunden = []
  const durchsuche = (relativ) => {
    for (const eintrag of fs.readdirSync(path.join(dir, relativ), { withFileTypes: true })) {
      const pfad = path.join(relativ, eintrag.name)
      if (eintrag.isDirectory()) durchsuche(pfad)
      else gefunden.push(pfad)
    }
  }
  durchsuche('')
  return gefunden.sort()
}

/**
 * Startet den Appwrite-Nachbau (mit dem Schema der AB-Collections, wie Appwrite mit 25 Treffern ohne limit), setzt die
 * Testumgebung (Schalter an, Upload-Verzeichnis im Temp-Ordner) und lädt erst danach config.js. Die Module des Strangs
 * importiert der Test selbst, danach.
 */
export async function starteUmgebung() {
  const uploadDir = legeUploadVerzeichnisAn()
  process.env.AUFTRAEGE_UPLOAD_DIR = uploadDir
  process.env.AUFTRAEGE_ENABLED = 'on'
  const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
  useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
  const { config } = await import('../../server/config.js')
  const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
  const stamm = () => [
    [config.collections.customers, kundeDoc()],
    [config.collections.customers, kundeDoc({ $id: FREMD.kunde, name: 'Fritz Fremd', companyName: '', email: 'fritz@example.invalid', kundenart: '' })],
    [config.collections.customers, kundeDoc({ $id: LEAD_KUNDE, name: 'Lea Lead', customerStatus: 'lead', kundenart: '' })],
    [config.collections.websiteProjects, projektDoc()],
    [config.collections.websiteProjects, projektDoc({ $id: FREMD.projekt, customerId: FREMD.kunde, projectName: 'Fremde Website', ticketId: FREMD.ticket })],
    [config.collections.workorders, ticketDoc()],
    [config.collections.workorders, ticketDoc({ $id: ZWEITES_TICKET })],
    [config.collections.workorders, ticketDoc({ $id: FREMD.ticket, customerId: FREMD.kunde })],
  ]
  return {
    aw,
    config,
    uploadDir,
    /** Vor jedem Test: Spione zurück, Schalter an, AB-Collections und Upload-Verzeichnis leer, Stammdaten neu. */
    async zuruecksetzen() {
      mock.restoreAll()
      config.auftraege.mode = 'on'
      config.auftraege.pilotEmails = []
      for (const collection of [config.collections.auftraege, config.collections.projektRechnungen]) {
        for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
      }
      leereVerzeichnis(uploadDir)
      for (const [collection, doc] of stamm()) aw.seed(collection, doc)
    },
    async schliesse() {
      await aw.close()
      raeumeUploadVerzeichnisAuf(uploadDir)
    },
  }
}
  • Schritt 5: Vertragstest test/auftraegeAbVertrag.test.js schreiben (vollständig):
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { starteUmgebung, entwurfsDaten, sha256, KUNDE_ID, PROJEKT_ID, VON } from './helpers/auftraegeAb.js'

// Vertragsprüfung: Die L0-Module (Fundament) liefern, was der Strang L3 braucht (Übersicht V1 bis V4). Schlägt sie fehl,
// weicht L0 vom Vertrag ab: nichts in fremden Dateien reparieren, sondern stoppen und melden.
const umg = await starteUmgebung()
after(() => umg.schliesse())
const DIENSTE = '../server/services/auftraege'
const store = await import(`${DIENSTE}/auftragStore.js`)
const ablage = await import(`${DIENSTE}/ablage.js`)
const { getSettings } = await import('../server/services/finance/settings.js')

const EXPORTE = {
  'konstanten.js': ['AUFTRAG_ARTEN', 'AUFTRAG_WEGE', 'ZAHLUNGSPLAN_ARTEN', 'KUNDENARTEN', 'VEREINBARTE_STATUS', 'STUNDENSATZ_STANDARD_CENTS', 'VIERTELSTUNDE', 'TOKEN_GUELTIG_TAGE', 'AGB_FASSUNG', 'LIMITS', 'SPERRE_NUMMER', 'sperreAuftrag', 'SCHALTER_AUS_MELDUNG'],
  'fehler.js': ['AuftragError', 'sendeFehler'],
  'schalter.js': ['auftraegeAktiv'],
  'auftragStore.js': ['getAuftrag', 'listAuftraege', 'createAuftrag', 'updateAuftrag', 'deleteAuftrag', 'findAuftragByTokenHash', 'findAuftragByNummer'],
  'projektRechnungenStore.js': ['getProjektRechnung', 'listProjektRechnungen', 'createProjektRechnung', 'updateProjektRechnung', 'umhaengen'],
  'auftragSicht.js': ['kundeSnapshot', 'auftragFuerTeam'],
  'ablage.js': ['speicherePdf', 'lesePdf', 'entfernePdf'], // entfernePdf: Nachtrag der Planrunde (Übersicht, Nachträge L0)
  'texte.js': ['TEXTE_FASSUNG'],
}

test('L0-Module, Konfiguration und Einstellungen liefern die Namen der Übersicht', async () => {
  for (const [datei, namen] of Object.entries(EXPORTE)) {
    const modul = await import(`${DIENSTE}/${datei}`)
    for (const name of namen) assert.ok(name in modul, `${datei} exportiert ${name} nicht`)
  }
  for (const feld of ['mode', 'pilotEmails', 'uploadDir']) assert.ok(feld in umg.config.auftraege, `config.auftraege.${feld} fehlt`)
  assert.equal(umg.config.auftraege.uploadDir, umg.uploadDir)
  assert.equal(umg.config.collections.auftraege, 'auftraege')
  assert.equal(umg.config.collections.projektRechnungen, 'projektRechnungen')
  assert.equal((await getSettings()).steuernummer, '')
  // Sperren (V2): je Sperre ein zusammengesetzter Schlüssel, weil withLocks jedes Listenelement für sich sperrt
  const { SPERRE_NUMMER, sperreAuftrag } = await import(`${DIENSTE}/konstanten.js`)
  assert.deepEqual(SPERRE_NUMMER, ['auftraege-nummer'])
  assert.deepEqual(sperreAuftrag('a_x1'), ['auftraege-ab:a_x1'])
})

test('createAuftrag legt einen Entwurf mit den Leerwerten aus V3 an, updateAuftrag ändert nur die genannten Felder', async () => {
  const a = await store.createAuftrag(entwurfsDaten())
  assert.deepEqual(
    [a.status, a.nummer, a.jahr, a.laufnummer, a.kunde, a.gesendetAm, a.tokenGueltigBis, a.bestaetigtAm, a.bestaetigtUeber, a.mailStatus, a.fruehbeginn, a.erstelltVon],
    ['entwurf', '', null, null, null, null, null, null, '', '', false, VON],
  )
  assert.equal(typeof a.id, 'string')
  assert.ok(!Number.isNaN(new Date(a.erstelltAm).getTime()))
  assert.deepEqual(a.positionen, entwurfsDaten().positionen)
  assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' })
  assert.deepEqual(await store.getAuftrag(a.id), a)
  const b = await store.updateAuftrag(a.id, { titel: 'Anderer Titel' })
  assert.equal(b.titel, 'Anderer Titel')
  assert.deepEqual([b.positionen, b.zeitquelle, b.zahlungsplan, b.summeCents], [a.positionen, a.zeitquelle, a.zahlungsplan, a.summeCents])
  assert.deepEqual(await store.getAuftrag(a.id), b)
  assert.equal(await store.getAuftrag('a_gibtsnicht'), null)
})

test('listAuftraege liefert alle Treffer, auch mehr als die 25 Dokumente, die Appwrite ohne limit liefert', async () => {
  for (let i = 0; i < 30; i += 1) await store.createAuftrag(entwurfsDaten({ kundenId: 'c_liste01' }))
  assert.equal((await store.listAuftraege({ kundenId: 'c_liste01' })).length, 30)
})

test('ablage: speicherePdf legt <kundenId>/<id>.pdf an, überschreibt nie; lesePdf liest und lehnt fremde Pfade ab; entfernePdf räumt auf', async () => {
  const pdf = Buffer.from('%PDF-1.4 vertrag')
  assert.deepEqual(await ablage.speicherePdf('c_ablage', 'a_ablage', pdf), { pdfDatei: 'c_ablage/a_ablage.pdf', pdfSha256: sha256(pdf) })
  assert.deepEqual(await ablage.lesePdf('c_ablage/a_ablage.pdf'), pdf)
  await assert.rejects(async () => ablage.speicherePdf('c_ablage', 'a_ablage', Buffer.from('%PDF-1.4 anders')))
  await assert.rejects(async () => ablage.lesePdf('../etc/passwd'), (err) => err.status === 404)
  await ablage.entfernePdf('c_ablage/a_ablage.pdf')
  await assert.rejects(async () => ablage.lesePdf('c_ablage/a_ablage.pdf'), (err) => err.status === 404)
  assert.equal((await ablage.speicherePdf('c_ablage', 'a_ablage', pdf)).pdfDatei, 'c_ablage/a_ablage.pdf')
})

test('umhaengen hängt alle Dokumente einer AB auf die neue um und zählt sie; ein zweiter Aufruf findet nichts mehr; list und update genügen für die Projektzuordnung', async () => {
  const { createProjektRechnung, getProjektRechnung, listProjektRechnungen, updateProjektRechnung, umhaengen } = await import(`${DIENSTE}/projektRechnungenStore.js`)
  const daten = (auftragId) => ({ kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: 'WEBKLAR-0001', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString() })
  await createProjektRechnung('in_Umhaengen1', daten('a_alt'))
  await createProjektRechnung('in_Umhaengen2', daten('a_anders'))
  assert.equal(await umhaengen('a_alt', 'a_neu'), 1)
  assert.equal((await getProjektRechnung('in_Umhaengen1')).auftragId, 'a_neu')
  assert.equal((await getProjektRechnung('in_Umhaengen2')).auftragId, 'a_anders')
  assert.equal(await umhaengen('a_alt', 'a_neu'), 0)
  // Projektzuordnung (Task 11): alle Dokumente einer AB, auch inaktive; die projektId lässt sich einzeln ändern, sonst bleibt alles
  await updateProjektRechnung('in_Umhaengen1', { aktiv: false })
  assert.deepEqual((await listProjektRechnungen({ auftragId: 'a_neu', nurAktiv: false })).map((r) => r.invoiceId), ['in_Umhaengen1'])
  assert.deepEqual(await listProjektRechnungen({ auftragId: 'a_neu' }), [])
  const geaendert = await updateProjektRechnung('in_Umhaengen1', { projektId: 'p_neu01' })
  assert.deepEqual([geaendert.projektId, geaendert.auftragId, geaendert.betragCents, geaendert.aktiv], ['p_neu01', 'a_neu', 40000, false])
})
  • Schritt 6: Vertragstest laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbVertrag.test.js Expected: 5 Tests grün. Rot heißt: L0 weicht ab (Name fehlt, sperreAuftrag liefert einen anderen Schlüssel, createAuftrag füllt Leerwerte nicht, updateAuftrag überschreibt nicht genannte Felder, listAuftraege ohne Limit, ablage überschreibt, umhaengen, listProjektRechnungen/updateProjektRechnung). Nicht reparieren, sondern anhalten und dem Orchestrator melden, welcher Satz des Tests fehlschlägt.

  • Schritt 7: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add test/helpers/auftraegeAb.js test/auftraegeAbVertrag.test.js
git commit -m "AB-Kern: Testhilfe und Vertragsprüfung der L0-Module" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: Nummernvergabe AB-JJJJ-NNNN

Files:

  • Create: server/services/auftraege/nummern.js
  • Test: test/auftraegeNummern.test.js

Interfaces:

  • Consumes: listAuftraege({ jahr }) → Promise<Auftrag[]>, findAuftragByNummer(nummer) → Promise<Auftrag|null> (L0 auftragStore.js); berlinDate(date) → 'YYYY-MM-DD' (server/services/finance/dates.js).

  • Produces: formatNummer(jahr: number, laufnummer: number) → string ('AB-2026-0001'); vergibNummer(jetzt?: Date) → Promise<{ nummer: string, jahr: number, laufnummer: number }> – ohne eigene Sperre und ohne zu schreiben (der Aufrufer hält SPERRE_NUMMER).

  • Schritt 1: Failing Test test/auftraegeNummern.test.js schreiben

import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { starteUmgebung, entwurfsDaten } from './helpers/auftraegeAb.js'

const umg = await starteUmgebung()
after(() => umg.schliesse())
beforeEach(umg.zuruecksetzen)
const { createAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { formatNummer, vergibNummer } = await import('../server/services/auftraege/nummern.js')

/** Eine versendete AB mit Nummer anlegen (über den Speicher von L0). */
async function legeNummerierteAn(jahr, laufnummer, patch = {}) {
  const entwurf = await createAuftrag(entwurfsDaten())
  return updateAuftrag(entwurf.id, { status: 'versendet', nummer: formatNummer(jahr, laufnummer), jahr, laufnummer, ...patch })
}

test('formatNummer: AB-JJJJ-NNNN mit vier Ziffern; ungültige Werte werfen RangeError', () => {
  assert.equal(formatNummer(2026, 1), 'AB-2026-0001')
  assert.equal(formatNummer(2026, 42), 'AB-2026-0042')
  assert.equal(formatNummer(2027, 9999), 'AB-2027-9999')
  for (const [jahr, lauf] of [[2026, 0], [2026, 10000], [2026, 1.5], [2026, '1'], [1999, 1], ['2026', 1], [2026, -3]]) {
    assert.throws(() => formatNummer(jahr, lauf), RangeError, `${jahr}/${lauf}`)
  }
})

test('vergibNummer: die erste Nummer des Jahres ist 0001', async () => {
  assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1 })
})

test('vergibNummer: größte Laufnummer des Jahres plus 1 – Lücken egal, andere Jahre und Entwürfe zählen nicht', async () => {
  for (const nr of [1, 2, 7]) await legeNummerierteAn(2026, nr)
  await legeNummerierteAn(2025, 40)
  await createAuftrag(entwurfsDaten()) // Entwurf ohne Nummer
  assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0008', jahr: 2026, laufnummer: 8 })
  assert.deepEqual(await vergibNummer(new Date('2025-03-01T10:00:00Z')), { nummer: 'AB-2025-0041', jahr: 2025, laufnummer: 41 })
})

test('vergibNummer: Jahreswechsel nach Berliner Zeit (31.12. 23:00 UTC ist in Berlin schon der 01.01.)', async () => {
  for (const nr of [1, 2, 3]) await legeNummerierteAn(2026, nr)
  assert.equal((await vergibNummer(new Date('2026-12-31T22:59:59Z'))).nummer, 'AB-2026-0004')
  assert.deepEqual(await vergibNummer(new Date('2026-12-31T23:00:00Z')), { nummer: 'AB-2027-0001', jahr: 2027, laufnummer: 1 })
  assert.equal((await vergibNummer(new Date('2027-01-01T08:00:00Z'))).nummer, 'AB-2027-0001')
})

test('vergibNummer: zählt auch bei mehr als 25 ABs im Jahr richtig (Seitengröße 25 wie bei Appwrite ohne limit)', async () => {
  for (let i = 1; i <= 30; i += 1) await legeNummerierteAn(2026, i)
  assert.equal((await vergibNummer(new Date('2026-10-06T10:00:00Z'))).nummer, 'AB-2026-0031')
})

test('vergibNummer: überspringt eine Nummer, die es schon gibt (Dokument mit nummer, aber ohne laufnummer)', async () => {
  const entwurf = await createAuftrag(entwurfsDaten())
  await updateAuftrag(entwurf.id, { status: 'versendet', nummer: 'AB-2026-0001' })
  assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2 })
})
  • Schritt 2: Test laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeNummern.test.js Expected: FAIL (Cannot find module '…/server/services/auftraege/nummern.js', ERR_MODULE_NOT_FOUND).

  • Schritt 3: nummern.js implementieren

  • formatNummer(jahr, laufnummer): RangeError('Ungültige Auftragsnummer.'), wenn jahr keine ganze Zahl von 2000 bis 9999 oder laufnummer keine ganze Zahl von 1 bis 9999 ist; sonst AB-${jahr}-${laufnummer} mit auf vier Stellen links mit 0 aufgefüllter Laufnummer.

  • vergibNummer(jetzt = new Date()) (async): jahr = Number(berlinDate(jetzt).slice(0, 4)); laufnummer = größte laufnummer aller listAuftraege({ jahr }) (fehlende oder null zählen als 0) plus 1; solange findAuftragByNummer(formatNummer(jahr, laufnummer)) einen Treffer liefert, laufnummer += 1. Rückgabe { nummer, jahr, laufnummer }. Kein Schreiben, keine eigene Sperre, Kopfkommentar auf Deutsch (Aufrufer hält SPERRE_NUMMER; die Nummer ist erst mit dem Speichern der AB vergeben).

  • Schritt 4: Test laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeNummern.test.js Expected: 6 Tests grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/nummern.js test/auftraegeNummern.test.js
git commit -m "AB-Kern: Nummernvergabe AB-JJJJ-NNNN nach Berliner Jahr" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: Eingabeprüfung pruefeEingabe

Files:

  • Create: server/services/auftraege/auftraege.js
  • Test: test/auftraegeAbEingabe.test.js

Interfaces:

  • Consumes: AUFTRAG_ARTEN, AUFTRAG_WEGE (Objekt, Schlüssel mit Object.hasOwn prüfen), ZAHLUNGSPLAN_ARTEN, KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, LIMITS (konstanten.js); AuftragError(status, message) (fehler.js).

  • Produces: istDokumentId(wert) → boolean; pruefeEingabe(eingabe, { teilweise = false } = {}) → BereinigteFelder (synchron, wirft AuftragError(400, …)) mit BereinigteFelder = { projektId, titel, kundenart, art, positionen: [{ beschreibung, menge, einzelpreisCents }], stundensatzCents, schaetzungMinuten, summeCents, zahlungsplan: { art, prozent, text }, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle: string[] } (Voll-Modus: alle Schlüssel; Teil-Modus: nur die übergebenen).

  • Schritt 1: Failing Test test/auftraegeAbEingabe.test.js schreiben

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
import { festpreisEingabe, aufwandEingabe, wirftFehler, MELDUNG } from './helpers/auftraegeAb.js'

// Rein: kein Appwrite-Zugriff. Die Umgebung zeigt trotzdem auf tote lokale Adressen, damit nie ein echtes System erreicht wird.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
const { pruefeEingabe } = await import('../server/services/auftraege/auftraege.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')

const pos = (patch = {}) => ({ beschreibung: 'Webdesign', menge: 1, einzelpreisCents: 100, ...patch })
const neunPositionen = () => Array.from({ length: 9 }, () => pos({ einzelpreisCents: 10_000_000 }))
const E = {
  art: 'Art: Bitte Festpreis oder Nach Aufwand wählen.',
  beschreibung: (n = 1) => `Position ${n}: Die Beschreibung braucht 1 bis 200 Zeichen.`,
  menge: 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.',
  preis: (n = 1) => `Position ${n}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`,
  summe: 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).',
  plan: 'Zahlungsplan: Bitte eine Zahlungsart wählen.',
  prozent: 'Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.',
  frei: 'Zahlungsplan: Der freie Text braucht 1 bis 500 Zeichen.',
  ziel: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.',
  datum: 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).',
  titel: 'Titel: Höchstens 200 Zeichen.',
  zeitquelle: 'Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.',
  stundensatz: 'Stundensatz: Der Stundensatz muss zwischen 0,01 € und 100.000,00 € liegen.',
  schaetzung: 'Schätzung: Die Schätzung muss ein Vielfaches von 15 Minuten sein (15 bis 99.960).',
}

test('Festpreis: bereinigte Felder – Texte getrimmt, Summe berechnet, Aufwandsfelder leer, Vorgaben gesetzt', () => {
  const felder = pruefeEingabe(
    festpreisEingabe({
      titel: '  Neue Website  ',
      positionen: [{ beschreibung: '  Webdesign  ', menge: 2, einzelpreisCents: 30000 }],
      zahlungsplan: { art: 'nach_abnahme' },
      zahlungszielTage: undefined,
      stundensatzCents: 12345, // gehört nicht zu einem Festpreis und fällt weg
      schaetzungMinuten: 30,
    }),
  )
  assert.deepEqual(felder, {
    projektId: 'p_projekt01',
    titel: 'Neue Website',
    kundenart: '',
    art: 'festpreis',
    positionen: [{ beschreibung: 'Webdesign', menge: 2, einzelpreisCents: 30000 }],
    stundensatzCents: null,
    schaetzungMinuten: null,
    summeCents: 60000,
    zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' },
    zahlungszielTage: 14,
    auftragVom: '2026-10-05',
    auftragWeg: 'telefon',
    startAm: '',
    fertigBis: '',
    zeitquelle: [],
  })
})

test('Aufwand: Stundensatz-Vorgabe 100 €, Schätzung optional, Positionen fallen weg, Summe leer', () => {
  const felder = pruefeEingabe(aufwandEingabe({ stundensatzCents: undefined, schaetzungMinuten: null, positionen: festpreisEingabe().positionen }))
  assert.deepEqual(felder, {
    projektId: 'p_projekt01', titel: '', kundenart: '', art: 'aufwand', positionen: [], stundensatzCents: 10000, schaetzungMinuten: null,
    summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05',
    auftragWeg: 'email', startAm: '', fertigBis: '', zeitquelle: [],
  })
})

test('Zahlungsplan: Anzahlung nimmt 50 % vor, frei trägt den Text, die anderen Arten verwerfen prozent und text', () => {
  const plan = (zahlungsplan, basis = festpreisEingabe) => pruefeEingabe(basis({ zahlungsplan })).zahlungsplan
  assert.deepEqual(plan({ art: 'anzahlung' }), { art: 'anzahlung', prozent: 50, text: '' })
  assert.deepEqual(plan({ art: 'anzahlung', prozent: 30, text: 'ignoriert' }), { art: 'anzahlung', prozent: 30, text: '' })
  assert.deepEqual(plan({ art: 'frei', prozent: 40, text: '  Zahlbar in zwei Raten.  ' }), { art: 'frei', prozent: null, text: 'Zahlbar in zwei Raten.' })
  assert.deepEqual(plan({ art: 'monatlich', text: 'x' }, aufwandEingabe), { art: 'monatlich', prozent: null, text: '' })
  assert.deepEqual(plan({ art: 'nach_abnahme', prozent: 10 }, aufwandEingabe), { art: 'nach_abnahme', prozent: null, text: '' })
})

test('Grenzwerte, die noch gelten: 20 Positionen, Summe genau 999.999,99 €, Zahlungsziel 0 und 60, Schätzung 15 und 99.960 Minuten', () => {
  const lang = (cents = 100, menge = 1) => ({ beschreibung: 'x'.repeat(200), menge, einzelpreisCents: cents })
  assert.equal(pruefeEingabe(festpreisEingabe({ positionen: Array.from({ length: 20 }, () => lang()) })).positionen.length, 20)
  assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [...neunPositionen(), pos({ einzelpreisCents: 9_999_999 })] })).summeCents, 99_999_999)
  assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [lang(100, 999)] })).summeCents, 99_900)
  assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 0 })).zahlungszielTage, 0)
  assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 60 })).zahlungszielTage, 60)
  assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 15 })).schaetzungMinuten, 15)
  assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 99_960 })).schaetzungMinuten, 99_960)
  for (const keine of [0, '', null]) assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: keine })).schaetzungMinuten, null)
  assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 1 })).stundensatzCents, 1)
  assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 10_000_000 })).stundensatzCents, 10_000_000)
})

const FESTPREIS_FEHLER = [
  ['Art fehlt', { art: undefined }, E.art],
  ['Art unbekannt', { art: 'pauschal' }, E.art],
  ['keine Positionen', { positionen: [] }, MELDUNG.positionen],
  ['Positionen fehlen', { positionen: undefined }, MELDUNG.positionen],
  ['21 Positionen', { positionen: Array.from({ length: 21 }, () => pos()) }, MELDUNG.positionen],
  ['Position ist kein Objekt', { positionen: ['x'] }, E.beschreibung()],
  ['Beschreibung leer', { positionen: [pos({ beschreibung: '   ' })] }, E.beschreibung()],
  ['Beschreibung 201 Zeichen (zweite Position)', { positionen: [pos(), pos({ beschreibung: 'x'.repeat(201) })] }, E.beschreibung(2)],
  ['Menge 0', { positionen: [pos({ menge: 0 })] }, E.menge],
  ['Menge 1000', { positionen: [pos({ menge: 1000 })] }, E.menge],
  ['Menge 1,5', { positionen: [pos({ menge: 1.5 })] }, E.menge],
  ['Menge als Text', { positionen: [pos({ menge: '2' })] }, E.menge],
  ['Einzelpreis 0 (zweite Position)', { positionen: [pos(), pos({ einzelpreisCents: 0 })] }, E.preis(2)],
  ['Einzelpreis über 100.000 €', { positionen: [pos({ einzelpreisCents: 10_000_001 })] }, E.preis()],
  ['Einzelpreis mit Nachkommastelle', { positionen: [pos({ einzelpreisCents: 99.5 })] }, E.preis()],
  ['Summe zu hoch', { positionen: [pos({ menge: 11, einzelpreisCents: 10_000_000 })] }, E.summe],
  ['Summe ein Cent über dem Höchstwert', { positionen: [...neunPositionen(), pos({ einzelpreisCents: 10_000_000 })] }, E.summe],
  ['Zahlungsplan fehlt', { zahlungsplan: undefined }, E.plan],
  ['Zahlungsart unbekannt', { zahlungsplan: { art: 'bar' } }, E.plan],
  ['Zahlungsplan kein Objekt', { zahlungsplan: 'anzahlung' }, E.plan],
  ['monatlich bei Festpreis', { zahlungsplan: { art: 'monatlich' } }, 'Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.'],
  ['Anzahlung 0 %', { zahlungsplan: { art: 'anzahlung', prozent: 0 } }, E.prozent],
  ['Anzahlung 100 %', { zahlungsplan: { art: 'anzahlung', prozent: 100 } }, E.prozent],
  ['Anzahlung 12,5 %', { zahlungsplan: { art: 'anzahlung', prozent: 12.5 } }, E.prozent],
  ['Anzahlung als Text', { zahlungsplan: { art: 'anzahlung', prozent: '50' } }, E.prozent],
  ['freier Text leer', { zahlungsplan: { art: 'frei', text: '  ' } }, E.frei],
  ['freier Text 501 Zeichen', { zahlungsplan: { art: 'frei', text: 'x'.repeat(501) } }, E.frei],
  ['Zahlungsziel -1', { zahlungszielTage: -1 }, E.ziel],
  ['Zahlungsziel 61', { zahlungszielTage: 61 }, E.ziel],
  ['Zahlungsziel 14,5', { zahlungszielTage: 14.5 }, E.ziel],
  ['Zahlungsziel als Text', { zahlungszielTage: '14' }, E.ziel],
  ['Auftragsdatum fehlt', { auftragVom: undefined }, E.datum],
  ['30. Februar', { auftragVom: '2026-02-30' }, E.datum],
  ['deutsches Datumsformat', { auftragVom: '05.10.2026' }, E.datum],
  ['Datum als Zahl', { auftragVom: 20261005 }, E.datum],
  ['Weg fehlt', { auftragWeg: undefined }, 'Weg: Bitte den Weg der Absprache wählen.'],
  ['Weg unbekannt', { auftragWeg: 'brieftaube' }, 'Weg: Bitte den Weg der Absprache wählen.'],
  ['Start kein Datum', { startAm: '2026-13-01' }, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
  ['Fertigstellung kein Datum', { fertigBis: 'bald' }, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
  ['Titel 201 Zeichen', { titel: 'x'.repeat(201) }, E.titel],
  ['Titel keine Zeichenkette', { titel: 5 }, E.titel],
  ['Projekt-Kennung mit ../', { projektId: '../x' }, 'Projekt: Ungültige Projekt-Kennung.'],
  ['Kundenart unbekannt', { kundenart: 'firma' }, 'Kundenart: Bitte Unternehmen oder Privatperson wählen.'],
  ['Zeitquelle keine Liste', { zeitquelle: 't_1' }, E.zeitquelle],
  ['11 Tickets', { zeitquelle: Array.from({ length: 11 }, (_, i) => `t_${i}`) }, 'Zeitquelle: Höchstens 10 Tickets.'],
  ['Ticket-Kennung mit ../', { zeitquelle: ['t_ok', '../x'] }, 'Zeitquelle: Ungültige Ticket-Kennung.'],
]
const AUFWAND_FEHLER = [
  ['Stundensatz 0', { stundensatzCents: 0 }, E.stundensatz],
  ['Stundensatz über 100.000 €', { stundensatzCents: 10_000_001 }, E.stundensatz],
  ['Stundensatz mit Nachkommastelle', { stundensatzCents: 100.5 }, E.stundensatz],
  ['Stundensatz als Text', { stundensatzCents: '100' }, E.stundensatz],
  ['Schätzung 10 Minuten', { schaetzungMinuten: 10 }, E.schaetzung],
  ['Schätzung negativ', { schaetzungMinuten: -15 }, E.schaetzung],
  ['Schätzung über dem Höchstwert', { schaetzungMinuten: 99_975 }, E.schaetzung],
  ['Schätzung als Text', { schaetzungMinuten: '15' }, E.schaetzung],
  ['Anzahlung bei Aufwand', { zahlungsplan: { art: 'anzahlung', prozent: 50 } }, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.'],
]

test('Ablehnungen beim Festpreis: jede Eingabe scheitert mit genau ihrer Meldung (400)', () => {
  for (const [name, patch, meldung] of FESTPREIS_FEHLER) wirftFehler(() => pruefeEingabe(festpreisEingabe(patch)), 400, meldung, name)
})

test('Ablehnungen bei Aufwand: Stundensatz, Schätzung und Anzahlung', () => {
  for (const [name, patch, meldung] of AUFWAND_FEHLER) wirftFehler(() => pruefeEingabe(aufwandEingabe(patch)), 400, meldung, name)
})

test('Eingaben, die kein einfaches Objekt sind, ergeben „Ungültige Eingabe.“ als AuftragError', () => {
  for (const eingabe of [null, undefined, 'x', 42, [], [festpreisEingabe()]]) {
    for (const teilweise of [false, true]) {
      assert.throws(() => pruefeEingabe(eingabe, { teilweise }), (err) => err instanceof AuftragError && err.status === 400 && err.message === 'Ungültige Eingabe.')
    }
  }
})

test('Unbekannte und geschützte Felder landen nie in den bereinigten Feldern', () => {
  const sauber = pruefeEingabe(festpreisEingabe())
  const mitMuell = pruefeEingabe({
    ...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'x', ersetztId: 'a_alt',
    erstelltVon: 'boese@example.invalid', pdfDatei: 'c/a.pdf', kunde: { name: 'x' }, id: 'a_fremd',
  })
  assert.deepEqual(mitMuell, sauber)
})

test('teilweise: nur übergebene Felder werden geprüft und bereinigt, nichts wird ergänzt', () => {
  assert.deepEqual(pruefeEingabe({}, { teilweise: true }), {})
  assert.deepEqual(pruefeEingabe({ kundenId: 'c_x', status: 'bestaetigt' }, { teilweise: true }), {})
  assert.deepEqual(pruefeEingabe({ titel: '  Neu  ', zeitquelle: ['t_1', 't_1', 't_2'] }, { teilweise: true }), { titel: 'Neu', zeitquelle: ['t_1', 't_2'] })
  assert.deepEqual(pruefeEingabe({ art: 'aufwand' }, { teilweise: true }), { art: 'aufwand' })
  assert.deepEqual(pruefeEingabe({ zahlungsplan: { art: 'anzahlung' } }, { teilweise: true }), { zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } })
  assert.deepEqual(
    pruefeEingabe({ positionen: [{ beschreibung: ' A ', menge: 1, einzelpreisCents: 5 }] }, { teilweise: true }),
    { positionen: [{ beschreibung: 'A', menge: 1, einzelpreisCents: 5 }] },
  )
  wirftFehler(() => pruefeEingabe({ zahlungszielTage: 99 }, { teilweise: true }), 400, E.ziel)
  wirftFehler(() => pruefeEingabe({ auftragVom: null }, { teilweise: true }), 400, E.datum)
})
  • Schritt 2: Test laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbEingabe.test.js Expected: FAIL (Cannot find module '…/server/services/auftraege/auftraege.js', ERR_MODULE_NOT_FOUND).

  • Schritt 3: auftraege.js mit istDokumentId und pruefeEingabe anlegen

Neue Datei mit deutschem Kopfkommentar (Aufgabe des Moduls: Abläufe der Auftragsbestätigung, Spec Abschnitt 2, Übersicht V4 „L3 – AB-Kern“). Funktionen:

  • export function istDokumentId(wert): typeof wert === 'string' und /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.
  • export function pruefeEingabe(eingabe, { teilweise = false } = {}) – synchron. Ist eingabe kein einfaches Objekt (Object.prototype.toString.call(eingabe) !== '[object Object]'), kommt Ungültige Eingabe. Gelesen wird nur die Whitelist der folgenden Tabelle; jedes übergebene Feld (!== undefined) läuft durch seine Feldregel, das Ergebnis steht im Rückgabeobjekt unter demselben Namen. Die Meldungen stehen wörtlich in „Meldungstexte“.
Feld Feldregel (gilt immer, wenn das Feld übergeben ist)
art in AUFTRAG_ARTEN, sonst Art: …
projektId null/'' → ''; sonst istDokumentId, sonst Projekt: Ungültige Projekt-Kennung.
titel null → ''; sonst Zeichenkette, getrimmt, höchstens LIMITS.maxTitel, sonst Titel: …
kundenart null/'' → ''; sonst in KUNDENARTEN, sonst Kundenart: …
positionen Liste (sonst Eine Auftragsbestätigung braucht …) mit höchstens LIMITS.maxPositionen Einträgen (sonst dieselbe Meldung; eine leere Liste ist hier erlaubt, ob sie genügt, entscheidet der Voll-Modus); je Eintrag der Reihe nach (N ab 1) beschreibung (Zeichenkette, getrimmt, 1 bis maxBeschreibung), menge (ganze Zahl 1 bis maxMenge), einzelpreisCents (ganze Zahl 1 bis maxEinzelpreisCents); Ergebnis je Eintrag genau diese drei Felder
stundensatzCents null → null; sonst ganze Zahl 1 bis maxEinzelpreisCents, sonst Stundensatz: …
schaetzungMinuten null, '', 0 → null; sonst ganze Zahl, Vielfaches von VIERTELSTUNDE, 15 bis maxSchaetzungMinuten, sonst Schätzung: …
zahlungsplan einfaches Objekt mit art in ZAHLUNGSPLAN_ARTEN, sonst Zahlungsplan: Bitte eine Zahlungsart wählen.; bei anzahlung: prozent fehlt/null → LIMITS.standardAnzahlungProzent, sonst ganze Zahl 1 bis 99, sonst Zahlungsplan: Die Anzahlung …; bei frei: text Zeichenkette, getrimmt, 1 bis maxZahlungsplanText, sonst Zahlungsplan: Der freie Text …; Ergebnis { art, prozent: art === 'anzahlung' ? prozent : null, text: art === 'frei' ? text : '' }
zahlungszielTage null → null; sonst ganze Zahl 0 bis maxZahlungszielTage, sonst Zahlungsziel: …
auftragVom gültiger Kalendertag, sonst Auftrag vom: … (null ist ungültig)
auftragWeg Schlüssel von AUFTRAG_WEGE, sonst Weg: …
startAm, fertigBis null/'' → ''; sonst gültiger Kalendertag, sonst Start: … bzw. Fertigstellung: …
zeitquelle null → []; sonst Liste (sonst Zeitquelle: Eine Liste …), jeder Eintrag istDokumentId (sonst Zeitquelle: Ungültige Ticket-Kennung.), Doppelte entfernt (Reihenfolge bleibt), danach höchstens LIMITS.maxZeitquellen (sonst Zeitquelle: Höchstens 10 Tickets.)

Gültiger Kalendertag: Zeichenkette YYYY-MM-DD, die als new Date(${wert}T00:00:00Z) wieder denselben Tag ergibt (kein 30.02.).

Voll-Modus (teilweise: false) – nach den Feldregeln, in dieser Reihenfolge: ① Pflicht sind art, zahlungsplan, auftragVom, auftragWeg; fehlt eines, kommt dieselbe Meldung wie bei einem ungültigen Wert (Reihenfolge der Prüfung: art, zahlungsplan, auftragVom, auftragWeg). ② Vorgaben für nicht übergebene oder null-Felder: projektId '', titel '', kundenart '', startAm '', fertigBis '', zeitquelle [], zahlungszielTage = LIMITS.standardZahlungszielTage. ③ festpreis: positionen leer oder nicht übergeben → Eine Auftragsbestätigung braucht …; summeCents = Σ menge × einzelpreisCents, über LIMITS.maxSummeCents → Die Auftragssumme ist zu hoch …; stundensatzCents und schaetzungMinuten werden null; Zahlungsplan monatlich → Zahlungsplan: „Monatlich nach Aufwand“ …. ④ aufwand: positionen wird [], summeCents null; stundensatzCents fehlt/null → STUNDENSATZ_STANDARD_CENTS; schaetzungMinuten fehlt → null; Zahlungsplan anzahlung → Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis. Rückgabe: alle Schlüssel von BereinigteFelder.

Teil-Modus (teilweise: true): nur die Feldregeln der übergebenen Felder; kein Pflicht-, Vorgabe- oder Querteil, summeCents wird nicht ergänzt.

  • Schritt 4: Test laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbEingabe.test.js Expected: 9 Tests grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeAbEingabe.test.js
git commit -m "AB-Kern: Eingabeprüfung für Auftragsbestätigungen (pruefeEingabe)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: Entwurf anlegen, ändern, löschen

Files:

  • Modify: server/services/auftraege/auftraege.js
  • Test: test/auftraegeEntwurf.test.js

Interfaces:

  • Consumes: pruefeEingabe, istDokumentId (Task 3); getAuftrag, createAuftrag, updateAuftrag, deleteAuftrag (L0 auftragStore.js); getDocument, updateDocument, listDocuments, Query (appwriteAdmin.js); config.collections.customers|websiteProjects|workorders; withLocks (server/services/keyedLock.js); sperreAuftrag(id) (konstanten.js, V2); LIMITS, AuftragError.

  • Produces: ladeAuftrag(id) → Promise<Auftrag> (404 Auftragsbestätigung nicht gefunden.); ladeKunde(kundenId) → Promise<Kundendokument> (404 Kunde nicht gefunden.); legeEntwurfAn(eingabe, { von } = {}) → Promise<Auftrag>; aendereEntwurf(id, eingabe) → Promise<Auftrag>; loescheEntwurf(id) → Promise<void>; intern eingabeAusAuftrag(auftrag), ticketsDesProjekts(projekt).

  • Schritt 1: Failing Test test/auftraegeEntwurf.test.js schreiben

import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import {
  starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, LEAD_KUNDE, FREMD, VON, MELDUNG,
  festpreisEingabe, aufwandEingabe, abgelehnt,
} from './helpers/auftraegeAb.js'

const umg = await starteUmgebung()
const { aw, config } = umg
after(() => umg.schliesse())
beforeEach(umg.zuruecksetzen)
const { getDocument } = await import('../server/services/appwriteAdmin.js')
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf } = await import('../server/services/auftraege/auftraege.js')
const kundenartVon = async (id) => (await getDocument(config.collections.customers, id)).kundenart || ''
const FREMDES_PROJEKT = MELDUNG.projektFremd
const FREMDES_TICKET = 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.'

test('legeEntwurfAn: Festpreis-Entwurf mit Titel und Zeitquelle aus dem Projekt, Summe und Ersteller', async () => {
  const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  assert.deepEqual([a.status, a.nummer, a.kundenId, a.projektId, a.titel], ['entwurf', '', KUNDE_ID, PROJEKT_ID, 'Website Beispiel'])
  assert.deepEqual(a.zeitquelle, [TICKET_ID])
  assert.deepEqual([a.art, a.summeCents, a.erstelltVon], ['festpreis', 80000, VON])
  assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' })
  assert.deepEqual(await getAuftrag(a.id), a)
})

test('legeEntwurfAn: ein eigener Titel gilt; ohne Projekt braucht es einen Titel', async () => {
  assert.equal((await legeEntwurfAn(festpreisEingabe({ titel: '  Relaunch Phase 1 ' }), { von: VON })).titel, 'Relaunch Phase 1')
  await abgelehnt(legeEntwurfAn(festpreisEingabe({ projektId: '' }), { von: VON }), 400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.')
  const frei = await legeEntwurfAn(festpreisEingabe({ projektId: '', titel: 'Logo-Entwurf' }), { von: VON })
  assert.deepEqual([frei.projektId, frei.zeitquelle], ['', []])
})

test('legeEntwurfAn: Aufwand-Entwurf ohne Positionen und Summe, mit Stundensatz, Schätzung und gewählten Tickets', async () => {
  const a = await legeEntwurfAn(aufwandEingabe({ zeitquelle: [TICKET_ID, ZWEITES_TICKET] }), { von: VON })
  assert.deepEqual([a.art, a.positionen, a.summeCents, a.stundensatzCents, a.schaetzungMinuten], ['aufwand', [], null, 10000, 600])
  assert.deepEqual(a.zeitquelle, [TICKET_ID, ZWEITES_TICKET])
  assert.deepEqual(a.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
})

test('legeEntwurfAn: Entwürfe gibt es auch für Interessenten (nur das Senden ist festen Kunden vorbehalten)', async () => {
  const a = await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'Angebotsentwurf' }), { von: VON })
  assert.deepEqual([a.kundenId, a.status], [LEAD_KUNDE, 'entwurf'])
})

test('kundenart aus der Eingabe wird am Kunden gespeichert – aber nur, wenn dort noch keine steht', async () => {
  await legeEntwurfAn(festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat' }), { von: VON })
  assert.equal(await kundenartVon(FREMD.kunde), 'privat')
  await legeEntwurfAn(festpreisEingabe({ kundenart: 'privat' }), { von: VON }) // der Kunde ist schon 'unternehmen'
  assert.equal(await kundenartVon(KUNDE_ID), 'unternehmen')
  await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'x', kundenart: '' }), { von: VON })
  assert.equal(await kundenartVon(LEAD_KUNDE), '')
})

test('fremde Bezüge werden abgelehnt, ohne dass etwas gespeichert wird (auch nicht die Kundenart)', async () => {
  const eingabe = (patch) => festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat', ...patch })
  const faelle = [
    ['Projekt eines anderen Kunden', { projektId: PROJEKT_ID }, FREMDES_PROJEKT],
    ['unbekanntes Projekt', { projektId: 'p_gibtsnicht' }, FREMDES_PROJEKT],
    ['Ticket eines anderen Kunden', { zeitquelle: [TICKET_ID] }, FREMDES_TICKET],
    ['ein Ticket unbekannt', { zeitquelle: [FREMD.ticket, 't_gibtsnicht'] }, FREMDES_TICKET],
  ]
  for (const [name, patch, meldung] of faelle) await abgelehnt(legeEntwurfAn(eingabe(patch), { von: VON }), 400, meldung, name)
  assert.equal(aw.docs(config.collections.auftraege).size, 0)
  assert.equal(await kundenartVon(FREMD.kunde), '')
})

test('Kunde: ohne Kennung 400, unbekannt 404, Kennung mit Sonderzeichen 404', async () => {
  await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: undefined }), { von: VON }), 400, 'Kunde: Bitte einen Kunden angeben.')
  await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: 'c_gibtsnicht' }), { von: VON }), 404, MELDUNG.kundeFehlt)
  await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: '../users' }), { von: VON }), 404, MELDUNG.kundeFehlt)
})

test('status, nummer, Token und Ersteller aus der Eingabe werden ignoriert', async () => {
  const a = await legeEntwurfAn(
    { ...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'abc', ersetztId: 'a_alt', erstelltVon: 'boese@example.invalid', pdfDatei: 'x/y.pdf' },
    { von: VON },
  )
  assert.deepEqual([a.status, a.nummer, a.jahr, a.erstelltVon], ['entwurf', '', null, VON])
  assert.ok(!a.tokenHash && !a.ersetztId && !a.pdfDatei)
})

test('aendereEntwurf: nur die genannten Felder ändern sich, der Rest bleibt', async () => {
  const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  const b = await aendereEntwurf(a.id, { titel: 'Neuer Titel', zahlungszielTage: 30 })
  assert.deepEqual([b.titel, b.zahlungszielTage, b.summeCents], ['Neuer Titel', 30, 80000])
  assert.deepEqual([b.positionen, b.zahlungsplan, b.zeitquelle], [a.positionen, a.zahlungsplan, a.zeitquelle])
  assert.deepEqual(await getAuftrag(a.id), b)
})

test('aendereEntwurf: Wechsel der Art prüft das Ganze (Anzahlung geht nicht bei Aufwand), mit passendem Plan klappt er', async () => {
  const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  await abgelehnt(aendereEntwurf(a.id, { art: 'aufwand' }), 400, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
  assert.equal((await getAuftrag(a.id)).art, 'festpreis')
  const b = await aendereEntwurf(a.id, { art: 'aufwand', stundensatzCents: 12000, zahlungsplan: { art: 'monatlich' } })
  assert.deepEqual([b.art, b.positionen, b.summeCents, b.stundensatzCents, b.schaetzungMinuten], ['aufwand', [], null, 12000, null])
})

test('aendereEntwurf: Kunde lässt sich nicht wechseln, fremdes Projekt wird abgelehnt, unbekannte Aufträge sind 404', async () => {
  const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  await abgelehnt(aendereEntwurf(a.id, { kundenId: FREMD.kunde }), 400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.')
  assert.equal((await aendereEntwurf(a.id, { kundenId: KUNDE_ID, titel: 'Gleicher Kunde' })).titel, 'Gleicher Kunde')
  await abgelehnt(aendereEntwurf(a.id, { projektId: FREMD.projekt }), 400, FREMDES_PROJEKT)
  await abgelehnt(aendereEntwurf('a_gibtsnicht', { titel: 'x' }), 404, MELDUNG.auftragFehlt)
  await abgelehnt(aendereEntwurf('../x', { titel: 'x' }), 404, MELDUNG.auftragFehlt)
})

test('Ändern und Löschen gibt es nur für Entwürfe (409); Löschen entfernt den Entwurf', async () => {
  const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  await updateAuftrag(a.id, { status: 'versendet', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1 })
  await abgelehnt(aendereEntwurf(a.id, { titel: 'Nachträglich' }), 409, MELDUNG.nurEntwurfAendern)
  await abgelehnt(loescheEntwurf(a.id), 409, MELDUNG.nurEntwurfLoeschen)
  assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel')
  const b = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  await loescheEntwurf(b.id)
  assert.equal(await getAuftrag(b.id), null)
  await abgelehnt(loescheEntwurf(b.id), 404, MELDUNG.auftragFehlt)
})
  • Schritt 2: Test laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeEntwurf.test.js Expected: FAIL (legeEntwurfAn is not a function o. Ä.).

  • Schritt 3: Funktionen in auftraege.js ergänzen (alle async, Kopfkommentare auf Deutsch, Importe nach Bedarf ergänzen):

  • ladeAuftrag(id): !istDokumentId(id) → AuftragError(404, 'Auftragsbestätigung nicht gefunden.'); sonst getAuftrag(id); null → dieselbe Meldung.

  • ladeKunde(kundenId): !istDokumentId(kundenId) → AuftragError(404, 'Kunde nicht gefunden.'); sonst getDocument(config.collections.customers, kundenId); Appwrite-Status 404 → dieselbe Meldung, andere Fehler weiterreichen. Liefert das Kundendokument.

  • eingabeAusAuftrag(auftrag) (intern): { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } aus den gleichnamigen Feldern des Auftrags (kein kundenId, kein kundenart).

  • ticketsDesProjekts(projekt) (intern): [projekt.ticketId] (getrimmt), wenn das Projekt ein ticketId mit Text hat, sonst [] – nie ein Leereintrag. pruefeBezuege und ordneProjektZu (Task 11) benutzen dieselbe Regel.

  • pruefeBezuege(kundeDoc, felder) (intern): liefert felder mit aufgelöstem titel und zeitquelle. Ist projektId gesetzt: Projekt über getDocument(config.collections.websiteProjects, projektId) laden (Appwrite-404 gilt wie „gehört einem anderen“) und projekt.customerId === kundeDoc.$id verlangen, sonst Projekt: Das Projekt gehört nicht zu diesem Kunden.. titel = felder.titel, sonst projekt.projectName (getrimmt, auf LIMITS.maxTitel gekürzt); ist beides leer: Titel: Bitte einen Titel angeben oder ein Projekt wählen. Ist zeitquelle leer: ticketsDesProjekts(projekt). Bei nicht leerer Zeitquelle: listDocuments(config.collections.workorders, [Query.equal('$id', zeitquelle), Query.limit(LIMITS.maxZeitquellen)]); jedes Ticket muss gefunden werden und customerId === kundeDoc.$id haben, sonst Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden..

  • schreibeKundenart(kundeDoc, kundenart) (intern): nur wenn kundenart nicht leer ist und kundeDoc.kundenart leer (getrimmt): updateDocument(config.collections.customers, kundeDoc.$id, { kundenart }). Ein vorhandener Wert wird nie überschrieben.

  • legeEntwurfAn(eingabe, { von } = {}): ① felder = pruefeEingabe(eingabe); ② eingabe.kundenId keine nicht leere Zeichenkette → Kunde: Bitte einen Kunden angeben.; ③ kundeDoc = await ladeKunde(eingabe.kundenId); ④ fertig = await pruefeBezuege(kundeDoc, felder); ⑤ await schreibeKundenart(kundeDoc, felder.kundenart) (erst nach allen Prüfungen); ⑥ createAuftrag({ kundenId, projektId, titel, zeitquelle, art, positionen, stundensatzCents, schaetzungMinuten, summeCents, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, erstelltVon: String(von || '') }) – genau diese Felder (kundenId aus der Eingabe, titel und zeitquelle aus fertig, die übrigen aus felder; ohne kundenart), den Rest füllt L0. Rückgabe: der Auftrag.

  • aendereEntwurf(id, eingabe): alles in withLocks(sperreAuftrag(id), …) (dieselbe Sperre, die sendeAuftrag für diesen Entwurf hält: ein laufendes Senden wird abgewartet, das Ändern scheitert danach mit 409): ① auftrag = await ladeAuftrag(id); Status nicht entwurf → 409 Nur ein Entwurf lässt sich ändern.; ② übergebene eingabe.kundenId (nicht undefined) ungleich auftrag.kundenId → Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.; ③ teil = pruefeEingabe(eingabe, { teilweise: true }); ④ felder = pruefeEingabe({ ...eingabeAusAuftrag(auftrag), ...teil }) (Voll-Modus über das Zusammengeführte); ⑤ kundeDoc = await ladeKunde(auftrag.kundenId), pruefeBezuege, schreibeKundenart; ⑥ updateAuftrag(id, …) mit denselben Feldern wie beim Anlegen (titel und zeitquelle aus pruefeBezuege; ohne kundenId, erstelltVon). Rückgabe: der Auftrag.

  • loescheEntwurf(id): in withLocks(sperreAuftrag(id), …): ladeAuftrag; Status nicht entwurf → 409 Nur ein Entwurf lässt sich löschen.; deleteAuftrag(id).

  • Schritt 4: Test laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeEntwurf.test.js test/auftraegeAbEingabe.test.js Expected: 21 Tests grün (12 in auftraegeEntwurf, die 9 von Task 3 bleiben grün).

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeEntwurf.test.js
git commit -m "AB-Kern: Entwürfe anlegen, ändern und löschen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: Vorschau-PDF und austauschbare Abhängigkeiten

Files:

  • Modify: server/services/auftraege/auftraege.js
  • Test: test/auftraegeAbVorschau.test.js

Interfaces:

  • Consumes: ladeAuftrag, ladeKunde (Task 4); kundeSnapshot(kundeDoc) (L0 auftragSicht.js); getSettings() (server/services/finance/settings.js, liefert nach L0 auch steuernummer); deps.erzeugeAuftragPdf(auftrag, { settings: { steuernummer, bankDetailsText }, vorschau?: boolean }) → Promise<Buffer> (L4, nur über deps).

  • Produces: setzeDepsFuerTests(deps | null); vorschauPdf(id, { deps } = {}) → Promise<Buffer>; intern logFehler(was, err), ladeDeps(deps, namen), pdfErzeugen(d, auftrag, optionen) (reicht einen AuftragError der PDF-Funktion durch, macht aus allem anderen AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')).

  • Schritt 1: Failing Test test/auftraegeAbVorschau.test.js schreiben

import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { starteUmgebung, VON, MELDUNG, festpreisEingabe, kundeDoc, baueDeps, abgelehnt, dateienIn } from './helpers/auftraegeAb.js'

const umg = await starteUmgebung()
const { aw, config, uploadDir } = umg
after(() => umg.schliesse())
beforeEach(umg.zuruecksetzen)
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { kundeSnapshot } = await import('../server/services/auftraege/auftragSicht.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { legeEntwurfAn, vorschauPdf, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js')
beforeEach(() => setzeDepsFuerTests(null))

test('vorschauPdf: Entwurf mit „Entwurf“ als Nummer, aktuellem Kunden und den Einstellungen – ohne etwas zu speichern', async () => {
  aw.seed(config.collections.financeSettings, { $id: 'main', steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' })
  const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  aw.seed(config.collections.customers, kundeDoc({ street: 'Neue Straße 5' })) // der Kunde ändert sich nach dem Entwurf
  const { deps, calls } = baueDeps()
  assert.deepEqual(await vorschauPdf(entwurf.id, { deps }), Buffer.from('%PDF-1.4 Entwurf'))
  assert.equal(calls.pdf.length, 1)
  const { auftrag, optionen } = calls.pdf[0]
  assert.deepEqual([auftrag.id, auftrag.nummer], [entwurf.id, 'Entwurf'])
  assert.deepEqual(auftrag.kunde, kundeSnapshot(kundeDoc({ street: 'Neue Straße 5' })))
  assert.equal(auftrag.kunde.strasse, 'Neue Straße 5')
  assert.deepEqual(optionen, { settings: { steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' }, vorschau: true })
  assert.equal(calls.token.length + calls.mail.length, 0)
  assert.deepEqual(dateienIn(uploadDir), [])
  const danach = await getAuftrag(entwurf.id)
  assert.deepEqual([danach.status, danach.nummer, danach.kunde], ['entwurf', '', null])
})

test('vorschauPdf: nur Entwürfe (409), unbekannt (404); wirft die PDF-Erzeugung oder liefert sie nichts, gibt es 500 mit fester Meldung; ein AuftragError der PDF-Funktion bleibt erhalten', async () => {
  mock.method(console, 'error', () => {})
  const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  await abgelehnt(vorschauPdf('a_gibtsnicht', { deps: baueDeps().deps }), 404, MELDUNG.auftragFehlt)
  await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdfFehler: new Error('pdfkit kaputt') }).deps }), 500, MELDUNG.pdf)
  await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdf: Buffer.alloc(0) }).deps }), 500, MELDUNG.pdf)
  const unvollstaendig = new AuftragError(409, 'Entwurf unvollständig: Anschrift fehlt.')
  await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdfFehler: unvollstaendig }).deps }), 409, 'Entwurf unvollständig: Anschrift fehlt.')
  await updateAuftrag(entwurf.id, { status: 'versendet' })
  await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps().deps }), 409, 'Eine Vorschau gibt es nur für Entwürfe.')
})

test('deps: ohne Angabe gilt der Ersatz aus setzeDepsFuerTests, ausdrückliche Angaben haben Vorrang, nötig ist nur die benutzte Funktion', async () => {
  const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  const ersatz = baueDeps({ pdf: Buffer.from('%PDF-1.4 ersatz') })
  const eigene = baueDeps({ pdf: Buffer.from('%PDF-1.4 eigene') })
  setzeDepsFuerTests(ersatz.deps)
  assert.deepEqual(await vorschauPdf(entwurf.id), Buffer.from('%PDF-1.4 ersatz'))
  assert.deepEqual(await vorschauPdf(entwurf.id, { deps: { erzeugeAuftragPdf: eigene.deps.erzeugeAuftragPdf } }), Buffer.from('%PDF-1.4 eigene'))
  assert.deepEqual([ersatz.calls.pdf.length, eigene.calls.pdf.length], [1, 1])
})
  • Schritt 2: Test laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbVorschau.test.js Expected: FAIL (setzeDepsFuerTests is not a function o. Ä.).

  • Schritt 3: In auftraege.js ergänzen

  • logFehler(was, err) (intern): console.error([auftraege] ${was} fehlgeschlagen:, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '') – nie err.message.

  • Abhängigkeiten (so übernehmen; STANDARD_QUELLEN ist relativ zu auftraege.js):

// PDF, Token und Mail entstehen in L4/L5 parallel: Standard ist ein dynamischer Import beim Aufruf, Tests übergeben Attrappen
const STANDARD_QUELLEN = { erzeugeAuftragPdf: './pdf.js', neuerToken: './token.js', sendeAuftragMail: './mails.js' }
let depsFuerTests = null

/** Nur für Tests: Ersatz für die Standard-Abhängigkeiten (null stellt die dynamischen Importe wieder her). */
export function setzeDepsFuerTests(deps) {
  depsFuerTests = deps
}

async function ladeDeps(deps, namen) {
  const quelle = { ...depsFuerTests, ...deps }
  const geladen = {}
  for (const name of namen) geladen[name] = quelle[name] ?? (await import(STANDARD_QUELLEN[name]))[name]
  return geladen
}

Vorrang: deps des Aufrufs, dann setzeDepsFuerTests, dann der dynamische Import; jede Funktion lädt nur die Abhängigkeiten, die sie braucht.

  • pdfErzeugen(d, auftrag, optionen) (intern, async): ruft d.erzeugeAuftragPdf(auftrag, optionen). Wirft die Funktion einen AuftragError (L4 meldet so unvollständige Entwürfe, z. B. 409 Entwurf unvollständig: …), wird er unverändert weitergeworfen. Wirft sie etwas anderes oder liefert sie keinen nicht leeren Buffer, dann logFehler('PDF-Erzeugung', err) (bei leerem Ergebnis mit new Error('leeres PDF')) und AuftragError(500, 'Das PDF konnte nicht erzeugt werden.').

  • vorschauPdf(id, { deps } = {}): ① d = await ladeDeps(deps, ['erzeugeAuftragPdf']); ② auftrag = await ladeAuftrag(id); Status nicht entwurf → 409 Eine Vorschau gibt es nur für Entwürfe.; ③ kundeDoc = await ladeKunde(auftrag.kundenId); ④ { steuernummer, bankDetailsText } = await getSettings(); ⑤ pdfErzeugen(d, { ...auftrag, nummer: 'Entwurf', kunde: kundeSnapshot(kundeDoc) }, { settings: { steuernummer, bankDetailsText }, vorschau: true }). Es wird nichts gespeichert.

  • Schritt 4: Test laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbVorschau.test.js Expected: 3 Tests grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeAbVorschau.test.js
git commit -m "AB-Kern: Vorschau-PDF und austauschbare Abhängigkeiten (deps)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Senden – Prüfungen vor der Nummer und Grundablauf

Files:

  • Modify: server/services/auftraege/auftraege.js
  • Test: test/auftraegeSenden.test.js (neu; Task 7 hängt weitere Tests an)

Interfaces:

  • Consumes: ladeAuftrag, ladeKunde, eingabeAusAuftrag, pruefeEingabe (Tasks 3–4); ladeDeps, pdfErzeugen, logFehler (Task 5); vergibNummer(jetzt) (Task 2); getSettings(); kundeSnapshot(kundeDoc); speicherePdf(kundenId, auftragId, buffer) → { pdfDatei, pdfSha256 }, updateAuftrag (L0); TEXTE_FASSUNG (texte.js), AGB_FASSUNG, KUNDENARTEN (konstanten.js); deps.neuerToken(jetzt?) → { token, hash, gueltigBis: Date } und deps.sendeAuftragMail(auftrag, { pdf, token, erinnerung? }) → Promise<void> (L5, wirft bei Fehler).

  • Produces: sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) → Promise<{ auftrag: Auftrag, mailFehler: boolean }> – in diesem Task der gerade Ablauf ohne Sperre und ohne Fehlerabbildung (mailFehler immer false); Task 7 ergänzt beides. Intern hatText(wert), pruefeKundeFuerVersand(kundeDoc).

  • Schritt 1: Failing Test test/auftraegeSenden.test.js schreiben (der Dateikopf enthält schon alles, was Task 7 braucht):

import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import {
  starteUmgebung, KUNDE_ID, VON, MELDUNG, festpreisEingabe, kundeDoc, baueDeps, sha256, abgelehnt,
  ergebnis, tor, warte, wartebis, dateienIn,
} from './helpers/auftraegeAb.js'

const umg = await starteUmgebung()
const { aw, config, uploadDir } = umg
after(() => umg.schliesse())
beforeEach(umg.zuruecksetzen)
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { getAuftrag, listAuftraege, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { lesePdf } = await import('../server/services/auftraege/ablage.js')
const { kundeSnapshot } = await import('../server/services/auftraege/auftragSicht.js')
const { TEXTE_FASSUNG } = await import('../server/services/auftraege/texte.js')
const { AGB_FASSUNG, TOKEN_GUELTIG_TAGE } = await import('../server/services/auftraege/konstanten.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag } = await import('../server/services/auftraege/auftraege.js')

const JETZT = new Date('2026-10-06T10:00:00Z') // Berlin: 12:00
const neuerEntwurf = (patch) => legeEntwurfAn(festpreisEingabe(patch), { von: VON })
const sende = (id, deps, extra = {}) => sendeAuftrag(id, { von: VON, deps, jetzt: JETZT, ...extra })

const VOR_DER_NUMMER = [
  ['Interessent', { customerStatus: 'lead' }, 400, MELDUNG.keinFesterKunde],
  ['verlorener Kunde (Schreibweise egal)', { customerStatus: ' Lost ' }, 400, MELDUNG.keinFesterKunde],
  ['Kundenart fehlt', { kundenart: '' }, 400, MELDUNG.keineKundenart],
  ['Kundenart unbekannt', { kundenart: 'firma' }, 400, MELDUNG.keineKundenart],
  ['E-Mail fehlt', { email: '' }, 400, MELDUNG.keineMail],
  ['E-Mail nur Leerzeichen', { email: '   ' }, 400, MELDUNG.keineMail],
  ['Straße fehlt', { street: '' }, 409, MELDUNG.adresse],
  ['PLZ nur Leerzeichen', { postalCode: '  ' }, 409, MELDUNG.adresse],
  ['Ort fehlt', { city: '' }, 409, MELDUNG.adresse],
  ['Interessent hat Vorrang vor allem anderen', { customerStatus: 'lead', kundenart: '', email: '', street: '' }, 400, MELDUNG.keinFesterKunde],
  ['Kundenart kommt vor E-Mail und Anschrift', { kundenart: '', email: '', street: '' }, 400, MELDUNG.keineKundenart],
  ['E-Mail kommt vor der Anschrift', { email: '', street: '' }, 400, MELDUNG.keineMail],
]

test('Senden: unvollständige Kundendaten werden vor der Nummer abgelehnt – nichts wird erzeugt, gespeichert oder verbraucht', async () => {
  const entwurf = await neuerEntwurf()
  const { deps, calls } = baueDeps()
  for (const [name, patch, status, meldung] of VOR_DER_NUMMER) {
    aw.seed(config.collections.customers, kundeDoc(patch))
    await abgelehnt(sende(entwurf.id, deps), status, meldung, name)
  }
  assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [0, 0, 0])
  assert.deepEqual(dateienIn(uploadDir), [])
  const unveraendert = await getAuftrag(entwurf.id)
  assert.deepEqual([unveraendert.status, unveraendert.nummer, unveraendert.jahr], ['entwurf', '', null])
  aw.seed(config.collections.customers, kundeDoc()) // der Kunde ist wieder vollständig: die erste echte Nummer ist 0001
  assert.equal((await sende(entwurf.id, deps)).auftrag.nummer, 'AB-2026-0001')
})

test('Senden: der Entwurf wird vor der Nummer noch einmal geprüft (beschädigte Daten ergeben die Feldmeldung)', async () => {
  const entwurf = await neuerEntwurf()
  await updateAuftrag(entwurf.id, { positionen: [] })
  await abgelehnt(sende(entwurf.id, baueDeps().deps), 400, MELDUNG.positionen)
})

test('Senden: Nummer, eingefrorener Kunde, PDF mit Prüfsumme, Token und Mail', async () => {
  aw.seed(config.collections.financeSettings, { $id: 'main', steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' })
  const entwurf = await neuerEntwurf()
  const { deps, calls } = baueDeps()
  const { auftrag, mailFehler } = await sende(entwurf.id, deps)
  assert.equal(mailFehler, false)
  assert.deepEqual([auftrag.id, auftrag.status, auftrag.nummer, auftrag.jahr, auftrag.laufnummer], [entwurf.id, 'versendet', 'AB-2026-0001', 2026, 1])
  assert.equal(new Date(auftrag.gesendetAm).getTime(), JETZT.getTime())
  assert.equal(auftrag.gesendetAn, 'anna@example.invalid')
  assert.deepEqual(auftrag.kunde, kundeSnapshot(kundeDoc()))
  assert.equal(auftrag.kunde.kundenart, 'unternehmen')
  assert.deepEqual([auftrag.texteFassung, auftrag.agbFassung], [TEXTE_FASSUNG, AGB_FASSUNG])
  assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler], ['gesendet', ''])
  // PDF: einmal mit der Nummer und dem Kunden-Schnappschuss erzeugt, abgelegt, Prüfsumme gespeichert
  const pdf = Buffer.from('%PDF-1.4 AB-2026-0001')
  assert.equal(calls.pdf.length, 1)
  assert.equal(calls.pdf[0].auftrag.nummer, 'AB-2026-0001')
  assert.deepEqual(calls.pdf[0].auftrag.kunde, kundeSnapshot(kundeDoc()))
  assert.deepEqual(calls.pdf[0].optionen, { settings: { steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' } })
  assert.equal(auftrag.pdfDatei, `${KUNDE_ID}/${entwurf.id}.pdf`)
  assert.equal(auftrag.pdfSha256, sha256(pdf))
  assert.deepEqual(await lesePdf(auftrag.pdfDatei), pdf)
  // Token: im Dokument nur der Hash, 60 Tage gültig ab dem Versand
  assert.equal(calls.token.length, 1)
  assert.equal(auftrag.tokenHash, sha256(calls.token[0]))
  assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), JETZT.getTime() + TOKEN_GUELTIG_TAGE * 86_400_000)
  // Mail: einmal, mit dem gespeicherten Dokument, dem PDF und dem Klartext-Token
  assert.equal(calls.mail.length, 1)
  assert.equal(calls.mail[0].auftrag.nummer, 'AB-2026-0001')
  assert.deepEqual(calls.mail[0].pdf, pdf)
  assert.deepEqual([calls.mail[0].token, calls.mail[0].erinnerung], [calls.token[0], false])
  assert.deepEqual(await getAuftrag(entwurf.id), auftrag)
})

test('Senden: Nummern zählen fortlaufend, im neuen Berliner Jahr beginnt wieder 0001', async () => {
  const { deps } = baueDeps()
  const nummern = []
  for (let i = 0; i < 2; i += 1) nummern.push((await sende((await neuerEntwurf()).id, deps)).auftrag.nummer)
  const neujahr = await sende((await neuerEntwurf()).id, deps, { jetzt: new Date('2026-12-31T23:30:00Z') })
  assert.deepEqual(nummern, ['AB-2026-0001', 'AB-2026-0002'])
  assert.deepEqual([neujahr.auftrag.nummer, neujahr.auftrag.jahr, neujahr.auftrag.laufnummer], ['AB-2027-0001', 2027, 1])
  assert.equal(new Date(neujahr.auftrag.gesendetAm).toISOString(), '2026-12-31T23:30:00.000Z')
})

test('Senden: nur Entwürfe (409), unbekannte Aufträge (404) und Kunden (404)', async () => {
  const { deps } = baueDeps()
  const entwurf = await neuerEntwurf()
  await sende(entwurf.id, deps)
  await abgelehnt(sende(entwurf.id, deps), 409, MELDUNG.nurEntwurfSenden)
  await abgelehnt(sende('a_gibtsnicht', deps), 404, MELDUNG.auftragFehlt)
  const zweiter = await neuerEntwurf()
  await deleteDocument(config.collections.customers, KUNDE_ID)
  await abgelehnt(sende(zweiter.id, deps), 404, MELDUNG.kundeFehlt)
})
  • Schritt 2: Test laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSenden.test.js Expected: FAIL (sendeAuftrag is not a function o. Ä.).

  • Schritt 3: sendeAuftrag in auftraege.js implementieren (gerader Ablauf, in dieser Reihenfolge; von wird angenommen und nicht gespeichert):

Hilfen:

  • hatText(wert) (intern): typeof wert === 'string' && wert.trim() !== ''.
  • pruefeKundeFuerVersand(kundeDoc) (intern), in dieser Reihenfolge: customerStatus (getrimmt, klein) lead oder lost → 400 Auftragsbestätigungen nur für feste Kunden.; kundeDoc.kundenart (getrimmt) nicht in KUNDENARTEN → 400 Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).; email ohne Text → 400 Für den Versand fehlt die E-Mail-Adresse des Kunden.; street, postalCode oder city ohne Text → 409 Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.

sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}):

  1. d = await ladeDeps(deps, ['erzeugeAuftragPdf', 'neuerToken', 'sendeAuftragMail']).
  2. entwurf = await ladeAuftrag(id); Status nicht entwurf → 409 Nur ein Entwurf lässt sich senden.
  3. pruefeEingabe(eingabeAusAuftrag(entwurf)) (wirft 400 mit der Feldmeldung).
  4. kundeDoc = await ladeKunde(entwurf.kundenId); pruefeKundeFuerVersand(kundeDoc); kunde = kundeSnapshot(kundeDoc).
  5. { steuernummer, bankDetailsText } = await getSettings().
  6. { nummer, jahr, laufnummer } = await vergibNummer(jetzt).
  7. zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm: jetzt.toISOString(), texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG }; pdf = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } }) (genau diese Optionen, ohne vorschau).
  8. { pdfDatei, pdfSha256 } = await speicherePdf(entwurf.kundenId, entwurf.id, pdf).
  9. { token, hash, gueltigBis } = d.neuerToken(jetzt).
  10. gespeichert = await updateAuftrag(entwurf.id, { status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm: jetzt.toISOString(), texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG, pdfDatei, pdfSha256, gesendetAn: kunde.email, tokenHash: hash, tokenGueltigBis: gueltigBis.toISOString() }).
  11. await d.sendeAuftragMail(gespeichert, { pdf, token }), danach final = await updateAuftrag(entwurf.id, { mailStatus: 'gesendet', mailFehler: '' }).
  12. Rückgabe { auftrag: final, mailFehler: false }.
  • Schritt 4: Test laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSenden.test.js Expected: 5 Tests grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeSenden.test.js
git commit -m "AB-Kern: Senden mit Prüfungen vor der Nummer, Nummer, PDF, Ablage, Token und Mail" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: Senden – Sperre, Fehlerpfade, Parallelität

Files:

  • Modify: server/services/auftraege/auftraege.js
  • Test: test/auftraegeSenden.test.js (Tests anhängen)

Interfaces:

  • Consumes: sendeAuftrag aus Task 6; withLocks(keys, fn) (server/services/keyedLock.js); SPERRE_NUMMER und sperreAuftrag(id) (konstanten.js, V2); entfernePdf(pdfDatei) → Promise<void> (L0 ablage.js, pfadgeprüft, folgenlos bei fehlender Datei); getAuftrag (L0).

  • Produces: sendeAuftrag endgültig: Schritte 2–10 unter withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], …), Fehler abgebildet, Dateirest aufgeräumt, Mail nach der Sperre; Rückgabe { auftrag, mailFehler: boolean }. Intern raeumePdfAuf(auftragId, pdfDatei), mailFehlerText(err), versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) → Promise<{ auftrag, mailFehler }> (Task 8 benutzt sie ebenfalls).

  • Schritt 1: Failing Tests ans Ende von test/auftraegeSenden.test.js anhängen

test('Senden: scheitert die PDF-Erzeugung (Fehler, leer oder unvollständiger Entwurf), bleibt alles wie vorher und die Nummer ist nicht verbraucht', async () => {
  mock.method(console, 'error', () => {})
  const entwurf = await neuerEntwurf()
  const faelle = [
    [baueDeps({ pdfFehler: new Error('pdfkit kaputt') }), 500, MELDUNG.pdf],
    [baueDeps({ pdf: Buffer.alloc(0) }), 500, MELDUNG.pdf],
    [baueDeps({ pdfFehler: new AuftragError(409, 'Entwurf unvollständig: Anschrift fehlt.') }), 409, 'Entwurf unvollständig: Anschrift fehlt.'],
  ]
  for (const [fehlerhaft, status, meldung] of faelle) {
    await abgelehnt(sende(entwurf.id, fehlerhaft.deps), status, meldung)
    assert.deepEqual([fehlerhaft.calls.token.length, fehlerhaft.calls.mail.length], [0, 0])
  }
  assert.deepEqual(dateienIn(uploadDir), [])
  const danach = await getAuftrag(entwurf.id)
  assert.deepEqual([danach.status, danach.nummer, danach.jahr], ['entwurf', '', null])
  assert.ok(!danach.pdfDatei && !danach.tokenHash)
  assert.equal((await sende(entwurf.id, baueDeps().deps)).auftrag.nummer, 'AB-2026-0001')
})

test('Senden: eine schon vorhandene Zieldatei wird nie überschrieben – Fehler, Entwurf und Datei bleiben', async () => {
  mock.method(console, 'error', () => {})
  const entwurf = await neuerEntwurf()
  const ziel = path.join(uploadDir, KUNDE_ID, `${entwurf.id}.pdf`)
  fs.mkdirSync(path.dirname(ziel), { recursive: true })
  fs.writeFileSync(ziel, 'Rest eines Absturzes')
  const { deps, calls } = baueDeps()
  await abgelehnt(sende(entwurf.id, deps), 500, MELDUNG.pdf)
  assert.equal(fs.readFileSync(ziel, 'utf8'), 'Rest eines Absturzes')
  assert.equal(calls.mail.length, 0)
  assert.equal((await getAuftrag(entwurf.id)).status, 'entwurf')
})

test('Senden: scheitert das Speichern der Auftragsbestätigung, wird die eben abgelegte Datei entfernt und das Senden lässt sich wiederholen', async () => {
  mock.method(console, 'error', () => {})
  const entwurf = await neuerEntwurf()
  const { deps, calls } = baueDeps()
  aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500 })
  await assert.rejects(sende(entwurf.id, deps), (err) => err.status === 500)
  assert.equal(calls.mail.length, 0)
  assert.deepEqual(dateienIn(uploadDir), [])
  assert.equal((await getAuftrag(entwurf.id)).status, 'entwurf')
  const { auftrag } = await sende(entwurf.id, deps)
  assert.equal(auftrag.nummer, 'AB-2026-0001')
  assert.deepEqual(dateienIn(uploadDir), [`${KUNDE_ID}/${entwurf.id}.pdf`])
})

test('Senden: scheitert nur die Mail, bleibt die AB versendet (mailStatus fehler) und der Fehlertext verrät nichts Internes', async () => {
  mock.method(console, 'error', () => {})
  const [e1, e2] = [await neuerEntwurf(), await neuerEntwurf()]
  const mitCode = baueDeps({ mailFehler: Object.assign(new Error('535 Login smtp.example.invalid'), { code: 'EAUTH' }) })
  const r1 = await sende(e1.id, mitCode.deps)
  assert.equal(r1.mailFehler, true)
  assert.deepEqual(
    [r1.auftrag.status, r1.auftrag.nummer, r1.auftrag.mailStatus, r1.auftrag.mailFehler],
    ['versendet', 'AB-2026-0001', 'fehler', 'E-Mail konnte nicht gesendet werden (EAUTH).'],
  )
  assert.ok(r1.auftrag.pdfDatei && r1.auftrag.tokenHash)
  assert.deepEqual(await getAuftrag(e1.id), r1.auftrag)
  const ohneCode = baueDeps({ mailFehler: new Error('Verbindung zu mail.example.invalid abgebrochen') })
  const r2 = await sende(e2.id, ohneCode.deps)
  assert.deepEqual([r2.auftrag.nummer, r2.auftrag.mailStatus, r2.auftrag.mailFehler], ['AB-2026-0002', 'fehler', 'E-Mail konnte nicht gesendet werden.'])
})

test('Senden: zwei Entwürfe gleichzeitig bekommen verschiedene Nummern', async () => {
  const [a, b] = [await neuerEntwurf(), await neuerEntwurf()]
  const { deps } = baueDeps({ pdfWarten: warte(30) })
  const ergebnisse = await Promise.all([sende(a.id, deps), sende(b.id, deps)])
  assert.deepEqual(ergebnisse.map((e) => e.auftrag.nummer).sort(), ['AB-2026-0001', 'AB-2026-0002'])
  assert.equal(new Set(ergebnisse.map((e) => e.auftrag.pdfDatei)).size, 2)
})

test('Senden: dieselbe AB zweimal gleichzeitig (Doppelklick) – einmal gesendet, das zweite Mal 409, genau eine Nummer und eine Mail', async () => {
  const entwurf = await neuerEntwurf()
  const { deps, calls } = baueDeps({ pdfWarten: warte(30) })
  const ergebnisse = await Promise.allSettled([sende(entwurf.id, deps), sende(entwurf.id, deps)])
  assert.deepEqual(ergebnisse.map((r) => r.status).sort(), ['fulfilled', 'rejected'])
  const abgewiesen = ergebnisse.find((r) => r.status === 'rejected').reason
  assert.deepEqual([abgewiesen.status, abgewiesen.message], [409, MELDUNG.nurEntwurfSenden])
  assert.deepEqual([calls.pdf.length, calls.mail.length], [1, 1])
  assert.deepEqual((await listAuftraege({ jahr: 2026 })).map((a) => a.nummer), ['AB-2026-0001'])
})

test('Senden hält die Sperre: Ändern und Löschen währenddessen warten und scheitern danach mit 409', async () => {
  const entwurf = await neuerEntwurf()
  const pdfTor = tor()
  const { deps, calls } = baueDeps({ pdfWarten: pdfTor.promise })
  const senden = ergebnis(sende(entwurf.id, deps))
  await wartebis(() => calls.pdf.length === 1) // das Senden hält die Sperre und wartet auf das PDF
  const aendern = ergebnis(aendereEntwurf(entwurf.id, { titel: 'Heimlich geändert' }))
  const loeschen = ergebnis(loescheEntwurf(entwurf.id))
  await warte(30) // beide sind unterwegs, dürfen aber noch nichts geschrieben haben
  assert.equal((await getAuftrag(entwurf.id)).titel, 'Website Beispiel')
  pdfTor.freigeben()
  const [s, a, l] = await Promise.all([senden, aendern, loeschen])
  assert.equal(s.wert.auftrag.status, 'versendet')
  assert.deepEqual([a.fehler?.status, a.fehler?.message], [409, MELDUNG.nurEntwurfAendern])
  assert.deepEqual([l.fehler?.status, l.fehler?.message], [409, MELDUNG.nurEntwurfLoeschen])
  const danach = await getAuftrag(entwurf.id)
  assert.deepEqual([danach.titel, danach.status], ['Website Beispiel', 'versendet'])
})

test('Senden: während die Mail unterwegs ist, ist die Sperre schon frei (die nächste AB bekommt ihre Nummer)', async () => {
  const [a, b] = [await neuerEntwurf(), await neuerEntwurf()]
  const mailTor = tor()
  const langsam = baueDeps({ mailWarten: mailTor.promise })
  const ersteMail = ergebnis(sende(a.id, langsam.deps))
  await wartebis(() => langsam.calls.mail.length === 1)
  const zweite = await sende(b.id, baueDeps().deps) // läuft durch, obwohl die Mail der ersten noch hängt
  assert.equal(zweite.auftrag.nummer, 'AB-2026-0002')
  mailTor.freigeben()
  assert.equal((await ersteMail).wert.auftrag.nummer, 'AB-2026-0001')
})
  • Schritt 2: Tests laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSenden.test.js Expected: FAIL in den Tests zu Zieldatei (kein 500 mit fester Meldung), Speichern (Datei bleibt liegen), Mail-Fehler (wirft statt mailFehler: true), zwei Entwürfe gleichzeitig (doppelte Nummer), Doppelklick und Sperre; bereits grün sind die Tests zu PDF-Fehler und „Mail unterwegs“ (er bewacht die Sperre erst, wenn es sie gibt) sowie die fünf aus Task 6.

  • Schritt 3: sendeAuftrag umbauen (die Reihenfolge der Schritte aus Task 6 bleibt):
  1. Die Schritte 2 bis 10 laufen in withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], async () => { … }) (ein Aufruf mit beiden Schlüsseln: withLocks reiht ihn für beide gleichzeitig ein, ein Deadlock mit anderen Aufrufen ist ausgeschlossen). SPERRE_NUMMER serialisiert die Nummernvergabe; sperreAuftrag(id) hält Ändern, Löschen, Projekt zuordnen und (bei L5) Bestätigen dieses Entwurfs während des Sendens fern. Innen wird keine andere unter diesen Schlüsseln gesperrte Funktion aufgerufen (Task 9 ergänzt darin die verschachtelte Sperre der alten AB mit dem Schlüssel sperreAuftrag(ersetztId)). Der Block liefert { gespeichert, pdf, token }; Schritt 11 (Mail) läuft danach außerhalb der Sperre.
  2. Schritt 8: scheitert speicherePdf, dann logFehler('PDF-Ablage', err) und AuftragError(500, 'Das PDF konnte nicht erzeugt werden.'); es wird nichts aufgeräumt (die Datei gehört nicht diesem Aufruf).
  3. Schritte 9 und 10 stehen in try { … } catch (err) { await raeumePdfAuf(entwurf.id, pdfDatei); throw err }. raeumePdfAuf(auftragId, pdfDatei) (intern, async): getAuftrag(auftragId); nur wenn der Status entwurf ist, await entfernePdf(pdfDatei); jeder Fehler darin wird nur mit logFehler('PDF-Rest entfernen', err) protokolliert, der ursprüngliche Fehler bleibt der geworfene.
  4. Schritt 11 wird zur internen Funktion versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) (async): try { await d.sendeAuftragMail(gespeichert, { pdf, token, erinnerung }); mailPatch = { mailStatus: 'gesendet', mailFehler: '' } } catch (err) { logFehler('Mailversand', err); mailPatch = { mailStatus: 'fehler', mailFehler: mailFehlerText(err) } }; danach final = await updateAuftrag(gespeichert.id, mailPatch); Rückgabe { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' }. sendeAuftrag ruft sie nach der Sperre auf und gibt ihr Ergebnis zurück. Je Versand gibt es genau einen Aufruf von sendeAuftragMail mit dem gespeicherten Dokument: die Teamkopie der K31 (ohne Link) baut L5 darin, L3 sendet nie eine zweite Mail. mailFehlerText(err) (intern): ist err.code eine Zeichenkette der Form /^[A-Za-z0-9_.-]{1,40}$/, dann E-Mail konnte nicht gesendet werden (${code})., sonst E-Mail konnte nicht gesendet werden.; nie err.message.
  • Schritt 4: Tests laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeSenden.test.js test/auftraegeEntwurf.test.js Expected: 13 Tests in auftraegeSenden (5 aus Task 6, 8 neue) und 12 in auftraegeEntwurf grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeSenden.test.js
git commit -m "AB-Kern: Senden unter Sperre, Fehlerpfade, Dateirest und Mail-Fehler" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 8: Erinnerung („Erinnerung senden“ und „Erneut senden“)

Files:

  • Modify: server/services/auftraege/auftraege.js
  • Test: test/auftraegeAbFolge.test.js (neu; Tasks 9 und 10 hängen Tests an)

Interfaces:

  • Consumes: ladeAuftrag, ladeKunde, ladeDeps, versendeMail, hatText (Tasks 4–7); lesePdf(pdfDatei) → Buffer (L0 ablage.js); updateAuftrag; withLocks (server/services/keyedLock.js); sperreAuftrag(id) (konstanten.js, V2); deps.neuerToken, deps.sendeAuftragMail.

  • Produces: ladeGespeichertesPdf(auftrag) → Promise<Buffer> (Datei über lesePdf, Prüfsumme gegen auftrag.pdfSha256; ohne pdfDatei 404 PDF nicht gefunden.; bei Abweichung 500 Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.); erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) → Promise<{ auftrag: Auftrag, mailFehler: boolean }> (Prüfung und Token-Tausch unter sperreAuftrag(id) aus konstanten.js, die Mail danach).

  • Schritt 1: Failing Test test/auftraegeAbFolge.test.js schreiben (der Dateikopf enthält schon alles, was die Tasks 9 und 10 brauchen):

import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import {
  starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, FREMD, VON, MELDUNG, festpreisEingabe, kundeDoc, projektDoc,
  baueDeps, sha256, abgelehnt, dateienIn, ergebnis, tor, warte, wartebis,
} from './helpers/auftraegeAb.js'

const umg = await starteUmgebung()
const { aw, config, uploadDir } = umg
after(() => umg.schliesse())
beforeEach(umg.zuruecksetzen)
const { getAuftrag, updateAuftrag, findAuftragByTokenHash } = await import('../server/services/auftraege/auftragStore.js')
const { createProjektRechnung, getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
const { lesePdf } = await import('../server/services/auftraege/ablage.js')
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag, erinnereAuftrag, neueFassung, storniereAuftrag, ordneProjektZu } = await import('../server/services/auftraege/auftraege.js')

const JETZT = new Date('2026-10-06T10:00:00Z')
/** Eine versendete AB (Entwurf anlegen und senden); liefert den Auftrag. */
async function gesendet(deps, patch) {
  const entwurf = await legeEntwurfAn(festpreisEingabe(patch), { von: VON })
  return (await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT })).auftrag
}

/**
 * Hält die Sperre der AB wie L5 beim Bestätigen (sperreAuftrag(id), V2), startet die Aktion, wartet, bis sie an der Sperre
 * steht, führt waehrend() aus (dort schreibt „L5“) und liefert das Ergebnis der Aktion ({ wert } oder { fehler }) nach dem Freigeben.
 */
async function unterFremderSperre(id, { aktion, bereit = () => true, waehrend }) {
  const sperre = tor()
  const haelt = withLocks(sperreAuftrag(id), () => sperre.promise)
  const lauf = ergebnis(aktion())
  await wartebis(bereit)
  await warte(30) // die Aktion steht jetzt an der Sperre und darf noch nichts geschrieben haben
  await waehrend()
  sperre.freigeben()
  await haelt
  return lauf
}

test('erinnereAuftrag: neuer Token (der alte gilt nicht mehr), gleiche PDF-Datei, Mail mit erinnerung: true, Versanddatum bleibt', async () => {
  const { deps, calls } = baueDeps()
  const erste = await gesendet(deps)
  const spaeter = new Date('2026-10-20T08:00:00Z')
  const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps, jetzt: spaeter })
  assert.equal(mailFehler, false)
  assert.equal(calls.token.length, 2)
  assert.notEqual(auftrag.tokenHash, erste.tokenHash)
  assert.equal(auftrag.tokenHash, sha256(calls.token[1]))
  assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), spaeter.getTime() + 60 * 86_400_000)
  assert.equal(await findAuftragByTokenHash(erste.tokenHash), null)
  assert.equal((await findAuftragByTokenHash(auftrag.tokenHash)).id, erste.id)
  assert.deepEqual(
    [auftrag.status, auftrag.nummer, auftrag.gesendetAm, auftrag.pdfDatei, auftrag.pdfSha256, auftrag.mailStatus],
    ['versendet', erste.nummer, erste.gesendetAm, erste.pdfDatei, erste.pdfSha256, 'gesendet'],
  )
  assert.equal(calls.pdf.length, 1) // kein neues PDF
  assert.equal(calls.mail.length, 2)
  assert.deepEqual(calls.mail[1].pdf, await lesePdf(erste.pdfDatei))
  assert.deepEqual([calls.mail[1].token, calls.mail[1].erinnerung], [calls.token[1], true])
  assert.deepEqual(await getAuftrag(erste.id), auftrag)
})

test('erinnereAuftrag nach einem Mail-Fehler: gewöhnliche Mail ohne Erinnerungs-Hinweis an die aktuelle Adresse des Kunden', async () => {
  mock.method(console, 'error', () => {})
  const erste = await gesendet(baueDeps({ mailFehler: new Error('Verbindung abgebrochen') }).deps)
  assert.deepEqual([erste.mailStatus, erste.gesendetAn], ['fehler', 'anna@example.invalid'])
  aw.seed(config.collections.customers, kundeDoc({ email: 'neu@example.invalid' })) // der Admin hat die Adresse korrigiert
  const { deps, calls } = baueDeps()
  const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps })
  assert.equal(mailFehler, false)
  assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler, auftrag.gesendetAn], ['gesendet', '', 'neu@example.invalid'])
  assert.deepEqual([calls.mail[0].erinnerung, calls.mail[0].auftrag.gesendetAn], [false, 'neu@example.invalid'])
})

test('erinnereAuftrag: scheitert die Mail, gilt trotzdem der neue Token; das Ergebnis meldet mailFehler', async () => {
  mock.method(console, 'error', () => {})
  const erste = await gesendet(baueDeps().deps)
  const kaputt = baueDeps({ mailFehler: Object.assign(new Error('x'), { code: 'ECONNREFUSED' }) })
  const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps: kaputt.deps })
  assert.equal(mailFehler, true)
  assert.deepEqual([auftrag.status, auftrag.mailStatus, auftrag.mailFehler], ['versendet', 'fehler', 'E-Mail konnte nicht gesendet werden (ECONNREFUSED).'])
  assert.equal(auftrag.tokenHash, sha256(kaputt.calls.token[0]))
  assert.equal(await findAuftragByTokenHash(erste.tokenHash), null)
})

test('erinnereAuftrag: nur versendete (409), unbekannt 404, ohne E-Mail 400, beschädigte PDF-Datei 500 – jeweils ohne neuen Token', async () => {
  mock.method(console, 'error', () => {})
  const { deps } = baueDeps()
  const a = await gesendet(deps)
  for (const status of ['entwurf', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
    await updateAuftrag(a.id, { status })
    await abgelehnt(erinnereAuftrag(a.id, { deps }), 409, MELDUNG.keineErinnerung, status)
  }
  await abgelehnt(erinnereAuftrag('a_gibtsnicht', { deps }), 404, MELDUNG.auftragFehlt)
  await updateAuftrag(a.id, { status: 'versendet' })
  aw.seed(config.collections.customers, kundeDoc({ email: ' ' }))
  await abgelehnt(erinnereAuftrag(a.id, { deps }), 400, MELDUNG.keineMail)
  aw.seed(config.collections.customers, kundeDoc())
  fs.writeFileSync(path.join(uploadDir, a.pdfDatei), 'manipuliert')
  await abgelehnt(erinnereAuftrag(a.id, { deps }), 500, MELDUNG.pruefsumme)
  assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash)
})

test('erinnereAuftrag wartet auf die Sperre der AB und prüft den Stand danach (L5 bestätigt gerade: keine Erinnerung mehr)', async () => {
  const { deps, calls } = baueDeps()
  const a = await gesendet(deps)
  const lauf = await unterFremderSperre(a.id, {
    aktion: () => erinnereAuftrag(a.id, { deps }),
    waehrend: async () => {
      assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) // noch kein neuer Token
      await updateAuftrag(a.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
    },
  })
  assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keineErinnerung])
  assert.equal(calls.mail.length, 1) // nur die Mail des ersten Sendens
  assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash)
})
  • Schritt 2: Test laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbFolge.test.js Expected: FAIL (erinnereAuftrag is not a function).

  • Schritt 3: In auftraege.js ergänzen

  • ladeGespeichertesPdf(auftrag) (async, Export): auftrag.pdfDatei ohne Text → AuftragError(404, 'PDF nicht gefunden.'); buffer = await lesePdf(auftrag.pdfDatei); stimmt SHA-256 (hex) von buffer nicht mit auftrag.pdfSha256 überein: console.error('[auftraege] Prüfsumme der gespeicherten PDF stimmt nicht:', auftrag.id) und AuftragError(500, 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.'); sonst buffer.

  • erinnereAuftrag(id, { deps, jetzt = new Date() } = {}): ① d = await ladeDeps(deps, ['neuerToken', 'sendeAuftragMail']); ② alles bis zum Token-Tausch in withLocks(sperreAuftrag(id), …) (die Mail läuft danach außerhalb): alt = await ladeAuftrag(id); Status nicht versendet → 409 Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.; ③ kundeDoc = await ladeKunde(alt.kundenId); email ohne Text → 400 Für den Versand fehlt die E-Mail-Adresse des Kunden.; ④ pdf = await ladeGespeichertesPdf(alt) (vor dem Token-Tausch: eine beschädigte Datei darf den alten Link nicht entwerten); ⑤ { token, hash, gueltigBis } = d.neuerToken(jetzt); gespeichert = await updateAuftrag(id, { tokenHash: hash, tokenGueltigBis: gueltigBis.toISOString(), gesendetAn: <getrimmte E-Mail des Kunden> }); gesendetAm, nummer und PDF bleiben unverändert; ⑥ nach der Sperre return versendeMail(d, gespeichert, { pdf, token, erinnerung: alt.mailStatus !== 'fehler' }) (Ergebnis { auftrag, mailFehler }; nach einem Mail-Fehler ist es eine gewöhnliche Mail ohne Erinnerungs-Hinweis).

  • Schritt 4: Test laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbFolge.test.js Expected: 5 Tests grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeAbFolge.test.js
git commit -m "AB-Kern: Erinnerung mit neuem Token und geprüfter PDF-Datei" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 9: Neue Fassung und Ersetzen der alten AB beim Senden

Files:

  • Modify: server/services/auftraege/auftraege.js
  • Test: test/auftraegeAbFolge.test.js (Tests anhängen)

Interfaces:

  • Consumes: VEREINBARTE_STATUS, sperreAuftrag(id) (konstanten.js, V2); listAuftraege({ kundenId }), createAuftrag, updateAuftrag, getAuftrag (L0); umhaengen(auftragIdAlt, auftragIdNeu) → Promise<number> (L0 projektRechnungenStore.js); sendeAuftrag aus Task 7; withLocks (server/services/keyedLock.js).

  • Produces: neueFassung(id, { von } = {}) → Promise<Auftrag> (neuer Entwurf mit ersetztId, unter sperreAuftrag(id) der alten AB); sendeAuftrag ersetzt beim Senden einer neuen Fassung die alte AB (verschachtelt unter sperreAuftrag(alt.id), mit erneuter Prüfung in der Sperre); intern pruefeErsetzteAb(entwurf) → Promise<Auftrag>, ersetzeAlteAb(entwurf) → Promise<void>.

  • Schritt 1: Failing Tests ans Ende von test/auftraegeAbFolge.test.js anhängen

test('neueFassung: Kopie als Entwurf mit ersetztId, ohne Nummer, Schnappschuss, PDF und Token; die alte AB bleibt unberührt', async () => {
  const alt = await gesendet(baueDeps().deps)
  const neu = await neueFassung(alt.id, { von: 'justin@webklar.com' })
  assert.deepEqual([neu.status, neu.ersetztId, neu.nummer, neu.kunde, neu.erstelltVon], ['entwurf', alt.id, '', null, 'justin@webklar.com'])
  assert.ok(!neu.pdfDatei && !neu.tokenHash && !neu.gesendetAm)
  for (const feld of ['kundenId', 'projektId', 'titel', 'zeitquelle', 'art', 'positionen', 'stundensatzCents', 'schaetzungMinuten', 'summeCents', 'zahlungsplan', 'zahlungszielTage', 'auftragVom', 'auftragWeg', 'startAm', 'fertigBis']) {
    assert.deepEqual(neu[feld], alt[feld], feld)
  }
  assert.deepEqual(await getAuftrag(alt.id), alt)
})

test('neueFassung: nur für versendete, bestätigte und abgerechnete (409); je AB nur ein offener Entwurf', async () => {
  const alt = await gesendet(baueDeps().deps)
  for (const status of ['entwurf', 'storniert', 'ersetzt', 'widerrufen']) {
    await updateAuftrag(alt.id, { status })
    await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.keineNeueFassung, status)
  }
  for (const status of ['bestaetigt', 'abgerechnet', 'versendet']) {
    await updateAuftrag(alt.id, { status })
    const neu = await neueFassung(alt.id, { von: VON })
    await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.schonEntwurf, status)
    await loescheEntwurf(neu.id)
  }
  await abgelehnt(neueFassung('a_gibtsnicht', { von: VON }), 404, MELDUNG.auftragFehlt)
})

test('Senden der neuen Fassung: die alte AB wird ersetzt, ihre Rechnungen wandern mit, der alte Link bleibt auffindbar', async () => {
  const { deps } = baueDeps()
  const alt = await gesendet(deps)
  await createProjektRechnung('in_Anzahlung1', {
    kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
    rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(),
  })
  const entwurf = await neueFassung(alt.id, { von: VON })
  await aendereEntwurf(entwurf.id, { positionen: [{ beschreibung: 'Webdesign Startseite', menge: 1, einzelpreisCents: 70000 }] })
  const { auftrag: neu } = await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: new Date('2026-10-07T10:00:00Z') })
  assert.deepEqual([neu.nummer, neu.status, neu.ersetztId, neu.summeCents], ['AB-2026-0002', 'versendet', alt.id, 70000])
  const altNachher = await getAuftrag(alt.id)
  assert.deepEqual([altNachher.status, altNachher.ersetztDurchId, altNachher.tokenHash], ['ersetzt', neu.id, alt.tokenHash])
  assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id)
  assert.equal((await getProjektRechnung('in_Anzahlung1')).auftragId, neu.id)
})

test('Senden der neuen Fassung: ist die alte AB inzwischen storniert, gibt es 409 vor der Nummer', async () => {
  const { deps, calls } = baueDeps()
  const alt = await gesendet(deps)
  const neu = await neueFassung(alt.id, { von: VON })
  await updateAuftrag(alt.id, { status: 'storniert' })
  await abgelehnt(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig)
  assert.equal(calls.pdf.length, 1) // nur die PDF der ersten AB
  const danach = await getAuftrag(neu.id)
  assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
  assert.equal((await getAuftrag(alt.id)).status, 'storniert')
})

test('Senden der neuen Fassung: scheitert das Speichern erst nach dem Ersetzen der alten AB, führt die Wiederholung alles zu Ende', async () => {
  mock.method(console, 'error', () => {})
  const { deps } = baueDeps()
  const alt = await gesendet(deps)
  await createProjektRechnung('in_Anzahlung2', {
    kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
    rechnungsnummer: 'WEBKLAR-0008', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(),
  })
  const neu = await neueFassung(alt.id, { von: VON })
  // erster PATCH auf auftraege: alte AB ersetzt (geht durch); zweiter PATCH: Speichern der neuen AB (scheitert)
  aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500, after: 1 })
  await assert.rejects(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), (err) => err.status === 500)
  assert.equal((await getAuftrag(neu.id)).status, 'entwurf')
  assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt
  const { auftrag } = await sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT })
  assert.equal(auftrag.nummer, 'AB-2026-0002')
  assert.deepEqual([(await getAuftrag(alt.id)).status, (await getAuftrag(alt.id)).ersetztDurchId], ['ersetzt', neu.id])
  assert.equal((await getProjektRechnung('in_Anzahlung2')).auftragId, neu.id)
})

test('Senden der neuen Fassung wartet auf die Sperre der alten AB und prüft sie danach noch einmal (L5 widerruft gerade)', async () => {
  const { deps, calls } = baueDeps()
  const alt = await gesendet(deps)
  const neu = await neueFassung(alt.id, { von: VON })
  const lauf = await unterFremderSperre(alt.id, {
    aktion: () => sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }),
    bereit: () => calls.pdf.length === 2, // das PDF der neuen AB ist fertig, als Nächstes kommt das Ersetzen
    waehrend: async () => {
      assert.equal((await getAuftrag(alt.id)).status, 'versendet') // noch nicht ersetzt
      await updateAuftrag(alt.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
    },
  })
  assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.ersetzteUngueltig])
  const danach = await getAuftrag(neu.id)
  assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
  assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt
  assert.equal((await getAuftrag(alt.id)).status, 'widerrufen')
})

test('Senden: zeigt die ersetztId eines Entwurfs auf ihn selbst (beschädigte Daten), gibt es 409 statt eines Hängers an der eigenen Sperre', { timeout: 5000 }, async () => {
  const { deps, calls } = baueDeps()
  const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
  await updateAuftrag(entwurf.id, { ersetztId: entwurf.id })
  await abgelehnt(sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig)
  assert.deepEqual([calls.pdf.length, calls.mail.length], [0, 0])
  const danach = await getAuftrag(entwurf.id)
  assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
  assert.deepEqual(dateienIn(uploadDir), [])
})
  • Schritt 2: Tests laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbFolge.test.js Expected: FAIL (neueFassung is not a function).

  • Schritt 3: In auftraege.js ergänzen und sendeAuftrag anpassen

  • neueFassung(id, { von } = {}) (alles in withLocks(sperreAuftrag(id), …), der Sperre der alten AB: je alter AB gibt es so nie zwei gleichzeitige Entwürfe, und ihr Status ändert sich währenddessen nicht, auch nicht durch L5 oder Storno): alt = await ladeAuftrag(id); Status nicht in VEREINBARTE_STATUS → 409 Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.; gibt es in listAuftraege({ kundenId: alt.kundenId }) einen Entwurf mit ersetztId === alt.id → 409 Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.; sonst createAuftrag({ kundenId, projektId, titel, zeitquelle, art, positionen, stundensatzCents, schaetzungMinuten, summeCents, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, ersetztId: alt.id, erstelltVon: String(von || '') }) mit den Feldwerten von alt. Nummer, Kunden-Schnappschuss, PDF, Token, Versand- und Bestätigungsfelder werden nicht kopiert.

  • pruefeErsetzteAb(entwurf) (intern): zeigt entwurf.ersetztId auf den Entwurf selbst (beschädigte Daten), sofort dieselbe 409-Meldung wie unten, ohne Lesen: sonst würde ersetzeAlteAb den Schlüssel sperreAuftrag(entwurf.id), den sendeAuftrag schon hält, noch einmal sperren und hängen. Sonst alt = await getAuftrag(entwurf.ersetztId); gültig sind Status in VEREINBARTE_STATUS oder ersetzt mit ersetztDurchId === entwurf.id (die Wiederholung nach einem Fehler); sonst (auch bei null) 409 Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen. Liefert alt.

  • ersetzeAlteAb(entwurf) (intern): alles in withLocks(sperreAuftrag(entwurf.ersetztId), …): alt = await pruefeErsetzteAb(entwurf) (Stand in der Sperre neu geprüft); ist alt.status !== 'ersetzt', dann updateAuftrag(alt.id, { status: 'ersetzt', ersetztDurchId: entwurf.id }); danach immer umhaengen(alt.id, entwurf.id). Der tokenHash der alten AB bleibt unverändert.

  • sendeAuftrag ändern: nach Schritt 4 if (entwurf.ersetztId) await pruefeErsetzteAb(entwurf) (frühe Prüfung vor Nummer und PDF); im try-Block der Schritte 9/10 unmittelbar vor dem updateAuftrag der neuen AB if (entwurf.ersetztId) await ersetzeAlteAb(entwurf) (dort ist die Sperre der alten AB verschachtelt in den Schlüsseln, die sendeAuftrag hält; es ist ein anderer Schlüssel, weil pruefeErsetzteAb schon vorher ersetztId === id ausgeschlossen hat). Scheitert die Prüfung dort, räumt der bestehende catch die Datei auf.

  • Schritt 4: Tests laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbFolge.test.js test/auftraegeSenden.test.js Expected: 12 Tests in auftraegeAbFolge (5 aus Task 8, 7 neue) und 13 in auftraegeSenden grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeAbFolge.test.js
git commit -m "AB-Kern: Neue Fassung und Ersetzen der alten AB beim Senden" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 10: Stornieren

Files:

  • Modify: server/services/auftraege/auftraege.js
  • Test: test/auftraegeAbFolge.test.js (Tests anhängen)

Interfaces:

  • Consumes: ladeAuftrag, updateAuftrag, withLocks, sperreAuftrag(id) (konstanten.js, V2).

  • Produces: storniereAuftrag(id) → Promise<Auftrag> (unter sperreAuftrag(id); Status storniert, storniertAm gesetzt; keine Mail; tokenHash, Nummer und PDF bleiben).

  • Schritt 1: Failing Tests ans Ende von test/auftraegeAbFolge.test.js anhängen

test('storniereAuftrag: versendet und bestätigt werden storniert (ohne Mail); Token, Nummer und PDF bleiben, damit der Link „storniert“ erklären kann', async () => {
  const { deps, calls } = baueDeps()
  const a = await gesendet(deps)
  const b = await storniereAuftrag(a.id)
  assert.equal(b.status, 'storniert')
  assert.ok(Math.abs(new Date(b.storniertAm).getTime() - Date.now()) < 60_000)
  assert.deepEqual([b.nummer, b.tokenHash, b.pdfDatei], [a.nummer, a.tokenHash, a.pdfDatei])
  assert.equal((await findAuftragByTokenHash(a.tokenHash)).id, a.id)
  assert.equal(calls.mail.length, 1) // nur die Mail des Sendens
  const c = await gesendet(deps)
  await updateAuftrag(c.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
  assert.equal((await storniereAuftrag(c.id)).status, 'storniert')
})

test('storniereAuftrag: sonst 409 (auch ein zweites Mal), unbekannt 404', async () => {
  const a = await gesendet(baueDeps().deps)
  for (const status of ['entwurf', 'abgerechnet', 'ersetzt', 'storniert', 'widerrufen']) {
    await updateAuftrag(a.id, { status })
    await abgelehnt(storniereAuftrag(a.id), 409, MELDUNG.keinStorno, status)
  }
  await abgelehnt(storniereAuftrag('a_gibtsnicht'), 404, MELDUNG.auftragFehlt)
})

test('storniereAuftrag wartet auf die Sperre der AB (L5 hält sie beim Widerruf) und sieht danach den neuen Stand', async () => {
  const a = await gesendet(baueDeps().deps)
  const lauf = await unterFremderSperre(a.id, {
    aktion: () => storniereAuftrag(a.id),
    waehrend: async () => {
      assert.equal((await getAuftrag(a.id)).status, 'versendet') // noch nicht storniert
      await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
    },
  })
  assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinStorno])
  assert.equal((await getAuftrag(a.id)).status, 'widerrufen')
})
  • Schritt 2: Tests laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbFolge.test.js Expected: FAIL (storniereAuftrag is not a function).

  • Schritt 3: storniereAuftrag(id) in auftraege.js implementieren

In withLocks(sperreAuftrag(id), …) (konstanten.js; dieselbe Sperre wie bei L2 und L5, nicht SPERRE_NUMMER: gesendete ABs ändert L3 nur unter ihr): auftrag = await ladeAuftrag(id) (der Stand wird in der Sperre gelesen); Status nicht versendet und nicht bestaetigt → 409 Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.; updateAuftrag(id, { status: 'storniert', storniertAm: new Date().toISOString() }). Keine Mail, tokenHash und alle anderen Felder bleiben unberührt. Rückgabe: der Auftrag.

  • Schritt 4: Tests laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbFolge.test.js Expected: 15 Tests grün (5 aus Task 8, 7 aus Task 9, 3 neue).

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeAbFolge.test.js
git commit -m "AB-Kern: Stornieren versendeter und bestätigter Aufträge" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 11: Projekt nachträglich zuordnen (ordneProjektZu)

Files:

  • Modify: server/services/auftraege/auftraege.js
  • Test: test/auftraegeAbFolge.test.js (Tests anhängen)

Interfaces:

  • Consumes: ladeAuftrag, istDokumentId, ticketsDesProjekts (Tasks 3 und 4); hatText (Task 6); getDocument (appwriteAdmin.js) mit config.collections.websiteProjects; updateAuftrag (L0 auftragStore.js); listProjektRechnungen({ auftragId, nurAktiv }), updateProjektRechnung(invoiceId, patch) (L0 projektRechnungenStore.js); withLocks (server/services/keyedLock.js); sperreAuftrag(id) (konstanten.js, V2).

  • Produces: ordneProjektZu(id, projektId) → Promise<Auftrag> (Nachtrag der Planrunde, Übersicht V5 „L3“, Entscheidung 13): setzt projektId und bei leerer zeitquelle das Ticket des Projekts und hängt die Projektrechnungen der AB ohne Projekt um, alles unter sperreAuftrag(id); PDF, Nummer, Status und Token bleiben. Die Route POST /:id/projekt dazu kommt in Task 13.

  • Schritt 1: Failing Tests ans Ende von test/auftraegeAbFolge.test.js anhängen

const OHNE_PROJEKT = { projektId: '', titel: 'Beratung ohne Projekt' }
const ZWEITES_PROJEKT = 'p_zweites02'
const legeZweitesProjektAn = () => aw.seed(config.collections.websiteProjects, projektDoc({ $id: ZWEITES_PROJEKT, projectName: 'Zweites Projekt', ticketId: ZWEITES_TICKET }))
/** Alle Felder außer den genannten (für den Vergleich „sonst hat sich nichts geändert“). */
const ohne = (auftrag, ...felder) => Object.fromEntries(Object.entries(auftrag).filter(([name]) => !felder.includes(name)))

test('ordneProjektZu: eine versendete AB ohne Projekt bekommt Projekt und Zeitquelle des Projekts; Nummer, Status, PDF, Prüfsumme, Token und Versanddaten bleiben', async () => {
  const { deps, calls } = baueDeps()
  const alt = await gesendet(deps, OHNE_PROJEKT)
  assert.deepEqual([alt.projektId, alt.zeitquelle], ['', []])
  const neu = await ordneProjektZu(alt.id, PROJEKT_ID)
  assert.deepEqual([neu.projektId, neu.zeitquelle], [PROJEKT_ID, [TICKET_ID]])
  assert.deepEqual(ohne(neu, 'projektId', 'zeitquelle', 'geaendertAm'), ohne(alt, 'projektId', 'zeitquelle', 'geaendertAm'))
  assert.deepEqual(await getAuftrag(alt.id), neu)
  assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id) // der Link gilt weiter
  assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // keine neue Datei
  assert.deepEqual(await lesePdf(alt.pdfDatei), Buffer.from(`%PDF-1.4 ${alt.nummer}`))
  assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [1, 1, 1]) // nichts neu erzeugt, keine Mail
})

/** Projektrechnung einer AB, wie L2 sie anlegt (hier ohne Projekt); die Rechnungs-ID muss zu /^in_\w{1,33}$/ passen. */
const projektRechnung = (auftragId, patch = {}) => ({
  kundenId: KUNDE_ID, projektId: '', auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
  rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, ...patch,
})

test('ordneProjektZu: hängt auch die Projektrechnungen der AB ohne Projekt um (aktive und stornierte); schon gesetzte Projekte, andere ABs und alle übrigen Felder bleiben', async () => {
  const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
  const andere = await gesendet(baueDeps().deps, OHNE_PROJEKT)
  await createProjektRechnung('in_OhneProjekt1', projektRechnung(a.id))
  await createProjektRechnung('in_OhneProjekt2', projektRechnung(a.id, { aktiv: false, rechnungsnummer: '' })) // storniert
  await createProjektRechnung('in_MitProjekt', projektRechnung(a.id, { projektId: 'p_anderes', art: 'aufwand' }))
  await createProjektRechnung('in_AndereAb', projektRechnung(andere.id))
  const mitProjekt = await getProjektRechnung('in_MitProjekt')
  const vorher = await getProjektRechnung('in_OhneProjekt1')
  await ordneProjektZu(a.id, PROJEKT_ID)
  const nachher = await getProjektRechnung('in_OhneProjekt1')
  assert.equal(nachher.projektId, PROJEKT_ID)
  assert.deepEqual(ohne(nachher, 'projektId'), ohne(vorher, 'projektId')) // sonst hat sich nichts geändert
  assert.equal((await getProjektRechnung('in_OhneProjekt2')).projektId, PROJEKT_ID, 'auch eine stornierte Projektrechnung')
  assert.deepEqual(await getProjektRechnung('in_MitProjekt'), mitProjekt) // schon ein anderes Projekt: bleibt
  assert.equal((await getProjektRechnung('in_AndereAb')).projektId, '') // andere AB: bleibt
  assert.equal((await getAuftrag(a.id)).projektId, PROJEKT_ID)
})

test('ordneProjektZu: scheitert das Umhängen einer Projektrechnung, bleibt die AB ohne Projekt, und die Wiederholung führt alles zu Ende', async () => {
  mock.method(console, 'error', () => {})
  const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
  await createProjektRechnung('in_Wiederholung1', projektRechnung(a.id))
  await createProjektRechnung('in_Wiederholung2', projektRechnung(a.id))
  aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500, after: 1 }) // die erste geht durch, die zweite scheitert
  await assert.rejects(ordneProjektZu(a.id, PROJEKT_ID), (err) => err.status === 500)
  assert.deepEqual([(await getAuftrag(a.id)).projektId, (await getProjektRechnung('in_Wiederholung1')).projektId, (await getProjektRechnung('in_Wiederholung2')).projektId], ['', PROJEKT_ID, ''])
  assert.equal((await ordneProjektZu(a.id, PROJEKT_ID)).projektId, PROJEKT_ID) // kein 409: die AB hatte noch kein Projekt
  for (const id of ['in_Wiederholung1', 'in_Wiederholung2']) assert.equal((await getProjektRechnung(id)).projektId, PROJEKT_ID, id)
})

test('ordneProjektZu: auch ein Entwurf bekommt das Projekt (der Titel bleibt); eine gewählte Zeitquelle bleibt; ein Projekt ohne Ticket ergibt keine Leereinträge', async () => {
  const entwurf = await legeEntwurfAn(festpreisEingabe({ ...OHNE_PROJEKT, titel: 'Freier Titel', zeitquelle: [ZWEITES_TICKET] }), { von: VON })
  const a = await ordneProjektZu(entwurf.id, PROJEKT_ID)
  assert.deepEqual([a.status, a.nummer, a.projektId, a.titel, a.zeitquelle], ['entwurf', '', PROJEKT_ID, 'Freier Titel', [ZWEITES_TICKET]])
  aw.seed(config.collections.websiteProjects, projektDoc({ $id: 'p_ohneticket', projectName: 'Projekt ohne Ticket', ticketId: '' }))
  const frei = await legeEntwurfAn(festpreisEingabe(OHNE_PROJEKT), { von: VON })
  const b = await ordneProjektZu(frei.id, 'p_ohneticket')
  assert.deepEqual([b.projektId, b.zeitquelle], ['p_ohneticket', []])
})

test('ordneProjektZu: fehlende oder ungültige Kennung, Projekt eines anderen Kunden und unbekanntes Projekt sind 400, eine unbekannte AB ist 404 – nichts ändert sich', async () => {
  const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
  const faelle = [
    ['Kennung fehlt', undefined, MELDUNG.projektFehlt],
    ['Kennung nur Leerzeichen', '  ', MELDUNG.projektFehlt],
    ['Kennung keine Zeichenkette', 5, MELDUNG.projektFehlt],
    ['Kennung mit ../', '../x', MELDUNG.projektUngueltig],
    ['Projekt eines anderen Kunden', FREMD.projekt, MELDUNG.projektFremd],
    ['unbekanntes Projekt', 'p_gibtsnicht', MELDUNG.projektFremd],
  ]
  for (const [name, projektId, meldung] of faelle) await abgelehnt(ordneProjektZu(a.id, projektId), 400, meldung, name)
  await abgelehnt(ordneProjektZu('a_gibtsnicht', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
  await abgelehnt(ordneProjektZu('../x', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
  assert.deepEqual(await getAuftrag(a.id), a)
})

test('ordneProjektZu: eine AB mit Projekt (auch dasselbe) sowie eine stornierte, ersetzte oder widerrufene AB lassen sich nicht zuordnen (409); versendet, bestätigt und abgerechnet gehen', async () => {
  const mit = await gesendet(baueDeps().deps) // gehört schon zu PROJEKT_ID
  legeZweitesProjektAn()
  await abgelehnt(ordneProjektZu(mit.id, PROJEKT_ID), 409, MELDUNG.schonProjekt, 'dasselbe Projekt')
  await abgelehnt(ordneProjektZu(mit.id, ZWEITES_PROJEKT), 409, MELDUNG.schonProjekt, 'anderes Projekt')
  await abgelehnt(ordneProjektZu(mit.id, FREMD.projekt), 409, MELDUNG.schonProjekt, 'fremdes Projekt: der Stand der AB gilt zuerst')
  assert.deepEqual(await getAuftrag(mit.id), mit)
  const frei = await gesendet(baueDeps().deps, OHNE_PROJEKT)
  for (const status of ['storniert', 'ersetzt', 'widerrufen']) {
    await updateAuftrag(frei.id, { status })
    await abgelehnt(ordneProjektZu(frei.id, PROJEKT_ID), 409, MELDUNG.keinProjekt, status)
  }
  assert.equal((await getAuftrag(frei.id)).projektId, '')
  for (const status of ['versendet', 'bestaetigt', 'abgerechnet']) {
    await updateAuftrag(frei.id, { status, projektId: '', zeitquelle: [] })
    assert.equal((await ordneProjektZu(frei.id, PROJEKT_ID)).projektId, PROJEKT_ID, status)
  }
})

test('ordneProjektZu wartet auf die Sperre der AB und prüft den Stand danach (L5 widerruft gerade: 409)', async () => {
  const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
  const lauf = await unterFremderSperre(a.id, {
    aktion: () => ordneProjektZu(a.id, PROJEKT_ID),
    waehrend: async () => {
      assert.equal((await getAuftrag(a.id)).projektId, '') // noch nichts geschrieben
      await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
    },
  })
  assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinProjekt])
  assert.equal((await getAuftrag(a.id)).projektId, '')
})

test('ordneProjektZu: zwei Aufrufe gleichzeitig (zwei Admins) – genau einer gelingt, der andere ist 409', async () => {
  const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
  legeZweitesProjektAn()
  const ergebnisse = await Promise.allSettled([ordneProjektZu(a.id, PROJEKT_ID), ordneProjektZu(a.id, ZWEITES_PROJEKT)])
  assert.deepEqual(ergebnisse.map((r) => r.status).sort(), ['fulfilled', 'rejected'])
  const abgewiesen = ergebnisse.find((r) => r.status === 'rejected').reason
  assert.deepEqual([abgewiesen.status, abgewiesen.message], [409, MELDUNG.schonProjekt])
  const gewinner = ergebnisse.find((r) => r.status === 'fulfilled').value
  assert.deepEqual(await getAuftrag(a.id), gewinner)
})
  • Schritt 2: Tests laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbFolge.test.js Expected: FAIL in den acht neuen Tests (ordneProjektZu is not a function); die 15 früheren bleiben grün.

  • Schritt 3: ordneProjektZu in auftraege.js implementieren

ordneProjektZu(id, projektId) (async, Kopfkommentar auf Deutsch; kein Zeitparameter: die Zuordnung braucht keinen Zeitwert, updateAuftrag setzt geaendertAm selbst, Vertragsfrage 15):

  1. Kennung prüfen, vor jedem Zugriff: projektId keine Zeichenkette oder nach dem Trimmen leer → 400 Projekt: Bitte ein Projekt angeben.; keine istDokumentId → 400 Projekt: Ungültige Projekt-Kennung.
  2. Alles Weitere in withLocks(sperreAuftrag(id), async () => { … }) (dieselbe Sperre wie bei Senden, Bestätigen, Widerruf, Storno und Erinnerung; die Prüfungen laufen in der Sperre, nur so sieht der zweite von zwei gleichzeitigen Aufrufen den Stand des ersten), in dieser Reihenfolge:
    • auftrag = await ladeAuftrag(id) (404 Auftragsbestätigung nicht gefunden.);
    • Status storniert, ersetzt oder widerrufen → 409 Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.;
    • hatText(auftrag.projektId) → 409 Diese Auftragsbestätigung gehört schon zu einem Projekt.;
    • projekt = await getDocument(config.collections.websiteProjects, projektId): ein Appwrite-404 gilt wie „gehört einem anderen“, jeder andere Fehler wird weitergereicht; fehlt das Projekt oder ist projekt.customerId !== auftrag.kundenId → 400 Projekt: Das Projekt gehört nicht zu diesem Kunden.;
    • Projektrechnungen umhängen: für jede Projektrechnung aus await listProjektRechnungen({ auftragId: id, nurAktiv: false }) (auch stornierte) mit projektId === '': await updateProjektRechnung(rechnung.invoiceId, { projektId }); eine mit schon gesetzter, auch anderer projektId bleibt unverändert; die Stripe-Metadaten der Rechnungen bleiben unverändert (kein Stripe-Aufruf, Vertragsfrage 16);
    • zuletzt die AB: patch = { projektId }; ist auftrag.zeitquelle leer, zusätzlich patch.zeitquelle = ticketsDesProjekts(projekt); return await updateAuftrag(id, patch). Die AB kommt zuletzt, damit ein Fehler beim Umhängen sie ohne Projekt lässt: ein zweiter Aufruf scheitert dann nicht an „schon ein Projekt“, überspringt die schon umgehängten Projektrechnungen (sie tragen ein Projekt) und führt alles zu Ende, wie das Ersetzen der alten AB (Entscheidung 5).
  3. Sonst geschieht nichts: kein PDF, keine Datei, kein Token, keine Mail, kein Stripe-Zugriff. Titel, Nummer, Status, Kunden-Schnappschuss, PDF-Datei und Prüfsumme, Token und Versandfelder der AB bleiben unberührt, von den Projektrechnungen nur deren projektId. Rückgabe: der gespeicherte Auftrag.
  • Schritt 4: Tests laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAbFolge.test.js Expected: 23 Tests grün (5 aus Task 8, 7 aus Task 9, 3 aus Task 10, 8 neue).

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/services/auftraege/auftraege.js test/auftraegeAbFolge.test.js
git commit -m "AB-Kern: Projekt nachträglich einer Auftragsbestätigung zuordnen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 12: Admin-Routen – Lesen, Anlegen, Ändern, Löschen, PDF

Files:

  • Modify: server/routes/admin/auftraege.js (Gerüst aus L0)
  • Test: test/auftraegeRoutesAb.test.js (neu; Task 13 hängt Tests an)

Interfaces:

  • Consumes: aus auftraege.js: ladeAuftrag, ladeKunde, legeEntwurfAn, aendereEntwurf, loescheEntwurf, vorschauPdf, ladeGespeichertesPdf, setzeDepsFuerTests (Tests); auftraegeAktiv(kunde) (schalter.js); SCHALTER_AUS_MELDUNG (konstanten.js); AuftragError, sendeFehler(res, was, err) (fehler.js); auftragFuerTeam(auftrag) → AuftragTeam (auftragSicht.js); listAuftraege({ kundenId }); requireAppwriteStaff (im Gerüst).

  • Produces (Mountpfad /api/admin/finanzen/auftraege, von L0 in server/index.js eingehängt): GET /?kunde=<id> → { auftraege: AuftragTeam[] }; POST / → { auftrag }; GET|PATCH /:id → { auftrag }; DELETE /:id → { ok: true }; GET /:id/pdf → application/pdf. Fehler immer { error }.

  • Schritt 1: Failing Test test/auftraegeRoutesAb.test.js schreiben (der Dateikopf enthält schon alles, was Task 13 braucht):

import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, FREMD, VON, MELDUNG, festpreisEingabe, aufwandEingabe, kundeDoc, baueDeps } from './helpers/auftraegeAb.js'

const umg = await starteUmgebung()
const { aw, config } = umg
const { startTestApp, send } = await import('./support/testApp.js')
const { default: router } = await import('../server/routes/admin/auftraege.js')
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js')
const { legeEntwurfAn, sendeAuftrag, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js')
const app = await startTestApp({ '/api/admin/finanzen/auftraege': router })
after(async () => {
  await app.close()
  await umg.schliesse()
})

let kontext // Attrappen für PDF, Token und Mail des aktuellen Tests
beforeEach(async () => {
  await umg.zuruecksetzen()
  kontext = baueDeps()
  setzeDepsFuerTests(kontext.deps)
})
const BASIS = '/api/admin/finanzen/auftraege'
const api = (methode, pfad, body, jwt = 'jwt-admin') => send(methode, BASIS + pfad, { headers: { Authorization: `Bearer ${jwt}` }, body })
const entwurfAnlegen = (patch) => legeEntwurfAn(festpreisEingabe(patch), { von: VON })

const ZUGRIFF = [
  ['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/'], ['GET', '/a_1'], ['PATCH', '/a_1'], ['DELETE', '/a_1'], ['GET', '/a_1/pdf'],
  ['POST', '/a_1/senden'], ['POST', '/a_1/erinnern'], ['POST', '/a_1/neue-fassung'], ['POST', '/a_1/stornieren'], ['POST', '/a_1/projekt'],
]

test('Zugriff: ohne JWT 401, ohne Admin-Label 403 – bei jeder Route', async () => {
  for (const [methode, pfad] of ZUGRIFF) {
    const body = ['GET', 'DELETE'].includes(methode) ? undefined : {}
    assert.equal((await send(methode, BASIS + pfad, { body })).status, 401, `${methode} ${pfad} ohne JWT`)
    for (const jwt of ['jwt-staff', 'jwt-kunde']) assert.equal((await api(methode, pfad, body, jwt)).status, 403, `${methode} ${pfad} mit ${jwt}`)
  }
})

test('Schalter: ist die Funktion für den Kunden aus, antworten Liste, Anlegen, Lesen, Ändern, Löschen und PDF mit 404 und der festen Meldung', async () => {
  const a = await entwurfAnlegen() // angelegt, solange der Schalter an ist
  config.auftraege.mode = 'off'
  const routen = [['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/', festpreisEingabe()], ['GET', `/${a.id}`], ['PATCH', `/${a.id}`, { titel: 'x' }], ['DELETE', `/${a.id}`], ['GET', `/${a.id}/pdf`]]
  for (const [methode, pfad, body] of routen) {
    const r = await api(methode, pfad, body)
    assert.deepEqual([r.status, r.body], [404, { error: SCHALTER_AUS_MELDUNG }], `${methode} ${pfad}`)
  }
  assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel') // nichts wurde geändert oder gelöscht
  config.auftraege.mode = 'pilot'
  config.auftraege.pilotEmails = ['anna@example.invalid'] // E-Mail des Kunden
  assert.equal((await api('GET', `/${a.id}`)).status, 200)
  config.auftraege.pilotEmails = ['jemand@example.invalid']
  assert.equal((await api('GET', `/${a.id}`)).status, 404)
})

test('GET Liste: Aufträge des Kunden, neueste zuerst, ohne interne Felder; 400 ohne Kunde, 404 bei unbekanntem Kunden', async () => {
  const a = await entwurfAnlegen()
  const b = await legeEntwurfAn(aufwandEingabe(), { von: VON })
  const r = await api('GET', `/?kunde=${KUNDE_ID}`)
  assert.equal(r.status, 200)
  assert.deepEqual(r.body.auftraege.map((x) => x.id), [b.id, a.id])
  for (const x of r.body.auftraege) {
    for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in x, false, feld)
    assert.equal(typeof x.pdfVorhanden, 'boolean')
  }
  const ohneKunde = await api('GET', '/')
  assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde fehlt.' }])
  assert.equal((await api('GET', '/?kunde=c_gibtsnicht')).status, 404)
  assert.equal((await api('GET', '/?kunde=../x')).status, 404)
})

test('POST: legt einen Entwurf an (200, AuftragTeam mit Texten); Fehler als { error } mit festen Meldungen', async () => {
  const r = await api('POST', '/', festpreisEingabe())
  assert.equal(r.status, 200)
  const a = r.body.auftrag
  assert.deepEqual([a.status, a.titel, a.summeCents, a.erstelltVon], ['entwurf', 'Website Beispiel', 80000, 'kenso@webklar.com'])
  assert.deepEqual([typeof a.zahlungsplanText, a.pdfVorhanden, 'tokenHash' in a], ['string', false, false])
  const ohnePositionen = await api('POST', '/', festpreisEingabe({ positionen: [] }))
  assert.deepEqual([ohnePositionen.status, ohnePositionen.body], [400, { error: MELDUNG.positionen }])
  const ohneKunde = await api('POST', '/', festpreisEingabe({ kundenId: undefined }))
  assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde: Bitte einen Kunden angeben.' }])
  assert.equal((await api('POST', '/', festpreisEingabe({ kundenId: 'c_gibtsnicht' }))).status, 404)
})

test('GET, PATCH, DELETE: Entwurf lesen, ändern, löschen; gesendete AB ist unveränderlich (409); ungültige Kennungen 404', async () => {
  const a = await entwurfAnlegen()
  const gelesen = await api('GET', `/${a.id}`)
  assert.deepEqual([gelesen.status, gelesen.body.auftrag.id], [200, a.id])
  const geaendert = await api('PATCH', `/${a.id}`, { titel: 'Neu' })
  assert.deepEqual([geaendert.status, geaendert.body.auftrag.titel], [200, 'Neu'])
  const falsch = await api('PATCH', `/${a.id}`, { zahlungszielTage: 99 })
  assert.deepEqual([falsch.status, falsch.body], [400, { error: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.' }])
  const geloescht = await api('DELETE', `/${a.id}`)
  assert.deepEqual([geloescht.status, geloescht.body], [200, { ok: true }])
  assert.deepEqual((await api('GET', `/${a.id}`)).body, { error: MELDUNG.auftragFehlt })
  for (const kennung of ['a%20b', 'x'.repeat(40), '..%2F..']) assert.equal((await api('GET', `/${kennung}`)).status, 404, kennung)
  const b = await entwurfAnlegen()
  await sendeAuftrag(b.id, { von: VON })
  const patch = await api('PATCH', `/${b.id}`, { titel: 'Nachträglich' })
  assert.deepEqual([patch.status, patch.body], [409, { error: MELDUNG.nurEntwurfAendern }])
  const loeschen = await api('DELETE', `/${b.id}`)
  assert.deepEqual([loeschen.status, loeschen.body], [409, { error: MELDUNG.nurEntwurfLoeschen }])
})

test('GET pdf: Entwurf als Vorschau, gesendete AB aus der gespeicherten Datei, mit Kopfzeilen', async () => {
  const entwurf = await entwurfAnlegen()
  const vorschau = await api('GET', `/${entwurf.id}/pdf`)
  assert.equal(vorschau.status, 200)
  assert.equal(vorschau.body, '%PDF-1.4 Entwurf')
  assert.match(vorschau.headers.get('content-type'), /^application\/pdf/)
  assert.equal(vorschau.headers.get('content-disposition'), 'inline; filename="Auftragsbestaetigung-Entwurf.pdf"')
  assert.equal(vorschau.headers.get('cache-control'), 'no-store')
  const { auftrag } = await sendeAuftrag(entwurf.id, { von: VON }) // Attrappen aus setzeDepsFuerTests
  const gespeichert = await api('GET', `/${entwurf.id}/pdf`)
  assert.equal(gespeichert.body, `%PDF-1.4 ${auftrag.nummer}`)
  assert.equal(gespeichert.headers.get('content-disposition'), `inline; filename="${auftrag.nummer}.pdf"`)
  assert.equal((await api('GET', '/a_gibtsnicht/pdf')).status, 404)
})

test('Unerwartete Fehler: 500 mit fester Meldung, nichts von Appwrite gelangt an die Seite', async () => {
  mock.method(console, 'error', () => {})
  const a = await entwurfAnlegen()
  aw.failNext({ collection: config.collections.auftraege, method: 'GET', status: 500, message: 'GEHEIM-Appwrite-Detail' })
  const r = await api('GET', `/${a.id}`)
  assert.deepEqual([r.status, r.body], [500, { error: 'Interner Fehler.' }])
})
  • Schritt 2: Test laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesAb.test.js Expected: FAIL in allen Tests außer „Zugriff“ (der ist schon grün, weil das Gerüst requireAppwriteStaff mitbringt; die übrigen Routen antworten noch 404 ohne Inhalt).

  • Schritt 3: Router in server/routes/admin/auftraege.js füllen

Das Gerüst bleibt (Router, requireAppwriteStaff, export default router); fehlt router.use(requireAppwriteStaff), als Erstes ergänzen. Importe ergänzen: auftraegeAktiv, SCHALTER_AUS_MELDUNG, AuftragError, sendeFehler, auftragFuerTeam, listAuftraege, aus auftraege.js: ladeAuftrag, ladeKunde, legeEntwurfAn, aendereEntwurf, loescheEntwurf, vorschauPdf, ladeGespeichertesPdf. Kopfkommentar auf Deutsch (Routen, Verweis auf Übersicht V5 „L3“). Hilfen im Router:

  • ladeKundeMitSchalter(kundenId): kunde = await ladeKunde(kundenId); !auftraegeAktiv(kunde) → AuftragError(404, SCHALTER_AUS_MELDUNG); Rückgabe kunde.
  • ladeMitSchalter(id): auftrag = await ladeAuftrag(id); await ladeKundeMitSchalter(auftrag.kundenId); Rückgabe auftrag.

Handler – jeder in try { … } catch (err) { return sendeFehler(res, '<Was>', err) }, Antworten immer 200 mit JSON, von = req.appwriteUser?.email:

  • GET /: req.query.kunde keine nicht leere Zeichenkette → 400 { error: 'Kunde fehlt.' }; ladeKundeMitSchalter; { auftraege: (await listAuftraege({ kundenId })).map(auftragFuerTeam) }.

  • POST /: ist req.body?.kundenId eine nicht leere Zeichenkette, zuerst ladeKundeMitSchalter (404 bei unbekanntem Kunden oder ausgeschaltetem Schalter); dann legeEntwurfAn(req.body, { von }); { auftrag: auftragFuerTeam(…) }.

  • GET /:id: ladeMitSchalter(req.params.id); { auftrag: auftragFuerTeam(auftrag) }.

  • PATCH /:id: ladeMitSchalter; aendereEntwurf(req.params.id, req.body ?? {}); { auftrag }.

  • DELETE /:id: ladeMitSchalter; loescheEntwurf(req.params.id); { ok: true }.

  • GET /:id/pdf: auftrag = await ladeMitSchalter(req.params.id); Status entwurf → vorschauPdf(auftrag.id), sonst ladeGespeichertesPdf(auftrag); Kopfzeilen Content-Type: application/pdf, Content-Disposition: inline; filename="<Name>", Cache-Control: no-store; Name: bei Entwurf Auftragsbestaetigung-Entwurf.pdf, sonst <nummer>.pdf, wenn nummer auf /^AB-\d{4}-\d{4}$/ passt, sonst Auftragsbestaetigung.pdf; Antwort res.send(buffer).

  • Schritt 4: Test laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesAb.test.js Expected: 7 Tests grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/routes/admin/auftraege.js test/auftraegeRoutesAb.test.js
git commit -m "AB-Kern: Admin-Routen zum Lesen, Anlegen, Ändern, Löschen und für das PDF" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 13: Admin-Routen – Senden, Erinnern, Neue Fassung, Stornieren, Projekt zuordnen

Files:

  • Modify: server/routes/admin/auftraege.js
  • Test: test/auftraegeRoutesAb.test.js (Tests anhängen)

Interfaces:

  • Consumes: sendeAuftrag(id, { von }), erinnereAuftrag(id), neueFassung(id, { von }), storniereAuftrag(id), ordneProjektZu(id, projektId) (Tasks 6–11); ladeMitSchalter, auftragFuerTeam, sendeFehler (Task 12).

  • Produces: POST /:id/senden und POST /:id/erinnern → { auftrag: AuftragTeam, mailFehler: boolean }; POST /:id/neue-fassung, POST /:id/stornieren und POST /:id/projekt (Body { projektId }) → { auftrag: AuftragTeam }; Fehlerstatus 400/409/500 aus den Diensten (V5).

  • Schritt 1: Failing Tests ans Ende von test/auftraegeRoutesAb.test.js anhängen

test('POST senden: 200 ohne Token und Dateipfad; Kundendaten-, PDF- und Statusfehler mit festen Meldungen; Mail-Fehler als mailFehler: true', async () => {
  mock.method(console, 'error', () => {})
  const a = await entwurfAnlegen()
  aw.seed(config.collections.customers, kundeDoc({ email: '' }))
  let r = await api('POST', `/${a.id}/senden`)
  assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.keineMail }])
  aw.seed(config.collections.customers, kundeDoc({ street: '' }))
  r = await api('POST', `/${a.id}/senden`)
  assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.adresse }])
  aw.seed(config.collections.customers, kundeDoc())
  kontext = baueDeps({ pdfFehler: new Error('pdfkit') })
  setzeDepsFuerTests(kontext.deps)
  r = await api('POST', `/${a.id}/senden`)
  assert.deepEqual([r.status, r.body], [500, { error: MELDUNG.pdf }])
  kontext = baueDeps({ mailFehler: new Error('smtp') })
  setzeDepsFuerTests(kontext.deps)
  r = await api('POST', `/${a.id}/senden`)
  assert.equal(r.status, 200)
  assert.equal(r.body.mailFehler, true)
  assert.deepEqual([r.body.auftrag.status, r.body.auftrag.mailStatus, r.body.auftrag.pdfVorhanden], ['versendet', 'fehler', true])
  assert.match(r.body.auftrag.nummer, /^AB-\d{4}-0001$/)
  for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in r.body.auftrag, false, feld)
  r = await api('POST', `/${a.id}/senden`)
  assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.nurEntwurfSenden }])
})

test('POST erinnern, neue-fassung, stornieren: Antworten, Folgezustände und 409 bei falschem Status', async () => {
  const a = await entwurfAnlegen()
  let r = await api('POST', `/${a.id}/erinnern`)
  assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keineErinnerung }])
  r = await api('POST', `/${a.id}/senden`)
  assert.deepEqual([r.status, r.body.mailFehler, r.body.auftrag.status], [200, false, 'versendet'])
  r = await api('POST', `/${a.id}/erinnern`)
  assert.deepEqual([r.status, r.body.mailFehler, r.body.auftrag.status], [200, false, 'versendet'])
  assert.equal(kontext.calls.mail.at(-1).erinnerung, true)
  r = await api('POST', `/${a.id}/neue-fassung`)
  assert.deepEqual([r.status, r.body.auftrag.status, r.body.auftrag.ersetztId], [200, 'entwurf', a.id])
  r = await api('POST', `/${a.id}/neue-fassung`)
  assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.schonEntwurf }])
  r = await api('POST', `/${a.id}/stornieren`)
  assert.deepEqual([r.status, r.body.auftrag.status], [200, 'storniert'])
  r = await api('POST', `/${a.id}/stornieren`)
  assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keinStorno }])
})

test('POST projekt: ordnet einer AB ohne Projekt das Projekt zu (200, AuftragTeam); 400 ohne oder mit fremdem Projekt, 404 bei unbekannter AB, 409 bei vorhandenem Projekt oder falschem Status', async () => {
  const a = await entwurfAnlegen({ projektId: '', titel: 'Beratung ohne Projekt' })
  await api('POST', `/${a.id}/senden`)
  let r = await api('POST', `/${a.id}/projekt`, {})
  assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFehlt }])
  r = await api('POST', `/${a.id}/projekt`)
  assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFehlt }])
  r = await api('POST', `/${a.id}/projekt`, { projektId: FREMD.projekt })
  assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFremd }])
  r = await api('POST', '/a_gibtsnicht/projekt', { projektId: PROJEKT_ID })
  assert.deepEqual([r.status, r.body], [404, { error: MELDUNG.auftragFehlt }])
  assert.equal((await getAuftrag(a.id)).projektId, '') // die abgelehnten Aufrufe haben nichts geändert
  r = await api('POST', `/${a.id}/projekt`, { projektId: PROJEKT_ID })
  assert.equal(r.status, 200)
  assert.deepEqual([r.body.auftrag.projektId, r.body.auftrag.zeitquelle, r.body.auftrag.status], [PROJEKT_ID, [TICKET_ID], 'versendet'])
  for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in r.body.auftrag, false, feld)
  r = await api('POST', `/${a.id}/projekt`, { projektId: PROJEKT_ID })
  assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.schonProjekt }])
  const b = await entwurfAnlegen({ projektId: '', titel: 'Zweite Beratung ohne Projekt' })
  await api('POST', `/${b.id}/senden`)
  await api('POST', `/${b.id}/stornieren`)
  r = await api('POST', `/${b.id}/projekt`, { projektId: PROJEKT_ID })
  assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keinProjekt }])
})

test('Schalter aus: auch senden, erinnern, neue-fassung, stornieren und projekt antworten 404 mit der festen Meldung', async () => {
  const a = await entwurfAnlegen({ projektId: '', titel: 'Beratung ohne Projekt' })
  config.auftraege.mode = 'off'
  for (const aktion of ['senden', 'erinnern', 'neue-fassung', 'stornieren', 'projekt']) {
    const r = await api('POST', `/${a.id}/${aktion}`, aktion === 'projekt' ? { projektId: PROJEKT_ID } : undefined)
    assert.deepEqual([r.status, r.body], [404, { error: SCHALTER_AUS_MELDUNG }], aktion)
  }
  const danach = await getAuftrag(a.id)
  assert.deepEqual([danach.status, danach.projektId], ['entwurf', ''])
  assert.equal(kontext.calls.pdf.length + kontext.calls.mail.length, 0)
})
  • Schritt 2: Tests laufen lassen – erwartet FAIL

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesAb.test.js Expected: FAIL in den vier neuen Tests (die Aktionsrouten gibt es noch nicht); die sieben aus Task 12 bleiben grün.

  • Schritt 3: Fünf Handler in server/routes/admin/auftraege.js ergänzen

Importe aus auftraege.js ergänzen: sendeAuftrag, erinnereAuftrag, neueFassung, storniereAuftrag, ordneProjektZu. Jeder Handler ruft zuerst ladeMitSchalter(req.params.id) (404 bei unbekannter AB und bei ausgeschaltetem Schalter), antwortet 200 und meldet Fehler über sendeFehler; von = req.appwriteUser?.email:

  • POST /:id/senden → ergebnis = await sendeAuftrag(id, { von }) → { auftrag: auftragFuerTeam(ergebnis.auftrag), mailFehler: ergebnis.mailFehler }.

  • POST /:id/erinnern → erinnereAuftrag(id) → dieselbe Antwortform.

  • POST /:id/neue-fassung → neueFassung(id, { von }) → { auftrag: auftragFuerTeam(neu) }.

  • POST /:id/stornieren → storniereAuftrag(id) → { auftrag: auftragFuerTeam(…) }.

  • POST /:id/projekt → ordneProjektZu(id, req.body?.projektId) → { auftrag: auftragFuerTeam(…) } (die Kennung prüft der Dienst: 400 bei fehlender, ungültiger oder fremder Kennung, 409 bei schon vorhandenem Projekt oder falschem Status; ein fehlender Body ist dasselbe wie eine fehlende Kennung).

  • Schritt 4: Tests laufen lassen – erwartet PASS

Run: cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeRoutesAb.test.js Expected: 11 Tests grün.

  • Schritt 5: Commit
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git add server/routes/admin/auftraege.js test/auftraegeRoutesAb.test.js
git commit -m "AB-Kern: Admin-Routen zum Senden, Erinnern, für neue Fassungen, zum Stornieren und zum Zuordnen eines Projekts" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 14: Selbstprüfung

Files: keine (Prüfung, kein Commit).

  • Schritt 1: Alle eigenen Tests laufen lassen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
free -m
EIGENE_TESTS="test/auftraegeAbVertrag.test.js test/auftraegeNummern.test.js test/auftraegeAbEingabe.test.js test/auftraegeEntwurf.test.js test/auftraegeAbVorschau.test.js test/auftraegeSenden.test.js test/auftraegeAbFolge.test.js test/auftraegeRoutesAb.test.js"
timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 $EIGENE_TESTS

Erwartet: tests 82, pass 82, fail 0 (5 + 6 + 9 + 12 + 3 + 13 + 23 + 11). Ein roter Test: Ursache klären (Code oder L0-Abweichung), nie die Erwartung abschwächen.

  • Schritt 2: Nur eigene Dateien geändert, Arbeitsbaum sauber
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
git diff --name-only feature/auftraege...HEAD
git log --oneline feature/auftraege..HEAD
git status --short

Erwartet: genau diese zwölf Dateien (Reihenfolge egal)

server/routes/admin/auftraege.js
server/services/auftraege/auftraege.js
server/services/auftraege/nummern.js
test/auftraegeAbEingabe.test.js
test/auftraegeAbFolge.test.js
test/auftraegeAbVertrag.test.js
test/auftraegeAbVorschau.test.js
test/auftraegeEntwurf.test.js
test/auftraegeNummern.test.js
test/auftraegeRoutesAb.test.js
test/auftraegeSenden.test.js
test/helpers/auftraegeAb.js

dazu dreizehn Commits (Tasks 1 bis 13) und eine leere Ausgabe von git status --short: der Symlink node_modules aus Task 1 wird von der .gitignore aus L0 ignoriert (Eintrag node_modules ohne Schrägstrich, Übersicht, Nachträge L0). Steht dort ?? node_modules, fehlt der Eintrag in L0: melden, nicht hier ändern. Jede andere Datei: zurücknehmen, sie gehört einem anderen Strang.

  • Schritt 3: Aufräumprüfungen
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l3
EIGENE_TESTS="test/auftraegeAbVertrag.test.js test/auftraegeNummern.test.js test/auftraegeAbEingabe.test.js test/auftraegeEntwurf.test.js test/auftraegeAbVorschau.test.js test/auftraegeSenden.test.js test/auftraegeAbFolge.test.js test/auftraegeRoutesAb.test.js"
command grep -a -n -E "\.only\(|console\.log|debugger|TODO|FIXME" server/services/auftraege/auftraege.js server/services/auftraege/nummern.js server/routes/admin/auftraege.js test/helpers/auftraegeAb.js $EIGENE_TESTS
command grep -a -n -i -E "stripe" server/services/auftraege/auftraege.js server/services/auftraege/nummern.js server/routes/admin/auftraege.js
command grep -a -n -E "createDocument|deleteDocument|collections\.(auftraege|projektRechnungen)" server/services/auftraege/auftraege.js server/services/auftraege/nummern.js server/routes/admin/auftraege.js
command grep -a -n "updateDocument" server/services/auftraege/auftraege.js
command grep -a -n -E "node:fs|node:path|from 'fs'" server/services/auftraege/auftraege.js server/services/auftraege/nummern.js server/routes/admin/auftraege.js
command grep -a -n -E "loeschePdf|sperreRechnung|'auftraege-ab'" server/services/auftraege/auftraege.js server/services/auftraege/nummern.js server/routes/admin/auftraege.js test/helpers/auftraegeAb.js $EIGENE_TESTS
command grep -a -n "withLocks(" server/services/auftraege/auftraege.js
for text in "Auftragsbestätigungen nur für feste Kunden." "Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson)." "Für den Versand fehlt die E-Mail-Adresse des Kunden." "Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen." "Das PDF konnte nicht erzeugt werden."; do
  printf '%s Treffer: %s\n' "$(command grep -a -c -F "$text" server/services/auftraege/auftraege.js)" "$text"
done

Erwartet: die ersten drei grep-Befehle ohne Ausgabe; updateDocument nur in der Zeile von schreibeKundenart (mit config.collections.customers); der Befehl mit node:fs ohne Ausgabe (PDFs nur über ablage.js); der Befehl mit loeschePdf|sperreRechnung|'auftraege-ab' ohne Ausgabe (nur entfernePdf, kein Rechnungsschlüssel, keine selbst gebauten Sperrschlüssel); in den Treffern von withLocks( steht überall sperreAuftrag( (im Senden zusammen mit SPERRE_NUMMER), nirgends ein Schlüssel als Literal; jeder der fünf Texte mindestens ein Treffer.

  • Schritt 4: Vertragsliste abhaken

  • V4 formatNummer(jahr, laufnummer) und vergibNummer(jetzt): AB-JJJJ-NNNN, Berliner Jahr, größte Laufnummer plus 1 (Task 2).

  • V4 pruefeEingabe(eingabe, { teilweise }) mit Feldmeldungen und den Grenzen aus LIMITS (Task 3 »Ablehnungen …«, »Grenzwerte …«).

  • V4 legeEntwurfAn, aendereEntwurf, loescheEntwurf: Projekt und Tickets gehören zum Kunden, Titel = Projektname, kundenart am Kunden gesetzt, nur Entwürfe änderbar (Task 4).

  • V4 vorschauPdf(id, { deps }): nummer: 'Entwurf', Schnappschuss des Live-Kunden, vorschau: true, nichts gespeichert (Task 5).

  • V4 sendeAuftrag(id, { von, deps }): Reihenfolge (Übersicht, Nachträge L3) Sperren SPERRE_NUMMER + sperreAuftrag(id) → darin Eingabe und Kunde prüfen → Nummer → PDF → speicherePdf → Token → Speichern (versendet) → Sperren frei → Mail → mailStatus; Antwort { auftrag, mailFehler }; Mail-Fehler: versendet + fehler; PDF-Fehler: 500, keine Nummer (Tasks 6, 7).

  • Prüfreihenfolge Interessent → Kundenart → E-Mail → Anschrift mit den vier festen Meldungen (Task 6, Review Focus 1).

  • V4 erinnereAuftrag(id, { deps }): neuer Token, alter ungültig, erinnerung: true; nach Mail-Fehler ohne Hinweis (Task 8).

  • V4 neueFassung(id, { von }): Kopie mit ersetztId; beim Senden alte AB ersetzt + ersetztDurchId, umhaengen(alt, neu) (Task 9).

  • V4 storniereAuftrag(id): nur versendet/bestaetigt, tokenHash bleibt (Task 10).

  • V2 Statusübergänge von L3: entwurf → versendet, versendet|bestaetigt → storniert, versendet|bestaetigt|abgerechnet → ersetzt; keine anderen (Tasks 6, 9, 10).

  • V5 ordneProjektZu(id, projektId) (ohne Zeitparameter) und POST …/auftraege/:id/projekt (Nachtrag der Planrunde): nur ohne Projekt und nicht storniert/ersetzt/widerrufen (sonst 409), das Projekt gehört zum Kunden (sonst 400), setzt projektId und bei leerer Zeitquelle [projekt.ticketId] ohne Leereinträge, hängt die Projektrechnungen der AB mit leerer projektId um (auch stornierte, andere Projekte und andere ABs bleiben, Stripe-Metadaten bleiben; zuerst die Projektrechnungen, zuletzt die AB), PDF, Nummer, Token und Status bleiben, alles unter sperreAuftrag(id), Schalter wie bei den anderen Routen (Tasks 11, 13).

  • V5 L3: elf Endpunkte (Liste, Anlegen, Lesen, Ändern, Löschen, PDF, Projekt zuordnen, Senden, Erinnern, Neue Fassung, Stornieren), Admin-only (401/403), Schalter aus → 404 SCHALTER_AUS_MELDUNG, Fehler { error } über sendeFehler, Antworten über auftragFuerTeam ohne tokenHash, pdfDatei, bestaetigtPdfSha256 (Tasks 12, 13).

  • PDF-Route: Entwurf = Vorschau, sonst die gespeicherte Datei mit Prüfsumme (Task 12, Task 8).

  • Sperren (V2, zusammengesetzte Einzel-Schlüssel aus konstanten.js): Senden hält [...SPERRE_NUMMER, ...sperreAuftrag(id)] bis zum Speichern, die Mail läuft außerhalb (Task 7); Ändern, Löschen, Neue Fassung, Storno, Erinnerung, Projekt zuordnen und das Ersetzen der alten AB (verschachtelt, anderer Schlüssel) laufen unter sperreAuftrag(id) wie bei L5, nie unter sperreRechnung (Tasks 4, 7–11); eine ersetztId, die auf den Entwurf selbst zeigt, hängt nicht (Task 9).

  • PDF-Fehler: AuftragError der PDF-Funktion wird durchgereicht, alles andere ist 500 Das PDF konnte nicht erzeugt werden.; nach einem Fehler nach speicherePdf räumt ablage.entfernePdf auf (Tasks 5, 7).

  • Keine Änderung an fremden Dateien, kein Push, kein Stripe-Aufruf (Schritte 2 und 3).

  • Schritt 5: Abschlussbericht an den Orchestrator: Branch, Commit-Liste, Testergebnis (82 grün), Abweichungen von L0-Bausteinen (falls Tests deswegen angepasst werden mussten oder Task 1 etwas gemeldet hat), und die Vertragsfragen unten (soweit sie sich bei der Umsetzung bestätigt haben). Nicht pushen. Die Standard-Importe von ./pdf.js, ./token.js und ./mails.js (L4/L5) prüft erst der Durchlauf-Test in Z1; L3 testet nur mit Attrappen.


Vertragsfragen

Stand nach der Planrunde (Übersicht, Abschnitt „Nachträge nach der Planrunde“) und den Entscheidungen des Koordinators zu den Punkten 15 bis 17: Alle Punkte sind entschieden und werden hier nur noch festgehalten; Punkt 14 ist ein Hinweis, keine Entscheidung nötig. Es sind keine Vertragsfragen offen.

  1. sperreAuftrag und die Sperrschlüssel (V2; L0, L2, L5) – entschieden (siehe Übersicht, V2 und Nachträge „Sperren“). withLocks(keys, fn) behandelt jedes Array-Element als eigenen Schlüssel; deshalb ist jede Sperre ein Array mit genau einem zusammengesetzten Schlüssel: SPERRE_NUMMER = ['auftraege-nummer'], sperreRechnung(kundenId) = ['auftraege-rechnung:<kundenId>'], sperreAuftrag(id) = ['auftraege-ab:<id>']. L3 importiert SPERRE_NUMMER und sperreAuftrag aus konstanten.js (Task 1 pinnt beide), benutzt nie sperreRechnung und nimmt innerhalb von sperreAuftrag(id) außer beim Ersetzen der alten AB keine weitere Sperre.
  2. ablage.entfernePdf (Zusatz zu V4) – entschieden (siehe Übersicht, Nachträge L0). Scheitert nach speicherePdf noch das Speichern der AB, bliebe die Datei liegen, und das Senden desselben Entwurfs scheiterte dauerhaft (speicherePdf überschreibt nie). L3 räumt deshalb mit entfernePdf(pdfDatei) auf – nur nach erneutem Lesen mit Status entwurf. Task 1 prüft, dass L0 die Funktion liefert.
  3. Schema-Hilfen für Tests – entschieden (siehe Übersicht, Nachträge L0: AUFTRAEGE_DATETIMES, schemaKeys auch für financeSettings). L3 leitet Schema und Datumsfelder in test/helpers/auftraegeAb.js weiterhin selbst aus AUFTRAEGE_COLLECTIONS ab (Annahme: Attribute { key, type }, Datumsfelder type: 'datetime') und hängt so nur an V1; die Umstellung auf die Exporte von L0 ist möglich, aber nicht nötig.
  4. idx_nummer und „unique“ – entschieden (siehe Übersicht, V1 und Nachträge: nicht eindeutig). Alle Entwürfe tragen nummer = ''; die Eindeutigkeit sichern SPERRE_NUMMER und die Doppelprüfung in vergibNummer. Die Spec (Abschnitt 5) schreibt noch „unique“ und sollte entsprechend angepasst werden.
  5. Vermerk „Ersetzt AB-… vom …“ im PDF (V7 AB_TEXTE.ersetzt) – entschieden wie vorgeschlagen (Rückmeldung der Planrunde); gelöst in L4. erzeugeAuftragPdf liest bei gesetztem ersetztId die alte AB selbst über getAuftrag (Nummer, Berliner Tag von gesendetAm); L3 übergibt nichts Zusätzliches und muss auch nichts tun, solange die alte AB beim Erzeugen noch ihre Nummer und gesendetAm trägt (so ist es: sie wird erst nach dem PDF ersetzt, und ersetzt ändert beides nicht).
  6. Stripe-Metadaten beim Umhängen – entschieden (siehe Übersicht, Nachträge L3: sie wandern nicht mit). Quelle der Wahrheit für „Rechnung ↔ AB“ ist projektRechnungen.auftragId (L3 ruft nur umhaengen); die Stripe-Metadaten auftragId/auftragNummer bleiben historisch (sie spiegeln den aufgedruckten Bezug), und L1/L2 lesen die Zuordnung nie aus ihnen.
  7. Empfänger der Mail K31 (L5) – entschieden (siehe Übersicht, Nachträge L3: gesendetAn = aktuelle Kunden-E-Mail). L3 setzt gesendetAn beim Senden aus dem Schnappschuss und beim erneuten Senden auf die aktuelle E-Mail-Adresse des Kunden (häufigster Grund eines Mail-Fehlers ist eine falsche Adresse); der Schnappschuss kunde.email bleibt eingefroren. L5 nimmt in empfaenger(auftrag) gesendetAn vor kunde.email.
  8. von bei sendeAuftrag – entschieden wie vorgeschlagen (Rückmeldung der Planrunde). V1 hat kein Feld gesendetVon; L3 nimmt von an und speichert es nicht (die Team-BCC der Mail belegt den Versand).
  9. Verhalten der L0-Speicher – entschieden wie vorgeschlagen (Rückmeldung der Planrunde). createAuftrag(daten) füllt fehlende Felder mit den Leerwerten aus V3 und liefert das gespeicherte Dokument (gleich getAuftrag), updateAuftrag(id, patch) nimmt Teil-Patches in V3-Form, lässt andere Felder unberührt und setzt geaendertAm, listAuftraege ist nicht auf 25 Treffer begrenzt, umhaengen zählt die umgehängten Dokumente. Task 1 prüft das; Abweichungen gehören in den Abschlussbericht.
  10. „Erneut senden“ nach Mail-Fehler – entschieden (siehe Übersicht, Nachträge L3: über /erinnern). Die Spec nennt „Erinnerung senden“ und „Erneut senden“, V5 hat nur POST …/erinnern. L3 entscheidet am mailStatus: bei fehler eine gewöhnliche Mail (erinnerung: false), sonst mit Erinnerungs-Hinweis. W2 beschriftet den Knopf entsprechend (mailStatus === 'fehler' → „Erneut senden“).
  11. Gemeinsame Sperre je AB – entschieden (siehe Übersicht, V2 und Nachträge „Sperren“). L5 bestätigt und widerruft, L2 setzt abgerechnet, und L3 ändert gesendete ABs (Storno, Token-Tausch der Erinnerung, Ersetzen, Projekt zuordnen) unter derselben Sperre sperreAuftrag(id) und prüft den Stand in der Sperre neu. Reihenfolge, wenn beide gebraucht werden: erst sperreRechnung, darin sperreAuftrag; innerhalb von sperreAuftrag nie sperreRechnung. Mails (K31, K32, T22) gehen erst nach dem Freigeben der Sperre (sonst würde ein hängender SMTP-Server auch das Senden einer neuen Fassung blockieren, das auf die Sperre der alten AB wartet und dabei SPERRE_NUMMER hält).
  12. Reihenfolge beim Senden (V4) – entschieden (siehe Übersicht, Nachträge L3: Prüfungen innerhalb der Sperre). V4 nennt die Prüfungen (Eingabe, Kunde) vor der Sperre. L3 führt sie in der Sperre aus (Entscheidung 3): vor jeder Nummer und jedem PDF bleiben sie, aber nur so sieht der zweite von zwei gleichzeitigen Aufrufen den neuen Status. Beobachtbar ist nur, dass auch ein abgelehnter Aufruf kurz auf die Sperre wartet.
  13. Fehler und Hinweise der PDF-Funktion (L4) – entschieden (siehe Übersicht, Nachträge L3: ein AuftragError aus der PDF-Funktion wird durchgereicht). L4 wirft AuftragError(409, 'Entwurf unvollständig: …') (und bei falscher Summe einen weiteren 409); L3 reicht das unverändert durch (Vorschau und Senden, Entscheidung 12); V5 nennt dafür 409 bei GET …/pdf und bei senden. Ist financeSettings.steuernummer leer, lässt L4 die Zeile im Briefkopf weg; L3 sendet trotzdem und meldet nichts (die Antwortform bleibt V5). Anregung für W2: im AB-Dialog einen Hinweis zeigen, wenn GET …/einstellungen die Steuernummer leer liefert.
  14. Bekannte Grenze (Hinweis, kein Vertragsproblem). Bricht der Server mitten im Senden einer neuen Fassung ab – nach dem Ersetzen der alten AB, vor dem Speichern der neuen –, bleibt die alte AB ersetzt und ihre Rechnungen hängen an der Kennung des Entwurfs. Erneutes Senden desselben Entwurfs führt alles zu Ende; wird der Entwurf stattdessen gelöscht, muss die alte AB von Hand zurückgesetzt werden. Vorschlag: so lassen (sehr seltene Kombination, reparierbar).
  15. { jetzt } bei ordneProjektZu – entschieden (Rückmeldung der Planrunde): gestrichen. Die Signatur ist ordneProjektZu(id, projektId): die Zuordnung braucht keinen Zeitwert (updateAuftrag setzt geaendertAm selbst, ein Datumsfeld „zugeordnet am“ gibt es nicht); Signatur, Tests und Texte kennen den Parameter nicht.
  16. Rechnungen einer AB ohne Projekt beim Zuordnen – entschieden (Rückmeldung der Planrunde): ordneProjektZu hängt die Projektrechnungen mit um. L2 erlaubt eine Anzahlungsrechnung auch zu einer AB ohne Projekt; der Eintrag in projektRechnungen hat dann projektId: '' (L2-Test „AB ohne Projekt …“). Jede Projektrechnung der AB (listProjektRechnungen({ auftragId, nurAktiv: false }), auch stornierte) mit leerer projektId bekommt per updateProjektRechnung(invoiceId, { projektId }) das Projekt, innerhalb derselben sperreAuftrag(id); eine mit anderer, schon gesetzter projektId und die Rechnungen anderer ABs bleiben, die Stripe-Metadaten bleiben unverändert (kein Stripe-Aufruf). Die Projektrechnungen kommen zuerst, die AB zuletzt: scheitert etwas dazwischen, hat die AB noch kein Projekt, und die Wiederholung führt alles zu Ende (Task 11, Test »… scheitert das Umhängen …«). W2 darf „Projekt zuordnen“ deshalb unabhängig von gebuchten Rechnungen anbieten. Zwei seltene Grenzen, keine Maßnahme nötig: Legt L2 genau in dem Moment eine Anzahlungsrechnung an (unter sperreRechnung, nicht unter sperreAuftrag), kann deren Eintrag ohne Projekt bleiben und erscheint unter „Ohne Projekt“; wählt der Admin nach einem Fehler beim Umhängen ein anderes Projekt, behalten die schon umgehängten Rechnungen das erste (von Hand korrigierbar).
  17. Meldungen der neuen Route – entschieden (Rückmeldung der Planrunde: so festgehalten). V5 nennt für POST …/auftraege/:id/projekt nur die Statuscodes. L3 legt fest (Tabelle „Meldungstexte“): 400 Projekt: Bitte ein Projekt angeben., Projekt: Ungültige Projekt-Kennung. oder Projekt: Das Projekt gehört nicht zu diesem Kunden. (auch bei unbekanntem Projekt), 404 Auftragsbestätigung nicht gefunden. (oder die Schalter-Meldung), 409 Diese Auftragsbestätigung gehört schon zu einem Projekt. oder Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen. Prüfreihenfolge: Kennung (400) → AB und Schalter (404) → Status (409) → vorhandenes Projekt (409) → Projekt und Kunde (400). W2 zeigt den Text des Servers, braucht also keine eigene Übersetzung; der Z-Test D17 darf auf diese Statuscodes bauen.