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

+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), [])
})