Aufträge: Konstanten und Fehlerklasse (L0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-07 14:03:25 +00:00
1 parent 1b5b5b7094
commit 16f08b5ac3
3 files changed
+143

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
/**
* Fehlerklasse der Aufträge-Module und einheitliche Fehlerantwort der Routen.
* AuftragError nur für Fälle, die ein Nutzer verstehen soll (404, 409, 400 bei zu langem Text); Programmfehler
* (ungültige Kennungen oder Typen) sind TypeError und enden als 500.
*/
import { InvoiceError } from '../finance/stripeFinance.js'
export class AuftragError extends Error {
constructor(status, message) {
if (!Number.isInteger(status) || status < 400 || status > 599) {
throw new TypeError('AuftragError: Status muss eine ganze Zahl von 400 bis 599 sein.')
}
super(message)
this.name = 'AuftragError'
this.status = status
}
}
/**
* Antwortet auf einen Fehler: AuftragError und InvoiceError mit Status und Meldung, alles andere (auch Fehler mit
* Status von Appwrite oder Stripe) als 500 „Interner Fehler.“. Das Protokoll nennt nie die Meldung, nur die Art.
* InvoiceError wird nur in der Funktion benutzt, nie beim Laden: stripeFinance.js importiert ab L2 selbst Module
* dieses Verzeichnisses, so bleibt der Importzyklus harmlos.
*/
export function sendeFehler(res, was, err) {
if (err instanceof AuftragError || err instanceof InvoiceError) {
return res.status(err.status).json({ error: err.message })
}
console.error(`[auftraege] ${was} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '')
return res.status(500).json({ error: 'Interner Fehler.' })
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Aufträge und Rechnungen: gemeinsame Konstanten (Übersicht V2).
* Gemeinsame Datei: nur L0 ändert sie (Ausnahme: die eine Zeile AGB_FASSUNG, die L7 anpassen darf).
*/
export const RECHNUNGS_ARTEN = ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss']
export const AB_RECHNUNGS_ARTEN = ['anzahlung', 'schluss'] // brauchen AB und Schalter
export const AUFTRAG_ARTEN = ['festpreis', 'aufwand']
export const AUFTRAG_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']
export const VEREINBARTE_STATUS = ['versendet', 'bestaetigt', 'abgerechnet'] // zählen als „vereinbart“
export const AUFTRAG_WEGE = { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' }
export const ZAHLUNGSPLAN_ARTEN = ['nach_abnahme', 'anzahlung', 'monatlich', 'frei']
export const KUNDENARTEN = ['unternehmen', 'privat']
export const STUNDENSATZ_STANDARD_CENTS = 10000
export const VIERTELSTUNDE = 15
export const TOKEN_GUELTIG_TAGE = 60
export const AGB_FASSUNG = '2026-09-30' // L7 darf diese eine Zeile anpassen
export const 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,
}
// withLocks sperrt jedes Listenelement einzeln: deshalb je Sperre GENAU EIN zusammengesetzter Schlüssel
export const SPERRE_NUMMER = ['auftraege-nummer']
export const sperreRechnung = (kundenId) => [`auftraege-rechnung:${kundenId}`] // Aufwand, Anzahlung, Schluss je Kunde
export const sperreAuftrag = (id) => [`auftraege-ab:${id}`] // jede Statusänderung einer AB (L2, L3, L5)
export const SCHALTER_AUS_MELDUNG = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
+85
View File
@@ -0,0 +1,85 @@
// test/auftraegeKonstanten.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'
import * as K from '../server/services/auftraege/konstanten.js'
import { AuftragError, sendeFehler } from '../server/services/auftraege/fehler.js'
import { InvoiceError } from '../server/services/finance/stripeFinance.js'
test('Konstanten stehen wie in Übersicht V2', () => {
assert.deepEqual(K.RECHNUNGS_ARTEN, ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss'])
assert.deepEqual(K.AB_RECHNUNGS_ARTEN, ['anzahlung', 'schluss'])
assert.deepEqual(K.AUFTRAG_ARTEN, ['festpreis', 'aufwand'])
assert.deepEqual(K.AUFTRAG_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen'])
assert.deepEqual(K.VEREINBARTE_STATUS, ['versendet', 'bestaetigt', 'abgerechnet'])
assert.deepEqual(K.AUFTRAG_WEGE, { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' })
assert.deepEqual(K.ZAHLUNGSPLAN_ARTEN, ['nach_abnahme', 'anzahlung', 'monatlich', 'frei'])
assert.deepEqual(K.KUNDENARTEN, ['unternehmen', 'privat'])
assert.equal(K.STUNDENSATZ_STANDARD_CENTS, 10000)
assert.equal(K.VIERTELSTUNDE, 15)
assert.equal(K.TOKEN_GUELTIG_TAGE, 60)
assert.match(K.AGB_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) // den Wert legt L7 fest
assert.deepEqual(K.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,
})
// withLocks sperrt jedes Listenelement einzeln: je Sperre genau ein zusammengesetzter Schlüssel (verschiedene Kunden und ABs blockieren sich nicht)
assert.deepEqual(K.SPERRE_NUMMER, ['auftraege-nummer'])
assert.deepEqual(K.sperreRechnung('c1'), ['auftraege-rechnung:c1'])
assert.deepEqual(K.sperreAuftrag('ab1'), ['auftraege-ab:ab1'])
assert.equal(K.SCHALTER_AUS_MELDUNG, 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.')
for (const status of K.VEREINBARTE_STATUS) assert.ok(K.AUFTRAG_STATUS.includes(status), status)
for (const art of K.AB_RECHNUNGS_ARTEN) assert.ok(K.RECHNUNGS_ARTEN.includes(art), art)
})
const fakeRes = () => ({
statusCode: 200,
body: undefined,
status(code) {
this.statusCode = code
return this
},
json(body) {
this.body = body
return this
},
})
test('AuftragError trägt Status und Meldung; ein Status außerhalb 400 bis 599 ist ein Programmfehler', () => {
const err = new AuftragError(409, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).')
assert.ok(err instanceof Error)
assert.equal(err.name, 'AuftragError')
assert.equal(err.status, 409)
assert.equal(err.message, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).')
for (const status of [200, 399, 600, 404.5, '404', undefined, null]) {
assert.throws(() => new AuftragError(status, 'x'), TypeError, String(status))
}
})
test('sendeFehler gibt AuftragError und InvoiceError mit Status und Meldung weiter', () => {
const a = fakeRes()
sendeFehler(a, 'AB senden', new AuftragError(400, 'Kundenart fehlt.'))
assert.deepEqual([a.statusCode, a.body], [400, { error: 'Kundenart fehlt.' }])
const b = fakeRes()
sendeFehler(b, 'Rechnung anlegen', new InvoiceError(502, 'Stripe ist gerade nicht erreichbar.'))
assert.deepEqual([b.statusCode, b.body], [502, { error: 'Stripe ist gerade nicht erreichbar.' }])
})
test('sendeFehler: alles andere wird 500 „Interner Fehler.“, das Protokoll nennt nie die Meldung', () => {
const log = mock.method(console, 'error', () => {})
try {
const appwrite = Object.assign(new Error('Appwrite: Key sk_live_geheim abgelehnt'), { status: 400, type: 'user_unauthorized' })
for (const err of [appwrite, 'sk_live_geheim', undefined, null]) {
const res = fakeRes()
sendeFehler(res, 'Projektkonto laden', err)
assert.deepEqual([res.statusCode, res.body], [500, { error: 'Interner Fehler.' }])
}
assert.equal(log.mock.calls.length, 4)
const zeilen = JSON.stringify(log.mock.calls.map((call) => call.arguments))
assert.ok(!zeilen.includes('geheim'), zeilen)
assert.ok(zeilen.includes('Projektkonto laden') && zeilen.includes('user_unauthorized'), zeilen)
} finally {
log.mock.restore()
}
})