Gemeinsame Auswahl juengsteAufwandAb (gueltig, jueengste nach gesendetAm) im AB-Speicher. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
204 lines
8.8 KiB
JavaScript
204 lines
8.8 KiB
JavaScript
/**
|
|
* 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, VEREINBARTE_STATUS, 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)
|
|
}
|
|
|
|
const sendezeit = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0
|
|
|
|
/** Jüngste zuerst nach Sendezeitpunkt (ersatzweise Anlagezeit), bei Gleichstand nach Kennung. */
|
|
export const juengsteZuerst = (a, b) => sendezeit(b) - sendezeit(a) || String(b.id).localeCompare(String(a.id))
|
|
|
|
/**
|
|
* Die Aufwands-AB, mit der ein Projekt abgerechnet und angezeigt wird: unter den gültigen (versendet, bestätigt) die jüngste
|
|
* nach gesendetAm, sonst null. Projektkonto und Rechnungsbau benutzen beide diese eine Auswahl.
|
|
*/
|
|
export function juengsteAufwandAb(liste) {
|
|
return liste.filter((ab) => ab.art === 'aufwand' && VEREINBARTE_STATUS.includes(ab.status)).sort(juengsteZuerst)[0] ?? null
|
|
}
|