Buchungskalender: Preise je Personenzahl (Server)
Jede Staffel darf personenCent tragen (Preis pro Nacht für 1 bis k Personen, k = 2 bis 10), ohne Schemaänderung im JSON-Feld staffeln. pruefePreise prüft Länge, Werte und Gleichheit über alle Staffeln und setzt preisProNachtCent = personenCent[0]. berechnePreis wählt die Spalte der Gästezahl (Posten mit Personenzahl), bei mehr Gästen als Spalten gilt die letzte plus Zusatzperson-Aufpreis. preiseVollstaendig prüft die Konsistenz. Ohne personenCent rechnet alles wie bisher. Tests mit Mimis Preismatrix, Doku in unterkunft-api.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b264d7f734
commit
a4ade5f649
5 files changed
+379
-12
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>`.
|
||||
- `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`.
|
||||
- `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? }] }`.
|
||||
- `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`.
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
// 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 { naechte } from './datum.js'
|
||||
|
||||
const SAETZE = [0, 7, 19]
|
||||
|
||||
/** Staffel: hoechste abNaechten <= naechte gilt fuer alle Naechte. */
|
||||
function staffelPreis(staffeln, n) {
|
||||
/** Grenzen der Preise je Personenzahl: Laenge k von personenCent (1 bis k Gaeste) und Wert in Cent. */
|
||||
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
|
||||
for (const s of staffeln || []) {
|
||||
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 }) {
|
||||
@@ -19,10 +34,23 @@ export function berechnePreis({ preise, von, bis, personen }) {
|
||||
const satzUe = klein ? 0 : preise.ustUebernachtung
|
||||
const satzRe = klein ? 0 : preise.ustReinigung
|
||||
const posten = []
|
||||
const nachtPreis = staffelPreis(preise.staffeln, n)
|
||||
posten.push({ text: `Übernachtung, ${n} Nächte`, menge: n, einzelCent: nachtPreis, ustProzent: satzUe })
|
||||
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 })
|
||||
}
|
||||
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) {
|
||||
posten.push({
|
||||
text: `Zusatzperson${extra > 1 ? 'en' : ''} (${extra}), ${n} Nächte`,
|
||||
@@ -96,12 +124,29 @@ export function formatEuro(cent) {
|
||||
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) {
|
||||
if (!preise || !Array.isArray(preise.staffeln) || preise.staffeln.length === 0) return false
|
||||
const ab = preise.staffeln.map((s) => s && s.abNaechten)
|
||||
if (!ab.every((a) => Number.isInteger(a) && a > 0)) 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 (!personenPreiseKonsistent(preise.staffeln)) return false
|
||||
if (!['regel', 'kleinunternehmer'].includes(preise.steuerart)) return false
|
||||
if (!Number.isInteger(preise.personenInklusive) || preise.personenInklusive < 1) return false
|
||||
const nn = (v) => Number.isInteger(v) && v >= 0
|
||||
|
||||
@@ -3,6 +3,7 @@ import { config } from '../../config.js'
|
||||
import { istTag, naechte } from './datum.js'
|
||||
import { AKTIVE_STATUS, ueberschneidet } from './belegung.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
|
||||
@@ -26,6 +27,8 @@ export const PLATZHALTER_TEXT = 'Bitte ersetze die Platzhalter in eckigen Klamme
|
||||
export const MAX_UNTERKUENFTE = 20
|
||||
export const MAX_ICAL_IMPORTE = 5
|
||||
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
|
||||
|
||||
@@ -149,7 +152,12 @@ export function pruefeUnterkunft(eingabe, { partiell = false } = {}) {
|
||||
|
||||
// ---- 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) {
|
||||
const e = eingabe && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {}
|
||||
const fehler = {}
|
||||
@@ -160,15 +168,42 @@ export function pruefePreise(eingabe) {
|
||||
else {
|
||||
const sauber = []
|
||||
let letzte = 0
|
||||
let personenAnzahl // undefined = noch keine Staffel gesehen, 0 = pauschal, sonst Laenge k von personenCent
|
||||
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.'
|
||||
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.'
|
||||
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) {
|
||||
fehler.staffeln = 'Die erste Preisstufe muss ab 1 Nacht gelten.'
|
||||
break
|
||||
@@ -178,8 +213,12 @@ export function pruefePreise(eingabe) {
|
||||
break
|
||||
}
|
||||
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
|
||||
}
|
||||
const zahl = (k, min, max, label, std) => {
|
||||
|
||||
@@ -176,3 +176,147 @@ test('berechnePreis: 20 Personen ueber 548 Naechte besteht pruefePosten', () =>
|
||||
assert.equal(r.posten[1].menge, 18 * 548)
|
||||
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 { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.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 })
|
||||
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)
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
const r = await post('/preisvorschau', { unterkunftId: 'u1', von: '2026-12-01', bis: '2026-12-03', personen: 3 })
|
||||
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)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
anfrage('a1')
|
||||
const r = await post('/anfragen/a1/freigeben', { posten: POSTEN, fristTage: 5, nachricht: 'Bis bald' })
|
||||
|
||||
Reference in new issue
Block a user