Merge auftraege/w2-auftraege: Auftrags-UI (Strang W2)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:48:55 +00:00
commit b7b3e834f8
13 files changed
+2596 -21

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { PDF_FREIGABE_NACH_MS, alsPdfBlob, oeffnePdfImTab } = await loadModule('src/lib/auftragPdf.js')
// Fester Ablauf: Tab zuerst (der Browser blockiert sonst das Pop-up), dann Blob holen, Objekt-URL erzeugen, später freigeben
function umgebung() {
const ablauf = []
const tab = { location: {}, opener: 'woms', close: () => ablauf.push('schliessen') }
const win = { open: (...args) => { ablauf.push('oeffnen'); win.args = args; return tab } }
const urlApi = {
createObjectURL: () => { ablauf.push('url'); return 'blob:https://woms.test/abc' },
revokeObjectURL: (url) => ablauf.push(`frei:${url}`),
}
const timer = (fn, ms) => { ablauf.push(`timer:${ms}`); timer.fn = fn }
return { ablauf, tab, win, urlApi, timer }
}
test('alsPdfBlob: setzt den Typ application/pdf, ein passender Blob bleibt unverändert', async () => {
const roh = new Blob(['%PDF-1.4'], { type: 'application/octet-stream' })
const pdf = alsPdfBlob(roh)
assert.equal(pdf.type, 'application/pdf')
assert.equal(await pdf.text(), '%PDF-1.4')
const schon = new Blob(['x'], { type: 'application/pdf' })
assert.equal(alsPdfBlob(schon), schon)
})
test('oeffnePdfImTab: Tab zuerst, dann Blob, Objekt-URL im Tab, später freigeben', async () => {
const u = umgebung()
await oeffnePdfImTab(async () => { u.ablauf.push('holen'); return new Blob(['%PDF'], { type: 'application/pdf' }) }, u)
assert.equal(PDF_FREIGABE_NACH_MS, 300000)
assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'url', 'timer:300000'])
assert.equal(u.tab.location.href, 'blob:https://woms.test/abc')
assert.equal(u.tab.opener, null)
u.timer.fn()
assert.equal(u.ablauf.at(-1), 'frei:blob:https://woms.test/abc')
})
test('oeffnePdfImTab: Fehler beim Holen schließt den Tab, es entsteht keine URL', async () => {
const u = umgebung()
await assert.rejects(() => oeffnePdfImTab(async () => { u.ablauf.push('holen'); throw new Error('PDF nicht gefunden.') }, u), /PDF nicht gefunden/)
assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'schliessen'])
})
test('oeffnePdfImTab: blockiertes Pop-up, der Blob wird gar nicht geholt', async () => {
let geholt = false
const win = { open: () => null }
await assert.rejects(() => oeffnePdfImTab(async () => { geholt = true; return new Blob() }, { win }), { message: 'Neuer Tab wurde vom Browser blockiert.' })
assert.equal(geholt, false)
})
+680
View File
@@ -0,0 +1,680 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, SCHRITTE, zahlungsplanOptionen,
parseStunden, stundenText, parseProzent, parseTage, heuteBerlin,
neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents,
pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle,
kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise,
AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf, zuordnenOptionen, projektZuordnenMoeglich, auftragAktionen,
auftragNotizen, auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, neueSperre,
} = await loadModule('src/lib/auftragView.js')
const KONTEN = [
{ projektId: 'p1', name: 'Website Praxis Müller', tickets: [{ id: 't1', woid: '100131' }] },
{ projektId: 'p2', name: 'Shop Relaunch', tickets: [{ id: 't2', woid: '100140' }, { id: 't3', woid: '100141' }] },
]
const KUNDE = { $id: 'c1', name: 'Praxis Müller', email: 'info@praxis.test', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', kundenart: '', customerStatus: 'customer' }
const festpreisForm = (over = {}) => ({
...neuesFormular({ heute: '2026-10-06' }),
titel: 'Website', kundenart: 'privat', auftragWeg: 'telefon',
positionen: [{ uid: 1, description: 'Startseite', quantity: '1', price: '800,00' }],
...over,
})
const aufwandForm = (over = {}) => festpreisForm({ art: 'aufwand', positionen: [], ...over })
const OK = { ok: true, fehler: {}, erstes: '' }
test('Konstanten gleichen dem Vertrag V2 und der Spec (Limits, Wege, Zahlungspläne, Schritte)', () => {
assert.deepEqual(AUFTRAG_LIMITS, {
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
})
assert.equal(STUNDENSATZ_STANDARD_CENTS, 10000)
assert.deepEqual(AUFTRAG_WEGE, [
{ value: 'muendlich', label: 'Mündlich' }, { value: 'telefon', label: 'Telefonisch' },
{ value: 'email', label: 'Per E-Mail' }, { value: 'vor_ort', label: 'Vor Ort' }, { value: 'chat', label: 'Im Chat' },
])
assert.deepEqual(zahlungsplanOptionen('festpreis'), [
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
{ value: 'anzahlung', label: 'Anzahlung bei Auftrag, Rest nach Abnahme' },
{ value: 'frei', label: 'Frei (eigener Text)' },
])
assert.deepEqual(zahlungsplanOptionen('aufwand'), [
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
{ value: 'monatlich', label: 'Monatlich nach Aufwand' },
{ value: 'frei', label: 'Frei (eigener Text)' },
])
assert.deepEqual(SCHRITTE, [
{ id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' },
])
})
test('parseStunden: Stunden mit Komma in 15-Minuten-Schritten werden Minuten', () => {
const gut = { '10': 600, '10,25': 615, '10,5': 630, '10,50': 630, '10,75': 645, '0,25': 15, '1': 60, '12,5 h': 750, '2 Std.': 120, ' 8 ': 480, '1666': 99960 }
for (const [text, minuten] of Object.entries(gut)) assert.equal(parseStunden(text), minuten, text)
for (const schlecht of ['', '0', '0,00', '10,3', '10,30', '10,1', '10.5', '-1', 'abc', '1666,25', '1667', '10,255', '1e3', ',5', '10 Minuten', null, undefined]) {
assert.equal(parseStunden(schlecht), null, String(schlecht))
}
})
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
assert.deepEqual([15, 30, 45, 60, 75, 120, 135, 150, 600, 99960].map(stundenText), ['0,25', '0,5', '0,75', '1', '1,25', '2', '2,25', '2,5', '10', '1666'])
assert.equal(stundenText(null), '–')
assert.equal(stundenText('x'), '–')
})
test('parseProzent und parseTage', () => {
assert.deepEqual(['50', ' 30 % ', '1', '99'].map(parseProzent), [50, 30, 1, 99])
for (const schlecht of ['', '0', '100', '-5', '33,3', '5a', 'x', null]) assert.equal(parseProzent(schlecht), null, String(schlecht))
assert.deepEqual(['14', '0', '60', ' 7 '].map(parseTage), [14, 0, 60, 7])
for (const schlecht of ['', '61', '-1', '1,5', 'x', '1000', null]) assert.equal(parseTage(schlecht), null, String(schlecht))
})
test('heuteBerlin: Berliner Kalendertag, nicht der UTC-Tag', () => {
assert.equal(heuteBerlin(Date.parse('2026-10-05T22:30:00.000Z')), '2026-10-06')
assert.equal(heuteBerlin(Date.parse('2026-10-05T21:59:00.000Z')), '2026-10-05')
assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
})
test('neuesFormular: Vorgaben; ein Projekt füllt Titel und Zeitquelle', () => {
const f = neuesFormular({ projektId: 'p2', konten: KONTEN, heute: '2026-10-06' })
const { positionen, ...rest } = f
assert.equal(positionen.length, 1)
const { uid, ...zeile } = positionen[0]
assert.equal(typeof uid, 'number')
assert.deepEqual(zeile, { description: '', quantity: '1', price: '' })
assert.deepEqual(rest, {
projektId: 'p2', titel: 'Shop Relaunch', titelGeaendert: false, kundenart: '', art: 'festpreis',
auftragVom: '2026-10-06', auftragWeg: '',
stundensatz: '100,00', schaetzung: '',
zahlungsart: 'nach_abnahme', prozent: '50', zahlungstext: '', zahlungsziel: '14',
startAm: '', fertigBis: '', zeitquelle: ['t2', 't3'], zeitquelleGeaendert: false,
})
assert.deepEqual(neuesFormular({ konten: KONTEN, heute: '2026-10-06' }).zeitquelle, [])
assert.equal(neuesFormular({ projektId: 'gibtsnicht', konten: KONTEN, heute: '2026-10-06' }).projektId, '')
assert.equal(neuesFormular({ projektId: 'p1', heute: '2026-10-06' }).projektId, '')
assert.notEqual(neuePosition().uid, neuePosition().uid)
})
test('auftragZuFormular: Entwurf vom Server wird zum Formular (Euro- und Stundentexte)', () => {
const entwurf = {
id: 'a1', status: 'entwurf', projektId: 'p1', titel: 'Website', art: 'festpreis',
positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }],
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'],
}
const f = auftragZuFormular(entwurf)
assert.deepEqual(f.positionen.map(({ uid, ...p }) => p), [{ description: 'Startseite', quantity: '2', price: '400,00' }])
const { positionen, ...rest } = f
assert.deepEqual(rest, {
projektId: 'p1', titel: 'Website', titelGeaendert: true, kundenart: '', art: 'festpreis',
auftragVom: '2026-10-05', auftragWeg: 'email',
stundensatz: '100,00', schaetzung: '',
zahlungsart: 'anzahlung', prozent: '30', zahlungstext: '', zahlungsziel: '21',
startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], zeitquelleGeaendert: true,
})
const aufwand = auftragZuFormular({
...entwurf, art: 'aufwand', positionen: [], stundensatzCents: 9550, schaetzungMinuten: 750, summeCents: null,
zahlungsplan: { art: 'frei', prozent: null, text: 'Nach Absprache' },
})
assert.equal(aufwand.stundensatz, '95,50')
assert.equal(aufwand.schaetzung, '12,5')
assert.equal(aufwand.positionen.length, 1) // eine leere Zeile für den Wechsel auf Festpreis
assert.deepEqual([aufwand.zahlungsart, aufwand.prozent, aufwand.zahlungstext], ['frei', '50', 'Nach Absprache'])
})
test('waehleProjekt: Titel und Zeitquelle folgen dem Projekt, bis sie von Hand geändert wurden', () => {
const f0 = neuesFormular({ konten: KONTEN, heute: '2026-10-06' })
const f1 = waehleProjekt(f0, 'p1', KONTEN)
assert.deepEqual([f1.projektId, f1.titel, f1.zeitquelle], ['p1', 'Website Praxis Müller', ['t1']])
const f2 = waehleProjekt(f1, 'p2', KONTEN)
assert.deepEqual([f2.titel, f2.zeitquelle], ['Shop Relaunch', ['t2', 't3']])
const f3 = waehleProjekt(f2, '', KONTEN)
assert.deepEqual([f3.projektId, f3.titel, f3.zeitquelle], ['', '', []])
assert.equal(waehleProjekt(f1, 'gibtsnicht', KONTEN).projektId, '')
const hand = { ...f1, titel: 'Eigener Titel', titelGeaendert: true, zeitquelle: ['t7'], zeitquelleGeaendert: true }
const f4 = waehleProjekt(hand, 'p2', KONTEN)
assert.deepEqual([f4.projektId, f4.titel, f4.zeitquelle], ['p2', 'Eigener Titel', ['t7']])
assert.equal(f0.projektId, '') // die Eingabe bleibt unverändert
})
test('wechselArt: ein unpassender Zahlungsplan wird zurückgesetzt', () => {
const f = { ...neuesFormular({ heute: '2026-10-06' }), zahlungsart: 'anzahlung' }
assert.equal(wechselArt(f, 'aufwand').art, 'aufwand')
assert.equal(wechselArt(f, 'aufwand').zahlungsart, 'nach_abnahme')
assert.equal(wechselArt(f, 'festpreis').zahlungsart, 'anzahlung')
assert.equal(wechselArt({ ...f, art: 'aufwand', zahlungsart: 'monatlich' }, 'festpreis').zahlungsart, 'nach_abnahme')
assert.equal(wechselArt({ ...f, zahlungsart: 'frei' }, 'aufwand').zahlungsart, 'frei')
assert.equal(wechselArt(f, 'quatsch'), f)
})
test('formularSummeCents: Festpreis summiert gültige Positionen, Aufwand hat keine Summe', () => {
const f = festpreisForm({
positionen: [
{ uid: 1, description: 'a', quantity: '2', price: '100,00' },
{ uid: 2, description: 'b', quantity: '1', price: '50' },
{ uid: 3, description: 'c', quantity: 'x', price: '9,99' },
],
})
assert.equal(formularSummeCents(f), 25000)
assert.equal(formularSummeCents(festpreisForm({ positionen: [] })), 0)
assert.equal(formularSummeCents(aufwandForm()), null)
})
test('pruefeSchritt 1: Titel ist Pflicht, die Kundenart nur, wenn sie am Kunden fehlt', () => {
const f = neuesFormular({ heute: '2026-10-06' })
const r = pruefeSchritt(1, f, { kunde: KUNDE })
assert.equal(r.ok, false)
assert.deepEqual(r.fehler, { titel: 'Bitte einen Titel eingeben (höchstens 200 Zeichen).', kundenart: 'Bitte die Kundenart wählen.' })
assert.equal(r.erstes, 'titel')
assert.deepEqual(pruefeSchritt(1, { ...f, titel: ' Website ', kundenart: 'privat' }, { kunde: KUNDE }), OK)
assert.deepEqual(pruefeSchritt(1, { ...f, titel: 'x' }, { kunde: { ...KUNDE, kundenart: 'privat' } }), OK)
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(201), kundenart: 'privat' }, { kunde: KUNDE }).ok, false)
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(200), kundenart: 'privat' }, { kunde: KUNDE }).ok, true)
assert.equal(pruefeSchritt(1, { ...f, titel: 'x', kundenart: 'firma' }, { kunde: KUNDE }).fehler.kundenart, 'Bitte die Kundenart wählen.')
})
test('pruefeSchritt 2 (Festpreis): Datum, Weg, Positionen und Summe', () => {
const pruefe = (over) => pruefeSchritt(2, festpreisForm(over), { kunde: KUNDE })
assert.deepEqual(pruefe(), OK)
const r = pruefe({ auftragVom: '', auftragWeg: '' })
assert.deepEqual(r.fehler, { auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.' })
assert.equal(r.erstes, 'auftragVom')
for (const schlecht of ['2026-02-30', '06.10.2026', '2026-1-1', 'morgen']) {
assert.equal(pruefe({ auftragVom: schlecht }).fehler.auftragVom, 'Bitte das Datum der Absprache angeben.', schlecht)
}
assert.equal(pruefe({ auftragWeg: 'brieftaube' }).fehler.auftragWeg, 'Bitte den Weg der Absprache wählen.')
assert.equal(pruefe({ art: 'xyz' }).fehler.art, 'Bitte die Art des Auftrags wählen.')
const pos = (o = {}) => ({ uid: 1, description: 'x', quantity: '1', price: '10,00', ...o })
const meldung = (positionen) => pruefe({ positionen }).fehler.positionen
const PREIS = 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
assert.equal(meldung([pos({ description: ' ' })]), 'Position 1: Bitte eine Beschreibung eingeben.')
assert.equal(meldung([pos(), pos({ description: 'y'.repeat(201) })]), 'Position 2: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
assert.equal(meldung([pos({ quantity: '0' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
assert.equal(meldung([pos({ quantity: '1000' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
assert.equal(meldung([pos({ price: '' })]), `Position 1: ${PREIS}`)
assert.equal(meldung([pos({ price: '100.000,01' })]), `Position 1: ${PREIS}`)
assert.equal(meldung([]), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
assert.equal(meldung(Array.from({ length: 21 }, () => pos())), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
assert.equal(meldung(Array.from({ length: 20 }, () => pos())), undefined)
// Summe: genau 999.999,99 € gehen, 1.000.000,00 € nicht
const neun = Array.from({ length: 9 }, () => pos({ price: '100.000,00' }))
assert.equal(meldung([...neun, pos({ price: '99.999,99' })]), undefined)
assert.equal(meldung([...neun, pos({ price: '100.000,00' })]), 'Die Summe ist zu hoch (höchstens 999.999,99 €).')
})
test('pruefeSchritt 2 (Aufwand): Stundensatz und Schätzung, Positionen zählen nicht', () => {
const pruefe = (over) => pruefeSchritt(2, aufwandForm(over), { kunde: KUNDE })
const SATZ = 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).'
assert.deepEqual(pruefe(), OK)
assert.equal(pruefe({ schaetzung: '12,5' }).ok, true)
assert.equal(pruefe({ stundensatz: '' }).fehler.stundensatz, SATZ)
assert.equal(pruefe({ stundensatz: '12.50' }).fehler.stundensatz, SATZ)
assert.equal(pruefe({ schaetzung: '10,3' }).fehler.schaetzung, 'Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens 1666).')
assert.equal(pruefe({ positionen: [{ uid: 1, description: '', quantity: 'x', price: '' }] }).ok, true)
})
test('pruefeSchritt 3: Zahlungsplan, Zahlungsziel, Termine, Zeitquelle', () => {
const pruefe = (f) => pruefeSchritt(3, f, { kunde: KUNDE })
assert.deepEqual(pruefe(festpreisForm()), OK)
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: '50' })).ok, true)
for (const schlecht of ['', '0', '100', '33,3']) {
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: schlecht })).fehler.prozent, 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', schlecht)
}
assert.equal(pruefe(festpreisForm({ zahlungsart: 'nach_abnahme', prozent: 'x' })).ok, true) // Prozent zählt nur bei „Anzahlung“
const TEXT = 'Bitte den Zahlungsplan als Text eingeben (höchstens 500 Zeichen).'
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' ' })).fehler.zahlungstext, TEXT)
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(501) })).fehler.zahlungstext, TEXT)
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(500) })).ok, true)
for (const schlecht of ['', '61', '-1', '1,5']) {
assert.equal(pruefe(festpreisForm({ zahlungsziel: schlecht })).fehler.zahlungsziel, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', schlecht)
}
assert.equal(pruefe(festpreisForm({ zahlungsziel: '0' })).ok, true)
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-30' })).ok, true)
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-01' })).ok, true)
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-02', fertigBis: '2026-11-01' })).fehler.fertigBis, 'Die Fertigstellung liegt vor dem Start.')
assert.equal(pruefe(festpreisForm({ startAm: '2026-13-01' })).fehler.startAm, 'Bitte ein gültiges Datum wählen.')
assert.equal(pruefe(festpreisForm({ fertigBis: 'bald' })).fehler.fertigBis, 'Bitte ein gültiges Datum wählen.')
const ids = (n) => Array.from({ length: n }, (_, i) => `t${i}`)
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(10) })).ok, true)
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(11) })).fehler.zeitquelle, 'Höchstens 10 Tickets als Zeitquelle.')
const PASST = 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.'
assert.equal(pruefe(festpreisForm({ zahlungsart: 'monatlich' })).fehler.zahlungsart, PASST)
assert.equal(pruefe(aufwandForm({ zahlungsart: 'anzahlung' })).fehler.zahlungsart, PASST)
})
test('formularZuNutzlast: Festpreis in Cent, getrimmte Texte, Kundenart nur wenn sie am Kunden fehlt', () => {
const f = festpreisForm({
titel: ' Website Praxis ', projektId: 'p1',
positionen: [
{ uid: 1, description: ' Startseite ', quantity: '2', price: '400,00' },
{ uid: 2, description: 'Kontaktformular', quantity: '1', price: '1.234,50' },
],
zahlungsart: 'anzahlung', prozent: '30', zahlungsziel: '21',
startAm: '2026-11-01', fertigBis: '2026-11-30', zeitquelle: ['t1', 't2'],
})
const r = formularZuNutzlast(f, { kunde: KUNDE })
assert.equal(r.ok, true)
assert.equal(r.summeCents, 203450)
assert.deepEqual(r.payload, {
projektId: 'p1', titel: 'Website Praxis', kundenart: 'privat', art: 'festpreis',
positionen: [
{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 },
{ beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 123450 },
],
stundensatzCents: null, schaetzungMinuten: null,
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
auftragVom: '2026-10-06', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '2026-11-30',
zeitquelle: ['t1', 't2'],
})
assert.equal('kundenart' in formularZuNutzlast(f, { kunde: { ...KUNDE, kundenart: 'unternehmen' } }).payload, false)
const frei = formularZuNutzlast(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' Zahlung in zwei Raten ' }), { kunde: KUNDE })
assert.deepEqual(frei.payload.zahlungsplan, { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten' })
assert.equal(frei.payload.projektId, '')
assert.deepEqual([frei.payload.startAm, frei.payload.fertigBis], ['', ''])
})
test('formularZuNutzlast: Aufwand mit Satz in Cent und Schätzung in Minuten, ohne Positionen und Summe', () => {
const r = formularZuNutzlast(aufwandForm({ stundensatz: '95,50', schaetzung: '12,5', zahlungsart: 'monatlich' }), { kunde: { ...KUNDE, kundenart: 'privat' } })
assert.equal(r.ok, true)
assert.equal(r.summeCents, null)
assert.deepEqual(r.payload.positionen, [])
assert.equal(r.payload.art, 'aufwand')
assert.equal(r.payload.stundensatzCents, 9550)
assert.equal(r.payload.schaetzungMinuten, 750)
assert.deepEqual(r.payload.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
assert.equal(formularZuNutzlast(aufwandForm({ schaetzung: '' }), { kunde: KUNDE }).payload.schaetzungMinuten, null)
})
test('formularZuNutzlast: Fehler nennen den Schritt und das erste Feld', () => {
const r1 = formularZuNutzlast(festpreisForm({ titel: '', zahlungsziel: '99' }), { kunde: KUNDE })
assert.deepEqual([r1.ok, r1.schritt, r1.erstes], [false, 1, 'titel'])
assert.equal(r1.fehler.titel, 'Bitte einen Titel eingeben (höchstens 200 Zeichen).')
const r2 = formularZuNutzlast(festpreisForm({ auftragWeg: '' }), { kunde: KUNDE })
assert.deepEqual([r2.ok, r2.schritt, r2.erstes], [false, 2, 'auftragWeg'])
const r3 = formularZuNutzlast(festpreisForm({ zahlungsziel: '99' }), { kunde: KUNDE })
assert.deepEqual([r3.ok, r3.schritt, r3.erstes], [false, 3, 'zahlungsziel'])
})
test('zahlungsplanVorschau: Wortlaut sinngemäß wie in der Auftragsbestätigung', () => {
const v = (o) => zahlungsplanVorschau({ art: 'nach_abnahme', prozent: null, text: '', summeCents: 80000, zielTage: 14, ...o })
assert.equal(v({}), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ summeCents: null }), 'Alles nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'anzahlung', prozent: 50 }), 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
// Rundung wie im Vertrag V3: Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: 33333 }), 'Anzahlung 50 % (166,67 €) nach Auftragsbestätigung, Rest (166,66 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'anzahlung', prozent: 33, summeCents: 33333 }), 'Anzahlung 33 % (110,00 €) nach Auftragsbestätigung, Rest (223,33 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: null }), 'Anzahlung 50 % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'anzahlung', prozent: null }), '')
assert.equal(v({ art: 'monatlich', summeCents: null }), 'Monatlich nach Aufwand; zahlbar innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'frei', text: ' Zwei Raten, je zur Hälfte ' }), 'Zwei Raten, je zur Hälfte (zahlbar innerhalb von 14 Tagen nach Rechnung).')
assert.equal(v({ art: 'frei', text: '' }), '')
assert.equal(v({ zielTage: 0 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar sofort nach Rechnung.')
assert.equal(v({ zielTage: 1 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 1 Tag nach Rechnung.')
assert.equal(v({ zielTage: null }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'unbekannt' }), '')
})
test('sendeGate: Lead, E-Mail, Anschrift und Kundenart halten das Senden auf, der Entwurf bleibt möglich', () => {
assert.deepEqual(sendeGate(KUNDE, 'privat'), { erlaubt: true, grund: '', zurUebersicht: false })
assert.deepEqual(sendeGate({ ...KUNDE, kundenart: 'privat' }, ''), { erlaubt: true, grund: '', zurUebersicht: false })
assert.deepEqual(sendeGate(KUNDE, ''), { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false })
assert.deepEqual(sendeGate({ ...KUNDE, street: '' }, 'privat'), {
erlaubt: false, grund: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
})
assert.deepEqual(sendeGate({ ...KUNDE, email: ' ' }, 'privat'), { erlaubt: false, grund: 'E-Mail fehlt', zurUebersicht: true })
assert.deepEqual(sendeGate({ ...KUNDE, customerStatus: 'lead' }, 'privat'), { erlaubt: false, grund: 'Noch kein fester Kunde', zurUebersicht: true })
assert.deepEqual(sendeGate(null, 'privat'), { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false })
})
const TICKETS = [
{ $id: 't9', woid: '100149', topic: 'Neueste Anfrage' },
{ $id: 't2', woid: '100140', topic: 'Shop Relaunch' },
{ $id: 't1', woid: '100131', topic: 'Website Praxis' },
{ $id: 'xyz12345', woid: '', title: 'Ohne Nummer' },
]
test('zeitquelleOptionen: Projekt-Tickets zuerst, dann gewählte, dann der Rest; unbekannte Auswahl bleibt sichtbar', () => {
const o = zeitquelleOptionen(TICKETS, ['t9', 'alt77'], ['t1'])
assert.deepEqual(o.map((x) => x.id), ['t1', 't9', 'alt77', 't2', 'xyz12345'])
assert.deepEqual(o.map((x) => x.gewaehlt), [false, true, true, false, false])
assert.deepEqual(o.map((x) => x.projekt), [true, false, false, false, false])
assert.equal(o[0].label, '#100131 Website Praxis')
assert.equal(o[1].label, '#100149 Neueste Anfrage')
assert.equal(o[2].label, 'Ticket alt77')
assert.equal(o[4].label, '#12345 Ohne Nummer')
assert.deepEqual(zeitquelleOptionen(null, [], []), [])
})
test('schalteZeitquelle: höchstens 10, keine Doppelten, Reihenfolge der Auswahl', () => {
assert.deepEqual(schalteZeitquelle(['a'], 'b', true), ['a', 'b'])
assert.deepEqual(schalteZeitquelle(['a', 'b'], 'a', false), ['b'])
assert.deepEqual(schalteZeitquelle(['a'], 'a', true), ['a'])
assert.deepEqual(schalteZeitquelle(undefined, 'a', true), ['a'])
const zehn = Array.from({ length: 10 }, (_, i) => `t${i}`)
assert.deepEqual(schalteZeitquelle(zehn, 'neu', true), zehn)
assert.deepEqual(schalteZeitquelle(zehn, 't3', false), zehn.filter((id) => id !== 't3'))
})
test('kundenartWirksam: die Kundenart am Kunden gilt, sonst die im Formular gewählte', () => {
assert.equal(kundenartWirksam({ kundenart: 'unternehmen' }, 'privat'), 'unternehmen')
assert.equal(kundenartWirksam({ kundenart: '' }, 'privat'), 'privat')
assert.equal(kundenartWirksam({}, 'unternehmen'), 'unternehmen')
assert.equal(kundenartWirksam(null, 'privat'), 'privat')
assert.equal(kundenartWirksam({ kundenart: 'privat' }, ''), 'privat')
assert.equal(kundenartWirksam({ kundenart: 'firma' }, ''), '')
assert.equal(kundenartWirksam({ kundenart: '' }, 'firma'), '')
assert.equal(kundenartWirksam(null, undefined), '')
})
test('vorOrtHinweis: nur bei „Vor Ort“ und Privatperson (§ 312f Abs. 1 BGB), nie als Sperre', () => {
const TEXT = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
assert.equal(VOR_ORT_HINWEIS, TEXT)
assert.equal(vorOrtHinweis('vor_ort', 'privat'), TEXT)
for (const [weg, art] of [['vor_ort', 'unternehmen'], ['vor_ort', ''], ['telefon', 'privat'], ['muendlich', 'privat'], ['email', 'privat'], ['chat', 'privat'], ['', 'privat'], [undefined, 'privat']]) {
assert.equal(vorOrtHinweis(weg, art), '', `${weg}/${art}`)
}
})
test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperson; sonst nichts', () => {
const STN = 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
const VOR = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
const hinweise = (o) => auftragHinweise({ kunde: KUNDE, form: festpreisForm(), einstellungen: { steuernummer: '12/345/67890' }, ...o })
assert.deepEqual(hinweise(), []) // festpreisForm: Privatperson, Weg „telefon“, Steuernummer vorhanden
assert.deepEqual(hinweise({ einstellungen: { steuernummer: '' } }), [{ key: 'steuernummer', text: STN }])
assert.deepEqual(hinweise({ einstellungen: {} }).map((h) => h.key), ['steuernummer'])
// Einstellungen noch nicht geladen (undefined) oder nicht ladbar (null): keine Behauptung
assert.deepEqual(hinweise({ einstellungen: undefined }), [])
assert.deepEqual(hinweise({ einstellungen: null }), [])
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort' }) }), [{ key: 'vorOrt', text: VOR }])
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: 'unternehmen' }) }), [])
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }), [])
// die Kundenart des Kunden hat Vorrang vor dem Formular
const privatAmKunden = { kunde: { ...KUNDE, kundenart: 'privat' }, form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }
assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt'])
assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt'])
})
// --- Liste -------------------------------------------------------------------------------------------------------------
const A = (o = {}) => ({
id: 'a1', nummer: 'AB-2026-0002', status: 'versendet', kundenId: 'c1', projektId: 'p1', titel: 'Website Praxis Müller',
art: 'festpreis', stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
gesendetAm: '2026-10-06T08:15:00.000+00:00', gesendetAn: 'info@praxis.test', mailStatus: 'gesendet', mailFehler: '',
bestaetigtAm: null, bestaetigtUeber: '', fruehbeginn: false, ersetztId: '', ersetztDurchId: '', widerrufenAm: null,
erstelltAm: '2026-10-05T16:00:00.000+00:00', geaendertAm: '2026-10-06T08:15:00.000+00:00',
pdfVorhanden: true, fruehesterBeginn: null, zahlungsplanText: '', aufwandText: null, ...o,
})
test('Status: Beschriftung und Ton für alle sieben Status (Vertrag V2)', () => {
assert.deepEqual(Object.entries(AUFTRAG_STATUS).map(([k, v]) => [k, v.label]), [
['entwurf', 'Entwurf'], ['versendet', 'Versendet'], ['bestaetigt', 'Bestätigt'], ['abgerechnet', 'Abgerechnet'],
['storniert', 'Storniert'], ['ersetzt', 'Ersetzt'], ['widerrufen', 'Widerrufen'],
])
assert.deepEqual(Object.values(AUFTRAG_STATUS).map((v) => v.tone), ['muted', 'info', 'ok', 'violet', 'plain', 'plain', 'danger'])
// „Projekt zuordnen“ gilt für diese Status (Übersicht, Nachträge; V5 `POST …/projekt` lehnt die übrigen mit 409 ab)
assert.deepEqual(PROJEKT_ZUORDNEN_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet'])
})
test('auftragZeilen: Nummer, Titel, Datum, Summe und Status', () => {
const [z] = auftragZeilen([A()])
assert.deepEqual([z.id, z.nummerText, z.titel, z.datumText], ['a1', 'AB-2026-0002', 'Website Praxis Müller', '06.10.2026'])
assert.deepEqual([z.summeText, z.summeSub, z.istEntwurf], ['800,00 €', '', false])
assert.deepEqual([z.statusLabel, z.statusTone], ['Versendet', 'info'])
assert.equal(z.auftrag.id, 'a1')
})
test('auftragZeilen: Entwurf ohne Nummer, nach Aufwand, Projektname nur wenn er vom Titel abweicht', () => {
const konten = [{ projektId: 'p1', name: 'Praxis Müller – Relaunch' }]
const entwurf = A({
id: 'a2', nummer: '', status: 'entwurf', gesendetAm: null, pdfVorhanden: false, art: 'aufwand', summeCents: null,
stundensatzCents: 10000, schaetzungMinuten: 600, erstelltAm: '2026-10-05T22:30:00.000+00:00',
})
const [z] = auftragZeilen([entwurf], { konten })
assert.deepEqual([z.nummerText, z.istEntwurf, z.datumText], ['Entwurf', true, '06.10.2026'])
assert.deepEqual([z.summeText, z.summeSub], ['nach Aufwand', '100,00 € pro Std., Schätzung etwa 10 Std.'])
assert.equal(z.projektText, 'Praxis Müller – Relaunch')
assert.equal(auftragZeilen([A()], { konten: [{ projektId: 'p1', name: 'Website Praxis Müller' }] })[0].projektText, '')
assert.equal(auftragZeilen([A({ projektId: '' })], { konten })[0].projektText, '')
assert.equal(auftragZeilen([A({ projektId: 'p9' })], { konten })[0].projektText, '')
assert.equal(auftragZeilen([A()])[0].projektText, '')
assert.equal(auftragZeilen([A({ art: 'aufwand', summeCents: null, stundensatzCents: 9550, schaetzungMinuten: null })])[0].summeSub, '95,50 € pro Std.')
assert.equal(auftragZeilen([A({ summeCents: null })])[0].summeText, '–')
})
test('auftragZeilen: Notizen und Aktionen hängen an der Zeile, die Reihenfolge des Servers bleibt', () => {
const zeilen = auftragZeilen([A({ id: 'x2', mailStatus: 'fehler' }), A({ id: 'x1', status: 'entwurf', nummer: '' })])
assert.deepEqual(zeilen.map((z) => z.id), ['x2', 'x1'])
assert.deepEqual(zeilen[0].notizen.map((n) => n.text), ['Mail nicht zugestellt'])
assert.equal(zeilen[0].aktionen[1].label, 'Erneut senden')
assert.equal(zeilen[1].istEntwurf, true)
assert.deepEqual(auftragZeilen(null), [])
assert.deepEqual(auftragZeilen([null, 'x', A()]).map((z) => z.id), ['a1'])
})
test('Zusatzzeilen: Mail, bestätigt am, Frühbeginn, Arbeitsbeginn, Fassungen, Widerruf', () => {
const texte = (o, alle) => auftragNotizen(A(o), alle).map((n) => n.text)
const BESTAETIGT = '2026-10-07T09:00:00.000+00:00'
assert.deepEqual(texte({}), [])
assert.deepEqual(texte({ mailStatus: 'fehler', mailFehler: 'SMTP' }), ['Mail nicht zugestellt'])
assert.deepEqual(auftragNotizen(A({ mailStatus: 'fehler' })).map((n) => n.tone), ['warn'])
// nach der Bestätigung zählt ein früherer Mailfehler nicht mehr
assert.deepEqual(texte({ status: 'bestaetigt', mailStatus: 'fehler', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026'])
assert.deepEqual(texte({ fruehesterBeginn: '2026-10-21' }), ['Arbeitsbeginn frühestens am 21.10.2026'])
assert.deepEqual(texte({ status: 'bestaetigt', fruehbeginn: true, bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Frühbeginn verlangt'])
assert.deepEqual(texte({ status: 'bestaetigt', fruehesterBeginn: '2026-10-21', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Arbeitsbeginn frühestens am 21.10.2026'])
// ohne Wirkung bei Entwurf und bei beendeten ABs
assert.deepEqual(texte({ status: 'entwurf', fruehesterBeginn: '2026-10-21', fruehbeginn: true }), [])
assert.deepEqual(texte({ status: 'storniert', fruehesterBeginn: '2026-10-21' }), [])
assert.deepEqual(texte({ status: 'widerrufen', widerrufenAm: '2026-10-09T10:00:00.000+00:00' }), ['Widerrufen am 09.10.2026'])
const alle = [
A({ id: 'a1', nummer: 'AB-2026-0002', status: 'ersetzt', ersetztDurchId: 'a3' }),
A({ id: 'a3', nummer: 'AB-2026-0004', ersetztId: 'a1' }),
A({ id: 'a4', nummer: '', status: 'entwurf', ersetztId: 'a3' }),
]
assert.deepEqual(auftragNotizen(alle[0], alle).map((n) => n.text), ['Ersetzt durch AB-2026-0004'])
assert.deepEqual(auftragNotizen(alle[1], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0002'])
assert.deepEqual(auftragNotizen(alle[2], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0004'])
assert.deepEqual(texte({ ersetztId: 'fremd' }, []), ['Neue Fassung'])
assert.deepEqual(texte({ status: 'ersetzt', ersetztDurchId: 'fremd' }, []), ['Ersetzt durch eine neue Fassung'])
})
test('auftragAktionen: je Status genau die erlaubten, nach dem Senden nur noch „Neue Fassung“ statt Bearbeiten', () => {
const keys = (o) => auftragAktionen(A(o)).map((a) => a.key)
assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'loeschen'])
assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'stornieren'])
assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung'])
for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
for (const status of ['versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
assert.equal(keys({ status }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false, status)
}
// ohne gespeichertes PDF keine PDF-Aktion (beim Entwurf gibt es die Vorschau immer)
assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'stornieren'])
assert.deepEqual(keys({ status: 'storniert', pdfVorhanden: false }), [])
assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).map((a) => a.label), ['Bearbeiten', 'Senden', 'Vorschau', 'Löschen'])
assert.deepEqual(auftragAktionen(A()).map((a) => a.label), ['PDF', 'Erinnerung senden', 'Neue Fassung', 'Stornieren'])
assert.equal(auftragAktionen(A({ mailStatus: 'fehler' }))[1].label, 'Erneut senden')
assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).filter((a) => a.danger).map((a) => a.key), ['loeschen'])
assert.deepEqual(auftragAktionen(A()).filter((a) => a.danger).map((a) => a.key), ['stornieren'])
assert.deepEqual(auftragAktionen(A({ status: 'xyz', pdfVorhanden: false })), [])
assert.deepEqual(auftragAktionen(null), [])
assert.equal(istEntwurf(A({ status: 'entwurf' })), true)
assert.equal(istEntwurf(A()), false)
assert.equal(istEntwurf(null), false)
})
test('Projekt zuordnen: Auswahl aus den Konten, Verfügbarkeit nach Status und Projekt', () => {
const konten = [{ projektId: 'p1', name: 'Website Praxis Müller' }, { projektId: 'p2', name: ' Shop Relaunch ' }, { projektId: 'p3', name: '' }, { name: 'ohne Kennung' }, null]
assert.deepEqual(zuordnenOptionen(konten), [
{ value: 'p1', label: 'Website Praxis Müller' }, { value: 'p2', label: 'Shop Relaunch' }, { value: 'p3', label: 'p3' },
])
assert.deepEqual(zuordnenOptionen(undefined), [])
assert.deepEqual(zuordnenOptionen('x'), [])
const ohne = (o) => A({ projektId: '', ...o })
for (const status of ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']) {
assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), true, status)
}
for (const status of ['storniert', 'ersetzt', 'widerrufen', 'xyz']) {
assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), false, status)
}
assert.equal(projektZuordnenMoeglich(ohne({ projektId: null }), konten), true)
assert.equal(projektZuordnenMoeglich(ohne({ projektId: undefined }), konten), true)
// hat die AB schon ein Projekt (auch ein unbekanntes), gibt es die Aktion nicht: der Server lehnt sie mit 409 ab
assert.equal(projektZuordnenMoeglich(A({ projektId: 'p1' }), konten), false)
assert.equal(projektZuordnenMoeglich(A({ projektId: 'p9' }), konten), false)
// ohne Projekt zur Auswahl ist nichts zu tun
assert.equal(projektZuordnenMoeglich(ohne({}), []), false)
assert.equal(projektZuordnenMoeglich(ohne({}), undefined), false)
assert.equal(projektZuordnenMoeglich(null, konten), false)
})
test('auftragAktionen mit Konten: „Projekt zuordnen“ nur ohne Projekt, vor den gefährlichen Aktionen', () => {
const konten = [{ projektId: 'p1', name: 'Website' }]
const keys = (o, k = konten) => auftragAktionen(A({ projektId: '', ...o }), { konten: k }).map((a) => a.key)
assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'projekt', 'loeschen'])
assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'projekt', 'stornieren'])
assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung', 'projekt'])
assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'projekt', 'stornieren'])
for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
// mit Projekt, ohne Konten und ohne Angabe der Konten gibt es die Aktion nicht
assert.deepEqual(auftragAktionen(A({ status: 'versendet' }), { konten }).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
assert.deepEqual(keys({ status: 'versendet' }, []), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
assert.deepEqual(auftragAktionen(A({ projektId: '' })).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
// Beschriftung, nie gefährlich; Bearbeiten, Senden und Löschen bleiben dem Entwurf vorbehalten
const aktion = auftragAktionen(A({ projektId: '' }), { konten }).find((a) => a.key === 'projekt')
assert.equal(aktion.label, 'Projekt zuordnen')
assert.notEqual(aktion.danger, true)
assert.equal(keys({ status: 'versendet' }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false)
// die Zeilen geben die Konten weiter
assert.deepEqual(auftragZeilen([A({ projektId: '' })], { konten })[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
assert.deepEqual(auftragZeilen([A({ projektId: '' })])[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
})
test('Rückfragen: Senden, Löschen, Stornieren, Erinnerung, Projekt zuordnen', () => {
assert.equal(
auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' }), { email: 'info@praxis.test' }),
'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an info@praxis.test senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
)
assert.equal(
auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' })),
'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
)
assert.equal(auftragBestaetigung('loeschen', A({ status: 'entwurf' })), 'Den Entwurf „Website Praxis Müller“ wirklich löschen? Das lässt sich nicht rückgängig machen.')
assert.equal(
auftragBestaetigung('stornieren', A()),
'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.'
)
assert.equal(
auftragBestaetigung('erinnern', A()),
'Die Auftragsbestätigung AB-2026-0002 jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.'
)
assert.equal(
auftragBestaetigung('projekt', A({ projektId: '' }), { projektName: 'Physio-Website' }),
'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ dem Projekt „Physio-Website“ zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.'
)
assert.match(auftragBestaetigung('projekt', A({ projektId: '' })), /„Website Praxis Müller“ einem Projekt zuordnen\? /)
// ein Entwurf bleibt über „Bearbeiten“ änderbar: keine Rückfrage
assert.equal(auftragBestaetigung('projekt', A({ status: 'entwurf', nummer: '', projektId: '' }), { projektName: 'Physio-Website' }), '')
for (const key of ['bearbeiten', 'pdf', 'neueFassung', 'gibtsnicht']) assert.equal(auftragBestaetigung(key, A()), '', key)
assert.match(auftragBestaetigung('senden', A({ titel: '' })), /„ohne Titel“/)
})
test('Erfolgsmeldungen (Wortlaut des Containers von W0 für „senden“)', () => {
const gesendet = A({ nummer: 'AB-2026-0003' }) // gesendetAn: info@praxis.test
assert.deepEqual(auftragErfolg('senden', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde an info@praxis.test gesendet.' })
assert.deepEqual(auftragErfolg('senden', A({ nummer: 'AB-2026-0003', gesendetAn: '' })), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde gesendet.' })
assert.deepEqual(auftragErfolg('senden', gesendet, { mailFehler: true }), {
tone: 'warn',
text: 'Auftragsbestätigung AB-2026-0003 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
})
assert.deepEqual(auftragErfolg('erinnern', gesendet), { tone: 'ok', text: 'Die Erinnerung zu AB-2026-0003 wurde an info@praxis.test gesendet.' })
assert.deepEqual(auftragErfolg('erinnern', gesendet, { mailFehler: true }), {
tone: 'warn', text: 'Die Erinnerung zu AB-2026-0003 konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.',
})
assert.deepEqual(auftragErfolg('stornieren', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde storniert.' })
assert.deepEqual(auftragErfolg('loeschen', A({ nummer: '' })), { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' })
assert.deepEqual(auftragErfolg('neueFassung', A({ nummer: '' })), { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' })
assert.deepEqual(auftragErfolg('projekt', gesendet, { projektName: 'Physio-Website' }), {
tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde dem Projekt „Physio-Website“ zugeordnet.',
})
assert.deepEqual(auftragErfolg('projekt', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde einem Projekt zugeordnet.' })
assert.deepEqual(auftragErfolg('projekt', A({ nummer: '' }), { projektName: 'Physio-Website' }), {
tone: 'ok', text: 'Die Auftragsbestätigung wurde dem Projekt „Physio-Website“ zugeordnet.',
})
assert.deepEqual(auftragErfolg('senden', null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gesendet.' })
assert.deepEqual(auftragErfolg('senden', null, { mailFehler: true }), {
tone: 'warn', text: 'Die Auftragsbestätigung ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
})
assert.deepEqual(auftragErfolg('gibtsnicht', gesendet), { tone: 'ok', text: '' })
})
test('auftragFehler: Kundendaten-Fehler führen zur Übersicht, andere zeigen die Meldung des Servers (Texte aus Plan L3)', () => {
const fehler = (status, message) => auftragFehler(Object.assign(new Error(message), { status }))
assert.deepEqual(fehler(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'), {
art: 'adresse', text: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
})
assert.deepEqual(fehler(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).'), {
art: 'kundenart',
text: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.',
zurUebersicht: true,
})
assert.deepEqual(fehler(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'), {
art: 'email', text: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.', zurUebersicht: true,
})
assert.deepEqual(fehler(400, 'Auftragsbestätigungen nur für feste Kunden.'), {
art: 'kunde', text: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.', zurUebersicht: true,
})
// Entwurf unvollständig (L4, wird von L3 beim Senden und bei der Vorschau durchgereicht): Meldung unverändert, kein „aktueller Stand“
assert.deepEqual(fehler(409, 'Entwurf unvollständig: Titel fehlt.'), {
art: 'entwurf', text: 'Entwurf unvollständig: Titel fehlt.', zurUebersicht: false,
})
assert.equal(fehler(409, 'Entwurf unvollständig: Kundendaten fehlen.').art, 'entwurf')
assert.deepEqual(fehler(409, 'Nur ein Entwurf lässt sich senden.'), {
art: 'stand', text: 'Nur ein Entwurf lässt sich senden. Die Liste zeigt den aktuellen Stand.', zurUebersicht: false,
})
assert.deepEqual(fehler(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.'), {
art: 'eingabe', text: 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.', zurUebersicht: false,
})
assert.deepEqual(fehler(500, 'Das PDF konnte nicht erzeugt werden.'), {
art: 'pdf', text: 'Das PDF konnte nicht erzeugt werden. Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.', zurUebersicht: false,
})
assert.deepEqual(fehler(500, 'Interner Fehler.'), {
art: 'server',
text: 'Interner Fehler. Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.',
zurUebersicht: false,
})
assert.deepEqual(fehler(0, 'Das Kundenportal ist gerade nicht erreichbar.'), { art: 'netz', text: 'Das Kundenportal ist gerade nicht erreichbar.', zurUebersicht: false })
assert.deepEqual(fehler(404, 'Auftragsbestätigung nicht gefunden.'), { art: 'sonst', text: 'Auftragsbestätigung nicht gefunden.', zurUebersicht: false })
assert.deepEqual(auftragFehler(new Error('')), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
assert.deepEqual(auftragFehler(null), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
})
test('neueSperre: ein zweiter Aufruf während des ersten wird ignoriert (Doppelklick)', async () => {
const sperre = neueSperre()
let aufrufe = 0
let fertig
const warten = new Promise((r) => { fertig = r })
const erster = sperre.run(async () => { aufrufe += 1; await warten; return 'ok' })
const zweiter = await sperre.run(async () => { aufrufe += 1; return 'doppelt' })
assert.deepEqual(zweiter, { uebersprungen: true })
assert.equal(sperre.belegt, true)
fertig()
assert.deepEqual(await erster, { uebersprungen: false, wert: 'ok' })
assert.equal(aufrufe, 1)
assert.equal(sperre.belegt, false)
assert.deepEqual(await sperre.run(async () => 'wieder'), { uebersprungen: false, wert: 'wieder' })
})
test('neueSperre: nach einem Fehler wieder frei, der Fehler wird weitergereicht', async () => {
const sperre = neueSperre()
await assert.rejects(() => sperre.run(async () => { throw new Error('kaputt') }), /kaputt/)
assert.equal(sperre.belegt, false)
})
+79
View File
@@ -0,0 +1,79 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung,
STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt,
} = await loadModule('src/lib/kundenFelder.js')
test('Kundenart: Werte (Vertrag V2), Auswahl und Beschriftung', () => {
assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat'])
assert.deepEqual(KUNDENART_OPTIONEN, [
{ value: '', label: 'Nicht festgelegt' },
{ value: 'unternehmen', label: 'Unternehmen' },
{ value: 'privat', label: 'Privatperson' },
])
assert.equal(KUNDENART_HINWEIS, 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.')
assert.equal(kundenartLabel('unternehmen'), 'Unternehmen')
assert.equal(kundenartLabel('privat'), 'Privatperson')
for (const v of ['', undefined, null, 'firma']) assert.equal(kundenartLabel(v), 'Nicht festgelegt')
})
test('kundenartAenderung: nur eine geänderte, bekannte Kundenart wird geschrieben', () => {
assert.deepEqual(kundenartAenderung('', ''), {})
assert.deepEqual(kundenartAenderung('', undefined), {})
assert.deepEqual(kundenartAenderung('privat', 'privat'), {})
assert.deepEqual(kundenartAenderung('privat', ''), { kundenart: 'privat' })
assert.deepEqual(kundenartAenderung('privat', undefined), { kundenart: 'privat' })
assert.deepEqual(kundenartAenderung('unternehmen', 'privat'), { kundenart: 'unternehmen' })
assert.deepEqual(kundenartAenderung('', 'privat'), { kundenart: '' })
// unbekannte Werte werden nie geschrieben
assert.deepEqual(kundenartAenderung('firma', ''), {})
assert.deepEqual(kundenartAenderung('firma', 'privat'), {})
})
test('Steuernummer: Hinweis und Grenze', () => {
assert.equal(STEUERNUMMER_HINWEIS, 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.')
assert.equal(STEUERNUMMER_MAX, 40)
})
test('steuernummerAenderung: unverändert sendet nichts, geändert den getrimmten Wert, leer löscht', () => {
assert.deepEqual(steuernummerAenderung('12/345/67890', '12/345/67890'), { ok: true, payload: {} })
assert.deepEqual(steuernummerAenderung('', ''), { ok: true, payload: {} })
assert.deepEqual(steuernummerAenderung('', null), { ok: true, payload: {} })
assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', '12/345/67890'), { ok: true, payload: {} })
assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', ''), { ok: true, payload: { steuernummer: '12/345/67890' } })
assert.deepEqual(steuernummerAenderung('', '12/345/67890'), { ok: true, payload: { steuernummer: '' } })
assert.equal(steuernummerAenderung('123 456 789-0', '').ok, true)
assert.equal(steuernummerAenderung('1'.repeat(40), '').ok, true)
})
test('steuernummerAenderung: Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“ sind erlaubt wie auf dem Server (V4)', () => {
for (const gut of ['12/345/67890', '12.345.67890', 'DE123456789', 'de 123 456 789', 'DE-123.456/789', 'a', 'USt-IdNr 12/345']) {
assert.deepEqual(steuernummerAenderung(gut, ''), { ok: true, payload: { steuernummer: gut } }, gut)
}
assert.deepEqual(steuernummerAenderung(' DE123456789 ', ''), { ok: true, payload: { steuernummer: 'DE123456789' } })
assert.deepEqual(steuernummerAenderung('A'.repeat(40), ''), { ok: true, payload: { steuernummer: 'A'.repeat(40) } })
})
test('steuernummerAenderung: andere Zeichen und zu lang', () => {
const zeichen = 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.'
for (const schlecht of ['12_345', '12,345', '12#34', 'Müller 12', 'ß12', '12\t34', '12\n34', '12+34', '123']) {
assert.deepEqual(steuernummerAenderung(schlecht, ''), { ok: false, fehler: zeichen }, JSON.stringify(schlecht))
}
const LANG = 'Die Steuernummer darf höchstens 40 Zeichen lang sein.'
assert.deepEqual(steuernummerAenderung('1'.repeat(41), ''), { ok: false, fehler: LANG })
assert.deepEqual(steuernummerAenderung('A'.repeat(41), ''), { ok: false, fehler: LANG })
assert.equal(steuernummerAenderung('_'.repeat(41), '').fehler, LANG) // die Länge wird zuerst geprüft
})
test('steuernummerFehlt: Hinweis nur, wenn die Einstellungen da sind und die Steuernummer leer ist', () => {
assert.equal(STEUERNUMMER_FEHLT_HINWEIS, 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.')
for (const leer of [{ steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }, { steuernummer: 12 }, {}, { bankDetailsText: 'IBAN' }]) {
assert.equal(steuernummerFehlt(leer), true, JSON.stringify(leer))
}
for (const da of [{ steuernummer: '12/345/67890' }, { steuernummer: ' DE123456789 ' }]) assert.equal(steuernummerFehlt(da), false)
// noch nicht geladen oder nicht ladbar: es wird nichts behauptet
for (const unbekannt of [undefined, null, 'x', 5, []]) assert.equal(steuernummerFehlt(unbekannt), false, String(unbekannt))
})