Compare commits
4
Commits
b264d7f734
...
a45f436124
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45f436124 | ||
|
|
1e0eb9f569 | ||
|
|
02a3732e4a | ||
|
|
a4ade5f649 |
No files matched your search
@@ -25,7 +25,10 @@ Antwortformen, die die Portal-Oberfläche erwartet (Geld in Cent, Daten `YYYY-MM
|
|||||||
- `PUT /einstellungen` Body = genau diese Felder ohne Wrapper. `rechnungAuftrag` (boolean) leitet der Server aus `rechnungAuftragAt` ab: `true` setzt es, falls leer, `false` widerruft (null). `onlineZahlung` nur `true`, wenn Stripe aktiv ist; der Präfix ist bei vorhandenen Rechnungen nicht änderbar. Feldfehler als `felder.<name>`.
|
- `PUT /einstellungen` Body = genau diese Felder ohne Wrapper. `rechnungAuftrag` (boolean) leitet der Server aus `rechnungAuftragAt` ab: `true` setzt es, falls leer, `false` widerruft (null). `onlineZahlung` nur `true`, wenn Stripe aktiv ist; der Präfix ist bei vorhandenen Rechnungen nicht änderbar. Feldfehler als `felder.<name>`.
|
||||||
- `GET /unterkuenfte` → `{ unterkuenfte:[{ id, name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv, icalImporte:[{ id, name, url, letzterAbrufAt, letzterErfolgAt, fehler }], icalExportUrl }] }` (`icalExportUrl` = volle URL mit Token).
|
- `GET /unterkuenfte` → `{ unterkuenfte:[{ id, name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv, icalImporte:[{ id, name, url, letzterAbrufAt, letzterErfolgAt, fehler }], icalExportUrl }] }` (`icalExportUrl` = volle URL mit Token).
|
||||||
- `POST /unterkuenfte`, `PUT /unterkuenfte/:id` Body `{ name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv }` → `{ unterkunft }`; Deaktivieren = `PUT` mit `aktiv:false`. `DELETE /unterkuenfte/:id`.
|
- `POST /unterkuenfte`, `PUT /unterkuenfte/:id` Body `{ name, beschreibung, maxPersonen, mindestNaechte, checkIn, checkOut, sort, aktiv }` → `{ unterkunft }`; Deaktivieren = `PUT` mit `aktiv:false`. `DELETE /unterkuenfte/:id`.
|
||||||
- `GET|PUT /unterkuenfte/:id/preise` → `{ preise:{ staffeln:[{abNaechten,preisProNachtCent}], reinigungCent, personenInklusive, aufpreisPersonNachtCent, direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }`; PUT sendet genau diese Felder.
|
- `GET|PUT /unterkuenfte/:id/preise` → `{ preise:{ staffeln:[{abNaechten,preisProNachtCent,personenCent?}], reinigungCent, personenInklusive, aufpreisPersonNachtCent, direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }`; PUT sendet genau diese Felder.
|
||||||
|
- Preismodell „pauschal“ (ohne `personenCent`): `preisProNachtCent` gilt für `personenInklusive` Gäste, jede weitere Person kostet `aufpreisPersonNachtCent` je Nacht (Posten „Zusatzperson“).
|
||||||
|
- Preismodell „je Personenzahl“: jede Staffel trägt `personenCent:[c1,c2,…,ck]` = Preis pro Nacht für die ganze Unterkunft bei 1, 2, …, k Gästen (k = 2 bis 10, Werte ganze Cent 100 bis 100000). Entweder haben alle Staffeln `personenCent` mit gleicher Länge k oder keine; sonst 400 `felder.staffeln`. Der Server setzt `preisProNachtCent = personenCent[0]` (eine mitgeschickte Zahl wird überschrieben). Eine Staffel gilt weiterhin ab ihrer Nächtezahl für alle Nächte. Der Posten heißt „Übernachtung, 7 Nächte, 3 Personen“; `personenInklusive` wird ignoriert. Bei mehr als k Gästen gilt `ck` plus der Posten „Zusatzperson“ (`aufpreisPersonNachtCent`, nur wenn größer 0) für die Gäste über k. Der Direktrabatt gilt auf Übernachtung und Zusatzpersonen.
|
||||||
|
- Kein neues Schema: `personenCent` liegt im JSON-Text `ukPreise.staffeln` (2000 Zeichen; der Höchstfall 6 Staffeln × 10 Werte braucht rund 800).
|
||||||
- `GET /unterkuenfte/:id/kalender?von&bis` → `{ bereiche:[{ von, bis (Abreisetag, exklusiv), quelle:'gebucht'|'reserviert'|'ical'|'manuell' (auch 'ical:<importId>', 'anfrage:<id>' zählt als gebucht), anfrageNummer?, sperreId?, notiz? }] }`.
|
- `GET /unterkuenfte/:id/kalender?von&bis` → `{ bereiche:[{ von, bis (Abreisetag, exklusiv), quelle:'gebucht'|'reserviert'|'ical'|'manuell' (auch 'ical:<importId>', 'anfrage:<id>' zählt als gebucht), anfrageNummer?, sperreId?, notiz? }] }`.
|
||||||
- `POST /unterkuenfte/:id/sperren` `{ von, bis, notiz }` → 201, 409 `zeitraum_belegt`; `DELETE /sperren/:id`.
|
- `POST /unterkuenfte/:id/sperren` `{ von, bis, notiz }` → 201, 409 `zeitraum_belegt`; `DELETE /sperren/:id`.
|
||||||
- `POST /unterkuenfte/:id/ical-importe` `{ name, url }` (nur https; Feldfehler `felder.url`) → 201; `DELETE /unterkuenfte/:id/ical-importe/:importId`; `POST /unterkuenfte/:id/ical-export/erneuern`.
|
- `POST /unterkuenfte/:id/ical-importe` `{ name, url }` (nur https; Feldfehler `felder.url`) → 201; `DELETE /unterkuenfte/:id/ical-importe/:importId`; `POST /unterkuenfte/:id/ical-export/erneuern`.
|
||||||
|
|||||||
+66
-10
@@ -1,9 +1,15 @@
|
|||||||
/* Buchungskalender: Preis-Editor einer Unterkunft (Staffeln, Reinigung, Zusatzpersonen, Rabatt, Steuer, Frist, Stornobedingungen) mit Beispielrechnung.
|
/* Buchungskalender: Preis-Editor einer Unterkunft (Staffeln, Preise je Personenzahl, Reinigung, Zusatzpersonen, Rabatt, Steuer, Frist, Stornobedingungen) mit Beispielrechnung.
|
||||||
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
|
GET|PUT /api/unterkunft/unterkuenfte/:id/preise => { preise:{ staffeln:[{abNaechten,preisProNachtCent,personenCent?}], reinigungCent, personenInklusive, aufpreisPersonNachtCent,
|
||||||
direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }
|
direktrabattProzent, steuerart:'kleinunternehmer'|'regel', ustUebernachtung, ustReinigung, fristTage, stornobedingungen } | null }
|
||||||
POST /api/unterkunft/preisvorschau { unterkunftId, von, bis, personen } => { naechte, posten, summen:{ bruttoCent } } (rechnet mit den gespeicherten Preisen). */
|
POST /api/unterkunft/preisvorschau { unterkunftId, von, bis, personen } => { naechte, posten, summen:{ bruttoCent } } (rechnet mit den gespeicherten Preisen).
|
||||||
|
Zwei Preismodelle, umgeschaltet mit dem Haken "Preis hängt von der Personenzahl ab" (#bk-p-personenmodus, nur bei unterkunft.maxPersonen >= 2):
|
||||||
|
- aus: ein Preis pro Nacht je Staffel; "Personen im Preis inklusive" und "Aufpreis je weiterer Person" gelten wie bisher.
|
||||||
|
- an: je Staffel k Euro-Felder (k = min(maxPersonen, 10), "1 Person" ... "k Personen") = Preis pro Nacht fuer die ganze Unterkunft; gesendet wird
|
||||||
|
staffeln:[{abNaechten, preisProNachtCent: personenCent[0], personenCent}]. Die beiden Personenfelder sind dann ausgeblendet, ihre Werte gehen unveraendert mit.
|
||||||
|
Beim Laden ist der Haken an, wenn die gespeicherten Staffeln personenCent haben (vorhandene Werte eingesetzt, fehlende leer, ueberzaehlige verworfen).
|
||||||
|
ctx.nurLesen sperrt alle Felder samt Haken. */
|
||||||
|
|
||||||
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, setzeFehler, zeigeFehler } from './shop-dom.js'
|
import { abschnitt, allgemeinerFehler, euroEingabe, euroText, feld, h, hakenFeld, setzeFehler, zeigeFehler } from './shop-dom.js'
|
||||||
import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
|
import { enc, heuteBerlin, plusTage, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
|
||||||
|
|
||||||
// Wortgleich zu docs/funktionen/unterkunft-storno-muster.md, Abschnitt "Stornobedingungen (Muster)" (ohne Markdown-Fettdruck); test/buchungenUi.test.js vergleicht beides.
|
// Wortgleich zu docs/funktionen/unterkunft-storno-muster.md, Abschnitt "Stornobedingungen (Muster)" (ohne Markdown-Fettdruck); test/buchungenUi.test.js vergleicht beides.
|
||||||
@@ -30,6 +36,7 @@ export const STORNO_MUSTER = [
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
|
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
|
||||||
const STANDARD = { staffeln: [{ abNaechten: 1, preisProNachtCent: null }], reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer', ustUebernachtung: 7, ustReinigung: 7, fristTage: 3, stornobedingungen: '' }
|
const STANDARD = { staffeln: [{ abNaechten: 1, preisProNachtCent: null }], reinigungCent: 0, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer', ustUebernachtung: 7, ustReinigung: 7, fristTage: 3, stornobedingungen: '' }
|
||||||
|
const PERSONEN_MAX = 10
|
||||||
const ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
|
const ganzzahl = (steuer) => { const t = String(steuer.value).trim(); return /^\d{1,9}$/.test(t) ? Number(t) : null }
|
||||||
|
|
||||||
export async function zeichnePreise(ctx, unterkunft) {
|
export async function zeichnePreise(ctx, unterkunft) {
|
||||||
@@ -44,13 +51,27 @@ export async function zeichnePreise(ctx, unterkunft) {
|
|||||||
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
|
wurzel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))
|
||||||
return wurzel
|
return wurzel
|
||||||
}
|
}
|
||||||
|
// Preise je Personenzahl: nur sinnvoll, wenn mehr als eine Person in die Unterkunft passt; k Spalten (1 bis k Personen)
|
||||||
|
const gaeste = Number.isInteger(unterkunft?.maxPersonen) ? Math.min(unterkunft.maxPersonen, PERSONEN_MAX) : 0
|
||||||
|
const modusMoeglich = gaeste >= 2
|
||||||
|
const modusBox = h('input', { type: 'checkbox', id: 'bk-p-personenmodus' })
|
||||||
|
modusBox.checked = modusMoeglich && Array.isArray(preise.staffeln) && preise.staffeln.some((s) => Array.isArray(s?.personenCent) && s.personenCent.length > 0)
|
||||||
|
const personenText = (n) => `${n} ${n === 1 ? 'Person' : 'Personen'}`
|
||||||
const zeilenEl = h('div', { class: 'bk-staffeln' })
|
const zeilenEl = h('div', { class: 'bk-staffeln' })
|
||||||
const zeile = (s) => {
|
const zeile = (s) => {
|
||||||
const ab = h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Ab Nächten', 'data-staffel': 'ab', autocomplete: 'off' })
|
const ab = h('input', { type: 'text', inputmode: 'numeric', 'aria-label': 'Ab Nächten', 'data-staffel': 'ab', autocomplete: 'off' })
|
||||||
ab.value = String(s.abNaechten ?? '')
|
ab.value = String(s.abNaechten ?? '')
|
||||||
const preis = euroEingabe({ cent: s.preisProNachtCent, label: 'Preis pro Nacht in Euro' })
|
const preis = euroEingabe({ cent: s.preisProNachtCent, label: 'Preis pro Nacht in Euro' })
|
||||||
preis.dataset.staffel = 'preis'
|
preis.dataset.staffel = 'preis'
|
||||||
const el = h('div', { class: 'bk-staffel', 'data-staffel-zeile': true }, 'ab ', ab, ' Nächten: ', preis, ' € pro Nacht ',
|
const spalten = Array.from({ length: modusMoeglich ? gaeste : 0 }, (_, i) => {
|
||||||
|
const eingabe = euroEingabe({ cent: Array.isArray(s.personenCent) ? s.personenCent[i] : null, label: `Preis pro Nacht in Euro für ${personenText(i + 1)}` })
|
||||||
|
eingabe.dataset.staffel = 'person'
|
||||||
|
eingabe.dataset.personen = String(i + 1)
|
||||||
|
return h('label', { class: 'bk-personpreis' }, h('span', null, personenText(i + 1)), eingabe)
|
||||||
|
})
|
||||||
|
const el = h('div', { class: 'bk-staffel', 'data-staffel-zeile': true }, 'ab ', ab, ' Nächten: ',
|
||||||
|
h('span', { class: 'bk-preis-einzeln' }, preis, ' € pro Nacht '),
|
||||||
|
h('span', { class: 'bk-personenpreise', 'data-personenpreise': true }, spalten),
|
||||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-weg', 'aria-label': 'Staffel entfernen', onclick: () => { if (zeilenEl.children.length > 1) { el.remove(); halteErste() } } }, '−'))
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-weg', 'aria-label': 'Staffel entfernen', onclick: () => { if (zeilenEl.children.length > 1) { el.remove(); halteErste() } } }, '−'))
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
@@ -92,14 +113,33 @@ export async function zeichnePreise(ctx, unterkunft) {
|
|||||||
}
|
}
|
||||||
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
|
for (const el of [naechteEl, personenEl]) el.addEventListener('change', vorschau)
|
||||||
|
|
||||||
|
const modusFeld = hakenFeld('personenmodus', modusBox, 'Preis hängt von der Personenzahl ab', 'Zum Beispiel 55 € für 1 Person, 70 € für 2 Personen und 82 € für 3 Personen pro Nacht.')
|
||||||
|
modusFeld.hidden = !modusMoeglich
|
||||||
|
modusBox.hidden = !modusMoeglich
|
||||||
|
const modusHinweis = h('p', { class: 'sh-hinweis', 'data-hinweis': 'personenpreise' }, 'Preis pro Nacht für die ganze Unterkunft, je nach Anzahl der Gäste.')
|
||||||
|
const inklusiveFeld = feld('personenInklusive', 'Personen im Preis inklusive', inklusive)
|
||||||
|
const aufpreisFeld = feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis)
|
||||||
|
/** Haken an: je Staffel die Spalten je Personenzahl statt des einen Preises; die zwei Personenfelder des alten Modells verschwinden (ihre Werte gehen unveraendert mit). */
|
||||||
|
const zeigeModus = () => {
|
||||||
|
const an = modusMoeglich && modusBox.checked
|
||||||
|
for (const el of zeilenEl.querySelectorAll('.bk-preis-einzeln')) el.hidden = an
|
||||||
|
for (const el of zeilenEl.querySelectorAll('.bk-personenpreise')) el.hidden = !an
|
||||||
|
modusHinweis.hidden = !an
|
||||||
|
inklusiveFeld.hidden = an
|
||||||
|
aufpreisFeld.hidden = an
|
||||||
|
}
|
||||||
|
modusBox.addEventListener('change', zeigeModus)
|
||||||
|
const staffelDazu = () => { zeilenEl.append(zeile({ abNaechten: null, preisProNachtCent: null })); zeigeModus() }
|
||||||
|
|
||||||
const form = h('form', { novalidate: true, class: 'bk-preisform', onsubmit: (ev) => ev.preventDefault() },
|
const form = h('form', { novalidate: true, class: 'bk-preisform', onsubmit: (ev) => ev.preventDefault() },
|
||||||
h('h4', null, 'Preis pro Nacht (Staffeln)'),
|
h('h4', null, 'Preis pro Nacht (Staffeln)'),
|
||||||
h('p', { class: 'sh-hinweis' }, 'Je länger der Aufenthalt, desto günstiger die Nacht: Die passende Staffel gilt für alle Nächte der Buchung.'),
|
h('p', { class: 'sh-hinweis' }, 'Je länger der Aufenthalt, desto günstiger die Nacht: Die passende Staffel gilt für alle Nächte der Buchung.'),
|
||||||
|
modusFeld, modusHinweis,
|
||||||
zeilenEl, h('div', { class: 'sh-fehler', 'data-fehler': 'staffeln', role: 'alert' }),
|
zeilenEl, h('div', { class: 'sh-fehler', 'data-fehler': 'staffeln', role: 'alert' }),
|
||||||
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-dazu', onclick: () => { zeilenEl.append(zeile({ abNaechten: null, preisProNachtCent: null })) } }, '+ Staffel'),
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'staffel-dazu', onclick: staffelDazu }, '+ Staffel'),
|
||||||
feld('reinigungCent', 'Endreinigung in Euro', reinigung),
|
feld('reinigungCent', 'Endreinigung in Euro', reinigung),
|
||||||
feld('personenInklusive', 'Personen im Preis inklusive', inklusive),
|
inklusiveFeld,
|
||||||
feld('aufpreisPersonNachtCent', 'Aufpreis je weiterer Person und Nacht in Euro', aufpreis),
|
aufpreisFeld,
|
||||||
feld('direktrabattProzent', 'Direktbuchungsrabatt in %', rabatt, 'Ganze Zahl von 0 bis 50. Gilt für Übernachtung und Zusatzpersonen, nicht für die Reinigung.'),
|
feld('direktrabattProzent', 'Direktbuchungsrabatt in %', rabatt, 'Ganze Zahl von 0 bis 50. Gilt für Übernachtung und Zusatzpersonen, nicht für die Reinigung.'),
|
||||||
feld('steuerart', 'Steuer', steuerart), ustFelder,
|
feld('steuerart', 'Steuer', steuerart), ustFelder,
|
||||||
feld('fristTage', 'Frist für die Buchung nach Freigabe in Tagen', frist, '1 bis 14 Tage.'),
|
feld('fristTage', 'Frist für die Buchung nach Freigabe in Tagen', frist, '1 bis 14 Tage.'),
|
||||||
@@ -109,6 +149,7 @@ export async function zeichnePreise(ctx, unterkunft) {
|
|||||||
h('p', { class: 'sh-hinweis', 'data-hinweis': 'storno-muster' }, STORNO_HINWEIS)),
|
h('p', { class: 'sh-hinweis', 'data-hinweis': 'storno-muster' }, STORNO_HINWEIS)),
|
||||||
allgemeinerFehler(),
|
allgemeinerFehler(),
|
||||||
h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'preise-speichern', onclick: speichern }, 'Preise speichern')))
|
h('div', { class: 'bk-aktionen' }, ctx.nurLesen ? null : h('button', { type: 'button', class: 'kb-btn', 'data-aktion': 'preise-speichern', onclick: speichern }, 'Preise speichern')))
|
||||||
|
zeigeModus()
|
||||||
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
|
if (ctx.nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
|
||||||
// Ein Feldfehler (z. B. Platzhalter in den Stornobedingungen) verschwindet, sobald im Feld getippt wird
|
// Ein Feldfehler (z. B. Platzhalter in den Stornobedingungen) verschwindet, sobald im Feld getippt wird
|
||||||
for (const el of form.querySelectorAll('[data-feld]')) {
|
for (const el of form.querySelectorAll('[data-feld]')) {
|
||||||
@@ -125,8 +166,16 @@ export async function zeichnePreise(ctx, unterkunft) {
|
|||||||
const fehler = {}
|
const fehler = {}
|
||||||
const staffeln = []
|
const staffeln = []
|
||||||
let staffelFehler = ''
|
let staffelFehler = ''
|
||||||
|
const personenModus = modusMoeglich && modusBox.checked
|
||||||
for (const z of zeilenEl.children) {
|
for (const z of zeilenEl.children) {
|
||||||
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
|
const ab = ganzzahl(z.querySelector('[data-staffel="ab"]'))
|
||||||
|
if (personenModus) {
|
||||||
|
const spalten = [...z.querySelectorAll('[data-staffel="person"]')].map((el) => el.inCent())
|
||||||
|
if (ab === null || ab < 1 || spalten.some((p) => p === null)) staffelFehler ||= 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und für jede Personenzahl einen Preis an.'
|
||||||
|
else if (spalten.some((p) => p < 100 || p > 100000)) staffelFehler ||= 'Der Preis pro Nacht muss zwischen 1,00 € und 1.000,00 € liegen.'
|
||||||
|
else staffeln.push({ abNaechten: ab, preisProNachtCent: spalten[0], personenCent: spalten })
|
||||||
|
continue
|
||||||
|
}
|
||||||
const p = z.querySelector('[data-staffel="preis"]').inCent()
|
const p = z.querySelector('[data-staffel="preis"]').inCent()
|
||||||
if (ab === null || ab < 1 || p === null) staffelFehler ||= 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und einen Preis an.'
|
if (ab === null || ab < 1 || p === null) staffelFehler ||= 'Bitte gib bei jeder Staffel Nächte (ganze Zahl) und einen Preis an.'
|
||||||
else if (p < 100 || p > 100000) staffelFehler ||= 'Der Preis pro Nacht muss zwischen 1,00 € und 1.000,00 € liegen.'
|
else if (p < 100 || p > 100000) staffelFehler ||= 'Der Preis pro Nacht muss zwischen 1,00 € und 1.000,00 € liegen.'
|
||||||
@@ -141,8 +190,15 @@ export async function zeichnePreise(ctx, unterkunft) {
|
|||||||
const zahl = (name, steuer, text, min, max) => { const n = ganzzahl(steuer); if (n === null || n < min || n > max) { fehler[name] = text; return null } return n }
|
const zahl = (name, steuer, text, min, max) => { const n = ganzzahl(steuer); if (n === null || n < min || n > max) { fehler[name] = text; return null } return n }
|
||||||
const reinigungCent = reinigung.istLeer() ? 0 : reinigung.inCent()
|
const reinigungCent = reinigung.istLeer() ? 0 : reinigung.inCent()
|
||||||
if (reinigungCent === null) fehler.reinigungCent = 'Bitte gib einen Betrag wie 60,00 an.'
|
if (reinigungCent === null) fehler.reinigungCent = 'Bitte gib einen Betrag wie 60,00 an.'
|
||||||
const aufpreisCent = aufpreis.istLeer() ? 0 : aufpreis.inCent()
|
// Im Personen-Modus sind "inklusive" und "Aufpreis" ausgeblendet: ihr Wert geht unveraendert mit; Unbrauchbares im unsichtbaren Feld bleibt ohne Fehlermeldung beim gespeicherten Wert
|
||||||
|
let aufpreisCent = aufpreis.istLeer() ? 0 : aufpreis.inCent()
|
||||||
|
if (personenModus && (aufpreisCent === null || aufpreisCent > 100000)) aufpreisCent = Number.isInteger(preise.aufpreisPersonNachtCent) ? preise.aufpreisPersonNachtCent : 0
|
||||||
if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.'
|
if (aufpreisCent === null) fehler.aufpreisPersonNachtCent = 'Bitte gib einen Betrag wie 15,00 an.'
|
||||||
|
const inklusiveAlt = Number.isInteger(preise.personenInklusive) && preise.personenInklusive >= 1 && preise.personenInklusive <= 50 ? preise.personenInklusive : 2
|
||||||
|
const inklusiveNeu = ganzzahl(inklusive)
|
||||||
|
const inklusiveWert = personenModus
|
||||||
|
? (inklusiveNeu !== null && inklusiveNeu >= 1 && inklusiveNeu <= 50 ? inklusiveNeu : inklusiveAlt)
|
||||||
|
: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000)
|
||||||
const regel = steuerart.value === 'regel'
|
const regel = steuerart.value === 'regel'
|
||||||
const ustWert = (name, steuer, alt) => {
|
const ustWert = (name, steuer, alt) => {
|
||||||
if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken
|
if (!regel) return ganzzahl(steuer) ?? alt // andere Steuerart: gespeicherten Wert unveraendert zurueckschicken
|
||||||
@@ -155,7 +211,7 @@ export async function zeichnePreise(ctx, unterkunft) {
|
|||||||
if (!fehler.stornobedingungen && /\[[^\]]*\]/.test(storn)) fehler.stornobedingungen = 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.'
|
if (!fehler.stornobedingungen && /\[[^\]]*\]/.test(storn)) fehler.stornobedingungen = 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.'
|
||||||
const body = {
|
const body = {
|
||||||
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
|
staffeln, reinigungCent, aufpreisPersonNachtCent: aufpreisCent,
|
||||||
personenInklusive: zahl('personenInklusive', inklusive, 'Bitte gib eine ganze Zahl ab 1 an.', 1, 1000),
|
personenInklusive: inklusiveWert,
|
||||||
direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.', 0, 50),
|
direktrabattProzent: zahl('direktrabattProzent', rabatt, 'Bitte gib eine ganze Zahl von 0 bis 50 an.', 0, 50),
|
||||||
steuerart: steuerart.value,
|
steuerart: steuerart.value,
|
||||||
ustUebernachtung: ustWert('ustUebernachtung', ustUe, preise.ustUebernachtung ?? 7),
|
ustUebernachtung: ustWert('ustUebernachtung', ustUe, preise.ustUebernachtung ?? 7),
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
.bk-farbe { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--kb-border); display: inline-block; }
|
.bk-farbe { width: 16px; height: 16px; border-radius: 4px; border: 1px solid var(--kb-border); display: inline-block; }
|
||||||
.bk-staffel { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
.bk-staffel { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
.bk-staffel input { width: 90px; }
|
.bk-staffel input { width: 90px; }
|
||||||
|
/* Preise je Personenzahl: ein Euro-Feld je Gaestezahl unter der Beschriftung; bricht auf dem Handy um, kein Seitwaertsscrollen */
|
||||||
|
.bk-personenpreise { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: flex-end; min-width: 0; max-width: 100%; }
|
||||||
|
.bk-personenpreise[hidden], .bk-preis-einzeln[hidden] { display: none; }
|
||||||
|
.bk-personpreis { display: grid; gap: 2px; font-size: 13px; font-weight: 600; }
|
||||||
|
.bk-personpreis > span { white-space: nowrap; }
|
||||||
.bk-vorschau { font-weight: 700; }
|
.bk-vorschau { font-weight: 700; }
|
||||||
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
|
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
|
||||||
.bk-anleitung details { margin: 8px 0; }
|
.bk-anleitung details { margin: 8px 0; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { aboMemo } from '../../services/aboStatus.js'
|
import { aboMemo } from '../../services/aboStatus.js'
|
||||||
import { validateTextform } from '../../services/analyse.js'
|
import { FUNKTION_CHANNELS, validateTextform } from '../../services/analyse.js'
|
||||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||||
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
|
import { featureConfig, featureHaken } from '../../services/featureRegistry.js'
|
||||||
import { withLocks } from '../../services/keyedLock.js'
|
import { withLocks } from '../../services/keyedLock.js'
|
||||||
@@ -139,7 +139,7 @@ async function textformFuerAktivierung({ body, customerId, key, doc }) {
|
|||||||
if (!textformVerlangt[key](kunde?.email)) return null
|
if (!textformVerlangt[key](kunde?.email)) return null
|
||||||
// nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat')
|
// nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat')
|
||||||
const text = (wert) => (typeof wert === 'string' ? wert : '')
|
const text = (wert) => (typeof wert === 'string' ? wert : '')
|
||||||
return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key) })
|
return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: textformFassung(key), channels: FUNKTION_CHANNELS })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */
|
/** Leistungsbeschreibung und AVV der Funktion als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const SITE_KEY_RE = /^[a-z0-9]{16,40}$/
|
|||||||
const COLOR_RE = /^#[0-9a-f]{6}$/
|
const COLOR_RE = /^#[0-9a-f]{6}$/
|
||||||
const DEFAULT_COLOR = '#111111'
|
const DEFAULT_COLOR = '#111111'
|
||||||
const CHANNELS = new Set(['chat', 'email'])
|
const CHANNELS = new Set(['chat', 'email'])
|
||||||
|
/** Wege der Zustimmung bei Funktionen (Terminplaner, Webshop, Buchungskalender): zusaetzlich WhatsApp (eine Nachricht dort ist Textform). */
|
||||||
|
export const FUNKTION_CHANNELS = new Set(['chat', 'email', 'whatsapp'])
|
||||||
export const MISSING_PRIVACY_MESSAGE =
|
export const MISSING_PRIVACY_MESSAGE =
|
||||||
'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.'
|
'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.'
|
||||||
|
|
||||||
@@ -109,9 +111,9 @@ const berlinToday = (now) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europ
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Zustimmung des Kunden in Textform (Mitarbeiter-Weg): Datum JJJJ-MM-TT (echt, nicht nach heute in Berlin,
|
* Zustimmung des Kunden in Textform (Mitarbeiter-Weg): Datum JJJJ-MM-TT (echt, nicht nach heute in Berlin,
|
||||||
* nicht vor der Fassung) und Weg chat/email. Wirft 400, sonst { date, channel }.
|
* nicht vor der Fassung) und Weg chat/email (channels: erlaubte Wege, Funktionen auch whatsapp). Wirft 400, sonst { date, channel }.
|
||||||
*/
|
*/
|
||||||
export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion } = {}) {
|
export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion, channels = CHANNELS } = {}) {
|
||||||
const date = String(textform?.date ?? '')
|
const date = String(textform?.date ?? '')
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
|
||||||
const real = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])).toISOString().slice(0, 10) === date
|
const real = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])).toISOString().slice(0, 10) === date
|
||||||
@@ -119,7 +121,7 @@ export function validateTextform(textform, { today = berlinToday(new Date()), mi
|
|||||||
if (date > today) throw httpError(400, 'Das Datum der Zustimmung liegt in der Zukunft')
|
if (date > today) throw httpError(400, 'Das Datum der Zustimmung liegt in der Zukunft')
|
||||||
if (date < minDate) throw httpError(400, `Das Datum der Zustimmung liegt vor der Fassung ${minDate}`)
|
if (date < minDate) throw httpError(400, `Das Datum der Zustimmung liegt vor der Fassung ${minDate}`)
|
||||||
const channel = String(textform?.channel ?? '')
|
const channel = String(textform?.channel ?? '')
|
||||||
if (!CHANNELS.has(channel)) throw httpError(400, 'Weg der Zustimmung fehlt: Chat oder E-Mail')
|
if (!channels.has(channel)) throw httpError(400, channels.has('whatsapp') ? 'Weg der Zustimmung fehlt: Chat, E-Mail oder WhatsApp' : 'Weg der Zustimmung fehlt: Chat oder E-Mail')
|
||||||
return { date, channel }
|
return { date, channel }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -614,7 +614,7 @@ async function assertAboLaeuftFuerAktivierung(customerId) {
|
|||||||
if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG)
|
if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' }
|
const TEXTFORM_WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail', whatsapp: 'WhatsApp' }
|
||||||
|
|
||||||
/** Funktionen, die das Team mit Zustimmung des Kunden in Textform aktivieren kann (Modus on), und die Fassung der Unterlagen, auf die sie sich bezieht. */
|
/** Funktionen, die das Team mit Zustimmung des Kunden in Textform aktivieren kann (Modus on), und die Fassung der Unterlagen, auf die sie sich bezieht. */
|
||||||
export const TEXTFORM_KEYS = ['termine', 'shop', 'unterkunft']
|
export const TEXTFORM_KEYS = ['termine', 'shop', 'unterkunft']
|
||||||
@@ -622,7 +622,7 @@ export const textformFassung = (key) => (key === 'shop' ? config.shop.fassung :
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im
|
* Text des Nachweises, wenn der Kunde dem Terminplaner oder dem Webshop in Textform zugestimmt hat (Aktivierung durch das Team im
|
||||||
* Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' } (validateTextform); fassung = textformFassung(key).
|
* Modus on, F13a). textform = { date: 'JJJJ-MM-TT', channel: 'chat' | 'email' | 'whatsapp' } (validateTextform); fassung = textformFassung(key).
|
||||||
*/
|
*/
|
||||||
export function textformNachweisText({ date, channel }, fassung, key = 'termine') {
|
export function textformNachweisText({ date, channel }, fassung, key = 'termine') {
|
||||||
const [jahr, monat, tag] = String(date).split('-')
|
const [jahr, monat, tag] = String(date).split('-')
|
||||||
|
|||||||
@@ -1,16 +1,31 @@
|
|||||||
// Preisrechnung Buchungskalender: reine Funktionen, keine I/O. Betraege in Cent.
|
// Preisrechnung Buchungskalender: reine Funktionen, keine I/O. Betraege in Cent.
|
||||||
|
//
|
||||||
|
// Preismodelle (preise.staffeln, Staffel = hoechste abNaechten <= Naechte, sie gilt fuer alle Naechte der Buchung):
|
||||||
|
// - pauschal (ohne personenCent): preisProNachtCent gilt fuer die ersten personenInklusive Gaeste, jede weitere Person
|
||||||
|
// kostet aufpreisPersonNachtCent je Nacht (eigener Posten "Zusatzperson").
|
||||||
|
// - je Personenzahl (alle Staffeln mit personenCent gleicher Laenge k, 2 bis 10): personenCent[p - 1] ist der Preis pro
|
||||||
|
// Nacht fuer die ganze Unterkunft bei p Gaesten. personenInklusive wird dann ignoriert; bei mehr als k Gaesten gilt
|
||||||
|
// personenCent[k - 1] plus der Posten "Zusatzperson" (aufpreisPersonNachtCent) fuer die Gaeste ueber k. Der Server
|
||||||
|
// haelt preisProNachtCent === personenCent[0] (pruefePreise), damit aeltere Leser weiter rechnen koennen.
|
||||||
|
// Der Direktrabatt gilt in beiden Modellen auf Uebernachtung und Zusatzpersonen, nicht auf die Reinigung.
|
||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { naechte } from './datum.js'
|
import { naechte } from './datum.js'
|
||||||
|
|
||||||
const SAETZE = [0, 7, 19]
|
const SAETZE = [0, 7, 19]
|
||||||
|
|
||||||
/** Staffel: hoechste abNaechten <= naechte gilt fuer alle Naechte. */
|
/** Grenzen der Preise je Personenzahl: Laenge k von personenCent (1 bis k Gaeste) und Wert in Cent. */
|
||||||
function staffelPreis(staffeln, n) {
|
export const PERSONEN_PREISE_MIN = 2
|
||||||
|
export const PERSONEN_PREISE_MAX = 10
|
||||||
|
export const NACHT_CENT_MIN = 100
|
||||||
|
export const NACHT_CENT_MAX = 100000
|
||||||
|
|
||||||
|
/** Die Staffel, die fuer n Naechte gilt (hoechste abNaechten <= n), sonst null. */
|
||||||
|
function waehleStaffel(staffeln, n) {
|
||||||
let best = null
|
let best = null
|
||||||
for (const s of staffeln || []) {
|
for (const s of staffeln || []) {
|
||||||
if (s.abNaechten <= n && (!best || s.abNaechten > best.abNaechten)) best = s
|
if (s.abNaechten <= n && (!best || s.abNaechten > best.abNaechten)) best = s
|
||||||
}
|
}
|
||||||
return best ? best.preisProNachtCent : 0
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
export function berechnePreis({ preise, von, bis, personen }) {
|
export function berechnePreis({ preise, von, bis, personen }) {
|
||||||
@@ -19,10 +34,23 @@ export function berechnePreis({ preise, von, bis, personen }) {
|
|||||||
const satzUe = klein ? 0 : preise.ustUebernachtung
|
const satzUe = klein ? 0 : preise.ustUebernachtung
|
||||||
const satzRe = klein ? 0 : preise.ustReinigung
|
const satzRe = klein ? 0 : preise.ustReinigung
|
||||||
const posten = []
|
const posten = []
|
||||||
const nachtPreis = staffelPreis(preise.staffeln, n)
|
const staffel = waehleStaffel(preise.staffeln, n)
|
||||||
|
const pc = staffel && Array.isArray(staffel.personenCent) && staffel.personenCent.length > 0 ? staffel.personenCent : null
|
||||||
|
let nachtPreis
|
||||||
|
let inklusive
|
||||||
|
if (pc) {
|
||||||
|
// je Personenzahl: die Spalte der Gaestezahl (hoechstens die letzte), der Rest ist Zusatzperson
|
||||||
|
const gaeste = Number.isFinite(Number(personen)) && Number(personen) >= 1 ? Math.floor(Number(personen)) : 1
|
||||||
|
inklusive = Math.min(gaeste, pc.length)
|
||||||
|
nachtPreis = pc[inklusive - 1]
|
||||||
|
posten.push({ text: `Übernachtung, ${n} Nächte, ${inklusive} ${inklusive === 1 ? 'Person' : 'Personen'}`, menge: n, einzelCent: nachtPreis, ustProzent: satzUe })
|
||||||
|
} else {
|
||||||
|
nachtPreis = staffel ? staffel.preisProNachtCent : 0
|
||||||
|
inklusive = Number(preise.personenInklusive)
|
||||||
posten.push({ text: `Übernachtung, ${n} Nächte`, menge: n, einzelCent: nachtPreis, ustProzent: satzUe })
|
posten.push({ text: `Übernachtung, ${n} Nächte`, menge: n, einzelCent: nachtPreis, ustProzent: satzUe })
|
||||||
|
}
|
||||||
let basis = n * nachtPreis
|
let basis = n * nachtPreis
|
||||||
const extra = Math.max(0, Number(personen) - Number(preise.personenInklusive))
|
const extra = Math.max(0, Number(personen) - inklusive)
|
||||||
if (extra > 0 && preise.aufpreisPersonNachtCent > 0) {
|
if (extra > 0 && preise.aufpreisPersonNachtCent > 0) {
|
||||||
posten.push({
|
posten.push({
|
||||||
text: `Zusatzperson${extra > 1 ? 'en' : ''} (${extra}), ${n} Nächte`,
|
text: `Zusatzperson${extra > 1 ? 'en' : ''} (${extra}), ${n} Nächte`,
|
||||||
@@ -96,12 +124,29 @@ export function formatEuro(cent) {
|
|||||||
return `${neg ? '−' : ''}${euro},${rest} €`
|
return `${neg ? '−' : ''}${euro},${rest} €`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preise je Personenzahl: entweder keine Staffel hat personenCent, oder alle gleich lang (2 bis 10) mit gueltigen Werten und preisProNachtCent === personenCent[0]. */
|
||||||
|
function personenPreiseKonsistent(staffeln) {
|
||||||
|
const hat = (s) => s.personenCent !== undefined && s.personenCent !== null
|
||||||
|
if (!staffeln.some(hat)) return true
|
||||||
|
const k = staffeln[0].personenCent?.length
|
||||||
|
return staffeln.every(
|
||||||
|
(s) =>
|
||||||
|
Array.isArray(s.personenCent) &&
|
||||||
|
s.personenCent.length === k &&
|
||||||
|
k >= PERSONEN_PREISE_MIN &&
|
||||||
|
k <= PERSONEN_PREISE_MAX &&
|
||||||
|
s.personenCent.every((c) => Number.isInteger(c) && c >= NACHT_CENT_MIN && c <= NACHT_CENT_MAX) &&
|
||||||
|
s.preisProNachtCent === s.personenCent[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function preiseVollstaendig(preise) {
|
export function preiseVollstaendig(preise) {
|
||||||
if (!preise || !Array.isArray(preise.staffeln) || preise.staffeln.length === 0) return false
|
if (!preise || !Array.isArray(preise.staffeln) || preise.staffeln.length === 0) return false
|
||||||
const ab = preise.staffeln.map((s) => s && s.abNaechten)
|
const ab = preise.staffeln.map((s) => s && s.abNaechten)
|
||||||
if (!ab.every((a) => Number.isInteger(a) && a > 0)) return false
|
if (!ab.every((a) => Number.isInteger(a) && a > 0)) return false
|
||||||
if (new Set(ab).size !== ab.length || !ab.includes(1)) return false
|
if (new Set(ab).size !== ab.length || !ab.includes(1)) return false
|
||||||
if (!preise.staffeln.every((s) => Number.isInteger(s.preisProNachtCent) && s.preisProNachtCent > 0)) return false
|
if (!preise.staffeln.every((s) => Number.isInteger(s.preisProNachtCent) && s.preisProNachtCent > 0)) return false
|
||||||
|
if (!personenPreiseKonsistent(preise.staffeln)) return false
|
||||||
if (!['regel', 'kleinunternehmer'].includes(preise.steuerart)) return false
|
if (!['regel', 'kleinunternehmer'].includes(preise.steuerart)) return false
|
||||||
if (!Number.isInteger(preise.personenInklusive) || preise.personenInklusive < 1) return false
|
if (!Number.isInteger(preise.personenInklusive) || preise.personenInklusive < 1) return false
|
||||||
const nn = (v) => Number.isInteger(v) && v >= 0
|
const nn = (v) => Number.isInteger(v) && v >= 0
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { config } from '../../config.js'
|
|||||||
import { istTag, naechte } from './datum.js'
|
import { istTag, naechte } from './datum.js'
|
||||||
import { AKTIVE_STATUS, ueberschneidet } from './belegung.js'
|
import { AKTIVE_STATUS, ueberschneidet } from './belegung.js'
|
||||||
import { listeAnfragenNachStatus, listeSperren } from './speicher.js'
|
import { listeAnfragenNachStatus, listeSperren } from './speicher.js'
|
||||||
|
import { NACHT_CENT_MAX, NACHT_CENT_MIN, PERSONEN_PREISE_MAX, PERSONEN_PREISE_MIN } from './preis.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buchungskalender, Verwaltung durch den Vermieter: Validierung der Eingaben (reine Funktionen, Rueckgabe { werte } oder
|
* Buchungskalender, Verwaltung durch den Vermieter: Validierung der Eingaben (reine Funktionen, Rueckgabe { werte } oder
|
||||||
@@ -26,6 +27,8 @@ export const PLATZHALTER_TEXT = 'Bitte ersetze die Platzhalter in eckigen Klamme
|
|||||||
export const MAX_UNTERKUENFTE = 20
|
export const MAX_UNTERKUENFTE = 20
|
||||||
export const MAX_ICAL_IMPORTE = 5
|
export const MAX_ICAL_IMPORTE = 5
|
||||||
export const MAX_STAFFELN = 6
|
export const MAX_STAFFELN = 6
|
||||||
|
/** Groesse des Textfelds ukPreise.staffeln (schema.js); test/unterkunftVerwaltung.test.js vergleicht beide Werte. */
|
||||||
|
export const STAFFELN_MAX_ZEICHEN = 2000
|
||||||
|
|
||||||
// ---- Einstellungen
|
// ---- Einstellungen
|
||||||
|
|
||||||
@@ -149,7 +152,12 @@ export function pruefeUnterkunft(eingabe, { partiell = false } = {}) {
|
|||||||
|
|
||||||
// ---- Preise
|
// ---- Preise
|
||||||
|
|
||||||
/** Pruefung der Preise einer Unterkunft (Staffeln aufsteigend, erste ab 1 Nacht, hoechstens 6). */
|
/**
|
||||||
|
* Pruefung der Preise einer Unterkunft (Staffeln aufsteigend, erste ab 1 Nacht, hoechstens 6).
|
||||||
|
* Jede Staffel darf personenCent tragen (Preis pro Nacht fuer 1..k Personen, k = 2 bis 10): entweder alle Staffeln mit gleich
|
||||||
|
* vielen Werten oder keine (Modell "pauschal", unveraendert). Im Personen-Modus setzt der Server preisProNachtCent = personenCent[0]
|
||||||
|
* (eine mitgeschickte Zahl wird ueberschrieben).
|
||||||
|
*/
|
||||||
export function pruefePreise(eingabe) {
|
export function pruefePreise(eingabe) {
|
||||||
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
|
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
|
||||||
const fehler = {}
|
const fehler = {}
|
||||||
@@ -160,15 +168,42 @@ export function pruefePreise(eingabe) {
|
|||||||
else {
|
else {
|
||||||
const sauber = []
|
const sauber = []
|
||||||
let letzte = 0
|
let letzte = 0
|
||||||
|
let personenAnzahl // undefined = noch keine Staffel gesehen, 0 = pauschal, sonst Laenge k von personenCent
|
||||||
for (const s of st) {
|
for (const s of st) {
|
||||||
if (!s || typeof s !== 'object' || !ganz(s.abNaechten) || !ganz(s.preisProNachtCent)) {
|
if (!s || typeof s !== 'object' || !ganz(s.abNaechten)) {
|
||||||
fehler.staffeln = 'Ungültige Preisstufe.'
|
fehler.staffeln = 'Ungültige Preisstufe.'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (s.preisProNachtCent < 100 || s.preisProNachtCent > 100000) {
|
const hatPersonen = s.personenCent !== undefined && s.personenCent !== null
|
||||||
|
if (hatPersonen) {
|
||||||
|
const pc = s.personenCent
|
||||||
|
if (!Array.isArray(pc)) {
|
||||||
|
fehler.staffeln = 'Die Preise je Personenzahl sind ungültig.'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pc.length < PERSONEN_PREISE_MIN || pc.length > PERSONEN_PREISE_MAX) {
|
||||||
|
fehler.staffeln = `Der Preis je Personenzahl braucht ${PERSONEN_PREISE_MIN} bis ${PERSONEN_PREISE_MAX} Werte (für 1 bis ${PERSONEN_PREISE_MAX} Personen).`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!pc.every((c) => ganz(c) && c >= NACHT_CENT_MIN && c <= NACHT_CENT_MAX)) {
|
||||||
fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.'
|
fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
} else if (!ganz(s.preisProNachtCent)) {
|
||||||
|
fehler.staffeln = 'Ungültige Preisstufe.'
|
||||||
|
break
|
||||||
|
} else if (s.preisProNachtCent < NACHT_CENT_MIN || s.preisProNachtCent > NACHT_CENT_MAX) {
|
||||||
|
fehler.staffeln = 'Der Preis pro Nacht muss zwischen 1 € und 1.000 € liegen.'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const anzahl = hatPersonen ? s.personenCent.length : 0
|
||||||
|
if (personenAnzahl !== undefined && anzahl !== personenAnzahl) {
|
||||||
|
fehler.staffeln = anzahl === 0 || personenAnzahl === 0
|
||||||
|
? 'Entweder haben alle Preisstufen Preise je Personenzahl oder keine.'
|
||||||
|
: 'Alle Preisstufen brauchen gleich viele Preise je Personenzahl.'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
personenAnzahl = anzahl
|
||||||
if (sauber.length === 0 && s.abNaechten !== 1) {
|
if (sauber.length === 0 && s.abNaechten !== 1) {
|
||||||
fehler.staffeln = 'Die erste Preisstufe muss ab 1 Nacht gelten.'
|
fehler.staffeln = 'Die erste Preisstufe muss ab 1 Nacht gelten.'
|
||||||
break
|
break
|
||||||
@@ -178,8 +213,12 @@ export function pruefePreise(eingabe) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
letzte = s.abNaechten
|
letzte = s.abNaechten
|
||||||
sauber.push({ abNaechten: s.abNaechten, preisProNachtCent: s.preisProNachtCent })
|
sauber.push(hatPersonen
|
||||||
|
? { abNaechten: s.abNaechten, preisProNachtCent: s.personenCent[0], personenCent: [...s.personenCent] }
|
||||||
|
: { abNaechten: s.abNaechten, preisProNachtCent: s.preisProNachtCent })
|
||||||
}
|
}
|
||||||
|
// Sicherung: das Textfeld ukPreise.staffeln fasst STAFFELN_MAX_ZEICHEN (schema.js); der Hoechstfall (6 x 10 Werte) braucht rund 800
|
||||||
|
if (!fehler.staffeln && JSON.stringify(sauber).length > STAFFELN_MAX_ZEICHEN) fehler.staffeln = 'Die Preisstufen sind zu umfangreich.'
|
||||||
if (!fehler.staffeln) werte.staffeln = sauber
|
if (!fehler.staffeln) werte.staffeln = sauber
|
||||||
}
|
}
|
||||||
const zahl = (k, min, max, label, std) => {
|
const zahl = (k, min, max, label, std) => {
|
||||||
|
|||||||
@@ -326,6 +326,10 @@ test('validateTextform: Datum, Zukunft, Fassung, Weg', () => {
|
|||||||
bad(undefined)
|
bad(undefined)
|
||||||
assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'email' }, ctx), { date: '2026-10-02', channel: 'email' })
|
assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'email' }, ctx), { date: '2026-10-02', channel: 'email' })
|
||||||
assert.deepEqual(an.validateTextform({ date: '2026-10-05', channel: 'chat' }, ctx), { date: '2026-10-05', channel: 'chat' })
|
assert.deepEqual(an.validateTextform({ date: '2026-10-05', channel: 'chat' }, ctx), { date: '2026-10-05', channel: 'chat' })
|
||||||
|
// WhatsApp nur, wenn der Aufrufer es erlaubt (Funktionen), nicht bei der Analyse
|
||||||
|
bad({ date: '2026-10-02', channel: 'whatsapp' })
|
||||||
|
assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'whatsapp' }, { ...ctx, channels: an.FUNKTION_CHANNELS }), { date: '2026-10-02', channel: 'whatsapp' })
|
||||||
|
assert.throws(() => an.validateTextform({ date: '2026-10-02', channel: 'telefon' }, { ...ctx, channels: an.FUNKTION_CHANNELS }), { status: 400 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Einschalten ohne Firma/Name oder mit ungültiger E-Mail: 409, nichts passiert', async () => {
|
test('Einschalten ohne Firma/Name oder mit ungültiger E-Mail: 409, nichts passiert', async () => {
|
||||||
|
|||||||
@@ -868,3 +868,206 @@ test('Preis-Editor: Platzhalter-Feldfehler verschwindet beim Tippen', mit(ukServ
|
|||||||
await fx.setze(storno, 'Bis zu 14 Tage vor Anreise kostenfrei stornierbar, danach 100 %.')
|
await fx.setze(storno, 'Bis zu 14 Tage vor Anreise kostenfrei stornierbar, danach 100 %.')
|
||||||
assert.equal(fehlerText(fx, panel, 'stornobedingungen'), '')
|
assert.equal(fehlerText(fx, panel, 'stornobedingungen'), '')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// ---- Preis-Editor: Preise je Personenzahl (staffeln[].personenCent), Haken #bk-p-personenmodus
|
||||||
|
const PERSONEN_PREISE = {
|
||||||
|
...PREISE,
|
||||||
|
staffeln: [
|
||||||
|
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
|
||||||
|
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const PUT_PREISE = { 'PUT /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: {} }) }
|
||||||
|
/** Server mit anderen Preisen und/oder anderer Gaestezahl der Unterkunft. */
|
||||||
|
const personenServer = (preise, maxPersonen = 3, o = {}) => ukServer({
|
||||||
|
'GET /api/unterkunft/unterkuenfte': () => ({ body: { unterkuenfte: [{ ...UK, maxPersonen }] } }),
|
||||||
|
'GET /api/unterkunft/unterkuenfte/u1/preise': () => ({ body: { preise } }),
|
||||||
|
...PUT_PREISE,
|
||||||
|
...o,
|
||||||
|
})
|
||||||
|
const zeilen = (fx, panel) => fx.alle(panel, '[data-staffel-zeile]')
|
||||||
|
const spalten = (fx, zeile) => fx.alle(zeile, '[data-staffel="person"]')
|
||||||
|
const putPreise = (fx) => posts(fx).filter((a) => a.url.includes('/preise')).at(-1)
|
||||||
|
const modusBox = (fx, panel) => fx.finde(panel, '#bk-p-personenmodus')
|
||||||
|
const sichtbar = (el) => { for (let e = el; e && e.hidden !== undefined; e = e.parentElement) if (e.hidden) return false; return true }
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: gespeicherte personenCent laden (Haken an, k Felder je Staffel), Speichern sendet sie unverändert', mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
assert.equal(modusBox(fx, panel).checked, true)
|
||||||
|
assert.equal(zeilen(fx, panel).length, 2)
|
||||||
|
for (const [i, erwartet] of [['5500', '7000', '8200'], ['5000', '6500', '7700']].entries()) {
|
||||||
|
const felder = spalten(fx, zeilen(fx, panel)[i])
|
||||||
|
assert.equal(felder.length, 3)
|
||||||
|
assert.deepEqual(felder.map((f) => f.value), erwartet.map((c) => `${c.slice(0, -2)},${c.slice(-2)}`))
|
||||||
|
assert.deepEqual(felder.map((f) => f.dataset.personen), ['1', '2', '3'])
|
||||||
|
assert.ok(felder.every(sichtbar))
|
||||||
|
assert.equal(sichtbar(fx.finde(zeilen(fx, panel)[i], '[data-staffel="preis"]')), false, 'der eine Preis ist ausgeblendet')
|
||||||
|
}
|
||||||
|
const beschriftung = fx.text(zeilen(fx, panel)[0])
|
||||||
|
assert.match(beschriftung, /1 Person(?!en)/)
|
||||||
|
assert.match(beschriftung, /2 Personen/)
|
||||||
|
assert.match(beschriftung, /3 Personen/)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), true)
|
||||||
|
assert.equal(fx.text(fx.finde(panel, '[data-hinweis="personenpreise"]')), 'Preis pro Nacht für die ganze Unterkunft, je nach Anzahl der Gäste.')
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), false)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), false)
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
const put = putPreise(fx)
|
||||||
|
assert.deepEqual(put.body.staffeln, PERSONEN_PREISE.staffeln)
|
||||||
|
assert.equal(put.body.personenInklusive, 2, 'ausgeblendete Felder gehen unverändert mit')
|
||||||
|
assert.equal(put.body.aufpreisPersonNachtCent, 1000)
|
||||||
|
assert.equal(put.body.reinigungCent, 6000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: preisProNachtCent im Body ist personenCent[0]; Felder lassen sich ändern', mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
await fx.setze(spalten(fx, zeilen(fx, panel)[1])[0], '48,50')
|
||||||
|
await fx.setze(spalten(fx, zeilen(fx, panel)[1])[2], '75')
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln[1], { abNaechten: 7, preisProNachtCent: 4850, personenCent: [4850, 6500, 7500] })
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: Haken schaltet vom alten Modell um (k Felder aus maxPersonen, Hilfsfelder weg) und zurück', mit(personenServer(PREISE, 4), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
const box = modusBox(fx, panel)
|
||||||
|
assert.equal(box.checked, false)
|
||||||
|
assert.equal(sichtbar(box), true)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), true)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), false)
|
||||||
|
assert.ok(zeilen(fx, panel).every((z) => sichtbar(fx.finde(z, '[data-staffel="preis"]')) && spalten(fx, z).every((f) => !sichtbar(f))))
|
||||||
|
await fx.klick(box)
|
||||||
|
assert.equal(box.checked, true)
|
||||||
|
for (const z of zeilen(fx, panel)) {
|
||||||
|
assert.equal(spalten(fx, z).length, 4, 'k = maxPersonen')
|
||||||
|
assert.ok(spalten(fx, z).every((f) => sichtbar(f) && f.value === ''), 'fehlende Werte bleiben leer')
|
||||||
|
assert.equal(sichtbar(fx.finde(z, '[data-staffel="preis"]')), false)
|
||||||
|
}
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), false)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-aufpreis')), false)
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '[data-hinweis="personenpreise"]')), true)
|
||||||
|
// neue Staffel hat die Felder ebenfalls
|
||||||
|
await fx.klick(aktion(fx, panel, 'staffel-dazu'))
|
||||||
|
assert.equal(spalten(fx, zeilen(fx, panel)[2]).length, 4)
|
||||||
|
assert.ok(spalten(fx, zeilen(fx, panel)[2]).every(sichtbar))
|
||||||
|
await fx.klick(fx.finde(zeilen(fx, panel)[2], '[data-aktion="staffel-weg"]'))
|
||||||
|
// zurück: alter Modus wie bisher, der eine Preis blieb erhalten
|
||||||
|
await fx.klick(box)
|
||||||
|
assert.equal(box.checked, false)
|
||||||
|
assert.equal(fx.finde(zeilen(fx, panel)[0], '[data-staffel="preis"]').value, '55,00')
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln, PREISE.staffeln)
|
||||||
|
assert.ok(putPreise(fx).body.staffeln.every((s) => !('personenCent' in s)))
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: Umschalten, alle Felder füllen, Speichern', mit(personenServer(PREISE, 3), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
await fx.klick(modusBox(fx, panel))
|
||||||
|
assert.equal(await versuche(fx, panel), 0, 'leere Felder werden nicht gesendet')
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
|
||||||
|
const werte = [['55', '70', '82'], ['50', '65', '77']]
|
||||||
|
for (const [i, z] of zeilen(fx, panel).entries()) for (const [j, f] of spalten(fx, z).entries()) await fx.setze(f, werte[i][j])
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln, [
|
||||||
|
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
|
||||||
|
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
|
||||||
|
])
|
||||||
|
assert.equal(putPreise(fx).body.personenInklusive, 2)
|
||||||
|
assert.equal(putPreise(fx).body.aufpreisPersonNachtCent, 1000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: Client prüft Preisgrenzen und Staffeln wie der Server', mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
const zeile = (i) => zeilen(fx, panel)[i]
|
||||||
|
await fx.setze(spalten(fx, zeile(0))[1], '0,50')
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /1,00 € und 1\.000,00 €/)
|
||||||
|
await fx.setze(spalten(fx, zeile(0))[1], '1.000,01')
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
await fx.setze(spalten(fx, zeile(0))[1], 'abc')
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
|
||||||
|
await fx.setze(spalten(fx, zeile(0))[1], '70')
|
||||||
|
await fx.setze(fx.finde(zeile(1), '[data-staffel="ab"]'), '1')
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /größer werden/)
|
||||||
|
await fx.setze(fx.finde(zeile(1), '[data-staffel="ab"]'), '7')
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: weniger gespeicherte Werte als Felder bleiben leer, überzählige werden verworfen', async () => {
|
||||||
|
const gespeichert = { ...PERSONEN_PREISE, staffeln: [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] }] }
|
||||||
|
// Unterkunft jetzt für 4 Gäste: vierte Spalte leer
|
||||||
|
await mit(personenServer(gespeichert, 4), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
assert.deepEqual(spalten(fx, zeilen(fx, panel)[0]).map((f) => f.value), ['55,00', '70,00', '82,00', ''])
|
||||||
|
assert.equal(await versuche(fx, panel), 0)
|
||||||
|
assert.match(fehlerText(fx, panel, 'staffeln'), /für jede Personenzahl einen Preis/)
|
||||||
|
})()
|
||||||
|
// Unterkunft jetzt für 2 Gäste: dritter Wert fällt weg
|
||||||
|
await mit(personenServer(gespeichert, 2), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
assert.deepEqual(spalten(fx, zeilen(fx, panel)[0]).map((f) => f.value), ['55,00', '70,00'])
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000] }])
|
||||||
|
})()
|
||||||
|
// mehr als 10 Gäste: höchstens 10 Felder
|
||||||
|
await mit(personenServer(gespeichert, 15), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
assert.equal(spalten(fx, zeilen(fx, panel)[0]).length, 10)
|
||||||
|
assert.match(fx.text(zeilen(fx, panel)[0]), /10 Personen/)
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: bei einer Person Höchstzahl kein Haken, altes Modell, personenCent fällt weg', mit(personenServer(PERSONEN_PREISE, 1), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
const box = modusBox(fx, panel)
|
||||||
|
assert.equal(box.checked, false)
|
||||||
|
assert.equal(sichtbar(box), false)
|
||||||
|
assert.ok(zeilen(fx, panel).every((z) => spalten(fx, z).length === 0 && sichtbar(fx.finde(z, '[data-staffel="preis"]'))))
|
||||||
|
assert.equal(sichtbar(fx.finde(panel, '#bk-p-inklusive')), true)
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.deepEqual(putPreise(fx).body.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }, { abNaechten: 7, preisProNachtCent: 5000 }])
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: nur lesen sperrt Haken und alle Felder', async () => {
|
||||||
|
await mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, ukTab.render, { nurLesen: true })
|
||||||
|
await fx.klick(aktion(fx, panel, 'bearbeiten'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.ok(modusBox(fx, panel).disabled)
|
||||||
|
assert.equal(spalten(fx, zeilen(fx, panel)[0]).length, 3)
|
||||||
|
assert.ok(fx.alle(panel, '.bk-preisform input, .bk-preisform select, .bk-preisform textarea, .bk-preisform button').every((el) => el.disabled), 'alles gesperrt')
|
||||||
|
assert.equal(aktion(fx, panel, 'preise-speichern'), null)
|
||||||
|
// Klick auf den gesperrten Haken ändert nichts
|
||||||
|
await fx.klick(modusBox(fx, panel))
|
||||||
|
assert.equal(modusBox(fx, panel).checked, true)
|
||||||
|
})()
|
||||||
|
// auch im alten Modell sperrt nur lesen den Haken
|
||||||
|
await mit(personenServer(PREISE, 3), async (fx) => {
|
||||||
|
const { panel } = await zeigeMit(fx, ukTab.render, { nurLesen: true })
|
||||||
|
await fx.klick(aktion(fx, panel, 'bearbeiten'))
|
||||||
|
await fx.ruhe()
|
||||||
|
assert.ok(modusBox(fx, panel).disabled)
|
||||||
|
await fx.klick(modusBox(fx, panel))
|
||||||
|
assert.equal(modusBox(fx, panel).checked, false)
|
||||||
|
assert.ok(zeilen(fx, panel).every((z) => spalten(fx, z).every((f) => f.disabled)))
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Preis-Editor Personen-Modus: unlesbare Reste in den ausgeblendeten Feldern blockieren das Speichern nicht', mit(personenServer(PERSONEN_PREISE), async (fx) => {
|
||||||
|
const panel = await preisEditor(fx)
|
||||||
|
await fx.setze(fx.finde(panel, '#bk-p-inklusive'), 'xyz')
|
||||||
|
await fx.setze(fx.finde(panel, '#bk-p-aufpreis'), 'abc')
|
||||||
|
assert.equal(await versuche(fx, panel), 1)
|
||||||
|
assert.equal(putPreise(fx).body.personenInklusive, 2)
|
||||||
|
assert.equal(putPreise(fx).body.aufpreisPersonNachtCent, 1000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('CSS: Felder je Personenzahl brechen um, ausgeblendete Teile bleiben unsichtbar', () => {
|
||||||
|
const css = readFileSync(new URL('../public/buchungen.css', import.meta.url), 'utf8')
|
||||||
|
assert.match(css, /\.bk-personenpreise\s*\{[^}]*flex-wrap:\s*wrap/)
|
||||||
|
assert.match(css, /\.bk-personenpreise\[hidden\][^{]*\{\s*display:\s*none/)
|
||||||
|
assert.match(css, /\.bk-preis-einzeln\[hidden\]/)
|
||||||
|
})
|
||||||
@@ -129,6 +129,24 @@ test('unterkunft textform: ohne Angaben 400 ohne Nachweis, mit Angaben 200; Nach
|
|||||||
for (const a of k16[0].attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
|
for (const a of k16[0].attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('unterkunft textform: Weg WhatsApp wird angenommen und im Nachweis genannt; unbekannter Weg 400', async (t) => {
|
||||||
|
t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails))
|
||||||
|
ukMit('pilot', ['whatsapp@example.invalid'])
|
||||||
|
const k = legeKunde({ email: 'whatsapp@example.invalid' })
|
||||||
|
const v = config.unterkunft.fassung
|
||||||
|
const telefon = await aktivieren(k, { textformDate: berlinTag(), textformChannel: 'telefon' })
|
||||||
|
assert.equal(telefon.status, 400)
|
||||||
|
assert.match(telefon.body.error, /Chat, E-Mail oder WhatsApp/)
|
||||||
|
assert.equal(consents(k.projectId).length, 0)
|
||||||
|
const tag = berlinTag()
|
||||||
|
const ok = await aktivieren(k, { textformDate: tag, textformChannel: 'whatsapp' })
|
||||||
|
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||||||
|
assert.equal(
|
||||||
|
leseZustimmung(consents(k.projectId)[0]).texte[0].text,
|
||||||
|
`Der Kunde hat am ${ttmmjjjj(tag)} per WhatsApp in Textform erklärt, dass er der Leistungsbeschreibung Buchungskalender und dem Auftragsverarbeitungsvertrag Buchungskalender (Fassung ${v}) zustimmt.`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('unterkunft textform entfällt: Modus off, schon aktiv, Abschalten; ungültige Datumsangaben 400', async (t) => {
|
test('unterkunft textform entfällt: Modus off, schon aktiv, Abschalten; ungültige Datumsangaben 400', async (t) => {
|
||||||
t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails))
|
t.after(() => ukMit(MODUS.mode, MODUS.pilotEmails))
|
||||||
ukMit('off')
|
ukMit('off')
|
||||||
|
|||||||
@@ -176,3 +176,147 @@ test('berechnePreis: 20 Personen ueber 548 Naechte besteht pruefePosten', () =>
|
|||||||
assert.equal(r.posten[1].menge, 18 * 548)
|
assert.equal(r.posten[1].menge, 18 * 548)
|
||||||
assert.ok(pruefePosten(r.posten, 'regel').posten)
|
assert.ok(pruefePosten(r.posten, 'regel').posten)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Preise je Personenzahl (staffeln[].personenCent): Mimis Monteurwohnung, Preis pro Nacht fuer die ganze Wohnung
|
||||||
|
|
||||||
|
const MIMI = {
|
||||||
|
staffeln: [
|
||||||
|
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
|
||||||
|
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
|
||||||
|
{ abNaechten: 14, preisProNachtCent: 4500, personenCent: [4500, 6000, 7200] },
|
||||||
|
{ abNaechten: 28, preisProNachtCent: 4000, personenCent: [4000, 5500, 6700] },
|
||||||
|
],
|
||||||
|
reinigungCent: 4000,
|
||||||
|
personenInklusive: 2,
|
||||||
|
aufpreisPersonNachtCent: 1500,
|
||||||
|
direktrabattProzent: 0,
|
||||||
|
steuerart: 'kleinunternehmer',
|
||||||
|
ustUebernachtung: 0,
|
||||||
|
ustReinigung: 0,
|
||||||
|
}
|
||||||
|
const mimi = (n, personen, extra = {}) => berechnePreis({ preise: { ...MIMI, ...extra }, von: '2026-11-01', bis: plusTage('2026-11-01', n), personen })
|
||||||
|
|
||||||
|
test('Personenpreise: Mimis Matrix (Naechte x Personen) mit Endreinigung 40 EUR', () => {
|
||||||
|
const erwartet = [
|
||||||
|
// [Naechte, Personen, Preis pro Nacht in Cent]
|
||||||
|
[3, 1, 5500], [3, 2, 7000], [3, 3, 8200],
|
||||||
|
[6, 3, 8200], [7, 1, 5000], [7, 2, 6500], [7, 3, 7700],
|
||||||
|
[13, 3, 7700], [14, 1, 4500], [14, 2, 6000], [14, 3, 7200],
|
||||||
|
[27, 3, 7200], [28, 1, 4000], [28, 2, 5500], [28, 3, 6700], [30, 3, 6700],
|
||||||
|
]
|
||||||
|
for (const [n, p, nacht] of erwartet) {
|
||||||
|
const r = mimi(n, p)
|
||||||
|
assert.equal(r.posten[0].einzelCent, nacht, `${n} Naechte, ${p} Personen`)
|
||||||
|
assert.equal(r.posten[0].menge, n)
|
||||||
|
assert.equal(r.summen.bruttoCent, n * nacht + 4000, `${n} Naechte, ${p} Personen`)
|
||||||
|
}
|
||||||
|
assert.equal(mimi(7, 3).summen.bruttoCent, 57900)
|
||||||
|
assert.equal(mimi(3, 2).summen.bruttoCent, 25000)
|
||||||
|
assert.equal(mimi(28, 1).summen.bruttoCent, 116000)
|
||||||
|
assert.equal(mimi(30, 3).summen.bruttoCent, 205000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Personenpreise: Postentexte mit Personenzahl, kein Zusatzperson-Posten', () => {
|
||||||
|
const r = mimi(7, 3)
|
||||||
|
assert.deepEqual(r.posten, [
|
||||||
|
{ text: 'Übernachtung, 7 Nächte, 3 Personen', menge: 7, einzelCent: 7700, ustProzent: 0 },
|
||||||
|
{ text: 'Endreinigung', menge: 1, einzelCent: 4000, ustProzent: 0 },
|
||||||
|
])
|
||||||
|
assert.equal(mimi(7, 1).posten[0].text, 'Übernachtung, 7 Nächte, 1 Person')
|
||||||
|
assert.equal(mimi(7, 2).posten[0].text, 'Übernachtung, 7 Nächte, 2 Personen')
|
||||||
|
assert.equal(mimi(1, 2).posten[0].text, 'Übernachtung, 1 Nächte, 2 Personen', 'Stil des bisherigen Textes: immer "Nächte"')
|
||||||
|
assert.ok(pruefePosten(mimi(30, 3).posten, 'kleinunternehmer').posten)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Personenpreise: personenInklusive wird ignoriert', () => {
|
||||||
|
const a = mimi(7, 3, { personenInklusive: 1 })
|
||||||
|
const b = mimi(7, 3, { personenInklusive: 3 })
|
||||||
|
assert.deepEqual(a, b)
|
||||||
|
assert.equal(a.posten.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Personenpreise: mehr Gaeste als Preisspalten = letzte Spalte + Zusatzperson-Posten', () => {
|
||||||
|
const r = mimi(7, 5)
|
||||||
|
assert.deepEqual(r.posten, [
|
||||||
|
{ text: 'Übernachtung, 7 Nächte, 3 Personen', menge: 7, einzelCent: 7700, ustProzent: 0 },
|
||||||
|
{ text: 'Zusatzpersonen (2), 7 Nächte', menge: 14, einzelCent: 1500, ustProzent: 0 },
|
||||||
|
{ text: 'Endreinigung', menge: 1, einzelCent: 4000, ustProzent: 0 },
|
||||||
|
])
|
||||||
|
assert.equal(r.summen.bruttoCent, 7 * 7700 + 14 * 1500 + 4000)
|
||||||
|
assert.equal(mimi(7, 4).posten[1].text, 'Zusatzperson (1), 7 Nächte')
|
||||||
|
// ohne Aufpreis kein Zusatzperson-Posten
|
||||||
|
const ohne = mimi(7, 5, { aufpreisPersonNachtCent: 0 })
|
||||||
|
assert.equal(ohne.posten.length, 2)
|
||||||
|
assert.equal(ohne.posten[0].text, 'Übernachtung, 7 Nächte, 3 Personen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Personenpreise: Direktrabatt auf Uebernachtung und Zusatzpersonen, nicht auf die Reinigung', () => {
|
||||||
|
const r = mimi(7, 5, { direktrabattProzent: 10 })
|
||||||
|
// Basis 7*7700 + 14*1500 = 74900; 10 % = 7490
|
||||||
|
assert.equal(r.posten.at(-1).text, 'Direktbuchungsrabatt 10 %')
|
||||||
|
assert.equal(r.posten.at(-1).einzelCent, -7490)
|
||||||
|
assert.equal(r.summen.bruttoCent, 74900 + 4000 - 7490)
|
||||||
|
const einfach = mimi(7, 2, { direktrabattProzent: 10 })
|
||||||
|
assert.equal(einfach.posten.at(-1).einzelCent, -Math.round((7 * 6500 * 10) / 100))
|
||||||
|
assert.equal(einfach.summen.bruttoCent, 45500 + 4000 - 4550)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Personenpreise: Regelbesteuerung 7 % Uebernachtung, 19 % Reinigung', () => {
|
||||||
|
const r = mimi(7, 3, { steuerart: 'regel', ustUebernachtung: 7, ustReinigung: 19 })
|
||||||
|
assert.deepEqual(r.posten.map((p) => p.ustProzent), [7, 19])
|
||||||
|
assert.equal(r.summen.bruttoCent, 57900)
|
||||||
|
assert.deepEqual(r.summen.ust, [
|
||||||
|
{ satz: 7, nettoCent: Math.round((53900 * 100) / 107), steuerCent: 53900 - Math.round((53900 * 100) / 107) },
|
||||||
|
{ satz: 19, nettoCent: Math.round((4000 * 100) / 119), steuerCent: 4000 - Math.round((4000 * 100) / 119) },
|
||||||
|
])
|
||||||
|
assert.equal(r.summen.ust.reduce((a, u) => a + u.nettoCent + u.steuerCent, 0), 57900)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Personenpreise: Staffelwahl wie bisher (hoechste abNaechten <= Naechte), ungeordnete Eingabe', () => {
|
||||||
|
const gemischt = { ...MIMI, staffeln: [...MIMI.staffeln].reverse() }
|
||||||
|
assert.equal(berechnePreis({ preise: gemischt, von: '2026-11-01', bis: plusTage('2026-11-01', 20), personen: 2 }).posten[0].einzelCent, 6000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Personenpreise: Personenzahl kleiner 1 oder keine Zahl rechnet wie 1 Person', () => {
|
||||||
|
assert.equal(mimi(7, 0).posten[0].einzelCent, 5000)
|
||||||
|
assert.equal(mimi(7, NaN).posten[0].einzelCent, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Alter Modus ohne personenCent rechnet unveraendert (Text ohne Personenangabe)', () => {
|
||||||
|
const r = berechnePreis({ preise: PREISE, von: '2026-11-02', bis: '2026-11-09', personen: 3 })
|
||||||
|
assert.deepEqual(r.posten.slice(0, 2), [
|
||||||
|
{ text: 'Übernachtung, 7 Nächte', menge: 7, einzelCent: 5500, ustProzent: 7 },
|
||||||
|
{ text: 'Zusatzperson (1), 7 Nächte', menge: 7, einzelCent: 1000, ustProzent: 7 },
|
||||||
|
])
|
||||||
|
// personenCent: null zaehlt als nicht vorhanden
|
||||||
|
const mitNull = { ...PREISE, staffeln: PREISE.staffeln.map((s) => ({ ...s, personenCent: null })) }
|
||||||
|
assert.deepEqual(berechnePreis({ preise: mitNull, von: '2026-11-02', bis: '2026-11-09', personen: 3 }), r)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preiseVollstaendig mit personenCent: konsistent ja, sonst nein', () => {
|
||||||
|
const f = (staffeln) => preiseVollstaendig({ ...MIMI, staffeln })
|
||||||
|
const st = (o = {}) => MIMI.staffeln.map((s, i) => ({ ...s, ...(typeof o === 'function' ? o(s, i) : o) }))
|
||||||
|
assert.equal(preiseVollstaendig(MIMI), true)
|
||||||
|
assert.equal(f(st()), true)
|
||||||
|
// nur eine Staffel hat personenCent
|
||||||
|
assert.equal(f(st((s, i) => (i === 2 ? { personenCent: undefined } : {}))), false)
|
||||||
|
// ungleiche Laengen
|
||||||
|
assert.equal(f(st((s, i) => (i === 1 ? { personenCent: s.personenCent.slice(0, 2) } : {}))), false)
|
||||||
|
// Laenge 1 und 11
|
||||||
|
assert.equal(f(st((s) => ({ personenCent: [s.preisProNachtCent] }))), false)
|
||||||
|
assert.equal(f(st((s) => ({ personenCent: Array(11).fill(s.preisProNachtCent) }))), false)
|
||||||
|
assert.equal(f(st((s) => ({ personenCent: Array(10).fill(s.preisProNachtCent) }))), true)
|
||||||
|
assert.equal(f(st((s) => ({ personenCent: [s.preisProNachtCent, s.preisProNachtCent] }))), true)
|
||||||
|
// ungueltige Werte
|
||||||
|
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: [5500, 99, 8200] } : {}))), false)
|
||||||
|
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: [5500, 100001, 8200] } : {}))), false)
|
||||||
|
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: [5500, 7000.5, 8200] } : {}))), false)
|
||||||
|
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: [5500, '7000', 8200] } : {}))), false)
|
||||||
|
assert.equal(f(st((s, i) => (i === 0 ? { personenCent: 'x' } : {}))), false)
|
||||||
|
// preisProNachtCent muss personenCent[0] sein
|
||||||
|
assert.equal(f(st((s, i) => (i === 3 ? { preisProNachtCent: 4100 } : {}))), false)
|
||||||
|
// null zaehlt als nicht vorhanden
|
||||||
|
assert.equal(preiseVollstaendig({ ...PREISE, staffeln: PREISE.staffeln.map((s) => ({ ...s, personenCent: null })) }), true)
|
||||||
|
// alter Modus mit personenCent in nur einer Staffel
|
||||||
|
assert.equal(preiseVollstaendig({ ...PREISE, staffeln: [{ ...PREISE.staffeln[0], personenCent: [6500, 7500] }, PREISE.staffeln[1], PREISE.staffeln[2]] }), false)
|
||||||
|
})
|
||||||
@@ -24,7 +24,7 @@ const zahlung = await import('../server/services/unterkunft/zahlung.js')
|
|||||||
const { UkFehler } = await import('../server/services/unterkunft/fehler.js')
|
const { UkFehler } = await import('../server/services/unterkunft/fehler.js')
|
||||||
const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js')
|
const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js')
|
||||||
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
|
||||||
const { pruefeIcalUrl, pruefePreise, pruefeEinstellungen } = await import('../server/services/unterkunft/verwaltung.js')
|
const { pruefeIcalUrl, pruefePreise, pruefeEinstellungen, STAFFELN_MAX_ZEICHEN } = await import('../server/services/unterkunft/verwaltung.js')
|
||||||
const app = await startTestApp({ '/': kundenansichtGuard, '/api/unterkunft': ukRoutes })
|
const app = await startTestApp({ '/': kundenansichtGuard, '/api/unterkunft': ukRoutes })
|
||||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||||
|
|
||||||
@@ -257,6 +257,128 @@ test('Preise: erste Staffel abNaechten:2, absteigend, mehr als 6, Preisgrenzen,
|
|||||||
assert.ok(pruefePreise(PREISE).werte)
|
assert.ok(pruefePreise(PREISE).werte)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Preise je Personenzahl (staffeln[].personenCent)
|
||||||
|
const MIMI_PREISE = {
|
||||||
|
...PREISE,
|
||||||
|
staffeln: [
|
||||||
|
{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000, 8200] },
|
||||||
|
{ abNaechten: 7, preisProNachtCent: 5000, personenCent: [5000, 6500, 7700] },
|
||||||
|
{ abNaechten: 14, preisProNachtCent: 4500, personenCent: [4500, 6000, 7200] },
|
||||||
|
{ abNaechten: 28, preisProNachtCent: 4000, personenCent: [4000, 5500, 6700] },
|
||||||
|
],
|
||||||
|
reinigungCent: 4000, personenInklusive: 2, aufpreisPersonNachtCent: 0, direktrabattProzent: 0, steuerart: 'kleinunternehmer',
|
||||||
|
}
|
||||||
|
const mitStaffeln = (staffeln) => pruefePreise({ ...MIMI_PREISE, staffeln })
|
||||||
|
const einzelne = (personenCent, extra = {}) => mitStaffeln([{ abNaechten: 1, preisProNachtCent: personenCent?.[0] ?? 5500, personenCent, ...extra }])
|
||||||
|
|
||||||
|
test('pruefePreise: Preise je Personenzahl gueltig, personenCent wird sauber uebernommen', () => {
|
||||||
|
const r = pruefePreise(MIMI_PREISE)
|
||||||
|
assert.deepEqual(r.fehler, undefined)
|
||||||
|
assert.deepEqual(r.werte.staffeln, MIMI_PREISE.staffeln)
|
||||||
|
assert.equal(r.werte.staffeln[0].personenCent !== MIMI_PREISE.staffeln[0].personenCent, true, 'Kopie, nicht dasselbe Array')
|
||||||
|
// Grenzen: 2 und 10 Werte, 100 und 100000 Cent
|
||||||
|
assert.ok(einzelne([100, 100000]).werte)
|
||||||
|
assert.ok(einzelne(Array(10).fill(100000)).werte)
|
||||||
|
// unbekannte Felder in der Staffel fallen weg
|
||||||
|
const mitMuell = mitStaffeln([{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000], x: 1 }])
|
||||||
|
assert.deepEqual(mitMuell.werte.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: [5500, 7000] }])
|
||||||
|
// alter Modus bleibt: kein personenCent im Ergebnis, auch bei null
|
||||||
|
const alt = pruefePreise(PREISE)
|
||||||
|
assert.deepEqual(alt.werte.staffeln, PREISE.staffeln)
|
||||||
|
assert.ok(alt.werte.staffeln.every((s) => !('personenCent' in s)))
|
||||||
|
const mitNull = mitStaffeln([{ abNaechten: 1, preisProNachtCent: 5500, personenCent: null }])
|
||||||
|
assert.deepEqual(mitNull.werte.staffeln, [{ abNaechten: 1, preisProNachtCent: 5500 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pruefePreise: preisProNachtCent wird im Personen-Modus mit personenCent[0] ueberschrieben', () => {
|
||||||
|
const r = einzelne([5500, 7000, 8200], { preisProNachtCent: 9999 })
|
||||||
|
assert.equal(r.werte.staffeln[0].preisProNachtCent, 5500)
|
||||||
|
const ohne = mitStaffeln([{ abNaechten: 1, personenCent: [5500, 7000] }])
|
||||||
|
assert.equal(ohne.werte.staffeln[0].preisProNachtCent, 5500)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pruefePreise: personenCent ungueltig -> klare Meldung unter staffeln', () => {
|
||||||
|
const faelle = {
|
||||||
|
'Laenge 1': [5500],
|
||||||
|
'Laenge 11': Array(11).fill(5500),
|
||||||
|
'leer': [],
|
||||||
|
'Wert 99': [5500, 99],
|
||||||
|
'Wert 100001': [5500, 100001],
|
||||||
|
'Kommazahl': [5500, 7000.5],
|
||||||
|
'Text': [5500, '7000'],
|
||||||
|
'null': [5500, null],
|
||||||
|
'NaN': [5500, NaN],
|
||||||
|
'kein Array': 'viele',
|
||||||
|
'Objekt': { 0: 5500, 1: 7000 },
|
||||||
|
'Zahl': 5500,
|
||||||
|
}
|
||||||
|
for (const [name, wert] of Object.entries(faelle)) {
|
||||||
|
const r = einzelne(wert)
|
||||||
|
assert.ok(r.fehler?.staffeln, name)
|
||||||
|
assert.ok(/\S{3,}/.test(r.fehler.staffeln), name)
|
||||||
|
assert.equal(Object.keys(r.fehler).length, 1, `${name}: nur das Feld staffeln`)
|
||||||
|
}
|
||||||
|
assert.match(einzelne([5500]).fehler.staffeln, /2 bis 10/)
|
||||||
|
assert.match(einzelne([5500, 99]).fehler.staffeln, /1 € und 1\.000 €/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pruefePreise: Staffeln mit unterschiedlich vielen oder teils fehlenden personenCent -> Fehler', () => {
|
||||||
|
const s = (ab, pc) => ({ abNaechten: ab, preisProNachtCent: 5000, ...(pc ? { personenCent: pc } : {}) })
|
||||||
|
assert.match(mitStaffeln([s(1, [5000, 6000, 7000]), s(7, [5000, 6000])]).fehler.staffeln, /gleich viele/)
|
||||||
|
assert.match(mitStaffeln([s(1, [5000, 6000, 7000]), s(7)]).fehler.staffeln, /alle Preisstufen/i)
|
||||||
|
assert.match(mitStaffeln([s(1), s(7, [5000, 6000, 7000])]).fehler.staffeln, /alle Preisstufen/i)
|
||||||
|
assert.ok(mitStaffeln([s(1, [5000, 6000]), s(7, [4500, 5500])]).werte)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pruefePreise: bestehende Staffel-Regeln gelten auch im Personen-Modus', () => {
|
||||||
|
const s = (ab, pc = [5500, 7000]) => ({ abNaechten: ab, preisProNachtCent: pc[0], personenCent: pc })
|
||||||
|
assert.match(mitStaffeln([s(2)]).fehler.staffeln, /ab 1 Nacht/)
|
||||||
|
assert.match(mitStaffeln([s(1), s(7), s(3)]).fehler.staffeln, /aufsteigend/)
|
||||||
|
assert.match(mitStaffeln([s(1), s(1)]).fehler.staffeln, /aufsteigend/)
|
||||||
|
assert.match(mitStaffeln([1, 2, 3, 4, 5, 6, 7].map((n) => s(n))).fehler.staffeln, /Höchstens 6/)
|
||||||
|
assert.ok(mitStaffeln([1, 2, 3, 4, 5, 6].map((n) => s(n))).werte)
|
||||||
|
assert.match(mitStaffeln([{ abNaechten: 1.5, personenCent: [5500, 7000] }]).fehler.staffeln, /Ungültige Preisstufe/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pruefePreise: schlimmster Fall (6 Staffeln x 10 Werte x 100000, ab 365) passt in das Textfeld staffeln (2000 Zeichen)', async () => {
|
||||||
|
const { UNTERKUNFT_COLLECTIONS } = await import('../server/services/unterkunft/schema.js')
|
||||||
|
const groesse = UNTERKUNFT_COLLECTIONS.ukPreise.attributes.find((a) => a.key === 'staffeln').size
|
||||||
|
assert.equal(STAFFELN_MAX_ZEICHEN, groesse, 'Konstante und Schema stimmen ueberein')
|
||||||
|
const staffeln = [360, 361, 362, 363, 364, 365].map((ab, i) => ({ abNaechten: i === 0 ? 1 : ab, preisProNachtCent: 100000, personenCent: Array(10).fill(100000) }))
|
||||||
|
const r = mitStaffeln(staffeln)
|
||||||
|
assert.ok(r.werte, JSON.stringify(r.fehler))
|
||||||
|
assert.ok(JSON.stringify(r.werte.staffeln).length <= groesse, `${JSON.stringify(r.werte.staffeln).length} > ${groesse}`)
|
||||||
|
assert.ok(JSON.stringify(r.werte.staffeln).length < 1200, 'deutlich unter der Grenze')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Preise (HTTP): Personen-Modus speichern, lesen, Vorschau; preisProNachtCent wird ueberschrieben', async () => {
|
||||||
|
const r = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: MIMI_PREISE.staffeln.map((s) => ({ ...s, preisProNachtCent: 1234 })) })
|
||||||
|
assert.equal(r.status, 200, JSON.stringify(r.body))
|
||||||
|
assert.deepEqual(r.body.preise.staffeln, MIMI_PREISE.staffeln)
|
||||||
|
assert.deepEqual((await get('/unterkuenfte/u1/preise')).body.preise.staffeln, MIMI_PREISE.staffeln)
|
||||||
|
assert.deepEqual(JSON.parse(aw.docs('ukPreise').get('pr1').staffeln), MIMI_PREISE.staffeln)
|
||||||
|
const v = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-08', personen: 3 })
|
||||||
|
assert.equal(v.status, 200)
|
||||||
|
assert.equal(v.body.summen.bruttoCent, 7 * 7700 + 4000)
|
||||||
|
assert.equal(v.body.posten[0].text, 'Übernachtung, 7 Nächte, 3 Personen')
|
||||||
|
// zurueck in den alten Modus: ohne personenCent gespeichert
|
||||||
|
const alt = await put('/unterkuenfte/u1/preise', PREISE)
|
||||||
|
assert.equal(alt.status, 200)
|
||||||
|
assert.deepEqual(JSON.parse(aw.docs('ukPreise').get('pr1').staffeln), PREISE.staffeln)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Preise (HTTP): ungueltige personenCent -> 400 felder.staffeln, gespeicherte Preise bleiben', async () => {
|
||||||
|
const vorherText = aw.docs('ukPreise').get('pr1').staffeln
|
||||||
|
for (const pc of [[5500], Array(11).fill(5500), [5500, 99], [5500, 100001], [5500, 'x']]) {
|
||||||
|
const r = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: [{ abNaechten: 1, preisProNachtCent: 5500, personenCent: pc }] })
|
||||||
|
assert.equal(r.status, 400, JSON.stringify(pc))
|
||||||
|
assert.ok(r.body.felder.staffeln, JSON.stringify(pc))
|
||||||
|
}
|
||||||
|
const ungleich = await put('/unterkuenfte/u1/preise', { ...MIMI_PREISE, staffeln: [MIMI_PREISE.staffeln[0], { ...MIMI_PREISE.staffeln[1], personenCent: [5000, 6500] }] })
|
||||||
|
assert.equal(ungleich.status, 400)
|
||||||
|
assert.equal(aw.docs('ukPreise').get('pr1').staffeln, vorherText)
|
||||||
|
})
|
||||||
|
|
||||||
test('Preisvorschau: berechnePreis mit gespeicherten Preisen; ohne Preise 409; ungueltige Eingaben 400', async () => {
|
test('Preisvorschau: berechnePreis mit gespeicherten Preisen; ohne Preise 409; ungueltige Eingaben 400', async () => {
|
||||||
const r = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-03', personen: 3 })
|
const r = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-03', personen: 3 })
|
||||||
assert.equal(r.status, 200)
|
assert.equal(r.status, 200)
|
||||||
@@ -384,6 +506,20 @@ test('Anfragen: Liste mit Filter und ohne Geheimnisse; Detail mit Vorschlag', as
|
|||||||
assert.equal((await get('/anfragen/nix')).status, 404)
|
assert.equal((await get('/anfragen/nix')).status, 404)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Anfragen im Personen-Modus: Vorschlag und Freigabe ohne Posten rechnen mit der Spalte der Gaestezahl', async () => {
|
||||||
|
aw.seed('ukPreise', { $id: 'pr1', unterkunftId: 'u1', featureId: FEATURE, ...MIMI_PREISE, staffeln: JSON.stringify(MIMI_PREISE.staffeln) })
|
||||||
|
anfrage('a1', { status: 'neu', von: '2026-11-01', bis: '2026-11-08', personen: 3 })
|
||||||
|
const d = await get('/anfragen/a1')
|
||||||
|
assert.equal(d.status, 200)
|
||||||
|
assert.deepEqual(d.body.vorschlag.posten.map((p) => [p.text, p.menge, p.einzelCent]), [['Übernachtung, 7 Nächte, 3 Personen', 7, 7700], ['Endreinigung', 1, 4000]])
|
||||||
|
assert.equal(d.body.vorschlag.summen.bruttoCent, 57900)
|
||||||
|
// Freigabe ohne eigene Posten uebernimmt die berechneten
|
||||||
|
const f = await post('/anfragen/a1/freigeben', {})
|
||||||
|
assert.equal(f.status, 200, JSON.stringify(f.body))
|
||||||
|
assert.equal(f.body.anfrage.summen.bruttoCent, 57900)
|
||||||
|
assert.equal(f.body.anfrage.posten[0].text, 'Übernachtung, 7 Nächte, 3 Personen')
|
||||||
|
})
|
||||||
|
|
||||||
test('Freigeben: Posten + Frist, Antwort mit buchungsLink (Token passt zum Hash), nie tokenHash; Anfrage freigegeben', async () => {
|
test('Freigeben: Posten + Frist, Antwort mit buchungsLink (Token passt zum Hash), nie tokenHash; Anfrage freigegeben', async () => {
|
||||||
anfrage('a1')
|
anfrage('a1')
|
||||||
const r = await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 5, nachricht: 'Bis bald' })
|
const r = await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 5, nachricht: 'Bis bald' })
|
||||||
|
|||||||
Reference in new issue
Block a user