Buchungskalender: Speicher und Belegung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 13:49:29 +00:00
1 parent c38c8e7c13
commit bdf191d945
3 files changed
+330

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
import { listeAnfragenNachStatus, listeSperren } from './speicher.js'
/** Status, die die Unterkunft endgueltig belegen. 'freigegeben' blockiert nur bis fristAt. */
export const AKTIVE_STATUS = ['gebucht', 'bezahlt']
/** Halboffene Intervalle [von,bis) auf Tagesstrings: Abreisetag gleich Anreisetag ueberschneidet nicht. */
export const ueberschneidet = (a, b) => a.von < b.bis && b.von < a.bis
/**
* Belegte Bereiche einer Unterkunft im Fenster [von,bis), auf das Fenster zugeschnitten, nach von sortiert.
* Quellen: manuell, ical (Sperre mit quelle 'ical:<id>'), gebucht (gebucht|bezahlt), reserviert (freigegeben mit fristAt in der Zukunft).
* ohneAnfrage: ID einer Anfrage, die nicht zaehlt (z. B. beim Freigeben der Anfrage selbst). now: Date oder ms.
*/
export async function belegteBereiche(unterkunftId, von, bis, { ohneAnfrage, now = Date.now() } = {}) {
const jetzt = now instanceof Date ? now.getTime() : now
const fenster = { von, bis }
const roh = []
for (const s of await listeSperren(unterkunftId)) {
roh.push({ von: s.von, bis: s.bis, quelle: String(s.quelle ?? '').startsWith('ical') ? 'ical' : 'manuell' })
}
for (const a of await listeAnfragenNachStatus(unterkunftId, [...AKTIVE_STATUS, 'freigegeben'])) {
if (ohneAnfrage && a.$id === ohneAnfrage) continue
if (a.status === 'freigegeben') {
const frist = Date.parse(a.fristAt)
if (!(frist > jetzt)) continue
roh.push({ von: a.von, bis: a.bis, quelle: 'reserviert' })
} else {
roh.push({ von: a.von, bis: a.bis, quelle: 'gebucht' })
}
}
return roh
.filter((r) => r.von && r.bis && ueberschneidet(r, fenster))
.map((r) => ({ von: r.von > von ? r.von : von, bis: r.bis < bis ? r.bis : bis, quelle: r.quelle }))
.sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : 0))
}
export async function istFrei(unterkunftId, von, bis, opts = {}) {
return (await belegteBereiche(unterkunftId, von, bis, opts)).length === 0
}
+129
View File
@@ -0,0 +1,129 @@
import { config } from '../../config.js'
import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js'
/**
* Buchungskalender: Lesen und Schreiben der sechs Collections (Schema: schema.js). JSON-Felder liegen als Text in Appwrite;
* hier werden sie beim Lesen geparst (kaputter Text => null) und beim Schreiben serialisiert. Rueckgaben sind flache Objekte
* mit $id. Beim Schreiben werden $-Felder aus der Eingabe verworfen, undefined-Felder uebersprungen.
*/
const JSON_FELDER = {
ukUnterkuenfte: ['icalImporte'],
ukPreise: ['staffeln'],
ukSperren: [],
ukAnfragen: ['gast', 'rechnungsadresse', 'posten', 'summen'],
ukEinstellungen: ['rechnungZaehler', 'stripeStatus', 'anfrage'],
}
function lesen(name, doc) {
if (!doc) return null
const out = { ...doc }
for (const f of JSON_FELDER[name]) {
if (typeof out[f] !== 'string') continue
try {
out[f] = JSON.parse(out[f])
} catch {
out[f] = null
}
}
return out
}
function schreiben(name, felder) {
const out = {}
for (const [k, v] of Object.entries(felder ?? {})) {
if (k.startsWith('$') || v === undefined) continue
out[k] = JSON_FELDER[name].includes(k) && v !== null && typeof v !== 'string' ? JSON.stringify(v) : v
}
return out
}
const nichtGefunden = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
async function ladeDok(name, id) {
if (!id) return null
try {
return lesen(name, await getDocument(config.collections[name], id))
} catch (err) {
if (nichtGefunden(err)) return null
throw err
}
}
const liste = async (name, queries) => (await listAllDocuments(config.collections[name], queries)).map((d) => lesen(name, d))
// ---- ukEinstellungen (Dokument-ID = featureId)
export const ladeEinstellungen = (featureId) => ladeDok('ukEinstellungen', featureId)
export async function speichereEinstellungen(featureId, felder) {
const daten = schreiben('ukEinstellungen', felder)
if (await ladeEinstellungen(featureId)) {
return lesen('ukEinstellungen', await updateDocument(config.collections.ukEinstellungen, featureId, daten))
}
return lesen('ukEinstellungen', await createDocument(config.collections.ukEinstellungen, { ...daten, featureId }, featureId))
}
// ---- ukUnterkuenfte
export async function listeUnterkuenfte(featureId, { nurAktive = false } = {}) {
const alle = await liste('ukUnterkuenfte', [Query.equal('featureId', featureId)])
return alle.filter((u) => !nurAktive || u.aktiv === true).sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || String(a.$id).localeCompare(String(b.$id)))
}
export const ladeUnterkunft = (id) => ladeDok('ukUnterkuenfte', id)
export async function speichereUnterkunft(featureId, felder, id) {
const daten = schreiben('ukUnterkuenfte', felder)
if (id) return lesen('ukUnterkuenfte', await updateDocument(config.collections.ukUnterkuenfte, id, daten))
return lesen('ukUnterkuenfte', await createDocument(config.collections.ukUnterkuenfte, { ...daten, featureId }))
}
// ---- ukPreise (1:1 je Unterkunft)
async function preiseDok(unterkunftId) {
const alle = await liste('ukPreise', [Query.equal('unterkunftId', unterkunftId)])
return alle[0] ?? null
}
export const ladePreise = (unterkunftId) => preiseDok(unterkunftId)
export async function speicherePreise(unterkunftId, featureId, felder) {
const daten = schreiben('ukPreise', felder)
const vorhanden = await preiseDok(unterkunftId)
if (vorhanden) return lesen('ukPreise', await updateDocument(config.collections.ukPreise, vorhanden.$id, daten))
return lesen('ukPreise', await createDocument(config.collections.ukPreise, { ...daten, unterkunftId, featureId }))
}
// ---- ukSperren
export const listeSperren = (unterkunftId) => liste('ukSperren', [Query.equal('unterkunftId', unterkunftId)])
export async function legeSperreAn(felder) {
return lesen('ukSperren', await createDocument(config.collections.ukSperren, schreiben('ukSperren', felder)))
}
export const loescheSperre = (id) => deleteDocument(config.collections.ukSperren, id)
// ---- ukAnfragen
export const ladeAnfrage = (id) => ladeDok('ukAnfragen', id)
export async function legeAnfrageDokAn(felder) {
return lesen('ukAnfragen', await createDocument(config.collections.ukAnfragen, schreiben('ukAnfragen', felder)))
}
export async function aktualisiereAnfrage(id, felder) {
return lesen('ukAnfragen', await updateDocument(config.collections.ukAnfragen, id, schreiben('ukAnfragen', felder)))
}
export function listeAnfragen(featureId, { status, unterkunftId } = {}) {
const queries = [Query.equal('featureId', featureId)]
if (unterkunftId) queries.push(Query.equal('unterkunftId', unterkunftId))
if (status?.length) queries.push(Query.equal('status', status))
return liste('ukAnfragen', queries)
}
/** Fuer belegung.js: Anfragen einer Unterkunft mit einem der Status. */
export const listeAnfragenNachStatus = (unterkunftId, status) =>
liste('ukAnfragen', [Query.equal('unterkunftId', unterkunftId), Query.equal('status', status)])
+162
View File
@@ -0,0 +1,162 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Buchungskalender Task 3: Speicher (services/unterkunft/speicher.js) und Belegung (belegung.js)
const NAMEN = ['ukUnterkuenfte', 'ukPreise', 'ukSperren', 'ukAnfragen', 'ukEinstellungen']
const aw = await startAppwriteMock({
schema: Object.fromEntries(NAMEN.map((n) => [n, schemaKeys(n)])),
datetimes: { ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt'] },
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' })
const { config } = await import('../server/config.js')
const { listDocuments, deleteDocument } = await import('../server/services/appwriteAdmin.js')
const sp = await import('../server/services/unterkunft/speicher.js')
const bel = await import('../server/services/unterkunft/belegung.js')
after(() => aw.close())
const NOW = new Date('2026-11-01T12:00:00Z')
const ZUKUNFT = '2026-11-02T12:00:00.000+00:00'
const VERGANGENHEIT = '2026-10-31T12:00:00.000+00:00'
let n = 0
const anfrage = (felder) => {
const doc = { $id: `a${++n}`, featureId: 'f1', unterkunftId: 'u1', von: '2026-11-10', bis: '2026-11-12', status: 'neu', ...felder }
aw.seed('ukAnfragen', doc)
return doc
}
beforeEach(async () => {
for (const c of NAMEN) for (const id of aw.docs(c).keys()) await deleteDocument(c, id)
})
test('ueberschneidet: Halboffen, Abreise gleich Anreise ist frei', () => {
assert.equal(bel.ueberschneidet({ von: '2026-11-01', bis: '2026-11-05' }, { von: '2026-11-05', bis: '2026-11-07' }), false)
assert.equal(bel.ueberschneidet({ von: '2026-11-01', bis: '2026-11-05' }, { von: '2026-11-04', bis: '2026-11-07' }), true)
assert.equal(bel.ueberschneidet({ von: '2026-11-04', bis: '2026-11-07' }, { von: '2026-11-01', bis: '2026-11-05' }), true)
assert.equal(bel.ueberschneidet({ von: '2026-11-01', bis: '2026-11-10' }, { von: '2026-11-03', bis: '2026-11-04' }), true)
})
test('Sperre manuell blockiert, Abreisetag nicht', async () => {
await sp.legeSperreAn({ unterkunftId: 'u1', featureId: 'f1', von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' })
assert.equal(await bel.istFrei('u1', '2026-11-11', '2026-11-13', { now: NOW }), false)
assert.equal(await bel.istFrei('u1', '2026-11-12', '2026-11-14', { now: NOW }), true)
assert.equal(await bel.istFrei('u1', '2026-11-08', '2026-11-10', { now: NOW }), true)
assert.equal(await bel.istFrei('u2', '2026-11-11', '2026-11-13', { now: NOW }), true)
})
test('Anfragen: freigegeben mit Frist, gebucht, bezahlt blockieren; Rest nicht', async () => {
anfrage({ status: 'freigegeben', fristAt: ZUKUNFT, von: '2026-12-01', bis: '2026-12-03' })
anfrage({ status: 'freigegeben', fristAt: VERGANGENHEIT, von: '2026-12-05', bis: '2026-12-07' })
anfrage({ status: 'gebucht', von: '2026-12-10', bis: '2026-12-12' })
anfrage({ status: 'bezahlt', von: '2026-12-15', bis: '2026-12-17' })
for (const [i, s] of ['neu', 'abgelehnt', 'abgelaufen', 'storniert'].entries()) {
anfrage({ status: s, fristAt: ZUKUNFT, von: `2026-12-${20 + i}`, bis: `2026-12-${21 + i}` })
}
const frei = (v, b) => bel.istFrei('u1', v, b, { now: NOW })
assert.equal(await frei('2026-12-01', '2026-12-02'), false)
assert.equal(await frei('2026-12-05', '2026-12-06'), true)
assert.equal(await frei('2026-12-10', '2026-12-11'), false)
assert.equal(await frei('2026-12-15', '2026-12-16'), false)
assert.equal(await frei('2026-12-20', '2026-12-25'), true)
const b = await bel.belegteBereiche('u1', '2026-12-01', '2026-12-31', { now: NOW })
assert.deepEqual(b.map((x) => [x.von, x.bis, x.quelle]), [
['2026-12-01', '2026-12-03', 'reserviert'],
['2026-12-10', '2026-12-12', 'gebucht'],
['2026-12-15', '2026-12-17', 'gebucht'],
])
})
test('now als Zahl (ms) und Standard Date.now()', async () => {
anfrage({ status: 'freigegeben', fristAt: ZUKUNFT })
assert.equal(await bel.istFrei('u1', '2026-11-10', '2026-11-12', { now: NOW.getTime() }), false)
assert.equal(await bel.istFrei('u1', '2026-11-10', '2026-11-12', { now: Date.parse('2026-11-03T00:00:00Z') }), true)
anfrage({ status: 'gebucht', von: '2027-01-01', bis: '2027-01-03' })
assert.equal(await bel.istFrei('u1', '2027-01-01', '2027-01-02'), false)
})
test('ohneAnfrage ignoriert genau diese Anfrage', async () => {
const a = anfrage({ status: 'gebucht' })
anfrage({ status: 'gebucht', von: '2026-11-20', bis: '2026-11-22' })
assert.equal(await bel.istFrei('u1', '2026-11-10', '2026-11-12', { now: NOW }), false)
assert.equal(await bel.istFrei('u1', '2026-11-10', '2026-11-12', { now: NOW, ohneAnfrage: a.$id }), true)
assert.equal(await bel.istFrei('u1', '2026-11-20', '2026-11-22', { now: NOW, ohneAnfrage: a.$id }), false)
})
test('belegteBereiche: Quelle ical, Ausschnitt auf [von,bis), sortiert', async () => {
await sp.legeSperreAn({ unterkunftId: 'u1', featureId: 'f1', von: '2026-11-08', bis: '2026-11-14', quelle: 'ical:abc' })
await sp.legeSperreAn({ unterkunftId: 'u1', featureId: 'f1', von: '2026-11-20', bis: '2026-11-22', quelle: 'manuell' })
const b = await bel.belegteBereiche('u1', '2026-11-10', '2026-11-21', { now: NOW })
assert.deepEqual(b, [
{ von: '2026-11-10', bis: '2026-11-14', quelle: 'ical' },
{ von: '2026-11-20', bis: '2026-11-21', quelle: 'manuell' },
])
assert.deepEqual(bel.AKTIVE_STATUS, ['gebucht', 'bezahlt'])
})
test('Einstellungen: anlegen mit featureId als ID, JSON geparst, aendern', async () => {
assert.equal(await sp.ladeEinstellungen('f1'), null)
const e = await sp.speichereEinstellungen('f1', { firma: 'Haus am See', rechnungZaehler: { RE: 3 }, anfrage: { a: 1 } })
assert.equal(e.$id, 'f1')
assert.equal(e.featureId, 'f1')
assert.deepEqual(e.rechnungZaehler, { RE: 3 })
const roh = (await listDocuments(config.collections.ukEinstellungen))[0]
assert.equal(roh.rechnungZaehler, '{"RE":3}')
const e2 = await sp.speichereEinstellungen('f1', { ort: 'Stetten', stripeStatus: { ok: true } })
assert.equal(e2.firma, 'Haus am See')
assert.equal(e2.ort, 'Stetten')
assert.deepEqual((await sp.ladeEinstellungen('f1')).stripeStatus, { ok: true })
assert.equal(aw.docs('ukEinstellungen').size, 1)
})
test('Unterkuenfte: sortiert, nurAktive, icalImporte JSON, aendern per ID', async () => {
const b = await sp.speichereUnterkunft('f1', { name: 'B', sort: 2, aktiv: true })
const a = await sp.speichereUnterkunft('f1', { name: 'A', sort: 1, aktiv: false, icalImporte: [{ id: 'abc', url: 'https://x' }] })
await sp.speichereUnterkunft('f2', { name: 'Fremd', sort: 0, aktiv: true })
assert.deepEqual((await sp.listeUnterkuenfte('f1')).map((u) => u.name), ['A', 'B'])
assert.deepEqual((await sp.listeUnterkuenfte('f1', { nurAktive: true })).map((u) => u.name), ['B'])
assert.deepEqual((await sp.ladeUnterkunft(a.$id)).icalImporte, [{ id: 'abc', url: 'https://x' }])
const b2 = await sp.speichereUnterkunft('f1', { name: 'B2' }, b.$id)
assert.equal(b2.$id, b.$id)
assert.equal(b2.sort, 2)
assert.equal((await sp.ladeUnterkunft(b.$id)).name, 'B2')
assert.equal(await sp.ladeUnterkunft('gibtsnicht'), null)
})
test('Preise: 1:1 je Unterkunft, staffeln JSON, zweites Speichern aendert', async () => {
assert.equal(await sp.ladePreise('u1'), null)
const p = await sp.speicherePreise('u1', 'f1', { staffeln: [{ abNaechte: 1, nachtCent: 9000 }], reinigungCent: 5000 })
assert.deepEqual(p.staffeln, [{ abNaechte: 1, nachtCent: 9000 }])
const p2 = await sp.speicherePreise('u1', 'f1', { reinigungCent: 6000 })
assert.equal(p2.$id, p.$id)
assert.equal(p2.reinigungCent, 6000)
assert.deepEqual(p2.staffeln, [{ abNaechte: 1, nachtCent: 9000 }])
assert.equal(aw.docs('ukPreise').size, 1)
})
test('Sperren: liste, anlegen, loeschen', async () => {
const s = await sp.legeSperreAn({ unterkunftId: 'u1', featureId: 'f1', von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell', notiz: 'Familie' })
await sp.legeSperreAn({ unterkunftId: 'u2', featureId: 'f1', von: '2026-11-10', bis: '2026-11-12', quelle: 'manuell' })
assert.equal((await sp.listeSperren('u1')).length, 1)
await sp.loescheSperre(s.$id)
assert.equal((await sp.listeSperren('u1')).length, 0)
})
test('Anfragen: anlegen, lesen, aendern, filtern, JSON geparst', async () => {
const a = await sp.legeAnfrageDokAn({ featureId: 'f1', unterkunftId: 'u1', nummer: 'A-1', von: '2026-11-10', bis: '2026-11-12', status: 'neu', gast: { name: 'Anna' }, posten: [{ t: 'x' }], summen: { brutto: 100 } })
assert.deepEqual(a.gast, { name: 'Anna' })
const roh = [...aw.docs('ukAnfragen').values()][0]
assert.equal(roh.gast, '{"name":"Anna"}')
const a2 = await sp.aktualisiereAnfrage(a.$id, { status: 'freigegeben', fristAt: ZUKUNFT, rechnungsadresse: { ort: 'X' } })
assert.equal(a2.status, 'freigegeben')
assert.deepEqual(a2.gast, { name: 'Anna' })
assert.deepEqual((await sp.ladeAnfrage(a.$id)).rechnungsadresse, { ort: 'X' })
assert.equal(await sp.ladeAnfrage('nix'), null)
anfrage({ status: 'gebucht', unterkunftId: 'u2' })
anfrage({ status: 'gebucht', featureId: 'f2' })
assert.equal((await sp.listeAnfragen('f1')).length, 2)
assert.equal((await sp.listeAnfragen('f1', { status: ['gebucht'] })).length, 1)
assert.equal((await sp.listeAnfragen('f1', { unterkunftId: 'u1' })).length, 1)
})