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>
This commit is contained in:
1 parent
689a21f9ac
commit
4601c5ef3b
4 files changed
+471
-34
No files matched your search
@@ -1,3 +1,5 @@
|
||||
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'
|
||||
@@ -5,7 +7,8 @@ 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).
|
||||
* „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)
|
||||
@@ -13,7 +16,13 @@ import { datumKurz, uhrzeit } from './zeit.js'
|
||||
* eine Wiederholung mit naechsterVersuch in der Zukunft ist nicht faellig und zaehlt nicht
|
||||
* pause die Anmeldepause des Mailservers laeuft (pausiertBis)
|
||||
*
|
||||
* Gemeinsamer Melder (meldeVorfall, auch fuer die Tagesjob-Vorfaelle von A10a3): je Schluessel
|
||||
* 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
|
||||
@@ -33,17 +42,29 @@ const MAIL_ABKUEHLZEIT_MS = 6 * STUNDE_MS
|
||||
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)
|
||||
@@ -151,7 +172,90 @@ export async function pruefeBetrieb(now) {
|
||||
return aktiv
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: den gemerkten Zustand (Zeit der letzten Warnung und Mail je Schluessel) zuruecksetzen. */
|
||||
// ---------------------------------------------------------------- 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
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { config } from '../../config.js'
|
||||
import { Query, deleteDocument, getDocument, listAllDocuments } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
|
||||
import { pruefeBetrieb } from './betrieb.js'
|
||||
import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js'
|
||||
import {
|
||||
abgelaufeneHaltungen,
|
||||
aendere,
|
||||
@@ -31,7 +31,9 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
|
||||
* 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), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen
|
||||
* taeglicherJob taeglich Loeschfristen (11), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen;
|
||||
* 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: jeder Job kehrt sofort zurueck, ohne Abfrage, mit { aus: true }.
|
||||
@@ -592,35 +594,65 @@ 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
|
||||
|
||||
/**
|
||||
* Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben),
|
||||
* Loeschfristen, abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen.
|
||||
* Loeschfristen, 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.
|
||||
* @param {Date} now
|
||||
* @returns {Promise<object | { aus: true } | { uebersprungen: true }>}
|
||||
*/
|
||||
export async function taeglicherJob(now) {
|
||||
return fuehreAus('taeglicherJob', now, async (lauf) => {
|
||||
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
|
||||
const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now)
|
||||
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
|
||||
funktionen: 0,
|
||||
buchungen: 0,
|
||||
gedeckelt: 0,
|
||||
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
|
||||
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
|
||||
const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(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,
|
||||
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
|
||||
})
|
||||
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`)
|
||||
}
|
||||
// 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 }
|
||||
return { festgefahren, loeschfristen: loeschung, gedeckelt, abgeschaltet, aufgeraeumt, zellen: zellenErgebnis.geloescht, fehler: lauf.fehler }
|
||||
})
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zeile fuers Log nach dem Tagesjob: nur Zahlen, keine personenbezogenen Daten und keine Buchungs- oder Funktions-IDs
|
||||
* (Spur der unwiderruflichen Loeschungen). null, wenn der Job nicht gelaufen ist (Modus off, uebersprungen).
|
||||
* (Spur der unwiderruflichen Loeschungen). Ganz am Ende steht „n aktive Betriebe“ (fehlt, wenn die Zahl unbekannt ist).
|
||||
* null, wenn der Job nicht gelaufen ist (Modus off, uebersprungen).
|
||||
* @param {object} ergebnis Rueckgabe von taeglicherJob
|
||||
* @returns {string|null}
|
||||
*/
|
||||
@@ -628,14 +660,15 @@ export function tagesbericht(ergebnis) {
|
||||
if (!ergebnis || ergebnis.aus || ergebnis.uebersprungen) return null
|
||||
const l = ergebnis.loeschfristen
|
||||
const a = ergebnis.abgeschaltet
|
||||
const gesamt = Object.values(l).reduce((summe, n) => summe + n, 0) + a.buchungen
|
||||
const gesamt = buchungenGeloescht(l, a)
|
||||
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 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}${ergebnis.fehler} Fehler`
|
||||
`${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${ergebnis.fehler} Fehler${betriebe}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user