diff --git a/server/services/unterkunft/belegung.js b/server/services/unterkunft/belegung.js new file mode 100644 index 0000000..3075431 --- /dev/null +++ b/server/services/unterkunft/belegung.js @@ -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:'), 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 +} diff --git a/server/services/unterkunft/speicher.js b/server/services/unterkunft/speicher.js new file mode 100644 index 0000000..7bbe046 --- /dev/null +++ b/server/services/unterkunft/speicher.js @@ -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)]) diff --git a/test/unterkunftBelegung.test.js b/test/unterkunftBelegung.test.js new file mode 100644 index 0000000..5952bb4 --- /dev/null +++ b/test/unterkunftBelegung.test.js @@ -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) +})