Files
Webklar-Kundenbereich/server/services/termine/betrieb.js
T
ClaudeandClaude Sonnet 5.5 4601c5ef3b Terminplaner: Tagesjob-Meldung, Betriebe-Zaehlung und Bericht
meldeTagesjob meldet am Ende des Tagesjobs ueber den Melder aus A10a2 die Vorfaelle
tagesjob (Fehler oder Abbruch), deckel (Loeschdeckel an 3 Tagesjobs in Folge) und
betriebe (ab 20 aktiven Betrieben, D8) und merkt letzterTagesjob (nur Zaehlungen).
taeglicherJob liefert geloescht und aktiveBetriebe, tagesbericht haengt
"n aktive Betriebe" an.

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

262 lines
13 KiB
JavaScript

import { config } from '../../config.js'
import { Query, countDocuments } from '../appwriteAdmin.js'
import { sendAdminNotice } from '../mailer.js'
import { mailBudget, pausiertBis, warteschlangeAlter } from './outbox.js'
import { pruefeNow, pruefeText } from './util.js'
import { datumKurz, uhrzeit } from './zeit.js'
/**
* Betriebspruefung des Terminplaners (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt 8, Punkt 5
* „Sichtbarkeit“): Das Team soll es erfahren, bevor Gaeste es merken. Der Minutenjob ruft pruefeBetrieb am Ende jedes Laufs auf
* (Modus off: gar nicht), der Tagesjob meldeTagesjob.
*
* Vorfaelle dieses Bausteins (Schluessel):
* budget mindestens 70 % von config.termine.mailsProStunde im Stundenfenster belegt (mailBudget)
* queue der aelteste faellige Eintrag der Warteschlange ist mehr als 10 Minuten ueberfaellig (warteschlangeAlter);
* eine Wiederholung mit naechsterVersuch in der Zukunft ist nicht faellig und zaehlt nicht
* pause die Anmeldepause des Mailservers laeuft (pausiertBis)
*
* Vorfaelle des Tagesjobs (meldeTagesjob, am Ende von taeglicherJob):
* tagesjob der Tagesjob endete mit Fehlern (fehler > 0) oder brach ab
* deckel die Loeschlaeufe endeten an 3 Tagesjobs in Folge am Deckel (irgendein Bereich: Fristen, abgeschaltete Funktionen, Zellen)
* betriebe mindestens 20 aktive Betriebe (Entscheidung D8: ab 25 wird der Umstieg auf einen EU-Versanddienst geplant)
* Daneben merkt meldeTagesjob den Stand des letzten Tagesjobs fuer die Statusroute (letzterTagesjob: nur Zaehlungen).
*
* Gemeinsamer Melder (meldeVorfall, auch fuer die Tagesjob-Vorfaelle): je Schluessel
* - Warnlog `[termine-betrieb] ...` hoechstens stuendlich (nur Zahlen, nie Adresse oder Name eines Gastes),
* - Team-Mail T20 (sendAdminNotice) beim Beginn und danach fruehestens nach 6 Stunden, auch wenn der Vorfall zwischendurch
* endete. Die Abkuehlzeit beginnt mit dem Versuch, nicht erst mit dem Erfolg: faellt der Mailserver aus (bei der Anmeldepause
* ist er es), scheitert auch die Team-Mail, und das Log bleibt der Rueckfall, statt dass jede Minute ein neuer Versuch folgt.
* Zustand (Zeit der letzten Warnung und der letzten Mail je Schluessel) lebt im Speicher dieses Prozesses wie das Mailbudget.
*
* Lesefehler (Appwrite) ergeben keinen Vorfall und keine Mail, nur ein Log (ebenfalls hoechstens stuendlich je Pruefung).
* `now` ist immer ein Parameter (feste Uhr in Tests).
*/
const LOG = '[termine-betrieb]'
const MIN_MS = 60 * 1000
const STUNDE_MS = 60 * MIN_MS
const LOG_ABSTAND_MS = STUNDE_MS
const MAIL_ABKUEHLZEIT_MS = 6 * STUNDE_MS
/** Die Warteschlange gilt als gestaut, wenn der aelteste faellige Eintrag laenger als so viele Minuten ueberfaellig ist. */
const QUEUE_ALTER_MIN = 10
/** Das Budget gilt als fast aufgebraucht ab 7/10 (ganzzahlig gerechnet: benutzt x 10 >= max x 7, kein Fliesskommafehler). */
const budgetErreicht = (benutzt, max) => max > 0 && benutzt * 10 >= max * 7
/** Der Loeschdeckel gilt als Dauerzustand, wenn so viele Tagesjobs in Folge daran endeten. */
const DECKEL_FOLGE_AB = 3
/** Ab so vielen aktiven Betrieben (D8) gehen Warnlog und Team-Mail; ab BETRIEBE_UMSTIEG wird der EU-Versanddienst geplant. */
const BETRIEBE_AB = 20
const BETRIEBE_UMSTIEG = 25
/** Kurze Ueberschrift der Team-Mail (Betreff `[Terminplaner] <Ueberschrift>`); ohne Eintrag gilt die allgemeine. */
const UEBERSCHRIFTEN = new Map([
['budget', 'Mailbudget fast aufgebraucht'],
['queue', 'Mail-Warteschlange staut sich'],
['pause', 'Mailversand pausiert'],
['tagesjob', 'Tagesjob mit Fehlern'],
['deckel', 'Löschläufe am Deckel'],
['betriebe', 'Viele aktive Betriebe'],
])
const ALLGEMEINE_UEBERSCHRIFT = 'Betriebsmeldung'
/** schluessel => { logMs, mailMs } (null = noch nie). */
const gemerkt = new Map()
/** Stand des letzten Tagesjobs ({ at, fehler, gedeckelt, geloescht }, nur Zaehlungen) oder null (nach einem Neustart bis zum Tagesjob). */
let letzter = null
/** Zahl der Tagesjobs in Folge, die in irgendeinem Bereich am Loeschdeckel endeten. */
let deckelFolge = 0
function merkzettel(schluessel) {
let zettel = gemerkt.get(schluessel)
if (!zettel) {
zettel = { logMs: null, mailMs: null }
gemerkt.set(schluessel, zettel)
}
return zettel
}
/** Ist seit dem letzten Mal (ms oder null = nie) mindestens abstandMs vergangen? */
const abgelaufen = (letzteMs, nowMs, abstandMs) => letzteMs === null || nowMs - letzteMs >= abstandMs
/** '05.10.2026, 10:15' (Berliner Wanduhr), so steht die Zeit in T20 und in den Meldungen. */
const zeitText = (date) => `${datumKurz(date)}, ${uhrzeit(date)}`
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(/\s+/g, ' ').slice(0, 200)}`
/**
* Meldet einen Vorfall: Warnlog (hoechstens stuendlich je Schluessel) und Team-Mail T20 (beim Beginn und danach fruehestens nach
* 6 Stunden je Schluessel). Wirft nur bei ungueltigen Eingaben (TypeError); ein Mailfehler steht im Log.
* @param {string} schluessel Kennung des Vorfalls ('budget', 'queue', 'pause', ...); die Abkuehlzeit gilt je Schluessel
* @param {string} text die Meldung (Log und Mail-Details): nur Zahlen, nie Adressen oder Namen
* @param {Date} now
* @returns {Promise<void>}
*/
export async function meldeVorfall(schluessel, text, now) {
pruefeText(schluessel, 'schluessel')
pruefeText(text, 'text')
const nowMs = pruefeNow(now, 'meldeVorfall')
const zettel = merkzettel(schluessel)
if (abgelaufen(zettel.logMs, nowMs, LOG_ABSTAND_MS)) {
zettel.logMs = nowMs
console.warn(`${LOG} ${text}`)
}
if (!abgelaufen(zettel.mailMs, nowMs, MAIL_ABKUEHLZEIT_MS)) return
// Vor dem Senden gemerkt: ein zweiter Aufruf, waehrend die Mail noch unterwegs ist, sendet nicht noch einmal
zettel.mailMs = nowMs
const ergebnis = await sendAdminNotice({
template: 'T20',
values: { vorfall: UEBERSCHRIFTEN.get(schluessel) ?? ALLGEMEINE_UEBERSCHRIFT, details: text, zeit: zeitText(now) },
})
if (!ergebnis?.sent) {
console.error(`${LOG} Team-Mail zu „${schluessel}“ nicht gesendet: ${String(ergebnis?.reason ?? 'unbekannt').slice(0, 200)}`)
}
}
/** Lesefehler einer Pruefung ins Log: hoechstens stuendlich je Pruefung, damit ein langer Ausfall das Log nicht fuellt. */
function meldeFehler(schluessel, err, nowMs) {
const zettel = merkzettel(`fehler:${schluessel}`)
if (!abgelaufen(zettel.logMs, nowMs, LOG_ABSTAND_MS)) return
zettel.logMs = nowMs
console.error(`${LOG} Prüfung „${schluessel}“ nicht möglich: ${fehlerText(err)}`)
}
// Die Pruefungen: Text des Vorfalls oder null (kein Vorfall); werfen duerfen sie (Lesefehler), pruefeBetrieb faengt das ab.
function budgetVorfall(now) {
const { benutzt, max } = mailBudget(now).stunde
if (!budgetErreicht(benutzt, max)) return null
return `Mailbudget ${benutzt}/${max} in der letzten Stunde (Hinweis ab 70 %); ist es voll, warten weitere Mails bis zur nächsten freien Stunde`
}
async function queueVorfall(now) {
const { wartet, aeltesteMin } = await warteschlangeAlter(now)
if (!(aeltesteMin > QUEUE_ALTER_MIN)) return null
const anzahl = wartet === null ? '' : `, ${wartet} wartend`
return `Mail-Warteschlange: der älteste fällige Eintrag ist seit ${aeltesteMin} Min überfällig${anzahl}`
}
function pauseVorfall(now) {
const bis = pausiertBis()
if (!bis || !(bis.getTime() > now.getTime())) return null
return `Mailversand pausiert bis ${zeitText(bis)} Uhr (SMTP-Anmeldung fehlgeschlagen), die Mails warten in der Warteschlange`
}
/** In dieser Reihenfolge werden Vorfaelle geprueft und gemeldet. */
const PRUEFUNGEN = [
['budget', budgetVorfall],
['queue', queueVorfall],
['pause', pauseVorfall],
]
/**
* Prueft Mailbudget, Warteschlange und Anmeldepause und meldet jeden aktiven Vorfall ueber meldeVorfall. Wirft nie (ausser mit
* TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): ein Lesefehler oder ein fehlgeschlagener Mailversand steht im Log
* und laesst die uebrigen Pruefungen laufen. Eine Pruefung, die nicht lesen konnte, meldet keinen Vorfall.
* @param {Date} now
* @returns {Promise<string[]>} die Schluessel der aktiven Vorfaelle ('budget', 'queue', 'pause')
*/
export async function pruefeBetrieb(now) {
const nowMs = pruefeNow(now, 'pruefeBetrieb')
const aktiv = []
for (const [schluessel, pruefe] of PRUEFUNGEN) {
try {
const text = await pruefe(now)
if (text === null) continue
aktiv.push(schluessel)
await meldeVorfall(schluessel, text, now)
} catch (err) {
meldeFehler(schluessel, err, nowMs)
}
}
return aktiv
}
// ---------------------------------------------------------------- Tagesjob
/** Eine Zaehlung: ganze Zahl ab 0, alles andere (fehlt, Text, negativ, Bruch) zaehlt als 0. */
const zaehlung = (wert) => (Number.isInteger(wert) && wert > 0 ? wert : 0)
/** gedeckelt = { loeschfristen, abgeschaltet, zellen } (Schritte, die am Deckel endeten) => ihre Summe. */
const summeGedeckelt = (gedeckelt) =>
gedeckelt !== null && typeof gedeckelt === 'object' ? Object.values(gedeckelt).reduce((summe, n) => summe + zaehlung(n), 0) : 0
/**
* Anzahl der aktiven Terminplaner-Betriebe: portalFeatures featureKey termine im Zustand active (ein Aufruf mit limit(1), gelesen wird
* total). Entwurf (off), bestellt, im Einbau und abgeschaltet zaehlen nicht.
* @returns {Promise<number|null>} null, wenn Appwrite keine Anzahl liefert; wirft bei einem Lesefehler
*/
export function zaehleAktiveBetriebe() {
return countDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'active')])
}
/** Stand des letzten Tagesjobs als Kopie ({ at (ISO), fehler, gedeckelt, geloescht }) oder null, solange seit dem Start keiner gemeldet hat. */
export function letzterTagesjob() {
return letzter === null ? null : { ...letzter }
}
/**
* Meldung am Ende des Tagesjobs (taeglicherJob ruft sie mit seinem Ergebnis): merkt den Stand fuer letzterTagesjob() und meldet ueber
* meldeVorfall die Vorfaelle tagesjob (fehler > 0 oder abbruch), deckel (Loeschdeckel an 3 Tagesjobs in Folge) und betriebe (ab 20
* aktiven Betrieben). Die Texte tragen nur Zahlen. Wirft nie (ausser mit TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): ein fehlgeschlagener Mailversand steht
* im Log, die uebrigen Vorfaelle werden trotzdem gemeldet. Zustand (letzter Lauf, Deckel-Folge) lebt im Speicher dieses Prozesses.
* @param {{ fehler?: number, gedeckelt?: { loeschfristen?: number, abgeschaltet?: number, zellen?: number }, geloescht?: number, aktiveBetriebe?: number|null, abbruch?: boolean }} ergebnis
* fehler = Zahl der Fehler des Laufs; gedeckelt = Schritte je Bereich, die am Deckel endeten; geloescht = Summe aller Loeschzaehler;
* aktiveBetriebe = Zahl oder null (unbekannt); abbruch = der Job brach unerwartet ab (dann fehlen die uebrigen Zahlen und die
* Deckel-Folge bleibt unveraendert)
* @param {Date} now
* @returns {Promise<string[]>} die Schluessel der Vorfaelle dieses Laufs ('tagesjob', 'deckel', 'betriebe')
*/
export async function meldeTagesjob(ergebnis, now) {
const nowMs = pruefeNow(now, 'meldeTagesjob')
const gemeldet = []
try {
const abbruch = ergebnis?.abbruch === true
const fehler = zaehlung(ergebnis?.fehler)
const gedeckelt = summeGedeckelt(ergebnis?.gedeckelt)
const betriebe = Number.isInteger(ergebnis?.aktiveBetriebe) && ergebnis.aktiveBetriebe >= 0 ? ergebnis.aktiveBetriebe : null
letzter = { at: new Date(nowMs).toISOString(), fehler, gedeckelt, geloescht: zaehlung(ergebnis?.geloescht) }
if (!abbruch) deckelFolge = gedeckelt > 0 ? deckelFolge + 1 : 0
const vorfaelle = []
if (abbruch || fehler > 0) {
vorfaelle.push([
'tagesjob',
abbruch
? `Tagesjob abgebrochen (unerwarteter Fehler), ${fehler} Fehler gezählt; Einzelheiten stehen im Log`
: `Tagesjob mit ${fehler} Fehler${fehler === 1 ? '' : 'n'}; Einzelheiten stehen im Log`,
])
}
if (deckelFolge >= DECKEL_FOLGE_AB) {
vorfaelle.push([
'deckel',
`Löschläufe endeten an ${deckelFolge} Tagesjobs in Folge am Deckel (im letzten Lauf ${gedeckelt} Schritte); der Rest folgt an den nächsten Tagen, hält der Rückstand an, sind die Löschdeckel zu prüfen`,
])
}
if (betriebe !== null && betriebe >= BETRIEBE_AB) {
vorfaelle.push([
'betriebe',
`${betriebe} aktive Betriebe (Hinweis ab ${BETRIEBE_AB}); ab ${BETRIEBE_UMSTIEG} aktiven Betrieben ist der Umstieg auf einen EU-Versanddienst zu planen`,
])
}
for (const [schluessel, text] of vorfaelle) {
gemeldet.push(schluessel)
try {
await meldeVorfall(schluessel, text, now)
} catch (err) {
meldeFehler(`meldung:${schluessel}`, err, nowMs)
}
}
} catch (err) {
meldeFehler('meldung', err, nowMs)
}
return gemeldet
}
/** Nur fuer Tests: den gemerkten Zustand (Zeit der letzten Warnung und Mail je Schluessel, letzter Tagesjob, Deckel-Folge) zuruecksetzen. */
export function resetBetriebForTests() {
gemerkt.clear()
letzter = null
deckelFolge = 0
}