Terminplaner: CSV-Export seitenweise gestreamt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a1732613c9
commit
d040a55f89
3 files changed
+187
-23
No files matched your search
+54
-23
@@ -8,7 +8,7 @@ import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
|
||||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||
import { verschiebeZeiten } from '../services/termine/ablauf.js'
|
||||
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
|
||||
import { hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
|
||||
import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
|
||||
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
|
||||
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
|
||||
import { zustellFehler } from '../services/termine/outbox.js'
|
||||
@@ -447,34 +447,65 @@ function csvZelle(wert) {
|
||||
return /[;"\r\n]/.test(zelle) ? `"${zelle.replace(/"/g, '""')}"` : zelle
|
||||
}
|
||||
|
||||
/** Eine Datenzeile der CSV-Datei (ohne Zeilenende). */
|
||||
function csvZeile(b) {
|
||||
const start = new Date(b.startsAt)
|
||||
return [
|
||||
buchungsNummer(b.$id),
|
||||
STATUS_TEXT[b.status] ?? b.status,
|
||||
datumKurz(start),
|
||||
uhrzeit(start),
|
||||
b.leistungName,
|
||||
b.ressourceName,
|
||||
b.gastName,
|
||||
b.gastEmail,
|
||||
b.gastTelefon,
|
||||
b.gastNachricht,
|
||||
].map(csvZelle).join(';')
|
||||
}
|
||||
|
||||
/** Schreibt einen Teil der Antwort; ist der Puffer voll, wartet es auf 'drain' (oder das Ende der Verbindung), damit ein langsamer Browser den Speicher nicht füllt. */
|
||||
function schreibe(res, text) {
|
||||
if (text === '' || res.destroyed || res.write(text)) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const weiter = () => {
|
||||
res.off('drain', weiter)
|
||||
res.off('close', weiter)
|
||||
resolve()
|
||||
}
|
||||
res.once('drain', weiter)
|
||||
res.once('close', weiter)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV-Export, seitenweise gestreamt: je Seite höchstens EXPORT_SEITE Zeilen (nach Beginn, Cursor = letzte Zeile der
|
||||
* ungefilterten Seite), Sperren werden je Seite herausgefiltert. Die erste Seite wird gelesen, bevor ein Header geht: ein Fehler
|
||||
* dort ist ein normales JSON-500. Danach bricht ein Fehler die Verbindung ab (res.destroy), damit der Browser einen abgebrochenen
|
||||
* Download statt einer still gekürzten Datei zeigt. Löscht der Tagesjob zwischen zwei Seiten genau die Buchung, die als Cursor
|
||||
* dient (selten), lehnt Appwrite den Cursor ab: der Export bricht ab und wird neu geladen.
|
||||
*/
|
||||
router.get('/export.csv', requireSession, withTermine(), route(async (req, res) => {
|
||||
const ctx = req.ctx
|
||||
// alle Buchungen der Funktion (die Aufbewahrungsfristen halten die Menge klein); keine Sperren
|
||||
const liste = (await imZeitraum(ctx.feature.$id, new Date(0), new Date('2100-01-01T00:00:00.000Z')))
|
||||
.filter((b) => b.quelle !== 'sperre')
|
||||
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
|
||||
const zeilen = [CSV_KOPF, ...liste.map((b) => {
|
||||
const start = new Date(b.startsAt)
|
||||
return [
|
||||
buchungsNummer(b.$id),
|
||||
STATUS_TEXT[b.status] ?? b.status,
|
||||
datumKurz(start),
|
||||
uhrzeit(start),
|
||||
b.leistungName,
|
||||
b.ressourceName,
|
||||
b.gastName,
|
||||
b.gastEmail,
|
||||
b.gastTelefon,
|
||||
b.gastNachricht,
|
||||
]
|
||||
})]
|
||||
const csv = `\uFEFF${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
|
||||
const featureId = req.ctx.feature.$id
|
||||
let seite = await exportSeite(featureId)
|
||||
res.set({
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="termine.csv"',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
})
|
||||
res.send(csv)
|
||||
try {
|
||||
await schreibe(res, `\uFEFF${CSV_KOPF.map(csvZelle).join(';')}\r\n`)
|
||||
for (;;) {
|
||||
await schreibe(res, seite.filter((b) => b.quelle !== 'sperre').map((b) => `${csvZeile(b)}\r\n`).join(''))
|
||||
if (seite.length < EXPORT_SEITE || res.destroyed) break
|
||||
seite = await exportSeite(featureId, { nach: seite.at(-1).$id })
|
||||
}
|
||||
res.end()
|
||||
} catch (err) {
|
||||
// nach dem ersten Byte geht kein JSON mehr; ins Log nur Art und Status, nie den Text (er könnte Gastdaten enthalten)
|
||||
console.error(`[termine] Export abgebrochen: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.type ?? ''}`.trim())
|
||||
res.destroy(err)
|
||||
}
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -222,6 +222,28 @@ export async function ersteBuchungen(featureId, limit = LOESCH_SEITE) {
|
||||
return listDocuments(col(), [Query.equal('featureId', featureId), Query.limit(limit), Query.select(['$id'])])
|
||||
}
|
||||
|
||||
/** Zeilen je Seite des CSV-Exports (exportSeite). */
|
||||
export const EXPORT_SEITE = 500
|
||||
|
||||
/**
|
||||
* Eine Seite Buchungen einer Funktion fuer den CSV-Export (jeder Status, auch Sperren; die filtert der Aufrufer je Seite
|
||||
* heraus): hoechstens `limit` (Standard 500), nach Beginn (startsAt) aufsteigend, ab dem Dokument `nach` (dessen ID, null =
|
||||
* erste Seite). Der Cursor ist immer die letzte Zeile der ungefilterten Seite. Sortiert wird nur nach startsAt (Index
|
||||
* idx_feature_start; Appwrite haengt die interne Reihenfolge fuer gleiche Beginne selbst an), nie nach $id.
|
||||
* Ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze: TypeError (nie eine Abfrage ohne Funktionsfilter: Gastdaten).
|
||||
*/
|
||||
export async function exportSeite(featureId, { nach = null, limit = EXPORT_SEITE } = {}) {
|
||||
if (typeof featureId !== 'string' || featureId === '') throw new TypeError('exportSeite: featureId muss ein nicht leerer Text sein')
|
||||
if (nach !== null && (typeof nach !== 'string' || nach === '')) throw new TypeError('exportSeite: nach muss null oder eine Dokument-ID sein')
|
||||
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`exportSeite: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`)
|
||||
return listDocuments(col(), [
|
||||
Query.equal('featureId', featureId),
|
||||
Query.orderAsc('startsAt'),
|
||||
Query.limit(limit),
|
||||
...(nach === null ? [] : [Query.cursorAfter(nach)]),
|
||||
])
|
||||
}
|
||||
|
||||
/** Zahl der offenen Anfragen (Status angefragt) einer Funktion. */
|
||||
export async function zaehleAnfragen(featureId) {
|
||||
return (await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('status', 'angefragt')])).length
|
||||
|
||||
Reference in new issue
Block a user