Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt

K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:00:17 +00:00
1 parent ea4457e6a4
commit c7cd436ec2
34 files changed
+359 -354

No files matched your search

+41 -41
View File
@@ -7,7 +7,7 @@ import { LOESCHFRIST_TAGE } from '../termine/frist.js'
import { pruefeNow, zeitMs } from '../termine/util.js'
import { heute, plusTage, zuUtc } from '../termine/zeit.js'
import { nachAbschaltung } from './abschalten.js'
import { k24Mail, k26Mail, k27Mail, sendeZeitenMail } from './mails.js'
import { k27Mail, k29Mail, k30Mail, sendeZeitenMail } from './mails.js'
import { stosseNeubauAn } from './neubau.js'
import { ladeZeiten, loescheZeiten } from './speicher.js'
@@ -20,25 +20,25 @@ import { ladeZeiten, loescheZeiten } from './speicher.js'
* Nur Dokumente mit unlockedBy 'abo' (unlockedByVon) sind ans Abo gekoppelt; 'kauf' und 'team' fasst der Abgleich nie an, und das Abo
* wird dann nicht einmal gelesen. Je Dokument gilt (Abo gelesen mit aboLage, grosszügige Regel inklusive Wechsel-Regel):
*
* setup/active, Abo läuft nicht, aboEndeAt leer Schritt 1: aboEndeAt = now, K26 (Variante nach art; Frist = Berliner Tag von
* setup/active, Abo läuft nicht, aboEndeAt leer Schritt 1: aboEndeAt = now, K29 (Variante nach art; Frist = Berliner Tag von
* aboEndeAt + 30 Tage)
* setup/active, Abo läuft nicht, Frist erreicht Schritt 2: disabled/'abo_ende', dann das Ersatz-Ticket des Teams
* (nachAbschaltung), dann K27 (Löschdatum = Abschalten + 30 Tage)
* (nachAbschaltung), dann K30 (Löschdatum = Abschalten + 30 Tage)
* setup/active, Abo läuft, aboEndeAt gesetzt Schritt 3a: das Abo ist vor dem Abschalten zurück: aboEndeAt, Marker und offene
* Behalten-Sitzung leeren, Zustand bleibt, KEIN Neubau (die Leisten waren nie weg),
* K24 "wieder" ohne den Kasten "Wie geht es weiter?" (k24Mail einbau null, R17b)
* K27 "wieder" ohne den Kasten "Wie geht es weiter?" (k27Mail einbau null, R17b)
* disabled/'abo_ende', Abo läuft Schritt 3b: zurück auf active (war die Funktion schon einmal freigeschaltet:
* activatedAt) sonst setup; Neubau nach der Sperre (stosseNeubauAn), K24 "wieder"
* activatedAt) sonst setup; Neubau nach der Sperre (stosseNeubauAn), K27 "wieder"
* mit dem echten einbau. Solange die Eingaben noch da sind: auch am Tag der Löschung
* geht die Rückkehr der Löschung vor (die Rückkehr wird je Lauf zuerst entschieden).
* disabled/'abo_ende', Abo läuft nicht, 30 Tage Schritt 4: loescheZeiten (daten, team, Merker) und state 'off'; ticketId, einbau,
* nach disabledAt k24At, activatedAt, aboEndeAt und behaltenSessionId leer (ersatzHash lebt im gelöschten
* nach disabledAt einschaltMailAt, activatedAt, aboEndeAt und behaltenSessionId leer (ersatzHash lebt im gelöschten
* Zeiten-Dokument). Grund 'team' (und jeder andere Grund) wird nie gelöscht.
*
* Tage zählen nach BERLINER KALENDERTAGEN (plusTage, nie Vielfache von 24 Stunden): "Tag 30" ist der 30. Berliner Tag nach dem Tag, an dem
* das Abo-Ende erkannt wurde, egal um welche Uhrzeit der Tagesjob an diesem Tag läuft (er läuft im 24-Stunden-Takt ab dem Start des Servers
* und kann je Tag ein paar Minuten früher oder später liegen; eine Zeitumstellung verschöbe die Frist um eine Stunde). Dieselben Tage stehen
* in K26 und K27.
* in K29 und K30.
*
* Regeln (wie termine/aboAbgleich.js):
* - Eine Listenabfrage für alle Kunden (Entwürfe 'off' bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird für die
@@ -49,12 +49,12 @@ import { ladeZeiten, loescheZeiten } from './speicher.js'
* - Fail-closed: ist das Abo nicht lesbar (Vorauswahl oder unter der Sperre), wird nichts abgeschaltet, nichts gelöscht und nichts
* eingeschaltet. Der Kunde wird übersprungen, `fehler` zählt, der nächste Lauf versucht es wieder. Ein Dokument mit unlesbarem Datum
* wird übersprungen (`fehler`), die übrigen laufen weiter. Das Log nennt weder Namen noch Adressen.
* - Mails: aboEndeMailAt ist der Marker "die Mail dieser Phase ging raus", gesetzt NACH dem Versand: in der Frist gilt er der K26, nach dem
* Abschalten der K27 (nur ein Marker, der nicht vor disabledAt liegt: k27Gesendet; ein Zurücksetzen beim Abschalten gibt es nicht, es
* - Mails: aboEndeMailAt ist der Marker "die Mail dieser Phase ging raus", gesetzt NACH dem Versand: in der Frist gilt er der K29, nach dem
* Abschalten der K30 (nur ein Marker, der nicht vor disabledAt liegt: k30Gesendet; ein Zurücksetzen beim Abschalten gibt es nicht, es
* könnte scheitern). Scheitert der Versand, bleibt der Marker leer, der nächste Lauf sendet nach (dieselben Daten; die Frist wächst
* nie, aboEndeAt und disabledAt ändert der Abgleich nicht mehr). Die K27 geht erst nach dem Ersatz-Ticket raus: scheitert es, gibt es
* weder K27 noch Marker, der nächste Lauf wiederholt beides (Ticket idempotent über den Hash, K27 so höchstens einmal). Ein Kunde ohne
* Adresse bekommt den Marker (es gibt nichts zu senden). K24 "wieder" geht nur an Kunden, die K26 oder K27 bekommen haben (Marker, vor dem Leeren gelesen)
* nie, aboEndeAt und disabledAt ändert der Abgleich nicht mehr). Die K30 geht erst nach dem Ersatz-Ticket raus: scheitert es, gibt es
* weder K30 noch Marker, der nächste Lauf wiederholt beides (Ticket idempotent über den Hash, K30 so höchstens einmal). Ein Kunde ohne
* Adresse bekommt den Marker (es gibt nichts zu senden). K27 "wieder" geht nur an Kunden, die K29 oder K30 bekommen haben (Marker, vor dem Leeren gelesen)
* und hat keinen Marker: höchstens einmal je Rückkehr (scheitert der Versand, steht es im Log und in `fehler`).
* - Ruling R25: die Rückkehr leert aboEndeAt und behaltenSessionId; eine noch offene Behalten-Zahlung bucht danach nicht mehr zum Kauf
* (bucheBehalten: Sitzung passt nicht, T19), und starteBehalten lehnt bei laufendem Abo ab.
@@ -97,27 +97,27 @@ const mittags = (tag) => zuUtc(tag, '12:00')
/**
* Was mit dem Dokument bei dieser Abo-Lage und diesem Tag zu tun ist (reine Entscheidung, null = nichts):
* 'ende' | 'k26' | 'abschalten' | 'k27' | 'loeschen' | 'zurueck' | 'zurueck_ohne_neubau'. Wirft bei unlesbarem Datum.
* 'ende' | 'k29' | 'abschalten' | 'k30' | 'loeschen' | 'zurueck' | 'zurueck_ohne_neubau'. Wirft bei unlesbarem Datum.
*/
function entscheide(doc, lage, now) {
const tag = heute(now)
if (istAbgeschaltet(doc)) {
if (lage.laeuft) return 'zurueck'
if (tag >= loeschTag(doc)) return 'loeschen'
return k27Gesendet(doc) ? null : 'k27'
return k30Gesendet(doc) ? null : 'k30'
}
if (lage.laeuft) return doc.aboEndeAt ? 'zurueck_ohne_neubau' : null
if (!doc.aboEndeAt) return 'ende'
if (tag >= fristTag(doc)) return 'abschalten'
return doc.aboEndeMailAt ? null : 'k26'
return doc.aboEndeMailAt ? null : 'k29'
}
/**
* Hat die K27 ihren Marker? aboEndeMailAt gilt der K27 nur, wenn es NACH dem Abschalten gesetzt wurde: der Marker der K26 (aus der Frist)
* ist älter als disabledAt. So hängt die K27 nicht daran, dass der Abgleich nach disable() den Marker noch zurücksetzen konnte
* (scheiterte dieses Schreiben, blieb der K26-Marker stehen und K27 samt Ersatz-Ticket liefen nie).
* Hat die K30 ihren Marker? aboEndeMailAt gilt der K30 nur, wenn es NACH dem Abschalten gesetzt wurde: der Marker der K29 (aus der Frist)
* ist älter als disabledAt. So hängt die K30 nicht daran, dass der Abgleich nach disable() den Marker noch zurücksetzen konnte
* (scheiterte dieses Schreiben, blieb der K29-Marker stehen und K30 samt Ersatz-Ticket liefen nie).
*/
const k27Gesendet = (doc) => Boolean(doc.aboEndeMailAt) && !(zeitMs(doc.aboEndeMailAt) < zeitMs(doc.disabledAt))
const k30Gesendet = (doc) => Boolean(doc.aboEndeMailAt) && !(zeitMs(doc.aboEndeMailAt) < zeitMs(doc.disabledAt))
/** Sind Eingaben des Kunden gespeichert? Lesefehler werden weitergereicht (dann bleibt alles, wie es ist). */
async function datenDa(featureId) {
@@ -131,7 +131,7 @@ async function loesche(frisch, ctx) {
...stateFields('off'),
ticketId: '',
einbau: '',
k24At: null,
einschaltMailAt: null,
activatedAt: null, // ein späteres Einschalten beginnt wieder in setup (sonst käme es durch das alte activatedAt zu früh auf active)
aboEndeAt: null,
behaltenSessionId: '',
@@ -157,17 +157,17 @@ async function unterSperre(doc, ctx) {
case 'ende': {
const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null })
erg.aboEnde += 1
return { aktion: 'k26', doc: neu, lage }
return { aktion: 'k29', doc: neu, lage }
}
case 'k26':
case 'k27':
case 'k29':
case 'k30':
return { aktion, doc: frisch, lage }
case 'abschalten': {
const aus = await disable(frisch.$id, { reason: GRUND, adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_ABGESCHALTET}`) })
if (aus.state !== 'disabled' || aus.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller
erg.abgeschaltet += 1
// ab jetzt gilt der Marker der K27, nicht mehr der der K26 (k27Gesendet: der K26-Marker ist älter als disabledAt, kein Zurücksetzen nötig)
return { aktion: 'k27', doc: aus, lage }
// ab jetzt gilt der Marker der K30, nicht mehr der der K29 (k30Gesendet: der K29-Marker ist älter als disabledAt, kein Zurücksetzen nötig)
return { aktion: 'k30', doc: aus, lage }
}
case 'loeschen':
await loesche(frisch, ctx)
@@ -221,9 +221,9 @@ const projektName = (projekt) => projekt?.projectName || projekt?.subdomain || '
const preis = () => config.features.zeiten.priceLabel
/**
* K26 oder K27 an die E-Mail des Kundenkontos; danach der Marker. Wirft nie bei Versandfehlern (fehler zählt, der nächste Lauf wiederholt).
* `nichtVor`: der Marker liegt nie vor diesem Zeitpunkt (bei der K27: disabledAt, das die Wanduhr beim Abschalten ein paar Sekunden nach dem
* `now` des Laufs schreibt; ein früherer Marker gälte k27Gesendet nicht und die K27 ginge jeden Tag erneut raus).
* K29 oder K30 an die E-Mail des Kundenkontos; danach der Marker. Wirft nie bei Versandfehlern (fehler zählt, der nächste Lauf wiederholt).
* `nichtVor`: der Marker liegt nie vor diesem Zeitpunkt (bei der K30: disabledAt, das die Wanduhr beim Abschalten ein paar Sekunden nach dem
* `now` des Laufs schreibt; ein früherer Marker gälte k30Gesendet nicht und die K30 ginge jeden Tag erneut raus).
*/
async function sendeMitMarker(doc, bauen, ctx, nichtVor = null) {
const customer = await customerOf(doc.customerId)
@@ -238,33 +238,33 @@ async function sendeMitMarker(doc, bauen, ctx, nichtVor = null) {
await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: marker })
}
/** K24 "wieder" (ohne Marker, höchstens einmal je Rückkehr). einbau null = es gab keinen Neubau. */
/** K27 "wieder" (ohne Marker, höchstens einmal je Rückkehr). einbau null = es gab keinen Neubau. */
async function sendeWiederMail(doc, einbau, ctx) {
const customer = await customerOf(doc.customerId)
const projekt = projektName(await ladeProjekt(doc.projectId))
const { sent, reason } = await sendeZeitenMail(customer, k24Mail({ kunde: customer, projekt, einbau, wieder: true }))
const { sent, reason } = await sendeZeitenMail(customer, k27Mail({ kunde: customer, projekt, einbau, wieder: true }))
if (sent) ctx.erg.mails += 1
else if (reason !== 'keine_adresse') ctx.erg.fehler += 1
}
/**
* Was nach der Sperre folgt (Netzwerk: Ticket, Neubau, Mails): K26 mit Marker; nach dem Abschalten das Ersatz-Ticket (R23,
* nachAbschaltung ist idempotent über den Ersatz-Hash und wirft nie), dann K27 mit Marker; nach der Rückkehr aus 'disabled' der Neubau
* und K24 "wieder" mit dem echten einbau, nach der Rückkehr ohne Abschalten K24 "wieder" mit einbau null.
* Was nach der Sperre folgt (Netzwerk: Ticket, Neubau, Mails): K29 mit Marker; nach dem Abschalten das Ersatz-Ticket (R23,
* nachAbschaltung ist idempotent über den Ersatz-Hash und wirft nie), dann K30 mit Marker; nach der Rückkehr aus 'disabled' der Neubau
* und K27 "wieder" mit dem echten einbau, nach der Rückkehr ohne Abschalten K27 "wieder" mit einbau null.
*/
async function danach({ aktion, doc, lage, hatteMail }, ctx) {
switch (aktion) {
case 'k26':
await sendeMitMarker(doc, ({ kunde, projekt }) => k26Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx)
case 'k29':
await sendeMitMarker(doc, ({ kunde, projekt }) => k29Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx)
return
case 'k27':
// Erst das Ersatz-Ticket, dann K27 mit Marker. Scheitert das Ticket, gibt es weder K27 noch Marker: der nächste Lauf wiederholt beides
// (das Ticket ist über den Hash idempotent, die K27 ging noch nicht raus und geht so höchstens einmal raus)
case 'k30':
// Erst das Ersatz-Ticket, dann K30 mit Marker. Scheitert das Ticket, gibt es weder K30 noch Marker: der nächste Lauf wiederholt beides
// (das Ticket ist über den Hash idempotent, die K30 ging noch nicht raus und geht so höchstens einmal raus)
if ((await nachAbschaltung(doc, { now: ctx.now })) === 'fehler') {
ctx.erg.fehler += 1
return
}
await sendeMitMarker(doc, ({ kunde, projekt }) => k27Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx, doc.disabledAt)
await sendeMitMarker(doc, ({ kunde, projekt }) => k30Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx, doc.disabledAt)
return
case 'zurueck': {
const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId))
@@ -323,8 +323,8 @@ async function bearbeiteKunde(customerId, dokumente, ctx) {
* @returns {Promise<{ geprueft: number, aboEnde: number, abgeschaltet: number, wiederEin: number, geloescht: number, mails: number, fehler: number }>}
* geprueft = ans Abo gekoppelte Dokumente (unlockedBy abo, setup/active oder abgeschaltet mit Grund abo_ende), deren Kunde lesbar war;
* aboEnde = in diesem Lauf als beendet erkannt (Schritt 1); abgeschaltet = in diesem Lauf abgeschaltet (2); wiederEin = in diesem Lauf
* zurückgekehrt (3, mit und ohne Abschalten); geloescht = in diesem Lauf gelöscht (4); mails = versendete Kundenmails K24 "wieder", K26 und
* K27; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets
* zurückgekehrt (3, mit und ohne Abschalten); geloescht = in diesem Lauf gelöscht (4); mails = versendete Kundenmails K27 "wieder", K29 und
* K30; fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie gescheiterte Mails und Ersatz-Tickets
*/
export async function gleicheZeitenAboAb(now) {
pruefeNow(now, 'gleicheZeitenAboAb')