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>
2448 lines
188 KiB
Markdown
2448 lines
188 KiB
Markdown
# 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**
|
||
|
||
```bash
|
||
free -m
|
||
```
|
||
|
||
Erwartet: Spalte „available“ mindestens 1500 MB, sonst warten.
|
||
|
||
- [ ] **Schritt 2: Worktree anlegen**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```js
|
||
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):
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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`):
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```js
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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.
|