Rechnungen: Positionen-Grundlagen (Stundentext, Kalenderwoche, feste Positionen, Prüfung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8627ed1124
commit
7553253746
2 files changed
+206
No files matched your search
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Rechnungszeilen je Art (rein, ohne Stripe und Appwrite): feste Positionen, Aufwand je Tag oder Kalenderwoche,
|
||||
* Anzahlung und Schlussrechnung mit Minuszeilen. Betraege sind ganze Cent.
|
||||
* Importzyklus (siehe Plan L2): zur Modulauswertung nichts aus fehler.js benutzen, nur innerhalb von Funktionen.
|
||||
*/
|
||||
import { AuftragError } from './fehler.js'
|
||||
import { LIMITS } from './konstanten.js'
|
||||
import { formatDe } from '../finance/dates.js'
|
||||
|
||||
const TAG_MS = 86_400_000
|
||||
|
||||
const istGanzzahlZwischen = (wert, min, max) => Number.isSafeInteger(wert) && wert >= min && wert <= max
|
||||
|
||||
/** Text auf hoechstens max Zeichen kuerzen (Auslassungszeichen am Ende, kein halbes Emoji). */
|
||||
export function kuerzen(text, max) {
|
||||
if (text.length <= max) return text
|
||||
let teil = text.slice(0, max - 1)
|
||||
const letzter = teil.charCodeAt(teil.length - 1)
|
||||
if (letzter >= 0xd800 && letzter <= 0xdbff) teil = teil.slice(0, -1)
|
||||
return `${teil.trimEnd()}…`
|
||||
}
|
||||
|
||||
/** Minuten als Stunden mit Komma, ohne Endnullen: 135 -> "2,25", 390 -> "6,5", 60 -> "1". */
|
||||
export function stundenText(minuten) {
|
||||
return String(Math.round((minuten / 60) * 100) / 100).replace('.', ',')
|
||||
}
|
||||
|
||||
/** Nur ein String YYYY-MM-DD, den es als Tag gibt. */
|
||||
export function gueltigerTag(ymd) {
|
||||
const treffer = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
|
||||
if (!treffer) return false
|
||||
const [j, m, t] = treffer.slice(1).map(Number)
|
||||
const pruef = new Date(Date.UTC(j, m - 1, t))
|
||||
return pruef.getUTCFullYear() === j && pruef.getUTCMonth() === m - 1 && pruef.getUTCDate() === t
|
||||
}
|
||||
|
||||
/** ISO-Woche samt ISO-Jahr; wirft RangeError bei ungueltigem Tag. */
|
||||
export function kalenderwoche(ymd) {
|
||||
if (!gueltigerTag(ymd)) throw new RangeError(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`)
|
||||
const tag = new Date(`${ymd}T00:00:00Z`)
|
||||
const wochentag = (tag.getUTCDay() + 6) % 7 // Montag = 0
|
||||
const donnerstag = new Date(tag.getTime() + (3 - wochentag) * TAG_MS)
|
||||
const jahr = donnerstag.getUTCFullYear()
|
||||
const woche = Math.floor((donnerstag.getTime() - Date.UTC(jahr, 0, 1)) / TAG_MS / 7) + 1
|
||||
return { jahr, woche }
|
||||
}
|
||||
|
||||
/** "06.10.2026" | "01.10.–20.10.2026" | "28.12.2026–05.01.2027" (Halbgeviertstrich ohne Leerzeichen). */
|
||||
export function leistungszeitraumText(von, bis) {
|
||||
if (bis < von) throw new RangeError('Das Ende liegt vor dem Beginn.')
|
||||
if (von === bis) return formatDe(von)
|
||||
if (von.slice(0, 4) === bis.slice(0, 4)) return `${formatDe(von).slice(0, 6)}–${formatDe(bis)}`
|
||||
return `${formatDe(von)}–${formatDe(bis)}`
|
||||
}
|
||||
|
||||
/** Gemeinsamer Pruefer fuer feste und Zusatzpositionen (Eingabe { description, quantity, unitAmountCents }). */
|
||||
function pruefeEingabePositionen(items, { leerErlaubt, praefix }) {
|
||||
const leer = items === undefined || items === null
|
||||
if (leer && leerErlaubt) return []
|
||||
if (!Array.isArray(items) || items.length > LIMITS.maxPositionen || (items.length < 1 && !leerErlaubt)) {
|
||||
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${LIMITS.maxPositionen} Positionen.`)
|
||||
}
|
||||
const positionen = items.map((item, index) => {
|
||||
const label = `Position ${index + 1}`
|
||||
const beschreibung = typeof item?.description === 'string' ? item.description.trim() : ''
|
||||
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
|
||||
throw new AuftragError(400, `${label}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
|
||||
}
|
||||
if (!istGanzzahlZwischen(item.quantity, 1, LIMITS.maxMenge)) {
|
||||
throw new AuftragError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
|
||||
}
|
||||
if (!istGanzzahlZwischen(item.unitAmountCents, 1, LIMITS.maxEinzelpreisCents)) {
|
||||
throw new AuftragError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`)
|
||||
}
|
||||
return {
|
||||
schluessel: `${praefix}-${index + 1}`,
|
||||
beschreibung,
|
||||
menge: item.quantity,
|
||||
einzelpreisCents: item.unitAmountCents,
|
||||
betragCents: item.quantity * item.unitAmountCents,
|
||||
}
|
||||
})
|
||||
if (positionen.reduce((summe, p) => summe + p.betragCents, 0) > LIMITS.maxSummeCents) {
|
||||
throw new AuftragError(400, 'Rechnungssumme zu hoch.')
|
||||
}
|
||||
return positionen
|
||||
}
|
||||
|
||||
/** Feste Positionen einer freien Rechnung oder eines Festbetrags: 1 bis 20, Schluessel pos-<n>. */
|
||||
export function festePositionen(items) {
|
||||
return pruefeEingabePositionen(items, { leerErlaubt: false, praefix: 'pos' })
|
||||
}
|
||||
|
||||
/** Zusatzpositionen (Nachtraege einer Schlussrechnung): wie festePositionen, aber leer erlaubt, Schluessel zusatz-<n>. */
|
||||
export function zusatzPositionen(items) {
|
||||
return pruefeEingabePositionen(items, { leerErlaubt: true, praefix: 'zusatz' })
|
||||
}
|
||||
|
||||
/** Prueft fertige Zeilen (auch Minuszeilen): Zahl 1 bis max, Beschreibungen, Summe; liefert die Summe in Cent. */
|
||||
export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {}) {
|
||||
if (!Array.isArray(positionen) || positionen.length < 1 || positionen.length > max) {
|
||||
throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${max} Positionen.`)
|
||||
}
|
||||
positionen.forEach((p, index) => {
|
||||
const laenge = typeof p.beschreibung === 'string' ? p.beschreibung.length : 0
|
||||
if (laenge < 1 || laenge > LIMITS.maxBeschreibung) {
|
||||
throw new AuftragError(400, `Position ${index + 1}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
|
||||
}
|
||||
})
|
||||
const summe = positionen.reduce((s, p) => s + p.betragCents, 0)
|
||||
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
|
||||
return summe
|
||||
}
|
||||
Reference in new issue
Block a user