Aufträge: Speicher der Auftragsbestätigungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-07 14:05:49 +00:00
1 parent 4e4feba4c9
commit a3db7f310a
2 files changed
+342

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
/**
* Speicher der Auftragsbestätigungen (Collection auftraege, Übersicht V1/V3/V4).
* Die Feldlisten stammen aus AUFTRAEGE_COLLECTIONS (Typ und Größe der Spalten); nur vier Spalten sind JSON und
* heißen im Auftrag anders: kundeJson = kunde, zeitquelleJson = zeitquelle, positionenJson = positionen,
* zahlungsplanJson = zahlungsplan. Programmfehler (falsche Typen, ungültige Filter) sind TypeError, ein zu langer
* Text ist ein AuftragError 400. Schreibende Aufrufer halten bei Statusänderungen sperreAuftrag(id) (konstanten.js).
*/
import { config } from '../../config.js'
import { createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { AuftragError } from './fehler.js'
import { LIMITS, ZAHLUNGSPLAN_ARTEN } from './konstanten.js'
import { AUFTRAEGE_COLLECTIONS } from './schema.js'
const JSON_SPALTEN = { kundeJson: 'kunde', zeitquelleJson: 'zeitquelle', positionenJson: 'positionen', zahlungsplanJson: 'zahlungsplan' }
const SPALTE_ZU_FELD = (spalte) => JSON_SPALTEN[spalte] || spalte
const SPALTEN = AUFTRAEGE_COLLECTIONS.auftraege.attributes
const TEXT_SPALTEN = SPALTEN.filter((a) => a.type === 'string' && !JSON_SPALTEN[a.key])
const ZAHL_SPALTEN = SPALTEN.filter((a) => a.type === 'integer').map((a) => a.key)
const DATUM_SPALTEN = SPALTEN.filter((a) => a.type === 'datetime').map((a) => a.key)
const SPALTEN_NACH_FELD = new Map(SPALTEN.map((a) => [SPALTE_ZU_FELD(a.key), a]))
// Appwrite-Regel für Dokument-IDs
const DOKUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
const gueltigeId = (id) => typeof id === 'string' && DOKUMENT_ID.test(id)
const TOKEN_HASH = /^[0-9a-f]{64}$/
const AB_NUMMER = /^AB-\d{4}-\d{4}$/
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function parseJson(text) {
if (typeof text !== 'string' || text === '') return undefined
try {
return JSON.parse(text)
} catch {
return undefined
}
}
function zeitpunkt(wert) {
if (wert === null || wert === undefined || wert === '') return null
const datum = new Date(wert)
return Number.isNaN(datum.getTime()) ? null : datum.toISOString()
}
export function docToAuftrag(doc) {
const auftrag = { id: doc.$id }
for (const { key } of TEXT_SPALTEN) auftrag[key] = typeof doc[key] === 'string' ? doc[key] : ''
for (const key of ZAHL_SPALTEN) auftrag[key] = Number.isSafeInteger(doc[key]) ? doc[key] : null
auftrag.zahlungszielTage = Number.isSafeInteger(doc.zahlungszielTage) ? doc.zahlungszielTage : LIMITS.standardZahlungszielTage
auftrag.fruehbeginn = doc.fruehbeginn === true
for (const key of DATUM_SPALTEN) auftrag[key] = zeitpunkt(doc[key])
auftrag.erstelltAm = zeitpunkt(doc.erstelltAm) ?? zeitpunkt(doc.$createdAt)
auftrag.geaendertAm = zeitpunkt(doc.geaendertAm) ?? zeitpunkt(doc.$updatedAt)
const kunde = parseJson(doc.kundeJson)
auftrag.kunde = istObjekt(kunde) ? kunde : null
const zeitquelle = parseJson(doc.zeitquelleJson)
auftrag.zeitquelle = Array.isArray(zeitquelle)
? zeitquelle.filter((id) => typeof id === 'string' && id.trim() !== '').map((id) => id.trim())
: []
const positionen = parseJson(doc.positionenJson)
auftrag.positionen = Array.isArray(positionen) ? positionen.filter(istObjekt) : []
const plan = parseJson(doc.zahlungsplanJson)
auftrag.zahlungsplan = istObjekt(plan)
? {
art: ZAHLUNGSPLAN_ARTEN.includes(plan.art) ? plan.art : 'nach_abnahme',
prozent: typeof plan.prozent === 'number' && Number.isFinite(plan.prozent) ? plan.prozent : null,
text: typeof plan.text === 'string' ? plan.text : '',
}
: { art: 'nach_abnahme', prozent: null, text: '' }
return auftrag
}
function pruefeLaenge(feld, text, spalte) {
if (text.length > spalte.size) throw new AuftragError(400, `Das Feld ${feld} ist zu lang.`)
return text
}
/**
* Wandelt nur die in `auftrag` vorhandenen Schlüssel um (auch teilweise Objekte, für updateAuftrag): id, $… und
* fremde Schlüssel bleiben weg, undefined fehlt, null löscht (Text → '', Zahl und Datum → null).
*/
export function auftragToDoc(auftrag) {
const doc = {}
for (const [feld, wert] of Object.entries(auftrag)) {
const spalte = SPALTEN_NACH_FELD.get(feld)
if (!spalte || wert === undefined) continue
const name = spalte.key
if (JSON_SPALTEN[name]) {
doc[name] = wert === null ? '' : pruefeLaenge(feld, JSON.stringify(wert), spalte)
} else if (spalte.type === 'string') {
if (wert === null) doc[name] = ''
else if (typeof wert === 'string') doc[name] = pruefeLaenge(feld, wert, spalte)
else throw new TypeError(`Auftrag.${feld} muss Text sein.`)
} else if (spalte.type === 'integer') {
if (wert !== null && !Number.isSafeInteger(wert)) throw new TypeError(`Auftrag.${feld} muss eine ganze Zahl oder null sein.`)
doc[name] = wert
} else if (spalte.type === 'boolean') {
if (typeof wert !== 'boolean') throw new TypeError(`Auftrag.${feld} muss ein Wahrheitswert sein.`)
doc[name] = wert
} else if (spalte.type === 'datetime') {
if (wert === null) doc[name] = null
else {
const iso = typeof wert === 'string' && wert !== '' ? zeitpunkt(wert) : wert instanceof Date ? zeitpunkt(wert) : null
if (!iso) throw new TypeError(`Auftrag.${feld} muss ein Zeitpunkt oder null sein.`)
doc[name] = iso
}
}
}
return doc
}
const nichtGefunden = () => new AuftragError(404, 'Auftragsbestätigung nicht gefunden.')
export async function getAuftrag(id) {
if (!gueltigeId(id)) return null
try {
return docToAuftrag(await getDocument(config.collections.auftraege, id))
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
function filterAbfragen(filter, { text = [], zahl = [] }) {
const abfragen = []
for (const key of text) {
if (filter[key] === undefined) continue
if (typeof filter[key] !== 'string' || filter[key] === '') throw new TypeError(`Filter ${key} muss ein nicht leerer Text sein.`)
abfragen.push(Query.equal(key, filter[key]))
}
for (const key of zahl) {
if (filter[key] === undefined) continue
if (!Number.isSafeInteger(filter[key])) throw new TypeError(`Filter ${key} muss eine ganze Zahl sein.`)
abfragen.push(Query.equal(key, filter[key]))
}
return abfragen
}
/** Neueste zuerst. Ein angegebener, aber leerer oder ungültiger Filter ist ein TypeError, nie „alle“. */
export async function listAuftraege(filter = {}) {
const abfragen = filterAbfragen(filter, { text: ['kundenId', 'projektId'], zahl: ['jahr'] })
const docs = await listAllDocuments(config.collections.auftraege, abfragen)
return docs
.map(docToAuftrag)
.sort((a, b) => (b.erstelltAm || '').localeCompare(a.erstelltAm || '') || b.id.localeCompare(a.id))
}
export async function createAuftrag(daten) {
const jetzt = new Date().toISOString()
const doc = auftragToDoc({ ...daten, status: 'entwurf', erstelltAm: jetzt, geaendertAm: jetzt })
return docToAuftrag(await createDocument(config.collections.auftraege, doc))
}
export async function updateAuftrag(id, patch) {
if (!gueltigeId(id)) throw nichtGefunden()
const doc = auftragToDoc({ ...patch, geaendertAm: new Date().toISOString() })
try {
return docToAuftrag(await updateDocument(config.collections.auftraege, id, doc))
} catch (err) {
if (err?.status === 404) throw nichtGefunden()
throw err
}
}
export async function deleteAuftrag(id) {
if (!gueltigeId(id)) return
try {
await deleteDocument(config.collections.auftraege, id)
} catch (err) {
if (err?.status !== 404) throw err
}
}
async function findeEine(feld, wert) {
const docs = await listDocuments(config.collections.auftraege, [Query.equal(feld, wert), Query.limit(1)])
return docs.length ? docToAuftrag(docs[0]) : null
}
/** Nur ein 64-stelliger Kleinbuchstaben-Hex-Hash führt zu einer Abfrage (ein leerer Hash träfe jeden Entwurf). */
export async function findAuftragByTokenHash(hash) {
if (typeof hash !== 'string' || !TOKEN_HASH.test(hash)) return null
return findeEine('tokenHash', hash)
}
export async function findAuftragByNummer(nummer) {
if (typeof nummer !== 'string' || !AB_NUMMER.test(nummer)) return null
return findeEine('nummer', nummer)
}
+152
View File
@@ -0,0 +1,152 @@
// test/auftraegeAuftragStore.test.js
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
// Speicher der Auftragsbestätigungen. Nachbau von Appwrite mit dem Schema aus schema.js, Datumsfeldern wie in Appwrite
// ("+00:00") und dem Standard-Limit 25.
const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { docToAuftrag, auftragToDoc, getAuftrag, listAuftraege, createAuftrag, updateAuftrag, deleteAuftrag, findAuftragByTokenHash, findAuftragByNummer } =
await import('../server/services/auftraege/auftragStore.js')
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
const COL = config.collections.auftraege
beforeEach(async () => {
for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id)
})
const ENTWURF = {
kundenId: 'c1', projektId: 'p1', titel: 'Website Bäckerei', zeitquelle: ['t1'], art: 'festpreis',
positionen: [{ beschreibung: 'Startseite', menge: 1, einzelpreisCents: 50000 }],
summeCents: 50000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
auftragVom: '2026-10-05', auftragWeg: 'telefon', erstelltVon: 'kenso@webklar.com',
}
const FELDER = 'id nummer jahr laufnummer status kundenId kunde projektId titel zeitquelle art positionen stundensatzCents schaetzungMinuten summeCents zahlungsplan zahlungszielTage auftragVom auftragWeg startAm fertigBis ersetztId ersetztDurchId texteFassung agbFassung pdfDatei pdfSha256 gesendetAm gesendetAn mailStatus mailFehler tokenHash tokenGueltigBis bestaetigtAm bestaetigtUeber bestaetigtPdfSha256 fruehbeginn fruehbeginnFassung storniertAm widerrufenAm erstelltVon erstelltAm geaendertAm'
const HASH = 'a'.repeat(64)
const seed = (id, felder = {}) => aw.seed(COL, { $id: id, kundenId: 'c1', status: 'entwurf', ...felder })
test('createAuftrag legt einen Entwurf an: Status erzwungen, Zeitpunkte gesetzt, JSON-Felder serialisiert', async () => {
const vor = Date.now()
const auftrag = await createAuftrag({ ...ENTWURF, status: 'versendet' })
assert.deepEqual(Object.keys(auftrag).sort(), FELDER.split(' ').sort(), 'genau die 43 Felder aus V3')
assert.match(auftrag.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
assert.equal(auftrag.status, 'entwurf')
assert.deepEqual(
{ nummer: auftrag.nummer, jahr: auftrag.jahr, laufnummer: auftrag.laufnummer, kunde: auftrag.kunde, gesendetAm: auftrag.gesendetAm, mailStatus: auftrag.mailStatus, fruehbeginn: auftrag.fruehbeginn },
{ nummer: '', jahr: null, laufnummer: null, kunde: null, gesendetAm: null, mailStatus: '', fruehbeginn: false },
)
assert.deepEqual(auftrag.positionen, ENTWURF.positionen)
assert.deepEqual(auftrag.zahlungsplan, ENTWURF.zahlungsplan)
assert.deepEqual(auftrag.zeitquelle, ['t1'])
assert.deepEqual([auftrag.stundensatzCents, auftrag.schaetzungMinuten], [null, null])
assert.match(auftrag.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
assert.equal(auftrag.geaendertAm, auftrag.erstelltAm)
assert.ok(Date.parse(auftrag.erstelltAm) >= vor)
const doc = aw.docs(COL).get(auftrag.id)
assert.equal(doc.status, 'entwurf')
assert.equal(doc.positionenJson, JSON.stringify(ENTWURF.positionen))
assert.equal(doc.zeitquelleJson, '["t1"]')
assert.deepEqual(await getAuftrag(auftrag.id), auftrag)
})
test('docToAuftrag: fehlende Felder bekommen Vorgaben, kaputtes JSON wirft nie, Zeitpunkte werden zu ISO mit Z', () => {
const auftrag = docToAuftrag({
$id: 'x1', kundenId: 'c1', status: 'versendet', nummer: 'AB-2026-0003', jahr: 2026, laufnummer: 3,
kundeJson: '', positionenJson: '{kaputt', zeitquelleJson: '["t1","",5," t2 "]', zahlungsplanJson: '[1]',
gesendetAm: '2026-10-06T10:00:00.000+00:00', tokenGueltigBis: null, fruehbeginn: true, summeCents: 1000,
$createdAt: '2026-10-06T08:00:00.000+00:00', $updatedAt: '2026-10-06T09:00:00.000+00:00',
})
assert.equal(auftrag.id, 'x1')
assert.deepEqual([auftrag.nummer, auftrag.jahr, auftrag.laufnummer, auftrag.summeCents], ['AB-2026-0003', 2026, 3, 1000])
assert.deepEqual([auftrag.kunde, auftrag.positionen, auftrag.zeitquelle], [null, [], ['t1', 't2']])
assert.deepEqual(auftrag.zahlungsplan, { art: 'nach_abnahme', prozent: null, text: '' })
assert.equal(auftrag.zahlungszielTage, 14)
assert.deepEqual([auftrag.gesendetAm, auftrag.tokenGueltigBis, auftrag.bestaetigtAm], ['2026-10-06T10:00:00.000Z', null, null])
assert.equal(auftrag.fruehbeginn, true)
assert.deepEqual([auftrag.erstelltAm, auftrag.geaendertAm], ['2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z'])
})
test('auftragToDoc: nur Felder aus V1, JSON serialisiert, undefined fehlt, null löscht', () => {
const doc = auftragToDoc({
id: 'x1', $id: 'x1', fremd: 1, status: 'versendet', kunde: { name: 'Anna Beispiel', kundenart: 'privat' },
zeitquelle: ['t1'], positionen: [], zahlungsplan: { art: 'frei', prozent: null, text: 'x' },
gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, titel: undefined, tokenHash: null,
})
assert.deepEqual(doc, {
status: 'versendet', kundeJson: '{"name":"Anna Beispiel","kundenart":"privat"}', zeitquelleJson: '["t1"]', positionenJson: '[]',
zahlungsplanJson: '{"art":"frei","prozent":null,"text":"x"}', gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, tokenHash: '',
})
assert.equal(auftragToDoc({ kunde: null }).kundeJson, '')
})
test('auftragToDoc: ein Text über der Spaltengröße ist ein 400, ein falscher Typ ein Programmfehler', () => {
assert.throws(() => auftragToDoc({ titel: 'x'.repeat(201) }), (err) => err instanceof AuftragError && err.status === 400 && /titel/.test(err.message))
assert.throws(() => auftragToDoc({ kunde: { name: 'x'.repeat(2001) } }), (err) => err instanceof AuftragError && err.status === 400 && /kunde/.test(err.message))
assert.doesNotThrow(() => auftragToDoc({ titel: 'x'.repeat(200) }))
for (const falsch of [{ titel: 5 }, { summeCents: 1.5 }, { jahr: '2026' }, { fruehbeginn: 'ja' }, { gesendetAm: 'gestern' }, { gesendetAm: '' }]) {
assert.throws(() => auftragToDoc(falsch), TypeError, JSON.stringify(falsch))
}
})
test('getAuftrag, updateAuftrag, deleteAuftrag: unbekannte und unmögliche IDs sind null, 404 bzw. folgenlos', async () => {
seed('u1', { titel: 'Alt', summeCents: 1000, erstelltAm: '2020-01-01T00:00:00.000Z', geaendertAm: '2020-01-01T00:00:00.000Z' })
assert.equal((await getAuftrag('u1')).titel, 'Alt')
for (const id of ['gibtsnicht', '', '../x', 'a/b', undefined, null, 42, 'x'.repeat(37)]) assert.equal(await getAuftrag(id), null, String(id))
const auftrag = await updateAuftrag('u1', { titel: 'Neu', positionen: [{ beschreibung: 'A', menge: 2, einzelpreisCents: 500 }], status: 'versendet' })
assert.deepEqual([auftrag.titel, auftrag.summeCents, auftrag.status], ['Neu', 1000, 'versendet'])
assert.equal(auftrag.positionen[0].menge, 2)
assert.equal(auftrag.erstelltAm, '2020-01-01T00:00:00.000Z')
assert.ok(auftrag.geaendertAm > '2020-01-01T00:00:00.000Z')
assert.equal(aw.docs(COL).get('u1').titel, 'Neu')
for (const id of ['gibtsnicht', '../x']) {
await assert.rejects(() => updateAuftrag(id, { titel: 'x' }), (err) => err instanceof AuftragError && err.status === 404, id)
}
await deleteAuftrag('u1')
assert.equal(aw.docs(COL).size, 0)
await deleteAuftrag('u1')
await deleteAuftrag('../x')
})
test('listAuftraege: Filter, neueste zuerst, mehr als 25 Treffer', async () => {
seed('a1', { projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-01T10:00:00.000Z' })
seed('a2', { projektId: 'p2', jahr: 2026, erstelltAm: '2026-10-03T10:00:00.000Z' })
seed('a3', { projektId: 'p1', jahr: 2025, erstelltAm: '2026-10-02T10:00:00.000Z' })
seed('b1', { kundenId: 'c2', projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-04T10:00:00.000Z' })
const ids = async (filter) => (await listAuftraege(filter)).map((a) => a.id)
assert.deepEqual(await ids({ kundenId: 'c1' }), ['a2', 'a3', 'a1'])
assert.deepEqual(await ids({ kundenId: 'c1', projektId: 'p1' }), ['a3', 'a1'])
assert.deepEqual(await ids({ jahr: 2026 }), ['b1', 'a2', 'a1'])
assert.deepEqual(await ids(), ['b1', 'a2', 'a3', 'a1'])
assert.deepEqual(await ids({ kundenId: 'gibtsnicht' }), [])
for (let i = 0; i < 30; i += 1) seed(`m${String(i).padStart(2, '0')}`, { kundenId: 'c9', erstelltAm: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T10:00:00.000Z` })
assert.equal((await listAuftraege({ kundenId: 'c9' })).length, 30)
})
test('listAuftraege: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => {
seed('a1')
for (const filter of [{ kundenId: '' }, { kundenId: null }, { kundenId: 5 }, { projektId: '' }, { jahr: '2026' }, { jahr: 2026.5 }, { jahr: null }]) {
await assert.rejects(() => listAuftraege(filter), TypeError, JSON.stringify(filter))
}
})
test('findAuftragByTokenHash und findAuftragByNummer: genau eine AB, leere und ungültige Schlüssel nie', async () => {
seed('e1', { nummer: '', tokenHash: '' })
seed('e2')
seed('v1', { status: 'versendet', nummer: 'AB-2026-0001', tokenHash: HASH })
assert.equal((await findAuftragByTokenHash(HASH)).id, 'v1')
assert.equal((await findAuftragByNummer('AB-2026-0001')).id, 'v1')
assert.equal(await findAuftragByTokenHash('b'.repeat(64)), null)
assert.equal(await findAuftragByNummer('AB-2026-0002'), null)
for (const hash of ['', ' ', 'abc', 'A'.repeat(64), `${HASH}0`, undefined, null, 5]) assert.equal(await findAuftragByTokenHash(hash), null, String(hash))
for (const nummer of ['', 'Entwurf', 'AB-2026-1', 'ab-2026-0001', 'AB-2026-0001 ', undefined, null]) assert.equal(await findAuftragByNummer(nummer), null, String(nummer))
})