Aufträge: Speicher der Auftragsbestätigungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e4feba4c9
commit
a3db7f310a
2 files changed
+342
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user