Files
ClaudeandClaude Sonnet 5.5 c2245b6905 Kontrolle: Projektkonto und Rechnungsbau waehlen dieselbe Aufwands-AB
Gemeinsame Auswahl juengsteAufwandAb (gueltig, jueengste nach gesendetAm) im AB-Speicher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:48 +00:00

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
}