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:
1 parent
ab47fabcb2
commit
008ad4fb1e
7 files changed
+869
-47
No files matched your search
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user