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
|
||||
}
|
||||
Reference in new issue
Block a user