Rechnungsdialog: Anfragekörper, Vorschau-Status, Zeilentexte und Absenden mit Doppelklick-Sperre samt Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e37c0a664e
commit
0f1dbabd98
2 files changed
+502
No files matched your search
@@ -0,0 +1,221 @@
|
||||
// Anfragen des Rechnungsdialogs (rein, ohne React und Appwrite; unter Node testbar).
|
||||
// Aufgaben: Körper für die Vorschau und für „Rechnung anlegen“ bauen und prüfen, Status der Vorschau, Zeilentexte der
|
||||
// Aufwandsrechnung und das Absenden mit Rückfrage, Adress-Sperre und Doppelklick-Sperre.
|
||||
// Die Eingabe `e` des Dialogs ist ein flaches Objekt (siehe rechnungAnfrage). Beträge sind Cent.
|
||||
import {
|
||||
INVOICE_LIMITS,
|
||||
ADDRESS_INCOMPLETE_TEXT,
|
||||
customerName,
|
||||
finalizedFilter,
|
||||
invoiceFailureMessage,
|
||||
invoiceSendConfirmText,
|
||||
invoiceSendGate,
|
||||
isFinalizedNotSent,
|
||||
parseQuantity,
|
||||
parseUnitPrice,
|
||||
validateInvoiceForm,
|
||||
} from './financeView'
|
||||
import { ausgewaehlteIds } from './rechnungDialogView'
|
||||
|
||||
export const LEISTUNGSZEITRAUM_MAX = 140
|
||||
export const BEZEICHNUNG_MAX = 60
|
||||
export const VORSCHAU_ARTEN = ['aufwand', 'anzahlung', 'schluss']
|
||||
|
||||
const ZEILEN_ART = /^(\d{4}-\d{2}-\d{2}|KW-\d{4}-\d{2})$/
|
||||
const TRENNER = ' – ' // Leerzeichen, Gedankenstrich (U+2013), Leerzeichen
|
||||
|
||||
const text = (v) => String(v ?? '').trim()
|
||||
|
||||
/** Zusatzzeilen prüfen; eine Zeile ohne Beschreibung und ohne Preis zählt nicht. */
|
||||
function zusatzPruefen(rows, label = 'Zusatzposition') {
|
||||
const items = []
|
||||
const liste = Array.isArray(rows) ? rows : []
|
||||
for (let i = 0; i < liste.length; i += 1) {
|
||||
const row = liste[i] || {}
|
||||
const description = text(row.description)
|
||||
if (!description && !text(row.price)) continue
|
||||
const name = `${label} ${i + 1}`
|
||||
if (!description) return { ok: false, error: `${name}: Bitte eine Beschreibung eingeben.` }
|
||||
if (description.length > INVOICE_LIMITS.maxDescription) {
|
||||
return { ok: false, error: `${name}: Die Beschreibung darf höchstens ${INVOICE_LIMITS.maxDescription} Zeichen lang sein.` }
|
||||
}
|
||||
const quantity = parseQuantity(row.quantity)
|
||||
if (quantity === null) return { ok: false, error: `${name}: Die Menge muss eine ganze Zahl von 1 bis ${INVOICE_LIMITS.maxQuantity} sein.` }
|
||||
const unit = parseUnitPrice(row.price)
|
||||
if (unit === null) return { ok: false, error: `${name}: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).` }
|
||||
items.push({ description, quantity, unitAmountCents: unit })
|
||||
}
|
||||
return { ok: true, items }
|
||||
}
|
||||
|
||||
/** '33,5' -> 33.5; nur 0 < Wert <= 100 mit höchstens zwei Nachkommastellen, sonst null. */
|
||||
function parseProzent(value) {
|
||||
const s = text(value).replace(',', '.')
|
||||
if (!/^[0-9]{1,3}(\.[0-9]{1,2})?$/.test(s)) return null
|
||||
const n = Number(s)
|
||||
return n > 0 && n <= 100 ? n : null
|
||||
}
|
||||
|
||||
/** Körper für die Vorschau; `error: ''` heißt „keine Vorschau für diese Art“. */
|
||||
export function vorschauAnfrage(e) {
|
||||
const art = e?.art
|
||||
if (!VORSCHAU_ARTEN.includes(art)) return { ok: false, error: '' }
|
||||
if (!e.kunde) return { ok: false, error: 'Bitte einen Kunden wählen.' }
|
||||
const body = { customerId: e.kunde.$id, art, projektId: e.projektId }
|
||||
|
||||
if (art === 'aufwand') {
|
||||
const ids = ausgewaehlteIds(e.blaetter, e.abgewaehlt)
|
||||
if (ids.length === 0) return { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||
body.arbeitsblattIds = ids
|
||||
} else {
|
||||
if (!e.auftragId) return { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' }
|
||||
body.auftragId = e.auftragId
|
||||
}
|
||||
|
||||
if (art === 'anzahlung') {
|
||||
const a = e.anzahlung || {}
|
||||
const betrag = {}
|
||||
if (a.modus === 'betrag') {
|
||||
const cents = parseUnitPrice(a.wert)
|
||||
if (cents === null) return { ok: false, error: 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.' }
|
||||
betrag.betragCents = cents
|
||||
} else {
|
||||
const prozent = parseProzent(a.wert)
|
||||
if (prozent === null) return { ok: false, error: 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.' }
|
||||
betrag.prozent = prozent
|
||||
}
|
||||
const bezeichnung = text(a.bezeichnung)
|
||||
if (!bezeichnung || bezeichnung.length > BEZEICHNUNG_MAX) {
|
||||
return { ok: false, error: `Bitte eine Bezeichnung eingeben (höchstens ${BEZEICHNUNG_MAX} Zeichen).` }
|
||||
}
|
||||
body.anzahlung = { ...betrag, bezeichnung }
|
||||
} else {
|
||||
const z = zusatzPruefen(e.zusatz)
|
||||
if (!z.ok) return z
|
||||
if (z.items.length) body.items = z.items
|
||||
}
|
||||
|
||||
if (art === 'aufwand') {
|
||||
const zeilentexte = {}
|
||||
for (const [schluessel, t] of Object.entries(e.zeilentexte || {})) {
|
||||
if (text(t)) zeilentexte[schluessel] = text(t)
|
||||
}
|
||||
if (Object.keys(zeilentexte).length) body.zeilentexte = zeilentexte
|
||||
}
|
||||
|
||||
const zeitraum = leistungszeitraumFeld(e)
|
||||
if (zeitraum.error) return { ok: false, error: zeitraum.error }
|
||||
if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert
|
||||
return { ok: true, body }
|
||||
}
|
||||
|
||||
function leistungszeitraumFeld(e) {
|
||||
const wert = e?.zeitraumGeaendert ? text(e.zeitraum) : ''
|
||||
if (wert.length > LEISTUNGSZEITRAUM_MAX) {
|
||||
return { error: `Der Leistungszeitraum darf höchstens ${LEISTUNGSZEITRAUM_MAX} Zeichen lang sein.`, wert: '' }
|
||||
}
|
||||
return { error: '', wert }
|
||||
}
|
||||
|
||||
/** Körper für „Rechnung anlegen“ (ohne `senden`); `totalCents` nur bei freien Positionen und Festbetrag. */
|
||||
export function rechnungAnfrage(e) {
|
||||
const art = e?.art
|
||||
if (art === 'frei' || art === 'festbetrag') {
|
||||
// die Sätze und Grenzen für Kunde, Positionen, Zahlungsziel und Notiz gelten unverändert aus dem bisherigen Dialog
|
||||
const form = validateInvoiceForm({ customer: e.kunde, items: e.items, days: e.days, memo: e.memo })
|
||||
if (!form.ok) return { ok: false, error: form.error }
|
||||
if (art === 'festbetrag' && !e.projektId) return { ok: false, error: 'Bitte ein Projekt wählen.' }
|
||||
const zeitraum = leistungszeitraumFeld(e)
|
||||
if (zeitraum.error) return { ok: false, error: zeitraum.error }
|
||||
const body = { ...form.payload, art }
|
||||
if (art === 'festbetrag') body.projektId = e.projektId
|
||||
if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert
|
||||
return { ok: true, body, totalCents: form.totalCents }
|
||||
}
|
||||
const v = vorschauAnfrage(e)
|
||||
if (!v.ok) return v
|
||||
const form = validateInvoiceForm({
|
||||
customer: e.kunde,
|
||||
items: [{ description: 'x', quantity: '1', price: '1' }],
|
||||
days: e.days,
|
||||
memo: e.memo,
|
||||
})
|
||||
if (!form.ok) return { ok: false, error: form.error }
|
||||
const body = { ...v.body, daysUntilDue: form.payload.daysUntilDue }
|
||||
if (form.payload.memo) body.memo = form.payload.memo
|
||||
return { ok: true, body, totalCents: null }
|
||||
}
|
||||
|
||||
/** Schlüssel der Eingabe, die Zahlen der Vorschau ändert (Texte und Leistungszeitraum zählen nicht). */
|
||||
export function vorschauSchluessel(body) {
|
||||
if (!body) return ''
|
||||
const { zeilentexte, leistungszeitraum, ...rest } = body
|
||||
void zeilentexte
|
||||
void leistungszeitraum
|
||||
if (Array.isArray(rest.arbeitsblattIds)) rest.arbeitsblattIds = [...rest.arbeitsblattIds].sort()
|
||||
return JSON.stringify(rest)
|
||||
}
|
||||
|
||||
/** Zustand der Vorschau: nicht-noetig | ungueltig | laedt | fehler | bereit. */
|
||||
export function vorschauStatus({ art, anfrage, vorschau, schluessel }) {
|
||||
if (!VORSCHAU_ARTEN.includes(art)) return { state: 'nicht-noetig', message: '' }
|
||||
if (!anfrage?.ok) return { state: 'ungueltig', message: anfrage?.error || '' }
|
||||
if (!vorschau || vorschau.key !== schluessel || vorschau.status === 'laedt') return { state: 'laedt', message: '' }
|
||||
if (vorschau.status === 'fehler') return { state: 'fehler', message: vorschau.message || 'Die Vorschau konnte nicht geladen werden.' }
|
||||
if (!(Number(vorschau.daten?.summeCents) > 0)) return { state: 'fehler', message: 'Nichts mehr offen.' }
|
||||
return { state: 'bereit', message: '' }
|
||||
}
|
||||
|
||||
/** Entwurf und Senden sind nur erlaubt, wenn keine Vorschau nötig ist oder sie zur aktuellen Eingabe gehört. */
|
||||
export function darfSpeichern(status) {
|
||||
return status?.state === 'bereit' || status?.state === 'nicht-noetig'
|
||||
}
|
||||
|
||||
/** Zeile der Aufwandsrechnung am letzten ' – ' teilen: vorn bearbeitbar, hinten fest. */
|
||||
export function zeileTeilen(beschreibung) {
|
||||
const s = String(beschreibung ?? '')
|
||||
const i = s.lastIndexOf(TRENNER)
|
||||
if (i < 0) return { vorn: s, hinten: '' }
|
||||
return { vorn: s.slice(0, i), hinten: s.slice(i) }
|
||||
}
|
||||
|
||||
/** Nur Zeilen mit Tag oder Kalenderwoche als Schlüssel und festem Hinten-Teil sind bearbeitbar. */
|
||||
export function zeileBearbeitbar(position) {
|
||||
return ZEILEN_ART.test(String(position?.schluessel ?? '')) && zeileTeilen(position?.beschreibung).hinten !== ''
|
||||
}
|
||||
|
||||
/** Höchstlänge des bearbeitbaren Teils, damit die ganze Zeile bei 200 Zeichen bleibt. */
|
||||
export function maxVorn(hinten) {
|
||||
return Math.max(1, INVOICE_LIMITS.maxDescription - String(hinten ?? '').length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absenden mit Rückfrage und Sperren. `submit({ anfrage, senden, kunde, totalCents })` liefert
|
||||
* ok | abgebrochen | laeuft | fehler | festgeschrieben. Ein zweiter Aufruf, solange der erste läuft, wird verworfen.
|
||||
*/
|
||||
export function createSubmitter({ create, confirm }) {
|
||||
let running = false
|
||||
return async function submit({ anfrage, senden, kunde, totalCents }) {
|
||||
if (running) return { status: 'laeuft' }
|
||||
if (!anfrage?.ok) return { status: 'fehler', message: anfrage?.error || '' }
|
||||
if (senden) {
|
||||
if (!invoiceSendGate(kunde, totalCents).sendAllowed) return { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT }
|
||||
const frage = invoiceSendConfirmText({ name: customerName(kunde), email: kunde?.email, totalCents })
|
||||
if (!confirm(frage)) return { status: 'abgebrochen' }
|
||||
}
|
||||
running = true
|
||||
try {
|
||||
const data = await create({ ...anfrage.body, senden: Boolean(senden) })
|
||||
return { status: 'ok', rechnung: data?.rechnung || null, senden: Boolean(senden) }
|
||||
} catch (err) {
|
||||
if (isFinalizedNotSent(err)) {
|
||||
return { status: 'festgeschrieben', notice: invoiceFailureMessage(err), finalizedFilter: finalizedFilter(err) }
|
||||
}
|
||||
const out = { status: 'fehler', message: invoiceFailureMessage(err) }
|
||||
if (Number.isInteger(err?.status)) out.httpStatus = err.status
|
||||
return out
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user