Buchungskalender: Speicher und Belegung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c38c8e7c13
commit
bdf191d945
3 files changed
+330
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)])
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user