diff --git a/server/services/termine/slots.js b/server/services/termine/slots.js index 9f2f25d..24c7b2a 100644 --- a/server/services/termine/slots.js +++ b/server/services/termine/slots.js @@ -7,10 +7,10 @@ import { heute, monatInfo, plusTage, wochentag, zuUtc } from './zeit.js' * sind UTC-Zeitpunkte und werden nur als Zeitpunkte verglichen (nie je Kalendertag), damit eine Belegung vom Vortag * (22:00 bis 06:00) und die Zeitumstellung stimmen. * - * Rangfolge fuer die Fenster eines Tages: Sonderoeffnung > Abwesenheit > Feiertag > Plan. - * Eine Sonderoeffnung gilt fuer alle Ressourcen und ersetzt Plan, Feiertag und Abwesenheit (leere Liste = geschlossen). - * Sonst schliesst eine Abwesenheit (Betrieb: ressourceId null) die Ressource, dann ein Feiertag (wenn - * feiertageGeschlossen), sonst gilt der Wochenplan. + * Fenster eines Tages: Oeffnungsfenster aus dem Wochenplan der Ressource oder aus der Sonderoeffnung des Datums + * (gilt fuer alle Ressourcen, ersetzt Plan und Feiertagssperre; leere Liste = geschlossen), minus Feiertag (wenn + * feiertageGeschlossen und keine Sonderoeffnung), minus Abwesenheiten: eine Abwesenheit der Ressource oder des + * Betriebs (ressourceId null) nimmt den Tag der betroffenen Ressourcen immer heraus, auch an einem Sonderoeffnungstag. */ const MIN = 60000 @@ -184,8 +184,8 @@ function tagZeiten(ctx, tag, nurPruefen) { const treffer = new Map() // ms -> Eintrag for (const r of ctx.ressourcen) { - let fenster = sonder - if (fenster === null) fenster = feiertagZu || abwesend(e.abwesenheiten, r.id, tag) ? [] : (r.plan?.[wt] || []) + let fenster = [] + if (!abwesend(e.abwesenheiten, r.id, tag)) fenster = sonder ?? (feiertagZu ? [] : (r.plan?.[wt] || [])) const belegtR = ctx.belegt.get(r.id) || [] for (const f of Array.isArray(fenster) ? fenster : []) { const von = minuten(f?.[0]) @@ -240,14 +240,17 @@ export function freieTage(parameter) { /** * Wahl unter mehreren freien Ressourcen ('egal'): die mit den wenigsten Buchungen an dem Tag; bei Gleichstand die * in der Einstellung zuerst genannte. Abgesagte, abgelehnte und verfallene Buchungen sowie Sperren zaehlen nicht. - * Keine Kandidaten => null. + * Mit now (optional) zaehlt eine Haltung nur, solange heldUntil > now. Keine Kandidaten => null. */ -export function waehleRessource(ids, buchungenAmTag, einstellung) { +export function waehleRessource(ids, buchungenAmTag, einstellung, now = null) { + if (now !== null) pruefeNow(now, 'waehleRessource') + const jetztMs = now === null ? null : now.getTime() const kandidaten = [...new Set((Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string'))] if (kandidaten.length === 0) return null const anzahl = new Map(kandidaten.map((id) => [id, 0])) for (const b of Array.isArray(buchungenAmTag) ? buchungenAmTag : []) { if (!b || b.quelle === 'sperre' || NICHT_BELEGT.has(b.status) || !anzahl.has(b.ressourceId)) continue + if (jetztMs !== null && b.status === 'gehalten' && !istBelegend(b, jetztMs)) continue anzahl.set(b.ressourceId, anzahl.get(b.ressourceId) + 1) } const rang = new Map((einstellung?.ressourcen || []).map((r, i) => [r?.id, i])) diff --git a/test/termineSlots.test.js b/test/termineSlots.test.js index eb7bfc2..32d9614 100644 --- a/test/termineSlots.test.js +++ b/test/termineSlots.test.js @@ -244,18 +244,41 @@ test('Sonderoeffnung mit leerer Liste: an diesem Tag geschlossen', () => { assert.ok(frei(e, '2026-10-13').length > 0) }) -test('Sonderoeffnung gilt fuer alle Ressourcen und schlaegt die Abwesenheit (Rangfolge Sonderoeffnung > Abwesenheit > Feiertag > Plan)', () => { +test('Sonderoeffnung gilt fuer alle Ressourcen, auch fuer solche ohne Plan an diesem Tag', () => { const e = einstellung({ ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan([['08:00', '09:00']]))], raster: 60, sonderoeffnungen: { [MO]: [['10:00', '12:00']] }, - abwesenheiten: [{ id: 'a_cccccccc', ressourceId: R1, von: MO, bis: MO, titel: 'Urlaub' }], }) const liste = frei(e, MO) assert.deepEqual(uhrzeiten(liste), ['10:00', '11:00']) assert.deepEqual(liste[0].ressourceIds, [R1, R2]) }) +test('Person im Urlaub ist an einem Sonderoeffnungstag nicht buchbar', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], + raster: 60, + sonderoeffnungen: { [MO]: [['10:00', '12:00']] }, + abwesenheiten: [{ id: 'a_cccccccc', ressourceId: R1, von: MO, bis: MO, titel: 'Urlaub' }], + }) + assert.deepEqual(frei(e, MO, { ressourceId: R1 }), []) + assert.deepEqual(frei(e, MO, { ressourceId: R2 }).map((z) => [z.uhrzeit, z.ressourceIds]), [['10:00', [R2]], ['11:00', [R2]]]) + assert.deepEqual(frei(e, MO).map((z) => z.ressourceIds), [[R2], [R2]], 'egal: nur die anwesende Person') +}) + +test('Betriebsurlaub schlaegt Sonderoeffnung', () => { + const e = einstellung({ + ressourcen: [ressource(R1, wochenplan(TAG)), ressource(R2, wochenplan(TAG))], + raster: 60, + sonderoeffnungen: { [MO]: [['10:00', '12:00']], '2026-10-13': [['10:00', '12:00']] }, + abwesenheiten: [{ id: 'a_dddddddd', ressourceId: null, von: MO, bis: MO, titel: 'Betriebsurlaub' }], + }) + assert.deepEqual(frei(e, MO), []) + assert.deepEqual(frei(e, MO, { ressourceId: R1 }), []) + assert.deepEqual(uhrzeiten(frei(e, '2026-10-13')), ['10:00', '11:00'], 'ohne Abwesenheit gilt die Sonderoeffnung') +}) + // ---------------------------------------------------------------- Belegung test('Belegung blockiert Ueberlappung, angrenzende Zeiten bleiben frei', () => { @@ -520,6 +543,18 @@ test('waehleRessource: bei Gleichstand gilt die Reihenfolge der Liste, abgesagte assert.equal(waehleRessource([R2, R1], [{ ressourceId: R1, status: 'erledigt' }], e), R2, 'erledigte Termine des Tages zaehlen mit') }) +test('waehleRessource: mit now zaehlt eine abgelaufene Haltung nicht, eine gueltige schon', () => { + const e = einstellung({ ressourcen: [ressource(R1, {}), ressource(R2, {})] }) + const now = new Date('2026-10-12T08:00:00Z') + const haltung = (ressourceId, heldUntil) => ({ ressourceId, status: 'gehalten', heldUntil: heldUntil && appwrite(new Date(heldUntil)) }) + const abgelaufen = [haltung(R1, '2026-10-12T07:59:00Z'), haltung(R1, '2026-10-12T08:00:00Z'), haltung(R1, undefined)] + assert.equal(waehleRessource([R1, R2], abgelaufen, e), R2, 'ohne now zaehlt jede Haltung') + assert.equal(waehleRessource([R2, R1], abgelaufen, e, now), R1, 'mit now: Gleichstand 0:0, Reihenfolge der Einstellung') + assert.equal(waehleRessource([R1, R2], [haltung(R1, '2026-10-12T08:00:01Z')], e, now), R2, 'gueltige Haltung zaehlt') + assert.equal(waehleRessource([R1, R2], [...abgelaufen, { ressourceId: R1, status: 'bestaetigt' }], e, now), R2, 'andere Status zaehlen weiter') + assert.throws(() => waehleRessource([R1], [], e, 'gestern'), TypeError) +}) + test('waehleRessource: keine Kandidaten ergibt null, fremde Ressourcen werden nicht gezaehlt', () => { const e = einstellung() assert.equal(waehleRessource([], [], e), null)