AB-Kern: Senden unter Sperre, Fehlerpfade, Dateirest und Mail-Fehler

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:14:29 +00:00
1 parent ed7567397d
commit c74f7eeea7
2 files changed
+181 -21

No files matched your search

+68 -21
View File
@@ -10,13 +10,13 @@ import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAd
import { getSettings } from '../finance/settings.js'
import { withLocks } from '../keyedLock.js'
import { kundeSnapshot } from './auftragSicht.js'
import { speicherePdf } from './ablage.js'
import { entfernePdf, speicherePdf } from './ablage.js'
import { createAuftrag, deleteAuftrag, getAuftrag, updateAuftrag } from './auftragStore.js'
import { AuftragError } from './fehler.js'
import { vergibNummer } from './nummern.js'
import { TEXTE_FASSUNG } from './texte.js'
import {
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
} from './konstanten.js'
// ---------------------------------------------------------------------------------------------------------------------
@@ -374,26 +374,73 @@ function pruefeKundeFuerVersand(kundeDoc) {
}
}
/** Entfernt die eben abgelegte PDF, wenn danach etwas scheiterte – aber nur, wenn die AB nachweislich noch ein Entwurf ist. */
async function raeumePdfAuf(auftragId, pdfDatei) {
try {
const aktuell = await getAuftrag(auftragId)
if (aktuell?.status === 'entwurf') await entfernePdf(pdfDatei)
} catch (err) {
logFehler('PDF-Rest entfernen', err)
}
}
/** Fester Text für mailFehler: nie die Meldung von SMTP oder Bibliothek, höchstens ein kurzer Fehlercode. */
function mailFehlerText(err) {
const code = err?.code
return typeof code === 'string' && /^[A-Za-z0-9_.-]{1,40}$/.test(code)
? `E-Mail konnte nicht gesendet werden (${code}).`
: 'E-Mail konnte nicht gesendet werden.'
}
/** Versendet die K31 mit dem gespeicherten Dokument (nach der Sperre) und hält das Ergebnis in mailStatus fest. */
async function versendeMail(d, gespeichert, { pdf, token, erinnerung = false }) {
let mailPatch
try {
await d.sendeAuftragMail(gespeichert, { pdf, token, erinnerung })
mailPatch = { mailStatus: 'gesendet', mailFehler: '' }
} catch (err) {
logFehler('Mailversand', err)
mailPatch = { mailStatus: 'fehler', mailFehler: mailFehlerText(err) }
}
const final = await updateAuftrag(gespeichert.id, mailPatch)
return { auftrag: final, mailFehler: mailPatch.mailStatus === 'fehler' }
}
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'])
const entwurf = await ladeAuftrag(id)
if (entwurf.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich senden.')
pruefeEingabe(eingabeAusAuftrag(entwurf))
const kundeDoc = await ladeKunde(entwurf.kundenId)
pruefeKundeFuerVersand(kundeDoc)
const kunde = kundeSnapshot(kundeDoc)
const { steuernummer, bankDetailsText } = await getSettings()
const { nummer, jahr, laufnummer } = await vergibNummer(jetzt)
const gesendetAm = jetzt.toISOString()
const zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG }
const pdf = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } })
const { pdfDatei, pdfSha256 } = await speicherePdf(entwurf.kundenId, entwurf.id, pdf)
const { token, hash, gueltigBis } = d.neuerToken(jetzt)
const gespeichert = await updateAuftrag(entwurf.id, {
status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG,
pdfDatei, pdfSha256, gesendetAn: kunde.email, tokenHash: hash, tokenGueltigBis: gueltigBis.toISOString(),
// Nummernvergabe und Statuswechsel unter einer Sperre: zwei Klicks ergeben eine Nummer, Ändern und Löschen warten
const { gespeichert, pdf, token } = await withLocks([...SPERRE_NUMMER, ...sperreAuftrag(id)], async () => {
const entwurf = await ladeAuftrag(id)
if (entwurf.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich senden.')
pruefeEingabe(eingabeAusAuftrag(entwurf))
const kundeDoc = await ladeKunde(entwurf.kundenId)
pruefeKundeFuerVersand(kundeDoc)
const kunde = kundeSnapshot(kundeDoc)
const { steuernummer, bankDetailsText } = await getSettings()
const { nummer, jahr, laufnummer } = await vergibNummer(jetzt)
const gesendetAm = jetzt.toISOString()
const zuSenden = { ...entwurf, status: 'versendet', nummer, jahr, laufnummer, kunde, gesendetAm, texteFassung: TEXTE_FASSUNG, agbFassung: AGB_FASSUNG }
const pdfBytes = await pdfErzeugen(d, zuSenden, { settings: { steuernummer, bankDetailsText } })
let ablage
try {
ablage = await speicherePdf(entwurf.kundenId, entwurf.id, pdfBytes)
} catch (err) {
// eine schon vorhandene Datei gehört nicht diesem Aufruf und bleibt unberührt
logFehler('PDF-Ablage', err)
throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')
}
try {
const neu = d.neuerToken(jetzt)
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(),
})
return { gespeichert: gespeichertesDokument, pdf: pdfBytes, token: neu.token }
} catch (err) {
await raeumePdfAuf(entwurf.id, ablage.pdfDatei)
throw err
}
})
await d.sendeAuftragMail(gespeichert, { pdf, token })
const final = await updateAuftrag(entwurf.id, { mailStatus: 'gesendet', mailFehler: '' })
return { auftrag: final, mailFehler: false }
return versendeMail(d, gespeichert, { pdf, token })
}
+113
View File
@@ -114,3 +114,116 @@ test('Senden: nur Entwürfe (409), unbekannte Aufträge (404) und Kunden (404)',
await deleteDocument(config.collections.customers, KUNDE_ID)
await abgelehnt(sende(zweiter.id, deps), 404, MELDUNG.kundeFehlt)
})
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')
})