AB-Kern: Senden mit Prüfungen vor der Nummer, Nummer, PDF, Ablage, Token und Mail
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a46210e9c
commit
ed7567397d
2 files changed
+161
-1
No files matched your search
@@ -10,10 +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 { createAuftrag, deleteAuftrag, getAuftrag, updateAuftrag } from './auftragStore.js'
|
||||
import { AuftragError } from './fehler.js'
|
||||
import { vergibNummer } from './nummern.js'
|
||||
import { TEXTE_FASSUNG } from './texte.js'
|
||||
import {
|
||||
AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
|
||||
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
|
||||
} from './konstanten.js'
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
@@ -353,3 +356,44 @@ export async function vorschauPdf(id, { deps } = {}) {
|
||||
const { steuernummer, bankDetailsText } = await getSettings()
|
||||
return pdfErzeugen(d, { ...auftrag, nummer: 'Entwurf', kunde: kundeSnapshot(kundeDoc) }, { settings: { steuernummer, bankDetailsText }, vorschau: true })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Senden
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Prüfreihenfolge: fester Kunde, Kundenart, E-Mail, Anschrift (jeweils mit fester Meldung). */
|
||||
function pruefeKundeFuerVersand(kundeDoc) {
|
||||
const status = String(kundeDoc.customerStatus || '').trim().toLowerCase()
|
||||
if (status === 'lead' || status === 'lost') throw new AuftragError(400, 'Auftragsbestätigungen nur für feste Kunden.')
|
||||
if (!KUNDENARTEN.includes(String(kundeDoc.kundenart || '').trim())) {
|
||||
throw new AuftragError(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).')
|
||||
}
|
||||
if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
|
||||
if (!hatText(kundeDoc.street) || !hatText(kundeDoc.postalCode) || !hatText(kundeDoc.city)) {
|
||||
throw new AuftragError(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendeAuftrag(id, { von, deps, jetzt = new Date() } = {}) {
|
||||
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(),
|
||||
})
|
||||
await d.sendeAuftragMail(gespeichert, { pdf, token })
|
||||
const final = await updateAuftrag(entwurf.id, { mailStatus: 'gesendet', mailFehler: '' })
|
||||
return { auftrag: final, mailFehler: false }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user