Files
Webklar-Kundenbereich/server/services/auftraege/positionen.js
T

284 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.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
}
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,
}
}
/** Berliner Tag des Sendens der AB, sonst der Tag der Absprache (YYYY-MM-DD). */
export function auftragDatum(auftrag) {
if (auftrag.gesendetAm) {
const zeitpunkt = new Date(auftrag.gesendetAm)
if (!Number.isNaN(zeitpunkt.getTime())) return berlinDate(zeitpunkt)
}
return auftrag.auftragVom || ''
}
const angegeben = (wert) => wert !== undefined && wert !== null
/** Anzahlungszeile zu einer AB: Prozent oder Betrag, hoechstens Summe minus bereits Berechnetes. */
export function anzahlungPosition(auftrag, angaben = {}, bereitsBerechnetCents = 0) {
const { prozent: prozentEingabe, betragCents: betragEingabe, bezeichnung: bezeichnungEingabe = 'Anzahlung' } = angaben || {}
const bezeichnung = typeof bezeichnungEingabe === 'string' ? bezeichnungEingabe.trim() : ''
if (bezeichnung.length < 1 || bezeichnung.length > 60) throw new AuftragError(400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
if (angegeben(prozentEingabe) && angegeben(betragEingabe)) {
throw new AuftragError(400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
}
let prozent = null
let betrag
if (angegeben(betragEingabe)) {
if (!Number.isSafeInteger(betragEingabe) || betragEingabe < 1) {
throw new AuftragError(400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
}
betrag = betragEingabe
} else {
prozent = angegeben(prozentEingabe) ? prozentEingabe
: auftrag.zahlungsplan?.art === 'anzahlung' && angegeben(auftrag.zahlungsplan.prozent) ? auftrag.zahlungsplan.prozent
: LIMITS.standardAnzahlungProzent
if (typeof prozent !== 'number' || !Number.isFinite(prozent) || prozent <= 0 || prozent > 100) {
throw new AuftragError(400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
}
betrag = Math.round((auftrag.summeCents * prozent) / 100)
if (betrag < 1) throw new AuftragError(400, 'Der Betrag muss mindestens 0,01 € betragen.')
}
const rest = auftrag.summeCents - bereitsBerechnetCents
if (rest <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
if (betrag > rest) {
throw new AuftragError(400, `Die Anzahlung darf höchstens ${euroDe(rest)} betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
}
const prozentTeil = prozent === null ? '' : ` ${String(prozent).replace('.', ',')} %`
const davor = `${bezeichnung}${prozentTeil} gemäß Auftragsbestätigung ${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}`
const titel = typeof auftrag.titel === 'string' ? auftrag.titel.trim() : ''
const beschreibung = titel ? `${davor} (${kuerzen(titel, LIMITS.maxBeschreibung - davor.length - 3)})` : davor
return { schluessel: 'anzahlung', beschreibung, menge: 1, einzelpreisCents: betrag, betragCents: betrag }
}
// Obergrenzen einer Schlussrechnung (Uebersicht, Nachtraege nach der Planrunde); alle anderen Rechnungen: LIMITS.maxPositionen
export const SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 }
/** Schlussrechnung: alle AB-Positionen, Nachtraege, je fruehere Rechnung eine Minuszeile. */
export function schlussPositionen(auftrag, fruehere, zusatz = []) {
const positionenAb = auftrag.positionen || []
if (positionenAb.length > SCHLUSS_GRENZEN.abPositionen) {
throw new AuftragError(400, `Die Auftragsbestätigung hat ${positionenAb.length} Positionen; eine Schlussrechnung übernimmt höchstens ${SCHLUSS_GRENZEN.abPositionen}.`)
}
if (Array.isArray(zusatz) && zusatz.length > SCHLUSS_GRENZEN.zusatz) {
throw new AuftragError(400, `Eine Schlussrechnung erlaubt höchstens ${SCHLUSS_GRENZEN.zusatz} Zusatzpositionen.`)
}
const abzuege = (fruehere || []).filter((f) => f.betragCents > 0)
if (abzuege.length > SCHLUSS_GRENZEN.abzuege) {
throw new AuftragError(400, `Die Schlussrechnung hätte ${abzuege.length} Abzüge für frühere Rechnungen; erlaubt sind höchstens ${SCHLUSS_GRENZEN.abzuege}.`)
}
const zeilen = positionenAb.map((p, i) => ({
schluessel: `ab-${i + 1}`,
beschreibung: p.beschreibung,
menge: p.menge,
einzelpreisCents: p.einzelpreisCents,
betragCents: p.menge * p.einzelpreisCents,
}))
zeilen.push(...zusatzPositionen(zusatz))
const sortiert = [...abzuege].sort((a, b) => a.datum.localeCompare(b.datum) || a.invoiceId.localeCompare(b.invoiceId))
for (const f of sortiert) {
zeilen.push({
schluessel: `abzug-${f.invoiceId}`,
beschreibung: `abzüglich ${f.art === 'anzahlung' ? 'Anzahlung' : 'Teilrechnung'}, Rechnung ${f.rechnungsnummer} vom ${formatDe(f.datum)}`,
menge: 1,
einzelpreisCents: -f.betragCents,
betragCents: -f.betragCents,
})
}
const summe = zeilen.reduce((s, p) => s + p.betragCents, 0)
if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.')
if (summe <= 0) throw new AuftragError(409, 'Nichts mehr offen.')
return zeilen
}