diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js
index d13541b..fa91905 100644
--- a/server/services/termine/einbauanleitung.js
+++ b/server/services/termine/einbauanleitung.js
@@ -1,12 +1,18 @@
-import { VORGABE_FARBE } from './einstellungen.js'
+import { config } from '../../config.js'
+import { VORGABE_FARBE, preisHinweis, preisText, sichtbareLeistungen, zahlungText, zeigtWiderruf } from './einstellungen.js'
+import { RECHTSTEXTE_ENTWURF } from './rechtstexte.js'
+import { datumKurz, heute } from './zeit.js'
/**
- * Einbau des Terminplaners auf der Website des Kunden: Bausteine fuer Einbau-Code und Farbvorschlag
- * (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt Einbauanleitung; Anleitung fuer das Team:
- * docs/funktionen/termine-einbau.md, Abschnitte 1 und 3).
+ * Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt
+ * Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md).
*
- * Reine Funktionen ohne Appwrite und ohne config: die Adresse des Portals (`apiBase`) und alle Werte kommen als Parameter.
- * Der Einbau-Code stand bis TP2 in routes/termine.js; GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
+ * Zwei Teile in einer Datei:
+ * - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse
+ * des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js;
+ * GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
+ * - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen.
+ * Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit).
*
* Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende),
* bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js
@@ -119,3 +125,309 @@ export function cssVorschlag(farbe) {
].join('\n')
return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise }
}
+
+// ================================================================ Generator einbauanleitung()
+// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b haengt Einbau bis Abrechnung, die
+// Warnungen und den vollen Volltext an, Task D4 die Kurzfassung (`kurz`).
+//
+// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender-
+// Abo der Ressource), die Betriebs-E-Mail (sie steht nur im Datenschutz-Absatz, D3b), Beschreibungen der Leistungen und alles,
+// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text) stehen unmaskiert als Text; wer sie anzeigt, setzt
+// sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, damit ein Name keine Ueberschrift vortaeuschen kann.
+
+const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
+const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
+const MAX_ABWESENHEITEN = 20
+const MAX_SONDEROEFFNUNGEN = 20
+const NICHT_ANGEGEBEN = 'nicht angegeben'
+const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
+const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' }
+const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' }
+
+const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
+const liste = (wert) => (Array.isArray(wert) ? wert : [])
+const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
+const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe)
+
+/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */
+const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '')
+
+/** '2026-10-05' => '05.10.2026'. */
+const tagText = (tag) => tag.split('-').reverse().join('.')
+
+/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */
+function fensterText(fenster) {
+ return liste(fenster)
+ .filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string')
+ .map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`)
+ .join(', ')
+}
+
+/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */
+export function wochenplanText(plan) {
+ const aus = []
+ for (let tag = 1; tag <= 7; tag += 1) {
+ const text = fensterText(istObjekt(plan) ? plan[tag] : null)
+ if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`)
+ }
+ return aus
+}
+
+/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */
+export function kuerzeListe(eintraege, max) {
+ if (eintraege.length <= max) return [...eintraege]
+ return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`]
+}
+
+const neuerAbschnitt = (id, titel, text) => ({ id, titel, text, kopierbar: false })
+
+/** Zeilen als Aufzaehlung: '- Eintrag'. */
+const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`)
+
+// ---------------------------------------------------------------- Abschnitt Modell
+
+function standText(d) {
+ if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version'
+ return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}`
+}
+
+function abschnittModell(d) {
+ const { e, betrieb: b } = d
+ const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length
+ const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN
+ const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '')
+ return neuerAbschnitt('modell', 'Modell', [
+ `Erzeugt am ${datumKurz(d.now)}`,
+ `Stand der Einstellungen: ${standText(d)}`,
+ ...(d.kunde ? [`Kunde: ${d.kunde}`] : []),
+ ...(d.projekt ? [`Projekt: ${d.projekt}`] : []),
+ `Betrieb: ${angabe(b.name)}`,
+ `Anschrift: ${angabe(b.adresse)}`,
+ `Telefon: ${angabe(b.telefon)}`,
+ `Impressum: ${angabe(b.impressumUrl)}`,
+ `Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`,
+ ...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []),
+ ...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []),
+ `Farbe: ${d.farbe}`,
+ `Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`,
+ `Gäste: ${gaeste}`,
+ `Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), `
+ + `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`,
+ `Portal-Adresse (data-api): ${d.portal}`,
+ ].join('\n'))
+}
+
+// ---------------------------------------------------------------- Abschnitt Leistungen
+
+/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */
+function zuordnungText(ids, nachId) {
+ const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean)
+ if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet'
+ const plaetze = treffer.filter((r) => r.typ === 'platz').length
+ const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze'
+ return `${label}: ${treffer.map((r) => r.name).join(', ')}`
+}
+
+function leistungsZeile(l, nachId) {
+ const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt'
+ const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : ''
+ return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, `
+ + `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}`
+}
+
+function abschnittLeistungen(d) {
+ if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.')
+ const aktiv = d.leistungen.filter((l) => l.aktiv).length
+ return neuerAbschnitt('leistungen', 'Leistungen', [
+ `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`,
+ ...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))),
+ ].join('\n'))
+}
+
+// ---------------------------------------------------------------- Abschnitt Ressourcen
+
+function ressourcenBlock(r) {
+ const plan = wochenplanText(r.plan)
+ return [
+ `- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`,
+ ...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`),
+ ].join('\n')
+}
+
+/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */
+function abwesenheitsZeilen(d) {
+ const eintraege = liste(d.e.abwesenheiten)
+ .filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag)
+ .sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0))
+ .map((a) => {
+ const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}`
+ const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle'
+ return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})`
+ })
+ return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN))
+}
+
+/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */
+function sonderoeffnungsZeilen(d) {
+ const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {}
+ const eintraege = Object.keys(roh)
+ .filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag)
+ .sort()
+ .map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`)
+ return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN))
+}
+
+function abschnittRessourcen(d) {
+ const abwesenheiten = abwesenheitsZeilen(d)
+ const sonder = sonderoeffnungsZeilen(d)
+ return neuerAbschnitt('ressourcen', 'Personen und Plätze', [
+ d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.',
+ ...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []),
+ ...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []),
+ ].join('\n\n'))
+}
+
+// ---------------------------------------------------------------- Abschnitt Regeln
+
+/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */
+function vorlaufText(minuten) {
+ if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN
+ if (minuten === 0) return 'kein Vorlauf'
+ if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage')
+ if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden')
+ return anzahl(minuten, 'Minute', 'Minuten')
+}
+
+function stornoZeilen(storno) {
+ const text = typeof storno?.text === 'string' ? storno.text.trim() : ''
+ if (!text) return ['Stornobedingungen: keine hinterlegt']
+ return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)]
+}
+
+function abschnittRegeln(d) {
+ const { e } = d
+ const r = istObjekt(e.regeln) ? e.regeln : {}
+ const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN)
+ return neuerAbschnitt('regeln', 'Regeln', [
+ `Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`,
+ `Vorlauf: ${vorlaufText(r.vorlaufMin)}`,
+ `Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`,
+ `Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`,
+ `Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`,
+ `Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`,
+ `Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`,
+ zahlungText(e),
+ `Preishinweis: ${preisHinweis(e)}`,
+ zeigtWiderruf(e)
+ ? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt'
+ : 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
+ ...stornoZeilen(e.storno),
+ ].join('\n'))
+}
+
+// ---------------------------------------------------------------- Abschnitt Gastfelder
+
+function abschnittGastfelder(d) {
+ const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {}
+ const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => {
+ const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text'
+ return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})`
+ })
+ return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [
+ 'Name: Pflicht',
+ 'E-Mail-Adresse: Pflicht',
+ `Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`,
+ `Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`,
+ ...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']),
+ ].join('\n'))
+}
+
+// ---------------------------------------------------------------- Abschnitt Benachrichtigungen
+
+function abschnittBenachrichtigungen(d) {
+ const { e } = d
+ const sichtbar = sichtbareLeistungen(e)
+ const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage')
+ const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage')
+ const widerruf = zeigtWiderruf(e)
+ const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher)
+ const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : ''
+ return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [
+ `Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), `
+ + 'Antworten gehen an den Betrieb.',
+ ['Mails an Gäste:', ...aufzaehlung([
+ `Bestätigungslink zur E-Mail-Adresse${haltezeit}`,
+ ...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []),
+ ...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []),
+ ...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []),
+ 'Absage und Verschiebung',
+ ...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []),
+ ])].join('\n'),
+ ['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([
+ ...(sofort ? ['Neue Buchung'] : []),
+ ...(anfrage ? ['Neue Terminanfrage'] : []),
+ 'Absage oder Verschiebung durch den Gast',
+ ...(widerruf ? ['Widerruf'] : []),
+ ])].join('\n'),
+ 'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.',
+ ].join('\n\n'))
+}
+
+// ---------------------------------------------------------------- Zusammenbau
+
+const ABSCHNITTE = [abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen]
+
+/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */
+const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n')
+
+/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */
+function projektText(project) {
+ const name = zeile(project?.projectName) || zeile(project?.subdomain)
+ const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain)
+ return name && domain && name !== domain ? `${name} (${domain})` : name || domain
+}
+
+/**
+ * Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now`
+ * ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen.
+ *
+ * @param {object} p
+ * @param {object} p.feature portalFeatures-Dokument der Funktion (D3b: siteKey, unlockedByPurchase; der Registereintrag
+ * config.features.termine wird nicht gebraucht)
+ * @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl)
+ * @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine
+ * @param {object|null} [p.fragebogen] geparster Fragebogen oder null (D3b: Kundenwuensche zum Einbau)
+ * @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert)
+ * @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError
+ * @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen
+ * @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name
+ * @param {boolean} [p.entwurf] D3b: Rechtstexte sind noch ENTWURF (Warnung); Standard RECHTSTEXTE_ENTWURF
+ * @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}}
+ * D3a: warnungen [] und kurz '' (kommen mit D3b und D4); ohne Einstellung keine Abschnitte. Die Anrede der Gaeste kommt aus einstellung.anrede.
+ */
+export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) {
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt')
+ const stand = { version, updatedAt }
+ if (!istObjekt(einstellung)) return { stand, warnungen: [], abschnitte: [], text: '', kurz: '' }
+
+ const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
+ const geaendert = updatedAt ? new Date(updatedAt) : null
+ const daten = {
+ e: einstellung,
+ now,
+ heuteTag: heute(now),
+ version,
+ geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '',
+ betrieb: istObjekt(einstellung.betrieb) ? einstellung.betrieb : {},
+ kunde: zeile(kunde?.name),
+ projekt: projektText(project),
+ farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE,
+ portal: zeile(config.publicBaseUrl),
+ haltezeit: config.termine?.holdMinutes,
+ ressourcen,
+ leistungen: liste(einstellung.leistungen).filter(istObjekt),
+ nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])),
+ }
+ const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
+ return { stand, warnungen: [], abschnitte, text: volltext(abschnitte), kurz: '' }
+}
diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js
index 005fc79..115c8c2 100644
--- a/test/termineEinbauanleitung.test.js
+++ b/test/termineEinbauanleitung.test.js
@@ -1,11 +1,16 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
-import { VORGABE_FARBE } from '../server/services/termine/einstellungen.js'
-import { cssVorschlag, einbauSnippet, esc, kontrast, luminanz, textFarbeAuf } from '../server/services/termine/einbauanleitung.js'
+import { config } from '../server/config.js'
+import { VORGABE_FARBE, neuerFeedToken } from '../server/services/termine/einstellungen.js'
+import { ausVorlage } from '../server/services/termine/vorlagen.js'
+import {
+ cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText,
+} from '../server/services/termine/einbauanleitung.js'
// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config.
// Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js.
+// Der Generator einbauanleitung() (Task D3a, ganz unten) liest config nur beim Aufruf (Portal-Adresse), sonst ist auch er rein.
const SITE_KEY = 'abcdefghijklmnop'
const API = 'https://project.webklar.com'
@@ -204,3 +209,478 @@ test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent
assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`)
}
})
+
+// ================================================================ Generator einbauanleitung() 1 (Task D3a)
+// Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Rein: kein I/O, nur ausdruecklich benannte Felder.
+
+const NOW = new Date('2026-10-05T10:00:00Z') // Montag, 12:00 Berliner Zeit
+const SPAETER = new Date('2026-10-06T10:00:00Z') // einen Tag spaeter: nur die "Erzeugt am"-Zeile aendert sich
+const GEHEIM = 'XYZ-GEHEIM'
+const BETRIEB = {
+ name: 'Salon Muster',
+ email: 'inhaber-intern@salon-muster.example',
+ telefon: '0631 123456',
+ adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
+ kleinunternehmer: false,
+ impressumUrl: 'https://salon-muster.example/impressum',
+ datenschutzUrl: 'https://salon-muster.example/datenschutz',
+ bundesland: 'RP',
+}
+
+/** Salon-Vorlage mit Farbe, zwei Personen und einem Platz, Wochenplan, Abwesenheiten, Sonderoeffnungen und einer Anfrage-Leistung. */
+function fixtureEinstellung() {
+ const e = ausVorlage('salon', BETRIEB, NOW)
+ e.farbe = '#9d4b6f'
+ const [anna] = e.ressourcen
+ anna.name = 'Anna'
+ anna.plan = { 1: [['09:00', '12:30'], ['14:00', '18:00']], 3: [['09:00', '12:30']], 7: [['10:00', '12:00']] }
+ e.ressourcen.push(
+ { id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() },
+ { id: 'r_stuhl001', typ: 'platz', name: 'Stuhl 2', aktiv: false, plan: {}, feedToken: neuerFeedToken() },
+ )
+ e.leistungen[0].ressourcen = [anna.id, 'r_ben00001']
+ e.leistungen[1].aktiv = false // Damenschnitt
+ e.leistungen[2].buchungsart = 'anfrage'
+ e.leistungen[2].preisArt = 'keiner'
+ e.leistungen[2].preisCent = 0
+ e.abwesenheiten = [
+ { id: 'a_alt00001', ressourceId: anna.id, von: '2026-09-28', bis: '2026-10-02', titel: 'Urlaub vorbei' },
+ { id: 'a_jetzt001', ressourceId: 'r_ben00001', von: '2026-10-05', bis: '2026-10-05', titel: 'Fortbildung' },
+ { id: 'a_laeuft01', ressourceId: anna.id, von: '2026-10-01', bis: '2026-10-07', titel: 'Kur' },
+ { id: 'a_zukunft1', ressourceId: null, von: '2026-12-24', bis: '2026-12-31', titel: 'Betriebsferien' },
+ ]
+ e.sonderoeffnungen = { '2026-10-03': [['09:00', '12:00']], '2026-12-24': [], '2026-11-07': [['10:00', '14:00']] }
+ e.gastFelder = {
+ telefon: 'pflicht',
+ nachricht: 'optional',
+ zusatz: [
+ { key: 'haare', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true },
+ { key: 'wunsch', label: 'Wunschfrisur', typ: 'text', optionen: [], pflicht: false },
+ ],
+ }
+ return e
+}
+
+const PROJEKT = { projectName: 'Salon Muster Website', subdomain: 'salon-muster', liveDomain: 'salon-muster.example', previewUrl: 'https://salon-muster.project.webklar.com' }
+
+/** Standard-Aufruf des Generators; Abweichungen ueber `zusatz`. */
+function erzeuge(zusatz = {}) {
+ return einbauanleitung({
+ feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup' },
+ project: PROJEKT,
+ einstellung: fixtureEinstellung(),
+ fragebogen: null,
+ version: 3,
+ updatedAt: '2026-10-03T08:15:00.000Z',
+ now: NOW,
+ kunde: { name: 'Salon Muster GbR' },
+ ...zusatz,
+ })
+}
+
+const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id)
+const zeilen = (text) => text.split('\n')
+
+test('D3a: Ergebnisform, die sechs Abschnitte in fester Reihenfolge, keiner kopierbar', () => {
+ const r = erzeuge()
+ assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen'])
+ assert.deepEqual(r.abschnitte.map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen'])
+ for (const a of r.abschnitte) {
+ assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
+ assert.equal(a.kopierbar, false, a.id)
+ assert.ok(a.titel && typeof a.titel === 'string', a.id)
+ assert.ok(a.text && typeof a.text === 'string', a.id)
+ }
+ assert.deepEqual(r.warnungen, [], 'Warnungen kommen mit D3b')
+ assert.equal(r.kurz, '', 'die Kurzfassung kommt mit D4')
+ // der Volltext fuehrt jeden Abschnitt mit Ueberschrift
+ assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), r.abschnitte.map((a) => `## ${a.titel}`))
+ for (const a of r.abschnitte) assert.ok(r.text.includes(a.text), a.id)
+})
+
+test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => {
+ assert.deepEqual(erzeuge().stand, { version: 3, updatedAt: '2026-10-03T08:15:00.000Z' })
+ assert.deepEqual(erzeuge({ updatedAt: undefined }).stand, { version: 3, updatedAt: null })
+ assert.deepEqual(erzeuge({ version: 0, updatedAt: undefined }).stand, { version: 0, updatedAt: null })
+})
+
+test('D3a Modell: Betrieb, Kontakt, Adressen, Farbe, Anrede, Portal-Adresse, Erzeugt-am und Stand der Einstellungen', () => {
+ const modell = abschnitt(erzeuge(), 'modell').text
+ for (const erwartet of [
+ 'Erzeugt am 05.10.2026',
+ 'Stand der Einstellungen: Version 3, zuletzt geändert 03.10.2026',
+ 'Kunde: Salon Muster GbR',
+ 'Projekt: Salon Muster Website (salon-muster.example)',
+ 'Betrieb: Salon Muster',
+ 'Anschrift: Hauptstraße 1, 67655 Kaiserslautern',
+ 'Telefon: 0631 123456',
+ 'Impressum: https://salon-muster.example/impressum',
+ 'Datenschutzerklärung: https://salon-muster.example/datenschutz',
+ 'Farbe: #9d4b6f',
+ 'Anrede der Gäste: Sie',
+ 'Gäste: Privatpersonen (Verbraucher)',
+ `Portal-Adresse (data-api): ${config.publicBaseUrl}`,
+ 'Umfang: 3 Personen und Plätze (2 aktiv), 4 Leistungen (3 aktiv)',
+ ]) assert.ok(zeilen(modell).includes(erwartet), `${erwartet}\n---\n${modell}`)
+})
+
+test('D3a Modell: fehlende Angaben stehen als nicht angegeben, Du-Anrede, Projekt ohne Domain, Version 0, keine Kundenzeile', () => {
+ const e = fixtureEinstellung()
+ e.betrieb.telefon = ''
+ e.betrieb.adresse = ''
+ e.betrieb.impressumUrl = null
+ e.betrieb.datenschutzUrl = null
+ e.anrede = 'du'
+ e.recht = { gaesteSind: 'gemischt', freizeit: true }
+ const modell = abschnitt(erzeuge({ einstellung: e, project: { projectName: 'Nur Name' }, kunde: null, version: 0, updatedAt: null }), 'modell').text
+ const z = zeilen(modell)
+ for (const erwartet of [
+ 'Betrieb: Salon Muster',
+ 'Anschrift: nicht angegeben',
+ 'Telefon: nicht angegeben',
+ 'Impressum: nicht angegeben',
+ 'Datenschutzerklärung: nicht angegeben',
+ 'Anrede der Gäste: Du',
+ 'Gäste: Privatpersonen und Unternehmen, Freizeitleistung',
+ 'Projekt: Nur Name',
+ 'Stand der Einstellungen: keine gespeicherte Version',
+ ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${modell}`)
+ assert.ok(!z.some((x) => x.startsWith('Kunde:')), 'ohne Kunde keine Kundenzeile')
+})
+
+test('D3a Modell: AGB- und Website-Adresse nur, wenn vorhanden; die Portal-Adresse wird beim Aufruf gelesen', () => {
+ const ohne = abschnitt(erzeuge(), 'modell').text
+ assert.ok(!/AGB|Website:/.test(ohne), ohne)
+ const e = fixtureEinstellung()
+ e.betrieb.agbUrl = 'https://salon-muster.example/agb'
+ e.betrieb.websiteUrl = 'https://salon-muster.example/'
+ const mit = zeilen(abschnitt(erzeuge({ einstellung: e }), 'modell').text)
+ assert.ok(mit.includes('AGB: https://salon-muster.example/agb'))
+ assert.ok(mit.includes('Website: https://salon-muster.example/'))
+ const vorher = config.publicBaseUrl
+ try {
+ config.publicBaseUrl = 'https://portal.test.example'
+ assert.ok(zeilen(abschnitt(erzeuge(), 'modell').text).includes('Portal-Adresse (data-api): https://portal.test.example'))
+ } finally {
+ config.publicBaseUrl = vorher
+ }
+})
+
+test('D3a Leistungen: Name, Dauer, Preis, Buchungsart, Personen; inaktive und Anfrage-Leistungen markiert', () => {
+ const text = abschnitt(erzeuge(), 'leistungen').text
+ const z = zeilen(text)
+ assert.equal(z[0], '4 Leistungen (3 aktiv):')
+ assert.ok(z.includes('- Herrenschnitt: 30 Min. (+ 5 Min. Pause danach), 28,00 €, sofort buchbar, Personen: Anna, Ben'), text)
+ assert.ok(z.includes('- Damenschnitt (inaktiv): 45 Min. (+ 5 Min. Pause danach), ab 45,00 €, sofort buchbar, Personen: Anna'), text)
+ assert.ok(z.includes('- Kinderschnitt: 30 Min. (+ 5 Min. Pause danach), kein Preis, nur als Anfrage, Personen: Anna'), text)
+ assert.ok(z.includes('- Färben: 120 Min. (+ 10 Min. Pause danach), ab 75,00 €, sofort buchbar, Personen: Anna'), text)
+ assert.equal(z.filter((x) => x.includes('(inaktiv)')).length, 1, 'nur die inaktive Leistung ist markiert')
+})
+
+test('D3a Leistungen: Plaetze heissen Plaetze, gemischt Personen und Plaetze, ohne Pause keine Klammer, unbekannte Zuordnung faellt weg', () => {
+ const e = fixtureEinstellung()
+ e.leistungen[0].ressourcen = ['r_stuhl001']
+ e.leistungen[1].ressourcen = [e.ressourcen[0].id, 'r_stuhl001', 'r_gibtsnicht']
+ e.leistungen[3].pufferNach = 0
+ e.leistungen[3].ressourcen = []
+ const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'leistungen').text)
+ assert.ok(z.some((x) => x.startsWith('- Herrenschnitt:') && x.endsWith('Plätze: Stuhl 2')), z.join('\n'))
+ assert.ok(z.some((x) => x.startsWith('- Damenschnitt (inaktiv):') && x.endsWith('Personen und Plätze: Anna, Stuhl 2')), z.join('\n'))
+ assert.ok(z.includes('- Färben: 120 Min., ab 75,00 €, sofort buchbar, keine Person und kein Platz zugeordnet'), z.join('\n'))
+})
+
+test('D3a Ressourcen: Wochenplan je Tag, nur kuenftige Abwesenheiten, Sonderoeffnungen ab heute', () => {
+ const text = abschnitt(erzeuge(), 'ressourcen').text
+ const z = zeilen(text)
+ assert.ok(z.includes('- Anna (Person)'), text)
+ assert.ok(z.includes(' Mo 09:00–12:30, 14:00–18:00'), text)
+ assert.ok(z.includes(' Mi 09:00–12:30'), text)
+ assert.ok(z.includes(' So 10:00–12:00'), text)
+ assert.ok(z.includes('- Ben (Person)') && z.includes(' Di 08:00–16:00'), text)
+ assert.ok(z.includes('- Stuhl 2 (Platz, inaktiv)'), text)
+ assert.ok(z.includes(' keine Zeiten eingetragen'), text)
+ // Abwesenheiten: abgelaufene fehlen, heute endende und laufende bleiben, nach Beginn sortiert
+ const ab = z.slice(z.indexOf('Abwesenheiten (ab heute):') + 1).filter((x) => x.startsWith('- ') && /Fortbildung|Kur|Betriebsferien|Urlaub/.test(x))
+ assert.deepEqual(ab, [
+ '- 01.10.2026–07.10.2026: Kur (Anna)',
+ '- 05.10.2026: Fortbildung (Ben)',
+ '- 24.12.2026–31.12.2026: Betriebsferien (alle)',
+ ])
+ assert.ok(!text.includes('Urlaub vorbei'), 'die abgelaufene Abwesenheit steht nirgends')
+ // Sonderoeffnungen: der 03.10. liegt vor heute und faellt weg, der 07.11. kommt vor dem 24.12.
+ const so = z.slice(z.indexOf('Sonderöffnungszeiten (ab heute, für alle):') + 1).filter((x) => x.startsWith('- '))
+ assert.deepEqual(so.slice(-2), ['- 07.11.2026: 10:00–14:00', '- 24.12.2026: geschlossen'])
+ assert.ok(!text.includes('03.10.2026'))
+})
+
+test('D3a Ressourcen: ohne Abwesenheiten und Sonderoeffnungen entfallen die Ueberschriften; ohne Ressourcen steht ein Hinweis', () => {
+ const e = fixtureEinstellung()
+ e.abwesenheiten = [{ id: 'a_alt00001', ressourceId: null, von: '2026-01-01', bis: '2026-01-02', titel: 'alt' }]
+ e.sonderoeffnungen = { '2026-01-01': [] }
+ const text = abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text
+ assert.ok(!text.includes('Abwesenheiten'), text)
+ assert.ok(!text.includes('Sonderöffnungszeiten'), text)
+ e.ressourcen = []
+ assert.equal(abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text, 'Keine Personen oder Plätze angelegt.')
+})
+
+test('D3a Regeln: Raster, Vorlauf, Horizont, Absagefrist, Obergrenze, Storno-Text mit Fassung, Zahlung', () => {
+ const e = fixtureEinstellung()
+ const text = abschnitt(erzeuge({ einstellung: e }), 'regeln').text
+ const z = zeilen(text)
+ for (const erwartet of [
+ 'Raster: 15 Minuten',
+ 'Vorlauf: 2 Stunden',
+ 'Buchungszeitraum: 60 Tage im Voraus',
+ 'Absage durch Gäste: bis 24 Stunden vor dem Termin',
+ 'Offene Termine je E-Mail-Adresse: höchstens 3',
+ 'Person wählbar: ja',
+ 'Feiertage: geschlossen (Bundesland RP)',
+ 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte',
+ 'Preishinweis: inkl. USt',
+ 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt',
+ `Stornobedingungen (Fassung ${e.storno.version}):`,
+ ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
+ assert.match(e.storno.version, /^\d{4}-\d{2}-\d{2}-\d+$/)
+ assert.ok(text.includes(` ${e.storno.text}`), 'der Storno-Text steht vollstaendig, eingerueckt')
+})
+
+test('D3a Regeln: andere Werte, kein Storno-Text, Unternehmen ohne Widerruf, Kleinunternehmer, keine Zahlarten', () => {
+ const e = fixtureEinstellung()
+ e.regeln = { raster: 30, vorlaufMin: 0, horizontTage: 14, absageBisStd: 0, maxOffeneJeEmail: 1, personWaehlbar: false }
+ e.feiertageGeschlossen = false
+ e.storno = { text: '', version: '2026-10-01-2' }
+ e.recht = { gaesteSind: 'unternehmer', freizeit: false }
+ e.betrieb.kleinunternehmer = true
+ e.zahlung = { modus: 'vorort', vorOrtArten: [] }
+ const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text)
+ for (const erwartet of [
+ 'Raster: 30 Minuten',
+ 'Vorlauf: kein Vorlauf',
+ 'Buchungszeitraum: 14 Tage im Voraus',
+ 'Absage durch Gäste: bis zum Terminbeginn',
+ 'Offene Termine je E-Mail-Adresse: höchstens 1',
+ 'Person wählbar: nein',
+ 'Feiertage: geöffnet wie im Wochenplan (Bundesland RP)',
+ 'Bezahlung vor Ort',
+ 'Preishinweis: kein Ausweis der USt nach § 19 UStG',
+ 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
+ 'Stornobedingungen: keine hinterlegt',
+ ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${z.join('\n')}`)
+ assert.ok(!z.join('\n').includes('2026-10-01-2'), 'ohne Text keine Fassung im Text')
+})
+
+test('D3a Regeln: Vorlauf in Minuten, Stunden und Tagen, Absagefrist in Tagen', () => {
+ const vorlauf = (minuten) => {
+ const e = fixtureEinstellung()
+ e.regeln.vorlaufMin = minuten
+ return zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text).find((x) => x.startsWith('Vorlauf:'))
+ }
+ assert.equal(vorlauf(45), 'Vorlauf: 45 Minuten')
+ assert.equal(vorlauf(60), 'Vorlauf: 1 Stunde')
+ assert.equal(vorlauf(90), 'Vorlauf: 90 Minuten')
+ assert.equal(vorlauf(1440), 'Vorlauf: 1 Tag')
+ assert.equal(vorlauf(20160), 'Vorlauf: 14 Tage')
+})
+
+test('D3a Gastfelder: Name und E-Mail immer Pflicht, Telefon und Nachricht nach Einstellung, Zusatzfelder mit Auswahl', () => {
+ const text = abschnitt(erzeuge(), 'gastfelder').text
+ const z = zeilen(text)
+ for (const erwartet of [
+ 'Name: Pflicht',
+ 'E-Mail-Adresse: Pflicht',
+ 'Telefon: Pflicht',
+ 'Nachricht: optional',
+ 'Zusatzfelder (2):',
+ '- Haarlänge (Auswahl: kurz, mittel, lang; Pflicht)',
+ '- Wunschfrisur (Text; optional)',
+ ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
+ const e = fixtureEinstellung()
+ e.gastFelder = { telefon: 'aus', nachricht: 'aus', zusatz: [] }
+ const leer = zeilen(abschnitt(erzeuge({ einstellung: e }), 'gastfelder').text)
+ assert.ok(leer.includes('Telefon: aus') && leer.includes('Nachricht: aus') && leer.includes('Zusatzfelder: keine'), leer.join('\n'))
+})
+
+test('D3a Benachrichtigungen: Mails an Gaeste nach Konfiguration, Mails an den Betrieb ohne Adresse, Kalender-Abo ohne Link', () => {
+ const text = abschnitt(erzeuge(), 'benachrichtigungen').text
+ const z = zeilen(text)
+ for (const erwartet of [
+ 'Gäste werden mit „Sie“ angesprochen. Absender ist der Betriebsname (Salon Muster), Antworten gehen an den Betrieb.',
+ 'Mails an Gäste:',
+ '- Bestätigungslink zur E-Mail-Adresse (gültig 30 Minuten)',
+ '- Terminbestätigung (sofort buchbare Leistungen)',
+ '- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)',
+ '- Erinnerung 24 Stunden vor dem Termin (nur wenn der Gast sie ankreuzt)',
+ '- Absage und Verschiebung',
+ '- Eingangsbestätigung des Widerrufs',
+ 'Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):',
+ '- Neue Buchung',
+ '- Neue Terminanfrage',
+ '- Absage oder Verschiebung durch den Gast',
+ '- Widerruf',
+ ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
+ assert.ok(text.includes('Kalender-Abo (ICS)') && text.includes('im Portal'), text)
+ assert.ok(!text.includes(BETRIEB.email), 'die Betriebs-E-Mail steht nicht in der Anleitung')
+ // die Gueltigkeit des Bestaetigungslinks wird beim Aufruf aus der Konfiguration gelesen
+ const vorher = config.termine.holdMinutes
+ try {
+ config.termine.holdMinutes = 45
+ assert.ok(zeilen(abschnitt(erzeuge(), 'benachrichtigungen').text).includes('- Bestätigungslink zur E-Mail-Adresse (gültig 45 Minuten)'))
+ } finally {
+ config.termine.holdMinutes = vorher
+ }
+})
+
+test('D3a Benachrichtigungen: ohne Erinnerung, ohne Anfrage-Leistung, ohne Widerruf und mit Du-Anrede entfallen die Zeilen', () => {
+ const e = fixtureEinstellung()
+ e.anrede = 'du'
+ e.erinnerung = { anbieten: false, stundenVorher: 24 }
+ e.leistungen[2].buchungsart = 'sofort'
+ e.recht = { gaesteSind: 'unternehmer', freizeit: false }
+ const text = abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text
+ const z = zeilen(text)
+ assert.ok(text.startsWith('Gäste werden mit „Du“ angesprochen.'), text)
+ for (const fehlt of ['- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', '- Neue Terminanfrage', '- Eingangsbestätigung des Widerrufs', '- Widerruf']) {
+ assert.ok(!z.includes(fehlt), `${fehlt}\n---\n${text}`)
+ }
+ assert.ok(!z.some((x) => x.startsWith('- Erinnerung')), text)
+ assert.ok(z.includes('- Neue Buchung') && z.includes('- Absage oder Verschiebung durch den Gast'), text)
+ // nur Anfrage-Leistungen: keine Terminbestaetigung und keine "Neue Buchung"
+ e.leistungen.forEach((l) => { l.buchungsart = 'anfrage' })
+ const nurAnfrage = zeilen(abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text)
+ assert.ok(!nurAnfrage.includes('- Terminbestätigung (sofort buchbare Leistungen)') && !nurAnfrage.includes('- Neue Buchung'))
+ assert.ok(nurAnfrage.includes('- Neue Terminanfrage'))
+ // inaktive Leistungen zaehlen nicht
+ const f = fixtureEinstellung()
+ f.leistungen[2].aktiv = false
+ assert.ok(!zeilen(abschnitt(erzeuge({ einstellung: f }), 'benachrichtigungen').text).includes('- Neue Terminanfrage'))
+})
+
+test('D3a feedToken: jede Ressource hat einen echten Token, das Ergebnis enthaelt weder einen Token noch das Wort feedToken', () => {
+ const e = fixtureEinstellung()
+ assert.ok(e.ressourcen.length >= 3)
+ for (const r of e.ressourcen) assert.match(r.feedToken, /^[A-Za-z0-9_-]{43}$/)
+ const json = JSON.stringify(erzeuge({ einstellung: e }))
+ assert.ok(!json.includes('feedToken'))
+ assert.ok(!json.toLowerCase().includes('feedtoken'))
+ for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken), `Token von ${r.name} steht im Ergebnis`)
+})
+
+test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und Kopf tauchen nirgends auf', () => {
+ const e = fixtureEinstellung()
+ e.geheim = GEHEIM
+ e.betrieb.geheim = GEHEIM
+ e.regeln.geheim = GEHEIM
+ e.gastFelder.geheim = GEHEIM
+ e.gastFelder.zusatz[0].geheim = GEHEIM
+ e.storno.geheim = GEHEIM
+ e.ressourcen[0].geheim = GEHEIM
+ e.ressourcen[0].notiz = 'NOTIZ-INTERN'
+ e.leistungen[0].geheim = GEHEIM
+ e.leistungen[0].notiz = 'NOTIZ-INTERN'
+ e.leistungen[0].beschreibung = 'BESCHREIBUNG-NUR-FUER-GAESTE'
+ e.abwesenheiten[3].geheim = GEHEIM
+ const json = JSON.stringify(erzeuge({
+ einstellung: e,
+ feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, geheim: GEHEIM, feedToken: GEHEIM, adminNote: GEHEIM },
+ project: { ...PROJEKT, geheim: GEHEIM, apiKey: GEHEIM },
+ kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM },
+ fragebogen: { geheim: GEHEIM },
+ }))
+ for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE', BETRIEB.email]) assert.ok(!json.includes(verboten), verboten)
+})
+
+test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => {
+ const e = fixtureEinstellung()
+ e.betrieb.name = 'Evil\n## Regeln\nRaster: 1'
+ e.ressourcen[0].name = '
& "Anna"'
+ e.leistungen[0].name = 'Schnitt\r\n## Gastfelder'
+ e.abwesenheiten[3].titel = 'Ferien\n## Modell'
+ const r = erzeuge({ einstellung: e, kunde: { name: 'Kunde\n## Leistungen' }, project: { projectName: 'Projekt\n## Ressourcen' } })
+ assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 6, r.text)
+ assert.ok(r.text.includes('
& "Anna"'), 'kein Maskieren: der Anzeiger setzt es als Text ein')
+ assert.ok(r.text.includes('Betrieb: Evil ## Regeln Raster: 1'), r.text)
+ assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1)
+})
+
+test('D3a ist deterministisch und veraendert die Eingabe nicht', () => {
+ const e = fixtureEinstellung()
+ // Abwesenheiten absichtlich unsortiert: der Generator sortiert, darf dabei die Eingabe aber nicht umordnen
+ e.abwesenheiten.reverse()
+ const vorher = structuredClone(e)
+ const a = erzeuge({ einstellung: e })
+ assert.deepEqual(e, vorher, 'nach dem ersten Aufruf: die Einstellung bleibt unveraendert (kein Sortieren oder Filtern in place)')
+ const b = erzeuge({ einstellung: e })
+ assert.deepEqual(e, vorher, 'nach dem zweiten Aufruf ebenso')
+ assert.deepEqual(a, b)
+})
+
+test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung vergangener Dinge', () => {
+ const normal = (r) => ({ ...r, text: r.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT'), abschnitte: r.abschnitte.map((a) => ({ ...a, text: a.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT') })) })
+ // einen Tag spaeter, ohne dass etwas ablaeuft (die Fortbildung liegt hier erst am 20.10.): nur die Erzeugt-am-Zeile unterscheidet
+ const spaeterEnde = fixtureEinstellung()
+ spaeterEnde.abwesenheiten[1].von = '2026-10-20'
+ spaeterEnde.abwesenheiten[1].bis = '2026-10-20'
+ const eins = erzeuge({ einstellung: spaeterEnde })
+ const zwei = erzeuge({ now: SPAETER, einstellung: spaeterEnde })
+ assert.notDeepEqual(eins, zwei)
+ assert.ok(abschnitt(eins, 'modell').text.includes('Erzeugt am 05.10.2026'))
+ assert.ok(abschnitt(zwei, 'modell').text.includes('Erzeugt am 06.10.2026'))
+ assert.deepEqual(normal(eins), normal(zwei), 'sonst bleibt alles gleich')
+ // mit der Fixture-Abwesenheit vom 05.10. faellt sie am Folgetag weg (bis < heute)
+ const a = erzeuge()
+ const b = erzeuge({ now: SPAETER })
+ assert.ok(abschnitt(a, 'ressourcen').text.includes('Fortbildung'))
+ assert.ok(!abschnitt(b, 'ressourcen').text.includes('Fortbildung'))
+ assert.ok(abschnitt(b, 'ressourcen').text.includes('Kur'), 'die laufende Kur bleibt')
+ // zwei Monate spaeter: vergangene Abwesenheiten und Sonderoeffnungen fehlen, sonst bleibt alles gleich
+ const spaet = erzeuge({ now: new Date('2026-12-20T10:00:00Z') })
+ const ressourcen = abschnitt(spaet, 'ressourcen').text
+ assert.ok(!ressourcen.includes('Kur') && !ressourcen.includes('Fortbildung') && !ressourcen.includes('07.11.2026'), ressourcen)
+ assert.ok(ressourcen.includes('Betriebsferien') && ressourcen.includes('24.12.2026: geschlossen'), ressourcen)
+ for (const id of ['leistungen', 'regeln', 'gastfelder', 'benachrichtigungen']) assert.equal(abschnitt(spaet, id).text, abschnitt(a, id).text, id)
+ // ein Abwesenheitstag, der in Berlin schon der naechste Tag ist, zaehlt nach Berliner Kalender (23:30 UTC = 01:30 Berlin am 06.10.)
+ const nachtgrenze = erzeuge({ now: new Date('2026-10-05T23:30:00Z') })
+ assert.ok(!abschnitt(nachtgrenze, 'ressourcen').text.includes('Fortbildung'), 'der 05.10. ist in Berlin schon vorbei')
+ assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026'))
+})
+
+test('D3a ohne Einstellung wirft nichts und liefert keine Abschnitte; ein ungueltiges now wirft', () => {
+ const r = erzeuge({ einstellung: null, version: 0, updatedAt: null })
+ assert.deepEqual(r.abschnitte, [])
+ assert.equal(r.text, '')
+ assert.deepEqual(r.stand, { version: 0, updatedAt: null })
+ for (const now of [undefined, null, 'heute', new Date('kaputt'), 1.7e12]) {
+ assert.throws(() => erzeuge({ now }), TypeError, String(now))
+ }
+})
+
+test('D3a Hilfen: wochenplanText und kuerzeListe', () => {
+ assert.deepEqual(wochenplanText({ 1: [['09:00', '12:30'], ['14:00', '18:00']], 5: [['09:00', '13:00']] }), ['Mo 09:00–12:30, 14:00–18:00', 'Fr 09:00–13:00'])
+ assert.deepEqual(wochenplanText({ 7: [['10:00', '12:00']], 2: [] }), ['So 10:00–12:00'], 'leere Tage fehlen, Reihenfolge Montag bis Sonntag')
+ assert.deepEqual(wochenplanText({}), [])
+ assert.deepEqual(wochenplanText(undefined), [])
+ assert.deepEqual(wochenplanText({ 1: 'kaputt', 8: [['09:00', '10:00']], 3: [['09:00']] }), [], 'Unbrauchbares wird uebersprungen')
+ assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 3), ['a', 'b', 'c'])
+ assert.deepEqual(kuerzeListe(['a', 'b', 'c', 'd', 'e'], 2), ['a', 'b', '… und 3 weitere'])
+ assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 2), ['a', 'b', '… und 1 weitere'])
+ assert.deepEqual(kuerzeListe([], 5), [])
+})
+
+test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N weitere, Leistungen stehen vollstaendig', () => {
+ const e = fixtureEinstellung()
+ e.abwesenheiten = Array.from({ length: 30 }, (_, i) => ({
+ id: `a_x${String(i).padStart(7, '0')}`, ressourceId: null, von: `2027-01-${String(i + 1).padStart(2, '0')}`, bis: `2027-01-${String(i + 1).padStart(2, '0')}`, titel: `Tag ${i + 1}`,
+ }))
+ e.sonderoeffnungen = Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`2027-02-${String(i + 1).padStart(2, '0')}`, [['10:00', '12:00']]]))
+ e.leistungen = Array.from({ length: 50 }, (_, i) => ({ ...e.leistungen[0], id: `s_l${String(i).padStart(6, '0')}`, name: `Leistung ${i + 1}` }))
+ const r = erzeuge({ einstellung: e })
+ const z = zeilen(abschnitt(r, 'ressourcen').text)
+ assert.equal(z.filter((x) => /^- \d\d\.01\.2027/.test(x)).length, 20)
+ assert.ok(z.includes('- … und 10 weitere'), z.join('\n'))
+ assert.equal(z.filter((x) => /^- \d\d\.02\.2027/.test(x)).length, 20)
+ assert.ok(z.includes('- … und 5 weitere'), z.join('\n'))
+ const l = zeilen(abschnitt(r, 'leistungen').text)
+ assert.equal(l[0], '50 Leistungen (50 aktiv):')
+ assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50)
+})