Files
Webklar-Kundenbereich/server/services/termine/jobs.js
T
ClaudeandClaude Sonnet 5.5 91e7f7d8a3 Tagesjob: Abo-Abgleich vor der Löschung, auch bei TERMINE_ENABLED=off mit bezahlten Dokumenten
taeglicherJob ruft gleicheAboAb(now) nach den Löschfristen und vor der Löschung
abgeschalteter Funktionen auf (Schritt „Abo-Abgleich“, ein Fehler dort stoppt weder
Löschung noch Rest; die Fehler des Abgleichs zählen in fehler). Das Ergebnis trägt
aboAbgleich, tagesbericht hängt „Abo-Abgleich N geprüft / N abgeschaltet / N wieder ein /
N Mails / N ausgenommen“ vor „N Fehler“ an.

fuehreAus nimmt trotzAus: Im Modus off läuft der Tagesjob nur, wenn es ein vom Kauf
freigeschaltetes Termine-Dokument gibt (eine Abfrage, limit 1; bei einem Lesefehler
Warnung und { aus: true }). Dann läuft ein eingeschränkter Lauf (eingeschraenkt: true,
gleiche Überlappungssperre): nur Abo-Abgleich und Löschung abgeschalteter Funktionen,
mit eigener Berichtszeile und den Feldern, die meldeTagesjob und die Statusroute lesen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 17:45:48 +00:00

825 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { config } from '../../config.js'
import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
import { gleicheAboAb } from './aboAbgleich.js'
import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js'
import {
abgelaufeneHaltungen,
aendere,
erinnerungsKandidaten,
ersteBuchungen,
hole,
loesche,
loeschBezugsfeld,
loeschKandidaten,
vergangeneAnfragen,
zuErledigen,
} from './buchungen.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
import { termineAus } from './schalter.js'
import { buchungsNummer } from './token.js'
import { pruefeNow, zeitMs } from './util.js'
import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
/**
* Hintergrund-Jobs des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 11). server/index.js
* ruft sie mit new Date() auf; `now` ist hier immer ein Parameter.
*
* minutenJob jede Minute zwei Teile, gemeinsam gestartet (Promise.all) und je Teil gegen Ueberlappung geschuetzt:
* Verfall: abgelaufene Haltungen => verfallen (Zellen frei);
* Mailversand: Mail-Warteschlange senden, zuletzt die Betriebspruefung (Budget, Warteschlange,
* Anmeldepause => Log und Team-Mail, betrieb.js)
* fuenfMinutenJob alle 5 Minuten Erinnerungen (G05); bestaetigt 1 Stunde nach Ende => erledigt; Anfragen mit Beginn in
* der Vergangenheit => abgelehnt (vom System, Zellen frei, keine Mail)
* taeglicherJob taeglich Loeschfristen (11), Abo-Abgleich (aboAbgleich.js: der Terminplaner endet mit dem Wartungs-Abo),
* abgeschaltete Funktionen, festgefahrene und alte Mails, verwaiste Zellen; der Abgleich laeuft VOR
* der Loeschung abgeschalteter Funktionen, damit ein Terminplaner, dessen Abo heute zurueckkehrt,
* nicht mehr geloescht wird; zuletzt die aktiven Betriebe zaehlen und den Lauf melden (Fehler,
* Loeschdeckel in Folge, ab 20 Betrieben => Log und Team-Mail, meldeTagesjob in betrieb.js)
*
* Gemeinsame Regeln:
* - Modus off: Minuten- und Fuenf-Minuten-Job kehren sofort zurueck, ohne Abfrage, mit { aus: true }. Der Tagesjob stellt eine einzige
* Frage (Guard: gibt es ein vom Kauf freigeschaltetes Termine-Dokument, unlockedByPurchase true?); ohne bezahltes Dokument oder bei
* einem Lesefehler (Warnung) kehrt auch er mit { aus: true } zurueck. Mit bezahltem Dokument laeuft ein eingeschraenkter Tagesjob
* (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei
* TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen
* bleiben aus.
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
* Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der
* naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind
* (sonst steht verfallUebersprungen bzw. mailUebersprungen im Ergebnis). Die Warnung nennt den Teil (`minutenJob (Verfall)`), das
* Fehlerlog behaelt `minutenJob` als Jobnamen.
* - Ein Schritt eines Jobs scheitert nie den ganzen Job: der Fehler steht im Log (Text des Fehlers, bei einer Buchung mit
* Buchungsnummer, nie mit Gastdaten), `fehler` im Ergebnis zaehlt ihn, die uebrigen Schritte laufen. Eine fehlende Collection
* (404 collection_not_found, Einrichtung unvollstaendig) meldet ein Job hoechstens einmal je Stunde.
* - Jede Zustandsaenderung einer Buchung laeuft unter `termine-buchung:<id>` mit frischem Lesen und erneuter Pruefung der
* Bedingung (Verfall nur, solange noch `gehalten` mit heldUntil <= jetzt usw.): ein Gast, der gerade bestaetigt, und der Job
* ergeben genau einen Endzustand. Mails gehen erst nach der Aenderung und ausserhalb der Sperre in die Warteschlange.
* - Zellen gibt nur zellen.js frei (gibZellenFrei / raeumeVerwaisteZellen, jeweils unter der Ressourcensperre).
* - Loeschlaeufe (Aufbewahrung) arbeiten seitenweise (arbeiteSeiten): eine Seite hoechstens 100 Buchungen ohne Gastfelder,
* Frist in der Abfrage, danach neu lesen. Ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen je Lauf (erledigt
* und nichterschienen zusammen je Funktion), alle Schritte eines Laufs zusammen hoechstens config.termine.loeschDeckelGesamt;
* der Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. Ebenso die Loeschung abgeschalteter
* Funktionen: je Funktion Seiten zu 100, hoechstens config.termine.loeschDeckel je Lauf, die Einstellung erst nach einer leeren Seite.
* Ebenso der Zellen-Sweep (raeumeVerwaisteZellen): Zellen mit Ende vor mehr als einem Tag, Seiten zu 100 ueber idx_bis, hoechstens
* config.termine.zellenDeckel je Lauf; am Deckel zaehlt `gedeckelt.zellen`.
*/
const MIN_MS = 60 * 1000
const STUNDE_MS = 60 * MIN_MS
const TAG_MS = 24 * STUNDE_MS
const VERFALLEN_BEHALTEN_MS = 48 * STUNDE_MS
const ABGESAGT_BEHALTEN_MS = 30 * TAG_MS
const ABGESCHALTET_BEHALTEN_MS = 30 * TAG_MS
/** Aufbewahrung von erledigt/nichterschienen: Vorgabe und erlaubter Bereich (Tage nach Terminende). */
const AUFBEWAHRUNG_VORGABE = 90
const AUFBEWAHRUNG_MIN = 14
const AUFBEWAHRUNG_MAX = 365
const laufend = new Set()
const ueberlappGemeldet = new Set()
/** Zeitpunkt (ms) der letzten Warnung wegen fehlender Collection je Job. */
const letzteWarnung = new Map()
const fehlendeCollection = (err) => err?.status === 404 && (err.type === 'collection_not_found' || err.type === 'database_not_found')
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`
/** Fehler eines Schritts ins Log (ohne Gastdaten) und in die Zaehlung; fehlende Collection hoechstens einmal je Stunde und Job. */
function melde(lauf, was, err, bookingId = null) {
lauf.fehler += 1
if (fehlendeCollection(err)) {
const zuletzt = letzteWarnung.get(lauf.job)
if (zuletzt !== undefined && lauf.nowMs - zuletzt < STUNDE_MS) return
letzteWarnung.set(lauf.job, lauf.nowMs)
console.warn(`[termine-jobs] ${lauf.job}, Schritt „${was}“: ${err.type}, die Termine-Einrichtung ist unvollständig (Warnung höchstens einmal je Stunde)`)
return
}
const nummer = bookingId ? ` (${buchungsNummer(bookingId)})` : ''
console.error(`[termine-jobs] ${lauf.job}: ${was}${nummer}: ${fehlerText(err)}`)
}
/** Fuehrt einen Schritt aus; ein Fehler wird gemeldet und ergibt `ersatz`. */
async function schritt(lauf, was, fn, ersatz) {
try {
return await fn()
} catch (err) {
melde(lauf, was, err)
return ersatz
}
}
/**
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
* `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der
* Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection).
* `trotzAus` (async, => wahr/falsch): im Modus off laeuft der Job nur, wenn es wahr liefert; dann steht `lauf.eingeschraenkt` auf true
* und der Job beschraenkt sich auf das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen)
* und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre
* gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen.
*/
async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) {
const nowMs = pruefeNow(now, job)
let eingeschraenkt = false
if (termineAus()) {
if (trotzAus === null) return { aus: true }
try {
if (!(await trotzAus())) return { aus: true }
} catch (err) {
console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`)
return { aus: true }
}
eingeschraenkt = true
}
const sperre = teil === null ? job : `${job}:${teil}`
if (laufend.has(sperre)) {
if (!ueberlappGemeldet.has(sperre)) {
ueberlappGemeldet.add(sperre)
console.warn(`[termine-jobs] ${teil === null ? job : `${job} (${teil})`}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
}
return { uebersprungen: true }
}
laufend.add(sperre)
try {
return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt })
} finally {
laufend.delete(sperre)
ueberlappGemeldet.delete(sperre)
}
}
/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 })
/**
* Eine Buchung unter ihrer Sperre, frisch gelesen: fn(frisch) laeuft nur, wenn es sie noch gibt.
* => Ergebnis von fn | false (Buchung fehlt) | undefined (Fehler, im Log; die Liste laeuft weiter)
*/
async function mitFrischer(lauf, was, id, fn) {
try {
return await withLocks([`termine-buchung:${id}`], async () => {
const frisch = await hole(id)
return frisch ? fn(frisch) : false
})
} catch (err) {
melde(lauf, was, err, id)
return undefined
}
}
/**
* Seitenweise Arbeit ohne Cursor: Seite lesen (holeSeite), jeden Eintrag bearbeiten (bearbeite), Liste neu lesen – was bearbeitet
* ist, fehlt dann. Gedacht fuer Loeschlaeufe (Aufbewahrung, abgeschaltete Funktionen, Zellen), die wegen der Zahl der Betriebe nie
* alles auf einmal lesen duerfen. Der Lauf endet
* - bei einer leeren Seite (alles erledigt),
* - am Deckel: sind `deckel` Eintraege geloescht und es kommt noch einer, zaehlt lauf.gedeckelt, eine Warnung ohne Kennungen
* steht im Log und der Rest folgt im naechsten Lauf (`deckel` 0 = nur nachsehen, ob noch etwas ansteht: eine leere Seite endet
* ohne Zaehlung, sonst zaehlt es als Deckel); `deckelText` benennt den Deckel in der Warnung,
* - bei einer Seite ohne einen bearbeiteten Eintrag (alle gescheitert oder schon versucht): die Fehler stehen im Log; eine Menge
* merkt die versuchten IDs, damit ein Eintrag, der sich nicht bearbeiten laesst, keinen Endlosdurchlauf ausloest.
* holeSeite() => Eintraege mit $id (hoechstens eine Seite); bearbeite(eintrag) => true (geloescht, zaehlt zum Deckel) | false (nichts
* zu tun, z. B. inzwischen anders oder weg: kein Fehler) | undefined (Fehler, schon gemeldet). Wirft holeSeite, steht der Fehler im
* Log und der Lauf endet unvollstaendig.
* => { geloescht, gedeckelt (am Deckel beendet), vollstaendig (leere Seite erreicht und kein Fehler in diesem Lauf) }
*/
async function arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel, was = 'Löschung', deckelText = `von ${deckel} je Lauf` }) {
if (!Number.isInteger(deckel) || deckel < 0) throw new TypeError(`arbeiteSeiten: deckel muss eine ganze Zahl ab 0 sein, nicht ${deckel}`)
const versucht = new Set()
let geloescht = 0
let fehlerBeiEintrag = false
for (;;) {
let seite
try {
seite = await holeSeite()
} catch (err) {
melde(lauf, `${was}: Liste nicht gelesen`, err)
return { geloescht, gedeckelt: false, vollstaendig: false }
}
if (seite.length === 0) return { geloescht, gedeckelt: false, vollstaendig: !fehlerBeiEintrag }
let bearbeitet = 0
for (const eintrag of seite) {
if (versucht.has(eintrag.$id)) continue
if (geloescht >= deckel) {
lauf.gedeckelt += 1
console.warn(`[termine-jobs] ${lauf.job}: ${was} endet am Deckel ${deckelText}, der Rest folgt an den nächsten Tagen`)
return { geloescht, gedeckelt: true, vollstaendig: false }
}
versucht.add(eintrag.$id)
let ergebnis
try {
ergebnis = await bearbeite(eintrag)
} catch (err) {
melde(lauf, was, err)
ergebnis = undefined
}
if (ergebnis === undefined) {
fehlerBeiEintrag = true
continue
}
bearbeitet += 1
if (ergebnis) geloescht += 1
}
if (bearbeitet === 0) return { geloescht, gedeckelt: false, vollstaendig: false }
}
}
// ---------------------------------------------------------------- Minutenjob
/** Abgelaufene Haltungen: verfallen und Zellen frei – nur, wenn die Buchung unter der Sperre noch `gehalten` und abgelaufen ist. */
async function verfalleHaltungen(lauf, now) {
let verfallen = 0
for (const kandidat of await abgelaufeneHaltungen(now)) {
const ok = await mitFrischer(lauf, 'Haltung nicht verfallen', kandidat.$id, async (frisch) => {
if (frisch.status !== 'gehalten' || !(zeitMs(frisch.heldUntil) <= lauf.nowMs)) return false
await aendere(frisch.$id, { status: 'verfallen', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return true
})
if (ok) verfallen += 1
}
return verfallen
}
/**
* Jede Minute, in zwei unabhaengig geschuetzten Teilen, die gemeinsam starten (Promise.all):
* - Verfall: abgelaufene Haltungen verfallen lassen;
* - Mailversand: Warteschlange senden, zuletzt den Betrieb pruefen (Vorfaelle stehen im Log und gehen als T20 ans Team; die Pruefung
* wirft nie und aendert das Ergebnis des Laufs nicht).
* Haengt ein Teil, wird nur dieser im naechsten Takt uebersprungen (verfallUebersprungen bzw. mailUebersprungen im Ergebnis), der
* andere laeuft weiter. { uebersprungen: true } kommt erst, wenn beide Teile uebersprungen werden.
* @param {Date} now
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number, verfallUebersprungen?: true, mailUebersprungen?: true } | { aus: true } | { uebersprungen: true }>}
*/
export async function minutenJob(now) {
const [verfall, mail] = await Promise.all([
fuehreAus(
'minutenJob',
now,
async (lauf) => {
const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0)
return { verfallen, fehler: lauf.fehler }
},
{ teil: 'Verfall' },
),
fuehreAus(
'minutenJob',
now,
async (lauf) => {
const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 })
// Zuletzt: die Pruefung sieht den Stand nach dem Versand (ein eben gesendeter Eintrag staut nichts mehr)
await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), [])
return { faellig, gesendet, fehler: lauf.fehler }
},
{ teil: 'Mailversand' },
),
])
if (verfall.aus || mail.aus) return { aus: true }
if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true }
return {
verfallen: verfall.verfallen ?? 0,
faellig: mail.faellig ?? 0,
gesendet: mail.gesendet ?? 0,
fehler: (verfall.fehler ?? 0) + (mail.fehler ?? 0),
...(verfall.uebersprungen ? { verfallUebersprungen: true } : {}),
...(mail.uebersprungen ? { mailUebersprungen: true } : {}),
}
}
// ---------------------------------------------------------------- Fuenf-Minuten-Job
/**
* Ist die Erinnerung jetzt zu senden? bestaetigt, Wunsch, kein Marker, Adresse da, Beginn in (jetzt + 1 h, jetzt + stundenVorher]
* und Vertragsschluss VOR Beginn - stundenVorher (wer erst danach gebucht hat, bekommt keine). Ohne Vertragsschluss nein.
*/
function erinnerungFaellig(buchung, stundenVorher, nowMs) {
if (buchung.status !== 'bestaetigt' || buchung.erinnerung !== true || buchung.erinnerungGesendetAt) return false
if (typeof buchung.gastEmail !== 'string' || buchung.gastEmail === '') return false
const start = zeitMs(buchung.startsAt)
const fruehestens = start - stundenVorher * STUNDE_MS
if (!(start > nowMs + STUNDE_MS && fruehestens <= nowMs)) return false
return vertragAm(buchung) < fruehestens
}
/**
* Erinnerungen: unter der Buchungssperre frisch lesen und pruefen, erst den Marker erinnerungGesendetAt setzen, dann (ausserhalb
* der Sperre) G05 einreihen. Scheitert das Einreihen, bleibt der Marker: eine Erinnerung geht hoechstens einmal raus.
* Der Kontext einer Funktion (Einstellung mit stundenVorher) wird je Lauf einmal gelesen. Ohne Funktion oder Einstellung: keine
* Erinnerung, kein Marker.
*/
async function sendeErinnerungen(lauf, now) {
const kontexte = new Map()
const kontext = (featureId) => {
if (!kontexte.has(featureId)) {
kontexte.set(
featureId,
kontextPerFeature(featureId).catch((err) => {
melde(lauf, 'Funktion nicht gelesen', err)
return null
}),
)
}
return kontexte.get(featureId)
}
let gesendet = 0
for (const kandidat of await erinnerungsKandidaten(now)) {
const id = kandidat.$id
const ctx = await kontext(kandidat.featureId)
const stundenVorher = ctx?.einstellung?.erinnerung?.stundenVorher
if (!Number.isInteger(stundenVorher) || stundenVorher < 1) continue
if (!erinnerungFaellig(kandidat, stundenVorher, lauf.nowMs)) continue
const markiert = await mitFrischer(lauf, 'Erinnerung nicht vermerkt', id, async (frisch) =>
erinnerungFaellig(frisch, stundenVorher, lauf.nowMs) ? aendere(id, { erinnerungGesendetAt: iso(now), updatedAt: iso(now) }) : false,
)
if (!markiert) continue
try {
await anGast('G05', ctx, markiert, {}, now)
gesendet += 1
} catch (err) {
melde(lauf, 'G05 nicht eingereiht', err, id)
}
}
return gesendet
}
/** bestaetigt mit Ende vor mehr als 1 Stunde (auch Sperren) => erledigt. Zellen bleiben (vergangen, der Tagesjob raeumt sie). */
async function erledige(lauf, now) {
let erledigt = 0
for (const kandidat of await zuErledigen(now)) {
const ok = await mitFrischer(lauf, 'Termin nicht erledigt', kandidat.$id, async (frisch) => {
if (frisch.status !== 'bestaetigt' || !(zeitMs(frisch.endsAt) < lauf.nowMs - STUNDE_MS)) return false
await aendere(frisch.$id, { status: 'erledigt', updatedAt: iso(now) })
return true
})
if (ok) erledigt += 1
}
return erledigt
}
/** angefragt mit Beginn in der Vergangenheit => abgelehnt vom System, Zellen frei, keine Mail. */
async function lehneVergangeneAnfragenAb(lauf, now) {
let abgelehnt = 0
for (const kandidat of await vergangeneAnfragen(now)) {
const ok = await mitFrischer(lauf, 'Anfrage nicht abgelehnt', kandidat.$id, async (frisch) => {
if (frisch.status !== 'angefragt' || !(zeitMs(frisch.startsAt) < lauf.nowMs)) return false
await aendere(frisch.$id, { status: 'abgelehnt', abgesagtVon: 'system', updatedAt: iso(now) })
await gibFreiSicher(frisch.$id)
return true
})
if (ok) abgelehnt += 1
}
return abgelehnt
}
/**
* Alle 5 Minuten: Erinnerungen, erledigte Termine, vergangene Anfragen.
* @param {Date} now
* @returns {Promise<{ erinnerungen: number, erledigt: number, abgelehnt: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
*/
export async function fuenfMinutenJob(now) {
return fuehreAus('fuenfMinutenJob', now, async (lauf) => {
const erinnerungen = await schritt(lauf, 'Erinnerungen', () => sendeErinnerungen(lauf, now), 0)
const erledigt = await schritt(lauf, 'Erledigt', () => erledige(lauf, now), 0)
const abgelehnt = await schritt(lauf, 'Vergangene Anfragen', () => lehneVergangeneAnfragenAb(lauf, now), 0)
return { erinnerungen, erledigt, abgelehnt, fehler: lauf.fehler }
})
}
// ---------------------------------------------------------------- Tagesjob
/**
* Aufbewahrung (Tage nach Terminende) einer Funktion. Nicht gespeichert oder unlesbar (null aus ladeEinstellung) oder ausserhalb
* 14-365: 90. Scheitert das Lesen selbst (Datenbank), null: die Buchungen dieser Funktion bleiben bis zum naechsten Lauf, es wird
* nie frueher geloescht als eingestellt.
*/
async function aufbewahrungTage(lauf, featureId) {
if (typeof featureId !== 'string' || featureId === '') return AUFBEWAHRUNG_VORGABE
try {
const tage = (await ladeEinstellung(featureId))?.einstellung?.aufbewahrungTage
return Number.isInteger(tage) && tage >= AUFBEWAHRUNG_MIN && tage <= AUFBEWAHRUNG_MAX ? tage : AUFBEWAHRUNG_VORGABE
} catch (err) {
melde(lauf, 'Aufbewahrung nicht lesbar, Funktion bleibt unberührt', err)
return null
}
}
/**
* Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). Mit `frist`
* ({ feld, vor } in ms) muss ausserdem die frisch gelesene Bezugszeit noch vor `vor` liegen (zweiter Boden gegen eine Liste, die
* seit dem Lesen veraltet ist). => true | false | undefined (Fehler)
*/
function loescheMitZellen(lauf, id, status, frist = null) {
return mitFrischer(lauf, 'Buchung nicht gelöscht', id, async (frisch) => {
if (status !== null && frisch.status !== status) return false
if (frist !== null && !(zeitMs(frisch[frist.feld]) < frist.vor)) return false
await gibZellenFrei(id)
await loesche(id)
return true
})
}
/**
* Aufbewahrungs-Loeschung (Spezifikation 11) fuer Buchungen in drei Durchgaengen, jeweils seitenweise (arbeiteSeiten):
* (1) verfallen (48 Stunden nach heldUntil), abgesagt und abgelehnt (30 Tage nach der letzten Aenderung) ueber alle Funktionen,
* (2) erledigt und nichterschienen je Funktion (portalFeatures featureKey termine, active oder disabled; nur die IDs werden
* gelesen) nach deren aufbewahrungTage (Vorgabe 90). Ist die Einstellung einer Funktion nicht lesbar, bleibt sie in diesem
* Lauf unberuehrt (nie frueher loeschen als eingestellt),
* (3) Sicherheitsnetz: erledigt und nichterschienen ohne Funktionsfilter nach 365 Tagen, der groessten erlaubten Aufbewahrung.
* Es faengt Buchungen ohne Funktionsdokument und Funktionen mit unlesbarer Einstellung spaetestens dann (Buchungen ohne
* Funktionsdokument bleiben bis dahin, Waisen gibt es in Produktion nicht).
* Die Frist steht in der Abfrage; unter der Buchungssperre wird Status und Bezugszeit noch einmal frisch geprueft (zweiter Boden).
* Deckel: ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen (erledigt und nichterschienen einer Funktion
* zusammen), alle Schritte des Laufs zusammen hoechstens config.termine.loeschDeckelGesamt. Jeder Schritt, der daran endet, zaehlt
* in `gedeckelt` (Warnung ohne Kennungen im Log); der Rest folgt im naechsten Lauf. Endet ein Schritt am Gesamtdeckel, ist der Lauf
* zu Ende: die uebrigen Schritte folgen am naechsten Tag. Ist der Gesamtdeckel genau erreicht, sieht jeder weitere Schritt nur nach,
* ob noch etwas ansteht (leere Seite = nicht gedeckelt).
* => Loeschzaehler je Status und `gedeckelt` = Zahl der Schritte, die am Deckel endeten
*/
async function loeschfristen(lauf, now) {
const geloescht = { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 }
const deckelJeSchritt = config.termine.loeschDeckel
const deckelGesamt = config.termine.loeschDeckelGesamt
let gedeckelt = 0
let rest = deckelGesamt // was vom Gesamtdeckel noch uebrig ist
let amGesamtdeckel = false // ein Schritt endete am Gesamtdeckel: keine weiteren Schritte in diesem Lauf
/**
* Ein Loeschschritt fuer Buchungen eines Status mit Bezugszeit vor `vor` (ms), optional nur einer Funktion, unter beiden Deckeln:
* hoechstens `eigener`, hoechstens der Rest des Gesamtdeckels. => { geloescht, gedeckelt }
*/
const loescheSeiten = async (was, status, eigener, eigenerText, vor, featureId = null) => {
if (amGesamtdeckel) return { geloescht: 0, gedeckelt: false }
const amGesamt = rest <= eigener
const frist = { feld: loeschBezugsfeld(status), vor }
const erg = await schritt(
lauf,
was,
() =>
arbeiteSeiten(lauf, {
was,
deckel: amGesamt ? rest : eigener,
deckelText: amGesamt ? `von ${deckelGesamt} für alle Schritte des Laufs zusammen (Gesamtdeckel)` : eigenerText,
holeSeite: () => loeschKandidaten(status, new Date(vor), featureId === null ? {} : { featureId }),
bearbeite: async (kandidat) => {
const weg = await loescheMitZellen(lauf, kandidat.$id, status, frist)
if (weg) geloescht[status] += 1
return weg
},
}),
{ geloescht: 0, gedeckelt: false },
)
rest -= erg.geloescht
if (erg.gedeckelt) {
gedeckelt += 1
if (amGesamt) amGesamtdeckel = true
}
return erg
}
// Durchgang (1): verfallen, abgesagt, abgelehnt
for (const [status, behalten] of [
['verfallen', VERFALLEN_BEHALTEN_MS],
['abgesagt', ABGESAGT_BEHALTEN_MS],
['abgelehnt', ABGESAGT_BEHALTEN_MS],
]) {
await loescheSeiten(`Löschfrist ${status}`, status, deckelJeSchritt, undefined, lauf.nowMs - behalten)
}
// Durchgang (2): erledigt/nichterschienen je Funktion nach deren Aufbewahrung, die beiden Status teilen sich den Deckel der Funktion
const funktionen = amGesamtdeckel
? []
: await schritt(
lauf,
'Löschfrist: Funktionen nicht gelesen',
() => listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', ['active', 'disabled']), Query.select(['$id'])]),
[],
)
for (const { $id: featureId } of funktionen) {
if (amGesamtdeckel) break
const tage = await aufbewahrungTage(lauf, featureId)
if (tage === null) continue
const vor = lauf.nowMs - tage * TAG_MS
let geloeschtJeFunktion = 0
for (const status of ['erledigt', 'nichterschienen']) {
const erg = await loescheSeiten(`Löschfrist ${status} (je Funktion)`, status, deckelJeSchritt - geloeschtJeFunktion, `von ${deckelJeSchritt} je Funktion`, vor, featureId)
geloeschtJeFunktion += erg.geloescht
if (erg.gedeckelt) break // die Funktion endete am Deckel, einmal gezaehlt
}
}
// Durchgang (3): Sicherheitsnetz, die groesste erlaubte Aufbewahrung gilt fuer jede Buchung, auch ohne lesbare Funktion
for (const status of ['erledigt', 'nichterschienen']) {
await loescheSeiten(`Löschfrist ${status} (Sicherheitsnetz)`, status, deckelJeSchritt, undefined, lauf.nowMs - AUFBEWAHRUNG_MAX * TAG_MS)
}
return { ...geloescht, gedeckelt }
}
/** So viele Buchungen einer abgeschalteten Funktion hoechstens, bevor ihr Zustand erneut gelesen wird. */
const ABGESCHALTET_NEU_PRUEFEN_ALLE = 50
/**
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Loeschschritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate sperrt nicht; es leert disabledAt): featureKey termine, state disabled
* und disabledAt vor der Grenze. Sonst false mit Log, ohne Wurf – auch bei einem Lesefehler: im Zweifel wird nichts geloescht.
*/
async function nochAbgeschaltet(lauf, funktionId, grenze) {
try {
const frisch = await getDocument(config.collections.portalFeatures, funktionId)
if (frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) return true
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) {
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion wurde inzwischen entfernt, die Löschung wird für sie abgebrochen`)
} else {
melde(lauf, 'Funktion vor dem Löschen nicht gelesen', err)
}
}
return false
}
/**
* Buchungen EINER abgeschalteten Funktion seitenweise loeschen (arbeiteSeiten): Seite lesen (hoechstens 100, nur Kennungen), jede
* Buchung samt Zellen unter ihrer Sperre loeschen, neu lesen, bis die Seite leer ist. Die Funktion wird frisch gelesen
* (nochAbgeschaltet) vor jeder Seite nach dem Lesen und nach jeweils 50 Buchungen; gilt sie nicht mehr als abgeschaltet, endet die
* Loeschung fuer sie sofort (Log, kein Wurf). Am Deckel config.termine.loeschDeckel endet der Lauf mit Warnung (der Rest folgt am
* naechsten Tag). Die Einstellung wird nur nach einer leeren Seite ohne Fehler geloescht, mit letzter Pruefung unter der Sperre
* termine-cfg:<id>; sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter.
* => { geloescht, gedeckelt, einstellungWeg }
*/
async function loescheAbgeschalteteFunktion(lauf, funktionId, grenze) {
const deckel = config.termine.loeschDeckel
let abgebrochen = false // die Funktion gilt nicht mehr als abgeschaltet (oder war nicht lesbar): nichts mehr loeschen
let begonnen = 0 // Buchungen, deren Loeschung begonnen wurde
let geprueftBei = 0 // Stand von `begonnen` bei der letzten Pruefung
const pruefe = async () => {
geprueftBei = begonnen
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) abgebrochen = true
return !abgebrochen
}
const seiten = await schritt(
lauf,
'Buchungen abgeschalteter Funktion',
() =>
arbeiteSeiten(lauf, {
was: 'Löschung abgeschalteter Funktion',
deckel,
deckelText: `von ${deckel} je Funktion`,
holeSeite: async () => {
if (abgebrochen) return []
const seite = await ersteBuchungen(funktionId)
// vor dem Loeschen der Seite frisch pruefen: das Personal kann die Funktion zwischen Lesen und Loeschen wieder einschalten
return seite.length > 0 && !(await pruefe()) ? [] : seite
},
bearbeite: async (buchung) => {
if (abgebrochen) return false
if (begonnen - geprueftBei >= ABGESCHALTET_NEU_PRUEFEN_ALLE && !(await pruefe())) return false
begonnen += 1
return loescheMitZellen(lauf, buchung.$id, null)
},
}),
{ geloescht: 0, gedeckelt: false, vollstaendig: false },
)
let einstellungWeg = false
if (seiten.vollstaendig && !abgebrochen) {
einstellungWeg = await schritt(
lauf,
'Einstellung abgeschalteter Funktion',
() =>
withLocks([`termine-cfg:${funktionId}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return false
try {
await deleteDocument(config.collections.termineConfig, funktionId)
return true
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) return false // schon weg
throw err
}
}),
false,
)
}
return { geloescht: seiten.geloescht, gedeckelt: seiten.gedeckelt, einstellungWeg }
}
/**
* Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung
* loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion). Endet eine Funktion am Deckel oder an einem Fehler,
* bleibt sie `disabled` mit altem disabledAt und der naechste Tag macht weiter; die anderen Funktionen kommen im selben Lauf dran.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung), gedeckelt die Funktionen, die am
* Deckel endeten. Zellen ohne Buchung raeumt danach der Tagesjob.
*/
async function loescheAbgeschaltete(lauf) {
const erg = { funktionen: 0, buchungen: 0, gedeckelt: 0 }
const grenze = lauf.nowMs - ABGESCHALTET_BEHALTEN_MS
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
for (const funktion of funktionen) {
if (!(zeitMs(funktion.disabledAt) < grenze)) continue
const weg = await loescheAbgeschalteteFunktion(lauf, funktion.$id, grenze)
erg.buchungen += weg.geloescht
if (weg.gedeckelt) erg.gedeckelt += 1
if (weg.geloescht > 0 || weg.einstellungWeg) erg.funktionen += 1
}
return erg
}
/**
* Loeschfristen (Spezifikation 11) fuer Buchungen: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten
* Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende, spaetestens nach 365 Tagen
* (Sicherheitsnetz); jeweils samt Zellen, je Schritt hoechstens config.termine.loeschDeckel und je Lauf hoechstens
* config.termine.loeschDeckelGesamt (der Rest folgt im naechsten Lauf). Baustein des Tagesjobs, ohne Schalter- und
* Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number, gedeckelt: number }>}
* gedeckelt = Zahl der Schritte, die am Deckel endeten
*/
export function wendeLoeschfristenAn(now) {
return loeschfristen(einzelLauf('loeschfristen', now), now)
}
/**
* Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen, seitenweise und je Funktion bis config.termine.
* loeschDeckel Buchungen je Lauf. Baustein des Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ funktionen: number, buchungen: number, gedeckelt: number }>}
* gedeckelt = Zahl der Funktionen, die am Deckel endeten
*/
export function loescheAbgeschalteteFunktionen(now) {
return loescheAbgeschaltete(einzelLauf('abgeschalteteFunktionen', now))
}
/** Summe aller Loeschzaehler eines Tagesjobs: Buchungen aus den Loeschfristen und aus abgeschalteten Funktionen (Zellen, Outbox und Mails zaehlen nicht). */
const buchungenGeloescht = (loeschfristen, abgeschaltet) => Object.values(loeschfristen).reduce((summe, n) => summe + n, 0) + abgeschaltet.buchungen
/** Zaehler des Abo-Abgleichs, wenn der Schritt selbst scheitert (gleicheAboAb wirft nie; der Fehler steht dann schon in lauf.fehler). */
const aboErsatz = () => ({ geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 })
/**
* Guard fuer den Modus off (fuehreAus: trotzAus): gibt es ein vom Kauf freigeschaltetes Termine-Dokument? Eine einzige Abfrage
* (limit 1) ohne Kunden- oder Gastdaten; ein Lesefehler wirft und haelt den Tagesjob aus.
*/
async function gibtBezahlteTerminplaner() {
const treffer = await listDocuments(config.collections.portalFeatures, [
Query.equal('featureKey', 'termine'),
Query.equal('unlockedByPurchase', true),
Query.limit(1),
])
return treffer.length > 0
}
/**
* Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des
* Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs
*/
async function aboAbgleichSchritt(lauf, now) {
const abgleich = await schritt(lauf, 'Abo-Abgleich', () => gleicheAboAb(now), aboErsatz())
lauf.fehler += abgleich.fehler
return abgleich
}
/**
* Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben),
* Loeschfristen, Abo-Abgleich (der Terminplaner endet mit dem Wartungs-Abo; VOR der Loeschung, damit ein Betrieb, dessen Abo heute
* zurueckkehrt, nicht mehr geloescht wird), abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen; zuletzt die
* aktiven Betriebe zaehlen (ein nicht lesbarer Zaehler ergibt null und einen Fehler) und den Lauf ans Team melden (meldeTagesjob:
* Fehler, Loeschdeckel an 3 Tagen in Folge, ab 20 Betrieben; wirft nie). Bricht der Job unerwartet ab, erfaehrt das Team es ebenfalls,
* und der Fehler geht unveraendert an den Aufrufer.
* `geloescht` = Summe aller Loeschzaehler (Buchungen aus Loeschfristen und abgeschalteten Funktionen), `aktiveBetriebe` = Zahl oder null,
* `aboAbgleich` = Zaehler des Abgleichs { geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler }.
* Modus off: ohne vom Kauf freigeschaltetes Termine-Dokument { aus: true } nach genau einer Abfrage (Guard). Mit einem bezahlten
* Dokument laeuft der eingeschraenkte Tagesjob nur mit Abo-Abgleich und Loeschung abgeschalteter Funktionen (Loeschfristen der
* Buchungen, Warteschlange und Zellen bleiben aus; ob die uebrigen Schritte dort auch laufen sollen, ist offen). Sein Ergebnis traegt
* dieselben Felder, die meldeTagesjob und die Statusroute lesen: { eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt
* { loeschfristen: 0, abgeschaltet, zellen: 0 }, geloescht, aktiveBetriebe: null, fehler }. Beide Laeufe teilen die Ueberlappungssperre.
* @param {Date} now
* @returns {Promise<object | { aus: true } | { uebersprungen: true }>}
*/
export async function taeglicherJob(now) {
let aktuellerLauf = null // gesetzt, sobald der Rahmen den Job startet (fuer die Meldung eines Abbruchs)
try {
return await fuehreAus(
'taeglicherJob',
now,
async (lauf) => {
aktuellerLauf = lauf
if (lauf.eingeschraenkt) return eingeschraenkterTagesjob(lauf, now)
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now)
const aboAbgleich = await aboAbgleichSchritt(lauf, now)
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
funktionen: 0,
buchungen: 0,
gedeckelt: 0,
})
const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0)
const zellenErgebnis = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), { geloescht: 0, gedeckelt: false })
if (zellenErgebnis.gedeckelt) {
console.warn(`[termine-jobs] ${lauf.job}: Zellen-Sweep endet am Deckel von ${config.termine.zellenDeckel} je Lauf, der Rest folgt an den nächsten Tagen`)
}
const aktiveBetriebe = await schritt(lauf, 'Aktive Betriebe', () => zaehleAktiveBetriebe(), null)
// gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen: 1, wenn der Zellen-Sweep daran endete); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt
const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: zellenErgebnis.gedeckelt ? 1 : 0 }
const ergebnis = {
festgefahren,
loeschfristen: loeschung,
gedeckelt,
abgeschaltet,
aboAbgleich,
aufgeraeumt,
zellen: zellenErgebnis.geloescht,
geloescht: buchungenGeloescht(loeschung, abgeschaltet),
aktiveBetriebe,
fehler: lauf.fehler,
}
// Zuletzt: die Meldung sieht den ganzen Lauf; sie wirft nie, ein Fehler dort stuende nur im Log
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
return ergebnis
},
{ trotzAus: gibtBezahlteTerminplaner },
)
} catch (err) {
// Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter
if (aktuellerLauf) await schritt(aktuellerLauf, 'Tagesjob-Meldung', () => meldeTagesjob({ abbruch: true, fehler: aktuellerLauf.fehler + 1 }, now), [])
throw err
}
}
/**
* Der eingeschraenkte Tagesjob (Modus off, es gibt bezahlte Terminplaner): erst der Abo-Abgleich, dann die Loeschung abgeschalteter
* Funktionen (gleiche Reihenfolge wie im vollen Lauf). Mehr nicht: keine Loeschfristen der Buchungen, keine Warteschlange, keine
* Zellen, keine Zaehlung der aktiven Betriebe (`aktiveBetriebe: null`). Das Ergebnis traegt auch die Felder, die meldeTagesjob liest,
* und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team).
*/
async function eingeschraenkterTagesjob(lauf, now) {
const aboAbgleich = await aboAbgleichSchritt(lauf, now)
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
funktionen: 0,
buchungen: 0,
gedeckelt: 0,
})
const ergebnis = {
eingeschraenkt: true,
aboAbgleich,
abgeschaltet,
gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 },
geloescht: abgeschaltet.buchungen,
aktiveBetriebe: null,
fehler: lauf.fehler,
}
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
return ergebnis
}
/** Zahl fuer die Berichtszeile: ganze Zahl ab 0, alles andere (fehlt, Text) zaehlt als 0. */
const zahl = (wert) => (Number.isInteger(wert) && wert > 0 ? wert : 0)
/**
* Eine Zeile fuers Log nach dem Tagesjob: nur Zahlen, keine personenbezogenen Daten und keine Buchungs- oder Funktions-IDs
* (Spur der unwiderruflichen Loeschungen). Reihenfolge der Segmente (verbindlich): `…, N Mails nicht zugestellt, [Löschläufe am
* Deckel: n, Rest folgt an den nächsten Tagen, ]Abo-Abgleich g geprüft / a abgeschaltet / w wieder ein / m Mails / x ausgenommen,
* N Fehler, n aktive Betriebe`; „n aktive Betriebe“ steht ganz am Ende und fehlt, wenn die Zahl unbekannt ist, „Abo-Abgleich“ fehlt,
* wenn das Ergebnis keine Zaehler dafuer traegt. Der eingeschraenkte Lauf (Modus off) hat eine eigene Zeile „[termine] Tagesjob (Modus
* off, nur Abo-Abgleich und Löschung): …“ ohne Zellen, Outbox, Mails und Betriebe.
* null, wenn der Job nicht gelaufen ist (Modus off ohne bezahlte Terminplaner, uebersprungen).
* @param {object} ergebnis Rueckgabe von taeglicherJob
* @returns {string|null}
*/
export function tagesbericht(ergebnis) {
if (!ergebnis || ergebnis.aus || ergebnis.uebersprungen) return null
const a = ergebnis.abgeschaltet
const amDeckel = Object.values(ergebnis.gedeckelt ?? {}).reduce((summe, n) => summe + n, 0)
const deckel = amDeckel > 0 ? `Löschläufe am Deckel: ${amDeckel}, Rest folgt an den nächsten Tagen, ` : ''
const abo = ergebnis.aboAbgleich
const aboSegment =
abo === null || typeof abo !== 'object'
? ''
: `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, `
if (ergebnis.eingeschraenkt) {
return (
`[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler`
)
}
const l = ergebnis.loeschfristen
const gesamt = buchungenGeloescht(l, a)
const betriebe = Number.isInteger(ergebnis.aktiveBetriebe) && ergebnis.aktiveBetriebe >= 0 ? `, ${ergebnis.aktiveBetriebe} aktive Betriebe` : ''
return (
`[termine] Tagesjob: ${gesamt} Buchungen gelöscht (verfallen ${l.verfallen}, abgesagt ${l.abgesagt}, abgelehnt ${l.abgelehnt}, ` +
`erledigt ${l.erledigt}, nichterschienen ${l.nichterschienen}, abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${ergebnis.zellen} Zellen, ${ergebnis.aufgeraeumt} Outbox, ` +
`${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler${betriebe}`
)
}
/** Nur fuer Tests: Ueberlappungsmerker und Warnungs-Zeitpunkte zuruecksetzen. */
export function resetJobsForTests() {
laufend.clear()
ueberlappGemeldet.clear()
letzteWarnung.clear()
}