From 6c1eaf697bb09b533fd389c9a8111f5188f3a6cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:28 +0000 Subject: [PATCH] Rechnungen: Aufwandszeilen je Tag, Kalenderwochen-Zusammenfassung ab 20 Zeilen Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/positionen.js | 82 ++++++++++++++++++ test/auftraegePositionen.test.js | 106 +++++++++++++++++++++++- 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/server/services/auftraege/positionen.js b/server/services/auftraege/positionen.js index bcbca49..b83be80 100644 --- a/server/services/auftraege/positionen.js +++ b/server/services/auftraege/positionen.js @@ -6,6 +6,7 @@ import { AuftragError } from './fehler.js' import { LIMITS } from './konstanten.js' import { formatDe } from '../finance/dates.js' +import { euroDe } from '../finance/format.js' const TAG_MS = 86_400_000 @@ -111,3 +112,84 @@ export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {} if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.') return summe } + +const vereinheitlicht = (text) => (typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '') + +/** Eine Zeile (Tag oder Woche) mit Standardtext oder ersetztem Text, Stunden und Satz; nie ueber 200 Zeichen. */ +function aufwandZeile(einheit, satzCents, zeilentexte) { + const eigen = zeilentexte && Object.hasOwn(zeilentexte, einheit.schluessel) ? vereinheitlicht(zeilentexte[einheit.schluessel]) : '' + const standard = `${einheit.kopf}: ${einheit.texte.length ? einheit.texte.join('; ') : 'Arbeitszeit'}` + const schluss = ` – ${stundenText(einheit.minuten)} Std. à ${euroDe(satzCents)}` + const betragCents = Math.round((einheit.minuten * satzCents) / 60) + return { + schluessel: einheit.schluessel, + beschreibung: `${kuerzen(eigen || standard, LIMITS.maxBeschreibung - schluss.length)}${schluss}`, + menge: 1, + einzelpreisCents: betragCents, + betragCents, + } +} + +/** + * Aufwandszeilen: eine Zeile je Tag; gibt es mehr Tage als maxZeilen, werden die aeltesten Kalenderwochen zu + * KW-Zeilen zusammengefasst. zeilentexte[schluessel] ersetzt den Text vor dem Gedankenstrich. + */ +export function aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = LIMITS.maxPositionen } = {}) { + if (!blaetter.length) return { positionen: [], minuten: 0, von: null, bis: null } + if (!istGanzzahlZwischen(satzCents, 1, LIMITS.maxEinzelpreisCents)) throw new AuftragError(400, 'Der Stundensatz ist ungültig.') + for (const blatt of blaetter) { + if (!gueltigerTag(blatt.datum) || !Number.isSafeInteger(blatt.minuten) || blatt.minuten <= 0) { + throw new AuftragError(400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.') + } + } + const sortiert = [...blaetter].sort((a, b) => a.datum.localeCompare(b.datum) || String(a.id).localeCompare(String(b.id))) + const tage = [] + for (const blatt of sortiert) { + let tag = tage.at(-1) + if (!tag || tag.datum !== blatt.datum) { + tag = { datum: blatt.datum, minuten: 0, texte: [] } + tage.push(tag) + } + tag.minuten += blatt.minuten + const text = vereinheitlicht(blatt.text) + if (text && !tag.texte.includes(text)) tag.texte.push(text) + } + const tagesEinheit = (tag) => ({ schluessel: tag.datum, kopf: formatDe(tag.datum), minuten: tag.minuten, texte: tag.texte }) + const wochenEinheit = (woche) => { + const texte = [] + for (const tag of woche.tage) for (const text of tag.texte) if (!texte.includes(text)) texte.push(text) + const ww = String(woche.woche).padStart(2, '0') + return { schluessel: `KW-${woche.jahr}-${ww}`, kopf: `KW ${ww}/${woche.jahr}`, minuten: woche.tage.reduce((s, t) => s + t.minuten, 0), texte } + } + const einheiten = [] + if (tage.length <= maxZeilen) { + einheiten.push(...tage.map(tagesEinheit)) + } else { + const wochen = [] + for (const tag of tage) { + const kw = kalenderwoche(tag.datum) + let woche = wochen.find((w) => w.jahr === kw.jahr && w.woche === kw.woche) + if (!woche) { + woche = { ...kw, tage: [] } + wochen.push(woche) + } + woche.tage.push(tag) + } + let anzahl = tage.length + for (const woche of wochen) { + if (anzahl > maxZeilen && woche.tage.length > 1) { + anzahl -= woche.tage.length - 1 + einheiten.push(wochenEinheit(woche)) + } else einheiten.push(...woche.tage.map(tagesEinheit)) + } + if (anzahl > maxZeilen) { + throw new AuftragError(400, `Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als ${maxZeilen} Zeilen). Bitte weniger Arbeitsblätter auswählen.`) + } + } + return { + positionen: einheiten.map((einheit) => aufwandZeile(einheit, satzCents, zeilentexte)), + minuten: tage.reduce((s, t) => s + t.minuten, 0), + von: tage[0].datum, + bis: tage.at(-1).datum, + } +} diff --git a/test/auftraegePositionen.test.js b/test/auftraegePositionen.test.js index 4504892..ca90f10 100644 --- a/test/auftraegePositionen.test.js +++ b/test/auftraegePositionen.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { stundenText, leistungszeitraumText, kalenderwoche, gueltigerTag, kuerzen, - festePositionen, zusatzPositionen, pruefePositionen, + festePositionen, zusatzPositionen, pruefePositionen, aufwandPositionen, } from '../server/services/auftraege/positionen.js' /** Wirft die Funktion einen AuftragError mit diesem Status und dieser Meldung? */ @@ -91,3 +91,107 @@ test('zusatzPositionen: leer erlaubt, Schlüssel zusatz-n, gleiche Prüfungen; p wirft(() => pruefePositionen([pos('x'.repeat(201), 1)]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.') wirft(() => pruefePositionen([pos('A', 99_999_999), pos('B', 1)]), 400, 'Rechnungssumme zu hoch.') }) + +const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt +const blatt = (id, datum, minuten, text = '', over = {}) => ({ id, ticketId: 't1', woid: 'WO-1', datum, minuten, text, aboZeit: false, kommentar: false, ...over }) +const werktage = (start, n) => { + const tage = [] + for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) { + const tag = new Date(ms) + if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10)) + } + return tage +} + +test('aufwandPositionen: eine Zeile je Tag mit Texten, Stunden und Satz (Menge 1, Betrag = Stunden × Satz)', () => { + const erg = aufwandPositionen([blatt('w1', '2026-10-06', 90, 'Startseite, Kontaktformular'), blatt('w2', '2026-10-06', 45, '')], { satzCents: 10000 }) + assert.deepEqual(erg.positionen, [ + { schluessel: '2026-10-06', beschreibung: `06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 22500, betragCents: 22500 }, + ]) + assert.deepEqual([erg.minuten, erg.von, erg.bis], [135, '2026-10-06', '2026-10-06']) +}) + +test('aufwandPositionen: Texte eines Tages mit "; " verbunden, Doppelte entfallen, Leerraum vereinheitlicht, ohne Text "Arbeitszeit"', () => { + const erg = aufwandPositionen([ + blatt('w1', '2026-10-06', 60, 'Startseite'), blatt('w2', '2026-10-06', 60, 'Kontaktformular\nmit Anhang'), blatt('w3', '2026-10-06', 15, ' Startseite '), + blatt('w4', '2026-10-07', 30, ''), + ], { satzCents: 10000 }) + assert.deepEqual(erg.positionen.map((p) => p.beschreibung), [ + `06.10.2026: Startseite; Kontaktformular mit Anhang – 2,25 Std. à 100,00${NBSP}€`, + `07.10.2026: Arbeitszeit – 0,5 Std. à 100,00${NBSP}€`, + ]) + assert.deepEqual(erg.positionen.map((p) => p.betragCents), [22500, 5000]) +}) + +test('aufwandPositionen: Reihenfolge der Eingabe egal (nach Tag und Kennung), von und bis, Satz ohne glatte Cent je Viertelstunde', () => { + const erg = aufwandPositionen([blatt('w3', '2026-10-08', 15, 'C'), blatt('w1', '2026-10-06', 15, 'A'), blatt('w2', '2026-10-07', 15, 'B')], { satzCents: 9999 }) + assert.deepEqual(erg.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07', '2026-10-08']) + assert.deepEqual([erg.von, erg.bis, erg.minuten], ['2026-10-06', '2026-10-08', 45]) + assert.equal(erg.positionen[0].beschreibung, `06.10.2026: A – 0,25 Std. à 99,99${NBSP}€`) + assert.equal(erg.positionen[0].betragCents, 2500) // 15 Minuten × 99,99 €/Std. = 24,9975 € → ganze Cent gerundet +}) + +test('aufwandPositionen: zeilentexte ersetzen den Text vor dem Gedankenstrich; leer oder unbekannt wirkt nicht; Zeile bleibt bei höchstens 200 Zeichen', () => { + const standard = `06.10.2026: Startseite – 2,25 Std. à 100,00${NBSP}€` + const mit = (zeilentexte, text = 'Startseite') => + aufwandPositionen([blatt('w1', '2026-10-06', 135, text)], { satzCents: 10000, zeilentexte }).positionen[0].beschreibung + assert.equal(mit({ '2026-10-06': '06.10.2026: Neue Startseite' }), `06.10.2026: Neue Startseite – 2,25 Std. à 100,00${NBSP}€`) + for (const wirkungslos of [undefined, null, {}, { '2026-10-06': ' ' }, { '2026-10-07': 'anderer Tag' }, { '2026-10-06': 5 }]) assert.equal(mit(wirkungslos), standard) + const schluss = ` – 2,25 Std. à 100,00${NBSP}€` + const ersetzt = mit({ '2026-10-06': 'x'.repeat(300) }) + assert.equal(ersetzt.length, 200) + assert.ok(ersetzt.endsWith(`x…${schluss}`)) + const lang = mit(undefined, 'y'.repeat(300)) + assert.equal(lang.length, 200) + assert.ok(lang.startsWith('06.10.2026: yyy')) + assert.ok(lang.endsWith(`y…${schluss}`)) +}) + +test('aufwandPositionen: zu viele Tage -> die ältesten Wochen werden zu KW-Zeilen (ISO-Woche, älteste zuerst), Einzeltage bleiben Tageszeilen', () => { + // KW 41: 5., 6., 7.10.; KW 42: 12., 13., 14.10.; KW 43: 19., 20.10.; je 90 Minuten + const tage = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20'] + const blaetter = tage.map((tag, i) => blatt(`w${i + 1}`, tag, 90, 'ABCDEFGH'[i])) + const lauf = (maxZeilen) => aufwandPositionen(blaetter, { satzCents: 10000, maxZeilen }) + assert.equal(lauf(8).positionen.length, 8) + assert.deepEqual(lauf(6).positionen.map((p) => p.schluessel), ['KW-2026-41', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20']) + assert.deepEqual(lauf(6).positionen[0], { + schluessel: 'KW-2026-41', beschreibung: `KW 41/2026: A; B; C – 4,5 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 45000, betragCents: 45000, + }) + assert.deepEqual(lauf(5).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19', '2026-10-20']) + assert.deepEqual(lauf(3).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', 'KW-2026-43']) + assert.equal(lauf(3).positionen[2].beschreibung, `KW 43/2026: G; H – 3 Std. à 100,00${NBSP}€`) + for (const n of [8, 6, 5, 3]) assert.equal(lauf(n).positionen.reduce((s, p) => s + p.betragCents, 0), 8 * 15000, `Summe bei ${n} Zeilen`) + assert.deepEqual([lauf(3).von, lauf(3).bis, lauf(3).minuten], ['2026-10-05', '2026-10-20', 720]) + wirft(() => lauf(2), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 2 Zeilen). Bitte weniger Arbeitsblätter auswählen.') + // eine Woche mit nur einem Tag wird nicht zusammengefasst (es spart keine Zeile) + const einzel = [blatt('a', '2026-10-06', 60, 'A'), blatt('b', '2026-10-12', 60, 'B'), blatt('c', '2026-10-13', 60, 'C'), blatt('d', '2026-10-14', 60, 'D')] + assert.deepEqual(aufwandPositionen(einzel, { satzCents: 10000, maxZeilen: 2 }).positionen.map((p) => p.schluessel), ['2026-10-06', 'KW-2026-42']) +}) + +test('aufwandPositionen: Standard 20 Zeilen, zeilentexte auch für KW-Zeilen, Woche 53 über den Jahreswechsel, mehr als 20 Wochen -> 400', () => { + const blaetter = werktage('2026-10-05', 25).map((tag, i) => blatt(`w${String(i + 1).padStart(2, '0')}`, tag, 60, `T${i + 1}`)) // KW 41 bis 45 + const erg = aufwandPositionen(blaetter, { satzCents: 10000, zeilentexte: { 'KW-2026-42': 'KW 42/2026: Relaunch Teil 2' } }) + assert.equal(erg.positionen.length, 17) + assert.deepEqual(erg.positionen.slice(0, 3).map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19']) + assert.equal(erg.positionen[1].beschreibung, `KW 42/2026: Relaunch Teil 2 – 5 Std. à 100,00${NBSP}€`) + assert.equal(erg.minuten, 1500) + + const wechsel = ['2026-12-30', '2026-12-31', '2027-01-01', '2027-01-04'].map((tag, i) => blatt(`j${i}`, tag, 60, `J${i}`)) + const zwei = aufwandPositionen(wechsel, { satzCents: 10000, maxZeilen: 2 }) + assert.deepEqual(zwei.positionen.map((p) => p.schluessel), ['KW-2026-53', '2027-01-04']) + assert.equal(zwei.positionen[0].beschreibung, `KW 53/2026: J0; J1; J2 – 3 Std. à 100,00${NBSP}€`) + + const montage = Array.from({ length: 21 }, (_, i) => new Date(Date.UTC(2026, 0, 5 + 7 * i)).toISOString().slice(0, 10)) + const wochen = montage.map((tag, i) => blatt(`m${i + 1}`, tag, 60, 'x')) + wirft(() => aufwandPositionen(wochen, { satzCents: 10000 }), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 20 Zeilen). Bitte weniger Arbeitsblätter auswählen.') + assert.equal(aufwandPositionen(wochen.slice(0, 20), { satzCents: 10000 }).positionen.length, 20) +}) + +test('aufwandPositionen: ungültiger Satz, leere Auswahl, kaputte Blätter', () => { + const eins = [blatt('w1', '2026-10-06', 60, 'A')] + for (const satzCents of [0, -1, 1.5, '10000', 10_000_001, undefined]) wirft(() => aufwandPositionen(eins, { satzCents }), 400, 'Der Stundensatz ist ungültig.') + assert.deepEqual(aufwandPositionen([], { satzCents: 10000 }), { positionen: [], minuten: 0, von: null, bis: null }) + for (const kaputt of [{ datum: '06.10.2026' }, { datum: '2026-13-01' }, { minuten: 0 }, { minuten: 7.5 }, { minuten: '60' }]) { + wirft(() => aufwandPositionen([blatt('w1', '2026-10-06', 60, 'A', kaputt)], { satzCents: 10000 }), 400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.') + } +})