diff --git a/docs/funktionen/unterkunft-api.md b/docs/funktionen/unterkunft-api.md index 678fbd3..17a9413 100644 --- a/docs/funktionen/unterkunft-api.md +++ b/docs/funktionen/unterkunft-api.md @@ -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.`. - `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:', 'anfrage:' 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`. diff --git a/server/services/unterkunft/preis.js b/server/services/unterkunft/preis.js index 350dc63..b676c5a 100644 --- a/server/services/unterkunft/preis.js +++ b/server/services/unterkunft/preis.js @@ -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 diff --git a/server/services/unterkunft/verwaltung.js b/server/services/unterkunft/verwaltung.js index c3a5006..b6ef1c0 100644 --- a/server/services/unterkunft/verwaltung.js +++ b/server/services/unterkunft/verwaltung.js @@ -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) => { diff --git a/test/unterkunftPreis.test.js b/test/unterkunftPreis.test.js index 61b7201..b6f9848 100644 --- a/test/unterkunftPreis.test.js +++ b/test/unterkunftPreis.test.js @@ -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) +}) diff --git a/test/unterkunftVerwaltung.test.js b/test/unterkunftVerwaltung.test.js index 066cdbb..6e08624 100644 --- a/test/unterkunftVerwaltung.test.js +++ b/test/unterkunftVerwaltung.test.js @@ -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' })