Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen

Neu: server/services/termine/entwuerfe.js. Ein Terminplaner im Zustand off ist ein
Entwurf (Fragebogen, Einstellung). loescheEntwuerfe(now) im Tagesjob (nach den
abgeschalteten Funktionen, nur im vollen Lauf): letzte Aenderung = juengerer Zeitpunkt
aus $updatedAt des Funktionsdokuments und termineConfig.updatedAt, aelter als 90 Tage
=> Einstellung weg, das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war
(sonst bleibt es wegen des siteKeys; ein von E8 zurueckgesetztes Dokument nie).
Vor jedem Loeschen unter feature-order, feature-admin und termine-cfg frisch lesen
(noch off, Frist, keine Buchung). Seiten zu 100 per cursorAfter, hoechstens 2000
geprueft und 100 geloescht je Lauf, der Rest folgt an Folgetagen; Einzelfehler zaehlen
in fehler. Tagesbericht: Segment "Entwuerfe n geloescht (m offen)".
loescheEntwuerfeVonKunde: Kundenloeschung entfernt alle off-Entwuerfe vollstaendig,
Fehler werden nur protokolliert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 20:33:33 +00:00
1 parent 29a38a9592
commit 91b1a3bcd8
6 files changed
+827 -24

No files matched your search

+9
View File
@@ -291,6 +291,15 @@ export async function deleteCustomerWithPortalAccess(customerId) {
console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
// Entwuerfe (Terminplaner im Zustand off: nie bestellt oder nach dem Abo-Ende zurueckgesetzt) gehen sofort mit dem Kunden, Funktionsdokument
// und Einstellung. Fehler halten das Loeschen nicht auf (wie oben); ein uebrig gebliebener Entwurf faellt nach 90 Tagen im Tageslauf.
try {
const { loescheEntwuerfeVonKunde } = await import('./termine/entwuerfe.js')
await loescheEntwuerfeVonKunde(customerId)
} catch (err) {
console.error(`[customerAdmin] Entwürfe von Kunde ${customerId} nicht gelöscht:`, err.message)
}
const portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
await deleteDocument(
+204
View File
@@ -0,0 +1,204 @@
import { config } from '../../config.js'
import { Query, deleteDocument, listDocuments } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { listCustomerFeatureDocs } from '../siteFeatures.js'
import { ersteBuchungen } from './buchungen.js'
import { holeOderNull } from './kontext.js'
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
* Entwuerfe des Terminplaners aufbewahren und loeschen (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt 3.7).
*
* Ein Entwurf ist ein Funktionsdokument im Zustand `off` (portalFeatures, featureKey termine) samt Einstellung und Fragebogen
* (termineConfig, gleiche ID), die ein Kunde vor der Bestellung ausfuellt. Bestellt er nie, bleiben diese Angaben nicht ewig liegen:
*
* - loescheEntwuerfe (Baustein des Tagesjobs, jobs.js): ein Entwurf, dessen letzte Aenderung laenger als ENTWURF_TAGE (90) Tage her
* ist, verliert seine Einstellung und, wenn es nie in Betrieb oder bezahlt war, auch das Funktionsdokument. Die letzte Aenderung
* ist der juengere Zeitpunkt aus `$updatedAt` des Funktionsdokuments (Zustandswechsel, z. B. nach einer abgebrochenen Zahlung) und
* `termineConfig.updatedAt` (Speichern der Einstellung oder des Fragebogens; PUT /fragebogen aendert nur das termineConfig-Dokument).
* - loescheEntwuerfeVonKunde (Kundenloeschung, customerAdmin.js): alle `off`-Entwuerfe des Kunden sofort und vollstaendig.
*
* Das Funktionsdokument bleibt, sobald es einmal in Betrieb oder bezahlt war (activatedAt, ticketId, disabledAt, unlockedByPurchase,
* purchaseStatus paid), und ebenso das von E8 zurueckgesetzte (purchaseStatus zurueckgesetzt, dauerhafte Marke der Loeschung nach dem
* Abo-Ende): sein siteKey steckt im Snippet auf der Website des Kunden und muss gueltig bleiben. Solche Dokumente verlieren hoechstens
* ihre Einstellung. Nie angefasst werden ordered, setup, active und disabled (die Abfrage liest nur `off`).
*
* Sicherheit gegen Rennen: Die Liste ist nur die Vorauswahl. Vor jedem Loeschen werden unter den Sperren
* feature-order:<kunde>:<projekt> (Bestellung), feature-admin:<projekt> (Team) und termine-cfg:<id> (Speichern der Einstellung) das
* Funktionsdokument und die Einstellung frisch gelesen und alle Bedingungen erneut geprueft (noch `off`, Frist noch ueberschritten,
* keine Buchung zur Funktion). Im Zweifel (Lesefehler, unlesbares Datum) wird nichts geloescht.
*
* Deckel: eine Seite der Liste hat hoechstens SEITE (100) Dokumente (sortiert nach $id, per cursorAfter). Die Liste wird ganz gelesen,
* bevor das erste Dokument geloescht wird (ein geloeschtes Dokument taugt nicht mehr als Cursor); nach MAX_GEPRUEFT (2000) Dokumenten
* endet sie mit einer Logzeile. Danach werden hoechstens MAX_GELOESCHT (100) Entwuerfe je Lauf geloescht; die uebrigen faelligen
* zaehlen als `uebrig`. Geloeschte Entwuerfe fehlen am naechsten Tag in der Liste, der Rest laeuft also an Folgetagen weiter.
* Einzelfehler zaehlen in `fehler` und brechen den Lauf nicht ab (Log ohne Kennungen: weder Kunden-, Projekt- und Funktions-IDs noch
* siteKeys).
*/
export const ENTWURF_TAGE = 90
export const MAX_GEPRUEFT = 2000
export const MAX_GELOESCHT = 100
const FUNKTION = 'termine'
const SEITE = 100
const TAG_MS = 24 * 60 * 60 * 1000
/** So viele Einzelfehler stehen je Lauf im Log; die Zahl steht danach in einer Schlusszeile (fehler im Ergebnis zaehlt alle). */
const MAX_FEHLER_IM_LOG = 5
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
/** Dokument loeschen. => true, wenn es geloescht wurde, false, wenn es schon weg war (404 ist kein Fehler). */
async function loescheOderWeg(collectionId, id) {
try {
await deleteDocument(collectionId, id)
return true
} catch (err) {
if (dokumentFehlt(err)) return false
throw err
}
}
/**
* Zeitpunkt (ms) der letzten Aenderung eines Entwurfs: der juengere aus $updatedAt des Funktionsdokuments und updatedAt der
* Einstellung (fehlt das Attribut, deren $updatedAt). NaN, wenn sich ein vorhandener Zeitpunkt nicht lesen laesst (dann gilt der
* Entwurf nie als faellig).
*/
function letzteAenderung(funktion, einstellung) {
const zeiten = [zeitMs(funktion.$updatedAt)]
if (einstellung) {
const eigene = zeitMs(einstellung.updatedAt)
zeiten.push(Number.isFinite(eigene) ? eigene : zeitMs(einstellung.$updatedAt))
}
return zeiten.every(Number.isFinite) ? Math.max(...zeiten) : Number.NaN
}
/** Darf auch das Funktionsdokument weg? Nur, wenn es nie in Betrieb oder bezahlt war und nicht von E8 zurueckgesetzt wurde (siteKey). */
const nieInBetrieb = (funktion) =>
!funktion.activatedAt &&
!funktion.ticketId &&
!funktion.disabledAt &&
!funktion.unlockedByPurchase &&
funktion.purchaseStatus !== 'paid' &&
funktion.purchaseStatus !== 'zurueckgesetzt'
/**
* Einen Entwurf entfernen: unter den drei Sperren frisch lesen, pruefen, dann die Einstellung loeschen und, wenn es zulaessig ist,
* das Funktionsdokument. `grenze` (ms): nur Entwuerfe, deren letzte Aenderung davor liegt; null = Kundenloeschung, alles was `off`
* ist, ohne Frist und ohne Pruefung auf Spuren (der siteKey stirbt mit dem Kunden). Buchungen zur Funktion halten den Entwurf in
* beiden Faellen zurueck (ein `off`-Dokument hat keine; waeren doch welche da, blieben sonst Gastdaten ohne Funktion zurueck).
* Die Einstellung wird zuerst geloescht: scheitert danach das Dokument, holt der naechste Lauf es nach (die Frist ist unveraendert).
* Ein Lesefehler oder ein Fehler beim Loeschen wirft; der Aufrufer zaehlt ihn.
* => true, wenn etwas geloescht wurde (Einstellung oder Funktionsdokument), sonst false
*/
function entferne(kandidat, grenze) {
const { $id: id, customerId, projectId } = kandidat
if (!id || !customerId || !projectId) throw new Error('Entwurf ohne Kunde oder Projekt')
return withLocks([`feature-order:${customerId}:${projectId}`, `feature-admin:${projectId}`, `termine-cfg:${id}`], async () => {
const funktion = await holeOderNull(config.collections.portalFeatures, id)
if (!funktion || funktion.featureKey !== FUNKTION || funktion.state !== 'off') return false
if (funktion.customerId !== customerId || funktion.projectId !== projectId) return false
const einstellung = await holeOderNull(config.collections.termineConfig, id)
if (grenze !== null && !(letzteAenderung(funktion, einstellung) < grenze)) return false
if ((await ersteBuchungen(id, 1)).length > 0) return false
let geloescht = false
if (einstellung && (await loescheOderWeg(config.collections.termineConfig, id))) geloescht = true
if ((grenze === null || nieInBetrieb(funktion)) && (await loescheOderWeg(config.collections.portalFeatures, id))) geloescht = true
return geloescht
})
}
/**
* Entwuerfe nach 90 Tagen loeschen (Baustein des Tagesjobs, ohne Schalter- und Ueberlappungspruefung). Wirft nie wegen eines
* einzelnen Dokuments oder einer nicht lesbaren Liste (das zaehlt in `fehler`); nur eine ungueltige Uhrzeit ist ein TypeError.
* @param {Date} now
* @returns {Promise<{ geprueft: number, geloescht: number, uebrig: number, fehler: number }>}
* geprueft = gelesene Kandidaten (hoechstens MAX_GEPRUEFT), geloescht = Entwuerfe, bei denen Einstellung oder Funktionsdokument
* geloescht wurden (hoechstens MAX_GELOESCHT), uebrig = faellig wirkende Kandidaten, die der Deckel aufhielt, fehler = Einzelfehler
*/
export async function loescheEntwuerfe(now) {
const nowMs = pruefeNow(now, 'loescheEntwuerfe')
const grenze = nowMs - ENTWURF_TAGE * TAG_MS
const erg = { geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 }
const melde = (was, err) => {
erg.fehler += 1
if (erg.fehler <= MAX_FEHLER_IM_LOG) console.error(`[termine-entwuerfe] ${was}: ${fehlerText(err)}`)
}
// 1 Vorauswahl: die ganze Liste lesen, bevor etwas geloescht wird. $updatedAt des Funktionsdokuments kann nur wachsen, ein
// Dokument, das schon hier juenger als die Frist ist, bleibt also auch nach dem frischen Lesen juenger.
const faellig = []
let cursor = null
liste: for (;;) {
let seite
try {
seite = await listDocuments(config.collections.portalFeatures, [
Query.equal('featureKey', FUNKTION),
Query.equal('state', 'off'),
Query.orderAsc('$id'),
Query.limit(SEITE),
...(cursor ? [Query.cursorAfter(cursor)] : []),
])
} catch (err) {
melde('Liste der Entwürfe nicht gelesen', err)
break
}
for (const kandidat of seite) {
if (erg.geprueft >= MAX_GEPRUEFT) {
console.warn(`[termine-entwuerfe] Prüfung endet nach ${MAX_GEPRUEFT} Dokumenten, der Rest folgt an den nächsten Tagen`)
break liste
}
erg.geprueft += 1
if (zeitMs(kandidat.$updatedAt) < grenze) faellig.push(kandidat)
}
if (seite.length < SEITE) break
cursor = seite[seite.length - 1].$id
}
// 2 Loeschen, je Kandidat unter den Sperren mit frischem Lesen (entferne); am Deckel zaehlt der Rest als uebrig
for (const kandidat of faellig) {
if (erg.geloescht >= MAX_GELOESCHT) {
erg.uebrig += 1
continue
}
try {
if (await entferne(kandidat, grenze)) erg.geloescht += 1
} catch (err) {
melde('Entwurf nicht bearbeitet', err)
}
}
if (erg.uebrig > 0) {
console.warn(`[termine-entwuerfe] Löschung endet am Deckel von ${MAX_GELOESCHT} je Lauf, ${erg.uebrig} Entwürfe offen, der Rest folgt an den nächsten Tagen`)
}
if (erg.fehler > MAX_FEHLER_IM_LOG) console.error(`[termine-entwuerfe] ${erg.fehler - MAX_FEHLER_IM_LOG} weitere Fehler (nicht einzeln protokolliert)`)
return erg
}
/**
* Kunde wird geloescht: alle seine Entwuerfe (Terminplaner im Zustand `off`) vollstaendig entfernen, Funktionsdokument und
* Einstellung, ohne Frist. Andere Zustaende bleiben (disableCustomerFeatures schaltet sie ab, die Loeschfrist von 30 Tagen
* entscheidet), ebenso andere Funktionen und andere Kunden. Scheitert ein Entwurf, werden die uebrigen trotzdem entfernt und danach der
* erste Fehler weitergereicht (der Aufrufer protokolliert ihn und loescht den Kunden trotzdem).
* @param {string} customerId
* @returns {Promise<{ geloescht: number }>}
*/
export async function loescheEntwuerfeVonKunde(customerId) {
pruefeText(customerId, 'customerId')
const entwuerfe = (await listCustomerFeatureDocs(customerId)).filter(
(doc) => doc.featureKey === FUNKTION && doc.state === 'off' && doc.customerId === customerId,
)
let geloescht = 0
let ersterFehler = null
for (const entwurf of entwuerfe) {
try {
if (await entferne(entwurf, null)) geloescht += 1
} catch (err) {
ersterFehler ??= err
}
}
if (ersterFehler) throw ersterFehler
return { geloescht }
}
+31 -9
View File
@@ -18,6 +18,7 @@ import {
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'
@@ -38,7 +39,9 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.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
* 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)
@@ -759,6 +762,19 @@ const buchungenGeloescht = (loeschfristen, abgeschaltet) => Object.values(loesch
/** 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.
@@ -785,15 +801,17 @@ async function aboAbgleichSchritt(lauf, now) {
/**
* 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
* 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 }.
* `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, Warteschlange und Zellen bleiben aus; ob die uebrigen Schritte dort auch laufen sollen, ist offen). Sein Ergebnis traegt
* 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
@@ -816,6 +834,7 @@ export async function taeglicherJob(now) {
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) {
@@ -830,6 +849,7 @@ export async function taeglicherJob(now) {
gedeckelt,
abgeschaltet,
aboAbgleich,
entwuerfe,
aufgeraeumt,
zellen: zellenErgebnis.geloescht,
geloescht: buchungenGeloescht(loeschung, abgeschaltet),
@@ -881,10 +901,10 @@ 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.
* 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}
@@ -894,6 +914,8 @@ export function tagesbericht(ergebnis) {
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'
@@ -912,7 +934,7 @@ export function tagesbericht(ergebnis) {
`[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}`
`${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${entwuerfeSegment}${aboSegment}${ergebnis.fehler} Fehler${betriebe}`
)
}