Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung

fragebogen.js ersetzt den Stub aus Block B: bestellpruefung (Kriterien (a) bis (h),
je Punkt ein Eintrag, nach Schrittfolge sortiert, Abo-Punkt nur bei mitAbo !== false),
SCHRITT_TITEL, gesundheitsWarnungen (Stichworte nur am Wortanfang, hoechstens 10
Meldungen) und bestellungAnsicht fuer die Bestellseite. einstellungen.js exportiert
sichtbareLeistungen und MELDUNG_PFLICHT. Testhelfer bestellbarerStand und entwurfDoc;
der Stub-Test entfaellt, die zwei B-Tests mit dem Stub laufen gegen das echte Gate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 19:00:12 +00:00
1 parent ab47fabcb2
commit 008ad4fb1e
7 files changed
+869 -47

No files matched your search

+2 -2
View File
@@ -559,13 +559,13 @@ function farbe(fehl, wert) {
// ---------------------------------------------------------------- Pflichtangaben und Widerruf
const MELDUNG_PFLICHT = {
export const MELDUNG_PFLICHT = {
'betrieb.telefon': 'Bitte gib die Telefonnummer deines Betriebs an. Gäste sehen sie als Ausweichweg und in ihren Mails.',
'betrieb.adresse': 'Bitte gib die Anschrift deines Betriebs an. Sie steht in der Widerrufsbelehrung, die Gäste vor der Buchung sehen.',
}
/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Ressource (wie in oeffentlicheEinstellung). */
function sichtbareLeistungen(einstellung) {
export function sichtbareLeistungen(einstellung) {
const aktiveIds = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id))
return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiveIds.has(id)))
}
+202 -8
View File
@@ -1,12 +1,16 @@
// STUB (Block B) – ersetzt Task C2 (bestellpruefung); Task C1 legt in dieser Datei den Rest des Fragebogen-Dienstes an
import { text } from './einstellungen.js'
import { config } from '../../config.js'
import { aboMeldung } from '../featureOrder.js'
import { featureHaken, oeffentlicheAnsicht } from '../featureRegistry.js'
import { MELDUNG_PFLICHT, fehlendePflichtangaben, hatPreis, sichtbareLeistungen, text } from './einstellungen.js'
import { vorlagenListe } from './vorlagen.js'
/**
* Bestell-Fragebogen des Terminplaners (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md): Der Kunde
* richtet den Terminplaner vor der Bestellung ein; der Entwurf steht in termineConfig.einstellung, der Fragebogen
* (Branche, bestaetigte Schritte, Wuensche zum Einbau) im Attribut termineConfig.fragebogen als JSON-Text.
* Dieser Teil: Schritte, Branchen, Grundform und Normalisierung. Reine Funktionen ohne Appwrite und ohne Uhr.
* Teil 1: Schritte, Branchen, Grundform und Normalisierung. Teil 2: Bestell-Gate (bestellpruefung), weiche
* Gesundheits-Stichwortpruefung und die Sicht der Bestellseite (bestellungAnsicht). Ohne Appwrite und ohne Uhr
* (bestellungAnsicht liest nur das Register und den Haken belehrung).
* Fehler sind { [pfad]: text } wie bei normalisiereEinstellung (Pfad '_' = das Ganze); Texte in Du-Form.
*/
@@ -109,12 +113,202 @@ export function normalisiereFragebogen(eingabe) {
return Object.keys(fehler).length ? { fehler } : { fragebogen }
}
// ---------------------------------------------------------------- Teil 2: Bestell-Gate
/**
* Bestellpruefung des Terminplaners: Liste der offenen Punkte `[{ schritt, text }]`, leer = bestellbar.
* Die Signatur ist endgueltig (Block C ersetzt nur den Rumpf): `mitAbo !== false` schaltet den Abo-Punkt (h)
* dazu, den nur die Vorschau (GET /api/termine/einstellungen) mit `abo` anfordert; der Checkout ruft mit
* `mitAbo: false` und ohne `abo`. Der Platzhalter meldet nichts offen.
* Titel der Schritte (und der Bestellseite) fuer Meldungen und Oberflaeche; Schluessel wie SCHRITTE plus `bestellen`
* ("Prüfen und bestellen"). Der Titel steht in "Bitte bestätige den Schritt „…“." und wird dort woertlich gezeigt.
*/
export const SCHRITT_TITEL = Object.freeze({
branche: 'Branche und Betrieb',
wer: 'Wer wird gebucht',
leistungen: 'Leistungen',
zeiten: 'Zeiten',
regeln: 'Regeln',
gastfelder: 'Gastfelder',
benachrichtigung: 'Benachrichtigung',
website: 'Website',
bestellen: 'Prüfen und bestellen',
})
// Schritt, in dem der Kunde eine fehlende Pflichtangabe nachtraegt (Name und Anschrift stehen in Schritt 1, Telefon in Schritt 8)
const PFLICHT_SCHRITT = { 'betrieb.telefon': 'website', 'betrieb.adresse': 'branche' }
const SCHRITT_FOLGE = [...SCHRITTE, 'bestellen']
const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
/**
* Bestellpruefung des Terminplaners: Liste der offenen Punkte `[{ schritt, text }]`, leer = bestellbar. Jeder Punkt ist
* ein eigener Eintrag; sortiert nach Schrittfolge (SCHRITTE, dann `bestellen`), innerhalb eines Schritts in der
* Reihenfolge der Kriterien (a) bis (h):
* (a) fehlende Pflichtangaben (Telefon ⇒ website, Anschrift ⇒ branche; Text aus MELDUNG_PFLICHT),
* (b) keine fuer Gaeste sichtbare Leistung ⇒ leistungen, (c) je unbestaetigter Schritt ein Punkt, dazu leistungen,
* wenn eine sichtbare Leistung einen Preis hat und die Preise nicht geprueft sind, (d) Bundesland nicht ausdruecklich
* gewaehlt ⇒ zeiten (der Serverstandard RP zaehlt nicht), (e) Einbau-Modus fehlt oder Knopf ohne Knopftext ⇒ website,
* (f) Branche fehlt oder Gesundheitsbereich ⇒ branche, (g) Impressum- oder Datenschutz-Adresse fehlt ⇒ website (je
* Adresse ein Punkt), (h) nur bei `mitAbo !== false`: das Abo laeuft nicht sauber ⇒ bestellen.
* `feature` = Register-Eintrag (nur label.name und aboMeldung); `abo` = Ergebnis von bestellbar(), gelesen nur bei
* mitAbo !== false: fehlt es (Lesefehler), gilt der Grund 'unbekannt' (im Zweifel nicht bestellbar). Der Checkout (B6)
* ruft mit `mitAbo: false` und ohne `abo`, dort liefert die Abo-Stufe (7) die Codes; nur GET /api/termine/einstellungen
* (C4a) uebergibt `abo`. Ohne Einstellung gibt es genau einen Punkt (branche). Rein: veraendert nichts, neues Array.
*/
export function bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true } = {}) {
return []
if (!istObjekt(einstellung)) return [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }]
const stand = istObjekt(fragebogen) ? fragebogen : {}
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
const einbau = istObjekt(stand.einbau) ? stand.einbau : {}
const offen = []
const punkt = (schritt, meldung) => offen.push({ schritt, text: meldung })
// (a) Pflichtangaben des Betriebs
for (const pfad of fehlendePflichtangaben(einstellung)) punkt(PFLICHT_SCHRITT[pfad], MELDUNG_PFLICHT[pfad])
// (b) mindestens eine Leistung, die Gaeste sehen
const sichtbar = sichtbareLeistungen(einstellung)
if (sichtbar.length === 0) {
punkt('leistungen', 'Bitte lege mindestens eine aktive Leistung an, die einer aktiven Person oder einem aktiven Platz zugeordnet ist. Nur solche Leistungen sehen Gäste.')
}
// (c) jeder Schritt bestaetigt; Preise geprueft, wenn eine sichtbare Leistung einen hat
const bestaetigt = Array.isArray(stand.bestaetigt) ? stand.bestaetigt : []
for (const schritt of SCHRITTE) {
if (!bestaetigt.includes(schritt)) punkt(schritt, `Bitte bestätige den Schritt „${SCHRITT_TITEL[schritt]}“.`)
}
if (sichtbar.some(hatPreis) && stand.preiseGeprueft !== true) {
punkt('leistungen', 'Bitte prüfe die Preise deiner Leistungen und bestätige sie. Die Preise der Vorlage sind nur Platzhalter.')
}
// (d) Bundesland ausdruecklich gewaehlt (der Serverstandard RP ist keine Wahl)
if (stand.bundeslandGewaehlt !== true) {
punkt('zeiten', 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.')
}
// (e) Einbau: Modus, bei einem Knopf auch dessen Text
if (!EINBAU_MODI.includes(einbau.modus)) {
punkt('website', 'Bitte wähle, ob der Terminplaner direkt auf deiner Seite oder hinter einem Knopf erscheint.')
} else if (einbau.modus === 'knopf' && !istText(einbau.knopfText)) {
punkt('website', 'Bitte gib an, was auf dem Knopf stehen soll, hinter dem sich der Terminplaner öffnet.')
}
// (f) Branche gewaehlt und nicht der Gesundheitsbereich
const branche = BRANCHEN.find((b) => b.key === stand.branche)
if (!branche) {
punkt('branche', 'Bitte wähle deine Branche.')
} else if (branche.gesundheit) {
const name = feature?.label?.name || 'Terminplaner'
punkt('branche', `Für Betriebe im Gesundheitsbereich ist „${name}“ nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.`)
}
// (g) Impressum und Datenschutzerklaerung (der Gaestehinweis verweist darauf)
if (!istText(betrieb.impressumUrl)) punkt('website', 'Bitte trage die Adresse deines Impressums ein. Gäste finden dort die Angaben zu deinem Betrieb.')
if (!istText(betrieb.datenschutzUrl)) punkt('website', 'Bitte trage die Adresse deiner Datenschutzerklärung ein. Der Hinweis für Gäste verweist darauf.')
// (h) Wartungs-Abo sauber laufend (nur wenn gefragt); fehlendes abo = Lesefehler = nicht bestellbar
if (mitAbo !== false && !(istObjekt(abo) && abo.ok === true)) {
punkt('bestellen', aboMeldung(feature, istObjekt(abo) ? abo.grund : 'unbekannt'))
}
// stabil nach Schrittfolge (Array.prototype.sort ist ab Node 12 stabil)
return offen.sort((a, b) => SCHRITT_FOLGE.indexOf(a.schritt) - SCHRITT_FOLGE.indexOf(b.schritt))
}
// ---------------------------------------------------------------- Gesundheits-Stichwortpruefung
// Regel: Treffer nur am Wortanfang (vor dem Treffer steht kein Buchstabe), Woerter = Folgen von Buchstaben.
// Gruppe 1: Stamm mit beliebiger Fortsetzung bis zum Wortende (Zusammensetzungen zaehlen: Physiotherapie, Arztpraxis).
const STAEMME_FREI = ['physio', 'therap', 'arzt', 'ärzt', 'zahnarzt', 'zahnärzt', 'heilpraktik', 'krankengymnast', 'osteopath', 'diagnos']
// Gruppe 2: Stamm plus hoechstens eine Beugungsendung, dann Wortende (Rezepte, Beschwerden, Behandlungen; nicht Rezeption, Rezeptur).
const STAEMME_BEUGUNG = ['rezept', 'beschwerde', 'behandlung']
const ENDUNGEN = new Set(['', 'e', 'en', 'n', 's', 'es'])
const MAX_WARNUNGEN = 10
const MAX_WORT = 60
const MAX_NAME = 40
const istGesundheitswort = (wort) =>
STAEMME_FREI.some((stamm) => wort.startsWith(stamm))
|| STAEMME_BEUGUNG.some((stamm) => wort.startsWith(stamm) && ENDUNGEN.has(wort.slice(stamm.length)))
/** Woerter eines Textes (Folgen von Buchstaben, NFC), die ein Gesundheitsstichwort sind, in Textreihenfolge. */
function gesundheitsWoerter(wert) {
if (typeof wert !== 'string') return []
return [...wert.normalize('NFC').matchAll(/[\p{L}\p{M}]+/gu)]
.map((m) => m[0])
.filter((wort) => istGesundheitswort(wort.toLowerCase()))
}
const kurz = (wert, max) => (wert.length > max ? `${wert.slice(0, max - 1)}…` : wert)
/**
* Weiche Pruefung auf Gesundheitsangaben (Spec 3.5, D6): Stichwortliste ueber Betriebsname, Leistungsnamen,
* Beschreibungen sowie Beschriftungen und Auswahlen der Zusatzfelder. Ergebnis: hoechstens 10 Meldungen (Klartext, ohne
* Doppelte), jede nennt den Fundort und das gefundene Wort. Nur Warnblock fuers Team (T16, Ticket): dem Kunden zeigt
* Block C nichts, eine Meldung ist kein Fehler. Liest nie Gaestedaten (nur die Einstellung des Betriebs).
* @param {object|null} einstellung
* @returns {string[]}
*/
export function gesundheitsWarnungen(einstellung) {
if (!istObjekt(einstellung)) return []
const meldungen = new Set()
const pruefe = (wert, fundort) => {
for (const wort of gesundheitsWoerter(wert)) {
if (meldungen.size >= MAX_WARNUNGEN) return
meldungen.add(`${fundort} enthält „${kurz(wort, MAX_WORT)}“`)
}
}
pruefe(einstellung.betrieb?.name, 'Betriebsname')
for (const leistung of Array.isArray(einstellung.leistungen) ? einstellung.leistungen : []) {
if (!istObjekt(leistung)) continue
pruefe(leistung.name, 'Leistungsname')
const name = typeof leistung.name === 'string' ? kurz(leistung.name.trim(), MAX_NAME) : ''
pruefe(leistung.beschreibung, name ? `Beschreibung der Leistung „${name}“` : 'Beschreibung einer Leistung')
}
const zusatz = einstellung.gastFelder?.zusatz
for (const feld of Array.isArray(zusatz) ? zusatz : []) {
if (!istObjekt(feld)) continue
pruefe(feld.label, 'Zusatzfeld-Beschriftung')
const label = typeof feld.label === 'string' ? kurz(feld.label.trim(), MAX_NAME) : ''
for (const option of Array.isArray(feld.optionen) ? feld.optionen : []) {
pruefe(option, label ? `Auswahl im Zusatzfeld „${label}“` : 'Auswahl in einem Zusatzfeld')
}
}
return [...meldungen]
}
// ---------------------------------------------------------------- Sicht der Bestellseite
// Titel der Rechtsunterlagen (Registerfelder) auf der Bestellseite
const DOKUMENT_TITEL = [
['leistungsbeschreibung', 'Leistungsbeschreibung'],
['avv', 'Auftragsverarbeitungsvertrag (AVV)'],
['widerruf', 'Widerrufsbelehrung'],
]
/**
* Sicht der Bestellseite ("Prüfen und bestellen"): reine Abbildung von oeffentlicheAnsicht(feature.key) (B1) plus die
* Belehrung. `consent` bleibt unveraendert, inklusive `knopf` und des `haken`-Flags je Eintrag (der Knopf zeigt genau diesen
* Text, B speichert genau diesen Text). `dokumente` stammen aus den Registerfeldern leistungsbeschreibung/avv/widerruf und
* nur mit gesetzter `url`. `belehrung` ist das Ergebnis des Hakens belehrung (derselbe Text, den B7 im Nachweis speichert;
* ohne Haken null). Keine internen Felder (ticket, mails). Unbekannte Funktion ⇒ null.
* @param {{ key: string }} feature Register-Eintrag
* @returns {Promise<{ preis: string, preisHinweis: string, einrichtung: string, consent: object, dokumente: object[], belehrung: string|null }|null>}
*/
export async function bestellungAnsicht(feature) {
const ansicht = typeof feature?.key === 'string' ? oeffentlicheAnsicht(feature.key) : null
if (!ansicht) return null
const dokumente = []
for (const [id, titel] of DOKUMENT_TITEL) {
const fassung = ansicht[id]
if (fassung && typeof fassung.url === 'string' && fassung.url !== '') dokumente.push({ id, titel, version: fassung.version, url: fassung.url })
}
const belehrung = (await featureHaken(feature.key).belehrung?.()) ?? null
return {
preis: ansicht.priceLabel,
preisHinweis: config.ustHinweis,
einrichtung: ansicht.setupEstimate,
consent: ansicht.consent,
dokumente,
belehrung,
}
}