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:
1 parent
ed7567397d
commit
c74f7eeea7
2 files changed
+181
-21
No files matched your search
@@ -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 })
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user