AB-Kern: Neue Fassung und Ersetzen der alten AB beim Senden

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:15:11 +00:00
1 parent 0da5b30119
commit cf87a7e1e0
2 files changed
+156 -2

No files matched your search

+51 -2
View File
@@ -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 || '') })
})
}
+105
View File
@@ -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), [])
})