import { config } from '../../config.js' import { ohneAboBisher } from '../aboAufbauphase.js' import { ABRECHNUNGEN, istAboModell } from '../aboModell.js' import { aboLage, aboMemo } from '../aboStatus.js' import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { activate, customerOf, disable, notizVorn, stateFields, unlockedByVon, ZEITEN_AKTIVIERUNG_FELDER } from '../siteFeatures.js' 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 { k27Mail, k29Mail, k30Mail, sendeZeitenMail } from './mails.js' import { stosseNeubauAn } from './neubau.js' import { ladeZeiten, loescheZeiten } from './speicher.js' /** * Abo-Abgleich von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 5 "Abo-Abgleich" und Z11): * Die Funktion gehört im Wartungs-Abo ohne Aufpreis dazu und endet mit ihm, aber nicht sofort: 30 Tage Frist zum Behalten (30 €), danach * ist sie abgeschaltet und die Eingaben bleiben noch 30 Tage. gleicheZeitenAboAb(now) ist der Baustein des Tagesjobs (angeschlossen in * Task 22); er kennt weder Schalter noch Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen. * * Nur Dokumente mit unlockedBy 'abo' oder 'projekt' (unlockedByVon) sind ans Abo gekoppelt; 'kauf' und 'team' fasst der Abgleich nie an, und * das Abo wird dann nicht einmal gelesen. 'projekt' (Kundenansicht neu, P6) gilt wie 'abo', ausser in der Aufbauphase (E9): ein projekt-Dokument * im Zustand setup oder active bleibt unberuehrt, solange das Abo nicht laeuft und der Kunde noch nie ein Abo hatte (ohneAboBisher): die * Website ist noch im Bau. Mit dem ersten Abo gilt die Abo-Regel auch fuer projekt. Dokumente des Abo-Modells (istAboModell: Marker * einrichtungAbrechnung) bekommen K40 und K41 statt K29 und K30 (ohne Preis) und kein Behalten (behalten.js); Gratis-Zeiten ohne Marker * behalten K29, K30 und den Behalten-Weg. 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, 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 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), * 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), 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 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 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 * Vorauswahl nur einmal gelesen; Dokumente, bei denen nichts zu tun ist (Abo läuft, kein Abo-Ende), kosten weder Sperre noch Schreiben. * - Geschrieben wird nur unter der Sperre feature-admin: (wie die Team-Route) und nach frischem Lesen von Dokument UND Abo * (aboLage direkt, nie der Memo): der frische Stand entscheidet, nicht die Vorauswahl. Mails, Neubau und Ersatz-Ticket folgen NACH * der Sperre (Netzwerk; Sperren sind nicht wiederbetretbar). * - 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 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 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. */ const KEY = 'zeiten' const GRUND = 'abo_ende' /** Frist zum Behalten nach dem erkannten Abo-Ende (Z11); die Datenfrist nach dem Abschalten ist LOESCHFRIST_TAGE (30). */ const FRIST_TAGE = 30 const NOTIZ_ABGESCHALTET = 'System: Abgeschaltet: Wartungs-Abo beendet, Frist abgelaufen' const NOTIZ_WIEDER = 'System: Wieder eingeschaltet: Wartungs-Abo läuft wieder' const NOTIZ_OHNE_NEUBAU = 'System: Wartungs-Abo läuft wieder, Abo-Ende zurückgenommen' const NOTIZ_GELOESCHT = `System: Öffnungszeiten gelöscht, ${LOESCHFRIST_TAGE} Tage nach dem Abschalten` const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g /** Fehlertext fürs Log: Name und gekürzte Meldung, E-Mail-Adressen (SMTP-Antworten nennen sie) unkenntlich. */ const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}` const portalFeatures = () => config.collections.portalFeatures const istLaufend = (doc) => doc.state === 'setup' || doc.state === 'active' const istAbgeschaltet = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND /** Ans Wartungs-Abo gekoppelt: von ihm freigeschaltet (abo oder, im Abo-Modell, projekt) und im laufenden Betrieb oder wegen des Abo-Endes abgeschaltet. */ const istGekoppelt = (doc) => ABRECHNUNGEN.includes(unlockedByVon(doc)) && (istLaufend(doc) || istAbgeschaltet(doc)) /** Aufbauphasen-Kandidat (E9): im Projekt eingebaut und noch im laufenden Betrieb; ob der Kunde je ein Abo hatte, entscheidet ohneAboBisher. */ const istProjektImBetrieb = (doc) => unlockedByVon(doc) === 'projekt' && istLaufend(doc) /** Berliner Kalendertag eines Datumsfeldes; unlesbar ist ein Fehler (das Dokument wird übersprungen), nie "heute". */ function tagVon(wert, feld) { const t = zeitMs(wert) if (!Number.isFinite(t)) throw new Error(`${feld} nicht lesbar`) return heute(new Date(t)) } /** Tag, an dem abgeschaltet wird: Berliner Tag von aboEndeAt + 30 Tage. */ const fristTag = (doc) => plusTage(tagVon(doc.aboEndeAt, 'aboEndeAt'), FRIST_TAGE) /** Tag, an dem die Eingaben gelöscht werden: Berliner Tag von disabledAt + 30 Tage. */ const loeschTag = (doc) => plusTage(tagVon(doc.disabledAt, 'disabledAt'), LOESCHFRIST_TAGE) /** Ein Date mitten an einem Berliner Tag: die Mail-Builder zeigen den Berliner Tag dieses Zeitpunkts (kein Springen über Mitternacht). */ 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' | '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 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 : 'k29' } /** * 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 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) { return (await ladeZeiten(featureId))?.daten != null } /** Daten löschen (unter der Sperre feature-admin:), danach das Dokument auf 'off' und leer. */ async function loesche(frisch, ctx) { await loescheZeiten(frisch.$id) await updateDocument(portalFeatures(), frisch.$id, { ...stateFields('off'), ticketId: '', einbau: '', 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: '', adminNote: notizVorn(frisch.adminNote, `${ctx.jetzt} ${NOTIZ_GELOESCHT}`), }) ctx.erg.geloescht += 1 } /** * Ein Dokument unter der Sperre feature-admin:: frisch lesen, frisch das Abo lesen, den frischen Stand entscheiden lassen und * schreiben. Liefert, was nach der Sperre zu tun ist ({ aktion, doc, lage, hatteMail }), oder null (nichts mehr zu tun, das Team war * schneller). Wirft bei Lesefehlern (Dokument oder Abo): dann ist nichts geschrieben. */ async function unterSperre(doc, ctx) { const { erg, jetzt, now } = ctx const frisch = await getDocument(portalFeatures(), doc.$id) if (!istGekoppelt(frisch)) return null // das Team war schneller (anderer Grund, anderer Zustand, nicht mehr im Abo) const lage = await aboLage(frisch.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist // Aufbauphase (E9): ein im Projekt eingebautes Dokument bleibt, solange es nie ein Abo gab (frisch gelesen; ein Lesefehler wirft: nichts geschrieben) if (istProjektImBetrieb(frisch) && !lage.laeuft && (await ohneAboBisher(frisch.customerId))) return null const aktion = entscheide(frisch, lage, now) const hatteMail = Boolean(frisch.aboEndeMailAt) // vor dem Leeren gelesen: wer nie vom Ende erfuhr, hört nicht von der Rückkehr switch (aktion) { case 'ende': { const neu = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: jetzt, aboEndeMailAt: null }) erg.aboEnde += 1 return { aktion: 'k29', doc: neu, lage } } 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 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) return null case 'zurueck': { // Löschtag erreicht und die Daten sind schon weg (ein früherer Lauf wurde nach dem Löschen unterbrochen): nichts einzuschalten if (heute(now) >= loeschTag(frisch) && !(await datenDa(frisch.$id))) { await loesche(frisch, ctx) return null } const note = notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_WIEDER}`) const zurueck = frisch.activatedAt ? await activate(frisch.$id, { adminNote: note, extra: ZEITEN_AKTIVIERUNG_FELDER }) : await updateDocument(portalFeatures(), frisch.$id, { ...stateFields('setup'), disabledAt: null, disabledReason: null, aboEndeMailAt: null, aboEndeAt: null, behaltenSessionId: '', adminNote: note, }) erg.wiederEin += 1 return { aktion, doc: zurueck, lage, hatteMail } } case 'zurueck_ohne_neubau': { const zurueck = await updateDocument(portalFeatures(), frisch.$id, { aboEndeAt: null, aboEndeMailAt: null, behaltenSessionId: '', adminNote: notizVorn(frisch.adminNote, `${jetzt} ${NOTIZ_OHNE_NEUBAU}`), }) erg.wiederEin += 1 return { aktion, doc: zurueck, lage, hatteMail } } default: return null } } /** Das Projekt für Neubau und Mailtext; nicht lesbar oder gelöscht ergibt null (der Neubau meldet dann 'manuell', der Name fehlt). */ async function ladeProjekt(projectId) { try { return await getDocument(config.collections.websiteProjects, projectId) } catch (err) { if (err?.status !== 404) console.error(`[zeiten-abo] Projekt ${projectId} nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) return null } } const projektName = (projekt) => projekt?.projectName || projekt?.subdomain || '' const preis = () => config.features.zeiten.priceLabel /** * 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) const projekt = projektName(await ladeProjekt(doc.projectId)) const { sent, reason } = await sendeZeitenMail(customer, bauen({ kunde: customer, projekt })) if (sent) ctx.erg.mails += 1 else if (reason !== 'keine_adresse') { ctx.erg.fehler += 1 // Marker bleibt leer: der nächste Lauf sendet nach return } const marker = nichtVor && zeitMs(nichtVor) > zeitMs(ctx.jetzt) ? new Date(zeitMs(nichtVor)).toISOString() : ctx.jetzt await updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: marker }) } /** 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, 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): 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 'k29': await sendeMitMarker(doc, ({ kunde, projekt }) => k29Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis(), modell: istAboModell(doc) }), ctx) return 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 }) => k30Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis(), modell: istAboModell(doc) }), ctx, doc.disabledAt) return case 'zurueck': { const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId)) try { await updateDocument(portalFeatures(), doc.$id, { einbau }) } catch (err) { console.error(`[zeiten-abo] einbau für Funktion ${doc.$id} nicht gespeichert: ${fehlerText(err)}`) } if (hatteMail) await sendeWiederMail(doc, einbau, ctx) return } case 'zurueck_ohne_neubau': if (hatteMail) await sendeWiederMail(doc, null, ctx) return default: } } /** Ein Dokument: unter der Sperre schreiben, danach die Folgen. Wirft bei Fehlern (der Aufrufer zählt fehler). */ async function bearbeiteDokument(doc, ctx) { const folge = await withLocks([`feature-admin:${doc.projectId}`], () => unterSperre(doc, ctx)) if (folge) await danach(folge, ctx) } async function bearbeiteKunde(customerId, dokumente, ctx) { const { erg, now } = ctx const gekoppelt = dokumente.filter(istGekoppelt) if (gekoppelt.length === 0) return // nichts zu bewerten: das Abo wird nicht gelesen let lage try { lage = await ctx.memo.lage(customerId) } catch (err) { erg.fehler += 1 console.error(`[zeiten-abo] Abo-Lage eines Kunden nicht lesbar, ${gekoppelt.length} Funktion(en) übersprungen: ${fehlerText(err)}`) return } // Aufbauphase (E9): im Projekt eingebaute Dokumente bleiben in Ruhe, solange das Abo nicht laeuft und der Kunde noch nie ein Abo hatte; // gelesen wird das nur, wenn es ein solches Dokument gibt. Ein Lesefehler ueberspringt den Kunden wie beim Lesen der Abo-Lage. let zuBewerten = gekoppelt if (!lage.laeuft && gekoppelt.some(istProjektImBetrieb)) { try { if (await ohneAboBisher(customerId)) zuBewerten = gekoppelt.filter((doc) => !istProjektImBetrieb(doc)) } catch (err) { erg.fehler += 1 console.error(`[zeiten-abo] Abo-Verlauf eines Kunden nicht lesbar, ${gekoppelt.length} Funktion(en) übersprungen: ${fehlerText(err)}`) return } } erg.geprueft += zuBewerten.length for (const doc of zuBewerten) { // je Dokument gefangen (auch Entscheidung, Builder und Versand): ein kaputtes Datum stoppt weder den Kunden noch den Lauf try { if (!entscheide(doc, lage, now)) continue // Vorauswahl mit dem Memo: nichts zu tun kostet weder Sperre noch Abo-Abfrage await bearbeiteDokument(doc, ctx) } catch (err) { erg.fehler += 1 console.error(`[zeiten-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`) } } } /** * Täglicher Abgleich von Öffnungszeiten live mit dem Wartungs-Abo (siehe Kopf der Datei). Wirft nie (ausser bei fehlendem `now`, wie * alle Termine-Jobs); jedes Problem steht im Log und in `fehler`. * @param {Date} now Zeitpunkt des Laufs (die Tage zählen nach dem Berliner Kalendertag von now) * @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 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') const erg = { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 } try { let dokumente try { dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', KEY), Query.notEqual('state', 'off')]) } catch (err) { erg.fehler += 1 console.error(`[zeiten-abo] Funktionen nicht lesbar, der Abgleich entfällt: ${fehlerText(err)}`) return erg } const nachKunde = new Map() for (const doc of dokumente) { if (!doc.customerId || !doc.projectId) { // ohne Kunde gälte "kein Abo": nie abschalten oder löschen, was sich nicht zuordnen lässt if (unlockedByVon(doc) === 'abo') { erg.fehler += 1 console.error(`[zeiten-abo] Funktion ${doc.$id} ohne Kunde oder Projekt übersprungen`) } continue } if (!nachKunde.has(doc.customerId)) nachKunde.set(doc.customerId, []) nachKunde.get(doc.customerId).push(doc) } const ctx = { erg, now, jetzt: now.toISOString(), memo: aboMemo() } for (const [customerId, liste] of nachKunde) { try { await bearbeiteKunde(customerId, liste, ctx) } catch (err) { erg.fehler += 1 console.error(`[zeiten-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`) } } } catch (err) { erg.fehler += 1 console.error(`[zeiten-abo] Abgleich abgebrochen: ${fehlerText(err)}`) } return erg }