From cf87a7e1e0e90330b4f7d0408566fa38f35936a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:15:11 +0000 Subject: [PATCH] AB-Kern: Neue Fassung und Ersetzen der alten AB beim Senden Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/auftraege.js | 53 ++++++++++++- test/auftraegeAbFolge.test.js | 105 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/server/services/auftraege/auftraege.js b/server/services/auftraege/auftraege.js index 815cd77..d104a4e 100644 --- a/server/services/auftraege/auftraege.js +++ b/server/services/auftraege/auftraege.js @@ -12,12 +12,13 @@ import { getSettings } from '../finance/settings.js' import { withLocks } from '../keyedLock.js' import { kundeSnapshot } from './auftragSicht.js' import { entfernePdf, lesePdf, speicherePdf } from './ablage.js' -import { createAuftrag, deleteAuftrag, getAuftrag, updateAuftrag } from './auftragStore.js' +import { createAuftrag, deleteAuftrag, getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js' import { AuftragError } from './fehler.js' import { vergibNummer } from './nummern.js' +import { umhaengen } from './projektRechnungenStore.js' import { TEXTE_FASSUNG } from './texte.js' import { - AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN, + AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN, } from './konstanten.js' // --------------------------------------------------------------------------------------------------------------------- @@ -407,6 +408,33 @@ async function versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) return { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' } } +const MELDUNG_ERSETZTE_UNGUELTIG = 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.' + +/** + * Prüft die AB, die der Entwurf ersetzen soll: gültig sind versendet, bestätigt, abgerechnet oder (bei der Wiederholung + * nach einem Fehler) schon durch genau diesen Entwurf ersetzt. Zeigt ersetztId auf den Entwurf selbst (beschädigte Daten), + * gibt es sofort 409, damit ersetzeAlteAb nicht den schon gehaltenen Schlüssel noch einmal sperrt. + */ +async function pruefeErsetzteAb(entwurf) { + if (entwurf.ersetztId === entwurf.id) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG) + const alt = await getAuftrag(entwurf.ersetztId) + const gueltig = alt && (VEREINBARTE_STATUS.includes(alt.status) || (alt.status === 'ersetzt' && alt.ersetztDurchId === entwurf.id)) + if (!gueltig) throw new AuftragError(409, MELDUNG_ERSETZTE_UNGUELTIG) + return alt +} + +/** + * Ersetzt die alte AB (Status ersetzt + ersetztDurchId) und hängt ihre Projektrechnungen um – vor dem Speichern der neuen + * AB, wiederholbar. Der Stand der alten AB wird unter ihrer eigenen Sperre noch einmal geprüft; ihr tokenHash bleibt. + */ +async function ersetzeAlteAb(entwurf) { + await withLocks(sperreAuftrag(entwurf.ersetztId), async () => { + const alt = await pruefeErsetzteAb(entwurf) + if (alt.status !== 'ersetzt') await updateAuftrag(alt.id, { status: 'ersetzt', ersetztDurchId: entwurf.id }) + await umhaengen(alt.id, entwurf.id) + }) +} + export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) { void von // wird nicht gespeichert (V1 hat kein Feld dafür); die Team-Kopie der Mail belegt den Versand const d = await ladeDeps(deps, ['erzeugeAuftragPdf', 'neuerToken', 'sendeAuftragMail']) @@ -417,6 +445,7 @@ export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) { pruefeEingabe(eingabeAusAuftrag(entwurf)) const kundeDoc = await ladeKunde(entwurf.kundenId) pruefeKundeFuerVersand(kundeDoc) + if (entwurf.ersetztId) await pruefeErsetzteAb(entwurf) // früh: vor Nummer und PDF const kunde = kundeSnapshot(kundeDoc) const { steuernummer, bankDetailsText } = await getSettings() const { nummer, jahr, laufnummer } = await vergibNummer(jetzt) @@ -433,6 +462,7 @@ export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) { } try { const neu = d.neuerToken(jetzt) + if (entwurf.ersetztId) await ersetzeAlteAb(entwurf) const gespeichertesDokument = await updateAuftrag(entwurf.id, { status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG, pdfDatei: ablage.pdfDatei, pdfSha256: ablage.pdfSha256, gesendetAn: kunde.email, tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), @@ -480,3 +510,22 @@ export async function erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) { }) return versendeMail(d, gespeichert, { pdf, token, erinnerung }) } + +// --------------------------------------------------------------------------------------------------------------------- +// Neue Fassung +// --------------------------------------------------------------------------------------------------------------------- + +/** Legt aus einer versendeten, bestätigten oder abgerechneten AB einen neuen Entwurf an (ersetztId); die alte bleibt. */ +export async function neueFassung(id, { von } = {}) { + return withLocks(sperreAuftrag(id), async () => { + const alt = await ladeAuftrag(id) + if (!VEREINBARTE_STATUS.includes(alt.status)) { + throw new AuftragError(409, 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.') + } + const vorhandene = await listAuftraege({ kundenId: alt.kundenId }) + if (vorhandene.some((a) => a.status === 'entwurf' && a.ersetztId === alt.id)) { + throw new AuftragError(409, 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.') + } + return createAuftrag({ kundenId: alt.kundenId, ...speicherFelder(alt), ersetztId: alt.id, erstelltVon: String(von || '') }) + }) +} diff --git a/test/auftraegeAbFolge.test.js b/test/auftraegeAbFolge.test.js index a19f48c..759744c 100644 --- a/test/auftraegeAbFolge.test.js +++ b/test/auftraegeAbFolge.test.js @@ -119,3 +119,108 @@ test('erinnereAuftrag wartet auf die Sperre der AB und prüft den Stand danach ( assert.equal(calls.mail.length, 1) // nur die Mail des ersten Sendens assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) }) + +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), []) +})