Auftragsbestaetigung: Dialoglogik (Parser, Validierung, Nutzlast, Zahlungsplan-Vorschau) 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:31:31 +00:00
1 parent 674cce26f0
commit 45dd73d8cc
2 files changed
+819

No files matched your search

+419
View File
@@ -0,0 +1,419 @@
// Reine Ansichtslogik der Auftragsbestaetigungen (AB) in WOMS: Eingaben pruefen, Cent und Minuten, Formular <-> Anfrage,
// Zahlungsplan-Vorschau, Hinweise, Status und Aktionen der Liste, Meldungen und Fehlerklassen. Kein React, kein Appwrite,
// unter Node testbar. Betraege sind ganze Cent, in Formularen Euro-Texte (de-DE); Texte deutsch und sachlich (keine Anrede).
// Die Grenzen entsprechen dem Vertrag V2 (LIMITS) der Uebersicht, der Server entscheidet.
import { canInvoice, dateDe, euro, ymdBerlin } from './financeFormat'
import { ADDRESS_INCOMPLETE_TEXT, addressComplete, invoiceTotalCents, parseQuantity, parseUnitPrice } from './financeView'
import { euroField } from './financeSettings'
import { ticketNumber } from './ticketView'
import { KUNDENARTEN, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt } from './kundenFelder'
// --- Konstanten --------------------------------------------------------------------------------------------------------
export const AUFTRAG_LIMITS = {
maxPositionen: 20,
maxBeschreibung: 200,
maxMenge: 999,
maxEinzelpreisCents: 10_000_000,
maxSummeCents: 99_999_999,
maxTitel: 200,
maxZahlungsplanText: 500,
maxZahlungszielTage: 60,
standardZahlungszielTage: 14,
standardAnzahlungProzent: 50,
maxZeitquellen: 10,
maxSchaetzungMinuten: 99_960,
maxLeistungszeitraum: 140,
}
export const STUNDENSATZ_STANDARD_CENTS = 10000
export const AUFTRAG_WEGE = [
{ value: 'muendlich', label: 'Mündlich' },
{ value: 'telefon', label: 'Telefonisch' },
{ value: 'email', label: 'Per E-Mail' },
{ value: 'vor_ort', label: 'Vor Ort' },
{ value: 'chat', label: 'Im Chat' },
]
export const ZAHLUNGSPLAENE = {
nach_abnahme: 'Alles nach Abnahme',
anzahlung: 'Anzahlung bei Auftrag, Rest nach Abnahme',
monatlich: 'Monatlich nach Aufwand',
frei: 'Frei (eigener Text)',
}
export const SCHRITTE = [
{ id: 1, label: 'Projekt' },
{ id: 2, label: 'Auftrag' },
{ id: 3, label: 'Zahlung und Termine' },
{ id: 4, label: 'Vorschau' },
]
const ARTEN = ['festpreis', 'aufwand']
/** Auswahl des Zahlungsplans je Art: „Anzahlung“ nur beim Festpreis, „Monatlich“ nur nach Aufwand. */
export function zahlungsplanOptionen(art) {
const werte = art === 'aufwand' ? ['nach_abnahme', 'monatlich', 'frei'] : ['nach_abnahme', 'anzahlung', 'frei']
return werte.map((value) => ({ value, label: ZAHLUNGSPLAENE[value] }))
}
// --- Hilfen ------------------------------------------------------------------------------------------------------------
const text = (v) => (typeof v === 'string' ? v : '')
const trimmed = (v) => text(v).trim()
const istObjekt = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
const objekte = (v) => (Array.isArray(v) ? v.filter(istObjekt) : [])
/** 'YYYY-MM-DD' und ein wirklich existierender Tag. */
function istTag(wert) {
return /^\d{4}-\d{2}-\d{2}$/.test(text(wert)) && dateDe(wert) !== '–'
}
// --- Eingaben ----------------------------------------------------------------------------------------------------------
/** Stunden mit Komma in 15-Minuten-Schritten („10“, „12,5“, „2 Std.“) -> Minuten; ungueltig, leer oder 0 -> null. */
export function parseStunden(eingabe) {
const s = trimmed(eingabe).replace(/\s*(std\.?|h)$/i, '')
const m = /^(\d{1,4})(?:,(\d{1,2}))?$/.exec(s)
if (!m) return null
const hundertstel = Number(m[1]) * 60 * 100 + Number((m[2] || '').padEnd(2, '0') || 0) * 60
if (hundertstel % 100 !== 0) return null
const minuten = hundertstel / 100
if (!(minuten > 0) || minuten % 15 !== 0 || minuten > AUFTRAG_LIMITS.maxSchaetzungMinuten) return null
return minuten
}
/** Minuten -> Stunden mit Komma ohne ueberfluessige Nullen (150 -> „2,5“); keine Zahl -> „–“. */
export function stundenText(minuten) {
if (typeof minuten !== 'number' || !Number.isFinite(minuten)) return '–'
return String(Number((minuten / 60).toFixed(2))).replace('.', ',')
}
/** „50“ oder „30 %“ -> 1..99, sonst null. */
export function parseProzent(eingabe) {
const s = trimmed(eingabe).replace(/\s*%$/, '')
if (!/^\d{1,2}$/.test(s)) return null
const n = Number(s)
return n >= 1 && n <= 99 ? n : null
}
/** Zahlungsziel in Tagen 0..60, sonst null. */
export function parseTage(eingabe) {
const s = trimmed(eingabe)
if (!/^\d{1,3}$/.test(s)) return null
const n = Number(s)
return n <= AUFTRAG_LIMITS.maxZahlungszielTage ? n : null
}
/** Berliner Kalendertag („YYYY-MM-DD“), nicht der UTC-Tag. */
export function heuteBerlin(now = Date.now()) {
return ymdBerlin(now)
}
// --- Formular ----------------------------------------------------------------------------------------------------------
let naechsteUid = 1
/** Leere Position des Formulars; `uid` ist nur der React-Schluessel. */
export function neuePosition() {
const uid = naechsteUid
naechsteUid += 1
return { uid, description: '', quantity: '1', price: '' }
}
const kontoMit = (konten, projektId) =>
projektId && Array.isArray(konten) ? objekte(konten).find((k) => k.projektId === projektId) || null : null
const ticketIds = (konto) => (konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t?.id).filter((id) => typeof id === 'string' && id) : [])
/** Neues Formular; ein vorgewaehltes Projekt fuellt Titel und Zeitquelle. */
export function neuesFormular({ projektId = '', konten = [], heute = heuteBerlin() } = {}) {
const konto = kontoMit(konten, projektId)
return {
projektId: konto ? konto.projektId : '',
titel: konto ? text(konto.name) : '',
titelGeaendert: false,
kundenart: '',
art: 'festpreis',
auftragVom: heute,
auftragWeg: '',
positionen: [neuePosition()],
stundensatz: euroField(STUNDENSATZ_STANDARD_CENTS),
schaetzung: '',
zahlungsart: 'nach_abnahme',
prozent: String(AUFTRAG_LIMITS.standardAnzahlungProzent),
zahlungstext: '',
zahlungsziel: String(AUFTRAG_LIMITS.standardZahlungszielTage),
startAm: '',
fertigBis: '',
zeitquelle: ticketIds(konto),
zeitquelleGeaendert: false,
}
}
/** Entwurf vom Server -> Formular (Euro- und Stundentexte). Titel und Zeitquelle gelten als von Hand gesetzt. */
export function auftragZuFormular(auftrag) {
const a = istObjekt(auftrag) ? auftrag : {}
const positionen = objekte(a.positionen).map((p) => ({
uid: neuePosition().uid,
description: text(p.beschreibung),
quantity: String(p.menge ?? ''),
price: euroField(p.einzelpreisCents),
}))
const plan = istObjekt(a.zahlungsplan) ? a.zahlungsplan : {}
return {
projektId: text(a.projektId),
titel: text(a.titel),
titelGeaendert: true,
kundenart: '',
art: ARTEN.includes(a.art) ? a.art : 'festpreis',
auftragVom: text(a.auftragVom),
auftragWeg: text(a.auftragWeg),
positionen: positionen.length ? positionen : [neuePosition()],
stundensatz: euroField(typeof a.stundensatzCents === 'number' ? a.stundensatzCents : STUNDENSATZ_STANDARD_CENTS),
schaetzung: typeof a.schaetzungMinuten === 'number' ? stundenText(a.schaetzungMinuten) : '',
zahlungsart: text(plan.art) || 'nach_abnahme',
prozent: typeof plan.prozent === 'number' ? String(plan.prozent) : String(AUFTRAG_LIMITS.standardAnzahlungProzent),
zahlungstext: text(plan.text),
zahlungsziel: String(typeof a.zahlungszielTage === 'number' ? a.zahlungszielTage : AUFTRAG_LIMITS.standardZahlungszielTage),
startAm: text(a.startAm),
fertigBis: text(a.fertigBis),
zeitquelle: Array.isArray(a.zeitquelle) ? a.zeitquelle.filter((id) => typeof id === 'string') : [],
zeitquelleGeaendert: true,
}
}
/** Projekt waehlen: Titel und Zeitquelle folgen dem Projekt, solange sie nicht von Hand geaendert wurden. */
export function waehleProjekt(form, projektId, konten) {
const konto = kontoMit(konten, projektId)
const neu = { ...form, projektId: konto ? konto.projektId : '' }
if (!form.titelGeaendert) neu.titel = konto ? text(konto.name) : ''
if (!form.zeitquelleGeaendert) neu.zeitquelle = ticketIds(konto)
return neu
}
/** Art wechseln; ein unpassender Zahlungsplan wird zu „Alles nach Abnahme“. */
export function wechselArt(form, art) {
if (!ARTEN.includes(art)) return form
const passt = zahlungsplanOptionen(art).some((o) => o.value === form.zahlungsart)
return { ...form, art, zahlungsart: passt ? form.zahlungsart : 'nach_abnahme' }
}
/** Summe der gueltigen Positionen in Cent (Festpreis), bei Aufwand null. */
export function formularSummeCents(form) {
return form?.art === 'festpreis' ? invoiceTotalCents(form.positionen) : null
}
// --- Pruefen -----------------------------------------------------------------------------------------------------------
const MELDUNG = {
titel: `Bitte einen Titel eingeben (höchstens ${AUFTRAG_LIMITS.maxTitel} Zeichen).`,
kundenart: 'Bitte die Kundenart wählen.',
auftragVom: 'Bitte das Datum der Absprache angeben.',
auftragWeg: 'Bitte den Weg der Absprache wählen.',
art: 'Bitte die Art des Auftrags wählen.',
anzahl: `Eine Auftragsbestätigung braucht 1 bis ${AUFTRAG_LIMITS.maxPositionen} Positionen.`,
menge: `Die Menge muss eine ganze Zahl von 1 bis ${AUFTRAG_LIMITS.maxMenge} sein.`,
preis: 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).',
summe: `Die Summe ist zu hoch (höchstens ${euro(AUFTRAG_LIMITS.maxSummeCents)}).`,
stundensatz: 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).',
schaetzung: `Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens ${AUFTRAG_LIMITS.maxSchaetzungMinuten / 60}).`,
zahlungsart: 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.',
prozent: 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.',
zahlungstext: `Bitte den Zahlungsplan als Text eingeben (höchstens ${AUFTRAG_LIMITS.maxZahlungsplanText} Zeichen).`,
zahlungsziel: `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${AUFTRAG_LIMITS.maxZahlungszielTage} Tagen sein.`,
datum: 'Bitte ein gültiges Datum wählen.',
fertigVorStart: 'Die Fertigstellung liegt vor dem Start.',
zeitquelle: `Höchstens ${AUFTRAG_LIMITS.maxZeitquellen} Tickets als Zeitquelle.`,
}
function positionenFehler(positionen) {
const liste = objekte(positionen)
if (liste.length < 1 || liste.length > AUFTRAG_LIMITS.maxPositionen) return MELDUNG.anzahl
for (let i = 0; i < liste.length; i += 1) {
const p = liste[i]
const vor = `Position ${i + 1}: `
const beschreibung = trimmed(p.description)
if (!beschreibung) return `${vor}Bitte eine Beschreibung eingeben.`
if (beschreibung.length > AUFTRAG_LIMITS.maxBeschreibung) return `${vor}Die Beschreibung darf höchstens ${AUFTRAG_LIMITS.maxBeschreibung} Zeichen lang sein.`
if (parseQuantity(p.quantity) === null) return `${vor}${MELDUNG.menge}`
if (parseUnitPrice(p.price) === null) return `${vor}${MELDUNG.preis}`
}
if (invoiceTotalCents(liste) > AUFTRAG_LIMITS.maxSummeCents) return MELDUNG.summe
return ''
}
/** Prueft einen Schritt (1 bis 3). Ergebnis { ok, fehler: { feld: Meldung }, erstes } (erstes = erstes fehlerhaftes Feld). */
export function pruefeSchritt(schritt, form, { kunde } = {}) {
const f = istObjekt(form) ? form : {}
const fehler = {}
if (schritt === 1) {
const titel = trimmed(f.titel)
if (titel.length < 1 || titel.length > AUFTRAG_LIMITS.maxTitel) fehler.titel = MELDUNG.titel
if (!trimmed(kunde?.kundenart) && !KUNDENARTEN.includes(f.kundenart)) fehler.kundenart = MELDUNG.kundenart
} else if (schritt === 2) {
if (!istTag(f.auftragVom)) fehler.auftragVom = MELDUNG.auftragVom
if (!AUFTRAG_WEGE.some((w) => w.value === f.auftragWeg)) fehler.auftragWeg = MELDUNG.auftragWeg
if (!ARTEN.includes(f.art)) {
fehler.art = MELDUNG.art
} else if (f.art === 'festpreis') {
const meldung = positionenFehler(f.positionen)
if (meldung) fehler.positionen = meldung
} else {
if (parseUnitPrice(f.stundensatz) === null) fehler.stundensatz = MELDUNG.stundensatz
if (trimmed(f.schaetzung) !== '' && parseStunden(f.schaetzung) === null) fehler.schaetzung = MELDUNG.schaetzung
}
} else if (schritt === 3) {
if (!zahlungsplanOptionen(f.art).some((o) => o.value === f.zahlungsart)) fehler.zahlungsart = MELDUNG.zahlungsart
if (f.zahlungsart === 'anzahlung' && parseProzent(f.prozent) === null) fehler.prozent = MELDUNG.prozent
if (f.zahlungsart === 'frei') {
const t = trimmed(f.zahlungstext)
if (t.length < 1 || t.length > AUFTRAG_LIMITS.maxZahlungsplanText) fehler.zahlungstext = MELDUNG.zahlungstext
}
if (parseTage(f.zahlungsziel) === null) fehler.zahlungsziel = MELDUNG.zahlungsziel
const start = trimmed(f.startAm)
const fertig = trimmed(f.fertigBis)
if (start !== '' && !istTag(start)) fehler.startAm = MELDUNG.datum
if (fertig !== '' && !istTag(fertig)) fehler.fertigBis = MELDUNG.datum
if (start !== '' && fertig !== '' && istTag(start) && istTag(fertig) && fertig < start) fehler.fertigBis = MELDUNG.fertigVorStart
if (Array.isArray(f.zeitquelle) && f.zeitquelle.length > AUFTRAG_LIMITS.maxZeitquellen) fehler.zeitquelle = MELDUNG.zeitquelle
}
const erstes = Object.keys(fehler)[0] || ''
return { ok: erstes === '', fehler, erstes }
}
/**
* Formular -> Anfrage fuer POST/PATCH …/auftraege. Prueft die Schritte 1 bis 3 der Reihe nach.
* { ok: true, payload, summeCents } oder { ok: false, schritt, erstes, fehler }. `kundenId` setzt der Aufrufer.
*/
export function formularZuNutzlast(form, { kunde } = {}) {
for (const schritt of [1, 2, 3]) {
const r = pruefeSchritt(schritt, form, { kunde })
if (!r.ok) return { ok: false, schritt, erstes: r.erstes, fehler: r.fehler }
}
const festpreis = form.art === 'festpreis'
const plan = form.zahlungsart
const schaetzung = trimmed(form.schaetzung)
const payload = {
projektId: text(form.projektId),
titel: trimmed(form.titel),
art: form.art,
positionen: festpreis
? form.positionen.map((p) => ({ beschreibung: trimmed(p.description), menge: parseQuantity(p.quantity), einzelpreisCents: parseUnitPrice(p.price) }))
: [],
stundensatzCents: festpreis ? null : parseUnitPrice(form.stundensatz),
schaetzungMinuten: festpreis || schaetzung === '' ? null : parseStunden(schaetzung),
zahlungsplan: {
art: plan,
prozent: plan === 'anzahlung' ? parseProzent(form.prozent) : null,
text: plan === 'frei' ? trimmed(form.zahlungstext) : '',
},
zahlungszielTage: parseTage(form.zahlungsziel),
auftragVom: trimmed(form.auftragVom),
auftragWeg: form.auftragWeg,
startAm: trimmed(form.startAm),
fertigBis: trimmed(form.fertigBis),
zeitquelle: [...form.zeitquelle],
}
if (!trimmed(kunde?.kundenart)) payload.kundenart = form.kundenart
return { ok: true, payload, summeCents: festpreis ? invoiceTotalCents(form.positionen) : null }
}
// --- Texte -------------------------------------------------------------------------------------------------------------
/** Sinngemaesser Zahlungsplan wie in der AB; massgeblich bleibt der Wortlaut im PDF. Unbekannt oder unvollstaendig: ''. */
export function zahlungsplanVorschau({ art, prozent, text: freitext, summeCents, zielTage } = {}) {
const tage = Number.isInteger(zielTage) ? zielTage : AUFTRAG_LIMITS.standardZahlungszielTage
const ziel = tage === 0 ? 'sofort nach Rechnung' : tage === 1 ? 'innerhalb von 1 Tag nach Rechnung' : `innerhalb von ${tage} Tagen nach Rechnung`
const summe = Number.isInteger(summeCents) ? summeCents : null
if (art === 'nach_abnahme') {
return `${summe === null ? 'Alles' : `Gesamtbetrag (${euro(summe)})`} nach Abnahme; zahlbar ${ziel}.`
}
if (art === 'anzahlung') {
if (!Number.isInteger(prozent)) return ''
if (summe === null) return `Anzahlung ${prozent} % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils ${ziel}.`
const anzahlung = Math.round((summe * prozent) / 100)
return `Anzahlung ${prozent} % (${euro(anzahlung)}) nach Auftragsbestätigung, Rest (${euro(summe - anzahlung)}) nach Abnahme; zahlbar jeweils ${ziel}.`
}
if (art === 'monatlich') return `Monatlich nach Aufwand; zahlbar ${ziel}.`
if (art === 'frei') {
const t = trimmed(freitext)
return t ? `${t} (zahlbar ${ziel}).` : ''
}
return ''
}
/**
* Darf gesendet werden? { erlaubt, grund, zurUebersicht }. Der Entwurf laesst sich immer speichern; `zurUebersicht`
* heisst: der Grund liegt in den Kundendaten (Reiter Uebersicht).
*/
export function sendeGate(kunde, kundenart = '') {
if (!kunde) return { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false }
const check = canInvoice(kunde)
if (!check.ok) return { erlaubt: false, grund: check.reason, zurUebersicht: true }
if (!addressComplete(kunde)) return { erlaubt: false, grund: ADDRESS_INCOMPLETE_TEXT, zurUebersicht: true }
if (!trimmed(kunde.kundenart) && !trimmed(kundenart)) {
return { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false }
}
return { erlaubt: true, grund: '', zurUebersicht: false }
}
// --- Zeitquelle --------------------------------------------------------------------------------------------------------
/**
* Tickets der Zeitquelle: erst die Tickets des Projekts, dann die uebrigen gewaehlten, dann alle weiteren. Eine gewaehlte
* Kennung, die nicht (mehr) in `tickets` steht, bleibt sichtbar („Ticket <Ende der Kennung>“).
*/
export function zeitquelleOptionen(tickets, auswahl, projektTicketIds) {
const liste = objekte(tickets)
const gewaehlt = Array.isArray(auswahl) ? auswahl : []
const projekt = Array.isArray(projektTicketIds) ? projektTicketIds : []
const nachId = new Map(liste.map((t) => [t.$id, t]))
const reihenfolge = []
const gesehen = new Set()
for (const id of [...projekt, ...gewaehlt, ...liste.map((t) => t.$id)]) {
if (typeof id !== 'string' || !id || gesehen.has(id)) continue
gesehen.add(id)
reihenfolge.push(id)
}
return reihenfolge.map((id) => {
const ticket = nachId.get(id)
const titel = ticket ? trimmed(ticket.topic) || trimmed(ticket.title) : ''
const label = ticket ? `#${ticketNumber(ticket)}${titel ? ` ${titel}` : ''}` : `Ticket ${id.slice(-5)}`
return { id, label, gewaehlt: gewaehlt.includes(id), projekt: projekt.includes(id) }
})
}
/** Ticket zur Auswahl hinzufuegen (hoechstens 10, keine Doppelten) oder entfernen; liefert eine neue Liste. */
export function schalteZeitquelle(auswahl, id, an) {
const liste = Array.isArray(auswahl) ? [...auswahl] : []
if (!an) return liste.filter((x) => x !== id)
if (liste.includes(id) || liste.length >= AUFTRAG_LIMITS.maxZeitquellen) return liste
return [...liste, id]
}
// --- Hinweise (nie blockierend) ----------------------------------------------------------------------------------------
/** Wirksame Kundenart: die am Kunden gespeicherte hat Vorrang (der Server schreibt sie nur, wenn sie dort leer ist). */
export function kundenartWirksam(kunde, kundenart) {
if (KUNDENARTEN.includes(kunde?.kundenart)) return kunde.kundenart
if (KUNDENARTEN.includes(kundenart)) return kundenart
return ''
}
/** § 312f Abs. 1 BGB: bei ausserhalb von Geschaeftsraeumen geschlossenen Vertraegen mit Verbrauchern eine Papierfassung geben. */
export const VOR_ORT_HINWEIS = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
export function vorOrtHinweis(weg, kundenart) {
return weg === 'vor_ort' && kundenart === 'privat' ? VOR_ORT_HINWEIS : ''
}
/** Hinweise fuer Schritt 4 (Steuernummer fehlt; „Vor Ort“ bei Privatperson). Reine Texte, sie sperren nichts. */
export function auftragHinweise({ kunde, form, einstellungen } = {}) {
const liste = []
if (steuernummerFehlt(einstellungen)) liste.push({ key: 'steuernummer', text: STEUERNUMMER_FEHLT_HINWEIS })
const vorOrt = vorOrtHinweis(form?.auftragWeg, kundenartWirksam(kunde, form?.kundenart))
if (vorOrt) liste.push({ key: 'vorOrt', text: vorOrt })
return liste
}