Rechnungsdialog: Logik für Projektwahl, Rechnungsarten, Auftragsbestätigungen und Arbeitsblätter mit Tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:32:20 +00:00
1 parent cc95018083
commit e37c0a664e
2 files changed
+421

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
// Dialoglogik der Rechnung „Rechnung schreiben“ (rein, ohne React und Appwrite; unter Node testbar).
// Aufgaben: Projektwahl, waehlbare Rechnungsarten, gültige Auftragsbestätigungen (AB), Vorgaben für Anzahlung und Standardart,
// Arbeitsblätter (Auswahl, Summen, Umwandlung aus Appwrite-Dokumenten) und die Ticketliste für „Weiteres Ticket hinzunehmen“.
import { euro, dateDe, ymdBerlin } from './financeFormat'
import { worksheetMinutes } from './support'
import { germanDateToIso } from './ticketForm'
import { ticketsText } from './projektkontoView'
export const ART_OPTION_LABEL = {
frei: 'Freie Positionen',
anzahlung: 'Anzahlung',
schluss: 'Schlussrechnung',
aufwand: 'Nach Aufwand',
festbetrag: 'Festbetrag',
}
export const AB_HINWEIS = 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.'
export const ENTWURF_HINWEIS = 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.'
export const OHNE_PROJEKT = 'Ohne Projekt'
const GUELTIG = ['versendet', 'bestaetigt']
const FINALISIERT = ['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich']
// --- Projektwahl --------------------------------------------------------------------------------------------
/** Vorauswahl der Aufrufer: undefined = keine (null), null = „Ohne Projekt“ (''), Text = dieses Projekt. */
export function startProjekt(propProjektId) {
if (propProjektId === undefined) return null
if (propProjektId === null) return ''
return String(propProjektId)
}
/** Auswahlliste: Projekte mit Ticketnummer, am Ende „Ohne Projekt“. */
export function projektOptionen(konten) {
const liste = Array.isArray(konten) ? konten : []
const optionen = liste
.filter((k) => k && String(k.projektId ?? '') !== '')
.map((k) => {
const name = String(k.name ?? '').trim() || 'Projekt'
const tickets = ticketsText(k.tickets)
return { value: String(k.projektId), label: tickets ? `${name} · ${tickets}` : name }
})
optionen.push({ value: '', label: OHNE_PROJEKT })
return optionen
}
/** null = noch zu wählen, '' = Ohne Projekt. Ein Kunde ohne Projekte hat nur „Ohne Projekt“. */
export function wirksamesProjekt(wahl, konten) {
if (wahl === '') return ''
if (typeof wahl === 'string' && wahl) {
if (!Array.isArray(konten)) return wahl
return konten.some((k) => k?.projektId === wahl) ? wahl : null
}
return Array.isArray(konten) && konten.length === 0 ? '' : null
}
// --- Auftragsbestätigungen ----------------------------------------------------------------------------------
function neuesteZuerst(liste) {
const zeit = (a) => {
const t = Date.parse(a?.gesendetAm)
return Number.isFinite(t) ? t : 0
}
return [...liste].sort((a, b) => zeit(b) - zeit(a) || String(b?.nummer ?? '').localeCompare(String(a?.nummer ?? '')))
}
function gueltige(auftraege, art) {
if (!Array.isArray(auftraege)) return []
return neuesteZuerst(auftraege.filter((a) => a && a.art === art && GUELTIG.includes(a.status)))
}
/** Gültige Festpreis-ABs (versendet oder bestätigt), neueste zuerst. */
export function gueltigeFestpreisAbs(auftraege) {
return gueltige(auftraege, 'festpreis')
}
/** Jüngste gültige Aufwands-AB oder null. */
export function aufwandAuftrag(auftraege) {
return gueltige(auftraege, 'aufwand')[0] ?? null
}
/** 'AB-2026-0002 vom 06.10.2026 · 800,00 €' bzw. '… · nach Aufwand'. */
export function abLabel(auftrag) {
let text = String(auftrag?.nummer ?? '')
const datum = dateDe(auftrag?.gesendetAm)
if (datum !== '–') text += ` vom ${datum}`
text += auftrag?.art === 'aufwand' ? ' · nach Aufwand' : ` · ${euro(auftrag?.summeCents)}`
return text
}
/** Summe der finalisierten Rechnungen zu dieser AB (Cent). */
export function berechnetFuerAuftrag(rechnungen, auftragId) {
if (!Array.isArray(rechnungen)) return 0
return rechnungen
.filter((r) => r && r.auftragId === auftragId && FINALISIERT.includes(r.status))
.reduce((summe, r) => summe + (Number(r.totalCents) || 0), 0)
}
/** Gibt es zu dieser AB einen noch nicht gesendeten Entwurf? */
export function hatEntwurfZurAuftrag(rechnungen, auftragId) {
return Array.isArray(rechnungen) && rechnungen.some((r) => r && r.auftragId === auftragId && r.status === 'entwurf')
}
// --- Rechnungsarten -----------------------------------------------------------------------------------------
/** Wählbare Arten mit Sperrgrund: [{ key, label, enabled, hint }]. */
export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) {
if (projektId === null || projektId === undefined) return []
const option = (key, enabled, hint = '') => ({ key, label: ART_OPTION_LABEL[key], enabled, hint: enabled ? '' : hint })
if (projektId === '') return [option('frei', true)]
const abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : []
const freie = abs.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))
const schlussHinweis = abs.length === 0 ? AB_HINWEIS : ENTWURF_HINWEIS
return [
option('anzahlung', abs.length > 0, AB_HINWEIS),
option('schluss', freie.length > 0, schlussHinweis),
option('aufwand', true),
option('festbetrag', true),
]
}
/** Vorbelegte Art: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage ('' ohne Wahl). */
export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten }) {
const liste = Array.isArray(optionen) ? optionen : []
if (liste.length === 0) return ''
if (liste.length === 1 && liste[0].key === 'frei') return 'frei'
const waehlbar = (key) => liste.some((o) => o.key === key && o.enabled)
const jung = gueltigeFestpreisAbs(auftraege)[0]
if (
waehlbar('anzahlung') &&
jung?.zahlungsplan?.art === 'anzahlung' &&
berechnetFuerAuftrag(rechnungen, jung.id) === 0 &&
!hatEntwurfZurAuftrag(rechnungen, jung.id)
) {
return 'anzahlung'
}
if (waehlbar('schluss')) return 'schluss'
return Number(offeneMinuten) > 0 ? 'aufwand' : 'festbetrag'
}
/** Vorbelegte AB: jüngste passende; bei Schlussrechnung ohne offenen Entwurf; sonst ''. */
export function standardAuftrag(art, auftraege, rechnungen = []) {
const abs = gueltigeFestpreisAbs(auftraege)
if (art === 'anzahlung') return abs[0]?.id ?? ''
if (art === 'schluss') return abs.find((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))?.id ?? ''
return ''
}
/** Vorgabe der Anzahlung: Prozent aus dem Zahlungsplan, sonst 50. */
export function anzahlungStandard(auftrag) {
const plan = auftrag?.zahlungsplan
const prozent = plan?.art === 'anzahlung' && Number(plan.prozent) > 0 ? Number(plan.prozent) : 50
return { modus: 'prozent', wert: String(prozent).replace('.', ','), bezeichnung: 'Anzahlung' }
}
// --- Arbeitsblätter -----------------------------------------------------------------------------------------
/** Kennungen der gewählten Blätter (alle außer den abgewählten) in Reihenfolge der Liste. */
export function ausgewaehlteIds(blaetter, abgewaehlt) {
if (!Array.isArray(blaetter)) return []
const weg = Array.isArray(abgewaehlt) ? abgewaehlt : []
return blaetter.map((b) => b.id).filter((id) => !weg.includes(id))
}
/** Anzahl und Minutensumme der Blätter mit diesen Kennungen. */
export function blaetterSumme(blaetter, ids) {
const liste = (Array.isArray(blaetter) ? blaetter : []).filter((b) => Array.isArray(ids) && ids.includes(b.id))
return { anzahl: liste.length, minuten: liste.reduce((s, b) => s + (Number(b.minuten) || 0), 0) }
}
/** Appwrite-Arbeitsblatt -> Blatt der Portal-Form; null, wenn es nicht abrechenbar ist oder schon abgerechnet wurde. */
export function blattAusAppwrite(ws, abgerechnet) {
if (!ws || !ws.$id) return null
if (ws.isComment || ws.countsToPlan) return null
if (abgerechnet && Object.prototype.hasOwnProperty.call(abgerechnet, ws.$id)) return null
const roh = worksheetMinutes(ws)
if (!(roh > 0)) return null
const zeile = String(ws.details ?? '')
.split('\n')
.map((z) => z.trim())
.find(Boolean)
return {
id: ws.$id,
ticketId: ws.workorderId,
woid: ws.woid,
datum: germanDateToIso(ws.startDate || ws.endDate) || ymdBerlin(ws.$createdAt) || '',
minuten: Math.ceil(roh / 15) * 15,
text: (zeile ?? '').slice(0, 120),
aboZeit: false,
kommentar: false,
}
}
/** Tickets des Kunden mit Nummer, ohne die schon gewählten. */
export function ticketOptionen(tickets, { ausgeschlossen = [] } = {}) {
if (!Array.isArray(tickets)) return []
return tickets
.filter((t) => t && t.$id && t.woid && !ausgeschlossen.includes(t.$id))
.map((t) => ({
value: t.$id,
ticketId: t.$id,
woid: t.woid,
label: `#${t.woid} · ${String(t.topic || t.title || '').trim() || 'Kein Betreff'}`,
}))
}
+217
View File
@@ -0,0 +1,217 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
AB_HINWEIS, ENTWURF_HINWEIS,
startProjekt, projektOptionen, wirksamesProjekt,
gueltigeFestpreisAbs, aufwandAuftrag, abLabel, berechnetFuerAuftrag, hatEntwurfZurAuftrag,
artOptionen, standardArt, standardAuftrag, anzahlungStandard,
ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen,
} = await loadModule('src/lib/rechnungDialogView.js')
const ab = (over = {}) => ({
id: 'a1', nummer: 'AB-2026-0002', status: 'bestaetigt', art: 'festpreis', summeCents: 80000,
gesendetAm: '2026-10-06T08:15:00.000Z', zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, stundensatzCents: null, ...over,
})
const rk = (over = {}) => ({
id: 'in_1', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1', ...over,
})
const bl = (id, minuten, over = {}) => ({
id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false, ...over,
})
const KONTEN = [
{ projektId: 'p1', name: 'Website Relaunch', tickets: [{ id: 't1', woid: '100131' }] },
{ projektId: 'p2', name: 'Shop', tickets: [] },
]
const optionen = (args) => artOptionen({ projektId: 'p1', auftraege: [], rechnungen: [], auftraegeAktiv: true, ...args })
const zustand = (o) => o.map((x) => [x.key, x.enabled])
test('startProjekt: Vorauswahl der Aufrufer (undefined = keine, null = Ohne Projekt, Text = Projekt)', () => {
assert.equal(startProjekt(undefined), null)
assert.equal(startProjekt(null), '')
assert.equal(startProjekt('p1'), 'p1')
})
test('projektOptionen: Projekte mit Ticketnummer, am Ende „Ohne Projekt“', () => {
assert.deepEqual(projektOptionen(KONTEN), [
{ value: 'p1', label: 'Website Relaunch · #100131' },
{ value: 'p2', label: 'Shop' },
{ value: '', label: 'Ohne Projekt' },
])
assert.deepEqual(projektOptionen([]), [{ value: '', label: 'Ohne Projekt' }])
assert.deepEqual(projektOptionen(undefined), [{ value: '', label: 'Ohne Projekt' }])
})
test('wirksamesProjekt: null = noch zu wählen, „“ = Ohne Projekt, Kunde ohne Projekte = Ohne Projekt', () => {
assert.equal(wirksamesProjekt('', KONTEN), '')
assert.equal(wirksamesProjekt('p2', KONTEN), 'p2')
assert.equal(wirksamesProjekt('gibtsnicht', KONTEN), null)
assert.equal(wirksamesProjekt('p1', undefined), 'p1') // Liste noch nicht geladen: die Vorauswahl bleibt
assert.equal(wirksamesProjekt(null, KONTEN), null)
assert.equal(wirksamesProjekt(null, []), '') // Kunde ohne Projekte: gleich „Ohne Projekt“
assert.equal(wirksamesProjekt(null, undefined), null)
})
test('gueltigeFestpreisAbs: nur Festpreis mit Status versendet oder bestätigt, neueste zuerst', () => {
const list = [
ab({ id: 'a1' }),
ab({ id: 'a2', status: 'entwurf' }),
ab({ id: 'a3', art: 'aufwand' }),
ab({ id: 'a4', status: 'abgerechnet' }),
ab({ id: 'a5', status: 'versendet', gesendetAm: '2026-10-07T08:00:00.000Z' }),
ab({ id: 'a6', status: 'storniert' }),
]
assert.deepEqual(gueltigeFestpreisAbs(list).map((a) => a.id), ['a5', 'a1'])
assert.deepEqual(gueltigeFestpreisAbs(undefined), [])
})
test('aufwandAuftrag: jüngste gültige Aufwands-AB', () => {
const list = [
ab({ id: 'x1', art: 'aufwand', status: 'bestaetigt', gesendetAm: '2026-10-01T08:00:00.000Z' }),
ab({ id: 'x2', art: 'aufwand', status: 'versendet', gesendetAm: '2026-10-05T08:00:00.000Z' }),
ab({ id: 'x3', art: 'aufwand', status: 'storniert', gesendetAm: '2026-10-09T08:00:00.000Z' }),
ab({ id: 'a1' }),
]
assert.equal(aufwandAuftrag(list).id, 'x2')
assert.equal(aufwandAuftrag([ab()]), null)
assert.equal(aufwandAuftrag(undefined), null)
})
test('abLabel: Nummer, Datum des Sendens und Summe bzw. „nach Aufwand“', () => {
assert.equal(abLabel(ab()), 'AB-2026-0002 vom 06.10.2026 · 800,00 €')
assert.equal(abLabel(ab({ art: 'aufwand', summeCents: null })), 'AB-2026-0002 vom 06.10.2026 · nach Aufwand')
})
test('berechnetFuerAuftrag und hatEntwurfZurAuftrag: nur finalisierte zählen, ein Entwurf blockiert', () => {
const list = [
rk(),
rk({ id: 'in_2', status: 'bezahlt', totalCents: 10000 }),
rk({ id: 'in_3', status: 'entwurf', totalCents: 5000 }),
rk({ id: 'in_4', status: 'storniert', totalCents: 7000 }),
rk({ id: 'in_5', auftragId: 'a2', totalCents: 9000 }),
]
assert.equal(berechnetFuerAuftrag(list, 'a1'), 50000)
assert.equal(berechnetFuerAuftrag(list, 'a2'), 9000)
assert.equal(berechnetFuerAuftrag(undefined, 'a1'), 0)
assert.equal(hatEntwurfZurAuftrag(list, 'a1'), true)
assert.equal(hatEntwurfZurAuftrag(list, 'a2'), false)
})
test('artOptionen: mit gültiger Festpreis-AB sind alle vier Arten wählbar', () => {
const o = optionen({ auftraege: [ab()] })
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', true], ['aufwand', true], ['festbetrag', true]])
assert.deepEqual(o.map((x) => x.label), ['Anzahlung', 'Schlussrechnung', 'Nach Aufwand', 'Festbetrag'])
})
test('artOptionen: ohne AB oder bei ausgeschaltetem Schalter erklärt ein Hinweis die Sperre', () => {
for (const args of [{ auftraege: [] }, { auftraege: [ab()], auftraegeAktiv: false }, { auftraege: [ab({ status: 'entwurf' })] }]) {
const o = optionen(args)
assert.deepEqual(zustand(o), [['anzahlung', false], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
assert.equal(o[0].hint, AB_HINWEIS)
assert.equal(o[1].hint, AB_HINWEIS)
}
assert.equal(AB_HINWEIS, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
})
test('artOptionen: ein offener Entwurf zur AB sperrt die Schlussrechnung, nicht die Anzahlung', () => {
const o = optionen({ auftraege: [ab()], rechnungen: [rk({ status: 'entwurf' })] })
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
assert.equal(o[1].hint, ENTWURF_HINWEIS)
assert.equal(ENTWURF_HINWEIS, 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.')
// zwei ABs: die Schlussrechnung bleibt wählbar, solange eine AB ohne Entwurf da ist
const zwei = optionen({ auftraege: [ab(), ab({ id: 'a2', nummer: 'AB-2026-0003' })], rechnungen: [rk({ status: 'entwurf' })] })
assert.equal(zwei[1].enabled, true)
})
test('artOptionen: ohne Wahl keine Arten, ohne Projekt nur die freie Rechnung', () => {
assert.deepEqual(artOptionen({ projektId: null, auftraege: [ab()], rechnungen: [], auftraegeAktiv: true }), [])
const frei = artOptionen({ projektId: '', auftraege: [ab()], rechnungen: [], auftraegeAktiv: true })
assert.deepEqual(zustand(frei), [['frei', true]])
})
test('standardArt: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage', () => {
const lage = (args, extra = {}) => {
const eingabe = { auftraege: [], rechnungen: [], offeneMinuten: 0, ...args, ...extra }
return standardArt(optionen({ auftraege: eingabe.auftraege, rechnungen: eingabe.rechnungen }), eingabe)
}
assert.equal(lage({ auftraege: [ab()] }), 'anzahlung') // Zahlungsplan Anzahlung, noch nichts berechnet
assert.equal(lage({ auftraege: [ab()], rechnungen: [rk()] }), 'schluss') // Anzahlung schon berechnet
const nachAbnahme = ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
assert.equal(lage({ auftraege: [nachAbnahme] }), 'schluss')
assert.equal(lage({}, { offeneMinuten: 90 }), 'aufwand')
assert.equal(lage({}), 'festbetrag')
const ohneProjekt = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
assert.equal(standardArt(ohneProjekt, { auftraege: [], rechnungen: [], offeneMinuten: 0 }), 'frei')
assert.equal(standardArt([], { auftraege: [], rechnungen: [], offeneMinuten: 0 }), '')
})
test('standardAuftrag: die jüngste passende AB, bei Schlussrechnung ohne offenen Entwurf', () => {
const neu = ab({ id: 'a5', gesendetAm: '2026-10-07T08:00:00.000Z' })
assert.equal(standardAuftrag('anzahlung', [ab(), neu]), 'a5')
assert.equal(standardAuftrag('schluss', [ab(), neu]), 'a5')
assert.equal(standardAuftrag('schluss', [ab(), neu], [rk({ auftragId: 'a5', status: 'entwurf' })]), 'a1')
assert.equal(standardAuftrag('anzahlung', []), '')
assert.equal(standardAuftrag('aufwand', [ab({ id: 'x2', art: 'aufwand' })]), '') // die Aufwands-AB ordnet der Server zu
assert.equal(standardAuftrag('festbetrag', [ab()]), '')
})
test('anzahlungStandard: Prozent aus dem Zahlungsplan, sonst 50', () => {
const vorgabe = { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }
assert.deepEqual(anzahlungStandard(ab()), vorgabe)
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'anzahlung', prozent: 33.5, text: '' } })), { ...vorgabe, wert: '33,5' })
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), vorgabe)
assert.deepEqual(anzahlungStandard(null), vorgabe)
})
test('ausgewaehlteIds und blaetterSumme: alle sind vorausgewählt, abgewählte fallen heraus', () => {
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
assert.deepEqual(ausgewaehlteIds(B, []), ['b1', 'b2', 'b3'])
assert.deepEqual(ausgewaehlteIds(B, ['b2']), ['b1', 'b3'])
assert.deepEqual(ausgewaehlteIds(undefined, []), [])
assert.deepEqual(blaetterSumme(B, ['b1', 'b3']), { anzahl: 2, minuten: 105 })
assert.deepEqual(blaetterSumme(B, []), { anzahl: 0, minuten: 0 })
})
const ws = (over = {}) => ({
$id: 'w1', woid: '100140', workorderId: 't2', totalTime: 60, supporters: '', isComment: false, countsToPlan: false,
startDate: '06.10.2026', endDate: '06.10.2026', details: 'Kontaktformular gebaut', $createdAt: '2026-10-06T07:00:00.000Z', ...over,
})
test('blattAusAppwrite: abrechenbares Blatt mit Minuten (inkl. Unterstützung), Datum und kurzem Text', () => {
assert.deepEqual(blattAusAppwrite(ws(), {}), {
id: 'w1', ticketId: 't2', woid: '100140', datum: '2026-10-06', minuten: 60, text: 'Kontaktformular gebaut', aboZeit: false, kommentar: false,
})
// Unterstützung zählt mit: 60 + 1 × 30
const mit = ws({ supporters: JSON.stringify([{ employeeId: 'e1', name: 'A', task: 'Review' }]) })
assert.equal(blattAusAppwrite(mit, {}).minuten, 90)
// ein Altwert außerhalb des 15-Minuten-Rasters wird aufgerundet
assert.equal(blattAusAppwrite(ws({ totalTime: 70 }), {}).minuten, 75)
// ohne gültiges Datum zählt der Erstellungstag, der Text ist die erste Zeile, höchstens 120 Zeichen
assert.equal(blattAusAppwrite(ws({ startDate: '', endDate: '' }), {}).datum, '2026-10-06')
assert.equal(blattAusAppwrite(ws({ details: 'Zeile 1\nZeile 2' }), {}).text, 'Zeile 1')
assert.equal(blattAusAppwrite(ws({ details: 'a'.repeat(300) }), {}).text.length, 120)
})
test('blattAusAppwrite: Kommentar, Abo-Zeit, 0 Minuten und schon Abgerechnetes sind null', () => {
assert.equal(blattAusAppwrite(ws({ isComment: true }), {}), null)
assert.equal(blattAusAppwrite(ws({ countsToPlan: true }), {}), null)
assert.equal(blattAusAppwrite(ws({ totalTime: 0 }), {}), null)
assert.equal(blattAusAppwrite(ws(), { w1: 'WEBKLAR-0009' }), null)
assert.equal(blattAusAppwrite(ws(), { w1: 'Entwurf' }), null)
assert.equal(blattAusAppwrite({}, {}), null)
})
test('ticketOptionen: Tickets des Kunden mit Nummer, ohne die schon gewählten', () => {
const T = [
{ $id: 't1', woid: '100131', topic: 'Relaunch' },
{ $id: 't2', woid: '100140', topic: 'Kontaktformular' },
{ $id: 't3', woid: '', topic: '', title: 'Alt' },
{ $id: 't4', woid: '100150', topic: '', title: '' },
]
assert.deepEqual(ticketOptionen(T, { ausgeschlossen: ['t1'] }), [
{ value: 't2', ticketId: 't2', woid: '100140', label: '#100140 · Kontaktformular' },
{ value: 't4', ticketId: 't4', woid: '100150', label: '#100150 · Kein Betreff' },
])
assert.deepEqual(ticketOptionen(undefined), [])
})