Käufermails laufen über die Warteschlange des Terminplaners (G09/G12 mit Vorrang), G09 wird bei Überlänge um den AGB-Text gekürzt (Link statt Text). Der Mailteil des Minutenjobs und die Aufräumschritte des Tagesjobs laufen auch bei TERMINE_ENABLED=off, solange SHOP_ENABLED nicht off ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
998 lines
55 KiB
JavaScript
998 lines
55 KiB
JavaScript
import { config } from '../../config.js'
|
||
import { aboLage } from '../aboStatus.js'
|
||
import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
|
||
import { withLocks } from '../keyedLock.js'
|
||
import { stateFields } from '../siteFeatures.js'
|
||
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
|
||
import { gleicheAboAb, istErstattungOffen } 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 { loescheEntwuerfe } from './entwuerfe.js'
|
||
import { LOESCHFRIST_TAGE } from './frist.js'
|
||
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
|
||
import { anGast } from './mails.js'
|
||
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
|
||
import { shopModus } from '../shop/schalter.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, Entwuerfe (entwuerfe.js: Terminplaner im Zustand off, 90 Tage nach der
|
||
* letzten Aenderung; nur im vollen Lauf, Entwuerfe sind nie bezahlt), 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)
|
||
* Abgeschaltete Funktionen: nach LOESCHFRIST_TAGE (30) Tagen Buchungen, Zellen, Einstellung loeschen
|
||
* (Grund abo_ende: vor jedem unumkehrbaren Schritt frisch pruefen, ob das Wartungs-Abo wieder laeuft),
|
||
* danach das Funktionsdokument auf `off` zuruecksetzen (siteKey bleibt; mit offenem Zahlungsfall,
|
||
* zahlungMeldung nicht leer und nicht 'gemeldet', bleibt es disabled bis zur Meldung, R108)
|
||
*
|
||
* 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.
|
||
* - Webshop (SHOP_ENABLED != off): die Mail-Warteschlange gehoert auch den Kaeufermails des Shops (G09-G14, shop/mails.js). Deshalb
|
||
* laufen bei Modus off des Terminplaners, aber aktivem Shop, der Mailversand-Teil des Minutenjobs (Warteschlange senden und
|
||
* Betriebspruefung) und im Tagesjob das Beenden festgefahrener und das Loeschen alter Warteschlangen-Eintraege weiter; alles
|
||
* andere bleibt an den Terminplaner-Modus gebunden. Der Tagesjob ist dann eingeschraenkt (`eingeschraenkt: true`, mit
|
||
* `festgefahren` und `aufgeraeumt` im Ergebnis); gibt es keine bezahlten Terminplaner, laufen Abo-Abgleich und Loeschung
|
||
* abgeschalteter Funktionen nicht.
|
||
* - 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.
|
||
* Das Funktionsdokument wird danach auf `off` gesetzt und fehlt damit in der Liste des naechsten Tages (loescheAbgeschaltete).
|
||
* 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
|
||
/** Frist zwischen dem Abschalten (disabledAt) und der Loeschung: eine Zahl fuer Anzeige, Mails, Rechtstexte und diesen Job (frist.js). */
|
||
const ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE * 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, => falsch/wahr/Objekt): im Modus off laeuft der Job nur, wenn es etwas Wahres liefert; dann steht
|
||
* `lauf.eingeschraenkt` auf true, `lauf.freigabe` traegt die Antwort (Tagesjob: { termine, mails }) 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
|
||
let freigabe = null
|
||
if (termineAus()) {
|
||
if (trotzAus === null) return { aus: true }
|
||
try {
|
||
freigabe = await trotzAus()
|
||
if (!freigabe) 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, freigabe })
|
||
} finally {
|
||
laufend.delete(sperre)
|
||
ueberlappGemeldet.delete(sperre)
|
||
}
|
||
}
|
||
|
||
/** Guard des Mailversand-Teils (fuehreAus: trotzAus): bei Modus off des Terminplaners laeuft er, solange der Webshop aktiv ist. */
|
||
const shopMailsLaufen = async () => shopModus() !== 'off'
|
||
|
||
/** 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', trotzAus: shopMailsLaufen },
|
||
),
|
||
])
|
||
if (verfall.aus && mail.aus) return { aus: true }
|
||
// Terminplaner aus, Webshop aktiv: nur der Mailversand-Teil lief
|
||
if (verfall.aus) {
|
||
if (mail.uebersprungen) return { uebersprungen: true }
|
||
return { eingeschraenkt: true, verfallen: 0, faellig: mail.faellig ?? 0, gesendet: mail.gesendet ?? 0, fehler: mail.fehler ?? 0 }
|
||
}
|
||
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 Schritt noch als abgeschaltet? Frisch gelesen, weil das Personal
|
||
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate leert disabledAt) und ein Wartungs-Abo zurueckkommen
|
||
* kann: featureKey termine, state disabled und disabledAt vor der Grenze. Bei Grund `abo_ende` (nur dort haengt die Loeschung am Abo)
|
||
* zusaetzlich frisch aboLage (nie der Memo): laeuft das Wartungs-Abo wieder, gilt sie nicht als abgeschaltet. Fuer `team`,
|
||
* `kunde_geloescht`, `abo_ende_vor_einrichtung` und Altbestand ohne Grund gibt es keine Abo-Abfrage. mitAbo false: nichts
|
||
* Unumkehrbares mehr offen (Zuruecksetzen), dann entfaellt auch die Abo-Pruefung. Sonst null mit Log, ohne Wurf – auch bei einem
|
||
* Lesefehler (Funktion oder Abo): im Zweifel wird nichts geloescht.
|
||
* => das frisch gelesene Funktionsdokument, wenn es noch als abgeschaltet gilt, sonst null
|
||
*/
|
||
async function leseAbgeschaltet(lauf, funktionId, grenze, { mitAbo = true } = {}) {
|
||
let frisch
|
||
try {
|
||
frisch = await getDocument(config.collections.portalFeatures, funktionId)
|
||
} 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 null
|
||
}
|
||
if (!(frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze)) {
|
||
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
|
||
return null
|
||
}
|
||
if (!mitAbo || frisch.disabledReason !== 'abo_ende') return frisch
|
||
try {
|
||
if (!frisch.customerId) throw new Error('Funktion ohne Kunde')
|
||
if (!(await aboLage(frisch.customerId)).laeuft) return frisch
|
||
console.warn(`[termine-jobs] ${lauf.job}: Abo läuft wieder, Löschung abgebrochen`)
|
||
} catch (err) {
|
||
melde(lauf, 'Abo vor dem Löschen nicht gelesen', err)
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** Wie leseAbgeschaltet, nur als Ja/Nein: gilt die Funktion unmittelbar vor einem unwiderruflichen Schritt noch als abgeschaltet? */
|
||
const nochAbgeschaltet = async (lauf, funktionId, grenze, optionen) => (await leseAbgeschaltet(lauf, funktionId, grenze, optionen)) !== null
|
||
|
||
/**
|
||
* Einstellung (termineConfig) einer abgeschalteten Funktion loeschen, unter der Sperre termine-cfg:<id>. Ist sie noch da, kommt vor
|
||
* dem unumkehrbaren Loeschen die letzte Pruefung (nochAbgeschaltet, bei Grund abo_ende auch das frische Abo); ist sie schon weg (404),
|
||
* ist nichts Unumkehrbares mehr offen und die Abo-Pruefung entfaellt. Ein Lesefehler wirft (der Aufrufer meldet ihn).
|
||
* => 'geloescht' (in diesem Lauf) | 'weg' (war schon weg) | 'bleibt' (die Funktion gilt nicht mehr als abgeschaltet)
|
||
*/
|
||
function loescheEinstellung(lauf, funktionId, grenze) {
|
||
const schonWeg = (err) => err?.status === 404 && !fehlendeCollection(err)
|
||
return withLocks([`termine-cfg:${funktionId}`], async () => {
|
||
try {
|
||
await getDocument(config.collections.termineConfig, funktionId)
|
||
} catch (err) {
|
||
if (schonWeg(err)) return 'weg'
|
||
throw err
|
||
}
|
||
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return 'bleibt'
|
||
try {
|
||
await deleteDocument(config.collections.termineConfig, funktionId)
|
||
return 'geloescht'
|
||
} catch (err) {
|
||
if (schonWeg(err)) return 'weg'
|
||
throw err
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Das Funktionsdokument nach der Loeschung zuruecksetzen, damit der Betrieb neu bestellen kann: unter den Sperren
|
||
* feature-admin:<projectId> (wie Aktivieren und Abschalten im WOMS) und termine-cfg:<id> frisch lesen und nochmals pruefen
|
||
* (leseAbgeschaltet ohne Abo: es gibt nichts Unumkehrbares mehr), dann schreiben. Hat das Team es inzwischen aktiviert, bleibt es.
|
||
* R108: steht am frisch gelesenen Dokument ein offener Zahlungsfall (zahlungMeldung nicht leer und nicht 'gemeldet', oder C2: bezahlte
|
||
* Einrichtung mit Grund abo_ende_vor_einrichtung und T19 nicht gemeldet, auch bei leerer zahlungMeldung), wird NICHT
|
||
* zurueckgesetzt (Warnung ohne Kennungen, +1 in lauf.fehler, kein Wurf): das Dokument bleibt disabled samt unlockedByPurchase, ticketId
|
||
* und zahlungMeldung, der taegliche Abo-Abgleich (aboAbgleich, Nachholen der Erstattungsmeldung T19) bleibt zustaendig und ein
|
||
* spaeterer Lauf setzt zurueck, sobald 'gemeldet' steht. Buchungen und Einstellung sind dann schon geloescht (bleibt so).
|
||
* purchaseStatus 'zurueckgesetzt' ist die dauerhafte Marke "hier wurde nach dem Abo-Ende geloescht" (weder paid noch Meldezustand;
|
||
* Block C6 loescht ein so markiertes Funktionsdokument nie, damit der siteKey besteht). siteKey sowie Bestell- und Rechnungsfelder
|
||
* bleiben stehen bis zur naechsten Bestellung.
|
||
* => true, wenn zurueckgesetzt
|
||
*/
|
||
function setzeFunktionZurueck(lauf, funktion, grenze) {
|
||
return withLocks([`feature-admin:${funktion.projectId}`, `termine-cfg:${funktion.$id}`], async () => {
|
||
const frisch = await leseAbgeschaltet(lauf, funktion.$id, grenze, { mitAbo: false })
|
||
if (!frisch) return false
|
||
// R108: ein offener Zahlungsfall (zahlungMeldung gesetzt, nicht 'gemeldet') bleibt sichtbar. Das Dokument bleibt disabled samt Markern
|
||
// (unlockedByPurchase, ticketId, zahlungMeldung), damit der Abo-Abgleich die Meldung (T19) weiter nachholt; ein spaeterer Lauf setzt zurueck,
|
||
// sobald 'gemeldet' steht. Ohne PII im Log, zaehlt in lauf.fehler (Tagesbericht), kein Wurf.
|
||
// C2: dazu die bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist, auch
|
||
// wenn zahlungMeldung noch leer ist (T19 scheiterte seit dem Abschalten): das Zuruecksetzen wuerde unlockedByPurchase, ticketId und
|
||
// zahlungMeldung loeschen, und mit ihnen die Spur der geschuldeten Erstattung. Der Abo-Abgleich (Schritt 2b) meldet T19 weiter.
|
||
if ((frisch.zahlungMeldung && frisch.zahlungMeldung !== 'gemeldet') || istErstattungOffen(frisch)) {
|
||
lauf.fehler += 1
|
||
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion hat einen offenen Zahlungsfall, sie wird nicht zurückgesetzt (der Abo-Abgleich meldet ihn weiter)`)
|
||
return false
|
||
}
|
||
await updateDocument(config.collections.portalFeatures, funktion.$id, {
|
||
...stateFields('off'),
|
||
unlockedByPurchase: false,
|
||
purchaseStatus: 'zurueckgesetzt',
|
||
zahlungMeldung: '',
|
||
ticketId: '',
|
||
activatedAt: null,
|
||
disabledAt: null,
|
||
disabledReason: null,
|
||
aboEndeMailAt: null,
|
||
adminNote: 'Daten gelöscht (Löschfrist abgelaufen)',
|
||
})
|
||
return true
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 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, bei Grund abo_ende samt frischem Abo) 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
|
||
* (loescheEinstellung, mit letzter Pruefung unter der Sperre termine-cfg:<id>); sind alle Buchungen weg und die Einstellung geloescht
|
||
* oder schon nicht mehr da, setzt der Lauf das Funktionsdokument auf `off` zurueck (setzeFunktionZurueck, fuer jeden Grund, ohne
|
||
* Abo-Pruefung). Sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter; scheitert nur das
|
||
* Zuruecksetzen, holt der naechste Tag es nach (die Einstellung ist dann weg, das Abo spielt keine Rolle mehr); bei einem offenen
|
||
* Zahlungsfall (R108) bleibt das Dokument disabled, bis zahlungMeldung 'gemeldet' ist.
|
||
* => { geloescht, gedeckelt, einstellungWeg } (einstellungWeg: in diesem Lauf geloescht; ein reines Zuruecksetzen zaehlt nicht)
|
||
*/
|
||
async function loescheAbgeschalteteFunktion(lauf, funktion, grenze) {
|
||
const funktionId = funktion.$id
|
||
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) {
|
||
const stand = await schritt(lauf, 'Einstellung abgeschalteter Funktion', () => loescheEinstellung(lauf, funktionId, grenze), 'bleibt')
|
||
einstellungWeg = stand === 'geloescht'
|
||
if (stand !== 'bleibt') {
|
||
await schritt(
|
||
lauf,
|
||
'Funktion zurücksetzen',
|
||
async () => {
|
||
if (!funktion.projectId) throw new Error('Funktion ohne Projekt') // ohne Projekt gibt es die Sperre feature-admin:<projectId> nicht: im Zweifel nichts schreiben
|
||
return setzeFunktionZurueck(lauf, funktion, grenze)
|
||
},
|
||
false,
|
||
)
|
||
}
|
||
}
|
||
return { geloescht: seiten.geloescht, gedeckelt: seiten.gedeckelt, einstellungWeg }
|
||
}
|
||
|
||
/**
|
||
* Funktionen im Zustand `disabled` seit mehr als LOESCHFRIST_TAGE (30) Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die
|
||
* Einstellung loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion), danach das Funktionsdokument auf `off`
|
||
* zuruecksetzen (es steht dann nicht mehr in der Liste dieses Jobs; bei einem offenen Zahlungsfall erst nach der Meldung, R108). Bei Grund `abo_ende` wird vor jedem unumkehrbaren Schritt frisch
|
||
* geprueft, ob das Wartungs-Abo wieder laeuft (dann bleibt alles). 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; ein reines Zuruecksetzen zaehlt nicht),
|
||
* 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, 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 })
|
||
|
||
/** Zaehler der Entwurfs-Loeschung, wenn der Schritt selbst scheitert (loescheEntwuerfe wirft nur bei ungueltiger Uhrzeit). */
|
||
const entwuerfeErsatz = () => ({ geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 })
|
||
|
||
/**
|
||
* Entwuerfe (entwuerfe.js) als Schritt des Tagesjobs: ein Fehler stoppt den Rest nicht. Die Einzelfehler der Loeschung (schon im Log)
|
||
* kommen in lauf.fehler, damit der Tagesbericht und die Meldung ans Team sie nennen. => Zaehler { geprueft, geloescht, uebrig, fehler }
|
||
*/
|
||
async function entwuerfeSchritt(lauf, now) {
|
||
const entwuerfe = await schritt(lauf, 'Entwürfe', () => loescheEntwuerfe(now), entwuerfeErsatz())
|
||
lauf.fehler += entwuerfe.fehler
|
||
return entwuerfe
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
|
||
/**
|
||
* Freigabe des Tagesjobs im Modus off (fuehreAus: trotzAus): false (aus) oder { termine, mails }. termine = es gibt ein bezahltes
|
||
* Termine-Dokument (Abo-Abgleich und Loeschung abgeschalteter Funktionen), mails = der Webshop ist aktiv (Warteschlange aufraeumen).
|
||
* Eine einzige Abfrage; scheitert sie bei aktivem Shop, laeuft nur der Mail-Teil (Warnung), sonst wirft sie wie bisher.
|
||
*/
|
||
async function tagesjobFreigabe() {
|
||
const mails = shopModus() !== 'off'
|
||
let termine = false
|
||
try {
|
||
termine = await gibtBezahlteTerminplaner()
|
||
} catch (err) {
|
||
if (!mails) throw err
|
||
console.warn(`[termine-jobs] taeglicherJob: die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, es läuft nur die Mail-Warteschlange: ${fehlerText(err)}`)
|
||
}
|
||
return termine || mails ? { termine, mails } : false
|
||
}
|
||
|
||
/**
|
||
* 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, Entwuerfe (entwuerfe.js; die Einzelfehler zaehlen in `fehler`),
|
||
* 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 },
|
||
* `entwuerfe` = Zaehler der Entwurfs-Loeschung { geprueft, geloescht, uebrig, fehler } (geloescht zaehlt Entwuerfe, nicht Buchungen).
|
||
* 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, Entwuerfe, 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 entwuerfe = await entwuerfeSchritt(lauf, now)
|
||
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,
|
||
entwuerfe,
|
||
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: tagesjobFreigabe },
|
||
)
|
||
} 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) {
|
||
// Ohne Freigabe-Angabe (Aufruf ohne tagesjobFreigabe) gilt das bisherige Verhalten: nur Terminplaner-Teile
|
||
const termine = lauf.freigabe?.termine !== false
|
||
const mails = lauf.freigabe?.mails === true
|
||
const aboAbgleich = termine ? await aboAbgleichSchritt(lauf, now) : aboErsatz()
|
||
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = termine
|
||
? await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0, gedeckelt: 0 })
|
||
: { funktionen: 0, buchungen: 0, gedeckelt: 0 }
|
||
// Webshop aktiv: festgefahrene Mails beenden (sie bleiben 48 Stunden als Zustellfehler sichtbar), dann alte Eintraege loeschen
|
||
const mailErgebnis = mails
|
||
? {
|
||
festgefahren: await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0),
|
||
aufgeraeumt: await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0),
|
||
}
|
||
: {}
|
||
const ergebnis = {
|
||
eingeschraenkt: true,
|
||
aboAbgleich,
|
||
abgeschaltet,
|
||
gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt ?? 0, zellen: 0 },
|
||
geloescht: abgeschaltet.buchungen,
|
||
aktiveBetriebe: null,
|
||
...mailErgebnis,
|
||
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, ]Entwürfe n gelöscht (m offen), 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“ und „Entwürfe“ fehlen, 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 Entwürfe, 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 e = ergebnis.entwuerfe
|
||
const entwuerfeSegment = e === null || typeof e !== 'object' ? '' : `Entwürfe ${zahl(e.geloescht)} gelöscht (${zahl(e.uebrig)} offen), `
|
||
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) {
|
||
// Webshop aktiv: dazu die Warteschlange (festgefahrene Mails beenden, alte Eintraege loeschen)
|
||
const mitMails = Number.isInteger(ergebnis.aufgeraeumt)
|
||
const mailSegment = mitMails ? `${ergebnis.aufgeraeumt} Outbox, ${zahl(ergebnis.festgefahren)} Mails nicht zugestellt, ` : ''
|
||
return (
|
||
`[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung${mitMails ? ' sowie Mail-Warteschlange' : ''}): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
|
||
`${a.funktionen} Funktionen bereinigt, ${mailSegment}${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}${entwuerfeSegment}${aboSegment}${ergebnis.fehler} Fehler${betriebe}`
|
||
)
|
||
}
|
||
|
||
/** Nur fuer Tests: Ueberlappungsmerker und Warnungs-Zeitpunkte zuruecksetzen. */
|
||
export function resetJobsForTests() {
|
||
laufend.clear()
|
||
ueberlappGemeldet.clear()
|
||
letzteWarnung.clear()
|
||
}
|