Auftragsbestaetigung: Dialoglogik (Parser, Validierung, Nutzlast, Zahlungsplan-Vorschau) mit Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
674cce26f0
commit
45dd73d8cc
2 files changed
+819
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, SCHRITTE, zahlungsplanOptionen,
|
||||
parseStunden, stundenText, parseProzent, parseTage, heuteBerlin,
|
||||
neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents,
|
||||
pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle,
|
||||
kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise,
|
||||
} = await loadModule('src/lib/auftragView.js')
|
||||
|
||||
const KONTEN = [
|
||||
{ projektId: 'p1', name: 'Website Praxis Müller', tickets: [{ id: 't1', woid: '100131' }] },
|
||||
{ projektId: 'p2', name: 'Shop Relaunch', tickets: [{ id: 't2', woid: '100140' }, { id: 't3', woid: '100141' }] },
|
||||
]
|
||||
const KUNDE = { $id: 'c1', name: 'Praxis Müller', email: 'info@praxis.test', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', kundenart: '', customerStatus: 'customer' }
|
||||
const festpreisForm = (over = {}) => ({
|
||||
...neuesFormular({ heute: '2026-10-06' }),
|
||||
titel: 'Website', kundenart: 'privat', auftragWeg: 'telefon',
|
||||
positionen: [{ uid: 1, description: 'Startseite', quantity: '1', price: '800,00' }],
|
||||
...over,
|
||||
})
|
||||
const aufwandForm = (over = {}) => festpreisForm({ art: 'aufwand', positionen: [], ...over })
|
||||
const OK = { ok: true, fehler: {}, erstes: '' }
|
||||
|
||||
test('Konstanten gleichen dem Vertrag V2 und der Spec (Limits, Wege, Zahlungspläne, Schritte)', () => {
|
||||
assert.deepEqual(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,
|
||||
})
|
||||
assert.equal(STUNDENSATZ_STANDARD_CENTS, 10000)
|
||||
assert.deepEqual(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' },
|
||||
])
|
||||
assert.deepEqual(zahlungsplanOptionen('festpreis'), [
|
||||
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
|
||||
{ value: 'anzahlung', label: 'Anzahlung bei Auftrag, Rest nach Abnahme' },
|
||||
{ value: 'frei', label: 'Frei (eigener Text)' },
|
||||
])
|
||||
assert.deepEqual(zahlungsplanOptionen('aufwand'), [
|
||||
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
|
||||
{ value: 'monatlich', label: 'Monatlich nach Aufwand' },
|
||||
{ value: 'frei', label: 'Frei (eigener Text)' },
|
||||
])
|
||||
assert.deepEqual(SCHRITTE, [
|
||||
{ id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' },
|
||||
])
|
||||
})
|
||||
|
||||
test('parseStunden: Stunden mit Komma in 15-Minuten-Schritten werden Minuten', () => {
|
||||
const gut = { '10': 600, '10,25': 615, '10,5': 630, '10,50': 630, '10,75': 645, '0,25': 15, '1': 60, '12,5 h': 750, '2 Std.': 120, ' 8 ': 480, '1666': 99960 }
|
||||
for (const [text, minuten] of Object.entries(gut)) assert.equal(parseStunden(text), minuten, text)
|
||||
for (const schlecht of ['', '0', '0,00', '10,3', '10,30', '10,1', '10.5', '-1', 'abc', '1666,25', '1667', '10,255', '1e3', ',5', '10 Minuten', null, undefined]) {
|
||||
assert.equal(parseStunden(schlecht), null, String(schlecht))
|
||||
}
|
||||
})
|
||||
|
||||
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
|
||||
assert.deepEqual([15, 30, 45, 60, 75, 120, 135, 150, 600, 99960].map(stundenText), ['0,25', '0,5', '0,75', '1', '1,25', '2', '2,25', '2,5', '10', '1666'])
|
||||
assert.equal(stundenText(null), '–')
|
||||
assert.equal(stundenText('x'), '–')
|
||||
})
|
||||
|
||||
test('parseProzent und parseTage', () => {
|
||||
assert.deepEqual(['50', ' 30 % ', '1', '99'].map(parseProzent), [50, 30, 1, 99])
|
||||
for (const schlecht of ['', '0', '100', '-5', '33,3', '5a', 'x', null]) assert.equal(parseProzent(schlecht), null, String(schlecht))
|
||||
assert.deepEqual(['14', '0', '60', ' 7 '].map(parseTage), [14, 0, 60, 7])
|
||||
for (const schlecht of ['', '61', '-1', '1,5', 'x', '1000', null]) assert.equal(parseTage(schlecht), null, String(schlecht))
|
||||
})
|
||||
|
||||
test('heuteBerlin: Berliner Kalendertag, nicht der UTC-Tag', () => {
|
||||
assert.equal(heuteBerlin(Date.parse('2026-10-05T22:30:00.000Z')), '2026-10-06')
|
||||
assert.equal(heuteBerlin(Date.parse('2026-10-05T21:59:00.000Z')), '2026-10-05')
|
||||
assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
|
||||
test('neuesFormular: Vorgaben; ein Projekt füllt Titel und Zeitquelle', () => {
|
||||
const f = neuesFormular({ projektId: 'p2', konten: KONTEN, heute: '2026-10-06' })
|
||||
const { positionen, ...rest } = f
|
||||
assert.equal(positionen.length, 1)
|
||||
const { uid, ...zeile } = positionen[0]
|
||||
assert.equal(typeof uid, 'number')
|
||||
assert.deepEqual(zeile, { description: '', quantity: '1', price: '' })
|
||||
assert.deepEqual(rest, {
|
||||
projektId: 'p2', titel: 'Shop Relaunch', titelGeaendert: false, kundenart: '', art: 'festpreis',
|
||||
auftragVom: '2026-10-06', auftragWeg: '',
|
||||
stundensatz: '100,00', schaetzung: '',
|
||||
zahlungsart: 'nach_abnahme', prozent: '50', zahlungstext: '', zahlungsziel: '14',
|
||||
startAm: '', fertigBis: '', zeitquelle: ['t2', 't3'], zeitquelleGeaendert: false,
|
||||
})
|
||||
assert.deepEqual(neuesFormular({ konten: KONTEN, heute: '2026-10-06' }).zeitquelle, [])
|
||||
assert.equal(neuesFormular({ projektId: 'gibtsnicht', konten: KONTEN, heute: '2026-10-06' }).projektId, '')
|
||||
assert.equal(neuesFormular({ projektId: 'p1', heute: '2026-10-06' }).projektId, '')
|
||||
assert.notEqual(neuePosition().uid, neuePosition().uid)
|
||||
})
|
||||
|
||||
test('auftragZuFormular: Entwurf vom Server wird zum Formular (Euro- und Stundentexte)', () => {
|
||||
const entwurf = {
|
||||
id: 'a1', status: 'entwurf', projektId: 'p1', titel: 'Website', art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'],
|
||||
}
|
||||
const f = auftragZuFormular(entwurf)
|
||||
assert.deepEqual(f.positionen.map(({ uid, ...p }) => p), [{ description: 'Startseite', quantity: '2', price: '400,00' }])
|
||||
const { positionen, ...rest } = f
|
||||
assert.deepEqual(rest, {
|
||||
projektId: 'p1', titel: 'Website', titelGeaendert: true, kundenart: '', art: 'festpreis',
|
||||
auftragVom: '2026-10-05', auftragWeg: 'email',
|
||||
stundensatz: '100,00', schaetzung: '',
|
||||
zahlungsart: 'anzahlung', prozent: '30', zahlungstext: '', zahlungsziel: '21',
|
||||
startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], zeitquelleGeaendert: true,
|
||||
})
|
||||
const aufwand = auftragZuFormular({
|
||||
...entwurf, art: 'aufwand', positionen: [], stundensatzCents: 9550, schaetzungMinuten: 750, summeCents: null,
|
||||
zahlungsplan: { art: 'frei', prozent: null, text: 'Nach Absprache' },
|
||||
})
|
||||
assert.equal(aufwand.stundensatz, '95,50')
|
||||
assert.equal(aufwand.schaetzung, '12,5')
|
||||
assert.equal(aufwand.positionen.length, 1) // eine leere Zeile für den Wechsel auf Festpreis
|
||||
assert.deepEqual([aufwand.zahlungsart, aufwand.prozent, aufwand.zahlungstext], ['frei', '50', 'Nach Absprache'])
|
||||
})
|
||||
|
||||
test('waehleProjekt: Titel und Zeitquelle folgen dem Projekt, bis sie von Hand geändert wurden', () => {
|
||||
const f0 = neuesFormular({ konten: KONTEN, heute: '2026-10-06' })
|
||||
const f1 = waehleProjekt(f0, 'p1', KONTEN)
|
||||
assert.deepEqual([f1.projektId, f1.titel, f1.zeitquelle], ['p1', 'Website Praxis Müller', ['t1']])
|
||||
const f2 = waehleProjekt(f1, 'p2', KONTEN)
|
||||
assert.deepEqual([f2.titel, f2.zeitquelle], ['Shop Relaunch', ['t2', 't3']])
|
||||
const f3 = waehleProjekt(f2, '', KONTEN)
|
||||
assert.deepEqual([f3.projektId, f3.titel, f3.zeitquelle], ['', '', []])
|
||||
assert.equal(waehleProjekt(f1, 'gibtsnicht', KONTEN).projektId, '')
|
||||
const hand = { ...f1, titel: 'Eigener Titel', titelGeaendert: true, zeitquelle: ['t7'], zeitquelleGeaendert: true }
|
||||
const f4 = waehleProjekt(hand, 'p2', KONTEN)
|
||||
assert.deepEqual([f4.projektId, f4.titel, f4.zeitquelle], ['p2', 'Eigener Titel', ['t7']])
|
||||
assert.equal(f0.projektId, '') // die Eingabe bleibt unverändert
|
||||
})
|
||||
|
||||
test('wechselArt: ein unpassender Zahlungsplan wird zurückgesetzt', () => {
|
||||
const f = { ...neuesFormular({ heute: '2026-10-06' }), zahlungsart: 'anzahlung' }
|
||||
assert.equal(wechselArt(f, 'aufwand').art, 'aufwand')
|
||||
assert.equal(wechselArt(f, 'aufwand').zahlungsart, 'nach_abnahme')
|
||||
assert.equal(wechselArt(f, 'festpreis').zahlungsart, 'anzahlung')
|
||||
assert.equal(wechselArt({ ...f, art: 'aufwand', zahlungsart: 'monatlich' }, 'festpreis').zahlungsart, 'nach_abnahme')
|
||||
assert.equal(wechselArt({ ...f, zahlungsart: 'frei' }, 'aufwand').zahlungsart, 'frei')
|
||||
assert.equal(wechselArt(f, 'quatsch'), f)
|
||||
})
|
||||
|
||||
test('formularSummeCents: Festpreis summiert gültige Positionen, Aufwand hat keine Summe', () => {
|
||||
const f = festpreisForm({
|
||||
positionen: [
|
||||
{ uid: 1, description: 'a', quantity: '2', price: '100,00' },
|
||||
{ uid: 2, description: 'b', quantity: '1', price: '50' },
|
||||
{ uid: 3, description: 'c', quantity: 'x', price: '9,99' },
|
||||
],
|
||||
})
|
||||
assert.equal(formularSummeCents(f), 25000)
|
||||
assert.equal(formularSummeCents(festpreisForm({ positionen: [] })), 0)
|
||||
assert.equal(formularSummeCents(aufwandForm()), null)
|
||||
})
|
||||
|
||||
test('pruefeSchritt 1: Titel ist Pflicht, die Kundenart nur, wenn sie am Kunden fehlt', () => {
|
||||
const f = neuesFormular({ heute: '2026-10-06' })
|
||||
const r = pruefeSchritt(1, f, { kunde: KUNDE })
|
||||
assert.equal(r.ok, false)
|
||||
assert.deepEqual(r.fehler, { titel: 'Bitte einen Titel eingeben (höchstens 200 Zeichen).', kundenart: 'Bitte die Kundenart wählen.' })
|
||||
assert.equal(r.erstes, 'titel')
|
||||
assert.deepEqual(pruefeSchritt(1, { ...f, titel: ' Website ', kundenart: 'privat' }, { kunde: KUNDE }), OK)
|
||||
assert.deepEqual(pruefeSchritt(1, { ...f, titel: 'x' }, { kunde: { ...KUNDE, kundenart: 'privat' } }), OK)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(201), kundenart: 'privat' }, { kunde: KUNDE }).ok, false)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(200), kundenart: 'privat' }, { kunde: KUNDE }).ok, true)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x', kundenart: 'firma' }, { kunde: KUNDE }).fehler.kundenart, 'Bitte die Kundenart wählen.')
|
||||
})
|
||||
|
||||
test('pruefeSchritt 2 (Festpreis): Datum, Weg, Positionen und Summe', () => {
|
||||
const pruefe = (over) => pruefeSchritt(2, festpreisForm(over), { kunde: KUNDE })
|
||||
assert.deepEqual(pruefe(), OK)
|
||||
const r = pruefe({ auftragVom: '', auftragWeg: '' })
|
||||
assert.deepEqual(r.fehler, { auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.' })
|
||||
assert.equal(r.erstes, 'auftragVom')
|
||||
for (const schlecht of ['2026-02-30', '06.10.2026', '2026-1-1', 'morgen']) {
|
||||
assert.equal(pruefe({ auftragVom: schlecht }).fehler.auftragVom, 'Bitte das Datum der Absprache angeben.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe({ auftragWeg: 'brieftaube' }).fehler.auftragWeg, 'Bitte den Weg der Absprache wählen.')
|
||||
assert.equal(pruefe({ art: 'xyz' }).fehler.art, 'Bitte die Art des Auftrags wählen.')
|
||||
|
||||
const pos = (o = {}) => ({ uid: 1, description: 'x', quantity: '1', price: '10,00', ...o })
|
||||
const meldung = (positionen) => pruefe({ positionen }).fehler.positionen
|
||||
const PREIS = 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
|
||||
assert.equal(meldung([pos({ description: ' ' })]), 'Position 1: Bitte eine Beschreibung eingeben.')
|
||||
assert.equal(meldung([pos(), pos({ description: 'y'.repeat(201) })]), 'Position 2: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
|
||||
assert.equal(meldung([pos({ quantity: '0' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
assert.equal(meldung([pos({ quantity: '1000' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
assert.equal(meldung([pos({ price: '' })]), `Position 1: ${PREIS}`)
|
||||
assert.equal(meldung([pos({ price: '100.000,01' })]), `Position 1: ${PREIS}`)
|
||||
assert.equal(meldung([]), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(meldung(Array.from({ length: 21 }, () => pos())), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(meldung(Array.from({ length: 20 }, () => pos())), undefined)
|
||||
// Summe: genau 999.999,99 € gehen, 1.000.000,00 € nicht
|
||||
const neun = Array.from({ length: 9 }, () => pos({ price: '100.000,00' }))
|
||||
assert.equal(meldung([...neun, pos({ price: '99.999,99' })]), undefined)
|
||||
assert.equal(meldung([...neun, pos({ price: '100.000,00' })]), 'Die Summe ist zu hoch (höchstens 999.999,99 €).')
|
||||
})
|
||||
|
||||
test('pruefeSchritt 2 (Aufwand): Stundensatz und Schätzung, Positionen zählen nicht', () => {
|
||||
const pruefe = (over) => pruefeSchritt(2, aufwandForm(over), { kunde: KUNDE })
|
||||
const SATZ = 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).'
|
||||
assert.deepEqual(pruefe(), OK)
|
||||
assert.equal(pruefe({ schaetzung: '12,5' }).ok, true)
|
||||
assert.equal(pruefe({ stundensatz: '' }).fehler.stundensatz, SATZ)
|
||||
assert.equal(pruefe({ stundensatz: '12.50' }).fehler.stundensatz, SATZ)
|
||||
assert.equal(pruefe({ schaetzung: '10,3' }).fehler.schaetzung, 'Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens 1666).')
|
||||
assert.equal(pruefe({ positionen: [{ uid: 1, description: '', quantity: 'x', price: '' }] }).ok, true)
|
||||
})
|
||||
|
||||
test('pruefeSchritt 3: Zahlungsplan, Zahlungsziel, Termine, Zeitquelle', () => {
|
||||
const pruefe = (f) => pruefeSchritt(3, f, { kunde: KUNDE })
|
||||
assert.deepEqual(pruefe(festpreisForm()), OK)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: '50' })).ok, true)
|
||||
for (const schlecht of ['', '0', '100', '33,3']) {
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: schlecht })).fehler.prozent, 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'nach_abnahme', prozent: 'x' })).ok, true) // Prozent zählt nur bei „Anzahlung“
|
||||
const TEXT = 'Bitte den Zahlungsplan als Text eingeben (höchstens 500 Zeichen).'
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' ' })).fehler.zahlungstext, TEXT)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(501) })).fehler.zahlungstext, TEXT)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(500) })).ok, true)
|
||||
for (const schlecht of ['', '61', '-1', '1,5']) {
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsziel: schlecht })).fehler.zahlungsziel, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsziel: '0' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-30' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-01' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-02', fertigBis: '2026-11-01' })).fehler.fertigBis, 'Die Fertigstellung liegt vor dem Start.')
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-13-01' })).fehler.startAm, 'Bitte ein gültiges Datum wählen.')
|
||||
assert.equal(pruefe(festpreisForm({ fertigBis: 'bald' })).fehler.fertigBis, 'Bitte ein gültiges Datum wählen.')
|
||||
const ids = (n) => Array.from({ length: n }, (_, i) => `t${i}`)
|
||||
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(10) })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(11) })).fehler.zeitquelle, 'Höchstens 10 Tickets als Zeitquelle.')
|
||||
const PASST = 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.'
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'monatlich' })).fehler.zahlungsart, PASST)
|
||||
assert.equal(pruefe(aufwandForm({ zahlungsart: 'anzahlung' })).fehler.zahlungsart, PASST)
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Festpreis in Cent, getrimmte Texte, Kundenart nur wenn sie am Kunden fehlt', () => {
|
||||
const f = festpreisForm({
|
||||
titel: ' Website Praxis ', projektId: 'p1',
|
||||
positionen: [
|
||||
{ uid: 1, description: ' Startseite ', quantity: '2', price: '400,00' },
|
||||
{ uid: 2, description: 'Kontaktformular', quantity: '1', price: '1.234,50' },
|
||||
],
|
||||
zahlungsart: 'anzahlung', prozent: '30', zahlungsziel: '21',
|
||||
startAm: '2026-11-01', fertigBis: '2026-11-30', zeitquelle: ['t1', 't2'],
|
||||
})
|
||||
const r = formularZuNutzlast(f, { kunde: KUNDE })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.summeCents, 203450)
|
||||
assert.deepEqual(r.payload, {
|
||||
projektId: 'p1', titel: 'Website Praxis', kundenart: 'privat', art: 'festpreis',
|
||||
positionen: [
|
||||
{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 },
|
||||
{ beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 123450 },
|
||||
],
|
||||
stundensatzCents: null, schaetzungMinuten: null,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
|
||||
auftragVom: '2026-10-06', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '2026-11-30',
|
||||
zeitquelle: ['t1', 't2'],
|
||||
})
|
||||
assert.equal('kundenart' in formularZuNutzlast(f, { kunde: { ...KUNDE, kundenart: 'unternehmen' } }).payload, false)
|
||||
const frei = formularZuNutzlast(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' Zahlung in zwei Raten ' }), { kunde: KUNDE })
|
||||
assert.deepEqual(frei.payload.zahlungsplan, { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten' })
|
||||
assert.equal(frei.payload.projektId, '')
|
||||
assert.deepEqual([frei.payload.startAm, frei.payload.fertigBis], ['', ''])
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Aufwand mit Satz in Cent und Schätzung in Minuten, ohne Positionen und Summe', () => {
|
||||
const r = formularZuNutzlast(aufwandForm({ stundensatz: '95,50', schaetzung: '12,5', zahlungsart: 'monatlich' }), { kunde: { ...KUNDE, kundenart: 'privat' } })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.summeCents, null)
|
||||
assert.deepEqual(r.payload.positionen, [])
|
||||
assert.equal(r.payload.art, 'aufwand')
|
||||
assert.equal(r.payload.stundensatzCents, 9550)
|
||||
assert.equal(r.payload.schaetzungMinuten, 750)
|
||||
assert.deepEqual(r.payload.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
|
||||
assert.equal(formularZuNutzlast(aufwandForm({ schaetzung: '' }), { kunde: KUNDE }).payload.schaetzungMinuten, null)
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Fehler nennen den Schritt und das erste Feld', () => {
|
||||
const r1 = formularZuNutzlast(festpreisForm({ titel: '', zahlungsziel: '99' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r1.ok, r1.schritt, r1.erstes], [false, 1, 'titel'])
|
||||
assert.equal(r1.fehler.titel, 'Bitte einen Titel eingeben (höchstens 200 Zeichen).')
|
||||
const r2 = formularZuNutzlast(festpreisForm({ auftragWeg: '' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r2.ok, r2.schritt, r2.erstes], [false, 2, 'auftragWeg'])
|
||||
const r3 = formularZuNutzlast(festpreisForm({ zahlungsziel: '99' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r3.ok, r3.schritt, r3.erstes], [false, 3, 'zahlungsziel'])
|
||||
})
|
||||
|
||||
test('zahlungsplanVorschau: Wortlaut sinngemäß wie in der Auftragsbestätigung', () => {
|
||||
const v = (o) => zahlungsplanVorschau({ art: 'nach_abnahme', prozent: null, text: '', summeCents: 80000, zielTage: 14, ...o })
|
||||
assert.equal(v({}), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ summeCents: null }), 'Alles nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50 }), 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
// Rundung wie im Vertrag V3: Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: 33333 }), 'Anzahlung 50 % (166,67 €) nach Auftragsbestätigung, Rest (166,66 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 33, summeCents: 33333 }), 'Anzahlung 33 % (110,00 €) nach Auftragsbestätigung, Rest (223,33 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: null }), 'Anzahlung 50 % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: null }), '')
|
||||
assert.equal(v({ art: 'monatlich', summeCents: null }), 'Monatlich nach Aufwand; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'frei', text: ' Zwei Raten, je zur Hälfte ' }), 'Zwei Raten, je zur Hälfte (zahlbar innerhalb von 14 Tagen nach Rechnung).')
|
||||
assert.equal(v({ art: 'frei', text: '' }), '')
|
||||
assert.equal(v({ zielTage: 0 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar sofort nach Rechnung.')
|
||||
assert.equal(v({ zielTage: 1 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 1 Tag nach Rechnung.')
|
||||
assert.equal(v({ zielTage: null }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'unbekannt' }), '')
|
||||
})
|
||||
|
||||
test('sendeGate: Lead, E-Mail, Anschrift und Kundenart halten das Senden auf, der Entwurf bleibt möglich', () => {
|
||||
assert.deepEqual(sendeGate(KUNDE, 'privat'), { erlaubt: true, grund: '', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, kundenart: 'privat' }, ''), { erlaubt: true, grund: '', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate(KUNDE, ''), { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, street: '' }, 'privat'), {
|
||||
erlaubt: false, grund: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, email: ' ' }, 'privat'), { erlaubt: false, grund: 'E-Mail fehlt', zurUebersicht: true })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, customerStatus: 'lead' }, 'privat'), { erlaubt: false, grund: 'Noch kein fester Kunde', zurUebersicht: true })
|
||||
assert.deepEqual(sendeGate(null, 'privat'), { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false })
|
||||
})
|
||||
|
||||
const TICKETS = [
|
||||
{ $id: 't9', woid: '100149', topic: 'Neueste Anfrage' },
|
||||
{ $id: 't2', woid: '100140', topic: 'Shop Relaunch' },
|
||||
{ $id: 't1', woid: '100131', topic: 'Website Praxis' },
|
||||
{ $id: 'xyz12345', woid: '', title: 'Ohne Nummer' },
|
||||
]
|
||||
|
||||
test('zeitquelleOptionen: Projekt-Tickets zuerst, dann gewählte, dann der Rest; unbekannte Auswahl bleibt sichtbar', () => {
|
||||
const o = zeitquelleOptionen(TICKETS, ['t9', 'alt77'], ['t1'])
|
||||
assert.deepEqual(o.map((x) => x.id), ['t1', 't9', 'alt77', 't2', 'xyz12345'])
|
||||
assert.deepEqual(o.map((x) => x.gewaehlt), [false, true, true, false, false])
|
||||
assert.deepEqual(o.map((x) => x.projekt), [true, false, false, false, false])
|
||||
assert.equal(o[0].label, '#100131 Website Praxis')
|
||||
assert.equal(o[1].label, '#100149 Neueste Anfrage')
|
||||
assert.equal(o[2].label, 'Ticket alt77')
|
||||
assert.equal(o[4].label, '#12345 Ohne Nummer')
|
||||
assert.deepEqual(zeitquelleOptionen(null, [], []), [])
|
||||
})
|
||||
|
||||
test('schalteZeitquelle: höchstens 10, keine Doppelten, Reihenfolge der Auswahl', () => {
|
||||
assert.deepEqual(schalteZeitquelle(['a'], 'b', true), ['a', 'b'])
|
||||
assert.deepEqual(schalteZeitquelle(['a', 'b'], 'a', false), ['b'])
|
||||
assert.deepEqual(schalteZeitquelle(['a'], 'a', true), ['a'])
|
||||
assert.deepEqual(schalteZeitquelle(undefined, 'a', true), ['a'])
|
||||
const zehn = Array.from({ length: 10 }, (_, i) => `t${i}`)
|
||||
assert.deepEqual(schalteZeitquelle(zehn, 'neu', true), zehn)
|
||||
assert.deepEqual(schalteZeitquelle(zehn, 't3', false), zehn.filter((id) => id !== 't3'))
|
||||
})
|
||||
|
||||
test('kundenartWirksam: die Kundenart am Kunden gilt, sonst die im Formular gewählte', () => {
|
||||
assert.equal(kundenartWirksam({ kundenart: 'unternehmen' }, 'privat'), 'unternehmen')
|
||||
assert.equal(kundenartWirksam({ kundenart: '' }, 'privat'), 'privat')
|
||||
assert.equal(kundenartWirksam({}, 'unternehmen'), 'unternehmen')
|
||||
assert.equal(kundenartWirksam(null, 'privat'), 'privat')
|
||||
assert.equal(kundenartWirksam({ kundenart: 'privat' }, ''), 'privat')
|
||||
assert.equal(kundenartWirksam({ kundenart: 'firma' }, ''), '')
|
||||
assert.equal(kundenartWirksam({ kundenart: '' }, 'firma'), '')
|
||||
assert.equal(kundenartWirksam(null, undefined), '')
|
||||
})
|
||||
|
||||
test('vorOrtHinweis: nur bei „Vor Ort“ und Privatperson (§ 312f Abs. 1 BGB), nie als Sperre', () => {
|
||||
const TEXT = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
|
||||
assert.equal(VOR_ORT_HINWEIS, TEXT)
|
||||
assert.equal(vorOrtHinweis('vor_ort', 'privat'), TEXT)
|
||||
for (const [weg, art] of [['vor_ort', 'unternehmen'], ['vor_ort', ''], ['telefon', 'privat'], ['muendlich', 'privat'], ['email', 'privat'], ['chat', 'privat'], ['', 'privat'], [undefined, 'privat']]) {
|
||||
assert.equal(vorOrtHinweis(weg, art), '', `${weg}/${art}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperson; sonst nichts', () => {
|
||||
const STN = 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
|
||||
const VOR = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
|
||||
const hinweise = (o) => auftragHinweise({ kunde: KUNDE, form: festpreisForm(), einstellungen: { steuernummer: '12/345/67890' }, ...o })
|
||||
assert.deepEqual(hinweise(), []) // festpreisForm: Privatperson, Weg „telefon“, Steuernummer vorhanden
|
||||
assert.deepEqual(hinweise({ einstellungen: { steuernummer: '' } }), [{ key: 'steuernummer', text: STN }])
|
||||
assert.deepEqual(hinweise({ einstellungen: {} }).map((h) => h.key), ['steuernummer'])
|
||||
// Einstellungen noch nicht geladen (undefined) oder nicht ladbar (null): keine Behauptung
|
||||
assert.deepEqual(hinweise({ einstellungen: undefined }), [])
|
||||
assert.deepEqual(hinweise({ einstellungen: null }), [])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort' }) }), [{ key: 'vorOrt', text: VOR }])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: 'unternehmen' }) }), [])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }), [])
|
||||
// die Kundenart des Kunden hat Vorrang vor dem Formular
|
||||
const privatAmKunden = { kunde: { ...KUNDE, kundenart: 'privat' }, form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }
|
||||
assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt'])
|
||||
assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt'])
|
||||
})
|
||||
Reference in new issue
Block a user