Rechnungen: Aufwandszeilen je Tag, Kalenderwochen-Zusammenfassung ab 20 Zeilen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7553253746
commit
6c1eaf697b
2 files changed
+187
-1
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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.')
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user