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>
315 lines
16 KiB
JavaScript
315 lines
16 KiB
JavaScript
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.
|
|
* 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.
|
|
*/
|
|
|
|
/** Die acht Schritte des Fragebogens in fester Reihenfolge; `bestaetigt` nennt die vom Kunden abgehakten. */
|
|
export const SCHRITTE = Object.freeze(['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website'])
|
|
|
|
/**
|
|
* Branchen zur Auswahl: alle Vorlagen in der Reihenfolge von vorlagenListe(), danach der Gesundheitsbereich
|
|
* (ohne Vorlage; er fuehrt in den Hinweis- und Pruefweg fuer Gesundheitsangaben). Schluessel und Name der letzten
|
|
* Branche gelten woertlich fuer Bestellpruefung, Oberflaeche, Einbauanleitung und Ticket.
|
|
*/
|
|
export const BRANCHEN = Object.freeze([
|
|
...vorlagenListe().map(({ key, name }) => Object.freeze({ key, name, vorlage: key, gesundheit: false })),
|
|
Object.freeze({ key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }),
|
|
])
|
|
|
|
/** Obergrenze fuer den JSON-Text des Fragebogens (Zeichen); die Spalte in Appwrite ist mit 20000 groesser. */
|
|
export const FRAGEBOGEN_MAX = 16000
|
|
|
|
const EINBAU_MODI = ['inline', 'knopf']
|
|
const TEXTGRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
|
|
const MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung'])
|
|
|
|
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
|
|
|
/** Ein leerer Fragebogen; bei jedem Aufruf ein neues Objekt. */
|
|
export function leerFragebogen() {
|
|
return {
|
|
schema: 1,
|
|
branche: null,
|
|
bestaetigt: [],
|
|
bundeslandGewaehlt: false,
|
|
preiseGeprueft: false,
|
|
einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' },
|
|
}
|
|
}
|
|
|
|
function wahrheit(fehl, pfad, wert) {
|
|
if (wert === undefined || wert === null) return false
|
|
if (typeof wert !== 'boolean') {
|
|
fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.')
|
|
return false
|
|
}
|
|
return wert
|
|
}
|
|
|
|
function einbauFelder(fehl, eingabe) {
|
|
const quelle = eingabe === undefined || eingabe === null ? {} : eingabe
|
|
if (!istObjekt(quelle)) {
|
|
fehl('einbau', 'Die Angaben zum Einbau sind ungültig.')
|
|
return leerFragebogen().einbau
|
|
}
|
|
const einbau = { modus: null }
|
|
if (quelle.modus !== undefined && quelle.modus !== null) {
|
|
if (EINBAU_MODI.includes(quelle.modus)) einbau.modus = quelle.modus
|
|
else fehl('einbau.modus', 'Bitte wähle, ob der Terminplaner im Text oder als Knopf erscheint.')
|
|
}
|
|
for (const [feld, max] of Object.entries(TEXTGRENZEN)) {
|
|
const wert = quelle[feld]
|
|
if (wert === undefined || wert === null) einbau[feld] = ''
|
|
else if (typeof wert !== 'string') {
|
|
fehl(`einbau.${feld}`, 'Der Text ist ungültig.')
|
|
einbau[feld] = ''
|
|
} else einbau[feld] = text(wert, max, { mehrzeilig: MEHRZEILIG.has(feld) })
|
|
}
|
|
return einbau
|
|
}
|
|
|
|
/**
|
|
* Prueft und bereinigt einen Fragebogen aus Kundeneingabe oder gespeichertem JSON.
|
|
* Baut ein neues Objekt Feld fuer Feld (unbekannte Schluessel und __proto__ fallen weg), kuerzt Texte, entfernt
|
|
* Steuerzeichen und ordnet `bestaetigt` nach SCHRITTE ohne Doppelte und Fremde. Fehlende Felder sind leer/false.
|
|
* Ergebnis: { fragebogen } oder { fehler: { [pfad]: text } }; idempotent.
|
|
*/
|
|
export function normalisiereFragebogen(eingabe) {
|
|
if (!istObjekt(eingabe)) return { fehler: { _: 'Der Fragebogen ist ungültig.' } }
|
|
const fehler = {}
|
|
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
|
|
|
|
let branche = null
|
|
if (eingabe.branche !== undefined && eingabe.branche !== null) {
|
|
if (BRANCHEN.some((b) => b.key === eingabe.branche)) branche = eingabe.branche
|
|
else fehl('branche', 'Bitte wähle eine Branche aus der Liste.')
|
|
}
|
|
|
|
let bestaetigt = []
|
|
if (eingabe.bestaetigt !== undefined && eingabe.bestaetigt !== null) {
|
|
if (Array.isArray(eingabe.bestaetigt)) bestaetigt = SCHRITTE.filter((schritt) => eingabe.bestaetigt.includes(schritt))
|
|
else fehl('bestaetigt', 'Die bestätigten Schritte sind ungültig.')
|
|
}
|
|
|
|
const fragebogen = {
|
|
schema: 1,
|
|
branche,
|
|
bestaetigt,
|
|
bundeslandGewaehlt: wahrheit(fehl, 'bundeslandGewaehlt', eingabe.bundeslandGewaehlt),
|
|
preiseGeprueft: wahrheit(fehl, 'preiseGeprueft', eingabe.preiseGeprueft),
|
|
einbau: einbauFelder(fehl, eingabe.einbau),
|
|
}
|
|
return Object.keys(fehler).length ? { fehler } : { fragebogen }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Teil 2: Bestell-Gate
|
|
|
|
/**
|
|
* 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 } = {}) {
|
|
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,
|
|
}
|
|
}
|