Merge auftraege/l3-ab-kern: Auftragsbestätigung Kern (Strang L3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
095694bc44
12 files changed
+2330
-2
No files matched your search
@@ -1,11 +1,145 @@
|
||||
/**
|
||||
* Gerüst von Strang L0: Auftragsbestätigungen (Admin). Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router).
|
||||
* L3 füllt: Liste, Anlegen, Ändern, Löschen, PDF, Projekt zuordnen, Senden, Erinnern, Neue Fassung, Stornieren.
|
||||
* Auftragsbestätigungen (Admin), Mount: /api/admin/finanzen/auftraege (vor dem Finanzen-Router, Übersicht V5 „L3“).
|
||||
* GET / (?kunde=), POST /, GET|PATCH|DELETE /:id, GET /:id/pdf, POST /:id/projekt, /:id/senden, /:id/erinnern,
|
||||
* /:id/neue-fassung, /:id/stornieren. Nur dünne HTTP-Schicht: Kunde laden, Schalter prüfen, Dienst aufrufen, Fehler über
|
||||
* sendeFehler. Ist der Schalter für den Kunden aus, antworten alle Routen 404 mit SCHALTER_AUS_MELDUNG.
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { auftraegeAktiv } from '../../services/auftraege/schalter.js'
|
||||
import { SCHALTER_AUS_MELDUNG } from '../../services/auftraege/konstanten.js'
|
||||
import { AuftragError, sendeFehler } from '../../services/auftraege/fehler.js'
|
||||
import { auftragFuerTeam } from '../../services/auftraege/auftragSicht.js'
|
||||
import { listAuftraege } from '../../services/auftraege/auftragStore.js'
|
||||
import {
|
||||
aendereEntwurf, erinnereAuftrag, ladeAuftrag, ladeGespeichertesPdf, ladeKunde, legeEntwurfAn, loescheEntwurf, neueFassung,
|
||||
ordneProjektZu, sendeAuftrag, storniereAuftrag, vorschauPdf,
|
||||
} from '../../services/auftraege/auftraege.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
|
||||
async function ladeKundeMitSchalter(kundenId) {
|
||||
const kunde = await ladeKunde(kundenId)
|
||||
if (!auftraegeAktiv(kunde)) throw new AuftragError(404, SCHALTER_AUS_MELDUNG)
|
||||
return kunde
|
||||
}
|
||||
|
||||
async function ladeMitSchalter(id) {
|
||||
const auftrag = await ladeAuftrag(id)
|
||||
await ladeKundeMitSchalter(auftrag.kundenId)
|
||||
return auftrag
|
||||
}
|
||||
|
||||
const vonAdmin = (req) => req.appwriteUser?.email
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const kundenId = req.query.kunde
|
||||
if (typeof kundenId !== 'string' || kundenId === '') return res.status(400).json({ error: 'Kunde fehlt.' })
|
||||
await ladeKundeMitSchalter(kundenId)
|
||||
return res.json({ auftraege: (await listAuftraege({ kundenId })).map(auftragFuerTeam) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigungen laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const kundenId = req.body?.kundenId
|
||||
if (typeof kundenId === 'string' && kundenId !== '') await ladeKundeMitSchalter(kundenId)
|
||||
return res.json({ auftrag: auftragFuerTeam(await legeEntwurfAn(req.body, { von: vonAdmin(req) })) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung anlegen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
return res.json({ auftrag: auftragFuerTeam(await ladeMitSchalter(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await aendereEntwurf(req.params.id, req.body ?? {})) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung ändern', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
await loescheEntwurf(req.params.id)
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung löschen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const auftrag = await ladeMitSchalter(req.params.id)
|
||||
const entwurf = auftrag.status === 'entwurf'
|
||||
const buffer = entwurf ? await vorschauPdf(auftrag.id) : await ladeGespeichertesPdf(auftrag)
|
||||
const name = entwurf ? 'Auftragsbestaetigung-Entwurf.pdf' : /^AB-\d{4}-\d{4}$/.test(auftrag.nummer) ? `${auftrag.nummer}.pdf` : 'Auftragsbestaetigung.pdf'
|
||||
res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${name}"`, 'Cache-Control': 'no-store' })
|
||||
return res.send(buffer)
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung-PDF laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
/** Antwort der Aktionen, die eine Mail auslösen: { auftrag, mailFehler }. */
|
||||
const mitMail = ({ auftrag, mailFehler }) => ({ auftrag: auftragFuerTeam(auftrag), mailFehler })
|
||||
|
||||
router.post('/:id/senden', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json(mitMail(await sendeAuftrag(req.params.id, { von: vonAdmin(req) })))
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung senden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/erinnern', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json(mitMail(await erinnereAuftrag(req.params.id)))
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung erinnern', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/neue-fassung', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await neueFassung(req.params.id, { von: vonAdmin(req) })) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Neue Fassung anlegen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/stornieren', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await storniereAuftrag(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Auftragsbestätigung stornieren', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:id/projekt', async (req, res) => {
|
||||
try {
|
||||
await ladeMitSchalter(req.params.id)
|
||||
return res.json({ auftrag: auftragFuerTeam(await ordneProjektZu(req.params.id, req.body?.projektId)) })
|
||||
} catch (err) {
|
||||
return sendeFehler(res, 'Projekt zuordnen', err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Abläufe der Auftragsbestätigung (AB): Eingabeprüfung, Entwurf, Vorschau, Senden, Erinnern, neue Fassung, Stornieren
|
||||
* und nachträgliches Zuordnen eines Projekts (Spec Abschnitt 2, Übersicht V4 „L3 – AB-Kern“).
|
||||
* Speicher nur über auftragStore.js und projektRechnungenStore.js, PDFs nur über ablage.js; PDF, Token und Mail kommen
|
||||
* über `deps` (L4/L5, Standard: dynamischer Import beim Aufruf). Jede Änderung an einer bestehenden AB läuft unter
|
||||
* sperreAuftrag(id) (konstanten.js), das Senden zusätzlich unter SPERRE_NUMMER; Mails gehen immer erst nach der Sperre.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
|
||||
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, listAuftraege, updateAuftrag } from './auftragStore.js'
|
||||
import { AuftragError } from './fehler.js'
|
||||
import { vergibNummer } from './nummern.js'
|
||||
import { listProjektRechnungen, umhaengen, updateProjektRechnung } from './projektRechnungenStore.js'
|
||||
import { TEXTE_FASSUNG } from './texte.js'
|
||||
import {
|
||||
AGB_FASSUNG, AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, SPERRE_NUMMER, sperreAuftrag, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
|
||||
} from './konstanten.js'
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Eingabeprüfung (rein, synchron)
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Appwrite-Dokument-ID (wie in auftragStore.js). */
|
||||
export function istDokumentId(wert) {
|
||||
return typeof wert === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(wert)
|
||||
}
|
||||
|
||||
const fehler400 = (meldung) => new AuftragError(400, meldung)
|
||||
const MELDUNG_POSITIONEN = 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.'
|
||||
const MELDUNG_ART = 'Art: Bitte Festpreis oder Nach Aufwand wählen.'
|
||||
const MELDUNG_PLAN = 'Zahlungsplan: Bitte eine Zahlungsart wählen.'
|
||||
const MELDUNG_VOM = 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).'
|
||||
const MELDUNG_WEG = 'Weg: Bitte den Weg der Absprache wählen.'
|
||||
const MELDUNG_SUMME = 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).'
|
||||
const PREIS_GRENZE = 'zwischen 0,01 € und 100.000,00 € liegen.'
|
||||
|
||||
const istEinfachesObjekt = (wert) => Object.prototype.toString.call(wert) === '[object Object]'
|
||||
|
||||
function istKalendertag(wert) {
|
||||
if (typeof wert !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(wert)) return false
|
||||
const datum = new Date(`${wert}T00:00:00Z`)
|
||||
return !Number.isNaN(datum.getTime()) && datum.toISOString().slice(0, 10) === wert
|
||||
}
|
||||
|
||||
function pruefePosition(roh, n) {
|
||||
const p = istEinfachesObjekt(roh) ? roh : {}
|
||||
const beschreibung = typeof p.beschreibung === 'string' ? p.beschreibung.trim() : ''
|
||||
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
|
||||
throw fehler400(`Position ${n}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
|
||||
}
|
||||
if (!Number.isInteger(p.menge) || p.menge < 1 || p.menge > LIMITS.maxMenge) {
|
||||
throw fehler400(`Position ${n}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
|
||||
}
|
||||
if (!Number.isInteger(p.einzelpreisCents) || p.einzelpreisCents < 1 || p.einzelpreisCents > LIMITS.maxEinzelpreisCents) {
|
||||
throw fehler400(`Position ${n}: Der Einzelpreis muss ${PREIS_GRENZE}`)
|
||||
}
|
||||
return { beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents }
|
||||
}
|
||||
|
||||
function pruefePlan(roh) {
|
||||
if (!istEinfachesObjekt(roh) || !ZAHLUNGSPLAN_ARTEN.includes(roh.art)) throw fehler400(MELDUNG_PLAN)
|
||||
let prozent = null
|
||||
let text = ''
|
||||
if (roh.art === 'anzahlung') {
|
||||
prozent = roh.prozent === undefined || roh.prozent === null ? LIMITS.standardAnzahlungProzent : roh.prozent
|
||||
if (!Number.isInteger(prozent) || prozent < 1 || prozent > 99) throw fehler400('Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.')
|
||||
}
|
||||
if (roh.art === 'frei') {
|
||||
text = typeof roh.text === 'string' ? roh.text.trim() : ''
|
||||
if (text.length < 1 || text.length > LIMITS.maxZahlungsplanText) throw fehler400(`Zahlungsplan: Der freie Text braucht 1 bis ${LIMITS.maxZahlungsplanText} Zeichen.`)
|
||||
}
|
||||
return { art: roh.art, prozent, text }
|
||||
}
|
||||
|
||||
function pruefeOptionalesDatum(wert, meldung) {
|
||||
if (wert === null || wert === '') return ''
|
||||
if (!istKalendertag(wert)) throw fehler400(meldung)
|
||||
return wert
|
||||
}
|
||||
|
||||
/** Feldregeln: jedes übergebene Feld (Wert !== undefined) wird geprüft und bereinigt. */
|
||||
const FELDREGELN = {
|
||||
art: (w) => {
|
||||
if (!AUFTRAG_ARTEN.includes(w)) throw fehler400(MELDUNG_ART)
|
||||
return w
|
||||
},
|
||||
projektId: (w) => {
|
||||
if (w === null || w === '') return ''
|
||||
if (!istDokumentId(w)) throw fehler400('Projekt: Ungültige Projekt-Kennung.')
|
||||
return w
|
||||
},
|
||||
titel: (w) => {
|
||||
if (w === null) return ''
|
||||
if (typeof w !== 'string' || w.trim().length > LIMITS.maxTitel) throw fehler400(`Titel: Höchstens ${LIMITS.maxTitel} Zeichen.`)
|
||||
return w.trim()
|
||||
},
|
||||
kundenart: (w) => {
|
||||
if (w === null || w === '') return ''
|
||||
if (!KUNDENARTEN.includes(w)) throw fehler400('Kundenart: Bitte Unternehmen oder Privatperson wählen.')
|
||||
return w
|
||||
},
|
||||
positionen: (w) => {
|
||||
if (!Array.isArray(w) || w.length > LIMITS.maxPositionen) throw fehler400(MELDUNG_POSITIONEN)
|
||||
return w.map((p, i) => pruefePosition(p, i + 1))
|
||||
},
|
||||
stundensatzCents: (w) => {
|
||||
if (w === null) return null
|
||||
if (!Number.isInteger(w) || w < 1 || w > LIMITS.maxEinzelpreisCents) throw fehler400(`Stundensatz: Der Stundensatz muss ${PREIS_GRENZE}`)
|
||||
return w
|
||||
},
|
||||
schaetzungMinuten: (w) => {
|
||||
if (w === null || w === '' || w === 0) return null
|
||||
if (!Number.isInteger(w) || w % VIERTELSTUNDE !== 0 || w < VIERTELSTUNDE || w > LIMITS.maxSchaetzungMinuten) {
|
||||
throw fehler400(`Schätzung: Die Schätzung muss ein Vielfaches von ${VIERTELSTUNDE} Minuten sein (15 bis 99.960).`)
|
||||
}
|
||||
return w
|
||||
},
|
||||
zahlungsplan: pruefePlan,
|
||||
zahlungszielTage: (w) => {
|
||||
if (w === null) return null
|
||||
if (!Number.isInteger(w) || w < 0 || w > LIMITS.maxZahlungszielTage) {
|
||||
throw fehler400(`Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis ${LIMITS.maxZahlungszielTage} Tagen sein.`)
|
||||
}
|
||||
return w
|
||||
},
|
||||
auftragVom: (w) => {
|
||||
if (!istKalendertag(w)) throw fehler400(MELDUNG_VOM)
|
||||
return w
|
||||
},
|
||||
auftragWeg: (w) => {
|
||||
if (typeof w !== 'string' || !Object.hasOwn(AUFTRAG_WEGE, w)) throw fehler400(MELDUNG_WEG)
|
||||
return w
|
||||
},
|
||||
startAm: (w) => pruefeOptionalesDatum(w, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
|
||||
fertigBis: (w) => pruefeOptionalesDatum(w, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
|
||||
zeitquelle: (w) => {
|
||||
if (w === null) return []
|
||||
if (!Array.isArray(w)) throw fehler400('Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.')
|
||||
if (!w.every(istDokumentId)) throw fehler400('Zeitquelle: Ungültige Ticket-Kennung.')
|
||||
const ohneDoppelte = [...new Set(w)]
|
||||
if (ohneDoppelte.length > LIMITS.maxZeitquellen) throw fehler400(`Zeitquelle: Höchstens ${LIMITS.maxZeitquellen} Tickets.`)
|
||||
return ohneDoppelte
|
||||
},
|
||||
}
|
||||
const FELD_REIHENFOLGE = Object.keys(FELDREGELN)
|
||||
|
||||
/**
|
||||
* Prüft und bereinigt die Eingabe einer AB. Nur Felder der Whitelist (FELDREGELN) werden übernommen; status, nummer,
|
||||
* tokenHash & Co. landen nie im Ergebnis. teilweise: nur die übergebenen Felder, ohne Pflicht-, Vorgabe- und Querprüfung.
|
||||
*/
|
||||
export function pruefeEingabe(eingabe, { teilweise = false } = {}) {
|
||||
if (!istEinfachesObjekt(eingabe)) throw fehler400('Ungültige Eingabe.')
|
||||
const felder = {}
|
||||
for (const name of FELD_REIHENFOLGE) {
|
||||
if (eingabe[name] !== undefined) felder[name] = FELDREGELN[name](eingabe[name])
|
||||
}
|
||||
if (teilweise) return felder
|
||||
|
||||
// Pflichtfelder: fehlt eines, kommt dieselbe Meldung wie bei einem ungültigen Wert
|
||||
if (felder.art === undefined) throw fehler400(MELDUNG_ART)
|
||||
if (felder.zahlungsplan === undefined) throw fehler400(MELDUNG_PLAN)
|
||||
if (felder.auftragVom === undefined) throw fehler400(MELDUNG_VOM)
|
||||
if (felder.auftragWeg === undefined) throw fehler400(MELDUNG_WEG)
|
||||
|
||||
const ergebnis = {
|
||||
projektId: felder.projektId ?? '',
|
||||
titel: felder.titel ?? '',
|
||||
kundenart: felder.kundenart ?? '',
|
||||
art: felder.art,
|
||||
positionen: [],
|
||||
stundensatzCents: null,
|
||||
schaetzungMinuten: null,
|
||||
summeCents: null,
|
||||
zahlungsplan: felder.zahlungsplan,
|
||||
zahlungszielTage: felder.zahlungszielTage ?? LIMITS.standardZahlungszielTage,
|
||||
auftragVom: felder.auftragVom,
|
||||
auftragWeg: felder.auftragWeg,
|
||||
startAm: felder.startAm ?? '',
|
||||
fertigBis: felder.fertigBis ?? '',
|
||||
zeitquelle: felder.zeitquelle ?? [],
|
||||
}
|
||||
if (felder.art === 'festpreis') {
|
||||
if (!felder.positionen || felder.positionen.length === 0) throw fehler400(MELDUNG_POSITIONEN)
|
||||
const summe = felder.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
|
||||
if (summe > LIMITS.maxSummeCents) throw fehler400(MELDUNG_SUMME)
|
||||
if (felder.zahlungsplan.art === 'monatlich') throw fehler400('Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.')
|
||||
ergebnis.positionen = felder.positionen
|
||||
ergebnis.summeCents = summe
|
||||
} else {
|
||||
if (felder.zahlungsplan.art === 'anzahlung') throw fehler400('Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
|
||||
ergebnis.stundensatzCents = felder.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
|
||||
ergebnis.schaetzungMinuten = felder.schaetzungMinuten ?? null
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Entwurf anlegen, ändern, löschen
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
|
||||
|
||||
export async function ladeAuftrag(id) {
|
||||
const auftrag = istDokumentId(id) ? await getAuftrag(id) : null
|
||||
if (!auftrag) throw new AuftragError(404, 'Auftragsbestätigung nicht gefunden.')
|
||||
return auftrag
|
||||
}
|
||||
|
||||
export async function ladeKunde(kundenId) {
|
||||
const nichtGefunden = () => new AuftragError(404, 'Kunde nicht gefunden.')
|
||||
if (!istDokumentId(kundenId)) throw nichtGefunden()
|
||||
try {
|
||||
return await getDocument(config.collections.customers, kundenId)
|
||||
} catch (err) {
|
||||
if (err?.status === 404) throw nichtGefunden()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Felder, die die Eingabeprüfung kennt, aus einem gespeicherten Auftrag (ohne kundenId und kundenart). */
|
||||
function eingabeAusAuftrag(auftrag) {
|
||||
const { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle } = auftrag
|
||||
return { projektId, titel, art, positionen, stundensatzCents, schaetzungMinuten, zahlungsplan, zahlungszielTage, auftragVom, auftragWeg, startAm, fertigBis, zeitquelle }
|
||||
}
|
||||
|
||||
/** Das Ticket eines Projekts als Zeitquelle (nie ein Leereintrag). */
|
||||
function ticketsDesProjekts(projekt) {
|
||||
return hatText(projekt?.ticketId) ? [projekt.ticketId.trim()] : []
|
||||
}
|
||||
|
||||
async function ladeProjekt(projektId) {
|
||||
try {
|
||||
return await getDocument(config.collections.websiteProjects, projektId)
|
||||
} catch (err) {
|
||||
if (err?.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Projekt und Tickets müssen dem Kunden gehören; liefert die Felder mit aufgelöstem Titel und aufgelöster Zeitquelle. */
|
||||
async function pruefeBezuege(kundeDoc, felder) {
|
||||
let titel = felder.titel
|
||||
let zeitquelle = felder.zeitquelle
|
||||
let projekt = null
|
||||
if (felder.projektId) {
|
||||
projekt = await ladeProjekt(felder.projektId)
|
||||
if (!projekt || projekt.customerId !== kundeDoc.$id) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.')
|
||||
}
|
||||
if (!titel) titel = String(projekt?.projectName || '').trim().slice(0, LIMITS.maxTitel).trim()
|
||||
if (!titel) throw new AuftragError(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.')
|
||||
if (zeitquelle.length === 0 && projekt) zeitquelle = ticketsDesProjekts(projekt)
|
||||
if (zeitquelle.length > 0) {
|
||||
const tickets = await listDocuments(config.collections.workorders, [Query.equal('$id', zeitquelle), Query.limit(LIMITS.maxZeitquellen)])
|
||||
const gefunden = new Map(tickets.map((t) => [t.$id, t]))
|
||||
if (!zeitquelle.every((id) => gefunden.get(id)?.customerId === kundeDoc.$id)) {
|
||||
throw new AuftragError(400, 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.')
|
||||
}
|
||||
}
|
||||
return { ...felder, titel, zeitquelle }
|
||||
}
|
||||
|
||||
/** Setzt die Kundenart am Kunden, aber nur, wenn dort noch keine steht. */
|
||||
async function schreibeKundenart(kundeDoc, kundenart) {
|
||||
if (!kundenart || hatText(kundeDoc.kundenart)) return
|
||||
await updateDocument(config.collections.customers, kundeDoc.$id, { kundenart })
|
||||
}
|
||||
|
||||
/** Die Felder einer AB, wie createAuftrag und updateAuftrag sie bekommen. */
|
||||
const speicherFelder = (f) => ({
|
||||
projektId: f.projektId, titel: f.titel, zeitquelle: f.zeitquelle, art: f.art, positionen: f.positionen,
|
||||
stundensatzCents: f.stundensatzCents, schaetzungMinuten: f.schaetzungMinuten, summeCents: f.summeCents,
|
||||
zahlungsplan: f.zahlungsplan, zahlungszielTage: f.zahlungszielTage, auftragVom: f.auftragVom, auftragWeg: f.auftragWeg,
|
||||
startAm: f.startAm, fertigBis: f.fertigBis,
|
||||
})
|
||||
|
||||
export async function legeEntwurfAn(eingabe, { von } = {}) {
|
||||
const felder = pruefeEingabe(eingabe)
|
||||
if (!hatText(eingabe.kundenId)) throw new AuftragError(400, 'Kunde: Bitte einen Kunden angeben.')
|
||||
const kundeDoc = await ladeKunde(eingabe.kundenId)
|
||||
const fertig = await pruefeBezuege(kundeDoc, felder)
|
||||
await schreibeKundenart(kundeDoc, felder.kundenart)
|
||||
return createAuftrag({ kundenId: eingabe.kundenId, ...speicherFelder(fertig), erstelltVon: String(von || '') })
|
||||
}
|
||||
|
||||
export async function aendereEntwurf(id, eingabe) {
|
||||
return withLocks(sperreAuftrag(id), async () => {
|
||||
const auftrag = await ladeAuftrag(id)
|
||||
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich ändern.')
|
||||
if (eingabe?.kundenId !== undefined && eingabe.kundenId !== auftrag.kundenId) {
|
||||
throw new AuftragError(400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.')
|
||||
}
|
||||
const teil = pruefeEingabe(eingabe, { teilweise: true })
|
||||
const felder = pruefeEingabe({ ...eingabeAusAuftrag(auftrag), ...teil })
|
||||
const kundeDoc = await ladeKunde(auftrag.kundenId)
|
||||
const fertig = await pruefeBezuege(kundeDoc, felder)
|
||||
await schreibeKundenart(kundeDoc, felder.kundenart)
|
||||
return updateAuftrag(id, speicherFelder(fertig))
|
||||
})
|
||||
}
|
||||
|
||||
export async function loescheEntwurf(id) {
|
||||
return withLocks(sperreAuftrag(id), async () => {
|
||||
const auftrag = await ladeAuftrag(id)
|
||||
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Nur ein Entwurf lässt sich löschen.')
|
||||
await deleteAuftrag(id)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Abhängigkeiten (PDF, Token, Mail) und Vorschau-PDF
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Ins Protokoll nur Art, Status und Code, nie die Meldung (sie kann Adressen oder Pfade enthalten). */
|
||||
function logFehler(was, err) {
|
||||
console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
|
||||
}
|
||||
|
||||
// PDF, Token und Mail entstehen in L4/L5 parallel: Standard ist ein dynamischer Import beim Aufruf, Tests übergeben Attrappen
|
||||
const STANDARD_QUELLEN = { erzeugeAuftragPdf: './pdf.js', neuerToken: './token.js', sendeAuftragMail: './mails.js' }
|
||||
let depsFuerTests = null
|
||||
|
||||
/** Nur für Tests: Ersatz für die Standard-Abhängigkeiten (null stellt die dynamischen Importe wieder her). */
|
||||
export function setzeDepsFuerTests(deps) {
|
||||
depsFuerTests = deps
|
||||
}
|
||||
|
||||
async function ladeDeps(deps, namen) {
|
||||
const quelle = { ...depsFuerTests, ...deps }
|
||||
const geladen = {}
|
||||
for (const name of namen) geladen[name] = quelle[name] ?? (await import(STANDARD_QUELLEN[name]))[name]
|
||||
return geladen
|
||||
}
|
||||
|
||||
/** Erzeugt das PDF; ein AuftragError der PDF-Funktion (z. B. 409 „Entwurf unvollständig“) bleibt erhalten. */
|
||||
async function pdfErzeugen(d, auftrag, optionen) {
|
||||
let pdf
|
||||
try {
|
||||
pdf = await d.erzeugeAuftragPdf(auftrag, optionen)
|
||||
if (!Buffer.isBuffer(pdf) || pdf.length === 0) throw new Error('leeres PDF')
|
||||
} catch (err) {
|
||||
if (err instanceof AuftragError) throw err
|
||||
logFehler('PDF-Erzeugung', err)
|
||||
throw new AuftragError(500, 'Das PDF konnte nicht erzeugt werden.')
|
||||
}
|
||||
return pdf
|
||||
}
|
||||
|
||||
export async function vorschauPdf(id, { deps } = {}) {
|
||||
const d = await ladeDeps(deps, ['erzeugeAuftragPdf'])
|
||||
const auftrag = await ladeAuftrag(id)
|
||||
if (auftrag.status !== 'entwurf') throw new AuftragError(409, 'Eine Vorschau gibt es nur für Entwürfe.')
|
||||
const kundeDoc = await ladeKunde(auftrag.kundenId)
|
||||
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.')
|
||||
}
|
||||
}
|
||||
|
||||
/** 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' }
|
||||
}
|
||||
|
||||
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'])
|
||||
// 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)
|
||||
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)
|
||||
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)
|
||||
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(),
|
||||
})
|
||||
return { gespeichert: gespeichertesDokument, pdf: pdfBytes, token: neu.token }
|
||||
} catch (err) {
|
||||
await raeumePdfAuf(entwurf.id, ablage.pdfDatei)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
return versendeMail(d, gespeichert, { pdf, token })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Erinnern („Erinnerung senden“ und „Erneut senden“)
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Liest die gespeicherte PDF und prüft die SHA-256-Summe: eine veränderte Datei wird nie verschickt oder angezeigt. */
|
||||
export async function ladeGespeichertesPdf(auftrag) {
|
||||
if (!hatText(auftrag.pdfDatei)) throw new AuftragError(404, 'PDF nicht gefunden.')
|
||||
const buffer = await lesePdf(auftrag.pdfDatei)
|
||||
if (createHash('sha256').update(buffer).digest('hex') !== auftrag.pdfSha256) {
|
||||
console.error('[auftraege] Prüfsumme der gespeicherten PDF stimmt nicht:', auftrag.id)
|
||||
throw new AuftragError(500, 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.')
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuer Bestätigungslink für eine versendete AB (der alte gilt ab dann nicht mehr) und erneute Mail mit der gespeicherten
|
||||
* PDF. Nach einem Mail-Fehler ist es eine gewöhnliche Mail ohne Erinnerungs-Hinweis; gesendetAn wird die aktuelle Adresse.
|
||||
*/
|
||||
export async function erinnereAuftrag(id, { deps, jetzt = new Date() } = {}) {
|
||||
const d = await ladeDeps(deps, ['neuerToken', 'sendeAuftragMail'])
|
||||
const { gespeichert, pdf, token, erinnerung } = await withLocks(sperreAuftrag(id), async () => {
|
||||
const alt = await ladeAuftrag(id)
|
||||
if (alt.status !== 'versendet') throw new AuftragError(409, 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.')
|
||||
const kundeDoc = await ladeKunde(alt.kundenId)
|
||||
if (!hatText(kundeDoc.email)) throw new AuftragError(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
|
||||
// vor dem Token-Tausch: eine beschädigte Datei darf den alten Link nicht entwerten
|
||||
const pdfBytes = await ladeGespeichertesPdf(alt)
|
||||
const neu = d.neuerToken(jetzt)
|
||||
const dokument = await updateAuftrag(id, { tokenHash: neu.hash, tokenGueltigBis: neu.gueltigBis.toISOString(), gesendetAn: kundeDoc.email.trim() })
|
||||
return { gespeichert: dokument, pdf: pdfBytes, token: neu.token, erinnerung: alt.mailStatus !== 'fehler' }
|
||||
})
|
||||
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 || '') })
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Stornieren
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Storniert eine versendete oder bestätigte AB (ohne Mail); tokenHash, Nummer und PDF bleiben, der Link erklärt „storniert“. */
|
||||
export async function storniereAuftrag(id) {
|
||||
return withLocks(sperreAuftrag(id), async () => {
|
||||
const auftrag = await ladeAuftrag(id) // der Stand wird in der Sperre gelesen
|
||||
if (auftrag.status !== 'versendet' && auftrag.status !== 'bestaetigt') {
|
||||
throw new AuftragError(409, 'Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.')
|
||||
}
|
||||
return updateAuftrag(id, { status: 'storniert', storniertAm: new Date().toISOString() })
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Projekt nachträglich zuordnen
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ordnet einer AB ohne Projekt nachträglich ein Projekt des Kunden zu: setzt projektId, bei leerer Zeitquelle das Ticket des
|
||||
* Projekts, und hängt die Projektrechnungen der AB mit leerer projektId um (Stripe bleibt unverändert). PDF, Nummer, Status
|
||||
* und Token bleiben. Zuerst die Projektrechnungen, zuletzt die AB: scheitert etwas dazwischen, bleibt die AB ohne Projekt
|
||||
* und die Wiederholung führt alles zu Ende.
|
||||
*/
|
||||
export async function ordneProjektZu(id, projektId) {
|
||||
if (!hatText(projektId)) throw new AuftragError(400, 'Projekt: Bitte ein Projekt angeben.')
|
||||
if (!istDokumentId(projektId)) throw new AuftragError(400, 'Projekt: Ungültige Projekt-Kennung.')
|
||||
return withLocks(sperreAuftrag(id), async () => {
|
||||
const auftrag = await ladeAuftrag(id)
|
||||
if (['storniert', 'ersetzt', 'widerrufen'].includes(auftrag.status)) {
|
||||
throw new AuftragError(409, 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.')
|
||||
}
|
||||
if (hatText(auftrag.projektId)) throw new AuftragError(409, 'Diese Auftragsbestätigung gehört schon zu einem Projekt.')
|
||||
const projekt = await ladeProjekt(projektId)
|
||||
if (!projekt || projekt.customerId !== auftrag.kundenId) throw new AuftragError(400, 'Projekt: Das Projekt gehört nicht zu diesem Kunden.')
|
||||
const rechnungen = await listProjektRechnungen({ auftragId: id, nurAktiv: false })
|
||||
for (const rechnung of rechnungen) {
|
||||
if (rechnung.projektId === '') await updateProjektRechnung(rechnung.invoiceId, { projektId })
|
||||
}
|
||||
const patch = { projektId }
|
||||
if (auftrag.zeitquelle.length === 0) patch.zeitquelle = ticketsDesProjekts(projekt)
|
||||
return updateAuftrag(id, patch)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Nummernvergabe der Auftragsbestätigungen: AB-JJJJ-NNNN, lückenlos je Berliner Kalenderjahr (Übersicht V4, L3).
|
||||
* vergibNummer schreibt nichts und sperrt nicht selbst: der Aufrufer hält SPERRE_NUMMER (konstanten.js), und die Nummer
|
||||
* ist erst mit dem Speichern der AB vergeben. Eine Nummer, die schon in Gebrauch ist, wird übersprungen.
|
||||
*/
|
||||
import { berlinDate } from '../finance/dates.js'
|
||||
import { findAuftragByNummer, listAuftraege } from './auftragStore.js'
|
||||
|
||||
export function formatNummer(jahr, laufnummer) {
|
||||
if (!Number.isInteger(jahr) || jahr < 2000 || jahr > 9999 || !Number.isInteger(laufnummer) || laufnummer < 1 || laufnummer > 9999) {
|
||||
throw new RangeError('Ungültige Auftragsnummer.')
|
||||
}
|
||||
return `AB-${jahr}-${String(laufnummer).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
export async function vergibNummer(jetzt = new Date()) {
|
||||
const jahr = Number(berlinDate(jetzt).slice(0, 4))
|
||||
const vorhandene = await listAuftraege({ jahr })
|
||||
let laufnummer = vorhandene.reduce((groesste, a) => Math.max(groesste, Number.isSafeInteger(a.laufnummer) ? a.laufnummer : 0), 0) + 1
|
||||
while (await findAuftragByNummer(formatNummer(jahr, laufnummer))) laufnummer += 1
|
||||
return { nummer: formatNummer(jahr, laufnummer), jahr, laufnummer }
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { festpreisEingabe, aufwandEingabe, wirftFehler, MELDUNG } from './helpers/auftraegeAb.js'
|
||||
|
||||
// Rein: kein Appwrite-Zugriff. Die Umgebung zeigt trotzdem auf tote lokale Adressen, damit nie ein echtes System erreicht wird.
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { pruefeEingabe } = await import('../server/services/auftraege/auftraege.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
|
||||
const pos = (patch = {}) => ({ beschreibung: 'Webdesign', menge: 1, einzelpreisCents: 100, ...patch })
|
||||
const neunPositionen = () => Array.from({ length: 9 }, () => pos({ einzelpreisCents: 10_000_000 }))
|
||||
const E = {
|
||||
art: 'Art: Bitte Festpreis oder Nach Aufwand wählen.',
|
||||
beschreibung: (n = 1) => `Position ${n}: Die Beschreibung braucht 1 bis 200 Zeichen.`,
|
||||
menge: 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.',
|
||||
preis: (n = 1) => `Position ${n}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`,
|
||||
summe: 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).',
|
||||
plan: 'Zahlungsplan: Bitte eine Zahlungsart wählen.',
|
||||
prozent: 'Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.',
|
||||
frei: 'Zahlungsplan: Der freie Text braucht 1 bis 500 Zeichen.',
|
||||
ziel: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.',
|
||||
datum: 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).',
|
||||
titel: 'Titel: Höchstens 200 Zeichen.',
|
||||
zeitquelle: 'Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.',
|
||||
stundensatz: 'Stundensatz: Der Stundensatz muss zwischen 0,01 € und 100.000,00 € liegen.',
|
||||
schaetzung: 'Schätzung: Die Schätzung muss ein Vielfaches von 15 Minuten sein (15 bis 99.960).',
|
||||
}
|
||||
|
||||
test('Festpreis: bereinigte Felder – Texte getrimmt, Summe berechnet, Aufwandsfelder leer, Vorgaben gesetzt', () => {
|
||||
const felder = pruefeEingabe(
|
||||
festpreisEingabe({
|
||||
titel: ' Neue Website ',
|
||||
positionen: [{ beschreibung: ' Webdesign ', menge: 2, einzelpreisCents: 30000 }],
|
||||
zahlungsplan: { art: 'nach_abnahme' },
|
||||
zahlungszielTage: undefined,
|
||||
stundensatzCents: 12345, // gehört nicht zu einem Festpreis und fällt weg
|
||||
schaetzungMinuten: 30,
|
||||
}),
|
||||
)
|
||||
assert.deepEqual(felder, {
|
||||
projektId: 'p_projekt01',
|
||||
titel: 'Neue Website',
|
||||
kundenart: '',
|
||||
art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Webdesign', menge: 2, einzelpreisCents: 30000 }],
|
||||
stundensatzCents: null,
|
||||
schaetzungMinuten: null,
|
||||
summeCents: 60000,
|
||||
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' },
|
||||
zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05',
|
||||
auftragWeg: 'telefon',
|
||||
startAm: '',
|
||||
fertigBis: '',
|
||||
zeitquelle: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('Aufwand: Stundensatz-Vorgabe 100 €, Schätzung optional, Positionen fallen weg, Summe leer', () => {
|
||||
const felder = pruefeEingabe(aufwandEingabe({ stundensatzCents: undefined, schaetzungMinuten: null, positionen: festpreisEingabe().positionen }))
|
||||
assert.deepEqual(felder, {
|
||||
projektId: 'p_projekt01', titel: '', kundenart: '', art: 'aufwand', positionen: [], stundensatzCents: 10000, schaetzungMinuten: null,
|
||||
summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05',
|
||||
auftragWeg: 'email', startAm: '', fertigBis: '', zeitquelle: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('Zahlungsplan: Anzahlung nimmt 50 % vor, frei trägt den Text, die anderen Arten verwerfen prozent und text', () => {
|
||||
const plan = (zahlungsplan, basis = festpreisEingabe) => pruefeEingabe(basis({ zahlungsplan })).zahlungsplan
|
||||
assert.deepEqual(plan({ art: 'anzahlung' }), { art: 'anzahlung', prozent: 50, text: '' })
|
||||
assert.deepEqual(plan({ art: 'anzahlung', prozent: 30, text: 'ignoriert' }), { art: 'anzahlung', prozent: 30, text: '' })
|
||||
assert.deepEqual(plan({ art: 'frei', prozent: 40, text: ' Zahlbar in zwei Raten. ' }), { art: 'frei', prozent: null, text: 'Zahlbar in zwei Raten.' })
|
||||
assert.deepEqual(plan({ art: 'monatlich', text: 'x' }, aufwandEingabe), { art: 'monatlich', prozent: null, text: '' })
|
||||
assert.deepEqual(plan({ art: 'nach_abnahme', prozent: 10 }, aufwandEingabe), { art: 'nach_abnahme', prozent: null, text: '' })
|
||||
})
|
||||
|
||||
test('Grenzwerte, die noch gelten: 20 Positionen, Summe genau 999.999,99 €, Zahlungsziel 0 und 60, Schätzung 15 und 99.960 Minuten', () => {
|
||||
const lang = (cents = 100, menge = 1) => ({ beschreibung: 'x'.repeat(200), menge, einzelpreisCents: cents })
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: Array.from({ length: 20 }, () => lang()) })).positionen.length, 20)
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [...neunPositionen(), pos({ einzelpreisCents: 9_999_999 })] })).summeCents, 99_999_999)
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [lang(100, 999)] })).summeCents, 99_900)
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 0 })).zahlungszielTage, 0)
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 60 })).zahlungszielTage, 60)
|
||||
assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 15 })).schaetzungMinuten, 15)
|
||||
assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 99_960 })).schaetzungMinuten, 99_960)
|
||||
for (const keine of [0, '', null]) assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: keine })).schaetzungMinuten, null)
|
||||
assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 1 })).stundensatzCents, 1)
|
||||
assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 10_000_000 })).stundensatzCents, 10_000_000)
|
||||
})
|
||||
|
||||
const FESTPREIS_FEHLER = [
|
||||
['Art fehlt', { art: undefined }, E.art],
|
||||
['Art unbekannt', { art: 'pauschal' }, E.art],
|
||||
['keine Positionen', { positionen: [] }, MELDUNG.positionen],
|
||||
['Positionen fehlen', { positionen: undefined }, MELDUNG.positionen],
|
||||
['21 Positionen', { positionen: Array.from({ length: 21 }, () => pos()) }, MELDUNG.positionen],
|
||||
['Position ist kein Objekt', { positionen: ['x'] }, E.beschreibung()],
|
||||
['Beschreibung leer', { positionen: [pos({ beschreibung: ' ' })] }, E.beschreibung()],
|
||||
['Beschreibung 201 Zeichen (zweite Position)', { positionen: [pos(), pos({ beschreibung: 'x'.repeat(201) })] }, E.beschreibung(2)],
|
||||
['Menge 0', { positionen: [pos({ menge: 0 })] }, E.menge],
|
||||
['Menge 1000', { positionen: [pos({ menge: 1000 })] }, E.menge],
|
||||
['Menge 1,5', { positionen: [pos({ menge: 1.5 })] }, E.menge],
|
||||
['Menge als Text', { positionen: [pos({ menge: '2' })] }, E.menge],
|
||||
['Einzelpreis 0 (zweite Position)', { positionen: [pos(), pos({ einzelpreisCents: 0 })] }, E.preis(2)],
|
||||
['Einzelpreis über 100.000 €', { positionen: [pos({ einzelpreisCents: 10_000_001 })] }, E.preis()],
|
||||
['Einzelpreis mit Nachkommastelle', { positionen: [pos({ einzelpreisCents: 99.5 })] }, E.preis()],
|
||||
['Summe zu hoch', { positionen: [pos({ menge: 11, einzelpreisCents: 10_000_000 })] }, E.summe],
|
||||
['Summe ein Cent über dem Höchstwert', { positionen: [...neunPositionen(), pos({ einzelpreisCents: 10_000_000 })] }, E.summe],
|
||||
['Zahlungsplan fehlt', { zahlungsplan: undefined }, E.plan],
|
||||
['Zahlungsart unbekannt', { zahlungsplan: { art: 'bar' } }, E.plan],
|
||||
['Zahlungsplan kein Objekt', { zahlungsplan: 'anzahlung' }, E.plan],
|
||||
['monatlich bei Festpreis', { zahlungsplan: { art: 'monatlich' } }, 'Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.'],
|
||||
['Anzahlung 0 %', { zahlungsplan: { art: 'anzahlung', prozent: 0 } }, E.prozent],
|
||||
['Anzahlung 100 %', { zahlungsplan: { art: 'anzahlung', prozent: 100 } }, E.prozent],
|
||||
['Anzahlung 12,5 %', { zahlungsplan: { art: 'anzahlung', prozent: 12.5 } }, E.prozent],
|
||||
['Anzahlung als Text', { zahlungsplan: { art: 'anzahlung', prozent: '50' } }, E.prozent],
|
||||
['freier Text leer', { zahlungsplan: { art: 'frei', text: ' ' } }, E.frei],
|
||||
['freier Text 501 Zeichen', { zahlungsplan: { art: 'frei', text: 'x'.repeat(501) } }, E.frei],
|
||||
['Zahlungsziel -1', { zahlungszielTage: -1 }, E.ziel],
|
||||
['Zahlungsziel 61', { zahlungszielTage: 61 }, E.ziel],
|
||||
['Zahlungsziel 14,5', { zahlungszielTage: 14.5 }, E.ziel],
|
||||
['Zahlungsziel als Text', { zahlungszielTage: '14' }, E.ziel],
|
||||
['Auftragsdatum fehlt', { auftragVom: undefined }, E.datum],
|
||||
['30. Februar', { auftragVom: '2026-02-30' }, E.datum],
|
||||
['deutsches Datumsformat', { auftragVom: '05.10.2026' }, E.datum],
|
||||
['Datum als Zahl', { auftragVom: 20261005 }, E.datum],
|
||||
['Weg fehlt', { auftragWeg: undefined }, 'Weg: Bitte den Weg der Absprache wählen.'],
|
||||
['Weg unbekannt', { auftragWeg: 'brieftaube' }, 'Weg: Bitte den Weg der Absprache wählen.'],
|
||||
['Start kein Datum', { startAm: '2026-13-01' }, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
|
||||
['Fertigstellung kein Datum', { fertigBis: 'bald' }, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
|
||||
['Titel 201 Zeichen', { titel: 'x'.repeat(201) }, E.titel],
|
||||
['Titel keine Zeichenkette', { titel: 5 }, E.titel],
|
||||
['Projekt-Kennung mit ../', { projektId: '../x' }, 'Projekt: Ungültige Projekt-Kennung.'],
|
||||
['Kundenart unbekannt', { kundenart: 'firma' }, 'Kundenart: Bitte Unternehmen oder Privatperson wählen.'],
|
||||
['Zeitquelle keine Liste', { zeitquelle: 't_1' }, E.zeitquelle],
|
||||
['11 Tickets', { zeitquelle: Array.from({ length: 11 }, (_, i) => `t_${i}`) }, 'Zeitquelle: Höchstens 10 Tickets.'],
|
||||
['Ticket-Kennung mit ../', { zeitquelle: ['t_ok', '../x'] }, 'Zeitquelle: Ungültige Ticket-Kennung.'],
|
||||
]
|
||||
const AUFWAND_FEHLER = [
|
||||
['Stundensatz 0', { stundensatzCents: 0 }, E.stundensatz],
|
||||
['Stundensatz über 100.000 €', { stundensatzCents: 10_000_001 }, E.stundensatz],
|
||||
['Stundensatz mit Nachkommastelle', { stundensatzCents: 100.5 }, E.stundensatz],
|
||||
['Stundensatz als Text', { stundensatzCents: '100' }, E.stundensatz],
|
||||
['Schätzung 10 Minuten', { schaetzungMinuten: 10 }, E.schaetzung],
|
||||
['Schätzung negativ', { schaetzungMinuten: -15 }, E.schaetzung],
|
||||
['Schätzung über dem Höchstwert', { schaetzungMinuten: 99_975 }, E.schaetzung],
|
||||
['Schätzung als Text', { schaetzungMinuten: '15' }, E.schaetzung],
|
||||
['Anzahlung bei Aufwand', { zahlungsplan: { art: 'anzahlung', prozent: 50 } }, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.'],
|
||||
]
|
||||
|
||||
test('Ablehnungen beim Festpreis: jede Eingabe scheitert mit genau ihrer Meldung (400)', () => {
|
||||
for (const [name, patch, meldung] of FESTPREIS_FEHLER) wirftFehler(() => pruefeEingabe(festpreisEingabe(patch)), 400, meldung, name)
|
||||
})
|
||||
|
||||
test('Ablehnungen bei Aufwand: Stundensatz, Schätzung und Anzahlung', () => {
|
||||
for (const [name, patch, meldung] of AUFWAND_FEHLER) wirftFehler(() => pruefeEingabe(aufwandEingabe(patch)), 400, meldung, name)
|
||||
})
|
||||
|
||||
test('Eingaben, die kein einfaches Objekt sind, ergeben „Ungültige Eingabe.“ als AuftragError', () => {
|
||||
for (const eingabe of [null, undefined, 'x', 42, [], [festpreisEingabe()]]) {
|
||||
for (const teilweise of [false, true]) {
|
||||
assert.throws(() => pruefeEingabe(eingabe, { teilweise }), (err) => err instanceof AuftragError && err.status === 400 && err.message === 'Ungültige Eingabe.')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Unbekannte und geschützte Felder landen nie in den bereinigten Feldern', () => {
|
||||
const sauber = pruefeEingabe(festpreisEingabe())
|
||||
const mitMuell = pruefeEingabe({
|
||||
...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'x', ersetztId: 'a_alt',
|
||||
erstelltVon: 'boese@example.invalid', pdfDatei: 'c/a.pdf', kunde: { name: 'x' }, id: 'a_fremd',
|
||||
})
|
||||
assert.deepEqual(mitMuell, sauber)
|
||||
})
|
||||
|
||||
test('teilweise: nur übergebene Felder werden geprüft und bereinigt, nichts wird ergänzt', () => {
|
||||
assert.deepEqual(pruefeEingabe({}, { teilweise: true }), {})
|
||||
assert.deepEqual(pruefeEingabe({ kundenId: 'c_x', status: 'bestaetigt' }, { teilweise: true }), {})
|
||||
assert.deepEqual(pruefeEingabe({ titel: ' Neu ', zeitquelle: ['t_1', 't_1', 't_2'] }, { teilweise: true }), { titel: 'Neu', zeitquelle: ['t_1', 't_2'] })
|
||||
assert.deepEqual(pruefeEingabe({ art: 'aufwand' }, { teilweise: true }), { art: 'aufwand' })
|
||||
assert.deepEqual(pruefeEingabe({ zahlungsplan: { art: 'anzahlung' } }, { teilweise: true }), { zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } })
|
||||
assert.deepEqual(
|
||||
pruefeEingabe({ positionen: [{ beschreibung: ' A ', menge: 1, einzelpreisCents: 5 }] }, { teilweise: true }),
|
||||
{ positionen: [{ beschreibung: 'A', menge: 1, einzelpreisCents: 5 }] },
|
||||
)
|
||||
wirftFehler(() => pruefeEingabe({ zahlungszielTage: 99 }, { teilweise: true }), 400, E.ziel)
|
||||
wirftFehler(() => pruefeEingabe({ auftragVom: null }, { teilweise: true }), 400, E.datum)
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
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, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, FREMD, VON, MELDUNG, festpreisEingabe, kundeDoc, projektDoc,
|
||||
baueDeps, sha256, abgelehnt, dateienIn, ergebnis, tor, warte, wartebis,
|
||||
} from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config, uploadDir } = umg
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { getAuftrag, updateAuftrag, findAuftragByTokenHash } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { createProjektRechnung, getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||||
const { lesePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag, erinnereAuftrag, neueFassung, storniereAuftrag, ordneProjektZu } = await import('../server/services/auftraege/auftraege.js')
|
||||
|
||||
const JETZT = new Date('2026-10-06T10:00:00Z')
|
||||
/** Eine versendete AB (Entwurf anlegen und senden); liefert den Auftrag. */
|
||||
async function gesendet(deps, patch) {
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(patch), { von: VON })
|
||||
return (await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT })).auftrag
|
||||
}
|
||||
|
||||
/**
|
||||
* Hält die Sperre der AB wie L5 beim Bestätigen (sperreAuftrag(id), V2), startet die Aktion, wartet, bis sie an der Sperre
|
||||
* steht, führt waehrend() aus (dort schreibt „L5“) und liefert das Ergebnis der Aktion ({ wert } oder { fehler }) nach dem Freigeben.
|
||||
*/
|
||||
async function unterFremderSperre(id, { aktion, bereit = () => true, waehrend }) {
|
||||
const sperre = tor()
|
||||
const haelt = withLocks(sperreAuftrag(id), () => sperre.promise)
|
||||
const lauf = ergebnis(aktion())
|
||||
await wartebis(bereit)
|
||||
await warte(30) // die Aktion steht jetzt an der Sperre und darf noch nichts geschrieben haben
|
||||
await waehrend()
|
||||
sperre.freigeben()
|
||||
await haelt
|
||||
return lauf
|
||||
}
|
||||
|
||||
test('erinnereAuftrag: neuer Token (der alte gilt nicht mehr), gleiche PDF-Datei, Mail mit erinnerung: true, Versanddatum bleibt', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const erste = await gesendet(deps)
|
||||
const spaeter = new Date('2026-10-20T08:00:00Z')
|
||||
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps, jetzt: spaeter })
|
||||
assert.equal(mailFehler, false)
|
||||
assert.equal(calls.token.length, 2)
|
||||
assert.notEqual(auftrag.tokenHash, erste.tokenHash)
|
||||
assert.equal(auftrag.tokenHash, sha256(calls.token[1]))
|
||||
assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), spaeter.getTime() + 60 * 86_400_000)
|
||||
assert.equal(await findAuftragByTokenHash(erste.tokenHash), null)
|
||||
assert.equal((await findAuftragByTokenHash(auftrag.tokenHash)).id, erste.id)
|
||||
assert.deepEqual(
|
||||
[auftrag.status, auftrag.nummer, auftrag.gesendetAm, auftrag.pdfDatei, auftrag.pdfSha256, auftrag.mailStatus],
|
||||
['versendet', erste.nummer, erste.gesendetAm, erste.pdfDatei, erste.pdfSha256, 'gesendet'],
|
||||
)
|
||||
assert.equal(calls.pdf.length, 1) // kein neues PDF
|
||||
assert.equal(calls.mail.length, 2)
|
||||
assert.deepEqual(calls.mail[1].pdf, await lesePdf(erste.pdfDatei))
|
||||
assert.deepEqual([calls.mail[1].token, calls.mail[1].erinnerung], [calls.token[1], true])
|
||||
assert.deepEqual(await getAuftrag(erste.id), auftrag)
|
||||
})
|
||||
|
||||
test('erinnereAuftrag nach einem Mail-Fehler: gewöhnliche Mail ohne Erinnerungs-Hinweis an die aktuelle Adresse des Kunden', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const erste = await gesendet(baueDeps({ mailFehler: new Error('Verbindung abgebrochen') }).deps)
|
||||
assert.deepEqual([erste.mailStatus, erste.gesendetAn], ['fehler', 'anna@example.invalid'])
|
||||
aw.seed(config.collections.customers, kundeDoc({ email: 'neu@example.invalid' })) // der Admin hat die Adresse korrigiert
|
||||
const { deps, calls } = baueDeps()
|
||||
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps })
|
||||
assert.equal(mailFehler, false)
|
||||
assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler, auftrag.gesendetAn], ['gesendet', '', 'neu@example.invalid'])
|
||||
assert.deepEqual([calls.mail[0].erinnerung, calls.mail[0].auftrag.gesendetAn], [false, 'neu@example.invalid'])
|
||||
})
|
||||
|
||||
test('erinnereAuftrag: scheitert die Mail, gilt trotzdem der neue Token; das Ergebnis meldet mailFehler', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const erste = await gesendet(baueDeps().deps)
|
||||
const kaputt = baueDeps({ mailFehler: Object.assign(new Error('x'), { code: 'ECONNREFUSED' }) })
|
||||
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps: kaputt.deps })
|
||||
assert.equal(mailFehler, true)
|
||||
assert.deepEqual([auftrag.status, auftrag.mailStatus, auftrag.mailFehler], ['versendet', 'fehler', 'E-Mail konnte nicht gesendet werden (ECONNREFUSED).'])
|
||||
assert.equal(auftrag.tokenHash, sha256(kaputt.calls.token[0]))
|
||||
assert.equal(await findAuftragByTokenHash(erste.tokenHash), null)
|
||||
})
|
||||
|
||||
test('erinnereAuftrag: nur versendete (409), unbekannt 404, ohne E-Mail 400, beschädigte PDF-Datei 500 – jeweils ohne neuen Token', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const { deps } = baueDeps()
|
||||
const a = await gesendet(deps)
|
||||
for (const status of ['entwurf', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
|
||||
await updateAuftrag(a.id, { status })
|
||||
await abgelehnt(erinnereAuftrag(a.id, { deps }), 409, MELDUNG.keineErinnerung, status)
|
||||
}
|
||||
await abgelehnt(erinnereAuftrag('a_gibtsnicht', { deps }), 404, MELDUNG.auftragFehlt)
|
||||
await updateAuftrag(a.id, { status: 'versendet' })
|
||||
aw.seed(config.collections.customers, kundeDoc({ email: ' ' }))
|
||||
await abgelehnt(erinnereAuftrag(a.id, { deps }), 400, MELDUNG.keineMail)
|
||||
aw.seed(config.collections.customers, kundeDoc())
|
||||
fs.writeFileSync(path.join(uploadDir, a.pdfDatei), 'manipuliert')
|
||||
await abgelehnt(erinnereAuftrag(a.id, { deps }), 500, MELDUNG.pruefsumme)
|
||||
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash)
|
||||
})
|
||||
|
||||
test('erinnereAuftrag wartet auf die Sperre der AB und prüft den Stand danach (L5 bestätigt gerade: keine Erinnerung mehr)', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const a = await gesendet(deps)
|
||||
const lauf = await unterFremderSperre(a.id, {
|
||||
aktion: () => erinnereAuftrag(a.id, { deps }),
|
||||
waehrend: async () => {
|
||||
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) // noch kein neuer Token
|
||||
await updateAuftrag(a.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
|
||||
},
|
||||
})
|
||||
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keineErinnerung])
|
||||
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), [])
|
||||
})
|
||||
|
||||
test('storniereAuftrag: versendet und bestätigt werden storniert (ohne Mail); Token, Nummer und PDF bleiben, damit der Link „storniert“ erklären kann', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const a = await gesendet(deps)
|
||||
const b = await storniereAuftrag(a.id)
|
||||
assert.equal(b.status, 'storniert')
|
||||
assert.ok(Math.abs(new Date(b.storniertAm).getTime() - Date.now()) < 60_000)
|
||||
assert.deepEqual([b.nummer, b.tokenHash, b.pdfDatei], [a.nummer, a.tokenHash, a.pdfDatei])
|
||||
assert.equal((await findAuftragByTokenHash(a.tokenHash)).id, a.id)
|
||||
assert.equal(calls.mail.length, 1) // nur die Mail des Sendens
|
||||
const c = await gesendet(deps)
|
||||
await updateAuftrag(c.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
|
||||
assert.equal((await storniereAuftrag(c.id)).status, 'storniert')
|
||||
})
|
||||
|
||||
test('storniereAuftrag: sonst 409 (auch ein zweites Mal), unbekannt 404', async () => {
|
||||
const a = await gesendet(baueDeps().deps)
|
||||
for (const status of ['entwurf', 'abgerechnet', 'ersetzt', 'storniert', 'widerrufen']) {
|
||||
await updateAuftrag(a.id, { status })
|
||||
await abgelehnt(storniereAuftrag(a.id), 409, MELDUNG.keinStorno, status)
|
||||
}
|
||||
await abgelehnt(storniereAuftrag('a_gibtsnicht'), 404, MELDUNG.auftragFehlt)
|
||||
})
|
||||
|
||||
test('storniereAuftrag wartet auf die Sperre der AB (L5 hält sie beim Widerruf) und sieht danach den neuen Stand', async () => {
|
||||
const a = await gesendet(baueDeps().deps)
|
||||
const lauf = await unterFremderSperre(a.id, {
|
||||
aktion: () => storniereAuftrag(a.id),
|
||||
waehrend: async () => {
|
||||
assert.equal((await getAuftrag(a.id)).status, 'versendet') // noch nicht storniert
|
||||
await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
|
||||
},
|
||||
})
|
||||
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinStorno])
|
||||
assert.equal((await getAuftrag(a.id)).status, 'widerrufen')
|
||||
})
|
||||
|
||||
const OHNE_PROJEKT = { projektId: '', titel: 'Beratung ohne Projekt' }
|
||||
const ZWEITES_PROJEKT = 'p_zweites02'
|
||||
const legeZweitesProjektAn = () => aw.seed(config.collections.websiteProjects, projektDoc({ $id: ZWEITES_PROJEKT, projectName: 'Zweites Projekt', ticketId: ZWEITES_TICKET }))
|
||||
/** Alle Felder außer den genannten (für den Vergleich „sonst hat sich nichts geändert“). */
|
||||
const ohne = (auftrag, ...felder) => Object.fromEntries(Object.entries(auftrag).filter(([name]) => !felder.includes(name)))
|
||||
|
||||
test('ordneProjektZu: eine versendete AB ohne Projekt bekommt Projekt und Zeitquelle des Projekts; Nummer, Status, PDF, Prüfsumme, Token und Versanddaten bleiben', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const alt = await gesendet(deps, OHNE_PROJEKT)
|
||||
assert.deepEqual([alt.projektId, alt.zeitquelle], ['', []])
|
||||
const neu = await ordneProjektZu(alt.id, PROJEKT_ID)
|
||||
assert.deepEqual([neu.projektId, neu.zeitquelle], [PROJEKT_ID, [TICKET_ID]])
|
||||
assert.deepEqual(ohne(neu, 'projektId', 'zeitquelle', 'geaendertAm'), ohne(alt, 'projektId', 'zeitquelle', 'geaendertAm'))
|
||||
assert.deepEqual(await getAuftrag(alt.id), neu)
|
||||
assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id) // der Link gilt weiter
|
||||
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // keine neue Datei
|
||||
assert.deepEqual(await lesePdf(alt.pdfDatei), Buffer.from(`%PDF-1.4 ${alt.nummer}`))
|
||||
assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [1, 1, 1]) // nichts neu erzeugt, keine Mail
|
||||
})
|
||||
|
||||
/** Projektrechnung einer AB, wie L2 sie anlegt (hier ohne Projekt); die Rechnungs-ID muss zu /^in_\w{1,33}$/ passen. */
|
||||
const projektRechnung = (auftragId, patch = {}) => ({
|
||||
kundenId: KUNDE_ID, projektId: '', auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
|
||||
rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, ...patch,
|
||||
})
|
||||
|
||||
test('ordneProjektZu: hängt auch die Projektrechnungen der AB ohne Projekt um (aktive und stornierte); schon gesetzte Projekte, andere ABs und alle übrigen Felder bleiben', async () => {
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
const andere = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
await createProjektRechnung('in_OhneProjekt1', projektRechnung(a.id))
|
||||
await createProjektRechnung('in_OhneProjekt2', projektRechnung(a.id, { aktiv: false, rechnungsnummer: '' })) // storniert
|
||||
await createProjektRechnung('in_MitProjekt', projektRechnung(a.id, { projektId: 'p_anderes', art: 'aufwand' }))
|
||||
await createProjektRechnung('in_AndereAb', projektRechnung(andere.id))
|
||||
const mitProjekt = await getProjektRechnung('in_MitProjekt')
|
||||
const vorher = await getProjektRechnung('in_OhneProjekt1')
|
||||
await ordneProjektZu(a.id, PROJEKT_ID)
|
||||
const nachher = await getProjektRechnung('in_OhneProjekt1')
|
||||
assert.equal(nachher.projektId, PROJEKT_ID)
|
||||
assert.deepEqual(ohne(nachher, 'projektId'), ohne(vorher, 'projektId')) // sonst hat sich nichts geändert
|
||||
assert.equal((await getProjektRechnung('in_OhneProjekt2')).projektId, PROJEKT_ID, 'auch eine stornierte Projektrechnung')
|
||||
assert.deepEqual(await getProjektRechnung('in_MitProjekt'), mitProjekt) // schon ein anderes Projekt: bleibt
|
||||
assert.equal((await getProjektRechnung('in_AndereAb')).projektId, '') // andere AB: bleibt
|
||||
assert.equal((await getAuftrag(a.id)).projektId, PROJEKT_ID)
|
||||
})
|
||||
|
||||
test('ordneProjektZu: scheitert das Umhängen einer Projektrechnung, bleibt die AB ohne Projekt, und die Wiederholung führt alles zu Ende', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
await createProjektRechnung('in_Wiederholung1', projektRechnung(a.id))
|
||||
await createProjektRechnung('in_Wiederholung2', projektRechnung(a.id))
|
||||
aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500, after: 1 }) // die erste geht durch, die zweite scheitert
|
||||
await assert.rejects(ordneProjektZu(a.id, PROJEKT_ID), (err) => err.status === 500)
|
||||
assert.deepEqual([(await getAuftrag(a.id)).projektId, (await getProjektRechnung('in_Wiederholung1')).projektId, (await getProjektRechnung('in_Wiederholung2')).projektId], ['', PROJEKT_ID, ''])
|
||||
assert.equal((await ordneProjektZu(a.id, PROJEKT_ID)).projektId, PROJEKT_ID) // kein 409: die AB hatte noch kein Projekt
|
||||
for (const id of ['in_Wiederholung1', 'in_Wiederholung2']) assert.equal((await getProjektRechnung(id)).projektId, PROJEKT_ID, id)
|
||||
})
|
||||
|
||||
test('ordneProjektZu: auch ein Entwurf bekommt das Projekt (der Titel bleibt); eine gewählte Zeitquelle bleibt; ein Projekt ohne Ticket ergibt keine Leereinträge', async () => {
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe({ ...OHNE_PROJEKT, titel: 'Freier Titel', zeitquelle: [ZWEITES_TICKET] }), { von: VON })
|
||||
const a = await ordneProjektZu(entwurf.id, PROJEKT_ID)
|
||||
assert.deepEqual([a.status, a.nummer, a.projektId, a.titel, a.zeitquelle], ['entwurf', '', PROJEKT_ID, 'Freier Titel', [ZWEITES_TICKET]])
|
||||
aw.seed(config.collections.websiteProjects, projektDoc({ $id: 'p_ohneticket', projectName: 'Projekt ohne Ticket', ticketId: '' }))
|
||||
const frei = await legeEntwurfAn(festpreisEingabe(OHNE_PROJEKT), { von: VON })
|
||||
const b = await ordneProjektZu(frei.id, 'p_ohneticket')
|
||||
assert.deepEqual([b.projektId, b.zeitquelle], ['p_ohneticket', []])
|
||||
})
|
||||
|
||||
test('ordneProjektZu: fehlende oder ungültige Kennung, Projekt eines anderen Kunden und unbekanntes Projekt sind 400, eine unbekannte AB ist 404 – nichts ändert sich', async () => {
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
const faelle = [
|
||||
['Kennung fehlt', undefined, MELDUNG.projektFehlt],
|
||||
['Kennung nur Leerzeichen', ' ', MELDUNG.projektFehlt],
|
||||
['Kennung keine Zeichenkette', 5, MELDUNG.projektFehlt],
|
||||
['Kennung mit ../', '../x', MELDUNG.projektUngueltig],
|
||||
['Projekt eines anderen Kunden', FREMD.projekt, MELDUNG.projektFremd],
|
||||
['unbekanntes Projekt', 'p_gibtsnicht', MELDUNG.projektFremd],
|
||||
]
|
||||
for (const [name, projektId, meldung] of faelle) await abgelehnt(ordneProjektZu(a.id, projektId), 400, meldung, name)
|
||||
await abgelehnt(ordneProjektZu('a_gibtsnicht', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
|
||||
await abgelehnt(ordneProjektZu('../x', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
|
||||
assert.deepEqual(await getAuftrag(a.id), a)
|
||||
})
|
||||
|
||||
test('ordneProjektZu: eine AB mit Projekt (auch dasselbe) sowie eine stornierte, ersetzte oder widerrufene AB lassen sich nicht zuordnen (409); versendet, bestätigt und abgerechnet gehen', async () => {
|
||||
const mit = await gesendet(baueDeps().deps) // gehört schon zu PROJEKT_ID
|
||||
legeZweitesProjektAn()
|
||||
await abgelehnt(ordneProjektZu(mit.id, PROJEKT_ID), 409, MELDUNG.schonProjekt, 'dasselbe Projekt')
|
||||
await abgelehnt(ordneProjektZu(mit.id, ZWEITES_PROJEKT), 409, MELDUNG.schonProjekt, 'anderes Projekt')
|
||||
await abgelehnt(ordneProjektZu(mit.id, FREMD.projekt), 409, MELDUNG.schonProjekt, 'fremdes Projekt: der Stand der AB gilt zuerst')
|
||||
assert.deepEqual(await getAuftrag(mit.id), mit)
|
||||
const frei = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen']) {
|
||||
await updateAuftrag(frei.id, { status })
|
||||
await abgelehnt(ordneProjektZu(frei.id, PROJEKT_ID), 409, MELDUNG.keinProjekt, status)
|
||||
}
|
||||
assert.equal((await getAuftrag(frei.id)).projektId, '')
|
||||
for (const status of ['versendet', 'bestaetigt', 'abgerechnet']) {
|
||||
await updateAuftrag(frei.id, { status, projektId: '', zeitquelle: [] })
|
||||
assert.equal((await ordneProjektZu(frei.id, PROJEKT_ID)).projektId, PROJEKT_ID, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('ordneProjektZu wartet auf die Sperre der AB und prüft den Stand danach (L5 widerruft gerade: 409)', async () => {
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
const lauf = await unterFremderSperre(a.id, {
|
||||
aktion: () => ordneProjektZu(a.id, PROJEKT_ID),
|
||||
waehrend: async () => {
|
||||
assert.equal((await getAuftrag(a.id)).projektId, '') // noch nichts geschrieben
|
||||
await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
|
||||
},
|
||||
})
|
||||
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinProjekt])
|
||||
assert.equal((await getAuftrag(a.id)).projektId, '')
|
||||
})
|
||||
|
||||
test('ordneProjektZu: zwei Aufrufe gleichzeitig (zwei Admins) – genau einer gelingt, der andere ist 409', async () => {
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
legeZweitesProjektAn()
|
||||
const ergebnisse = await Promise.allSettled([ordneProjektZu(a.id, PROJEKT_ID), ordneProjektZu(a.id, ZWEITES_PROJEKT)])
|
||||
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.schonProjekt])
|
||||
const gewinner = ergebnisse.find((r) => r.status === 'fulfilled').value
|
||||
assert.deepEqual(await getAuftrag(a.id), gewinner)
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { starteUmgebung, entwurfsDaten, sha256, KUNDE_ID, PROJEKT_ID, VON } from './helpers/auftraegeAb.js'
|
||||
|
||||
// Vertragsprüfung: Die L0-Module (Fundament) liefern, was der Strang L3 braucht (Übersicht V1 bis V4). Schlägt sie fehl,
|
||||
// weicht L0 vom Vertrag ab: nichts in fremden Dateien reparieren, sondern stoppen und melden.
|
||||
const umg = await starteUmgebung()
|
||||
after(() => umg.schliesse())
|
||||
const DIENSTE = '../server/services/auftraege'
|
||||
const store = await import(`${DIENSTE}/auftragStore.js`)
|
||||
const ablage = await import(`${DIENSTE}/ablage.js`)
|
||||
const { getSettings } = await import('../server/services/finance/settings.js')
|
||||
|
||||
const EXPORTE = {
|
||||
'konstanten.js': ['AUFTRAG_ARTEN', 'AUFTRAG_WEGE', 'ZAHLUNGSPLAN_ARTEN', 'KUNDENARTEN', 'VEREINBARTE_STATUS', 'STUNDENSATZ_STANDARD_CENTS', 'VIERTELSTUNDE', 'TOKEN_GUELTIG_TAGE', 'AGB_FASSUNG', 'LIMITS', 'SPERRE_NUMMER', 'sperreAuftrag', 'SCHALTER_AUS_MELDUNG'],
|
||||
'fehler.js': ['AuftragError', 'sendeFehler'],
|
||||
'schalter.js': ['auftraegeAktiv'],
|
||||
'auftragStore.js': ['getAuftrag', 'listAuftraege', 'createAuftrag', 'updateAuftrag', 'deleteAuftrag', 'findAuftragByTokenHash', 'findAuftragByNummer'],
|
||||
'projektRechnungenStore.js': ['getProjektRechnung', 'listProjektRechnungen', 'createProjektRechnung', 'updateProjektRechnung', 'umhaengen'],
|
||||
'auftragSicht.js': ['kundeSnapshot', 'auftragFuerTeam'],
|
||||
'ablage.js': ['speicherePdf', 'lesePdf', 'entfernePdf'], // entfernePdf: Nachtrag der Planrunde (Übersicht, Nachträge L0)
|
||||
'texte.js': ['TEXTE_FASSUNG'],
|
||||
}
|
||||
|
||||
test('L0-Module, Konfiguration und Einstellungen liefern die Namen der Übersicht', async () => {
|
||||
for (const [datei, namen] of Object.entries(EXPORTE)) {
|
||||
const modul = await import(`${DIENSTE}/${datei}`)
|
||||
for (const name of namen) assert.ok(name in modul, `${datei} exportiert ${name} nicht`)
|
||||
}
|
||||
for (const feld of ['mode', 'pilotEmails', 'uploadDir']) assert.ok(feld in umg.config.auftraege, `config.auftraege.${feld} fehlt`)
|
||||
assert.equal(umg.config.auftraege.uploadDir, umg.uploadDir)
|
||||
assert.equal(umg.config.collections.auftraege, 'auftraege')
|
||||
assert.equal(umg.config.collections.projektRechnungen, 'projektRechnungen')
|
||||
assert.equal((await getSettings()).steuernummer, '')
|
||||
// Sperren (V2): je Sperre ein zusammengesetzter Schlüssel, weil withLocks jedes Listenelement für sich sperrt
|
||||
const { SPERRE_NUMMER, sperreAuftrag } = await import(`${DIENSTE}/konstanten.js`)
|
||||
assert.deepEqual(SPERRE_NUMMER, ['auftraege-nummer'])
|
||||
assert.deepEqual(sperreAuftrag('a_x1'), ['auftraege-ab:a_x1'])
|
||||
})
|
||||
|
||||
test('createAuftrag legt einen Entwurf mit den Leerwerten aus V3 an, updateAuftrag ändert nur die genannten Felder', async () => {
|
||||
const a = await store.createAuftrag(entwurfsDaten())
|
||||
assert.deepEqual(
|
||||
[a.status, a.nummer, a.jahr, a.laufnummer, a.kunde, a.gesendetAm, a.tokenGueltigBis, a.bestaetigtAm, a.bestaetigtUeber, a.mailStatus, a.fruehbeginn, a.erstelltVon],
|
||||
['entwurf', '', null, null, null, null, null, null, '', '', false, VON],
|
||||
)
|
||||
assert.equal(typeof a.id, 'string')
|
||||
assert.ok(!Number.isNaN(new Date(a.erstelltAm).getTime()))
|
||||
assert.deepEqual(a.positionen, entwurfsDaten().positionen)
|
||||
assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' })
|
||||
assert.deepEqual(await store.getAuftrag(a.id), a)
|
||||
const b = await store.updateAuftrag(a.id, { titel: 'Anderer Titel' })
|
||||
assert.equal(b.titel, 'Anderer Titel')
|
||||
assert.deepEqual([b.positionen, b.zeitquelle, b.zahlungsplan, b.summeCents], [a.positionen, a.zeitquelle, a.zahlungsplan, a.summeCents])
|
||||
assert.deepEqual(await store.getAuftrag(a.id), b)
|
||||
assert.equal(await store.getAuftrag('a_gibtsnicht'), null)
|
||||
})
|
||||
|
||||
test('listAuftraege liefert alle Treffer, auch mehr als die 25 Dokumente, die Appwrite ohne limit liefert', async () => {
|
||||
for (let i = 0; i < 30; i += 1) await store.createAuftrag(entwurfsDaten({ kundenId: 'c_liste01' }))
|
||||
assert.equal((await store.listAuftraege({ kundenId: 'c_liste01' })).length, 30)
|
||||
})
|
||||
|
||||
test('ablage: speicherePdf legt <kundenId>/<id>.pdf an, überschreibt nie; lesePdf liest und lehnt fremde Pfade ab; entfernePdf räumt auf', async () => {
|
||||
const pdf = Buffer.from('%PDF-1.4 vertrag')
|
||||
assert.deepEqual(await ablage.speicherePdf('c_ablage', 'a_ablage', pdf), { pdfDatei: 'c_ablage/a_ablage.pdf', pdfSha256: sha256(pdf) })
|
||||
assert.deepEqual(await ablage.lesePdf('c_ablage/a_ablage.pdf'), pdf)
|
||||
await assert.rejects(async () => ablage.speicherePdf('c_ablage', 'a_ablage', Buffer.from('%PDF-1.4 anders')))
|
||||
await assert.rejects(async () => ablage.lesePdf('../etc/passwd'), (err) => err.status === 404)
|
||||
await ablage.entfernePdf('c_ablage/a_ablage.pdf')
|
||||
await assert.rejects(async () => ablage.lesePdf('c_ablage/a_ablage.pdf'), (err) => err.status === 404)
|
||||
assert.equal((await ablage.speicherePdf('c_ablage', 'a_ablage', pdf)).pdfDatei, 'c_ablage/a_ablage.pdf')
|
||||
})
|
||||
|
||||
test('umhaengen hängt alle Dokumente einer AB auf die neue um und zählt sie; ein zweiter Aufruf findet nichts mehr; list und update genügen für die Projektzuordnung', async () => {
|
||||
const { createProjektRechnung, getProjektRechnung, listProjektRechnungen, updateProjektRechnung, umhaengen } = await import(`${DIENSTE}/projektRechnungenStore.js`)
|
||||
const daten = (auftragId) => ({ kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: 'WEBKLAR-0001', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString() })
|
||||
await createProjektRechnung('in_Umhaengen1', daten('a_alt'))
|
||||
await createProjektRechnung('in_Umhaengen2', daten('a_anders'))
|
||||
assert.equal(await umhaengen('a_alt', 'a_neu'), 1)
|
||||
assert.equal((await getProjektRechnung('in_Umhaengen1')).auftragId, 'a_neu')
|
||||
assert.equal((await getProjektRechnung('in_Umhaengen2')).auftragId, 'a_anders')
|
||||
assert.equal(await umhaengen('a_alt', 'a_neu'), 0)
|
||||
// Projektzuordnung (Task 11): alle Dokumente einer AB, auch inaktive; die projektId lässt sich einzeln ändern, sonst bleibt alles
|
||||
await updateProjektRechnung('in_Umhaengen1', { aktiv: false })
|
||||
assert.deepEqual((await listProjektRechnungen({ auftragId: 'a_neu', nurAktiv: false })).map((r) => r.invoiceId), ['in_Umhaengen1'])
|
||||
assert.deepEqual(await listProjektRechnungen({ auftragId: 'a_neu' }), [])
|
||||
const geaendert = await updateProjektRechnung('in_Umhaengen1', { projektId: 'p_neu01' })
|
||||
assert.deepEqual([geaendert.projektId, geaendert.auftragId, geaendert.betragCents, geaendert.aktiv], ['p_neu01', 'a_neu', 40000, false])
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { starteUmgebung, VON, MELDUNG, festpreisEingabe, kundeDoc, baueDeps, abgelehnt, dateienIn } from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config, uploadDir } = umg
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { kundeSnapshot } = await import('../server/services/auftraege/auftragSicht.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
const { legeEntwurfAn, vorschauPdf, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js')
|
||||
beforeEach(() => setzeDepsFuerTests(null))
|
||||
|
||||
test('vorschauPdf: Entwurf mit „Entwurf“ als Nummer, aktuellem Kunden und den Einstellungen – ohne etwas zu speichern', async () => {
|
||||
aw.seed(config.collections.financeSettings, { $id: 'main', steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' })
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
aw.seed(config.collections.customers, kundeDoc({ street: 'Neue Straße 5' })) // der Kunde ändert sich nach dem Entwurf
|
||||
const { deps, calls } = baueDeps()
|
||||
assert.deepEqual(await vorschauPdf(entwurf.id, { deps }), Buffer.from('%PDF-1.4 Entwurf'))
|
||||
assert.equal(calls.pdf.length, 1)
|
||||
const { auftrag, optionen } = calls.pdf[0]
|
||||
assert.deepEqual([auftrag.id, auftrag.nummer], [entwurf.id, 'Entwurf'])
|
||||
assert.deepEqual(auftrag.kunde, kundeSnapshot(kundeDoc({ street: 'Neue Straße 5' })))
|
||||
assert.equal(auftrag.kunde.strasse, 'Neue Straße 5')
|
||||
assert.deepEqual(optionen, { settings: { steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' }, vorschau: true })
|
||||
assert.equal(calls.token.length + calls.mail.length, 0)
|
||||
assert.deepEqual(dateienIn(uploadDir), [])
|
||||
const danach = await getAuftrag(entwurf.id)
|
||||
assert.deepEqual([danach.status, danach.nummer, danach.kunde], ['entwurf', '', null])
|
||||
})
|
||||
|
||||
test('vorschauPdf: nur Entwürfe (409), unbekannt (404); wirft die PDF-Erzeugung oder liefert sie nichts, gibt es 500 mit fester Meldung; ein AuftragError der PDF-Funktion bleibt erhalten', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await abgelehnt(vorschauPdf('a_gibtsnicht', { deps: baueDeps().deps }), 404, MELDUNG.auftragFehlt)
|
||||
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdfFehler: new Error('pdfkit kaputt') }).deps }), 500, MELDUNG.pdf)
|
||||
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdf: Buffer.alloc(0) }).deps }), 500, MELDUNG.pdf)
|
||||
const unvollstaendig = new AuftragError(409, 'Entwurf unvollständig: Anschrift fehlt.')
|
||||
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdfFehler: unvollstaendig }).deps }), 409, 'Entwurf unvollständig: Anschrift fehlt.')
|
||||
await updateAuftrag(entwurf.id, { status: 'versendet' })
|
||||
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps().deps }), 409, 'Eine Vorschau gibt es nur für Entwürfe.')
|
||||
})
|
||||
|
||||
test('deps: ohne Angabe gilt der Ersatz aus setzeDepsFuerTests, ausdrückliche Angaben haben Vorrang, nötig ist nur die benutzte Funktion', async () => {
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
const ersatz = baueDeps({ pdf: Buffer.from('%PDF-1.4 ersatz') })
|
||||
const eigene = baueDeps({ pdf: Buffer.from('%PDF-1.4 eigene') })
|
||||
setzeDepsFuerTests(ersatz.deps)
|
||||
assert.deepEqual(await vorschauPdf(entwurf.id), Buffer.from('%PDF-1.4 ersatz'))
|
||||
assert.deepEqual(await vorschauPdf(entwurf.id, { deps: { erzeugeAuftragPdf: eigene.deps.erzeugeAuftragPdf } }), Buffer.from('%PDF-1.4 eigene'))
|
||||
assert.deepEqual([ersatz.calls.pdf.length, eigene.calls.pdf.length], [1, 1])
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, LEAD_KUNDE, FREMD, VON, MELDUNG,
|
||||
festpreisEingabe, aufwandEingabe, abgelehnt,
|
||||
} from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config } = umg
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { getDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf } = await import('../server/services/auftraege/auftraege.js')
|
||||
const kundenartVon = async (id) => (await getDocument(config.collections.customers, id)).kundenart || ''
|
||||
const FREMDES_PROJEKT = MELDUNG.projektFremd
|
||||
const FREMDES_TICKET = 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.'
|
||||
|
||||
test('legeEntwurfAn: Festpreis-Entwurf mit Titel und Zeitquelle aus dem Projekt, Summe und Ersteller', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
assert.deepEqual([a.status, a.nummer, a.kundenId, a.projektId, a.titel], ['entwurf', '', KUNDE_ID, PROJEKT_ID, 'Website Beispiel'])
|
||||
assert.deepEqual(a.zeitquelle, [TICKET_ID])
|
||||
assert.deepEqual([a.art, a.summeCents, a.erstelltVon], ['festpreis', 80000, VON])
|
||||
assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' })
|
||||
assert.deepEqual(await getAuftrag(a.id), a)
|
||||
})
|
||||
|
||||
test('legeEntwurfAn: ein eigener Titel gilt; ohne Projekt braucht es einen Titel', async () => {
|
||||
assert.equal((await legeEntwurfAn(festpreisEingabe({ titel: ' Relaunch Phase 1 ' }), { von: VON })).titel, 'Relaunch Phase 1')
|
||||
await abgelehnt(legeEntwurfAn(festpreisEingabe({ projektId: '' }), { von: VON }), 400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.')
|
||||
const frei = await legeEntwurfAn(festpreisEingabe({ projektId: '', titel: 'Logo-Entwurf' }), { von: VON })
|
||||
assert.deepEqual([frei.projektId, frei.zeitquelle], ['', []])
|
||||
})
|
||||
|
||||
test('legeEntwurfAn: Aufwand-Entwurf ohne Positionen und Summe, mit Stundensatz, Schätzung und gewählten Tickets', async () => {
|
||||
const a = await legeEntwurfAn(aufwandEingabe({ zeitquelle: [TICKET_ID, ZWEITES_TICKET] }), { von: VON })
|
||||
assert.deepEqual([a.art, a.positionen, a.summeCents, a.stundensatzCents, a.schaetzungMinuten], ['aufwand', [], null, 10000, 600])
|
||||
assert.deepEqual(a.zeitquelle, [TICKET_ID, ZWEITES_TICKET])
|
||||
assert.deepEqual(a.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
|
||||
})
|
||||
|
||||
test('legeEntwurfAn: Entwürfe gibt es auch für Interessenten (nur das Senden ist festen Kunden vorbehalten)', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'Angebotsentwurf' }), { von: VON })
|
||||
assert.deepEqual([a.kundenId, a.status], [LEAD_KUNDE, 'entwurf'])
|
||||
})
|
||||
|
||||
test('kundenart aus der Eingabe wird am Kunden gespeichert – aber nur, wenn dort noch keine steht', async () => {
|
||||
await legeEntwurfAn(festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat' }), { von: VON })
|
||||
assert.equal(await kundenartVon(FREMD.kunde), 'privat')
|
||||
await legeEntwurfAn(festpreisEingabe({ kundenart: 'privat' }), { von: VON }) // der Kunde ist schon 'unternehmen'
|
||||
assert.equal(await kundenartVon(KUNDE_ID), 'unternehmen')
|
||||
await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'x', kundenart: '' }), { von: VON })
|
||||
assert.equal(await kundenartVon(LEAD_KUNDE), '')
|
||||
})
|
||||
|
||||
test('fremde Bezüge werden abgelehnt, ohne dass etwas gespeichert wird (auch nicht die Kundenart)', async () => {
|
||||
const eingabe = (patch) => festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat', ...patch })
|
||||
const faelle = [
|
||||
['Projekt eines anderen Kunden', { projektId: PROJEKT_ID }, FREMDES_PROJEKT],
|
||||
['unbekanntes Projekt', { projektId: 'p_gibtsnicht' }, FREMDES_PROJEKT],
|
||||
['Ticket eines anderen Kunden', { zeitquelle: [TICKET_ID] }, FREMDES_TICKET],
|
||||
['ein Ticket unbekannt', { zeitquelle: [FREMD.ticket, 't_gibtsnicht'] }, FREMDES_TICKET],
|
||||
]
|
||||
for (const [name, patch, meldung] of faelle) await abgelehnt(legeEntwurfAn(eingabe(patch), { von: VON }), 400, meldung, name)
|
||||
assert.equal(aw.docs(config.collections.auftraege).size, 0)
|
||||
assert.equal(await kundenartVon(FREMD.kunde), '')
|
||||
})
|
||||
|
||||
test('Kunde: ohne Kennung 400, unbekannt 404, Kennung mit Sonderzeichen 404', async () => {
|
||||
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: undefined }), { von: VON }), 400, 'Kunde: Bitte einen Kunden angeben.')
|
||||
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: 'c_gibtsnicht' }), { von: VON }), 404, MELDUNG.kundeFehlt)
|
||||
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: '../users' }), { von: VON }), 404, MELDUNG.kundeFehlt)
|
||||
})
|
||||
|
||||
test('status, nummer, Token und Ersteller aus der Eingabe werden ignoriert', async () => {
|
||||
const a = await legeEntwurfAn(
|
||||
{ ...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'abc', ersetztId: 'a_alt', erstelltVon: 'boese@example.invalid', pdfDatei: 'x/y.pdf' },
|
||||
{ von: VON },
|
||||
)
|
||||
assert.deepEqual([a.status, a.nummer, a.jahr, a.erstelltVon], ['entwurf', '', null, VON])
|
||||
assert.ok(!a.tokenHash && !a.ersetztId && !a.pdfDatei)
|
||||
})
|
||||
|
||||
test('aendereEntwurf: nur die genannten Felder ändern sich, der Rest bleibt', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
const b = await aendereEntwurf(a.id, { titel: 'Neuer Titel', zahlungszielTage: 30 })
|
||||
assert.deepEqual([b.titel, b.zahlungszielTage, b.summeCents], ['Neuer Titel', 30, 80000])
|
||||
assert.deepEqual([b.positionen, b.zahlungsplan, b.zeitquelle], [a.positionen, a.zahlungsplan, a.zeitquelle])
|
||||
assert.deepEqual(await getAuftrag(a.id), b)
|
||||
})
|
||||
|
||||
test('aendereEntwurf: Wechsel der Art prüft das Ganze (Anzahlung geht nicht bei Aufwand), mit passendem Plan klappt er', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await abgelehnt(aendereEntwurf(a.id, { art: 'aufwand' }), 400, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
|
||||
assert.equal((await getAuftrag(a.id)).art, 'festpreis')
|
||||
const b = await aendereEntwurf(a.id, { art: 'aufwand', stundensatzCents: 12000, zahlungsplan: { art: 'monatlich' } })
|
||||
assert.deepEqual([b.art, b.positionen, b.summeCents, b.stundensatzCents, b.schaetzungMinuten], ['aufwand', [], null, 12000, null])
|
||||
})
|
||||
|
||||
test('aendereEntwurf: Kunde lässt sich nicht wechseln, fremdes Projekt wird abgelehnt, unbekannte Aufträge sind 404', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await abgelehnt(aendereEntwurf(a.id, { kundenId: FREMD.kunde }), 400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.')
|
||||
assert.equal((await aendereEntwurf(a.id, { kundenId: KUNDE_ID, titel: 'Gleicher Kunde' })).titel, 'Gleicher Kunde')
|
||||
await abgelehnt(aendereEntwurf(a.id, { projektId: FREMD.projekt }), 400, FREMDES_PROJEKT)
|
||||
await abgelehnt(aendereEntwurf('a_gibtsnicht', { titel: 'x' }), 404, MELDUNG.auftragFehlt)
|
||||
await abgelehnt(aendereEntwurf('../x', { titel: 'x' }), 404, MELDUNG.auftragFehlt)
|
||||
})
|
||||
|
||||
test('Ändern und Löschen gibt es nur für Entwürfe (409); Löschen entfernt den Entwurf', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await updateAuftrag(a.id, { status: 'versendet', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1 })
|
||||
await abgelehnt(aendereEntwurf(a.id, { titel: 'Nachträglich' }), 409, MELDUNG.nurEntwurfAendern)
|
||||
await abgelehnt(loescheEntwurf(a.id), 409, MELDUNG.nurEntwurfLoeschen)
|
||||
assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel')
|
||||
const b = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await loescheEntwurf(b.id)
|
||||
assert.equal(await getAuftrag(b.id), null)
|
||||
await abgelehnt(loescheEntwurf(b.id), 404, MELDUNG.auftragFehlt)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { starteUmgebung, entwurfsDaten } from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { createAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { formatNummer, vergibNummer } = await import('../server/services/auftraege/nummern.js')
|
||||
|
||||
/** Eine versendete AB mit Nummer anlegen (über den Speicher von L0). */
|
||||
async function legeNummerierteAn(jahr, laufnummer, patch = {}) {
|
||||
const entwurf = await createAuftrag(entwurfsDaten())
|
||||
return updateAuftrag(entwurf.id, { status: 'versendet', nummer: formatNummer(jahr, laufnummer), jahr, laufnummer, ...patch })
|
||||
}
|
||||
|
||||
test('formatNummer: AB-JJJJ-NNNN mit vier Ziffern; ungültige Werte werfen RangeError', () => {
|
||||
assert.equal(formatNummer(2026, 1), 'AB-2026-0001')
|
||||
assert.equal(formatNummer(2026, 42), 'AB-2026-0042')
|
||||
assert.equal(formatNummer(2027, 9999), 'AB-2027-9999')
|
||||
for (const [jahr, lauf] of [[2026, 0], [2026, 10000], [2026, 1.5], [2026, '1'], [1999, 1], ['2026', 1], [2026, -3]]) {
|
||||
assert.throws(() => formatNummer(jahr, lauf), RangeError, `${jahr}/${lauf}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('vergibNummer: die erste Nummer des Jahres ist 0001', async () => {
|
||||
assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1 })
|
||||
})
|
||||
|
||||
test('vergibNummer: größte Laufnummer des Jahres plus 1 – Lücken egal, andere Jahre und Entwürfe zählen nicht', async () => {
|
||||
for (const nr of [1, 2, 7]) await legeNummerierteAn(2026, nr)
|
||||
await legeNummerierteAn(2025, 40)
|
||||
await createAuftrag(entwurfsDaten()) // Entwurf ohne Nummer
|
||||
assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0008', jahr: 2026, laufnummer: 8 })
|
||||
assert.deepEqual(await vergibNummer(new Date('2025-03-01T10:00:00Z')), { nummer: 'AB-2025-0041', jahr: 2025, laufnummer: 41 })
|
||||
})
|
||||
|
||||
test('vergibNummer: Jahreswechsel nach Berliner Zeit (31.12. 23:00 UTC ist in Berlin schon der 01.01.)', async () => {
|
||||
for (const nr of [1, 2, 3]) await legeNummerierteAn(2026, nr)
|
||||
assert.equal((await vergibNummer(new Date('2026-12-31T22:59:59Z'))).nummer, 'AB-2026-0004')
|
||||
assert.deepEqual(await vergibNummer(new Date('2026-12-31T23:00:00Z')), { nummer: 'AB-2027-0001', jahr: 2027, laufnummer: 1 })
|
||||
assert.equal((await vergibNummer(new Date('2027-01-01T08:00:00Z'))).nummer, 'AB-2027-0001')
|
||||
})
|
||||
|
||||
test('vergibNummer: zählt auch bei mehr als 25 ABs im Jahr richtig (Seitengröße 25 wie bei Appwrite ohne limit)', async () => {
|
||||
for (let i = 1; i <= 30; i += 1) await legeNummerierteAn(2026, i)
|
||||
assert.equal((await vergibNummer(new Date('2026-10-06T10:00:00Z'))).nummer, 'AB-2026-0031')
|
||||
})
|
||||
|
||||
test('vergibNummer: überspringt eine Nummer, die es schon gibt (Dokument mit nummer, aber ohne laufnummer)', async () => {
|
||||
const entwurf = await createAuftrag(entwurfsDaten())
|
||||
await updateAuftrag(entwurf.id, { status: 'versendet', nummer: 'AB-2026-0001' })
|
||||
assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2 })
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, FREMD, VON, MELDUNG, festpreisEingabe, aufwandEingabe, kundeDoc, baueDeps } from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config } = umg
|
||||
const { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { default: router } = await import('../server/routes/admin/auftraege.js')
|
||||
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { legeEntwurfAn, sendeAuftrag, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js')
|
||||
const app = await startTestApp({ '/api/admin/finanzen/auftraege': router })
|
||||
after(async () => {
|
||||
await app.close()
|
||||
await umg.schliesse()
|
||||
})
|
||||
|
||||
let kontext // Attrappen für PDF, Token und Mail des aktuellen Tests
|
||||
beforeEach(async () => {
|
||||
await umg.zuruecksetzen()
|
||||
kontext = baueDeps()
|
||||
setzeDepsFuerTests(kontext.deps)
|
||||
})
|
||||
const BASIS = '/api/admin/finanzen/auftraege'
|
||||
const api = (methode, pfad, body, jwt = 'jwt-admin') => send(methode, BASIS + pfad, { headers: { Authorization: `Bearer ${jwt}` }, body })
|
||||
const entwurfAnlegen = (patch) => legeEntwurfAn(festpreisEingabe(patch), { von: VON })
|
||||
|
||||
const ZUGRIFF = [
|
||||
['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/'], ['GET', '/a_1'], ['PATCH', '/a_1'], ['DELETE', '/a_1'], ['GET', '/a_1/pdf'],
|
||||
['POST', '/a_1/senden'], ['POST', '/a_1/erinnern'], ['POST', '/a_1/neue-fassung'], ['POST', '/a_1/stornieren'], ['POST', '/a_1/projekt'],
|
||||
]
|
||||
|
||||
test('Zugriff: ohne JWT 401, ohne Admin-Label 403 – bei jeder Route', async () => {
|
||||
for (const [methode, pfad] of ZUGRIFF) {
|
||||
const body = ['GET', 'DELETE'].includes(methode) ? undefined : {}
|
||||
assert.equal((await send(methode, BASIS + pfad, { body })).status, 401, `${methode} ${pfad} ohne JWT`)
|
||||
for (const jwt of ['jwt-staff', 'jwt-kunde']) assert.equal((await api(methode, pfad, body, jwt)).status, 403, `${methode} ${pfad} mit ${jwt}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Schalter: ist die Funktion für den Kunden aus, antworten Liste, Anlegen, Lesen, Ändern, Löschen und PDF mit 404 und der festen Meldung', async () => {
|
||||
const a = await entwurfAnlegen() // angelegt, solange der Schalter an ist
|
||||
config.auftraege.mode = 'off'
|
||||
const routen = [['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/', festpreisEingabe()], ['GET', `/${a.id}`], ['PATCH', `/${a.id}`, { titel: 'x' }], ['DELETE', `/${a.id}`], ['GET', `/${a.id}/pdf`]]
|
||||
for (const [methode, pfad, body] of routen) {
|
||||
const r = await api(methode, pfad, body)
|
||||
assert.deepEqual([r.status, r.body], [404, { error: SCHALTER_AUS_MELDUNG }], `${methode} ${pfad}`)
|
||||
}
|
||||
assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel') // nichts wurde geändert oder gelöscht
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['anna@example.invalid'] // E-Mail des Kunden
|
||||
assert.equal((await api('GET', `/${a.id}`)).status, 200)
|
||||
config.auftraege.pilotEmails = ['jemand@example.invalid']
|
||||
assert.equal((await api('GET', `/${a.id}`)).status, 404)
|
||||
})
|
||||
|
||||
test('GET Liste: Aufträge des Kunden, neueste zuerst, ohne interne Felder; 400 ohne Kunde, 404 bei unbekanntem Kunden', async () => {
|
||||
const a = await entwurfAnlegen()
|
||||
const b = await legeEntwurfAn(aufwandEingabe(), { von: VON })
|
||||
const r = await api('GET', `/?kunde=${KUNDE_ID}`)
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual(r.body.auftraege.map((x) => x.id), [b.id, a.id])
|
||||
for (const x of r.body.auftraege) {
|
||||
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in x, false, feld)
|
||||
assert.equal(typeof x.pdfVorhanden, 'boolean')
|
||||
}
|
||||
const ohneKunde = await api('GET', '/')
|
||||
assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde fehlt.' }])
|
||||
assert.equal((await api('GET', '/?kunde=c_gibtsnicht')).status, 404)
|
||||
assert.equal((await api('GET', '/?kunde=../x')).status, 404)
|
||||
})
|
||||
|
||||
test('POST: legt einen Entwurf an (200, AuftragTeam mit Texten); Fehler als { error } mit festen Meldungen', async () => {
|
||||
const r = await api('POST', '/', festpreisEingabe())
|
||||
assert.equal(r.status, 200)
|
||||
const a = r.body.auftrag
|
||||
assert.deepEqual([a.status, a.titel, a.summeCents, a.erstelltVon], ['entwurf', 'Website Beispiel', 80000, 'kenso@webklar.com'])
|
||||
assert.deepEqual([typeof a.zahlungsplanText, a.pdfVorhanden, 'tokenHash' in a], ['string', false, false])
|
||||
const ohnePositionen = await api('POST', '/', festpreisEingabe({ positionen: [] }))
|
||||
assert.deepEqual([ohnePositionen.status, ohnePositionen.body], [400, { error: MELDUNG.positionen }])
|
||||
const ohneKunde = await api('POST', '/', festpreisEingabe({ kundenId: undefined }))
|
||||
assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde: Bitte einen Kunden angeben.' }])
|
||||
assert.equal((await api('POST', '/', festpreisEingabe({ kundenId: 'c_gibtsnicht' }))).status, 404)
|
||||
})
|
||||
|
||||
test('GET, PATCH, DELETE: Entwurf lesen, ändern, löschen; gesendete AB ist unveränderlich (409); ungültige Kennungen 404', async () => {
|
||||
const a = await entwurfAnlegen()
|
||||
const gelesen = await api('GET', `/${a.id}`)
|
||||
assert.deepEqual([gelesen.status, gelesen.body.auftrag.id], [200, a.id])
|
||||
const geaendert = await api('PATCH', `/${a.id}`, { titel: 'Neu' })
|
||||
assert.deepEqual([geaendert.status, geaendert.body.auftrag.titel], [200, 'Neu'])
|
||||
const falsch = await api('PATCH', `/${a.id}`, { zahlungszielTage: 99 })
|
||||
assert.deepEqual([falsch.status, falsch.body], [400, { error: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.' }])
|
||||
const geloescht = await api('DELETE', `/${a.id}`)
|
||||
assert.deepEqual([geloescht.status, geloescht.body], [200, { ok: true }])
|
||||
assert.deepEqual((await api('GET', `/${a.id}`)).body, { error: MELDUNG.auftragFehlt })
|
||||
for (const kennung of ['a%20b', 'x'.repeat(40), '..%2F..']) assert.equal((await api('GET', `/${kennung}`)).status, 404, kennung)
|
||||
const b = await entwurfAnlegen()
|
||||
await sendeAuftrag(b.id, { von: VON })
|
||||
const patch = await api('PATCH', `/${b.id}`, { titel: 'Nachträglich' })
|
||||
assert.deepEqual([patch.status, patch.body], [409, { error: MELDUNG.nurEntwurfAendern }])
|
||||
const loeschen = await api('DELETE', `/${b.id}`)
|
||||
assert.deepEqual([loeschen.status, loeschen.body], [409, { error: MELDUNG.nurEntwurfLoeschen }])
|
||||
})
|
||||
|
||||
test('GET pdf: Entwurf als Vorschau, gesendete AB aus der gespeicherten Datei, mit Kopfzeilen', async () => {
|
||||
const entwurf = await entwurfAnlegen()
|
||||
const vorschau = await api('GET', `/${entwurf.id}/pdf`)
|
||||
assert.equal(vorschau.status, 200)
|
||||
assert.equal(vorschau.body, '%PDF-1.4 Entwurf')
|
||||
assert.match(vorschau.headers.get('content-type'), /^application\/pdf/)
|
||||
assert.equal(vorschau.headers.get('content-disposition'), 'inline; filename="Auftragsbestaetigung-Entwurf.pdf"')
|
||||
assert.equal(vorschau.headers.get('cache-control'), 'no-store')
|
||||
const { auftrag } = await sendeAuftrag(entwurf.id, { von: VON }) // Attrappen aus setzeDepsFuerTests
|
||||
const gespeichert = await api('GET', `/${entwurf.id}/pdf`)
|
||||
assert.equal(gespeichert.body, `%PDF-1.4 ${auftrag.nummer}`)
|
||||
assert.equal(gespeichert.headers.get('content-disposition'), `inline; filename="${auftrag.nummer}.pdf"`)
|
||||
assert.equal((await api('GET', '/a_gibtsnicht/pdf')).status, 404)
|
||||
})
|
||||
|
||||
test('Unerwartete Fehler: 500 mit fester Meldung, nichts von Appwrite gelangt an die Seite', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const a = await entwurfAnlegen()
|
||||
aw.failNext({ collection: config.collections.auftraege, method: 'GET', status: 500, message: 'GEHEIM-Appwrite-Detail' })
|
||||
const r = await api('GET', `/${a.id}`)
|
||||
assert.deepEqual([r.status, r.body], [500, { error: 'Interner Fehler.' }])
|
||||
})
|
||||
|
||||
test('POST senden: 200 ohne Token und Dateipfad; Kundendaten-, PDF- und Statusfehler mit festen Meldungen; Mail-Fehler als mailFehler: true', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const a = await entwurfAnlegen()
|
||||
aw.seed(config.collections.customers, kundeDoc({ email: '' }))
|
||||
let r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.keineMail }])
|
||||
aw.seed(config.collections.customers, kundeDoc({ street: '' }))
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.adresse }])
|
||||
aw.seed(config.collections.customers, kundeDoc())
|
||||
kontext = baueDeps({ pdfFehler: new Error('pdfkit') })
|
||||
setzeDepsFuerTests(kontext.deps)
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body], [500, { error: MELDUNG.pdf }])
|
||||
kontext = baueDeps({ mailFehler: new Error('smtp') })
|
||||
setzeDepsFuerTests(kontext.deps)
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.mailFehler, true)
|
||||
assert.deepEqual([r.body.auftrag.status, r.body.auftrag.mailStatus, r.body.auftrag.pdfVorhanden], ['versendet', 'fehler', true])
|
||||
assert.match(r.body.auftrag.nummer, /^AB-\d{4}-0001$/)
|
||||
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in r.body.auftrag, false, feld)
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.nurEntwurfSenden }])
|
||||
})
|
||||
|
||||
test('POST erinnern, neue-fassung, stornieren: Antworten, Folgezustände und 409 bei falschem Status', async () => {
|
||||
const a = await entwurfAnlegen()
|
||||
let r = await api('POST', `/${a.id}/erinnern`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keineErinnerung }])
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body.mailFehler, r.body.auftrag.status], [200, false, 'versendet'])
|
||||
r = await api('POST', `/${a.id}/erinnern`)
|
||||
assert.deepEqual([r.status, r.body.mailFehler, r.body.auftrag.status], [200, false, 'versendet'])
|
||||
assert.equal(kontext.calls.mail.at(-1).erinnerung, true)
|
||||
r = await api('POST', `/${a.id}/neue-fassung`)
|
||||
assert.deepEqual([r.status, r.body.auftrag.status, r.body.auftrag.ersetztId], [200, 'entwurf', a.id])
|
||||
r = await api('POST', `/${a.id}/neue-fassung`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.schonEntwurf }])
|
||||
r = await api('POST', `/${a.id}/stornieren`)
|
||||
assert.deepEqual([r.status, r.body.auftrag.status], [200, 'storniert'])
|
||||
r = await api('POST', `/${a.id}/stornieren`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keinStorno }])
|
||||
})
|
||||
|
||||
test('POST projekt: ordnet einer AB ohne Projekt das Projekt zu (200, AuftragTeam); 400 ohne oder mit fremdem Projekt, 404 bei unbekannter AB, 409 bei vorhandenem Projekt oder falschem Status', async () => {
|
||||
const a = await entwurfAnlegen({ projektId: '', titel: 'Beratung ohne Projekt' })
|
||||
await api('POST', `/${a.id}/senden`)
|
||||
let r = await api('POST', `/${a.id}/projekt`, {})
|
||||
assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFehlt }])
|
||||
r = await api('POST', `/${a.id}/projekt`)
|
||||
assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFehlt }])
|
||||
r = await api('POST', `/${a.id}/projekt`, { projektId: FREMD.projekt })
|
||||
assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFremd }])
|
||||
r = await api('POST', '/a_gibtsnicht/projekt', { projektId: PROJEKT_ID })
|
||||
assert.deepEqual([r.status, r.body], [404, { error: MELDUNG.auftragFehlt }])
|
||||
assert.equal((await getAuftrag(a.id)).projektId, '') // die abgelehnten Aufrufe haben nichts geändert
|
||||
r = await api('POST', `/${a.id}/projekt`, { projektId: PROJEKT_ID })
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual([r.body.auftrag.projektId, r.body.auftrag.zeitquelle, r.body.auftrag.status], [PROJEKT_ID, [TICKET_ID], 'versendet'])
|
||||
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in r.body.auftrag, false, feld)
|
||||
r = await api('POST', `/${a.id}/projekt`, { projektId: PROJEKT_ID })
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.schonProjekt }])
|
||||
const b = await entwurfAnlegen({ projektId: '', titel: 'Zweite Beratung ohne Projekt' })
|
||||
await api('POST', `/${b.id}/senden`)
|
||||
await api('POST', `/${b.id}/stornieren`)
|
||||
r = await api('POST', `/${b.id}/projekt`, { projektId: PROJEKT_ID })
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keinProjekt }])
|
||||
})
|
||||
|
||||
test('Schalter aus: auch senden, erinnern, neue-fassung, stornieren und projekt antworten 404 mit der festen Meldung', async () => {
|
||||
const a = await entwurfAnlegen({ projektId: '', titel: 'Beratung ohne Projekt' })
|
||||
config.auftraege.mode = 'off'
|
||||
for (const aktion of ['senden', 'erinnern', 'neue-fassung', 'stornieren', 'projekt']) {
|
||||
const r = await api('POST', `/${a.id}/${aktion}`, aktion === 'projekt' ? { projektId: PROJEKT_ID } : undefined)
|
||||
assert.deepEqual([r.status, r.body], [404, { error: SCHALTER_AUS_MELDUNG }], aktion)
|
||||
}
|
||||
const danach = await getAuftrag(a.id)
|
||||
assert.deepEqual([danach.status, danach.projektId], ['entwurf', ''])
|
||||
assert.equal(kontext.calls.pdf.length + kontext.calls.mail.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
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)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mock } from 'node:test'
|
||||
import { startAppwriteMock } from '../support/appwriteMock.js'
|
||||
import { useTestEnv } from '../support/testEnv.js'
|
||||
import { AUFTRAEGE_COLLECTIONS } from '../../server/services/auftraege/schema.js'
|
||||
|
||||
// Testhilfen des Strangs L3 (AB-Kern). Kein statischer Import von server/config.js: starteUmgebung() setzt zuerst die
|
||||
// Umgebung, erst danach darf config.js geladen werden.
|
||||
|
||||
const NAMEN = Object.keys(AUFTRAEGE_COLLECTIONS)
|
||||
const attribute = (name) => AUFTRAEGE_COLLECTIONS[name].attributes
|
||||
/** Attribut-Schlüssel je AB-Collection für startAppwriteMock({ schema }). */
|
||||
export const AUFTRAEGE_SCHEMA = Object.fromEntries(NAMEN.map((name) => [name, attribute(name).map((a) => a.key)]))
|
||||
/** Datumsfelder je AB-Collection für startAppwriteMock({ datetimes }): der Nachbau schreibt sie wie Appwrite ("+00:00"). */
|
||||
export const AUFTRAEGE_DATETIMES = Object.fromEntries(
|
||||
NAMEN.map((name) => [name, attribute(name).filter((a) => a.type === 'datetime').map((a) => a.key)]),
|
||||
)
|
||||
|
||||
export const KUNDE_ID = 'c_kunde01'
|
||||
export const PROJEKT_ID = 'p_projekt01'
|
||||
export const TICKET_ID = 't_ticket01'
|
||||
export const ZWEITES_TICKET = 't_zwei02'
|
||||
export const LEAD_KUNDE = 'c_lead01'
|
||||
export const FREMD = { kunde: 'c_fremd01', projekt: 'p_fremd01', ticket: 't_fremd01' }
|
||||
export const VON = 'kenso@webklar.com'
|
||||
|
||||
/** Feste Meldungen (wörtlich wie in der Tabelle „Meldungstexte“ des Plans). */
|
||||
export const MELDUNG = {
|
||||
auftragFehlt: 'Auftragsbestätigung nicht gefunden.',
|
||||
kundeFehlt: 'Kunde nicht gefunden.',
|
||||
positionen: 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.',
|
||||
nurEntwurfAendern: 'Nur ein Entwurf lässt sich ändern.',
|
||||
nurEntwurfLoeschen: 'Nur ein Entwurf lässt sich löschen.',
|
||||
nurEntwurfSenden: 'Nur ein Entwurf lässt sich senden.',
|
||||
keinFesterKunde: 'Auftragsbestätigungen nur für feste Kunden.',
|
||||
keineKundenart: 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).',
|
||||
keineMail: 'Für den Versand fehlt die E-Mail-Adresse des Kunden.',
|
||||
adresse: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.',
|
||||
pdf: 'Das PDF konnte nicht erzeugt werden.',
|
||||
pruefsumme: 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.',
|
||||
ersetzteUngueltig: 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.',
|
||||
keineErinnerung: 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.',
|
||||
keineNeueFassung: 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.',
|
||||
schonEntwurf: 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.',
|
||||
keinStorno: 'Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.',
|
||||
projektFehlt: 'Projekt: Bitte ein Projekt angeben.',
|
||||
projektUngueltig: 'Projekt: Ungültige Projekt-Kennung.',
|
||||
projektFremd: 'Projekt: Das Projekt gehört nicht zu diesem Kunden.',
|
||||
schonProjekt: 'Diese Auftragsbestätigung gehört schon zu einem Projekt.',
|
||||
keinProjekt: 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.',
|
||||
}
|
||||
|
||||
/** Kundendokument wie in Appwrite: Unternehmen mit vollständiger Anschrift und E-Mail. */
|
||||
export const kundeDoc = (patch = {}) => ({
|
||||
$id: KUNDE_ID,
|
||||
name: 'Anna Beispiel',
|
||||
companyName: 'Beispiel GmbH',
|
||||
email: 'anna@example.invalid',
|
||||
street: 'Hauptstraße 1',
|
||||
postalCode: '67722',
|
||||
city: 'Winnweiler',
|
||||
country: 'DE',
|
||||
code: 'K-1001',
|
||||
customerStatus: 'active',
|
||||
kundenart: 'unternehmen',
|
||||
...patch,
|
||||
})
|
||||
export const projektDoc = (patch = {}) => ({ $id: PROJEKT_ID, customerId: KUNDE_ID, projectName: 'Website Beispiel', ticketId: TICKET_ID, ...patch })
|
||||
export const ticketDoc = (patch = {}) => ({ $id: TICKET_ID, customerId: KUNDE_ID, woid: '100200', topic: 'Website Beispiel', ...patch })
|
||||
|
||||
/** Gültige POST-Eingabe: Festpreis, Summe 800,00 € (600 + 2 × 100), Anzahlung 50 % = 400,00 €. */
|
||||
export const festpreisEingabe = (patch = {}) => ({
|
||||
kundenId: KUNDE_ID,
|
||||
projektId: PROJEKT_ID,
|
||||
titel: '',
|
||||
art: 'festpreis',
|
||||
positionen: [
|
||||
{ beschreibung: 'Webdesign Startseite', menge: 1, einzelpreisCents: 60000 },
|
||||
{ beschreibung: 'Kontaktformular', menge: 2, einzelpreisCents: 10000 },
|
||||
],
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
|
||||
zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05',
|
||||
auftragWeg: 'telefon',
|
||||
startAm: '',
|
||||
fertigBis: '',
|
||||
zeitquelle: [],
|
||||
...patch,
|
||||
})
|
||||
|
||||
/** Gültige POST-Eingabe: nach Aufwand zu 100 €/Std., Schätzung 10 Stunden. */
|
||||
export const aufwandEingabe = (patch = {}) => ({
|
||||
kundenId: KUNDE_ID,
|
||||
projektId: PROJEKT_ID,
|
||||
titel: '',
|
||||
art: 'aufwand',
|
||||
stundensatzCents: 10000,
|
||||
schaetzungMinuten: 600,
|
||||
zahlungsplan: { art: 'monatlich', prozent: null, text: '' },
|
||||
zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05',
|
||||
auftragWeg: 'email',
|
||||
startAm: '',
|
||||
fertigBis: '',
|
||||
zeitquelle: [],
|
||||
...patch,
|
||||
})
|
||||
|
||||
/** Genau die Felder, die legeEntwurfAn an createAuftrag übergibt (bereinigt, ohne kundenart). */
|
||||
export const entwurfsDaten = (patch = {}) => ({
|
||||
kundenId: KUNDE_ID,
|
||||
projektId: PROJEKT_ID,
|
||||
titel: 'Website Beispiel',
|
||||
zeitquelle: [TICKET_ID],
|
||||
art: 'festpreis',
|
||||
positionen: festpreisEingabe().positionen,
|
||||
stundensatzCents: null,
|
||||
schaetzungMinuten: null,
|
||||
summeCents: 80000,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
|
||||
zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05',
|
||||
auftragWeg: 'telefon',
|
||||
startAm: '',
|
||||
fertigBis: '',
|
||||
erstelltVon: VON,
|
||||
...patch,
|
||||
})
|
||||
|
||||
export const sha256 = (daten) => createHash('sha256').update(daten).digest('hex')
|
||||
export const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
/** Offene Zusage, die der Test später einlöst: { promise, freigeben }. */
|
||||
export function tor() {
|
||||
let freigeben
|
||||
const promise = new Promise((resolve) => {
|
||||
freigeben = resolve
|
||||
})
|
||||
return { promise, freigeben }
|
||||
}
|
||||
/** Wartet (höchstens eine Sekunde), bis bedingung() wahr ist. */
|
||||
export async function wartebis(bedingung) {
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
if (bedingung()) return
|
||||
await warte(5)
|
||||
}
|
||||
throw new Error('Bedingung nicht eingetreten')
|
||||
}
|
||||
/** Fängt eine Zusage ab, damit sie nie unbehandelt scheitert: { wert } oder { fehler }. */
|
||||
export const ergebnis = (promise) => promise.then((wert) => ({ wert }), (fehler) => ({ fehler }))
|
||||
|
||||
const pruefeFehler = (status, message, name) => (err) => {
|
||||
assert.equal(err.status, status, `${name} Status ${err.status}: ${err.message}`)
|
||||
if (message !== undefined) assert.equal(err.message, message, name)
|
||||
return true
|
||||
}
|
||||
/** Die Zusage scheitert mit diesem Status und (wenn angegeben) dieser Meldung. */
|
||||
export const abgelehnt = (promise, status, message, name = '') => assert.rejects(promise, pruefeFehler(status, message, name))
|
||||
/** Der Aufruf wirft sofort mit diesem Status und (wenn angegeben) dieser Meldung. */
|
||||
export const wirftFehler = (aufruf, status, message, name = '') => assert.throws(aufruf, pruefeFehler(status, message, name))
|
||||
|
||||
let tokenZaehler = 0
|
||||
/**
|
||||
* Attrappen für deps (kein pdfkit, kein SMTP); calls zeichnet jeden Aufruf auf. Optionen: pdf (feste Antwort, sonst
|
||||
* "%PDF-1.4 <nummer>"), pdfFehler, pdfWarten (Promise, auf die die PDF-Erzeugung wartet), mailFehler, mailWarten.
|
||||
*/
|
||||
export function baueDeps({ pdf = null, pdfFehler = null, pdfWarten = null, mailFehler = null, mailWarten = null } = {}) {
|
||||
const calls = { pdf: [], token: [], mail: [] }
|
||||
const deps = {
|
||||
async erzeugeAuftragPdf(auftrag, optionen) {
|
||||
calls.pdf.push({ auftrag: structuredClone(auftrag), optionen })
|
||||
if (pdfWarten) await pdfWarten
|
||||
if (pdfFehler) throw pdfFehler
|
||||
return pdf ?? Buffer.from(`%PDF-1.4 ${auftrag.nummer}`)
|
||||
},
|
||||
neuerToken(jetzt = new Date()) {
|
||||
tokenZaehler += 1
|
||||
const token = `t${tokenZaehler}`.padEnd(43, 'x')
|
||||
calls.token.push(token)
|
||||
return { token, hash: sha256(token), gueltigBis: new Date(jetzt.getTime() + 60 * 86_400_000) }
|
||||
},
|
||||
async sendeAuftragMail(auftrag, optionen) {
|
||||
calls.mail.push({ auftrag: structuredClone(auftrag), pdf: optionen.pdf, token: optionen.token, erinnerung: optionen.erinnerung ?? false })
|
||||
if (mailWarten) await mailWarten
|
||||
if (mailFehler) throw mailFehler
|
||||
},
|
||||
}
|
||||
return { deps, calls }
|
||||
}
|
||||
|
||||
const PRAEFIX = 'auftraege-l3-'
|
||||
function pruefeTestVerzeichnis(dir) {
|
||||
if (path.dirname(dir) !== os.tmpdir() || !path.basename(dir).startsWith(PRAEFIX)) throw new Error(`Kein Testverzeichnis des Strangs L3: ${dir}`)
|
||||
}
|
||||
export const legeUploadVerzeichnisAn = () => fs.mkdtempSync(path.join(os.tmpdir(), PRAEFIX))
|
||||
export function raeumeUploadVerzeichnisAuf(dir) {
|
||||
pruefeTestVerzeichnis(dir)
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
export function leereVerzeichnis(dir) {
|
||||
pruefeTestVerzeichnis(dir)
|
||||
for (const name of fs.readdirSync(dir)) fs.rmSync(path.join(dir, name), { recursive: true, force: true })
|
||||
}
|
||||
/** Alle Dateien unterhalb von dir, relativ und sortiert (leere Verzeichnisse zählen nicht). */
|
||||
export function dateienIn(dir) {
|
||||
const gefunden = []
|
||||
const durchsuche = (relativ) => {
|
||||
for (const eintrag of fs.readdirSync(path.join(dir, relativ), { withFileTypes: true })) {
|
||||
const pfad = path.join(relativ, eintrag.name)
|
||||
if (eintrag.isDirectory()) durchsuche(pfad)
|
||||
else gefunden.push(pfad)
|
||||
}
|
||||
}
|
||||
durchsuche('')
|
||||
return gefunden.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Appwrite-Nachbau (mit dem Schema der AB-Collections, wie Appwrite mit 25 Treffern ohne limit), setzt die
|
||||
* Testumgebung (Schalter an, Upload-Verzeichnis im Temp-Ordner) und lädt erst danach config.js. Die Module des Strangs
|
||||
* importiert der Test selbst, danach.
|
||||
*/
|
||||
export async function starteUmgebung() {
|
||||
const uploadDir = legeUploadVerzeichnisAn()
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = uploadDir
|
||||
process.env.AUFTRAEGE_ENABLED = 'on'
|
||||
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../../server/config.js')
|
||||
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
|
||||
const stamm = () => [
|
||||
[config.collections.customers, kundeDoc()],
|
||||
[config.collections.customers, kundeDoc({ $id: FREMD.kunde, name: 'Fritz Fremd', companyName: '', email: 'fritz@example.invalid', kundenart: '' })],
|
||||
[config.collections.customers, kundeDoc({ $id: LEAD_KUNDE, name: 'Lea Lead', customerStatus: 'lead', kundenart: '' })],
|
||||
[config.collections.websiteProjects, projektDoc()],
|
||||
[config.collections.websiteProjects, projektDoc({ $id: FREMD.projekt, customerId: FREMD.kunde, projectName: 'Fremde Website', ticketId: FREMD.ticket })],
|
||||
[config.collections.workorders, ticketDoc()],
|
||||
[config.collections.workorders, ticketDoc({ $id: ZWEITES_TICKET })],
|
||||
[config.collections.workorders, ticketDoc({ $id: FREMD.ticket, customerId: FREMD.kunde })],
|
||||
]
|
||||
return {
|
||||
aw,
|
||||
config,
|
||||
uploadDir,
|
||||
/** Vor jedem Test: Spione zurück, Schalter an, AB-Collections und Upload-Verzeichnis leer, Stammdaten neu. */
|
||||
async zuruecksetzen() {
|
||||
mock.restoreAll()
|
||||
config.auftraege.mode = 'on'
|
||||
config.auftraege.pilotEmails = []
|
||||
for (const collection of [config.collections.auftraege, config.collections.projektRechnungen]) {
|
||||
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
|
||||
}
|
||||
leereVerzeichnis(uploadDir)
|
||||
for (const [collection, doc] of stamm()) aw.seed(collection, doc)
|
||||
},
|
||||
async schliesse() {
|
||||
await aw.close()
|
||||
raeumeUploadVerzeichnisAuf(uploadDir)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user